<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Sergio Farfan Cardenete</title>
    <description>The latest articles on DEV Community by Sergio Farfan Cardenete (@sergio_farfn_b071cafc7ed).</description>
    <link>https://dev.to/sergio_farfn_b071cafc7ed</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3828298%2F93ba3193-a163-43de-b5a5-88d654bffd3d.jpg</url>
      <title>DEV Community: Sergio Farfan Cardenete</title>
      <link>https://dev.to/sergio_farfn_b071cafc7ed</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/sergio_farfn_b071cafc7ed"/>
    <language>en</language>
    <item>
      <title>Apple Music Consolidator: merge and dedupe hundreds of Apple Music playlists, safely</title>
      <dc:creator>Sergio Farfan Cardenete</dc:creator>
      <pubDate>Wed, 12 Aug 2026 19:32:57 +0000</pubDate>
      <link>https://dev.to/sergio_farfn_b071cafc7ed/apple-music-consolidator-merge-and-dedupe-hundreds-of-apple-music-playlists-safely-18nm</link>
      <guid>https://dev.to/sergio_farfn_b071cafc7ed/apple-music-consolidator-merge-and-dedupe-hundreds-of-apple-music-playlists-safely-18nm</guid>
      <description>&lt;p&gt;
  &lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fraw.githubusercontent.com%2Fsergio-farfan%2FMusicConsolidator%2Ffaf0ba2%2Fmacos-app%2Fassets%2Fappicon%2Fmaster-1024.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fraw.githubusercontent.com%2Fsergio-farfan%2FMusicConsolidator%2Ffaf0ba2%2Fmacos-app%2Fassets%2Fappicon%2Fmaster-1024.png" width="800" alt="Apple Music Consolidator" height="800"&gt;&lt;/a&gt;
&lt;/p&gt;

&lt;p&gt;You switched streaming services. Spotify to Apple Music, or YouTube Music, or Tidal — or you just got tired of paying two subscriptions at once. You ran one of the transfer tools, it churned for twenty minutes, and your playlists appeared in Music. Job done.&lt;/p&gt;

&lt;p&gt;Then you started noticing things.&lt;/p&gt;

&lt;p&gt;The transfer ran twice, because the first attempt looked like it stalled. Or it ran once, but iCloud Music Library pushed it to your Mac while your old local library was still sitting there. Or you imported from two services that shared half their playlists. Now you have &lt;strong&gt;Road Trip&lt;/strong&gt;, &lt;strong&gt;Road Trip&lt;/strong&gt;, and &lt;strong&gt;Road Trip&amp;nbsp;&lt;/strong&gt; — that last one with a trailing space, which Music is perfectly happy to let you keep — and all three have &lt;em&gt;slightly different&lt;/em&gt; contents, because for the last eight months you've been adding songs to whichever one you happened to tap.&lt;/p&gt;

&lt;p&gt;Inside them it's worse. The same song sits in one playlist three times: once as your ALAC rip from a CD, once as the 256 kbps Apple Music version, and once as a cloud entry that's now greyed out because the label pulled it. Same title, same artist, same duration. Three rows.&lt;/p&gt;

&lt;p&gt;That's how mine got to just under 400 playlists, with dozens of duplicate groups buried in there.&lt;/p&gt;

&lt;h2&gt;
  
  
  Now try fixing that by hand
&lt;/h2&gt;

&lt;p&gt;Take one triple. Open two of the three side by side, sort both by name, and eyeball 300 rows against another 300 rows. Every time you hit a duplicate, decide which copy to keep — which means checking the bit rate of each one, and noticing that a third one is greyed out. Drag the survivors into a new playlist. Delete the two originals. Hope you didn't miss anything.&lt;/p&gt;

&lt;p&gt;Thirty minutes for one group, if you're focused, and nobody stays focused past row 180. Now do that sixty times. That's a weekend gone, and the mistakes are silent: you don't discover you dropped a song until it isn't there on a drive six months later.&lt;/p&gt;

&lt;p&gt;And the delete is forever. Music.app has no undo for a deleted playlist, no transaction log, no Recently Deleted. One misclick on a playlist you spent a decade building and it's simply gone.&lt;/p&gt;

&lt;h2&gt;
  
  
  "Isn't there a tool for this?"
&lt;/h2&gt;

&lt;p&gt;Sort of. I looked before writing anything. &lt;a href="https://soundiiz.com/features" rel="noopener noreferrer"&gt;Soundiiz&lt;/a&gt; is the best-known playlist manager, and it genuinely does have both &lt;strong&gt;Merge&lt;/strong&gt; and &lt;strong&gt;Delete Duplicates&lt;/strong&gt;, and they work with Apple Music. Two things sent me back to my editor:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;They're subscription features.&lt;/strong&gt; Both sit behind Premium — $39/year or $5/month (&lt;a href="https://soundiiz.com/pricing" rel="noopener noreferrer"&gt;pricing&lt;/a&gt;). The free plan is one-playlist-at-a-time transfers, up to 200 tracks. This is a chore I need to do properly once, and then again the next time a sync goes sideways; a recurring bill for that is a strange trade.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It's a cloud service reaching in through the streaming API.&lt;/strong&gt; You authorize it against your Apple Music account, and it sees your library the way the API exposes it — as catalog tracks. It cannot see that &lt;em&gt;this&lt;/em&gt; copy is your 1,411 kbps ALAC rip, &lt;em&gt;that&lt;/em&gt; one is a 256 kbps AAC stream, and the third is flagged "no longer available." That is exactly the information that should decide which duplicate survives. It also can't see the local files that never came from the catalog at all.&lt;/p&gt;

&lt;p&gt;Neither approach gives you the thing I actually wanted most: a chance to &lt;strong&gt;read the exact change before it happens&lt;/strong&gt;, and proof afterward that what landed in the library is what I approved — on an operation that has no undo.&lt;/p&gt;

&lt;p&gt;So I built &lt;strong&gt;Apple Music Consolidator&lt;/strong&gt; — a native macOS app that merges duplicate playlists and deduplicates the tracks inside them, entirely on your Mac. Nothing is uploaded, no account is connected, nothing is behind a paywall. Every write is planned first, executed through a guarded writer, and then proven correct by reading the library back.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://github.com/sergio-farfan/MusicConsolidator/releases/latest" rel="noopener noreferrer"&gt;Download the latest .dmg →&lt;/a&gt;&lt;/strong&gt; — open it and drag the app to Applications.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5o0gpdynquvjayx3qw45.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5o0gpdynquvjayx3qw45.png" alt="The Merge tab: one checklist of every playlist — combine any of them, or merge same-name groups as units" width="800" height="586"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  What It Does
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Merge&lt;/strong&gt; — combines playlists into one new &lt;code&gt;&amp;lt;Name&amp;gt; — Merged&lt;/code&gt; playlist, deduplicating across copies. Merge same-name copies as a unit, or check off any arbitrary mix of playlists and merge those; the new playlist's description records when it was merged and from which sources. Source playlists are never touched.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Consolidate&lt;/strong&gt; — deduplicates tracks &lt;em&gt;within&lt;/em&gt; one playlist into a new &lt;code&gt;&amp;lt;Name&amp;gt; — Consolidated&lt;/code&gt; playlist, with every omitted track individually accounted for.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Batch runs&lt;/strong&gt; — select any number of playlists and process them unattended. Each one still gets its own fresh library read, its own plan, and its own verification.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cleanup&lt;/strong&gt; — delete or rename any playlist behind a single confirmation, or batch-rename a whole page of them with a find/replace fill helper (e.g. strip &lt;code&gt;" — Merged"&lt;/code&gt; from thirty playlists in one pass).&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  How It Works
&lt;/h2&gt;

&lt;p&gt;Everything runs on your Mac, inside one small native app (Swift 6, SwiftUI). It talks to Music.app directly; macOS asks you once for permission to allow that, and the grant survives updates.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What counts as a duplicate.&lt;/strong&gt; Two tracks match only when their normalized title, normalized artist, and exact duration all agree. Normalization is forgiving where it's safe — curly quotes match straight quotes, dashes match hyphens, extra whitespace and letter case don't matter — and strict where it isn't: accents are preserved, so "Nina" and "Niña" stay different artists, and there is no fuzzy "close enough" matching. A false match silently drops a song you wanted to keep, so when in doubt the app keeps both rows.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Which copy survives.&lt;/strong&gt; When several tracks match, the app keeps the best one by a fixed, predictable preference: available beats greyed-out, lossless beats lossy, then higher sample rate, then higher bit rate. This is the comparison a cloud service can't make — it takes actually seeing your files. The same library always produces the same answer.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Every change is planned, then proven.&lt;/strong&gt; No operation ever mutates the library from live state. First the app re-reads your library fresh, then it writes the exact intended change to a small report you can read — every track, every keep-or-omit decision, and why. Only after that does it execute, through a writer that re-checks the playlists one final time &lt;em&gt;immediately&lt;/em&gt; before the single create step, and refuses to touch anything that has changed since the plan — or to reuse a playlist that already exists. Then it reads the library back and confirms, track for track, that what landed is what the plan said. If any step disagrees, the app stops and tells you exactly what state it left behind. It never retries and never "repairs" — recovery is always a fresh pass that you start.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Sources are never modified.&lt;/strong&gt; Merge and Consolidate always create a &lt;em&gt;new&lt;/em&gt; playlist. Your originals sit untouched until you delete them yourself in Cleanup, after you've verified the result.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;And it's fast.&lt;/strong&gt; Reading a library through Apple events is normally the slow part of any Music automation. The app fetches data in bulk columns instead of track by track, so scanning ~400 playlists takes about a second, and snapshotting a big playlist takes seconds instead of minutes.&lt;/p&gt;




