<?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.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Updated July 2026:&lt;/em&gt; five releases have shipped since this was written. The feature list below is current, and Five Releases Later at the end covers what changed — per-repo auto-fetch, repo groups, a ⌘K palette, a diff view with hunk staging, an in-window command runner, and more.&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%2Fspke1tby7vk54w8nl3vy.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%2Fspke1tby7vk54w8nl3vy.png" alt="RepoDeck dashboard" width="800" height="428"&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 any set of folders; RepoDeck recursively discovers every git repo underneath them&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Live status via FSEvents&lt;/strong&gt; — no polling, no timers; the sidebar badge updates the moment a file changes on disk&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; across every tracked repo at once&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Diff view with hunk staging&lt;/strong&gt; — unified diffs for files and commits in a side inspector; stage or unstage one hunk at a time&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Per-repo auto-fetch&lt;/strong&gt; — each repo on its own interval, on a background lane that never delays anything you do yourself&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&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Undo for pull and auto-rebase&lt;/strong&gt; — HEAD snapshotted to a ref first; restore with &lt;code&gt;git reset --keep&lt;/code&gt;, never &lt;code&gt;--hard&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Repo groups, pinning, 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;, &lt;strong&gt;GitHub PR/CI badges&lt;/strong&gt; (via &lt;code&gt;gh&lt;/code&gt;, entirely optional), 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; — a docked pane running shell commands in the repo's directory, output streamed live&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;History search&lt;/strong&gt; by commit message, author, file path, or content (git's pickaxe search)&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.&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 means every operation inherits the user's actual &lt;code&gt;~/.gitconfig&lt;/code&gt; — credential helpers, aliases, hooks, SSH config, everything — for free. It's the same reason VS Code's Source Control panel, GitHub Desktop, and &lt;code&gt;lazygit&lt;/code&gt; all do the same thing: reimplementing what real git already does correctly, and what your credential helper already knows how to do, is a losing trade.&lt;/p&gt;

&lt;p&gt;The package builds two targets: &lt;code&gt;RepoDeckKit&lt;/code&gt; — every bit of git, parsing, and filesystem-watching logic, with no SwiftUI imports — and &lt;code&gt;RepoDeck&lt;/code&gt;, the app target, which is just views and view models wired to it. Nothing in &lt;code&gt;RepoDeckKit&lt;/code&gt; touches a window or the screen; every git call and every filesystem event passes through a seam a test can drive directly. That split is why the code below is unit- and integration-testable at all.&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="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="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;Every git invocation in the app funnels through a single function: async, deadlock-free, cancellable, and globally bounded. Two rules made that worth centralizing instead of calling &lt;code&gt;Process&lt;/code&gt; ad hoc wherever a view model needed git.&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 &lt;code&gt;git status&lt;/code&gt; output gets big enough to fill the pipe. &lt;code&gt;ProcessRunner&lt;/code&gt; drains stdout and stderr concurrently, in a loop, while the process is still running:&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;async&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;stderrData&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;drainAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;stderrStream&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;stdoutData&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;Data&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;truncated&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;chunk&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="n"&gt;stdoutStream&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;stdoutData&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="n"&gt;chunk&lt;/span&gt;&lt;span class="p"&gt;)&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;cap&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;maxOutputBytes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;stdoutData&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="n"&gt;cap&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;truncated&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
        &lt;span class="n"&gt;process&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;terminate&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="k"&gt;break&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;Only after both streams are fully drained does it wait for the actual process exit. The output cap lives in the same loop — once it's hit, the process is terminated on the spot rather than left to keep filling a pipe nobody will finish reading.&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;Every subprocess runs with &lt;code&gt;GIT_TERMINAL_PROMPT=0&lt;/code&gt; so a missing credential fails fast with a real error instead of git silently hanging on a terminal prompt that will never appear in a GUI app:&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;And because bulk operations (Fetch All, Pull All) can fire dozens of git processes at once, a process-wide counting semaphore 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;Every &lt;code&gt;ProcessRunner.run&lt;/code&gt; call acquires a slot before launching and releases it on every exit path — success, failure, or cancellation.&lt;/p&gt;

