<?xml version="1.0" encoding="UTF-8"?>
<?xml-stylesheet type="text/xsl" href="feed.xsl"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
  <channel>
    <title>Jorvik Software — Notes</title>
    <link>https://jorviksoftware.cc/notes</link>
    <description>Development notes and technical write-ups from Jorvik Software.</description>
    <language>en-gb</language>
    <lastBuildDate>Sat, 23 May 2026 20:41:14 GMT</lastBuildDate>
    <atom:link href="https://jorviksoftware.cc/notes/feed.xml" rel="self" type="application/rss+xml"/>
    <item>
      <title>The OS Upgrade Tax</title>
      <link>https://jorviksoftware.cc/notes/2026/05/23/the-os-upgrade-tax</link>
      <guid isPermaLink="true">https://jorviksoftware.cc/notes/2026/05/23/the-os-upgrade-tax</guid>
      <pubDate>Sat, 23 May 2026 19:39:23 GMT</pubDate>
      <description><![CDATA[<div class="photo-frame"><img src="https://jorviksoftware.cc/notes/2026/05/23/apple-macintosh.jpg" alt="Apple Macintosh SE 30"></div>
<p class="image-caption">Photo by <a href="https://unsplash.com/@julianhochgesang?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText">Julian Hochgesang</a> on <a href="https://unsplash.com/photos/macintosh-machine-dc-I7GCibzs?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText">Unsplash</a></p>

<p>I started the day thinking I&rsquo;d commit a little <a href="https://jorviksoftware.cc/utilities/activespace">ActiveSpace</a> 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&rsquo;d done my post-upgrade checks. I&rsquo;d run my standard test suite. All seemed to be in order. But there were <abbr data-title="Operating System">OS</abbr>-level changes that broke a couple of the Jorvik apps, subtle changes, innocuous, devoid of headlines, but carrying a cost.</p>
<p>This is the tax. Every major macOS upgrade comes with a bill, and you sometimes get it months later, payable in WTFs.</p>
<h2>Rainy Day stopped hiding the cursor</h2>
<p>The setup: when the <a href="https://jorviksoftware.cc/screensavers/rainyday">Rainy Day screensaver</a> activates, the cursor needs to disappear. I had two mechanisms running, both of which used to work.</p>
<p>One was a <abbr data-title="Cascading Style Sheets">CSS</abbr> rule &mdash; <span class="lock"><code>* { cursor: none !important; }</code></span> &mdash; inside the <code>WKWebView</code> that hosts the raindrop effect. WebKit honours this when it processes <span class="lock"><code>mouseMoved</code>,</span> so as long as the window has keyboard focus, the cursor inside the WebView is hidden.</p>
<p>The other was a fallback for the dual-display case, where only one of the two screensaver windows is ever key. An <span class="lock"><code>.activeAlways</code></span> <code>NSTrackingArea</code> on the container view, calling <span class="lock"><code>NSCursor.set()</code></span> on a 1x1 transparent <code>NSCursor</code> on every <code>mouseEntered</code> / <span class="lock"><code>mouseMoved</code>.</span> macOS delivers <code>mouseMoved</code> to non-key windows when the tracking area opts in, so the cursor gets hidden on every display regardless of key status.</p>
<p>Both mechanisms had been bulletproof since I shipped the dual-display fix. Today&rsquo;s report from the user: cursor visible on laptop-only, on single-external, on dual-external. Every configuration tested.</p>
<p>The laptop-only case is the one that didn&rsquo;t make sense. One window, that window is key, WebKit&rsquo;s CSS rule should fire. It didn&rsquo;t.</p>
<p>I don&rsquo;t know exactly what Tahoe changed. The symptom is consistent with WebKit no longer honouring <span class="lock"><code>cursor: none</code></span> at <span class="lock"><code>.screenSaver</code></span> window level, or honouring it only when an element has explicit input focus, or some other shift in policy. I didn&rsquo;t try to find out. I added a third path instead.</p>
<p>Three mechanisms layered, none bulletproof on its own:</p>
<ol>
<li><span class="lock"><code>resetCursorRects()</code></span> + <span class="lock"><code>addCursorRect()</code></span> 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 &ldquo;use this cursor in this rect,&rdquo; that&rsquo;s what the user sees. Works on every display.</li>
<li>The existing tracking area, with one fix: <span class="lock"><code>NSImage(size:)</code></span> alone doesn&rsquo;t create a bitmap representation. <code>NSCursor</code> materialises a fallback when you hand it an image with zero reps, and that fallback may not be transparent. Drawing into the image with <span class="lock"><code>lockFocus()</code></span> + a clear fill guarantees a transparent bitmap rep exists.</li>
<li><span class="lock"><code>CGDisplayHideCursor(CGMainDisplayID())</code></span> in the activate path, preceded by <span class="lock"><code>NSApp.activate(ignoringOtherApps: true)</code>.</span> Rainy Day is <code>LSUIElement</code> &mdash; not a regular foreground app &mdash; and <span class="lock"><code>CGDisplayHideCursor</code></span> requires the caller to be frontmost. <span class="lock"><code>NSApp.activate</code></span> 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.</li>
</ol>
<p>The user tested. All three paths fired in the log. Cursor hidden across every configuration.</p>
<p>I left the diagnostic logging in until the fix was confirmed and stripped it after. The three-path stack stays.</p>
<h2>Rainy Day couldn&rsquo;t lock the screen on dismiss</h2>
<p>Same app, different bug, found by reading the log while I was diagnosing the first one.</p>
<p>Rainy Day has a &ldquo;lock on dismiss&rdquo; 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, <span class="lock"><span class="kbd">control</span><span class="kbd">command</span><span class="kbd">Q</span>,</span> by posting a <code>CGEvent</code> 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.</p>
<p>The log told a much louder story. Thirteen <span class="lock"><code>LockScreen.lock()</code></span> calls in ONE millisecond. Then four seconds of nothing. Then the safety-net timeout fired and tore the saver down without locking anything.</p>
<p>Two bugs at once.</p>
<p>The first was the burst. Each screensaver window has an <span class="lock"><code>NSEvent.addLocalMonitorForEvents</code></span> watching for <span class="lock"><code>.mouseMoved</code>,</span> <span class="lock"><code>.keyDown</code>,</span> etc. When the user moves the mouse to dismiss, <code>mouseMoved</code> fires repeatedly &mdash; it&rsquo;s per-pixel-of-motion. The monitor closure called <span class="lock"><code>onDismiss</code>,</span> which called <span class="lock"><code>dismissWindows</code>,</span> which called <span class="lock"><code>LockScreen.lock()</code></span> and armed the lock observer. The monitor itself wasn&rsquo;t removed until full teardown, which the lock path defers to the unlock observer. So every <span class="lock"><code>mouseMoved</code></span> during the dismiss burst re-armed the whole flow. Thirteen attempts in a flick of the cursor.</p>
<p>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.</p>
<p>The second bug is the Tahoe regression: none of those thirteen synthesised keystrokes reached <span class="lock"><code>loginwindow</code>.</span> The <code>CGEvent</code> was consumed by the session tap and went nowhere. Whether Tahoe is rejecting synthesised system shortcuts at a finer-grained policy layer, or whether <span class="lock"><span class="kbd">control</span><span class="kbd">command</span><span class="kbd">Q</span></span> has been moved behind a new permission, I don&rsquo;t know. It doesn&rsquo;t work.</p>
<p>What does work is <span class="lock"><code>SACLockScreenImmediate</code>,</span> a private symbol in <span class="lock"><code>/System/Library/PrivateFrameworks/login.framework</code>.</span> It&rsquo;s the <abbr data-title="Inter Process Communication">IPC</abbr> path Apple uses internally and what every other menu-bar lock app (Hammerspoon, Bear, etc.) actually calls. It doesn&rsquo;t synthesise a keystroke &mdash; it just tells <code>loginwindow</code> to lock. No Accessibility permission required, because there&rsquo;s no keystroke to require permission for.</p>
<p>Private <abbr data-title="Application Programming Interface">API</abbr>. Could be removed in a future macOS. I verified it&rsquo;s alive in 26.5 via <code>dlsym</code> before committing to it, kept the existing 4-second safety-net timeout so a future removal degrades to &ldquo;saver tears down without locking&rdquo; rather than &ldquo;user stuck in saver forever,&rdquo; and moved on.</p>
<h2>BrowserNotes put the note on the wrong screen</h2>
<p>Different app, different shape of bug, same OS.</p>
<p><a href="https://jorviksoftware.cc/utilities/browsernotes">BrowserNotes</a> 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 &mdash; 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&rsquo;s frame, converts those coordinates to AppKit&rsquo;s coordinate space, and sets the panel&rsquo;s origin.</p>
<p>The user reported: with the browser on the second display, the panel appears on the first.</p>
<p>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&rsquo;s height. The math is simple if you use the right pivot.</p>
<p>The code used <span class="lock"><code>NSScreen.main.frame.height</code></span> as the pivot. Sonoma documented <span class="lock"><code>NSScreen.main</code></span> as &ldquo;the screen containing the window with keyboard focus.&rdquo; If the browser is the focused app on display 2, <span class="lock"><code>NSScreen.main</code></span> is display 2, and &mdash; assuming both displays are the same height, which they were in the report &mdash; the math accidentally works out.</p>
<p>Tahoe changed the semantics. <span class="lock"><code>NSScreen.main</code></span> now returns the primary display (display 1) regardless of where the focused window is.</p>
<p>Even that wouldn&rsquo;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 <code>min()</code> clamp to keep the panel from spilling past the right edge of the display, using <span class="lock"><code>NSScreen.main.visibleFrame.maxX</code>.</span> On Tahoe that maxX is display 1&rsquo;s, so the clamp dragged any x past display 1&rsquo;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.</p>
<p>Two bugs, one symptom, one Tahoe regression masking the other.</p>
<p>The fix is a helper &mdash; <span class="lock"><code>axRectToAppKit(_:)</code></span> that pivots on <span class="lock"><code>NSScreen.screens.first.frame.height</code></span> (the actual anchor of the AX coord space, always, no matter what <span class="lock"><code>NSScreen.main</code></span> reports), and <span class="lock"><code>screenContaining(_:)</code></span> to find the <code>NSScreen</code> whose frame actually contains a given point. The page-highlights <abbr data-title="Head Up Display">HUD</abbr> now finds the browser&rsquo;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.</p>
<h2>The point</h2>
<p>Two apps. One OS upgrade. Three completely different shapes of breakage:</p>
<ul>
<li>Tahoe changed the cursor-visibility policy at screensaver window level.</li>
<li>Tahoe stopped delivering synthesised system shortcuts to loginwindow.</li>
<li>Tahoe changed the semantics of an AppKit API I depend on for AX coordinate math.</li>
</ul>
<p>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.</p>
<p>You don&rsquo;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.</p>
<p>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.</p>
<p>Tahoe 26.5 was supposed to be a quiet release. It mostly is. The work is in finding the &ldquo;mostly.&rdquo;</p>
<h2>I could be wrong</h2>
<p>It&rsquo;s entirely possible that I have misinterpreted things. I am not, by any reasonable measure, a macOS/Swift/SwiftC expert. I&rsquo;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 &mdash; 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.</p>
<h2>In fairness</h2>
<p>If you&rsquo;ve read more than one of my posts you might come away thinking I have it in for macOS. I don&rsquo;t.</p>
<p>Software evolves. APIs deprecate. Documentation drifts. That&rsquo;s true of every platform with this much surface area, and complaining about it would be like complaining that the tide comes in. I&rsquo;m not. I&rsquo;m just writing down what I find when I trip over it, because the tripping-over is where the interesting detail lives.</p>
<p>The blog is a logbook. The wins go in too &mdash; they just don&rsquo;t write themselves up as easily as the breakages, because &ldquo;thing worked exactly as documented&rdquo; isn&rsquo;t much of a story.</p>
<p>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&rsquo;s on me, not on Apple.</p>
]]></description>
    </item>
    <item>
      <title>The Column I Couldn't Copy</title>
      <link>https://jorviksoftware.cc/notes/2026/05/16/the-column-i-could-not-copy</link>
      <guid isPermaLink="true">https://jorviksoftware.cc/notes/2026/05/16/the-column-i-could-not-copy</guid>
      <pubDate>Sat, 16 May 2026 22:41:14 GMT</pubDate>
      <description><![CDATA[<div class="photo-frame"><img src="https://jorviksoftware.cc/notes/2026/05/16/copy-lens.jpg" alt="magnifying glass, camera and lens, notepad and pencil atop an aged map along with some postcards"></div>
<p class="image-caption">Photo by <a href="https://unsplash.com/@svsokolov?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText">Sergey Sokolov</a> on <a href="https://unsplash.com/photos/an-open-notebook-magnifying-glass-a-pen-and-a-pair-of--g657KN4Ah4?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText">Unsplash</a></p>

<p>Last week I needed to copy a column out of an <abbr data-title="HyperText Markup Language">HTML</abbr> table in an email.</p>
<p>That sentence describes about ten seconds of intention and fifteen seconds of work. The intention: take this column of values, paste it into a spreadsheet, get on with my day. The work, as anyone who has tried it will know: select the whole table (because Mail doesn&rsquo;t do columns), paste it into a scratch document, manually trim it back to just the column I wanted, and then paste from the scratch document. Easy enough. Daft enough.</p>
<p>The problem isn&rsquo;t the tax of any one of those steps. It&rsquo;s that the steps exist at all &mdash; that the obvious operation, &ldquo;I want this column&rdquo;, has to be expressed as a circuitous series of operations because the only verb available is select-the-text-flow. Text flows in reading order. Reading order doesn&rsquo;t know about columns. A column, visually, is a rectangle. The verb that matches a rectangle is &ldquo;draw a rectangle around it.&rdquo;</p>
<p>That&rsquo;s <a href="https://jorviksoftware.cc/utilities/copylens">CopyLens</a>.</p>
<h2>The verb is drawing</h2>
<p>The idea is small enough to fit in one sentence. Hit a hotkey; drag a rectangle around the thing you want; release; the thing is on your clipboard. The hotkey defaults to <span class="lock"><span class="kbd">hyper</span><span class="kbd">\</span>,</span> which pairs with <a href="https://jorviksoftware.cc/utilities/hypercaps">HyperCaps</a> and lives on the same hand as <span class="lock"><span class="kbd">command</span><span class="kbd">v</span>.</span> Drawing a rectangle has the property that block-select doesn&rsquo;t: it doesn&rsquo;t care about reading order, document structure, or where the text &ldquo;wants&rdquo; to be. It cares about the rectangle.</p>
<p>For a column out of a table, that&rsquo;s perfect. Draw a thin rectangle from the top of the column to the bottom; release; paste. The column appears on the clipboard, top to bottom, one line per row, plain text. Mail had no idea what just happened. The browser tab next to it had no idea either. Whatever app is showing the table is uninvolved; CopyLens is reading pixels, not poking at the <abbr data-title="Document Object Model">DOM</abbr>.</p>
<p>For a column out of a <abbr data-title="Portable Document Format">PDF</abbr>, same gesture, same result. For a column out of an image that&rsquo;s a screenshot of a PDF, also same gesture, same result. The thing on screen doesn&rsquo;t need to be selectable text in any conventional sense. It just needs to be visible.</p>
<h2>Two outcomes, one gesture</h2>
<p>Here&rsquo;s the part I&rsquo;m pleased with.</p>
<p>Sometimes the rectangle you draw doesn&rsquo;t contain text. You wanted a chart, a piece of a diagram, a panel of a <abbr data-title="User Interface">UI</abbr> mockup. The naive design has two hotkeys: one for &ldquo;copy text from this region&rdquo;, one for &ldquo;copy image from this region&rdquo;. The user picks the right one each time. That&rsquo;s how every cropping tool I&rsquo;ve ever used works.</p>
<p>CopyLens doesn&rsquo;t ask. You draw the rectangle. If there&rsquo;s text, the text lands on the clipboard. If there isn&rsquo;t, the cropped image does &mdash; as <abbr data-title="Portable Network Graphics">PNG</abbr> and <abbr data-title="Tagged Image File Format">TIFF</abbr>, ready to paste into Notes or Keynote or wherever else takes images. The gesture is the same. The output adapts to the content.</p>
<p>Under the hood, the rectangle is captured at native pixel density via ScreenCaptureKit, Apple&rsquo;s Vision framework runs over the image, and the result either is or isn&rsquo;t a list of recognised text strings. If it is, they&rsquo;re joined in reading order and written to the pasteboard as text. If it isn&rsquo;t, the image is written to the pasteboard. A small <abbr data-title="Head Up Display">HUD</abbr> confirms which path ran &mdash; &ldquo;Copied 247 chars&rdquo; or &ldquo;Copied image 320x180&rdquo; &mdash; and you can paste immediately.</p>
<p>The win isn&rsquo;t the <abbr data-title="Optical Character Recognition">OCR</abbr>; it&rsquo;s the missing mode switch. You don&rsquo;t have to know, ahead of time, whether the region you&rsquo;re looking at is text or pixels. You just draw, and the right thing happens.</p>
<h2>Full disclosure</h2>
<p>Here&rsquo;s the part I should be honest about.</p>
<p>I built CopyLens in an afternoon. Most of the credit doesn&rsquo;t belong to me. The engine &mdash; ScreenCaptureKit one-shot capture, Vision OCR with language picking, reading-order sort, the image-fallback decision, the multi-display overlay, the lot &mdash; I had Claude Code write. One reasonably plain prompt: &ldquo;build a macOS app that lets the user draw a rectangle on the screen with a hotkey, captures it via ScreenCaptureKit, runs Vision over it, copies text to the clipboard if Vision finds any, otherwise copies the cropped image&rdquo;. A few back-and-forths to nail the multi-display behaviour and the reading-order sort. A few minutes, give or take.</p>
<p>What I added is the chrome:</p>
<ul>
<li>JorvikKit. The About modal, the Settings frame, the menu bar item, the window helper &mdash; the same scaffold every Jorvik utility uses, dropped into a new bundle.</li>
<li>The hotkey recorder. The user-facing thing in Settings, where you click into a field and press the combination you want, and the app re-registers a Carbon hot-key under the covers. That code is older than CopyLens.</li>
<li>The Sparkle integration. Appcast <abbr data-title="Uniform Resource Locator">URL</abbr>, EdDSA-signed updates, the &ldquo;Check for Updates&rdquo; menu item, the once-a-day background poll. Standard issue across the suite.</li>
<li>The HUD toast. &ldquo;Copied 247 chars.&rdquo; Small thing, big difference to whether you trust the app.</li>
</ul>
<p>If I&rsquo;d built the engine myself, it would&rsquo;ve taken me half a day. Maybe longer. I&rsquo;d have spent an hour finding the right ScreenCaptureKit incantation for a one-shot capture (the <abbr data-title="Application Programming Interface">API</abbr> is built around streams; one-shot is a sub-case you have to coax it into). I&rsquo;d have spent another hour discovering that Vision&rsquo;s text observations come back in a coordinate system I&rsquo;d need to flip before sorting. I&rsquo;d have spent an unknown amount of time deciding what &ldquo;reading order&rdquo; actually means for multi-column text, and getting the sort right. None of it is hard. All of it is fiddly. Claude got the whole pipeline working in a single pass, and I am genuinely impressed with the result.</p>
<p>I reviewed the code. It&rsquo;s clean, idiomatic, well-factored, the way I&rsquo;d have written it if I&rsquo;d had the patience to. I ran it through the standard Jorvik test suite &mdash; the one I&rsquo;ve built up over the last few months to validate every utility before release &mdash; and it passed. The shape of the code is the shape I would have produced. The things I didn&rsquo;t have to invest was the time and the patience.</p>
<p>That&rsquo;s the trade I&rsquo;m interested in. Not the trade of &ldquo;<abbr data-title="Artificial Intelligence">AI</abbr> does my work for me.&rdquo; The trade of &ldquo;I get to spend my attention on the part of the project that actually needs me.&rdquo; The engine doesn&rsquo;t need me &mdash; it&rsquo;s well-understood Vision plus well-understood ScreenCaptureKit, glued together in the obvious way. The product needs me. The <abbr data-title="User Experience">UX</abbr>, the settings, the where-does-it-live-in-the-menu-bar, the does-the-hotkey-actually-feel-right &mdash; those are the parts that take a finished mechanism and turn it into a thing somebody would use. Those I wrote. Those I wanted to write.</p>
<p>The lens metaphor in the name turned out to be a happy accident. You hold up a viewport over a region of the screen; whatever&rsquo;s inside the lens becomes portable. The pun on &ldquo;copy&rdquo; is fine for product-page purposes. The honest version is that I named it before I&rsquo;d thought too hard about the metaphor, and it landed.</p>
<h2>What it&rsquo;s actually for</h2>
<p>I&rsquo;ve been using CopyLens for a week. The use cases that have come up, ranked by frequency:</p>
<ol>
<li>Columns out of HTML tables. The original motivation.</li>
<li>Code snippets from screenshots people send me on Teams. You don&rsquo;t realise how often this happens until you can do something about it.</li>
<li>Quoted paragraphs from PDFs that are scans rather than text. Scientific papers, government documents, anything older than about 2010.</li>
<li>Pieces of diagrams from architecture documents. CopyLens hands you the cropped image, you paste into your own document, no link or reference back to the source &mdash; which is what you want when the source is mid-edit.</li>
<li>Numbers from charts. You point at the legend, you get the legend text. You point at the chart, you get the chart as an image. Two different outputs, one gesture. An accident of the design, not a deliberate feature, but a lovely one.</li>
</ol>
<p>There&rsquo;s a sixth category that I expected to use but haven&rsquo;t: copying text out of running video. The capture is fast enough in principle, but in practice if I want text out of a video I&rsquo;d rather scrub to a still frame and use a screenshot tool. CopyLens probably could do it. I haven&rsquo;t needed it to.</p>
<h2><a href="https://jorviksoftware.cc/utilities/copylens">The product page</a></h2>
<p>Standard Jorvik shape: macOS 14 or later, universal binary, free, EdDSA-signed updates via Sparkle, no telemetry, no network traffic beyond the appcast poll. The only permission it asks for is Screen Recording, which it has to have in order to read pixels off your screen. Accessibility isn&rsquo;t required; the hotkey uses Carbon&rsquo;s RegisterEventHotKey, which doesn&rsquo;t need it.</p>
<p>If you&rsquo;ve installed any other Jorvik utility, you know the drill. Installer or .zip, drag to Applications, launch, grant permission, set your hotkey, done. The whole onboarding fits in three minutes.</p>
<h2>Coda</h2>
<p>Writing this up, I had a thought about the kind of small utility CopyLens is. It&rsquo;s the sort of thing that probably exists in a hundred slightly different forms scattered across the App Store and various AI-flavoured startup websites. I didn&rsquo;t go looking. The whole thing is small enough that finding the right existing one would have taken longer than building this one. And mine is free, source-available, has no subscription, doesn&rsquo;t ship my screen contents anywhere, and was built to scratch one specific itch.</p>
<p>That last point is the one I keep coming back to. The reason this utility is small and pleasant to use is that I knew exactly what I wanted it to do before I started, because I&rsquo;d run into the problem the day before. The reason it only took an afternoon is that the engine wasn&rsquo;t the interesting part, and I got to skip it. The reason it exists at all is that I had a column I couldn&rsquo;t copy.</p>
<p>Now I can.</p>
]]></description>
    </item>
    <item>
      <title>Rainy Day Isn't a Screensaver</title>
      <link>https://jorviksoftware.cc/notes/2026/05/12/rainy-day-is-not-a-screensaver</link>
      <guid isPermaLink="true">https://jorviksoftware.cc/notes/2026/05/12/rainy-day-is-not-a-screensaver</guid>
      <pubDate>Tue, 12 May 2026 19:12:28 GMT</pubDate>
      <description><![CDATA[<div class="photo-frame"><img src="https://jorviksoftware.cc/notes/2026/05/12/rainy-day.jpg" alt="screen capture from within the Rainy Day screensaver: a stream running through a wooded mountain valley, with mist and raindrops"></div>

<p><a href="https://jorviksoftware.cc/screensavers/rainyday">Rainy Day</a> shipped this past weekend. It puts photo-realistic rain on your desktop after a few minutes of idle time &mdash; the kind of slow, ambient rendering you&rsquo;d expect a screensaver to do. The product page lists it on the <a href="https://jorviksoftware.cc/screensavers">Screensavers</a> shelf. The activation flow, the user mental model, the place it occupies on the website &mdash; everything about it says &ldquo;screensaver.&rdquo;</p>
<p>The bundle on disk says something else. <span class="lock"><code>Rainy Day.app</code></span>. Regular <span class="lock"><code>.app</code></span> extension. No <span class="lock"><code>.saver</code></span> bundle anywhere. It installs to <span class="lock"><code>/Applications</code></span> like a normal application, registers itself as a login item, and waits in the menu bar until your Mac goes idle. When it activates, it does the screensaver thing. When you move the mouse, it does the dismiss thing. But strictly speaking, macOS doesn&rsquo;t know it&rsquo;s a screensaver at all. As far as the operating system is concerned, it&rsquo;s just an LSUIElement app that happens to put fullscreen windows up sometimes.</p>
<p>This post is about why.</p>
<h2>The plan, originally</h2>
<p>When I started Rainy Day I assumed it would be a <span class="lock"><code>.saver</code></span> bundle. I&rsquo;d shipped two screensavers that way already &mdash; <a href="https://jorviksoftware.cc/screensavers/reverie">Reverie</a> and <a href="https://jorviksoftware.cc/screensavers/asciisaver"><abbr data-title="American Standard Code for Information Interchange">ASCII</abbr> Saver</a> &mdash; and I&rsquo;d written about <a href="https://jorviksoftware.cc/notes/2026/04/19/screen-savers-still-exist">why I still think screensavers matter</a>. The <span class="lock"><code>.saver</code></span> route is the orthodox path: subclass <span class="lock"><code>ScreenSaverView</code></span>, override <span class="lock"><code>animateOneFrame()</code></span>, drop the bundle into <span class="lock"><code>~/Library/Screen Savers/</code>,</span> and it appears in System Settings. The <abbr data-title="Operating System">OS</abbr> handles activation, deactivation, hot corners, the preview rendering, the configuration sheet. Your code just draws.</p>
<p>That works fine when your code can actually draw. Reverie&rsquo;s rendering is roulette curves on a <span class="lock"><code>CALayer</code></span> &mdash; standard <abbr data-title="Application Programming Interface">API</abbr>, no surprises. ASCII Saver hits the camera through an out-of-process helper agent, but the rendering itself is also boring AppKit: a grid of characters drawn into the view. Neither one needs anything the <span class="lock"><code>legacyScreenSaver</code></span> host process &mdash; the system process that loads <span class="lock"><code>.saver</code></span> bundles &mdash; refuses to give it.</p>
<p>Rainy Day was supposed to use <a href="https://github.com/SardineFish/raindrop-fx">raindrop-fx</a>, a beautiful WebGL library that renders rain on a transparent canvas with proper light refraction through the droplets. The rendering pipeline is WebKit + WebGL, hosted in a <span class="lock"><code>WKWebView</code></span>, with photographic background images blurred behind the canvas. The first version of Rainy Day was a <span class="lock"><code>.saver</code></span> bundle hosting a <span class="lock"><code>WKWebView</code>.</span> It worked perfectly. For about four seconds.</p>
<h2>The wall</h2>
<p>What I learned over the following session, roughly in the order they bit me:</p>
<p><strong>WebContent suspends within seconds of activation.</strong> <span class="lock"><code>legacyScreenSaver</code></span> flags everything it hosts as a background view, and macOS&rsquo;s memory management aggressively suspends the WebContent process behind any background <span class="lock"><code>WKWebView</code>.</span> The rain starts falling, runs for a few seconds while the system is still deciding whether you mean it, and then freezes. Subsequent reactivations get a still frame until WebContent decides to wake up again. Sometimes that&rsquo;s seconds. Sometimes it&rsquo;s minutes. There is no API to opt out.</p>
<p><strong>The escape hatch was removed in macOS 26.</strong> WebKit has an SPI called <span class="lock"><code>_alwaysRunsAtForegroundPriority</code></span> that, until last year, did exactly what its name implies &mdash; tell the system to leave this WebContent alone. It&rsquo;s still in the headers, you can still set it, and it does nothing. Apple removed the implementation when they overhauled the WebContent lifecycle in macOS 26, presumably because the property always smelled like a hack. The headers haven&rsquo;t caught up. You can spend an afternoon, as I did, convinced you&rsquo;ve found the magic switch, before discovering it&rsquo;s a dead one.</p>
<p><strong><span class="lock"><code>CARenderer</code></span> returns blank frames for WebKit content.</strong> With WebContent suspended, I tried rendering offscreen and pushing the result into the saver view manually. <span class="lock"><code>CARenderer</code></span> can snapshot a Core Animation layer tree into an <span class="lock"><code>IOSurface</code>,</span> which I could then paint into the screensaver view at whatever frame rate I liked. Except <span class="lock"><code>CARenderer</code></span> doesn&rsquo;t follow remote layer hosting into WebContent&rsquo;s <abbr data-title="Graphics Processing Unit">GPU</abbr> process. It captures the local layer backing store, sees nothing there, and returns black.</p>
<p><strong><span class="lock"><code>WKWebView.takeSnapshot</code></span> returns blank for WebGL.</strong> Same problem, same root cause. The snapshot API grabs whatever&rsquo;s in the layer backing store. For 2D content (text, images, Core Graphics) that works fine. For WebGL, the actual pixels live in a GL framebuffer that&rsquo;s never composited into the layer backing &mdash; the compositor reads it directly. <span class="lock"><code>takeSnapshot</code></span> doesn&rsquo;t know how to ask the GPU process for those pixels, so it returns black for the canvas region.</p>
<p><strong><span class="lock"><code>ScreenCaptureKit</code></span> hits an occlusion edge case.</strong> Right, I thought, fine. I&rsquo;ll spawn a separate non-saver helper window off-screen that hosts the <span class="lock"><code>WKWebView</code>,</span> let <em>that</em> render at full priority, and use <span class="lock"><code>ScreenCaptureKit</code></span> to capture the helper window into the saver view. This nearly worked. The helper rendered correctly. SCK happily streamed frames from it at sixty hertz. Then the saver activated, covered every display, and SCK&rsquo;s occlusion logic kicked in &mdash; because the helper window was now fully covered by the saver, SCK classified it as occluded and stopped delivering frames. There&rsquo;s no flag for &ldquo;capture this even if it&rsquo;s covered.&rdquo; SCK at <span class="lock"><code>desktopWindow</code></span> level zero-frames any window the saver covers, by design, because the assumption is you&rsquo;re capturing things the user can actually see.</p>
<p><strong>Multi-instance lifecycle thrash.</strong> System Settings doesn&rsquo;t just preview a <span class="lock"><code>.saver</code></span> &mdash; it spawns several instances of your view class at once. There&rsquo;s the small thumbnail in the saver list. There&rsquo;s the larger preview when your saver is selected. There&rsquo;s a separate instance per display when you click Preview. Each one calls <span class="lock"><code>init</code></span>, then <span class="lock"><code>deist</code></span>, in whatever order the framework feels like. For a stateless drawing saver, that&rsquo;s fine. For anything that owns a helper process, a capture stream, or any expensive resource, you spend each transition fighting the previous instance&rsquo;s teardown to make sure your new instance can set up cleanly. The bug surface here is enormous and most of it is invisible until a user opens System Settings.</p>
<p><strong>Ad-hoc signing breaks <abbr data-title="Transparency, Consent, and Control">TCC</abbr> grants every rebuild.</strong> During development I rebuild dozens of times a day. The screensaver host process inherits permission grants &mdash; accessibility, screen recording &mdash; from the parent bundle, indexed by the binary&rsquo;s code signature. Ad-hoc signing produces a new hash on every rebuild, which macOS interprets as a new app, which means every rebuild starts with all permissions revoked. You can paper over this with a stable team signing identity during dev, but the friction is real and the failure mode is silent. You launch the saver, the screen capture doesn&rsquo;t work, and you waste twenty minutes wondering why your code broke before you remember to re-grant the permission.</p>
<p>That&rsquo;s the list. Each one took a session to discover, a session to understand, and a session to admit there was no workaround that would survive contact with real users. Cumulatively, several days of work, none of it leaving anything behind but a slightly deeper understanding of where the <span class="lock"><code>.saver</code></span> sandbox stops.</p>
<h2>The pivot</h2>
<p>I sat with this for a while. The honest summary was: <span class="lock"><code>legacyScreenSaver</code>&rsquo;s</span> sandbox is fine for a saver that does standard 2D drawing into an <span class="lock"><code>NSView</code>.</span> The moment you want to host a serious rendering pipeline &mdash; WebKit, WebGL, Metal, a video decoder, anything with its own process or its own GPU context &mdash; you&rsquo;re asking <span class="lock"><code>legacyScreenSaver</code></span> to do things it was never designed to do, against a system that is actively trying to keep background processes from burning power.</p>
<p>You can fight the system, lose, and ship something that limps. Or you can stop pretending the constraints don&rsquo;t exist and build the thing as what it actually wants to be: a regular application.</p>
<p>That&rsquo;s where Rainy Day ended up. The architecture is mundane:</p>
<ul>
<li>A regular <span class="lock"><code>.app</code>,</span> signed with the team Developer ID.</li>
<li><span class="lock"><code>LSUIElement=YES</code></span> in <span class="lock"><code>Info.plist</code>,</span> so it&rsquo;s an accessory app with no Dock icon and no menu bar of its own.</li>
<li>Registered as a login item via <span class="lock"><code>SMAppService.mainApp.register()</code></span> on first launch.</li>
<li>A menu-bar status item with About / Activate Now / Settings / Check for Updates / Quit.</li>
<li>A 1Hz timer polling <span class="lock"><code>CGEventSource.secondsSinceLastEventType(.combinedSessionState, …)</code></span> for idle time.</li>
<li>When idle exceeds the user&rsquo;s threshold, the app opens one fullscreen <span class="lock"><code>NSWindow</code></span> per connected <span class="lock"><code>NSScreen</code>,</span> at <span class="lock"><code>NSWindow.Level.screenSaver</code>,</span> hosting a <span class="lock"><code>WKWebView</code>.</span></li>
<li>Any mouse or keyboard event in any of those windows tears them all down.</li>
</ul>
<p>Every constraint of the <span class="lock"><code>.saver</code></span> path falls away.</p>
<p>WebKit runs at full speed because it&rsquo;s in <em>my</em> app&rsquo;s process, not <span class="lock"><code>legacyScreenSaver</code>&rsquo;s.</span> There&rsquo;s no &ldquo;background view&rdquo; classification, no aggressive suspension, no need for an SPI escape hatch that doesn&rsquo;t work any more. WebGL pixels reach the screen via WindowServer&rsquo;s normal compositing pipeline, the same way any other browser tab&rsquo;s WebGL reaches the screen &mdash; no SCK, no <abbr data-title="Inter Process Communication">IPC</abbr> shenanigans, no occlusion edge cases. There&rsquo;s one process and one lifecycle: preview, fullscreen activation, and per-display rendering are all just &ldquo;open and close some ordinary windows.&rdquo; TCC grants persist across rebuilds because my team Developer ID signature is stable. Custom <abbr data-title="User Interface">UI</abbr> &mdash; settings window, About modal, global hotkey for Activate Now &mdash; is trivial because it&rsquo;s just AppKit and SwiftUI doing what they do in any other app.</p>
<p>The whole pivot took one afternoon. Most of the code was reusable from the failed <span class="lock"><code>.saver</code></span> attempt &mdash; the WKWebView setup, the raindrop-fx integration, the photography handling. What I threw away was the entire scaffold around it: the <span class="lock"><code>ScreenSaverView</code></span> subclass, the configuration sheet, the System Settings integration, all the helper-process workarounds I&rsquo;d been building to escape the sandbox. None of that has any place in an app that <em>is</em> its own host.</p>
<h2>The trade-off</h2>
<p>There is one. Rainy Day doesn&rsquo;t appear in System Settings &rarr; Screen Saver, because it isn&rsquo;t a screensaver. If you&rsquo;ve set the system saver to Aerial and you want to switch to Rainy Day, you don&rsquo;t do that from System Settings &mdash; you launch Rainy Day, set the system saver to <em>Never</em>, and let Rainy Day handle idle activation directly. There&rsquo;s a paragraph about this on the <a href="https://jorviksoftware.cc/screensavers/rainyday">product page</a> and a sentence in the welcome dialog. It hasn&rsquo;t generated any user confusion yet, partly because Jorvik&rsquo;s audience tends to read the docs, and partly because once you&rsquo;ve set it up you never think about it again.</p>
<p>The upside of not being in System Settings is that I get to provide my own configuration UI, which is dramatically richer than the <span class="lock"><code>.saver</code></span> configuration sheet would have permitted. Rainy Day&rsquo;s Settings window has an Activation section with idle threshold and global hotkey, an On Dismiss section with options for what happens when you move the mouse (return to desktop / lock the Mac), a Capture section for the &ldquo;save current frame&rdquo; feature, a Backgrounds section with full management of the rotating photography library, and the standard Jorvik General section with Launch at Login. That&rsquo;s around a dozen controls. The <span class="lock"><code>.saver</code></span> configuration sheet would have given me a modal panel with maybe four. I&rsquo;d have had to fight the sandbox to do half of what&rsquo;s in there now.</p>
<p>For users who like keyboard control: every action has a global hotkey, including Activate Now, which means you can trigger Rainy Day at any time with <span class="kbd">control</span> <span class="kbd">option</span> <span class="kbd">command</span> <span class="kbd">R</span> (or whatever you remap it to). The <span class="lock"><code>.saver</code></span> path can&rsquo;t do that &mdash; global hotkeys require either Carbon&rsquo;s <span class="lock"><code>RegisterEventHotKey</code></span> from an active process, or accessibility-client status, neither of which fits the saver model.</p>
<h2>The wider observation</h2>
<p>The interesting thing isn&rsquo;t the technical detail of any individual friction point. It&rsquo;s the pattern.</p>
<p><span class="lock"><code>legacyScreenSaver</code></span> is a system service that Apple ships, maintains in the sense of &ldquo;keeps compiling,&rdquo; and clearly hasn&rsquo;t actually developed in years. The framework around it &mdash; <span class="lock"><code>ScreenSaverView</code>,</span> the configuration sheet API, the hot-corner activation path &mdash; assumes a 2002 rendering model where your saver draws into an <span class="lock"><code>NSView</code></span> with Quartz primitives. Everything that&rsquo;s been added to macOS since &mdash; remote layer hosting, separate GPU processes, aggressive background suspension, TCC, SCK&rsquo;s occlusion logic &mdash; was bolted onto the rest of the system without anyone updating the saver framework to coexist with it. The result is that the API still works for the cases it was built for, and falls down for everything that requires a modern graphics stack.</p>
<p>That isn&rsquo;t unique to screensavers. There&rsquo;s a thread of this through several corners of macOS: subsystems that Apple keeps alive but no longer thinks about, where the documented path remains the documented path because nobody has felt strongly enough to deprecate it, but the surrounding system has moved on and the path no longer leads anywhere useful. Login items used to mean a list of <span class="lock"><code>.app</code></span> paths in System Settings; the modern, well-supported way is <span class="lock"><code>SMAppService</code>,</span> but the old <span class="lock"><code>LSSharedFileList</code></span> API still compiles. <span class="lock"><code>CGSession</code>&rsquo;s</span> <span class="lock"><code>-suspend</code></span> mode used to lock the screen from code; in macOS 26 the implementation is gone, only the header remains, and you have to drive <span class="lock"><code>osascript</code></span> and trigger an Accessibility prompt to do what one syscall used to do. The seams between &ldquo;the part Apple still cares about&rdquo; and &ldquo;the part Apple maintains but doesn&rsquo;t love&rdquo; are everywhere if you go looking.</p>
<p>For an independent developer, the practical question is recognising which side of that seam you&rsquo;re on early enough to stop wasting time. With Rainy Day the answer came after about three days of fighting the sandbox. If I&rsquo;d known what I know now I&rsquo;d have skipped to the <span class="lock"><code>.app</code></span> architecture on day one. That&rsquo;s what conventions are for &mdash; I wrote one up the day after Rainy Day shipped, &ldquo;Screensaver as standalone app&rdquo;, and the next two products in the rendering-heavy screensaver pipeline will start there rather than re-litigating the question.</p>
<h2>Coda</h2>
<p>I still believe screensavers are an interesting and underused canvas. I said as much in the last post on the subject, and shipping Rainy Day hasn&rsquo;t changed my mind. What&rsquo;s shifted is my view of the bundle format. The <span class="lock"><code>.saver</code></span> extension is a packaging detail. The user experience &mdash; an ambient full-screen thing that activates when you step away &mdash; doesn&rsquo;t require the OS to host you. It just requires you to behave correctly when the user goes idle, which any application can do.</p>
<p>Rainy Day calls itself a screensaver on the product page because that&rsquo;s what users want it to be. The bundle calls itself an app because that&rsquo;s what macOS will let it actually be. Both labels are correct for their audience. The mismatch is fine. The rain falls either way.</p>
]]></description>
    </item>
    <item>
      <title>Putting The House In Order</title>
      <link>https://jorviksoftware.cc/notes/2026/05/07/putting-the-house-in-order</link>
      <guid isPermaLink="true">https://jorviksoftware.cc/notes/2026/05/07/putting-the-house-in-order</guid>
      <pubDate>Thu, 07 May 2026 09:58:14 GMT</pubDate>
      <description><![CDATA[<div class="photo-frame"><img src="https://jorviksoftware.cc/notes/2026/05/07/manor-house.jpg" alt="manor house"></div>
<p class="image-caption">Photo by <a href="https://unsplash.com/@attercliffe?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText">Mike Smith</a> on <a href="https://unsplash.com/photos/selective-focus-photography-of-brown-concrete-building-t153G5AfVm0?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText">Unsplash</a></p>

<p>The fortnight that&rsquo;s just ended started with one of the smallest possible problems: <span class="lock"><span class="kbd">command</span><span class="kbd">space</span>, &ldquo;screenlock&rdquo;, <span class="kbd">return</span>,</span> and the wrong version of <a href="https://jorviksoftware.cc/utilities/screenlock">ScreenLock</a> launched. An older build. The settings layout was a previous iteration&rsquo;s. The pill toggle wasn&rsquo;t there. I&rsquo;d been certain I&rsquo;d installed the new build into <span class="lock"><code>/Applications</code></span>, but here was Spotlight handing me something that contradicted that, looking otherwise indistinguishable from the right answer.</p>
<p>I said &ldquo;huh&rdquo; out loud, restarted the app from <span class="lock"><code>/Applications</code></span> directly, finished the test I was doing, and tried to forget about it. Then a few hours later I caught myself doing the same wrong thing with <a href="https://jorviksoftware.cc/utilities/hypercaps">HyperCaps</a>. And after that, with the Notes Editor.</p>
<p>That&rsquo;s the moment my week stopped being about whatever I&rsquo;d planned to do and started being about housekeeping. Two weeks later I&rsquo;ve fixed every known bug across the suite, shipped two new products and a top-level Apps section on the website, <a href="https://jorviksoftware.cc/notes/2026/05/06/i-deleted-most-of-my-release-manager">rebuilt Release Manager</a> into a thin Swift dispatcher around a shared Make include, delivered on every user feature request that was queued, and reclaimed roughly nine and a half gigabytes of accumulated cruft from disk along the way. I want to write down what the audit-and-cleanup half of that effort looked like, because it&rsquo;s the part where the most was learned per hour spent.</p>
<h2>The audit I should have built earlier</h2>
<p>I run about seventeen tiny macOS utilities. They sit in the menu bar and they do their thing and I largely forget they exist &mdash; which is, by design, exactly how they&rsquo;re supposed to work. <a href="https://jorviksoftware.cc/utilities/clipman">ClipMan</a> watches the clipboard. <a href="https://jorviksoftware.cc/utilities/activespace">ActiveSpace</a> shows my current space. <a href="https://jorviksoftware.cc/utilities/quitprotect">QuitProtect</a> intercepts accidental quits. <a href="https://jorviksoftware.cc/utilities/rainbowapple">RainbowApple</a> draws a stripey Apple logo over the Apple in the menu bar &mdash; because life is short. They&rsquo;re invisible infrastructure.</p>
<p>The problem with invisible infrastructure is that you stop checking it. You assume it&rsquo;s running. You assume it&rsquo;s running from the right place. You assume the version installed locally is the version you released to GitHub, and that the version you released to GitHub is the version that&rsquo;s in your repository, and that all of those are in agreement with each other. You assume things are consistent, because why wouldn&rsquo;t they be? You&rsquo;re the only person touching them.</p>
<p>The Spotlight thing was the moment I noticed that several of those assumptions weren&rsquo;t actually being verified by anyone. My pipeline had been <em>producing</em> releases for months, but nothing in the system was <em>checking</em> the releases afterwards. I was solo developer, solo reviewer, solo QA, solo ops. The checks I forgot to write were the checks that didn&rsquo;t happen.</p>
<p>So I built a system audit. The first version was a Sunday afternoon&rsquo;s work. I added a button to Release Manager&rsquo;s toolbar. It opens a table with one row per app and three columns: version, path, source. Each cell is green, red, or amber. The whole table fits on one screen. The audit completes in about four seconds for the whole catalogue.</p>
<p>The three checks each answer a different question, and together they cover the dimensions of deployment consistency I cared about.</p>
<p><strong>Version: is what I have what I shipped?</strong> The installed app has a <span class="lock"><code>CFBundleShortVersionString</code></span> stamped in its <span class="lock"><code>Info.plist</code></span>. The Release Manager catalogue has the version of the latest GitHub release. If the two match, green. If they don&rsquo;t, red &mdash; with both numbers shown, so the direction of the mismatch tells me which kind of problem I&rsquo;m looking at.</p>
<p><strong>Path: is it running from where it should be?</strong> This is where <span class="lock"><code>ps</code></span> came in. <span class="lock"><code>ps -eo pid,args</code></span> returns one line per process with the full path to the executable. The audit captures the output, finds processes whose path contains the app&rsquo;s product name, and checks whether the path begins with <span class="lock"><code>/Applications/</code></span>. If yes, green. If no, the audit categorises where it actually is &mdash; <span class="lock"><code>AppTranslocation</code></span>, <span class="lock"><code>DerivedData</code></span>, the source tree&rsquo;s build output, somewhere unexpected.</p>
<p><strong>Source: is the repository in sync with the release?</strong> A <span class="lock"><code>git tag --sort=-v:refname</code></span> finds the highest semantic-version tag, then a commit count between that tag and HEAD tells me whether there&rsquo;s unreleased work sitting in the repo. Zero commits ahead is green. Anything else is amber: a reminder to release.</p>
<p>The whole audit engine is about 190 lines of Swift, shells out to four commands (<span class="lock"><code>ps</code></span>, <span class="lock"><code>git</code></span>, <span class="lock"><code>gh</code></span>, <span class="lock"><code>PlistBuddy</code></span>), uses no frameworks beyond Foundation, makes one network request (the GitHub <abbr data-title="Application Programming Interface">API</abbr> call to refresh the release-version cache). It&rsquo;s the smallest thing I&rsquo;ve added to Release Manager in months, and it might be the most useful.</p>
<h2>What the first run found</h2>
<p>The very first time I ran the audit, three things were wrong.</p>
<p>Release Manager itself showed <strong>N/A</strong> for the path check, even though I was obviously running it. After a few minutes of thinking I&rsquo;d coded the path check incorrectly, I realised it was actually working perfectly &mdash; my naming was off. The audit was searching for <span class="lock"><code>JorvikReleaseManager.app</code></span> (the build name) in the process paths, but the installed bundle is <span class="lock"><code>Jorvik Release Manager.app</code></span> with spaces (the install name). <span class="lock"><code>String.contains()</code></span> returned false, so the search couldn&rsquo;t find its own host process. A correct negative from incorrect assumptions. An audit that finds its own bugs has a certain charm; I fixed the lookup to consider both names and moved on.</p>
<p>Notes Editor showed <strong>Running from AppTranslocation</strong>. I&rsquo;d been running it for weeks, editing and deploying blog posts, with no indication that anything was wrong. The Dock icon showed the right name. The About window showed the right version. The app itself had no idea it had been silently relocated to a randomised directory deep inside <span class="lock"><code>/private/var/folders/</code></span> by macOS&rsquo;s anti-tampering machinery. <span class="lock"><code>ps</code></span> knew. None of the rest of my workflow did.</p>
<p>I&rsquo;d installed Notes Editor by downloading the zip from GitHub in a browser, extracting it, and dragging it to <span class="lock"><code>/Applications</code></span>. The browser had attached <span class="lock"><code>com.apple.quarantine</code></span> to the download. macOS&rsquo;s App Translocation feature had silently relocated the app on first launch. Every subsequent launch had been finding the translocated copy. The version in <span class="lock"><code>/Applications/Jorvik Notes Editor.app</code></span> was, in some technical sense, not the version I&rsquo;d been running.</p>
<p>This is the kind of finding that makes you question everything you thought you knew about your own machine. The app is in <span class="lock"><code>/Applications</code></span>. You launched it from <span class="lock"><code>/Applications</code></span>. But it&rsquo;s not running from <span class="lock"><code>/Applications</code></span>. macOS performed a silent redirect, and the only way to know is to ask the process table.</p>
<p>The third finding was a small but real bug in my source check. I&rsquo;d originally implemented the &ldquo;latest tag&rdquo; lookup with <span class="lock"><code>git describe --tags --abbrev=0</code></span>, which sorts by <em>commit ancestry</em> rather than by <em>semantic version</em>. On a couple of repos where I&rsquo;d cherry-picked commits between tags, the nearest tag by graph distance was an older release than the highest tag by version number. The check was answering a slightly different question than the one I was asking. I rewrote it to use <span class="lock"><code>git tag --sort=-v:refname | head -n 1</code></span> and the answers became right.</p>
<p>Three checks, three problems, on the very first run. The audit paid for itself before I&rsquo;d finished writing this paragraph.</p>
<h2>And then the disk</h2>
<p>If the apps were in disagreement with the catalogue, what else was? I ran a one-line <span class="lock"><code>mdfind</code></span> query against my whole machine:</p>
<pre><code class="language-bash">mdfind &#39;kMDItemCFBundleIdentifier == &quot;cc.jorviksoftware.*&quot;&#39;
</code></pre>
<p>The output was longer than I&rsquo;d expected. Forty-something <span class="lock"><code>.app</code></span> bundles whose identifiers started with <span class="lock"><code>cc.jorviksoftware.</code></span>, scattered across:</p>
<ul>
<li><span class="lock"><code>/Applications/</code></span> &mdash; the canonical install for each app, fine.</li>
<li><span class="lock"><code>~/Desktop/Jorvik Software/&lt;App&gt;/</code></span> &mdash; live build outputs from every <span class="lock"><code>./build.sh</code></span> I&rsquo;d ever run, fine.</li>
<li><span class="lock"><code>~/Desktop/Jorvik Software/&lt;App&gt;/_BuildOutput/</code></span> &mdash; Release Manager intermediate builds, fine.</li>
<li>And &mdash; less fine &mdash;</li>
<li><span class="lock"><code>~/Library/Mobile Documents/com~apple~CloudDocs/Desktop/Jorvik Software/</code></span> &mdash; an iCloud Drive clone of the entire source tree, weighing in at 4.2&nbsp;GB. I&rsquo;d once briefly enabled Apple&rsquo;s &ldquo;Sync Desktop &amp; Documents to iCloud&rdquo; feature, then disabled it, and the <abbr data-title="Operating System">OS</abbr> had left behind a phantom copy of every file that had been synced at the moment of disable. The cleanup never quite happened.</li>
<li><span class="lock"><code>~/backups/Desktop/Jorvik Software/</code></span> &mdash; a separate, older 4.2&nbsp;GB backup, dating to early April. This one I genuinely don&rsquo;t remember making. It contains apps with names that no longer exist &mdash; a long-defunct BrowserMCP, a similarly defunct BrowserMarker, a deprecated WindowRecall that was superseded by SpaceMan a few weeks ago. Some kind of system snapshot from before that consolidation, presumably.</li>
<li>A handful of stray Xcode build directories: <span class="lock"><code>ActiveSpace/build/</code></span>, <span class="lock"><code>ActiveSpace/_DebugBuild/</code></span>, <span class="lock"><code>CalendarUpcoming/build/</code></span>, <span class="lock"><code>Notes Editor/_build/</code></span>. Total combined: 1.27&nbsp;GB. Build artefacts from earlier build setups that don&rsquo;t get touched by anything any more.</li>
</ul>
<p>So the situation Spotlight had been facing wasn&rsquo;t &ldquo;two ScreenLock bundles.&rdquo; It was &ldquo;five copies of every Jorvik app, two-thirds of them old, all of them indexed, and a heuristic algorithm trying to pick one.&rdquo; The wonder isn&rsquo;t that it occasionally picked the wrong one. The wonder is that it ever picked the right one.</p>
<h2>The Spotlight cache trap</h2>
<p>I dropped a <span class="lock"><code>.metadata_never_index</code></span> marker into the source tree. macOS supports this convention for any directory you want Spotlight to ignore. Empty file, unambiguous semantics, takes about a second to create:</p>
<pre><code class="language-bash">touch &quot;/Users/jonathanhollin/Desktop/Jorvik Software/.metadata_never_index&quot;
</code></pre>
<p>Then I deleted the iCloud clone, the <span class="lock"><code>~/backups/</code></span> tree, and the four stray build directories. Total reclaimed: about 9.6 gigabytes. Not enormous in modern terms &mdash; a single Xcode install is bigger &mdash; but the amount wasn&rsquo;t the point. The point was that none of those gigabytes were doing any work. They were silently confusing search results, populating duplicate matches, and (in the iCloud case) forcing me to reason about a parallel-universe copy of every file I edited.</p>
<p>I tested Spotlight again. The wrong copy of MouseCatcher was still being returned.</p>
<p>This was the bit I learned the hard way. <span class="lock"><code>.metadata_never_index</code></span> stops <em>future</em> indexing. It does not retroactively flush the cache. macOS still knew about <span class="lock"><code>MouseCatcher.app</code></span> because it had indexed the file sometime last week, before the marker existed; the marker tells the indexer &ldquo;don&rsquo;t visit this path again,&rdquo; but the entry already in the index just sits there. Cached entries normally age out as the indexer revisits the volume &mdash; but for a directory under a <span class="lock"><code>.metadata_never_index</code></span> marker, the indexer doesn&rsquo;t revisit. So the cached entries can persist indefinitely.</p>
<p>This produced a fairly perfect bit of self-trolling. I&rsquo;d marked the directory. I&rsquo;d deleted the dead trees. I&rsquo;d stripped 9.6&nbsp;GB of files I shouldn&rsquo;t have had. The world was tidier than it had ever been. And Spotlight was lying about it.</p>
<p>The fix is, as Apple-fix things tend to be, hidden in plain sight: <strong>System Settings &rarr; Spotlight &rarr; Privacy</strong>. Drag a folder into that pane. macOS treats the addition as &ldquo;immediately drop everything you have for paths under here.&rdquo; This is <em>materially</em> different behaviour from the marker file &mdash; Privacy is an active flush, the marker is a passive don&rsquo;t-index instruction. You can use both together: drag the source tree into Privacy, watch the cache get flushed within seconds, leave it there or remove it again afterwards (the marker keeps it from being re-indexed regardless). I went with leave-it-in-Privacy &mdash; belt and braces, plus a permanent indication in System Settings that this path is intentionally excluded.</p>
<p>After all of that, <span class="lock"><code>mdfind &#39;kMDItemCFBundleIdentifier == &quot;cc.jorviksoftware.*&quot;&#39;</code></span> returned exactly one result per app: the canonical install in <span class="lock"><code>/Applications/</code></span>. Spotlight launches the right app every time, because there is no wrong app on the system any more.</p>
<h2>The other strands</h2>
<p>While the audit was teaching me things, the rest of the fortnight kept happening alongside it. I want to mention the work briefly because each of these strands fed into the same arc: making the estate easier to live in.</p>
<p><a href="https://jorviksoftware.cc/notes/2026/05/06/i-deleted-most-of-my-release-manager">Release Manager itself got rebuilt</a> &mdash; the part of the codebase that was the audit&rsquo;s sibling rather than its subject. The 1,600-line Swift pipeline that built, signed, notarised, and packaged every Jorvik app became a 770-line dispatcher around a shared Make include. The build/sign/notarise/staple/package work moved out into shell, where every tool involved natively lives, and the Swift app kept the pieces that genuinely benefit from being Swift. That migration alone was ten days&rsquo; work, but it&rsquo;s its own post.</p>
<p>A handful of long-standing user feature requests landed during the same window. The <span class="lock"><a href="https://jorviksoftware.cc/notes/2026/04/04/why-i-built-my-own-website-editor">Web Editor</a>&rsquo;s</span> file browser learned to refresh on focus, so files added in another tool stop requiring an editor restart. The Daily News reader gained a per-feed health pill (green / amber / red) and a real OPML import-time dedup, both of which were necessary to manage a 300-plus-feed collection without losing your mind. Release Manager&rsquo;s Prod-status indicator stopped lying when the source had been pushed to GitHub but not actually deployed to Cloudflare Pages. The Web Editor&rsquo;s commit dialog stopped having a single-line text field with no visible cursor (a small bug that I had been silently working around for six weeks while telling myself I&rsquo;d fix it next time).</p>
<p>Two new product pages went up on jorviksoftware.cc &mdash; one for <a href="https://jorviksoftware.cc/apps/dailynews">Daily News</a>, one for <a href="https://jorviksoftware.cc/screensavers/reverie">Reverie</a> &mdash; and a new top-level &ldquo;Apps&rdquo; section was added to the navigation, with the homepage and every static page updated, the build script updated to propagate the change to every regenerated note page, the sitemap updated, the Cloudflare deploy refreshed. About sixty files touched in one commit, and not a single visual regression on the homepage.</p>
<p>I rewrote a clutch of stale <abbr data-title="Knowledge Base">KB</abbr> documents, deleted three pieces of memory that were no longer accurate, and added two new conventions (one for keyboard-shortcut style on the website, one for catalogue/Makefile drift in the build pipeline). The KB grew. The misinformation in it shrank.</p>
<p>Each of those strands looked like its own thing while it was happening. Looking back from this end of the fortnight, they were all the same thing: every place where the estate was a bit out of square got squared up.</p>
<h2>The principle</h2>
<p>The audit, the cleanup, the RM rebuild, the feature requests, the KB hygiene &mdash; if there&rsquo;s a single thread tying them together, it&rsquo;s this: <strong>trust, but verify, and make verification cheap enough that you actually do it.</strong></p>
<p>I trust my pipeline. I built it. I know what it does. But trust without verification is faith, and faith is a poor foundation for engineering. The audit exists so that I can trust the pipeline and confirm that my trust is justified, routinely, without effort. Four seconds and one button is cheap enough.</p>
<p>The same principle applied to the disk. I trusted that <span class="lock"><code>/Applications</code></span> was canonical. <span class="lock"><code>mdfind</code></span> verified that it wasn&rsquo;t, because the iCloud clone, the backups directory, and the build dirs were all populating the same answer in different shapes. I trusted that Spotlight reflected reality. The cache verified that it didn&rsquo;t, until I forced it to. Each of these was an assumption I&rsquo;d held for months without ever testing &mdash; and each of them turned out to be wrong in some interesting way.</p>
<p>The same principle applied to the Release Manager rebuild. I trusted that putting the build pipeline inside a Swift host app was the right shape, because Swift is what I write Mac apps in. The audit-style framing of &ldquo;but does the structure match the work?&rdquo; revealed that the answer was no &mdash; the build pipeline was shell-shaped work, and putting it in a Swift host had been adding one bug per new project type for over a year. Verification told me what habit had been hiding.</p>
<p>I think this is the lesson of the fortnight, more than any of the individual cleanups. The estate runs better when the assumptions underneath it are routinely checked. Most of the time the checks come back green and you proceed. Occasionally one comes back red, and you find a thing that&rsquo;s been quietly wrong for weeks &mdash; possibly months &mdash; and that you&rsquo;d never have found through normal use because it was nowhere your normal use looked.</p>
<h2>A small confession</h2>
<p>The pleasure of finishing this fortnight isn&rsquo;t the disk space, although the disk space is nice. It isn&rsquo;t even the RM line count, although that&rsquo;s also nice. The pleasure is the <em>correlation</em> between the system as I understand it and the system as it actually is.</p>
<p>After the cleanup, when I ask &ldquo;where is ScreenLock,&rdquo; there is one answer. Before, there were five. Five is too many. One is the right number. After the audit, when I ask &ldquo;is the version I&rsquo;m running the version I shipped,&rdquo; the table shows me green for every app and I believe the green. After the RM rebuild, when I ask &ldquo;why did this build behave this way,&rdquo; the answer lives in 430 readable lines of <span class="lock"><code>release.mk</code></span> rather than 1,600 lines of Swift across thirteen interlocking pipeline stages.</p>
<p>Anyone who&rsquo;s spent time in a kitchen knows the feeling. Half the joy of a clean countertop isn&rsquo;t the cleanliness; it&rsquo;s that you can put a thing down without first having to move three other things out of the way. The countertop is in correspondence with itself.</p>
<p>A clean filesystem is a clean countertop. A clean catalogue is a clean drawer. A clean release pipeline is a clean cooker hood. None of it is glamorous, but if you&rsquo;ve ever sat down to start work and realised that <em>the workspace itself</em> is in your way, you&rsquo;ll know the feeling I mean. Today, mine isn&rsquo;t. Tomorrow I get to start something new on a workspace that won&rsquo;t fight me when I do.</p>
<p>Two weeks of housekeeping, paid for by the small dignity of asking my own tools to be honest with me about themselves. I&rsquo;d do it again. I will do it again, periodically, because <em>that&rsquo;s the deal</em>. Estates don&rsquo;t stay tidy on their own.</p>
<p>But not tomorrow. Tomorrow, I think, I&rsquo;m going to build something.</p>
]]></description>
    </item>
    <item>
      <title>I Deleted Most Of My Release Manager</title>
      <link>https://jorviksoftware.cc/notes/2026/05/06/i-deleted-most-of-my-release-manager</link>
      <guid isPermaLink="true">https://jorviksoftware.cc/notes/2026/05/06/i-deleted-most-of-my-release-manager</guid>
      <pubDate>Wed, 06 May 2026 20:47:05 GMT</pubDate>
      <description><![CDATA[<div class="photo-frame"><img src="https://jorviksoftware.cc/notes/2026/05/06/demolition.jpg" alt="orange excavator on a demolition site"></div>
<p class="image-caption">Photo by <a href="https://unsplash.com/@nightseller?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText">Oleg Solodkov</a> on <a href="https://unsplash.com/photos/yellow-excavator-near-brown-concrete-building-during-daytime-CeGP3MkcnxA?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText">Unsplash</a></p>

<p>I wrote Release Manager because I needed something to build, sign, notarise, package, and ship every Jorvik app from one window. I wrote it in Swift because Swift is what I write Mac apps in, and Release Manager is a Mac app. I wrote it as one big pipeline because pipelines are sequential and Swift is good at sequential. By the time it was working end-to-end it was about a thousand lines long, and I felt entirely justified.</p>
<p>A year and change later it was 1,600 lines and I wasn&rsquo;t justified anymore.</p>
<p>I want to be careful about how I describe what changed, because it&rsquo;s tempting to write this as &ldquo;the old code was bad and I made it good,&rdquo; and that isn&rsquo;t honest. The old code was fine. It worked. It shipped real software for a year. The thing that changed is that I came to understand which parts of the job <em>belonged</em> in Swift and which parts I had only put there because Swift was the language I happened to be holding.</p>
<h2>How the pipeline got fat</h2>
<p>The Release Manager pipeline started simple. Eleven stages, sequenced in order: preflight checks, build, verify the build, sign the bundle, verify the signature, submit to Apple&rsquo;s notary service, staple the ticket, verify notarisation, package as <code>.zip</code> (or <code>.pkg</code>, for things like the screen savers), sign the zip with Sparkle&rsquo;s EdDSA key, push to GitHub. Each stage was a Swift function. They called <span class="lock"><code>Process</code></span> with a list of arguments, captured stdout via <span class="lock"><code>Pipe</code></span>, parsed the result, and either passed or failed.</p>
<p>This was clean architecture for the original suite, which was almost entirely menu-bar utilities built with one of two patterns. <span class="lock"><code>xcodebuild</code></span> for the apps that lived in Xcode projects, <span class="lock"><code>swift build</code></span> for the ones using Swift Package Manager. The pipeline branched on <span class="lock"><code>buildSystem</code></span> at one point, ran the right shell-out, and continued.</p>
<p>Then I started adding apps that didn&rsquo;t fit the pattern.</p>
<p>The first wobble was apps that were neither full Xcode projects nor <abbr data-title="Swift Package Manager">SPM</abbr> packages &mdash; little single-file utilities I&rsquo;d written with a direct <span class="lock"><code>swiftc</code></span> invocation in a <span class="lock"><code>build.sh</code>.</span> Adding <code>swiftc</code> as a third build mode meant a new branch in the build stage. Then I started embedding <span class="lock"><code>Sparkle.framework</code></span> for auto-update, which meant the sign stage had to walk the framework&rsquo;s nested code in dependency order &mdash; XPC services, the updater app, the framework binary itself &mdash; and codesign each leaf before sealing the outer bundle. That&rsquo;s a recursive shell helper in any sensible world. In Release Manager it became a hundred lines of Swift that walked <span class="lock"><code>Contents/Frameworks/</code></span> with file enumerators.</p>
<p>Then I added a screen saver. <a href="https://jorviksoftware.cc/screensavers/asciisaver"><abbr data-title="American Standard Code for Information Interchange">ASCII</abbr> Saver</a> isn&rsquo;t a single bundle &mdash; it&rsquo;s a <span class="lock"><code>.saver</code></span> plus a helper <span class="lock"><code>.app</code></span> that handles camera access, distributed as a multi-component installer. That meant the package stage had to know about <span class="lock"><code>productbuild</code>,</span> <span class="lock"><code>Distribution.xml</code></span>, multiple inner packages combined into an outer package, with the helper pre-signed differently from the main bundle. More Swift, more branches, more hardcoded <span class="lock"><code>if app.id == &quot;ASCIISaver&quot;</code></span> paths I told myself I&rsquo;d clean up later.</p>
<p>Then Reverie came along. <a href="https://jorviksoftware.cc/screensavers/reverie">Reverie is a screensaver</a> too &mdash; a meditative roulette-curve thing that I&rsquo;d been wanting to build for ages &mdash; and on its first release through Release Manager it surfaced six distinct bugs in five distinct places.</p>
<h2>The Reverie bugs</h2>
<p>I want to walk through these because they&rsquo;re what convinced me. Each one, looked at in isolation, was a small thing. Looked at together, they were a pattern.</p>
<p>The build stage for <code>swiftc</code> apps was missing <span class="lock"><code>-emit-library</code></span> for <span class="lock"><code>.saver</code></span> bundles. (Screen savers are dylibs, not executables, because <span class="lock"><code>legacyScreenSaver</code></span> loads them.) The package stage had a hard-coded reference to <span class="lock"><code>ASCIISaver-Installer.pkg</code></span> in the asset upload path that I&rsquo;d forgotten about. The install location was hard-coded to <span class="lock"><code>/Applications</code></span> even for <span class="lock"><code>.saver</code></span> bundles, which actually go to <span class="lock"><code>/Library/Screen Savers</code>.</span> The swiftc source-discovery method had a wildcard that didn&rsquo;t recurse sub-directories, missing Reverie&rsquo;s <span class="lock"><code>Sources/</code></span> layout. <span class="lock"><code>Process</code></span>&rsquo;s <span class="lock"><code>FileHandle.nullDevice</code></span> for stdin wasn&rsquo;t actually propagating to grandchild processes when I shelled out via a wrapper script, causing <span class="lock"><code>notarytool</code></span> to hang waiting for input. And the inner <span class="lock"><code>.saver</code></span> inside the produced pkg was being signed with the ad-hoc certificate by the first nested build, then the outer pkg was getting Developer ID Installer signing &mdash; so the package signature was right but the bundle inside it was wrong, and the user&rsquo;s install would fail Gatekeeper.</p>
<p>I sat with that list for a while. None of these were architecture problems. They were <em>details</em> &mdash; little factual things about how <code>codesign</code> and <code>notarytool</code> and <code>pkgbuild</code> actually work, accumulated as Swift conditionals because every time I&rsquo;d met one I&rsquo;d added a branch. But the cumulative weight was an architecture problem. The reason these bugs kept appearing wasn&rsquo;t that I was a careless coder. It was that I was reimplementing, in Swift, a domain that already had a perfectly good native vocabulary.</p>
<h2>Shell tools belong in shell</h2>
<p>Here&rsquo;s the thing I should have admitted years earlier: <span class="lock"><code>codesign</code></span> and <span class="lock"><code>xcrun notarytool</code></span> and <span class="lock"><code>pkgbuild</code></span> and <span class="lock"><code>ditto</code></span> and <span class="lock"><code>stapler</code></span> are <em>shell tools</em>. Their inputs and outputs are designed for a shell. Their stdin handling is designed for a shell. Their exit codes are designed for a shell. Their timeout semantics, their environment variable conventions, their output formats &mdash; all of it assumes you&rsquo;re calling them from a shell, not from a process spawning library inside a host language.</p>
<p>Calling them from Swift via <span class="lock"><code>Process</code></span>/<span class="lock"><code>Pipe</code></span> isn&rsquo;t wrong, exactly. It works. But it&rsquo;s a layer of indirection between the tool and the natural way to use the tool, and every layer of indirection is a place where bugs can live. The stdin-not-propagating-to-grandchildren bug, in particular, isn&rsquo;t something a shell user would ever even meet. They&rsquo;d redirect stdin from <code>/dev/null</code> once at the top of the script and the matter would be settled. In Swift, I&rsquo;d been carefully constructing <span class="lock"><code>FileHandle.nullDevice</code></span> objects and passing them as <span class="lock"><code>standardInput</code>,</span> which works for the immediate child but doesn&rsquo;t inherit the way an honest <span class="lock"><code>&lt; /dev/null</code></span> redirect would, and Apple&rsquo;s notarytool happens to launch a daemon that <em>needs</em> to inherit a real stdin descriptor or it hangs forever. I&rsquo;d burned a literal day on that one.</p>
<p>There&rsquo;s a version of this argument that goes &ldquo;use the right tool for the job, ya numpty&rdquo; and feels obvious in retrospect. There&rsquo;s another version that&rsquo;s subtler and worth dwelling on: I&rsquo;d been treating &ldquo;Swift is the native macOS language&rdquo; as if it meant &ldquo;Swift is the right tool for everything you do on macOS.&rdquo; Those aren&rsquo;t the same statement. Swift is wonderful for building the user-facing app &mdash; the window, the buttons, the catalogue, the audit reports, the settings. It is <em>not</em> particularly suited to orchestrating a sequence of shell-shaped tasks where every task already has a battle-tested shell idiom. I had been using it for both because it was the language I&rsquo;d started in.</p>
<h2>The split</h2>
<p>Once I&rsquo;d framed the problem that way, the design wrote itself.</p>
<p>Release Manager would stay a Swift app. It would keep the user interface, the catalogue of apps and their configurations, the preflight checks (which need consistent reporting in the <abbr data-title="User Interface">UI</abbr>), the <em>verification</em> stages (which earn their keep precisely by being independent of the build), the EdDSA signing of the produced zip (which yields structured metadata that goes back into the catalogue), and the GitHub release upload. All of that is real Swift work, with state and UI and structured outputs that benefit from a real type system.</p>
<p>The build, signing, notarisation, stapling, and packaging would move out. Out into a Make include &mdash; one shared file called <span class="lock"><code>release.mk</code></span> &mdash; that every project in the suite would <code>include</code> from its own minimal <code>Makefile</code>. The project Makefile would declare identity (bundle name, build system, frameworks, entitlements, embedded frameworks like Sparkle) and that&rsquo;s it. <span class="lock"><code>release.mk</code></span> would do the work, in shell, where every tool involved natively lives.</p>
<p>This was a non-trivial commitment. I had twenty-three apps in the catalogue. Each would need a Makefile. The shared <code>release.mk</code> would need to handle three build systems, single-target and multi-target installers, with-and-without embedded Sparkle, optional install-name renaming (<span class="lock"><code>JorvikReleaseManager.app</code></span> &rarr; <span class="lock"><code>Jorvik Release Manager.app</code></span> at install time, that kind of thing), <span class="lock"><code>alsoShipPkg</code></span> dual-shipping, helper apps with their own entitlements. None of that complexity was <em>new</em> &mdash; it all already existed, in Release Manager, in Swift &mdash; but I&rsquo;d be retranslating it to shell. With migration mistakes likely.</p>
<p>So I phased it. Phase 1: build the shared infrastructure with Reverie as the test subject &mdash; the simplest case, single-target, no embedded frameworks, no install-name rename. If <span class="lock"><code>release.mk</code></span> could ship Reverie cleanly through the terminal end-to-end &mdash; produce a signed, notarised, stapled <span class="lock"><code>.pkg</code></span> &mdash; the foundation was sound.</p>
<p>Phase 1.5: validate against <a href="https://jorviksoftware.cc/utilities/menutidy">MenuTidy</a>. MenuTidy is a <code>swiftc</code> app with embedded Sparkle and <span class="lock"><code>alsoShipPkg</code></span>-driven dual-ship. It was the next-hardest case, and I deliberately put it before any Release Manager wiring. The Sparkle leaves-first signing recursion is the part of the recipe with the most opinionated detail; if it didn&rsquo;t work for MenuTidy there was no point continuing. I ran it in shadow-mode &mdash; the existing Swift pipeline in Release Manager <em>and</em> the new <span class="lock"><code>gmake release</code></span> target, side by side &mdash; and diff&rsquo;d the produced bundles modulo timestamps and signature randomness. They matched. That was the moment I knew the rest was just typing.</p>
<p>Phase 2: add the dispatcher to Release Manager, gate it on a per-app boolean. Apps with the flag use the new path; everything else stays on the legacy Swift code, untouched. This let me migrate one app at a time without holding the entire suite&rsquo;s release cadence hostage to the rewrite.</p>
<p>Phase 3: roll through the suite. Twelve menu-bar utilities first, then the news reader, then the calendar app, then the Xcode-based internal tools, then ASCII Saver as the final boss. Each migration took thirty minutes and surfaced precisely zero new bugs in <span class="lock"><code>release.mk</code></span> after MenuTidy. That itself was a quiet vindication: the cases I&rsquo;d been adding hardcoded Swift branches for had all been varieties of the same handful of orthogonal questions (build system &times; framework embedding &times; package format &times; multi-target). Once those were composable variables in a shared file, nothing was special anymore.</p>
<p>ASCII Saver did need new shape in <span class="lock"><code>release.mk</code>,</span> because its multi-component <span class="lock"><code>productbuild</code></span> flow with the camera-agent helper had no analogue in the simpler cases. I added a <span class="lock"><code>HELPER_TARGETS</code></span> variable that&rsquo;s a list of colon-delimited records (one per helper: target name, product name, entitlements file, package identifier, package filename) and let the Make include loop over them in build, stamp, sign, notarise, staple, and pkgbuild. That was the only real extension. The fact that I needed to add it for one app and zero others did make me wonder, briefly, whether ASCII Saver was just structurally weird &mdash; but the answer is that screen savers with helpers are a real pattern (any future saver that needs camera or microphone access will need the same shape), and the variable now exists for the next one.</p>
<p>Phase 4: delete the legacy Swift code. This is the part I&rsquo;d been looking forward to.</p>
<h2>The number</h2>
<p>Phase 4 took out roughly 1,100 lines from <span class="lock"><code>PipelineEngine.swift</code></span> &mdash; <span class="lock"><code>buildXcode</code></span>, <span class="lock"><code>buildSPM</code></span>, <span class="lock"><code>buildSwiftc</code></span>, <span class="lock"><code>buildMake</code></span>, the entire <span class="lock"><code>executeSign</code></span> framework-recursion loop, <span class="lock"><code>executeNotarise</code></span>, <span class="lock"><code>executeStaple</code></span>, <span class="lock"><code>executePackage</code></span>, <span class="lock"><code>packageZip</code></span>, <span class="lock"><code>packagePkg</code></span>, <span class="lock"><code>buildSingleAppPkg</code></span>, the shared <span class="lock"><code>notariseAndStaple</code></span> helper that had been factored out at one point. Plus the orphaned helpers that had only ever been called from those (<span class="lock"><code>bundleInstallRoot</code></span>, <span class="lock"><code>SwiftcSourceResolution</code></span>, the swiftc source drift detector). And then a follow-up pass took out the catalogue fields the Swift code had been the only consumer of (<span class="lock"><code>xcodeProject</code></span>, <span class="lock"><code>xcodeScheme</code></span>, <span class="lock"><code>xcodeTarget</code></span>, <span class="lock"><code>swiftcSourceFiles</code></span>, <span class="lock"><code>swiftcFrameworks</code></span>) along with the catalogue editor sections that used them.</p>
<p>Release Manager went from about 1,600 lines of pipeline code to about 770. The shared <span class="lock"><code>release.mk</code></span> is around 430 lines. The per-project Makefiles are 15 to 30 lines each. Net: less code, more capability.</p>
<p>But honestly the line count isn&rsquo;t the part I care about. The part I care about is what <em>kind</em> of code it is.</p>
<p>The code that&rsquo;s left in Release Manager is the code that earns its keep by being Swift. The catalogue is a typed model with structured persistence. The verification stages run independent inspections of the produced artefacts and <em>catch</em> lies the build path might tell &mdash; if <span class="lock"><code>release.mk</code></span> somehow shipped a bundle with the wrong bundle ID, Verify Build would reject it before anything got pushed to GitHub. The EdDSA signing produces structured metadata that flows back into the catalogue, where the appcast generator reads it. The release stage prompts the user to confirm GitHub repo creation if needed, integrates with <span class="lock"><code>gh</code>&rsquo;s</span> output for asset uploads, writes back to the catalogue. All of that benefits from a real type system, async/await, and SwiftUI. None of it would be better in shell.</p>
<p>The code that left is the code that was always shell-shaped. It&rsquo;s now in a place where it can use shell idioms freely. <span class="lock"><code>set -eu -o pipefail</code>.</span> <span class="lock"><code>.DELETE_ON_ERROR</code>.</span> Quoting paths once, at the recipe level, instead of escaping them inside Swift string interpolations. Real <span class="lock"><code>&lt; /dev/null</code></span> redirects that work the way they&rsquo;re documented to work. <span class="lock"><code>for FW in $(EMBEDDED_FRAMEWORKS); do ... done</code></span> instead of a SwiftUI-style enumerator over a <span class="lock"><code>Sequence&lt;String&gt;</code>.</span></p>
<p>It feels appropriate, in a way that&rsquo;s hard to describe without sounding mystical about it. The right tool for the job, sitting in the place where it&rsquo;s most natural to read.</p>
<h2>The bigger structural win</h2>
<p>The line-count thing is satisfying. The structural thing is more important.</p>
<p>When I find a bug or an edge case in <span class="lock"><code>release.mk</code></span> now, I fix it in <span class="lock"><code>release.mk</code>,</span> push, and it&rsquo;s live across all twenty-three apps on the next build. No Release Manager rebuild. No notarisation. No reinstall. No fan-out to twenty-three repositories. One commit to the shared Make include, and every project benefits.</p>
<p>This sounds obvious when stated. In the old monolithic-Swift architecture, every release-pipeline fix required a Release Manager rebuild and reinstall, because the fix lived inside the Mac app. If the fix was the kind of thing that surfaces during a release attempt &mdash; which most are &mdash; you&rsquo;d be in the awkward position of trying to ship a fixed Release Manager <em>with</em> the broken Release Manager. I&rsquo;d become accustomed to the dance: notice the bug, fix it, ship a Release Manager point release, install it, retry the original release. Half my release-related bugs took a multi-rebuild loop to land.</p>
<p>That&rsquo;s gone now. I felt the shape of the change most acutely when I was migrating ASCII Saver and discovered four separate edge cases in <span class="lock"><code>release.mk</code></span> &mdash; nested helper signing, bare Mach-O signing for ActiveSpace&rsquo;s <span class="lock"><code>switch_helper</code>,</span> stale <span class="lock"><code>_BuildOutput</code></span> cleanup, install-name copy-not-rename. All four were <span class="lock"><code>release.mk</code></span> patches, pushed within fifteen minutes of each other. None of them required Release Manager to be touched. They just <em>worked</em>, on the next build, for every app at once.</p>
<p>I think this is the structural lesson of the whole exercise. Not &ldquo;Swift is the wrong tool for orchestration,&rdquo; though I do believe that. The deeper one: <em>give the rapidly-iterating part of a system the freedom to iterate</em>. The build pipeline is the part of any release toolchain that picks up the most edge cases over time, because every new app you add brings new edge cases. Putting it inside the Mac app you ship through that very pipeline created an iteration loop that was at minimum a Release Manager rebuild long. Putting it in a Make include cloned alongside every project shrunk that loop to whatever <span class="lock"><code>git push</code></span> takes.</p>
<h2>What I should have known sooner</h2>
<p>I think I would have arrived here faster if I&rsquo;d been less attached to the idea of Release Manager as &ldquo;a Mac app.&rdquo; The user-facing parts &mdash; the catalogue browser, the per-app status, the verify stages, the audit dashboard, the Build &amp; Release / Finalise two-button workflow &mdash; absolutely <em>are</em> a Mac app. The pipeline plumbing isn&rsquo;t. It just lived inside the Mac app because it had nowhere else to go, and once it was there, it was easier to keep adding to it than to extract it.</p>
<p>The temptation to keep adding to a monolith is strong, especially when the monolith is yours. Every new branch you add feels like progress &mdash; another case handled, another app supported &mdash; even when each branch is making the whole structure more brittle. The signal that you&rsquo;ve crossed the line isn&rsquo;t any single bug; it&rsquo;s the <em>pattern</em> of bugs. If the same kind of bug keeps coming up in different shapes, your abstraction is wrong.</p>
<p>Six bugs in one Reverie release was that pattern. It just took me a while to recognise it as a pattern rather than as bad luck.</p>
<h2>What&rsquo;s left</h2>
<p>The pipeline ships every app in the suite cleanly. Release Manager is smaller, simpler, and visibly does less. The shared <span class="lock"><code>release.mk</code></span> does most of the actual work and lives in a sibling repository &mdash; <span class="lock"><code>PerpetualBeta/jorvik-release</code>,</span> public, open source, available to anyone whose Mac app distribution flow has the same shape as mine. I&rsquo;m honestly not expecting many takers; the audience for &ldquo;a shared Make include for orchestrating macOS code signing and notarisation&rdquo; is small. But it&rsquo;s there. And when I add the next Jorvik app, its release pipeline will be a fifteen-line file that says &ldquo;here&rsquo;s my identity, please ship me,&rdquo; and the rest will happen automatically, in shell, where it always belonged.</p>
<p>Sometimes the most useful thing you can do for a piece of software is give some of its responsibility to a different piece of software, written in a language better suited to that responsibility. The total amount of work being done is the same. The amount being done well goes up.</p>
]]></description>
    </item>
    <item>
      <title>The Icons Behind The Notch</title>
      <link>https://jorviksoftware.cc/notes/2026/05/03/the-icons-behind-the-notch</link>
      <guid isPermaLink="true">https://jorviksoftware.cc/notes/2026/05/03/the-icons-behind-the-notch</guid>
      <pubDate>Sun, 03 May 2026 20:56:38 GMT</pubDate>
      <description><![CDATA[<div class="photo-frame"><img src="https://jorviksoftware.cc/notes/2026/05/03/the-notch.jpg" alt="the notch on an Apple MacBook screen">
</div>
<p class="image-caption">That Damn Notch!</p>

<p>I&rsquo;d filed <a href="https://jorviksoftware.cc/utilities/menutidy">MenuTidy</a> as &ldquo;done.&rdquo; That&rsquo;s an embarrassing thing to admit about your own app, but it&rsquo;s the truth. It&rsquo;s a tiny utility &mdash; you click a chevron in the menu bar and the third-party icons to its left collapse out of sight; click again, they come back. It does that one job, it does it well (or so I thought), it&rsquo;s been shipping for months without complaints. So in my head it sat in a folder marked &ldquo;maintenance only,&rdquo; and I&rsquo;d been quietly working on shinier things instead.</p>
<p>Today I went back to it because of a feature I&rsquo;d been postponing.</p>
<p>If you have a MacBook Pro with a notch &mdash; any 14&rdquo; or 16&rdquo;, plus the notched Airs &mdash; you&rsquo;ve probably noticed that when you accumulate enough menu bar icons, the leftmost ones don&rsquo;t just get squeezed; they vanish. The notch sits in the middle of the menu bar, and macOS treats it as an exclusion zone. Status items live to the right of it, growing leftward as more apps add their icons. When the queue gets too long, the items at the front of the line stop being rendered. They&rsquo;re still there in the system &mdash; the apps still own them &mdash; but you can&rsquo;t see them, and you can&rsquo;t click them. They&rsquo;re behind the notch in every functional sense.</p>
<p>Bartender users have been able to deal with this since the very first notched Mac shipped. I haven&rsquo;t. The MenuTidy approach &mdash; collapse the entire row of third-party icons behind a chevron &mdash; sidesteps the problem rather than solving it: if your icons are collapsed, none of them are visible, so the notch doesn&rsquo;t matter. But that&rsquo;s not always what you want. Sometimes you specifically want certain icons available, and you&rsquo;ve carefully arranged them to be the right of the spacer (always visible), and the notch then chooses one or two of <em>those</em> favourites to eat anyway.</p>
<p>So today: I added a feature called <strong>Reveal Hidden Icons</strong>. Right-click MenuTidy&rsquo;s chevron, choose the menu item, get a little floating panel listing every status icon that&rsquo;s currently behind the notch. Left-click an entry, the corresponding app behaves as if you&rsquo;d clicked its icon directly. Right-click, it behaves as if you&rsquo;d right-clicked. That was the design.</p>
<p>The implementation took rather longer than the design suggested it should.</p>
<h2>What the notch actually is, technically</h2>
<p>I want to spend a moment on the geometry, because I had a wrong mental model for most of the morning.</p>
<p>I&rsquo;d been thinking of the notch as a <em>visual</em> clip &mdash; the menu bar extends across the whole screen, and the notch is just a black rectangle drawn on top, hiding whatever&rsquo;s behind it. If that were true, you could click in the notch area and the click would still land on the icon underneath; it just wouldn&rsquo;t be visually obvious where the cursor was.</p>
<p>That isn&rsquo;t how it works. The notch is a <em>physical</em> hole in the screen &mdash; that&rsquo;s why it exists, the camera and Face ID hardware sit in there &mdash; and macOS knows it. The system menu bar is laid out around the notch, with two distinct rectangular regions, one to its left and one to its right. <span class="lock"><code>NSScreen</code></span> exposes them as <span class="lock"><code>auxiliaryTopLeftArea</code></span> and <span class="lock"><code>auxiliaryTopRightArea</code></span>. The space between them is the notch, and the menu bar isn&rsquo;t there in any meaningful sense. There&rsquo;s no menu bar pixel to render onto. There&rsquo;s no menu bar event-handler to receive clicks. It&rsquo;s a gap.</p>
<p>When status items overflow the right region, macOS doesn&rsquo;t reflow them into the left region or scroll them or compress them &mdash; it just stops rendering them. The AX tree still includes them, and they still report a frame, but the frame they report is the position they <em>would</em> occupy if the menu bar continued through the notch &mdash; which it doesn&rsquo;t. So an item that&rsquo;s &ldquo;behind the notch&rdquo; reports an X coordinate inside the notch&rsquo;s horizontal range, where there&rsquo;s nothing but air.</p>
<p>I figured this out the slow way.</p>
<h2>The first attempt: AXPress works, then doesn&rsquo;t</h2>
<p>The Accessibility <abbr data-title="Application Programming Interface">API</abbr> can enumerate every running app&rsquo;s status items via <span class="lock"><code>AXExtrasMenuBar</code></span>, an attribute on each application&rsquo;s AX element that returns a kind of meta-menu-bar containing exactly that app&rsquo;s status items. Walking <span class="lock"><code>NSWorkspace.runningApplications</code></span> and asking each for its <span class="lock"><code>AXExtrasMenuBar</code></span> children gave me, on a typical run, around forty status items across thirty apps. I filtered them to ones whose frame X-centre fell inside the notch range, which gave me four: the icons currently being eaten.</p>
<p>Showing them in a panel was easy. Activating them was the hard part.</p>
<p>For the first attempt I used <span class="lock"><code>AXUIElementPerformAction</code></span> with <span class="lock"><code>kAXPressAction</code></span> &mdash; the canonical &ldquo;simulate a click on this element&rdquo; call. AX bypasses the normal hit-testing path and goes straight to the receiving app&rsquo;s event handlers, which is exactly what I needed. I tested with <a href="https://jorviksoftware.cc/utilities/browsernotes">Browser Notes</a>, which had been getting eaten by my notch all week.</p>
<p>The menu opened.</p>
<p>I moved my cursor toward it.</p>
<p>The menu closed.</p>
<p>That happened reliably. Click the row in MenuTidy&rsquo;s panel, BrowserNotes&rsquo; menu would pop into view at the icon&rsquo;s logical position, and the moment I started moving the cursor toward the menu, the menu would dismiss as if I&rsquo;d clicked outside it.</p>
<p>I knew immediately what was probably happening, because I&rsquo;d seen this once before with a different app. NSStatusItem-attached menus track the cursor: when the menu opens, it reads the current cursor position and starts watching for movement. If the cursor is far from the icon when the menu opens (because the user clicked some panel mid-screen, say), the very first <span class="lock"><code>mouseMoved</code></span> event reads as &ldquo;moved away from the icon, dismiss.&rdquo; The menu&rsquo;s tracking model assumes you&rsquo;re drag-selecting from the icon; if you&rsquo;re not, the model decides you&rsquo;re leaving.</p>
<p>The fix should have been to warp the cursor onto the icon&rsquo;s position before the AXPress call, so the menu&rsquo;s tracking model would see a consistent origin. <span class="lock"><code>CGWarpMouseCursorPosition</code></span> takes a point and moves the system cursor there. I added a pre-warp.</p>
<p>The menu still closed.</p>
<h2>The CGEvent detour</h2>
<p>Now I was less confident in my mental model. If pre-warping didn&rsquo;t fix it, maybe AXPress was opening the menu in some weird half-tracking mode that any subsequent mouse-move would dismiss regardless. So I tried a fundamentally different approach: synthesise a real mouse-down + mouse-up pair via <span class="lock"><code>CGEvent</code></span>, posted at the icon&rsquo;s frame centre. That should make the click look exactly like a real user click &mdash; cursor warps to the click position naturally, mouse-down fires, mouse-up fires, status item button receives the standard event sequence, menu opens in proper click-and-stay mode.</p>
<p>I removed the AXPress call, swapped in CGEvent, rebuilt, tested.</p>
<p>The cursor warped.</p>
<p>No menu appeared.</p>
<p>This was, briefly, baffling. The CGEvent click was definitely being posted &mdash; the cursor jump was visible &mdash; but the icon wasn&rsquo;t responding. After a couple of minutes of staring, I realised what should have been obvious from the start: the icon&rsquo;s frame is <em>behind the notch</em>, and the click is being posted at coordinates <em>inside the notch</em>. The CGEvent system doesn&rsquo;t care that I think there should be a status item there. It posts the event into the global event stream at the given coordinate; macOS&rsquo;s window-server hit-tests that coordinate against rendered windows; nothing renders there; the event goes nowhere.</p>
<p>The notch is a hardware clip. CGEvent is a software click. They don&rsquo;t meet.</p>
<h2>Why AX worked when CGEvent didn&rsquo;t</h2>
<p>This was the moment the geometry clicked into place properly. AX isn&rsquo;t just a fancier version of CGEvent &mdash; it&rsquo;s a fundamentally different interaction model. CGEvent posts events into the <abbr data-title="Operating System">OS</abbr>&rsquo;s event stream, where they&rsquo;re dispatched by hit-testing against windows. AX talks directly to the application&rsquo;s accessibility hierarchy, asking the app to perform an action on a specific element by reference, with no hit-testing involved. <span class="lock"><code>AXPress</code></span> on an element doesn&rsquo;t need the element to be on-screen, or visible, or even unobscured. It just asks the app to behave as if its primary action had been triggered.</p>
<p>That&rsquo;s why my first attempt worked &mdash; the menu opened &mdash; and that&rsquo;s why the CGEvent attempt couldn&rsquo;t. Behind the notch, CGEvent has nothing to hit. AX bypasses the entire question.</p>
<p>So the right answer was AXPress, plus &mdash; I returned to my pre-warp theory, but more carefully. Maybe my earlier pre-warp hadn&rsquo;t worked because I&rsquo;d combined it with a <em>post-warp</em>: I&rsquo;d been warping the cursor onto the icon, calling AXPress, then warping the cursor below the menu bar onto the freshly-opened menu. I&rsquo;d been assuming both warps would help. What if the second warp was the thing dismissing the menu? Cursor warping generates a mouseMoved event; what if that event &mdash; happening 60 milliseconds after the menu opened &mdash; was the very thing the menu&rsquo;s tracking interpreted as “user moved off the icon”?</p>
<p>I dropped the post-warp. Just AXPress, with the cursor pre-warped to the icon&rsquo;s position.</p>
<p>The menu opened. I physically moved the mouse downward. The cursor &mdash; which was warped to the icon position, behind the notch, invisible to me &mdash; moved with my physical input, descending out from below the notch onto the menu&rsquo;s drop area. The menu tracked normally. I clicked “Settings…”.</p>
<p>It opened.</p>
<p>I sat there for a moment, slightly dazed by how thoroughly the simpler version had been the right answer all along.</p>
<h2>The right-click case</h2>
<p>Right-clicks have a different complication. AX doesn&rsquo;t have a standard &ldquo;right-press&rdquo; action; <span class="lock"><code>AXPress</code></span> always simulates a primary click. For apps that distinguish left and right click (Jorvik apps sometimes do &mdash; left opens a popover, right shows the menu), I needed a way to fire a right-click specifically.</p>
<p>For right-clicks I fall back to CGEvent &mdash; <span class="lock"><code>rightMouseDown</code></span> + <span class="lock"><code>rightMouseUp</code></span> at the icon&rsquo;s frame centre &mdash; with the same caveat as before: if the icon is currently behind the notch, the click goes into the notch&rsquo;s coordinate space and finds nothing. For visible icons elsewhere on the menu bar, it works perfectly. For behind-notch icons, right-click won&rsquo;t register without AX support. That&rsquo;s a documented limitation of the feature, and an honest one: I can&rsquo;t reach behind the notch for right-click without AX cooperation that doesn&rsquo;t exist as a standard interface.</p>
<p>In practice this matters less than you&rsquo;d think, because most apps use left-click to open their menu, with right-click as a redundant alternative. If you&rsquo;ve got an app that <em>only</em> responds to right-click on the icon, you&rsquo;ll need to wait for it to drift out from behind the notch by quitting something else. I&rsquo;m not aware of any such app, but I won&rsquo;t pretend they don&rsquo;t exist.</p>
<h2>Making it feel instant</h2>
<p>The first version of the reveal panel was correct but sluggish. Every time you opened it, MenuTidy walked all forty-or-so running apps, asked each for its <span class="lock"><code>AXExtrasMenuBar</code></span>, walked the children, computed frames, filtered to the notch range. That took roughly half a second on a quiet system; with a busier process list and a few apps that were slow to respond to AX queries, longer.</p>
<p>Half a second isn&rsquo;t catastrophic, but it doesn&rsquo;t feel right for a panel that&rsquo;s meant to drop down instantly when you click a menu item. So I added a live cache. The cache populates on launch, refreshes whenever <span class="lock"><code>NSWorkspace</code></span> fires <span class="lock"><code>didLaunchApplicationNotification</code></span> or <span class="lock"><code>didTerminateApplicationNotification</code></span> (with a small delay so the launching app has time to actually create its status item), and gets read instantly when the panel opens. There&rsquo;s a background refresh on every panel open too, just in case something drifted between events &mdash; the panel doesn&rsquo;t wait for that, but the next open will reflect any changes.</p>
<p>The result is that opening the reveal panel feels responsive. Click the menu item; it&rsquo;s already there. Which, for a feature you might use a dozen times a day, is the difference between &ldquo;tolerable&rdquo; and &ldquo;feels native.&rdquo;</p>
<h2>And, while we&rsquo;re here, the position-memory bug</h2>
<p>This part is much shorter, mostly because the bug was much smaller, but I want to mention it because it&rsquo;s the kind of thing you only notice when you&rsquo;ve been quietly tolerating it for a long time.</p>
<p>MenuTidy creates two NSStatusItems: a chevron (visible) and an invisible spacer that you can drag around to define which icons get hidden when collapsed. Both items have <span class="lock"><code>autosaveName</code></span> set, which means macOS persists their menu bar positions across launches in the standard <span class="lock"><code>NSStatusItem Preferred Position</code></span> <span class="lock"><code>UserDefaults</code></span> keys. If you <span class="kbd">command</span>+drag the spacer to a new position, that position should be remembered next time you launch the app. The README has been claiming so since the first release.</p>
<p>The README was lying. Not deliberately &mdash; I&rsquo;d genuinely intended for positions to persist &mdash; but the actual code did this in <span class="lock"><code>applicationDidFinishLaunching</code></span>:</p>
<pre><code class="language-swift">UserDefaults.standard.set(150, forKey: &quot;NSStatusItem Preferred Position MenuTidyChevron&quot;)
UserDefaults.standard.set(300, forKey: &quot;NSStatusItem Preferred Position MenuTidySpacer&quot;)
</code></pre>
<p>Setting the autosave keys, on every launch, to fixed values, <em>before</em> the NSStatusItems were created and could read them. Whatever the user had dragged the spacer to last session was overwritten with <code>300</code> before macOS got a chance to use it. The spacer would dutifully appear at position 300 every time, and the user&rsquo;s carefully-arranged divider would silently reset, week after week.</p>
<p>The fix is a one-shot flag:</p>
<pre><code class="language-swift">if !UserDefaults.standard.bool(forKey: didSeedDefaultPositionsKey) {
    UserDefaults.standard.set(150, forKey: &quot;NSStatusItem Preferred Position MenuTidyChevron&quot;)
    UserDefaults.standard.set(300, forKey: &quot;NSStatusItem Preferred Position MenuTidySpacer&quot;)
    UserDefaults.standard.set(true, forKey: didSeedDefaultPositionsKey)
}
</code></pre>
<p>Seed the initial positions once, on the first ever launch, then never touch the keys again. Autosave handles the rest. The README isn&rsquo;t lying anymore.</p>
<p>I&rsquo;m a little embarrassed this had been wrong since the first release. Nobody had reported it as a bug, presumably because the symptom &mdash; &ldquo;the spacer keeps reverting to where it always was&rdquo; &mdash; reads, to a casual user, as just how the app works. They were dragging it back into position every Monday and treating that as part of the experience. That&rsquo;s not a feature; that&rsquo;s a tax. I&rsquo;m glad it&rsquo;s gone.</p>
<h2>What changed today, really</h2>
<p>The code change is bounded: a single Swift file, a few hundred lines of new logic, one bug fix that&rsquo;s not even thirty lines including the comment explaining it. The conceptual change is bigger.</p>
<p>I&rsquo;d treated MenuTidy as a small, simple utility that did its small simple thing. Today I treated it like an app that ought to <em>meet users where they actually are</em>, which means dealing with the Mac they actually own &mdash; the one with the notch. The notch is ugly, both literally and as an engineering problem. It&rsquo;s easier to pretend it&rsquo;s not your concern when your app has a different focus. But it absolutely is the user&rsquo;s concern, every single day, and a tool that lives in the menu bar can&rsquo;t reasonably ignore the menu bar&rsquo;s most distinctive feature.</p>
<p>I&rsquo;d also been treating the position-memory bug as a small thing. It wasn&rsquo;t. It was the README saying one thing and the app doing another, every launch, for months. The fact that nobody had noticed loudly enough to file an issue isn&rsquo;t because it wasn&rsquo;t real; it&rsquo;s because users had absorbed the cost.</p>
<p>There&rsquo;s a thing that happens in long-running software where you stop seeing it the way users do. You know how it works, you know its quirks, you mentally route around them, and after a while you&rsquo;re no longer experiencing the friction. That&rsquo;s how an app gets filed in your head as &ldquo;done&rdquo; while still containing several rough edges that users encounter daily and patiently work around because <em>what else are they going to do</em>. The only real cure is to use your own software the way a stranger would, every now and again, and let the friction surprise you.</p>
<p>MenuTidy 1.3.0 ships today &mdash; .pkg and .zip, signed and notarised as always. The reveal feature is opt-in (only appears in the menu when you have a notched display, which I&rsquo;m happy about because half my readers are on Studio Displays where it would just be visual noise). Position memory works the way the README has always claimed it did. The Settings panel grew a Permissions section so the AX requirement is upfront, not surprising.</p>
<p>I no longer think of MenuTidy as the &ldquo;also-ran&rdquo; app in our collection. It&rsquo;s funny how much of that change was actually about how I was thinking, rather than what the app does.</p>
]]></description>
    </item>
    <item>
      <title>Default ON Was The Wrong Default</title>
      <link>https://jorviksoftware.cc/notes/2026/05/03/default-on-was-the-wrong-default</link>
      <guid isPermaLink="true">https://jorviksoftware.cc/notes/2026/05/03/default-on-was-the-wrong-default</guid>
      <pubDate>Sun, 03 May 2026 18:12:35 GMT</pubDate>
      <description><![CDATA[<p>There&rsquo;s a particular kind of bug that doesn&rsquo;t show up in any test, doesn&rsquo;t break any feature, and produces no visible failure. The user doesn&rsquo;t file a ticket. Nothing crashes. The app keeps doing what it&rsquo;s supposed to do. And yet, from the user&rsquo;s perspective, <em>something has changed without their permission</em>, and the trust deficit it produces is real even if the code that produced it looks innocent.</p>
<p>I shipped one of those bugs across eleven apps yesterday. Then I un-shipped it across nine of them today. Some of you may have caught it. If you didn&rsquo;t, the relevant detail is that the menu-bar icons in your Jorvik utilities briefly grew an opinionated grey pill background, then went back to looking the way they always had. I owe you the explanation.</p>
<h2>The convention, briefly</h2>
<p>Every Jorvik menu-bar utility has an optional grey background &ldquo;pill&rdquo; that sits behind the status-bar icon. Some users like it &mdash; it improves contrast against busy or wallpaper-tinted menu bars, and it gives the icon a discrete bounding box that can be useful in dense bars. Other users prefer the plain glyph. So it&rsquo;s a setting. Right-click the menu-bar icon, choose Settings, find <em>Show background pill</em>, toggle to taste.</p>
<p>Yesterday I did a big rollout: standardising the pill design across every app in the suite, replacing an older user-coloured variant that didn&rsquo;t look right against macOS 16&rsquo;s wallpaper-tinted menu bars. The new pill is fixed grey, drawing-handler-composed, and behaves correctly on every menu-bar background I&rsquo;ve thrown at it. It&rsquo;s a clear win.</p>
<p>The <abbr data-title="Knowledge Base">KB</abbr> convention I wrote a few weeks ago, when the new pill design first emerged in <a href="https://jorviksoftware.cc/utilities/activespace">ActiveSpace</a> and <a href="https://jorviksoftware.cc/utilities/calendarupcoming">CalendarUpcoming</a>, said this:</p>
<blockquote>
<p>Settings exposes a single &ldquo;Show background pill&rdquo; toggle; default ON for fresh installs via <span class="lock"><code>UserDefaults.register(defaults:)</code>.</span></p>
</blockquote>
<p>I read that line as the design intent and followed it across the eleven apps in the rollout. Each of them gained the line:</p>
<pre><code class="language-swift">UserDefaults.standard.register(defaults: [&quot;menuBarPillEnabled&quot;: true])
</code></pre>
<p>at the top of <code>applicationDidFinishLaunching</code>. This tells <code>UserDefaults</code> to use <code>true</code> as the value for <code>menuBarPillEnabled</code> when no value has been set explicitly. Fresh installs see the pill turn on by default; users can switch it off if they don&rsquo;t like it.</p>
<p>That all sounds reasonable. It is, in fact, almost reasonable. The one wrinkle is what <em>fresh install</em> means.</p>
<h2>What &ldquo;fresh install&rdquo; doesn&rsquo;t mean</h2>
<p><span class="lock"><code>UserDefaults.register(defaults:)</code></span> is sometimes described in tutorials as &ldquo;set defaults for new users.&rdquo; It isn&rsquo;t. What it actually does is provide a fallback value for any key that has not been explicitly set. The fallback is in-memory; it is consulted by <span class="lock"><code>UserDefaults.bool(forKey:)</code></span> whenever the on-disk preference store has nothing to say about that key.</p>
<p>This is subtly different from &ldquo;new users.&rdquo; Specifically, it doesn&rsquo;t distinguish between:</p>
<ol>
<li>A genuinely fresh install where the user has never run the app before.</li>
<li>An <em>existing</em> install where the user has run the app many times &mdash; but never explicitly toggled this particular setting.</li>
</ol>
<p>For the menu-bar pill, almost everyone falls into category 2. The pre-rollout pill code did this:</p>
<pre><code class="language-swift">let enabled = UserDefaults.standard.bool(forKey: &quot;menuBarPillEnabled&quot;)
guard enabled else { return }
</code></pre>
<p>with no <span class="lock"><code>register(defaults:)</code></span> call. A user who never touched the setting had no value stored on disk. <span class="lock"><code>UserDefaults.bool(forKey:)</code></span> returned <code>false</code> (the default for missing booleans), and the pill stayed off. That&rsquo;s how it had been, quietly, for as long as the toggle had existed.</p>
<p>When I added <span class="lock"><code>register(defaults: [&quot;menuBarPillEnabled&quot;: true])</code></span> to every app and shipped, every existing user who had never touched the toggle suddenly experienced this on the next launch:</p>
<ul>
<li>App reads <span class="lock"><code>UserDefaults.bool(forKey: &quot;menuBarPillEnabled&quot;)</code>.</span></li>
<li>No value on disk, but <code>register</code> has provided <code>true</code> as the fallback.</li>
<li>Function returns <code>true</code>. Pill turns on.</li>
</ul>
<p>The user had not asked for the pill. The user had not changed any setting. The user had simply opened their utility one day and found that their menu bar icon now had a grey blob behind it. For all they knew, the developer had unilaterally redesigned their app.</p>
<p>And &mdash; this is the part that bothered me &mdash; they were right.</p>
<h2>The thing the convention paper didn&rsquo;t say</h2>
<p>Re-reading our conventions document with this in mind, I notice something. It says &ldquo;default ON for fresh installs.&rdquo; It doesn&rsquo;t say &ldquo;default ON for fresh installs and not for upgraders who haven&rsquo;t yet expressed a preference.&rdquo; It can&rsquo;t say that, because the implementation it specifies &mdash; <span class="lock"><code>UserDefaults.register</code></span> &mdash; has no way to distinguish those two cases. There&rsquo;s no &ldquo;first launch ever&rdquo; hook in <code>UserDefaults</code>. There&rsquo;s no &ldquo;this app has been run before&rdquo; flag. There&rsquo;s just the absence of a key, which two different users can produce for two completely different reasons, and to which the <abbr data-title="Application Programming Interface">API</abbr> responds identically.</p>
<p>You could build that distinction yourself. On first launch, write a sentinel like <span class="lock"><code>didInitDefaults = true</code>.</span> The next launch checks the sentinel; if absent, treat as fresh and set the user-facing default; if present, leave the absence of <code>menuBarPillEnabled</code> as it was. That works, but it adds a one-shot guard to every app and a small amount of state to every preference store, and it has its own subtleties. (What if the user resets their preferences? What if their preferences file has been lost in an iCloud sync? Are they fresh, or upgrading?)</p>
<p>The simpler answer was waiting all along: don&rsquo;t change the default. The pre-rollout behaviour of &ldquo;off until the user toggles it on&rdquo; was correct in every relevant sense. Users who wanted the pill turned it on &mdash; once &mdash; and the value was persisted forever. Users who didn&rsquo;t want it never had to think about it. New users got a clean install with no pill, which is an entirely defensible default for an <em>optional visual decoration</em>.</p>
<p>So I dropped the <code>register</code> line everywhere. Ten commits later (the eleven affected apps minus <a href="https://jorviksoftware.cc/utilities/screenlock">ScreenLock</a>, where it had already been reverted as part of the original spot-fix), and I updated our conventions doc to say:</p>
<blockquote>
<p><strong>Default OFF.</strong> Do not register <span class="lock"><code>[&quot;menuBarPillEnabled&quot;: true]</code>.</span> <span class="lock"><code>UserDefaults.bool(forKey:)</code></span> returns <code>false</code> for missing keys, which is the correct default &mdash; the pill is opt-in.</p>
</blockquote>
<p>With a brief note explaining why. <em>Default ON via register-defaults silently turned the pill on for upgraders who&rsquo;d never set the key explicitly, surprising users on every Jorvik app&rsquo;s update day. Reverted.</em></p>
<h2>The harder question</h2>
<p>There&rsquo;s a slightly uncomfortable question buried inside this: when <em>is</em> it OK to ship a setting as default-ON?</p>
<p>The honest answer is: when the change is genuinely better for everyone, and when users have an obvious way to discover and revert it. Auto-update being on by default is one of those &mdash; most users want updates, and the few who don&rsquo;t can find the toggle in Settings. macOS&rsquo;s &ldquo;Open at Login&rdquo; being off by default is the inverse &mdash; most users don&rsquo;t want background processes piling up, and the few who do can opt in.</p>
<p>The pill failed both halves of that test. It&rsquo;s not genuinely better for everyone &mdash; it&rsquo;s a visual choice, and a noticeable one, and many users have spent considerable time arranging their menu bars to look exactly the way they want them to. And the discovery mechanism for &ldquo;why did my menu bar suddenly grow a grey blob&rdquo; isn&rsquo;t intuitive &mdash; most users would not, on their own, think to right-click the icon and look for a Settings entry. They&rsquo;d assume it&rsquo;s some new system thing, or a glitch, and either tolerate it or close the app.</p>
<p>The right test isn&rsquo;t &ldquo;would I want this as a user?&rdquo; It&rsquo;s &ldquo;would a user who didn&rsquo;t ask for this notice and resent it?&rdquo; The pill change failed that test silently. It&rsquo;s the same failure mode as software that auto-enables analytics, or auto-enrols you in newsletters, or quietly turns on a feature you&rsquo;d previously declined. Each individual case is small. The aggregate is the reason people don&rsquo;t trust software.</p>
<h2>The mechanics of reversal</h2>
<p>Reversing across ten repos in one sweep was easier than I&rsquo;d expected, mostly because I&rsquo;d been disciplined about putting the offending line in exactly the same place in each app&rsquo;s <code>applicationDidFinishLaunching</code>. A simple string match <span class="lock">(<code>register(defaults: [&quot;menuBarPillEnabled&quot;: true])</code>)</span> found every instance. Each removal was a one-line diff. Build, sign, push, repeat.</p>
<p>The interesting question was <em>which apps to include</em>. The eleven from yesterday&rsquo;s rollout, obviously. CalendarUpcoming, which had been the canonical reference for the new pill &mdash; it had also picked up the register line (I&rsquo;d literally copied it from there originally). And <a href="https://jorviksoftware.cc/utilities/lookout">Lookout</a>, the new app I&rsquo;d shipped two days earlier with the convention baked in from v0.1.0.</p>
<p>Thirteen apps in total, ten of which got new commits today (three already had it reverted as part of earlier spot-fixes). Each commit message says the same thing, give or take:</p>
<blockquote>
<p>Drops the UserDefaults.register(defaults: [menuBarPillEnabled: true]) line. The pre-rollout behaviour was default-off &mdash; users who never explicitly toggled the pill in Settings had no pill &mdash; and the register-default-true silently turned it on for those upgraders.</p>
</blockquote>
<blockquote>
<p>UserDefaults.bool(forKey:) returns false for missing keys, so removing the register restores the legacy default without any migration logic. Users who explicitly enabled or disabled the toggle keep their stored preference.</p>
</blockquote>
<p>The &ldquo;users who explicitly enabled or disabled the toggle keep their stored preference&rdquo; line is the bit I want to draw attention to. The fix doesn&rsquo;t blow away anyone&rsquo;s preferences. If you actively turned the pill on yesterday because you liked it, you still have it. If you turned it off because you didn&rsquo;t, that also still applies. Only the people who never expressed a preference are affected, and they revert to the original &ldquo;no pill&rdquo; default.</p>
<p>That&rsquo;s the cleanest possible reversal: it touches state only for the population that didn&rsquo;t have state in the first place.</p>
<h2>What I learned</h2>
<p>Three things, in order of importance.</p>
<p>The first is technical and fairly mechanical: <span class="lock"><code>UserDefaults.register(defaults:)</code></span> is a <em>fallback</em>, not a <em>default for new users</em>. Every Apple-provided documentation page I&rsquo;ve ever read on it treats the two as interchangeable. They&rsquo;re not. If you ship a <code>register</code> call in an existing app, every user who hasn&rsquo;t touched that key gets the new behaviour silently.</p>
<p>The second is a habit shift: when reviewing a convention document, ask &ldquo;what happens to existing users who didn&rsquo;t pick a value?&rdquo; Not &ldquo;what happens on a fresh install?&rdquo; The fresh-install case is rare in any mature product. The &ldquo;user with no preference set&rdquo; case is <em>the entire installed base</em> the first time a feature is introduced.</p>
<p>The third is the thing I keep relearning, in slightly different shapes, every few months: the difference between <em>making the right choice</em> and <em>changing the user&rsquo;s choice</em> is enormous, and software does the second far more often than it knows. Every flag, every setting, every bit of saved state is a tiny contract with the user. They picked it; you respected it. The moment your code starts changing values silently &mdash; even values it set itself, even values that &ldquo;the user never explicitly chose&rdquo; &mdash; you&rsquo;ve broken the contract, and the user is right to notice.</p>
<p>The pill change wasn&rsquo;t malicious. It was a clean technical implementation of what looked like a clean convention. It still produced a small unwanted change for everyone, and the only honest fix was to back it out.</p>
<p>I&rsquo;m pretty sure no one will notice the reversal. The pill goes from &ldquo;uninvited to absent,&rdquo; and absent is what users had before. The change won&rsquo;t trigger any hot takes; nobody&rsquo;s going to write a thinkpiece about The Day The Pills Disappeared. Which is, in a way, the lesson: the right default is the one users don&rsquo;t have to think about, because nothing changed underneath them.</p>
<p>That sounds boring. Boring is correct.</p>
]]></description>
    </item>
    <item>
      <title>The App I Needed At 1am</title>
      <link>https://jorviksoftware.cc/notes/2026/05/03/the-app-i-needed-at-1am</link>
      <guid isPermaLink="true">https://jorviksoftware.cc/notes/2026/05/03/the-app-i-needed-at-1am</guid>
      <pubDate>Sun, 03 May 2026 08:54:13 GMT</pubDate>
      <description><![CDATA[<div class="photo-frame">
    <img src="https://jorviksoftware.cc/notes/2026/05/03/mouse.jpg" alt="computer mouse">
</div>
<p class="image-caption">Photo by <a href="https://unsplash.com/@saad_jml?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText">Saad Jameel</a> on <a href="https://unsplash.com/photos/a-black-and-white-photo-of-a-mouse-and-keyboard-Gy6O7gh6GGM?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText">Unsplash</a></p>

<p>I lost my mouse pointer at one in the morning.</p>
<p>I was dogfooding the latest <a href="https://jorviksoftware.cc/utilities/activespace">ActiveSpace</a> build &mdash; running it on my own machine for a few days before declaring it shipped &mdash; and I&rsquo;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.</p>
<p>Pointer invisibility is one of those things you can almost feel as much as see. The pointer isn&rsquo;t at the centre of your attention &mdash; it&rsquo;s the thing that <em>directs</em> your attention &mdash; so its absence registers as a kind of low-grade vertigo before you consciously notice it&rsquo;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&rsquo;t hung. So it&rsquo;s the pointer specifically. It&rsquo;s gone <em>somewhere</em>.</p>
<p>I had a strong suspicion about where.</p>
<h2>The 640×480 black hole</h2>
<p>If you&rsquo;ve read <a href="https://jorviksoftware.cc/notes/2026/04/26/when-the-stars-align-final-cut-maybe">the third instalment of the stars-align trilogy</a>, you&rsquo;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 &mdash; macOS&rsquo;s single-display identifier of &ldquo;Main&rdquo; breaks the Dock&rsquo;s gesture handling, and a second display forces the <abbr data-title="Operating System">OS</abbr> into proper <abbr data-title="Universally Unique Identifier">UUID</abbr>-based identifiers, which fixes everything &mdash; but it has a side effect: the OS treats the virtual as a perfectly valid place for the pointer to live.</p>
<p>The fence I built into ActiveSpace catches the pointer when it tries to enter the virtual&rsquo;s space and warps it back to the inside edge of main. It&rsquo;s a session-level <code>CGEventTap</code> on <code>mouseMoved</code> and the <code>*MouseDragged</code> events, fast, arrangement-aware, and reliable… ninety-nine and a bit per cent of the time. There&rsquo;s a small window during park-and-unpark of the virtual display &mdash; specifically when the screen wakes from lock, while the fence is briefly disarmed because the virtual display it&rsquo;s policing is mid-move &mdash; where a fast pointer throw can slip past. About 750 milliseconds, in practice. Hard to hit deliberately. Easy to hit by accident if you&rsquo;ve moved your mouse at the wrong moment of waking.</p>
<p>So at 1am, my pointer was at (7000, 240) or thereabouts, sitting peaceably on the virtual where I couldn&rsquo;t see it. The good news: I knew the bug. The bad news: I&rsquo;d run out of mouse to use to fix the bug.</p>
<h2>What I did about it (the wrong way)</h2>
<p>I knew exactly how to recover. <code>CGWarpMouseCursorPosition</code>. Three lines of Swift. I just needed to run it.</p>
<p>I opened Terminal. I typed… <span class="lock"><span class="kbd">command</span><span class="kbd">space</span></span> for Spotlight, &ldquo;terminal&rdquo;, <span class="kbd">return</span>. Terminal opens to the focus of the front-most application, which depending on what was up could be helpful or not. I typed in:</p>
<pre><code>echo &#39;import CoreGraphics; CGWarpMouseCursorPosition(CGPoint(x: 200, y: 200))&#39; | swift -
</code></pre>
<p>The pointer reappeared, near the top of the menu bar, exactly where I&rsquo;d aimed it. Crisis averted. I went to bed at 1.20am, having added the recipe to ActiveSpace&rsquo;s README under &ldquo;If your mouse pointer disappears.&rdquo; A documentation fix, I told myself. The cursor fence catches almost everything; in the rare case it doesn&rsquo;t, here&rsquo;s a one-liner.</p>
<p>The next morning I read the README again with a fresher pair of eyes. Two things made me immediately uncomfortable.</p>
<p>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) <em>and</em> the keyboard incantation for opening apps via Spotlight (which most users do) <em>and</em> 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.</p>
<p>Second: the recipe lives on a web page. The web page lives on <span class="lock">jorviksoftware.cc.</span> Our user, in order to read the recipe, needs to… navigate… to it… in a browser…</p>
<p>The README was inviting users to play a deeply unfair version of the game where every standard recovery action assumed the precise capability they&rsquo;d just lost. It read fine; it was useless.</p>
<h2>Introducing MouseCatcher</h2>
<p>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 &mdash; not a panel, not a setting, not an option somewhere in ActiveSpace itself &mdash; an app that repositions the pointer and exits. Spotlight is keyboard-only. <span class="lock"><span class="kbd">command</span><span class="kbd">space</span></span>, type <code>mousecatcher</code>, <span class="kbd">return</span>. If the app is in <code>/Applications</code>, Spotlight finds it. The whole recovery becomes three keystrokes and a twelve-letter word.</p>
<h2>Building it</h2>
<p>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:</p>
<pre><code class="language-swift">import Cocoa
import CoreGraphics

_ = CGAssociateMouseAndMouseCursorPosition(1)
let bounds = CGDisplayBounds(CGMainDisplayID())
CGWarpMouseCursorPosition(CGPoint(x: bounds.midX, y: bounds.midY))
exit(0)
</code></pre>
<p><span class="lock"><code>CGAssociateMouseAndMouseCursorPosition(1)</code></span> is defensive: if for some reason the cursor has been detached from mouse motion (rare, but cheap insurance &mdash; you don&rsquo;t want to have warped it to a visible position only to find that wiggling the mouse doesn&rsquo;t move it), this re-associates the two. <span class="lock"><code>CGMainDisplayID()</code></span> 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. <code>CGWarpMouseCursorPosition</code> 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 <code>.app</code> bundle.</p>
<p>The bundle has <span class="lock"><code>LSUIElement = true</code></span> and the activation policy never escalates, so there&rsquo;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&rsquo;t see anything happen except the cursor reappearing, near the centre of your main display. Then nothing else, because there is nothing else.</p>
<p>It&rsquo;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.</p>
<h2>How to ship it</h2>
<p>The interesting question wasn&rsquo;t how to write MouseCatcher; it was how to <em>distribute</em> it. The Jorvik convention is one repo per app, one Release Manager entry per app, one GitHub release per app. MouseCatcher could&rsquo;ve been all of those things. Three new files in a new repo, an entry in the catalogue, a release page. Done.</p>
<p>It would have been over-engineered. The whole point of MouseCatcher is that it&rsquo;s a partner of ActiveSpace &mdash; you wouldn&rsquo;t need it without ActiveSpace&rsquo;s virtual display, you wouldn&rsquo;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&rsquo;s release. One <code>.pkg</code>, one <code>.zip</code>, two <code>.app</code>s.</p>
<p>The implementation is what Apple sometimes calls a helper-app architecture. <span class="lock">MouseCatcher.app</span> is built by a Run Script build phase inside ActiveSpace&rsquo;s Xcode project, then copied into <span class="lock">ActiveSpace.app&rsquo;s</span> <span class="lock"><code>Contents/Helpers/</code></span> directory. Both bundles get signed, both get notarised, both get stapled. The outer ActiveSpace bundle is what users download; MouseCatcher rides inside, invisible, unseen.</p>
<p>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.</p>
<p>Mostly.</p>
<h2>The /Applications/Utilities trap</h2>
<p>I had originally planned to deploy MouseCatcher to <span class="lock"><code>/Applications/Utilities/</code>.</span> Conceptually it&rsquo;s a utility; conceptually that&rsquo;s where utilities go; macOS has had that subfolder since approximately the dawn of time. It felt right.</p>
<p>I shipped a build with that target path. ActiveSpace launched. Nothing appeared at the deployed location.</p>
<p>The <code>aslog</code> 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&rsquo;t run at all (likely, given a race condition I&rsquo;d been worried about), or the deploy had thrown silently somewhere I wasn&rsquo;t catching (unlikely, given how the code was structured), or the deploy <em>had</em> run, the catch <em>had</em> fired, and the log line had ended up somewhere I wasn&rsquo;t looking.</p>
<p>It was the third one. The <code>launchd</code> 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&rsquo;d been blind-firing into the void.</p>
<p>The actual cause turned out to be embarrassingly simple. Type this:</p>
<pre><code>ls -lde /Applications/Utilities
</code></pre>
<p>And you get something like this:</p>
<pre><code>drwxr-xr-x  5 root  wheel  160 10 Apr 06:23 /Applications/Utilities
</code></pre>
<p><span class="lock">Mode 755,</span> owner <code>root</code>, group <code>wheel</code>. The <code>wheel</code> group, not <code>admin</code>. Even an admin user can&rsquo;t write into <span class="lock"><code>/Applications/Utilities</code></span> without elevation &mdash; without a <code>sudo</code>, or an Authorisation Services prompt, or a privileged helper. macOS reserves the folder for system utilities. I&rsquo;d been quietly blocked every time the deploy ran, and the launchd-related log loss had hidden the evidence.</p>
<p>This is the kind of thing where you find out the assumption you&rsquo;ve been making for a decade is wrong. <code>/Applications</code>, the parent, is <span class="lock"><code>drwxrwxr-x root admin</code>:</span> 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. <span class="lock"><code>/Applications/Utilities</code></span> is a different beast. Apple owns it, even on your own machine.</p>
<p>The fix is the path nobody had any reason to feel strongly about: <span class="lock"><code>/Applications/MouseCatcher.app</code>,</span> sibling of <span class="lock"><code>/Applications/ActiveSpace.app</code>,</span> exactly where every other Jorvik utility installs. The deploy works. The cursor is recoverable. Spotlight finds it.</p>
<p>I&rsquo;ll concede that aesthetically I&rsquo;d still prefer it under <span class="lock"><code>/Applications/Utilities/</code>.</span> But there is no point in fighting the OS&rsquo;s permission model for a tiny one-shot application, and inviting users to type their password to install this utility is the kind of <abbr data-title="User Experience">UX</abbr> that makes the cure worse than the disease.</p>
<h2>The Spotlight cache plot twist</h2>
<p>One last thing. Even after the deploy started working, Spotlight kept surfacing the wrong copy of MouseCatcher.</p>
<p>I&rsquo;d done a Spotlight cleanup the day before &mdash; 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 <span class="lock"><code>.metadata_never_index</code></span> marker at the root of <span class="lock"><code>~/Dev/Jorvik Software/</code></span> so Spotlight would stop indexing build artefacts in there going forward. <em>Going forward</em>. The cached entries from before the marker was added were still in the index.</p>
<p>What that meant practically: <span class="lock"><span class="kbd">command</span><span class="kbd">space</span></span> &rarr; &ldquo;MouseCatcher&rdquo; and Spotlight returned <span class="lock"><code>/Users/&lt;username&gt;/Dev/Jorvik Software/ActiveSpace/MouseCatcher/MouseCatcher.app</code></span> &mdash; the build artefact in the source tree &mdash; because that path had been indexed before the marker existed. The marker stops <em>new</em> indexing; it doesn&rsquo;t flush old entries.</p>
<p>The way to flush is to add the path to <strong>System Settings &rarr; Spotlight &rarr; Privacy</strong>. macOS treats that as &ldquo;immediately drop everything you have for paths under here.&rdquo; 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).</p>
<p>I added it. Cached entries vanished. Spotlight returned only <span class="lock"><code>/Applications/MouseCatcher.app</code>.</span> The recovery flow worked, end to end, in three keystrokes.</p>
<h2>The bigger question, briefly</h2>
<p>I want to be honest about what kind of utility MouseCatcher is. It&rsquo;s the workaround to a known limitation of a workaround. ActiveSpace&rsquo;s virtual display is itself a workaround for an OS bug; the cursor fence is a workaround for the virtual display&rsquo;s side effects; MouseCatcher is the recovery flow for the rare case where the fence misses.</p>
<p>That&rsquo;s a tower of mitigations, and I notice I&rsquo;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.</p>
<p>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 &mdash; and on the rare day the rent <em>does</em> come due, the recovery is a quick Spotlight search away.</p>
<p>That feels like an acceptable bargain.</p>
<p>It also feels like the kind of bargain you only notice you&rsquo;ve struck when you&rsquo;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&rsquo;t currently have.</p>
<p>Now they don&rsquo;t.</p>
]]></description>
    </item>
    <item>
      <title>When the Stars Align (Final Cut)(Maybe)</title>
      <link>https://jorviksoftware.cc/notes/2026/04/26/when-the-stars-align-final-cut-maybe</link>
      <guid isPermaLink="true">https://jorviksoftware.cc/notes/2026/04/26/when-the-stars-align-final-cut-maybe</guid>
      <pubDate>Sun, 26 Apr 2026 21:13:08 GMT</pubDate>
      <description><![CDATA[<div class="photo-frame"><img src="https://jorviksoftware.cc/notes/2026/04/26/stars-align.jpg" alt="Three stars in alignment"></div>
<p class="image-caption">Photo by <a href="https://unsplash.com/@alex_andrews?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText">Alexander Andrews</a> on <a href="https://unsplash.com/photos/star-constellation-HzT5Du-UFW8?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText">Unsplash</a></p>

<p>This is the third post in what I now realise is a series. <a href="https://jorviksoftware.cc/notes/2026/04/10/when-the-stars-align">When the Stars Align</a> was the triumph &mdash; instant space switching, about six hours before we found out it was broken on single-monitor setups. <a href="https://jorviksoftware.cc/notes/2026/04/11/when-the-stars-align-redux">When the Stars Align (Redux)</a> was the fix &mdash; an invisible <span class="lock">640×480</span> virtual display to force macOS off its &ldquo;Main&rdquo; display identifier and onto proper UUIDs, restoring the Dock&rsquo;s ability to composite correctly.</p>
<p>&ldquo;The user never knows it&rsquo;s there,&rdquo; I wrote then. In fairness to April-11-me, I had just spent the better part of twenty hours convinced the compositor was a sealed black box, and arriving at a working solution at 5am does tend to produce overconfident prose.</p>
<p>As it turns out, the user does know it&rsquo;s there. Not because the display is visible &mdash; it isn&rsquo;t &mdash; but because the <span class="lock">640×480</span> rectangle of coordinate space that macOS thinks the virtual display occupies is emphatically <em>not</em> inert territory, no matter what the word &ldquo;virtual&rdquo; might suggest.</p>
<h2>The cracks</h2>
<p>For about a week after the Redux, <a href="https://jorviksoftware.cc/utilities/activespace">ActiveSpace</a> felt fine. Instant space switching, intact menu bars, working compositor. Every now and then I&rsquo;d notice my Dock had briefly disappeared. Always in the same kind of moment &mdash; waking from sleep, plugging in an external monitor, switching back to laptop-only &mdash; and always self-healing within a few seconds. I put it down to general macOS wake flakiness.</p>
<p>Then one Saturday morning, with ActiveSpace freshly running in a debug build, the Dock didn&rsquo;t come back. I ran <code>killall Dock</code> and it returned, but by that point the pattern had clicked into place. The Dock went missing when the virtual display was present. Never when it wasn&rsquo;t.</p>
<p>I dragged my cursor to the right edge of my main display. It kept going. Off the edge. Into the <span class="lock">640×480</span> invisible region where the virtual lived. I couldn&rsquo;t see the cursor any more, but I could watch it come back when I pulled it to the left.</p>
<p>&ldquo;That&rsquo;s not as virtual as we thought,&rdquo; I said out loud, to no-one.</p>
<h2>What &ldquo;virtual&rdquo; apparently means</h2>
<p><code>CGVirtualDisplay</code> creates a display that has no physical hardware and no surface &mdash; but structurally, it&rsquo;s a real display as far as <code>WindowServer</code> is concerned. It sits in <span class="lock"><code>NSScreen.screens</code>.</span> It has an origin, a size, a <abbr data-title="Universally Unique Identifier">UUID</abbr>, an entry in <span class="lock"><code>~/Library/Preferences/com.apple.spaces.plist</code>.</span> The Dock, looking for somewhere to anchor itself when the display arrangement changes, sees a valid display &mdash; 640 by 480, adjacent to the main &mdash; and sometimes decides that&rsquo;s a perfectly fine place to render. The cursor, on hitting the right edge of main, does what cursors always do when there&rsquo;s an adjacent display: it crosses onto it.</p>
<p>What my virtual-display trick actually produces isn&rsquo;t an invisible display. It&rsquo;s an <strong>uninhabited visible display</strong>. macOS doesn&rsquo;t have a concept of &ldquo;real display with no framebuffer&rdquo; separate from &ldquo;logical display.&rdquo; Everything it knows about how to treat a display comes from the display list, and our virtual is in the list.</p>
<p>I&rsquo;d handed the Dock a perfectly plausible second monitor and then been surprised when it took me up on the offer.</p>
<h2>Three fixes, in order of cheapness</h2>
<p>I won&rsquo;t dwell on all the intermediate attempts &mdash; they&rsquo;d make this post longer than it deserves &mdash; but three specific fixes are worth naming because each resolved a distinct way the virtual display failed to stay out of the user&rsquo;s way.</p>
<p><strong>The drift reposition.</strong> At creation time I was placing the virtual display adjacent to the right edge of the main display, on the theory that macOS wouldn&rsquo;t reposition a secondary display that was already in the position it naturally wants. Apparently macOS disagrees. On several workflows &mdash; particularly the brief window after display reconfiguration &mdash; it would silently move the virtual from <span class="lock"><code>(2560, 0)</code></span> (right of main) to <span class="lock"><code>(-640, 0)</code></span> (left of main). That leftward move was what dragged the Dock off with it, because whatever heuristic picks the Dock&rsquo;s display prefers the leftmost. Fix: after every <code>didChangeScreenParametersNotification</code>, check whether the virtual has drifted, and if so re-apply <code>CGConfigureDisplayOrigin</code> with a rate-limit and a small retry cap to stop us fighting macOS in a loop. In practice one attempt is always enough.</p>
<p><strong>The cursor fence.</strong> The drift fix keeps the virtual anchored where I want it, but the cursor can still cross onto it at the contiguous right edge. I spent the best part of a day hunting through the introspection dump of <code>CGVirtualDisplaySettings</code> for a <code>headless</code> or <code>isInternal</code> flag that would mark the display as non-routable for <abbr data-title="User Interface">UI</abbr>. There wasn&rsquo;t one &mdash; the only candidate, <code>isReference</code>, affects nothing useful. So: a session-level <code>CGEventTap</code> watching <code>mouseMoved</code> / <code>mouseDragged</code>, which warps the cursor back to the inside edge of main whenever an event location lands inside the virtual&rsquo;s rect. The fence is arrangement-agnostic (clamps left or right depending on which side of main the virtual lives on) and fast enough to run on every mouse event without any visible lag.</p>
<p><strong>The restart mechanism that didn&rsquo;t need to exist.</strong> With the drift reposition and cursor fence in place, the virtual was finally behaving like &ldquo;invisible&rdquo; was meant to imply. But I&rsquo;d also convinced myself that ActiveSpace needed a self-restart mechanism for edge-case drift conditions &mdash; the app should terminate cleanly on &ldquo;dock ended up on virtual&rdquo; or &ldquo;spaces got reordered by screensaver wake&rdquo; and let a launchd keep-alive agent respawn it with a fresh slate. I built the whole thing: a six-source reconfiguration observer, a drift classifier, a launchd agent plist with <span class="lock"><code>KeepAlive: SuccessfulExit=false</code></span> and a 30-second throttle, a trigger-class-aware grace window, a post-restart cooldown to prevent loops. It took the better part of a day. I dogfooded it in dry-run mode for two days.</p>
<p>Across those two days of real use &mdash; laptop-only, dual-external, screensaver cycles, lid-close sleep/wake, adding and deleting spaces &mdash; the classifier fired seven times. In every case the other mitigations (the drift reposition, the cursor fence, <span class="lock"><code>SpaceObserver.refresh()</code></span> re-querying <code>CGS</code> from scratch on every poll tick) had already dealt with the observable problem by the time a restart would have kicked in. ActiveSpace&rsquo;s in-memory state never became unrecoverably stale. There was no poisoned cache. There was no wedge. A restart wouldn&rsquo;t have improved anything; it would have just interrupted my work for 300ms and reset my Switcher MRU stack.</p>
<p>So I deleted the restart. The observer and classifier stayed &mdash; they&rsquo;re genuinely useful for logging and future debugging &mdash; but the <code>RestartCoordinator</code> became a <code>DriftMonitor</code> that writes <code>drift-observed(…)</code> to <span class="lock"><code>~/Library/Logs/ActiveSpace.log</code></span> and does nothing else. The launchd keep-alive agent stayed too, for its unrelated benefit: if ActiveSpace ever does crash, launchd respawns it. That&rsquo;s worth having. But the &ldquo;self-restart on drift&rdquo; framing was a sledgehammer for a nail that two smaller hammers had already hit.</p>
<h2>Pre-warmed icons, while we&rsquo;re here</h2>
<p>One last thing. The Switcher <abbr data-title="Head Up Display">HUD</abbr> &mdash; the <span style="white-space: nowrap;"><span class="kbd">command</span><span class="kbd">tab</span></span> overlay that lets you cycle between apps on the current space &mdash; had a subtle lag. The first time you opened it after launching a new app, the app&rsquo;s icon took a second or so to appear. Not catastrophic, but noticeable.</p>
<p><span class="lock"><code>NSRunningApplication.icon</code></span> is lazy. First access goes out to icon services to resolve the bundle&rsquo;s icon file. Then <code>NSImageView</code> does the scale-down-to-96pt render, on the main thread, during HUD assembly. Both costs were being paid inline. The fix is a small in-memory cache: at app startup, we pre-warm icons for every currently-running regular app by fetching them and pre-drawing into a bitmap at the display size. Then we observe <span class="lock"><code>NSWorkspace.didLaunchApplicationNotification</code></span> and warm newly-launched apps as they come online. In the HUD path, we serve from the cache instead of asking <code>NSRunningApplication</code> every time.</p>
<p>No more lag. A week of small fixes adds up to an app that feels fundamentally different to use.</p>
<h2>What &ldquo;fixed&rdquo; means</h2>
<p>The lesson I keep coming back to across this series is that &ldquo;I&rsquo;ve fixed it&rdquo; is rarely true the first time you say it, and often not the second. The original post was right that instant switching was a real win. The Redux was right that the virtual-display trick made it work on every display configuration. But &ldquo;works&rdquo; and &ldquo;ships cleanly&rdquo; are not the same thing, and there was a whole week of subtle misbehaviour standing between them that only daily use could surface.</p>
<p>The other lesson is that engineers &mdash; me, specifically, in this case &mdash; reach for complicated solutions when simple ones would do. The self-restart mechanism was beautiful and correct and completely unnecessary. I enjoyed building it; I enjoyed deleting it rather more. The data showed it wasn&rsquo;t needed, so it went.</p>
<p>ActiveSpace is shipped. It&rsquo;s the first version I&rsquo;m genuinely happy to hand a user who doesn&rsquo;t know how to debug <span class="lock"><code>~/Library/Logs</code>.</span> The virtual display stays where I put it. The cursor doesn&rsquo;t wander off. The Dock stays home. The Switcher HUD feels snappy from the first keypress. Space switching is instant in every display configuration I&rsquo;ve tested.</p>
<p>The stars aligned. Then they fell apart. Then we hung a new one in the sky. Then we discovered the new one had its own orbit, fought with it for a week, and finally got it to sit still.</p>
<p>Third time&rsquo;s the charm. I hope.</p>
]]></description>
    </item>
    <item>
      <title>Screen Savers Still Exist</title>
      <link>https://jorviksoftware.cc/notes/2026/04/19/screen-savers-still-exist</link>
      <guid isPermaLink="true">https://jorviksoftware.cc/notes/2026/04/19/screen-savers-still-exist</guid>
      <pubDate>Sun, 19 Apr 2026 20:48:15 GMT</pubDate>
      <description><![CDATA[<div class="photo-frame"><img src="https://jorviksoftware.cc/notes/2026/04/19/screensaver.jpg" alt="Desert sands screen-saver on an Apple iMac"></div>
<p class="image-caption">Photo by <a href="https://unsplash.com/@claybanks?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText">Clay Banks</a> on <a href="https://unsplash.com/photos/silver-imac-on-brown-wooden-table-TQYTWfN1b7M?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText">Unsplash</a></p>

<p>I built a screen saver in 2026. Not ironically. Not as a joke. A genuine <code>.saver</code> bundle that installs into System Settings, activates after five minutes of idle time, and does something mildly interesting with your screen while you&rsquo;re away.</p>
<p>Most people I mention this to react the same way: &ldquo;macOS still has screen savers?&rdquo;</p>
<p>It does. And building one is a surprisingly interesting exercise in working with a part of the operating system that Apple seems to have mostly forgotten about.</p>
<h2>Why screen savers still exist</h2>
<p>The original purpose of a screen saver was to prevent phosphor burn-in on <abbr data-title="Cathode Ray Tube">CRT</abbr> monitors. That problem hasn&rsquo;t existed for twenty years. Modern displays &mdash; <abbr data-title="Liquid Crystal Display">LCD</abbr>, <abbr data-title="Organic Light-Emitting Diode">OLED</abbr>, mini-LED &mdash; don&rsquo;t burn in from static images in any meaningful timeframe (OLED has some susceptibility, but the <abbr data-title="Operating System">OS</abbr> handles that with pixel shifting, not screen savers).</p>
<p>So why does macOS still ship with a screen saver framework?</p>
<p>Partly inertia. The <code>ScreenSaverView</code> <abbr data-title="Application Programming Interface">API</abbr> has been part of macOS since the early days of OS X, and Apple doesn&rsquo;t remove things from the system frameworks unless they have a compelling reason. Partly because screen savers serve a purpose that has nothing to do with screen protection: they&rsquo;re a visual signal that a machine is idle. In an office, a screen saver means &ldquo;this person stepped away.&rdquo; It&rsquo;s a social convention that happens to be implemented in software.</p>
<p>And partly because they&rsquo;re fun. Apple clearly thinks so &mdash; macOS Sonoma added a set of beautiful slow-motion video screen savers that double as lock screen backgrounds. They don&rsquo;t prevent burn-in. They&rsquo;re just nice to look at.</p>
<h2><abbr data-title="American Standard Code for Information Interchange">ASCII</abbr> Saver</h2>
<p>My screen saver &mdash; <a href="https://jorviksoftware.cc/screensavers/asciisaver">ASCII Saver</a> &mdash; takes the feed from your Mac&rsquo;s camera, converts each frame to ASCII art, and renders it as a full-screen grid of characters on a black background. Your face, your desk, your cat, your empty chair &mdash; all reduced to a matrix of letters, numbers, and symbols that approximate the light and shadow of the original image.</p>
<p>The effect is somewhere between retro computing aesthetic and security camera footage from a particularly artistic building. It&rsquo;s recognisably &ldquo;you&rdquo; but abstracted enough to be interesting rather than unsettling. The character density is high enough that you can make out shapes and movement, but low enough that the individual characters are visible. It updates at roughly 15 frames per second &mdash; fast enough for smooth motion, slow enough that you can actually see the characters changing.</p>
<p>There&rsquo;s something oddly meditative about watching yourself rendered in monospace text. Your movements become patterns of shifting characters. Raising your hand changes a column of spaces into a cascade of <code>M</code>s and <code>W</code>s (the densest ASCII characters). Leaning back dissolves your outline into dots and commas. The mapping from brightness to character is deterministic &mdash; the same light level always produces the same character &mdash; so the image has a consistency that pure noise wouldn&rsquo;t have.</p>
<p>I convinced you&hellip; you want to build your own screen saver now, don&rsquo;t you?</p>
<h2>The .saver bundle</h2>
<p>A macOS screen saver is a bundle with the <code>.saver</code> extension that contains a compiled framework. The entry point is a subclass of <code>ScreenSaverView</code> &mdash; an <code>NSView</code> subclass provided by the <code>ScreenSaver</code> framework. You override <code>animateOneFrame()</code>, which the system calls at whatever frame rate you&rsquo;ve requested, and draw into the view.</p>
<pre><code class="language-swift">import ScreenSaver

class ASCIISaverView: ScreenSaverView {
    override func animateOneFrame() {
        // Capture frame, convert to ASCII, draw
    }
}
</code></pre>
<p>That&rsquo;s the skeleton. The system handles activation, deactivation, configuration sheets, preview rendering in System Settings, and hot-corner triggering. Your code just draws.</p>
<p>Installation is into <span class="lock"><code>~/Library/Screen Savers/</code></span> (per-user) or <span class="lock"><code>/Library/Screen Savers/</code></span> (system-wide). Drop the <code>.saver</code> bundle there and it appears in System Settings under Screen Saver. No registration, no manifest, no app wrapper required.</p>
<h2>What Apple forgot</h2>
<p>Building a screen saver in 2026 means working with an API that Apple maintains but doesn&rsquo;t actively develop. The <code>ScreenSaver</code> framework hasn&rsquo;t had significant updates in years. The documentation is sparse and occasionally wrong. The preview rendering in System Settings is buggy &mdash; it sometimes caches old versions of the screen saver, requiring a logout to see changes.</p>
<p>The configuration sheet mechanism &mdash; <code>configureSheet()</code> &mdash; returns an <code>NSWindow</code> that the system displays as a modal sheet when the user clicks &ldquo;Options&rdquo; in Screen Saver settings. This works, but SwiftUI views don&rsquo;t play nicely inside it. You end up bridging SwiftUI into AppKit with <code>NSHostingController</code>, which adds complexity for what should be a simple preferences panel.</p>
<p>The hot corner activation has a curious behaviour: when triggered by a hot corner, the screen saver starts in a different lifecycle state than when triggered by the idle timer. Some initialisation that happens automatically in the timer path doesn&rsquo;t happen in the hot corner path. If your screen saver has setup that assumes a particular activation order, hot corners will break it.</p>
<p>And then there&rsquo;s the code signing. Screen savers need to be signed and notarised like any other distributable macOS binary. But they also need to be signed in a way that the <code>legacyScreenSaver</code> host process trusts. If the signature is wrong &mdash; or if the signing identity doesn&rsquo;t match what the system expects &mdash; the screen saver silently fails to load. No error message. It just doesn&rsquo;t appear in System Settings.</p>
<h2>The forgotten platform</h2>
<p>Screen savers occupy a strange niche in the macOS ecosystem. They&rsquo;re not apps &mdash; they don&rsquo;t have their own process, their own Dock icon, or their own lifecycle. They&rsquo;re plugins hosted by a system process, with all the constraints that implies. They can&rsquo;t do anything the host process doesn&rsquo;t allow, they can&rsquo;t request permissions the host process doesn&rsquo;t have, and they can&rsquo;t persist state in the usual ways because they&rsquo;re loaded and unloaded at the system&rsquo;s discretion.</p>
<p>And yet they have direct access to the full screen, the <abbr data-title="Graphics Processing Unit">GPU</abbr>, and &mdash; through service/agent workarounds &mdash; hardware peripherals. They run when the user is away, which means they can do things that would be distracting during active use. They&rsquo;re a canvas with no <abbr data-title="User Interface">UI</abbr> obligations &mdash; no buttons, no menus, no accessibility requirements. Just a rectangle of pixels that you can fill however you want.</p>
<p>I think there&rsquo;s more to be done with this canvas than we&rsquo;re currently doing. The Apple-provided screen savers in Sonoma are beautiful, but they&rsquo;re passive &mdash; pre-recorded video. A screen saver has access to real-time data: the time, the date, the weather, the calendar, the camera, the microphone, the network. It could show you your next meeting. It could visualise your currently-playing music. It could render your recent git activity as a contribution graph. It could do anything that a full-screen, always-idle application can do.</p>
<p>Nobody&rsquo;s building these things, partly because the API is old and the documentation is thin, and partly because screen savers have a branding problem. They sound like a relic. They sound like the flying toasters and maze solvers of the 1990s. They sound unserious.</p>
<p>But a dedicated full-screen display that activates when you step away from your desk? That&rsquo;s not unserious. That&rsquo;s a digital photo frame. That&rsquo;s a dashboard. That&rsquo;s ambient information design. It&rsquo;s just wearing the wrong name.</p>
<h2>Building one yourself</h2>
<p>If you want to build a macOS screen saver, here&rsquo;s what I wish someone had told me:</p>
<p>Start with a new Xcode project using the Screen Saver template. It gives you the <code>ScreenSaverView</code> subclass and the correct bundle structure. Build it, and copy the <code>.saver</code> to <code>~/Library/Screen Savers/</code>. It should appear in System Settings immediately, but if it doesn&rsquo;t, log out and back in.</p>
<p>Keep your first version simple. Override <code>animateOneFrame()</code>, draw something, see it work. The preview in System Settings is small but functional &mdash; you don&rsquo;t need to test with the full screen saver activated until you&rsquo;re further along.</p>
<p>If you need any hardware access &mdash; camera, microphone, location &mdash; you need an out-of-process helper. Accept this early and architect for it from the start. Don&rsquo;t spend hours trying to make camera access work inside the sandbox. It won&rsquo;t.</p>
<p>Sign everything. The <code>.saver</code>, the helper app, all of it. An unsigned screen saver will silently fail to load on any Mac with the default security settings.</p>
<p>And test with hot corners, not just the idle timer. The activation paths are different, and the differences will find your bugs.</p>
<p>Screen savers are a forgotten corner of macOS. But forgotten corners are where the interesting work happens &mdash; where the API is stable because nobody&rsquo;s changing it, where the competition is thin because nobody&rsquo;s building for it, and where a small, focused project can produce something genuinely delightful.</p>
<p>Turning your idle Mac into a real-time ASCII art rendering of your office? That&rsquo;s delightful. I&rsquo;ll stand by that.</p>
]]></description>
    </item>
  </channel>
</rss>