&lt;h2&gt;
  
  
  Take It for a Test Drive
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Install&lt;/strong&gt; — download the &lt;code&gt;.dmg&lt;/code&gt;, drag the app to Applications, right-click → &lt;strong&gt;Open&lt;/strong&gt; on first launch (it's signed with a personal certificate, not notarized), and grant it permission to control Music.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Scan&lt;/strong&gt; — one click reads your whole library. Same-name duplicate groups are highlighted automatically.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pick your victims&lt;/strong&gt; — in the Merge tab, every playlist is one checklist. Run a duplicate group as a unit, or check any combination and &lt;strong&gt;Merge selected as one…&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Read the plan&lt;/strong&gt; — before anything happens, you see exactly what will be created and every duplicate decision the app intends to make.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Apply&lt;/strong&gt; — a new &lt;code&gt;&amp;lt;Name&amp;gt; — Merged&lt;/code&gt; playlist appears, its description recording when it was merged and from which sources. The app reads the library back and confirms the result matches the plan.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tidy up&lt;/strong&gt; — happy with the merge? Use Cleanup to batch-delete the originals, or batch-rename to drop the &lt;code&gt;" — Merged"&lt;/code&gt; suffix.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Start small: pick one duplicate pair you know well, merge it, and inspect the result in Music before turning it loose on sixty groups. That's what the plan-first design is for.&lt;/p&gt;




&lt;h2&gt;
  
  
  Build &amp;amp; Install
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Easiest:&lt;/strong&gt; grab &lt;code&gt;AppleMusicConsolidator-&amp;lt;version&amp;gt;.dmg&lt;/code&gt; from the &lt;a href="https://github.com/sergio-farfan/MusicConsolidator/releases/latest" rel="noopener noreferrer"&gt;latest release&lt;/a&gt; (SHA-256 checksum alongside it), open it, and drag the app to &lt;strong&gt;Applications&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;From source&lt;/strong&gt; — Swift Package Manager, no Xcode project:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone git@github.com:sergio-farfan/MusicConsolidator.git
&lt;span class="nb"&gt;cd &lt;/span&gt;MusicConsolidator/macos-app/ConsolidatorKit
swift &lt;span class="nb"&gt;test&lt;/span&gt;            &lt;span class="c"&gt;# 917 tests, fully offline&lt;/span&gt;

&lt;span class="nb"&gt;cd&lt;/span&gt; ../..
bash macos-app/scripts/build-app.sh     &lt;span class="c"&gt;# build, assemble, sign the bundle&lt;/span&gt;
bash macos-app/scripts/package-dmg.sh   &lt;span class="c"&gt;# package the installer&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Requirements: macOS 14+, Swift 6, and Music.app with an Apple Music library.&lt;/p&gt;




&lt;h2&gt;
  
  
  Source Code
&lt;/h2&gt;

&lt;p&gt;MIT licensed, on GitHub: &lt;a href="https://github.com/sergio-farfan/MusicConsolidator" rel="noopener noreferrer"&gt;github.com/sergio-farfan/MusicConsolidator&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;If you're curious about the engineering underneath — the Apple events cost model that took library scans from minutes to seconds, a bug that only reproduced on empty playlists in a live library, and why verification compares strings code point by code point — the &lt;a href="https://github.com/sergio-farfan/MusicConsolidator/blob/main/docs/dev-notes-full-article.md" rel="noopener noreferrer"&gt;full developer notes&lt;/a&gt; are in the repo.&lt;/p&gt;

&lt;p&gt;Issues and feedback are open — if this untangles your library, I'd love to hear about it.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Built with Swift 6 and SwiftUI on macOS.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>swift</category>
      <category>macos</category>
      <category>swiftui</category>
      <category>opensource</category>
    </item>
    <item>
      <title>RepoDeck: a native macOS dashboard for all your git repos</title>
      <dc:creator>Sergio Farfan Cardenete</dc:creator>
      <pubDate>Thu, 09 Jul 2026 05:11:59 +0000</pubDate>
      <link>https://dev.to/sergio_farfn_b071cafc7ed/repodeck-a-native-macos-dashboard-for-all-your-git-repos-472c</link>
      <guid>https://dev.to/sergio_farfn_b071cafc7ed/repodeck-a-native-macos-dashboard-for-all-your-git-repos-472c</guid>
      <description>&lt;p&gt;I have somewhere around thirty git repositories checked out on my Mac at any given time — side projects, forks, work I forgot to push before closing the laptop. Every few days I'd ask myself the same question: which of these have uncommitted changes? Which ones are behind their remote and need a pull? The honest answer was always "I don't know," because finding out meant opening each folder in an editor just to glance at the Source Control panel — one repo at a time, over and over.&lt;/p&gt;

&lt;p&gt;So I built &lt;strong&gt;RepoDeck&lt;/strong&gt; — a native macOS dashboard that tracks a set of folders, recursively finds every git repository underneath them, and shows you at a glance which ones need attention. It now handles branches, worktrees, conflicts, and code reviews too, without losing that overview.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Updated September 2026:&lt;/em&gt; &lt;strong&gt;&lt;a href="https://github.com/sergio-farfan/repodeck/releases/tag/v1.11.0" rel="noopener noreferrer"&gt;1.11.0 is out as a stable release&lt;/a&gt;&lt;/strong&gt;. This update adds readable messages with recovery actions, searchable offline Help, commit-author setup inside the app, and a fix for the diff-window layout crash reported on macOS 27. Making Errors Useful covers what changed; From Dashboard to Git Client explains the graph, branches, worktrees, conflicts, and reviews behind the current app.&lt;/p&gt;

&lt;p&gt;Want to try it right now? &lt;strong&gt;&lt;a href="https://github.com/sergio-farfan/repodeck/releases/latest/download/RepoDeck.dmg" rel="noopener noreferrer"&gt;Download RepoDeck.dmg →&lt;/a&gt;&lt;/strong&gt; — open it and drag RepoDeck to Applications.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2eczkw7czzxfvlqv8euz.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2eczkw7czzxfvlqv8euz.png" alt="RepoDeck history graph and repository sidebar" width="799" height="511"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Features
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Multi-repo dashboard&lt;/strong&gt; — track folders, discover repositories and their registered worktrees, and filter for changes, conflicts, ahead/behind state, or errors&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Live status via FSEvents&lt;/strong&gt; — filesystem-driven refreshes, including the separate Git metadata used by linked worktrees&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Stage, commit, and sync&lt;/strong&gt; — pull, push, and fetch per repo, plus &lt;strong&gt;Fetch All&lt;/strong&gt; / &lt;strong&gt;Pull All&lt;/strong&gt; with success, failure, and skipped counts plus per-repository results&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Diff view with hunk staging&lt;/strong&gt; — unified file and commit diffs; stage or unstage eligible text hunks, with whole-file alternatives when exact content or metadata cannot be preserved&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Commit graph and history search&lt;/strong&gt; — parent connections, branch/tag labels, current/all-branch views, 100-commit pages, and search by message, author, file path, or content&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Branches and worktrees&lt;/strong&gt; — create, switch, rename, safely delete merged branches, set tracking, and create, open, or safely remove worktrees&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Text conflict workspace&lt;/strong&gt; — base/current/incoming text, an editable result, separate &lt;strong&gt;Save&lt;/strong&gt; and &lt;strong&gt;Mark Resolved&lt;/strong&gt;, and operation-aware &lt;strong&gt;Continue&lt;/strong&gt; / &lt;strong&gt;Abort&lt;/strong&gt; controls&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;GitHub and GitLab reviews&lt;/strong&gt; — browse PRs/MRs, file changes, discussion, and checks; create requests, comment, approve, merge, and check out a review into its own worktree, using optional &lt;code&gt;gh&lt;/code&gt; / &lt;code&gt;glab&lt;/code&gt; integrations&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Per-repo auto-fetch&lt;/strong&gt; — configurable intervals, a capped background lane, and priority for queued interactive work&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Auto-rebase on rejected push&lt;/strong&gt; — opt-in per repo: a rejected push runs &lt;code&gt;git pull --rebase --autostash&lt;/code&gt; and retries once; conflicts or a retained autostash can still require recovery&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Undo for pull and auto-rebase&lt;/strong&gt; — restore the recorded commit with &lt;code&gt;git reset --keep&lt;/code&gt;, bound to the original branch and worktree; this is not a backup of arbitrary local files&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Repo groups, pinning, hiding/restoring repos, and a ⌘K command palette&lt;/strong&gt; for getting around a big sidebar fast&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Stash support&lt;/strong&gt;, optional &lt;strong&gt;GitHub PR/CI badges&lt;/strong&gt;, and an optional &lt;strong&gt;menu-bar mode&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;In-window command runner&lt;/strong&gt; — shell commands in the repo's directory, bounded live output, and cancellation that cleans up the owned process group; open a terminal for interactive programs&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Developer tool preferences&lt;/strong&gt; — configurable Git/hosting executables, preferred editor and terminal, and per-repository overrides&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Actionable messages&lt;/strong&gt; — readable notices inside the workspace, recovery actions where available, and selectable, copyable diagnostic details&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Offline Help&lt;/strong&gt; — 18 searchable topics covering setup, everyday workflows, troubleshooting, and recovery&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Commit-author setup&lt;/strong&gt; — see the effective author Git will use, configure repository-specific or global defaults, and distinguish commit attribution from SSH and hosting authentication&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Themes&lt;/strong&gt; — System/Light/Dark, custom accent color, fonts, and font size (⌘,)&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  The Stack
&lt;/h2&gt;

&lt;p&gt;RepoDeck is Swift Package Manager only — there's no &lt;code&gt;.xcodeproj&lt;/code&gt;, no &lt;code&gt;.pbxproj&lt;/code&gt; to merge-conflict over. &lt;code&gt;swift build&lt;/code&gt;, &lt;code&gt;swift test&lt;/code&gt;, &lt;code&gt;swift run RepoDeck&lt;/code&gt; are the entire dev loop.&lt;/p&gt;

&lt;p&gt;The UI is SwiftUI, state is &lt;code&gt;@Observable&lt;/code&gt;, and the codebase builds under Swift 6's strict concurrency checking. The current baseline is &lt;strong&gt;macOS 15+ and Swift 6.2+&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The one deliberate architectural choice worth calling out: RepoDeck shells out to the real &lt;code&gt;git&lt;/code&gt; binary instead of linking libgit2. That's slower per call, but it keeps ordinary Git configuration, credential helpers, hooks, and SSH setup in play. Machine-parsed commands set their own output options — a display filter or terminal color setting must not change the bytes that end up staged. I want the installed Git to do the Git work, with the app responsible for choosing the right command and explaining the result.&lt;/p&gt;

&lt;p&gt;The package now has three main targets: &lt;code&gt;RepoDeckKit&lt;/code&gt; contains Git execution, parsing, discovery, file watching, and hosting adapters; &lt;code&gt;RepoDeckCore&lt;/code&gt; contains application and worktree state, scheduling, and refresh coordination; and &lt;code&gt;RepoDeck&lt;/code&gt; contains the SwiftUI/AppKit presentation. Tests can inject clients, scanners, clocks, preferences, and watcher events without opening a window. That matters for bugs like an older refresh replacing a newer result, or a slow commit clearing a draft I've edited since pressing Commit.&lt;/p&gt;




&lt;h2&gt;
  
  
  Parsing &lt;code&gt;git status --porcelain=v2 -z&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;Status parsing runs on &lt;code&gt;git status --porcelain=v2 --branch --untracked-files=all -z&lt;/code&gt; — NUL-separated so filenames with spaces, newlines, or anything else don't need escaping. &lt;code&gt;PorcelainParser&lt;/code&gt; is a pure function over &lt;code&gt;Data&lt;/code&gt;, no &lt;code&gt;Process&lt;/code&gt;, no I/O, which makes it trivial to unit test without ever invoking git.&lt;/p&gt;

&lt;h3&gt;
  
  
  The XY fan-out
&lt;/h3&gt;

&lt;p&gt;Porcelain v2's ordinary-change records carry a two-letter &lt;code&gt;XY&lt;/code&gt; code: &lt;code&gt;X&lt;/code&gt; is the index status, &lt;code&gt;Y&lt;/code&gt; is the worktree status. A file that's staged &lt;em&gt;and&lt;/em&gt; has further unstaged edits is one record, but RepoDeck's UI wants it in two different sections — Staged and Changes. &lt;code&gt;appendFanOut&lt;/code&gt; does the split:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;appendFanOut&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;xy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Substring&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;originalPath&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="p"&gt;?,&lt;/span&gt; &lt;span class="n"&gt;into&lt;/span&gt; &lt;span class="nv"&gt;changes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;inout&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kt"&gt;FileChange&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;letters&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;xy&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;guard&lt;/span&gt; &lt;span class="n"&gt;letters&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;count&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;indexStatus&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;letters&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;worktreeStatus&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;letters&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;indexStatus&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="s"&gt;"."&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;changes&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;FileChange&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;originalPath&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;originalPath&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;area&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;staged&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;statusLetter&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;indexStatus&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;worktreeStatus&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="s"&gt;"."&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;changes&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;FileChange&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;originalPath&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;originalPath&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;area&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;unstaged&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;statusLetter&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;worktreeStatus&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One record becomes zero, one, or two &lt;code&gt;FileChange&lt;/code&gt; rows depending on which half of &lt;code&gt;XY&lt;/code&gt; isn't a dot.&lt;/p&gt;

&lt;h3&gt;
  
  
  Renames consume the next token
&lt;/h3&gt;

&lt;p&gt;Rename and copy records (&lt;code&gt;2 ...&lt;/code&gt;) are the one record kind where a single logical event spans two NUL-delimited tokens: the record itself, then the original path as a separate token immediately after it. The dispatch loop has to know to look ahead and skip an extra slot:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="s"&gt;"2"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;originalPath&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;index&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;records&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;count&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;records&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;index&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;nil&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;originalPath&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nf"&gt;parseRenameOrCopy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;record&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;originalPath&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;originalPath&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;into&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;changes&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;index&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="n"&gt;originalPath&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="kc"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Miss that &lt;code&gt;index += 2&lt;/code&gt; and the parser starts reading the next file's rename-origin path as if it were a new status record — everything after the first rename in the list comes out garbled.&lt;/p&gt;

&lt;h3&gt;
  
  
  Untracked files and merge conflicts
&lt;/h3&gt;

&lt;p&gt;Two more record kinds skip the fan-out entirely. Untracked files (&lt;code&gt;?&lt;/code&gt; records) are just the path after a fixed two-character prefix:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;parseUntracked&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="nv"&gt;record&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;into&lt;/span&gt; &lt;span class="nv"&gt;changes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;inout&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kt"&gt;FileChange&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;guard&lt;/span&gt; &lt;span class="n"&gt;record&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;count&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;path&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;record&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dropFirst&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="c1"&gt;// drop "? "&lt;/span&gt;
    &lt;span class="n"&gt;changes&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;FileChange&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;area&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;untracked&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;statusLetter&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"U"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Unmerged conflicts (&lt;code&gt;u&lt;/code&gt; records, left behind by a failed merge or rebase) carry their own two-character conflict code and go straight into a dedicated &lt;code&gt;.unmerged&lt;/code&gt; area rather than through the staged/unstaged split — a conflicted file isn't meaningfully "staged," it's blocking, and the UI treats it that way.&lt;/p&gt;




&lt;h2&gt;
  
  
  ProcessRunner: One Subprocess Primitive for Everything
&lt;/h2&gt;

&lt;p&gt;Git and hosting commands share one subprocess runner, with async entry points, bounded output, cancellation, and a global concurrency limit. The command pane uses the same machinery. The current implementation keeps the original reason for centralizing it, but the implementation now owns a POSIX process group for each job.&lt;/p&gt;

&lt;h3&gt;
  
  
  The pipe-drain deadlock
&lt;/h3&gt;

&lt;p&gt;Pipes have a fixed OS buffer. Wait for a child process to exit &lt;em&gt;before&lt;/em&gt; reading its stdout, and if that process writes more output than the pipe can hold, the child blocks writing while you're blocked waiting for it to exit — a real deadlock, and it only shows up once the output gets big enough.&lt;/p&gt;

&lt;p&gt;The current worker uses nonblocking file descriptors and services both output streams while the child is running. These two calls sit in the same loop as cancellation, timeout, stdin, and exit handling:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="nf"&gt;readOutput&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;outputFD&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;stream&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;stdout&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;decoder&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;outDecoder&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;stdout&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;readOutput&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;errorFD&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;stream&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;stderr&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;decoder&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;errDecoder&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;stderr&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The combined stdout/stderr budget defaults to &lt;strong&gt;16 MiB&lt;/strong&gt;, with smaller limits where a caller needs them. Exceeding the budget starts process-group shutdown. Pipe draining is bounded during shutdown too: a descendant holding an inherited pipe open must not turn a short timeout into a long wait. The blocking &lt;code&gt;poll&lt;/code&gt;/exit-handling work runs off Swift's cooperative executor.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;GIT_TERMINAL_PROMPT=0&lt;/code&gt; and a 6-slot semaphore
&lt;/h3&gt;

&lt;p&gt;The runner sets Git's terminal-prompt behavior and locale before applying any explicit caller overrides:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;env&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;ProcessInfo&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;processInfo&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environment&lt;/span&gt;
&lt;span class="n"&gt;env&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"GIT_TERMINAL_PROMPT"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"0"&lt;/span&gt;
&lt;span class="n"&gt;env&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"LC_ALL"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"C"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That prevents Git from waiting on its own username/password terminal prompt in a GUI operation. It doesn't make every external credential helper or hook noninteractive, so timeouts and useful errors still matter.&lt;/p&gt;

&lt;p&gt;Bulk operations can fire dozens of commands at once, so a process-wide limiter caps concurrency at 6:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;concurrencyLimit&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;6&lt;/span&gt;
&lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;limiter&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;ConcurrencyLimiter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;concurrencyLimit&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Background work can hold at most 4 of those slots; queued interactive work has priority. Acquisition itself is cancellable, cancellation is checked again before launch, and every acquired slot is released on success, failure, or cancellation. A running background job still uses resources — this is scheduling priority, not a promise that background work has zero cost.&lt;/p&gt;

&lt;h3&gt;
  
  
  Cancellation owns the whole job
&lt;/h3&gt;

&lt;p&gt;Cancelling a Swift &lt;code&gt;Task&lt;/code&gt; has to stop the command's work, not just stop awaiting its result. Killing only the parent process is insufficient when a child has inherited the output pipes. The runner creates a separate process group at launch, then starts shutdown with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;beginStopping&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;guard&lt;/span&gt; &lt;span class="n"&gt;stoppingAt&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="kc"&gt;nil&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;stoppingAt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;now&lt;/span&gt;
    &lt;span class="nf"&gt;kill&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;pid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;SIGTERM&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;closeFD&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;inputFD&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The negative PID targets the owned process group. Shutdown escalates to &lt;code&gt;SIGKILL&lt;/code&gt; after 500 ms and closes remaining output pipes after one second; the leader is reaped before the job returns. A command's background children belong to that command's lifetime, so the command pane isn't a daemon launcher.&lt;/p&gt;

&lt;p&gt;There's one more lifetime to keep straight: cancelling a stream consumer can end iteration before process cleanup finishes. The streaming handle exposes &lt;code&gt;waitForCompletion()&lt;/code&gt;, and the command pane holds its repository coordination lock until cleanup is done. A stopped pane must not let the next Git mutation overlap a job that's still exiting.&lt;/p&gt;




&lt;h2&gt;
  
  
  Watching the Filesystem Without Hammering It
&lt;/h2&gt;

&lt;p&gt;Local status refreshes are driven by FSEvents rather than a polling loop. &lt;code&gt;RepoWatcher&lt;/code&gt; wraps the C API and emits debounced events on an &lt;code&gt;AsyncStream&lt;/code&gt;. Optional auto-fetch and hosting refreshes are separate scheduled work; “event-driven status” doesn't mean the entire app has no timers.&lt;/p&gt;

&lt;h3&gt;
  
  
  Map the repository before filtering the path
&lt;/h3&gt;

&lt;p&gt;Git writes and removes &lt;code&gt;index.lock&lt;/code&gt; during index updates. Ignoring that lock-file churn avoids unnecessary refreshes, while the actual index and ref changes still matter. The watcher also ignores temporary watchman-cookie events.&lt;/p&gt;

&lt;p&gt;The less obvious part is &lt;em&gt;where&lt;/em&gt; filtering happens. An absolute path can contain a folder named &lt;code&gt;vendor&lt;/code&gt; or &lt;code&gt;target&lt;/code&gt; above a perfectly valid repository. Dropping the whole event because one component looks like a dependency directory makes that repository silently stop refreshing.&lt;/p&gt;

&lt;p&gt;For a known repository, the watcher first computes a relative path:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;relative&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dropFirst&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;repo&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;count&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="k"&gt;guard&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="k"&gt;Self&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;shouldIgnore&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;relative&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;forKnownRepo&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The filter only uses dependency/build-directory pruning for discovery paths, not changes inside a known repository:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;component&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="n"&gt;components&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;component&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;contains&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;".watchman-cookie"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;forKnownRepo&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;prunedNames&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;contains&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;component&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Linked worktrees need another mapping: their Git directory and shared common directory can live outside the checkout. Those metadata paths are watched too, and a shared change can refresh every affected worktree. If FSEvents reports dropped events, the app schedules repository refreshes and folder discovery instead of assuming its current snapshot is complete.&lt;/p&gt;

&lt;h3&gt;
  
  
  A 300ms debounce — no timer hammering your disk
&lt;/h3&gt;

&lt;p&gt;Saving a file, running a build, or checking out a branch can fire dozens of callbacks in a fraction of a second. A burst for the same repo collapses into a single emission about 300ms after the last event — cancel-and-reschedule, not a recurring status poll:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;schedule&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="nv"&gt;event&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;WatchEvent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;debounce&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;]?&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;cancel&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;item&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;DispatchWorkItem&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="k"&gt;weak&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt;
        &lt;span class="k"&gt;guard&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;self&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;debounce&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;nil&lt;/span&gt;
        &lt;span class="k"&gt;guard&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;stopped&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;continuation&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;yield&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;debounce&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;item&lt;/span&gt;
    &lt;span class="n"&gt;queue&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;asyncAfter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;deadline&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="k"&gt;Self&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;debounceInterval&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The state layer then coalesces rescans and rejects obsolete results. Relevant external Git changes also refresh history, stashes, open diffs, and review context — keeping a sidebar count current isn't enough if the open workspace still describes the old branch.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Truncation Seam
&lt;/h2&gt;

&lt;p&gt;This is the bug I'm most annoyed I didn't catch sooner, because both halves of it were individually correct — and individually unit tested.&lt;/p&gt;

&lt;p&gt;The original &lt;code&gt;ProcessRunner&lt;/code&gt; output cap protected against a repo with hundreds of thousands of untracked files turning &lt;code&gt;git status&lt;/code&gt; into an unbounded memory sink: past the cap, it stopped the command and returned what had been captured, flagged as truncated. The unit tests for &lt;code&gt;ProcessRunner&lt;/code&gt; covered that behavior.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;GitClient.status&lt;/code&gt; was supposed to pass a truncated read through to &lt;code&gt;PorcelainParser&lt;/code&gt;, which drops the trailing partial record and sets &lt;code&gt;didHitLimit&lt;/code&gt; on the result. The parser behavior was also unit tested.&lt;/p&gt;

&lt;p&gt;Here's the seam: stopping the command with &lt;code&gt;SIGTERM&lt;/code&gt; could produce a signal exit of 15 instead of 0. The shared helper had this guard:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="k"&gt;guard&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;exitCode&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="kt"&gt;GitError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;command&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;commandString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fullArguments&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nv"&gt;exitCode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;exitCode&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;stderr&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;stderr&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When output-limit shutdown produced a nonzero exit, the helper threw a generic &lt;code&gt;GitError&lt;/code&gt; before &lt;code&gt;PorcelainParser&lt;/code&gt; saw the bytes. The "Too many changes — showing a partial list" banner, built and unit tested against a &lt;code&gt;RepoStatus&lt;/code&gt; with &lt;code&gt;didHitLimit == true&lt;/code&gt;, therefore failed to appear in exactly that case. A usable partial result looked like an ordinary Git failure.&lt;/p&gt;

&lt;p&gt;Nothing caught this in isolation, because nothing in isolation was wrong. &lt;code&gt;ProcessRunner&lt;/code&gt;'s truncation tests never touched &lt;code&gt;GitClient&lt;/code&gt;. &lt;code&gt;PorcelainParser&lt;/code&gt;'s truncation tests fed it pre-truncated bytes directly, never through a real process exit code. The only place the seam existed was the exact path connecting output-limit shutdown, a nonzero exit, and that exit-code guard — and that only shows up in an end-to-end pass, not a unit test of either side alone.&lt;/p&gt;

&lt;p&gt;The fix was to let explicitly truncated output reach the caller instead of rejecting it solely because shutdown produced a nonzero exit. Status turns it into a partial list; diff operations reject oversized output instead of offering an incomplete patch. Timed-out results are errors. The runner now has a default cap for other commands too, so the truncation flag is part of the caller's contract, not a general sign of success.&lt;/p&gt;

