<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: swift</title>
    <description>The latest articles tagged 'swift' on DEV Community.</description>
    <link>https://dev.to/t/swift</link>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/tag/swift"/>
    <language>en</language>
    <item>
      <title>How I built a multi-agent simulator as a native SwiftUI app</title>
      <dc:creator>Guilherme Brasil</dc:creator>
      <pubDate>Mon, 19 Oct 2026 17:56:09 +0000</pubDate>
      <link>https://dev.to/gbrasil720/how-i-built-a-multi-agent-simulator-as-a-native-swiftui-app-3h61</link>
      <guid>https://dev.to/gbrasil720/how-i-built-a-multi-agent-simulator-as-a-native-swiftui-app-3h61</guid>
      <description>&lt;p&gt;I built &lt;strong&gt;Sorcerer&lt;/strong&gt;, a free, open-source macOS app (SwiftUI, macOS 15+, MIT) that simulates a community of AI characters reacting to a "what if…?" question. You write a hypothesis ("what if Lumen raised its plan from $9 to $14 a month?"), summon a cast, and watch them post, reply, react, change their minds and take actions, round by round.&lt;/p&gt;

&lt;p&gt;Repo: &lt;a href="https://github.com/gbrasil720/sorcerer" rel="noopener noreferrer"&gt;https://github.com/gbrasil720/sorcerer&lt;/a&gt; · Site: &lt;a href="https://sorcererapp.dev" rel="noopener noreferrer"&gt;https://sorcererapp.dev&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This post covers how it works under the hood.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. No API keys: drive the CLIs people already have
&lt;/h2&gt;

&lt;p&gt;I didn't want to ship a model or ask anyone for a key. Sorcerer runs the official tools the user is already signed in to: the &lt;code&gt;claude&lt;/code&gt; CLI (Claude Code), the &lt;code&gt;codex&lt;/code&gt; CLI (ChatGPT), or a local Ollama.&lt;/p&gt;

&lt;p&gt;The first problem: an app launched from Finder doesn't get your Terminal's &lt;code&gt;PATH&lt;/code&gt;, so &lt;code&gt;claude&lt;/code&gt; is "command not found". Sorcerer asks an interactive zsh once and remembers the answer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="c1"&gt;/// The PATH your Terminal has. A login shell alone misses what ~/.zshrc adds (~/.local/bin, where&lt;/span&gt;
&lt;span class="c1"&gt;/// the claude installer puts it; nvm, bun…), so ask an interactive shell once and remember it.&lt;/span&gt;
&lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;userPath&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;p&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;Process&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;executableURL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;fileURLWithPath&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"/bin/zsh"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;arguments&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"-ilc"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="err"&gt;#&lt;/span&gt;&lt;span class="s"&gt;"printf '&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s"&gt;__SORCERER_PATH__%s' "&lt;/span&gt;&lt;span class="n"&gt;$PATH&lt;/span&gt;&lt;span class="s"&gt;""&lt;/span&gt;&lt;span class="err"&gt;#&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It also has a 5-second deadline, because an interactive shell can hang on a prompt or plugin.&lt;/p&gt;

&lt;p&gt;Then each provider is one &lt;code&gt;switch&lt;/code&gt; case. Claude runs with no tools, no session file and no user settings. It uses &lt;code&gt;stream-json&lt;/code&gt; because that output also carries the subscription's 5-hour and weekly usage windows, so the app can pause &lt;em&gt;before&lt;/em&gt; you hit a limit:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="s"&gt;"claude -p --tools '' --no-session-persistence --setting-sources '' --output-format stream-json --verbose&lt;/span&gt;&lt;span class="se"&gt;\(&lt;/span&gt;&lt;span class="n"&gt;pick&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;
    &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;onText&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="kc"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;" --include-partial-messages"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Codex runs with &lt;code&gt;codex exec --skip-git-repo-check --ephemeral -s read-only … -o &amp;lt;tmpfile&amp;gt; -&lt;/code&gt;. Ollama is a plain &lt;code&gt;POST&lt;/code&gt; to &lt;code&gt;http://localhost:11434/api/generate&lt;/code&gt; with &lt;code&gt;"stream": true&lt;/code&gt;, which reads one JSON object per line.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Characters
&lt;/h2&gt;

&lt;p&gt;A character is a plain &lt;code&gt;Codable&lt;/code&gt; struct:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="kd"&gt;struct&lt;/span&gt; &lt;span class="kt"&gt;Persona&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Codable&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;Identifiable&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;Hashable&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt;
    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;role&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt;
    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;bio&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt;
    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;traits&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Double&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[:]&lt;/span&gt;
    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;interests&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;goals&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;mood&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.0&lt;/span&gt;
    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;memories&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
    &lt;span class="c1"&gt;/// handle → relation ("coworker", "rivals"). Optional so older worlds still load.&lt;/span&gt;
    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;ties&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="p"&gt;]?&lt;/span&gt;
    &lt;span class="c1"&gt;/// Knows the brief's insider facts (employees, partners, friends of the team).&lt;/span&gt;
    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;insider&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Bool&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;mood&lt;/code&gt; is the character's attitude toward the hypothesis, from -1 to 1. &lt;code&gt;ties&lt;/code&gt; are how influence spreads. &lt;code&gt;memories&lt;/code&gt; are one-liners the model writes each round.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. A round is one prompt, and the rules live in pure functions
&lt;/h2&gt;

&lt;p&gt;The simulation lives in &lt;code&gt;Engine.swift&lt;/code&gt;, whose header says "Prompts and parsing. Pure functions, so the simulation rules are testable without a model."&lt;/p&gt;

