<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Swift on Man-You</title><link>https://man-you.ringum.net/tags/swift/</link><description>Recent content in Swift on Man-You</description><generator>Hugo -- gohugo.io</generator><language>en-us</language><lastBuildDate>Mon, 20 Apr 2026 10:00:00 +0200</lastBuildDate><atom:link href="https://man-you.ringum.net/tags/swift/index.xml" rel="self" type="application/rss+xml"/><item><title>Higgins: a neurosymbolic menubar buddy</title><link>https://man-you.ringum.net/posts/higgins/</link><pubDate>Mon, 20 Apr 2026 10:00:00 +0200</pubDate><guid>https://man-you.ringum.net/posts/higgins/</guid><description>&lt;p&gt;Higgins is a local assistant that lives in the menubar. A Qwen 2.5 7B model runs on-device via MLX, a SQLite triple store remembers things across sessions, and a small collection of tools — AppleScript runners, EventKit bridges, &lt;code&gt;gh&lt;/code&gt; CLI wrappers — lets him actually do work on the Mac. No cloud, no subscription, no data leaving the machine.&lt;/p&gt;
&lt;p&gt;The name is a nod to Magnum P.I.&amp;rsquo;s reserved English major-domo. The vibe was aspirational; most of the engineering effort went into making sure he doesn&amp;rsquo;t cheerfully hallucinate your dentist appointment into 2023.&lt;/p&gt;
&lt;h2 id="why-not-just-a-chat-wrapper"&gt;Why not just a chat wrapper&lt;/h2&gt;
&lt;p&gt;The starting pitch was neuro-symbolic: &amp;ldquo;LLMs are great at vibes and terrible at strict logic; symbolic systems are the opposite; put a logic engine inside a neural one.&amp;rdquo; Nice in a blog post, vague as a project.&lt;/p&gt;
&lt;p&gt;What it actually means in code: the neural side (Qwen) handles language, intent, paraphrasing. The symbolic side (SQLite facts table, typed tool schemas, EventKit queries) handles everything where being wrong is worse than being silent — dates, names, calendar events, PR numbers. The LLM never gets to compute a date from priors; it forwards your words to a parser. The LLM never claims to remember your dog&amp;rsquo;s name; the fact comes from a SQL row, or it doesn&amp;rsquo;t answer.&lt;/p&gt;
&lt;p&gt;That split is the whole project. Everything else is plumbing.&lt;/p&gt;
&lt;h2 id="the-qwen-brain"&gt;The Qwen brain&lt;/h2&gt;
&lt;p&gt;Qwen 2.5 7B Instruct, 4-bit quantized, ~4.5 GB on disk. Loaded via &lt;a href="https://github.com/ml-explore/mlx-swift-lm"&gt;mlx-swift-lm&lt;/a&gt; with progress reported through a &lt;code&gt;NSKeyValueObservation&lt;/code&gt; on the download&amp;rsquo;s &lt;code&gt;Progress&lt;/code&gt; object — since &lt;code&gt;swift-huggingface&lt;/code&gt; fires the handler once then mutates in place, not on every byte.&lt;/p&gt;
&lt;p&gt;Picking Qwen over Gemma was almost incidental. Gemma 3 4B worked but wouldn&amp;rsquo;t accept a &lt;code&gt;system&lt;/code&gt; role, forcing a hacky primer-pair trick. Gemma 4 26B-a4b is MoE, which mlx-swift-lm&amp;rsquo;s &lt;code&gt;Gemma4Model&lt;/code&gt; doesn&amp;rsquo;t implement yet. Qwen accepts system prompts cleanly and, crucially, was trained to emit native tool calls — which matters a lot more than benchmark scores.&lt;/p&gt;
&lt;p&gt;The old path was prompt-engineered: &lt;code&gt;TOOL_CALL toolname {...}&lt;/code&gt; in the output, parsed with regex. Fragile. Qwen wants to emit something like:&lt;/p&gt;
&lt;figure class="code-block-figure"&gt;
&lt;pre tabindex="0"&gt;&lt;code&gt;&amp;lt;tool_call&amp;gt;
{&amp;#34;name&amp;#34;: &amp;#34;reminder_add&amp;#34;, &amp;#34;arguments&amp;#34;: {&amp;#34;text&amp;#34;: &amp;#34;…&amp;#34;, &amp;#34;datetime&amp;#34;: &amp;#34;…&amp;#34;}}
&amp;lt;/tool_call&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;/figure&gt;
&lt;p&gt;mlx-swift-lm surfaces this as &lt;code&gt;.toolCall(ToolCall)&lt;/code&gt; events in the generation stream, if you pass structured tool specs into &lt;code&gt;UserInput(chat:, tools:)&lt;/code&gt;. Once wired up, the model stops inventing its own format and starts using the one it was RL&amp;rsquo;d on.&lt;/p&gt;
&lt;figure class="code-block-figure"&gt;
&lt;figcaption&gt;Generation loop excerpt&lt;/figcaption&gt;
&lt;div class="highlight" title="Generation loop excerpt"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-swift" data-lang="swift"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;await&lt;/span&gt; &lt;span class="n"&gt;gen&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="n"&gt;MLXLMCommon&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;generate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;input&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;input&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;parameters&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;switch&lt;/span&gt; &lt;span class="n"&gt;gen&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;case&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="kd"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;s&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="n"&gt;output&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;toolCall&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;c&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="n"&gt;call&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;info&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;break&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;/figure&gt;
&lt;p&gt;The text parser stuck around as a fallback for edge cases.&lt;/p&gt;
&lt;h2 id="the-symbolic-side"&gt;The symbolic side&lt;/h2&gt;
&lt;p&gt;A SQLite file at &lt;code&gt;~/Library/Application Support/Symbolic/memory.db&lt;/code&gt; with two tables:&lt;/p&gt;
&lt;figure class="code-block-figure"&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sql" data-lang="sql"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;TABLE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;episodes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;INTEGER&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;PRIMARY&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;user_msg&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;assistant_reply&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;signal&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;INTEGER&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;ts&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;REAL&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;);&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;TABLE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;facts&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;INTEGER&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;PRIMARY&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;subject&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;predicate&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;object&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;confidence&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;REAL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;last_seen&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;REAL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;UNIQUE&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;subject&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;predicate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;object&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;);&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;/figure&gt;
&lt;p&gt;Episodes are the raw log: every chat turn lands here. Facts are the distilled, queryable form — subject/predicate/object triples with a confidence score. The interesting work happens in the pipe between the two.&lt;/p&gt;
&lt;h2 id="sleep"&gt;Sleep&lt;/h2&gt;
&lt;p&gt;The consolidation metaphor is cribbed directly from human memory: episodic hippocampal traces don&amp;rsquo;t become semantic knowledge until you sleep. Higgins does the same. After 15 minutes of chat silence, or when you tap the 🌙 button, he runs a pass:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;NREM&lt;/strong&gt;: the model reads the day&amp;rsquo;s recent episodes and emits one JSON object per line — extracted stable facts about you, your world, your preferences. Ephemeral conversational filler (greetings, acknowledgments, weather chat) is skipped.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Synaptic downscaling&lt;/strong&gt;: every fact&amp;rsquo;s confidence is multiplied by 0.95. Unused memories decay. Recalled ones get re-bumped above 1.0 and capped.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Purge&lt;/strong&gt;: facts below 0.05 confidence are dropped.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Dream log&lt;/strong&gt;: a human-readable markdown summary lands in &lt;code&gt;~/Library/Application Support/Symbolic/dreams/YYYY-MM-DD.md&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;
&lt;figure class="code-block-figure"&gt;
&lt;figcaption&gt;Sleeper entrypoint&lt;/figcaption&gt;
&lt;div class="highlight" title="Sleeper entrypoint"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-swift" data-lang="swift"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;nightCycle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;episodeLimit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Int&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;async&lt;/span&gt; &lt;span class="kr"&gt;throws&lt;/span&gt; &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;Report&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;episodes&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="n"&gt;await&lt;/span&gt; &lt;span class="n"&gt;store&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;recentEpisodes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;episodeLimit&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;facts&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;await&lt;/span&gt; &lt;span class="n"&gt;extractFacts&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;from&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;episodes&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="n"&gt;facts&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="n"&gt;await&lt;/span&gt; &lt;span class="n"&gt;store&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;addFact&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;subject&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;subject&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;predicate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;predicate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;object&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;object&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="n"&gt;await&lt;/span&gt; &lt;span class="n"&gt;store&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;decayFacts&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;multiplier&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.95&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;purged&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="n"&gt;await&lt;/span&gt; &lt;span class="n"&gt;store&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;purgeWeakFacts&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;threshold&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.05&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;/figure&gt;
&lt;p&gt;There&amp;rsquo;s no &lt;code&gt;remember&lt;/code&gt; command to learn. Facts accumulate while you use the thing.&lt;/p&gt;
&lt;h2 id="per-turn-recall-closing-the-loop"&gt;Per-turn recall: closing the loop&lt;/h2&gt;
&lt;p&gt;Sleep would be useless if the facts just piled up. Before each generation, the user&amp;rsquo;s query is keyword-matched against the &lt;code&gt;facts&lt;/code&gt; table (top 5 hits, &lt;code&gt;LIKE&lt;/code&gt; on subject/predicate/object). Matching facts are injected into &lt;em&gt;that turn&amp;rsquo;s&lt;/em&gt; system context as a bracketed prefix:&lt;/p&gt;
&lt;figure class="code-block-figure"&gt;
&lt;pre tabindex="0"&gt;&lt;code&gt;[Known facts from memory:
- dog / name / Rex (confidence 0.95)
- manz / employer / Woosmap (confidence 0.87)
]
what&amp;#39;s my dog&amp;#39;s name?&lt;/code&gt;&lt;/pre&gt;
&lt;/figure&gt;
&lt;p&gt;History stays clean — the decoration only exists for the single inference call. No tool call needed for the model to answer &amp;ldquo;Rex&amp;rdquo;. The symbolic substrate grounds the neural output on the way past.&lt;/p&gt;
&lt;p&gt;At low scale (hundreds of facts) &lt;code&gt;LIKE&lt;/code&gt; matching is good enough. Embedding-backed similarity search is the upgrade when it isn&amp;rsquo;t.&lt;/p&gt;
&lt;h2 id="tools"&gt;Tools&lt;/h2&gt;
&lt;p&gt;The tool layer is where the project actually earns its keep. Three surfaces:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;AppleScript library.&lt;/strong&gt; Curated scripts live in &lt;code&gt;~/Library/Application Support/Symbolic/scripts/&lt;/code&gt;, each with frontmatter metadata (&lt;code&gt;-- name:&lt;/code&gt;, &lt;code&gt;-- description:&lt;/code&gt;, &lt;code&gt;-- args:&lt;/code&gt;). Seed scripts ship with the app; the user can edit or add more. The model calls them by name — &lt;code&gt;calendar_today&lt;/code&gt;, &lt;code&gt;note_quick&lt;/code&gt;, etc. — as if they were first-class tools.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;EventKit bridge.&lt;/strong&gt; AppleScript against Calendar.app is infamously slow — iterating every event on every calendar through the bridge takes 30+ seconds for a busy calendar. EventKit queries the same data in milliseconds. The catch: it needs &lt;code&gt;NSCalendarsFullAccessUsageDescription&lt;/code&gt; in a real &lt;code&gt;Info.plist&lt;/code&gt;. The SPM executable embeds one via a linker trick:&lt;/p&gt;
&lt;figure class="code-block-figure"&gt;
&lt;figcaption&gt;Package.swift&lt;/figcaption&gt;
&lt;div class="highlight" title="Package.swift"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-swift" data-lang="swift"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;linkerSettings&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;unsafeFlags&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s"&gt;&amp;#34;-Xlinker&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;&amp;#34;-sectcreate&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s"&gt;&amp;#34;-Xlinker&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;&amp;#34;__TEXT&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s"&gt;&amp;#34;-Xlinker&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;&amp;#34;__info_plist&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s"&gt;&amp;#34;-Xlinker&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;&amp;#34;Resources/Info.plist&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;]),&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;/figure&gt;
&lt;p&gt;&lt;code&gt;-sectcreate __TEXT __info_plist&lt;/code&gt; injects the plist into a section TCC reads when deciding whether to prompt for permission. Works without wrapping the binary in a &lt;code&gt;.app&lt;/code&gt; bundle.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;gh&lt;/code&gt; CLI wrappers.&lt;/strong&gt; &lt;code&gt;gh_my_prs&lt;/code&gt;, &lt;code&gt;gh_review_queue&lt;/code&gt;, &lt;code&gt;gh_notifications&lt;/code&gt;, &lt;code&gt;gh_recent_merged&lt;/code&gt;. Subprocess calls to the user&amp;rsquo;s existing &lt;code&gt;gh&lt;/code&gt; session — no OAuth dance, no token storage. A &lt;code&gt;ProcessRunner&lt;/code&gt; actor handles timeouts and respects &lt;code&gt;Task.isCancelled&lt;/code&gt; so a stuck subprocess doesn&amp;rsquo;t freeze the UI when you hit the red stop button.&lt;/p&gt;
&lt;p&gt;Tool results render as folded cards with a wrench icon, tool name, and a one-line summary. Click to expand. Errors auto-expand. &lt;code&gt;#123 · PR title · owner/repo&lt;/code&gt; links are real anchors thanks to &lt;a href="https://github.com/gonzalezreal/swift-markdown-ui"&gt;MarkdownUI&lt;/a&gt; on the assistant-side rendering.&lt;/p&gt;
&lt;h2 id="dates-or-why-the-model-doesnt-do-arithmetic"&gt;Dates, or: why the model doesn&amp;rsquo;t do arithmetic&lt;/h2&gt;
&lt;p&gt;7B models are bad at date math. &amp;ldquo;Remind me tomorrow at 8:15&amp;rdquo; reliably produced ISO strings from 2023, 2024, anywhere but &lt;em&gt;actually tomorrow&lt;/em&gt;.&lt;/p&gt;
&lt;p&gt;The fix is to not let the model do arithmetic at all. Any tool argument named &lt;code&gt;datetime&lt;/code&gt;, &lt;code&gt;when&lt;/code&gt;, &lt;code&gt;due_date&lt;/code&gt;, etc. gets intercepted in Swift before dispatch. A tiny table handles English and French relative words (&lt;code&gt;tomorrow&lt;/code&gt;, &lt;code&gt;today&lt;/code&gt;, &lt;code&gt;demain&lt;/code&gt;, &lt;code&gt;hier&lt;/code&gt;) — &lt;code&gt;NSDataDetector&lt;/code&gt; isn&amp;rsquo;t locale-parameterizable and picks up the system locale, which fails on English keywords under a French macOS. Anything the table doesn&amp;rsquo;t catch falls through to &lt;code&gt;NSDataDetector&lt;/code&gt;, which anchors relative phrasing against &lt;code&gt;Date()&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Critically, the user&amp;rsquo;s raw message is the source of truth, not the model&amp;rsquo;s output. If you typed &amp;ldquo;tomorrow&amp;rdquo;, &amp;ldquo;tomorrow&amp;rdquo; is what gets parsed — even if the model hallucinates an ISO. The model&amp;rsquo;s job is intent extraction, not date reasoning.&lt;/p&gt;
&lt;h2 id="the-applescript-run-button"&gt;The AppleScript Run button&lt;/h2&gt;
&lt;p&gt;When the model writes code in an assistant reply, the code block renders with a language tag and a copy button. When the language is &lt;code&gt;applescript&lt;/code&gt;, a Run button appears:&lt;/p&gt;
&lt;figure class="code-block-figure"&gt;
&lt;figcaption&gt;CodeBlock with conditional run action&lt;/figcaption&gt;
&lt;div class="highlight" title="CodeBlock with conditional run action"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-swift" data-lang="swift"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;MarkdownUI&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Markdown&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;turn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;markdownBlockStyle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="err"&gt;\&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;codeBlock&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;config&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;CodeBlock&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;code&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;language&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;language&lt;/span&gt; &lt;span class="p"&gt;??&lt;/span&gt; &lt;span class="s"&gt;&amp;#34;&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;onRun&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;runAction&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;for&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;language&lt;/span&gt; &lt;span class="p"&gt;??&lt;/span&gt; &lt;span class="s"&gt;&amp;#34;&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;code&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;/figure&gt;
&lt;p&gt;The action shells out to &lt;code&gt;osascript -e &amp;lt;script&amp;gt;&lt;/code&gt;, captures stdout, appends the result as a tool turn. The user stays in charge — nothing runs without the click. Safer than giving the model unrestricted automation tools, more flexible than the curated script library.&lt;/p&gt;
&lt;h2 id="what-grew-out-of-it"&gt;What grew out of it&lt;/h2&gt;
&lt;p&gt;The original plan was to fine-tune a custom voice — a &amp;ldquo;genius caveman&amp;rdquo; Grug, all short fragments. Hours of LoRA experiments on Gemma 2 2B, Gemma 3 4B, Gemma 4 E4B later, the honest conclusion: voice tuning on small models produces caricature or collapse, and nobody cares about voice when the tools don&amp;rsquo;t work. The pivot was embracing stock Qwen and pouring the effort into the symbolic substrate instead.&lt;/p&gt;
&lt;p&gt;What&amp;rsquo;s there now:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;On-device Qwen 2.5 7B&lt;/strong&gt; with native tool calling, loading with proper progress feedback&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Conversation persistence&lt;/strong&gt; as JSONL (machine) + Markdown sidecar (human), auto-saved per turn, restored on launch if recent&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Tool calls&lt;/strong&gt; through Qwen&amp;rsquo;s native format, fallback text parser, per-tool timeouts, cancel button, pending spinner&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Sleep/nap consolidation&lt;/strong&gt; turning episodes into facts, confidence decay, dream logs&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Per-turn recall&lt;/strong&gt; grounding generation on stored facts without touching history&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;EventKit&lt;/strong&gt; for calendar/reminders in milliseconds, &lt;code&gt;gh&lt;/code&gt; CLI for GitHub, NSDataDetector for natural-language dates&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Right-click menu&lt;/strong&gt;: new conversation, sleep now, open dreams/conversations/memory in Finder&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Markdown-rendered assistant replies&lt;/strong&gt; via MarkdownUI, plain monospaced tool output&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;AppleScript Run button&lt;/strong&gt; on any &lt;code&gt;applescript&lt;/code&gt; code block in a reply&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The fine-tune loop (REM-derived training candidates → periodic LoRA refresh of stable voice and high-confidence facts) is designed but deferred. Retrieval has a faster iteration loop; weights-baking earns its keep when retrieval stops being enough. Given Higgins has had exactly zero users for three days, that threshold is a while off.&lt;/p&gt;
&lt;p&gt;Meanwhile he tells me what&amp;rsquo;s due tomorrow, doesn&amp;rsquo;t make up dates, remembers the cat&amp;rsquo;s name is Charlie, and opens PR review queues when I ask. Good enough for now.&lt;/p&gt;</description></item><item><title>The most useless way to port a macOS app</title><link>https://man-you.ringum.net/posts/clone-desktop/</link><pubDate>Tue, 24 Mar 2026 09:30:00 +0100</pubDate><guid>https://man-you.ringum.net/posts/clone-desktop/</guid><description>&lt;p&gt;I grew up fascinated by projects like GNUStep, Haiku, Etoile, Wine, and ReactOS. Engineering feats, all of them. They reverse-engineer or reimplement entire operating system APIs so that software written for one platform can run on another. And they almost always end up in the same place: impressive technically, starved for contributors, forever chasing a moving target they can never quite catch.&lt;/p&gt;
&lt;p&gt;I never liked the state of the Linux desktop either. Not because it&amp;rsquo;s bad per se, but because it&amp;rsquo;s fragmented. A KDE app on GNOME looks alien. Firefox rolls its own everything. GTK and Qt will never agree on anything. Every toolkit draws its own widgets, manages its own text rendering, handles its own accessibility story. The result is a desktop that feels like a coalition of independent projects rather than a coherent system.&lt;/p&gt;
&lt;p&gt;This project is not going to fix any of that. But it&amp;rsquo;s an interesting story about how a combination of prior work, Apple open-sourcing key components, and an AI pair programmer led me down a rabbit hole I didn&amp;rsquo;t plan to enter.&lt;/p&gt;
&lt;h2 id="the-prior-art-that-made-this-possible"&gt;The prior art that made this possible&lt;/h2&gt;
&lt;p&gt;I&amp;rsquo;ve been working with wgpu (Rust&amp;rsquo;s WebGPU implementation) for a while now, building a &lt;a href="https://man-you.ringum.net/backroom/woosmap-tiles/"&gt;map renderer&lt;/a&gt; for Woosmap. That project taught me the fundamentals: how to manage GPU pipelines, how to do instanced rendering, how to deal with text atlases and glyph rasterization, how to bridge Rust and Swift through UniFFI.&lt;/p&gt;
&lt;p&gt;On the Swift side, I had two apps: &lt;strong&gt;Tunes&lt;/strong&gt;, a music player, and &lt;strong&gt;Leela&lt;/strong&gt;, an internal management tool for Woosmap services. Both are SwiftUI apps, both depend on Apple&amp;rsquo;s frameworks in the usual way.&lt;/p&gt;
&lt;p&gt;So one evening, half-curious and half-joking, I fed Claude the source of those projects alongside some context about GNUStep and friends, and typed:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&amp;ldquo;What would it take to make Tunes build and run on Linux?&amp;rdquo;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;And then things got out of control.&lt;/p&gt;
&lt;h2 id="what-claude-came-back-with"&gt;What Claude came back with&lt;/h2&gt;
&lt;p&gt;The answer was, predictably, &amp;ldquo;a lot.&amp;rdquo; But the interesting part was the breakdown. SwiftUI is the main dependency, and SwiftUI is closed-source. But Swift itself is open-source. Swift Foundation is open-source. The Swift Package Manager works on Linux. So the gap is really:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;A SwiftUI implementation (the view hierarchy, state management, layout engine, modifiers)&lt;/li&gt;
&lt;li&gt;Some AppKit shims (NSColor, NSAppearance, the bits that SwiftUI still leans on)&lt;/li&gt;
&lt;li&gt;A rendering backend that isn&amp;rsquo;t Core Animation or Metal&lt;/li&gt;
&lt;li&gt;A compositor to manage windows, menus, and the desktop chrome&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Instead of stopping at &amp;ldquo;that&amp;rsquo;s insane, don&amp;rsquo;t do it,&amp;rdquo; I kept going. Claude kept going. We started building.&lt;/p&gt;
&lt;h2 id="architecture"&gt;Architecture&lt;/h2&gt;
&lt;p&gt;The project (codenamed &lt;strong&gt;Clone&lt;/strong&gt;) splits pretty naturally along a language boundary.&lt;/p&gt;
&lt;p&gt;Rust does what Rust is good at: GPU work. A wgpu renderer with pipelines for rectangles, rounded rects (SDF-based, because I can&amp;rsquo;t stop using SDFs apparently), shadows, text via a glyph atlas, and wallpapers. It also runs the window compositor through winit.&lt;/p&gt;
&lt;p&gt;Swift does what Swift is good at: UI. A from-scratch SwiftUI implementation (about 50 View types, &lt;code&gt;@State&lt;/code&gt;/&lt;code&gt;@Binding&lt;/code&gt;/&lt;code&gt;@Environment&lt;/code&gt;, &lt;code&gt;ViewBuilder&lt;/code&gt;, layout, modifiers), the whole declarative stack. Plus enough AppKit shims to keep real apps happy, a SwiftData reimplementation backed by SQLite, and an IPC protocol over Unix sockets.&lt;/p&gt;
&lt;p&gt;UniFFI bridges the two. Each frame, Rust asks Swift for render commands, Swift resolves the view tree into a flat list of positioned primitives, and Rust batches them into instanced GPU draws. It&amp;rsquo;s the same bridge I use in the map renderer, so at least that part wasn&amp;rsquo;t new territory.&lt;/p&gt;
&lt;pre class="mermaid"&gt;graph LR
A["App.body"] --&gt; B["ViewBuilder"] --&gt; C["_resolve()"] --&gt; D["Layout"]
D --&gt; E["CommandFlattener"] --&gt; F["IPC
CGFloat → Float"] --&gt; G["Rust batcher"] --&gt; H["wgpu draws"]
style A fill:#c4a7e7,stroke:#6e6a86,color:#191724
style B fill:#c4a7e7,stroke:#6e6a86,color:#191724
style C fill:#c4a7e7,stroke:#6e6a86,color:#191724
style D fill:#c4a7e7,stroke:#6e6a86,color:#191724
style E fill:#f6c177,stroke:#6e6a86,color:#191724
style F fill:#f6c177,stroke:#6e6a86,color:#191724
style G fill:#9ccfd8,stroke:#6e6a86,color:#191724
style H fill:#9ccfd8,stroke:#6e6a86,color:#191724
&lt;/pre&gt;
&lt;h2 id="first-signs-of-life"&gt;First signs of life&lt;/h2&gt;
&lt;p&gt;The moment I&amp;rsquo;ll remember is the grey rectangle. &amp;ldquo;Clone Desktop&amp;rdquo; in the center, a dock at the bottom with colored squares. I&amp;rsquo;d been at it for hours, wrestling with UniFFI bindings and layout math, and suddenly there it was: pixels on screen, drawn by Swift code, pushed through a Rust GPU backend, on something that was decidedly not macOS.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;
&lt;img
src="https://man-you.ringum.net/posts/clone-desktop/clone-1_hu_60da9db4b46e573c.webp"
srcset="https://man-you.ringum.net/posts/clone-desktop/clone-1_hu_60da9db4b46e573c.webp 960w, https://man-you.ringum.net/posts/clone-desktop/clone-1_hu_cd2b29062a903b83.webp 2784w"
sizes="(max-width: 960px) 100vw, 960px"
alt="The first Clone Desktop render: a grey surface, centered label, and a color-swatch dock"
width="960"
height="651"
loading="lazy"
decoding="async"
/&gt;
&lt;figcaption&gt;Day one: a grey box, a label, and a dock. It&amp;#39;s not much, but it compiles and renders.&lt;/figcaption&gt;
&lt;/figure&gt;&lt;/p&gt;
&lt;h2 id="settings-and-finder"&gt;Settings and Finder&lt;/h2&gt;
&lt;p&gt;Once the basic views worked (stacks, text, lists, navigation), I needed something real to throw at them. Settings was a natural first target: sidebars, forms, toggles, text fields, the kind of layout variety that breaks things fast. Finder came next because browsing a directory with &lt;code&gt;List&lt;/code&gt; and &lt;code&gt;ForEach&lt;/code&gt; is such a fundamental SwiftUI pattern that if it didn&amp;rsquo;t work, nothing would.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;
&lt;img
src="https://man-you.ringum.net/posts/clone-desktop/clone-2_hu_c535fac1e06a428f.webp"
srcset="https://man-you.ringum.net/posts/clone-desktop/clone-2_hu_c535fac1e06a428f.webp 960w, https://man-you.ringum.net/posts/clone-desktop/clone-2_hu_d543a0938420ea6e.webp 2784w"
sizes="(max-width: 960px) 100vw, 960px"
alt="Clone Desktop running Settings (Wi-Fi panel) and Finder side by side, dark mode"
width="960"
height="651"
loading="lazy"
decoding="async"
/&gt;
&lt;figcaption&gt;Settings showing Wi-Fi preferences alongside Finder browsing the home directory. Both are SwiftUI apps running through Clone&amp;#39;s stack.&lt;/figcaption&gt;
&lt;/figure&gt;&lt;/p&gt;
&lt;p&gt;Dark mode just kind of happened. Once I shimmed &lt;code&gt;NSAppearance&lt;/code&gt; and implemented the semantic color system (&lt;code&gt;Color.primary&lt;/code&gt;, &lt;code&gt;.secondary&lt;/code&gt;, the system grays), flipping between light and dark was a toggle. A small thing, but unreasonably satisfying: it made the whole experiment feel like a real desktop for the first time.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;
&lt;img
src="https://man-you.ringum.net/posts/clone-desktop/clone-3_hu_7656abf32f2c59d8.webp"
srcset="https://man-you.ringum.net/posts/clone-desktop/clone-3_hu_7656abf32f2c59d8.webp 960w, https://man-you.ringum.net/posts/clone-desktop/clone-3_hu_477ea4514a69efe6.webp 2784w"
sizes="(max-width: 960px) 100vw, 960px"
alt="Settings showing the General panel in light mode"
width="960"
height="651"
loading="lazy"
decoding="async"
/&gt;
&lt;figcaption&gt;The same Settings app in light mode. Appearance switching works through the reimplemented NSAppearance and Color system.&lt;/figcaption&gt;
&lt;/figure&gt;&lt;/p&gt;
&lt;h2 id="tunes"&gt;Tunes&lt;/h2&gt;
&lt;p&gt;This was the whole point, remember? &amp;ldquo;What would it take to make Tunes build and run on Linux?&amp;rdquo; Well, it builds. The login sheet renders inside the app window rather than as a separate &lt;code&gt;NSPanel&lt;/code&gt; (a compromise, but not a terrible one), and the SwiftUI code is essentially unchanged. &lt;code&gt;TextField&lt;/code&gt;, &lt;code&gt;SecureField&lt;/code&gt;, &lt;code&gt;Toggle&lt;/code&gt;, &lt;code&gt;Button&lt;/code&gt;, &lt;code&gt;Link&lt;/code&gt;: they all resolve and render. Same source, different universe.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;
&lt;img
src="https://man-you.ringum.net/posts/clone-desktop/clone-4_hu_5ff44dcb7735661f.webp"
srcset="https://man-you.ringum.net/posts/clone-desktop/clone-4_hu_5ff44dcb7735661f.webp 960w, https://man-you.ringum.net/posts/clone-desktop/clone-4_hu_b08f4d867416f64c.webp 2784w"
sizes="(max-width: 960px) 100vw, 960px"
alt="Tunes showing a login form with username/password fields, a toggle, and a registration link, alongside a Finder window"
width="960"
height="651"
loading="lazy"
decoding="async"
/&gt;
&lt;figcaption&gt;Tunes running its login flow through Clone. The sheet renders in-window rather than as a separate panel, one of many compromises.&lt;/figcaption&gt;
&lt;/figure&gt;&lt;/p&gt;
&lt;h2 id="leela"&gt;Leela&lt;/h2&gt;
&lt;p&gt;Leela is a management dashboard for Woosmap services: tabs, lists, nested navigation, version selectors, deploy queues. Most of the UI is driven by API responses, so it hammers &lt;code&gt;ForEach&lt;/code&gt; with dynamic data, &lt;code&gt;@StateObject&lt;/code&gt;, and conditional rendering. If Settings was a stroll through the park, Leela was a stress test.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;
&lt;img
src="https://man-you.ringum.net/posts/clone-desktop/clone-5_hu_2145acbb1b59b75f.webp"
srcset="https://man-you.ringum.net/posts/clone-desktop/clone-5_hu_2145acbb1b59b75f.webp 960w, https://man-you.ringum.net/posts/clone-desktop/clone-5_hu_a5c8020ac9f97b4f.webp 2784w"
sizes="(max-width: 960px) 100vw, 960px"
alt="Leela showing a service management view with sidebar navigation, tab bar, version tags, and service listings"
width="960"
height="651"
loading="lazy"
decoding="async"
/&gt;
&lt;figcaption&gt;Leela&amp;#39;s services view running through Clone. Tabs, lists, version badges, sidebar navigation, all SwiftUI, all reimplemented.&lt;/figcaption&gt;
&lt;/figure&gt;&lt;/p&gt;
&lt;p&gt;I won&amp;rsquo;t pretend it was smooth. Getting here meant implementing a surprising amount of SwiftUI&amp;rsquo;s surface area. The state management system alone (&lt;code&gt;StateGraph&lt;/code&gt;, scoped identity for &lt;code&gt;ForEach&lt;/code&gt;, call-index disambiguation) went through several iterations where everything would render once and then silently stop updating. The kind of bug where you stare at a diff for an hour before realizing a closure captured a copy instead of a reference.&lt;/p&gt;
&lt;h2 id="under-the-hood"&gt;Under the hood&lt;/h2&gt;
&lt;h3 id="text-rendering"&gt;Text rendering&lt;/h3&gt;
&lt;p&gt;Text goes through cosmic-text for shaping, then gets rasterized into a 4096x4096 glyph atlas (single-channel, R8). Each glyph is cached and rendered as an instanced GPU quad. Fonts are bundled: Inter for UI text, Phosphor for icons.&lt;/p&gt;
&lt;p&gt;This is almost certainly not how Apple does it. A real system would share GPU textures between the compositor and app, or pull from a system font cache. But it works, and there&amp;rsquo;s a special joy in watching a glyph atlas fill up character by character as the UI renders for the first time.&lt;/p&gt;
&lt;h3 id="layout"&gt;Layout&lt;/h3&gt;
&lt;p&gt;I reverse-engineered SwiftUI&amp;rsquo;s layout by reading &lt;a href="https://www.objc.io/books/thinking-in-swiftui/"&gt;objc.io&amp;rsquo;s&lt;/a&gt; thinking-in-swiftui and a lot of trial and error. The model: parents propose a size to children, children report back what they need, parents position them. Sounds simple until &lt;code&gt;ZStack&lt;/code&gt; enters the picture: nil-sized views from &lt;code&gt;.background()&lt;/code&gt; modifiers would expand to fill constraints and eat space from real siblings. That one took a while.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;ScrollView&lt;/code&gt; was another head-scratcher: it fills its proposed size but lays out content unbounded in the scroll axis. Getting that inversion right, where the container constrains in one direction and is infinite in the other, broke my mental model twice before clicking.&lt;/p&gt;
&lt;h3 id="ipc"&gt;IPC&lt;/h3&gt;
&lt;p&gt;Each app is a separate process. The compositor (CloneDesktop) runs the Rust event loop and manages surfaces. Apps connect over a Unix socket at &lt;code&gt;/tmp/clone-compositor.sock&lt;/code&gt; and exchange length-prefixed JSON messages. There&amp;rsquo;s an annoying &lt;code&gt;CGFloat&lt;/code&gt;-to-&lt;code&gt;Float&lt;/code&gt; conversion at the wire boundary: Swift thinks in 64-bit coordinates, the GPU thinks in &lt;code&gt;f32&lt;/code&gt;, and someone has to reconcile that at the border.&lt;/p&gt;
&lt;h3 id="the-ycodebuild-trick"&gt;The &lt;code&gt;ycodebuild&lt;/code&gt; trick&lt;/h3&gt;
&lt;p&gt;This is probably my favorite hack in the project. To compile an existing macOS app against Clone instead of Apple&amp;rsquo;s frameworks, &lt;code&gt;ycodebuild&lt;/code&gt; generates a shadow SPM package that maps &lt;code&gt;import SwiftUI&lt;/code&gt; to Clone&amp;rsquo;s SwiftUI, &lt;code&gt;import AppKit&lt;/code&gt; to Clone&amp;rsquo;s shims, and so on. The app source code doesn&amp;rsquo;t change at all: you just build against a different package graph, and the compiled binary talks to the compositor over the socket. The same &lt;code&gt;.swift&lt;/code&gt; file, two completely different platforms.&lt;/p&gt;
&lt;h2 id="honest-assessment"&gt;Honest assessment&lt;/h2&gt;
&lt;p&gt;I should be upfront about the gap between &amp;ldquo;it renders&amp;rdquo; and &amp;ldquo;it works.&amp;rdquo;&lt;/p&gt;
&lt;p&gt;Animations are mostly stubbed. Accessibility is nonexistent. The compositor redraws every surface every frame like it&amp;rsquo;s 1997. There are &lt;code&gt;// TODO: implement&lt;/code&gt; scattered across the codebase where AppKit APIs return no-ops and hope nobody notices. Sheets render in-window because I never built the panel system. The glyph atlas will fall over the moment someone opens a CJK document.&lt;/p&gt;
&lt;p&gt;And (this is the awkward part) it doesn&amp;rsquo;t actually run on Linux yet. The whole premise was &amp;ldquo;what would it take,&amp;rdquo; and the answer turned out to include some Apple framework dependencies I haven&amp;rsquo;t replaced with their open-source equivalents. It&amp;rsquo;s doable. It&amp;rsquo;s just not done.&lt;/p&gt;
&lt;p&gt;But here&amp;rsquo;s the thing that caught me off guard. The time from that initial Claude prompt to a state where Tunes and Leela compile and render recognizable UI was hours, not weeks. Not autonomous AI magic (plenty of manual fixes and architectural decisions along the way) but Claude carried an enormous amount of boilerplate: generating 50 View type stubs, wiring up modifier chains, implementing the state graph, setting up IPC. The kind of work that would&amp;rsquo;ve taken me days of tedious typing, compressed into a conversation.&lt;/p&gt;
&lt;h2 id="why-this-matters-a-little"&gt;Why this matters (a little)&lt;/h2&gt;
&lt;p&gt;Apple open-sourcing Swift and Foundation was a bigger deal than most people realize. Not because anyone&amp;rsquo;s going to ship a SwiftUI app on Linux tomorrow, but because it lowered the floor for experiments like this from &amp;ldquo;completely impossible&amp;rdquo; to &amp;ldquo;merely impractical.&amp;rdquo;&lt;/p&gt;
&lt;p&gt;The projects I admired growing up (GNUStep, Haiku, Wine) were built by small teams reverse-engineering closed systems over years. The combination of open-source language infrastructure and AI assistance compresses that timeline dramatically. Not to a point where it&amp;rsquo;s practical or production-ready, but to a point where a single person can explore the shape of the problem in a weekend.&lt;/p&gt;
&lt;p&gt;That&amp;rsquo;s the real takeaway. Not &amp;ldquo;I ported macOS to Linux.&amp;rdquo; I didn&amp;rsquo;t. But I went from a throwaway prompt to colored boxes on screen running real SwiftUI app code, and that felt like something worth writing about.&lt;/p&gt;
&lt;h2 id="whats-next"&gt;What&amp;rsquo;s next&lt;/h2&gt;
&lt;p&gt;Honestly? Probably nothing. The project scratched a twenty-year itch. But I know myself, and I know there&amp;rsquo;s a Kawase blur pipeline sitting unused in the renderer that&amp;rsquo;s going to call my name at 11pm some Tuesday. If I do keep going:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Per-window offscreen textures&lt;/strong&gt;: the compositor shouldn&amp;rsquo;t redraw everything every frame, that&amp;rsquo;s embarrassing&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Glassmorphism / backdrop blur&lt;/strong&gt;: because what&amp;rsquo;s the point of reimplementing macOS if you can&amp;rsquo;t have the frosted glass&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Actually running on Linux&lt;/strong&gt;: the whole original premise, still unfinished&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Shared GPU textures&lt;/strong&gt;: how a real compositor would work, instead of copying pixels through a socket like an animal&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Or maybe it stays as a weekend experiment and a blog post. Either way, the kid who thought GNUStep was the coolest thing ever is pretty happy right now.&lt;/p&gt;</description></item><item><title>Tunes</title><link>https://man-you.ringum.net/posts/tunes/</link><pubDate>Tue, 01 Jul 2025 10:31:38 +0200</pubDate><guid>https://man-you.ringum.net/posts/tunes/</guid><description>&lt;p&gt;iTunes Match worked great for years: you uploaded your library, Apple matched what it could, and you had access to everything from any device. But Apple is clearly moving everyone toward Apple Music, and iTunes Match has been slowly rotting. The writing was on the wall.&lt;/p&gt;
&lt;p&gt;I didn&amp;rsquo;t want to subscribe to Apple Music. I wanted to keep my library, my ratings, my play counts, my playlists, all the metadata accumulated over 20 years. So I built my own streaming setup: a Go backend that serves the library over HTTP, and SwiftUI apps that play it on iOS, macOS, and tvOS.&lt;/p&gt;
&lt;h2 id="exporting-the-library"&gt;Exporting the library&lt;/h2&gt;
&lt;p&gt;Apple Music (formerly iTunes) can export the library as an XML plist file. It&amp;rsquo;s a flat dictionary of tracks keyed by ID, with every field you&amp;rsquo;d expect: name, artist, album, play count, rating, date added, file location, and about 60 more.&lt;/p&gt;
&lt;figure class="code-block-figure"&gt;
&lt;figcaption&gt;A track entry in the exported XML&lt;/figcaption&gt;
&lt;div class="highlight" title="A track entry in the exported XML"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-xml" data-lang="xml"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nt"&gt;&amp;lt;key&amp;gt;&lt;/span&gt;1234&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nt"&gt;&amp;lt;dict&amp;gt;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nt"&gt;&amp;lt;key&amp;gt;&lt;/span&gt;Track ID&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&amp;lt;integer&amp;gt;&lt;/span&gt;1234&lt;span class="nt"&gt;&amp;lt;/integer&amp;gt;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nt"&gt;&amp;lt;key&amp;gt;&lt;/span&gt;Name&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&amp;lt;string&amp;gt;&lt;/span&gt;Windowlicker&lt;span class="nt"&gt;&amp;lt;/string&amp;gt;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nt"&gt;&amp;lt;key&amp;gt;&lt;/span&gt;Artist&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&amp;lt;string&amp;gt;&lt;/span&gt;Aphex Twin&lt;span class="nt"&gt;&amp;lt;/string&amp;gt;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nt"&gt;&amp;lt;key&amp;gt;&lt;/span&gt;Album&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&amp;lt;string&amp;gt;&lt;/span&gt;Windowlicker&lt;span class="nt"&gt;&amp;lt;/string&amp;gt;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nt"&gt;&amp;lt;key&amp;gt;&lt;/span&gt;Total Time&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&amp;lt;integer&amp;gt;&lt;/span&gt;381573&lt;span class="nt"&gt;&amp;lt;/integer&amp;gt;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nt"&gt;&amp;lt;key&amp;gt;&lt;/span&gt;Location&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&amp;lt;string&amp;gt;&lt;/span&gt;file:///Music/Aphex%20Twin/Windowlicker/01%20Windowlicker.m4a&lt;span class="nt"&gt;&amp;lt;/string&amp;gt;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nt"&gt;&amp;lt;/dict&amp;gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;/figure&gt;
&lt;p&gt;The Go struct that maps to this uses &lt;code&gt;plist&lt;/code&gt; tags for XML parsing and &lt;code&gt;gorm&lt;/code&gt; tags for database storage, with every optional field as a pointer for proper nil handling:&lt;/p&gt;
&lt;figure class="code-block-figure"&gt;
&lt;figcaption&gt;Track model (abbreviated)&lt;/figcaption&gt;
&lt;div class="highlight" title="Track model (abbreviated)"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-go" data-lang="go"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;type&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;ITunesTrack&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kd"&gt;struct&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;TrackID&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;`plist:&amp;#34;Track ID&amp;#34; json:&amp;#34;track_id&amp;#34; gorm:&amp;#34;primaryKey&amp;#34;`&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;Name&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;`plist:&amp;#34;Name&amp;#34; json:&amp;#34;name&amp;#34; gorm:&amp;#34;index&amp;#34;`&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;Artist&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;`plist:&amp;#34;Artist&amp;#34; json:&amp;#34;artist&amp;#34; gorm:&amp;#34;index&amp;#34;`&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;Album&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;`plist:&amp;#34;Album&amp;#34; json:&amp;#34;album&amp;#34; gorm:&amp;#34;index&amp;#34;`&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;TotalTime&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;`plist:&amp;#34;Total Time&amp;#34; json:&amp;#34;total_time&amp;#34;`&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;Location&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;`plist:&amp;#34;Location&amp;#34; json:&amp;#34;location&amp;#34;`&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;Rating&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;`plist:&amp;#34;Rating&amp;#34; json:&amp;#34;rating&amp;#34; gorm:&amp;#34;index&amp;#34;`&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;PlayCount&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;`plist:&amp;#34;Play Count&amp;#34; json:&amp;#34;play_count&amp;#34;`&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;DateAdded&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="nx"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Time&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;`plist:&amp;#34;Date Added&amp;#34; json:&amp;#34;date_added&amp;#34; gorm:&amp;#34;index&amp;#34;`&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;// ... 60+ more fields&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;/figure&gt;
&lt;p&gt;The initial version parsed the XML on every startup using &lt;code&gt;howett.net/plist&lt;/code&gt;. That worked fine for a 15,000-track library (about 2 seconds of parsing). But it eventually moved to SQLite with GORM for persistence, which also enabled FTS5 full-text search and pre-aggregated album caches.&lt;/p&gt;
&lt;h2 id="the-go-backend"&gt;The Go backend&lt;/h2&gt;
&lt;p&gt;The server is straightforward. A &lt;code&gt;LibraryService&lt;/code&gt; holds the database, artwork cache, rate limiter, and various sub-services. Routes are plain &lt;code&gt;net/http&lt;/code&gt; handlers with a JWT auth middleware:&lt;/p&gt;
&lt;figure class="code-block-figure"&gt;
&lt;figcaption&gt;Route setup (abbreviated)&lt;/figcaption&gt;
&lt;div class="highlight" title="Route setup (abbreviated)"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-go" data-lang="go"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;// Auth routes (no middleware)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nx"&gt;http&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;HandleFunc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;/auth/login&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;authHandlers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;HandleLogin&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nx"&gt;http&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;HandleFunc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;/auth/register&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;authHandlers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;HandleRegister&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;// Library routes (auth required)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nx"&gt;http&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Handle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;/tracks&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nf"&gt;authMiddleware&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;http&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;HandlerFunc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;service&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;HandleTracks&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nx"&gt;http&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Handle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;/artists&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nf"&gt;authMiddleware&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;http&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;HandlerFunc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;service&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;HandleArtists&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nx"&gt;http&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Handle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;/albums&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nf"&gt;authMiddleware&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;http&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;HandlerFunc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;service&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;HandleAlbums&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nx"&gt;http&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Handle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;/stream/&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nf"&gt;authMiddleware&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;http&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;HandlerFunc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;service&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;HandleStream&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nx"&gt;http&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Handle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;/artwork/track/&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nf"&gt;authMiddleware&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;http&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;HandlerFunc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;service&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;HandleTrackArtwork&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;/figure&gt;
&lt;p&gt;The &lt;code&gt;/stream/{id}&lt;/code&gt; endpoint serves audio files directly with &lt;code&gt;http.ServeFile&lt;/code&gt;, which handles range requests and conditional caching automatically. No need to build a custom streaming protocol. HTTP does the job.&lt;/p&gt;
&lt;p&gt;Authentication uses Argon2 for password hashing and session tokens stored in the database. It later grew WebAuthn support for passkey login, and a QR code flow for tvOS (where typing passwords with a remote is painful).&lt;/p&gt;
&lt;h2 id="album-artwork"&gt;Album artwork&lt;/h2&gt;
&lt;p&gt;This was the most annoying part. The exported XML doesn&amp;rsquo;t include artwork. The actual cover images are either embedded in the audio files or live somewhere in Apple&amp;rsquo;s ecosystem that isn&amp;rsquo;t accessible after export.&lt;/p&gt;
&lt;p&gt;The solution is a three-tier artwork system:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Embedded extraction&lt;/strong&gt;: read the audio file metadata with &lt;code&gt;github.com/dhowden/tag&lt;/code&gt;, pull out the cover image if present, cache it locally&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;iTunes Store lookup&lt;/strong&gt;: search Apple&amp;rsquo;s public API for the album, download the highest resolution artwork available&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Manual upload&lt;/strong&gt;: for albums that don&amp;rsquo;t match either source&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;The iTunes Store lookup uses a scoring system to match results. Exact album + artist name match scores highest, partial matches score lower, and anything below a minimum threshold is rejected:&lt;/p&gt;
&lt;figure class="code-block-figure"&gt;
&lt;figcaption&gt;iTunes Store artwork search&lt;/figcaption&gt;
&lt;div class="highlight" title="iTunes Store artwork search"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-go" data-lang="go"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;func&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ls&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="nx"&gt;LibraryService&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nf"&gt;searchITunesStore&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;artist&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;album&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;tracks&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="nx"&gt;ITunesTrack&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="nx"&gt;ITunesSearchItem&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;searchTerm&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;:=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;fmt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Sprintf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;%s %s&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;strings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;TrimSpace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;artist&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;strings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;TrimSpace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;album&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;:=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Values&lt;/span&gt;&lt;span class="p"&gt;{}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;term&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;searchTerm&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;entity&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;#34;album&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;media&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;#34;music&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;limit&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;#34;10&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;searchURL&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;:=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;fmt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Sprintf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;https://itunes.apple.com/search?%s&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Encode&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;// ... fetch, decode, find best match by score&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;/figure&gt;
&lt;p&gt;Apple&amp;rsquo;s API returns 100x100 thumbnails by default. The URL pattern is predictable (replace &lt;code&gt;100x100&lt;/code&gt; with &lt;code&gt;600x600&lt;/code&gt;), but not all resolutions exist for all albums. The server tries 600, 512, and 300 with HEAD requests before falling back to the original.&lt;/p&gt;
&lt;p&gt;The real constraint is rate limiting. Apple&amp;rsquo;s Search API starts returning 403s if you hit it too hard. The server uses a channel-based rate limiter that caps requests to 2 per second by default, configurable via TOML config.&lt;/p&gt;
&lt;h2 id="the-gapless-playback-problem"&gt;The gapless playback problem&lt;/h2&gt;
&lt;p&gt;This one took a while to figure out. Live albums, DJ mixes, classical recordings: any music that&amp;rsquo;s supposed to flow continuously between tracks gets a gap when played back on iOS.&lt;/p&gt;
&lt;p&gt;The root cause: iOS &lt;code&gt;AVFoundation&lt;/code&gt; ignores the &lt;code&gt;iTunSMPB&lt;/code&gt; gapless metadata in MP3 files. This is the atom that tells the player how many encoder delay and padding samples to skip. Apple&amp;rsquo;s own Music app reads it. &lt;code&gt;AVFoundation&lt;/code&gt; does not.&lt;/p&gt;
&lt;p&gt;The fix is converting MP3s to AAC. The AAC container stores gapless information in a way that &lt;code&gt;AVFoundation&lt;/code&gt; actually respects. The server has a built-in transcoder:&lt;/p&gt;
&lt;figure class="code-block-figure"&gt;
&lt;figcaption&gt;Startup conversion&lt;/figcaption&gt;
&lt;div class="highlight" title="Startup conversion"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-go" data-lang="go"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nx"&gt;transcoder&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;:=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;tunes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;NewTranscoderService&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;service&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;tunes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;TranscodeOptions&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;Concurrency&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;DeleteSource&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;// Keep original MP3s&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;})&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nx"&gt;results&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;:=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;transcoder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ConvertAllMP3s&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Background&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;/figure&gt;
&lt;p&gt;It uses &lt;code&gt;ffmpeg&lt;/code&gt; under the hood, runs 4 workers in parallel, keeps the originals, and updates the database paths. A 15,000-track library takes a couple of hours. After that, gapless playback works perfectly.&lt;/p&gt;
&lt;h2 id="swiftui-clients"&gt;SwiftUI clients&lt;/h2&gt;
&lt;p&gt;The app targets iOS, macOS, and tvOS from a single codebase, with platform-specific extensions for navigation and layout. iOS uses tab navigation with a mini-player overlay, macOS uses a split view with sidebar, and tvOS uses focus-based navigation for the remote.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;
&lt;img
src="https://man-you.ringum.net/posts/tunes/tunes-albums-grid_hu_98feb22ba5340b06.webp"
srcset="https://man-you.ringum.net/posts/tunes/tunes-albums-grid_hu_98feb22ba5340b06.webp 960w, https://man-you.ringum.net/posts/tunes/tunes-albums-grid_hu_a0f513b36da95c6f.webp 2622w"
sizes="(max-width: 960px) 100vw, 960px"
alt="Albums grid view on macOS"
width="960"
height="689"
loading="lazy"
decoding="async"
/&gt;
&lt;/figure&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;
&lt;img
src="https://man-you.ringum.net/posts/tunes/tunes-album-detail_hu_83f6a079ecbe5fc8.webp"
srcset="https://man-you.ringum.net/posts/tunes/tunes-album-detail_hu_83f6a079ecbe5fc8.webp 960w, https://man-you.ringum.net/posts/tunes/tunes-album-detail_hu_b6cec79bf14624e5.webp 2622w"
sizes="(max-width: 960px) 100vw, 960px"
alt="Album detail with track listing"
width="960"
height="689"
loading="lazy"
decoding="async"
/&gt;
&lt;/figure&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;
&lt;img
src="https://man-you.ringum.net/posts/tunes/tunes-play-queue_hu_eb8ab7a2bbd0b024.webp"
srcset="https://man-you.ringum.net/posts/tunes/tunes-play-queue_hu_eb8ab7a2bbd0b024.webp 960w, https://man-you.ringum.net/posts/tunes/tunes-play-queue_hu_559f2a90737a9dd6.webp 2622w"
sizes="(max-width: 960px) 100vw, 960px"
alt="Play queue popover"
width="960"
height="689"
loading="lazy"
decoding="async"
/&gt;
&lt;/figure&gt;&lt;/p&gt;
&lt;p&gt;The audio player is built on &lt;code&gt;AVQueuePlayer&lt;/code&gt; for seamless track transitions. It pre-buffers the next track 10 seconds before the current one ends, maintaining up to 3 items in the queue at any time.&lt;/p&gt;
&lt;p&gt;One hard-won lesson: &lt;code&gt;@Published&lt;/code&gt; property updates in background cause iOS to terminate the app for excessive main-thread UI work. The fix is gating all published writes behind a foreground check:&lt;/p&gt;
&lt;figure class="code-block-figure"&gt;
&lt;figcaption&gt;Background-safe state management&lt;/figcaption&gt;
&lt;div class="highlight" title="Background-safe state management"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-swift" data-lang="swift"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;struct&lt;/span&gt; &lt;span class="nc"&gt;PlaybackState&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Equatable&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;currentTime&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;TimeInterval&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;duration&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;TimeInterval&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;isPlaying&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;isLoading&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lhs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;PlaybackState&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;rhs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;PlaybackState&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;Bool&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;Int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lhs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;currentTime&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="nb"&gt;Int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rhs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;currentTime&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nb"&gt;Int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lhs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;duration&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="nb"&gt;Int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rhs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;duration&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;lhs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;isPlaying&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="n"&gt;rhs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;isPlaying&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;lhs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;isLoading&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="n"&gt;rhs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;isLoading&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;/figure&gt;
&lt;p&gt;Grouping related properties into structs (instead of individual &lt;code&gt;@Published&lt;/code&gt; vars) and implementing coarse equality comparison reduced SwiftUI re-renders significantly. The player only triggers a view update when the &lt;em&gt;second&lt;/em&gt; changes, not on every time observer tick.&lt;/p&gt;
&lt;p&gt;The API layer uses Combine publishers with automatic 401 retry: if a request fails with an expired token, the service transparently refreshes it and replays the original request. Network monitoring via &lt;code&gt;NWPathMonitor&lt;/code&gt; lets the app degrade gracefully when offline, falling back to Core Data for cached data.&lt;/p&gt;
&lt;h2 id="what-grew-out-of-it"&gt;What grew out of it&lt;/h2&gt;
&lt;p&gt;What started as &amp;ldquo;stream my library from a Raspberry Pi&amp;rdquo; has accumulated features over time:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Full-text search&lt;/strong&gt; with SQLite FTS5 across all track metadata&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Play history tracking&lt;/strong&gt;: every play gets logged with duration and completion status, which feeds a yearly replay feature (like Spotify Wrapped)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Lyrics&lt;/strong&gt;: embedded USLT frames from audio files, plus LRClib API integration for synced timestamps&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Offline mode&lt;/strong&gt;: Core Data persistence with background sync, download management for keeping albums locally&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Discogs integration&lt;/strong&gt;: artist biographies and metadata from a separate database&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Vector similarity search&lt;/strong&gt;: track embeddings for &amp;ldquo;genius playlists&amp;rdquo; based on audio features and metadata&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;It&amp;rsquo;s not a replacement for Apple Music. It doesn&amp;rsquo;t have a catalog of 100 million songs. But it plays &lt;em&gt;my&lt;/em&gt; music, with &lt;em&gt;my&lt;/em&gt; metadata, on all my devices, without a subscription. That was the goal.&lt;/p&gt;</description></item></channel></rss>