&lt;p&gt;The status cap remains injectable (&lt;strong&gt;4 MB&lt;/strong&gt; by default). The integration test creates twenty untracked files and lowers the cap to 256 bytes — enough for some complete records, but not the whole list:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;GitClient&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;statusOutputLimit&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;256&lt;/span&gt;

&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;in&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;repo&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="cp"&gt;#expect(status.didHitLimit == true)&lt;/span&gt;
&lt;span class="cp"&gt;#expect(status.changes.count &amp;gt;= 1)&lt;/span&gt;
&lt;span class="cp"&gt;#expect(status.changes.count &amp;lt; 20)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The lesson stuck: when two components are each individually correct and each individually tested, that says nothing about the seam between them. A whole-codebase review pass is what caught it — not either of the unit suites, which had been green the entire time. If a bug can only exist in the handoff, only a test that exercises that exact handoff will ever find it.&lt;/p&gt;




&lt;h2&gt;
  
  
  From Dashboard to Git Client
&lt;/h2&gt;

&lt;p&gt;The early releases filled in the everyday gaps: per-repo auto-fetch, groups, a ⌘K palette, opt-in auto-rebase, undo, stashes, PR/CI badges, a menu-bar mode, diffs, and hunk staging. Later came the sidebar identity footer and hiding repositories without deleting their folders. &lt;strong&gt;1.10.1&lt;/strong&gt; took the next step: I can move from noticing a repository needs attention to working through its branch, conflict, or review in the same app.&lt;/p&gt;

&lt;h3&gt;
  
  
  History, branches, and worktrees
&lt;/h3&gt;

&lt;p&gt;History now includes a commit graph with parent connections and branch/tag decorations, current/all-branch views, and 100-commit pagination. Branch and worktree management sit alongside it. Discovery asks Git for its registered worktrees, so a linked sibling outside the folder I originally added can still appear in the dashboard.&lt;/p&gt;

&lt;p&gt;A review checkout gets its own worktree, leaving the current checkout's files in place. Safe removal checks ignored and untracked files too — a clean-looking Git status must not be taken as permission to discard a local &lt;code&gt;.env&lt;/code&gt; file. Selection, drafts, and review context belong to each worktree instead of whichever view happens to be mounted.&lt;/p&gt;

&lt;h3&gt;
  
  
  Conflicts and code review
&lt;/h3&gt;

&lt;p&gt;The conflict workspace shows base, current, and incoming text with an editable result. &lt;strong&gt;Save&lt;/strong&gt; and &lt;strong&gt;Mark Resolved&lt;/strong&gt; are separate actions, and both check whether the file or index changed externally. Binary files, unsupported encodings, submodules, and complex conflicts use an external editor/terminal workflow instead of pretending everything is editable text.&lt;/p&gt;

&lt;p&gt;GitHub and GitLab reviews use the optional &lt;code&gt;gh&lt;/code&gt; and &lt;code&gt;glab&lt;/code&gt; CLIs. Public and self-hosted destinations are represented explicitly; the preview identifies the account, repository, branches, and action before a write. Review lists, descriptions, changed files, discussion, checks, request creation, comments, approvals, and merge are available according to provider capabilities and server permissions. GitHub supports formal change-request reviews; GitLab currently offers comments and approvals for that part of the workflow.&lt;/p&gt;

&lt;p&gt;A review can change while it's open. Approving or merging therefore rechecks the reviewed head. A failed write preserves the draft, and uncertain create/comment responses are reconciled before retrying, using the same operation identifier. Local Git workflows still work without either hosting CLI installed.&lt;/p&gt;

&lt;h3&gt;
  
  
  The safety work behind the buttons
&lt;/h3&gt;

&lt;p&gt;A hunk-staging button is only useful if it stages exactly the bytes I selected. Partial operations now require lossless UTF-8 and supported file metadata, disable text conversion/external diff helpers/color, and revalidate the displayed diff before writing. Unsupported encodings, symlinks, submodules, renames, or mode changes get a visible whole-file alternative. Selected filenames are literal, so &lt;code&gt;literal[1].txt&lt;/code&gt; doesn't accidentally select &lt;code&gt;literal1.txt&lt;/code&gt; too.&lt;/p&gt;

&lt;p&gt;Stash selections follow object IDs instead of trusting an old row number. Undo verifies the original worktree, branch, and expected commit. Mutations that share Git metadata are coordinated, and their previews are checked again after waiting for capacity. External Git programs don't participate in that coordination, so I don't describe this as an atomic transaction against every other process on the machine.&lt;/p&gt;

&lt;p&gt;The 1.10.1 release also added safer icon-resource lookup: missing resources no longer invoke the fatal SwiftPM accessor during launch. Seven fixture scenarios cover packaged apps, executable-adjacent bundles, and absent resources. For the current &lt;strong&gt;1.11.0&lt;/strong&gt; release source, &lt;a href="https://github.com/sergio-farfan/repodeck/actions/runs/34817711162" rel="noopener noreferrer"&gt;native Apple silicon and Intel CI&lt;/a&gt; each passed &lt;strong&gt;389 tests across 28 suites&lt;/strong&gt;, alongside build and packaging checks. Both published DMG names were downloaded and checked against the release SHA-256; the app inside was verified for its version, universal architecture, and ad-hoc signature.&lt;/p&gt;

&lt;p&gt;That still leaves work to validate on real setups: clean-machine installation and first launch, the full VoiceOver/appearance matrix, and live GitHub/GitLab writes across permissions and hosting configurations. Those limits are recorded in the &lt;a href="https://github.com/sergio-farfan/repodeck/releases/tag/v1.11.0" rel="noopener noreferrer"&gt;release notes&lt;/a&gt;. Interactive rebase editing, inline threaded-review editing, issue tracking, and Windows/Linux ports remain future work.&lt;/p&gt;

&lt;p&gt;The full per-release detail is in the &lt;a href="https://github.com/sergio-farfan/repodeck/blob/main/CHANGELOG.md" rel="noopener noreferrer"&gt;changelog&lt;/a&gt;, and the README carries a &lt;a href="https://github.com/sergio-farfan/repodeck#releases--roadmap" rel="noopener noreferrer"&gt;release history and roadmap&lt;/a&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  Making Errors Useful
&lt;/h2&gt;

&lt;p&gt;One of the most useful bug reports was also one of the simplest: a message was drawn across the top of the window, over the sidebar title, and I couldn't read it. Worse, a successful &lt;strong&gt;Fetch All&lt;/strong&gt; was wearing a warning icon. Even a correct Git operation feels broken when the app explains it badly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1.11.0&lt;/strong&gt; puts notices inside the workspace with bounded, wrapping text. Success, warning, and failure have distinct presentations. Longer diagnostics live in a scrollable details view where I can select and copy them, instead of stretching a banner across the window. When the failure is recognized, the app offers an appropriate next step — opening the relevant workspace, settings, terminal, or Help topic. It doesn't silently run a destructive command to make the message disappear.&lt;/p&gt;

&lt;p&gt;Bulk operations also keep the result for each repository. If &lt;strong&gt;Pull All&lt;/strong&gt; reports a failure or a busy repository was skipped, I can inspect which repository it was and what happened. A single total isn't enough when I'm managing thirty checkouts.&lt;/p&gt;

&lt;h3&gt;
  
  
  Help that works offline
&lt;/h3&gt;

&lt;p&gt;Choosing &lt;strong&gt;Help → RepoDeck Help&lt;/strong&gt; now opens an actual guide instead of macOS's “Help isn't available” dialog. There are &lt;strong&gt;18 searchable topics&lt;/strong&gt;, with related links and back/forward navigation, covering setup, changes and commits, branches and worktrees, conflicts, hosting reviews, tools, and troubleshooting. Search with &lt;strong&gt;⌘F&lt;/strong&gt; inside the Help window.&lt;/p&gt;

&lt;p&gt;The guide explains recovery limits too. Undo has a specific branch and worktree context; it isn't a backup of every local file. Partial staging has whole-file alternatives when exact content can't be preserved. I want those limits available at the moment I need them, including when the network is down.&lt;/p&gt;

&lt;h3&gt;
  
  
  Commit authors are different from login credentials
&lt;/h3&gt;

&lt;p&gt;Another confusing message was “No git identity configured.” SSH was working, GitHub showed the right account, and commits could still have an author. The problem was that the footer was looking at configuration defaults rather than asking Git for the effective commit author.&lt;/p&gt;

&lt;p&gt;The footer now uses the same Git author lookup an ordinary new commit relies on:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git var GIT_AUTHOR_IDENT
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That includes Git's author overrides and the environment inherited by the app. SSH keys prove I can connect to a remote; a GitHub or GitLab login grants hosting access. Neither automatically supplies the name and email recorded in a commit.&lt;/p&gt;

&lt;p&gt;Click &lt;strong&gt;Configure Author…&lt;/strong&gt; or &lt;strong&gt;Edit Author…&lt;/strong&gt; in the sidebar to edit the name and email for &lt;strong&gt;this repository&lt;/strong&gt; or the &lt;strong&gt;global default&lt;/strong&gt;, then explicitly save. Repository-specific settings are useful for keeping work and personal addresses separate. The form shows the effective author separately from those editable defaults and explains when an override takes precedence. Failed saves preserve the draft. Existing commits keep their original authors, and the app doesn't replace SSH keys or hosting credentials.&lt;/p&gt;

&lt;h3&gt;
  
  
  A steadier diff workspace
&lt;/h3&gt;

&lt;p&gt;This release also fixes a reported macOS 27 crash involving the native diff inspector's layout updates. The diff now lives in a split workspace that preserves the current view state as it opens and closes. Repeated open/close cycles were checked locally; the broader keyboard, VoiceOver, and appearance checks remain listed separately in the release verification notes.&lt;/p&gt;




&lt;h2&gt;
  
  
  Build &amp;amp; Install
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Easiest:&lt;/strong&gt; &lt;strong&gt;&lt;a href="https://github.com/sergio-farfan/repodeck/releases/latest/download/RepoDeck.dmg" rel="noopener noreferrer"&gt;download RepoDeck.dmg directly&lt;/a&gt;&lt;/strong&gt; (or browse the &lt;a href="https://github.com/sergio-farfan/repodeck/releases/latest" rel="noopener noreferrer"&gt;latest release&lt;/a&gt;), open it, and drag &lt;strong&gt;RepoDeck&lt;/strong&gt; onto &lt;strong&gt;Applications&lt;/strong&gt;. When updating, quit the running app before replacing it in Applications. The installer is universal for &lt;strong&gt;Apple silicon and Intel&lt;/strong&gt;, and requires &lt;strong&gt;macOS 15 or later&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;RepoDeck is &lt;strong&gt;ad-hoc signed and not notarized&lt;/strong&gt;. After verifying the download's SHA-256 against the release checksum and attempting to open it, you may need &lt;strong&gt;System Settings → Privacy &amp;amp; Security → Open Anyway&lt;/strong&gt;. See &lt;a href="https://support.apple.com/en-lamr/102445" rel="noopener noreferrer"&gt;Apple's first-launch instructions&lt;/a&gt;; managed Macs may have additional restrictions. This is the standard distribution — Developer ID signing and notarization are optional future improvements.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;From source&lt;/strong&gt; — Swift 6.2 or newer, Swift Package Manager, no Xcode project needed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/sergio-farfan/repodeck.git
&lt;span class="nb"&gt;cd &lt;/span&gt;repodeck
swift build
swift &lt;span class="nb"&gt;test
&lt;/span&gt;swift run RepoDeck
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Install and authenticate &lt;code&gt;gh&lt;/code&gt; or &lt;code&gt;glab&lt;/code&gt; only if you want the corresponding hosting integration. Full build, packaging, and release commands are in the &lt;a href="https://github.com/sergio-farfan/repodeck#build-from-source" rel="noopener noreferrer"&gt;README&lt;/a&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  Source Code
&lt;/h2&gt;

&lt;p&gt;RepoDeck is on GitHub: &lt;a href="https://github.com/sergio-farfan/repodeck" rel="noopener noreferrer"&gt;github.com/sergio-farfan/repodeck&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Built with Swift 6 and SwiftUI on macOS.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>swift</category>
      <category>git</category>
      <category>macos</category>
      <category>opensource</category>
    </item>
    <item>
      <title>NoSleep: Prevent your Mac to go sleep. Period.</title>
      <dc:creator>Sergio Farfan Cardenete</dc:creator>
      <pubDate>Sun, 22 Mar 2026 05:09:02 +0000</pubDate>
      <link>https://dev.to/sergio_farfn_b071cafc7ed/nosleep-a-lightweight-macos-menu-bar-app-with-swiftui-5fap</link>
      <guid>https://dev.to/sergio_farfn_b071cafc7ed/nosleep-a-lightweight-macos-menu-bar-app-with-swiftui-5fap</guid>
      <description>&lt;p&gt;Have you ever been mid-presentation, watching a long build compile, or waiting for a large file to download — only for your Mac to decide it's nap time? macOS ships a built-in tool for exactly this: &lt;code&gt;caffeinate&lt;/code&gt;. But running it from the terminal every time is clunky.&lt;/p&gt;

&lt;p&gt;So I built &lt;strong&gt;NoSleep&lt;/strong&gt; — a tiny macOS menu bar utility that wraps &lt;code&gt;caffeinate&lt;/code&gt; in a one-click toggle. No Dock icon. No main window. Just a cup icon in your menu bar.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffpxs9rr9zhtzum481rc9.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffpxs9rr9zhtzum481rc9.png" alt="NoSleep menu bar dropdown" width="526" height="934"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Update — v1.2.0:&lt;/strong&gt; a full code review of the whole repository (about 500 lines of app Swift plus the build and packaging scripts) turned up far more than I expected — a dozen confirmed bugs in the app alone, among them a first-launch default that silently meant &lt;em&gt;Indefinite&lt;/em&gt;, a countdown that froze while you were looking at it, and a &lt;code&gt;caffeinate&lt;/code&gt; child that outlived the app after a crash. The eight headline bugs are fixed below, Start at Login is rebuilt on &lt;code&gt;SMAppService&lt;/code&gt;, the packaging scripts are fixed too, the menu got native checkmarks, an in-menu record of the last session, &lt;strong&gt;Activate on Launch&lt;/strong&gt; and &lt;strong&gt;About&lt;/strong&gt;, and the test suite went from 5 to 87. Details in the v1.2.0 section below. Install with Homebrew — &lt;code&gt;brew install --cask sergio-farfan/tap/nosleep&lt;/code&gt; — or download &lt;a href="https://github.com/sergio-farfan/nosleep/releases/download/v1.2.0/NoSleep-1.2.0.dmg" rel="noopener noreferrer"&gt;NoSleep-1.2.0.dmg&lt;/a&gt; (universal, macOS 14+). The 1.2.0 DMG was rebuilt on 2026-09-12 to include the menu additions; both builds report 1.2.0, so if your menu has no &lt;strong&gt;About NoSleep&lt;/strong&gt; item, download it again (or &lt;code&gt;brew upgrade --cask nosleep&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Update — v1.1.0:&lt;/strong&gt; NoSleep now ships as a downloadable, drag-to-install &lt;code&gt;.dmg&lt;/code&gt; (universal), activates the moment you pick a duration, shows a green active indicator with a readable countdown, and pops a notification with an &lt;strong&gt;Extend 1 hour&lt;/strong&gt; action when a timed session ends (hover the notification to reveal the button; the &lt;em&gt;Alerts&lt;/em&gt; style keeps it on screen until you do). The new bits — and the async race the notification introduced — are covered in the v1.1.0 section below.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Features
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Homebrew or DMG&lt;/strong&gt; — &lt;code&gt;brew install --cask sergio-farfan/tap/nosleep&lt;/code&gt;, or grab the &lt;code&gt;.dmg&lt;/code&gt; from Releases and drag NoSleep to Applications (universal: Apple Silicon + Intel)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One-click toggle&lt;/strong&gt; — start/stop caffeinate from the menu bar&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Auto-activate&lt;/strong&gt; — pick a duration and it starts immediately, no extra click&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Duration presets&lt;/strong&gt; — 15 min, 30 min, 1 hr, 2 hr, 4 hr, 8 hr, 10 hr, or Indefinite&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Live countdown&lt;/strong&gt; — a green active dot and remaining time while active (e.g. &lt;code&gt;2h 34m&lt;/code&gt;); after a timed session ends, the menu says when&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Completion notification&lt;/strong&gt; — when a timed session ends, a notification offers &lt;strong&gt;Extend 1 hour&lt;/strong&gt; (hover the notification to reveal the button; the &lt;em&gt;Alerts&lt;/em&gt; style keeps it on screen until you do)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Start at Login&lt;/strong&gt; — registers a login item so it auto-starts when you log in (a LaunchAgent plist in ≤ 1.1.0, &lt;code&gt;SMAppService&lt;/code&gt; since 1.2.0)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Activate on Launch&lt;/strong&gt; — optional: start the saved duration the moment NoSleep launches, so login-time protection needs no click&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Single instance&lt;/strong&gt; — launching a second copy exits immediately, and a lock-aware build quits a still-running pre-lock copy (1.1.0, or the 1.2.0 DMG published before 2026-09-12) and its caffeinate, so an upgrade cannot leave two icons&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Prevents display + idle sleep&lt;/strong&gt; — uses &lt;code&gt;caffeinate -d -i&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  The Stack
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Swift 6.0&lt;/strong&gt; with strict concurrency&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SwiftUI&lt;/strong&gt; + &lt;code&gt;MenuBarExtra&lt;/code&gt; (macOS 13+)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;UserNotifications&lt;/strong&gt; — for the session-complete alert and its Extend action&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Observation&lt;/strong&gt; (&lt;code&gt;@Observable&lt;/code&gt;) — replaced &lt;code&gt;ObservableObject&lt;/code&gt; in 1.2.0&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;ServiceManagement&lt;/strong&gt; (&lt;code&gt;SMAppService&lt;/code&gt;) — Start at Login since 1.2.0&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Swift Package Manager&lt;/strong&gt; — no Xcode project file required; ships a &lt;strong&gt;universal binary&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Minimum target: &lt;strong&gt;macOS 14 (Sonoma)&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  App Entry Point: MenuBarExtra
&lt;/h2&gt;