&lt;p&gt;Each round, &lt;code&gt;roundPrompt&lt;/code&gt; gives the model the whole world:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the cast, with role, mood, ties, last 4 memories and last action&lt;/li&gt;
&lt;li&gt;past events and anything injected this round&lt;/li&gt;
&lt;li&gt;the last 24 posts, each with a stable id like &lt;code&gt;A3&lt;/code&gt; (third post of branch A)&lt;/li&gt;
&lt;li&gt;what characters may know (more on that below)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The model plays every character and must reply with only JSON:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"posts"&lt;/span&gt;&lt;span class="p"&gt;:[{&lt;/span&gt;&lt;span class="nl"&gt;"author"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"handle"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"replyTo"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"post id it answers, e.g. A3, or null"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"text"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"mood"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mf"&gt;0.0&lt;/span&gt;&lt;span class="p"&gt;}],&lt;/span&gt;&lt;span class="nl"&gt;"reactions"&lt;/span&gt;&lt;span class="p"&gt;:{&lt;/span&gt;&lt;span class="nl"&gt;"A3"&lt;/span&gt;&lt;span class="p"&gt;:{&lt;/span&gt;&lt;span class="nl"&gt;"agree"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"disagree"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;}},&lt;/span&gt;&lt;span class="nl"&gt;"memories"&lt;/span&gt;&lt;span class="p"&gt;:{&lt;/span&gt;&lt;span class="nl"&gt;"handle"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"one short line this character will remember"&lt;/span&gt;&lt;span class="p"&gt;}}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The prompt nudges toward realism: not everyone posts every round, opinions shift gradually, replies come most often from people with ties, and quiet characters still react with agree/disagree counts.&lt;/p&gt;

&lt;p&gt;Then &lt;code&gt;apply&lt;/code&gt; validates the output instead of trusting it. Unknown authors are dropped and moods are clamped. Actions must be on the experiment's allowed list. Only someone who left can "come back", and there's at most one invite per round. Because this is pure code, &lt;code&gt;swift test&lt;/code&gt; covers it without calling a model.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Who knows what
&lt;/h2&gt;

&lt;p&gt;You can point Sorcerer at a GitHub repo (through your own &lt;code&gt;gh&lt;/code&gt; login), a folder, a site or files. It writes a &lt;strong&gt;brief&lt;/strong&gt; where every fact is tagged &lt;code&gt;public&lt;/code&gt;, &lt;code&gt;insider&lt;/code&gt; or &lt;code&gt;hidden&lt;/code&gt;. &lt;code&gt;knowledge()&lt;/code&gt; builds each prompt's knowledge section: insider facts reach only characters marked &lt;code&gt;insider&lt;/code&gt;, hidden ones reach nobody, and it ends with "Characters never quote facts they couldn't know." When reading sources, it only reads docs. &lt;code&gt;.env&lt;/code&gt; files and keys are never opened, and characters see the brief, never your code.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Streaming half a JSON
&lt;/h2&gt;

&lt;p&gt;To show "&lt;a class="mentioned-user" href="https://dev.to/wren"&gt;@wren&lt;/a&gt; is typing…" with words appearing live, Sorcerer parses the round's JSON &lt;em&gt;while it streams&lt;/em&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="c1"&gt;/// Reads a round's JSON while it streams in. Complete post objects are decoded; the open one&lt;/span&gt;
&lt;span class="c1"&gt;/// yields its author and the text so far. Never throws: half a JSON is the normal case here.&lt;/span&gt;
&lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;partialRound&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="nv"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="kt"&gt;Partial&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It's a small hand-rolled scanner. It finds &lt;code&gt;"posts"&lt;/code&gt;, tracks brace depth and string escapes, decodes every closed &lt;code&gt;{…}&lt;/code&gt; as a finished post, and pulls &lt;code&gt;author&lt;/code&gt; and partial &lt;code&gt;text&lt;/code&gt; out of the open one. Claude (with &lt;code&gt;--include-partial-messages&lt;/code&gt;) and Ollama stream. Codex doesn't yet, and that's &lt;a href="https://github.com/gbrasil720/sorcerer/issues/21" rel="noopener noreferrer"&gt;issue #21&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Forking the world
&lt;/h2&gt;

&lt;p&gt;Forking copies the branch with the same people and the same memories, then starts a new path:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;fork&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;guard&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;b&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;shown&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;e&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;experiment&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;letters&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"ABCDEFGHIJKLMNOPQRSTUVWXYZ"&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="kd"&gt;init&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;copy&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;
    &lt;span class="n"&gt;copy&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;UUID&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;copy&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;letters&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;branches&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;count&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;letters&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;count&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
    &lt;span class="n"&gt;copy&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;parent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"from &lt;/span&gt;&lt;span class="se"&gt;\(&lt;/span&gt;&lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="s"&gt; at r&lt;/span&gt;&lt;span class="se"&gt;\(&lt;/span&gt;&lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;round&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;mutate&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;$0&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;branches&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;copy&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nf"&gt;switchBranch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;copy&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;To fork from a &lt;em&gt;past&lt;/em&gt; round, &lt;code&gt;rewind(_:to:start:)&lt;/code&gt; returns the branch as it stood after round &lt;code&gt;n&lt;/code&gt;. It filters posts, events, actions and polls, drops people who joined later, removes memories tagged with later rounds, and rebuilds each mood from the posts. There's an honest comment in there: agree/disagree counts are totals, so reactions from later rounds stay on old posts. That's a known trade-off.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. Counted, not guessed
&lt;/h2&gt;

&lt;p&gt;Reports separate what the model &lt;em&gt;judges&lt;/em&gt; from what the world &lt;em&gt;counted&lt;/em&gt;. If six people answer a poll, the report says "would you pay $14 · no · 3 / 6" from the actual answers. An action tally only counts each person's latest action, so "upgrade" then "cancel" counts once, as cancel.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;App: pure Swift, SwiftUI, Swift Package, &lt;strong&gt;no third-party dependencies&lt;/strong&gt;. Run it with &lt;code&gt;cd apps/macos &amp;amp;&amp;amp; swift run&lt;/code&gt; and test with &lt;code&gt;swift test&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Landing page: Bun + Turbo monorepo (React, TanStack Router, Tailwind, Hono, tRPC), deployed on Cloudflare Pages.&lt;/li&gt;
&lt;li&gt;Releases: push a &lt;code&gt;vX.Y.Z&lt;/code&gt; tag and CI tests, builds a universal DMG and publishes it.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Want to help?
&lt;/h2&gt;

&lt;p&gt;I opened well-scoped issues with context, file pointers and acceptance criteria:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://github.com/gbrasil720/sorcerer/issues/15" rel="noopener noreferrer"&gt;#15 Non-default Ollama host&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/gbrasil720/sorcerer/issues/16" rel="noopener noreferrer"&gt;#16 Accessibility labels for VoiceOver&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/gbrasil720/sorcerer/issues/17" rel="noopener noreferrer"&gt;#17 Add a new experiment template&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/gbrasil720/sorcerer/issues/18" rel="noopener noreferrer"&gt;#18 Unit tests for Ollama stream parsing&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/gbrasil720/sorcerer/issues/19" rel="noopener noreferrer"&gt;#19 Biome and landing build in CI&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/gbrasil720/sorcerer/issues/20" rel="noopener noreferrer"&gt;#20 Remove leftover scaffold module&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/gbrasil720/sorcerer/issues/21" rel="noopener noreferrer"&gt;#21 Stream codex replies (help wanted)&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Tip: test with Ollama so you don't burn your Claude or ChatGPT limits.&lt;/p&gt;

&lt;p&gt;Simulations surface possible reactions; they don't replace real-world evidence. I'd love to hear where it feels convincing and where it falls apart. Repo: &lt;a href="https://github.com/gbrasil720/sorcerer" rel="noopener noreferrer"&gt;https://github.com/gbrasil720/sorcerer&lt;/a&gt;&lt;/p&gt;

</description>
      <category>showdev</category>
      <category>swift</category>
      <category>ai</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Apple's 4.3 spam check is a 30-second batch job. 22 rejections prove it</title>
      <dc:creator>Matt Busel</dc:creator>
      <pubDate>Mon, 19 Oct 2026 17:26:28 +0000</pubDate>
      <link>https://dev.to/mattbusel/apples-43-spam-check-is-a-30-second-batch-job-22-rejections-prove-it-3nab</link>
      <guid>https://dev.to/mattbusel/apples-43-spam-check-is-a-30-second-batch-job-22-rejections-prove-it-3nab</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://tensorust.com/articles/guideline-4-3-spam/" rel="noopener noreferrer"&gt;tensorust.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; Apple App Review rejected 11 of my iPhone apps under &lt;a href="https://developer.apple.com/app-store/review/guidelines/#spam" rel="noopener noreferrer"&gt;Guideline 4.3 Spam&lt;/a&gt;. Then it did it again. &lt;strong&gt;Both times, all 11 rejections landed inside 30 seconds&lt;/strong&gt; (30 s on October 5, 27 s on October 12), one email every 3 seconds, in an order that has nothing to do with the order I submitted them. Round two came &lt;strong&gt;167.7 to 167.9 hours&lt;/strong&gt; after I resubmitted, for every single app: a seven-day timer, accurate to 12 minutes. I filed three appeals with the App Review Board on October 5. &lt;strong&gt;14 days later: zero replies.&lt;/strong&gt; Today I ran my own "spam detector" over all 20 app repos as Apple saw them. The two most alike apps I have ever shipped are both live. Apple is not reviewing apps for 4.3. It is reviewing accounts, and it is not saying so.&lt;/p&gt;

&lt;p&gt;If you came here from Google asking what "Guideline 4.3 spam" means: it is Apple's rule against duplicate apps. Part (a) bans "multiple Bundle IDs of the same app", and part (b) bans apps "indistinguishable from what's already widely available". The rejection usually says your app "shares a similar binary, metadata, and/or concept" with other apps, with "only minor differences". It does not tell you which app, which file or which screen. The short fix list is at the bottom: stop batch-submitting, reply before you resubmit, and book a call with App Review. The rest of this article is the receipts.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxy6ui6yfla9xdsegps2y.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxy6ui6yfla9xdsegps2y.gif" alt="Animated chart of App Review decision timestamps. Two lanes. Oct 5 batch, submitted over 15.5 hours: 11 red dots drop in between 0 and 30 seconds. Oct 12 batch, resubmitted within 10 minutes: 11 dots between 0 and 27 seconds. Final line: one rejection every 3 seconds. No reviewer opens 11 apps that fast." width="800" height="450"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;The 22 Guideline 4.3 rejection emails for 11 apps, Gmail timestamps, Oct 5 and Oct 12 2026. Static frame: batch.png.&lt;/em&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  11 apps, 30 seconds, one email every 3 seconds
&lt;/h2&gt;

&lt;p&gt;On September 26, 2026 I submitted 11 new iPhone apps, all built from Windows with the &lt;a href="https://tensorust.com/articles/build-ios-app-on-windows/" rel="noopener noreferrer"&gt;no-Mac pipeline&lt;/a&gt; I wrote about before: Odometer (car maintenance), Parked (parking meter), Rooms (home insurance inventory), Pricebook (grocery prices), Pawprint (pet meds), Chores (kids' chore chart), Clockout (tips for service workers), Curve (grade calculator), Climbbook (bouldering log), Race Pace (running splits) and Cutoff (caffeine and sleep). The submissions were spread over &lt;strong&gt;15.5 hours&lt;/strong&gt;: the first at 7:10 AM Pacific, the last at 10:42 PM.&lt;/p&gt;

&lt;p&gt;Nine days later, on October 5, every one of them got the same "There's an issue with your submission" email. I pulled the timestamps out of Gmail this morning. The first arrived at 5:29:32 PM Eastern. The last arrived at &lt;strong&gt;5:30:02 PM&lt;/strong&gt;. Eleven apps that each solve a different problem, for a different person, with a different design, judged in thirty seconds.&lt;/p&gt;

&lt;p&gt;I also checked whether the decisions came out in submission order, the way a human working down a queue would produce them. They did not. The Spearman rank correlation between "order submitted" and "order rejected" is &lt;strong&gt;-0.06&lt;/strong&gt; for the first batch and &lt;strong&gt;0.06&lt;/strong&gt; for the second. That is zero. Pawprint was submitted 4th and rejected 1st. Odometer was submitted 1st and rejected 11th. Whatever made these decisions, it was not a person opening apps one after another.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Eleven different apps, judged in thirty seconds. That is not a review. That is a &lt;code&gt;for&lt;/code&gt; loop.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;
  
  
  Round two: a seven-day timer, accurate to 12 minutes
&lt;/h2&gt;

&lt;p&gt;Here is where I broke Apple's one rule, and I will own it. The rejection said: if you disagree, reply in App Store Connect and do not resubmit. I replied to all 11, answering all nine of App Review's questions per app. Then I resubmitted all 11 anyway, within 10 minutes (3:13 to 3:23 PM Pacific on October 5).&lt;/p&gt;

&lt;p&gt;The second rejections arrived on October 12, between 6:04:46 and 6:05:13 PM Eastern. &lt;strong&gt;27 seconds for 11 apps.&lt;/strong&gt; But the wait is the weird part. Every app waited between &lt;strong&gt;167.7 and 167.9 hours&lt;/strong&gt; from resubmission to rejection. 168 hours is exactly seven days. Eleven apps, eleven waits, a spread of 12 minutes.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fk9sq9nmclos882avuc3w.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fk9sq9nmclos882avuc3w.png" alt="Bar chart of hours from submission to rejection for the same 11 apps. First round, submitted Sep 26: 208 to 223 hours. Second round, resubmitted Oct 5: every app 168 hours, between 167.7 and 167.9. Headline: seven days minus about 15 minutes for all 11, a spread of 12 minutes." width="800" height="450"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;App Review emails, Submitted line in Pacific time vs email timestamp. Apple says 90% of reviews finish in under 48 hours.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;For comparison, Apple's own &lt;a href="https://developer.apple.com/distribute/app-review/" rel="noopener noreferrer"&gt;App Review page&lt;/a&gt; says it reviews "at least 50% of submissions in less than 24 hours and 90% in less than 48 hours." When I timed &lt;a href="https://tensorust.com/articles/app-store-review-time/" rel="noopener noreferrer"&gt;all 64 App Review decisions on my account&lt;/a&gt; last weekend, the median was 73 hours, already worse than Apple's promise. Nothing else on my account ever waited a clean 168.&lt;/p&gt;

&lt;p&gt;My read, and it is a read, not a leak: flagged accounts go into a holding queue with a one-week timer, and when the timer fires, the whole batch gets the same canned rejection. A human may press the button. Nobody is opening the apps. If you have a better explanation for 11 different apps landing on 7 days minus 15 minutes, I want to hear it.&lt;/p&gt;
&lt;h2&gt;
  
  
  I'm not the only one. Same week, same 30-second treatment
&lt;/h2&gt;

&lt;p&gt;I went looking on X for other developers who got this in October 2026. It took one search. These are real posts, captured today, unedited.&lt;/p&gt;

&lt;p&gt;On October 5, the same day as my first batch, &lt;a href="https://x.com/krPorsche918/status/2107255281721983412" rel="noopener noreferrer"&gt;@krPorsche918&lt;/a&gt; posted that Apple rejected five of their apps "in the same minute, with the same sentence: Guideline 4.3 Spam." Their &lt;a href="https://dev.ootssu.com/en/posts/i-am-stopping-new-ios-launches" rel="noopener noreferrer"&gt;write-up&lt;/a&gt; has the best line anyone has written about this: "To me, 14 apps in 13 days was productivity. To Apple, it was a pattern." The same week, Apple approved all 53 in-app events on their live apps.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fc5v7ia9l0btvu13qexxc.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fc5v7ia9l0btvu13qexxc.png" alt="Screenshot of a post by @krPorsche918, Oct 5 2026: Apple rejected five of my apps in the same minute, with the same sentence: Guideline 4.3 Spam. The same week, all 53 in-app events for my live apps were approved. So I'm stopping new iOS launches. Updates and Android continue." width="800" height="755"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;@krPorsche918 on X, Oct 5 2026, 275 views, captured 1:15 PM ET Oct 19 2026, real and unedited.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;On October 8, &lt;a href="https://x.com/vovalukashov/status/2108106988588503140" rel="noopener noreferrer"&gt;@vovalukashov&lt;/a&gt; (Luka Shaw) asked the question everyone in the replies was thinking: "Looks like Apple stopped approving my new apps... Is this what happens to everyone who builds small apps that do one thing?" Three new apps rejected in four weeks, one of them twice. "Updates to my old apps still go through." 62K people saw it.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fk651cjib6ntnqm2qqcqk.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fk651cjib6ntnqm2qqcqk.png" alt="Screenshot of a post by @vovalukashov, Luka Shaw, Oct 8 2026, 62K views: Looks like Apple stopped approving my new apps. All I get is rejections that say Design: Spam. Is this what happens to everyone who builds small apps that do one thing? In 4 weeks they rejected 3 new apps, one of them twice. Updates to my old apps still go through. Attached: Sun Path and Pillmark, both iOS 1.0 Rejected." width="800" height="613"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;@vovalukashov on X, Oct 8 2026, captured 1:15 PM ET Oct 19 2026, real and unedited.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;And on October 12, at 6:26 PM Eastern, &lt;strong&gt;22 minutes after my second batch of 11 hit&lt;/strong&gt;, &lt;a href="https://x.com/martindonadieu/status/2109772731570962859" rel="noopener noreferrer"&gt;@martindonadieu&lt;/a&gt; (Martin Donadieu, the Capgo founder) posted his App Store Connect screen: a wall of red "Rejected" badges and the caption "Moved from no review to almost all rejected LOL". 223K views. Three days later he &lt;a href="https://x.com/martindonadieu/status/2110782981186916820" rel="noopener noreferrer"&gt;posted again&lt;/a&gt;: rejected for 4.3 a second time, for a 100% local ticketing app that ran a 500-person event this summer and is already on Google Play.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmfr85mc3hnlumi5ncavx.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmfr85mc3hnlumi5ncavx.png" alt="Screenshot of a post by @martindonadieu, Martin Donadieu, Oct 12 2026 6:26 PM, 223.1K views: Moved from no review to almost all rejected LOL. Attached: his App Store Connect apps grid with red rejected badges on Mapp, Monday Quest, Auto Browse, AgeVerif and Simple Fest." width="800" height="577"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;@martindonadieu on X, Oct 12 2026, 22 minutes after our second batch, captured 1:15 PM ET Oct 19 2026, real and unedited.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Look at the pattern across all four of us. &lt;strong&gt;New apps die. Updates pass.&lt;/strong&gt; My own account shows it: in the same window that killed 11 new apps twice, Apple approved updates to Chain and Minder (October 12) and six updates on September 29. All six of those September updates added the same paywall pattern the rejected apps had (Pro.swift; Purchases.swift in Chain), committed the same morning (September 26, between 9:26 and 9:29 AM Eastern). Same code, same day. Updates: approved in about 3 days. New apps: rejected twice. Luka says his updates go through. Porsche got 53 in-app events approved. Martin's existing apps are still up.&lt;/p&gt;

&lt;p&gt;So here is where I disagree with Luka. His conclusion was "I guess it's a sign to focus on the ones already in the App Store." That is exactly the behavior this filter is training into every small developer, and it is the wrong lesson. The filter is not saying your new app is bad. It is saying your account is shipping too fast for Apple's comfort. Those are different problems with different fixes.&lt;/p&gt;
&lt;h2&gt;
  
  
  Was Apple right? I ran a spam detector on my own apps
&lt;/h2&gt;

&lt;p&gt;Fair question: maybe my apps really are clones. I am one developer with 20 iPhone apps in 30 days, built with AI coding agents, all from the same pipeline. If anyone deserves a 4.3, it might be me. So this afternoon I tested it, instead of arguing about it.&lt;/p&gt;

&lt;p&gt;The experiment: for each of my 20 local app repos, take the git commit Apple actually saw (for the 8 live apps, the last commit before their first approval; for the 11 rejected apps, the last commit before the October 5 resubmit). Then compare every pair of apps, 190 pairs, on five things a 4.3 filter could plausibly look at:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;File tree overlap:&lt;/strong&gt; Jaccard similarity of the tracked file paths (shared paths divided by all paths). This is the "skeleton".&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Identical files:&lt;/strong&gt; files that are byte-for-byte the same after replacing the app's own name.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Swift line overlap:&lt;/strong&gt; share of distinct non-trivial Swift source lines the two apps have in common.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Store text overlap:&lt;/strong&gt; overlap of three-word phrases in the App Store description.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Screenshot distance:&lt;/strong&gt; the closest pair of store screenshots between the two apps, by 256-bit &lt;a href="https://www.hackerfactor.com/blog/index.php?/archives/432-Looks-Like-It.html" rel="noopener noreferrer"&gt;average hash&lt;/a&gt; (lower means more alike).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgpfif2lc2q1u0rjdjps3.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgpfif2lc2q1u0rjdjps3.png" alt="Heatmap of file tree similarity between 19 of my iPhone apps, each at the commit Apple reviewed. Live apps in green: Baseline, Fairway, Chain, Ironbook, Quiver, Minder, Pocket Beings, Clear the Strait. Rejected apps in red. Rejected vs rejected median 0.76, live vs live median 0.56, Clockout vs Odometer 1.00 identical tree, Baseline vs Fairway 0.90 and both live." width="800" height="600"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;git ls-tree of each repo at its submit-time commit, fingerprint.py, 1:19 PM ET Oct 19 2026.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The honest result first, because it does not flatter me: &lt;strong&gt;the rejected 11 really do share a skeleton.&lt;/strong&gt; Their median file tree overlap is &lt;strong&gt;0.76&lt;/strong&gt;, against &lt;strong&gt;0.56&lt;/strong&gt; for the live apps. They share a median of &lt;strong&gt;14 identical files&lt;/strong&gt; per pair against 5, and about three times the Swift lines (0.067 against 0.021). Clockout and Odometer have literally identical file trees: the same seven Swift files, with the same names, in the same folders. If Apple's filter looks at project structure inside the binary, my rejected batch lights up.&lt;/p&gt;

&lt;p&gt;Now the part Apple should explain. &lt;strong&gt;The single most similar pair of apps in all 190 is Baseline Ledger and Fairway Ledger&lt;/strong&gt;, my tennis journal and my golf journal. They share a third of their Swift lines (0.333), 20 identical files, a 0.90 file tree, the most similar store description and the closest screenshots of any pair. Both were approved, two days apart, on September 22 and 24. &lt;strong&gt;Both are live today.&lt;/strong&gt; Chain and Quiver share a 0.968 file tree. Both live.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fk1s9efwn096thtmmfhw7.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fk1s9efwn096thtmmfhw7.png" alt="Grouped bar chart of median pair scores, rejected pairs vs live pairs. File tree overlap 0.76 vs 0.56. Identical files 14 vs 5. Swift line overlap 0.067 vs 0.021. Store text overlap 0.010 vs 0.009. Screenshot hash distance 76 vs 82. Headline: rejected apps share more plumbing, not more words or pixels." width="800" height="450"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;fingerprint.py, 55 rejected pairs and 28 live pairs, each repo at its submit-time commit, Oct 19 2026.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;And the things a human reviewer actually looks at, the store page and the screenshots, do not separate the two groups at all. Store text overlap: 0.010 for rejected pairs, 0.009 for live pairs. Screenshot distance: 76 against 82. A person scrolling the listings could not tell the rejected apps were "more similar". Only a machine diffing the bundles could.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fqhtnmve8b111fvgc2dov.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fqhtnmve8b111fvgc2dov.png" alt="Terminal output of fingerprint.py over 20 iOS repos, 190 pairs. Top Swift overlap: baseline-ledger and fairway-ledger 0.333, both live. Top tree overlap: clockout and odometer 1.000, both rejected. Next: chain and quiver 0.968, both live. Identical in all 20: Store/signing.py. 11 of 11 rejected apps had Sources/Pro.swift, a paywall, at submit; 0 of 8 live apps had it at first approval." width="799" height="493"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;fingerprint.py run 1:19 PM ET Oct 19 2026.&lt;/em&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  The one file every rejected app had
&lt;/h2&gt;

&lt;p&gt;The fingerprint that splits the groups cleanly is not a similarity score. It is one file. &lt;strong&gt;All 11 rejected apps shipped &lt;code&gt;Sources/Pro.swift&lt;/code&gt; in their first submission.&lt;/strong&gt; None of the 8 live apps had it at first approval. Pro.swift is a StoreKit 2 paywall: the app is free, and a one-time $4.99 purchase unlocks the full version. Here is the top of Odometer's, at the commit Apple reviewed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;// Sources/Pro.swift (odometer repo, commit af40d3658)
/// Odometer Pro: one non-consumable. Keeping one car on schedule is free forever; Pro is the
/// whole garage and the paperwork.
@MainActor
@Observable
final class Pro {
    static let productID = "com.mattbusel.odometer.pro"
    /// The first build that has Pro in it. Anything earlier had every feature.
    static let firstFreemiumBuild = 2

    enum Reason: String, Identifiable { case costs, garage, history, export, settings; var id: String { rawValue } }

    private(set) var unlocked: Bool
    private(set) var grandfathered = false
    private(set) var product: Product?

    var price: String { product?.displayPrice ?? "$4.99" }

    func ask(_ why: Reason) { if !unlocked { paywall = why } }
    ...
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The files are not copies of each other (no two rejected apps have an identical Pro.swift; each has its own product, its own free tier and its own reasons to upgrade). But the shape is the same: one non-consumable, one &lt;code&gt;Pro&lt;/code&gt; class, one paywall sheet. The live apps were approved without it and only went freemium later, through updates submitted on September 26, the same day as the 11 new apps. Those updates sailed through on September 29.&lt;/p&gt;

&lt;p&gt;Here is what I think is really happening, and Apple will never say it out loud. A brand-new free app with a $4.99 unlock, from an account that drops 11 of them in one day, is the exact silhouette of a template farm. The &lt;a href="https://developer.apple.com/app-store/review/guidelines/#spam" rel="noopener noreferrer"&gt;4.3 text&lt;/a&gt; and its sister rule &lt;a href="https://developer.apple.com/app-store/review/guidelines/#4.2.6" rel="noopener noreferrer"&gt;4.2.6&lt;/a&gt; are written about "commercialized template or app generation services". In 2026, with every coding agent able to spit out an app a day, the template farm and the solo dev with Claude Code look the same from the outside. Apple's filter judges the silhouette, and the human who signs off never opens the app. Correlation, not proof: 19 apps is a small sample. But it is a cleaner split than anything in the guideline text.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I sent back, and what came back: nothing
&lt;/h2&gt;

&lt;p&gt;The 4.3 rejection comes with nine questions: what the app does, who it is for, how it differs, how you tested it, whether it shares code with your other apps, and so on. I answered every one, per app, honestly. Including the parts that probably hurt me, like this from Odometer's reply:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"No shared framework or asset pack. Every screen, model, illustration and text is specific to Odometer. Two files follow a pattern I reuse: the StoreKit 2 purchase handling for its one non-consumable (the paywall's text, free/Pro split and look are specific to the app), and a test-only driver that steps through the app's screens to record the walkthrough video and is inactive in normal use."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Then I filed appeals with the &lt;a href="https://developer.apple.com/help/app-review/after-submitting-for-review/appeal-to-the-app-review-board" rel="noopener noreferrer"&gt;App Review Board&lt;/a&gt; through Apple's form, which takes at most 5 apps and 2,000 characters per reason, so it took three forms (5 + 5 + 1). The argument was simple: all 11 were rejected within one minute with word-for-word identical text, so no individual review happened, and I asked for a human to open each app.&lt;/p&gt;

&lt;p&gt;That was October 5. I searched my inbox this morning for anything from Apple since. There are Apple Ads billing emails, an iPhone Duo pre-order ad, and a notice that the App Review Guidelines were revised on October 16. There is &lt;strong&gt;no email from App Review or the App Review Board since the second batch on October 12&lt;/strong&gt;, and nothing at all about the appeals. 13.8 days and counting.&lt;/p&gt;

&lt;p&gt;That October 16 revision is its own small insult. Apple rewrote seven parts of the guidelines (new rules on creative assets and hidden features, payment links, child safety). &lt;strong&gt;Guideline 4.3 was not one of them.&lt;/strong&gt; In the middle of a public wave of 4.3 batch rejections, the rule stayed word-for-word the same.&lt;/p&gt;

&lt;h2&gt;
  
  
  Apple's own numbers say you are not special
&lt;/h2&gt;

&lt;p&gt;Apple publishes a yearly &lt;a href="https://www.apple.com/legal/app-store/transparency/2025/" rel="noopener noreferrer"&gt;App Store Transparency Report&lt;/a&gt;. The 2025 edition counts &lt;strong&gt;9,100,620&lt;/strong&gt; submissions reviewed and &lt;strong&gt;2,093,244&lt;/strong&gt; rejected, about 23%. The Design section, which includes spam and copycats, accounts for 415,532 rejections. Apple's &lt;a href="https://www.apple.com/newsroom/2026/05/the-app-store-stopped-over-2-point-2-billion-usd-in-fraudulent-transactions-in-2025/" rel="noopener noreferrer"&gt;May 2026 newsroom post&lt;/a&gt; says it rejected more than 371,000 submissions for copying other apps, spam, or misleading users.&lt;/p&gt;

&lt;p&gt;Now look at the line nobody quotes. The same report counts 26,305 appeals of app removals and &lt;strong&gt;423 restorations&lt;/strong&gt;. That is 1.6%. Back in 2021, Apple's Senior Director of App Review said in a court filing that the team had more than 500 human reviewers and that &lt;a href="https://9to5mac.com/2021/05/07/app-store-review-process-has-over-500-human-experts-less-than-1-of-rejections-are-appealed/" rel="noopener noreferrer"&gt;less than 1% of rejections are appealed&lt;/a&gt;. Do the arithmetic yourself: 9.1 million submissions a year is roughly 25,000 a day. 371,000 spam-type rejections is about 1,000 a day. Nobody is opening 1,000 apps a day with care. The 30-second batch is not a glitch in that system. It &lt;em&gt;is&lt;/em&gt; the system.&lt;/p&gt;

&lt;h2&gt;
  
  
  Hot takes, ranked
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;4.3 has become a volume tax on solo developers.&lt;/strong&gt; It does not catch clones. My two most similar apps are live. It catches accounts that ship more than Apple thinks one person should. In the AI coding era, that is every indie dev who is any good.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The App Review Board is a black hole for batch rejections.&lt;/strong&gt; My bet: these 11 appeals get no human reply before November, and if they ever do, it will be one templated paragraph for all 11.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Apple will never publish 4.3 numbers by itself.&lt;/strong&gt; The transparency report lists "4.3.0 Spam" only for removals (418 apps), never for rejections, because a per-guideline count would show how much of the "spam" fight is really a throttle on new developers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Updates pass because updates already make Apple money.&lt;/strong&gt; An update to a live app is a known quantity with a payment history. A new app is a cost. Every account in this article got updates approved during the same weeks its new apps were killed. That is not a coincidence, it is a business model.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Google is going to eat this.&lt;/strong&gt; Martin's app was on Google Play a week before Apple rejected it a second time. When the indie dev's first launch moves to Android, Apple's "quality" story gets a lot quieter.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  How to survive a Guideline 4.3 spam rejection
&lt;/h2&gt;

&lt;p&gt;This is what I would do differently, from the data above and Apple's own pages. It is the advice I am now following myself.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Never batch-submit new apps.&lt;/strong&gt; One or two new apps per account per week. Porsche's "14 apps in 13 days" and my "11 in one day" got the same answer. Updates to live apps can go as fast as you like.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reply, and do not resubmit.&lt;/strong&gt; The rejection says it, and I ignored it. My resubmits landed on a seven-day timer and died in 27 seconds. Answer the nine questions in App Store Connect and wait.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Launch new apps without the paywall, add IAP in an update.&lt;/strong&gt; 0 of my 8 approved apps had a paywall at first approval; 11 of 11 rejected apps did. Small sample, but it is the one clean split I found, and the freemium updates passed every time.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;If your apps are variants, merge them.&lt;/strong&gt; 4.3(a) literally tells you to ship one app and sell the variations as in-app purchases. If a reviewer could describe two of your apps with one sentence, they will.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Book a call with App Review.&lt;/strong&gt; Apple offers 30-minute &lt;a href="https://developer.apple.com/distribute/app-review/" rel="noopener noreferrer"&gt;Webex appointments&lt;/a&gt; with App Review to discuss the guidelines. Almost nobody mentions this. It is the only channel where a human has to look at you. It is my next move.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Appeal with specifics, one appeal per submission.&lt;/strong&gt; Apple's appeal page asks for "specific reasons why you believe your app complies". Timestamps of a batch rejection are specific. So far they have not been enough, so pair them with item 5.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If you want the pipeline these 20 apps were built and shipped with (from a Windows PC, no Mac), the &lt;a href="https://tensorust.com/articles/ios-development-on-windows/" rel="noopener noreferrer"&gt;complete iOS-on-Windows guide&lt;/a&gt; covers it, and &lt;a href="https://tensorust.com/articles/iphone-app-development-cost/" rel="noopener noreferrer"&gt;what 20 real apps cost to build&lt;/a&gt; has the money side.&lt;/p&gt;

&lt;h2&gt;
  
  
  The numbers so far
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Rejected under 4.3, twice:&lt;/strong&gt; 11 apps (22 rejections), October 5 and October 12, 2026&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Time to reject 11 apps:&lt;/strong&gt; 30 seconds (Oct 5), 27 seconds (Oct 12)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Submit order vs reject order:&lt;/strong&gt; Spearman -0.06 and 0.06 (no relationship)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Wait on round two:&lt;/strong&gt; 167.7 to 167.9 hours for all 11 (Apple says 90% within 48 h)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Appeals filed:&lt;/strong&gt; 3 forms covering 11 apps, October 5. &lt;strong&gt;Replies: 0&lt;/strong&gt; after 13.8 days (inbox checked 1:10 PM ET, October 19)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Live on the US App Store today:&lt;/strong&gt; 8 apps (iTunes lookup, 1:12 PM ET October 19), unchanged since October 17&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Most similar pair of all 190:&lt;/strong&gt; Baseline Ledger + Fairway Ledger, 33% shared Swift lines. Both live.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Paywall at first submission:&lt;/strong&gt; 11 of 11 rejected apps, 0 of 8 approved apps&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The data, and how to rerun it
&lt;/h2&gt;

&lt;p&gt;Decision timestamps: the 22 "There's an issue with your submission" emails from Apple's App Review sender, pulled from Gmail with their "Submitted:" lines (Pacific Daylight Time) on October 17 and re-checked on October 19 at 1:10 PM Eastern for anything newer (none). Fingerprints: &lt;code&gt;fingerprint.py&lt;/code&gt; run at 1:19 PM Eastern October 19 over 20 local repos, each at the commit before its decisive submission (&lt;code&gt;git rev-list -1 --before=...&lt;/code&gt;), reading files with &lt;code&gt;git show&lt;/code&gt;, PNGs excluded from file comparisons, the app's own name replaced with a placeholder before hashing. Live status: the iTunes Search API lookup for my developer account, 1:12 PM Eastern. Posts by @krPorsche918, @vovalukashov and @martindonadieu were captured from X at about 1:15 PM Eastern October 19, real and unedited.&lt;/p&gt;

&lt;p&gt;So, &lt;a href="https://x.com/martindonadieu" rel="noopener noreferrer"&gt;@martindonadieu&lt;/a&gt;, &lt;a href="https://x.com/vovalukashov" rel="noopener noreferrer"&gt;@vovalukashov&lt;/a&gt;, &lt;a href="https://x.com/krPorsche918" rel="noopener noreferrer"&gt;@krPorsche918&lt;/a&gt;: check your own rejection emails. Did yours land within seconds of each other, and did your resubmits wait exactly a week? If three more accounts show the same 168-hour timer, this stops being my theory. Everyone else: has Apple's App Review Board ever actually answered your 4.3 appeal, and how long did it take? Drop your numbers in the comments.&lt;/p&gt;

&lt;p&gt;Building iPhone apps without a Mac and want the pipeline set up on your own repo (signing, screenshots, fastlane submission and the review notes I now use)? &lt;a href="https://tensorust.com/go/ios-setup/guideline-4-3-spam?utm_source=devto&amp;amp;utm_campaign=guideline-4-3-spam" rel="noopener noreferrer"&gt;The No-Mac iOS Pipeline is $299&lt;/a&gt;, first TestFlight build in 5 business days.&lt;/p&gt;

</description>
      <category>ios</category>
      <category>appstore</category>
      <category>mobile</category>
      <category>swift</category>
    </item>
    <item>
      <title>A macOS disk cleaner that shows the cost before it deletes</title>
      <dc:creator>ran Guo</dc:creator>
      <pubDate>Mon, 19 Oct 2026 12:41:16 +0000</pubDate>
      <link>https://dev.to/dreamofxm/a-macos-disk-cleaner-that-shows-the-cost-before-it-deletes-37i3</link>
      <guid>https://dev.to/dreamofxm/a-macos-disk-cleaner-that-shows-the-cost-before-it-deletes-37i3</guid>
      <description>&lt;p&gt;Most cleanup tools ask for the same thing: trust. You press Clean, a progress bar moves, a number goes up. What the number never tells you is whether the 12 GB it just removed was a cache the app rebuilds in ten minutes, or a project you spent the week on.&lt;/p&gt;

&lt;p&gt;DiskWise is an open-source macOS cleaner built around one rule: before anything moves, show the cost of every item.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two labels instead of a score
&lt;/h2&gt;

&lt;p&gt;The scanner produces a list of reclaimable directories. Every entry gets one of two labels:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;Harmless&lt;/code&gt; — caches, logs, temp files. They come back when needed.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;Downloads again&lt;/code&gt; — recoverable, but only by downloading or rebuilding.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;No score out of 100. A score invites arguments; a label forces a decision. Anything the scanner cannot describe with confidence stays out of the cleanup list.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three steps, one delete path
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Plan.&lt;/strong&gt; The scan groups directories by cause: app caches, Xcode build artifacts, simulator data, node_modules, Homebrew downloads, Docker layers, AI coding tool sessions. You pick.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Confirm.&lt;/strong&gt; The app restates the selection with paths and sizes. It feels redundant until you are staring at twelve similarly named folders.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Receipt.&lt;/strong&gt; When it finishes, you get a summary of what moved, what is in the Trash, and how to undo.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The delete path is deliberately boring: &lt;code&gt;FileManager.default.trashItem&lt;/code&gt;, plus two guards — a protected-path check and an allowed-root check. No &lt;code&gt;rm&lt;/code&gt;, no privileged helper. Undo restores from the Trash, so there is no private journal that can disagree with the filesystem.&lt;/p&gt;

&lt;h2&gt;
  
  
  The scan is conservative, because the allowlist is the whole product
&lt;/h2&gt;

&lt;p&gt;Every entry carries a written explanation: what this folder is, and what happens after it is removed. Developer locations are treated differently from caches. Xcode DerivedData, simulator devices, package manager caches, and node_modules are presented read-only: the app points at them, you decide what to do.&lt;/p&gt;

&lt;p&gt;That is also why there is no "clean everything" button. Aggressive cleanup is easy to demo and hard to forgive.&lt;/p&gt;

&lt;h2&gt;
  
  
  The same guardrails for agents
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;diskwise mcp&lt;/code&gt; starts a local stdio MCP server with six tools: &lt;code&gt;scan_disk&lt;/code&gt;, &lt;code&gt;explain_path&lt;/code&gt;, &lt;code&gt;plan_cleanup&lt;/code&gt;, &lt;code&gt;execute_cleanup&lt;/code&gt;, &lt;code&gt;undo_cleanup&lt;/code&gt;, &lt;code&gt;cleanup_history&lt;/code&gt;. The protocol surface is intentionally narrow. An agent gets a plan it must execute explicitly, deletion still goes to the Trash, and the last operation can be undone. No network access, no telemetry.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it is not
&lt;/h2&gt;

&lt;p&gt;It is not a speed-up tool, an antivirus, or an app uninstaller. It shows where the space went, explains what each removal costs, and keeps the whole thing reversible. That is the entire product.&lt;/p&gt;

&lt;p&gt;Repo: &lt;a href="https://github.com/DreamOfXM/diskwise" rel="noopener noreferrer"&gt;https://github.com/DreamOfXM/diskwise&lt;/a&gt; — free and open source, available from GitHub Releases and the Mac App Store.&lt;/p&gt;

</description>
      <category>productivity</category>
      <category>showdev</category>
      <category>swift</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Tagara: hold a key, speak, and the text appears at the cursor</title>
      <dc:creator>Mateus Fonseca</dc:creator>
      <pubDate>Sun, 18 Oct 2026 21:51:54 +0000</pubDate>
      <link>https://dev.to/mateusfonsek/tagara-hold-a-key-speak-and-the-text-appears-at-the-cursor-54i3</link>
      <guid>https://dev.to/mateusfonsek/tagara-hold-a-key-speak-and-the-text-appears-at-the-cursor-54i3</guid>
      <description>&lt;p&gt;Most of what I write during the day is faster to say than to type. So I built Tagara, a small dictation app for macOS.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffuwhwbkpu777dqgcohoj.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffuwhwbkpu777dqgcohoj.png" alt="Hold ⌥, speak, let go" width="800" height="479"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Hold the right ⌥ key, talk, let go. The text is typed wherever your cursor is: the terminal, Slack, the browser, any text field. A sentence becomes text about 0.2–0.4 s after you release the key.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fde9eaxyl1qtc9oy709hj.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fde9eaxyl1qtc9oy709hj.png" alt="Works wherever you can type" width="800" height="479"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Everything runs on your Mac. Transcription uses the open Parakeet model on Apple Silicon's Neural Engine. The audio is never saved or sent anywhere, and there's no account. When idle it uses 0% CPU and the mic stays closed.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;English and Portuguese, detected on every dictation&lt;/li&gt;
&lt;li&gt;Puts your clipboard back the way it was after typing&lt;/li&gt;
&lt;li&gt;Local history of the last 50 dictations (you can turn it off)&lt;/li&gt;
&lt;li&gt;Native SwiftUI menu bar app, light and dark mode&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Free and open source (MIT). macOS 15+, Apple Silicon. It isn't signed with an Apple developer certificate yet, so macOS asks you to allow it once.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Site: &lt;a href="https://mateusfonsek.github.io/tagara/" rel="noopener noreferrer"&gt;https://mateusfonsek.github.io/tagara/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Code: &lt;a href="https://github.com/mateusfonsek/tagara" rel="noopener noreferrer"&gt;https://github.com/mateusfonsek/tagara&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Feedback welcome, especially on accuracy and latency on different Macs.&lt;/p&gt;

</description>
      <category>macos</category>
      <category>opensource</category>
      <category>swift</category>
      <category>showdev</category>
    </item>
    <item>
      <title>iOS development on Windows: the complete guide (21 apps, no Mac)</title>
      <dc:creator>Matt Busel</dc:creator>
      <pubDate>Sun, 18 Oct 2026 20:28:04 +0000</pubDate>
      <link>https://dev.to/mattbusel/ios-development-on-windows-the-complete-guide-21-apps-no-mac-2103</link>
      <guid>https://dev.to/mattbusel/ios-development-on-windows-the-complete-guide-21-apps-no-mac-2103</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://tensorust.com/articles/ios-development-on-windows/" rel="noopener noreferrer"&gt;tensorust.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; You can do iOS development on Windows in 2026 if you rent the Mac part by the minute. I shipped &lt;strong&gt;21 iPhone apps&lt;/strong&gt; to App Store Connect from a Windows PC between September 10 and September 27, 2026, and &lt;strong&gt;8 are live&lt;/strong&gt; on the US App Store today. For this guide I pulled every CI run, every job step, every commit and every App Review email behind them on October 18: the median app went from its &lt;strong&gt;first commit to its first App Store submission in 2.2 hours&lt;/strong&gt;, a release costs about &lt;strong&gt;4 minutes&lt;/strong&gt; of rented Mac time, and the thing that stopped 13 of the 21 apps was never Windows. It was App Review.&lt;/p&gt;

&lt;p&gt;Short answer for the search. Xcode only runs on macOS, so "iOS development on Windows" really means splitting the work. You write the Swift, the project file, the store listing and the release scripts on Windows. A macOS machine you never touch compiles, signs and uploads. The cheapest version of that machine is a &lt;a href="https://github.com/actions/runner-images" rel="noopener noreferrer"&gt;GitHub Actions macOS runner&lt;/a&gt;, which GitHub &lt;a href="https://docs.github.com/en/billing/concepts/product-billing/github-actions" rel="noopener noreferrer"&gt;does not charge for on public repositories&lt;/a&gt;. You still need the &lt;a href="https://developer.apple.com/programs/" rel="noopener noreferrer"&gt;$99 a year Apple Developer Program&lt;/a&gt;. You do not need Keychain Access, Xcode's Organizer window, or a Mac on your desk.&lt;/p&gt;

&lt;p&gt;This is the pillar guide for everything I have written about the pipeline. It walks the whole path once, from an empty folder on Windows to "Ready for Distribution", and links the deep-dives where each step gets its own article: &lt;a href="https://tensorust.com/articles/build-ios-app-on-windows/" rel="noopener noreferrer"&gt;the CI pipeline and signing&lt;/a&gt;, &lt;a href="https://tensorust.com/articles/iphone-app-development-cost/" rel="noopener noreferrer"&gt;what the 20 paid-for builds really cost&lt;/a&gt;, &lt;a href="https://tensorust.com/articles/app-store-review-time/" rel="noopener noreferrer"&gt;how long App Review took across 64 decisions&lt;/a&gt;, and &lt;a href="https://tensorust.com/articles/swiftui-spring-animation-cartoon-eyes/" rel="noopener noreferrer"&gt;one of the apps that came out of it&lt;/a&gt;. Everything below is real code from the app repos and real numbers pulled today.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8f2zcv93jfd3xii5ully.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8f2zcv93jfd3xii5ully.gif" alt="Animated timeline, one row per app for 21 iPhone apps, Sep 10 to Oct 18 2026. A cursor sweeps across the dates. Lime bars run from each first commit to the first App Store submission, mostly a few hours; green bars are review rounds that ended in approval, red bars review rounds that ended in a rejection or a question. Eight rows turn into thin green live lines by Sep 25; eleven rows starting Sep 26 end in two long red bars each, ending Oct 5 and Oct 12. Counter ends at 8 live." width="600" height="430"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Every app from first commit to today: git, the GitHub Actions API, Apple's App Review emails and the iTunes lookup API, pulled Oct 18 2026, 4:12 to 4:20 PM ET. Static version: timeline.png.&lt;/em&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  The data behind this guide
&lt;/h2&gt;

&lt;p&gt;Every number here comes from records the pipeline already leaves behind, pulled on October 18, 2026 between 4:12 and 4:20 PM Eastern. Nothing was rebuilt for the article and no app was touched.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;GitHub&lt;/strong&gt;: all &lt;strong&gt;384 workflow runs&lt;/strong&gt; in the 21 app repos, every commit, and for each of the 81 successful App Store runs, every step with its start and end time, through the &lt;a href="https://docs.github.com/en/rest/actions/workflow-runs" rel="noopener noreferrer"&gt;workflow runs&lt;/a&gt; and &lt;a href="https://docs.github.com/en/rest/actions/workflow-jobs" rel="noopener noreferrer"&gt;workflow jobs&lt;/a&gt; REST APIs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Apple's emails&lt;/strong&gt;: the 64 App Review decisions from the &lt;a href="https://tensorust.com/articles/app-store-review-time/" rel="noopener noreferrer"&gt;review time article&lt;/a&gt;, each with Apple's own "Submitted" timestamp. I checked the inbox again this morning: no new decisions since October 12.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The App Store&lt;/strong&gt;: the &lt;a href="https://performance-partners.apple.com/search-api" rel="noopener noreferrer"&gt;iTunes lookup API&lt;/a&gt; for every app on my developer account, with version, release date and file size.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The code&lt;/strong&gt;: a shallow clone of each repo, counting non-blank lines of Swift.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The collector is 40 lines of Python around the &lt;a href="https://cli.github.com/" rel="noopener noreferrer"&gt;GitHub CLI&lt;/a&gt;. This is the core of it (&lt;code&gt;collect.py&lt;/code&gt;, trimmed):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;def api(path):
    r = subprocess.run(["gh", "api", "-H", "Accept: application/vnd.github+json", path],
                       capture_output=True, text=True, encoding="utf-8")
    if r.returncode != 0:
        if "429" in r.stderr or "rate limit" in r.stderr.lower():
            sys.exit("RATE LIMIT, stopping")
        return None
    return json.loads(r.stdout)

for repo in REPOS:
    runs, page = [], 1
    while True:
        d = api(f"repos/Mattbusel/{repo}/actions/runs?per_page=100&amp;amp;page={page}")
        if not d: break
        runs += d["workflow_runs"]
        if len(d["workflow_runs"]) &amp;lt; 100: break
        page += 1
    jobs = {}
    for r in runs:
        if r["name"] == "App Store" and r["conclusion"] == "success":
            jobs[r["id"]] = api(f"repos/Mattbusel/{repo}/actions/runs/{r['id']}/jobs")
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Point it at your own repos and you get the same picture of your own pipeline: where the minutes go and which steps fail. It costs a few hundred API calls, well inside GitHub's hourly limit.&lt;/p&gt;

&lt;p&gt;The headline numbers, before the how-to:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;53,932 lines of Swift&lt;/strong&gt; pushed to the 21 public repos, median &lt;strong&gt;2,308&lt;/strong&gt; non-blank lines per app (the local working copies hold a bit more). No &lt;code&gt;.xcodeproj&lt;/code&gt; in any of the 21 repos.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;384 CI runs&lt;/strong&gt;: 139 App Store runs, 165 Build runs, 18 review-video recordings, 62 GitHub Pages deploys. About &lt;strong&gt;2,572 minutes&lt;/strong&gt; on macOS runners, queue to finish.&lt;/li&gt;
&lt;li&gt;First commit to first App Store submission: median &lt;strong&gt;2.2 hours&lt;/strong&gt;, fastest &lt;strong&gt;25 minutes&lt;/strong&gt; (Race Pace, September 27).&lt;/li&gt;
&lt;li&gt;First submission to approval, for the 8 apps that got one: median &lt;strong&gt;4.4 days&lt;/strong&gt;, range 3.7 to 7.2.&lt;/li&gt;
&lt;li&gt;Status today: &lt;strong&gt;21&lt;/strong&gt; built, &lt;strong&gt;21&lt;/strong&gt; submitted, &lt;strong&gt;8&lt;/strong&gt; approved, &lt;strong&gt;8&lt;/strong&gt; live. &lt;strong&gt;0 of 21&lt;/strong&gt; approved on the first try.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What you can and cannot do on Windows
&lt;/h2&gt;

&lt;p&gt;The split is cleaner than most forum threads make it sound. Apple's toolchain (the compiler with the iOS SDK, the code signer, the Simulator and the uploader) only ships inside &lt;a href="https://developer.apple.com/xcode/" rel="noopener noreferrer"&gt;Xcode&lt;/a&gt;, and Xcode only runs on macOS. Everything that is text, or talks to Apple over HTTPS, works from Windows.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Task&lt;/th&gt;
&lt;th&gt;Where it ran for the 21 apps&lt;/th&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Writing Swift and SwiftUI&lt;/td&gt;
&lt;td&gt;Windows&lt;/td&gt;
&lt;td&gt;any editor + coding agents&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The Xcode project&lt;/td&gt;
&lt;td&gt;Windows (as YAML), generated on the Mac&lt;/td&gt;
&lt;td&gt;&lt;a href="https://github.com/yonaskolb/XcodeGen" rel="noopener noreferrer"&gt;XcodeGen&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bundle ID, listing text, age rating, review notes, screenshots upload, in-app purchases&lt;/td&gt;
&lt;td&gt;Windows&lt;/td&gt;
&lt;td&gt;
&lt;a href="https://developer.apple.com/documentation/appstoreconnectapi" rel="noopener noreferrer"&gt;App Store Connect API&lt;/a&gt; from Python&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The distribution certificate&lt;/td&gt;
&lt;td&gt;Windows, once&lt;/td&gt;
&lt;td&gt;Python &lt;code&gt;cryptography&lt;/code&gt; + the API&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Compiling for iOS, unit tests, Simulator screenshots&lt;/td&gt;
&lt;td&gt;rented Mac&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;xcodebuild&lt;/code&gt;, &lt;code&gt;simctl&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Signing, uploading, submitting for review&lt;/td&gt;
&lt;td&gt;rented Mac&lt;/td&gt;
&lt;td&gt;&lt;a href="https://docs.fastlane.tools/" rel="noopener noreferrer"&gt;fastlane&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Creating the app record&lt;/td&gt;
&lt;td&gt;a browser, once per app&lt;/td&gt;
&lt;td&gt;App Store Connect website&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That last row is the one surprise. The API can edit almost everything about an app, but our &lt;code&gt;POST /v1/apps&lt;/code&gt; came back 403, so each new app record is one form in the App Store Connect website. It takes about a minute. Every other field is scripted.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fw5el7szq6m5dnmj6bir8.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fw5el7szq6m5dnmj6bir8.png" alt="Diagram in three columns. Your Windows PC: write Swift and SwiftUI, project.yml for XcodeGen, Python scripts that talk to the App Store Connect API for the listing, purchases and screenshots, signing.py makes the certificate once, gh workflow run. A rented Mac in CI: macos-15 runner for about 4 minutes, pick Xcode 26 and a simulator, xcodegen generate, xcodebuild build and test, import the certificate into a throwaway keychain, fastlane signs, uploads and submits. Apple: App Store Connect processes the build, TestFlight, App Review with a median of 73 hours a round, Ready for Distribution, App Store." width="799" height="453"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;What runs where in the pipeline behind the 21 apps.&lt;/em&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  Your options for the Mac part, compared
&lt;/h2&gt;

&lt;p&gt;You need some macOS machine for the compile-sign-upload half. Here are the real choices, with what I could verify about each on October 18:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Option&lt;/th&gt;
&lt;th&gt;What it is&lt;/th&gt;
&lt;th&gt;The catch&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;GitHub Actions macOS runner (what I used)&lt;/td&gt;
&lt;td&gt;A fresh macOS VM per job, Xcode preinstalled&lt;/td&gt;
&lt;td&gt;Free on public repos. Private repos pay &lt;a href="https://docs.github.com/en/billing/reference/actions-runner-pricing" rel="noopener noreferrer"&gt;$0.062 a minute&lt;/a&gt; for the standard 3 or 4 core macOS runner. No screen: everything is scripted.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Expo EAS Build&lt;/td&gt;
&lt;td&gt;If you write React Native with Expo, &lt;a href="https://docs.expo.dev/build/introduction/" rel="noopener noreferrer"&gt;"iOS builds run on macOS runners hosted in Expo's macOS cloud"&lt;/a&gt;
&lt;/td&gt;
&lt;td&gt;Only for Expo/React Native projects. You trade Swift for JavaScript.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AWS EC2 Mac&lt;/td&gt;
&lt;td&gt;A real Mac mini in a data center&lt;/td&gt;
&lt;td&gt;A &lt;a href="https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/ec2-mac-instances.html" rel="noopener noreferrer"&gt;minimum allocation of 24 hours&lt;/a&gt; per dedicated host, so even a 4 minute build bills a day.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cloud Mac rental by the month&lt;/td&gt;
&lt;td&gt;A remote desktop on a Mac&lt;/td&gt;
&lt;td&gt;You get the GUI, but you are paying for a whole machine to sit idle between releases.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Flutter&lt;/td&gt;
&lt;td&gt;Cross-platform UI in Dart&lt;/td&gt;
&lt;td&gt;Flutter's &lt;a href="https://docs.flutter.dev/platform-integration/ios/setup" rel="noopener noreferrer"&gt;iOS setup page&lt;/a&gt; starts with installing Xcode. You still need one of the Macs above to build for iOS.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;macOS in a VM on your PC&lt;/td&gt;
&lt;td&gt;The "Hackintosh" route&lt;/td&gt;
&lt;td&gt;Apple's &lt;a href="https://www.apple.com/legal/sla/" rel="noopener noreferrer"&gt;macOS license&lt;/a&gt; limits installs to Apple-branded hardware. I did not try it and would not build a business on it.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;My opinion: for a solo developer or a small studio, the CI runner wins by a mile. You only ever need the Mac for about 4 minutes per release, the machine is fresh every time (no "works on my Mac"), and the whole setup lives in one file in the repo. The cost of that choice is that you can't click anything, which turned out to be a feature: every fix became a line in a file the next app could read.&lt;/p&gt;
&lt;h2&gt;
  
  
  Step 1: get Swift onto your Windows machine
&lt;/h2&gt;

&lt;p&gt;You don't strictly need a Swift compiler on Windows. None of the 21 apps were compiled locally; the feedback loop was a CI run in "compile" mode, whose compile step took a median of &lt;strong&gt;35 seconds&lt;/strong&gt; across 37 runs. Add the runner's startup and you get an answer in under two minutes.&lt;/p&gt;

&lt;p&gt;If you want local feedback anyway, Swift has an &lt;a href="https://www.swift.org/install/windows/" rel="noopener noreferrer"&gt;official Windows toolchain&lt;/a&gt;, installed with &lt;code&gt;winget install --id Swift.Toolchain -e --source winget&lt;/code&gt; after Visual Studio's build tools, and the &lt;a href="https://github.com/swiftlang/vscode-swift" rel="noopener noreferrer"&gt;Swift extension for VS Code&lt;/a&gt; gives you completion and errors. The limit is the SDK: SwiftUI, UIKit, StoreKit and WidgetKit are Apple frameworks, so on Windows you can compile and test the plain-Swift parts (your models, your date math, your parsers) but not the screens.&lt;/p&gt;

&lt;p&gt;That split is a decent design rule anyway: keep the logic that matters (streak math, scoring, pricing) in plain Swift files with no &lt;code&gt;import SwiftUI&lt;/code&gt;, and keep the views thin. Coding agents are much better at code they can reason about without a screen, and so are you.&lt;/p&gt;

&lt;p&gt;What you do need on Windows: &lt;code&gt;git&lt;/code&gt;, Python 3 (for the App Store Connect scripts), and the &lt;a href="https://cli.github.com/" rel="noopener noreferrer"&gt;GitHub CLI&lt;/a&gt; to start the CI runs. If you run coding agents on this, read &lt;a href="https://tensorust.com/articles/run-20-ai-coding-agents-on-one-pc/" rel="noopener noreferrer"&gt;what broke running 20 agents on one PC&lt;/a&gt; first.&lt;/p&gt;
&lt;h2&gt;
  
  
  Step 2: describe the project in YAML, never in Xcode
&lt;/h2&gt;

&lt;p&gt;An Xcode project file (&lt;code&gt;project.pbxproj&lt;/code&gt;) is a large generated file with random IDs that you can't sensibly edit by hand, and you can't open Xcode on Windows to edit it for you. So the 21 repos don't have one. &lt;a href="https://github.com/yonaskolb/XcodeGen" rel="noopener noreferrer"&gt;XcodeGen&lt;/a&gt; builds it from a short YAML file at the start of every CI run. This is Chain's real spec, trimmed (&lt;code&gt;project.yml&lt;/code&gt;; Chain has an app plus a widget extension):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;## Chain, XcodeGen project spec. The .xcodeproj is generated in CI, never committed.
name: Chain
options:
  bundleIdPrefix: com.mattbusel
  deploymentTarget:
    iOS: "17.0"
settings:
  base:
    MARKETING_VERSION: "1.2"
    CURRENT_PROJECT_VERSION: "1"
    CODE_SIGN_STYLE: Manual
    DEVELOPMENT_TEAM: ${DEVELOPMENT_TEAM}
targets:
  Chain:
    type: application
    platform: iOS
    dependencies:
      - target: ChainWidgets
    sources:
      - path: Sources
      - path: Resources/Assets.xcassets
        buildPhase: resources
    entitlements:
      path: Resources/Chain.entitlements
      properties:
        com.apple.security.application-groups:
          - group.com.mattbusel.chainhabits
    settings:
      base:
        PRODUCT_BUNDLE_IDENTIFIER: com.mattbusel.chainhabits
        PROVISIONING_PROFILE_SPECIFIER: "${APP_PROFILE}"
  ChainWidgets:
    type: app-extension
    platform: iOS
    sources:
      - path: Widgets
      - path: Sources/Shared
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three details carry weight. &lt;code&gt;DEVELOPMENT_TEAM&lt;/code&gt; and the profile names come from environment variables, so no account identifier sits in the repo. &lt;code&gt;CODE_SIGN_STYLE: Manual&lt;/code&gt; is deliberate; the signing section below explains why automatic signing can't work without a registered iPhone. And because the project is generated, there is never a merge conflict in a pbxproj, which matters a lot once two agents edit the same app.&lt;/p&gt;

&lt;p&gt;The generate step on the runner took a median of &lt;strong&gt;under one second&lt;/strong&gt; across 81 successful runs. It's the cheapest step in the pipeline and it removes the one file that makes iOS repos hostile to Windows.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: rent the Mac with one workflow file
&lt;/h2&gt;

&lt;p&gt;All of the Mac lives in &lt;code&gt;.github/workflows/appstore.yml&lt;/code&gt;. It has one input with three modes, started from Windows with &lt;code&gt;gh workflow run appstore.yml -f action=dry_run&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;on:
  workflow_dispatch:
    inputs:
      action:
        description: "What to do"
        required: true
        default: compile
        type: choice
        options:
          - compile       # build + unit tests, no simulator screenshots
          - dry_run       # build, sign, upload; do not submit
          - release       # everything, including submit for review

jobs:
  ship:
    runs-on: macos-15
    timeout-minutes: 90
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;compile&lt;/code&gt; is your local build button. &lt;code&gt;dry_run&lt;/code&gt; puts a signed build in App Store Connect (and TestFlight) without asking Apple to review it. &lt;code&gt;release&lt;/code&gt; does the same and submits.&lt;/p&gt;

&lt;p&gt;Two runner traps cost real runs, and both are in the file as comments now. First, the Xcode that isn't there. On the macos-15 image, &lt;code&gt;/Applications/Xcode_26.app&lt;/code&gt; existed but had no toolchain inside, so selecting it failed while &lt;code&gt;ls&lt;/code&gt; listed it happily. The fix picks the newest Xcode 26 that is real:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;      - name: Select Xcode 26
        run: |
          chosen=""
          for app in $(ls -d /Applications/Xcode_26*.app 2&amp;gt;/dev/null | sort -Vr); do
            if [ -d "$app/Contents/Developer" ]; then chosen="$app"; break; fi
          done
          if [ -z "$chosen" ]; then
            echo "no usable Xcode 26:"; ls /Applications | grep -i xcode; exit 1
          fi
          sudo xcode-select -s "$chosen/Contents/Developer"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Second, simulators that move. A hardcoded device name worked for six runs and failed on the seventh with "Unable to find a device matching the provided destination specifier", because the runner fleet doesn't always carry the same simulators. Now the newest available iPhone wins:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;          name=$(xcrun simctl list devices available \
                 | grep -oE "iPhone [0-9]+ Pro Max" | sort -Vr | head -1)
          echo "SIM_NAME=$name" &amp;gt;&amp;gt; "$GITHUB_ENV"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here is what a whole release looks like when it works, Chain 1.2 on October 8:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fx5wli5ec0b5d0yg5mdjw.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fx5wli5ec0b5d0yg5mdjw.gif" alt="Animated diagram of the pipeline: a Windows PC runs gh workflow run, a GitHub macOS runner picks Xcode 26 and compiles in 52 seconds, imports the signing certificate in 1 second, then fastlane builds and uploads in 2 minutes 31 seconds, 4 minutes 22 seconds in total." width="799" height="450"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;How Chain 1.2 went from one command on Windows to App Store Connect on Oct 8 2026. Step times from the GitHub Actions run log.&lt;/em&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  Where the Mac minutes actually go
&lt;/h2&gt;

&lt;p&gt;This is the new measurement for this guide. GitHub's jobs API returns every step of every job with its start and end time, so I summed the time inside each step for all 81 successful App Store runs across the 21 apps: &lt;strong&gt;646 minutes&lt;/strong&gt; of step time in total.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8s1kbtvmhsaqp4z151bq.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8s1kbtvmhsaqp4z151bq.png" alt="Bar chart of minutes spent inside each CI step across 81 successful App Store runs, 646 minutes in total: capture App Store screenshots 222 minutes, 34 percent; build and run unit tests 162, 25 percent; sign, upload and submit with fastlane 101, 16 percent; find a simulator 47, 7 percent; record gameplay or review video 46, 7 percent; tools, Xcode and checkout 34, 5 percent; compile only 28, 4 percent; everything else 6, 1 percent." width="800" height="507"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Step start and end times from the GitHub Actions jobs API, 81 successful App Store runs in 21 repos, Sep 10 to Oct 9 2026, pulled Oct 18 2026.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The surprise is the top bar. &lt;strong&gt;34%&lt;/strong&gt; of all the Mac time went to capturing App Store screenshots in the Simulator, more than signing, uploading and submitting combined (&lt;strong&gt;16%&lt;/strong&gt;). The unit tests of the first three apps (the games, the only ones with an XCTest suite in the release lane) took another quarter. The step everyone fears, code signing, was a rounding error once it worked: importing the certificate took a median of &lt;strong&gt;1 second&lt;/strong&gt;. Finding a simulator took &lt;strong&gt;7%&lt;/strong&gt;, which is the cost of not hardcoding one, and worth it.&lt;/p&gt;

&lt;p&gt;The pipeline changed shape halfway through September, and the data shows exactly what that bought. The early apps captured fresh screenshots (and, for the three games, ran their tests) on every App Store run. Starting with the September 18 batch, most apps committed their screenshots to the repo once, and the App Store run became compile, sign and upload:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7gr5dligcsp6wmdku0br.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7gr5dligcsp6wmdku0br.png" alt="Two bars. Successful App Store runs that captured screenshots in CI: 44 runs, median 12.0 minutes. Runs that used screenshots committed to git: 37 runs, median 4.1 minutes." width="800" height="450"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Median job time of a successful App Store run by where its screenshots came from. GitHub Actions jobs API, pulled Oct 18 2026.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The median successful run dropped from &lt;strong&gt;12.0 minutes&lt;/strong&gt; (44 runs) to &lt;strong&gt;4.1 minutes&lt;/strong&gt; (37 runs), and the slowest 10% went from &lt;strong&gt;17.8&lt;/strong&gt; to &lt;strong&gt;5.3 minutes&lt;/strong&gt;. In fairness to the slow lane, it also ran the unit tests, so screenshots are not the whole difference. The point stands: a release should ship what a human already looked at, not re-shoot it. Tests and screenshots belong in the Build workflow that runs on every push, not in the one that talks to Apple.&lt;/p&gt;

&lt;p&gt;The queue was never the problem. The median wait from "run requested" to "job started" was &lt;strong&gt;10 seconds&lt;/strong&gt;; the worst one was 17 minutes.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1u6e5ir0iupbnyxkkxgj.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1u6e5ir0iupbnyxkkxgj.png" alt="Screenshot of the GitHub Actions job for Chain 1.2: Select Xcode 26 4s, install xcodegen and fastlane 7s, pick a simulator 25s, compile 52s, write the signing key 0s, import the signing certificate 1s, build, upload and submit 2m 31s. Job succeeded in 4m 9s." width="800" height="559"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;A release in the fast lane: Chain 1.2, Oct 8 2026, 4m 9s of job time. This was a dry_run, so it stopped after the upload.&lt;/em&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  Step 4: signing without a Mac (the part that broke)
&lt;/h2&gt;

&lt;p&gt;Code signing is how Apple knows a build came from you. It takes a certificate (a public key Apple has signed), its private key, and a provisioning profile that ties both to your app ID. On a Mac, Xcode hides all of it. On a CI runner it's where the first app spent a morning. The full story is in &lt;a href="https://tensorust.com/articles/build-ios-app-on-windows/" rel="noopener noreferrer"&gt;Build an iOS app on Windows&lt;/a&gt;; here is the short version and the code that matters.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fh4impp4tu7vi4y0tmdeg.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fh4impp4tu7vi4y0tmdeg.png" alt="Screenshot of Glyphstorm's App Store workflow runs on GitHub Actions, Sep 10 2026: run 10 green at 5:49 AM, run 11 and runs 13 to 19 failed, run 12 cancelled, run 20 green at 6:38 AM EDT." width="800" height="743"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Glyphstorm's App Store runs on Sep 10 2026, 5:49 to 6:38 AM ET, the morning signing broke (github.com/Mattbusel/glyphstorm, captured Oct 16).&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The expensive mistake was letting CI create the certificate. When fastlane's certificate step runs on a runner, the private key is born on a machine that is deleted minutes later. Apple keeps a certificate nobody can ever use again, and Apple limits how many distribution certificates an account can hold. Three failed runs orphaned three of them on September 10 before anyone noticed.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Never let CI create a certificate. Make the key once on your own machine, and let CI only import it.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;So the key is made on Windows, by a Python script that generates it with the &lt;code&gt;cryptography&lt;/code&gt; library, asks Apple's API to sign it, and pipes a &lt;code&gt;.p12&lt;/code&gt; straight into &lt;code&gt;gh secret set&lt;/code&gt;. Every API call uses a short-lived token signed with your App Store Connect key (&lt;code&gt;Store/asc.py&lt;/code&gt;, the token function; the key ID and issuer ID come from environment variables):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;def token() -&amp;gt; str:
    """A fresh twenty minute ES256 token.

    Apple rejects anything longer lived than that, so this is minted per run
    rather than cached anywhere.
    """
    private_key = KEY_PATH.read_text()
    now = int(time.time())
    payload = {
        "iss": ISSUER_ID,
        "iat": now - 60,
        "exp": now + 20 * 60,
        "aud": "appstoreconnect-v1",
    }
    return jwt.encode(
        payload, private_key, algorithm="ES256", headers={"kid": KEY_ID, "typ": "JWT"}
    )
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That twenty minute limit is &lt;a href="https://developer.apple.com/documentation/appstoreconnectapi/generating-tokens-for-api-requests" rel="noopener noreferrer"&gt;Apple's rule&lt;/a&gt;, not a choice. On the runner, the certificate goes into a throwaway keychain, and one line in this step decides whether the job finishes or hangs forever:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;          keychain="$RUNNER_TEMP/signing.keychain-db"
          security create-keychain -p "" "$keychain"
          security set-keychain-settings -lut 21600 "$keychain"
          security unlock-keychain -p "" "$keychain"
          echo "$DIST_CERT_P12" | base64 --decode &amp;gt; "$RUNNER_TEMP/dist.p12"
          security import "$RUNNER_TEMP/dist.p12" -k "$keychain" \
            -P "$DIST_CERT_PASSWORD" -T /usr/bin/codesign -T /usr/bin/security
          # Without the partition list, codesign blocks on a GUI prompt that
          # nobody can answer and the job hangs until it times out.
          security set-key-partition-list -S apple-tool:,apple:,codesign: \
            -s -k "" "$keychain" &amp;gt;/dev/null
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then fastlane asks for the App Store profile directly and signs manually. Automatic signing resolves a development profile during the archive, and a development profile needs at least one registered iPhone. An account with no devices gets "Your team has no devices from which to generate a provisioning profile" on an App Store build, which needs no device at all. Asking for the distribution profile directly skips the step that can't succeed.&lt;/p&gt;

&lt;p&gt;fastlane reads the API key from the secret, so there is no Apple ID password and no two-factor prompt anywhere (&lt;code&gt;fastlane/Fastfile&lt;/code&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  def asc_key
    app_store_connect_api_key(
      key_id: ENV.fetch("ASC_KEY_ID"),
      issuer_id: ENV.fetch("ASC_ISSUER_ID"),
      key_content: ENV.fetch("ASC_KEY_CONTENT"),
      is_key_content_base64: true,
      in_house: false
    )
  end
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;a href="https://docs.fastlane.tools/actions/app_store_connect_api_key/" rel="noopener noreferrer"&gt;app_store_connect_api_key&lt;/a&gt; action is the one to read. &lt;code&gt;in_house: false&lt;/code&gt; tells it this is a normal developer account, not an enterprise one; getting that wrong produces an authentication error that says nothing useful. Build numbers are the other trap (Apple refuses a repeated one), so each build asks App Store Connect for the latest and adds one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5: screenshots and the listing, all from Windows
&lt;/h2&gt;

&lt;p&gt;Apple wants iPhone screenshots at the 6.9 inch size, 1320 by 2868 pixels (see the &lt;a href="https://developer.apple.com/help/app-store-connect/reference/screenshot-specifications" rel="noopener noreferrer"&gt;screenshot specifications&lt;/a&gt;). Without a Mac you can't click around a Simulator, so each app takes a launch argument that opens a given screen with demo data, and the Build workflow loops through them on a fresh simulator with the status bar set to the classic 9:41 and a full battery. A fresh simulator matters: booting the runner's default one hung for over 15 minutes once.&lt;/p&gt;

&lt;p&gt;The raw PNGs come back as a build artifact, a script on Windows puts a headline on each one, and the finished set is committed to &lt;code&gt;fastlane/screenshots/&lt;/code&gt;. Chain's in-app purchase code even knows about it: in demo mode the store screenshots show the whole app unlocked, and the paywall shot shows it locked.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fcky326dwz499zohjc6yz.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fcky326dwz499zohjc6yz.png" alt="Four of Chain's App Store screenshots: Five seconds a day, Watch your year fill in, Missed a day? Save the chain, and Know your best days, each showing the dark app UI with lime green habit squares." width="799" height="501"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Four of Chain's ten App Store screenshots: raw captures from the CI simulator, headlines added by a script on Windows.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The listing text lives in plain files (&lt;code&gt;fastlane/metadata/en-US/name.txt&lt;/code&gt;, &lt;code&gt;subtitle.txt&lt;/code&gt;, &lt;code&gt;description.txt&lt;/code&gt;, &lt;code&gt;keywords.txt&lt;/code&gt; and so on), so it's reviewed and versioned like code. A second script pushes the rest of the listing through the API. Its header says what it does and the one thing Apple won't let it do (&lt;code&gt;Store/listing.py&lt;/code&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;"""The rest of the listing: age rating, review details, screenshots.

Everything here needs the app record to already exist, which is the one
step Apple's API refuses to do (`POST /v1/apps` returns 403 by design, so an app
record has to be created once in the web UI).

Usage:
    python Store/listing.py age-rating
    python Store/listing.py review-details
    python Store/listing.py screenshots
    python Store/listing.py finish        # everything that needs no binary
"""
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Write the review notes like a test script for a stranger. Chain's notes tell the reviewer exactly how to reach each paid feature on a fresh install ("add three habits, then tap New habit again"), what the Health permission reads and why, and that no login is needed. Apple's 2.1 "Information Needed" responses were the second most common reason in my data, and most of them were answered by notes like these plus a video.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 6: testing when you can't hold the iPhone
&lt;/h2&gt;

&lt;p&gt;You can't plug a phone into the CI runner, so testing happens in three layers.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;A build on every push.&lt;/strong&gt; The Build workflow compiles for the Simulator and walks every screen to capture screenshots, so a screen that crashes fails the push. Across the 21 repos it ran 165 times: 101 green, 19 red, 45 cancelled. Only the first three apps also run an XCTest suite in CI. That's thinner than it should be; if I started again, the plain-Swift logic would get tests on every push.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A recorded walkthrough.&lt;/strong&gt; 20 of the 21 apps have a &lt;code&gt;-demoAutoplay&lt;/code&gt; launch argument that clicks through the main flow by itself. A third workflow records it with &lt;code&gt;simctl&lt;/code&gt;, and the video goes to App Review when they ask how something works. It ran 18 times, 18 green.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;TestFlight.&lt;/strong&gt; Every &lt;code&gt;dry_run&lt;/code&gt; upload lands in &lt;a href="https://developer.apple.com/testflight/" rel="noopener noreferrer"&gt;TestFlight&lt;/a&gt;, so anyone with an iPhone can install the real signed build before it is submitted.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The recording step is short enough to copy (&lt;code&gt;.github/workflows/review-video.yml&lt;/code&gt;, trimmed):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;          rt=$(xcrun simctl list runtimes | grep -oE "com.apple.CoreSimulator.SimRuntime.iOS-[0-9-]+" | sort -V | tail -1)
          udid=$(xcrun simctl create review "com.apple.CoreSimulator.SimDeviceType.iPhone-16-Pro-Max" "$rt")
          xcrun simctl boot "$udid" || true
          xcrun simctl status_bar "$udid" override --time "9:41" --batteryState charged --batteryLevel 100 || true
          xcrun simctl install "$udid" "$app"

          xcrun simctl io "$udid" recordVideo --codec h264 --force capture/review.mp4 &amp;amp;
          rec=$!
          xcrun simctl launch "$udid" com.mattbusel.chainhabits -demoAutoplay
          data=$(xcrun simctl get_app_container "$udid" com.mattbusel.chainhabits data)
          for i in $(seq 1 600); do
            if [ -f "$data/Documents/demo_done" ]; then break; fi
            sleep 0.5
          done
          kill -INT $rec
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The trick is the &lt;code&gt;demo_done&lt;/code&gt; file: the app writes it when the walkthrough ends, so the recording stops at the right frame instead of after a guessed sleep. A recording run averaged about &lt;strong&gt;8 minutes&lt;/strong&gt; (148 minutes over 18 runs), which is why it lives in its own workflow and only runs when a review needs one.&lt;/p&gt;

&lt;p&gt;A word on what this doesn't catch. The Simulator is not an iPhone: no real camera, no real motion sensors, no real HealthKit data. Minder's eyes react to the phone being picked up (Core Motion), and no Simulator run can exercise that; it needs a real device through TestFlight. If your app leans on hardware, budget for a cheap used iPhone or a friend with one. My bet is that this is the one place a Windows-only setup genuinely costs you something.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 7: in-app purchases with StoreKit 2
&lt;/h2&gt;

&lt;p&gt;All 8 live apps are free with optional one-time purchases. The purchase code is plain StoreKit 2 and compiles on the runner like anything else. The part worth copying is how Chain decides what the user owns (&lt;code&gt;Sources/Purchases.swift&lt;/code&gt;, trimmed):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;    func refreshEntitlement() async {
        var has: Set&amp;lt;String&amp;gt; = []
        for await result in Transaction.currentEntitlements {
            if case .verified(let t) = result, t.revocationDate == nil, t.productType == .nonConsumable { has.insert(t.productID) }
        }
        // currentEntitlements works offline from StoreKit's own cache, so this is the truth,
        // including a refund taking an unlock away.
        owned = has
        publish()
    }
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://developer.apple.com/documentation/storekit/transaction/currententitlements" rel="noopener noreferrer"&gt;&lt;code&gt;Transaction.currentEntitlements&lt;/code&gt;&lt;/a&gt; is the source of truth, refunds included, so there is no server and no receipt parsing. When Chain went from paid to free, people who had paid were grandfathered by reading &lt;a href="https://developer.apple.com/documentation/storekit/apptransaction" rel="noopener noreferrer"&gt;&lt;code&gt;AppTransaction&lt;/code&gt;&lt;/a&gt;, which says which build a person first downloaded:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;    func checkPaidEra() async {
        guard !grandfathered, let result = try? await AppTransaction.shared,
              case .verified(let app) = result, app.environment == .production else { return }
        let build = Int(app.originalAppVersion.split(separator: ".").first ?? "") ?? Int.max
        if build &amp;lt; Purchases.firstFreeBuild &amp;amp;&amp;amp; app.originalPurchaseDate &amp;lt; Purchases.paidCutoff {
            grandfathered = true
            UserDefaults.standard.set(true, forKey: "chain.grandfathered")
            publish()
        }
    }
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;app.environment == .production&lt;/code&gt; guard matters: in the sandbox and in App Review, everyone looks like an original buyer, and the reviewer has to see the real paywall.&lt;/p&gt;

&lt;p&gt;The purchases themselves are created from Windows through the API, with one exception that cost an evening. From the header of &lt;code&gt;Store/iap.py&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Apple's API refuses the FIRST non-consumable of an app (STATE_ERROR.FIRST_NON_CONSUMABLE_MUST_BE_SUBMITTED_ON_VERSION),
even with the version already in an open review submission. For that one, attach it by hand on the version page
(In-App Purchases and Subscriptions, Select, tick it, Done), then Add for Review and Submit. Later purchases
can go through `submit`.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So the first purchase rides along with an app version, by hand, once. After that, purchases review fast: the 5 purchase-only submissions in my data were decided in a median of &lt;strong&gt;12.2 hours&lt;/strong&gt;, all approved. That's the quickest thing Apple reviews.&lt;/p&gt;

&lt;h2&gt;
  
  
  The errors you will see, and what each one really means
&lt;/h2&gt;

&lt;p&gt;Apple's error messages are written for someone sitting at Xcode. On a CI runner they point the wrong way more often than not. These are the real ones from the 21 repos' logs and workflow comments, in roughly the order a new pipeline meets them.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;The message&lt;/th&gt;
&lt;th&gt;What it really means&lt;/th&gt;
&lt;th&gt;The fix&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Selecting &lt;code&gt;/Applications/Xcode_26.app&lt;/code&gt; fails, though &lt;code&gt;ls&lt;/code&gt; lists it&lt;/td&gt;
&lt;td&gt;The runner image has a stub &lt;code&gt;Xcode_26.app&lt;/code&gt; with no toolchain inside&lt;/td&gt;
&lt;td&gt;Pick the newest Xcode 26 that has &lt;code&gt;Contents/Developer&lt;/code&gt; (snippet above)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;"Unable to find a device matching the provided destination specifier"&lt;/td&gt;
&lt;td&gt;Not your project. This runner doesn't carry that simulator today&lt;/td&gt;
&lt;td&gt;Choose the newest available iPhone at runtime&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;"Your team has no devices from which to generate a provisioning profile"&lt;/td&gt;
&lt;td&gt;Automatic signing wants a development profile, which needs a registered iPhone, even for an App Store build&lt;/td&gt;
&lt;td&gt;Manual signing with an App Store profile&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;"Could not find the newly generated certificate installed"&lt;/td&gt;
&lt;td&gt;CI just created a certificate whose private key dies with the runner&lt;/td&gt;
&lt;td&gt;Make the certificate once on your PC; revoke the orphans&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;"MAC verification failed during PKCS12 import (wrong password?)"&lt;/td&gt;
&lt;td&gt;The password is fine. The &lt;code&gt;.p12&lt;/code&gt; uses an encryption format the macOS &lt;code&gt;security&lt;/code&gt; tool won't read&lt;/td&gt;
&lt;td&gt;Export the &lt;code&gt;.p12&lt;/code&gt; in the legacy format&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A signing step that never ends&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;codesign&lt;/code&gt; is waiting on a keychain permission dialog nobody can click&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;security set-key-partition-list&lt;/code&gt; after the import&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;"You can't enable both :development and :adhoc"&lt;/td&gt;
&lt;td&gt;fastlane counts a flag that is present as chosen, even when set to false&lt;/td&gt;
&lt;td&gt;Leave those arguments out entirely&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Cannot find "MyApp.xcodeproj/../YES"&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;increment_build_number&lt;/code&gt; drives Apple's &lt;code&gt;agvtool&lt;/code&gt;, which gets confused by a generated project&lt;/td&gt;
&lt;td&gt;Pass &lt;code&gt;CURRENT_PROJECT_VERSION&lt;/code&gt; as a build setting: latest TestFlight build + 1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Simulator boot hangs for 15+ minutes&lt;/td&gt;
&lt;td&gt;The runner's default simulator is in a bad state&lt;/td&gt;
&lt;td&gt;Create a fresh device with &lt;code&gt;simctl create&lt;/code&gt; per job&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;403&lt;/code&gt; on &lt;code&gt;POST /v1/apps&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;The API does not create app records&lt;/td&gt;
&lt;td&gt;Make the record once in the website; script everything else&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;STATE_ERROR.FIRST_NON_CONSUMABLE_MUST_BE_SUBMITTED_ON_VERSION&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;An app's first one-time purchase has to ride along with a version&lt;/td&gt;
&lt;td&gt;Attach it by hand on the version page once; later purchases go through the API&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Eleven messages, and every one of them cost at least one run before it was understood, most of them in the first two days. If you hit one that isn't on this list, the jobs API trick from the data section tells you exactly which step it came from and how long it ran before it died, which is usually enough to tell a configuration problem (fails in seconds) from a hang (fails at the timeout).&lt;/p&gt;

&lt;h2&gt;
  
  
  The learning curve: 13 failures, then zero
&lt;/h2&gt;

&lt;p&gt;The pipeline wasn't good on day one. The first two apps, Glyphstorm and Clear the Strait, ate &lt;strong&gt;13 failed App Store runs each&lt;/strong&gt;, most of them on September 10. Pocket Beings, a day later, had 5. By the September 24 batch it was mostly zero, and the last ten apps shipped with no failed App Store run at all. Climbbook, Cutoff, Parked and Race Pace each went to App Store Connect on their first try on September 27.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Flprfmpe86k4wbecybxvg.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Flprfmpe86k4wbecybxvg.png" alt="Bar chart of failed App Store workflow runs per app in launch order: Glyphstorm 13, Clear the Strait 13, Pocket Beings 5, Minder 3, Fairway Ledger 2, Baseline Ledger 2, Ironbook 2, Race Fuel 1, Chain 1, Quiver 0, Rooms 1, then Clockout, Odometer, Curve, Pricebook, Pawprint, Chores, Climbbook, Cutoff, Parked and Race Pace all 0." width="800" height="787"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Failed App Store workflow runs per app, apps in order of their first run. GitHub Actions API, pulled Oct 16 2026 (the Oct 18 pull is the same, 43 failures in all).&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Nothing clever happened in between. Every failure became a comment in the workflow file, and the next app started from that file. That's the whole method, and it's why the data matters more than the code: the failures are concentrated in the first two days, so if you copy a pipeline that has already eaten them, you skip that part. Since the start, 139 App Store runs: &lt;strong&gt;81 succeeded, 43 failed, 15 cancelled&lt;/strong&gt;. The last two failures were version bumps on the evening of October 8 (Eastern); Pocket Beings went green two minutes after its failure.&lt;/p&gt;

&lt;p&gt;A practical warning if a coding agent drives this loop for you: an agent fixing signing at 6 AM will happily "fix" the check instead of the problem. The guardrails I use are in &lt;a href="https://tensorust.com/articles/stop-coding-agents-editing-tests/" rel="noopener noreferrer"&gt;stop coding agents editing tests&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  App Review is the real bottleneck
&lt;/h2&gt;

&lt;p&gt;Here's what the timeline at the top of this page shows when you look at it as a funnel. Every one of the 21 apps got a green, signed build and went to App Review. Thirteen are still not on the store.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzn3fnqhvvvf6i26xskp0.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzn3fnqhvvvf6i26xskp0.png" alt="Funnel of 21 iPhone apps: 21 app repos on GitHub written on a Windows PC, 21 with a green App Store build in CI, 21 submitted to App Review with 64 decisions, 8 approved with 0 of 21 on the first try, 8 live on the US App Store." width="800" height="467"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Every app built Sep 10 to Sep 27 2026, status on Oct 18 2026. GitHub API, App Review emails, iTunes lookup.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The build half is fast and boring now. The first commit to first submission took a median of &lt;strong&gt;2.2 hours&lt;/strong&gt;, and 13 of the 21 apps were submitted within 3 hours of their first commit (the others waited on purpose, for batch submissions). Then Apple's half starts, and it is measured in days.&lt;/p&gt;

&lt;p&gt;From the &lt;a href="https://tensorust.com/articles/app-store-review-time/" rel="noopener noreferrer"&gt;App Store review time&lt;/a&gt; data: 64 decisions, median wait &lt;strong&gt;73 hours&lt;/strong&gt;, against Apple's &lt;a href="https://developer.apple.com/distribute/app-review/" rel="noopener noreferrer"&gt;stated&lt;/a&gt; 50% within 24 hours and 90% within 48. Updates to apps already live took a median of 64 hours. New apps that got approved took a median of 70 hours on the round that approved them, and none of the 21 was approved on its first submission.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxyto0h8xnqoz39nnrh4y.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxyto0h8xnqoz39nnrh4y.png" alt="Dot plot of 64 App Review decisions by hours from submission to decision email. In-app purchases only: 5 decisions, median 12 hours, all approved. App updates: 16, median 64 hours. New apps: 21, median 51 hours, a mix of fast rejections under a day and approvals at 2 to 5 days. New apps in the two guideline 4.3 spam batches: 22, median 188 hours, all rejected at 7 to 9.3 days. Lines mark Apple's stated 24 and 48 hour targets." width="800" height="507"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Every App Review decision for the 21 apps, Sep 10 to Oct 12 2026, from Apple's own emails (pulled Oct 17 2026; no new decisions as of Oct 18).&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Plan for it like this: the first submission of a new app on a new account will probably come back with a question (guideline 2.1, "Information Needed"). Answer it with a short screen recording and clear review notes, and the second round is where approval usually lands. For the 8 apps that made it, first submission to approval took a median of &lt;strong&gt;4.4 days&lt;/strong&gt;, and first commit to approval &lt;strong&gt;5.3 days&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Guideline 4.3: what happens when shipping gets too cheap
&lt;/h2&gt;

&lt;p&gt;The 13 apps that aren't live are mostly one story. On October 5, Apple rejected 11 apps in 30 seconds under &lt;a href="https://developer.apple.com/app-store/review/guidelines/" rel="noopener noreferrer"&gt;guideline 4.3 (spam)&lt;/a&gt; and 4.2.6, after they had waited 8.7 to 9.3 days. I replied to each and resubmitted; on October 12, all 11 came back rejected again, in another 30 second burst, exactly 7 days later.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fe8zixpb2hy4shbzqojh1.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fe8zixpb2hy4shbzqojh1.png" alt="Two columns of email timestamps. Monday Oct 5 2026: 11 rejection emails from 5:29:32 to 5:30:02 PM Eastern, each app having waited 8.7 to 9.3 days. Monday Oct 12: 11 more from 6:04:46 to 6:05:13 PM, each exactly 7.0 days after resubmission." width="800" height="467"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;The two guideline 4.3 batches as they landed in the inbox, Eastern time.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Look at which apps those are: the September 24 to 27 batches, the ones where the pipeline had zero failed runs. That's the plot twist of the whole project. Once shipping an app costs nothing, the question Apple asks changes from "does it work" to "is this different enough from everything else this account and the store already have". Guideline 4.3 is written for that: it covers near-copies of the same app and piling into categories that are already saturated.&lt;/p&gt;

&lt;p&gt;My read, which I can't prove: a new account sending eleven utility apps in three days looks like a template farm to a reviewer, whatever is inside each binary. If I did it again, I'd ship two or three apps, let them pick up real users, and space the rest out. The pipeline makes speed free. App Review charges for it anyway.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it costs
&lt;/h2&gt;

&lt;p&gt;The full breakdown, with a calculator, is in &lt;a href="https://tensorust.com/articles/iphone-app-development-cost/" rel="noopener noreferrer"&gt;iPhone app development cost&lt;/a&gt;. The short version for the Windows route:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Item&lt;/th&gt;
&lt;th&gt;What I paid&lt;/th&gt;
&lt;th&gt;If the repos were private&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Apple Developer Program&lt;/td&gt;
&lt;td&gt;$99 a year&lt;/td&gt;
&lt;td&gt;$99 a year&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A Mac&lt;/td&gt;
&lt;td&gt;$0&lt;/td&gt;
&lt;td&gt;$0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CI: 322 macOS runs, about 2,572 minutes&lt;/td&gt;
&lt;td&gt;$0 (public repos)&lt;/td&gt;
&lt;td&gt;about $159 at $0.062 a minute&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;One release today (4.1 min median)&lt;/td&gt;
&lt;td&gt;$0&lt;/td&gt;
&lt;td&gt;about 25 cents&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Apple's cut of sales&lt;/td&gt;
&lt;td&gt;15% under the &lt;a href="https://developer.apple.com/app-store/small-business-program/" rel="noopener noreferrer"&gt;Small Business Program&lt;/a&gt;
&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbzroepbw8dyhklwffli7.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbzroepbw8dyhklwffli7.gif" alt="Animated list of what 20 iPhone apps cost, one line at a time: Apple Developer Program 99 dollars a year, Mac 0 dollars via GitHub Actions, CI 0 dollars for 2,416 minutes and 333 runs (149.82 dollars if private), Swift 56,030 lines in 69.8 commit-hours by coding agents, store fee 15 percent, quote for the same apps 30.5 thousand dollars and up each, apps Apple approved 8 of 20." width="799" height="450"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Every cost line for 20 of the apps, from the iPhone app development cost article (git, GitHub Actions API, iTunes lookup, pulled Oct 17 2026 at 1:12 PM ET; line counts from the local working copies).&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The cost that doesn't show up in that table is the one in the funnel: an approved listing. Money can't buy you past guideline 4.3, only a different-enough product can.&lt;/p&gt;

&lt;h2&gt;
  
  
  What came out the other end
&lt;/h2&gt;

&lt;p&gt;Eight apps on the US App Store as of this afternoon, all free with optional purchases. Every one of them is under 3 MB, because SwiftUI apps with no third-party SDKs are small: Clear the Strait, a game, is 0.6 MB. Chain, with widgets, alternate icons and eight in-app purchases, is 2.9 MB.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvoqhfvzqv8jehntc9fmu.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvoqhfvzqv8jehntc9fmu.png" alt="Table of the 8 live apps: Clear the Strait live since Sep 16, version 1.1.1, 0.6 MB, 3,557 Swift lines, 6.1 days from first commit to approval; Pocket Beings Sep 20, 1.1, 1.0 MB, 2,089 lines, 9.4 days; Fairway Ledger Sep 22, 1.1, 2.0 MB, 4,082 lines, 4.3 days; Baseline Ledger Sep 24, 1.1, 2.0 MB, 2,861 lines, 6.2 days; Ironbook Sep 24, 1.1, 1.2 MB, 2,969 lines, 3.8 days; Quiver Sep 24, 1.1, 1.2 MB, 2,153 lines, 3.8 days; Minder Sep 25, 1.2, 2.4 MB, 3,379 lines, 7.3 days; Chain Sep 25, 1.2, 2.9 MB, 2,967 lines, 4.5 days." width="799" height="453"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;iTunes lookup API (US) for version, release date and file size; non-blank Swift lines in each public GitHub repo; approval times from App Review emails. Oct 18 2026.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;None of these were written in Xcode, and none of them know it. If you're curious what a SwiftUI app built this way looks like from the inside, &lt;a href="https://tensorust.com/articles/swiftui-spring-animation-cartoon-eyes/" rel="noopener noreferrer"&gt;Minder's cartoon eyes&lt;/a&gt; are a good example: springs, saccades and blinks in a plain SwiftUI Canvas, compiled on a machine nobody ever saw.&lt;/p&gt;

&lt;h2&gt;
  
  
  A 7-day plan to ship your first iOS app from Windows
&lt;/h2&gt;

&lt;p&gt;This is the order that would have saved the most runs, with the numbers above as a guide to how long each part really takes.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Day 1: accounts.&lt;/strong&gt; Join the &lt;a href="https://developer.apple.com/programs/" rel="noopener noreferrer"&gt;Apple Developer Program&lt;/a&gt;. Create an App Store Connect API key under Users and Access, Integrations, and store the &lt;code&gt;.p8&lt;/code&gt;, key ID and issuer ID as GitHub secrets. Never commit them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Day 2: the empty app.&lt;/strong&gt; A public GitHub repo with &lt;code&gt;project.yml&lt;/code&gt;, a "Hello" SwiftUI view, and the App Store workflow in &lt;code&gt;compile&lt;/code&gt; mode. Get one green run. Expect the Xcode and simulator traps; use the snippets above.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Day 2: signing.&lt;/strong&gt; Make the certificate on Windows through the API, save it as a &lt;code&gt;.p12&lt;/code&gt; in a secret, and import it on the runner with the partition list line. Run &lt;code&gt;dry_run&lt;/code&gt; until a build appears in TestFlight. If CI ever made certificates for you, revoke the orphans.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Days 3 to 5: the app.&lt;/strong&gt; Write it. Keep logic in plain Swift files and views thin. Add a &lt;code&gt;-shot&lt;/code&gt; launch argument per screen and a &lt;code&gt;-demoAutoplay&lt;/code&gt; walkthrough as you go, not at the end.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Day 5: the listing.&lt;/strong&gt; Create the app record in the website (the one manual form), then push name, subtitle, description, keywords, age rating, privacy URL and review notes from the metadata files.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Day 6: screenshots.&lt;/strong&gt; Capture them once in the Build workflow, add headlines on Windows, commit them. Never re-capture in the release lane: that's the 12 minute vs 4 minute difference.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Day 6: submit.&lt;/strong&gt; &lt;code&gt;release&lt;/code&gt; mode. Then wait about three days, answer the first question with a video, and expect approval on round two.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If you want the files rather than the instructions, the whole thing (the workflow with every trap commented, the Fastfile, the signing script, the App Store Connect scripts and an agent skill that walks through it) is what the No-Mac iOS Pipeline setup installs on your repo.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ: iOS development on Windows
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Can I install Xcode on Windows?
&lt;/h3&gt;

&lt;p&gt;No. &lt;a href="https://developer.apple.com/xcode/" rel="noopener noreferrer"&gt;Xcode&lt;/a&gt; is macOS only, and there is no Windows version. What you can do is run Xcode's command-line tools on a rented Mac (a CI runner) and drive it from Windows, which is what this whole guide does.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can I run the iOS Simulator on Windows?
&lt;/h3&gt;

&lt;p&gt;Not locally. The Simulator ships with Xcode. On the runner it works fine without a screen: &lt;code&gt;simctl&lt;/code&gt; boots devices, installs builds, takes screenshots and records video, as in the review-video step above. You see the results as files, not as a live window.&lt;/p&gt;

&lt;h3&gt;
  
  
  Do I need an iPhone?
&lt;/h3&gt;

&lt;p&gt;Not to build, sign, upload or get approved: none of the 21 apps needed a registered device, once signing was manual. You do want one for anything that touches real hardware (camera, motion, HealthKit, haptics), through TestFlight.&lt;/p&gt;

&lt;h3&gt;
  
  
  What about Flutter or React Native?
&lt;/h3&gt;

&lt;p&gt;Both still need macOS to build the iOS app. Flutter's iOS setup starts with installing Xcode. Expo's EAS Build is the exception that proves the rule: it runs the build on Expo's own Mac fleet. Either way, someone's Mac compiles it; the question is only whose.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is running macOS in a virtual machine on my PC allowed?
&lt;/h3&gt;

&lt;p&gt;Apple's &lt;a href="https://www.apple.com/legal/sla/" rel="noopener noreferrer"&gt;software license&lt;/a&gt; limits macOS to Apple-branded hardware. I didn't go that way, and with free macOS runners for public repos there's little reason to.&lt;/p&gt;

&lt;h3&gt;
  
  
  How long does it take, start to finish?
&lt;/h3&gt;

&lt;p&gt;In my data, the build side takes hours (median 2.2 hours from first commit to first submission) and the review side takes days (median 4.4 days from first submission to approval for the apps that got approved). A realistic first app is about a week, most of it waiting.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does this work for iPad, widgets and in-app purchases?
&lt;/h3&gt;

&lt;p&gt;Yes for widgets and purchases: Chain has a WidgetKit extension and eight purchases, all built the same way. The 21 apps are iPhone only (&lt;code&gt;TARGETED_DEVICE_FAMILY: "1"&lt;/code&gt;), so iPad is untested here, but it's the same toolchain.&lt;/p&gt;

&lt;h2&gt;
  
  
  The numbers
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What&lt;/th&gt;
&lt;th&gt;Number&lt;/th&gt;
&lt;th&gt;Source, pulled Oct 18 2026&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Apps built and submitted from Windows&lt;/td&gt;
&lt;td&gt;21&lt;/td&gt;
&lt;td&gt;GitHub repos, App Review emails&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Live on the US App Store&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;iTunes lookup API&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Swift, all apps (non-blank lines)&lt;/td&gt;
&lt;td&gt;53,932 (median 2,308)&lt;/td&gt;
&lt;td&gt;shallow clones&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CI runs, all workflows&lt;/td&gt;
&lt;td&gt;384&lt;/td&gt;
&lt;td&gt;GitHub Actions API&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;App Store runs: ok / failed / cancelled&lt;/td&gt;
&lt;td&gt;81 / 43 / 15&lt;/td&gt;
&lt;td&gt;GitHub Actions API&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Median successful release run, then vs now&lt;/td&gt;
&lt;td&gt;12.0 min to 4.1 min&lt;/td&gt;
&lt;td&gt;jobs API, 81 runs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Share of Mac time spent on screenshots&lt;/td&gt;
&lt;td&gt;34%&lt;/td&gt;
&lt;td&gt;jobs API, step timings&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Median first commit to first submission&lt;/td&gt;
&lt;td&gt;2.2 hours&lt;/td&gt;
&lt;td&gt;git + App Review emails&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Median first submission to approval&lt;/td&gt;
&lt;td&gt;4.4 days (8 apps)&lt;/td&gt;
&lt;td&gt;App Review emails&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Approved on the first try&lt;/td&gt;
&lt;td&gt;0 of 21&lt;/td&gt;
&lt;td&gt;App Review emails&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Rejected under 4.3 (twice)&lt;/td&gt;
&lt;td&gt;11&lt;/td&gt;
&lt;td&gt;App Review emails&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  What's next
&lt;/h2&gt;

&lt;p&gt;The 11 apps stuck on guideline 4.3 are the open problem, and they're a product problem, not a pipeline one. The next experiment is the opposite of the September sprint: fewer apps, each with something no other app in its category does, spaced out over weeks. I'll add the result here.&lt;/p&gt;

&lt;p&gt;The question I'd like answered in the comments: if you ship iOS apps without a Mac, what was the one step you were sure needed one? Mine was signing, and it turned out to be the fastest step in the pipeline.&lt;/p&gt;

&lt;p&gt;If you'd rather not spend your own September 10 on the signing traps, the &lt;a href="https://tensorust.com/go/ios-setup/ios-development-on-windows?utm_source=devto&amp;amp;utm_campaign=ios-development-on-windows" rel="noopener noreferrer"&gt;No-Mac iOS Pipeline&lt;/a&gt; puts this exact setup on your repo, and if you want the app itself built, the &lt;a href="https://tensorust.com/build/?utm_source=devto&amp;amp;utm_campaign=ios-development-on-windows" rel="noopener noreferrer"&gt;fixed prices are here&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>ios</category>
      <category>swift</category>
      <category>mobile</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>MacOS Spotlight Alternative</title>
      <dc:creator>ugurgunes95</dc:creator>
      <pubDate>Sun, 18 Oct 2026 20:05:23 +0000</pubDate>
      <link>https://dev.to/ugurgunes/macos-spotlight-alternative-27l1</link>
      <guid>https://dev.to/ugurgunes/macos-spotlight-alternative-27l1</guid>
      <description>&lt;p&gt;I built my own Spotlight alternative without knowing Swift. There were a few things about Spotlight that bothered me, along with some macOS utilities I wished I had in one place. So I decided to try something different. Using AI and a little vibe coding, I built&amp;nbsp;Lilith&amp;nbsp;— an open-source macOS launcher tailored to my own needs. I described what I wanted, tried the results, gave feedback, and kept iterating until I had something I was happy with. I didn't write the Swift code myself, but it was interesting to see how far I could take an idea simply by knowing what I wanted to build.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/ugurgunes95/Lilith" rel="noopener noreferrer"&gt;Github&lt;/a&gt;&lt;/p&gt;

</description>
      <category>swift</category>
      <category>vibecoding</category>
      <category>ai</category>
      <category>opensource</category>
    </item>
    <item>
      <title>App Store review time: 64 real decisions, median 73 hours</title>
      <dc:creator>Matt Busel</dc:creator>
      <pubDate>Sun, 18 Oct 2026 14:13:03 +0000</pubDate>
      <link>https://dev.to/mattbusel/app-store-review-time-64-real-decisions-median-73-hours-4a52</link>
      <guid>https://dev.to/mattbusel/app-store-review-time-64-real-decisions-median-73-hours-4a52</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://tensorust.com/articles/app-store-review-time/" rel="noopener noreferrer"&gt;tensorust.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; I pulled every App Review decision email my developer account has ever received: &lt;strong&gt;64 decisions on 21 iPhone apps&lt;/strong&gt;, September 10 to October 12, 2026, each with Apple's own "Submitted" timestamp and the time the decision landed. The median wait was &lt;strong&gt;73 hours&lt;/strong&gt;. Apple's page says it reviews &lt;a href="https://developer.apple.com/distribute/app-review/" rel="noopener noreferrer"&gt;"at least 50% of submissions in less than 24 hours and 90% in less than 48 hours"&lt;/a&gt;. Mine: &lt;strong&gt;19% inside 24 hours, 27% inside 48&lt;/strong&gt;. Updates to apps already on the store took a median &lt;strong&gt;64 hours&lt;/strong&gt;, in-app purchases on their own took &lt;strong&gt;12&lt;/strong&gt;, and &lt;strong&gt;0 of 21 new apps were approved on their first submission&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Short answer for the search. If you are planning a launch in 2026, budget &lt;strong&gt;3 days per review round&lt;/strong&gt; for an update, &lt;strong&gt;2 to 5 days&lt;/strong&gt; for a brand new app's approval, and &lt;strong&gt;at least one extra round&lt;/strong&gt; for a new app on a new account, because the first one usually comes back with a question. A review that sits for a week is not a slow approval. In my data, every wait longer than 5.1 days except one ended in a guideline 4.3 spam rejection.&lt;/p&gt;

&lt;p&gt;If you are still at the "what will this cost me" stage, the free &lt;a href="https://tensorust.com/tools/iphone-app-cost/" rel="noopener noreferrer"&gt;iPhone app cost calculator&lt;/a&gt; turns features into hours and dollars; this article is about the part no calculator shows, the calendar time between "Submit" and "Ready for Distribution". I build these apps from a Windows PC with no Mac (&lt;a href="https://tensorust.com/articles/build-ios-app-on-windows/" rel="noopener noreferrer"&gt;here is the pipeline&lt;/a&gt;), so every submission below went out from a CI job or the App Store Connect website, never from Xcode.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F18urpoprxheitg7d3vhc.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F18urpoprxheitg7d3vhc.png" alt="Dot plot of 64 App Review decisions by hours from submission to decision email. In-app purchases only: 5 decisions, median 12 hours, all approved. App updates: 16, median 64 hours. New apps: 21, median 51 hours, a mix of fast rejections under a day and approvals at 2 to 5 days. New apps in the two guideline 4.3 spam batches: 22, median 188 hours, all rejected at 7 to 9.3 days. Lines mark Apple's stated 24 and 48 hour targets." width="800" height="507"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Every App Review decision for 21 iOS apps on one developer account, Sep 10 to Oct 12 2026, from Apple's own emails (pulled Oct 17 2026).&lt;/em&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  How long does App Store review take in 2026?
&lt;/h2&gt;

&lt;p&gt;Here are the numbers, split the way you will actually meet them. "Wait" is the time from the "Submitted" line in Apple's email to the moment the decision email arrived, so it includes the queue, not just the minutes a reviewer spent with the app.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What you submit&lt;/th&gt;
&lt;th&gt;Decisions&lt;/th&gt;
&lt;th&gt;Median wait&lt;/th&gt;
&lt;th&gt;Fastest&lt;/th&gt;
&lt;th&gt;Slowest&lt;/th&gt;
&lt;th&gt;Approved&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;In-app purchases only&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;12.2 h&lt;/td&gt;
&lt;td&gt;11.7 h&lt;/td&gt;
&lt;td&gt;13.4 h&lt;/td&gt;
&lt;td&gt;5 of 5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Update to a live app&lt;/td&gt;
&lt;td&gt;16&lt;/td&gt;
&lt;td&gt;64.4 h&lt;/td&gt;
&lt;td&gt;6.3 h&lt;/td&gt;
&lt;td&gt;203.6 h&lt;/td&gt;
&lt;td&gt;11 of 16&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;New app (normal rounds)&lt;/td&gt;
&lt;td&gt;21&lt;/td&gt;
&lt;td&gt;51.2 h&lt;/td&gt;
&lt;td&gt;3.1 h&lt;/td&gt;
&lt;td&gt;120.5 h&lt;/td&gt;
&lt;td&gt;8 of 21&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;New app (the two 4.3 spam batches)&lt;/td&gt;
&lt;td&gt;22&lt;/td&gt;
&lt;td&gt;187.8 h&lt;/td&gt;
&lt;td&gt;167.7 h&lt;/td&gt;
&lt;td&gt;223.3 h&lt;/td&gt;
&lt;td&gt;0 of 22&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;All&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;64&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;73.1 h&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;3.1 h&lt;/td&gt;
&lt;td&gt;223.3 h&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;24 of 64&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two things jump out. Rejections and questions on new apps come back fast (the 13 normal ones had a median of 27.8 hours), because the reviewer stops at the first problem. Approvals take longer: the 8 first-version approvals had a median of &lt;strong&gt;69.9 hours&lt;/strong&gt; and none came back inside 48. So a quick reply is, statistically, bad news.&lt;/p&gt;
&lt;h2&gt;
  
  
  The experiment: one timestamp pair per email
&lt;/h2&gt;

&lt;p&gt;App Store Connect does not give you a history of review durations. Your inbox does. Every decision Apple sends has the same shape: a subject that says either "Review of your &lt;em&gt;App&lt;/em&gt; (iOS) submission is complete." or "There's an issue with your &lt;em&gt;App&lt;/em&gt; (iOS) submission.", and a body line like &lt;code&gt;Submitted: Oct 08, 2026 at 03:48 PM Pacific Daylight Time&lt;/code&gt;. The email's own sent time is the decision time. That is the whole dataset.&lt;/p&gt;

&lt;p&gt;I searched Gmail with one &lt;a href="https://support.google.com/mail/answer/7190" rel="noopener noreferrer"&gt;search operator&lt;/a&gt; query, &lt;code&gt;from:no_reply@email.apple.com newer_than:60d&lt;/code&gt;, on October 17, 2026. It returned 69 threads; 64 are decisions (the other five are the App Store Connect welcome, API access approval, two "Welcome to the App Store" notes and one "Thank you for submitting"). The account opened on September 10, so this is the complete history, not a sample. Then the parse:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# media/deepdive/review-time/analyze.py (trimmed)
&lt;/span&gt;&lt;span class="n"&gt;PT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;timezone&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;timedelta&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;hours&lt;/span&gt;&lt;span class="o"&gt;=-&lt;/span&gt;&lt;span class="mi"&gt;7&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;  &lt;span class="c1"&gt;# Pacific Daylight Time, as printed in Apple's emails
&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;sub_dt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;  &lt;span class="c1"&gt;# "Oct 08 03:48 PM" from the email body
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;strptime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;2026 &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;%Y %b %d %I:%M %p&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tzinfo&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;PT&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;by&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;kind&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;ROWS&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;sub_dt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;utc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;          &lt;span class="c1"&gt;# e = the email's sent time, UTC
&lt;/span&gt;    &lt;span class="n"&gt;h&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;d&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;total_seconds&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;3600&lt;/span&gt;   &lt;span class="c1"&gt;# hours from Submitted to decision
&lt;/span&gt;    &lt;span class="n"&gt;recs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;outcome&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;kind&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;hours&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;...))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each row is also tagged by what was in the submission: a new app's first version, an update, or in-app purchases on their own. I took that from the app's own records (the repos' commit history and my notes) and checked the ambiguous batch against the email body, which lists the items: Chain's October 8 night submission says &lt;code&gt;In-App Purchases (6)&lt;/code&gt; and no app version. Rejection reasons are not in the emails at all (the body just says "We noticed an issue"), so those came from the Resolution Center texts I had saved and the replies I sent.&lt;/p&gt;

&lt;p&gt;What this method cannot see: submissions I cancelled before a decision (no email, no row), and the exact moment a reviewer opened the app. If you want "time in review" rather than "time waiting", the &lt;a href="https://developer.apple.com/documentation/appstoreconnectapi/app-store-versions" rel="noopener noreferrer"&gt;App Store Connect API&lt;/a&gt; exposes the version state, but not its history, so you would have to poll it yourself.&lt;/p&gt;

&lt;h2&gt;
  
  
  Apple's 24 hour number vs mine
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4ceb6wfwrj0ityeuyknc.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4ceb6wfwrj0ityeuyknc.png" alt="Bar chart of the share of submissions decided within 24 and 48 hours. Apple's stated target: 50 percent and 90 percent. All 64 of my decisions: 19 and 27 percent. Without the 4.3 batches: 29 and 40 percent. Approvals only: 25 and 29 percent. In-app purchases only: 100 and 100 percent." width="800" height="527"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Claim from developer.apple.com/distribute/app-review (read Oct 17 2026); measured from my App Review emails.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Apple's wording is careful: it says it &lt;em&gt;reviews&lt;/em&gt; half of submissions in under 24 hours. That can be true and still not match what you feel, for three reasons that show up in my data.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;First, the clock starts in a queue.&lt;/strong&gt; My approvals cluster at almost exactly the same elapsed time. Seven of the 16 updates landed between 63.5 and 64.5 hours, because they were submitted together on Saturday morning, September 26, and decided together within 32 minutes late on Monday night, Pacific time. Whatever the review itself took, the wait was set by when the queue reached them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Second, Apple does not appear to work Saturdays on new decisions, at least not mine.&lt;/strong&gt; Of 64 decisions, Pacific time: Monday 35, Friday 10, Thursday 7, Wednesday 5, Sunday 4, Tuesday 3, Saturday &lt;strong&gt;0&lt;/strong&gt;. The Monday pile is inflated by the two spam batches (22 decisions), but even without them nothing landed on a Saturday. So yes, App Review works on Sundays (two of the four Sunday decisions were approvals), but a Saturday morning submission still waited until Monday night.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Third, the slow tail is not random.&lt;/strong&gt; Without the two 4.3 batches my numbers look a lot closer to Apple's: 29% inside 24 hours, 40% inside 48. The 22 spam rejections took 7 to 9.3 days each. Long waits were a signal, not bad luck.&lt;/p&gt;

&lt;p&gt;The fast lane is in-app purchases. Five IAP-only submissions (new themes and packs for five live apps, sent through the API late on the night of Thursday, October 8) all came back approved between 11.7 and 13.4 hours later, within 95 minutes of each other. If you need to ship a new price point or a pack quickly, ship it as its own submission and leave the binary alone.&lt;/p&gt;
&lt;h2&gt;
  
  
  New apps: 0 of 21 approved on the first try
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5yhg3yiguvz0u0d1kted.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5yhg3yiguvz0u0d1kted.png" alt="Bar chart of why 40 submissions were not approved: guideline 4.3 spam plus 4.2.6, 22 (11 apps, twice); 2.1 Information Needed, 11; 5.1.1 privacy, 2; 1.4.1 health claims need citations, 1; reason not recorded, 4. None of 21 new apps was approved on its first submission." width="799" height="373"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Reasons from the App Store Connect Resolution Center texts and my replies, Sep 10 to Oct 12 2026.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Every one of the 21 apps had its first submission returned. That sounds alarming until you read the reasons. Ten of the 21 first-round emails were &lt;a href="https://developer.apple.com/app-store/review/guidelines/#2.1" rel="noopener noreferrer"&gt;guideline 2.1&lt;/a&gt; "Information Needed" (the other 11 were the spam batch below), the routine questions a new developer account gets: send a screen recording of the app on a device, explain the main features, say whether it needs a login, list any external services. Apple's guidelines ask for this up front; the reviewers ask again anyway when the account is new.&lt;/p&gt;

&lt;p&gt;The fix that worked was to stop treating the review notes as a formality. Every app now ships with a &lt;code&gt;Store/review-notes.md&lt;/code&gt; that the pipeline pushes into App Store Connect's review details, written as a walkthrough a reviewer can follow in under a minute, including how to reach every paid screen:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="gh"&gt;# chain/Store/review-notes.md (trimmed)&lt;/span&gt;
No account, login or network connection is required.

IN-APP PURCHASES (StoreKit 2, all optional, Restore in Settings):
&lt;span class="p"&gt;-&lt;/span&gt; Chain Unlimited (existing non-consumable): add three habits, tap New habit again; ...
&lt;span class="p"&gt;-&lt;/span&gt; Streak Repair, $0.99 consumable: when a chain of 2+ days breaks, Today shows a
  "chain broke" banner and the rescue sheet opens. ... To see it on a fresh install:
  add a habit, open Week, tick the two days before yesterday, leave yesterday empty,
  then relaunch.

PRIVACY: no data is collected. No accounts, analytics, ads or third-party SDKs.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After the first 2.1 round, Chain, Ironbook and Quiver were resubmitted on September 22 at 4:21 AM Pacific with answers and a screen recording, and all three were approved 51 to 67 hours later. Of the 8 apps that made it to the store, the median time from the very first submission to the first approval was &lt;strong&gt;4.4 days&lt;/strong&gt; (range 3.75 to 7.2). That is the honest number to put in a launch plan for a new app on a new account: not 24 hours, closer to a working week.&lt;/p&gt;

&lt;p&gt;The other first-round reasons are worth knowing because each one is cheap to prevent:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;5.1.1 privacy, twice, same app.&lt;/strong&gt; Minder, a focus timer whose cartoon eyes can follow your face (the eye animation is in &lt;a href="https://tensorust.com/articles/swiftui-spring-animation-cartoon-eyes/" rel="noopener noreferrer"&gt;this SwiftUI piece&lt;/a&gt;), was asked exactly what face data it collects, keeps and shares. The answer (one x/y position from Apple's on-device Vision framework, never stored) had to be in the privacy policy, not just in the reply. A week later it was rejected again because the privacy policy URL itself had stopped loading. I fixed the hosting, resubmitted six minutes after the email, and it was approved 24.8 hours later.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;1.4.1, health claims need sources.&lt;/strong&gt; Race Fuel gives carbohydrate, fluid and sodium targets for endurance races. &lt;a href="https://developer.apple.com/app-store/review/guidelines/#1.4.1" rel="noopener noreferrer"&gt;Guideline 1.4.1&lt;/a&gt; wants medical-adjacent numbers backed by citations. The fix was a sources screen with seven DOI-linked papers and a link under every figure.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Guideline 4.3: a week of silence, then 11 rejections in 30 seconds
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fit19c4es20mwp6r6vce1.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fit19c4es20mwp6r6vce1.png" alt="Two columns of email timestamps. Monday Oct 5 2026: 11 rejection emails from 5:29:32 to 5:30:02 PM Eastern, each app having waited 8.7 to 9.3 days. Monday Oct 12: 11 more from 6:04:46 to 6:05:13 PM, each exactly 7.0 days after resubmission." width="800" height="467"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;The two guideline 4.3 batches as they landed in the inbox, Eastern time.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Eleven of my apps (a car maintenance log, a parking timer, a home inventory, a grocery price log, a pet medication tracker, a chore chart, a tip tracker, a grade calculator, a bouldering log, a running pace calculator and a caffeine cutoff calculator) were submitted on September 26. They sat in the queue for 8.7 to 9.3 days. Then, on Monday, October 5, all 11 rejection emails arrived between 5:29:32 PM and 5:30:02 PM Eastern. Same text for every app: &lt;a href="https://developer.apple.com/app-store/review/guidelines/#4.3" rel="noopener noreferrer"&gt;guideline 4.3&lt;/a&gt; Spam plus 4.2.6, apps made from a template or app generation service, with nine questions to answer. The Resolution Center showed every message timestamped 5:29 PM.&lt;/p&gt;

&lt;p&gt;The rejection text was explicit about what to do next: if you think the app already complies, &lt;em&gt;do not resubmit&lt;/em&gt;, reply to the message and answer all nine questions. I replied to each one (no public beta, tested through automated Simulator runs; a shared StoreKit purchase class and test driver, disclosed), filed &lt;a href="https://developer.apple.com/contact/request/app-review/appeal/" rel="noopener noreferrer"&gt;App Review Board appeals&lt;/a&gt; for all 11, and also resubmitted all 11 that same afternoon. The resubmissions were the mistake. Seven days later, on Monday, October 12, 11 more rejections arrived within 27 seconds, each one 7.0 days after its resubmission.&lt;/p&gt;

&lt;p&gt;What the data says about 4.3, as a reader planning your own launch:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The wait is the tell.&lt;/strong&gt; In my data every review that ran past 5.1 days except one was a 4.3 rejection. The exception was Clear the Strait 1.1.1, which I resubmitted together with a long written explanation of how the game works after the reviewer asked; it took 8.5 days and was approved.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It is decided per developer, not per app.&lt;/strong&gt; Both batches landed inside 30 seconds. Whatever the review was, it was not 11 separate sessions with 11 apps.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Do not resubmit unchanged.&lt;/strong&gt; Apple's own email said a resubmission without substantial changes would be rejected again. It was, on a seven day clock, which cost a week per round.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Earlier I measured how much Swift code those apps actually share. The median overlap with the rest of the account was 34.5%, and the two most similar apps on the account (a golf journal and a tennis journal) are both live. Code similarity alone did not explain who got flagged; the numbers are in &lt;a href="https://tensorust.com/articles/iphone-app-development-cost/" rel="noopener noreferrer"&gt;the iPhone app cost article&lt;/a&gt;. My read, labelled as a read: 11 single-purpose utilities from one new account, submitted the same morning, look like an app factory from the queue no matter how different the screens are. If you have several small app ideas, ship one, let it collect reviews and updates, and space out the rest, or fold them into one app with in-app purchases, which is exactly what guideline 4.3(a) suggests.&lt;/p&gt;
&lt;h2&gt;
  
  
  The whole calendar in one picture
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4h8374pwjguo5oxtatug.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4h8374pwjguo5oxtatug.gif" alt="Animated calendar, one row per app for 21 apps, Sep 10 to Oct 13 2026. Bars grow from each submission to its decision, green for approved, red for not approved, purple while waiting. Early weeks show short red bars then green approvals; from Sep 26 eleven apps sit in two long red bars ending on Oct 5 and Oct 12, while live apps get updates approved in about 3 days. Final count: 24 approved, 40 not approved." width="800" height="467"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Every submission start to decision, 34 days, from Apple's decision emails. Static version: calendar.png.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Seen as a calendar, the pattern is obvious. The first two weeks are short red bars (fast 2.1 questions) followed by green bars of two to five days. From September 26 on, the account splits in two: apps that were already live get updates through in about 64 hours, while the 11 new utilities sit in two long red bars that end in the same 30-second window each time.&lt;/p&gt;
&lt;h2&gt;
  
  
  After approval: live in minutes
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fe1ghcb9y5qybmddmjhsl.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fe1ghcb9y5qybmddmjhsl.png" alt="The App Store page for Chain: Habit Tracker, one of the 8 apps that made it through review: free with in-app purchases, 4+, Productivity, by Matthew Busel, with four screenshots showing the habit list, Lock Screen widgets, a year heat map and the Streak Repair sheet." width="799" height="562"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Chain on the US App Store, screenshot Oct 17 2026. Its 1.2 update went live 1.9 minutes after the approval email.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The email says "it can take up to 24 hours" for an approved item to show up. For my 8 live apps, the public release timestamp came much faster. I read it from Apple's own &lt;a href="https://performance-partners.apple.com/search-api" rel="noopener noreferrer"&gt;iTunes Search API&lt;/a&gt;, which returns a &lt;code&gt;currentVersionReleaseDate&lt;/code&gt; for every app a developer has on the store:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# every app on the account, with the release time of its current version&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s2"&gt;"https://itunes.apple.com/lookup?id=6810582483&amp;amp;entity=software&amp;amp;country=us"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Against the approval emails, that release time landed &lt;strong&gt;0.7 to 7.5 minutes&lt;/strong&gt; after approval, median 2.5 minutes, for all 8 apps. If your launch date matters (press, an ad campaign, a Product Hunt slot), choose a &lt;a href="https://developer.apple.com/help/app-store-connect/manage-your-app-s-availability/select-an-app-store-version-release-option" rel="noopener noreferrer"&gt;manual release&lt;/a&gt; so an approval at 2 AM does not go live before your announcement.&lt;/p&gt;

&lt;p&gt;While you wait, you will want the state without opening App Store Connect. This is the status check every one of my app repos carries; it asks the App Store Connect API for the last five versions and builds:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# chain/Store/asc.py
&lt;/span&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="n"&gt;app&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;find_app&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;no app record for&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;BUNDLE_ID&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;attributes&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;name&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;  (&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;BUNDLE_ID&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;)  id=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;versions&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;GET&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/v1/apps/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/appStoreVersions&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;limit&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;versions&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="p"&gt;{}).&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;data&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[]):&lt;/span&gt;
        &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;attributes&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
        &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;  version &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;versionString&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;appStoreState&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The submission itself is one fastlane call at the end of a CI job, using &lt;a href="https://docs.fastlane.tools/actions/upload_to_app_store/" rel="noopener noreferrer"&gt;upload_to_app_store&lt;/a&gt;. The three &lt;code&gt;submission_information&lt;/code&gt; answers matter more than they look: they are the export compliance, advertising identifier and content rights questions, and answering them in code means a submission never stalls waiting for someone to click through a form.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# chain/fastlane/Fastfile, lane :release (trimmed)&lt;/span&gt;
&lt;span class="n"&gt;upload_to_app_store&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="ss"&gt;api_key: &lt;/span&gt;&lt;span class="n"&gt;asc_key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="ss"&gt;app_identifier: &lt;/span&gt;&lt;span class="no"&gt;APP_IDENTIFIER&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="ss"&gt;ipa: &lt;/span&gt;&lt;span class="s2"&gt;"build/Chain.ipa"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="c1"&gt;# Submit, rather than leaving it sitting in Connect for a human to press&lt;/span&gt;
  &lt;span class="c1"&gt;# the button on. This is the last step and it is the irreversible one.&lt;/span&gt;
  &lt;span class="ss"&gt;submit_for_review: &lt;/span&gt;&lt;span class="kp"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="ss"&gt;force: &lt;/span&gt;&lt;span class="kp"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="ss"&gt;precheck_include_in_app_purchases: &lt;/span&gt;&lt;span class="kp"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="ss"&gt;submission_information: &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="ss"&gt;add_id_info_uses_idfa: &lt;/span&gt;&lt;span class="kp"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="ss"&gt;export_compliance_uses_encryption: &lt;/span&gt;&lt;span class="kp"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="ss"&gt;content_rights_contains_third_party_content: &lt;/span&gt;&lt;span class="kp"&gt;false&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Does App Store review work on weekends?
&lt;/h2&gt;

&lt;p&gt;Partly. Four of my 64 decisions came on a Sunday, Pacific time: Pocket Beings' first approval at 4:05 PM on September 20, and three on the evening of October 11 (two 1.2 updates sent back, Chain 1.2 approved at 10:42 PM). None came on a Saturday. The 19 submissions I made on Saturday, September 26 were all decided on Monday night or later, and the five updates I sent on Thursday, October 8 came back Sunday evening and early Monday. If you can choose, submit Monday to Wednesday; anything sent Thursday or later, assume it lands after the weekend.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would do in your project
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Plan in rounds, not days.&lt;/strong&gt; One round is about 3 days for an update and 2 to 5 for a new app. A new app on a new account needs two rounds: budget a working week from first submit to approval.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Write the review notes like a test script.&lt;/strong&gt; Login (or "no login"), where every paid screen is, how to trigger the edge cases, what data you collect. Attach a screen recording to the first submission so 2.1 has nothing to ask.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check your privacy policy URL loads before every submission.&lt;/strong&gt; One dead link cost me a full round.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ship in-app purchases separately when speed matters.&lt;/strong&gt; 12 hours, 5 of 5 approved.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Treat a review over 5 days as a warning.&lt;/strong&gt; Prepare answers to the nine 4.3 questions before you need them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;When Apple says reply, do not resubmit.&lt;/strong&gt; A resubmission without changes bought me another identical rejection, seven days later to the hour.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fix and resubmit fast when the issue is concrete.&lt;/strong&gt; Minder's broken privacy link: resubmitted in 6 minutes, approved in 25 hours.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Need it sooner?&lt;/strong&gt; Apple has an &lt;a href="https://developer.apple.com/contact/app-store/?topic=expedite" rel="noopener noreferrer"&gt;expedited review request&lt;/a&gt; for critical bug fixes and time-sensitive events. I have not used it, so I have no number for it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Measure your own.&lt;/strong&gt; Run the same Gmail query on your account. Ten minutes gives you your real review calendar, which beats any average, including this one.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The data, and how to rerun it
&lt;/h2&gt;

&lt;p&gt;Everything is in one folder: &lt;code&gt;emails.py&lt;/code&gt; (the 64 rows transcribed from the emails, with the Gmail query and pull time), &lt;code&gt;analyze.py&lt;/code&gt; (medians, buckets, batches, approval-to-live lag), &lt;code&gt;summary.json&lt;/code&gt; (every computed number in this article) and &lt;code&gt;make_charts.py&lt;/code&gt; (the charts). Pulled October 17, 2026 around 10:15 PM Eastern; the iTunes lookup ran at about 10:20 PM. Apple's claim was read from &lt;a href="https://developer.apple.com/distribute/app-review/" rel="noopener noreferrer"&gt;developer.apple.com/distribute/app-review&lt;/a&gt; the same night, and the submission flow is described in Apple's &lt;a href="https://developer.apple.com/help/app-store-connect/manage-submissions-to-app-review/submit-for-review" rel="noopener noreferrer"&gt;Submit for review&lt;/a&gt; help page. One developer account, 21 apps, five weeks: a small sample, but a complete one, and the only kind of review-time data you can actually verify.&lt;/p&gt;

&lt;p&gt;If you are building on Windows and want the same pipeline (cloud builds, signing, screenshots, fastlane submission, the review notes template) set up on your own repo, that is the &lt;a href="https://tensorust.com/go/ios-setup/app-store-review-time" rel="noopener noreferrer"&gt;No-Mac iOS Pipeline, $299&lt;/a&gt;: your first TestFlight build from a Windows PC in 5 business days. If you would rather hand over the whole app, &lt;a href="https://tensorust.com/build/?utm_source=devto&amp;amp;utm_campaign=app-store-review-time" rel="noopener noreferrer"&gt;fixed-price builds are on /build/&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>ios</category>
      <category>swift</category>
      <category>mobile</category>
      <category>appstore</category>
    </item>
    <item>
      <title>Understanding Macros in Swift</title>
      <dc:creator>rahul bandal</dc:creator>
      <pubDate>Sun, 18 Oct 2026 12:57:42 +0000</pubDate>
      <link>https://dev.to/rahul_bandal_afe623cbf2e2/understanding-macros-in-swift-54kd</link>
      <guid>https://dev.to/rahul_bandal_afe623cbf2e2/understanding-macros-in-swift-54kd</guid>
      <description>&lt;p&gt;Swift Macros, introduced in Swift 5.9, represent a significant evolution in how developers can generate and transform code. While many developers might be familiar with the basic concept of macros in other languages, Swift's implementation offers a safe, type-checked, and powerful way to reduce boilerplate and create highly expressive APIs without sacrificing compiler guarantees.&lt;/p&gt;

&lt;p&gt;This article goes beyond a basic introduction, aiming to provide a deeper understanding of how Swift Macros work under the hood, their different forms, and how they…&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Read the full article on Swift By Rahul&lt;/strong&gt; → &lt;a href="https://www.swiftbyrahul.com/posts/UnderstandingMacrosInSwift/" rel="noopener noreferrer"&gt;Continue reading&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The complete tutorial includes Swift code examples, SVG diagrams, and step-by-step explanations on our website.&lt;/p&gt;

</description>
      <category>swift</category>
      <category>ios</category>
      <category>programming</category>
    </item>
    <item>
      <title>Using your own photo as an iPhone countdown widget background: what works, and what iOS won't let you do</title>
      <dc:creator>BeforeGo</dc:creator>
      <pubDate>Sun, 18 Oct 2026 12:36:56 +0000</pubDate>
      <link>https://dev.to/_8f5abeda89bd0a842a1d0/using-your-own-photo-as-an-iphone-countdown-widget-background-what-works-and-what-ios-wont-let-39b1</link>
      <guid>https://dev.to/_8f5abeda89bd0a842a1d0/using-your-own-photo-as-an-iphone-countdown-widget-background-what-works-and-what-ios-wont-let-39b1</guid>
      <description>&lt;p&gt;Short answer. Several iPhone countdown apps let you put your own photo behind the countdown. As of October 2026, Photo Countdown Widget lists your photo as the background with every widget free, and in our app, Countdown Widget: Any Event, your own photo is part of Countdown Pro. Whatever app you pick, the photo only shows on Home Screen widgets. Lock Screen widgets are drawn by iOS in a single tint, so a photo can't show there. In our app the photo shows with the Home Screen's Default or Dark look; with a Tinted or Clear Home Screen, iOS removes the widget background.&lt;/p&gt;

&lt;p&gt;I build Countdown Widget: Any Event. Here's what I learned getting a photo to look right behind a number, in three widget sizes plus the app's own event page.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The Lock Screen won't show it, and that's iOS
Lock Screen widget families (accessoryCircular, accessoryRectangular, accessoryInline) are rendered with the system's vibrant, monochrome treatment: colours are flattened, images become single-tone, and the container background is removed. So in our widget code those families draw no backdrop at all. Drawing a backdrop there would only fight the system material.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If you want your photo on the Lock Screen, set the photo as your wallpaper and put the countdown widget over it.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;One photo, framed for each shape&lt;br&gt;
A small widget is square, a medium one is wide, and a large one is close to square but much bigger. If you centre-crop one photo for all three, someone's face ends up cut in half in at least one of them. So the photo isn't stored as one crop. It's stored as a focus point plus a separate framing for each target: the event's own page, small, medium and large. The editor shows a preview for each one (Detail, Small, Medium, Large), and you drag the frame in each preview until it sits right, dragging its corners to resize it.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;The number has to stay readable&lt;br&gt;
A white number on a bright beach photo disappears. Three sliders handle that:&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Opacity fades the photo into the backdrop colour.&lt;br&gt;
Blur softens the detail behind the digits.&lt;br&gt;
Text Contrast adds a scrim, so the text stands out without changing the photo itself.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;Widgets get small JPEGs, prepared in advance&lt;br&gt;
A widget extension runs with a tight memory budget and shouldn't be decoding a 12-megapixel photo. The app prepares renditions ahead of time: 1,200 px on the long edge for the event page, 690 px for widgets and 480 px for list tiles. The widget only ever loads a small JPEG.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Sync without stale pictures&lt;br&gt;
The photo is stored as an attachment that syncs through iCloud. Each device builds its own renditions from it. Each rendition remembers which version of the photo it came from, so if you change the photo on your iPad, your iPhone doesn't keep showing the old one.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Doing it in Countdown Widget: Any Event&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Tap Add Event and pick a ready-made event or Blank Event. For a countdown you already have, open it and tap the pencil to edit it. Then tap Backdrop.&lt;/li&gt;
&lt;li&gt;Choose Custom, then Add Photo, and pick a photo.&lt;/li&gt;
&lt;li&gt;In Photo adjustments, frame it in the Detail, Small, Medium and Large previews, then set Opacity, Blur and Text Contrast.&lt;/li&gt;
&lt;li&gt;Tap Done, then add the widget to your Home Screen (Default or Dark look).
Using your own photo needs Pro. Unlimited events and every widget size are free, with no ads. The app is on the App Store as "Countdown Widget: Any Event".&lt;/li&gt;
&lt;/ol&gt;

</description>
      <category>ios</category>
      <category>swift</category>
      <category>widget</category>
      <category>iphone</category>
    </item>
    <item>
      <title>I Built a Remote to Answer Claude Code From My Phone</title>
      <dc:creator>Lucas Neves Pereira</dc:creator>
      <pubDate>Sun, 18 Oct 2026 12:26:41 +0000</pubDate>
      <link>https://dev.to/lucasnevespereira/i-built-a-remote-to-answer-claude-code-from-my-phone-i40</link>
      <guid>https://dev.to/lucasnevespereira/i-built-a-remote-to-answer-claude-code-from-my-phone-i40</guid>
      <description>&lt;p&gt;Hello there!&lt;/p&gt;

&lt;p&gt;If you run Claude Code or Codex for real work, you know the moment. You start a task, it runs for a while, and you go make a coffee. When you come back, the agent has been waiting ten minutes for you to answer one question.&lt;/p&gt;

&lt;p&gt;"The fix is in. What should I do with it?"&lt;/p&gt;

&lt;p&gt;That question is why I built Farol Remote. It lets you answer your coding agents from your phone, wherever you are, while the work keeps running on your Mac or on your own server.&lt;/p&gt;

&lt;h2&gt;
  
  
  A bit of context
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://farol.sh" rel="noopener noreferrer"&gt;Farol&lt;/a&gt; is a macOS terminal I made for coding agents. Its sidebar shows every session with a small dot: hollow when idle, breathing while the agent works, rippling when it waits for you, filled when it's done.&lt;/p&gt;

&lt;p&gt;It tells you when an agent needs you. But only if you're at your Mac.&lt;/p&gt;

&lt;p&gt;So the idea was simple. Put that sidebar in my pocket. See the sessions, read the conversation, and answer with a tap.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the phone does
&lt;/h2&gt;

&lt;p&gt;The iPhone app lists your sessions with their status and opens each one as its agent's conversation, not as a wall of terminal text.&lt;/p&gt;

&lt;p&gt;When the agent asks for an approval or a question, the answers show up as buttons. One tap and it carries on. You can also reply with a slash command, send a screenshot for the agent to look at, review what changed file by file, and start a new session in a project.&lt;/p&gt;

&lt;p&gt;It notifies you when an agent waits or finishes. And a Live Activity on the lock screen keeps count of the agents that work or wait, even with the app closed.&lt;/p&gt;

&lt;p&gt;One phone can know several Macs and servers at once. Your work Mac and a server you run agents on show up in the same list, each session labeled with its host.&lt;/p&gt;

&lt;h2&gt;
  
  
  The hard part: the middle
&lt;/h2&gt;

&lt;p&gt;A phone and a Mac don't talk to each other directly over the internet. Your Mac sits behind a home router, your phone is on 4G, and neither can reach the other.&lt;/p&gt;

&lt;p&gt;So there's a relay in between. Both sides connect out to it, and it passes messages along.&lt;/p&gt;

&lt;p&gt;That raises the obvious question. If everything goes through a server, what can that server see? These messages are your code, your prompts, and a phone that can type into your terminal. I didn't want the answer to be "trust me".&lt;/p&gt;

&lt;p&gt;The goal became this: the relay passes messages it cannot read, and keeps none of them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pairing, once
&lt;/h2&gt;

&lt;p&gt;It all starts with a QR code. On the Mac you turn on Remote in Settings and press Show code. On the phone you scan it.&lt;/p&gt;

&lt;p&gt;The code carries three things: the relay's address, the Mac's public key, and a secret made just for that code. It's a link, and everything rides after the &lt;code&gt;#&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;o&lt;/span&gt; &lt;span class="n"&gt;offer&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;link&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Marshal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s"&gt;"https://farol.sh/pair#"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;base64&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;RawURLEncoding&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;EncodeToString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The part after the &lt;code&gt;#&lt;/code&gt; is never sent to a server. So the secret never reaches the relay, which means the relay can't pair a device of its own.&lt;/p&gt;

&lt;p&gt;The phone answers with one sealed message that proves two things at once: it holds the key it claims, and it saw the secret. A code works once and lasts two minutes. After that, the Mac knows the phone's key and the phone knows the Mac's.&lt;/p&gt;

&lt;h2&gt;
  
  
  Every connection
&lt;/h2&gt;

&lt;p&gt;Each time they connect, both sides say hello in the clear with their public key and a random number made for this connection.&lt;/p&gt;

&lt;p&gt;Then each direction becomes its own HPKE stream, sealed to the other side's key and signed with ours. I used CryptoKit's &lt;code&gt;Curve25519_SHA256_ChachaPoly&lt;/code&gt; suite. Here's the sending side on the Mac and the phone:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="k"&gt;mutating&lt;/span&gt; &lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;to&lt;/span&gt; &lt;span class="nv"&gt;peer&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;ChannelHello&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;trusting&lt;/span&gt; &lt;span class="nv"&gt;trusted&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kt"&gt;Data&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="k"&gt;throws&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="kt"&gt;Data&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;guard&lt;/span&gt; &lt;span class="n"&gt;trusted&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;contains&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;peer&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="kt"&gt;RemoteError&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;unknownDevice&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;sender&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="kt"&gt;HPKE&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="kt"&gt;Sender&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nv"&gt;recipientKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Curve25519&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="kt"&gt;KeyAgreement&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="kt"&gt;PublicKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;rawRepresentation&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;peer&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nv"&gt;ciphersuite&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Pairing&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;suite&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nv"&gt;info&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;info&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;from&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;side&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;with&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;peer&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nv"&gt;authenticatedBy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;me&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;secret&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;peer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;peer&lt;/span&gt;
    &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sender&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;sender&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;sender&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;encapsulatedKey&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;info&lt;/code&gt; includes both hello numbers. So a recording of an older connection doesn't open on a new one, and messages only open in the order they were sealed. If one fails to open, the connection ends.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the relay actually sees
&lt;/h2&gt;

&lt;p&gt;Mostly bytes.&lt;/p&gt;

&lt;p&gt;It sees a public key, and a room name that's the hash of the host's key. That's how a Mac and its phones find each other without the relay learning who they are.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;room&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;public&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="kt"&gt;byte&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;sum&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;sha256&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Sum256&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;public&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;hex&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;EncodeToString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then it sees encrypted messages and how big they are. It stores none of it, and its log holds totals only.&lt;/p&gt;

&lt;h2&gt;
  
  
  Notifications without reading anything
&lt;/h2&gt;

&lt;p&gt;This one was fun. I wanted a notification when an agent waits, even with the app closed. That means Apple has to push it, and something has to ask Apple.&lt;/p&gt;

&lt;p&gt;The Mac asks the relay to forward a push. The relay sees the phone's notification token and one word: waiting or done. Which session it's about travels sealed, for the phone alone.&lt;/p&gt;

&lt;p&gt;The Live Activity works the same way. The host keeps it current through the relay, so the counts on your lock screen move while the app sleeps.&lt;/p&gt;

&lt;h2&gt;
  
  
  Running Claude Code on a server with tmux
&lt;/h2&gt;

&lt;p&gt;Some people run their agents on a machine that stays on, so they can close the laptop. For them there's &lt;code&gt;farol-host&lt;/code&gt;, a small Go CLI.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-fsSL&lt;/span&gt; https://farol.sh/host.sh | sh
farol-host setup
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Setup connects the hooks for Claude Code and Codex, keeps &lt;code&gt;farol-host&lt;/code&gt; running with systemd, and shows a QR code to scan. Then every tmux window on that machine is a session on your phone, like a tab in Farol on the Mac.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;tmux new &lt;span class="nt"&gt;-s&lt;/span&gt; api &lt;span class="nt"&gt;-c&lt;/span&gt; ~/code/api
claude
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  One protocol, two languages
&lt;/h2&gt;

&lt;p&gt;The Mac and the iPhone share a Swift package. The server speaks the same protocol, written again in Go.&lt;/p&gt;

&lt;p&gt;Two implementations of the same crypto and messages is a great way to have bugs that only show up on a real phone. So they check each other.&lt;/p&gt;

&lt;p&gt;The Swift tests write sample messages and a sealed conversation to JSON files. The Go tests open those files and must read them exactly. When a message changes, I regenerate them:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;FAROL_WRITE_WIRE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1 swift &lt;span class="nb"&gt;test&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If Go and Swift disagree on a single field, a test fails before any phone sees it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's not covered yet
&lt;/h2&gt;

&lt;p&gt;I'd rather say this upfront.&lt;/p&gt;

&lt;p&gt;If a host's key were stolen later, traffic recorded from a phone to that host could be read. A host also doesn't prove itself to the relay yet, so someone who learned a room's name could knock the real host off. They couldn't read or send anything, but it's still on my list.&lt;/p&gt;

&lt;p&gt;And the pairing and the encryption haven't had an outside review. If that's your thing, I'd love a second pair of eyes. It's all in one small repo, on purpose.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;Farol is free and open source. The iPhone app is free too, in beta on TestFlight. In Farol, open Settings, choose Remote, turn it on, press Show code, and scan it from the app.&lt;/p&gt;

&lt;p&gt;The security parts live in &lt;a href="https://github.com/snowztech/farol-remote" rel="noopener noreferrer"&gt;farol-remote&lt;/a&gt;: the Swift package, the relay, and &lt;code&gt;farol-host&lt;/code&gt;. The &lt;a href="https://github.com/snowztech/farol-remote/blob/main/docs/security.md" rel="noopener noreferrer"&gt;security page&lt;/a&gt; goes deeper than this post.&lt;/p&gt;

&lt;p&gt;Hope you find it useful! Feel free to open an issue or contribute. And next time your agent asks you a question while you're out, you can just answer it. ☕&lt;/p&gt;

</description>
      <category>swift</category>
      <category>go</category>
      <category>ios</category>
      <category>security</category>
    </item>
    <item>
      <title>I built Bitcut: turn a long video into Shorts with captions on your iPhone</title>
      <dc:creator>Alexey Yarmots</dc:creator>
      <pubDate>Sun, 18 Oct 2026 10:35:48 +0000</pubDate>
      <link>https://dev.to/ayarmots/how-i-made-video-cuts-land-on-the-beat-without-cutting-speech-mid-word-ios-accelerate-1jj9</link>
      <guid>https://dev.to/ayarmots/how-i-made-video-cuts-land-on-the-beat-without-cutting-speech-mid-word-ios-accelerate-1jj9</guid>
      <description>&lt;p&gt;I'm Alexey, a solo developer, and I built &lt;a href="https://bitcut.app/?utm_source=devto&amp;amp;utm_medium=social&amp;amp;utm_campaign=seed-2026-10" rel="noopener noreferrer"&gt;Bitcut&lt;/a&gt;. It's an iPhone app that turns a long video (a podcast, interview, lecture or vlog) into vertical Shorts, Reels and TikToks.&lt;/p&gt;

&lt;p&gt;Cutting clips from a long recording by hand means three jobs: finding moments that work on their own, adding captions and reframing to 9:16. Bitcut does all three on the phone.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it does
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Generate Shorts.&lt;/strong&gt; It listens to what is said and picks moments that make sense on their own, ranked by quality. Trailer mode builds one reel from the strongest highlights; Parts splits a recording into equal clips.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Word-by-word captions.&lt;/strong&gt; 18 caption looks with karaoke highlighting; you can fix text and timing right on the timeline.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Face tracking.&lt;/strong&gt; A 16:9 video becomes 9:16 and the frame follows the speaker across scene changes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cuts on the beat.&lt;/strong&gt; Cuts snap to the music, and speech is never cut mid-word.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A full multi-track editor&lt;/strong&gt;: trim, split, voiceover, title cards, transitions, voice enhancement. Import from Photos, Files or a YouTube link.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Editing and rendering happen on the phone. For captions only the audio track is sent for transcription. You don't need an account to start.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is free
&lt;/h2&gt;

&lt;p&gt;The free plan has the full editor and 30 AI minutes a month; exports carry a watermark. Paid plans are listed on the App Store page. There are also free browser tools on &lt;a href="https://bitcut.app/tools/?utm_source=devto&amp;amp;utm_medium=social&amp;amp;utm_campaign=seed-2026-10" rel="noopener noreferrer"&gt;bitcut.app/tools&lt;/a&gt;: a YouTube transcript and YouTube to Shorts.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would like feedback on
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Clips where the chosen moment doesn't stand on its own: what was the video about?&lt;/li&gt;
&lt;li&gt;Edits where the cuts feel wrong for the music: which genre and caption style you used.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Bitcut is on the &lt;a href="https://apps.apple.com/app/bitcut/id6759696573" rel="noopener noreferrer"&gt;App Store&lt;/a&gt; for iPhone and iPad (iOS 18+). I'll answer questions in the comments.&lt;/p&gt;

</description>
      <category>showdev</category>
      <category>ios</category>
      <category>swift</category>
      <category>ai</category>
    </item>
    <item>
      <title>du Said Chrome Was Using 67 GB on My Mac. APFS Said 2. Here's a Script to Tell the Difference</title>
      <dc:creator>Md Ayan bag</dc:creator>
      <pubDate>Sun, 18 Oct 2026 04:36:25 +0000</pubDate>
      <link>https://dev.to/md_ayanbag_03a42545b17ac/du-said-chrome-was-using-67-gb-on-my-mac-apfs-said-2-heres-a-script-to-tell-the-difference-597e</link>
      <guid>https://dev.to/md_ayanbag_03a42545b17ac/du-said-chrome-was-using-67-gb-on-my-mac-apfs-said-2-heres-a-script-to-tell-the-difference-597e</guid>
      <description>&lt;p&gt;My MacBook Air was down to 18 GB free, so I went hunting. The biggest folder on the whole disk, according to &lt;code&gt;du&lt;/code&gt;, was this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;/private/var/folders/…/X/com.google.Chrome.code_sign_clone   67G
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Inside: &lt;strong&gt;48 full copies of Google Chrome&lt;/strong&gt;. None of them the version I actually have installed.&lt;/p&gt;

&lt;p&gt;Then I measured it the way APFS actually stores it, and the 67 GB turned into about 2. Here's the folder, the trap, and a 30-line Swift script that measures any folder the same way.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;(Written with help from an AI assistant. Every number and command was run on my own Mac.)&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Check yours (read-only)
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;ls&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;getconf DARWIN_USER_TEMP_DIR&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;/../X/com.google.Chrome.code_sign_clone"&lt;/span&gt; | &lt;span class="nb"&gt;wc&lt;/span&gt; &lt;span class="nt"&gt;-l&lt;/span&gt;
&lt;span class="nb"&gt;du&lt;/span&gt; &lt;span class="nt"&gt;-sh&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;getconf DARWIN_USER_TEMP_DIR&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;/../X/"&lt;/span&gt;&lt;span class="k"&gt;*&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Mine printed &lt;code&gt;48&lt;/code&gt; and &lt;code&gt;67G&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;From what I can tell, Chrome makes one of these each time it launches after an update. The running browser keeps a validly signed copy of itself while the new version is swapped in underneath. They're meant to be temporary. On my Mac they weren't: 3 copies of Chrome 154 and 45 of 155.0.8059.40, while &lt;code&gt;/Applications&lt;/code&gt; has 155.0.8059.80.&lt;/p&gt;

&lt;h2&gt;
  
  
  The trap: &lt;code&gt;du&lt;/code&gt; doesn't understand clones
&lt;/h2&gt;

&lt;p&gt;APFS can &lt;em&gt;clone&lt;/em&gt; a file. The clone looks like a full copy to every tool, but it shares blocks with the original until one side changes. Chrome makes its copies as clones.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;du&lt;/code&gt;, Finder's Get Info and most disk-map apps add every clone up at full size. 48 × ~1.4 GB = 67 GB that mostly doesn't exist.&lt;/p&gt;

&lt;p&gt;APFS will tell you the truth, you just have to ask through &lt;code&gt;getattrlist&lt;/code&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;ATTR_CMNEXT_CLONEID&lt;/code&gt;: which clone family a file belongs to&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;ATTR_CMNEXT_EXT_FLAGS&lt;/code&gt;: bit 0 means "may share blocks"&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;ATTR_CMNEXT_PRIVATESIZE&lt;/code&gt;: bytes that belong only to this file&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So: for each clone, count its private bytes, and count the shared part once per family.&lt;/p&gt;

&lt;h2&gt;
  
  
  The script
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="kd"&gt;import&lt;/span&gt; &lt;span class="kt"&gt;Foundation&lt;/span&gt;

&lt;span class="c1"&gt;// Usage: swift realsize.swift &amp;lt;folder&amp;gt;&lt;/span&gt;
&lt;span class="c1"&gt;// Counts APFS clones once: each clone adds only its private bytes,&lt;/span&gt;
&lt;span class="c1"&gt;// and the shared blocks are counted once per clone family.&lt;/span&gt;
&lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;attrs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="nv"&gt;path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="nv"&gt;bits&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;UInt32&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="nv"&gt;n&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kt"&gt;UInt64&lt;/span&gt;&lt;span class="p"&gt;]?&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;al&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;attrlist&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="n"&gt;al&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;bitmapcount&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;u_short&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;ATTR_BIT_MAP_COUNT&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="n"&gt;al&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;forkattr&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;bits&lt;/span&gt;
    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;buf&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kt"&gt;UInt8&lt;/span&gt;&lt;span class="p"&gt;](&lt;/span&gt;&lt;span class="nv"&gt;repeating&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;count&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;buf&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;withUnsafeMutableBytes&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt;
        &lt;span class="k"&gt;guard&lt;/span&gt; &lt;span class="nf"&gt;getattrlist&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;al&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;baseAddress&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;count&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mh"&gt;0x21&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="nf"&gt;return&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="o"&gt;..&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;map&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;loadUnaligned&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;fromByteOffset&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nv"&gt;$0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;as&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;UInt64&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;root&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;CommandLine&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;arguments&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;naive&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Int64&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;real&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Int64&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;families&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;Set&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;UInt64&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;e&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;FileManager&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="k"&gt;default&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;enumerator&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;atPath&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;root&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;
&lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;rel&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;nextObject&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;as?&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;p&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;root&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="s"&gt;"/"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;rel&lt;/span&gt;
    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;st&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;stat&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="k"&gt;guard&lt;/span&gt; &lt;span class="nf"&gt;lstat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;st&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;st&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;st_mode&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="kt"&gt;S_IFMT&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="kt"&gt;S_IFREG&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;full&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;Int64&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;st&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;st_blocks&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;512&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;naive&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="n"&gt;full&lt;/span&gt;
    &lt;span class="c1"&gt;// ATTR_CMNEXT_CLONEID | ATTR_CMNEXT_EXT_FLAGS; flag 1 = may share blocks&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;c&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;attrs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mh"&gt;0x100&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="mh"&gt;0x200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;priv&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;attrs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mh"&gt;0x8&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)?[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;real&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="kt"&gt;Int64&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;priv&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;families&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;insert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;inserted&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;real&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="n"&gt;full&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="kt"&gt;Int64&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;priv&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;  &lt;span class="c1"&gt;// shared part, once&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;real&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="n"&gt;full&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;g&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;1_073_741_824.0&lt;/span&gt;  &lt;span class="c1"&gt;// GiB, same unit as du -h&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;format&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"du-style: %.1fG   on disk: %.1fG"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;Double&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;naive&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;Double&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;real&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;g&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;0x21&lt;/code&gt; is &lt;code&gt;FSOPT_ATTR_CMN_EXTENDED | FSOPT_NOFOLLOW&lt;/code&gt;. You need Xcode's command line tools for &lt;code&gt;swift&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;On the Chrome folder:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;swift realsize.swift &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;getconf DARWIN_USER_TEMP_DIR&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;/../X/com.google.Chrome.code_sign_clone"&lt;/span&gt;
du-style: 67.3G   on disk: 2.1G
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;67 GB on paper, about 2 GB on disk.&lt;/strong&gt; Still waste (those clones keep old Chrome versions' blocks alive), but a 30× smaller problem.&lt;/p&gt;

&lt;p&gt;It's a simplification: a file's "shared" part can be shared with files outside the folder, so treat the result as "roughly what deleting this folder gives back". (My app's stricter version credits those blocks to whichever file owns them, and lands on 1.8 GB for the same folder.) The honest final check is always &lt;code&gt;df -h /&lt;/code&gt; before and after (and empty the Trash).&lt;/p&gt;

&lt;h2&gt;
  
  
  Should you delete them?
&lt;/h2&gt;

&lt;p&gt;Probably fine, with two rules: quit Chrome completely first (Cmd-Q), and expect them back after the next update. Open the folder with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;open &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;getconf DARWIN_USER_TEMP_DIR&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;/../X"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;…and drag &lt;code&gt;com.google.Chrome.code_sign_clone&lt;/code&gt; to the Trash. Expect a GB or two back, not 67.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I care
&lt;/h2&gt;

&lt;p&gt;Clones are everywhere now: Finder's Duplicate, pnpm's store, Chrome's copies. Any disk tool that sums file sizes will show you folders bigger than your drive, and you'll delete things expecting space you never get.&lt;/p&gt;

&lt;p&gt;I ran into this while building &lt;a href="https://tidymac.space/?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=chrome-clones" rel="noopener noreferrer"&gt;Tidy Mac&lt;/a&gt;, a Mac cleaner. Full disclosure: it's mine. It counts clones once like the script above, labels what's safe to clear, and moves everything to the Trash first.&lt;/p&gt;

&lt;p&gt;How many copies did your Mac have? Post your &lt;code&gt;ls | wc -l&lt;/code&gt; in the comments. I'm curious if anyone beats 48.&lt;/p&gt;

</description>
      <category>macos</category>
      <category>swift</category>
      <category>apple</category>
      <category>productivity</category>
    </item>
  </channel>
</rss>
