<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>heynavid</title>
    <link>https://heynavid.com/blog/</link>
    <atom:link href="https://heynavid.com/rss.xml" rel="self" type="application/rss+xml" />
    <description>Notes from building indie iOS apps: what shipped, what broke, and building for iPhone Duo.</description>
    <language>en</language>
    <lastBuildDate>Mon, 28 Sep 2026 00:00:00 GMT</lastBuildDate>
    <item>
      <title>Designing Fold Cue around an outer screen I can't test yet</title>
      <link>https://heynavid.com/blog/designing-for-an-outer-screen-i-cant-test/</link>
      <guid isPermaLink="true">https://heynavid.com/blog/designing-for-an-outer-screen-i-cant-test/</guid>
      <pubDate>Mon, 28 Sep 2026 00:00:00 GMT</pubDate>
      <dc:creator>Navid</dc:creator>
      <category>foldcue</category>
      <category>iphone-duo</category>
      <category>swiftui</category>
      <description>Fold Cue's best feature on iPhone Duo is the script on the outer screen, beside the camera. The system decides when that screen is mine, and I won't hold a Duo until it goes on sale. So the app is designed to be fine without it.</description>
      <content:encoded><![CDATA[<p><img src="https://heynavid.com/blog/images/designing-for-an-outer-screen-i-cant-test-hero.jpg" alt="" /></p><p>The reason Fold Cue exists is one pose. Stand iPhone Duo up like a book with the backs towards you, and the outer screen sits right beside the main camera. Put the script there and you read straight into the lens, while the best camera on the phone films you and whoever is holding it sees a director’s view on the inside.</p>
<p>It’s also the one part of the app I can’t test. Apps only get the outer screen through a camera capture accessory, the system decides whether to show it, and the simulator has no camera. I wrote about <a href="https://heynavid.com/blog/iphone-duo-outer-screen-camera-only/">that rule</a> when I first read the SDK. This post is about what it did to the design once I was building on it, with a device I won’t hold until 23 October.</p>
<p>Everything below is implemented and seen working in Apple’s iPhone Duo simulator, in the flat and closed poses. None of it has run on a real Duo yet.</p>
<h2 id="the-outer-screen-is-an-enhancement">The outer screen is an enhancement</h2>
<p>The accessory is only a few lines of SwiftUI, attached to the capture screen and nowhere else:</p>
<pre class="astro-code github-light" style="background-color:#fff;color:#24292e; overflow-x: auto;" tabindex="0" data-language="swift"><code><span class="line"><span style="color:#24292E">content.</span><span style="color:#005CC5">sceneAccessory</span><span style="color:#24292E"> {</span></span>
<span class="line"><span style="color:#005CC5">    CameraCaptureAccessory</span><span style="color:#24292E">(</span><span style="color:#005CC5">isEnabled</span><span style="color:#24292E">: $isEnabled) {</span></span>
<span class="line"><span style="color:#005CC5">        OuterPrompterView</span><span style="color:#24292E">(</span><span style="color:#005CC5">model</span><span style="color:#24292E">: model)</span></span>
<span class="line"><span style="color:#24292E">    }</span></span>
<span class="line"><span style="color:#24292E">    .</span><span style="color:#005CC5">onAvailabilityChange</span><span style="color:#24292E"> { available </span><span style="color:#D73A49">in</span></span>
<span class="line"><span style="color:#005CC5">        Task</span><span style="color:#24292E"> { </span><span style="color:#D73A49">@MainActor</span><span style="color:#D73A49"> in</span><span style="color:#24292E"> model.accessoryAvailable </span><span style="color:#D73A49">=</span><span style="color:#24292E"> available }</span></span>
<span class="line"><span style="color:#24292E">    }</span></span>
<span class="line"><span style="color:#24292E">}</span></span></code></pre>
<p>The interesting line is the last one. The system can decline to show the accessory, or withdraw it mid-take, and the app finds out through that callback. So the design question was never “how does the outer screen look”. It was “what happens to a take when the outer screen goes away”.</p>
<p>The answer is that nothing happens to the take. Recording never depends on the accessory. The inner screen always carries a copy of the script, and when the outer one isn’t showing it, that copy grows to fill its side and becomes the prompter:</p>
<pre class="astro-code github-light" style="background-color:#fff;color:#24292e; overflow-x: auto;" tabindex="0" data-language="swift"><code><span class="line"><span style="color:#6A737D">/// The outer screen isn't showing the script, so the mirror grows.</span></span>
<span class="line"><span style="color:#D73A49">private</span><span style="color:#D73A49"> var</span><span style="color:#24292E"> fallbackActive: </span><span style="color:#005CC5">Bool</span><span style="color:#24292E"> {</span></span>
<span class="line"><span style="color:#24292E">    model.isLive </span><span style="color:#D73A49">&#x26;&#x26;</span><span style="color:#24292E"> (</span><span style="color:#D73A49">!</span><span style="color:#24292E">model.accessoryAvailable </span><span style="color:#D73A49">||</span><span style="color:#D73A49"> !</span><span style="color:#24292E">settings.outerScriptOn)</span></span>
<span class="line"><span style="color:#24292E">}</span></span></code></pre>
<p>Everything you need mid-take is on the inner screen too, for the same reason. Apple’s guidance for accessory content is minimal interaction, and the person reading is standing a little way back anyway, so the outer screen has the script, a status line and at most two big buttons. You can’t drag the text with a finger out there, but a Bluetooth keyboard or a page-turner still moves it.</p>
<h2 id="one-model-for-every-screen">One model for every screen</h2>
<p>The outer prompter, the inner director’s view and the plain prompter on every other iPhone all read the same <code>@Observable</code> capture model. Apple’s article on the accessory recommends sharing the model rather than passing messages between scenes, and it made the fallback almost free: when the accessory disappears there’s no state to hand over, because there was only ever one copy of it.</p>
<p>That model is a state machine that every screen draws from: ready, checking the microphone, counting down, rolling, paused, saved, interrupted. “Hold on” pauses the script, never the camera.</p>
<h2 id="two-layouts-chosen-by-who-is-looking">Two layouts, chosen by who is looking</h2>
<p>The inner screen has two jobs, and which one depends on which camera is recording.</p>
<p>With the main camera, someone else is usually holding the phone, or it’s standing on a table facing you. You read the outer screen, so the inner screen belongs to the operator: the framing on one side, and the script, the sound level, the takes and the record button on the other. That’s the pattern Apple shows in its own camera talk.</p>
<p>With the inner front camera, you’re filming yourself on the big screen. Now the inner screen is for you, so its right-hand page becomes one large page of script with the reading line just under the lens.</p>
<p>The layout is chosen by the camera, not by the device. On any other iPhone the same views collapse into a front-camera prompter with the script just under the camera.</p>
<h2 id="size-classes-nearly-identify-the-duo">Size classes nearly identify the Duo</h2>
<p>My first rule was “regular width means the Duo’s inner screen”. It isn’t true. A large iPhone in landscape is regular width too, so on my own phone every rotation switched to the director layout and the rear camera, and each switch reconfigured the camera. The screen froze while it did.</p>
<p>The fix was to require regular width and regular height, which among iPhones only the Duo’s inner screen has. The Duo-only APIs sit behind <code>if #available(iOS 27.1, *)</code>, and there’s no device-model check anywhere. It’s a small thing, but it’s the difference between an app that adapts and one that guesses.</p>
<h2 id="the-fold-is-a-region-and-sometimes-it-isnt-there">The fold is a region, and sometimes it isn’t there</h2>
<p>The fold comes from the runtime, as a reserved region of kind <code>.division</code>. I expected to keep content off it all the time. Then the simulator reported it as inactive whenever the Duo was flat, and that turns out to be the point: flat, the inner screen is one continuous display, and there’s nothing to avoid. The fold only matters while the device is folded.</p>
<p>So content steps off the hinge only while that region is active. <code>reservedRegions(kind:)</code> returns only active regions unless you ask for inactive ones too, so the loop doesn’t need its own check:</p>
<pre class="astro-code github-light" style="background-color:#fff;color:#24292e; overflow-x: auto;" tabindex="0" data-language="swift"><code><span class="line"><span style="color:#6A737D">// Active divisions only: the fold matters while the Duo is folded.</span></span>
<span class="line"><span style="color:#6A737D">// Flat, it's one continuous screen and reports the division inactive.</span></span>
<span class="line"><span style="color:#D73A49">for</span><span style="color:#24292E"> d </span><span style="color:#D73A49">in</span><span style="color:#24292E"> geo.</span><span style="color:#005CC5">reservedRegions</span><span style="color:#24292E">(</span><span style="color:#005CC5">kind</span><span style="color:#24292E">: .division).</span><span style="color:#005CC5">map</span><span style="color:#24292E">(\.frame)</span></span>
<span class="line"><span style="color:#D73A49">where</span><span style="color:#24292E"> d.maxX </span><span style="color:#D73A49">></span><span style="color:#005CC5"> 0</span><span style="color:#D73A49"> &#x26;&#x26;</span><span style="color:#24292E"> d.minX </span><span style="color:#D73A49">&#x3C;</span><span style="color:#24292E"> width {</span></span>
<span class="line"><span style="color:#D73A49">    if</span><span style="color:#24292E"> d.midX </span><span style="color:#D73A49">&#x3C;</span><span style="color:#24292E"> width </span><span style="color:#D73A49">/</span><span style="color:#005CC5"> 2</span><span style="color:#24292E"> { insets.leading </span><span style="color:#D73A49">=</span><span style="color:#005CC5"> max</span><span style="color:#24292E">(insets.leading, d.maxX </span><span style="color:#D73A49">+</span><span style="color:#24292E"> spacing) }</span></span>
<span class="line"><span style="color:#D73A49">    else</span><span style="color:#24292E"> { insets.trailing </span><span style="color:#D73A49">=</span><span style="color:#005CC5"> max</span><span style="color:#24292E">(insets.trailing, width </span><span style="color:#D73A49">-</span><span style="color:#24292E"> d.minX </span><span style="color:#D73A49">+</span><span style="color:#24292E"> spacing) }</span></span>
<span class="line"><span style="color:#24292E">}</span></span></code></pre>
<p>Two more surprises from the same place. <code>ArrangementView</code> splits the safe width, not the screen at the fold, so its two panes don’t necessarily meet at the hinge. I stopped trying to force a split ratio to make them. And the inner camera sits under the display, so it only occludes the screen while it’s recording, and the simulator always reports it inactive. Only the reading layout clears the lens, because only that layout is used while the inner camera films, and it clears it even when the region says it’s inactive.</p>
<p>A debug launch argument outlines every reserved region on screen, in one colour for the fold and another for the cameras, with whether each is active. It was the quickest way to stop guessing where the hardware was.</p>
<h2 id="system-containers-everywhere-else">System containers everywhere else</h2>
<p>Outside the capture screen, Fold Cue uses the containers Apple already adapted to the fold. Scripts, takes and settings are ordinary split views that collapse to stacks on a phone. Onboarding is an <code>ArrangementView</code> spread. The take review uses plain stacks, because an <code>ArrangementView</code> inside a list or a scroll view is exactly what the documentation tells you not to do. Sheets stay system sheets.</p>
<p>I’d planned custom layouts for several of these screens. Each time, the system container already did the right thing on the fold, and my version would have been one more thing to verify on hardware I don’t have.</p>
<h2 id="a-stand-guide-that-says-its-a-guess">A stand guide that says it’s a guess</h2>
<p>Standing the Duo up only works at some angles. Too closed and it tips; too open and the camera and the outer screen aren’t square to you. So Fold Cue reads the hinge with <code>onHingeChange</code> and suggests a range of angles to stand it at, with “open it a little wider” or “close it a little” until you’re there.</p>
<p>That range is a starting point, and the code says so. I’ll only know the real one when I can stand a real Duo on a real desk. It’s also set-up help and nothing more: no part of recording depends on the hinge.</p>
<h2 id="what-the-simulator-can-and-cant-tell-you">What the simulator can and can’t tell you</h2>
<p>The iPhone Duo simulator is good for layout and useless for the camera. The pose can only be set by hand in Device Hub, and only the awake display renders. It has no camera and no speech model, so the capture screen falls back to steady scroll with a message saying why. That fallback earned its keep in testing long before any user sees it.</p>
<p>To get useful screenshots anyway, debug builds take launch arguments that put the app straight into a state: the capture screen mid-take, paused, counting down, the reading layout, seeded takes, the reserved-region outlines. Combined with the simulator’s screenshot command for each display, that was enough to review every screen on both displays without being able to tap either. Several real bugs turned up that way, like text running under the inner camera and controls overlapping the script.</p>
<p>The list of things that need a real Duo is short and specific: whether the accessory shows reliably when the phone is standing, the director’s view with an actual camera image in it, which rear camera really faces you, and the stand angle.</p>
<p>Designing the fallback first felt like pessimism at the time. It’s the reason I’m not worried about the list.</p>]]></content:encoded>
    </item>
    <item>
      <title>Following a voice through a script, on the device</title>
      <link>https://heynavid.com/blog/following-a-voice-through-a-script/</link>
      <guid isPermaLink="true">https://heynavid.com/blog/following-a-voice-through-a-script/</guid>
      <pubDate>Mon, 28 Sep 2026 00:00:00 GMT</pubDate>
      <dc:creator>Navid</dc:creator>
      <category>foldcue</category>
      <category>ios</category>
      <category>swift</category>
      <description>Fold Cue's script scrolls as you speak and waits when you stop. The hard part isn't hearing the words. It's deciding where in the script the speaker is right now, and never guessing out loud.</description>
      <content:encoded><![CDATA[<p><img src="https://heynavid.com/blog/images/following-a-voice-through-a-script-hero.jpg" alt="" /></p><p>Fold Cue is a teleprompter that records you. The script scrolls as you speak, and it waits when you stop. That one behaviour is the whole product, so it was the first thing I built and the thing I tested hardest.</p>
<p>Before writing any of it I read a lot of reviews of other teleprompter apps that scroll by voice. The complaints were remarkably consistent. The text stops moving and nothing says why. It races ahead during an ad-lib. It drifts over a long session until it’s reading somewhere else entirely. Nobody complained that recognition was slightly wrong. They complained that the app was wrong without saying so.</p>
<p>So the engine has one rule above the others: it may lose you, but it must never pretend it hasn’t.</p>
<h2 id="two-kinds-of-words">Two kinds of words</h2>
<p>Speech comes from <code>SpeechAnalyzer</code> with a <code>SpeechTranscriber</code>, which is new in iOS 26 and runs entirely on the device. Once the language model is downloaded it works offline, and no audio, transcript or script leaves the phone.</p>
<p>The transcriber gives you two kinds of result. <strong>Volatile</strong> results arrive quickly and keep changing as it hears more. <strong>Final</strong> results arrive a little later and don’t change again. A teleprompter needs both: volatile words to move the text without lag, and final words, with their audio time ranges, for everything that happens after the take.</p>
<pre class="astro-code github-light" style="background-color:#fff;color:#24292e; overflow-x: auto;" tabindex="0" data-language="swift"><code><span class="line"><span style="color:#D73A49">let</span><span style="color:#24292E"> transcriber </span><span style="color:#D73A49">=</span><span style="color:#005CC5"> SpeechTranscriber</span><span style="color:#24292E">(</span></span>
<span class="line"><span style="color:#005CC5">    locale</span><span style="color:#24292E">: locale,</span></span>
<span class="line"><span style="color:#005CC5">    transcriptionOptions</span><span style="color:#24292E">: [],</span></span>
<span class="line"><span style="color:#005CC5">    reportingOptions</span><span style="color:#24292E">: [.volatileResults, .fastResults],</span></span>
<span class="line"><span style="color:#005CC5">    attributeOptions</span><span style="color:#24292E">: [.audioTimeRange, .transcriptionConfidence]</span></span>
<span class="line"><span style="color:#24292E">)</span></span></code></pre>
<p>Each take gets its own analysis session, and the model stays loaded between them. The script’s rare words, like names and brand terms, go in as contextual strings, which helps the recogniser with exactly the words a general model is most likely to get wrong.</p>
<p>Every heard word is normalised before it gets near the script: lower case, curly quotes folded, punctuation stripped, and figures written out as words in the script’s language. Nobody reads a figure aloud as digits, so the script has to meet the speaker halfway.</p>
<h2 id="where-is-the-speaker-right-now">Where is the speaker right now?</h2>
<p>This is the real problem. The follower takes the last handful of heard words and aligns them against a small window of the script around the current position, reaching a little behind and further ahead. It’s Smith-Waterman local alignment, the same idea biologists use to find where a short sequence sits inside a long one.</p>
<p>Two details make it work for speech. Words match fuzzily, by the share of letters they have in common, so “recognise” still matches “recognize”. But below a floor a word scores nothing at all, so a badly misheard word doesn’t drag the alignment somewhere odd, and its neighbours carry it instead. And the alignment has to end on the newest word heard, because the question isn’t “where does this phrase appear” but “where is the speaker now”.</p>
<p>Small words like “the” and “and” count for less. They’re everywhere, so they’re weak evidence of position.</p>
<h2 id="forward-is-cheap-backward-is-expensive">Forward is cheap, backward is expensive</h2>
<p>The position moves on different levels of evidence, and they’re deliberately lopsided:</p>
<pre class="astro-code github-light" style="background-color:#fff;color:#24292e; overflow-x: auto;" tabindex="0" data-language="swift"><code><span class="line"><span style="color:#D73A49">if</span><span style="color:#D73A49"> let</span><span style="color:#24292E"> r </span><span style="color:#D73A49">=</span><span style="color:#24292E"> local, r.score </span><span style="color:#D73A49">>=</span><span style="color:#24292E"> tuning.acceptScore, r.matches </span><span style="color:#D73A49">>=</span><span style="color:#24292E"> tuning.acceptMatches {</span></span>
<span class="line"><span style="color:#6A737D">    // modest evidence, just ahead of where we were: move forward</span></span>
<span class="line"><span style="color:#D73A49">    if</span><span style="color:#24292E"> r.</span><span style="color:#005CC5">end</span><span style="color:#D73A49"> ></span><span style="color:#24292E"> position { position </span><span style="color:#D73A49">=</span><span style="color:#24292E"> r.</span><span style="color:#005CC5">end</span><span style="color:#24292E">; moved </span><span style="color:#D73A49">=</span><span style="color:#005CC5"> true</span><span style="color:#24292E"> }</span></span>
<span class="line"><span style="color:#24292E">} </span><span style="color:#D73A49">else</span><span style="color:#D73A49"> if</span><span style="color:#D73A49"> let</span><span style="color:#24292E"> j </span><span style="color:#D73A49">=</span><span style="color:#24292E"> aligner.</span><span style="color:#005CC5">align</span><span style="color:#24292E">(heard, script, </span><span style="color:#005CC5">in</span><span style="color:#24292E">: (position </span><span style="color:#D73A49">+</span><span style="color:#005CC5"> 1</span><span style="color:#24292E">)</span><span style="color:#D73A49">..&#x3C;</span><span style="color:#24292E">end),</span></span>
<span class="line"><span style="color:#24292E">          j.score </span><span style="color:#D73A49">>=</span><span style="color:#24292E"> tuning.jumpScore, j.matches </span><span style="color:#D73A49">>=</span><span style="color:#24292E"> tuning.jumpMatches {</span></span>
<span class="line"><span style="color:#6A737D">    // strong evidence further on: the speaker skipped ahead</span></span>
<span class="line"><span style="color:#24292E">    position </span><span style="color:#D73A49">=</span><span style="color:#24292E"> j.</span><span style="color:#005CC5">end</span><span style="color:#24292E">; jumped </span><span style="color:#D73A49">=</span><span style="color:#005CC5"> true</span></span>
<span class="line"><span style="color:#24292E">} </span><span style="color:#D73A49">else</span><span style="color:#D73A49"> if</span><span style="color:#24292E"> update.time </span><span style="color:#D73A49">></span><span style="color:#24292E"> anchorUntil,</span></span>
<span class="line"><span style="color:#D73A49">          let</span><span style="color:#24292E"> b </span><span style="color:#D73A49">=</span><span style="color:#24292E"> aligner.</span><span style="color:#005CC5">align</span><span style="color:#24292E">(heard, script, </span><span style="color:#005CC5">in</span><span style="color:#24292E">: earlier),</span></span>
<span class="line"><span style="color:#24292E">          b.score </span><span style="color:#D73A49">>=</span><span style="color:#24292E"> tuning.backScore, b.matches </span><span style="color:#D73A49">>=</span><span style="color:#24292E"> tuning.backMatches {</span></span>
<span class="line"><span style="color:#6A737D">    // very strong evidence behind: the speaker really did go back</span></span>
<span class="line"><span style="color:#24292E">    position </span><span style="color:#D73A49">=</span><span style="color:#24292E"> b.</span><span style="color:#005CC5">end</span><span style="color:#24292E">; jumped </span><span style="color:#D73A49">=</span><span style="color:#005CC5"> true</span></span>
<span class="line"><span style="color:#24292E">}</span></span></code></pre>
<p>Reading on is what people do almost all the time, so it needs only a little evidence. Skipping a paragraph happens, so a jump is allowed, but only when many words agree. Going back is rare and a wrong backward move is the most disorienting thing a prompter can do, so it needs the most evidence of all. It also happens on purpose when you say “again”, which ends the take and starts a new one from the top.</p>
<p>If you nudge the text by hand, by tapping a word or dragging a line, the follower takes that as the new position and ignores backward evidence for a moment. Without that, it would sometimes snap back to where it thought you were, which is exactly the fight with the app that people complain about.</p>
<h2 id="an-ad-lib-never-moves-the-text">An ad-lib never moves the text</h2>
<p>When speech keeps coming but matches nothing, the position stays put. An aside, a joke or a reworded sentence shouldn’t scroll the script.</p>
<p>If it goes on for a while, the state changes from following to lost, and the prompter says so: “Lost you. Say the line again.” There are only a few states, listening, following and lost, and one of them is always on screen while you record. That small status line answers the question every review was really asking, which is whether the thing is still listening.</p>
<p>Silence stops the text for free. There’s no separate silence detector: the text only moves when heard words line up with the script, so a pause, or a noisy room, can’t scroll it. I’d planned to use the speech detector as an extra signal, but in the spike it never reported anything, and the follower turned out not to need it.</p>
<h2 id="commands-that-arent-lines">Commands that aren’t lines</h2>
<p>Fold Cue listens for spoken commands between lines: “cut” saves the take, “again” saves it and starts a new one from the top, “keep” said after a cut stars the take you just saved, and “hold on” holds the script. The trouble is that scripts contain those words. “Cut the stems at an angle” is a line, not an instruction.</p>
<p>So a command only counts when it’s said on its own. It has to be a very short utterance between pauses, and it must not line up with the script where the speaker is:</p>
<pre class="astro-code github-light" style="background-color:#fff;color:#24292e; overflow-x: auto;" tabindex="0" data-language="swift"><code><span class="line"><span style="color:#D73A49">guard</span><span style="color:#D73A49"> !</span><span style="color:#24292E">words.</span><span style="color:#005CC5">isEmpty</span><span style="color:#24292E">, words.</span><span style="color:#005CC5">count</span><span style="color:#D73A49"> &#x3C;=</span><span style="color:#24292E"> maxWords,</span></span>
<span class="line"><span style="color:#24292E">      silenceBefore </span><span style="color:#D73A49">>=</span><span style="color:#24292E"> minSilence, silenceAfter </span><span style="color:#D73A49">>=</span><span style="color:#24292E"> minSilence,</span></span>
<span class="line"><span style="color:#24292E">      scriptScore </span><span style="color:#D73A49">&#x3C;</span><span style="color:#24292E"> readingThreshold </span><span style="color:#D73A49">else</span><span style="color:#24292E"> { </span><span style="color:#D73A49">return</span><span style="color:#005CC5"> nil</span><span style="color:#24292E"> }</span></span></code></pre>
<p>It acts on final results, which the recogniser only delivers after a pause, so the pause afterwards comes with the result rather than being timed separately. Fillers like “um” and “okay” are dropped first, so “okay, cut” still works. The command’s time range is kept, padded slightly, so Pro can trim it out of the finished video. Commands work in English and German; scripts in other languages use the English words.</p>
<h2 id="moving-by-line-not-by-word">Moving by line, not by word</h2>
<p>The prompter moves by whole lines, never word by word. Text that shuffles under your eyes is harder to read than text that stays still and then steps. The line holding the next word to say sits on the reading line, and words already said go grey.</p>
<p>The prototype had a bright highlight on the current word. It looked great in a demo and was tiring to read from, so it went. Now a small amber tick marks the reading line, and nothing else moves until you reach the end of it.</p>
<h2 id="testing-a-voice-without-a-voice">Testing a voice without a voice</h2>
<p>The engine lives in a small Swift package, <code>StudioCore</code>, with no UIKit and no AVFoundation. The follower, the command detector, the line planner and the caption builder are all pure Swift and all run as ordinary tests on the Mac. That mattered more than anything else here, because you can’t tune something like this by reading it aloud to your phone again and again.</p>
<p>The first version was a spike on the Mac. I wrote a sample script and a deliberately messy reading of it: an ad-lib, a skipped sentence, a false start, a reworded line and a whole skipped paragraph. It was rendered with the system’s text-to-speech in a British and an American voice, plus a far-field copy with added echo and noise. A small command-line tool streamed each file into <code>SpeechAnalyzer</code> at real-time pace and logged every result with the moment it arrived.</p>
<p>The follower was written first against those logs, as a quick script, and then ported to Swift with the same parameters. The logs became test fixtures. <code>FollowerRegressionTests</code> replays each one through the Swift follower and checks the behaviour: that it keeps up, that an ad-lib moves nothing, and that a skipped paragraph is picked up quickly. Every tuning change has to pass the whole suite.</p>
<p>The far-field fixture passes too, with looser limits on lag than the clean ones: it holds still through the ad-lib and makes the paragraph jump.</p>
<p>Synthetic voices are still the obvious weakness. They’re too clean and too regular. The real-world evidence so far is me reading to my own iPhone, where voice follow and “cut” behaved, and that reading wasn’t captured as a fixture. Nothing has been tested at a distance yet, or on iPhone Duo’s own microphones. The harness that made the fixtures is still in the repo, so every real reading I record from now on can become another one.</p>
<p>The best thing the tests gave me wasn’t confidence that it works. It was the freedom to change the tuning and find out in seconds what I’d broken.</p>]]></content:encoded>
    </item>
    <item>
      <title>Takes that survive a phone call, and captions spelled the way you wrote them</title>
      <link>https://heynavid.com/blog/takes-that-survive-a-phone-call/</link>
      <guid isPermaLink="true">https://heynavid.com/blog/takes-that-survive-a-phone-call/</guid>
      <pubDate>Mon, 28 Sep 2026 00:00:00 GMT</pubDate>
      <dc:creator>Navid</dc:creator>
      <category>foldcue</category>
      <category>ios</category>
      <category>avfoundation</category>
      <description>Fold Cue records with an asset writer rather than the simple movie output, feeds the same microphone audio to the recording and to speech recognition, and keeps everything on one clock. That one decision is what makes crash-safe takes, trimmed commands and script-perfect captions possible.</description>
      <content:encoded><![CDATA[<p><img src="https://heynavid.com/blog/images/takes-that-survive-a-phone-call-hero.jpg" alt="" /></p><p>The quickest way to record video on iOS is <code>AVCaptureMovieFileOutput</code>. You add it to a capture session, give it a file and tell it to start. For most camera apps that’s the right choice.</p>
<p>Fold Cue doesn’t use it, and almost everything I like about the app follows from that.</p>
<h2 id="why-not-the-movie-file-output">Why not the movie file output</h2>
<p>A teleprompter needs the microphone for two jobs at once. The recording needs the audio, and voice follow needs the same audio at the same moment to work out where you are in the script. The SDK doesn’t promise that a movie file output can share a session with an audio data output, and I didn’t want the one feature the whole app depends on to rest on behaviour nobody documents.</p>
<p>So Fold Cue takes the long way round. Video and audio both arrive as sample buffers through data outputs, and an <code>AVAssetWriter</code> writes the movie. Each audio buffer is converted once and handed to two places: the writer, and the speech engine. The meter before a take reads from the same copy.</p>
<p>It’s more code than the movie output. It also gave me things the movie output couldn’t.</p>
<h2 id="a-take-survives-almost-anything">A take survives almost anything</h2>
<p>The writer writes a fragmented movie. Every so often it finishes a fragment, and everything up to that point is a playable file on disk, whatever happens next.</p>
<pre class="astro-code github-light" style="background-color:#fff;color:#24292e; overflow-x: auto;" tabindex="0" data-language="swift"><code><span class="line"><span style="color:#D73A49">let</span><span style="color:#24292E"> writer </span><span style="color:#D73A49">=</span><span style="color:#D73A49"> try</span><span style="color:#005CC5"> AVAssetWriter</span><span style="color:#24292E">(</span><span style="color:#005CC5">outputURL</span><span style="color:#24292E">: url, </span><span style="color:#005CC5">fileType</span><span style="color:#24292E">: .mov)</span></span>
<span class="line"><span style="color:#24292E">writer.movieFragmentInterval </span><span style="color:#D73A49">=</span><span style="color:#24292E"> fragmentInterval</span></span></code></pre>
<p>When a take starts, Fold Cue also writes a small marker file saying a take is in progress, and removes it when the take is saved. If the app is killed mid-take, by a crash, the system or a flat battery, the marker is still there on the next launch. The app finds it and recovers the take from its fragments, with everything up to a moment before the end.</p>
<p>A phone call is gentler. The system takes the camera away, the take is finalised, and the screen says “Take saved up to” and the time it reached, then offers to carry on. Heat is handled the same way. When the phone gets hot while recording at the highest resolution, the app suggests stepping down after the current take. If it gets too hot to carry on, it saves the take cleanly and says why, rather than letting the recording fail. The rule behind all of this is that a take you recorded is never lost because of something that wasn’t your fault.</p>
<h2 id="one-clock-for-everything">One clock for everything</h2>
<p>The second thing the writer gives you is exact time. Every audio buffer that goes to speech is stamped with its position on the take’s own timeline, counted from the first video frame. The speech engine passes that through, so every word it recognises comes back with a time range that lines up with the recording.</p>
<p>That sounds like bookkeeping, and it is. It’s also the whole foundation of the Pro features. A spoken command, a caption and a stumble mark are all just times on that one clock.</p>
<h2 id="cutting-the-commands-out">Cutting the commands out</h2>
<p>When you say “cut” or “again” between lines, the command detector keeps the time range of what you said, padded slightly on both sides. At export, Pro removes those ranges. The composition is the take minus the merged command ranges, stitched back together:</p>
<pre class="astro-code github-light" style="background-color:#fff;color:#24292e; overflow-x: auto;" tabindex="0" data-language="swift"><code><span class="line"><span style="color:#6A737D">// Keep-ranges = everything minus the (merged) command ranges.</span></span>
<span class="line"><span style="color:#D73A49">var</span><span style="color:#24292E"> keep: [</span><span style="color:#005CC5">ClosedRange</span><span style="color:#24292E">&#x3C;</span><span style="color:#005CC5">Double</span><span style="color:#24292E">>] </span><span style="color:#D73A49">=</span><span style="color:#24292E"> []</span></span>
<span class="line"><span style="color:#D73A49">var</span><span style="color:#24292E"> cursor </span><span style="color:#D73A49">=</span><span style="color:#005CC5"> 0.0</span></span>
<span class="line"><span style="color:#D73A49">for</span><span style="color:#24292E"> r </span><span style="color:#D73A49">in</span><span style="color:#24292E"> trims {</span></span>
<span class="line"><span style="color:#D73A49">    if</span><span style="color:#24292E"> r.lowerBound </span><span style="color:#D73A49">></span><span style="color:#24292E"> cursor { keep.</span><span style="color:#005CC5">append</span><span style="color:#24292E">(cursor</span><span style="color:#D73A49">...</span><span style="color:#24292E">r.lowerBound) }</span></span>
<span class="line"><span style="color:#24292E">    cursor </span><span style="color:#D73A49">=</span><span style="color:#005CC5"> max</span><span style="color:#24292E">(cursor, r.upperBound)</span></span>
<span class="line"><span style="color:#24292E">}</span></span>
<span class="line"><span style="color:#D73A49">if</span><span style="color:#24292E"> duration </span><span style="color:#D73A49">></span><span style="color:#24292E"> cursor { keep.</span><span style="color:#005CC5">append</span><span style="color:#24292E">(cursor</span><span style="color:#D73A49">...</span><span style="color:#24292E">duration) }</span></span></code></pre>
<p>Each kept range is inserted into an <code>AVMutableComposition</code>, video and audio together, one after another. The words “cut” and “again” never make it into the finished video.</p>
<h2 id="captions-spelled-like-the-script">Captions spelled like the script</h2>
<p>Automatic captions have one well-known weakness: they spell things the way they sound. Names, product words and anything unusual come out wrong, which is why people spend so long fixing them.</p>
<p>A teleprompter has an advantage here, because it knows what you meant to say. The caption builder aligns the heard words, with their times, to the script’s words, using the same aligner as voice follow. Every matched word is shown with the script’s spelling and punctuation, timed to when you actually said it. Words you ad-libbed aren’t in the script, so they appear as they were heard. Cues break at the ends of sentences, at pauses, and before they get too long to read.</p>
<p>The captions go out as SRT or WebVTT files, or burned into the video with a Core Animation layer during export. One detail matters more than it looks: each take keeps its own copy of the script text it was read from. Edit the script tomorrow and yesterday’s take still captions against the words you actually read.</p>
<h2 id="handing-it-to-final-cut-pro">Handing it to Final Cut Pro</h2>
<p>For anyone who edits properly, Fold Cue can export a folder for Final Cut Pro: the untouched take and an FCPXML project that refers to it. The project is written with the removed commands as real edits rather than baked into the video, a marker at the start of every script paragraph, found from when you said its first words, and the captions as a caption lane inside the project. The idea is that you can undo any of the cuts in Final Cut. I still have to open one in Final Cut on a Mac to confirm it imports the way I intend.</p>
<p>The writer is pure string building in the engine package, with no AVFoundation at all, so it’s deterministic and tested on the Mac like the rest of the engine. Final Cut is fussy about time, and everything on its timeline has to fall on a whole frame, which is exactly the kind of rule a test catches and a person doesn’t.</p>
<p>The take on disk is never changed by any of this. Every export starts from the original, which is what makes it safe to try things.</p>
<h2 id="scoring-as-a-sorting-aid">Scoring, as a sorting aid</h2>
<p>The same alignment scores each take against the script: how much of it you covered, and where you stumbled, meaning a run of words that matched nothing in the middle of a passage, or a restart. “Pick the best” uses that to suggest a take.</p>
<p>It’s a sorting aid, not a grade. The code says so in a comment, because the best take is sometimes the one where you went off script on purpose, and no score should talk you out of it.</p>
<p>What I keep noticing about this part of the app is how little of it is about video. The camera and the encoder are the system’s job. The value is in knowing what was said, and when, on a clock everything else agrees with.</p>]]></content:encoded>
    </item>
    <item>
      <title>The teleprompter text that cost more than the camera</title>
      <link>https://heynavid.com/blog/teleprompter-text-cost-more-than-the-camera/</link>
      <guid isPermaLink="true">https://heynavid.com/blog/teleprompter-text-cost-more-than-the-camera/</guid>
      <pubDate>Mon, 28 Sep 2026 00:00:00 GMT</pubDate>
      <dc:creator>Navid</dc:creator>
      <category>foldcue</category>
      <category>ios</category>
      <category>swiftui</category>
      <category>performance</category>
      <description>Fold Cue froze for seconds every time I rotated my phone. Two of the causes were visible in the code without any tools. The biggest one only showed up in a trace: the scrolling script was being redrawn on the main thread, over and over.</description>
      <content:encoded><![CDATA[<p><img src="https://heynavid.com/blog/images/teleprompter-text-cost-more-than-the-camera-hero.jpg" alt="" /></p><p>The first time I recorded a real take with Fold Cue on my own phone, it worked. Voice follow kept up, “cut” saved the take, and the video was fine. Then I turned the phone sideways and the screen froze for long enough that I thought it had crashed.</p>
<p>A teleprompter is a strange app to make fast. The expensive parts, the camera, the encoder and speech recognition, all run in the system on their own hardware. The app mostly draws text. It should cost almost nothing, and when it doesn’t, the text is where to look. It took me a while to believe that.</p>
<h2 id="two-bugs-you-could-find-by-reading">Two bugs you could find by reading</h2>
<p>Before running any tools I read the capture code, and two problems were sitting there.</p>
<p>The first was a layout rule. Fold Cue uses the regular-width size class to mean “this is iPhone Duo’s inner screen”, where it shows the director’s view and records on the rear camera. But a large iPhone in landscape is regular width too. So every rotation on my phone switched to the Duo layout and the rear camera, and switching cameras reconfigures the capture session, which is slow. The fix was to require regular width and regular height, which among iPhones only the Duo’s inner screen has. Rotation stopped touching the camera at all.</p>
<p>The second was the timecode. The recording clock ticked many times a second, and each tick read the recording time from the capture queue with <code>queue.sync</code>. Most of the time that returns instantly. While the session is reconfiguring, the capture queue is busy for a long time, and <code>queue.sync</code> makes the main thread wait for it. That was the frozen screen.</p>
<p>Now the capture side publishes a small snapshot behind a lock, and the interface reads that instead. The main thread never waits for the capture queue, whatever it’s doing:</p>
<pre class="astro-code github-light" style="background-color:#fff;color:#24292e; overflow-x: auto;" tabindex="0" data-language="swift"><code><span class="line"><span style="color:#6A737D">/// What the UI reads while recording. A lock, never `queue.sync`: the main</span></span>
<span class="line"><span style="color:#6A737D">/// thread must never wait for the capture queue, which can be busy for</span></span>
<span class="line"><span style="color:#6A737D">/// a long time while the session reconfigures.</span></span>
<span class="line"><span style="color:#D73A49">private</span><span style="color:#D73A49"> let</span><span style="color:#24292E"> shared </span><span style="color:#D73A49">=</span><span style="color:#005CC5"> Mutex</span><span style="color:#24292E">(</span><span style="color:#005CC5">Snapshot</span><span style="color:#24292E">())</span></span>
<span class="line"></span>
<span class="line"><span style="color:#D73A49">func</span><span style="color:#6F42C1"> snapshot</span><span style="color:#24292E">() </span><span style="color:#D73A49">-></span><span style="color:#24292E"> Snapshot { shared.</span><span style="color:#005CC5">withLock</span><span style="color:#24292E"> { </span><span style="color:#005CC5">$0</span><span style="color:#24292E"> } }</span></span></code></pre>
<p>The timecode also updates once a second now. Nobody reads a clock faster than that.</p>
<h2 id="a-trace-of-the-wrong-moment">A trace of the wrong moment</h2>
<p>Then I profiled on the device, with <code>xctrace</code> recording the Time Profiler for every process and filtering the results by the app’s process afterwards. Attaching to the app by name turned out to be unreliable, and recording everything was simpler.</p>
<p>My first trace was useless. It caught the app sitting idle, and many of the function names didn’t resolve. It’s worth saying, because it’s easy to look at a quiet trace and conclude there’s no problem. A trace only tells you about the moment it recorded. The useful one was taken while actually doing the thing that hurt: reading a script aloud while recording, and rotating the phone once in the middle.</p>
<p>That trace showed two things I hadn’t expected at all.</p>
<h2 id="the-text-was-being-drawn-on-the-main-thread">The text was being drawn on the main thread</h2>
<p>The prompter was SwiftUI <code>Text</code>, large, and animated as it scrolled. SwiftUI was re-rasterising those glyphs on the CPU, on the main thread, continuously, for as long as the text moved. For an app whose main job is showing text, the text was the most expensive thing it did.</p>
<p>I rewrote the prompter’s text in UIKit. There’s one <code>UILabel</code> per visible line. Each label is drawn once and only redrawn when its own words change colour, when a word goes from unsaid to said. Scrolling doesn’t redraw anything: it moves one container layer, which the GPU does for free.</p>
<pre class="astro-code github-light" style="background-color:#fff;color:#24292e; overflow-x: auto;" tabindex="0" data-language="swift"><code><span class="line"><span style="color:#6A737D">/// A line needs redrawing only if one of these changes.</span></span>
<span class="line"><span style="color:#D73A49">private</span><span style="color:#D73A49"> struct</span><span style="color:#6F42C1"> LineKey</span><span style="color:#24292E">: </span><span style="color:#005CC5">Equatable</span><span style="color:#24292E"> {</span></span>
<span class="line"><span style="color:#D73A49">    var</span><span style="color:#24292E"> saidTokens: </span><span style="color:#005CC5">Int</span></span>
<span class="line"><span style="color:#D73A49">    var</span><span style="color:#24292E"> dimmed: </span><span style="color:#005CC5">Bool</span></span>
<span class="line"><span style="color:#D73A49">    var</span><span style="color:#24292E"> size: CGFloat</span></span>
<span class="line"><span style="color:#D73A49">    var</span><span style="color:#24292E"> layoutID: </span><span style="color:#005CC5">Int</span></span>
<span class="line"><span style="color:#24292E">}</span></span></code></pre>
<p>A small detail from the same file: the text colours are opaque on purpose. Text drawn in a colour with transparency needs an extra transparency layer for every run when it’s rasterised. The dimmed greys are real greys, not white at low opacity.</p>
<p>After the rewrite, text drawing on the main thread went from the biggest thing in the trace to nothing I could find.</p>
<h2 id="the-compositor-was-re-blurring-the-camera">The compositor was re-blurring the camera</h2>
<p>The other surprise wasn’t in my process at all. The system compositor was working hard, and the reason was the frosted panels I’d put over the live camera preview. A blur material over video has to be recomputed for every frame of the video. They looked nice and cost a lot, continuously, for as long as the camera ran.</p>
<p>They’re solid translucent fills now. On a camera screen nobody misses the frost.</p>
<h2 id="the-fade-that-came-back-as-dark-bands">The fade that came back as dark bands</h2>
<p>The script fades out towards the top and bottom edges, so lines arrive and leave softly. During the rewrite I did that with SwiftUI gradient overlays on top of the text. Over the live camera they showed up as dark bands, a stripe of gradient sitting on the video rather than fading the text into it.</p>
<p>The fix was to do the fade where the text is: a <code>CAGradientLayer</code> set as the mask of the UIKit view that holds the lines. A layer mask is composited on the GPU, the labels underneath are never redrawn because of it, and it cost nothing I could measure in the next trace.</p>
<h2 id="everything-else-off-the-main-thread">Everything else, off the main thread</h2>
<p>Around those, the rest was moving work to where it belonged:</p>
<ul>
<li><strong>Voice follow, commands and scoring</strong> run in a background actor. The main thread gets a finished position, not the alignment work that produced it.</li>
<li><strong>One camera preview layer for the whole session.</strong> It’s moved between layouts rather than recreated, because a new preview layer means a new connection to the camera.</li>
<li><strong>File writes and disk-space checks</strong> happen in detached tasks.</li>
<li><strong>Values that change many times a second</strong>, like the audio level, are read only by the small views that show them, so a changing meter never re-evaluates a whole screen.</li>
<li><strong>The text size never changes mid-take.</strong> Fold Cue estimates how far away you’re standing and sizes the text to match, but only between takes, so a reflow can never make you lose your place, and the face detection runs on its own queue without touching the video.</li>
</ul>
<p>The result, on my phone, is that recording now costs the main thread almost nothing. The worst moment, a rotation, is a small fraction of what it was, and there were no hangs in any trace since.</p>
<h2 id="the-engine-got-faster-too">The engine got faster too</h2>
<p>The same pass found slow spots in the engine. Scoring a long take compares every heard word against every script word, and on a long script it was painfully slow. Now the aligner works in windows, and word comparison works on bytes and throws away pairs that can’t possibly match before doing any real work:</p>
<pre class="astro-code github-light" style="background-color:#fff;color:#24292e; overflow-x: auto;" tabindex="0" data-language="swift"><code><span class="line"><span style="color:#6A737D">// The best possible ratio is limited by the shorter word.</span></span>
<span class="line"><span style="color:#6A737D">// Skip pairs that can't reach the floor before doing any DP.</span></span>
<span class="line"><span style="color:#D73A49">if</span><span style="color:#005CC5"> 2.0</span><span style="color:#D73A49"> *</span><span style="color:#005CC5"> Double</span><span style="color:#24292E">(</span><span style="color:#005CC5">min</span><span style="color:#24292E">(n, m)) </span><span style="color:#D73A49">/</span><span style="color:#005CC5"> Double</span><span style="color:#24292E">(n </span><span style="color:#D73A49">+</span><span style="color:#24292E"> m) </span><span style="color:#D73A49">&#x3C;</span><span style="color:#24292E"> floor { </span><span style="color:#D73A49">return</span><span style="color:#005CC5"> 0</span><span style="color:#24292E"> }</span></span></code></pre>
<p>Long-take scoring went from something you’d wait for to something you don’t notice. Both have performance tests now, so they can’t quietly slip back.</p>
<p>The lesson I keep relearning is that the code you think is expensive is rarely the code that is. I’d have bet on speech recognition or the video encoder. It was a paragraph of text, and a few frosted panels.</p>]]></content:encoded>
    </item>
    <item>
      <title>What Fold Cue would never cut, and what it isn't trying to be</title>
      <link>https://heynavid.com/blog/what-fold-cue-would-never-cut/</link>
      <guid isPermaLink="true">https://heynavid.com/blog/what-fold-cue-would-never-cut/</guid>
      <pubDate>Mon, 28 Sep 2026 00:00:00 GMT</pubDate>
      <dc:creator>Navid</dc:creator>
      <category>foldcue</category>
      <category>iphone-duo</category>
      <category>indie</category>
      <category>behind-the-scenes</category>
      <description>Fold Cue was planned in a hurry, for a phone that isn't out yet. The most useful part of the plan wasn't the feature list. It was deciding in advance what would never be traded away when time ran short, and what the app wasn't going to try to be.</description>
      <content:encoded><![CDATA[<p><img src="https://heynavid.com/blog/images/what-fold-cue-would-never-cut-hero.jpg" alt="" /></p><p>Fold Cue went from an idea to a working build very quickly, for a phone that goes on sale on 23 October. Plans written in a hurry tend to be feature lists. This one worked better than most of mine, and looking back, the reason is that the most important sections weren’t about features at all.</p>
<h2 id="research-before-code">Research before code</h2>
<p>Before any app code there was a clickable design prototype, a pile of notes from reading reviews of teleprompter apps, and a small spike to find out whether on-device speech could keep up with a reader at all.</p>
<p>The reviews were the most useful of those. People didn’t ask for more features. They asked for the features they had to stop failing quietly: text that stalls with no explanation, scrolling that drifts over a long session, a take lost to a phone call, captions that need fixing word by word, and a subscription for something they use now and then. That list became the product.</p>
<p>The spike came next, because if speech couldn’t follow a messy reading on the device, there was no app. It could, well enough to build on, and its logs became the engine’s first tests. I wrote about that part in <a href="https://heynavid.com/blog/following-a-voice-through-a-script/">Following a voice through a script, on the device</a>.</p>
<h2 id="the-engine-first-in-its-own-package">The engine first, in its own package</h2>
<p>The first real code was a small Swift package for the engine, with no UIKit and no AVFoundation. The follower, the command detector, the captions and the scoring are the part that makes Fold Cue good or bad, and they’re also the part that’s easiest to test away from a phone.</p>
<p>Doing it in that order meant the hardest logic had tests before there was an app to put it in, and every later change to the app sat on something that had already been checked.</p>
<h2 id="what-would-never-be-cut">What would never be cut</h2>
<p>Every plan like this has a list of what to drop when you’re behind. The more useful list is the opposite one: what doesn’t get dropped, however late it is. For Fold Cue it’s short.</p>
<p><strong>Voice-follow quality.</strong> It’s the reason to choose this app over a free one. A teleprompter that follows badly is worse than one that scrolls at a fixed pace, because at least the fixed one is predictable. If time ran short, features would go before the follower got less careful.</p>
<p><strong>Takes that survive.</strong> A take is someone’s effort, and often their nerve. Crash-safe recording, recovery on the next launch and saving cleanly on a phone call aren’t polish. Losing a take is the one failure a user never forgives.</p>
<p><strong>The fallback for the outer screen.</strong> The outer-screen prompter is the headline feature on iPhone Duo, and it’s the one the system is allowed to withdraw. The inner screen always carries the script, so a take never depends on it. I wrote about designing around that in <a href="https://heynavid.com/blog/designing-for-an-outer-screen-i-cant-test/">Designing Fold Cue around an outer screen I can’t test yet</a>.</p>
<p><strong>The mic check.</strong> Before each take, the countdown waits until it can actually hear you. It’s a small feature and a boring one, and it exists because a take recorded with a muted microphone looks fine until you play it back.</p>
<p>Writing these down early made later decisions easy. Whenever something had to give, the question was only whether it touched something on that list.</p>
<h2 id="free-is-the-whole-app">Free is the whole app</h2>
<p>The line between free and Pro was decided before the paywall existed, and it’s a simple one. Free is everything you need to record a script: voice follow, steady scroll, recording, spoken commands, rehearsing, the full iPhone Duo setup, and export to Photos with no watermark and no time limit. It isn’t a trial.</p>
<p>Pro is the studio tools on top: captions from your script, spoken commands trimmed out, take scoring to pick the best one, higher-resolution recording, script sync, and Final Cut Pro projects. It’s one purchase that’s yours to keep. There’s no subscription and no ads.</p>
<p>The rule that matters most is written into the project’s own notes: never move a free feature behind the paywall later. Plenty of apps start generous and tighten, and the reviews show how people feel about it.</p>
<h2 id="what-it-isnt-trying-to-be">What it isn’t trying to be</h2>
<p>The plan also had a list of non-goals for the first version, and I’ve found that list as valuable as the goals. For now, Fold Cue doesn’t write your script for you. It doesn’t fake eye contact, generate an avatar, float over other apps, stream live or replace your background. None of those are bad ideas, and some might suit it one day. For a first version they’d have been a different app, and every one of them would have taken time from the things above.</p>
<p>Saying no in writing, before anyone asks, is much easier than saying no to a feature that’s half built.</p>
<h2 id="designing-for-a-device-you-cant-hold">Designing for a device you can’t hold</h2>
<p>The last part of the plan was the humblest: a list of what can’t be known without a real iPhone Duo. Whether the system shows the outer screen reliably while the phone is standing. The angle it stands steadily at. How the script reads from a little way back, at a slight angle. How well its microphones hear you from across a room.</p>
<p>Most of them have a place in the app where the answer plugs in, and a sensible default until then. The stand guide says its range is a starting point, and the reading line and the text size can move once I’ve seen them from across a room. The microphones are the exception: if speech struggles at a distance, that will mean new code, not a new setting. The fallback doesn’t care whether the outer screen appears. The first day with a Duo is already written down as a test plan, so it can be spent measuring rather than wondering what to measure.</p>
<p>There’s a version of this app that waited for the hardware before designing anything. It would have been more certain, and it would have missed the launch. Planning around what I didn’t know turned out to be most of the plan.</p>]]></content:encoded>
    </item>
    <item>
      <title>I'm building apps for a phone I haven't held yet</title>
      <link>https://heynavid.com/blog/building-for-a-phone-i-havent-held/</link>
      <guid isPermaLink="true">https://heynavid.com/blog/building-for-a-phone-i-havent-held/</guid>
      <pubDate>Sat, 26 Sep 2026 00:00:00 GMT</pubDate>
      <dc:creator>Navid</dc:creator>
      <category>iphone-duo</category>
      <category>indie</category>
      <category>behind-the-scenes</category>
      <description>iPhone Duo isn't in anyone's hands yet, and I'm already building for it. Why now, what makes an app a Duo app rather than a big phone app, and the kinds of app I've decided not to build.</description>
      <content:encoded><![CDATA[<p><img src="https://heynavid.com/blog/images/building-for-a-phone-i-havent-held-hero.jpg" alt="" /></p><p>Apple announced iPhone Duo in September, and the simulator arrived in the Xcode 27.1 beta a week or so later. Almost nobody outside Apple has used one yet. I’ve decided to spend the next few months building for it anyway.</p>
<p>This is the reasoning, written down before the device exists, so I can check it against reality later.</p>
<h2 id="why-now">Why now</h2>
<p>A new kind of iPhone is the rare moment when the App Store has more room in it than apps. People who buy a folding phone go looking for things that show off the fold, and on day one there aren’t many. Apple’s editors are likely to look for the same thing. The window is short, because the big apps catch up, and after that a new app is just another app.</p>
<p>There’s also a quieter reason. A new platform is where an indie developer’s lack of a marketing budget matters least. Nobody has an audience for iPhone Duo apps yet, including me.</p>
<h2 id="a-bigger-screen-isnt-the-point">A bigger screen isn’t the point</h2>
<p>The first thing I learned in the simulator is how much an app gets for free if it uses the standard system components. Rebuild with the current SDK and the app fills the inner display. System toolbars and tab bars move to the side edge in most poses. Sheets and alerts step around the fold on their own.</p>
<p>That’s good for users and bad for anyone hoping “it works on the Duo” is a selling point. If an app only looks good on the bigger screen, the competitor that rebuilt last Tuesday looks just as good.</p>
<p>So I started asking a narrower question: what can an app do on this phone that it can’t do on any other iPhone? The answer is shorter than I expected.</p>
<figure class="duo-fig"><img src="https://heynavid.com/duo/figures/free-vs-duo.svg" alt="After a rebuild every app fills the inner screen, gets bars on the side edge and has sheets that avoid the fold. Only the Duo has the hinge as input, the book and tabletop poses, and the outer screen while filming." loading="lazy" width="600" height="420"><figcaption>The left side comes free with a rebuild. The right side is where a Duo app has to earn its place.</figcaption></figure>
<h2 id="the-parts-only-this-phone-has">The parts only this phone has</h2>
<p><strong>The hinge.</strong> Apps can read whether the phone is closed, partly open or fully open, and a live angle in between. Apple suggests using it for interactions and effects, not layout.</p>
<p><strong>The poses.</strong> Partly folded, the fold becomes a region the system reserves. That gives you a book with a spine, or a tabletop with something to look at above the fold and something to touch below it.</p>
<p><strong>The outer screen, while the camera runs.</strong> This one has a rule attached. An app can offer interactive content for the outer screen, facing whoever is being filmed, and the system shows it only while the app is using the camera. That single rule decides a lot about which ideas are possible.</p>
<figure class="duo-fig"><img src="https://heynavid.com/duo/figures/poses.svg" alt="Closed: the outer screen in one hand. Flat: one big screen or a shared board. Book: two pages with the fold as a spine, seen from above. Tabletop: a view above the fold and controls below. Tent: standing on its edges." loading="lazy" width="600" height="500"><figcaption>The poses, seen from the side. The dot is the hinge.</figcaption></figure>
<p>Everything I’m building starts from one of those three. If an idea would be just as good on a flat phone, I drop it, however much I like it.</p>
<h2 id="what-im-not-building">What I’m not building</h2>
<p>Some ideas die because Apple got there first. Apple’s own camera already shows the person being photographed a live preview on the outer screen. FaceTime already lets a second person join from the outer screen. StandBy already runs on either screen. Building a smaller version of a system feature is a way to lose slowly.</p>
<p>Other ideas die because of the outer screen rule. Face-to-face translation sounds perfect for a phone with a screen on each side, and I’d love to build it. But it isn’t a camera app, so the outer screen isn’t available to it. The same goes for a customer-facing total at a market stall, or a second player’s private screen in a game that doesn’t use the camera.</p>
<p>And some die because the only thing they add is size. A recipe app on the inner screen is a nicer recipe app. It isn’t a Duo app.</p>
<h2 id="what-that-leaves">What that leaves</h2>
<p>What’s left is small and specific: apps where opening, closing or standing the phone is part of how you use it. ComicFlow is first, because a comic already is a spread with a gutter down the middle, and the fold lands exactly where the gutter goes. The others I’ll name when they’re close to shipping.</p>
<p>I’m also keeping a <a href="https://heynavid.com/duo/developers/">developer reference</a> with everything I check about the device, and a <a href="https://heynavid.com/duo/apps/">directory</a> of apps for the Duo. Both exist because I needed them myself and couldn’t find them.</p>
<p>The strange part is designing physical interactions for a device I can’t touch. The simulator has a slider for the hinge, and a slider is not a hinge. Some of this will be wrong the first time I hold one, and I’d rather find out which parts on launch day than a year later.</p>]]></content:encoded>
    </item>
    <item>
      <title>The lines that made ComicFlow assume every iPhone was the same shape</title>
      <link>https://heynavid.com/blog/comicflow-assumed-every-iphone-was-the-same-shape/</link>
      <guid isPermaLink="true">https://heynavid.com/blog/comicflow-assumed-every-iphone-was-the-same-shape/</guid>
      <pubDate>Sat, 26 Sep 2026 00:00:00 GMT</pubDate>
      <dc:creator>Navid</dc:creator>
      <category>iphone-duo</category>
      <category>comicflow</category>
      <category>ios</category>
      <description>Running ComicFlow in the iPhone Duo simulator showed stretched layouts, phone-sized covers and a reader that decided on spreads by orientation. The Duo exposed them, but several of the bugs were already shipping on iPad.</description>
      <content:encoded><![CDATA[<p><img src="https://heynavid.com/blog/images/comicflow-assumed-every-iphone-was-the-same-shape-hero.jpg" alt="" /></p><p>The first time I ran ComicFlow in the iPhone Duo simulator, it mostly worked. The tab bar moved to the side of the screen on its own, because the app uses a standard tab view. The What’s New sheet presented as a card instead of stretching. Nothing crashed.</p>
<p>It also looked like a phone app that had been pulled wider. The empty library’s import button stretched from one edge of the inner display to the other, with everything crowded into the top of the screen. Once I loaded some comics, the Continue Reading card did the same, and the library grid kept the covers and columns I’d picked for a phone, with the extra width spent on gaps. When I went looking for why, I found the same mistake in several places, and it wasn’t a Duo mistake.</p>
<h2 id="deciding-by-device-instead-of-by-space">Deciding by device instead of by space</h2>
<p>Apple’s guidance for the Duo says to stop making layout decisions from three things: the screen, the device idiom and the orientation. ComicFlow used all three.</p>
<p><strong>The idiom.</strong> The Continue Reading card had a width cap, but only on iPad. In shape, it was this:</p>
<pre class="astro-code github-light" style="background-color:#fff;color:#24292e; overflow-x: auto;" tabindex="0" data-language="swift"><code><span class="line"><span style="color:#24292E">.</span><span style="color:#005CC5">frame</span><span style="color:#24292E">(</span><span style="color:#005CC5">maxWidth</span><span style="color:#24292E">: UIDevice.current.userInterfaceIdiom </span><span style="color:#D73A49">==</span><span style="color:#24292E"> .pad </span><span style="color:#D73A49">?</span><span style="color:#24292E"> cardCap </span><span style="color:#D73A49">:</span><span style="color:#24292E"> .</span><span style="color:#005CC5">infinity</span><span style="color:#24292E">)</span></span></code></pre>
<p>iPhone Duo reports the phone idiom, so on its inner display the card got no cap at all. The library grid had the same shape of bug in a different place. The grid picked its cover size and its column count from the idiom, so the Duo’s inner display got the phone’s covers and the phone’s columns.</p>
<p><strong>The orientation.</strong> The reader’s automatic page layout showed a two-page spread when the device was in landscape and a setting was on:</p>
<pre class="astro-code github-light" style="background-color:#fff;color:#24292e; overflow-x: auto;" tabindex="0" data-language="swift"><code><span class="line"><span style="color:#D73A49">case</span><span style="color:#24292E"> .auto</span><span style="color:#D73A49">:</span><span style="color:#D73A49"> return</span><span style="color:#24292E"> isLandscape </span><span style="color:#D73A49">&#x26;&#x26;</span><span style="color:#24292E"> settings.doublePagerInLandscape</span></span></code></pre>
<p>That reads fine until you think about windows. An iPad in portrait has plenty of room for two pages and got one. An iPad in landscape running ComicFlow in a narrow Split View window had room for one and got two. Both were shipping on iPad.</p>
<p><strong>The screen.</strong> Tap zones, the page size in vertical mode, and the size hint for decoding pages all came from <code>UIScreen.main.bounds</code>. That’s the whole screen, not the window ComicFlow is drawn in, so tap zones and vertical page sizing were wrong in Split View and Slide Over, and pages were decoded larger than they needed to be.</p>
<h2 id="measuring-instead">Measuring instead</h2>
<p>The fixes are mostly small.</p>
<p>The card now has a plain width cap with no device check. The grid counts its columns from the width it actually has. The reader shows a spread when there’s room for two readable pages, whatever the orientation, so half an iPad gets one page and a wide window gets two. The setting that tied spreads to landscape is gone, because nothing in the interface could change it anyway. Tap zones are now a fraction of the reader’s own width.</p>
<p>I also added a unit test that fails if <code>UIScreen.main</code>, the device idiom or an orientation check appears anywhere in the app’s sources. It’s a blunt rule, and it’s the one I kept breaking.</p>
<figure class="duo-fig"><img src="https://heynavid.com/duo/figures/comicflow-before-after.svg" alt="Before: the Continue Reading card stretches across the whole inner display and the covers stay at phone size. After: the card is capped and the grid counts its columns from the width it has, so the covers fill the screen." loading="lazy" width="600" height="659"><figcaption>The library on the inner display. Before, the card stretched and the covers stayed phone-sized. After, the card is capped and the grid fills the width it has.</figcaption></figure>
<h2 id="then-the-fold">Then the fold</h2>
<p>With the inputs fixed, the Duo-specific work turned out to be small, because the reader already knew how to show spreads.</p>
<p>In book pose, the reader shows a two-page spread with the gutter on the crease, so no page straddles the fold. A comic is already laid out as facing pages with a gutter between them, and the fold lands exactly where the gutter should be. PDFs get an approximation: a gap the width of the fold in the middle of the pair, which lands on the crease when the fold is centred in the window.</p>
<figure class="duo-fig"><img src="https://heynavid.com/duo/figures/comicflow-spread.svg" alt="In book pose ComicFlow shows two facing pages, one on each half of the inner display, with the gutter between them lying exactly on the fold." loading="lazy" width="600" height="454"><figcaption>Book pose: one page per half, with the gutter on the fold.</figcaption></figure>
<p>Tabletop pose is designed but not yet seen working. The plan is the page above the fold and the reading controls in the half below it: the scrubber, the page indicator and a strip of thumbnails, with no forced spread, because the crease runs across the page rather than down the middle. The code is there. What I don’t have yet is a look at it on screen, because rotating the simulator into that pose has to be done by hand in Device Hub, and I haven’t done it yet. I’d rather not describe something as working until I’ve watched it work.</p>
<p>The reader finds the fold from the region the system reserves for it, not from the hinge angle. I left the hinge out entirely. A page that reflows while you adjust your grip is worse than one that stays put, and zoom now only resets when the fold appears or disappears, not every time the angle moves.</p>
<p>The book pose and layout work is in ComicFlow 3.5, coming around the iPhone Duo launch. It has been checked in the simulator only, since nobody has the hardware yet.</p>
<h2 id="what-the-duo-was-really-testing">What the Duo was really testing</h2>
<p>The three inputs looked right on every device I tested on. Every iPhone was one shape per orientation, and every iPad was bigger than every iPhone. But iPad windows could already be any size when I wrote that code, and I had never tried ComicFlow in one. A phone that can be as wide as a small tablet only made the mistake impossible to miss.</p>
<p>The Duo didn’t introduce new bugs so much as remove the last device that was hiding them. The iPad bugs had been there for a while. It took a phone that unfolds for me to go and look.</p>]]></content:encoded>
    </item>
    <item>
      <title>The iPhone Duo's inner screen is drawn larger than its panel</title>
      <link>https://heynavid.com/blog/iphone-duo-inner-screen-drawn-larger/</link>
      <guid isPermaLink="true">https://heynavid.com/blog/iphone-duo-inner-screen-drawn-larger/</guid>
      <pubDate>Sat, 26 Sep 2026 00:00:00 GMT</pubDate>
      <dc:creator>Navid</dc:creator>
      <category>iphone-duo</category>
      <category>ios</category>
      <category>swiftui</category>
      <description>The simulator, App Store Connect and Apple's spec sheet give different pixel sizes for the iPhone Duo's inner display. None of them is wrong. The simplest explanation is that the system renders larger than the panel and scales down, which is one more reason to lay out in points.</description>
      <content:encoded><![CDATA[<p><img src="https://heynavid.com/blog/images/iphone-duo-inner-screen-drawn-larger-hero.jpg" alt="" /></p><p>When I first booted the iPhone Duo simulator, I read the display sizes straight out of the runtime and wrote them in my notes as settled. The inner display’s pixel size didn’t match what the press had been reporting, so I wrote down that the press figure was wrong.</p>
<p>It wasn’t. The press figure came from Apple’s own spec sheet. I had found two correct numbers and assumed one of them had to be a mistake.</p>
<h2 id="three-sources-two-answers">Three sources, two answers</h2>
<p>There are three places a developer would look for the size of the inner display:</p>
<ul>
<li><strong>The simulator</strong>, which reports the size in points and a scale factor.</li>
<li><strong>App Store Connect’s screenshot specifications</strong>, which list the pixel size for Duo screenshots.</li>
<li><strong>Apple’s technical specifications page</strong>, which lists the panel’s resolution and pixel density.</li>
</ul>
<p>The first two agree with each other. The spec sheet lists a smaller resolution than both of them, and a lower pixel density than the outer display.</p>
<p>The outer display has no such gap. Its size in points times its scale factor lands exactly on the panel resolution Apple lists. Only the inner one differs. The exact figures are in the <a href="https://heynavid.com/duo/developers/#displays">developer reference</a>.</p>
<h2 id="what-is-probably-going-on">What is probably going on</h2>
<p>The inner display appears to render at its size in points times its scale factor, then get scaled down onto a panel with fewer physical pixels. iPhone 6 Plus did the same thing years ago: the system drew a frame larger than the screen and downsampled it, so apps could work at a clean scale factor while the hardware used a panel that didn’t divide evenly.</p>
<p>I should be clear that this is my reading of the numbers. Apple doesn’t say it anywhere I’ve found. But it’s the simplest explanation that makes all three sources right at once, and it fits how the outer display behaves.</p>
<p>The simulator’s own display profile points the same way. It describes the inner screen at the same density as the outer one, and scaling that down to the density on Apple’s spec sheet lands almost exactly on the panel’s resolution.</p>
<figure class="duo-fig"><img src="https://heynavid.com/duo/figures/render-then-scale.svg" alt="On the inner display the system appears to draw a frame larger than the physical panel and scale it down to fit. On the outer display the drawn frame and the panel are the same size." loading="lazy" width="600" height="330"><figcaption>The inner display appears to draw a frame larger than its panel and scale it down. The outer display draws exactly its panel.</figcaption></figure>
<h2 id="why-it-doesnt-change-anything-in-your-code">Why it doesn’t change anything in your code</h2>
<p>If your app lays out in points, none of this reaches you. SwiftUI and Auto Layout work in points, the simulator reports points, and the scale-down happens after your frame is finished. A layout that looks right in the simulator will look the same size on the panel, just drawn with slightly fewer physical pixels.</p>
<p>Where it can reach you:</p>
<p><strong>Pixel-exact artwork.</strong> A hairline one rendered pixel wide lands on slightly less than one physical pixel, and it may shimmer. Prefer strokes that survive scaling.</p>
<p><strong>Anything that reads the screen.</strong> Code that asks <code>UIScreen.main</code> for its size is building on the wrong foundation anyway. It has been deprecated since iOS 26, and on a device with two displays it’s ambiguous which screen you’d even get. If you render pixel-exact content with Metal, read the native scale from your view’s own screen, the way Apple advised for iPhone 6 Plus, rather than from <code>UIScreen.main</code>. Read sizes from the scene or the view you’re drawing in.</p>
<p><strong>Screenshots.</strong> App Store Connect wants the rendered size, not the panel size. The simulator’s screenshots already come out at the rendered size, so capture from there.</p>
<h2 id="points-were-always-the-contract">Points were always the contract</h2>
<p>The iPhone has had enough screen sizes that most of us stopped thinking in pixels years ago. The Duo is the first device in a while where that habit is doing real work, and where the spec sheet and the developer tools can disagree without either of them being wrong.</p>
<p>I’d rather have found this by reading more carefully than by correcting my own note. Two numbers that don’t match are usually two numbers measuring different things, and this time they were.</p>]]></content:encoded>
    </item>
    <item>
      <title>On iPhone Duo, the outer screen belongs to apps using the camera</title>
      <link>https://heynavid.com/blog/iphone-duo-outer-screen-camera-only/</link>
      <guid isPermaLink="true">https://heynavid.com/blog/iphone-duo-outer-screen-camera-only/</guid>
      <pubDate>Sat, 26 Sep 2026 00:00:00 GMT</pubDate>
      <dc:creator>Navid</dc:creator>
      <category>iphone-duo</category>
      <category>ios</category>
      <category>swiftui</category>
      <description>Third-party apps can show interactive content on the iPhone Duo's outer screen while they run on the inner one, but only through a camera capture accessory. That one rule decides which Duo ideas are possible, and it closed off a few I wanted to build.</description>
      <content:encoded><![CDATA[<p><img src="https://heynavid.com/blog/images/iphone-duo-outer-screen-camera-only-hero.jpg" alt="" /></p><p>A phone with a screen on each side invites a particular kind of idea. You look at the inner screen, someone across the table looks at the outer one, and each of you sees something different. My list of iPhone Duo ideas was full of them before I read the documentation properly.</p>
<p>Then I read it properly.</p>
<h2 id="the-one-way-in">The one way in</h2>
<p>When an app is open on the inner display, there is today one supported way for it to put anything on the outer display at the same time: a scene accessory registered for camera capture. In SwiftUI it’s a view modifier:</p>
<pre class="astro-code github-light" style="background-color:#fff;color:#24292e; overflow-x: auto;" tabindex="0" data-language="swift"><code><span class="line"><span style="color:#005CC5">CameraScreen</span><span style="color:#24292E">()</span></span>
<span class="line"><span style="color:#24292E">    .</span><span style="color:#005CC5">sceneAccessory</span><span style="color:#24292E"> {</span></span>
<span class="line"><span style="color:#005CC5">        CameraCaptureAccessory</span><span style="color:#24292E">(</span><span style="color:#005CC5">isEnabled</span><span style="color:#24292E">: $showsScript) {</span></span>
<span class="line"><span style="color:#005CC5">            ScriptView</span><span style="color:#24292E">(</span><span style="color:#005CC5">script</span><span style="color:#24292E">: script)</span></span>
<span class="line"><span style="color:#24292E">        }</span></span>
<span class="line"><span style="color:#24292E">        .</span><span style="color:#005CC5">onAvailabilityChange</span><span style="color:#24292E"> { available </span><span style="color:#D73A49">in</span></span>
<span class="line"><span style="color:#24292E">            accessoryAvailable </span><span style="color:#D73A49">=</span><span style="color:#24292E"> available</span></span>
<span class="line"><span style="color:#24292E">        }</span></span>
<span class="line"><span style="color:#24292E">    }</span></span></code></pre>
<p>In UIKit, a view controller registers <code>UISceneAccessory.cameraCapture(sceneConfiguration:)</code> with <code>registerSceneAccessory(_:)</code> and gets a registration back that reports whether the accessory is available and enabled. The system gives the resulting scene a dedicated session role, so the app can tell that scene apart from its main one.</p>
<p>The content can be interactive. Someone in front of the lens can tap it.</p>
<figure class="duo-fig"><img src="https://heynavid.com/duo/figures/outer-rule.svg" alt="You see the app on the inner screen. The person in front of the camera sees the accessory on the outer screen. It can only appear when the app is in the foreground, a capture session with a visible preview is running, the device is open and the app is full screen on the inner display, and even then the system decides." loading="lazy" width="600" height="560"><figcaption>You see the app on the inner screen. The person being filmed sees the accessory on the outer screen, and only when every condition holds.</figcaption></figure>
<h2 id="the-conditions">The conditions</h2>
<p>The accessory can only appear when all of these are true: the app is in the foreground, it has an active camera capture session, the device is open, and the app’s interface is full screen on the inner display, with the view that registered the accessory on screen. That full screen condition means Split View is out. Apple’s overview describes the case as the device fully open and capturing with the rear camera.</p>
<p>Even then, the system decides whether and where to show it. Apple’s own framing is that an accessory enhances the app when it’s available and the app must work fully without it. I’m treating it the way I’d treat a feature that might be switched off by the user: build the whole app first, then let the accessory make it better.</p>
<h2 id="why-a-camera-specifically">Why a camera, specifically</h2>
<p>I wondered whether this was a first version that would open up later, so I looked for anything Apple had said beyond the documentation. On the developer forums, an Apple engineer answered it directly. The camera capture accessory is the only way to use both displays at the same time, and camera cases are “the only supported and intended use cases for dual display at present”. A capture session with only an input isn’t supported either, because it doesn’t actually run the camera: you have to show a camera preview on one or both displays. That “at present” leaves the question open, which is the most I can say about the future.</p>
<p>That last part closes the obvious workaround. You can’t start a camera session just to unlock the outer screen for something else. The preview has to be real and on screen, and the camera has to be the point.</p>
<h2 id="what-it-rules-out">What it rules out</h2>
<p>Face-to-face translation was the idea I most wanted. Each person reads the other’s words on the screen facing them. It needs the outer screen and has no reason to use a camera, so it can’t be built this way.</p>
<p>The same goes for a customer-facing total on a phone used as a till, a private hand for the second player in a card game, and a presenter’s notes on one side with slides on the other. All of them are natural on a two-screen phone, and none of them are camera apps.</p>
<p>Apple’s own dual-screen features follow the same shape. Duo Preview shows the person being photographed a live preview on the outer screen, Kid Cue plays animations there to catch a child’s attention, and Duo FaceTime lets a second person join from the outer screen. All of them are camera experiences.</p>
<h2 id="what-it-makes-possible">What it makes possible</h2>
<p>Flip the rule around and it describes a very specific opportunity: apps where the person in front of the camera needs to see or touch something.</p>
<p>A creator filming themselves with the rear camera can read a script from the screen right next to the lens. Someone being interviewed can see the current question. A person doing a workout can see their count and form cues while the camera watches them. A party game can film one player’s reaction while showing them the challenge.</p>
<p>Those are all camera apps first, and the outer screen makes each of them better. That’s the shape Apple is asking for, and it’s narrower than the space of ideas a two-screen phone suggests.</p>
<h2 id="testing-it">Testing it</h2>
<p>The simulator has no camera, so presenting the accessory can’t be tried there, although its layout can be checked in previews and in the simulator. I’ve been building the accessory content as ordinary SwiftUI views that I can preview and test on their own, and keeping the camera side separate. The part where the system decides when to show the accessory will only be answerable on a real device.</p>
<p>I lost a few ideas to this rule and gained a clearer sense of what the outer screen is for. It isn’t a second display. It’s the screen that faces whoever the camera is looking at, and the apps that fit it are the ones where that person matters.</p>]]></content:encoded>
    </item>
    <item>
      <title>Reading the hinge on iPhone Duo, and designing around what Apple won't promise</title>
      <link>https://heynavid.com/blog/reading-the-iphone-duo-hinge/</link>
      <guid isPermaLink="true">https://heynavid.com/blog/reading-the-iphone-duo-hinge/</guid>
      <pubDate>Sat, 26 Sep 2026 00:00:00 GMT</pubDate>
      <dc:creator>Navid</dc:creator>
      <category>iphone-duo</category>
      <category>ios</category>
      <category>swiftui</category>
      <description>iOS 27.1 gives every app the iPhone Duo's hinge status and a live angle, with no entitlement. The API is small. The interesting part is the one promise Apple leaves out, and how to design an interaction that doesn't need it.</description>
      <content:encoded><![CDATA[<p><img src="https://heynavid.com/blog/images/reading-the-iphone-duo-hinge-hero.jpg" alt="" /></p><p>The hinge was the first thing I looked for in the iOS 27.1 SDK, because it’s the one part of iPhone Duo no other iPhone has. I half expected it to be private, or behind an entitlement, or only exposed as a layout change.</p>
<p>It’s none of those. Any app can read it, in SwiftUI or UIKit, and the headers are short enough to read in one sitting.</p>
<h2 id="swiftui">SwiftUI</h2>
<p>The modifier is <code>onHingeChange</code>. It hands you the old and the new context, and the context holds an optional hinge:</p>
<pre class="astro-code github-light" style="background-color:#fff;color:#24292e; overflow-x: auto;" tabindex="0" data-language="swift"><code><span class="line"><span style="color:#D73A49">struct</span><span style="color:#6F42C1"> PageView</span><span style="color:#24292E">: </span><span style="color:#6F42C1">View </span><span style="color:#24292E">{</span></span>
<span class="line"><span style="color:#D73A49">    @State</span><span style="color:#D73A49"> private</span><span style="color:#D73A49"> var</span><span style="color:#24292E"> isPartlyOpen </span><span style="color:#D73A49">=</span><span style="color:#005CC5"> false</span></span>
<span class="line"></span>
<span class="line"><span style="color:#D73A49">    var</span><span style="color:#24292E"> body: </span><span style="color:#D73A49">some</span><span style="color:#24292E"> View {</span></span>
<span class="line"><span style="color:#005CC5">        Content</span><span style="color:#24292E">(</span><span style="color:#005CC5">isPartlyOpen</span><span style="color:#24292E">: isPartlyOpen)</span></span>
<span class="line"><span style="color:#24292E">            .</span><span style="color:#005CC5">onHingeChange</span><span style="color:#24292E"> { </span><span style="color:#005CC5">_</span><span style="color:#24292E">, new </span><span style="color:#D73A49">in</span></span>
<span class="line"><span style="color:#D73A49">                guard</span><span style="color:#D73A49"> let</span><span style="color:#24292E"> hinge </span><span style="color:#D73A49">=</span><span style="color:#24292E"> new.hinge </span><span style="color:#D73A49">else</span><span style="color:#24292E"> {</span></span>
<span class="line"><span style="color:#6A737D">                    // No hinge on this device.</span></span>
<span class="line"><span style="color:#24292E">                    isPartlyOpen </span><span style="color:#D73A49">=</span><span style="color:#005CC5"> false</span></span>
<span class="line"><span style="color:#D73A49">                    return</span></span>
<span class="line"><span style="color:#24292E">                }</span></span>
<span class="line"><span style="color:#24292E">                isPartlyOpen </span><span style="color:#D73A49">=</span><span style="color:#24292E"> hinge.status </span><span style="color:#D73A49">==</span><span style="color:#24292E"> .partiallyOpen</span></span>
<span class="line"><span style="color:#24292E">            }</span></span>
<span class="line"><span style="color:#24292E">    }</span></span>
<span class="line"><span style="color:#24292E">}</span></span></code></pre>
<p><code>DeviceHinge</code> has a <code>status</code> and an <code>angle</code>, and the angle is a SwiftUI <code>Angle</code>. A <code>nil</code> hinge means the device doesn’t have one, which is what every other iPhone reports.</p>
<p>One small surprise: <code>DeviceHinge.Status</code> is a struct with static members, not an enum. You compare against <code>.closed</code>, <code>.partiallyOpen</code> and <code>.fullyOpen</code>, but a <code>switch</code> over it needs a <code>default</code> case.</p>
<h2 id="uikit">UIKit</h2>
<p>UIKit uses an interaction, the same pattern as pointer and drag interactions:</p>
<pre class="astro-code github-light" style="background-color:#fff;color:#24292e; overflow-x: auto;" tabindex="0" data-language="swift"><code><span class="line"><span style="color:#D73A49">let</span><span style="color:#24292E"> interaction </span><span style="color:#D73A49">=</span><span style="color:#005CC5"> UIHingeInteraction</span><span style="color:#24292E"> { [</span><span style="color:#D73A49">weak</span><span style="color:#005CC5"> self</span><span style="color:#24292E">] </span><span style="color:#005CC5">_</span><span style="color:#24292E">, update </span><span style="color:#D73A49">in</span></span>
<span class="line"><span style="color:#D73A49">    guard</span><span style="color:#D73A49"> let</span><span style="color:#005CC5"> self</span><span style="color:#D73A49"> else</span><span style="color:#24292E"> { </span><span style="color:#D73A49">return</span><span style="color:#24292E"> }</span></span>
<span class="line"><span style="color:#D73A49">    guard</span><span style="color:#D73A49"> let</span><span style="color:#24292E"> hinge </span><span style="color:#D73A49">=</span><span style="color:#24292E"> update.hinge </span><span style="color:#D73A49">else</span><span style="color:#24292E"> {</span></span>
<span class="line"><span style="color:#005CC5">        self</span><span style="color:#24292E">.</span><span style="color:#005CC5">hingeBecameUnavailable</span><span style="color:#24292E">()</span></span>
<span class="line"><span style="color:#D73A49">        return</span></span>
<span class="line"><span style="color:#24292E">    }</span></span>
<span class="line"><span style="color:#005CC5">    self</span><span style="color:#24292E">.</span><span style="color:#005CC5">apply</span><span style="color:#24292E">(</span><span style="color:#005CC5">status</span><span style="color:#24292E">: hinge.status, </span><span style="color:#005CC5">angle</span><span style="color:#24292E">: hinge.angle)</span></span>
<span class="line"><span style="color:#24292E">}</span></span>
<span class="line"><span style="color:#24292E">view.</span><span style="color:#005CC5">addInteraction</span><span style="color:#24292E">(interaction)</span></span></code></pre>
<p>The handler fires once with the current state and again on each update. It also fires when the view moves between hierarchies, and the hinge is <code>nil</code> when the view has left a hierarchy that provides hinge updates. The UIKit status has an extra <code>.unknown</code> case the SwiftUI one doesn’t.</p>
<p>Two details that bit me on first read. The UIKit angle is a <code>CGFloat</code> in radians, while the SwiftUI angle is an <code>Angle</code>, so shared code needs to pick one. And a disabled interaction doesn’t queue updates: turn it back on and you get the current state once, not the history you missed.</p>
<p>There’s nothing in Core Motion, Game Controller or Metal. If a game wants the hinge, it reads it through UIKit or SwiftUI like everyone else.</p>
<h2 id="the-promise-apple-leaves-out">The promise Apple leaves out</h2>
<p>This is the line in the header I keep coming back to. On UIKit’s <code>UIHinge.angle</code>, Apple writes that the rate and granularity of updates are system policy and can change with system state, so you shouldn’t depend on a particular update frequency or precision. The SwiftUI documentation doesn’t repeat it, but I’d assume the same policy applies. If you only need to know whether the hinge is closed, partly open or fully open, use <code>status</code>.</p>
<p>That’s an unusually direct warning. It means an interaction that needs a smooth, fine-grained stream of angles might work on one day and feel rough on another, depending on things the app can’t see. I can’t test how rough in the simulator, because there I’m driving the hinge myself, so it can’t tell me what update rate real hardware delivers.</p>
<p>So I’ve started designing hinge interactions in three tiers.</p>
<p><strong>Status first.</strong> Anything that can be expressed as closed, partly open or fully open should be. The system decides the status from the angle and the device’s orientation, so you inherit Apple’s judgement about where the boundaries are.</p>
<figure class="duo-fig"><img src="https://heynavid.com/duo/figures/hinge-status.svg" alt="One half of the device lies flat while the other rotates from closed to fully open. Near closed the status is closed, across the middle it is partially open, and at the end it is fully open. The continuous angle is measured between the halves. The system decides the boundaries." loading="lazy" width="600" height="420"><figcaption>Status is coarse and dependable. The angle underneath it is continuous, and its update rate is up to the system.</figcaption></figure>
<p><strong>Thresholds second.</strong> Some interactions need a boundary Apple doesn’t define, like “this half has been lifted far enough”. Those use two thresholds rather than one, so a hinge hovering near the boundary doesn’t flicker:</p>
<pre class="astro-code github-light" style="background-color:#fff;color:#24292e; overflow-x: auto;" tabindex="0" data-language="swift"><code><span class="line"><span style="color:#D73A49">struct</span><span style="color:#6F42C1"> LiftDetector</span><span style="color:#24292E"> {</span></span>
<span class="line"><span style="color:#D73A49">    var</span><span style="color:#24292E"> showBelow: Angle</span></span>
<span class="line"><span style="color:#D73A49">    var</span><span style="color:#24292E"> hideAbove: Angle</span></span>
<span class="line"><span style="color:#D73A49">    private</span><span style="color:#24292E">(</span><span style="color:#D73A49">set</span><span style="color:#24292E">) </span><span style="color:#D73A49">var</span><span style="color:#24292E"> isLifted </span><span style="color:#D73A49">=</span><span style="color:#005CC5"> false</span></span>
<span class="line"></span>
<span class="line"><span style="color:#D73A49">    mutating</span><span style="color:#D73A49"> func</span><span style="color:#6F42C1"> update</span><span style="color:#24292E">(</span><span style="color:#6F42C1">with</span><span style="color:#24292E"> hinge: DeviceHinge) </span><span style="color:#D73A49">-></span><span style="color:#005CC5"> Bool</span><span style="color:#24292E"> {</span></span>
<span class="line"><span style="color:#D73A49">        if</span><span style="color:#D73A49"> !</span><span style="color:#24292E">isLifted, hinge.angle </span><span style="color:#D73A49">&#x3C;</span><span style="color:#24292E"> showBelow { isLifted </span><span style="color:#D73A49">=</span><span style="color:#005CC5"> true</span><span style="color:#24292E"> }</span></span>
<span class="line"><span style="color:#D73A49">        if</span><span style="color:#24292E"> isLifted, hinge.angle </span><span style="color:#D73A49">></span><span style="color:#24292E"> hideAbove { isLifted </span><span style="color:#D73A49">=</span><span style="color:#005CC5"> false</span><span style="color:#24292E"> }</span></span>
<span class="line"><span style="color:#D73A49">        return</span><span style="color:#24292E"> isLifted</span></span>
<span class="line"><span style="color:#24292E">    }</span></span>
<span class="line"><span style="color:#24292E">}</span></span></code></pre>
<p>A threshold only needs the angle to cross a line eventually. It doesn’t care how many updates arrived on the way.</p>
<figure class="duo-fig"><img src="https://heynavid.com/duo/figures/hysteresis.svg" alt="As the hinge angle falls past the show threshold the content appears, and it only hides again once the angle rises back past the higher hide threshold, so small wobbles near either line change nothing." loading="lazy" width="600" height="360"><figcaption>Two thresholds: content appears once the angle falls past one line, and hides only after it rises back past the other.</figcaption></figure>
<p><strong>Continuous last.</strong> Interactions that follow the angle directly, like a bellows or a lever, are the most fun and the most exposed to that warning. I’m building them, but with smoothing between updates, and with the expectation that they’ll need tuning the day I hold real hardware.</p>
<h2 id="dont-lay-out-with-it">Don’t lay out with it</h2>
<p>The other thing Apple is clear about is that the hinge isn’t a layout tool. When the device is partly open, the fold becomes a reserved region of the view, and that’s what layout should read. ComicFlow’s reader finds the fold that way and never looks at the angle at all.</p>
<p>It took me a while to see why that split matters. Layout that follows the angle reflows every time someone adjusts their grip. Layout that follows the fold region changes once, when the fold appears, and then leaves the content alone.</p>
<p>The hinge ended up being less of a sensor than I imagined and more of a switch with a dial attached. The switch is dependable. The dial is a bonus, and I’m designing as if it might not always be there.</p>]]></content:encoded>
    </item>
    <item>
      <title>Setting up the iPhone Duo simulator, and the things that tripped me up</title>
      <link>https://heynavid.com/blog/setting-up-the-iphone-duo-simulator/</link>
      <guid isPermaLink="true">https://heynavid.com/blog/setting-up-the-iphone-duo-simulator/</guid>
      <pubDate>Sat, 26 Sep 2026 00:00:00 GMT</pubDate>
      <dc:creator>Navid</dc:creator>
      <category>iphone-duo</category>
      <category>ios</category>
      <category>tooling</category>
      <description>The iPhone Duo simulator ships in the Xcode 27.1 beta. Getting it running took one surprise from xip, a renamed Simulator app, a permission error on my external drive, a noisy first boot, and screenshots that came out black.</description>
      <content:encoded><![CDATA[<p><img src="https://heynavid.com/blog/images/setting-up-the-iphone-duo-simulator-hero.jpg" alt="" /></p><p>The iPhone Duo simulator arrived in the Xcode 27.1 beta, and setting it up should have been a short afternoon. It was, eventually, but most of the time went on things that weren’t mentioned anywhere I looked. These are those things, in the order they happened.</p>
<h2 id="xip-replaced-my-xcode">xip replaced my Xcode</h2>
<p>I downloaded the beta as a <code>.xip</code> and expanded it next to my existing Xcode. I assumed it would appear as <code>Xcode-beta.app</code>, because the file was called <code>Xcode_27.1_beta.xip</code>.</p>
<p>It didn’t. With an <code>Xcode.app</code> already in Applications, <code>xip</code> installed the beta as <code>Xcode.app</code> and quietly renamed my release Xcode to <code>Xcode 1.app</code>. For a few minutes my default toolchain was the beta, and the Mac App Store copy of Xcode was no longer where the App Store expected it.</p>
<p>The fix is just renaming them back: the beta to <code>Xcode-beta.app</code>, the release copy to <code>Xcode.app</code>. After that I keep the release Xcode as the default and opt into the beta per command:</p>
<pre class="astro-code github-light" style="background-color:#fff;color:#24292e; overflow-x: auto;" tabindex="0" data-language="sh"><code><span class="line"><span style="color:#24292E">DEVELOPER_DIR</span><span style="color:#D73A49">=</span><span style="color:#032F62">/Applications/Xcode-beta.app/Contents/Developer</span><span style="color:#6F42C1"> xcodebuild</span><span style="color:#005CC5"> -version</span></span></code></pre>
<p>That way nothing I ship by accident gets built with a beta SDK.</p>
<h2 id="the-simulator-app-is-gone">The Simulator app is gone</h2>
<p>In Xcode 27, the app you open to see simulators is Device Hub. It lives at <code>Contents/Applications/DeviceHub.app</code> inside the Xcode bundle, and the old <code>Simulator.app</code> path doesn’t exist at all, in the release Xcode as well as the beta.</p>
<p>If you have a script or an alias that opens <code>Simulator.app</code> by path, it’s already broken. I found out when my own <code>open -a</code> on that path failed.</p>
<h2 id="keep-simulators-on-the-internal-drive">Keep simulators on the internal drive</h2>
<p>The Duo device type comes with the Xcode beta, but it needs the iOS 27.1 simulator runtime, which is a separate and sizeable download. I keep most of my development files on an external drive, and I’d like simulators to live there too.</p>
<p>On my setup, CoreSimulator gets a permission error writing to the external volume, which comes from macOS privacy controls rather than from Xcode. So the runtime and the simulators stay on the internal drive. Granting CoreSimulator Full Disk Access might get around it, but I haven’t tried. If internal storage is tight, clear space first.</p>
<h2 id="the-first-boot-looks-broken">The first boot looks broken</h2>
<p>The release notes warn that the first Simulator launch can take several minutes. What they don’t say is that the Duo runtime logs a data migration failure on boot, and that right after booting, <code>simctl spawn</code> failed to run a simple command even though the device reported as booted.</p>
<p>I erased the device and booted it again from clean, and got exactly the same messages. SpringBoard came up, apps launched, screenshots worked. As far as I can tell, in this beta the messages are cosmetic. Later in the same session, <code>simctl spawn</code> worked fine for listing processes, reading logs and writing defaults, so the early failure looks like a device that hadn’t finished starting.</p>
<h2 id="two-displays-one-awake">Two displays, one awake</h2>
<p>This is the one that cost me the most time. The Duo simulator has two displays, and <code>simctl io</code> screenshots take a <code>--display</code> argument. It accepts a screen number, a name or a UUID, and the numbers aren’t what you’d guess, because a TV-out screen sits between the two real ones. The device names are the safe choice: <code>--display=primary</code> is the outer display and <code>--display=primary-1</code> is the inner one.</p>
<p>Then I took a batch of screenshots and every one came back as the same black frame. Only the display that’s awake renders. With the device open, the outer display is off. With it closed, the inner one is. I noticed because the files were identical to the byte, and after that I captured both displays every time and kept whichever wasn’t black.</p>
<figure class="duo-fig"><img src="https://heynavid.com/duo/figures/simulator-displays.svg" alt="The inner display is named primary-1 and the outer display is named primary. With the device open only the inner display renders, and with it closed only the outer one does. Capturing the sleeping display returns black." loading="lazy" width="600" height="300"><figcaption>The display names the screenshot command expects, and which one is awake in each state.</figcaption></figure>
<h2 id="poses-are-manual">Poses are manual</h2>
<p>Device Hub has controls to open, close, rotate and fold the device. I found no <code>simctl</code> command for any of it.</p>
<p>Apple gives you no command-line way to set a pose. Third-party tools can set the fold angle through a private simulator interface, and my own test script uses one, but I wouldn’t build anything important on a private interface. Rotating the Duo still needs a hand in Device Hub, so any screenshot or test that depends on the device’s orientation needs a person.</p>
<h2 id="where-that-leaves-the-simulator">Where that leaves the simulator</h2>
<p>Even with all of this, the simulator is good. It has real display geometry, the system behaviour for bars and sheets and the fold region, and a hinge I can drive. What it can’t do is anything with the camera, since there isn’t one, and it can’t tell me how the hinge feels in a hand.</p>
<p>I’ve kept a running list of these details in my <a href="https://heynavid.com/duo/developers/#simulator">iPhone Duo developer reference</a>, so the next time I set up a machine I can skip the afternoon.</p>
<p>The pattern across all of them was the same: each one failed quietly rather than loudly. An installer that renames instead of asking, logs that look fatal and aren’t, a command that fails only in the first moments after boot, screenshots that succeed and contain nothing. The fixes were all easy once I stopped trusting that “no error” meant “worked”.</p>]]></content:encoded>
    </item>
    <item>
      <title>The Bug I Was Certain I Had, and the Six I Actually Had</title>
      <link>https://heynavid.com/blog/the-bug-i-was-certain-i-had/</link>
      <guid isPermaLink="true">https://heynavid.com/blog/the-bug-i-was-certain-i-had/</guid>
      <pubDate>Tue, 15 Sep 2026 00:00:00 GMT</pubDate>
      <dc:creator>Navid</dc:creator>
      <category>comicflow</category>
      <category>ios</category>
      <category>behind-the-scenes</category>
      <description>ComicFlow 3.4 was finished when a paying reader said a comic would not open. I was sure the cause was RAR5. It was six mundane things wearing the same blank page, because one catch block had been swallowing every error since the reader's first commit.</description>
      <content:encoded><![CDATA[<p><img src="https://heynavid.com/blog/images/blank-books-hero.jpg" alt="" /></p><p>ComicFlow 3.4 is live. It was supposed to be live about a week ago.</p>
<p>By 5 September the release was done. Watch folders and Wi-Fi upload were built, the What’s New sheet was written, and the release notes were translated into every language the app supports. Build 1 was uploaded on 6 September and build 2 the day after. All that remained was to press the button.</p>
<p>On 12 September a one-star review arrived against 3.3, the version that was already live. It came from someone who had paid for the app. A comic would not open. That was the whole complaint, and it was accurate, and nothing in the app or in my analytics could say what had happened to them.</p>
<p>So I held the release. Build 3 went in on 13 September with an error-reporting layer the reader should have had from its first commit, and 3.4 shipped as a bigger update than the one I had planned. This post is about that build. The two features have <a href="https://applestan.com/blog/posts/comicflow-3-4-release/">their own write-up</a>, for users, and this is not it.</p>
<h2 id="what-a-blank-book-looks-like-from-the-inside">What a blank book looks like from the inside</h2>
<p>The symptom was a comic that opened to nothing. The reader appeared, drew its background, and showed no pages. No error, no message, no spinner that never stopped. Just an empty book you could close again.</p>
<p>Every layer of the app agreed this was fine. The library kept a row for the comic with a page count of zero. The reader view had a branch for “pages exist” and a branch for “an error was set” and, when neither was true, drew nothing. Analytics logged a successful <code>reader_opened</code> event, because that event fired the moment the view appeared, before any page had been asked for.</p>
<p>Underneath all of it were these lines in the CBR page provider:</p>
<pre class="astro-code github-light" style="background-color:#fff;color:#24292e; overflow-x: auto;" tabindex="0" data-language="swift"><code><span class="line"><span style="color:#D73A49">do</span><span style="color:#24292E"> {</span></span>
<span class="line"><span style="color:#24292E">    archive </span><span style="color:#D73A49">=</span><span style="color:#D73A49"> try</span><span style="color:#005CC5"> Archive</span><span style="color:#24292E">(</span><span style="color:#005CC5">path</span><span style="color:#24292E">: url.path)</span></span>
<span class="line"><span style="color:#24292E">} </span><span style="color:#D73A49">catch</span><span style="color:#24292E"> {</span></span>
<span class="line"><span style="color:#D73A49">    return</span></span>
<span class="line"><span style="color:#24292E">}</span></span>
<span class="line"><span style="color:#D73A49">guard</span><span style="color:#D73A49"> let</span><span style="color:#24292E"> allEntries </span><span style="color:#D73A49">=</span><span style="color:#D73A49"> try?</span><span style="color:#24292E"> archive.</span><span style="color:#005CC5">entries</span><span style="color:#24292E">() </span><span style="color:#D73A49">else</span><span style="color:#24292E"> { </span><span style="color:#D73A49">return</span><span style="color:#24292E"> }</span></span></code></pre>
<p>Both paths return silently and leave the entry list empty. The rest of the app reads an empty entry list as zero pages, and zero pages is a valid state, so nothing downstream had any reason to complain.</p>
<p>Those lines are from the reader’s first commit on 22 January. They were unchanged until build 3. For as long as ComicFlow has had a reader, every distinct thing that could go wrong opening a RAR has produced the same empty book.</p>
<h2 id="the-hypothesis-i-was-sure-of">The hypothesis I was sure of</h2>
<p>When I finally went through the telemetry with the right question, one thing stood out. Files with a bare <code>.rar</code> extension opened blank far more often than anything else in the library. Not a little more often. It was the worst format by a wide margin.</p>
<p>The explanation I reached for was RAR5. It is the current RAR format and most comic archives made in recent years use it. The story I told myself was that the bundled unrar was too old for it, and that an old unrar handed a RAR5 file would list zero entries rather than fail. I did not check either half of that. It was a specific, plausible, mechanistic story, and it fit the symptom exactly: the archive opens, the list is empty, the book is blank.</p>
<p>Then I did the thing I should have done first and built a real RAR5 archive of coloured test pages, and opened it.</p>
<p>Every page was there. I built a solid RAR5. Every page. RAR4, plain and solid. Every page. Unrar.swift 0.5.1 bundles unrar 7.13, which has read RAR5 for as long as RAR5 has existed.</p>
<p>The hypothesis survived exactly as long as it took to build a fixture and open it. It had felt like knowledge for considerably longer than that.</p>
<h2 id="what-was-actually-in-the-blank-books">What was actually in the blank books</h2>
<p>Once the RAR5 theory was gone I built every awkward kind of archive I could think of and pushed each one through the reader. Six of them came out blank.</p>
<p><strong>Password-protected archives.</strong> The archive lists every entry, each page is flagged as encrypted, and extraction needs a password the app never asks for. The old code dropped encrypted entries from the page list as unsafe to hand to the unpacker, which was correct, and then reported the result as zero pages, which was not.</p>
<p><strong>Archives with encrypted headers.</strong> A different failure with the same face. When the headers themselves are encrypted the archive will not even list its contents. The <code>entries()</code> call throws, the <code>try?</code> turns that into nil, and the guard returns.</p>
<p><strong>Multi-volume sets.</strong> One part of several. The archive header says it is a volume, and it says whether it is the first one, and the old code never read either flag. It opened the file, found it could not go anywhere, and returned.</p>
<p><strong>Nested archives.</strong> A <code>.cbr</code> that contains <code>.cbr</code> files, usually a whole series packed into one download. Every entry is real and none of them is an image, so the page filter removed all of them. Zero pages, technically true.</p>
<p><strong>A web page saved as a comic.</strong> A download that returned an HTML error page, which the browser saved under the comic’s filename with the comic’s extension. The header sniffing I added in 3.3 correctly said this is not any archive it knows, fell back to the extension, which said RAR, and handed it to unrar, which refused. The refusal landed in the <code>catch</code> and disappeared.</p>
<p><strong>Legacy code-page ZIP names.</strong> This is the one I like least, because the archive was perfectly good. A ZIP written by an older Windows tool stores its entry names in the machine’s local code page and does not set the flag that says “these are UTF-8”. My ZIP reader decoded every name as UTF-8:</p>
<pre class="astro-code github-light" style="background-color:#fff;color:#24292e; overflow-x: auto;" tabindex="0" data-language="swift"><code><span class="line"><span style="color:#D73A49">let</span><span style="color:#24292E"> fileName </span><span style="color:#D73A49">=</span><span style="color:#005CC5"> String</span><span style="color:#24292E">(</span><span style="color:#005CC5">data</span><span style="color:#24292E">: fileNameData, </span><span style="color:#005CC5">encoding</span><span style="color:#24292E">: .</span><span style="color:#005CC5">utf8</span><span style="color:#24292E">) </span><span style="color:#D73A49">??</span><span style="color:#032F62"> ""</span></span></code></pre>
<p>A name like <code>página_001.jpg</code> is not valid UTF-8 in that encoding, so the decode returned nil, and the fallback made the name an empty string. An empty string has no extension. No extension means not an image. Every page in the archive was silently dropped, the archive reported zero pages, and anyone whose collection had been zipped that way got a shelf of blank books.</p>
<p>Not one of the six was RAR5. Two of them were not RAR at all.</p>
<h2 id="one-bit-where-there-should-have-been-a-code">One bit where there should have been a code</h2>
<p>The thing I keep coming back to is not that the errors were lost. It is what losing them did to my reasoning.</p>
<p>A swallowed error does not just delete information. It merges unrelated failures into a single symptom. Six causes, six different fixes, six different things to tell the user, all collapsed into “zero pages”. And a single undifferentiated symptom is exactly the input that lets you build a confident theory, because there is nothing left in the data to contradict it. I was not being careless about RAR5. I was doing what anyone does with one bit of information, which is fill in the rest.</p>
<p>The system was giving me one bit. It should have been giving me a code.</p>
<h2 id="giving-the-failure-a-name">Giving the failure a name</h2>
<p>The fix in build 3 is structural rather than a stack of six patches. There is now one failure type, thrown by every path that can fail to open an archive:</p>
<pre class="astro-code github-light" style="background-color:#fff;color:#24292e; overflow-x: auto;" tabindex="0" data-language="swift"><code><span class="line"><span style="color:#D73A49">nonisolated</span><span style="color:#D73A49"> enum</span><span style="color:#6F42C1"> ArchiveOpenFailureReason</span><span style="color:#24292E">: </span><span style="color:#005CC5">String</span><span style="color:#24292E">, </span><span style="color:#6F42C1">Sendable</span><span style="color:#24292E">, </span><span style="color:#6F42C1">CaseIterable </span><span style="color:#24292E">{</span></span>
<span class="line"><span style="color:#D73A49">    case</span><span style="color:#005CC5"> unreadable</span><span style="color:#D73A49"> =</span><span style="color:#032F62"> "unreadable"</span></span>
<span class="line"><span style="color:#D73A49">    case</span><span style="color:#005CC5"> notAnArchive</span><span style="color:#D73A49"> =</span><span style="color:#032F62"> "not_an_archive"</span></span>
<span class="line"><span style="color:#D73A49">    case</span><span style="color:#005CC5"> webPage</span><span style="color:#D73A49"> =</span><span style="color:#032F62"> "web_page"</span></span>
<span class="line"><span style="color:#D73A49">    case</span><span style="color:#005CC5"> corrupted</span><span style="color:#D73A49"> =</span><span style="color:#032F62"> "corrupted"</span></span>
<span class="line"><span style="color:#D73A49">    case</span><span style="color:#005CC5"> passwordProtected</span><span style="color:#D73A49"> =</span><span style="color:#032F62"> "password_protected"</span></span>
<span class="line"><span style="color:#D73A49">    case</span><span style="color:#005CC5"> multiVolume</span><span style="color:#D73A49"> =</span><span style="color:#032F62"> "multi_volume"</span></span>
<span class="line"><span style="color:#D73A49">    case</span><span style="color:#005CC5"> nestedArchives</span><span style="color:#D73A49"> =</span><span style="color:#032F62"> "nested_archives"</span></span>
<span class="line"><span style="color:#D73A49">    case</span><span style="color:#005CC5"> documentsOnly</span><span style="color:#D73A49"> =</span><span style="color:#032F62"> "documents_only"</span></span>
<span class="line"><span style="color:#D73A49">    case</span><span style="color:#005CC5"> noImages</span><span style="color:#D73A49"> =</span><span style="color:#032F62"> "no_images"</span></span>
<span class="line"><span style="color:#D73A49">    case</span><span style="color:#005CC5"> pagesTooLarge</span><span style="color:#D73A49"> =</span><span style="color:#032F62"> "pages_too_large"</span></span>
<span class="line"><span style="color:#D73A49">    case</span><span style="color:#005CC5"> unsupported7z</span><span style="color:#D73A49"> =</span><span style="color:#032F62"> "unsupported_7z"</span></span>
<span class="line"><span style="color:#D73A49">    case</span><span style="color:#005CC5"> unsupportedCompression</span><span style="color:#D73A49"> =</span><span style="color:#032F62"> "unsupported_compression"</span></span>
<span class="line"><span style="color:#6A737D">    // …</span></span>
<span class="line"><span style="color:#24292E">}</span></span></code></pre>
<p><code>ArchiveOpenFailure</code> carries one of those, the counts that make it concrete, an analytics-only detail code, and the localised sentence the user sees. It never carries a filename. The reader’s page providers throw it, the converter’s extractors wrap it, and the importer wraps it, so the same file produces the same reason in all three places.</p>
<p>The decision of pages-or-a-reason lives in one classifier that both the reader and the converter call. It walks the entry list once and, if no usable page came out, works down a list of explanations in order of how specific they are:</p>
<pre class="astro-code github-light" style="background-color:#fff;color:#24292e; overflow-x: auto;" tabindex="0" data-language="swift"><code><span class="line"><span style="color:#D73A49">if</span><span style="color:#D73A49"> !</span><span style="color:#24292E">usable.</span><span style="color:#005CC5">isEmpty</span><span style="color:#24292E"> { </span><span style="color:#D73A49">return</span><span style="color:#24292E"> .</span><span style="color:#005CC5">success</span><span style="color:#24292E">(usable) }</span></span>
<span class="line"></span>
<span class="line"><span style="color:#D73A49">if</span><span style="color:#24292E"> imageCount </span><span style="color:#D73A49">></span><span style="color:#005CC5"> 0</span><span style="color:#24292E"> {</span></span>
<span class="line"><span style="color:#D73A49">    if</span><span style="color:#24292E"> encryptedImages </span><span style="color:#D73A49">></span><span style="color:#005CC5"> 0</span><span style="color:#24292E"> { </span><span style="color:#D73A49">return</span><span style="color:#24292E"> .</span><span style="color:#005CC5">failure</span><span style="color:#24292E">(</span><span style="color:#005CC5">ArchiveOpenFailure</span><span style="color:#24292E">(.passwordProtected, </span><span style="color:#D73A49">…</span><span style="color:#24292E">)) }</span></span>
<span class="line"><span style="color:#D73A49">    if</span><span style="color:#24292E"> unsupportedImages </span><span style="color:#D73A49">></span><span style="color:#005CC5"> 0</span><span style="color:#24292E"> { </span><span style="color:#D73A49">return</span><span style="color:#24292E"> .</span><span style="color:#005CC5">failure</span><span style="color:#24292E">(</span><span style="color:#005CC5">ArchiveOpenFailure</span><span style="color:#24292E">(.unsupportedCompression, </span><span style="color:#D73A49">…</span><span style="color:#24292E">)) }</span></span>
<span class="line"><span style="color:#D73A49">    return</span><span style="color:#24292E"> .</span><span style="color:#005CC5">failure</span><span style="color:#24292E">(</span><span style="color:#005CC5">ArchiveOpenFailure</span><span style="color:#24292E">(.pagesTooLarge, </span><span style="color:#D73A49">…</span><span style="color:#24292E">))</span></span>
<span class="line"><span style="color:#24292E">}</span></span>
<span class="line"><span style="color:#D73A49">if</span><span style="color:#24292E"> nestedArchives </span><span style="color:#D73A49">></span><span style="color:#005CC5"> 0</span><span style="color:#24292E"> { </span><span style="color:#D73A49">return</span><span style="color:#24292E"> .</span><span style="color:#005CC5">failure</span><span style="color:#24292E">(</span><span style="color:#005CC5">ArchiveOpenFailure</span><span style="color:#24292E">(.nestedArchives, </span><span style="color:#005CC5">relevantCount</span><span style="color:#24292E">: nestedArchives)) }</span></span>
<span class="line"><span style="color:#D73A49">if</span><span style="color:#24292E"> documents </span><span style="color:#D73A49">></span><span style="color:#005CC5"> 0</span><span style="color:#24292E"> { </span><span style="color:#D73A49">return</span><span style="color:#24292E"> .</span><span style="color:#005CC5">failure</span><span style="color:#24292E">(</span><span style="color:#005CC5">ArchiveOpenFailure</span><span style="color:#24292E">(.documentsOnly, </span><span style="color:#005CC5">relevantCount</span><span style="color:#24292E">: documents)) }</span></span>
<span class="line"><span style="color:#D73A49">return</span><span style="color:#24292E"> .</span><span style="color:#005CC5">failure</span><span style="color:#24292E">(</span><span style="color:#005CC5">ArchiveOpenFailure</span><span style="color:#24292E">(.noImages, </span><span style="color:#005CC5">entryCount</span><span style="color:#24292E">: fileCount))</span></span></code></pre>
<p>The order matters. An archive full of encrypted images is password-protected, not “no images”, even though from the page filter’s point of view both have zero usable pages. The user needs to hear the specific one.</p>
<p>Around that classifier sit two shared archive readers, one for RAR and one for ZIP, that open, classify and order pages for both the reader and the converter. Before 3.4 those two halves of the app each had their own idea of what was in a file. Now they cannot disagree, on which entries are pages, on what order they go in, or on why there are none. Page order is by full path now too, so a comic packed as chapter folders reads chapter one, then chapter two, instead of interleaving them by bare filename.</p>
<p>The ZIP reader tries UTF-8 first, then the ZIP specification’s default code page, then a lossy decode that cannot fail. The accented letter in <code>página</code> may come out wrong, because the default code page is not the one a Spanish Windows machine used, but the extension is ASCII and survives any eight-bit decoding, and the extension is all the page filter needs. A name that is slightly wrong is a comic that opens. A name that is empty is a blank book.</p>
<p>And the one rule that makes the rest hold: a provider handed to the reader has at least one page, or it was never handed over. The reader shows an error screen with the reason and a suggestion, the importer refuses the file and says why, and analytics records <code>reader_open_failed</code> with the reason code. The successful-open event now fires when the first page is on screen, not when the view appears.</p>
<h2 id="the-archive-that-took-forever">The archive that took forever</h2>
<p>The same investigation produced a small probe that reads the RAR main header straight from the file’s first bytes. It reports the format version, so a failure can be split by RAR4 and RAR5 in analytics instead of guessed at, and it reports whether an archive is <em>solid</em>. Solid archives turned out to have a problem of their own.</p>
<p>In a normal archive every file is compressed on its own. In a solid archive the compression runs continuously across file boundaries, so the data for any page depends on the state left behind by every page before it. Solid archives are smaller, which is why comic packers like them. Reaching any single entry means decompressing everything before it.</p>
<p>Unrar.swift’s <code>extract</code> reopens the archive and skips forward for every entry it is asked for. On a solid archive, “skipping” to a page costs a decode of every page before it. Extracting the whole book costs the sum of that, which is quadratic. I measured it on a real, full-length solid CBR and on a non-solid twin with identical pages.</p>
<p>On the solid archive the per-entry path took long enough to sit and watch, and the one pass was done before I could. The gap grew with the square of the page count, which is the shape you expect. On the non-solid twin the two paths were indistinguishable, and that second result is the one I care about: the one-pass version costs essentially nothing on archives that were never the problem.</p>
<p>The one pass drops below Unrar.swift to the bundled unrar C API. Open the archive once in extract mode, walk the headers in archive order, test the entries we want into a callback that collects the bytes, skip the ones we do not:</p>
<pre class="astro-code github-light" style="background-color:#fff;color:#24292e; overflow-x: auto;" tabindex="0" data-language="swift"><code><span class="line"><span style="color:#D73A49">let</span><span style="color:#24292E"> handle </span><span style="color:#D73A49">=</span><span style="color:#005CC5"> RAROpenArchiveEx</span><span style="color:#24292E">(</span><span style="color:#D73A49">&#x26;</span><span style="color:#24292E">flags)           </span><span style="color:#6A737D">// OpenMode = RAR_OM_EXTRACT, once</span></span>
<span class="line"><span style="color:#005CC5">RARSetCallback</span><span style="color:#24292E">(handle, callback, sinkPointer)  </span><span style="color:#6A737D">// bytes arrive here; unrar writes nothing itself</span></span>
<span class="line"></span>
<span class="line"><span style="color:#D73A49">while</span><span style="color:#005CC5"> RARReadHeaderEx</span><span style="color:#24292E">(handle, </span><span style="color:#D73A49">&#x26;</span><span style="color:#24292E">header) </span><span style="color:#D73A49">==</span><span style="color:#24292E"> ERAR_SUCCESS {</span></span>
<span class="line"><span style="color:#D73A49">    let</span><span style="color:#24292E"> name </span><span style="color:#D73A49">=</span><span style="color:#005CC5"> String</span><span style="color:#24292E">(</span><span style="color:#005CC5">cString</span><span style="color:#24292E">: </span><span style="color:#D73A49">&#x26;</span><span style="color:#24292E">header.FileName.0)</span></span>
<span class="line"><span style="color:#D73A49">    guard</span><span style="color:#D73A49"> let</span><span style="color:#24292E"> index </span><span style="color:#D73A49">=</span><span style="color:#24292E"> indexByName[name] </span><span style="color:#D73A49">else</span><span style="color:#24292E"> {</span></span>
<span class="line"><span style="color:#005CC5">        RARProcessFile</span><span style="color:#24292E">(handle, RAR_SKIP, </span><span style="color:#005CC5">nil</span><span style="color:#24292E">, </span><span style="color:#005CC5">nil</span><span style="color:#24292E">)    </span><span style="color:#6A737D">// not a page we want</span></span>
<span class="line"><span style="color:#D73A49">        continue</span></span>
<span class="line"><span style="color:#24292E">    }</span></span>
<span class="line"><span style="color:#24292E">    sink.</span><span style="color:#005CC5">reset</span><span style="color:#24292E">()</span></span>
<span class="line"><span style="color:#005CC5">    RARProcessFile</span><span style="color:#24292E">(handle, RAR_TEST, </span><span style="color:#005CC5">nil</span><span style="color:#24292E">, </span><span style="color:#005CC5">nil</span><span style="color:#24292E">)        </span><span style="color:#6A737D">// decode into the sink</span></span>
<span class="line"><span style="color:#D73A49">    try</span><span style="color:#24292E"> sink.data.</span><span style="color:#005CC5">write</span><span style="color:#24292E">(</span><span style="color:#005CC5">to</span><span style="color:#24292E">: directory.</span><span style="color:#005CC5">appendingPathComponent</span><span style="color:#24292E">(</span><span style="color:#005CC5">String</span><span style="color:#24292E">(</span><span style="color:#005CC5">format</span><span style="color:#24292E">: </span><span style="color:#032F62">"%05d_%@"</span><span style="color:#24292E">, index, name)))</span></span>
<span class="line"><span style="color:#24292E">}</span></span></code></pre>
<p>The interesting decision is not the loop. It is where the loop runs.</p>
<p>The converter uses it for every RAR, because a conversion extracts every page anyway and there is nothing to lose. The reader is more careful. It uses the one pass only for archives the header probe says are solid, and only once someone asks for a page past the first few. The opening pages still come out the direct way, one at a time.</p>
<p>The reason is the import. When a comic is added to the library the app needs its cover and nothing else. Page one of a solid archive costs one decode on the direct path, which is cheap. Running a full pass over a long book to produce a thumbnail would make the common case slower in order to fix a rare one. So the reader pays the setup cost only when a person is demonstrably reading deep into a solid book, spills the pages to a temporary directory once, serves everything after that from disk, and deletes the directory when the book is closed.</p>
<h2 id="not-selling-to-someone-you-just-failed">Not selling to someone you just failed</h2>
<p>The review that held the release open came from someone who had paid. Whatever else went wrong for them, the app had asked for money from a person it was, at that moment, failing. Pro has nothing to do with whether a file opens, but from the outside that sequence is indistinguishable from being cheated, and the review said so.</p>
<p>So build 3 also has a rule about when not to sell. For a day after the app fails a user, whether that is a conversion error, an import it could not add, or a comic that would not open, the promotional Pro surfaces go quiet. No What’s New upsell, no post-conversion card, no onboarding link.</p>
<pre class="astro-code github-light" style="background-color:#fff;color:#24292e; overflow-x: auto;" tabindex="0" data-language="swift"><code><span class="line"><span style="color:#D73A49">static</span><span style="color:#D73A49"> func</span><span style="color:#6F42C1"> presentation</span><span style="color:#24292E">(</span><span style="color:#6F42C1">for</span><span style="color:#24292E"> surface: PaywallSurface, </span><span style="color:#6F42C1">lastTroubleDate</span><span style="color:#24292E">: Date</span><span style="color:#D73A49">?</span><span style="color:#24292E">) </span><span style="color:#D73A49">-></span><span style="color:#24292E"> PaywallPresentation {</span></span>
<span class="line"><span style="color:#D73A49">    guard</span><span style="color:#005CC5"> hasRecentTrouble</span><span style="color:#24292E">(</span><span style="color:#005CC5">lastTroubleDate</span><span style="color:#24292E">: lastTroubleDate) </span><span style="color:#D73A49">else</span><span style="color:#24292E"> { </span><span style="color:#D73A49">return</span><span style="color:#24292E"> .show }</span></span>
<span class="line"><span style="color:#D73A49">    switch</span><span style="color:#24292E"> surface {</span></span>
<span class="line"><span style="color:#D73A49">    case</span><span style="color:#24292E"> .promotional</span><span style="color:#D73A49">:</span><span style="color:#D73A49">   return</span><span style="color:#24292E"> .suppress</span></span>
<span class="line"><span style="color:#D73A49">    case</span><span style="color:#24292E"> .userInitiated</span><span style="color:#D73A49">:</span><span style="color:#D73A49"> return</span><span style="color:#24292E"> .showWithTroubleNotice</span></span>
<span class="line"><span style="color:#24292E">    }</span></span>
<span class="line"><span style="color:#24292E">}</span></span></code></pre>
<p>The second case is the tension, and I want to be honest that it is one. A paywall the user opens themselves, by tapping a Pro feature or the Pro row in Settings, is not suppressed. Hiding the purchase from someone who is actively trying to buy is its own failure. Instead it shows, with a short note on top that says Pro adds features and does not change which files open, and a link to help. Pro stays discoverable. It just stops being offered as a fix.</p>
<p>There is a counter on the suppressed impressions, so I will know what this costs. A rule like this should be accountable, not just virtuous.</p>
<h2 id="the-corpus">The corpus</h2>
<p>None of this would have been found by the unit tests I had, which fed synthetic headers to the sniffer and passed throughout. So the fixtures are real archives now, generated by a script that runs the real tools: RAR4 and RAR5, plain and solid, a long solid one, encrypted files, encrypted headers, a volume set, a recovery record, comics nested in a <code>.rar</code>, a PDF inside a <code>.rar</code>, chapter folders, an HTML page saved as <code>.cbr</code>, a 7z, a ZIP with legacy code-page names, an encrypted ZIP, a ZIP64 ZIP, and a bzip2 ZIP.</p>
<p>Every fixture is pushed through both the reader and the converter, and the tests assert not just that each one opens or fails with the right reason, but that the two halves of the app agree. The rule going forward is simple: when a new flavour of archive fails in the wild, it becomes a fixture before it becomes a fix.</p>
<h2 id="what-i-would-tell-myself-in-january">What I would tell myself in January</h2>
<p><strong>A swallowed error does not hide one failure. It merges all of them.</strong> Six causes became one symptom, and one symptom is exactly enough to build a wrong theory on.</p>
<p><strong>Confidence that comes from a single bit is not knowledge.</strong> I had a specific, mechanistic, plausible explanation and it was wrong, because nothing in the system could have told me otherwise. The way out was not to think harder. It was to build a real RAR5 file and look.</p>
<p><strong>A success event that fires before anything is on screen is not measuring success.</strong> Since April my analytics had counted blank books as opened books, and I read the number as reassurance.</p>
<p><strong>Test the hypothesis before the fix.</strong> One real fixture would have saved me from designing around a library that was never broken.</p>
<p><strong>A finished release is not a reason to ship.</strong> The features were done. Shipping them on top of a reader that could still open a blank book would have put a new What’s New sheet in front of the next person it failed.</p>
<p>7z is still not supported, and the app now says so instead of showing an empty book. I still do not know which of the six that reader hit. Neither did the app, which was the whole problem, and the next person will be told.</p>]]></content:encoded>
    </item>
    <item>
      <title>A One-Star Review, and the Eight Bytes That Fixed It</title>
      <link>https://heynavid.com/blog/mislabelled-comic-archives/</link>
      <guid isPermaLink="true">https://heynavid.com/blog/mislabelled-comic-archives/</guid>
      <pubDate>Sun, 06 Sep 2026 00:00:00 GMT</pubDate>
      <dc:creator>Navid</dc:creator>
      <category>comicflow</category>
      <category>ios</category>
      <category>behind-the-scenes</category>
      <description>A reader said ComicFlow didn't recognise 90% of his comic files. He was right, and every one of those files was fine. I was routing archives by their filename instead of reading what was actually inside them.</description>
      <content:encoded><![CDATA[<p><img src="https://heynavid.com/blog/images/mislabelled-archives-hero.jpg" alt="" /></p><p>On 2 September a US reader left ComicFlow one star. The review said it doesn’t recognise 90% of CBZ and CBR files.</p>
<p>My first reaction was that 90% is obviously wrong. I convert comics in this app every week. The reader is fine, the converter is fine, and if it failed on nine files out of ten there would be more than one review saying so.</p>
<p>That reaction was the bug. Not a bug in the code, a bug in me: I checked the claim against my own files, which all came from places that name things correctly, and concluded the claim was exaggerated. His files came from somewhere else, and for his library the number was probably about right.</p>
<p>Version 3.3 shipped the following day. The fix reads eight bytes.</p>
<h2 id="what-a-cbz-actually-is">What a CBZ actually is</h2>
<p>A CBZ is a ZIP archive of numbered page images. A CBR is a RAR archive of the same thing. A CB7 is 7-Zip. The extension exists to tell a reader “open this in comic mode” and carries no other information.</p>
<p>Nothing enforces it. Renaming <code>volume01.cbr</code> to <code>volume01.cbz</code> changes the name and not one byte of the contents. The file is still a RAR.</p>
<p>So the extension is a hint supplied by whoever last touched the file, which on a comic archive is a long list of people: scanlation groups who rename by hand, sites whose export script hardcodes one extension, and readers who rename a file to the other one hoping it will start working. That last one is the cruel case. They are trying to fix it, and they are making it undiagnosable.</p>
<h2 id="the-code-that-was-wrong">The code that was wrong</h2>
<p>Both paths through the app made the same decision the same way. The reader picked a page provider, the converter picked an extractor, and both branched on <code>pathExtension</code>:</p>
<pre class="astro-code github-light" style="background-color:#fff;color:#24292e; overflow-x: auto;" tabindex="0" data-language="swift"><code><span class="line"><span style="color:#D73A49">switch</span><span style="color:#24292E"> url.pathExtension.</span><span style="color:#005CC5">lowercased</span><span style="color:#24292E">() {</span></span>
<span class="line"><span style="color:#D73A49">case</span><span style="color:#032F62"> "cbz"</span><span style="color:#24292E">, </span><span style="color:#032F62">"zip"</span><span style="color:#D73A49">:</span><span style="color:#D73A49"> return</span><span style="color:#005CC5"> ZIPExtractor</span><span style="color:#24292E">()</span></span>
<span class="line"><span style="color:#D73A49">case</span><span style="color:#032F62"> "pdf"</span><span style="color:#D73A49">:</span><span style="color:#D73A49">        return</span><span style="color:#005CC5"> PDFRasterizer</span><span style="color:#24292E">()</span></span>
<span class="line"><span style="color:#D73A49">default:</span><span style="color:#D73A49">           return</span><span style="color:#005CC5"> RARExtractor</span><span style="color:#24292E">()</span></span>
<span class="line"><span style="color:#24292E">}</span></span></code></pre>
<p>Look at the <code>default</code>. Anything unrecognised goes to RAR, which is a reasonable guess in 2019 and is also the line that makes the failure silent. A ZIP named <code>.cbr</code> lands in the RAR extractor. A RAR named <code>.cbz</code> lands in the ZIP extractor, where <code>ZIPArchive(url:)</code> returns nil:</p>
<pre class="astro-code github-light" style="background-color:#fff;color:#24292e; overflow-x: auto;" tabindex="0" data-language="swift"><code><span class="line"><span style="color:#D73A49">guard</span><span style="color:#D73A49"> let</span><span style="color:#24292E"> archive </span><span style="color:#D73A49">=</span><span style="color:#005CC5"> ZIPArchive</span><span style="color:#24292E">(</span><span style="color:#005CC5">url</span><span style="color:#24292E">: zipURL) </span><span style="color:#D73A49">else</span><span style="color:#24292E"> {</span></span>
<span class="line"><span style="color:#24292E">    continuation.</span><span style="color:#005CC5">yield</span><span style="color:#24292E">(.</span><span style="color:#005CC5">failed</span><span style="color:#24292E">(.invalidFile))</span></span>
<span class="line"><span style="color:#D73A49">    return</span></span>
<span class="line"><span style="color:#24292E">}</span></span></code></pre>
<p>Both directions surface as <code>invalidFile</code>. The user sees a message that amounts to “this file is broken”, closes the app, opens the same file in any other reader, and it works. From where they are standing, my app is the broken one. They are not wrong.</p>
<h2 id="what-the-telemetry-said-once-i-went-looking">What the telemetry said once I went looking</h2>
<p>Twenty <code>invalidFile</code> reports on the shipped 3.2 build between 8 August and 2 September. Not twenty over the app’s lifetime. Twenty in about three and a half weeks, on one version.</p>
<p>I had been reading that number as a rounding error, because twenty is small next to total conversions and because <code>invalidFile</code> is exactly what you would expect a genuinely corrupt download to produce. It is a plausible error. That is what made it invisible: the bug was hiding behind an error message that was doing its job.</p>
<p>The one-star review is what reframed it. Twenty reports of “your app is broken” from people who each had a working file is not a rounding error, it is a category of failure I had labelled as user error and stopped looking at.</p>
<h2 id="the-fix">The fix</h2>
<p>Read the header. Every archive format starts with a fixed signature, and eight bytes covers all of them:</p>
<pre class="astro-code github-light" style="background-color:#fff;color:#24292e; overflow-x: auto;" tabindex="0" data-language="swift"><code><span class="line"><span style="color:#D73A49">static</span><span style="color:#D73A49"> func</span><span style="color:#6F42C1"> container</span><span style="color:#24292E">(</span><span style="color:#6F42C1">forHeader</span><span style="color:#24292E"> bytes: [</span><span style="color:#005CC5">UInt8</span><span style="color:#24292E">]) </span><span style="color:#D73A49">-></span><span style="color:#24292E"> ArchiveContainer {</span></span>
<span class="line"><span style="color:#6A737D">    // ZIP: local file header, plus the empty and spanned variants.</span></span>
<span class="line"><span style="color:#D73A49">    if</span><span style="color:#005CC5"> matches</span><span style="color:#24292E">([</span><span style="color:#005CC5">0x50</span><span style="color:#24292E">, </span><span style="color:#005CC5">0x4B</span><span style="color:#24292E">, </span><span style="color:#005CC5">0x03</span><span style="color:#24292E">, </span><span style="color:#005CC5">0x04</span><span style="color:#24292E">]) </span><span style="color:#D73A49">||</span></span>
<span class="line"><span style="color:#005CC5">       matches</span><span style="color:#24292E">([</span><span style="color:#005CC5">0x50</span><span style="color:#24292E">, </span><span style="color:#005CC5">0x4B</span><span style="color:#24292E">, </span><span style="color:#005CC5">0x05</span><span style="color:#24292E">, </span><span style="color:#005CC5">0x06</span><span style="color:#24292E">]) </span><span style="color:#D73A49">||</span></span>
<span class="line"><span style="color:#005CC5">       matches</span><span style="color:#24292E">([</span><span style="color:#005CC5">0x50</span><span style="color:#24292E">, </span><span style="color:#005CC5">0x4B</span><span style="color:#24292E">, </span><span style="color:#005CC5">0x07</span><span style="color:#24292E">, </span><span style="color:#005CC5">0x08</span><span style="color:#24292E">]) { </span><span style="color:#D73A49">return</span><span style="color:#24292E"> .zip }</span></span>
<span class="line"><span style="color:#6A737D">    // RAR 5:  Rar!\x1A\x07\x01\x00      RAR 4: Rar!\x1A\x07\x00</span></span>
<span class="line"><span style="color:#D73A49">    if</span><span style="color:#005CC5"> matches</span><span style="color:#24292E">([</span><span style="color:#005CC5">0x52</span><span style="color:#24292E">, </span><span style="color:#005CC5">0x61</span><span style="color:#24292E">, </span><span style="color:#005CC5">0x72</span><span style="color:#24292E">, </span><span style="color:#005CC5">0x21</span><span style="color:#24292E">, </span><span style="color:#005CC5">0x1A</span><span style="color:#24292E">, </span><span style="color:#005CC5">0x07</span><span style="color:#24292E">, </span><span style="color:#005CC5">0x01</span><span style="color:#24292E">, </span><span style="color:#005CC5">0x00</span><span style="color:#24292E">]) </span><span style="color:#D73A49">||</span></span>
<span class="line"><span style="color:#005CC5">       matches</span><span style="color:#24292E">([</span><span style="color:#005CC5">0x52</span><span style="color:#24292E">, </span><span style="color:#005CC5">0x61</span><span style="color:#24292E">, </span><span style="color:#005CC5">0x72</span><span style="color:#24292E">, </span><span style="color:#005CC5">0x21</span><span style="color:#24292E">, </span><span style="color:#005CC5">0x1A</span><span style="color:#24292E">, </span><span style="color:#005CC5">0x07</span><span style="color:#24292E">, </span><span style="color:#005CC5">0x00</span><span style="color:#24292E">]) { </span><span style="color:#D73A49">return</span><span style="color:#24292E"> .rar }</span></span>
<span class="line"><span style="color:#D73A49">    if</span><span style="color:#005CC5"> matches</span><span style="color:#24292E">([</span><span style="color:#005CC5">0x25</span><span style="color:#24292E">, </span><span style="color:#005CC5">0x50</span><span style="color:#24292E">, </span><span style="color:#005CC5">0x44</span><span style="color:#24292E">, </span><span style="color:#005CC5">0x46</span><span style="color:#24292E">, </span><span style="color:#005CC5">0x2D</span><span style="color:#24292E">]) { </span><span style="color:#D73A49">return</span><span style="color:#24292E"> .pdf }        </span><span style="color:#6A737D">// %PDF-</span></span>
<span class="line"><span style="color:#D73A49">    if</span><span style="color:#005CC5"> matches</span><span style="color:#24292E">([</span><span style="color:#005CC5">0x37</span><span style="color:#24292E">, </span><span style="color:#005CC5">0x7A</span><span style="color:#24292E">, </span><span style="color:#005CC5">0xBC</span><span style="color:#24292E">, </span><span style="color:#005CC5">0xAF</span><span style="color:#24292E">, </span><span style="color:#005CC5">0x27</span><span style="color:#24292E">, </span><span style="color:#005CC5">0x1C</span><span style="color:#24292E">]) { </span><span style="color:#D73A49">return</span><span style="color:#24292E"> .sevenZip }</span></span>
<span class="line"><span style="color:#D73A49">    return</span><span style="color:#24292E"> .unknown</span></span>
<span class="line"><span style="color:#24292E">}</span></span></code></pre>
<p>Then one rule about precedence:</p>
<pre class="astro-code github-light" style="background-color:#fff;color:#24292e; overflow-x: auto;" tabindex="0" data-language="swift"><code><span class="line"><span style="color:#D73A49">static</span><span style="color:#D73A49"> func</span><span style="color:#6F42C1"> resolvedContainer</span><span style="color:#24292E">(</span><span style="color:#6F42C1">of</span><span style="color:#24292E"> url: URL) </span><span style="color:#D73A49">-></span><span style="color:#24292E"> ArchiveContainer {</span></span>
<span class="line"><span style="color:#D73A49">    let</span><span style="color:#24292E"> sniffed </span><span style="color:#D73A49">=</span><span style="color:#005CC5"> container</span><span style="color:#24292E">(</span><span style="color:#005CC5">of</span><span style="color:#24292E">: url)</span></span>
<span class="line"><span style="color:#D73A49">    if</span><span style="color:#24292E"> sniffed </span><span style="color:#D73A49">!=</span><span style="color:#24292E"> .unknown { </span><span style="color:#D73A49">return</span><span style="color:#24292E"> sniffed }</span></span>
<span class="line"><span style="color:#D73A49">    return</span><span style="color:#005CC5"> containerFromExtension</span><span style="color:#24292E">(</span><span style="color:#005CC5">of</span><span style="color:#24292E">: url)</span></span>
<span class="line"><span style="color:#24292E">}</span></span></code></pre>
<p>The header wins. The extension is the fallback for a file whose header we cannot read or do not recognise, and that ordering is the whole decision: a mislabelled file is common, an unreadable header is not. Getting it the other way round would have fixed nothing.</p>
<p>The extension fallback also keeps the old <code>default: .rar</code> behaviour intact, deliberately. I did not want a file that happened to work in 3.2 to stop working in 3.3 because I had tidied up an unrelated branch.</p>
<h2 id="the-trap-i-nearly-shipped">The trap I nearly shipped</h2>
<p>EPUB is a ZIP. Its header is <code>PK\x03\x04</code> like any other ZIP, so a sniffer routes an EPUB straight to the CBZ provider, which opens it as a bag of images and shows you the cover art, the publisher logo and whatever illustrations the book contains, in archive order.</p>
<p>That is worse than the bug I was fixing. Failing to open a file is annoying. Opening a novel as forty loose JPEGs is a wrong answer delivered confidently.</p>
<p>So the declared format has to be checked first, before any sniffing happens:</p>
<pre class="astro-code github-light" style="background-color:#fff;color:#24292e; overflow-x: auto;" tabindex="0" data-language="swift"><code><span class="line"><span style="color:#6A737D">// EPUB is a ZIP container by header, so it MUST be rejected on the declared</span></span>
<span class="line"><span style="color:#6A737D">// format before sniffing, otherwise the detector routes it to the CBZ</span></span>
<span class="line"><span style="color:#6A737D">// provider, which would open a book as a bag of images.</span></span>
<span class="line"><span style="color:#D73A49">guard</span><span style="color:#24292E"> declared </span><span style="color:#D73A49">!=</span><span style="color:#24292E"> .epub </span><span style="color:#D73A49">else</span><span style="color:#24292E"> { </span><span style="color:#D73A49">throw</span><span style="color:#24292E"> PageProviderError.unsupportedFormat }</span></span></code></pre>
<p>Content sniffing tells you what a file <em>is</em>. It cannot tell you what it is <em>for</em>. When two formats share a container, the extension is the only signal you have about intent, and the fix is to keep using it for that one question rather than to throw it away because it lied to you about something else.</p>
<h2 id="being-right-about-the-error-not-just-the-outcome">Being right about the error, not just the outcome</h2>
<p>One of the ZIP signatures in that list is not a real archive. <code>PK\x05\x06</code> is an empty ZIP: a valid file with nothing inside it.</p>
<p>I could have left it out. It is not going to extract either way. But an empty ZIP with no signature match falls to the extension fallback, gets routed to RAR, and fails as <code>invalidFile</code>, which tells the user their file is corrupt. Detected as a ZIP, it reaches the ZIP extractor and fails as <code>noImagesFound</code>, which tells them the archive is empty.</p>
<p>Both are failures. Only one of them is true, and only one tells the user something they can act on. Error accuracy is worth code even when it does not change whether the operation succeeds, because the error is the entire product at the moment it fires.</p>
<h2 id="the-test-i-had-and-the-test-i-needed">The test I had, and the test I needed</h2>
<p>I already had unit tests over the signatures. They fed synthetic eight-byte arrays to <code>container(forHeader:)</code> and asserted the right enum came back. They passed the whole time the app was failing.</p>
<p>They were testing the part I got right. The bug was two layers up, in the routing, and no test touched it.</p>
<p>What was missing builds a real archive and drives the real code over it:</p>
<pre class="astro-code github-light" style="background-color:#fff;color:#24292e; overflow-x: auto;" tabindex="0" data-language="swift"><code><span class="line"><span style="color:#D73A49">func</span><span style="color:#6F42C1"> testRealZipNamedCBRIsDetectedAndExtracts</span><span style="color:#24292E">() </span><span style="color:#D73A49">async</span><span style="color:#D73A49"> throws</span><span style="color:#24292E"> {</span></span>
<span class="line"><span style="color:#D73A49">    let</span><span style="color:#24292E"> cbr </span><span style="color:#D73A49">=</span><span style="color:#D73A49"> try</span><span style="color:#D73A49"> await</span><span style="color:#005CC5"> Self</span><span style="color:#24292E">.</span><span style="color:#005CC5">makeRealZip</span><span style="color:#24292E">(</span><span style="color:#005CC5">in</span><span style="color:#24292E">: tempDir, </span><span style="color:#005CC5">named</span><span style="color:#24292E">: </span><span style="color:#032F62">"Volume 01.cbr"</span><span style="color:#24292E">, </span><span style="color:#005CC5">pages</span><span style="color:#24292E">: </span><span style="color:#005CC5">4</span><span style="color:#24292E">)</span></span>
<span class="line"></span>
<span class="line"><span style="color:#005CC5">    XCTAssertEqual</span><span style="color:#24292E">(ArchiveContainerDetector.</span><span style="color:#005CC5">resolvedContainer</span><span style="color:#24292E">(</span><span style="color:#005CC5">of</span><span style="color:#24292E">: cbr), .zip,</span></span>
<span class="line"><span style="color:#032F62">                   "a real ZIP named .cbr must route to the ZIP extractor"</span><span style="color:#24292E">)</span></span>
<span class="line"></span>
<span class="line"><span style="color:#D73A49">    let</span><span style="color:#24292E"> urls </span><span style="color:#D73A49">=</span><span style="color:#D73A49"> try</span><span style="color:#D73A49"> await</span><span style="color:#005CC5"> Self</span><span style="color:#24292E">.</span><span style="color:#005CC5">extractZip</span><span style="color:#24292E">(</span><span style="color:#005CC5">at</span><span style="color:#24292E">: cbr, </span><span style="color:#005CC5">to</span><span style="color:#24292E">: extracted)</span></span>
<span class="line"><span style="color:#005CC5">    XCTAssertEqual</span><span style="color:#24292E">(urls.</span><span style="color:#005CC5">count</span><span style="color:#24292E">, </span><span style="color:#005CC5">4</span><span style="color:#24292E">, </span><span style="color:#032F62">"all pages must come out of the mislabelled archive"</span><span style="color:#24292E">)</span></span>
<span class="line"><span style="color:#24292E">}</span></span></code></pre>
<p>It writes a genuine ZIP with the app’s own exporter, renames it <code>.cbr</code>, and pushes it through the real extractor. That is the user’s scenario exactly, and against 3.2 it failed in about 28 milliseconds, four times in a row, with <code>invalidFile</code>.</p>
<p>There is a control test beside it that runs the same archive under its correct name, so I can prove the fix did not trade one mislabelling for another.</p>
<h2 id="the-other-thing-the-same-investigation-found">The other thing the same investigation found</h2>
<p>Once I was looking at how entries get chosen rather than how archives get opened, a second bug was sitting in plain sight.</p>
<p>Zip a folder in macOS Finder and you get a hidden <code>__MACOSX</code> directory carrying a <code>._page001.jpg</code> AppleDouble sidecar for every real <code>page001.jpg</code>. Those sidecars have an image extension and are real entries, so my page filter accepted them. They also sort <em>ahead</em> of the real pages, and the PDF generator hard-failed if it could not read the first image.</p>
<p>The result: <strong>any CBZ zipped on a Mac failed to convert at all</strong>, with <code>pdfCreationFailed("Could not read first image")</code>.</p>
<p>The page filter had been copy-pasted into three places, <code>Set(["jpg", "jpeg", "png", "webp"])</code> in the ZIP path, the RAR path and the post-extraction sort, with no filter for archive metadata in any of them. That duplication also meant comics whose pages were GIF, BMP, TIFF or HEIC reported <code>noImagesFound</code> while being full of images iOS decodes natively.</p>
<p>All three now call one function that knows what a page is and what is housekeeping:</p>
<pre class="astro-code github-light" style="background-color:#fff;color:#24292e; overflow-x: auto;" tabindex="0" data-language="swift"><code><span class="line"><span style="color:#D73A49">static</span><span style="color:#D73A49"> func</span><span style="color:#6F42C1"> isMetadata</span><span style="color:#24292E">(</span><span style="color:#6F42C1">path</span><span style="color:#24292E">: </span><span style="color:#005CC5">String</span><span style="color:#24292E">) </span><span style="color:#D73A49">-></span><span style="color:#005CC5"> Bool</span><span style="color:#24292E"> {</span></span>
<span class="line"><span style="color:#D73A49">    let</span><span style="color:#24292E"> components </span><span style="color:#D73A49">=</span><span style="color:#24292E"> path.</span><span style="color:#005CC5">split</span><span style="color:#24292E">(</span><span style="color:#005CC5">separator</span><span style="color:#24292E">: </span><span style="color:#032F62">"/"</span><span style="color:#24292E">, </span><span style="color:#005CC5">omittingEmptySubsequences</span><span style="color:#24292E">: </span><span style="color:#005CC5">true</span><span style="color:#24292E">)</span></span>
<span class="line"><span style="color:#D73A49">    if</span><span style="color:#24292E"> components.</span><span style="color:#005CC5">contains</span><span style="color:#24292E">(</span><span style="color:#005CC5">where</span><span style="color:#24292E">: { </span><span style="color:#005CC5">$0</span><span style="color:#D73A49"> ==</span><span style="color:#032F62"> "__MACOSX"</span><span style="color:#24292E"> }) { </span><span style="color:#D73A49">return</span><span style="color:#005CC5"> true</span><span style="color:#24292E"> }</span></span>
<span class="line"><span style="color:#D73A49">    guard</span><span style="color:#D73A49"> let</span><span style="color:#24292E"> name </span><span style="color:#D73A49">=</span><span style="color:#24292E"> components.</span><span style="color:#005CC5">last</span><span style="color:#D73A49"> else</span><span style="color:#24292E"> { </span><span style="color:#D73A49">return</span><span style="color:#005CC5"> true</span><span style="color:#24292E"> }</span></span>
<span class="line"><span style="color:#D73A49">    if</span><span style="color:#24292E"> name.</span><span style="color:#005CC5">hasPrefix</span><span style="color:#24292E">(</span><span style="color:#032F62">"._"</span><span style="color:#24292E">) { </span><span style="color:#D73A49">return</span><span style="color:#005CC5"> true</span><span style="color:#24292E"> }</span></span>
<span class="line"><span style="color:#D73A49">    let</span><span style="color:#24292E"> lowercased </span><span style="color:#D73A49">=</span><span style="color:#24292E"> name.</span><span style="color:#005CC5">lowercased</span><span style="color:#24292E">()</span></span>
<span class="line"><span style="color:#D73A49">    return</span><span style="color:#24292E"> lowercased </span><span style="color:#D73A49">==</span><span style="color:#032F62"> ".ds_store"</span><span style="color:#D73A49"> ||</span><span style="color:#24292E"> lowercased </span><span style="color:#D73A49">==</span><span style="color:#032F62"> "thumbs.db"</span></span>
<span class="line"><span style="color:#24292E">}</span></span></code></pre>
<p>Three copies of a rule are three chances to be wrong in different ways, and I had taken all three.</p>
<h2 id="what-i-would-tell-myself-in-august">What I would tell myself in August</h2>
<p><strong>A filename is user input.</strong> I know this about text fields and I did not know it about file extensions, which are the same thing with more steps: a string, supplied by a person, that I was treating as a fact about the bytes on disk.</p>
<p><strong>Any format where the label is separable from the contents will get separated.</strong> Not occasionally. Continuously, by well-meaning people, at a rate you cannot influence. Formats that carry a magic number are telling you they expect this.</p>
<p><strong>A plausible error message hides the bug behind it.</strong> <code>invalidFile</code> was doing its job so convincingly that I read twenty reports as twenty broken downloads. If I had picked one of them up and asked whether the file was actually broken, I would have found this four weeks earlier.</p>
<p><strong>Test the routing, not the parser.</strong> My signature tests were green throughout. Confidence came from the layer that was already correct, which is the least useful place to have coverage.</p>
<p>There is still a list of things this does not fix. Encrypted archives need the password, split RARs need every part joined on a computer, and CB7 needs 7-Zip support I have not written. Those genuinely cannot be solved on a phone, and 3.3 at least now says which one you have hit instead of calling all of them invalid.</p>
<p>The reader who left that review has not come back, which is fair. His files work now.</p>]]></content:encoded>
    </item>
    <item>
      <title>Measuring Light With an iPhone, and Admitting How Wrong It Might Be</title>
      <link>https://heynavid.com/blog/honest-light-measurement-iphone/</link>
      <guid isPermaLink="true">https://heynavid.com/blog/honest-light-measurement-iphone/</guid>
      <pubDate>Wed, 26 Aug 2026 00:00:00 GMT</pubDate>
      <dc:creator>Navid</dc:creator>
      <category>solspot</category>
      <category>ios</category>
      <category>behind-the-scenes</category>
      <description>iOS won't give third-party apps the ambient light sensor. You can derive lux from the camera's exposure metering instead, but the number is soft. Building Solspot was mostly about what to do with that.</description>
      <content:encoded><![CDATA[<p><img src="https://heynavid.com/blog/images/honest-light-measurement-hero.jpg" alt="" /></p><p>Solspot measures how much light a spot in a home gets, in lux, and tells you which houseplants will live there. The measuring part is four lines of arithmetic wrapped in a great deal of doubt, and the doubt turned out to be the whole design problem.</p>
<p>This is a write-up of what an iPhone can and cannot tell you about light, and what I decided to do about the gap.</p>
<h2 id="ios-will-not-give-you-the-light-sensor">iOS will not give you the light sensor</h2>
<p>Every iPhone has an ambient light sensor. It sits near the front camera and it is why your screen dims when you walk into a dark room. It is a real photometric sensor and it is exactly the thing you want.</p>
<p>You cannot have it. There is no public API that hands a third-party app a lux value from it. It has been that way for years, and there is a decent argument for it: a background-capable app reading ambient light continuously is a side channel for a lot of things Apple would rather it not be.</p>
<p>So every light meter app on iOS is doing the same thing instead: pointing the camera at the problem and working backwards from how the camera chose to expose.</p>
<h2 id="working-backwards-from-exposure">Working backwards from exposure</h2>
<p>A camera metering a scene is already solving this. It picks an ISO, a shutter duration and an aperture that will produce a correctly exposed image, and those three values encode how much light is arriving. Run the relationship in reverse and you get a brightness estimate.</p>
<p>The photographic shorthand is exposure value:</p>
<pre class="astro-code github-light" style="background-color:#fff;color:#24292e; overflow-x: auto;" tabindex="0" data-language="plaintext"><code><span class="line"><span>EV = log2((N * N) / t) - log2(S / 100)</span></span></code></pre>
<p>where <code>N</code> is the f-number, <code>t</code> the exposure duration in seconds, and <code>S</code> the ISO. That gives you a number on a scale photographers have been using for a century. Converting to lux is then a single constant:</p>
<pre class="astro-code github-light" style="background-color:#fff;color:#24292e; overflow-x: auto;" tabindex="0" data-language="plaintext"><code><span class="line"><span>lux ≈ C * 2^EV</span></span></code></pre>
<p>and <code>C</code> is where all the trouble lives. The textbook value sits around 2.5, derived from the incident-light calibration used by handheld meters. Whether it is right for the specific piece of glass and silicon in the phone in your hand is another question entirely.</p>
<h2 id="everything-that-makes-the-number-soft">Everything that makes the number soft</h2>
<p>The arithmetic is exact. The inputs are not.</p>
<p><strong>The lens is not a lab instrument.</strong> The front camera sits behind a cover glass that has fingerprints on it, and possibly a screen protector, and possibly a case lip. Every one of those attenuates light. None of them are measurable from inside the app.</p>
<p><strong>Apple does not publish per-device calibration.</strong> Sensor and lens vary across models and across production runs of the same model. Two phones on the same windowsill will not agree. Which one is right is not a question I can answer from software.</p>
<p><strong>Cameras are tuned for pictures, not photometry.</strong> Auto-exposure is trying to produce a pleasing image. It has opinions about highlights, it applies scene-dependent behaviour, and it will happily clip. Above a certain brightness the sensor saturates and the meter simply stops climbing, which means bright conditions read as less bright than they are, not more.</p>
<p><strong>The measurement geometry is wrong by default.</strong> A camera measures light arriving from the direction it is pointed, within its field of view. A leaf receives light arriving at a surface from the whole hemisphere above it. Those are different quantities. A proper incident light meter has a white diffuser dome over the sensor to integrate that hemisphere. A phone has flat glass.</p>
<p>That last one is the biggest single source of error, and it is the one that no amount of calibration constant fixes.</p>
<h2 id="what-i-did-about-it">What I did about it</h2>
<p>The honest response to all of that is not to hide it behind a confident number.</p>
<p><strong>Measure the spot, never the plant.</strong> The instruction the app gives is to lay the phone flat, screen facing up, where the pot would stand. Flat and face-up is the closest a phone gets to the geometry an incident meter uses, and it is also the geometry a leaf actually experiences. Pointing the phone <em>at</em> a plant measures light reflected off leaves, which is a different and much less useful quantity. Most of the value in the app is in that one instruction, and it is free.</p>
<p><strong>Gate the reading on the conditions being right.</strong> The app checks that the device is actually flat and that the reading has settled before it will commit to a number, and it says so on screen. A reading taken while the phone is tilted or while auto-exposure is still hunting is worse than no reading, because it looks identical to a good one.</p>
<p><strong>Report a range, not a point.</strong> Every reading carries the interval the true value could honestly sit in. When the device has not been calibrated, that interval is wide, and the app says the number is a rough estimate and the category is the reliable part. When the sensor is saturating, it says the spot is brighter than it can measure rather than reporting the ceiling as if it were the answer.</p>
<figure class="phone-shot">
  <img src="https://heynavid.com/apps/shots/solspot/2.png" alt="A Solspot reading screen showing a spot in the Bright Indirect band, with the lux figure, the range the true value could sit in, and a note that the device is not calibrated so the category is the reliable part" width="1024" height="2225" loading="lazy">
  <figcaption>The range and the calibration note sit on the same screen as the number, not behind an info button.</figcaption>
</figure>
<p><strong>Make the category the product.</strong> Readings sort into four bands: low under 2,500 lux, medium from 2,500 to 10,000, bright indirect from 10,000 to 20,000, direct above 20,000. Those bands are wide on purpose. A category survives a calibration error that a three-digit lux figure does not, and the category is the thing that actually answers the user’s question, which is whether a plant will live there.</p>
<h2 id="the-insight-that-makes-it-work-anyway">The insight that makes it work anyway</h2>
<p>Here is the part that took me a while to see, and it is the reason the app is worth shipping despite everything above.</p>
<p>Most of the error is systematic, not random. The same phone with the same smudge on the same glass will be wrong in the same direction every time. Which means that comparing two readings from one device cancels almost all of it.</p>
<p>“Is this windowsill brighter than that bookshelf” is answerable to a high degree of confidence by a badly calibrated sensor. “Is this windowsill exactly 12,400 lux” is not. Real users overwhelmingly ask the first question. They are choosing between two spots in one home, or checking whether a shelf is anywhere near what a plant needs.</p>
<p>So the app leans into comparison. You save spots, and you re-measure them later. A windowsill in June and the same windowsill in December are meaningfully different places, and that difference is measurable with an instrument far too crude to tell you either absolute value.</p>
<p>The uncomfortable corollary is that a light meter app which brags about accuracy is either better calibrated than mine or lying, and there is no way for a user to tell which. I decided the only defensible position was to state the uncertainty on the same screen as the number.</p>
<h2 id="what-i-left-out">What I left out</h2>
<p>Version one does not do PPFD or DLI. Those are the units growers use, and deriving them from a lux figure means assuming a spectrum, because lux is weighted to human vision and photosynthesis is not. You can publish a conversion factor for daylight and be roughly right, and be badly wrong under an LED. Adding a unit that implies more rigour than the measurement supports would undo the entire point of the previous section.</p>
<p>It also does not identify plants from photos. That is a different app, several companies do it well, and bolting it on would have meant a worse version of something that already exists.</p>
<h2 id="what-i-would-tell-someone-building-this">What I would tell someone building this</h2>
<p>Not the calibration constant. You will find it in ten minutes and it will not be your problem.</p>
<p>Your problem is that you are shipping an instrument, and an instrument that overstates its precision is worse than no instrument, because people act on it. Someone buys a fern for a shelf your app called bright, and the fern dies, and the failure is silent and slow and they will blame the fern.</p>
<p>Work out which question your users are really asking. Mine turned out to be “will this plant live here”, not “what is the lux here”. Then build the smallest measurement that answers that question, and be loud about the parts you had to estimate.</p>
<p><a href="https://heynavid.com/apps/solspot/">Solspot</a> is free on the App Store, works entirely offline, and has nothing to buy inside it.</p>]]></content:encoded>
    </item>
    <item>
      <title>Nine Apps, Five Different StoreKit Shapes</title>
      <link>https://heynavid.com/blog/storekit-product-types-ten-apps/</link>
      <guid isPermaLink="true">https://heynavid.com/blog/storekit-product-types-ten-apps/</guid>
      <pubDate>Wed, 26 Aug 2026 00:00:00 GMT</pubDate>
      <dc:creator>Navid</dc:creator>
      <category>ios</category>
      <category>storekit</category>
      <category>indie</category>
      <description>Paid up front, paid plus an unlock, free plus an unlock, free plus consumable credits, free plus a subscription. What each one costs you in code, and the one I removed after shipping it.</description>
      <content:encoded><![CDATA[<p><img src="https://heynavid.com/blog/images/storekit-shapes-hero.jpg" alt="" /></p><p>I have nine apps on the App Store and I did not set out to try every monetisation model Apple offers. It happened one app at a time, each decision sensible in isolation, and I now maintain five distinct StoreKit shapes across the portfolio.</p>
<p>Here is what each one actually costs to build and support, including the one I shipped and then took back out.</p>
<h2 id="the-five-shapes">The five shapes</h2>
<p><strong>Paid up front, no in-app purchase.</strong> Mino at $2.99, Nava at $2.99, PlotCraft and DiceCraft at $0.99. There is no StoreKit code in these apps at all. The App Store handles the transaction before the app ever runs.</p>
<p><strong>Paid up front plus a non-consumable unlock.</strong> ComicFlow is $2.99 with an optional $6.99 Pro unlock that adds e-reader export and Face ID-locked collections.</p>
<p><strong>Free plus a non-consumable unlock.</strong> PhotoStrip is free to download and free to use for five photos per batch. One $4.99 purchase removes the limit.</p>
<p><strong>Free plus consumables.</strong> Negink gives three free generation credits, then sells credit packs: ten for $1.99 as a first purchase, thirty for $9.99, a hundred for $24.99, three hundred for $59.99.</p>
<p><strong>Free plus a subscription.</strong> Wallora gives free credits during onboarding, then sells a recurring plan for continued generation.</p>
<p>And then Solspot and Timeflip, which are free with nothing to buy. Solspot has since moved to another developer account. I have kept it in here because the shape is one I built and supported, and the lesson does not transfer with the app.</p>
<h2 id="paid-up-front-is-nearly-free-to-build">Paid up front is nearly free to build</h2>
<p>Worth stating plainly because it is easy to forget once you are deep in StoreKit: an app with no in-app purchase has no purchase code, no receipt validation, no restore flow, no entitlement state, no paywall, no sandbox testing, and no support email that starts “I paid but it still says free”.</p>
<p>Every one of those is a real ongoing cost, and four of my apps pay none of it.</p>
<p>The trade is that you have no free tier, so nobody tries before buying, and your App Store page has to do all the convincing on its own. For a $0.99 utility that is a fine trade. For anything where the value is hard to explain in six screenshots, it is not.</p>
<h2 id="non-consumables-are-the-easy-iap">Non-consumables are the easy IAP</h2>
<p>If you are going to sell something, sell a non-consumable. It is one purchase, permanent, and Apple handles almost everything.</p>
<p>The important property is that it is <strong>restorable</strong>. A user who reinstalls, or picks up a second device on the same Apple ID, gets their unlock back from the App Store. You do not need an account. You do not need a server. You do not need to know who they are. StoreKit 2 makes this close to trivial, because current entitlements are a property you read rather than a receipt you parse:</p>
<pre class="astro-code github-light" style="background-color:#fff;color:#24292e; overflow-x: auto;" tabindex="0" data-language="swift"><code><span class="line"><span style="color:#D73A49">for</span><span style="color:#D73A49"> await</span><span style="color:#24292E"> result </span><span style="color:#D73A49">in</span><span style="color:#24292E"> Transaction.currentEntitlements {</span></span>
<span class="line"><span style="color:#D73A49">    guard</span><span style="color:#D73A49"> case</span><span style="color:#24292E"> .</span><span style="color:#005CC5">verified</span><span style="color:#24292E">(</span><span style="color:#D73A49">let</span><span style="color:#24292E"> transaction) </span><span style="color:#D73A49">=</span><span style="color:#24292E"> result </span><span style="color:#D73A49">else</span><span style="color:#24292E"> { </span><span style="color:#D73A49">continue</span><span style="color:#24292E"> }</span></span>
<span class="line"><span style="color:#D73A49">    if</span><span style="color:#24292E"> transaction.productID </span><span style="color:#D73A49">==</span><span style="color:#24292E"> proProductID {</span></span>
<span class="line"><span style="color:#24292E">        isPro </span><span style="color:#D73A49">=</span><span style="color:#005CC5"> true</span></span>
<span class="line"><span style="color:#24292E">    }</span></span>
<span class="line"><span style="color:#24292E">}</span></span></code></pre>
<p>That is the whole entitlement check. No account system, no backend, no user identity. Both ComicFlow’s Pro unlock and PhotoStrip’s batch unlock are this shape, and neither has generated a support email about a lost purchase.</p>
<p>The gating decision is more interesting than the code. PhotoStrip gates on batch size and nothing else: every feature is available in the free tier, on five photos. You can watermark, strip EXIF, convert between all six formats, and see exactly what the app does before paying. What you cannot do is run it on your whole camera roll.</p>
<p>I like this better than feature-gating because it is honest about what you are buying. You are not buying access to the watermark tool. You are buying the removal of an artificial limit, and you know precisely what that limit feels like before you decide.</p>
<h2 id="consumables-are-where-the-work-is">Consumables are where the work is</h2>
<p>Negink sells credits. Credits are consumables, and consumables are a different animal, because <strong>they are not restorable</strong>.</p>
<p>Apple does not track how many credits a user has left. That is your problem. If a user buys a hundred credits, uses forty and reinstalls, Apple will tell you nothing useful. There is a purchase in their history and no notion of a remaining balance.</p>
<p>Which leaves you three options: build an account system, keep the balance on device and accept that a wipe loses it, or persist an anonymous identifier somewhere that survives reinstalls. Negink keeps an anonymous device identifier in the Keychain, which survives app deletion, so a reinstall does not resurrect the free credits and does not lose paid ones. No account, no email, no login.</p>
<p>That “does not resurrect the free credits” clause is the other half of consumables. A free allowance plus a delete-and-reinstall cycle is an infinite free tier if you are careless, and the cost of a generation is real money to me, not a rounding error.</p>
<p>There is a further wrinkle. The intro pack, ten credits for $1.99, is first-purchase-only. Apple has no built-in concept of that for consumables the way it does for subscription introductory offers, so it is something you enforce yourself and hide from the UI afterwards.</p>
<p>None of this is difficult. It is all just <em>there</em>, permanently, in a way the non-consumable apps simply do not have.</p>
<h2 id="the-subscription-i-removed">The subscription I removed</h2>
<p>Negink originally sold a subscription. Weekly or yearly, a hundred credits a month. In version 2.0 I replaced it with the credit packs.</p>
<p>The reasoning was that the shape did not match the behaviour. Tattoo design is bursty. Someone plans a piece over a couple of weeks, generates thirty or forty concepts, takes them to an artist and is done for a year. Charging them monthly for a thing they use in a fortnight means they either cancel immediately, which makes the subscription a clumsy one-off purchase, or forget to cancel, which is revenue I do not want.</p>
<p>Credit packs match that. Buy what you need, credits do not expire, come back in a year and your balance is still there.</p>
<p>The part I want to flag for anyone considering the same move is what happens to the people already subscribed. <strong>You cannot cancel a subscription on a user’s behalf.</strong> You can stop offering it, you can remove it from your paywall, and existing subscribers keep renewing until they choose to cancel in their own Apple ID settings.</p>
<p>Which means the subscription code does not go away. The paywall stops showing it, but the entitlement check has to keep honouring it, the Terms of Service needs a legacy clause explaining that those plans continue, and support has to be able to answer a question about a product you no longer sell.</p>
<p>Removing a subscription is strictly additive work. Every path that existed still exists, plus the new one. If I had known that in advance I would have thought harder before shipping the subscription in the first place.</p>
<p>Wallora still has one, and there it fits: generating art is an ongoing activity for the people who like it, and the cost to me is per-generation and recurring, so a recurring price genuinely tracks a recurring cost.</p>
<h2 id="free-with-nothing-to-buy">Free with nothing to buy</h2>
<p>Solspot ships with nothing you can buy, which is not the same as shipping no StoreKit. There is a product configuration, a Pro service and a paywall screen sitting in that project right now. They are dormant: the app never starts the purchase manager, so nothing in it leads to that screen, and the store has never been asked for a product.</p>
<p>Nor is it true that nothing is held back. The day curve, the DLI it feeds and the reminders that hang off both are built, tested and deliberately unreachable in this version. They are gated. They are simply not for sale. A test pins the reserved product identifier and the short list of features allowed to sit behind it, so anything already free cannot quietly migrate to the paid side in a later release.</p>
<p>This was a deliberate choice for a first version and not a permanent promise, which is a distinction worth being careful about in public. I have not said the app will be free forever, because I do not know that, and an indie developer who promises permanence and then charges has spent something they cannot get back.</p>
<p>What I have said is narrower and I can keep it: every light reading is free, including the difficult bright ones that a competitor might reasonably paywall. That is a commitment about a specific feature rather than about the entire future of the app.</p>
<h2 id="what-i-would-do-differently">What I would do differently</h2>
<p>Fewer shapes. Not because any individual choice was wrong, but because five shapes means five paywall implementations, five sets of sandbox test cases, five bodies of support knowledge and five things to get right when StoreKit changes.</p>
<p>If I were starting again with the same nine apps, I would push almost everything toward two: paid up front for utilities where the value is obvious from the screenshots, and free plus a non-consumable unlock where it is not. Those two cover seven of my nine. They are restorable, they need no server, they need no account, and they cannot leave a user out of pocket for something they stopped using.</p>
<p>Consumables and subscriptions are for when the thing you sell costs you money every time someone uses it. That is a real category, and it is smaller than the App Store’s incentives make it look.</p>]]></content:encoded>
    </item>
    <item>
      <title>Three of My Apps Had the Wrong Price on My Own Website</title>
      <link>https://heynavid.com/blog/wrong-price-on-my-own-website/</link>
      <guid isPermaLink="true">https://heynavid.com/blog/wrong-price-on-my-own-website/</guid>
      <pubDate>Wed, 26 Aug 2026 00:00:00 GMT</pubDate>
      <dc:creator>Navid</dc:creator>
      <category>indie</category>
      <category>tooling</category>
      <category>behind-the-scenes</category>
      <description>Mino was listed as free while charging $2.99. Timeflip charged $0.99 while being free. Nobody noticed for months, because nothing was comparing the pages to the store. Here's the 150 lines that now do.</description>
      <content:encoded><![CDATA[<p><img src="https://heynavid.com/blog/images/wrong-price-hero.jpg" alt="" /></p><p>I ship ten iOS apps on my own. Their prices, minimum OS versions and feature lists are written down in four places: App Store Connect, the marketing site at applestan.com, this site, and the odd blog post that mentions them in passing.</p>
<p>Only one of those four is the truth. The other three are copies, and copies rot.</p>
<p>Last week I went looking, and found that three apps had prices on my own websites that were simply wrong:</p>
<ul>
<li><strong>Mino</strong> was listed as free. It costs $2.99, and had done since version 1.2.</li>
<li><strong>Timeflip</strong> was listed at $0.99. It had gone free.</li>
<li><strong>PhotoStrip</strong> was listed at a flat $4.99. It is a free download with a $4.99 unlock.</li>
</ul>
<p>Two of those are the worst kind of wrong. Telling someone an app is free when it costs money sends them to a paywall they were not expecting. Telling them it costs money when it is free just loses the download quietly.</p>
<p>There were softer errors underneath. Mino had gained PDF merging and splitting in 1.2 and the page still described a compression-only app. Negink had moved off subscriptions to one-time credit packs and the pricing table still advertised $7.99 a week. Wallora had grown from 15 art styles to 25. ComicFlow had gone from 8 localisations to 18.</p>
<p>None of this was noticed for months, and the reason is boring: nothing was checking.</p>
<h2 id="the-actual-failure-mode">The actual failure mode</h2>
<p>It is tempting to file this under carelessness. I do not think that is what it is.</p>
<p>Every one of these errors was created by a <em>correct</em> action. I raised Mino’s price on purpose. I made Timeflip free on purpose. I moved Negink to credit packs on purpose. Each was a deliberate decision made in App Store Connect, which is exactly where you make it.</p>
<p>The failure was that the decision had four downstream consequences and I only executed one of them. Not because I forgot the website exists, but because the website is not in the room when you change a price. You are in App Store Connect, the change takes effect, the thing you wanted is done, and the feedback loop closes.</p>
<p>There is no error. No build fails. No test goes red. The site keeps serving a page that was true in March.</p>
<p>This is the general shape of it: <strong>a fact copied into a second place with no mechanism to reconcile them will drift, and the drift is silent.</strong> Silent is the operative word, because I did not need better discipline. I needed something that fails loudly.</p>
<h2 id="the-check-that-already-existed-and-i-was-not-using">The check that already existed and I was not using</h2>
<p>Apple has run a public lookup endpoint for years. No key, no auth, no SDK:</p>
<pre class="astro-code github-light" style="background-color:#fff;color:#24292e; overflow-x: auto;" tabindex="0" data-language="plaintext"><code><span class="line"><span>https://itunes.apple.com/lookup?id=6757166913&#x26;country=us&#x26;entity=software</span></span></code></pre>
<p>You get back JSON with the app’s current price, version, minimum OS version, release date, release notes and canonical URL. You can pass a comma-separated list of IDs and get all of them in one request.</p>
<p>That is the entire missing ingredient. The truth was queryable the whole time.</p>
<pre class="astro-code github-light" style="background-color:#fff;color:#24292e; overflow-x: auto;" tabindex="0" data-language="js"><code><span class="line"><span style="color:#D73A49">const</span><span style="color:#005CC5"> url</span><span style="color:#D73A49"> =</span><span style="color:#032F62"> `https://itunes.apple.com/lookup?id=${</span><span style="color:#24292E">ids</span><span style="color:#032F62">.</span><span style="color:#6F42C1">join</span><span style="color:#032F62">(</span><span style="color:#032F62">','</span><span style="color:#032F62">)</span><span style="color:#032F62">}`</span><span style="color:#D73A49"> +</span></span>
<span class="line"><span style="color:#032F62">            `&#x26;country=us&#x26;entity=software`</span><span style="color:#24292E">;</span></span>
<span class="line"><span style="color:#D73A49">const</span><span style="color:#24292E"> { </span><span style="color:#005CC5">results</span><span style="color:#24292E"> } </span><span style="color:#D73A49">=</span><span style="color:#D73A49"> await</span><span style="color:#24292E"> (</span><span style="color:#D73A49">await</span><span style="color:#6F42C1"> fetch</span><span style="color:#24292E">(url)).</span><span style="color:#6F42C1">json</span><span style="color:#24292E">();</span></span>
<span class="line"><span style="color:#D73A49">const</span><span style="color:#005CC5"> store</span><span style="color:#D73A49"> =</span><span style="color:#D73A49"> new</span><span style="color:#6F42C1"> Map</span><span style="color:#24292E">(results.</span><span style="color:#6F42C1">map</span><span style="color:#24292E">((</span><span style="color:#E36209">r</span><span style="color:#24292E">) </span><span style="color:#D73A49">=></span><span style="color:#24292E"> [</span><span style="color:#6F42C1">String</span><span style="color:#24292E">(r.trackId), r]));</span></span></code></pre>
<p>Two small conversions make the comparison work. The store gives you <code>formattedPrice</code> as a display string, and the site stores a bare number:</p>
<pre class="astro-code github-light" style="background-color:#fff;color:#24292e; overflow-x: auto;" tabindex="0" data-language="js"><code><span class="line"><span style="color:#D73A49">const</span><span style="color:#6F42C1"> storePriceToNumber</span><span style="color:#D73A49"> =</span><span style="color:#24292E"> (</span><span style="color:#E36209">formatted</span><span style="color:#24292E">) </span><span style="color:#D73A49">=></span></span>
<span class="line"><span style="color:#D73A49">  !</span><span style="color:#24292E">formatted </span><span style="color:#D73A49">||</span><span style="color:#032F62"> /</span><span style="color:#D73A49">^</span><span style="color:#032F62">free</span><span style="color:#D73A49">$</span><span style="color:#032F62">/</span><span style="color:#D73A49">i</span><span style="color:#24292E">.</span><span style="color:#6F42C1">test</span><span style="color:#24292E">(formatted) </span><span style="color:#D73A49">?</span><span style="color:#032F62"> '0'</span></span>
<span class="line"><span style="color:#D73A49">                                          :</span><span style="color:#24292E"> formatted.</span><span style="color:#6F42C1">replace</span><span style="color:#24292E">(</span><span style="color:#032F62">/</span><span style="color:#005CC5">[</span><span style="color:#D73A49">^</span><span style="color:#005CC5">0-9.]</span><span style="color:#032F62">/</span><span style="color:#D73A49">g</span><span style="color:#24292E">, </span><span style="color:#032F62">''</span><span style="color:#24292E">);</span></span></code></pre>
<p>And minimum OS arrives as <code>18.6</code> while the site writes <code>iOS 18.6+, iPadOS 18.6+</code>, so pull the first version number out and compare that.</p>
<p>After that it is just a loop and an inequality. The whole store comparison is under fifty lines.</p>
<h2 id="what-i-actually-built">What I actually built</h2>
<p>It grew into a single script with no dependencies that both sites can be run through. It checks seven things:</p>





































<table><thead><tr><th>check</th><th>catches</th></tr></thead><tbody><tr><td>store</td><td>price, minimum OS and publish date drifting from the App Store</td></tr><tr><td>version</td><td>an app shipping a new version since the last run</td></tr><tr><td>links</td><td>an App Store URL pointing at the wrong app, or a slug Apple has renamed</td></tr><tr><td>cross-site</td><td>my two sites disagreeing about the same app</td></tr><tr><td>price-prose</td><td>a dollar amount in an app’s copy that is not its price or a known IAP</td></tr><tr><td>claim</td><td>a per-app statement that has become untrue</td></tr><tr><td>banned-phrase</td><td>pre-release codenames and phrases I have decided never to publish</td></tr></tbody></table>
<p>Three of those are worth explaining because they were not obvious to me at the start.</p>
<h3 id="prices-hiding-in-prose">Prices hiding in prose</h3>
<p>Fixing the JSON that feeds a page does not fix the page. Mino’s price lived in structured data, in the hero, in an FAQ answer and in a paragraph three-quarters of the way down a blog post from March.</p>
<p>So the checker scans every file under an app’s directory for anything matching <code>\$\d+\.\d{2}</code>, and compares each hit against a set: the app’s own price, plus its known in-app purchase prices from a config file. Anything else is flagged.</p>
<pre class="astro-code github-light" style="background-color:#fff;color:#24292e; overflow-x: auto;" tabindex="0" data-language="js"><code><span class="line"><span style="color:#D73A49">const</span><span style="color:#005CC5"> allowed</span><span style="color:#D73A49"> =</span><span style="color:#D73A49"> new</span><span style="color:#6F42C1"> Set</span><span style="color:#24292E">(</span><span style="color:#005CC5">CONFIG</span><span style="color:#24292E">.iap[app.slug] </span><span style="color:#D73A49">??</span><span style="color:#24292E"> []);</span></span>
<span class="line"><span style="color:#D73A49">if</span><span style="color:#24292E"> (app.price </span><span style="color:#D73A49">!==</span><span style="color:#032F62"> '0'</span><span style="color:#24292E">) allowed.</span><span style="color:#6F42C1">add</span><span style="color:#24292E">(app.price);</span></span>
<span class="line"></span>
<span class="line"><span style="color:#D73A49">for</span><span style="color:#24292E"> (</span><span style="color:#D73A49">const</span><span style="color:#005CC5"> m</span><span style="color:#D73A49"> of</span><span style="color:#24292E"> text.</span><span style="color:#6F42C1">matchAll</span><span style="color:#24292E">(</span><span style="color:#032F62">/</span><span style="color:#22863A;font-weight:bold">\$</span><span style="color:#032F62">(</span><span style="color:#005CC5">\d</span><span style="color:#D73A49">+</span><span style="color:#22863A;font-weight:bold">\.</span><span style="color:#005CC5">\d</span><span style="color:#D73A49">{2}</span><span style="color:#032F62">)</span><span style="color:#D73A49">\b</span><span style="color:#032F62">/</span><span style="color:#D73A49">g</span><span style="color:#24292E">)) {</span></span>
<span class="line"><span style="color:#D73A49">  if</span><span style="color:#24292E"> (</span><span style="color:#D73A49">!</span><span style="color:#24292E">allowed.</span><span style="color:#6F42C1">has</span><span style="color:#24292E">(m[</span><span style="color:#005CC5">1</span><span style="color:#24292E">])) </span><span style="color:#6F42C1">report</span><span style="color:#24292E">(app, file, m[</span><span style="color:#005CC5">1</span><span style="color:#24292E">]);</span></span>
<span class="line"><span style="color:#24292E">}</span></span></code></pre>
<p>Listing the IAP prices explicitly turned out to be the useful part, not a workaround. Writing them into a config forced me to state, in one place, exactly which amounts each app is allowed to mention.</p>
<p>It needs an exclusion list. Blog posts that compare competitors are full of other people’s prices, so paths like <code>/blog/</code> and the free tools section are skipped for this particular check.</p>
<h3 id="claims-that-are-not-numbers">Claims that are not numbers</h3>
<p>The price scan cannot catch “Mino is completely free”, because that sentence contains no dollar sign. It was sitting in a blog post, and it is the single most misleading thing on the site, because it is a full sentence a reader will believe.</p>
<p>So there is a second list, per app, of statements that have become false:</p>
<pre class="astro-code github-light" style="background-color:#fff;color:#24292e; overflow-x: auto;" tabindex="0" data-language="json"><code><span class="line"><span style="color:#032F62">"mino"</span><span style="color:#24292E">: [{</span></span>
<span class="line"><span style="color:#005CC5">  "pattern"</span><span style="color:#24292E">: </span><span style="color:#032F62">"Mino[^.]{0,70}(completely free|is free|for free)"</span><span style="color:#24292E">,</span></span>
<span class="line"><span style="color:#005CC5">  "why"</span><span style="color:#24292E">: </span><span style="color:#032F62">"Mino costs $2.99. It was free until 1.2."</span><span style="color:#24292E">,</span></span>
<span class="line"><span style="color:#005CC5">  "severity"</span><span style="color:#24292E">: </span><span style="color:#032F62">"error"</span></span>
<span class="line"><span style="color:#24292E">}]</span></span></code></pre>
<p>These get checked in blog posts too. A wrong price in a comparison post misleads a reader no matter how old the post is.</p>
<p>The first version of this had a bug that is obvious in hindsight. A rule looking for the word “subscription” near “Negink” fired on the sentence <em>“Negink has no subscription”</em>, which is the correction, not the error. So each rule takes an optional <code>unless</code> pattern, tested against the surrounding sentence:</p>
<pre class="astro-code github-light" style="background-color:#fff;color:#24292e; overflow-x: auto;" tabindex="0" data-language="js"><code><span class="line"><span style="color:#D73A49">if</span><span style="color:#24292E"> (rule.unless) {</span></span>
<span class="line"><span style="color:#D73A49">  const</span><span style="color:#005CC5"> window</span><span style="color:#D73A49"> =</span><span style="color:#24292E"> text.</span><span style="color:#6F42C1">slice</span><span style="color:#24292E">(m.index </span><span style="color:#D73A49">-</span><span style="color:#005CC5"> 90</span><span style="color:#24292E">, m.index </span><span style="color:#D73A49">+</span><span style="color:#24292E"> m[</span><span style="color:#005CC5">0</span><span style="color:#24292E">].</span><span style="color:#005CC5">length</span><span style="color:#D73A49"> +</span><span style="color:#005CC5"> 90</span><span style="color:#24292E">);</span></span>
<span class="line"><span style="color:#D73A49">  if</span><span style="color:#24292E"> (</span><span style="color:#D73A49">new</span><span style="color:#6F42C1"> RegExp</span><span style="color:#24292E">(rule.unless, </span><span style="color:#032F62">'i'</span><span style="color:#24292E">).</span><span style="color:#6F42C1">test</span><span style="color:#24292E">(window)) </span><span style="color:#D73A49">continue</span><span style="color:#24292E">;</span></span>
<span class="line"><span style="color:#24292E">}</span></span></code></pre>
<p>Any rule that matches a keyword rather than a claim needs this. Negations are the normal way people write corrections, and a checker that flags every correction gets switched off within a week.</p>
<h3 id="version-bumps-as-an-early-warning">Version bumps as an early warning</h3>
<p>The most useful check turned out to be the least clever one. The script records the version of each app on every run. When a version changes, it says so:</p>
<blockquote>
<p>ComicFlow went 3.2 to 3.3 since the last check. Read the release notes and update the page.</p>
</blockquote>
<p>It cannot know what changed. It does not need to. Every one of my soft errors, the missing merge feature, the wrong style count, the wrong language count, entered the site at a version bump. Flagging the bump puts the question in front of me at the only moment I can answer it cheaply.</p>
<h2 id="two-details-that-decide-whether-you-keep-using-it">Two details that decide whether you keep using it</h2>
<p><strong>It has to block something.</strong> The script exits non-zero on errors and is wired into my deploy script:</p>
<pre class="astro-code github-light" style="background-color:#fff;color:#24292e; overflow-x: auto;" tabindex="0" data-language="json"><code><span class="line"><span style="color:#032F62">"deploy"</span><span style="color:#24292E">: </span><span style="color:#032F62">"npm run build &#x26;&#x26; npm run factcheck &#x26;&#x26; firebase deploy"</span></span></code></pre>
<p>A check I have to remember to run is a check I will stop running. A check that stands between me and shipping is one I cannot skip by accident.</p>
<p><strong>It has to shut up when nothing is wrong.</strong> My first version wrote a timestamp into its state file on every run, so a check that found nothing still left a modified file in <code>git status</code>. That is precisely how you train yourself to ignore a tool. It now writes only when a version actually moved.</p>
<p>Same instinct applies to warnings. Errors block the deploy. Everything advisory is a warning that does not. If everything blocks, you will start passing the flag that skips it.</p>
<h2 id="where-it-does-not-help">Where it does not help</h2>
<p>It compares my sites to the store. It cannot tell me the store itself is wrong, and mine was: the App Store description for Negink still described the subscription that version 2.0 removed, while the release notes for that same version announced the credit packs. The listing contradicted itself and no tool of mine could have known which half to believe. I had to go and decide.</p>
<p>It also cannot check taste, or whether a sentence is any good, or whether a screenshot still shows the current UI. It checks facts that have exactly one correct value.</p>
<p>That is a narrow job. It is also the job where I was making every single one of my mistakes.</p>
<h2 id="if-you-ship-more-than-one-app">If you ship more than one app</h2>
<p>The version that would have saved me is genuinely small. Fetch the lookup endpoint for your app IDs, compare <code>formattedPrice</code> and <code>minimumOsVersion</code> against whatever your site thinks, exit non-zero on a mismatch, and put it in front of your deploy. That is an afternoon, and it covers the errors that actually embarrass you.</p>
<p>Everything after that is refinement. The prose scanning, the claim rules, the negation guards, all of it exists because I kept finding a new place the same fact had been copied to.</p>
<p>Which is the real lesson, and it is not about App Store metadata at all. I did not have a discipline problem. I had ten apps, four copies of every fact, and nothing that failed when they disagreed.</p>]]></content:encoded>
    </item>
    <item>
      <title>ComicFlow 3.0: Send Comics to Your E-Reader, Lock Private Shelves, and Free CBZ Export</title>
      <link>https://heynavid.com/blog/comicflow-3-release/</link>
      <guid isPermaLink="true">https://heynavid.com/blog/comicflow-3-release/</guid>
      <pubDate>Sat, 13 Jun 2026 00:00:00 GMT</pubDate>
      <dc:creator>Navid</dc:creator>
      <category>comicflow</category>
      <category>release</category>
      <category>behind-the-scenes</category>
      <description>ComicFlow 3.0 adds EPUB export for Kobo, Boox and reMarkable, Face ID-locked collections, reader presets, and free CBZ export. Still one-time, still offline, still no subscription.</description>
      <content:encoded><![CDATA[<p><img src="https://heynavid.com/blog/images/comicflow-v3-hero.jpg" alt="" /></p><p>ComicFlow 3.0 is out. It’s the biggest update since the app shipped, and it answers the two requests that came up most often in App Store reviews and emails: “can I get my comics onto my Kobo,” and “can I hide a few of these from people who pick up my phone.”</p>
<p>The short version: the $2.99 app stays fully featured and gets free CBZ export. A new optional Pro unlock adds e-reader export, Face ID-locked collections, and saved reader presets. No subscription, anywhere. Here’s what changed and why.</p>
<h2 id="free-for-everyone-cbz-export">Free for everyone: CBZ export</h2>
<p>Until now, the converter only produced PDF. PDF is great for sharing and for Apple Books, but it’s not what most other comic readers want. CBZ is the native format for apps like Chunky, KOReader, and the readers built into Kobo and Boox.</p>
<p>So 3.0 adds CBZ as a second export format, free for everyone. Convert a CBR to CBZ to clean up a messy archive, or repack a folder of images into a single file your other apps understand. It’s a repack, not a re-encode, so there’s no quality loss. Everything else about conversion is unchanged: three PDF quality levels, batch mode, and it all runs on your device with nothing uploaded.</p>
<h2 id="comicflow-pro">ComicFlow Pro</h2>
<p>The three bigger features ship behind a one-time Pro unlock ($6.99). I’ll explain the pricing reasoning further down, but first, what you get.</p>
<h3 id="send-comics-to-your-e-reader">Send comics to your e-reader</h3>
<p>This is the feature I most wanted to build. ComicFlow Pro exports any comic to EPUB tuned for a specific e-reader, with device profiles for Kobo, Boox, reMarkable, and PocketBook. Pick your device and the export matches its screen: the right resolution, grayscale where it helps, and e-ink image optimization so pages look sharp on electronic paper instead of washed out.</p>
<p>You can browse the device profiles and set up the whole export for free. The Pro unlock only applies at the final export step, so you can see exactly what you’re getting before deciding.</p>
<p>One honest caveat: Kindle does not read sideloaded EPUB directly. There’s no app on iOS that can change that. So for Kindle owners, the app points to the Calibre route (EPUB to AZW3 over USB) rather than pretending it works. If you have a Kobo, Boox, reMarkable, or PocketBook, it’s a direct transfer.</p>
<h3 id="lock-private-collections-behind-face-id">Lock private collections behind Face ID</h3>
<p>The second-most-common request. Create a collection, turn on the lock, and it sits behind Face ID, Touch ID, or your device passcode. Hand someone your phone to show them a page and the rest of that shelf stays closed.</p>
<p>It uses the device’s own authentication, so the app never stores or even sees your biometric data. Locked collections re-lock automatically the moment you leave the app, not after some timer, so nothing stays open in the background.</p>
<h3 id="reader-presets">Reader presets</h3>
<p>If you read more than one kind of comic, you know the annoyance: manga wants right-to-left and double pages, western comics want left-to-right single pages, webtoons want a vertical scroll that fits the width. Switching all of that by hand every time gets old.</p>
<p>Presets save your reading setup (direction, layout, and fit) and let you switch in one tap. Manga, western, webtoon, ready to go.</p>
<h2 id="why-a-pro-unlock-and-why-its-still-not-a-subscription">Why a Pro unlock, and why it’s still not a subscription</h2>
<p>ComicFlow has always been a one-time purchase with no ads and no ad tracking. That doesn’t change. The base app is still $2.99 and still does everything it did before, plus free CBZ export.</p>
<p>The three new features are heavier to build and maintain than the core app, and they serve a narrower group: people with e-readers, people who want a private shelf, people juggling multiple reading styles. Putting them behind an optional one-time $6.99 unlock means the readers who want them fund them, and everyone else pays nothing extra and loses nothing. Buy Pro once and it’s yours forever, including future updates. No subscription, because there’s nothing recurring about reading a comic.</p>
<p>If you only ever read and convert, the $2.99 app is all you need. If you want to send a series to your Kobo on a Sunday afternoon, Pro is there.</p>
<h2 id="everything-else">Everything else</h2>
<p>3.0 keeps what the app already did: read CBR, CBZ, and PDF directly with the native reader, convert to PDF, batch conversion, the full library with collections, ratings, tags, notes, and bookmarks, automatic reading progress, eight languages, and optional iCloud sync. It all still works offline.</p>
<p>If you want it: <a href="https://apps.apple.com/us/app/comicflow-cbr-cbz-to-pdf/id6757245069">ComicFlow on the App Store</a>. The story behind the app is in <a href="https://heynavid.com/blog/why-i-built-comicflow/">Why I Built ComicFlow</a>.</p>]]></content:encoded>
    </item>
    <item>
      <title>Why I Built ComicFlow (And What I Learned Shipping a Niche iOS App)</title>
      <link>https://heynavid.com/blog/why-i-built-comicflow/</link>
      <guid isPermaLink="true">https://heynavid.com/blog/why-i-built-comicflow/</guid>
      <pubDate>Sat, 16 May 2026 00:00:00 GMT</pubDate>
      <dc:creator>Navid</dc:creator>
      <category>comicflow</category>
      <category>indie</category>
      <category>behind-the-scenes</category>
      <description>I'm an indie iOS dev. I shipped a comic reader to a market dominated by Panels and Shonen Jump. Here are the unsexy decisions that made it work, and the mistakes I made along the way.</description>
      <content:encoded><![CDATA[<p><img src="https://heynavid.com/blog/images/why-i-built-comicflow-hero.jpg" alt="" /></p><p>A friend asked me last month why I built another comic reader when Panels already exists. The honest answer is that I didn’t <em>set out</em> to build one. I set out to read a single <code>.cbr</code> file on my phone one weekend in 2025, failed, and a few weeks later I had shipped an app.</p>
<p>This is the story of how that happens to an indie iOS developer. It’s also a deliberately honest write-up of what worked, what didn’t, and what I’d do differently, because I read a lot of “I made $X with my app” posts when I was starting out, and almost none of them include the parts where the founder looked stupid.</p>
<h2 id="the-problem-that-wouldnt-solve-itself">The problem that wouldn’t solve itself</h2>
<p>I had a <code>.cbr</code> file from a Humble Bundle. iOS wouldn’t open it. The Files app shrugged. Apple Books said no. I tried renaming it to <code>.zip</code>, which technically extracted, and then I had a folder of 187 JPEGs that I was supposed to read one at a time in the Photos app.</p>
<p>So I downloaded the popular alternatives. Panels was beautiful, and asked me to pay $10 upfront plus $30/year if I wanted Dropbox sync. Chunky Comic Reader was fine, but the free tier capped at six recent issues, and the paid tier bundled a Mac app I didn’t need.</p>
<p>I’m a developer. I have the destructive habit of looking at any tool that costs money and thinking “I could build that.” Most of the time the correct answer is to pay the $10 and get back to your actual work. But the more I looked at the underlying problem, extract an archive, render images in order, swipe between them, save the page, the more I thought <em>this is genuinely a weekend project.</em></p>
<p>It wasn’t quite. We’ll get to that.</p>
<h2 id="why-not-just-use-panels">Why not just use Panels?</h2>
<p>I want to take this seriously, because “why not use the existing thing” is the question every indie dev should be able to answer before they pour their life into a new app.</p>
<p>Panels is genuinely good. If I had a 5,000-issue library spread across multiple cloud providers and wanted OPDS streaming from a home server, Panels would be the right call. But that’s not what I had. I had a folder of CBR files in iCloud Drive, and I wanted to tap one and read it. The subscription model, built for the heavy-collector workflow, was overkill for “open file, read comic.”</p>
<p>That’s a recurring pattern in iOS apps. Most categories have one or two “professional” apps with strong cloud features behind subscription paywalls, and almost nothing in the casual middle. There’s a real gap between “free with ads” and “$30/year subscription,” and I bet on filling it for comics.</p>
<p>The bet was: there are enough people with a few hundred CBR files on iCloud who’d pay $2.99 once to read them, without subscriptions or accounts or a cloud connection. We’ll see if I was right.</p>
<h2 id="the-decisions-i-made-that-mattered">The decisions I made that mattered</h2>
<p>These are the choices I’d defend if you cornered me at a meetup.</p>
<p><strong>One-time purchase, $2.99, no subscription.</strong> Comic readers are a tool category, not a service category. There’s nothing recurring about reading a CBR file. A subscription would have been pure rent-extraction. One shot to convince each user, which feels honest.</p>
<p><strong>Offline-first, no account, no sync.</strong> Selfishly: I read comics on flights. Practically: every cloud-syncing app in this category has a “first run wants you to connect Dropbox” flow that adds 30 seconds of friction before you can read anything. I wanted file, reader, done.</p>
<p><strong>No ads. No ad tracking. No third-party ad SDKs.</strong> Easy to brag about, less easy to commit to when you’re trying to figure out <em>why</em> people abandon the app. I eventually added PostHog for product analytics, which told me that a lot of people gave up at the “import your first file” step of onboarding. That’s the kind of data I needed. But it’s <em>product telemetry you can switch off in Settings</em>, not the ad-tech SDKs that sell user data to third parties. Different category.</p>
<p><strong>Native RAR support.</strong> This sounds boring but it was the decision that made ComicFlow actually useful. iOS has zero native RAR support. Most reader apps work around this by telling users to convert CBR to CBZ on a PC first. I shipped a native RAR extractor. That’s the reason the App Store name is “ComicFlow: CBR to PDF” rather than just “Comic Reader,” handling RAR natively is the technical differentiator that nobody mentions in their store listing.</p>
<p><strong>CBR-to-PDF conversion as a side feature, not the headline.</strong> Originally I thought the converter would be the differentiator. It’s a feature, not the lead. The lead is “open the file and read.” Conversion matters when you specifically want a PDF, for sharing, Apple Books, or printing, but it’s not the daily workflow.</p>
<h2 id="what-didnt-work">What didn’t work</h2>
<p>This is the part most indie-hacker posts skip. I have a list.</p>
<p><strong>The first version of the import flow was bad.</strong> I assumed users would tap a CBR file in Files and naturally find “Open in ComicFlow” in the share sheet. They didn’t. Everyone who gave up at onboarding was the cost of that assumption. I’ve since added an in-app file picker and a Files-app integration guide, and the drop-off is still higher than I’d like. There’s more work to do.</p>
<p><strong>My first marketing attempt was the wrong audience.</strong> I posted on developer forums about how I built a comic reader. Got upvotes. Zero downloads. Developers don’t read comics, or if they do, they’re not buying tools to do it from other developers. The actual audience was on TikTok, where casual manga readers complain about CBR files in the comments of every “where can I read X” post.</p>
<p><strong>TikTok was its own learning curve.</strong> My first carousel got around three views and a 1.8/6 photos-viewed completion rate. The content quality was the problem. Text-heavy slides after a lifestyle photo will make people bounce instantly. It took me thirty-ish posts to figure out that the title card has to be a single eye-catching lifestyle photo with a question hook, and the inner slides have to be visually distinct from the title card. I had to rediscover most of this post-by-post because nobody writes about the actual mechanics.</p>
<p><strong>I overestimated how many people search for “comic reader.”</strong> This was the biggest one. I tracked rankings for “comic reader,” “manga reader,” “comic book reader,” and ComicFlow doesn’t rank in the top 200 for any of them. Panels (around 11K ratings) and Shonen Jump (around 250K ratings) own those terms and there’s no realistic path to outranking them without a marketing budget I don’t have. What I actually rank well for is “cbr to pdf” (which was #1 for a while) and “cbr cbz” (#4). Niche keywords with high purchase intent and low search volume. That’s the moat. It took me a while to accept that “stop trying to win generic queries” was the right move.</p>
<p><strong>App Store updates can hurt your ranking.</strong> I shipped four updates in six days during a feature sprint in early 2026. ComicFlow’s “cbr to pdf” ranking crashed from #1 to around #70 over that week. Updating an app too often is apparently a signal Apple uses in stability scoring. I now batch changes and ship every 2-3 weeks at most.</p>
<h2 id="the-shape-of-the-numbers">The shape of the numbers</h2>
<p>I’m not going to do a revenue breakdown, too many specifics that would distract from the story. But the shape of it:</p>
<ul>
<li>ComicFlow earns more than it costs to keep running. That’s a low bar, and most indie apps don’t clear it.</li>
<li>Most downloads come from App Store search. The website you’re reading this on is the second-biggest channel.</li>
<li>TikTok drives a smaller but more loyal segment. People who arrive from a manga recommendation carousel tend to read more, leave more reviews, and ask better feature-request questions in the App Store.</li>
<li>Refunds are rare. The $2.99 price seems to set expectations honestly. People who buy it knew what they were paying for.</li>
</ul>
<p>This is not a “quit my job, $10K MRR” story. It’s a “this earns enough that I’m motivated to keep improving it, and it pays for the next experiment” story. Most indie apps that survive are the second kind. The first kind makes louder posts.</p>
<h2 id="what-id-do-differently">What I’d do differently</h2>
<p>If I were starting ComicFlow over today:</p>
<ol>
<li><strong>Ship onboarding in v1.</strong> The import-step drop-off was avoidable. A short in-app walkthrough showing exactly how to get a file from Files into ComicFlow would have caught most of those users. I shipped without it because I thought it was obvious. It wasn’t.</li>
<li><strong>Target the niche keywords from day one.</strong> I spent the first stretch trying to compete on “comic reader.” Should have gone straight for “cbr to pdf” and “open cbr on iphone” with clear positioning.</li>
<li><strong>Build the website earlier.</strong> It’s now my #2 acquisition channel. Should have shipped it in month one of the app, not month six.</li>
<li><strong>Be more honest in marketing copy.</strong> The early App Store description called ComicFlow “the best comic reader.” That’s not true (Panels is better in several specific ways) and it makes the listing sound like every other app. Now I say what it specifically <em>is</em>, “open CBR/CBZ files without conversion,” and let the comparison stand on its own.</li>
</ol>
<h2 id="whats-coming">What’s coming</h2>
<p>I’ll keep writing these. The next thing I want to dig into is the PostHog-driven onboarding rewrite, because the analysis is interesting even if some of the conclusions are obvious in hindsight.</p>
<p>If you want the app: <a href="https://apps.apple.com/us/app/comicflow-cbr-cbz-to-pdf/id6757245069">ComicFlow on the App Store</a>, $2.99 once, no subscription, no ads. If you’d rather try alternatives I respect: <a href="https://apps.apple.com/us/app/panels-comic-reader/id1236567663">Panels</a> ($10 + $30/yr, beautiful UI, cloud sync) and <a href="https://apps.apple.com/us/app/chunky-comic-reader/id663573867">Chunky</a> (free + $5 pro). All three actually work. Pick the one that matches your library and your budget.</p>]]></content:encoded>
    </item>
  </channel>
</rss>