&lt;p&gt;(Since 1.3.0 that semaphore is two-tier: background work — auto-fetch, PR/CI polling — can hold at most 4 of the 6 slots, and interactive work you triggered jumps ahead of queued background work. Network calls also carry a &lt;code&gt;SIGTERM&lt;/code&gt;-then-&lt;code&gt;SIGKILL&lt;/code&gt; watchdog timeout, so a hung remote can't pin a slot forever.)&lt;/p&gt;

&lt;h3&gt;
  
  
  Cancellation kills the child, not just the &lt;code&gt;Task&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;Cancelling the surrounding Swift &lt;code&gt;Task&lt;/code&gt; — closing a repo, navigating away mid-fetch — has to actually kill the git process, not just stop awaiting it and leave it running orphaned in the background:&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="p"&gt;}&lt;/span&gt; &lt;span class="nv"&gt;onCancel&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;kill&lt;/span&gt;&lt;span class="p"&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;Process&lt;/code&gt; itself isn't &lt;code&gt;Sendable&lt;/code&gt;, so only the &lt;code&gt;pid&lt;/code&gt; — captured before entering the cancellation handler — is allowed to cross into that &lt;code&gt;@Sendable&lt;/code&gt; closure. The drain loops then end naturally once the killed child closes its pipes.&lt;/p&gt;




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

&lt;p&gt;RepoDeck doesn't poll. &lt;code&gt;RepoWatcher&lt;/code&gt; wraps the FSEvents C API directly and emits debounced events on an &lt;code&gt;AsyncStream&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Ignoring &lt;code&gt;index.lock&lt;/code&gt; churn
&lt;/h3&gt;

&lt;p&gt;Git itself writes and deletes &lt;code&gt;.git/index.lock&lt;/code&gt; constantly during normal operations — every &lt;code&gt;git add&lt;/code&gt;, every &lt;code&gt;git commit&lt;/code&gt; touches it. Without filtering that out, RepoDeck would refresh its own view of a repo's status in response to changes &lt;em&gt;caused by its own git calls&lt;/em&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;static&lt;/span&gt; &lt;span class="kd"&gt;func&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;_&lt;/span&gt; &lt;span class="nv"&gt;rawPath&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="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="kt"&gt;Bool&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;components&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;rawPath&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;separator&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"/"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;omittingEmptySubsequences&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="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="kd"&gt;init&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;components&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;last&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="s"&gt;"index.lock"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;components&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;".git"&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;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="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;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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 FSEvents callbacks in a fraction of a second. &lt;code&gt;RepoWatcher&lt;/code&gt; collapses a burst for the same repo into a single emission, 300ms after the last event in the burst — cancel-and-reschedule, not a recurring 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;There's no timer running in the background polling anything. The app is silent until FSEvents says something changed, and even then it waits out the burst before doing a single &lt;code&gt;git status&lt;/code&gt;.&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;&lt;code&gt;ProcessRunner&lt;/code&gt;'s output cap protects 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 sends &lt;code&gt;SIGTERM&lt;/code&gt; to the child and returns whatever was read, flagged as truncated. That's correct, and the unit tests for &lt;code&gt;ProcessRunner&lt;/code&gt; confirmed it.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;GitClient.status&lt;/code&gt; passes 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. Also correct, also unit tested.&lt;/p&gt;

&lt;p&gt;Here's the seam: &lt;code&gt;SIGTERM&lt;/code&gt; makes the process's exit code come back as 15, not 0. And the shared helper that every &lt;code&gt;GitClient&lt;/code&gt; method funneled through had one guard for all of them:&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;A truncated status never reached &lt;code&gt;PorcelainParser&lt;/code&gt; at all — it was thrown as a generic &lt;code&gt;GitError&lt;/code&gt; before the parser ever saw the bytes. Which meant the "Too many changes — showing a partial list" banner in the Changes list, built and unit tested against a &lt;code&gt;RepoStatus&lt;/code&gt; with &lt;code&gt;didHitLimit == true&lt;/code&gt;, was &lt;strong&gt;unreachable&lt;/strong&gt; in the running app. Every real truncation just looked like git had failed outright.&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 a real &lt;code&gt;SIGTERM&lt;/code&gt;, a real exit code 15, 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 widening one guard: &lt;code&gt;result.exitCode == 0 || result.outputTruncated&lt;/code&gt;. Only &lt;code&gt;status&lt;/code&gt; ever passes an output cap, so no other command's real failures get masked. To lock the seam shut for good, the status output cap became an injectable public property (4 MB by default) so a test could shrink it to 64 bytes against a real temporary repo with real files on disk — forcing an actual truncation, actual &lt;code&gt;SIGTERM&lt;/code&gt;, actual exit 15, without generating megabytes of fixture data:&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;64&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;/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;
  
  
  Five Releases Later