&lt;p&gt;The entire app lives in the menu bar, which SwiftUI makes surprisingly clean with &lt;code&gt;MenuBarExtra&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="kd"&gt;@main&lt;/span&gt;
&lt;span class="kd"&gt;struct&lt;/span&gt; &lt;span class="kt"&gt;NoSleepApp&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;App&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;@StateObject&lt;/span&gt; &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;caffeinateManager&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;CaffeinateManager&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="kd"&gt;@StateObject&lt;/span&gt; &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;loginManager&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;LoginItemManager&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kd"&gt;some&lt;/span&gt; &lt;span class="kt"&gt;Scene&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kt"&gt;MenuBarExtra&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="kt"&gt;MenuBarView&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;manager&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;caffeinateManager&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;loginManager&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;loginManager&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="nv"&gt;label&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="kt"&gt;Image&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;systemName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;caffeinateManager&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;isActive&lt;/span&gt;
                  &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="s"&gt;"cup.and.saucer.fill"&lt;/span&gt;
                  &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"cup.and.saucer"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the whole entry point. &lt;code&gt;MenuBarExtra&lt;/code&gt; handles all the menu bar plumbing — no &lt;code&gt;NSStatusItem&lt;/code&gt;, no AppKit boilerplate. The icon toggles between a filled and outlined cup based on whether caffeinate is running.&lt;/p&gt;

&lt;p&gt;Setting &lt;code&gt;LSUIElement: true&lt;/code&gt; in &lt;code&gt;Info.plist&lt;/code&gt; hides the Dock icon and removes the main window entirely.&lt;/p&gt;




&lt;h2&gt;
  
  
  Core Logic: CaffeinateManager
&lt;/h2&gt;

&lt;p&gt;The heart of the app is &lt;code&gt;CaffeinateManager&lt;/code&gt; — an &lt;code&gt;@MainActor&lt;/code&gt; &lt;code&gt;ObservableObject&lt;/code&gt; that manages the &lt;code&gt;caffeinate&lt;/code&gt; child process and a countdown timer.&lt;/p&gt;

&lt;h3&gt;
  
  
  Spawning the Process
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;start&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;stop&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;proc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;Process&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;proc&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;executableURL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;fileURLWithPath&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"/usr/bin/caffeinate"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;args&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"-d"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"-i"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;selectedDuration&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;indefinite&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;args&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"-t"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\(&lt;/span&gt;&lt;span class="n"&gt;selectedDuration&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;rawValue&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
        &lt;span class="n"&gt;remainingSeconds&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;selectedDuration&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;rawValue&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;proc&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;arguments&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;args&lt;/span&gt;

    &lt;span class="n"&gt;proc&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;terminationHandler&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="k"&gt;weak&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt;
        &lt;span class="kt"&gt;Task&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="kd"&gt;@MainActor&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="k"&gt;weak&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt;
            &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;handleTermination&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;proc&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;process&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;proc&lt;/span&gt;
    &lt;span class="n"&gt;isActive&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;selectedDuration&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;indefinite&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;timer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;Timer&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;scheduledTimer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;withTimeInterval&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;repeats&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="k"&gt;weak&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt;
            &lt;span class="kt"&gt;Task&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="kd"&gt;@MainActor&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="k"&gt;weak&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt;
                &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;tick&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few things worth noting:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;-d -i&lt;/code&gt; flags&lt;/strong&gt; — &lt;code&gt;-d&lt;/code&gt; prevents the display from sleeping, &lt;code&gt;-i&lt;/code&gt; prevents idle sleep. Together they cover the common use cases.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;-t &amp;lt;seconds&amp;gt;&lt;/code&gt;&lt;/strong&gt; — when a duration is selected, caffeinate self-terminates after that many seconds. The app also runs a &lt;code&gt;Timer&lt;/code&gt; in parallel to track remaining time for the UI.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;terminationHandler&lt;/code&gt;&lt;/strong&gt; — if caffeinate exits on its own (duration expired, or the system killed it), this handler fires and cleans up app state. The &lt;code&gt;Task { @MainActor in ... }&lt;/code&gt; pattern bridges from the background callback thread into the main actor, which Swift 6 strict concurrency requires. (In v1.1.0 this handler grew a &lt;em&gt;run-token&lt;/em&gt; guard — more on why below.)&lt;/p&gt;

&lt;h3&gt;
  
  
  Duration Options
&lt;/h3&gt;

&lt;p&gt;Durations are a typed enum with raw values in seconds:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="kd"&gt;enum&lt;/span&gt; &lt;span class="kt"&gt;SleepDuration&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;CaseIterable&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;Identifiable&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;Sendable&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="n"&gt;fifteenMin&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;900&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="n"&gt;thirtyMin&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1800&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="n"&gt;oneHour&lt;/span&gt;    &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;3600&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="n"&gt;twoHours&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;7200&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="n"&gt;fourHours&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;14400&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="n"&gt;eightHours&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;28800&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="n"&gt;tenHours&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;36000&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="n"&gt;indefinite&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The selected duration is persisted in &lt;code&gt;UserDefaults&lt;/code&gt; so the preference survives app restarts.&lt;/p&gt;




&lt;h2&gt;
  
  
  Login Item: LaunchAgent Plist
&lt;/h2&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Correction (v1.2.0):&lt;/strong&gt; this section describes NoSleep ≤ 1.1.0. &lt;code&gt;SMAppService&lt;/code&gt; does &lt;strong&gt;not&lt;/strong&gt; require a sandboxed app — that was my mistake — and the plist approach below broke silently whenever the bundle moved (the path is baked in at enable time) and reported "enabled" purely from the file's existence. Since 1.2.0 NoSleep uses &lt;code&gt;SMAppService.mainApp&lt;/code&gt;, reads the real Background Task Management status, and carries an existing plist's setting over when launched from an installed copy — see Start at Login, done properly below.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;NoSleep 1.1.0 wrote a &lt;code&gt;LaunchAgent&lt;/code&gt; plist directly to &lt;code&gt;~/Library/LaunchAgents/&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="cp"&gt;&amp;lt;?xml version="1.0" encoding="UTF-8"?&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;plist&lt;/span&gt; &lt;span class="na"&gt;version=&lt;/span&gt;&lt;span class="s"&gt;"1.0"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;dict&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;key&amp;gt;&lt;/span&gt;Label&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;string&amp;gt;&lt;/span&gt;com.nosleep.app&lt;span class="nt"&gt;&amp;lt;/string&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;key&amp;gt;&lt;/span&gt;ProgramArguments&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;array&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;string&amp;gt;&lt;/span&gt;/Users/you/Applications/NoSleep.app/Contents/MacOS/NoSleep&lt;span class="nt"&gt;&amp;lt;/string&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;/array&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;key&amp;gt;&lt;/span&gt;RunAtLoad&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;true/&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/dict&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/plist&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This approach works without sandboxing and gives full control over the plist.&lt;/p&gt;




&lt;h2&gt;
  
  
  v1.1.0: Auto-Activate, Completion Alerts, and a Real Download
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Pick a duration → it just starts
&lt;/h3&gt;

&lt;p&gt;Originally you picked a duration and then clicked Start. Now selecting any preset activates immediately:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;changeDuration&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="nv"&gt;duration&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;SleepDuration&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;selectedDuration&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;duration&lt;/span&gt;
    &lt;span class="nf"&gt;start&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;   &lt;span class="c1"&gt;// auto-activate on selection (re-selecting restarts the timer)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  A completion notification you can act on
&lt;/h3&gt;

&lt;p&gt;When a timed session ends, NoSleep posts a notification with an &lt;strong&gt;Extend 1 hour&lt;/strong&gt; action, using &lt;code&gt;UserNotifications&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;extend&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;UNNotificationAction&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;identifier&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"EXTEND_1H"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                                  &lt;span class="nv"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"Extend 1 hour"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;options&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[])&lt;/span&gt;
&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;category&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;UNNotificationCategory&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;identifier&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"SESSION_COMPLETE"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                                      &lt;span class="nv"&gt;actions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;extend&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
                                      &lt;span class="nv"&gt;intentIdentifiers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[],&lt;/span&gt; &lt;span class="nv"&gt;options&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[])&lt;/span&gt;
&lt;span class="kt"&gt;UNUserNotificationCenter&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;current&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setNotificationCategories&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="n"&gt;category&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Tapping &lt;strong&gt;Extend 1 hour&lt;/strong&gt; starts a fresh one-hour session. One gotcha: the delegate has to be registered &lt;strong&gt;at launch&lt;/strong&gt; — Apple requires it &lt;em&gt;before the app finishes launching&lt;/em&gt;, so doing it lazily when the menu first opens can drop the action response.&lt;/p&gt;

&lt;h3&gt;
  
  
  The stale-termination trap
&lt;/h3&gt;