&lt;/h2&gt;

&lt;p&gt;Since launch, RepoDeck has gone from a status dashboard to something closer to a daily driver: &lt;strong&gt;1.3.0&lt;/strong&gt; added a per-repo settings sheet, per-repo auto-fetch intervals, sidebar groups, a ⌘K command palette, and opt-in auto-rebase on rejected pushes; &lt;strong&gt;1.4.0&lt;/strong&gt; added one-level undo for pulls, stash support, GitHub PR/CI badges (via the &lt;code&gt;gh&lt;/code&gt; CLI — the app's only non-git subprocess, and entirely optional), and a menu-bar mode; &lt;strong&gt;1.5.0&lt;/strong&gt; brought a unified diff view; &lt;strong&gt;1.6.0&lt;/strong&gt; an in-window command runner; and &lt;strong&gt;1.7.0&lt;/strong&gt; finished the diff view with per-hunk staging.&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;
  
  
  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;. It's ad-hoc signed, not notarized, so the first launch needs one manual nudge past Gatekeeper — right-click &lt;strong&gt;RepoDeck.app → Open&lt;/strong&gt;, or:&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/RepoDeck.app
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;From source&lt;/strong&gt; — Swift Package Manager only, 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 git@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;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%2Ft47ow2zgmh97aypb2sg9.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%2Ft47ow2zgmh97aypb2sg9.png" alt="NoSleep menu bar dropdown" width="242" height="401"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&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 a one-tap &lt;strong&gt;Extend 1 hour&lt;/strong&gt; when a timed session ends. 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;Download &amp;amp; run&lt;/strong&gt; — 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;)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Completion notification&lt;/strong&gt; — when a timed session ends, a notification offers a one-tap &lt;strong&gt;Extend 1 hour&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Start at Login&lt;/strong&gt; — optional LaunchAgent so it auto-starts on boot&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;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;p&gt;Rather than using &lt;code&gt;SMAppService&lt;/code&gt; (which requires a sandboxed app), NoSleep writes 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;
  
  
  Build &amp;amp; Install
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Easiest:&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. On first launch, run the &lt;code&gt;xattr&lt;/code&gt; command above (or right-click → Open) once.&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, 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;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
&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 on first launch: right-click &lt;strong&gt;AltTab.app → Open&lt;/strong&gt;, or&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;
  
  
  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 (32)&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;). Explicit activations are pinned so the asynchronous window-raise can't demote the window you just picked.&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;
  
  
  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,100 lines&lt;/strong&gt; of Swift across 13 source files, plus a unit-tested core (32 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 (Jul 2026):&lt;/strong&gt; v1.1.0 is out — aspect-correct icons, the official Oracle v24.2 palette and container styles, orthogonal edge routing by default, and a shipped overlap checker. Existing scripts keep running unchanged. See "What's new in v1.1.0" below, or jump straight to the &lt;a href="https://github.com/sergio-farfan/OCI-draw.io-Architect/releases/tag/v1.1.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 15 categories and 220+ 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 three input types: a Terraform directory path — parsed to extract VCNs, subnets, gateways, and DRG attachments — a VCN name resolved against existing &lt;code&gt;.tfvars&lt;/code&gt; files, or a plain-text description of the target architecture.&lt;/p&gt;

&lt;p&gt;From any of these inputs, it computes a pixel-precise grid layout, calculating container bounding boxes to eliminate element overlap, and generates a Python script leveraging a custom &lt;code&gt;DrawioBuilder&lt;/code&gt; class &lt;strong&gt;backed by 220 bundled OCI SVG icons&lt;/strong&gt;. Executing the script produces a &lt;code&gt;.drawio&lt;/code&gt; file fully styled with Oracle's official color palette.&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;&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&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The plugin bundles 220 OCI SVG icons and auto-installs Pillow (Python imaging library) if missing.&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.1.0/oci-drawio-architect-v1.1.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, Pillow)&lt;/li&gt;
&lt;li&gt;Install Pillow automatically if missing&lt;/li&gt;
&lt;li&gt;Create a local marketplace at &lt;code&gt;~/.claude/plugins/marketplaces/local/&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Copy the plugin files into the marketplace&lt;/li&gt;
&lt;li&gt;Verify all components (5 checks)&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.1.0
&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>