&lt;p&gt;Here's the interesting bug the notification surfaced. &lt;code&gt;caffeinate&lt;/code&gt; runs as a child process; when it exits, its &lt;code&gt;terminationHandler&lt;/code&gt; fires on a background thread. But that handler fires for &lt;em&gt;three&lt;/em&gt; different reasons: the timer expired (→ notify), the user hit Stop (→ don't notify), or a &lt;strong&gt;restart&lt;/strong&gt; replaced the process (→ don't notify, and don't clobber the new session's state).&lt;/p&gt;

&lt;p&gt;The restart case is a classic async race: &lt;code&gt;start()&lt;/code&gt; calls &lt;code&gt;stop()&lt;/code&gt; (terminating the old process), then launches a new one — but the old process's termination callback arrives &lt;em&gt;later&lt;/em&gt;, after the new session is already live. A naive "was this a user stop?" flag misfires.&lt;/p&gt;

&lt;p&gt;The fix is a monotonic &lt;strong&gt;run token&lt;/strong&gt;. Each &lt;code&gt;start()&lt;/code&gt; bumps a counter, and the termination handler captures the value it was launched with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;start&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;stop&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;runToken&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;token&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;runToken&lt;/span&gt;
    &lt;span class="c1"&gt;// ...spawn caffeinate...&lt;/span&gt;
    &lt;span class="n"&gt;proc&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;terminationHandler&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="k"&gt;weak&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt;
        &lt;span class="kt"&gt;Task&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="kd"&gt;@MainActor&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="k"&gt;weak&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;handleTermination&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;token&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;handleTermination&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;token&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;guard&lt;/span&gt; &lt;span class="n"&gt;token&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;runToken&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;   &lt;span class="c1"&gt;// stale (restarted) — ignore it&lt;/span&gt;
    &lt;span class="c1"&gt;// ...decide natural-expiry vs user-stop, then maybe post the notification&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Because the main actor runs &lt;code&gt;start()&lt;/code&gt; synchronously through the token bump, any stale handler that arrives afterward sees a token that no longer matches — and bails out before touching the new session or firing a notification. The whole "should this fire?" decision is a small pure function, which made it easy to unit-test in isolation.&lt;/p&gt;

&lt;h3&gt;
  
  
  Download-and-run distribution
&lt;/h3&gt;

&lt;p&gt;The biggest change for users: NoSleep now ships a real &lt;code&gt;.dmg&lt;/code&gt;. The entire pipeline uses only tooling that's already on every Mac — no third-party dependencies:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;App icon&lt;/strong&gt; — a small AppKit script renders the &lt;code&gt;cup.and.saucer.fill&lt;/code&gt; SF Symbol onto a gradient squircle, then &lt;code&gt;sips&lt;/code&gt; + &lt;code&gt;iconutil&lt;/code&gt; turn it into &lt;code&gt;AppIcon.icns&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Universal binary&lt;/strong&gt; — &lt;code&gt;swift build -c release --arch arm64 --arch x86_64&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Styled DMG&lt;/strong&gt; — &lt;code&gt;hdiutil&lt;/code&gt; plus a little AppleScript lay out the window: the app on the left, an arrow to an Applications drop-target, a background image, and a volume icon.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Because it's ad-hoc signed (not notarized), the first launch needs a one-time Gatekeeper nudge:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;xattr &lt;span class="nt"&gt;-dr&lt;/span&gt; com.apple.quarantine /Applications/NoSleep.app
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  v1.2.0: Eight Bugs, a Login Item Rewrite, and 87 Tests
&lt;/h2&gt;

&lt;p&gt;Before this release I ran an automated, multi-agent code review over the whole repository: ten lens-specific reviewers, every bug and medium-severity finding checked by three adversarial verifiers (reproduce / skeptic / impact) and lower-severity items by one, then two further verification passes over the fixes themselves. It found more than I expected in about 500 lines of app Swift plus the scripts around it. The snippets earlier in this article show the 1.1.0 code; here is what changed and why.&lt;/p&gt;

&lt;h3&gt;
  
  
  The default that was secretly "Indefinite"
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;saved&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;UserDefaults&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;standard&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;integer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;forKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"selectedDuration"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;selectedDuration&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;SleepDuration&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;rawValue&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;saved&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;??&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;fourHours&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Looks fine. But &lt;code&gt;integer(forKey:)&lt;/code&gt; returns &lt;code&gt;0&lt;/code&gt; for a missing key, and &lt;code&gt;0&lt;/code&gt; is the raw value of &lt;code&gt;.indefinite&lt;/code&gt;. So the fallback never ran, and every fresh install started with &lt;strong&gt;Indefinite&lt;/strong&gt; selected — the one preset with no timer and no completion notification. The fix reads the raw object and decides in a pure, unit-tested function:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="kd"&gt;nonisolated&lt;/span&gt; &lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;restoredDuration&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;from&lt;/span&gt; &lt;span class="nv"&gt;stored&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Int&lt;/span&gt;&lt;span class="p"&gt;?)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="kt"&gt;SleepDuration&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;guard&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;stored&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;saved&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;SleepDuration&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;rawValue&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;stored&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;fourHours&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;saved&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="c1"&gt;// in init:  restoredDuration(from: defaults.object(forKey: key) as? Int)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  The child that outlived its parent
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;caffeinate&lt;/code&gt; is a child process. If NoSleep crashed, was force-quit, or got &lt;code&gt;kill&lt;/code&gt;ed, macOS did &lt;strong&gt;not&lt;/strong&gt; kill the child — it was reparented to launchd and kept the Mac awake with no UI attached (forever, for Indefinite). &lt;code&gt;caffeinate&lt;/code&gt; has a flag for exactly this, and it composes with &lt;code&gt;-t&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;args&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"-d"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"-i"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"-w"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\(&lt;/span&gt;&lt;span class="kt"&gt;ProcessInfo&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;processInfo&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;processIdentifier&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;-w &amp;lt;pid&amp;gt;&lt;/code&gt; releases the assertion and exits as soon as that process is gone.&lt;/p&gt;

&lt;h3&gt;
  
  
  The countdown that froze while you looked at it
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;Timer.scheduledTimer&lt;/code&gt; registers in the run loop's &lt;code&gt;.default&lt;/code&gt; mode. While an &lt;code&gt;NSMenu&lt;/code&gt; is open, the main run loop runs in &lt;code&gt;NSEventTrackingRunLoopMode&lt;/code&gt; — where &lt;code&gt;.default&lt;/code&gt;-mode timers never fire. So the "live countdown" stood still exactly while the menu was open. Worse, each tick &lt;em&gt;decremented&lt;/em&gt; a counter, so every second spent looking at the menu went uncounted, and for the rest of the session the display showed more time than actually remained — caffeinate's &lt;code&gt;-t&lt;/code&gt; timer expired while the menu still showed minutes left.&lt;/p&gt;

&lt;p&gt;Two changes: add the timer in &lt;code&gt;.common&lt;/code&gt; mode, and derive the value from a deadline instead of counting down:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;t&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;Timer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;timeInterval&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;repeats&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="k"&gt;weak&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt;
    &lt;span class="kt"&gt;MainActor&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;assumeIsolated&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;tick&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="kt"&gt;RunLoop&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;main&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;forMode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;common&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;tick&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;remainingSeconds&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;Int&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;deadlineUptime&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nf"&gt;uptime&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;rounded&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;up&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt;
    &lt;span class="c1"&gt;// …&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The deadline is on &lt;code&gt;ProcessInfo.systemUptime&lt;/code&gt;, the same clock family &lt;code&gt;caffeinate -t&lt;/code&gt; uses, so both pause together during system sleep. There is a test for this that puts the main run loop into a tracking-style common mode and checks the countdown still moves; reverting to &lt;code&gt;.default&lt;/code&gt; fails it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Two percent CPU for nothing
&lt;/h3&gt;

&lt;p&gt;That 1 Hz tick wrote an &lt;code&gt;@Published&lt;/code&gt; property on the app-root &lt;code&gt;@StateObject&lt;/code&gt;. Every write fired &lt;code&gt;objectWillChange&lt;/code&gt;, which invalidated the whole &lt;code&gt;MenuBarExtra&lt;/code&gt; scene, which re-set the status bar button's image, laid it out, committed to WindowServer and re-snapshotted the item for both appearances — about 20 ms of main-thread work per second — the review measured roughly 2 % CPU with the menu &lt;em&gt;closed&lt;/em&gt;, for a glyph that had not changed.&lt;/p&gt;

&lt;p&gt;Migrating to the Observation framework fixed it with almost no code:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="kd"&gt;@MainActor&lt;/span&gt; &lt;span class="kd"&gt;@Observable&lt;/span&gt;
&lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="kt"&gt;CaffeinateManager&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;isActive&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
    &lt;span class="kd"&gt;private(set)&lt;/span&gt; &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;remainingSeconds&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
    &lt;span class="c1"&gt;// …&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// NoSleepApp:  @State private var caffeinateManager = CaffeinateManager()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;@Observable&lt;/code&gt; tracks &lt;em&gt;which properties&lt;/em&gt; each view read. The menu-bar label only reads &lt;code&gt;isActive&lt;/code&gt;, so &lt;code&gt;remainingSeconds&lt;/code&gt; ticking away no longer touches it. In the same measurement the same tick under &lt;code&gt;@Observable&lt;/code&gt; cost about 0.1 %.&lt;/p&gt;

&lt;h3&gt;
  
  
  The crash outside an .app bundle
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;UNUserNotificationCenter.current()&lt;/code&gt; raises &lt;code&gt;bundleProxyForCurrentProcess is nil&lt;/code&gt; and aborts the process unless you are running from a real &lt;code&gt;.app&lt;/code&gt;. &lt;code&gt;CaffeinateManager.init()&lt;/code&gt; called it — so &lt;code&gt;swift run&lt;/code&gt; died instantly, and no unit test could construct the manager. Only the pure notify-decision function had tests; the state machine the whole app depends on had none.&lt;/p&gt;

&lt;p&gt;The fix: guard on &lt;code&gt;Bundle.main.bundleURL.pathExtension == "app"&lt;/code&gt;, and give the manager three injectable seams — a launcher (&lt;code&gt;CaffeinateLaunching&lt;/code&gt;), a notification poster (&lt;code&gt;NotificationPosting&lt;/code&gt;) and a store (&lt;code&gt;DurationStore&lt;/code&gt;). With fakes for all three, the whole start / stop / restart / termination machine runs under &lt;code&gt;swift test&lt;/code&gt; without spawning a process or touching &lt;code&gt;UserDefaults&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  The stale Extend button
&lt;/h3&gt;

&lt;p&gt;Delivered notifications sit in Notification Center indefinitely. A "Your 2 hours session has ended" banner from the morning still had a live &lt;strong&gt;Extend 1 hour&lt;/strong&gt; button at 4 pm — and tapping it replaced whatever session was running with a one-hour one. Now every &lt;code&gt;stop()&lt;/code&gt; (which every restart goes through) clears delivered notifications, and &lt;code&gt;extendOneHour()&lt;/code&gt; ignores the action while a session is active.&lt;/p&gt;

&lt;h3&gt;
  
  
  "Session has ended" — no, it was killed
&lt;/h3&gt;

&lt;p&gt;The termination handler treated every exit the same, so &lt;code&gt;killall caffeinate&lt;/code&gt; produced a cheerful completion notification. The launcher now reads the exit reason and only a clean exit counts as expiry:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="n"&gt;proc&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;terminationHandler&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;clean&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;terminationReason&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;exit&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;terminationStatus&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
    &lt;span class="kt"&gt;Task&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="kd"&gt;@MainActor&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;onTermination&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;clean&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There is also a test that runs the real launcher against &lt;code&gt;/usr/bin/true&lt;/code&gt;, &lt;code&gt;/usr/bin/false&lt;/code&gt; and a &lt;code&gt;terminate()&lt;/code&gt;d &lt;code&gt;sleep&lt;/code&gt;, so the mapping itself is covered — not just the code that consumes it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Start at Login, done properly
&lt;/h3&gt;

&lt;p&gt;The LaunchAgent approach I described above was wrong twice. &lt;code&gt;SMAppService&lt;/code&gt; never required a sandbox. And baking &lt;code&gt;Bundle.main.executablePath&lt;/code&gt; into a plist meant the login item broke silently the moment the app moved — say, from the mounted DMG to Applications — while the toggle stayed checked because the file still existed.&lt;/p&gt;

&lt;p&gt;1.2.0 uses &lt;code&gt;SMAppService.mainApp&lt;/code&gt;. The toggle reflects the real Background Task Management status, refreshed every time the menu opens; if the item needs your approval a caption says so, and clicking the toggle takes you to System Settings › Login Items instead of trying to re-register. An existing 1.1.0 plist is migrated when the app is launched from an installed copy. If the old agent was still on, the plist is only deleted once the new registration is actually &lt;code&gt;.enabled&lt;/code&gt; — while it awaits your approval the plist stays and a later launch finishes the job. If you had already switched the old agent off under Login Items, the plist is removed without registering anything, so "off" carries over. Either way nobody loses the setting on upgrade. The migration decision is a pure function with its own tests, because the first version of it had a bug the review's second pass caught: it treated "registered, awaiting approval" as "the user turned it off".&lt;/p&gt;

&lt;h3&gt;
  
  
  Packaging, too
&lt;/h3&gt;

&lt;p&gt;The DMG script assumed its image would mount at &lt;code&gt;/Volumes/NoSleep&lt;/code&gt;; with a NoSleep DMG already open it mounted at &lt;code&gt;/Volumes/NoSleep 1&lt;/code&gt; and the script ejected the wrong disk. It now refuses to run while a NoSleep volume is already mounted, reads the device node back from &lt;code&gt;hdiutil attach&lt;/code&gt; so it can only ever detach its own image, retries &lt;code&gt;detach&lt;/code&gt; while Finder still holds the volume, and ships a multi-resolution TIFF background so Retina displays get the sharp version. The app icon no longer includes 16 and 32 px representations, which current macOS (verified on 27) draws shrunk on a grey plate.&lt;/p&gt;

&lt;h3&gt;
  
  
  The improvements that rode along
&lt;/h3&gt;

&lt;p&gt;The same review listed a second tier of things that were not bugs but were worth doing. A second pass of the same automated process landed them on 2026-09-12, two days after the first 1.2.0 build, and I rebuilt the 1.2.0 DMG rather than cut a new version — so if you downloaded it before then, download it again. Everything below came from the review except the About box, which I added on my own:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Native checkmarks.&lt;/strong&gt; The duration presets are real menu toggles, so the selected one gets the system checkmark (and VoiceOver reads it), instead of SF Symbol dots that recent macOS stopped drawing in menus.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The menu remembers.&lt;/strong&gt; When a timed session runs out, the status line shows an orange dot and "Kept awake for 2 hours — ended 14:32" until you start something new, so a missed notification no longer leaves you guessing. The wording gains the date once it is no longer today's.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Activate on Launch.&lt;/strong&gt; An opt-in toggle that starts the saved duration as soon as the app launches — the missing half of Start at Login.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;About NoSleep.&lt;/strong&gt; Icon, name, tagline and the installed version, one click away.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Extend keeps your preference.&lt;/strong&gt; "Extend 1 hour" runs a one-hour session without overwriting the duration you had picked; the checkmark follows the running session and snaps back afterwards.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One instance.&lt;/strong&gt; A kernel file lock (&lt;code&gt;O_EXLOCK&lt;/code&gt;) arbitrates between copies started by &lt;code&gt;open&lt;/code&gt;, a login item and launchd within the same millisecond — my first attempt used &lt;code&gt;NSRunningApplication&lt;/code&gt;, and the review's probes caught it racing: two copies started together left zero or two instances, because a directly exec'd copy is not registered with LaunchServices until &lt;code&gt;NSApplication&lt;/code&gt; initialises and two LaunchServices-launched copies can each see the other and both exit. A lock-aware build also quits a still-running pre-lock build, so upgrading by dragging the DMG over the old app cannot leave two icons.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Notifications.&lt;/strong&gt; The banner reads "Session ended / Kept your Mac awake for 2 hours. It can sleep again.", stays in Notification Center, and new installs get the persistent &lt;em&gt;Alerts&lt;/em&gt; style, so the notification stays on screen until you act (hover it to reveal the Extend button). If you have denied notifications, the menu offers to open the right System Settings pane.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Packaging.&lt;/strong&gt; The DMG finally ships its volume icon — Finder was deleting the file during the layout step, so it is now applied afterwards — plus Apple's 824-point icon grid, the GPL text inside the bundle, and proper &lt;code&gt;Info.plist&lt;/code&gt; metadata.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CI.&lt;/strong&gt; Every push to &lt;code&gt;main&lt;/code&gt; now builds, runs the tests, produces the signed universal bundle, packages and verifies the DMG, and lints the shell scripts on a GitHub-hosted Mac.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Tests: 5 → 87
&lt;/h3&gt;

&lt;p&gt;Every Swift fix above except the Observation migration has a test that fails if the fix is reverted (the packaging changes are shell scripts and assets, outside the test target): the pure decisions, the state machine through fakes, the real launcher's exit mapping, the run-loop-mode test, a deadline-resync test with an injected clock (two ticks inside one second must not double-decrement; one tick after a 65 s stall must jump to the right value), the login-item migration against a scripted fake and a temp plist, the instance lock (exclusivity, stale contents, no leak into the child), the launch hook, the ended-session cue, and the notification text and action routing.&lt;/p&gt;

&lt;p&gt;The lesson I am taking from this release: the bugs were not in the clever part (the run-token race from 1.1.0 held up fine). They were in the boring parts — a default value, a run-loop mode, a child process nobody waits for — and none of them were reachable by tests until the class could be constructed outside an &lt;code&gt;.app&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Install:&lt;/strong&gt; the quickest way is now Homebrew, from my personal tap:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;brew &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;--cask&lt;/span&gt; sergio-farfan/tap/nosleep   &lt;span class="c"&gt;# first time: accept the prompt to trust the tap&lt;/span&gt;
brew upgrade &lt;span class="nt"&gt;--cask&lt;/span&gt; nosleep                      &lt;span class="c"&gt;# later updates&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or download &lt;a href="https://github.com/sergio-farfan/nosleep/releases/download/v1.2.0/NoSleep-1.2.0.dmg" rel="noopener noreferrer"&gt;NoSleep-1.2.0.dmg&lt;/a&gt; — universal (Apple Silicon + Intel), macOS 14+ — open it and drag &lt;strong&gt;NoSleep&lt;/strong&gt; onto Applications. Either way, do the one-time Gatekeeper step in Build &amp;amp; Install below; because releases are ad-hoc signed, macOS asks again after each upgrade. Upgrading from 1.1.0 or from the earlier 1.2.0 build just means replacing the app (Homebrew does it for you); your Start at Login setting is carried over, and the new copy quits the old one.&lt;/p&gt;

&lt;p&gt;The tap side is automated too: pushing a version tag builds the DMG, publishes the GitHub Release with a &lt;code&gt;.sha256&lt;/code&gt; sidecar, and fires a &lt;code&gt;repository_dispatch&lt;/code&gt; at the tap repository, whose workflow rewrites the cask's &lt;code&gt;version&lt;/code&gt; and &lt;code&gt;sha256&lt;/code&gt;, re-validates it on a clean runner, and pushes — so &lt;code&gt;brew upgrade&lt;/code&gt; sees a new NoSleep within minutes of a release, with no hand-edited formula.&lt;/p&gt;




&lt;h2&gt;
  
  
  Build &amp;amp; Install
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Easiest:&lt;/strong&gt; Homebrew, from my tap:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;brew &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;--cask&lt;/span&gt; sergio-farfan/tap/nosleep
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Homebrew 6 and later ask you to trust a third-party tap the first time (&lt;code&gt;brew trust sergio-farfan/tap&lt;/code&gt;). Update with &lt;code&gt;brew upgrade --cask nosleep&lt;/code&gt;; remove with &lt;code&gt;brew uninstall --cask --zap nosleep&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Or the DMG:&lt;/strong&gt; download &lt;code&gt;NoSleep-&amp;lt;version&amp;gt;.dmg&lt;/code&gt; from the &lt;a href="https://github.com/sergio-farfan/nosleep/releases" rel="noopener noreferrer"&gt;latest release&lt;/a&gt;, open it, and drag &lt;strong&gt;NoSleep&lt;/strong&gt; onto Applications.&lt;/p&gt;

&lt;p&gt;Either way, on first launch run the &lt;code&gt;xattr&lt;/code&gt; command above once (or open it, then &lt;strong&gt;System Settings → Privacy &amp;amp; Security → Open Anyway&lt;/strong&gt;); ad-hoc signed builds get a new code identity per release, so expect that step again after each upgrade until I notarize.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;From source&lt;/strong&gt; — the project uses Swift Package Manager, no &lt;code&gt;.xcodeproj&lt;/code&gt; needed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Build (universal binary, bundles, ad-hoc code signs)&lt;/span&gt;
./build.sh

&lt;span class="c"&gt;# Run&lt;/span&gt;
open NoSleep.app

&lt;span class="c"&gt;# Package a distributable .dmg&lt;/span&gt;
./package-dmg.sh

&lt;span class="c"&gt;# Install to ~/Applications (optional)&lt;/span&gt;
./install.sh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Requirements to build: Swift 6.0+, Xcode Command Line Tools (full Xcode for &lt;code&gt;swift test&lt;/code&gt;), macOS 14+.&lt;/p&gt;




&lt;h2&gt;
  
  
  Source Code
&lt;/h2&gt;

&lt;p&gt;NoSleep is open source under the GPLv3.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;GitHub:&lt;/strong&gt; &lt;a href="https://github.com/sergio-farfan/nosleep" rel="noopener noreferrer"&gt;github.com/sergio-farfan/nosleep&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Contributions, issues, and stars are all welcome. If you run into any macOS quirks with &lt;code&gt;caffeinate&lt;/code&gt;, &lt;code&gt;MenuBarExtra&lt;/code&gt;, or notifications from an ad-hoc-signed app, feel free to open an issue.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Built with Swift 6 and SwiftUI on macOS Sonoma.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>macos</category>
      <category>swift</category>
      <category>swiftui</category>
      <category>opensource</category>
    </item>
    <item>
      <title>I Built a Windows-Style Alt+Tab Window Switcher for macOS in Pure Swift</title>
      <dc:creator>Sergio Farfan Cardenete</dc:creator>
      <pubDate>Sun, 22 Mar 2026 04:16:11 +0000</pubDate>
      <link>https://dev.to/sergio_farfn_b071cafc7ed/i-built-a-windows-style-alttab-window-switcher-for-macos-in-pure-swift-4pp7</link>
      <guid>https://dev.to/sergio_farfn_b071cafc7ed/i-built-a-windows-style-alttab-window-switcher-for-macos-in-pure-swift-4pp7</guid>
      <description>&lt;h2&gt;
  
  
  What It Does
&lt;/h2&gt;

&lt;p&gt;Hold &lt;code&gt;Option&lt;/code&gt; and tap &lt;code&gt;Tab&lt;/code&gt; — a panel appears showing every open window across all your apps, on the screen where your mouse is. Cycle through them with &lt;code&gt;Tab&lt;/code&gt; / &lt;code&gt;Shift-Tab&lt;/code&gt; or the arrow keys, then release &lt;code&gt;Option&lt;/code&gt; to jump straight to the selected window. Click any thumbnail to switch instantly.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fz01lq3iqh49j7gt427gf.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fz01lq3iqh49j7gt427gf.jpg" alt="The AltTab switcher in action — Option-Tab cycling through every open window" width="794" height="138"&gt;&lt;/a&gt;&lt;/p&gt;
One Option-Tab: every open window, most-recent first, on the screen where your mouse is.



&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Shortcut&lt;/th&gt;
&lt;th&gt;Action&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Option-Tab&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Open switcher / next window&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Tab&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Cycle forward&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Shift-Tab&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Cycle backward&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;← / →&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Navigate left / right&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Escape&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Cancel&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Enter&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Confirm and switch&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Click&lt;/td&gt;
&lt;td&gt;Select and switch immediately&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The switcher includes minimized windows (they unminimize when selected), windows of &lt;code&gt;Cmd-H&lt;/code&gt;-hidden apps, and windows sitting on other Spaces. No Dock icon, no clutter — just a clean menu bar icon with a small preferences menu.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F99zgbju36xqq3p9mz7g0.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F99zgbju36xqq3p9mz7g0.png" alt="AltTab menu bar menu" width="281" height="221"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  What's New in 1.3.3
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Glass Strength.&lt;/strong&gt; Liquid Glass now has four levels — &lt;strong&gt;Light / Medium / High / Max&lt;/strong&gt; — in the status menu (macOS 26+). &lt;code&gt;NSGlassEffectView&lt;/code&gt; exposes no intensity control, so the levels are emulated: &lt;strong&gt;High&lt;/strong&gt; is the original regular glass; &lt;strong&gt;Light&lt;/strong&gt; and &lt;strong&gt;Medium&lt;/strong&gt; lay an appearance-adaptive translucent plate over it, calming the effect toward the Solid look; &lt;strong&gt;Max&lt;/strong&gt; switches to Apple's clear glass style, the most see-through. Takes effect on the next &lt;code&gt;Option-Tab&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Homebrew.&lt;/strong&gt; &lt;code&gt;brew install --cask sergio-farfan/tap/alttab&lt;/code&gt; — and the release pipeline now bumps the cask itself, so &lt;code&gt;brew upgrade --cask alttab&lt;/code&gt; tracks every release.&lt;/li&gt;
&lt;li&gt;First-launch guidance for the unsigned build follows Apple's current flow (System Settings → Privacy &amp;amp; Security → &lt;strong&gt;Open Anyway&lt;/strong&gt;).&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What's New in 1.3.2
&lt;/h2&gt;

&lt;p&gt;1.3.1 fixed &lt;em&gt;which slot&lt;/em&gt; the first Tab lands on; 1.3.2 makes sure the list itself is never stale — and makes the slow paths fast:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The window list stays warm.&lt;/strong&gt; The list used to be re-gathered only while the switcher was open, so the first &lt;code&gt;Option-Tab&lt;/code&gt; after a long idle opened from a snapshot frozen at your previous session. Now every focus change, app activation, launch, and termination schedules a debounced background refresh — rate-limited, so sustained window-hopping costs one sweep per 8 seconds, not one per switch.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A busy app can't corrupt the order.&lt;/strong&gt; A single 0.25s Accessibility timeout from a wedged or App-Napped app used to silently drop its minimized and other-Space windows from the list and permanently demote their MRU ranks. Windows of apps that fail to answer are now carried over from the previous gather, ranks intact.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;Option-Shift-Tab&lt;/code&gt; opens the switcher backward.&lt;/strong&gt; Just like Windows: the initial reverse invoke anchors on the &lt;em&gt;least&lt;/em&gt;-recently-used window and keeps cycling backward from there.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Faster everywhere it was slow.&lt;/strong&gt; The per-app Accessibility pass now runs concurrently instead of stacking per-app timeouts sequentially; raising or unminimizing a window of a wedged app is bounded by per-window timeouts (previously up to ~6s per call); the focused-window probe moved off the main thread so a slow app can't stall the event tap; app icons resolve off the main thread too.&lt;/li&gt;
&lt;li&gt;The pure-logic core is now five unit-tested types (&lt;code&gt;SwitcherSelection&lt;/code&gt;, &lt;code&gt;MRUOrder&lt;/code&gt;, &lt;code&gt;SwitcherStateMachine&lt;/code&gt;, &lt;code&gt;GatherMerge&lt;/code&gt;, &lt;code&gt;Debouncer&lt;/code&gt;) — &lt;strong&gt;83 tests&lt;/strong&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What's New in 1.3.1
&lt;/h2&gt;

&lt;p&gt;This release is entirely about the &lt;em&gt;first&lt;/em&gt; &lt;code&gt;Option-Tab&lt;/code&gt;. The bug report that started it: after the switcher had sat idle for a while, a single Tab would sometimes land two or three windows back instead of on the window you had just left. An adversarially verified code review traced it to five root causes. All five are fixed, and the selection logic that decides which slot the first Tab lands on is now a pure, unit-tested type.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The first Tab lands on the previous window — every time.&lt;/strong&gt; The panel opens from a cached window list re-sorted by live MRU order, so a window you had opened (or closed) since the last gather was missing from (or lingering in) the list, shifting every slot by one. The initial highlight is now anchored against the window that &lt;em&gt;actually&lt;/em&gt; has focus, and the mid-session reconcile re-anchors against the fresh list unless you have already cycled or clicked.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Background apps can no longer scramble the order.&lt;/strong&gt; &lt;code&gt;kAXFocusedWindowChanged&lt;/code&gt; fires for non-frontmost apps too — Electron/Chromium window churn, a Terminal window closing when its job finishes — and each event used to silently jump to the front of the MRU while you worked elsewhere. Only the frontmost app's focus changes count now.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No more phantom switches.&lt;/strong&gt; Confirming a window that had been closed since the last gather used to fall through to "raise the app's first window" — an arbitrary one. Confirm now verifies liveness with one batched WindowServer query and falls through to the next real window; the arbitrary-raise fallback is gone.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Intra-app tracking survives updates.&lt;/strong&gt; Every release re-prompts for Accessibility (ad-hoc signing), and the per-app AX observers were registered &lt;em&gt;before&lt;/em&gt; the grant — the failures were stored as if they had succeeded, so &lt;code&gt;Cmd-`&lt;/code&gt; switches went untracked for the whole session. Observers now register only on success, reinstall the moment the grant arrives, retry for just-launched apps, and self-heal on an app's first activation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rapid toggling is stable.&lt;/strong&gt; Option-Tab-ing straight back after a switch treats the in-flight activation as focus ground truth, so a quick flick can't anchor on the window you just switched to and turn into a no-op.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What's New in 1.3
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Liquid Glass.&lt;/strong&gt; On macOS 26 (Tahoe), the switcher can render on Apple's native glass material via &lt;code&gt;NSGlassEffectView&lt;/code&gt; — the real thing, not a blur imitation. Status menu → &lt;strong&gt;Background → Liquid Glass&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Selectable backgrounds.&lt;/strong&gt; Three styles, applied on the next &lt;code&gt;Option-Tab&lt;/code&gt; with no relaunch: &lt;strong&gt;Solid&lt;/strong&gt; (default — opaque, theme-adaptive), &lt;strong&gt;Transparent&lt;/strong&gt; (the classic translucent HUD with vibrancy), and &lt;strong&gt;Liquid Glass&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Appearance override.&lt;/strong&gt; The panel follows the OS Light/Dark theme by default (including scheduled Auto switching), and you can force Light or Dark from the menu regardless of the system setting.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;WCAG-tested readability.&lt;/strong&gt; The text under each window is no longer hardcoded white on a translucent panel. Labels use semantic system colors, and on the default Solid background the label/background pair meets WCAG 2.x AA (contrast ≥ 4.5:1) — not as an aspiration, but verified by unit tests that resolve the &lt;em&gt;live&lt;/em&gt; system colors under both Light and Dark appearances and compute the actual contrast ratio. If Apple ever changes the palette in a way that breaks AA, the test suite fails.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What's New in 1.2
&lt;/h2&gt;

&lt;p&gt;Instant-open switcher (cached window list, reconciled off the main thread), a 0.25s messaging timeout on every Accessibility call so one wedged app can't freeze the hotkey, opt-in live window previews via ScreenCaptureKit (macOS 14+, no Screen Recording prompt unless you enable it), click-to-switch fixed, multi-monitor support, and a unit-tested core.&lt;/p&gt;




&lt;h2&gt;
  
  
  Installation
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Requirements:&lt;/strong&gt; macOS 13 Ventura or later (Liquid Glass needs macOS 26)&lt;/p&gt;

&lt;h3&gt;
  
  
  Download (recommended)
&lt;/h3&gt;

&lt;p&gt;Grab the prebuilt universal DMG (Apple Silicon + Intel) from the &lt;a href="https://github.com/sergio-farfan/alttab-macos/releases/latest" rel="noopener noreferrer"&gt;releases page&lt;/a&gt;, drag AltTab to Applications, and launch it. The build is not notarized yet, so macOS blocks the first launch: open &lt;strong&gt;System Settings → Privacy &amp;amp; Security&lt;/strong&gt;, click &lt;strong&gt;Open Anyway&lt;/strong&gt;, then launch again (or clear the quarantine flag):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;xattr &lt;span class="nt"&gt;-dr&lt;/span&gt; com.apple.quarantine /Applications/AltTab.app
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Homebrew
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;brew &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;--cask&lt;/span&gt; sergio-farfan/tap/alttab
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The fully qualified name trusts just this cask (Homebrew 6+ requires third-party taps to be trusted). For short names, &lt;code&gt;brew trust sergio-farfan/tap &amp;amp;&amp;amp; brew tap sergio-farfan/tap&lt;/code&gt; first. Updates: &lt;code&gt;brew upgrade --cask alttab&lt;/code&gt;. The same first-launch &lt;strong&gt;Open Anyway&lt;/strong&gt; step applies.&lt;/p&gt;

&lt;h3&gt;
  
  
  Build from source
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/sergio-farfan/alttab-macos.git &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;cd &lt;/span&gt;alttab-macos &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; ./build.sh &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; open ~/Applications/AltTab.app
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;./build.sh build          &lt;span class="c"&gt;# Build Release binary only&lt;/span&gt;
./build.sh &lt;span class="nb"&gt;install&lt;/span&gt;        &lt;span class="c"&gt;# Build and install to ~/Applications&lt;/span&gt;
./build.sh run            &lt;span class="c"&gt;# Build and launch immediately&lt;/span&gt;
./build.sh uninstall      &lt;span class="c"&gt;# Remove the installed app&lt;/span&gt;
swift &lt;span class="nb"&gt;test&lt;/span&gt;                &lt;span class="c"&gt;# Run the unit tests (90)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note: building the Liquid Glass code requires Xcode 26+ (macOS 26 SDK); the app still runs on macOS 13+.&lt;/p&gt;

&lt;h3&gt;
  
  
  Permissions
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Accessibility&lt;/strong&gt; (required) — intercepts the &lt;code&gt;Option-Tab&lt;/code&gt; hotkey, reads window titles, raises windows&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Screen Recording&lt;/strong&gt; (never prompted by default) — only requested if you explicitly enable "Show Window Previews" in the menu&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  How It Works
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Global hotkey interception&lt;/strong&gt; — A CGEvent tap installed at the session level intercepts &lt;code&gt;Option-Tab&lt;/code&gt; system-wide before it reaches any app, without stealing keyboard focus. The tap plumbing only decodes events; the session logic lives in a pure &lt;code&gt;SwitcherStateMachine&lt;/code&gt; struct, unit-testable without synthesizing CGEvents.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Complete window discovery&lt;/strong&gt; — On-screen windows come from &lt;code&gt;CGWindowList&lt;/code&gt;. Everything else (titles, minimized windows, hidden apps, other Spaces) comes from a single AXUIElement pass per application, with a messaging timeout on every element. Fun fact: &lt;code&gt;AXUIElementSetMessagingTimeout&lt;/code&gt; is per-&lt;em&gt;element&lt;/em&gt; — setting it on the app element does nothing for the window elements it returns.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Selectable backgrounds&lt;/strong&gt; — The panel builds one of three root views per style: an opaque &lt;code&gt;NSBox&lt;/code&gt; (Solid), a &lt;code&gt;.hudWindow&lt;/code&gt; &lt;code&gt;NSVisualEffectView&lt;/code&gt; (Transparent — labels become effect-view descendants, so macOS renders them with vibrancy), or an &lt;code&gt;NSGlassEffectView&lt;/code&gt; (Liquid Glass) with the content embedded through its &lt;code&gt;contentView&lt;/code&gt; property, which is the only placement the SDK header guarantees. The persistent scroll view is re-parented between roots only when the preference actually changes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;MRU ordering&lt;/strong&gt; — Windows are sorted most-recently-used first via NSWorkspace notifications plus per-app AXObserver callbacks that catch intra-app switches (like &lt;code&gt;Cmd-`&lt;/code&gt;) — accepted only from the frontmost app, because background apps fire focus-changed notifications too. Explicit activations are pinned so the asynchronous window-raise can't demote the window you just picked, and the pin is superseded the moment a different app or window takes focus.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Focus-anchored selection&lt;/strong&gt; — The list you see is served from a cache and can be one gather stale, so the switcher never assumes slot 0 is the window you are on. At open it determines which window really has focus (a fresh in-flight activation if there is one; otherwise the frontmost app's topmost window via a single WindowServer query, escalating to one bounded Accessibility call only when that window is unknown to the cache) and highlights the first window that &lt;em&gt;isn't&lt;/em&gt; it. Confirm walks the same order past any window that no longer exists. The whole policy — anchor, cycle, reconcile, confirmation order — lives in a pure &lt;code&gt;SwitcherSelection&lt;/code&gt; type with a test table for every stale-cache shape.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Non-activating overlay&lt;/strong&gt; — The switcher is an &lt;code&gt;NSPanel&lt;/code&gt; with &lt;code&gt;.nonactivatingPanel&lt;/code&gt;, so it appears without stealing focus. Releasing &lt;code&gt;Option&lt;/code&gt; activates the target window, not AltTab.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkrvt45fckzhioflvbrin.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkrvt45fckzhioflvbrin.png" alt="AltTab About dialog, version 1.3.0" width="325" height="282"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Two More macOS Gotchas
&lt;/h2&gt;

&lt;p&gt;The 1.3 work surfaced two API behaviors worth knowing about.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;withAlphaComponent()&lt;/code&gt; freezes dynamic colors.&lt;/strong&gt; macOS semantic colors like &lt;code&gt;labelColor&lt;/code&gt; are &lt;em&gt;dynamic&lt;/em&gt; — they resolve differently per appearance, at draw time. But call &lt;code&gt;withAlphaComponent(0.78)&lt;/code&gt; on one and you get back a &lt;strong&gt;static&lt;/strong&gt; color, resolved against whatever appearance happens to be current &lt;em&gt;at that call site&lt;/em&gt;. My unit test caught this before it shipped: the derived color measured 1.21:1 contrast in Dark mode — because it had frozen the &lt;em&gt;Light&lt;/em&gt; variant (black text) and composited it onto a dark background. The fix is &lt;code&gt;NSColor(name:dynamicProvider:)&lt;/code&gt;, which re-derives the color inside each appearance resolution. If you take one thing from this post: contrast assertions in unit tests that resolve live system colors are cheap, and they catch exactly this class of bug.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;#available&lt;/code&gt; gates runtime, not compile time.&lt;/strong&gt; The Liquid Glass code is properly wrapped in &lt;code&gt;#available(macOS 26.0, *)&lt;/code&gt; — and the release build still failed in CI with &lt;code&gt;cannot find 'NSGlassEffectView' in scope&lt;/code&gt;. Locally everything compiled fine. The difference: my Mac has the macOS 26 SDK; the CI runner had Xcode 16 with the macOS 15 SDK, where the &lt;em&gt;symbol doesn't exist at all&lt;/em&gt;. Availability checks assume the symbol is in the SDK you compile against — they only guard which OS executes it. Bumping the CI image to one with Xcode 26 fixed it. If you adopt Tahoe APIs behind &lt;code&gt;#available&lt;/code&gt;, check your CI's SDK before you tag the release.&lt;/p&gt;




&lt;h2&gt;
  
  
  Three More, from 1.3.1
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;[CGWindowID] as CFArray&lt;/code&gt; silently matches nothing.&lt;/strong&gt; &lt;code&gt;CGWindowListCreateDescriptionFromArray&lt;/code&gt; takes a &lt;code&gt;CFArray&lt;/code&gt; of window IDs — and the natural Swift bridge, &lt;code&gt;ids as CFArray&lt;/code&gt;, compiles, runs, and returns an empty array for every live window. The function wants the IDs stored &lt;em&gt;directly as pointer-sized values&lt;/em&gt; with NULL callbacks, not as &lt;code&gt;CFNumber&lt;/code&gt;s. My first liveness check did exactly the wrong thing, which would have turned every confirm into a no-op. A ten-line probe against two real window IDs plus one bogus one caught it before it shipped (and four independent review passes flagged it too). The working form:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;values&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kt"&gt;UnsafeRawPointer&lt;/span&gt;&lt;span class="p"&gt;?]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ids&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;map&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="kt"&gt;UnsafeRawPointer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;bitPattern&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;UInt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$0&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;array&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;CFArrayCreate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;kCFAllocatorDefault&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;values&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;values&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;count&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;&lt;code&gt;kAXFocusedWindowChanged&lt;/code&gt; does not mean "the user focused a window".&lt;/strong&gt; It means "&lt;em&gt;this app's&lt;/em&gt; focused window changed" — and it fires for apps in the background. Chromium- and Electron-based apps churn windows while idle; a Terminal window closing when its job exits moves focus to a sibling. Each event, taken at face value, is a counterfeit "most recently used" entry. Check the element's pid against &lt;code&gt;NSWorkspace.shared.frontmostApplication&lt;/code&gt; before you believe it (&lt;code&gt;AXUIElementGetPid&lt;/code&gt; reads the pid out of the element token — no IPC).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;AXObserverAddNotification&lt;/code&gt; returns an error you must read.&lt;/strong&gt; Before Accessibility is granted it fails with &lt;code&gt;kAXErrorAPIDisabled&lt;/code&gt;; right after an app launches it can fail because the app's AX server isn't accepting registrations yet. If you store the observer anyway — and gate "already installed" on that dictionary — the app stays silently unobserved until you relaunch. Since ad-hoc signing re-prompts for Accessibility on every release, that made the first session after &lt;em&gt;every update&lt;/em&gt; the broken one. Store on &lt;code&gt;.success&lt;/code&gt; only, reinstall when the grant arrives, and let an app's first activation heal anything that slipped through.&lt;/p&gt;




&lt;h2&gt;
  
  
  When the Accessibility Toggle Lies
&lt;/h2&gt;

&lt;p&gt;(From the 1.2.1 investigation, still the best bug of this project.) After updating, &lt;code&gt;Option-Tab&lt;/code&gt; went dead while System Settings showed the Accessibility toggle confidently &lt;strong&gt;ON&lt;/strong&gt;. macOS pins every TCC permission grant to a &lt;em&gt;code signing requirement&lt;/em&gt; — for unsigned apps, essentially the cdhash of one exact build. My DMG script built with &lt;code&gt;CODE_SIGNING_ALLOWED=NO&lt;/code&gt;, which ships an &lt;strong&gt;unsealed&lt;/strong&gt; bundle, and macOS fabricates unpredictable code identities for those — so the recorded grant never matched the running binary. The toggle renders "an allowed entry exists"; enforcement asks "does this binary match the requirement". Different questions.&lt;/p&gt;

&lt;p&gt;Diagnosing it meant reading the &lt;code&gt;csreq&lt;/code&gt; blob out of &lt;code&gt;TCC.db&lt;/code&gt; with &lt;code&gt;sqlite3&lt;/code&gt;, decompiling it with &lt;code&gt;csreq -r -t&lt;/code&gt;, comparing cdhashes with &lt;code&gt;codesign -dvvv&lt;/code&gt;, and confirming with &lt;code&gt;CGGetEventTapList&lt;/code&gt; that no event tap existed. The fix: always apply an ad-hoc seal (&lt;code&gt;codesign --force --deep -s -&lt;/code&gt;) when no signing identity is available. If you ever hit a stuck grant on an unsigned app:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;tccutil reset Accessibility &amp;lt;bundle-id&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;then relaunch and grant again.&lt;/p&gt;




&lt;h2&gt;
  
  
  Project Stats
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;~2,800 lines&lt;/strong&gt; of Swift across 17 source files, plus a unit-tested core (90 tests, &lt;code&gt;swift test&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Zero external dependencies&lt;/strong&gt; — pure Swift + AppKit + ScreenCaptureKit&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;MIT licensed&lt;/strong&gt; — fork it, modify it, ship it&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;macOS 13+&lt;/strong&gt; (Ventura through Tahoe; Liquid Glass on 26+)&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Try It Out
&lt;/h2&gt;

&lt;p&gt;The code is on GitHub: &lt;strong&gt;&lt;a href="https://github.com/sergio-farfan/alttab-macos" rel="noopener noreferrer"&gt;github.com/sergio-farfan/alttab-macos&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If you have been frustrated by macOS window switching, give it a try. And if you are curious about the low-level macOS APIs — CGEvent taps, AXUIElement, ScreenCaptureKit, NSGlassEffectView, or how TCC really decides whether your app is trusted — the codebase is small enough to read in an afternoon.&lt;/p&gt;

&lt;p&gt;Feedback, issues, and PRs are welcome!&lt;/p&gt;

</description>
      <category>swift</category>
      <category>macos</category>
      <category>opensource</category>
      <category>productivity</category>
    </item>
    <item>
      <title>Generate OCI Architecture Diagrams from Terraform with One Claude Code Command</title>
      <dc:creator>Sergio Farfan Cardenete</dc:creator>
      <pubDate>Tue, 17 Mar 2026 01:16:57 +0000</pubDate>
      <link>https://dev.to/sergio_farfn_b071cafc7ed/generate-oci-architecture-diagrams-from-terraform-with-one-claude-code-command-1f4b</link>
      <guid>https://dev.to/sergio_farfn_b071cafc7ed/generate-oci-architecture-diagrams-from-terraform-with-one-claude-code-command-1f4b</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Update (Sep 2026):&lt;/strong&gt; v1.5.0 is out — one diagram model, many views. Six purpose presets — network topology, application / data flow, security, inventory, dependency and high availability — compose detail levels, label modes, draw.io layers and filters behind a single flag, and the new &lt;code&gt;network&lt;/code&gt; label mode renders a resource’s name, private IP and ports from the model’s metadata. Routes, security, IAM and the connector kinds can be emitted as real draw.io layers on request, and filtering by tag, compartment, VCN, subnet, resource type and environment — plus a participating mode that draws only what takes part in the architecture and an optional tenancy-scoped global-services bucket — keeps a large tenancy readable. See "What's new in v1.5.0" below, or jump straight to the &lt;a href="https://github.com/sergio-farfan/OCI-draw.io-Architect/releases/tag/v1.5.0" rel="noopener noreferrer"&gt;release notes&lt;/a&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The problem
&lt;/h2&gt;

&lt;p&gt;Manually drawing OCI diagrams in draw.io is tedious...&lt;br&gt;
If you have ever had to document an OCI architecture, the process is familiar. You open draw.io, locate the correct Oracle icon set, drag shapes onto the canvas, manually wire up VCNs and subnets, nudge elements into alignment — then spend another 30 minutes reconciling colors against Oracle's official template, only to discover that the Terraform configuration changed the previous week and the diagram is already out of date.&lt;/p&gt;

&lt;p&gt;For cloud architects working with OCI, this is a recurring overhead on every project:&lt;/p&gt;
&lt;h2&gt;
  
  
  Diagrams drift from reality
&lt;/h2&gt;

&lt;p&gt;Terraform is the source of truth, but draw.io has no awareness of it. Every infrastructure change requires a manual diagram update — one that typically does not occur until someone requests it during a review.&lt;/p&gt;
&lt;h2&gt;
  
  
  The OCI icon set is not native to draw.io
&lt;/h2&gt;

&lt;p&gt;It must be located, imported, and mapped to the correct services across a dozen categories and 150+ icons — a non-trivial exercise before any actual diagramming begins.&lt;/p&gt;
&lt;h2&gt;
  
  
  Layout is time-consuming
&lt;/h2&gt;

&lt;p&gt;Correctly representing the &lt;code&gt;Region → VCN → Subnet → Service&lt;/code&gt; hierarchy, with proper spacing, non-overlapping containers, and Oracle's color scheme, requires significant effort even for experienced practitioners.&lt;/p&gt;
&lt;h2&gt;
  
  
  Hub-and-spoke topologies are especially difficult
&lt;/h2&gt;

&lt;p&gt;Arranging 10–15 spoke VCNs connected through a DRG in a clean, readable layout is an hour-long exercise in manual positioning.&lt;/p&gt;
&lt;h2&gt;
  
  
  Diagrams are created once and abandoned
&lt;/h2&gt;

&lt;p&gt;Because updates are costly, teams stop maintaining them. By the time a new team member onboards or an audit is conducted, the diagram reflects an architecture from two sprints prior.&lt;/p&gt;



&lt;p&gt;&lt;strong&gt;&lt;em&gt;The root cause is that architecture diagrams are treated as design artifacts — something produced manually — rather than something derived directly from the infrastructure definition.&lt;/em&gt;&lt;/strong&gt;&lt;/p&gt;


&lt;h2&gt;
  
  
  What I built
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/sergio-farfan/OCI-draw.io-Architect" rel="noopener noreferrer"&gt;https://github.com/sergio-farfan/OCI-draw.io-Architect&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A Claude Code plugin — type /drawio-architect in any project...&lt;/p&gt;
&lt;h2&gt;
  
  
  How it works
&lt;/h2&gt;

&lt;p&gt;The plugin accepts a Terraform directory, a &lt;code&gt;terraform show -json&lt;/code&gt; plan or state file, a VCN name resolved against your &lt;code&gt;.tfvars&lt;/code&gt;, or a plain-text description of the target architecture. An experimental mode can also read a live tenancy through the OCI CLI topology API.&lt;/p&gt;

&lt;p&gt;From any of these inputs it builds a small &lt;strong&gt;model&lt;/strong&gt; of the architecture — VCNs, subnets with their tier, the resources in each subnet, regional services, gateways, the on-premises side and the connections — and hands it to a deterministic layout recipe. The recipe places subnets in traffic order, puts the OCI Services panel beside them, stretches the data tier underneath, lines up the gateways, centres the hub on the VCN, and routes every connector through the gutters so lines never cross an icon or a caption. The result is a &lt;code&gt;.drawio&lt;/code&gt; file styled with Oracle's Redwood palette &lt;strong&gt;backed by 159 bundled OCI SVG icons&lt;/strong&gt;, validated for overlaps, containment and collisions before it is written, and rendered to PNG when draw.io desktop is installed.&lt;/p&gt;

&lt;p&gt;The entire workflow is invoked within Claude Code via a single &lt;code&gt;/drawio-architect&lt;/code&gt; command.&lt;/p&gt;
&lt;h2&gt;
  
  
  Step by Step Installation
&lt;/h2&gt;
&lt;h3&gt;
  
  
  Prerequisites
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Claude Code&lt;/strong&gt; (CLI) installed and working&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Python 3.9+&lt;/strong&gt; (standard library only)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;draw.io desktop&lt;/strong&gt; for viewing generated &lt;code&gt;.drawio&lt;/code&gt; files and for the PNG export&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Pillow is optional and only needed to embed PNG logos; SVG logos need nothing.&lt;/p&gt;
&lt;h3&gt;
  
  
  Installation
&lt;/h3&gt;
&lt;h4&gt;
  
  
  Step 1 - Download, Extract and Install
&lt;/h4&gt;

&lt;p&gt;One command:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-fsSL&lt;/span&gt; https://github.com/sergio-farfan/OCI-draw.io-Architect/releases/download/v1.5.0/oci-drawio-architect-v1.5.0.tar.gz | &lt;span class="nb"&gt;tar&lt;/span&gt; &lt;span class="nt"&gt;-xz&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; ./oci-drawio-architect/install.sh

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This will:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Download, extract and execute the installer script&lt;/li&gt;
&lt;li&gt;Check prerequisites (Python 3; tries to install Pillow, but continues without it)&lt;/li&gt;
&lt;li&gt;Create or update the local marketplace at &lt;code&gt;~/.claude/plugins/marketplaces/local/&lt;/code&gt; (other plugins in it are preserved)&lt;/li&gt;
&lt;li&gt;Copy the plugin files into the marketplace&lt;/li&gt;
&lt;li&gt;Verify all components (8 checks, including a smoke test that builds and validates a demo diagram)&lt;/li&gt;
&lt;/ul&gt;

&lt;h4&gt;
  
  
  Step 2 - Register in Claude Code
&lt;/h4&gt;

&lt;p&gt;Open Claude Code and run these two commands:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;/plugin marketplace add ~/.claude/plugins/marketplaces/local&lt;/code&gt;&lt;br&gt;
&lt;code&gt;/plugin install oci-drawio-architect@local&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Exit Claude Code and reopen it for the plugin to load.&lt;/p&gt;
&lt;h4&gt;
  
  
  Step 3 - Verify
&lt;/h4&gt;

&lt;p&gt;Run inside Claude Code:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;/drawio-architect
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The command should prompt you for what to diagram.&lt;/p&gt;

&lt;p&gt;Example Snippet:&lt;br&gt;
&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fpdqpe2sucr46xr68jp8g.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fpdqpe2sucr46xr68jp8g.png" alt=" " width="559" height="566"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  What's new in v1.5.0
&lt;/h2&gt;

&lt;p&gt;v1.4.0 settled &lt;em&gt;where a thing goes&lt;/em&gt;; v1.5.0 answers &lt;em&gt;what goes in, and how much of it&lt;/em&gt; — one discovered topology, several readable views. The controls implement the team's diagram guidelines — the sections on filters, connector semantics, label modes and layers — and reviewer feedback that a full-detail hybrid diagram is unreadable as a management artefact.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8dktim3g96o5bdvjk7cq.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8dktim3g96o5bdvjk7cq.png" alt="The reference diagram as v1.5.0 renders it: captions rendered from the model's metadata, the load balancer carrying HTTPS/443 and the Autonomous Database 1522" width="800" height="599"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One Terraform stack, six diagrams.&lt;/strong&gt; &lt;code&gt;--purpose&lt;/code&gt; names the view — network topology, application / data flow, security architecture, resource / inventory, dependency / relationship, deployment / high availability — the six the guidelines list. Each composes a detail level, a label mode, a layer set and a mode; any explicit flag still beats the preset.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;An executive-to-engineering detail ladder.&lt;/strong&gt; &lt;code&gt;--detail executive|application|network|engineering&lt;/code&gt; gates content and sets the rest of the view's defaults. &lt;code&gt;network&lt;/code&gt; is today's output; &lt;code&gt;executive&lt;/code&gt; drops the badges, the CIDRs and the connector labels and reduces captions to names; &lt;code&gt;engineering&lt;/code&gt; shows everything. One model, both the one-page overview and the engineering drawing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Captions are a rendered field list.&lt;/strong&gt; &lt;code&gt;--label-mode minimal|network|detailed&lt;/code&gt; follows the guidelines' label-mode table over a documented vocabulary: name, type, private and public IP, CIDR, FQDN, port / protocol, compartment, AD / FD, lifecycle and tags. An OCID is never rendered in a caption, in any mode.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Layers you toggle in draw.io.&lt;/strong&gt; With &lt;code&gt;--layers auto&lt;/code&gt;, route, security, IAM, data-flow, management and association content goes onto real draw.io layers above a base layer named &lt;code&gt;Network&lt;/code&gt;, switched in the layer panel (&lt;code&gt;Cmd/Ctrl+Shift+L&lt;/code&gt;) without regenerating the file. The pass only re-parents cells, so enabling it cannot move a pixel.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Filters for large tenancies.&lt;/strong&gt; One predicate over tags, compartment, region, VCN, subnet, resource type, environment and application, applied identically by the parser, the live-tenancy reader and the layout CLI. &lt;code&gt;--mode participating&lt;/code&gt; draws only what takes part in the architecture — the live-tenancy default; Terraform stays on &lt;code&gt;all&lt;/code&gt; — and &lt;code&gt;--global-services bucket&lt;/code&gt; moves IAM, Policies, Audit and public DNS into a tenancy-scoped box below the region.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What changes if you change nothing&lt;/strong&gt; is the caption of a &lt;em&gt;parsed&lt;/em&gt; model: the shape line moves into the metadata, and a private IP and a port list appear when the input has them. A hand-written label is untouched, &lt;code&gt;--label-mode minimal --label-fields display_name,shape&lt;/code&gt; brings the 1.4 caption back exactly, the geometry is identical, and the schema stays at 2.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One caveat:&lt;/strong&gt; a purpose's hidden layers only take effect with &lt;code&gt;--layers auto&lt;/code&gt; (or an explicit list). Layers are off by default, and with them off a "hidden" layer's content is simply not drawn rather than emitted and hidden.&lt;/p&gt;

&lt;p&gt;Full release notes: &lt;a href="https://github.com/sergio-farfan/OCI-draw.io-Architect/releases/tag/v1.5.0" rel="noopener noreferrer"&gt;https://github.com/sergio-farfan/OCI-draw.io-Architect/releases/tag/v1.5.0&lt;/a&gt;&lt;/p&gt;


&lt;h2&gt;
  
  
  What's new in v1.4.0
&lt;/h2&gt;

&lt;p&gt;v1.3.0 made placement topology-aware inside the region box; v1.4.0 takes it outside. Everything here is about &lt;em&gt;where a thing goes&lt;/em&gt;, and the rules come from Oracle's Architecture Diagram Toolkit deck and its published reference architectures, read alongside the team's diagram guidelines and reviewer feedback.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2qnh192nuqc3oo9kslxt.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2qnh192nuqc3oo9kslxt.png" alt="The reference diagram as v1.4.0 renders it: On-Premises left of the OCI Region, Internet and 3rd Party Cloud stacked on its right, Internet and NAT gateways on the VCN border facing the Internet box, and the Oracle Services Network as a band below the VCN stack" width="800" height="599"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The location boxes leave the region.&lt;/strong&gt; On-Premises, Internet and 3rd Party Cloud are page-level siblings of the OCI Region — On-Premises left, Internet and 3rd Party stacked right — the arrangement Oracle's Location Canvas defines. The &lt;code&gt;Site-to-Site VPN&lt;/code&gt; / &lt;code&gt;FastConnect&lt;/code&gt; / &lt;code&gt;Remote Peering&lt;/code&gt; label sits in the gap between the on-premises box and the region, and a CPE or virtual circuit straddles that box's region-facing border the way a gateway straddles a VCN border. For a hybrid architecture the whole chain — CPE, hybrid link, attachment, DRG, VCN — now reads across the page.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Gateways face what they connect to.&lt;/strong&gt; The Internet Gateway and the NAT Gateway take the VCN border facing the Internet box: the right border for the rightmost VCN column, the top border with captions above the glyphs for every other column, since a left-hand column's right border faces the next VCN. Each Internet Gateway gets an attachment connector to the Internet box, mirroring the Service Gateway's to the Oracle Services Network. Within a border the order is always IGW, NAT, Service Gateway, LPG — never model order.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Oracle Services Network is a band below the VCN stack&lt;/strong&gt;, fed by the Service Gateway on the VCN's bottom border — the right-hand column is where the Internet box went, and a services panel there would have every Internet-bound connector crossing it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Compartments and grouping boxes become drawable.&lt;/strong&gt; A compartment can wrap the VCNs it holds, nested, with an optional tenancy wrapper — opt-in, because a view with every compartment drawn is unreadable. Grouping boxes cover an OKE cluster inside a subnet (the Terraform parser emits one when a cluster and its node pools share a subnet) plus Oracle's tier and user-group boxes. A box is a real container: members are re-parented into it, the router avoids it, and a connector may terminate on it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Smaller things:&lt;/strong&gt; the DRG carries up to two route-table badges — the pair Oracle creates by default — subnet labels are two lines with a &lt;code&gt;(Public)&lt;/code&gt; / &lt;code&gt;(Private)&lt;/code&gt; token, the legend gains a row per badge kind drawn, and the attachment connector can be solid or dotted.&lt;/p&gt;

&lt;p&gt;The old canvas is one flag away: &lt;code&gt;--locations nested&lt;/code&gt; restores the 1.3.x layout exactly. Five new flags on &lt;code&gt;oci_layout.py&lt;/code&gt; — &lt;code&gt;--locations&lt;/code&gt;, &lt;code&gt;--gateway-edge&lt;/code&gt;, &lt;code&gt;--subnet-label&lt;/code&gt;, &lt;code&gt;--attachment-style&lt;/code&gt; and &lt;code&gt;--show-compartments&lt;/code&gt; — have matching model keys. The schema stays at 2, new keys are optional, and a 1.3.x model renders unchanged apart from the two default flips.&lt;/p&gt;

&lt;p&gt;Full release notes: &lt;a href="https://github.com/sergio-farfan/OCI-draw.io-Architect/releases/tag/v1.4.0" rel="noopener noreferrer"&gt;https://github.com/sergio-farfan/OCI-draw.io-Architect/releases/tag/v1.4.0&lt;/a&gt;&lt;/p&gt;


&lt;h2&gt;
  
  
  What's new in v1.3.0
&lt;/h2&gt;

&lt;p&gt;v1.2.0 made the layout deterministic; v1.3.0 makes it topology-aware, which matters most if what you document is hub-and-spoke or hybrid. The placement rules come from Oracle's Architecture Diagram Toolkit deck and Oracle's published reference architectures, read alongside the team's diagram guidelines and reviewer feedback on what the plugin was producing.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fe3u8xfmn5n44e965dh3w.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fe3u8xfmn5n44e965dh3w.png" alt="The reference diagram as v1.3.0 renders it: a region-level DRG with its VCN attachment, gateways on the VCN border, the Oracle Services Network panel, and NSG shields on the protected resources" width="799" height="467"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The DRG is regional connectivity infrastructure&lt;/strong&gt;, so it is drawn outside every VCN, centred on the VCN stack, with one labelled box per attachment beside it — VCN attachments facing the VCNs, Site-to-Site VPN, FastConnect and Remote Peering attachments facing the on-premises side. Before, the DRG arrived as an item in the on-premises panel or as a gateway icon inside the VCN; now the chain VCN — attachment — DRG — remote network is explicit on the canvas.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Gateways sit on the VCN border.&lt;/strong&gt; Internet and NAT gateways straddle the bottom edge, the Service Gateway the right edge facing the services, and a Local Peering Gateway the edge facing its peer VCN.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Regional services share one Oracle Services Network panel.&lt;/strong&gt; Logging, Vault, Notifications, IAM, Object Storage, Generative AI and the like are region-level rather than VCN-resident, so they are drawn once, right of the VCN columns, and reached through the Service Gateway.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Connector style carries meaning:&lt;/strong&gt; a solid arrow is application or data flow, a dashed arrow is management traffic, a dotted line is an association or dependency, and a plain line without an arrowhead is a structural attachment. The legend names all four.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Route tables, security lists and NSGs are badges, not icons.&lt;/strong&gt; A subnet carries its route table and security lists as small badges on its top-right corner, and an NSG is a shield on the resource it protects, with the names in the tooltip.&lt;/p&gt;

&lt;p&gt;A classifier picks the presentation from the model — single VCN, multi-VCN, VCN with a DRG, hub-and-spoke, hybrid — and &lt;code&gt;--drg-style icon|box&lt;/code&gt; overrides it. The validator refuses a DRG drawn inside a VCN, and any icon sitting in a VCN or subnet it does not belong to. Models written for 1.2.0 still build; they are migrated to schema 2 with a warning.&lt;/p&gt;

&lt;p&gt;One thing v1.3.0 deliberately does not do is use draw.io's MCP connector: draw.io's built-in shape libraries carry no OCI icons, local generation keeps diagram content — VCN names, CIDRs, OCIDs — on your machine, and the connector's inline preview needs an MCP Apps host, which Claude Code is not.&lt;/p&gt;

&lt;p&gt;Full release notes: &lt;a href="https://github.com/sergio-farfan/OCI-draw.io-Architect/releases/tag/v1.3.0" rel="noopener noreferrer"&gt;https://github.com/sergio-farfan/OCI-draw.io-Architect/releases/tag/v1.3.0&lt;/a&gt;&lt;/p&gt;


&lt;h2&gt;
  
  
  What's new in v1.2.0
&lt;/h2&gt;

&lt;p&gt;v1.1.0 fixed rendering fidelity; v1.2.0 fixes consistency. I went back to the diagram I had published as the reference and asked why newly generated diagrams kept drifting from it. The answer was a mix of tooling bugs and under-specified instructions, and v1.2.0 addresses both.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6u2y5umwq9d4i9hvgul6.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6u2y5umwq9d4i9hvgul6.png" alt="The reference diagram as v1.2.0 renders it: uniform icons, left-aligned labels, connectors routed through the gutters" width="800" height="590"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Diagrams are data now.&lt;/strong&gt; Instead of asking Claude to compute coordinates, the command fills in a model dict — subnets with a tier, the icons in each, the services panel, the gateways, the hub, the edges — and &lt;code&gt;oci_layout.py&lt;/code&gt; lays it out with the same recipe every time. The reference diagram itself is now just a model (&lt;code&gt;examples/generate_reference_layout.py&lt;/code&gt;), and it rebuilds pixel-for-pixel in the same structure.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;sys&lt;/span&gt;
&lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;insert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/path/to/oci-drawio-architect/scripts&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;oci_layout&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;write_diagram&lt;/span&gt;

&lt;span class="n"&gt;MODEL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;subject&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;app-prod&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;region&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;eu-frankfurt-1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;region_label&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Frankfurt&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;vcns&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;app-vcn&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cidr&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;10.0.0.0/16&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;subnets&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sn-lb&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cidr&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;10.0.0.0/24&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tier&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;lb&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
         &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;items&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;icon&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;load_balancer&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;label&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Load Balancer&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s"&gt;10.0.0.7&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;address&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;lb&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}]},&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sn-app&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cidr&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;10.0.1.0/24&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tier&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;app&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
         &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;items&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;icon&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;vm&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;label&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;App VM&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s"&gt;10.0.1.5&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s"&gt;4 OCPU / 32 GB&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;address&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;app&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}]},&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sn-db&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cidr&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;10.0.2.0/24&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tier&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;data&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
         &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;items&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;icon&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;autonomous_db&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;label&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ADB prod&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s"&gt;app-db&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;address&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;adb&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}]}],&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;gateways&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;icon&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;service_gateway&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;label&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Service&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s"&gt;Gateway&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;address&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sgw&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}]}],&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;edges&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;source&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;lb&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;target&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;app&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;label&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;8080&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;kind&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;data&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
              &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;source&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;app&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;target&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;adb&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;label&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;1522&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;kind&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;data&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}],&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nf"&gt;write_diagram&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;MODEL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;app-prod_Architecture.drawio&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;render_fmt&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;png&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Terraform in, model out.&lt;/strong&gt; &lt;code&gt;parse_terraform.py&lt;/code&gt; reads HCL (with &lt;code&gt;var.&lt;/code&gt; and &lt;code&gt;local.&lt;/code&gt; resolution, &lt;code&gt;cidrsubnet()&lt;/code&gt; included) or &lt;code&gt;terraform show -json&lt;/code&gt; plan and state files and produces that model, so &lt;code&gt;count&lt;/code&gt;, &lt;code&gt;for_each&lt;/code&gt; and computed CIDRs are handled by Terraform rather than by regexes. An experimental &lt;code&gt;query_tenancy.py&lt;/code&gt; does the same from a live tenancy via &lt;code&gt;oci network vcn-topology get&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Connectors that route themselves.&lt;/strong&gt; v1.1.0 handed edges to draw.io's orthogonal router, which does not avoid anything. v1.2.0 routes each connector on a lattice of container margins and gutters, penalising icons, captions, container titles and foreign containers, spreading parallel edges across corridors and placing labels where they collide with nothing. On the reference diagram the estimated crossings went from six (my hand-routed original) to zero. &lt;code&gt;route="direct"&lt;/code&gt; and pinned ports are still there when you want them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Icons finally look alike.&lt;/strong&gt; Sixteen of the most common icons — VM, Functions, Block Volume, Object Storage among them — carried a leftover caption-placeholder rectangle from the original stencil conversion. It drew a faint box under the glyph and made those glyphs about a third smaller than their neighbours. The rectangle is gone, every viewBox is cropped to the glyph, and each glyph is fitted into the same 70×70 box. Fifty-five empty stencil shells that rendered as invisible cells were removed; the set is now 159 real icons, every one addressable by its file name or one of 206 short aliases.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The look you expect, with an official escape hatch.&lt;/strong&gt; Region and compartment labels are back at the top-left, the services panel is charcoal again, dashed connectors have a proper dash pattern, and every style carries a font stack so machines without Oracle Sans get a sans-serif face instead of Times. Three profiles: &lt;code&gt;default&lt;/code&gt; (the reference look with Oracle's 12 px labels), &lt;code&gt;official&lt;/code&gt; (strict toolkit values: 1 pt sharp connectors, open arrowheads) and &lt;code&gt;v1.0&lt;/code&gt; (byte-for-byte the old sample).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A validator instead of an overlap checker.&lt;/strong&gt; &lt;code&gt;check_overlaps.py&lt;/code&gt; now reports overlaps between any two containers, shapes that leave their parent, icon and caption collisions, unknown parent or endpoint ids (which used to make draw.io silently drop the whole diagram), captions that need more lines than they have, and estimated edge crossings. Exit 0 means clean; &lt;code&gt;--strict&lt;/code&gt; makes crossings blocking. &lt;code&gt;render_drawio.py&lt;/code&gt; exports a PNG through draw.io desktop so the command can look at its own output before reporting.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Bugs you might have hit.&lt;/strong&gt; Copying &lt;code&gt;drawio_builder.py&lt;/code&gt; into a project, as the old docs said to do, broke icon lookup — the builder now finds its icons from the plugin location and the docs say to import instead. &lt;code&gt;region = var.region&lt;/code&gt; is resolved. &lt;code&gt;eu-london-1&lt;/code&gt; never existed; London is &lt;code&gt;uk-london-1&lt;/code&gt;. The installer no longer dies when &lt;code&gt;pip&lt;/code&gt; is missing, no longer rewrites other plugins' marketplace entries, and falls back to &lt;code&gt;cp&lt;/code&gt; when &lt;code&gt;rsync&lt;/code&gt; is absent. The settings file is really gitignored now.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Also:&lt;/strong&gt; legends, HTML rule tables, multi-page files, deterministic cell ids (&lt;code&gt;key=&lt;/code&gt;), metadata/tooltips/links, a reproducible &lt;code&gt;pack.sh&lt;/code&gt;, an Oracle attribution notice for the icons, and 954 unit tests.&lt;/p&gt;

&lt;h3&gt;
  
  
  Compatibility
&lt;/h3&gt;

&lt;p&gt;v1.0 and v1.1 scripts keep running: &lt;code&gt;check_overlaps()&lt;/code&gt;, pinned ports, waypoints and the &lt;code&gt;hub&lt;/code&gt; alias are all preserved, and &lt;code&gt;write()&lt;/code&gt; now also accepts plain strings. Regenerated diagrams will look different — uniform icons, left-aligned location labels, routed edges — which is the point. The one thing to change is the import: point &lt;code&gt;sys.path&lt;/code&gt; at the plugin's &lt;code&gt;scripts/&lt;/code&gt; directory instead of copying the builder.&lt;/p&gt;

&lt;p&gt;Full release notes: &lt;a href="https://github.com/sergio-farfan/OCI-draw.io-Architect/releases/tag/v1.2.0" rel="noopener noreferrer"&gt;https://github.com/sergio-farfan/OCI-draw.io-Architect/releases/tag/v1.2.0&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  What's new in v1.1.0 (July 2026)
&lt;/h2&gt;

&lt;p&gt;I've kept using this plugin since I posted this, and v1.1.0 is a big step up in rendering fidelity and workflow safety. Here's what changed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Aspect-correct icons.&lt;/strong&gt; v1.0.0 forced every icon into a fixed 75x95 slot, which stretched non-square icons by 15-20% on one axis. v1.1.0 reads each SVG's native aspect ratio and derives the cell size from it — fixed 95px height, width computed from the icon's own proportions (or the other way around if you only pass &lt;code&gt;w&lt;/code&gt; or &lt;code&gt;h&lt;/code&gt; to &lt;code&gt;add_icon()&lt;/code&gt;). Icons render true to their actual shape now.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Styles aligned to Oracle's official v24.2 toolkit, plus 5 new container types.&lt;/strong&gt; The color palette and every container style are now extracted directly from Oracle's official OCI Architecture Diagram Toolkit (v24.2): dotted-Sienna compartments with Bark labels, a &lt;code&gt;#9E9892&lt;/code&gt; 2pt services-panel border, centered region labels, plain dashed connectors. Five new group types fill out the container vocabulary: &lt;code&gt;tenancy&lt;/code&gt;, &lt;code&gt;availability_domain&lt;/code&gt;, &lt;code&gt;fault_domain&lt;/code&gt;, &lt;code&gt;oracle_services_network&lt;/code&gt;, and &lt;code&gt;onprem&lt;/code&gt; (the old &lt;code&gt;hub&lt;/code&gt; type still works — it's now a deprecated alias of &lt;code&gt;onprem&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Orthogonal edge routing by default.&lt;/strong&gt; Port-less &lt;code&gt;add_edge()&lt;/code&gt; calls now hand routing to draw.io's own orthogonal router instead of pinning a fixed exit/entry side. You get cleaner, auto-routed connectors without touching your script.&lt;/p&gt;

&lt;p&gt;Before (v1.0.0, pinned ports on every edge):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add_edge&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lb&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;vm&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;443&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;parent&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;vcn&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
           &lt;span class="n"&gt;exit_x&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;1.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;exit_y&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;0.5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;entry_x&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;0.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;entry_y&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;0.5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After (v1.1.0, default — let draw.io route it):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add_edge&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lb&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;vm&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;443&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;parent&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;vcn&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Scripts that still pass &lt;code&gt;exit_x&lt;/code&gt;/&lt;code&gt;exit_y&lt;/code&gt;/&lt;code&gt;entry_x&lt;/code&gt;/&lt;code&gt;entry_y&lt;/code&gt; or &lt;code&gt;waypoints&lt;/code&gt; keep their exact v1.0.0 pinned behavior — nothing breaks.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A shipped overlap checker, now a workflow gate.&lt;/strong&gt; &lt;code&gt;scripts/check_overlaps.py&lt;/code&gt; is a standalone CLI (exit &lt;code&gt;0&lt;/code&gt; clean, &lt;code&gt;1&lt;/code&gt; overlaps found, &lt;code&gt;2&lt;/code&gt; usage/parse error), backed by &lt;code&gt;DrawioBuilder.check_overlaps()&lt;/code&gt;. The &lt;code&gt;/drawio-architect&lt;/code&gt; command now runs it as a mandatory gate before handing you the &lt;code&gt;.drawio&lt;/code&gt; file, so overlapping containers get caught before you open draw.io, not after.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Metadata and tooltips.&lt;/strong&gt; &lt;code&gt;add_icon()&lt;/code&gt; and &lt;code&gt;add_group()&lt;/code&gt; take optional &lt;code&gt;metadata=&lt;/code&gt; and &lt;code&gt;tooltip=&lt;/code&gt; arguments now, stored as draw.io &lt;code&gt;&amp;lt;object&amp;gt;&lt;/code&gt; attributes. Hover a shape in draw.io to see the tooltip, or open Edit Data to see the metadata — useful for attaching OCIDs, workload tags, or anything else you want to keep with a shape.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;detect_settings&lt;/code&gt; hardening.&lt;/strong&gt; The Terraform-directory and tenancy-detection logic got a pass: the tenancy-OCID regex is now bounded (no more cross-variable false matches), Terraform-directory discovery is deterministic, OCI CLI JSON handling is more robust, and there's an optional plugin-local &lt;code&gt;logos/&lt;/code&gt; directory for embedding your own logo in a diagram.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A real demo.&lt;/strong&gt; &lt;code&gt;examples/generate_demo_diagram.py&lt;/code&gt; builds a full sample architecture — tenancy → on-prem + region → availability/fault domains, compartment → VCN → subnets, a services panel, an Oracle Services Network panel — exercising every container type, the main icon-sizing and edge modes, metadata/tooltips, and the overlap gate in one script. It doubles as a post-install smoke test.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Python 3.9 is now the floor&lt;/strong&gt; (was 3.8).&lt;/p&gt;

&lt;h3&gt;
  
  
  Compatibility
&lt;/h3&gt;

&lt;p&gt;Existing v1.0.0 scripts run unchanged — positional arguments, keyword ports plus waypoints, and the &lt;code&gt;hub&lt;/code&gt; group type are all preserved. Regenerated diagrams will pick up the refreshed v24.2 styling and aspect-correct icons automatically.&lt;/p&gt;

&lt;p&gt;Full release notes: &lt;a href="https://github.com/sergio-farfan/OCI-draw.io-Architect/releases/tag/v1.1.0" rel="noopener noreferrer"&gt;https://github.com/sergio-farfan/OCI-draw.io-Architect/releases/tag/v1.1.0&lt;/a&gt;&lt;/p&gt;

</description>
      <category>claudecode</category>
      <category>oci</category>
      <category>terraform</category>
      <category>devops</category>
    </item>
  </channel>
</rss>
