<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
  <channel>
    <title>twocentstudios</title>
    <description>A coding blog covering iOS, Swift, and other programming topics.</description>
    <link>https://twocentstudios.com/blog/tags/ios/index.html</link>
    <atom:link href="https://twocentstudios.com/blog/tags/ios/feed.xml" rel="self" type="application/rss+xml"/>
    <pubDate>Sun, 01 Feb 2026 00:12:37 -0600</pubDate>
    <lastBuildDate>Sun, 01 Feb 2026 00:12:37 -0600</lastBuildDate>
    <generator>Jekyll v3.9.3</generator>
    
      <item>
        <title>Print Design Mindset to App Design Mindset</title>
        <description>&lt;h2 id=&quot;the-print-design-era&quot;&gt;The print design era&lt;/h2&gt;

&lt;p&gt;There was a time when iOS &lt;strong&gt;app design&lt;/strong&gt; wasn’t all that far off from &lt;strong&gt;print design&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;For the first couple years of the iPhone SDK (2008-2011):&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;All iPhones had 320 width by 480 height screens.&lt;/li&gt;
  &lt;li&gt;All iPhone screens were &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;@1x&lt;/code&gt; non-retina.&lt;/li&gt;
  &lt;li&gt;The platform design language was highly skeuomorphic and rewarded delicately crafted Photoshop assets.&lt;/li&gt;
  &lt;li&gt;iOS &lt;em&gt;did&lt;/em&gt; support localization but not for RTL languages and the launch country list was relatively English-speaking (and it was never &lt;em&gt;required&lt;/em&gt; for App Store listing).&lt;/li&gt;
  &lt;li&gt;Accessibility features that dynamically altered app content visually did not exist.&lt;/li&gt;
&lt;/ul&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/print-design-iphones-early-era-320pt.jpg&quot; width=&quot;&quot; height=&quot;&quot; alt=&quot;iPhone, iPhone 3G, iPhone 4, and iPhone 5 — all 320 points wide&quot; title=&quot;iPhone, iPhone 3G, iPhone 4, and iPhone 5 — all 320 points wide&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;iPhone, iPhone 3G, iPhone 4, and iPhone 5 — all 320 points wide&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Sure, there were dynamic elements like buttons with highlighted, disabled, and selected states. Sure, there were scrolling screens. But the overall workflow of app design to app development was taking a Photoshop mockup and recreating it piece by piece in the SDK.&lt;/p&gt;

&lt;p&gt;Until &lt;a href=&quot;https://www.sketch.com/&quot;&gt;Sketch&lt;/a&gt; gained some prominence in the 2013-2015 era, Photoshop was still the most popular tool for less technical designers to work in before artifact hand off.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/print-design-sketch-ios-artboard-presets.jpg&quot; width=&quot;&quot; height=&quot;&quot; alt=&quot;Sketch app showing iOS device artboard presets including iPhone 6, iPhone 5, and iPad sizes&quot; title=&quot;Sketch app showing iOS device artboard presets including iPhone 6, iPhone 5, and iPad sizes&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Sketch app showing iOS device artboard presets including iPhone 6, iPhone 5, and iPad sizes&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;The first cracks in the print design workflow surprisingly started with the sudden differentiation between points and pixels with the iPhone 4 in 2010. Even then, that didn’t necessitate a mindset change away from print design, just a few configuration changes in Photoshop and some extra work come asset export time.&lt;/p&gt;

&lt;p&gt;It wasn’t until the iPhone 6 in 2014 that designers (and developers) could no longer assume devices had the same 320 point width. As an iOS developer, you could get away with hard-coding dimensions for your &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;UIView&lt;/code&gt;s for a very long time! (Assuming you supported only landscape &lt;em&gt;OR&lt;/em&gt; portrait orientation).&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/print-design-iphones-various-sizes.jpg&quot; width=&quot;&quot; height=&quot;&quot; alt=&quot;iPhone 6 (375pt), iPhone X (375pt), iPhone 13 (390pt), and iPad Air (768pt)&quot; title=&quot;iPhone 6 (375pt), iPhone X (375pt), iPhone 13 (390pt), and iPad Air (768pt)&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;iPhone 6 (375pt), iPhone X (375pt), iPhone 13 (390pt), and iPad Air (768pt)&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;And if varying screen widths weren’t enough, the iPhone X in 2017 broke the assumption that the screen was even a simple rectangle. The notch, rounded corners, and the home indicator meant designers could no longer treat the canvas as a predictable bordered frame.&lt;/p&gt;

&lt;h2 id=&quot;the-complexity-catches-up&quot;&gt;The complexity catches up&lt;/h2&gt;

&lt;p&gt;I’ll skip to present day to say that we’re long past the point that most app designers can, with a good conscience, keep a print design mindset when doing visual design for mobile.&lt;/p&gt;

&lt;p&gt;The complexity grew, and luckily for iOS developers, the tooling provided by Apple to rein in that complexity has slowly caught up:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;For view layout, we have stacks, content margins, size classes, line limits, leading/trailing instead of left/right.&lt;/li&gt;
  &lt;li&gt;For Dynamic Type (user adjustable font sizes), we have semantic fonts like &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.body&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.headline&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.largeTitle&lt;/code&gt;. We have the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;@Scaled&lt;/code&gt; property wrapper in SwiftUI. We have &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;UIFontMetrics&lt;/code&gt; for scaling non-system fonts.&lt;/li&gt;
  &lt;li&gt;For Dark Mode, we have semantic colors like &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.label&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.background&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.secondary&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.blue&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.tint&lt;/code&gt;, and asset catalogs.&lt;/li&gt;
  &lt;li&gt;For localization, we have auto-updating &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;xcstrings&lt;/code&gt; files, Double-Length pseudolanguage, RTL pseudolanguage, asset catalog localization, plural strings, and localization aware formatters.&lt;/li&gt;
&lt;/ul&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/print-design-hig-semantic-label-colors.jpg&quot; width=&quot;&quot; height=&quot;&quot; alt=&quot;HIG semantic label colors adapt automatically across appearances&quot; title=&quot;HIG semantic label colors adapt automatically across appearances&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;HIG semantic label colors adapt automatically across appearances&lt;/div&gt;&lt;/div&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/print-design-hig-system-colors.jpg&quot; width=&quot;&quot; height=&quot;&quot; alt=&quot;HIG system colors vary across light mode, dark mode, and increased contrast&quot; title=&quot;HIG system colors vary across light mode, dark mode, and increased contrast&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;HIG system colors vary across light mode, dark mode, and increased contrast&lt;/div&gt;&lt;/div&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/print-design-hig-dynamic-type-sizes.jpg&quot; width=&quot;&quot; height=&quot;&quot; alt=&quot;HIG Dynamic Type defines seven non-accessibility size categories with semantic text styles&quot; title=&quot;HIG Dynamic Type defines seven non-accessibility size categories with semantic text styles&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;HIG Dynamic Type defines seven non-accessibility size categories with semantic text styles&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;However, there’s a similar time lag (measured in years) between SDK support and true developer tooling support that exists between developer tooling support and designer tooling support for all these features. There probably always will be. Users will adopt them and demand them faster than our tools will support them.&lt;/p&gt;

&lt;h2 id=&quot;the-mindset-shift&quot;&gt;The mindset shift&lt;/h2&gt;

&lt;p&gt;But going back to print design.&lt;/p&gt;

&lt;p&gt;I love (well, maybe love/hate) designing apps, but what I love more is working with talented designers. There’s something about the design field that draws individuals with a tenacious eye for detail and an equally fierce demand for seeing their work reflected with pixel perfection. And most of the time, I would interpret this desire as driven by empathy for the user and not selfishness. They know users recognize and deserve beauty. In other words, their intentions are good.&lt;/p&gt;

&lt;p&gt;The trap I’ve seen some of these designers fall into is assuming it’s enough to have empathy for &lt;em&gt;a&lt;/em&gt; user and not &lt;em&gt;all&lt;/em&gt; users of their designs. In the early iPhone era, 100% of users would see the same screen pixel-for-pixel that the designer created in their Photoshop document. In 2026, the percent of users that see a screen exactly as it was designed is ballpark closer to single digit percentages, if that.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;There are simply too many variables that affect the final visual design a user sees&lt;/strong&gt; when they open your app. Too many to optimize for pixel accuracy with a print design mindset. Dozens of screen sizes. Dozens of languages. Dark Mode. Near a dozen Dynamic Type settings. Bold text. Button shapes. Virtual home buttons. A virtual cornucopia of more esoteric accessibility options. And on iPad, not even fixed screen sizes thanks to Slide Over, Split View, and Stage Manager.&lt;/p&gt;

&lt;p&gt;And that’s not even digging into the impedance mismatches baked into the design tools themselves: system fonts displaying a little bit smaller on device than in Figma; color spaces not matching; static assets losing fidelity during export.&lt;/p&gt;

&lt;p&gt;I’ve worked alongside many incredibly talented designers over the years. Almost all of them sooner or later made the jump from print design mindset to app design mindset. It took a lot of frustrating (on both sides!) conversations.&lt;/p&gt;

&lt;p&gt;At the end of the day, as a developer, I knew the device was the final arbiter of truth. The painstakingly crafted mockups only reflected reality for the brief period of time before the app went live on App Store, and after that they were vestigial colored rectangles, decaying with a half life in the order of days not weeks. The same way that multi-page product spec lovingly crafted by the product manager is lost to the abyss of Google Docs at the end of the sprint.&lt;/p&gt;

&lt;p&gt;I knew the signs of the mindset shift, and they all came like manna from heaven. I’d start to receive mockups with loading states leading to the happy path. I’d get error messages with &lt;a href=&quot;https://developer.apple.com/design/human-interface-guidelines/&quot;&gt;HIG&lt;/a&gt;-respectful copywriting. I’d get sidecar notes in the margins pointing out fixed and flexible dimensions. I’d get line limits for text. I’d see table cells with long and short user names. With empty and populated avatar images. The apps were starting to show signs of &lt;em&gt;being apps&lt;/em&gt; before I’d actually turned them into living &amp;amp; breathing apps.&lt;/p&gt;

&lt;p&gt;More importantly, I would &lt;em&gt;stop&lt;/em&gt; receiving design review feedback about marketing copy not line breaking at the exact same words as the mockup. About using a custom alert view instead of the system one.&lt;/p&gt;

&lt;p&gt;The designers were now channeling their intrinsic motivation and eye for detail that previously went into pixel perfection into ensuring the maximum number of users with any combination of device settings would still be able to use the damn app to get their task accomplished. And hopefully be able to appreciate the fact the app looked and felt nice while they were getting things done.&lt;/p&gt;

&lt;p&gt;The inter-department friction would melt away. We’d ship faster even though we were supporting more design variables than ever before. After all, designers were taking on more of the work I’d previously been doing turning screen after lifeless screen into constraints and states and conditionals. I had more brain bandwidth to double check their assumptions, cover more edge cases, explore architectures that would keep the code from rotting, experiment with animations, and still have time to pair with them on polish. And even more-so, develop abstractions to make this work faster and easier in the future.&lt;/p&gt;

&lt;p&gt;When you make the foundational work easy, you have more time left for whimsy. Which is more fun for both the app makers and the app users.&lt;/p&gt;

&lt;h2 id=&quot;making-the-shift&quot;&gt;Making the shift&lt;/h2&gt;

&lt;p&gt;So how can a designer with a print mindset adopt an app design mindset?&lt;/p&gt;

&lt;p&gt;Half the battle is simply recognizing the differences between the two. Giving each a name. Recognizing the signs and fighting your instincts for pixel perfection.&lt;/p&gt;

&lt;p&gt;From there, I can offer a few suggestions:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Pairing with developers who &lt;em&gt;care&lt;/em&gt; about accessibility, design systems, the human interface guidelines, and most of all, users.&lt;/li&gt;
  &lt;li&gt;Immersing in Apple-sanctioned resources like the &lt;a href=&quot;https://developer.apple.com/design/human-interface-guidelines/&quot;&gt;HIG&lt;/a&gt;, &lt;a href=&quot;https://developer.apple.com/videos/&quot;&gt;WWDC videos&lt;/a&gt;, and learning how to search, read, and understand the developer &lt;a href=&quot;https://developer.apple.com/documentation&quot;&gt;documentation&lt;/a&gt;.&lt;/li&gt;
  &lt;li&gt;Immediately learning the avant-garde features of design tools that lag behind the native developer tools.&lt;/li&gt;
  &lt;li&gt;Following the unicorn designer/developer hybrid folks that work in and share wisdom from both sides of the aisle.&lt;/li&gt;
  &lt;li&gt;Gaining enough proficiency with developer (and AI?) tools to become dangerous (or just be able to update a color value or knock out a quick interactive prototype).&lt;/li&gt;
  &lt;li&gt;Visualizing apps not just as a collection of snapshots of happy path screens, but a rich journey of pushes, pops, loads, errors, mistaps, drags, interruptions, and everything in between.&lt;/li&gt;
  &lt;li&gt;Do the painful work of watching over a user’s shoulder as they tap through &lt;em&gt;your&lt;/em&gt; app on &lt;em&gt;their&lt;/em&gt; 8 year old iPhone with &lt;em&gt;their&lt;/em&gt; settings and &lt;em&gt;their&lt;/em&gt; cracked screen.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Cheers to many more happy and healthy designer 🤝 developer partnerships in the years to come.&lt;/p&gt;
</description>
        <pubDate>Thu, 29 Jan 2026 14:26:08 -0600</pubDate>
        <link>https://twocentstudios.com/2026/01/29/print-design-mindset/</link>
        <guid isPermaLink="true">https://twocentstudios.com/2026/01/29/print-design-mindset/</guid>
        
        <category>apple</category>
        
        <category>design</category>
        
        <category>ios</category>
        
        <category>commentary</category>
        
        
      </item>
    
      <item>
        <title>Closing the Loop on iOS with Claude Code</title>
        <description>&lt;p&gt;Closing the loop means giving Claude Code a way to view the output of its work. I’ll be focusing on iOS app development workflows.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 1&lt;/strong&gt; of closing the loop: &lt;strong&gt;building&lt;/strong&gt; a target so that Claude Code can see the errors and warnings. And doing so in a way that preserves the build cache (clean builds take a long time). This allows Claude Code to see its syntax errors and fix them before you review its work.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 2&lt;/strong&gt; of closing the loop: &lt;strong&gt;installing &amp;amp; launching&lt;/strong&gt; on the simulator. This saves you the step of opening Xcode and hitting build &amp;amp; run, letting you test each proposed code change right away.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 3&lt;/strong&gt; of closing the loop: reading the &lt;strong&gt;console &amp;amp; log output&lt;/strong&gt;. This allows Claude Code to proactively verify codepaths and reactively do debugging.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 4&lt;/strong&gt; of closing the loop: &lt;strong&gt;controlling &amp;amp; viewing&lt;/strong&gt; the iOS simulator. This allows Claude Code to step through entire flows, evaluate visual designs, and generate its own logs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 5&lt;/strong&gt; of closing the loop: building, installing, launching, and logging &lt;strong&gt;on device&lt;/strong&gt;. This allows you and Claude Code to test Apple Frameworks that are absent or broken on the simulator.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/closing-loop-cc-hero.jpg&quot; width=&quot;&quot; height=&quot;500&quot; alt=&quot;Building, installing, launching on the simulator from Claude Code&quot; title=&quot;Building, installing, launching on the simulator from Claude Code&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Building, installing, launching on the simulator from Claude Code&lt;/div&gt;&lt;/div&gt;

&lt;h3 id=&quot;disclaimers-before-we-start&quot;&gt;Disclaimers before we start&lt;/h3&gt;

&lt;p&gt;Agentic tooling is changing rapidly with model and agent versions. I’ll cover each step as thoroughly as I can. The strategies in this post cover about a month of work in &lt;strong&gt;December 2025&lt;/strong&gt; with Claude &lt;strong&gt;Opus 4.5&lt;/strong&gt; inside Claude Code v2.0.76 (and several versions below). I used &lt;strong&gt;Xcode 26.1 and 26.2&lt;/strong&gt; on macOS 15.7.3 mostly developing for &lt;strong&gt;iOS 26&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;This post is written for humans but can easily be adapted to a Skill or added to your CLAUDE.md file. The command structure will change based on how your project and schemes are set up. I outline a few different strategies that are useful in different situations, but you may only want to use one workflow as your default, or completely ignore certain steps altogether.&lt;/p&gt;

&lt;p&gt;If you’ve always used a manual Xcode-based flow, trying to both understand and incorporate these steps into your workflow can be intimidating. If you’ve never working with Xcode via the command line before, start with just the first step for a while. The best part about this workflow is you can seamlessly dip in and out of using Xcode and there’s no switching cost (not even needing to do clean builds).&lt;/p&gt;

&lt;p&gt;The below CLI commands also share a lot of coverage with &lt;a href=&quot;https://github.com/cameroncooke/XcodeBuildMCP&quot;&gt;XcodeBuildMCP&lt;/a&gt;, a more full-service MCP-based solution. I won’t get into the pros and cons of MCPs vs CLIs (its author has already &lt;a href=&quot;https://www.async-let.com/posts/my-take-on-the-mcp-verses-cli-debate/&quot;&gt;written about that&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;I’m specifically targeting this post to Claude Code and Opus 4.5 based on my first-hand knowledge of their combined capabilities. Other harness and model pairs will work with most of the commands in this post. The command backgrounding feature in step 3 is the only potential snag for some harnesses.&lt;/p&gt;

&lt;h2 id=&quot;step-1-building&quot;&gt;Step 1: Building&lt;/h2&gt;

&lt;p&gt;Allowing Claude Code to build after every proposed change is a requirement for agentic workflows. Like it does for human developers, the compiler catches dumb syntax errors and, with Swift concurrency, even data races. The alternative is tabbing back over to Xcode, hitting cmd+b, waiting, copying and pasting error messages into the terminal; a massive waste of human time.&lt;/p&gt;

&lt;h3 id=&quot;prerequisites&quot;&gt;Prerequisites&lt;/h3&gt;

&lt;h4 id=&quot;move-deriveddata-location-to-your-project-folder-optional&quot;&gt;Move DerivedData location to your project folder (optional)&lt;/h4&gt;

&lt;p&gt;Moving DerivedData to a location inside your project folder is perhaps an unusual suggestion, but it has several benefits for an agentic workflow:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;Permissions&lt;/strong&gt;: you’ll encounter fewer permissions dialogs when Claude is reading inside the project folder that you’re presumably running it in. Most devs expect DerivedData to be cleared regularly so it’s safe.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Git worktrees&lt;/strong&gt;: An advanced technique is to use &lt;a href=&quot;https://git-scm.com/docs/git-worktree&quot;&gt;Git worktrees&lt;/a&gt; to have independent copies of your repo. Colocating DerivedData ensures the separate repos don’t interfere with each others build artifacts.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Docs&lt;/strong&gt;: The DerivedData has a full copy of your Swift Packages, including any documentation. Claude Code can do fast greps to verify syntax or find examples. In my CLAUDE.md I have a direct link for each important package:&lt;/li&gt;
&lt;/ul&gt;

&lt;div class=&quot;language-markdown highlighter-rouge&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;p&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;sb&quot;&gt;`DerivedData/train-timetable/SourcePackages/checkouts/swift-composable-architecture/Sources/ComposableArchitecture/Documentation.docc`&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;-&lt;/span&gt; When using Search/Grep/etc. tools, ignore anything in the /DerivedData folder by default unless specifically looking for build artifacts or code/docs for Swift Packages used by this project
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;p&gt;Before making this change, add &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;DerivedData/&lt;/code&gt; as a line in your &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.gitignore&lt;/code&gt; file if it’s not already there.&lt;/p&gt;

&lt;p&gt;Find the setting in Xcode Settings -&amp;gt; Locations. Set Derived Data to “Relative” and Build Location to “Unique”. It will report &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;/DerivedData&lt;/code&gt; as the location.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/closing-loop-cc-deriveddata-settings.jpg&quot; width=&quot;&quot; height=&quot;300&quot; alt=&quot;DerivedData settings in Xcode&quot; title=&quot;DerivedData settings in Xcode&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;DerivedData settings in Xcode&lt;/div&gt;&lt;/div&gt;

&lt;h4 id=&quot;document-project-file--scheme&quot;&gt;Document project file &amp;amp; scheme&lt;/h4&gt;

&lt;p&gt;Build commands use your project/workspace file location and scheme name. Claude Code can find these pretty easily with tools but it’s faster to document them in CLAUDE.md.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/closing-loop-cc-scheme-selection.jpg&quot; width=&quot;&quot; height=&quot;400&quot; alt=&quot;Finding scheme names from Xcode&quot; title=&quot;Finding scheme names from Xcode&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Finding scheme names from Xcode&lt;/div&gt;&lt;/div&gt;

&lt;h4 id=&quot;get-simulators&quot;&gt;Get simulators&lt;/h4&gt;

&lt;p&gt;I’ll assume you’ve already downloaded the iOS simulators and iOS runtime versions you’d like to use in the Xcode interface.&lt;/p&gt;

&lt;p&gt;The usual command you’ll use to find the simulator you want produces a very long list, so it’s reasonable to cache your favorite simulator’s UDID so each new Claude Code session doesn’t need do this from scratch each time. “Cache” meaning note it in your CLAUDE.md file, add it as an environment variable, etc.&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;# Get all available simulators
xcrun simctl list devices available

     -- iOS 26.1 --
         iPhone 17 Pro (89F6D0BC-E855-4BF7-A400-9C19ED7A7350) (Shutdown)
         iPhone 17 Pro Max (F1FA81FA-ED32-40C4-BD78-753254D685AC) (Shutdown)
         iPhone Air (77702E5F-85F5-4997-BA14-BC8D8F639B84) (Shutdown)
		 ...
     -- iOS 26.2 --
         iPhone 17 Pro (DB0531E0-B47E-42AC-9AAB-FEB76D3D563A) (Booted)
         iPhone 17 Pro Max (0C54CF4B-8A45-450E-AB93-B800B97BD4DA) (Shutdown)
         iPhone Air (83BECA5F-7894-4705-B198-3DCAE0C4778E) (Shutdown)
         ...
     -- ...
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;p&gt;At first, you’ll probably start with a single threaded workflow, having one preferred simulator booted and in use at a time. The below command will output just the UDID for the latest iPhone Pro with the latest installed iOS version.&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;# Get the UDID of the latest iPhone Pro (non-max) model with the latest available iOS version 
xcrun simctl list devices available | grep &quot;iPhone.*Pro (&quot; | tail -1 | grep -Eo &apos;[A-F0-9]{8}-[A-F0-9]{4}-[A-F0-9]{4}-[A-F0-9]{4}-[A-F0-9]{12}&apos;
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;p&gt;Once you have more Claudes running in parallel, you’ll can ask each to find its own UDID by looking for a non-booted simulator before it starts building.&lt;/p&gt;

&lt;p&gt;Note that even though some commands can be run with more vague identifiers like name, os, or “booted”, it’s much more reliable to select a UDID and use it across commands. For example: specifying &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;&quot;platform=iOS Simulator,name=iPhone 17 Pro&quot;&lt;/code&gt; in the build command will pick any simulator that matches, which could be any iOS version. For the build command it’s not as big of an issue, but to ensure predictable runs I recommend using UDIDs only.&lt;/p&gt;

&lt;h4 id=&quot;install-xcsift&quot;&gt;Install xcsift&lt;/h4&gt;

&lt;p&gt;&lt;a href=&quot;https://github.com/ldomaradzki/xcsift&quot;&gt;xcsift&lt;/a&gt; is a companion parsing library for build output. You’ll be building &lt;em&gt;a lot&lt;/em&gt;, and you don’t want to fill up your context with hundreds of lines of “file.swift built”. xcsift solves this by producing just the actionable errors and warnings in json.&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;brew install xcsift
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;p&gt;I add the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;-w&lt;/code&gt; flag to also include warnings in the output. I recommend browsing the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;xcsift&lt;/code&gt; docs to find other flags you might find useful for your project.&lt;/p&gt;

&lt;h3 id=&quot;building&quot;&gt;Building&lt;/h3&gt;

&lt;p&gt;OK, after all that setup, we should have all the info we need to assemble the actual build command.&lt;/p&gt;

&lt;p&gt;Claude Code will be able to derive and customize the exact build command you need. I recommend doing a quick session with Claude - the goal being to produce a single, always-working command you can document somewhere and use automatically in each future session.&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;# Example: working build command for a specific simulator
xcodebuild -project train-timetable.xcodeproj -scheme &quot;train-timetable&quot; -destination &quot;platform=iphonesimulator,id=DB0531E0-B47E-42AC-9AAB-FEB76D3D563A&quot; -derivedDataPath DerivedData -configuration Debug build 2&amp;gt;&amp;amp;1 | xcsift -w
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;-project&lt;/code&gt;&lt;/strong&gt;: path to your xcodeproj file. Use &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;-workspace&lt;/code&gt; if you use an xcworkspace file.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;-scheme&lt;/code&gt;&lt;/strong&gt;: scheme name we found above.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;-destination&lt;/code&gt;&lt;/strong&gt;: for simulator, we use &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;&quot;platform=iphonesimulator,id=$UDID&quot;&lt;/code&gt; where &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;id&lt;/code&gt; is the UDID of our favorite simulator instance. Note &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;platform=iOS Simulator&lt;/code&gt; has the same meaning and also works.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;-derivedDataPath&lt;/code&gt;&lt;/strong&gt;: this is super important if you’ve moved the DerivedData to the project folder. Without this, the Xcode instance will be using a different directory and you’ll have super slow (clean) builds each time.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;-configuration&lt;/code&gt;&lt;/strong&gt;: &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Debug&lt;/code&gt; is the default, so you don’t usually need this flag. It’s better to be explicit though because this affects the folder where your app binary will be copied to (see step 2).&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;build&lt;/strong&gt;: the actual build command&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;2&amp;gt;&amp;amp;1 | xcsift -w&lt;/code&gt;&lt;/strong&gt;: combines stdout and stderr and pipes them both into &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;xcsift&lt;/code&gt; so it has access to all output. &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;-w&lt;/code&gt; tells &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;xcsift&lt;/code&gt; to also show build warnings, not just errors.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It’s important to test your ideal build command to confirm:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;It doesn’t force a clean build each time.&lt;/li&gt;
  &lt;li&gt;It produces a concise set of errors and warnings.&lt;/li&gt;
  &lt;li&gt;It doesn’t interfere with builds via Xcode; you should be able to build/install/run from Xcode, use other SourceKit features, etc. and not clear the build cache.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In my understanding for builds, the simulator UDID (or at least simulator name) does not affect the build artifacts or app binary. However, there’s a lot going on behind the scenes so for simplicity I recommend using the same simulator UDID across all build, install, &amp;amp; launch steps. You need to be careful if running multiple simulators from the same project folder (i.e. without git worktrees) because each &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;build&lt;/code&gt; command will overwrite the app binary regardless of whether you run the build command with different simulator UDIDs.&lt;/p&gt;

&lt;h3 id=&quot;clearing-deriveddata&quot;&gt;Clearing DerivedData&lt;/h3&gt;

&lt;p&gt;When left to its own devices (literally), sometimes Claude will get frustrated when a build is failing continuously and it can’t figure out how to fix things. It will sometimes try to remove the entire DerivedData folder. This is a bad idea because 1. clearing DerivedData usually doesn’t fix the underlying problem and 2. it will temporarily break Xcode’s ability to read your Swift packages and you’ll need to restart Xcode to get everything working again.&lt;/p&gt;

&lt;p&gt;After I got my build commands more streamlined, Claude stopped doing this as much. But I still have decently strict permissions, so when it does happen, the session will usually block on any &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;rm&lt;/code&gt; command and I’ll get a chance to step in and reprimand it. If you run into this problem, you can dig deeper into a configuration-based solution and modify your permissions, add hooks, or add more to your CLAUDE.md. Just something to look out for.&lt;/p&gt;

&lt;h2 id=&quot;step-2-installing--launching&quot;&gt;Step 2: Installing &amp;amp; Launching&lt;/h2&gt;

&lt;p&gt;Building should streamline a lot of your Claude Code workflow. But I slept on the automated install &amp;amp; launch step for too long.&lt;/p&gt;

&lt;p&gt;When properly set up with step 1, you should be able to wait for Claude Code to build its changes and return control to you. Then you can tab over to Xcode and hit “run without building” to handle the install &amp;amp; launch.&lt;/p&gt;

&lt;p&gt;But when you’re doing build-&amp;gt;install-&amp;gt;launch dozens of times a day, it’s way more streamlined to check Claude’s session output then tab over to the simulator and tap through screens to test out the changes.&lt;/p&gt;

&lt;h3 id=&quot;prerequisites-1&quot;&gt;Prerequisites&lt;/h3&gt;

&lt;h4 id=&quot;document-the-app-binary-location-for-your-scheme&quot;&gt;Document the app binary location for your scheme&lt;/h4&gt;

&lt;p&gt;Ask Claude to find the location of the app binary produced by the build command.&lt;/p&gt;

&lt;p&gt;Since my setup has DerivedData in the project folder and a build directory inside it, that’s where my app binary is: &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;DerivedData/Build/Products/Debug-iphonesimulator/Eki Bright.app&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;You’ll notice the folder: &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Debug-iphonesimulator&lt;/code&gt;, which corresponds to our build configuration of &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Debug&lt;/code&gt; from earlier and the platform &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;iphonesimulator&lt;/code&gt;. If you go off the beaten path and want to try out a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Release&lt;/code&gt; build for example, make sure to understand this relationship.&lt;/p&gt;

&lt;p&gt;Also be cautious because you want to make sure the most recent build is what you’re installing and launching and looking at. And there’s nothing in the file name that will indicate that.&lt;/p&gt;

&lt;h4 id=&quot;find-your-bundle-identifier&quot;&gt;Find your bundle identifier&lt;/h4&gt;

&lt;p&gt;This will be in your Target’s general settings pane beside Bundle Identifier: &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;com.twocentstudios.train-timetable&lt;/code&gt;&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/closing-loop-cc-bundle-id.jpg&quot; width=&quot;&quot; height=&quot;400&quot; alt=&quot;Bundle identifier in Xcode target settings&quot; title=&quot;Bundle identifier in Xcode target settings&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Bundle identifier in Xcode target settings&lt;/div&gt;&lt;/div&gt;

&lt;h3 id=&quot;installing-the-app-on-the-simulator&quot;&gt;Installing the app on the simulator&lt;/h3&gt;

&lt;p&gt;Installing is copying over the app binary into a specific simulator’s storage. &lt;strong&gt;This step depends on the build step having produced an app binary.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The parameters in the install command are:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;UDID&lt;/strong&gt;: for least headaches this should be the same simulator UDID you specified in the build command.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;path/to/My App.app&lt;/strong&gt;: the app binary location specified by your scheme.&lt;/li&gt;
&lt;/ul&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;# Example: install a previously built app binary on the simulator by UDID
xcrun simctl install DB0531E0-B47E-42AC-9AAB-FEB76D3D563A &quot;DerivedData/Build/Products/Debug-iphonesimulator/Eki Bright.app&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;h3 id=&quot;launching-the-app-on-the-simulator&quot;&gt;Launching the app on the simulator&lt;/h3&gt;

&lt;p&gt;Launching the equivalent of tapping your app’s icon in Springboard. It of course depends on the install step having copied over the app binary.&lt;/p&gt;

&lt;p&gt;The parameters in the install command are:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;UDID&lt;/strong&gt;: the simulator UDID you specified in the build &amp;amp; install commands.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;bundle id&lt;/strong&gt;: apps are uniquely identified by bundle id after installation.&lt;/li&gt;
&lt;/ul&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;# Example: launch a previously installed app binary by bundle id
xcrun simctl launch DB0531E0-B47E-42AC-9AAB-FEB76D3D563A com.twocentstudios.train-timetable
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;h3 id=&quot;navigating-by-url-bonus&quot;&gt;Navigating by URL (bonus)&lt;/h3&gt;

&lt;p&gt;Depending on your app’s navigation structure and pre-existing support for universal links or App Intents, you can save yourself even more time by having Claude automatically navigate the app to the tab, sheet, or navigation destination you’re currently testing.&lt;/p&gt;

&lt;p&gt;The full set of caveats is beyond the scope of this post. In my experience, adding Universal Links support without some caution can lead to giving Claude access to data or flows that are impossible for normal app users to see. It may also add maintenance burden for initializers that are only used during debug. Regardless, jumping through a dozen screens automatically can save you hours of unnecessary manual screen-clicking labor.&lt;/p&gt;

&lt;p&gt;The parameters for the openurl command are:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;UDID&lt;/strong&gt;: the simulator UDID you specified in the build &amp;amp; install commands.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;URL&lt;/strong&gt;: the deep link URL your app knows how to process.&lt;/li&gt;
&lt;/ul&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;xcrun simctl openurl DB0531E0-B47E-42AC-9AAB-FEB76D3D563A &quot;train-timetable://tab?name=search&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;p&gt;In my testing, it’s safe to have Claude to run the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;openurl&lt;/code&gt; command &lt;em&gt;immediately&lt;/em&gt; after the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;install&lt;/code&gt; command (or even in the same line) without needing a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;sleep&lt;/code&gt; or otherwise waiting for the launch to complete.&lt;/p&gt;

&lt;h2 id=&quot;step-3-reading-console--log-output&quot;&gt;Step 3: Reading Console &amp;amp; Log Output&lt;/h2&gt;

&lt;p&gt;With Step 1, Claude has access to the compiler’s evaluation of its code changes. We can also give Claude access to the console and log outputs so it can evaluate the runtime results.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;There are two strategies&lt;/strong&gt;: console output via &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;print&lt;/code&gt; statements and log output via &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;OSLog&lt;/code&gt;/&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Logger&lt;/code&gt;. I use both depending on the situation.&lt;/p&gt;

&lt;p&gt;Depending on the strategy, we’ll either amend the launch command from step 2 or prepend a CLI command.&lt;/p&gt;

&lt;h3 id=&quot;blocking-vs-non-blocking&quot;&gt;Blocking vs. non-blocking&lt;/h3&gt;

&lt;p&gt;Claude can do &lt;em&gt;blocking&lt;/em&gt; and &lt;em&gt;non-blocking&lt;/em&gt; for the console variant, and &lt;em&gt;non-blocking&lt;/em&gt;-only for the log output.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Blocking&lt;/em&gt; means that the prompt input and Claude’s thinking will be suspended until you explicitly stop it or the default timeout (currently 10 minutes) triggers. It will print output from the command inline, but usually truncate portions.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Non-blocking&lt;/em&gt; means Claude will use its background capability to keep the command alive and retain access to its output but immediately move the command to the background so that the prompt input is available.&lt;/p&gt;

&lt;p&gt;I recommend the &lt;em&gt;blocking&lt;/em&gt; flow for when you want to add a few quick print statements to verify a limited (maybe less than 15 seconds) code execution flow that you, the human, are driving in the simulator and have Claude immediately evaluate the results inline. The amount of lines generated should be small, within 10s of lines.&lt;/p&gt;

&lt;p&gt;I recommend &lt;em&gt;non-blocking&lt;/em&gt; for all other scenarios, including:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;when you want Claude to drive the simulator (discussed in step 4) while monitoring the output.&lt;/li&gt;
  &lt;li&gt;when you want to use &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Logger&lt;/code&gt; instead of &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;print&lt;/code&gt; logging, probably for more permanent logging code in your codebase.&lt;/li&gt;
  &lt;li&gt;when you’re expecting to generate dozens or hundreds of lines of logs in a single run. In order to be smart about preserving the session context, you’ll want to write to a file and allow either a subagent to extract meaning from it, or have the primary agent use parsing tools to read only the relevant portions.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3 id=&quot;--terminate-running-process&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;--terminate-running-process&lt;/code&gt;&lt;/h3&gt;

&lt;p&gt;Adding the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;--terminate-running-process&lt;/code&gt; flag to &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;launch&lt;/code&gt; ensures idempotency by terminating any existing instance of your app and ensuring the app is always cold launched with the console output available.&lt;/p&gt;

&lt;p&gt;Adding the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;--terminate-running-process&lt;/code&gt; is super important to the logging flow since you may not be rebuilding and reinstalling between launches.&lt;/p&gt;

&lt;p&gt;When you don’t terminate an existing process, the app instance will stay in memory on the simulator. By default, the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;launch&lt;/code&gt; command will &lt;strong&gt;not&lt;/strong&gt; relaunch the app if it’s already launched. This will happen silently. Critically, it will also &lt;strong&gt;not&lt;/strong&gt; read any console output and Claude will get very confused about why nothing is being logged and it will start thrashing and making very dumb changes, ranging from adding more print commands to clearing DerivedData.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;(This was my biggest roadblock in getting a reliable and robust debugging flow with Claude; please learn from my mistakes).&lt;/strong&gt;&lt;/p&gt;

&lt;h3 id=&quot;launching-the-app-on-simulator-and-reading-the-output&quot;&gt;Launching the app on simulator and reading the output&lt;/h3&gt;

&lt;p&gt;Replace the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;launch&lt;/code&gt; commands from step 2 with any of the below variants depending on your use case.&lt;/p&gt;

&lt;h4 id=&quot;blocking-consoleprint-direct-when-you-know-output-volume-is-reasonable&quot;&gt;Blocking console/print direct (when you know output volume is reasonable)&lt;/h4&gt;

&lt;p&gt;Relevant flags and parameters:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;--console-pty&lt;/code&gt;&lt;/strong&gt;: produce console print output.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;--terminate-running-process&lt;/code&gt;&lt;/strong&gt;: as discussed above, ensure the command actually runs.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;UDID&lt;/strong&gt; - the simulator UDID you specified in the build &amp;amp; install commands.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;bundle id&lt;/strong&gt; - bundle id of the target that produces the app binary.&lt;/li&gt;
&lt;/ul&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;# Example with simulator UDID and bundle id
xcrun simctl launch --console-pty --terminate-running-process DB0531E0-B47E-42AC-9AAB-FEB76D3D563A com.twocentstudios.train-timetable
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;p&gt;Note: the flags &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;--stdout --stderr&lt;/code&gt; do not work. Don’t use them. Use &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;--console-pty&lt;/code&gt; instead.&lt;/p&gt;

&lt;h4 id=&quot;blocking-consoleprint-to-file-safer-for-unknown-or-expected-heavy-output&quot;&gt;Blocking console/print to file (safer for unknown or expected heavy output)&lt;/h4&gt;

&lt;p&gt;Relevant flags and parameters:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;--console-pty&lt;/code&gt;&lt;/strong&gt;: produce console print output.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;--terminate-running-process&lt;/code&gt;&lt;/strong&gt;: as discussed above, ensure the command actually runs.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;UDID&lt;/strong&gt;: the simulator UDID you specified in the build &amp;amp; install commands.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;bundle id&lt;/strong&gt;: bundle id of the target that produces the app binary.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;output file path&lt;/strong&gt;: the plain text file console output will be written to. Note: I write to a tmp folder within DerivedData to ensure Claude has access to the result without triggering unnecessary permissions dialogs.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;2&amp;gt;&amp;amp;1&lt;/code&gt;&lt;/strong&gt;: ensure stdout &amp;amp; stderr both end up in the file.&lt;/li&gt;
&lt;/ul&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;xcrun simctl launch --console-pty --terminate-running-process DB0531E0-B47E-42AC-9AAB-FEB76D3D563A com.twocentstudios.train-timetable &amp;gt; DerivedData/tmp/console.log 2&amp;gt;&amp;amp;1
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;h4 id=&quot;non-blocking-consoleprint&quot;&gt;Non-blocking console/print&lt;/h4&gt;

&lt;p&gt;Non-blocking requires using Claude Code’s &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;run_in_background&lt;/code&gt; parameter on the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Bash&lt;/code&gt; tool. This will produce a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;task_id&lt;/code&gt; that Claude can later use to get the output (from an implicitly created text file) and kill the task.&lt;/p&gt;

&lt;p&gt;After running the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Bash&lt;/code&gt; tool, the prompt will be unblocked and you can ask Claude to monitor the output or ask it do anything else you want.&lt;/p&gt;

&lt;p&gt;The non-blocking flow requires a bit more ceremony; you’ll need to tell Claude when you’re done working with the simulator and it should analyze the results. It usually leaves the background task running (potentially writing log data to the output), so you’ll need to specifically tell it to stop.&lt;/p&gt;

&lt;p&gt;The command itself is the same as the one from &lt;em&gt;Blocking console/print direct&lt;/em&gt;.&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;Bash(
  command: &quot;xcrun simctl launch --console-pty --terminate-running-process DB0531E0-B47E-42AC-9AAB-FEB76D3D563A com.twocentstudios.train-timetable&quot;,
  run_in_background: true
)

Command running in background with ID: b8e2ca5.

# *wait for next user prompt*

TaskOutput(task_id: &quot;b8e2ca5&quot;)
KillShell(shell_id: &quot;b8e2ca5&quot;)
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;h4 id=&quot;non-blocking-loggeroslog&quot;&gt;Non-blocking Logger/OSLog&lt;/h4&gt;

&lt;p&gt;With only its training data, Claude knows how to use &lt;a href=&quot;https://developer.apple.com/documentation/os/logging&quot;&gt;Logging&lt;/a&gt; by importing the OSLog framework. OSLog has strengths and weaknesses compared to console/print logging. You may already be using it in your app. I consider it more of a long term solution you’d add to your codebase alongside each feature and keep it up to date with any changes.&lt;/p&gt;

&lt;p&gt;Giving Claude access to these logs is different from the print/console flow we just discussed.&lt;/p&gt;

&lt;p&gt;The root command is &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;xcrun simctl spawn&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;spawn log stream&lt;/code&gt; only captures logs emitted while it’s running (not before). If you want logs starting from launch, always run it before the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;launch&lt;/code&gt; command.&lt;/p&gt;

&lt;p&gt;Blocking on &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;spawn log stream&lt;/code&gt; doesn’t make sense because you still need to launch the app. You should dispatch it directly to the background as non-blocking.&lt;/p&gt;

&lt;p&gt;Relevant flags and parameters (Claude knows how to adjust these freely):&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;UDID&lt;/strong&gt; - the simulator UDID you specified in the build &amp;amp; install commands.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;--level&lt;/code&gt;&lt;/strong&gt;: matches the log level in your code; &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;debug&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;info&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;warning&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;error&lt;/code&gt;, etc.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;--predicate&lt;/code&gt;&lt;/strong&gt;: filters the firehose output the messages you’re interested in. Lots of options here depending on how you’ve defined &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Logger&lt;/code&gt;s and added log statements in your codebase.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;After &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;spawn log stream&lt;/code&gt; is dispatched to the background, you’ll need to launch the app with the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;launch&lt;/code&gt; command. You can choose a blocking or non-blocking &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;launch&lt;/code&gt; command.&lt;/p&gt;

&lt;p&gt;Note that the raw &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;spawn log stream&lt;/code&gt; command is not actually monitoring the specific app process. You can start this early in your session, cast a wide net, and keep this running through your whole session, asking Claude to filter the relevant time periods from the output. I personally haven’t needed this flow though.&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;Bash(
  command: &quot;xcrun simctl spawn DB0531E0-B47E-42AC-9AAB-FEB76D3D563A log stream --level=debug --predicate &apos;subsystem == &quot;com.twocentstudios.train-timetable&quot;&apos;)&quot;
  run_in_background: true
)

Command running in background with ID: b8e2ca5.

Bash(xcrun simctl launch --terminate-running-process DB0531E0-B47E-42AC-9AAB-FEB76D3D563A com.twocentstudios.train-timetable)

# *blocking prompt until user escapes*

TaskOutput(task_id: &quot;b8e2ca5&quot;)
KillShell(shell_id: &quot;b8e2ca5&quot;)
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;h4 id=&quot;non-blocking-consoleprint--loggeroslog&quot;&gt;Non-blocking console/print &amp;amp; Logger/OSLog&lt;/h4&gt;

&lt;p&gt;You can combine everything above and give Claude access to both console/print output and Logger/OSLog output. The &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;launch&lt;/code&gt; command can be blocking or non-blocking, but the below example is non-blocking.&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;Bash(
  command: &quot;xcrun simctl spawn DB0531E0-B47E-42AC-9AAB-FEB76D3D563A log stream --level=debug --predicate &apos;subsystem == &quot;com.twocentstudios.train-timetable&quot;&apos;)&quot;
  run_in_background: true
) 
Command running in background with ID: b8e2ca5.

Bash(
  command: &quot;xcrun simctl launch --console-pty --terminate-running-process DB0531E0-B47E-42AC-9AAB-FEB76D3D563A com.twocentstudios.train-timetable&quot;,
  run_in_background: true
)
Command running in background with ID: a792db1.

# *wait for next user prompt*
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;h2 id=&quot;step-4-controlling--viewing-the-ios-simulator&quot;&gt;Step 4: Controlling &amp;amp; Viewing the iOS simulator&lt;/h2&gt;

&lt;p&gt;Giving Claude eyes and virtual fingers to see and control the iOS simulator is where we start to reach the avant-garde. At the current (end of 2025) model &amp;amp; harness capabilities things start to go off the rails pretty quickly. I wouldn’t expect great results from Claude at tasks related to manipulating the simulator like a human, but in certain scenarios, the benefits outweigh the costs.&lt;/p&gt;

&lt;h3 id=&quot;prerequisites-2&quot;&gt;Prerequisites&lt;/h3&gt;

&lt;h4 id=&quot;axe&quot;&gt;AXe&lt;/h4&gt;

&lt;blockquote&gt;
  &lt;p&gt;&lt;a href=&quot;https://github.com/cameroncooke/AXe&quot;&gt;AXe&lt;/a&gt; is a comprehensive CLI tool for interacting with iOS Simulators using Apple’s Accessibility APIs and HID (Human Interface Device) functionality.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Claude can use AXe to manipulate the simulator through taps, swipes, button presses, and keyboard typing.&lt;/p&gt;

&lt;p&gt;Under the hood, AXe uses Facebook’s &lt;a href=&quot;https://github.com/facebook/idb&quot;&gt;idb&lt;/a&gt; CLI.&lt;/p&gt;

&lt;p&gt;Install AXe with Homebrew.&lt;/p&gt;

&lt;h4 id=&quot;image-magick-optional&quot;&gt;Image Magick (optional)&lt;/h4&gt;

&lt;blockquote&gt;
  &lt;p&gt;&lt;a href=&quot;https://github.com/ImageMagick/ImageMagick&quot;&gt;ImageMagick&lt;/a&gt;® is a free and open-source software suite, used for editing and manipulating digital images.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Claude can use ImageMagick to do some post-processing on screenshots from the simulator.&lt;/p&gt;

&lt;p&gt;Install ImageMagick with Homebrew.&lt;/p&gt;

&lt;h4 id=&quot;ffmpeg-optional&quot;&gt;FFmpeg (optional)&lt;/h4&gt;

&lt;p&gt;Claude can use the venerable &lt;a href=&quot;https://www.ffmpeg.org/&quot;&gt;FFmpeg&lt;/a&gt; CLI for advanced video manipulation use cases. You may not need it but there’s a good chance you already have it.&lt;/p&gt;

&lt;h3 id=&quot;reading-from-the-simulator&quot;&gt;Reading from the simulator&lt;/h3&gt;

&lt;p&gt;In order to navigate the simulator beyond the universal links &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;openurl&lt;/code&gt; use case we detailed above, Claude needs to be able to see the current state of the simulator.&lt;/p&gt;

&lt;p&gt;There are 3 options for this:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;Accessibility info&lt;/strong&gt; - Claude can read a hierarchical text description of the current screen using accessibility info.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Screenshots&lt;/strong&gt; - Claude can take a screenshot of the simulator and use the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Read&lt;/code&gt; tool to access its multimodal capabilities.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Video&lt;/strong&gt; - Claude can record a short video capture of the simulator, slice it up into frames, and read a few to assess an animation.&lt;/li&gt;
&lt;/ul&gt;

&lt;h4 id=&quot;accessibility-info-via-describe-ui&quot;&gt;Accessibility info via &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;describe-ui&lt;/code&gt;&lt;/h4&gt;

&lt;p&gt;The AXe command for getting the accessibility trace is:&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;axe describe-ui --udid SIMULATOR_UDID

  ...
      {
        &quot;frame&quot;: {&quot;y&quot;: 82, &quot;x&quot;: 346, &quot;width&quot;: 36, &quot;height&quot;: 36},
        &quot;AXLabel&quot;: &quot;閉じる&quot;,
        &quot;type&quot;: &quot;Button&quot;
      }
  ...
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;p&gt;The output is a big JSON array.&lt;/p&gt;

&lt;p&gt;I thought Claude would be better at understanding and navigation with text information than image information, but in practice it almost always ignored my instructions in CLAUDE.md to use &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;describe-ui&lt;/code&gt; before the screenshot flow. Perhaps there’s something in the system prompt or it’s less efficient to hunt through all the text.&lt;/p&gt;

&lt;p&gt;I also immediately ran into a &lt;a href=&quot;https://github.com/cameroncooke/AXe/issues/8&quot;&gt;reported issue&lt;/a&gt; in AXe and idb where &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;describe-ui&lt;/code&gt; does not print tab or toolbar info, perhaps only from iOS 26. This makes it very difficult to deterministically do any sort of navigation from the root in many apps.&lt;/p&gt;

&lt;p&gt;All this is to say that, at the moment, it’s more reliable to use screenshots.&lt;/p&gt;

&lt;h4 id=&quot;screenshots&quot;&gt;Screenshots&lt;/h4&gt;

&lt;p&gt;Claude can use &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;simctl&lt;/code&gt; to get screenshots.&lt;/p&gt;

&lt;p&gt;Like the other commands, I prefer to write to a tmp folder within DerivedData.&lt;/p&gt;

&lt;p&gt;Screenshots for most simulators are taken at &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;3x&lt;/code&gt; scale, but input taps and swipes are at &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;1x&lt;/code&gt;. For the dual purposes of 1. reducing the amount of calculation required to translate screen position to next tap position and 2. reducing the amount of image data that needs to be sent to and processed by Claude, I automatically resize all screenshots to &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;1x&lt;/code&gt; via &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;magick&lt;/code&gt;.&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;xcrun simctl io DB0531E0-B47E-42AC-9AAB-FEB76D3D563A screenshot DerivedData/tmp/screen.png &amp;amp;&amp;amp; magick DerivedData/tmp/screen.png -resize 33.333% DerivedData/tmp/screen_1x.png
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;h4 id=&quot;video&quot;&gt;Video&lt;/h4&gt;

&lt;p&gt;Reading live or even recorded video is currently beyond Opus 4.5’s capabilities. I’m guessing this will be a supported flow sometime in 2026, but until then analyzing video output of the simulator is still at proof-of-concept maturity.&lt;/p&gt;

&lt;p&gt;While I was debugging a tricky animation, I gave Claude some leash to test whether it could:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;start recording a short clip immediately before a tap.&lt;/li&gt;
  &lt;li&gt;stop the recording after 2 seconds.&lt;/li&gt;
  &lt;li&gt;use FFmpeg to grab 5 or 6 frames spaced out across the video.&lt;/li&gt;
  &lt;li&gt;read the frames and analyze the motion.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It sort of worked? Not really? If you have a use case, you can try experimenting more with this flow. For now I’d consider the actual animation analysis a human-only endeavor. But Claude can still help get the simulator staged up to the start screen.&lt;/p&gt;

&lt;p&gt;I believe I used this AXe command as a Claude Code background task:&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;axe record-video --udid DB0531E0-B47E-42AC-9AAB-FEB76D3D563A --fps 30 --output DerivedData/tmp/recording.mp4
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;h3 id=&quot;manipulating-the-simulator-with-taps-and-swipes&quot;&gt;Manipulating the simulator with taps and swipes&lt;/h3&gt;

&lt;p&gt;AXe has a variety of tap and gesture commands. Claude can tap on points or accessibility labels.&lt;/p&gt;

&lt;div class=&quot;language-bash highlighter-rouge&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;c&quot;&gt;# Tap at logical coordinates (use frame center from describe-ui)&lt;/span&gt;
axe tap -x 201 -y 297 --udid DB0531E0-B47E-42AC-9AAB-FEB76D3D563A --post-delay 0.5
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;p&gt;Without additional guidance, &lt;strong&gt;Claude gets confused about which scroll command maps to what logical direction&lt;/strong&gt;.&lt;/p&gt;

&lt;div class=&quot;language-bash highlighter-rouge&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;c&quot;&gt;# Scroll (named by finger direction, not content direction)&lt;/span&gt;
&lt;span class=&quot;c&quot;&gt;# scroll-up = finger UP = content UP = see content BELOW = triggers .onScrollDown&lt;/span&gt;
&lt;span class=&quot;c&quot;&gt;# scroll-down = finger DOWN = content DOWN = see content ABOVE&lt;/span&gt;
axe gesture scroll-down --udid DB0531E0-B47E-42AC-9AAB-FEB76D3D563A --post-delay 0.5
axe gesture scroll-up --udid DB0531E0-B47E-42AC-9AAB-FEB76D3D563A --post-delay 0.5
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;p&gt;It’s useful to note the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;swipe-from-left-edge&lt;/code&gt; gesture because it’s the quickest way for Claude to pop back a level in a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;NavigationStack&lt;/code&gt;.&lt;/p&gt;

&lt;div class=&quot;language-bash highlighter-rouge&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;c&quot;&gt;# Edge swipes (for back navigation, etc.)&lt;/span&gt;
axe gesture swipe-from-left-edge --udid DB0531E0-B47E-42AC-9AAB-FEB76D3D563A --post-delay 0.5
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;h3 id=&quot;strategies-for-increasing-tap-accuracy&quot;&gt;Strategies for increasing tap accuracy&lt;/h3&gt;

&lt;p&gt;The most significant source of indeterministic behavior is in Claude’s ability to accurately measure of coordinates on screen. In other words, it can’t read an image and always find the center point of a button. This means there is plenty of opportunity for situations like:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Claude reads a screen and wants to tap a button.&lt;/li&gt;
  &lt;li&gt;Claude makes a bad guess and taps above the button.&lt;/li&gt;
  &lt;li&gt;Claude reads the screen again. There was no change.&lt;/li&gt;
  &lt;li&gt;Claude makes another bad guess and taps below the button.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Claude’s only feedback about whether its tap was successful is based on its next screenshot. This can lead to situations where it gets irrecoverably lost while navigating your app:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Claude reads a screen and wants to tap a button.&lt;/li&gt;
  &lt;li&gt;Claude makes a bad guess and taps above the button, hitting a completely different button.&lt;/li&gt;
  &lt;li&gt;Claude reads the screen again.&lt;/li&gt;
  &lt;li&gt;Claude sees it’s on a different screen than expected and becomes confused.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I came up with an experimental flow to try to improve Claude’s accuracy, but:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;It slows down the entire process by 2x.&lt;/li&gt;
  &lt;li&gt;By the time Claude realizes it needs to use the experimental flow, it’s already too far lost to recover.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Regardless, my flow, also using ImageMagick, is to make Claude draw a red circle on a screenshot in its targeted tap location before actually performing the tap:&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;Take screenshot and resize to 1x (so pixels = points):
    &lt;div class=&quot;language-bash highlighter-rouge&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;xcrun simctl io DB0531E0-B47E-42AC-9AAB-FEB76D3D563A screenshot DerivedData/tmp/screen.png &lt;span class=&quot;o&quot;&gt;&amp;amp;&amp;amp;&lt;/span&gt; magick DerivedData/tmp/screen.png -resize 33.333% DerivedData/tmp/screen_1x.png
&lt;/code&gt;&lt;/pre&gt;
    &lt;/div&gt;
  &lt;/li&gt;
  &lt;li&gt;Read the 1x image and estimate target element center in points&lt;/li&gt;
  &lt;li&gt;Verify guess by drawing a red box at those coordinates:
    &lt;div class=&quot;language-bash highlighter-rouge&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;magick DerivedData/tmp/screen_1x.png -fill none -stroke red -strokewidth 2 -draw &lt;span class=&quot;s2&quot;&gt;&quot;rectangle &lt;/span&gt;&lt;span class=&quot;k&quot;&gt;$((&lt;/span&gt;X-30&lt;span class=&quot;k&quot;&gt;))&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;$((&lt;/span&gt;Y-30&lt;span class=&quot;k&quot;&gt;))&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt; &lt;/span&gt;&lt;span class=&quot;k&quot;&gt;$((&lt;/span&gt;X+30&lt;span class=&quot;k&quot;&gt;))&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;$((&lt;/span&gt;Y+30&lt;span class=&quot;k&quot;&gt;))&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;&lt;/span&gt; DerivedData/tmp/screen_marked.png
&lt;/code&gt;&lt;/pre&gt;
    &lt;/div&gt;
  &lt;/li&gt;
  &lt;li&gt;Read marked image to check if box is on target&lt;/li&gt;
  &lt;li&gt;If missed, adjust coordinates and repeat from step 3&lt;/li&gt;
  &lt;li&gt;If correct, tap at the verified coordinates&lt;/li&gt;
&lt;/ol&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/closing-loop-cc-tap-accuracy.jpg&quot; width=&quot;&quot; height=&quot;400&quot; alt=&quot;Tap accuracy verification flow - original screenshot, marked target, and result after tap&quot; title=&quot;Tap accuracy verification flow - original screenshot, marked target, and result after tap&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Tap accuracy verification flow - original screenshot, marked target, and result after tap&lt;/div&gt;&lt;/div&gt;

&lt;h3 id=&quot;putting-it-all-together&quot;&gt;Putting it all together&lt;/h3&gt;

&lt;p&gt;So far in practice, I’ve used this capability alongside universal links to fix and verify simple visual bugs. I have Claude craft input to reproduce the error and find the screen where it occurs, take a “before” screenshot, implement the fix, build/install/launch, then find the same screen, verify the fix, and take an “after” screenshot.&lt;/p&gt;

&lt;p&gt;It takes way longer for Claude to do than me, but it’s mostly tedious work, and I’m usually in another tab working on a plan with another Claude. When I come back and see the before and after screenshots alongside the code change, I can feel confident in Claude’s work.&lt;/p&gt;

&lt;h2 id=&quot;step-5-building-installing-launching-reading-output-on-a-physical-device&quot;&gt;Step 5: Building, Installing, Launching, Reading Output on a Physical Device&lt;/h2&gt;

&lt;p&gt;Finally, for those Apple SDKs that only work on device, for ensuring observed buggy behavior isn’t just a simulator quirk, or just to get a more realistic look at our apps in context, we can implement steps 1, 2, and 3 on a physical device. Unfortunately, as far as I can tell, there’s no way to control a physical device via CLI tool, so step 4 is out reach for now.&lt;/p&gt;

&lt;p&gt;However, building, installing, launching, and logging can still save some time and annoyance during iterative debugging sessions. It’s especially useful to have Claude help analyze logs for (underdocumented) frameworks like Core Location that behave wildly different on a real device than on the simulator.&lt;/p&gt;

&lt;p&gt;Below is a collection of tested CLI commands for doing all the above tasks on a physical device.&lt;/p&gt;

&lt;p&gt;Note that in my testing all the relevant commands below work equally for devices on the same network and devices connected directly to your Mac via USB.&lt;/p&gt;

&lt;h3 id=&quot;prerequisites-3&quot;&gt;Prerequisites&lt;/h3&gt;

&lt;h4 id=&quot;get-devices&quot;&gt;Get devices&lt;/h4&gt;

&lt;p&gt;The device &lt;strong&gt;Name&lt;/strong&gt; and &lt;strong&gt;Identifier&lt;/strong&gt; are both important for on-device debugging. &lt;strong&gt;State&lt;/strong&gt; will be &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;available&lt;/code&gt; when Wi-Fi debugging is available, and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;connected&lt;/code&gt; when directly connect via USB.&lt;/p&gt;

&lt;div class=&quot;language-bash highlighter-rouge&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;c&quot;&gt;# Get all connected devices&lt;/span&gt;
xcrun devicectl list devices

Name          Hostname                      Identifier                             State                Model                                
-----------   ---------------------------   ------------------------------------   ------------------   -------------------------------------
CT&lt;span class=&quot;s1&quot;&gt;&apos;s iPhone   CTs-iPhone.coredevice.local   ABCDEF01-1111-5555-AAAA-F7D81A900001   connected (no DDI)   iPhone 14 Pro (iPhone15,2)           
CT’s iPad     CTs-iPad.coredevice.local     ABCDEF01-2222-6666-BBBB-F44A19F00002   available            iPad Pro (11-inch) (iPad8,1)         
CT’s iPad     CTs-iPad-1.coredevice.local   ABCDEF01-3333-7777-CCCC-D33889000003   unavailable          iPad mini (5th generation) (iPad11,1)
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;p&gt;Some example use cases for parsing out values in one go:&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;# Get the Identifier of the first accessible iPhone (WiFi or USB)
xcrun devicectl list devices | grep &quot;iPhone&quot; | grep -E &quot;(available|connected)&quot; | head -1 | grep -Eo &apos;[A-F0-9]{8}-[A-F0-9]{4}-[A-F0-9]{4}-[A-F0-9]{4}-[A-F0-9]{12}&apos;

ABCDEF01-1111-5555-AAAA-F7D81A900001
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;# Get the name of the first accessible iPhone (WiFi or USB)
xcrun devicectl list devices | grep &quot;iPhone&quot; | grep -E &quot;(available|connected)&quot; | head -1 | awk -F&apos;  +&apos; &apos;{print $1}&apos;

CT&apos;s iPhone
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;h3 id=&quot;build-for-device&quot;&gt;Build for device&lt;/h3&gt;

&lt;p&gt;Build commands are the same as those for the simulator, except &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;platform=iOS&lt;/code&gt; instead of &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;platform=iphonesimulator&lt;/code&gt;/&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;platform=iOS Simulator&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Use the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;name&lt;/code&gt; of your target device from the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;list devices&lt;/code&gt; command.&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;# Using device name
xcodebuild -project train-timetable.xcodeproj -scheme &quot;train-timetable&quot; -destination &quot;platform=iOS,name=CT&apos;s iPhone&quot; -derivedDataPath DerivedData build 2&amp;gt;&amp;amp;1 | xcsift -w
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;p&gt;Note: &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;platform=iphoneos&lt;/code&gt; does not work. &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;id&lt;/code&gt; instead of &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;name&lt;/code&gt; does not work.&lt;/p&gt;

&lt;h3 id=&quot;install-on-device&quot;&gt;Install on device&lt;/h3&gt;

&lt;p&gt;Install commands use &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;devicectl&lt;/code&gt; but are the similar to those for the simulator, except &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;--device&lt;/code&gt; should use the device ID, and the build product directory should use &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Debug-iphoneos&lt;/code&gt; instead of &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Debug-iphonesimulator&lt;/code&gt;.&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;xcrun devicectl device install app --device E7E3E660-9E7A-5814-8BBB-F7D81A965CEB &quot;DerivedData/Build/Products/Debug-iphoneos/Eki Bright.app&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;h3 id=&quot;launch-on-device&quot;&gt;Launch on device&lt;/h3&gt;

&lt;p&gt;The vanilla launch command is below. I again recommend using &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;--terminate-existing&lt;/code&gt;, the device equivalent of the simulator’s &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;--terminate-running-process&lt;/code&gt;.&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;xcrun devicectl device process launch --device E7E3E660-9E7A-5814-8BBB-F7D81A965CEB --console --terminate-existing com.twocentstudios.train-timetable
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;h3 id=&quot;blocking-consoleprint-capture-on-device&quot;&gt;Blocking console/print capture on device&lt;/h3&gt;

&lt;p&gt;For console/print capture, use the launch command above with the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;--console&lt;/code&gt; flag. It works over USB and Wi-Fi.&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;xcrun devicectl device process launch --device E7E3E660-9E7A-5814-8BBB-F7D81A965CEB --console --terminate-existing com.twocentstudios.train-timetable
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;h3 id=&quot;non-blocking-consoleprint-capture-on-device&quot;&gt;Non-blocking console/print capture on device&lt;/h3&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;# Use run_in_background: true on Bash tool
# Works over USB and WiFi
Bash(
  command: &quot;xcrun devicectl device process launch --device E7E3E660-9E7A-5814-8BBB-F7D81A965CEB --console --terminate-existing com.twocentstudios.train-timetable&quot;,
  run_in_background: true
)

Command running in background with ID: b8e2ca5.

# *wait for next user prompt*

TaskOutput(task_id: &quot;b8e2ca5&quot;)
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;h3 id=&quot;loggeroslog-capture-on-device-requires-manual-sudo&quot;&gt;Logger/OSLog capture on device (requires manual sudo)&lt;/h3&gt;

&lt;p&gt;A downside of OSLog is that it requires &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;sudo&lt;/code&gt; and Claude Code can’t use sudo commands directly. There are presumably some ways to give Claude this capability in more a dangerous fashion. But a safer workaround for now is for you, the human, to run the below commands in another terminal tab. Claude can give you the full command to copy/paste into the other terminal.&lt;/p&gt;

&lt;p&gt;Another downside is that the process is slow and produces lots of logs.&lt;/p&gt;

&lt;p&gt;These commands will produce groups of files that Claude can read.&lt;/p&gt;

&lt;p&gt;Note that the log collect is of &lt;strong&gt;everything on the device&lt;/strong&gt; in the past, and can quickly balloon to gigabytes of storage. The command to collect even the last 2 minutes of logs can take about 30 seconds to complete on Wi-Fi.&lt;/p&gt;

&lt;p&gt;Content filtering with &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;--predicate&lt;/code&gt; is not supported: &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Warning: --predicate is ignored when collecting from attached device&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Claude will handle filtering while reading/analyzing with &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;log show --predicate ...&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The most logical way to use this is to:&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;have Claude build, install, and launch the app on your device (ensure it’s unlocked), and have it note the start time.&lt;/li&gt;
  &lt;li&gt;tap around and do the testing you need in order to generate the logs you want.&lt;/li&gt;
  &lt;li&gt;have Claude give you the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;sudo log collect&lt;/code&gt; command with a start time a little before the launch time.&lt;/li&gt;
  &lt;li&gt;run the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;sudo log collect&lt;/code&gt; command in a separate terminal window, enter your password, wait ~1m for it to finish.&lt;/li&gt;
  &lt;li&gt;ask Claude to analyze the log archive.&lt;/li&gt;
&lt;/ol&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;# Human user must run these commands in another terminal tab (Claude Code can&apos;t provide sudo password)

# `--last` collects from N minutes before the command was run
sudo log collect --device-name &quot;CT&apos;s iPhone&quot; --last 2m --output DerivedData/tmp/device-logs.logarchive

# `--start` collects from the specified start time until the command was run
sudo log collect --device-name &quot;CT&apos;s iPhone&quot; --start &quot;2025-12-30 16:11:00&quot; --output DerivedData/tmp/device-logs.logarchive
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;# Then analyze with log show (Claude can do this)
log show DerivedData/tmp/device-logs.logarchive --predicate &apos;subsystem == &quot;com.twocentstudios.train-timetable&quot;&apos;
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;p&gt;Note &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;--device-udid&lt;/code&gt; does not work, use &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;--device-name instead&lt;/code&gt; - &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;log: failed to create archive: Device not configured (6)&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Note &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;--size&lt;/code&gt; does not (seem to) work either.&lt;/p&gt;

&lt;h2 id=&quot;final-thoughts&quot;&gt;Final thoughts&lt;/h2&gt;

&lt;h3 id=&quot;how-to-parameterize-names-ids-etc-for-these-commands&quot;&gt;How to parameterize names, ids, etc. for these commands&lt;/h3&gt;

&lt;p&gt;So far, I’ve just been hardcoding these commands with my favorite simulator UDID and project path into my CLAUDE.md. When a new version of Xcode comes out I ask Claude to update all mentions of the UDID to the most recent simulator version and it only takes a minute. Hardcoding these values leaves the least room for hallucination. When running these commands dozens of times a day, you really want consistency.&lt;/p&gt;

&lt;p&gt;Other ways to handle this would be:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;set environment variables at some level.&lt;/li&gt;
  &lt;li&gt;add a start hook to have Claude fill in the environment variables fresh for each session.&lt;/li&gt;
  &lt;li&gt;set up another layer of orchestration that handles the pool of simulators and dispatches an ID to each new Claude instance that requests one.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These are all beyond the scope of this post. If you work primarily on one project with others, you probably already have some tooling for specifying the Xcode version, etc.&lt;/p&gt;

&lt;h3 id=&quot;why-not-include-traditional-testing&quot;&gt;Why not include traditional testing?&lt;/h3&gt;

&lt;p&gt;Arguably, TDD was the original “closing the loop” in software development. TDD has never caught on in the iOS world.&lt;/p&gt;

&lt;p&gt;I dabbled with an actual Swift Testing-based testing flow for another &lt;a href=&quot;/2025/12/25/shinkansen-live-developing-the-app-for-ios/#ocr-and-parsing-the-ticket-image&quot;&gt;recent project&lt;/a&gt;, and even wrote about another experimental system a few months ago in &lt;a href=&quot;/2025/07/13/giving-claude-code-eyes-to-see-your-swiftui-views/&quot;&gt;Giving Claude Code Eyes to See Your SwiftUI Views&lt;/a&gt; that used snapshot testing. What I found was although tests are great for verifying correct behavior over the long term, in the short term they are super slow on iOS:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Installing and launching requires instantiating a brand new simulator for each run (e.g. &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Clone 1&lt;/code&gt;), which takes a long time.&lt;/li&gt;
  &lt;li&gt;All builds are clean builds (this could have just been a fluke in my setup at the time though).&lt;/li&gt;
  &lt;li&gt;Swift Testing does not output failures in a way that Claude can read and iterate on (again, potentially solvable).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Admittedly, I didn’t spend as much time debugging these flows. Hopefully someone else will fill in the blanks for testing and write this guide.&lt;/p&gt;

&lt;h3 id=&quot;dont-sleep-on-simctl&quot;&gt;Don’t sleep on &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;simctl&lt;/code&gt;&lt;/h3&gt;

&lt;p&gt;The &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;simctl&lt;/code&gt; CLI we’ve used throughout this post has a ton of other abilities that Claude can use to make our lives easier. This includes adding images to Photos, changing the system time, changing the system language, resetting privacy, resetting the keychain, and many more.&lt;/p&gt;

&lt;p&gt;Ask Claude to configure your simulator on the fly instead of clicking through settings with your mouse.&lt;/p&gt;

&lt;h3 id=&quot;what-does-the-future-hold&quot;&gt;What does the future hold?&lt;/h3&gt;

&lt;p&gt;I’m honestly not sure how long the hard-won knowledge in this post will be relevant, given the pace of model &amp;amp; harness capabilities. Peter Steinberger &lt;a href=&quot;https://steipete.me/posts/2025/shipping-at-inference-speed&quot;&gt;already says&lt;/a&gt; Codex is good enough and doesn’t need any additional guidance about build commands or working with the simulator.&lt;/p&gt;

&lt;p&gt;I can definitely see a world where Claude Code has a live feed of the simulator output it can process and react to at 60 fps, tapping and swiping with full accuracy. This is probably what’s missing in fully closing the development loop on iOS. Doing the same for a real device hopefully isn’t close behind.&lt;/p&gt;

&lt;p&gt;At that point though, I’m not sure what else about development will have changed.&lt;/p&gt;

&lt;h3 id=&quot;going-forward&quot;&gt;Going forward&lt;/h3&gt;

&lt;p&gt;Most of the material in this guide has been slowly compiled over the month in my various CLAUDE.md files. It was great getting a chance to formalize it even if I can’t make a quickly installable Plugin or Skill to share (hopefully you understand why after reading the post). I’m looking forward to seeing how far I can take each of these steps in the near future.&lt;/p&gt;

&lt;h3 id=&quot;corrections&quot;&gt;Corrections&lt;/h3&gt;

&lt;p&gt;Please reach out if you find any corrections or can contribute any additional knowledge or edge cases.&lt;/p&gt;

</description>
        <pubDate>Sat, 27 Dec 2025 15:37:01 -0600</pubDate>
        <link>https://twocentstudios.com/2025/12/27/closing-the-loop-on-ios-with-claude-code/</link>
        <guid isPermaLink="true">https://twocentstudios.com/2025/12/27/closing-the-loop-on-ios-with-claude-code/</guid>
        
        <category>apple</category>
        
        <category>ios</category>
        
        <category>claudecode</category>
        
        
      </item>
    
      <item>
        <title>Shinkansen Live - Developing the App for iOS</title>
        <description>&lt;p&gt;In my &lt;a href=&quot;/2025/12/24/shinkansen-live-scan-your-ticket-get-a-live-activity/&quot;&gt;last post&lt;/a&gt; I introduced the motivation and feature set of &lt;a href=&quot;https://apps.apple.com/app/id6756808516&quot;&gt;Shinkansen Live&lt;/a&gt;, my latest iOS app. I encourage you to read that one first to learn about what the app does.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/shinkansen-v1-app-icon.jpg&quot; width=&quot;&quot; height=&quot;300&quot; alt=&quot;Shinkansen Live app icon&quot; title=&quot;Shinkansen Live app icon&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Shinkansen Live app icon&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;In this post, I’ll discuss a few of the interesting development challenges I faced during its week of development from concept to App Store release.&lt;/p&gt;

&lt;h2 id=&quot;contents&quot;&gt;Contents&lt;/h2&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;a href=&quot;#overall-development-strategy&quot;&gt;Overall development strategy&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;#ocr-and-parsing-the-ticket-image&quot;&gt;OCR and parsing the ticket image&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;#live-activities&quot;&gt;Live Activities&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;#animations&quot;&gt;Animations&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;#alarmkit&quot;&gt;AlarmKit&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;#localization&quot;&gt;Localization&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;#dynamic-type&quot;&gt;Dynamic Type&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;#visionkit-camera&quot;&gt;VisionKit Camera&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h2 id=&quot;overall-development-strategy&quot;&gt;Overall development strategy&lt;/h2&gt;

&lt;p&gt;I created an Xcode project myself with Xcode 26.1 (later switching to Xcode 26.2), then added the &lt;a href=&quot;https://github.com/pointfreeco/swift-composable-architecture&quot;&gt;TCA&lt;/a&gt; package.&lt;/p&gt;

&lt;p&gt;Then, I set off to work using Claude Code with Opus 4.5. I started by having it lay out the SwiftUI View and TCA Feature without any logic. Then I built out the rest of the infrastructure around getting the input image, doing OCR, parsing the output, and displaying the results. I’ll go through more of the history later on in the post.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/shinkansen-v1-early-layouts-4panel.jpg&quot; width=&quot;&quot; height=&quot;400&quot; alt=&quot;Early view layouts during initial development&quot; title=&quot;Early view layouts during initial development&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Early view layouts during initial development&lt;/div&gt;&lt;/div&gt;

&lt;h2 id=&quot;ocr-and-parsing-the-ticket-image&quot;&gt;OCR and parsing the ticket image&lt;/h2&gt;

&lt;p&gt;The most difficult part of getting this app to production was the ticket OCR &amp;amp; parsing system. This system went through the most churn over the week, partially due to expanding scope and partially due to my expanding understanding of the problem space.&lt;/p&gt;

&lt;p&gt;My initial thought during the prototyping stage was to target only the “ticket” screenshot from Eki-net you get after purchase. It looks something like this:&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/shinkansen-v1-example-ekinet.jpg&quot; width=&quot;&quot; height=&quot;400&quot; alt=&quot;Example Eki-net ticket screenshot&quot; title=&quot;Example Eki-net ticket screenshot&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Example Eki-net ticket screenshot&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;In theory, it’d be reasonable to limit the app’s input space to app screenshots from Eki-net (JR-East) and SmartEX (JR-central). Even including web browser screenshots wouldn’t be that much more burden on an OCR-based system. But later on in the project when I’d decided I was happy enough with the prototype that I wanted to target a production release on the App Store, I started thinking about how it would make marketing much harder to say “only works on screenshots” and not physical tickets.&lt;/p&gt;

&lt;h3 id=&quot;why-ocr-why-not-multi-modal-llms&quot;&gt;Why OCR? Why not multi-modal LLMs?&lt;/h3&gt;

&lt;p&gt;OCR via &lt;a href=&quot;https://developer.apple.com/documentation/visionkit&quot;&gt;VisionKit&lt;/a&gt; alongside manual parsing has a lot of upsides:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Free for user &amp;amp; developer&lt;/li&gt;
  &lt;li&gt;Fast&lt;/li&gt;
  &lt;li&gt;Multilingual&lt;/li&gt;
  &lt;li&gt;Privacy baked in&lt;/li&gt;
  &lt;li&gt;No network usage&lt;/li&gt;
  &lt;li&gt;Relatively mature: less risk of accuracy churn&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In theory, multimodal LLMs can handle OCR and understanding more variations of tickets layouts and bad lighting. In fact, while I was writing the parser, I used Opus 4.5 to read the test ticket images in order to create the ground truth test expectation data.&lt;/p&gt;

&lt;p&gt;My issue with prototyping with LLMs further was that they had essentially the opposite pros and cons as the VisionKit system:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Unknown, but some ongoing cost (meaning I’d need to come up with a monetization strategy before release)&lt;/li&gt;
  &lt;li&gt;Unknown which level of model would correctly balance accuracy, cost, and speed over the short term.&lt;/li&gt;
  &lt;li&gt;Requires network access&lt;/li&gt;
  &lt;li&gt;Requires sending photo data off device (not hugely private, but still)&lt;/li&gt;
  &lt;li&gt;Different outputs for the same input&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What I didn’t explore directly was using the Apple Foundation model at the &lt;em&gt;parsing&lt;/em&gt; layer. The current Apple Foundation model in iOS 26 has no image input API, but I could perhaps prompt it to take the raw output of the VisionKit model and try to make sense of it. My instinct is that this would be a waste of time, but still worth keeping on the table.&lt;/p&gt;

&lt;h3 id=&quot;overall-strategy&quot;&gt;Overall strategy&lt;/h3&gt;

&lt;p&gt;While working on this, I honestly wasn’t thinking strictly in terms of prototype &amp;amp; production. From the first spark of idea I had an understanding of what the overall UX flow of the app would be. It was mostly getting to an answer of “is this feasible to productionize in a couple days?” while still being flexible on the scope of what &lt;em&gt;production-ready&lt;/em&gt; meant.&lt;/p&gt;

&lt;p&gt;That meant that I started by adding my ticket screenshot to the project, giving Claude my overall strategy, and having Claude create the OCR &amp;amp; parser system that output results directly into the UI.&lt;/p&gt;

&lt;h3 id=&quot;ocr-prototype&quot;&gt;OCR prototype&lt;/h3&gt;

&lt;p&gt;The first prototype parser supported just the two screenshots I had of Eki-net tickets (one from the morning of my trip; another from a similar trip a few months ago).&lt;/p&gt;

&lt;p&gt;The implementation set up a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;VNRecognizeTextRequest&lt;/code&gt; in Japanese language mode, read out the highest ranking results into several lines of text (&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;[String]&lt;/code&gt;), then fed that to Claude’s homegrown parser that pulled the ticket attributes out of that glob of text mostly using regex.&lt;/p&gt;

&lt;p&gt;Since the input was from a perfectly legible screenshot, there was no issues with the VisionKit part nor the parser.&lt;/p&gt;

&lt;h3 id=&quot;ocr-for-real-shinkansen-ticket-images&quot;&gt;OCR for real Shinkansen ticket images&lt;/h3&gt;

&lt;p&gt;As soon as I tested the system on a real Shinkansen ticket in a photo, the system fell apart.&lt;/p&gt;

&lt;p&gt;I searched through my personal Photo library for as many Shinkansen tickets as I could find. I googled for more. I started with 4 images (and later in development I ended up with about 10 images).&lt;/p&gt;

&lt;p&gt;At first I was simply trying to naively patch out the parser with Claude. To do this, I set up a unit test system where I’d do the VisionKit request for each image once and write the resulting &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;[String]&lt;/code&gt; data structure to disk. Then each unit test would read that data in, run the parser, and compare the expected ticket structure to the test result.&lt;/p&gt;

&lt;p&gt;Unintuitively, the standard Swift Testing unit test setup was actually the less efficient way to iterate on this. Claude was having a lot of trouble reading the detailed test failure information after it ran xcodebuild in the command line. Each build &amp;amp; run &amp;amp; test iteration needed to boot up a fresh simulator, install the app, run the test, then tear down the simulator.&lt;/p&gt;

&lt;p&gt;Instead, a built a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;@main&lt;/code&gt; App-based test harness that:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;temporarily disabled the real UI.&lt;/li&gt;
  &lt;li&gt;ran the test code on &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;onAppear&lt;/code&gt;.&lt;/li&gt;
  &lt;li&gt;printed the test results via &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;print&lt;/code&gt; statements.&lt;/li&gt;
  &lt;li&gt;called &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;exit(0)&lt;/code&gt; when finished.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For quickly prototyping, iterating, and understanding the scope of the problem space, this solved all the issues with the Swift Testing setup. The same booted simulator was reused on each run. &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;DerivedData&lt;/code&gt; and build artifacts were reused, so builds were fast. Claude had no trouble reading print statements from the console output.&lt;/p&gt;

&lt;p&gt;I let Claude run in its own loop for a while to see what it could and couldn’t improve with the parser based on the limitations of our system.&lt;/p&gt;

&lt;p&gt;Claude found several underlying limitations with the VisionKit setup that were unsolvable at the parser level.&lt;/p&gt;

&lt;p&gt;For example, concatenating all the text recognition objects into &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;[String]&lt;/code&gt; was done somewhat naively by comparing y-coordinates. If the y-coordinates of objects were within a certain range, they were assumed to be on the same line. When the ticket was tilted, this strategy was interleaving text.&lt;/p&gt;

&lt;p&gt;Additionally, some numbers and letters were just flat out being interpreted incorrectly. A &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;37&lt;/code&gt; was read as a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;30&lt;/code&gt;. &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CAR&lt;/code&gt; was read as &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CDR&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Off running on its own, Claude was trying to special case as much of these failure cases it could to get the tests passing.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/shinkansen-v1-test-ticket-04.jpg&quot; width=&quot;&quot; height=&quot;400&quot; alt=&quot;Example test ticket image that produced the Japanese-only VisionKit parsing output shown below&quot; title=&quot;Example test ticket image that produced the Japanese-only VisionKit parsing output shown below&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Example test ticket image that produced the Japanese-only VisionKit parsing output shown below&lt;/div&gt;&lt;/div&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;CC制
東
3月18日（
はくたから5り考
¥3,380
新幹線特急券
京
→
8:41発）
軽井
（9:4着）
7号車
3番B席
R001
2025.-3.18東京北乗FN7（2－）
50159-01
沢
0
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;p&gt;Comparing some of the fields, you can see the OCR output taken naively is not great:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;The departure time is there but the arrival time is &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;9:4&lt;/code&gt; instead of &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;9:43&lt;/code&gt;.&lt;/li&gt;
  &lt;li&gt;The arrival station’s &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;軽井&lt;/code&gt; and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;沢&lt;/code&gt; are split up and should be &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;軽井沢&lt;/code&gt;.&lt;/li&gt;
  &lt;li&gt;The departure station’s &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;東&lt;/code&gt; and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;京&lt;/code&gt; are split up and should be &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;東京&lt;/code&gt;.&lt;/li&gt;
  &lt;li&gt;The train name and number are a mess: &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;はくたから5り考&lt;/code&gt; instead of &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;はくたか 555号&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h3 id=&quot;using-spatial-data&quot;&gt;Using spatial data&lt;/h3&gt;

&lt;p&gt;The OCR part of the system was trying to abstract away the spatial parts from the parser. Looking at the raw data, my intuition was that the spacial data could be useful within the parsing layer. Instead of passing &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;[String]&lt;/code&gt; between layers, I was now passing:&lt;/p&gt;

&lt;div class=&quot;language-swift highlighter-rouge&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;kd&quot;&gt;struct&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;TextObservation&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Equatable&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Sendable&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;text&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;String&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;x&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Double&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;y&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Double&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;width&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Double&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;height&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Double&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;p&gt;Each field parser within the parsing system could decide for itself how to use the spacial data. This is especially useful considering how important &lt;em&gt;anchor values&lt;/em&gt; are. For example “発” (indicating a departure time) and “→” (indicating the station to the left is the departure station).&lt;/p&gt;

&lt;p&gt;This improved things a bit for 5 ticket images, but after adding another 5 and going all in on English ticket support, there were plenty more edge cases to consider.&lt;/p&gt;

&lt;h3 id=&quot;transforms&quot;&gt;Transforms&lt;/h3&gt;

&lt;p&gt;Only after seeing some wonky positioning of the text boxes in my loading animation (see below), I realized that I wasn’t accounting for the image transform properly when converting the input &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;UIImage&lt;/code&gt; to &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CGImage&lt;/code&gt; as input to &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;VNRecognizeTextRequest&lt;/code&gt;.&lt;/p&gt;

&lt;div class=&quot;language-swift highlighter-rouge&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;kd&quot;&gt;extension&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;CGImagePropertyOrientation&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;nf&quot;&gt;init&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;_&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;uiOrientation&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;UIImage&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;Orientation&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;switch&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;uiOrientation&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;case&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;up&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;self&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;up&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;case&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;upMirrored&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;self&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;upMirrored&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;case&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;down&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;self&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;down&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;case&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;downMirrored&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;self&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;downMirrored&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;case&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;left&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;self&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;left&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;case&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;leftMirrored&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;self&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;leftMirrored&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;case&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;right&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;self&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;right&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;case&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;rightMirrored&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;self&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;rightMirrored&lt;/span&gt;
        &lt;span class=&quot;kd&quot;&gt;@unknown&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;default&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;self&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;up&lt;/span&gt;
        &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;h3 id=&quot;levenshtein-distance&quot;&gt;Levenshtein distance&lt;/h3&gt;

&lt;p&gt;Like our previous example of &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CDR&lt;/code&gt; instead of &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CAR&lt;/code&gt;, VisionKit was reading strings like &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;TOKIO&lt;/code&gt; instead of &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;TOKYO&lt;/code&gt;. For these parts, I figured calculating &lt;a href=&quot;https://en.wikipedia.org/wiki/Levenshtein_distance&quot;&gt;Levenshtein distance&lt;/a&gt; from known strings was the right strategy. The Shinkansen system is large but not so large that I couldn’t ingest the station name values and the other known strings like &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;JAN&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;FEB&lt;/code&gt;, etc.&lt;/p&gt;

&lt;p&gt;One strategy I haven’t tested yet is using &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;VNRecognizeTextRequest.customWords&lt;/code&gt; with the full dictionary of station names, etc. to see if that eliminates the need for using Levenshtein distance at all.&lt;/p&gt;

&lt;h3 id=&quot;english-and-japanese-mode-ocr&quot;&gt;English and Japanese mode OCR&lt;/h3&gt;

&lt;p&gt;For some background, tickets can be printed in an “English” variant that includes a mix of English and Japanese text. If you buy from a ticket vending machine and complete the purchase in English mode, it’ll print an English ticket. Similarly, if you buy from a human ticket vendor at the counter, they will print your ticket in English variant if you speak English to them.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/shinkansen-v1-example-english-ticket.jpg&quot; width=&quot;&quot; height=&quot;400&quot; alt=&quot;Example of an English variant Shinkansen ticket&quot; title=&quot;Example of an English variant Shinkansen ticket&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Example of an English variant Shinkansen ticket&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;At the VisionKit layer, I was first using &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;VNRecognizeTextRequest&lt;/code&gt; in Japanese-mode only.&lt;/p&gt;

&lt;p&gt;I tried expanding a single instance to include both English and Japanese text. But checking the raw results, a dual language setup severely impaired its abilities.&lt;/p&gt;

&lt;p&gt;For a while, I had a dual parsing system that would check for a few English strings, and if any were found, it would assume the ticket was an “English ticket” and run a separate English &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;VNRecognizeTextRequest&lt;/code&gt; and return those results. This didn’t work well for a few reasons:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;The Japanese &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;VNRecognizeTextRequest&lt;/code&gt; was surprisingly bad at reading numbers compared to the English one.&lt;/li&gt;
  &lt;li&gt;Deciding at the VisionKit layer which text results should come from the English request didn’t make a lot of sense conceptually if I wanted to keep the majority of the logic in the parser layer.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Therefore, I decided to run both the English and Japanese &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;VNRecognizeTextRequest&lt;/code&gt;s on every input and provide all the results to the parser.&lt;/p&gt;

&lt;div class=&quot;language-swift highlighter-rouge&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;kd&quot;&gt;struct&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;BilingualOCRResult&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Sendable&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Equatable&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Codable&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;jp&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;TextObservation&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;en&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;TextObservation&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;p&gt;I was also previously discarding the confidence score (&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;0.0...1.0&lt;/code&gt;) that VisionKit provides with each observation. This score was actually different in the parallel observations for English and Japanese in some cases, especially number recognition. I added the confidence score to the output so the parser could use it.&lt;/p&gt;

&lt;div class=&quot;language-swift highlighter-rouge&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;kd&quot;&gt;struct&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;TextObservation&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Equatable&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Sendable&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Codable&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;text&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;String&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;x&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Double&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;y&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Double&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;width&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Double&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;height&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Double&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;var&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;confidence&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Float&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;h3 id=&quot;unparseable-fields-fallback-ux&quot;&gt;Unparseable fields fallback UX&lt;/h3&gt;

&lt;p&gt;At this point in the development of the parser, I was pretty certain reading photos of tickets was never going to be reach 100% accuracy for every field.&lt;/p&gt;

&lt;p&gt;I took a break from working on the parser to implement editing for every field in the ticket UI. This meant that users could manually recover from parsing errors and omissions.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/shinkansen-v1-editing-screen.jpg&quot; width=&quot;&quot; height=&quot;400&quot; alt=&quot;Editing screen with editable ticket fields&quot; title=&quot;Editing screen with editable ticket fields&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Editing screen with editable ticket fields&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Not needing to reach 100% accuracy in the parser while still ensuring the user gets value out of the system as a whole opened up a more reasonable strategy for the parser.&lt;/p&gt;

&lt;h3 id=&quot;defense-in-depth-parsing-system&quot;&gt;Defense-in-depth parsing system&lt;/h3&gt;

&lt;p&gt;After tweaking more and more of the VisionKit layer, I had to regenerate the VisionKit output test data for each test ticket image, and then essentially rewrite the parser layer.&lt;/p&gt;

&lt;p&gt;This time, the strategy was to:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Pass in the full English and Japanese results including relative x and y coordinates to each field.&lt;/li&gt;
  &lt;li&gt;Create a sub-parser dedicated to each field of the ticket that needed to be parsed.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Within each sub-parser, my strategy was to start with the best case scenario of input data quality, then step-by-step keep loosening the guidelines to account for more unideal cases that had come up in the test data, then finally falling back to returning &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;nil&lt;/code&gt; for that field.&lt;/p&gt;

&lt;h3 id=&quot;ocr-testing-system&quot;&gt;OCR testing system&lt;/h3&gt;

&lt;p&gt;Claude iterated on the field parser implementations for an hour or two, one at a time, checking the test output to ensure there were no regressions along the way.&lt;/p&gt;

&lt;p&gt;At a certain point all the tests were passing and as much as I wanted to keep finding test data and tweaking the parser, I knew I had to move on.&lt;/p&gt;

&lt;h2 id=&quot;live-activities&quot;&gt;Live Activities&lt;/h2&gt;

&lt;p&gt;During development I had to keep reminding myself that the whole point of this endeavor was to have a slick (read: &lt;em&gt;useful&lt;/em&gt;) Live Activity.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/shinkansen-v1-live-activity-3panel.jpg&quot; width=&quot;&quot; height=&quot;400&quot; alt=&quot;Live Activity in Dynamic Island compact, expanded, and lock screen views&quot; title=&quot;Live Activity in Dynamic Island compact, expanded, and lock screen views&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Live Activity in Dynamic Island compact, expanded, and lock screen views&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;My design process was quick and to-the-point:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;list out all the Live Activity contexts: lock screen, dynamic island compact leading, compact trailing, minimal, and expanded.&lt;/li&gt;
  &lt;li&gt;consider all the ticket info I had available from the parser output.&lt;/li&gt;
  &lt;li&gt;consider all of the above for the “before” and “during” trip phases (if I knew I had more accurate control over Live Activity update timing, I might have divided these phases up even further).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In lock screen and expanded contexts, you can &lt;em&gt;mostly&lt;/em&gt; display everything you want. The challenge is in aesthetics and visual hierarchy like in any design.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/shinkansen-v1-lockscreen-live-activity.jpg&quot; width=&quot;&quot; height=&quot;400&quot; alt=&quot;Lock screen Live Activity showing trip details&quot; title=&quot;Lock screen Live Activity showing trip details&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Lock screen Live Activity showing trip details&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;When dealing with the compact and minimal contexts, you really do only have the equivalent of about 6 very small characters to work with, and 2 lines if you want to push your luck. If you try to fill the entire available space of the Dynamic Island on either side, you’ll lose the system clock which is no go for my use case.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/shinkansen-v1-compact-live-activity.jpg&quot; width=&quot;&quot; height=&quot;400&quot; alt=&quot;Compact Live Activity in Dynamic Island&quot; title=&quot;Compact Live Activity in Dynamic Island&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Compact Live Activity in Dynamic Island&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;When I thought about it, the most important of all the contexts was the compact leading and trailing in the &lt;em&gt;before&lt;/em&gt; trip phase. This is when the information is most needed at a glance.&lt;/p&gt;

&lt;p&gt;I stacked in departure time and train number in the compact leading. This information is used to decide when to go to the platform and which platform to go to.&lt;/p&gt;

&lt;p&gt;I stacked the car and seat number in the compact trailing. These are used to decide where to line up on the platform and of course where you’ll sit.&lt;/p&gt;

&lt;h3 id=&quot;view-guidelines-and-tips&quot;&gt;View guidelines and tips&lt;/h3&gt;

&lt;p&gt;Designing for Live Activities is painful. There are significantly more constraints to the SwiftUI View system than in normal app contexts. Most are undocumented. Some quirks can be teased out in the SwiftUI Preview if you’re lucky. Others only appear on the simulator or a real device.&lt;/p&gt;

&lt;p&gt;I have a couple guidelines and tips I follow for Live Activities.&lt;/p&gt;

&lt;h4 id=&quot;use-non-semantic-font-sizes-for-compact-and-minimal&quot;&gt;Use non-semantic font sizes for compact and minimal&lt;/h4&gt;

&lt;p&gt;The system ignores Dynamic Type settings in the compact and minimal Dynamic Island contexts. Using point sizes directly gives more flexibility while designing in the very limited space.&lt;/p&gt;

&lt;p&gt;In the lock screen and expanded contexts, there’s limited Dynamic Type support (4-levels total), so it’s still worth using semantic fonts as usual (e.g. &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.headline&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.title3&lt;/code&gt;).&lt;/p&gt;

&lt;h4 id=&quot;for-dynamically-updating-times-prepare-to-spend-a-lot-of-time-in-trial-and-error&quot;&gt;For dynamically updating times, prepare to spend a lot of time in trial and error&lt;/h4&gt;

&lt;p&gt;Maybe someday I’ll write a full explainer post on which countdown-style &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Text&lt;/code&gt; fields are supported. In short, if you want dynamically updated fields in any part of your Live Activity, your formatting options are limited and underdocumented.&lt;/p&gt;

&lt;p&gt;A couple configurations I used:&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;// `44 min, 23 sec` or `1 hr, 52 min`
Text(attributes.departureTime, style: .relative)

// Centers `Departing in N min, M, sec` due to `.relative`&apos;s implicit `maxWidth: .infinity`
HStack(spacing: 4) {
    Text(String(localized: &quot;widget.departing-in&quot;, comment: &quot;Footer label shown before departure&quot;))
        .frame(maxWidth: .infinity, alignment: .trailing)
    Text(attributes.departureTime, style: .relative)
}

// Linear progress view with no label
ProgressView(timerInterval: attributes.departureTime ... attributes.arrivalTime, countsDown: false)
    .progressViewStyle(.linear)
    .labelsHidden()
   
// `60:00`
Text(
     timerInterval: (Date.now)...(Date(timeIntervalSinceNow: 60*60)),
     pauseTime: Date(timeIntervalSinceNow: 60*60),
     countsDown: true,
     showsHours: false
)
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;p&gt;As noted above, any &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Text&lt;/code&gt; using: &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Text(Date(), style: .relative)&lt;/code&gt; will expand to fill its full width.&lt;/p&gt;

&lt;p&gt;It’s frustrating just thinking about this again. I basically just banged my head against the wall until I landed on a design I felt embarrassed but comfortable shipping.&lt;/p&gt;

&lt;h4 id=&quot;clamped-width-custom-layout&quot;&gt;Clamped width custom Layout&lt;/h4&gt;

&lt;p&gt;I use this custom &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Layout&lt;/code&gt; judiciously in the Dynamic Island.&lt;/p&gt;

&lt;p&gt;It makes the underlying view’s frame collapse to fit its ideal width, but clamped to a maximum value.&lt;/p&gt;

&lt;p&gt;I want the compact or minimal context to be as narrow as possible. I want short input text to result in a very narrow Dynamic Island layout. I want longer input not to expand beyond a certain width; and even more, I want to use the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;minimumScaleFactor&lt;/code&gt; modifier to further shrink the text size once that maximum width is reached.&lt;/p&gt;

&lt;div class=&quot;language-swift highlighter-rouge&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;kd&quot;&gt;private&lt;/span&gt; &lt;span class=&quot;kd&quot;&gt;struct&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;SingleViewClampedWidthLayout&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Layout&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;var&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;maxWidth&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;CGFloat&lt;/span&gt;

    &lt;span class=&quot;kd&quot;&gt;func&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;sizeThatFits&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;proposal&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;ProposedViewSize&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;subviews&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Subviews&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;cache&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;inout&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Void&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;-&amp;gt;&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;CGSize&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;guard&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;subview&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;subviews&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;first&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;else&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;zero&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;idealWidth&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;subview&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;sizeThatFits&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;init&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;width&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kc&quot;&gt;nil&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;height&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;proposal&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;height&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;))&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;width&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;width&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;min&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;idealWidth&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;maxWidth&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;subview&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;sizeThatFits&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;init&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;width&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;width&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;height&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;proposal&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;height&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;))&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;

    &lt;span class=&quot;kd&quot;&gt;func&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;placeSubviews&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;in&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;bounds&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;CGRect&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;proposal&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;ProposedViewSize&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;subviews&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Subviews&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;cache&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;inout&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Void&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;guard&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;subview&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;subviews&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;first&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;else&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;subview&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;place&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;at&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;bounds&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;origin&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;proposal&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;init&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;bounds&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;size&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;))&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;

&lt;span class=&quot;kd&quot;&gt;private&lt;/span&gt; &lt;span class=&quot;kd&quot;&gt;struct&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;ClampedWidth&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;ViewModifier&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;maxWidth&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;CGFloat&lt;/span&gt;

    &lt;span class=&quot;kd&quot;&gt;func&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;body&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;content&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Content&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;-&amp;gt;&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;some&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;View&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
        &lt;span class=&quot;kt&quot;&gt;SingleViewClampedWidthLayout&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;maxWidth&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;maxWidth&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
            &lt;span class=&quot;n&quot;&gt;content&lt;/span&gt;
        &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;

&lt;span class=&quot;kd&quot;&gt;extension&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;View&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;kd&quot;&gt;@ViewBuilder&lt;/span&gt; &lt;span class=&quot;kd&quot;&gt;func&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;clamped&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;maxWidth&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;CGFloat&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;-&amp;gt;&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;some&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;View&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
        &lt;span class=&quot;nf&quot;&gt;modifier&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;ClampedWidth&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;maxWidth&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;maxWidth&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;))&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;h3 id=&quot;updating-in-background&quot;&gt;Updating in background&lt;/h3&gt;

&lt;p&gt;Live Activities can usually only be updated via remote Push Notification. But if your app gets a chance to wake up and run in the background, it can also issue updates to the Live Activity.&lt;/p&gt;

&lt;p&gt;One of the few reliable ways to have your app woken up in the background regularly is to use significant location updates from Core Location. In order to have your app get background time you need to:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Be approved by the user for &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Always&lt;/code&gt; Location Services permission.&lt;/li&gt;
  &lt;li&gt;Be approved by the user for &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;WhenInUse&lt;/code&gt; Location Services permission AND one of the following
    &lt;ul&gt;
      &lt;li&gt;Have an active Live Activity OR&lt;/li&gt;
      &lt;li&gt;Start a CLBackgroundActivitySession (that essentially creates a default Live Activity)&lt;/li&gt;
    &lt;/ul&gt;
  &lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In the case of Shinkansen Live, I have two phases for the Live Activity:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;before the train departs&lt;/li&gt;
  &lt;li&gt;after the train departs&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The time of that change over is pretty reliably scheduled. But Live Activities have no update schedule like Widgets do (for some reason).&lt;/p&gt;

&lt;p&gt;To update the Live Activity after the train departs I could:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Have the user tap a button in the lock screen or expanded Live Activity that triggers an intent to wake up the app in the background and update the Live Activity.&lt;/li&gt;
  &lt;li&gt;Hope the user opens the app on their own.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Both of these options are unideal, so instead I ask for &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;WhenInUse&lt;/code&gt; Location Services permission and start a monitoring for significant locations. One of these will be fired not long after the train departs (within about 1km or 5 minutes). That trigger will open the app in the background, update the Live Activity based on the current time, then go back to sleep.&lt;/p&gt;

&lt;h3 id=&quot;persisting-the-trip&quot;&gt;Persisting the trip&lt;/h3&gt;

&lt;p&gt;There’s an edge case I wanted to handle with the Live Activity lifetime.&lt;/p&gt;

&lt;p&gt;If the system kills the app in the middle of a trip, the Live Activity in theory should continue uninterrupted since it’s an App Extension. But in the case of Shinkansen Live, I’m expecting to update the Live Activity while the app is backgrounded. This means there’s a potential flow where:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;The app is in the background with the Live Activity running.&lt;/li&gt;
  &lt;li&gt;The app is killed by the system.&lt;/li&gt;
  &lt;li&gt;The Live Activity continues to run.&lt;/li&gt;
  &lt;li&gt;The system cold launches the app in the background.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;At this point, I could decide to query the Live Activities framework to see if there’s a Live Activity running and if so, restore the ticket model layer and UI. However, I prefer not to treat the Live Activity as the source of truth for the model layer.&lt;/p&gt;

&lt;p&gt;I added support with the &lt;a href=&quot;https://github.com/pointfreeco/swift-sharing&quot;&gt;Sharing&lt;/a&gt; library to persist the ticket model automatically on changes. On the above flow, I use the persisted ticket model to restore the UI and Live Activity and AlarmKit state, ensuring the ticket data is still valid.&lt;/p&gt;

&lt;h3 id=&quot;handling-dismissal&quot;&gt;Handling dismissal&lt;/h3&gt;

&lt;p&gt;One final bit of UX that’s not mission critical but is very user friendly is to respond to user-initiated Live Activity dismissals from the lock screen. If the user swipes from right to left on your Live Activity, the system dismisses it. When your app next runs, it will receive an update from &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Activity.activityStateUpdates&lt;/code&gt; stream (if you’re monitoring it).&lt;/p&gt;

&lt;p&gt;If the app detects a user-initiated dismissal, I consider that their trip has ended, clear the ticket, and go back to the app home screen. There’s an argument that it’d be safer to simply toggle the Live Activity, but since my app’s only purpose is to show a Live Activity, I don’t think it makes sense to build in more complexity to keep multiple states.&lt;/p&gt;

&lt;h2 id=&quot;animations&quot;&gt;Animations&lt;/h2&gt;

&lt;p&gt;The focused nature of this app allowed me a bit more breathing room to experiment with custom screen transitions and multi-stage animations.&lt;/p&gt;

&lt;h3 id=&quot;root-level-transitions&quot;&gt;Root level transitions&lt;/h3&gt;

&lt;p&gt;At the bare minimum, I usually try to use a default opacity transition for root level views when they aren’t covered by system transitions like a navigation push or sheet presentation.&lt;/p&gt;

&lt;p&gt;For Shinkansen Live, I added a little bit of extra scale effect to the usual opacity transition of the initial screen, both on cold launch and when returning from the trip screen.&lt;/p&gt;

&lt;video controls=&quot;&quot; loop=&quot;&quot; style=&quot;max-height: 400px;&quot;&gt;
  &lt;source src=&quot;/images/shinkansen-v1-initial-transition.mp4&quot; type=&quot;video/mp4&quot; /&gt;
&lt;/video&gt;

&lt;video controls=&quot;&quot; loop=&quot;&quot; style=&quot;max-height: 400px;&quot;&gt;
  &lt;source src=&quot;/images/shinkansen-v1-initial-transition-slowmo.mp4&quot; type=&quot;video/mp4&quot; /&gt;
&lt;/video&gt;

&lt;p&gt;For the trip screen, I first animated the card down from the top with some scale and opacity, then fade in the other sections with some scale.&lt;/p&gt;

&lt;video controls=&quot;&quot; loop=&quot;&quot; style=&quot;max-height: 400px;&quot;&gt;
  &lt;source src=&quot;/images/shinkansen-v1-trip-transition.mp4&quot; type=&quot;video/mp4&quot; /&gt;
&lt;/video&gt;

&lt;video controls=&quot;&quot; loop=&quot;&quot; style=&quot;max-height: 400px;&quot;&gt;
  &lt;source src=&quot;/images/shinkansen-v1-trip-transition-slowmo.mp4&quot; type=&quot;video/mp4&quot; /&gt;
&lt;/video&gt;

&lt;h3 id=&quot;scanning-animation&quot;&gt;Scanning animation&lt;/h3&gt;

&lt;p&gt;The most fun was doing the scanning animation. Once the selected image is downloaded and displayable, I animate it in with a bit of 3D effect. Then I use the coordinate results of the text observations to animate those boxes onto the image.&lt;/p&gt;

&lt;p&gt;This animation serves a few purposes in my opinion:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Visually expresses to the user what the app is actually doing.&lt;/li&gt;
  &lt;li&gt;Buys time for the parser to do its job.&lt;/li&gt;
  &lt;li&gt;Feels fun and playful in a way that is motivating for users to want to go through the trouble of submitting their ticket image.&lt;/li&gt;
&lt;/ul&gt;

&lt;video controls=&quot;&quot; loop=&quot;&quot; style=&quot;max-height: 400px;&quot;&gt;
  &lt;source src=&quot;/images/shinkansen-v1-scanning-transition.mp4&quot; type=&quot;video/mp4&quot; /&gt;
&lt;/video&gt;

&lt;video controls=&quot;&quot; loop=&quot;&quot; style=&quot;max-height: 400px;&quot;&gt;
  &lt;source src=&quot;/images/shinkansen-v1-scanning-transition-slowmo.mp4&quot; type=&quot;video/mp4&quot; /&gt;
&lt;/video&gt;

&lt;h3 id=&quot;ticket-image-modal-animation&quot;&gt;Ticket image modal animation&lt;/h3&gt;

&lt;p&gt;My final bit of (self) user testing made me realize that even though I’d built in a way to update ticket values that were missing or erroneously parsed, I had no in-app UI for actually doing the field checking. Depending on their input source, the user would have to hold up their physical ticket next to the app’s virtual ticket to double check the fields. Or if they’d used a screenshot, they’d have to flip back and forth between Photos app.&lt;/p&gt;

&lt;p&gt;As my last big task, I added support for showing the original image inline with the ticket in a modal overlay.&lt;/p&gt;

&lt;p&gt;This setup uses &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;matchedGeometryEffect&lt;/code&gt; and was a nightmare to work through. In the end it’s not perfect, but the speed conceals some of the jankiness. &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;matchedGeometryEffect&lt;/code&gt; has a lot of undocumented incompatibilities with other modifiers, so it was just hours upon hours of reordering modifiers and building and running to check what had changed. I came out of the experience with little new demonstrably true observations I can share here, unfortunately.&lt;/p&gt;

&lt;video controls=&quot;&quot; loop=&quot;&quot; style=&quot;max-height: 400px;&quot;&gt;
  &lt;source src=&quot;/images/shinkansen-v1-image-transition.mp4&quot; type=&quot;video/mp4&quot; /&gt;
&lt;/video&gt;

&lt;video controls=&quot;&quot; loop=&quot;&quot; style=&quot;max-height: 400px;&quot;&gt;
  &lt;source src=&quot;/images/shinkansen-v1-image-transition-slowmo.mp4&quot; type=&quot;video/mp4&quot; /&gt;
&lt;/video&gt;

&lt;h3 id=&quot;card-dragging&quot;&gt;Card dragging&lt;/h3&gt;

&lt;p&gt;Whenever there’s a card-looking UI on screen, I want it to be interactable even if there’s no real gesture that makes sense.&lt;/p&gt;

&lt;p&gt;I created a custom drag gesture with rubberbanding that allows the user to drag the ticket a little bit in any direction. When released, it snaps back with a custom haptic that mirrors the visual.&lt;/p&gt;

&lt;video controls=&quot;&quot; loop=&quot;&quot; style=&quot;max-height: 400px;&quot;&gt;
  &lt;source src=&quot;/images/shinkansen-v1-card-drag.mp4&quot; type=&quot;video/mp4&quot; /&gt;
&lt;/video&gt;

&lt;h2 id=&quot;alarmkit&quot;&gt;AlarmKit&lt;/h2&gt;

&lt;p&gt;&lt;a href=&quot;https://developer.apple.com/documentation/alarmkit&quot;&gt;AlarmKit&lt;/a&gt; is new in iOS 26 and I thought it might be a good fit for Shinkansen Live’s use case.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/shinkansen-v1-alarm-2panel.jpg&quot; width=&quot;&quot; height=&quot;400&quot; alt=&quot;Alarm setting in trip screen and full screen alarm notification&quot; title=&quot;Alarm setting in trip screen and full screen alarm notification&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Alarm setting in trip screen and full screen alarm notification&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;The integration was mostly straightforward, but it added another layer of complexity to the reducer implementation to ensure that it was added, changed, and removed for all the relevant cases:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Arrival time exists or doesn’t exist.&lt;/li&gt;
  &lt;li&gt;Arrival time is updated manually by the user.&lt;/li&gt;
  &lt;li&gt;System time is too close to arrival time to set an alarm.&lt;/li&gt;
  &lt;li&gt;Journey is ended by the user before arrival time.&lt;/li&gt;
  &lt;li&gt;Live Activity is dismissed from the lock screen.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And more.&lt;/p&gt;

&lt;h3 id=&quot;permission-dialog&quot;&gt;Permission dialog&lt;/h3&gt;

&lt;p&gt;A last minute annoyance with AlarmKit was testing localization: I &lt;a href=&quot;https://hachyderm.io/@twocentstudios/115740319076548675&quot;&gt;found a bug&lt;/a&gt; where the localized text for the AlarmKit permissions dialog was not being used on iOS 26.0. But the bug was fixed for iOS 26.1. And no, the bug nor the fix were mentioned in any official SDK release notes.&lt;/p&gt;

&lt;h2 id=&quot;localization&quot;&gt;Localization&lt;/h2&gt;

&lt;p&gt;One of my favorite usages for coding agents is doing Localization setup. Note I’m specifically &lt;em&gt;not&lt;/em&gt; talking about LLMs doing the actual translation, but instead:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;doing the initial conversion from inline strings to string keys e.g, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;loaded.end-journey-button&lt;/code&gt;.&lt;/li&gt;
  &lt;li&gt;adding localizer comments to each string key e.g. &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Button to end the current journey&lt;/code&gt;.&lt;/li&gt;
  &lt;li&gt;maintaining default values for when string interpolations are required e.g.&lt;/li&gt;
&lt;/ul&gt;

&lt;div class=&quot;language-swift highlighter-rouge&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;kt&quot;&gt;Button&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
    &lt;span class=&quot;kt&quot;&gt;String&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
        &lt;span class=&quot;nv&quot;&gt;localized&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;loaded.arrival-alert.status.minutes-before&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;defaultValue&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;se&quot;&gt;\(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;mins&lt;/span&gt;&lt;span class=&quot;se&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;s&quot;&gt; min before&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
        &lt;span class=&quot;nv&quot;&gt;comment&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;Status showing minutes before arrival (for menu items)&quot;&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;...&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;p&gt;The initial conversion uses a custom markdown document with some basic rules. It takes about an hour for the first run and then a few more passes with a human in the loop to ensure the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;xcstrings&lt;/code&gt; file is clean.&lt;/p&gt;

&lt;h2 id=&quot;dynamic-type&quot;&gt;Dynamic Type&lt;/h2&gt;

&lt;p&gt;The app still lays out pretty well with most levels of Dynamic Type. I only use semantic font qualifiers. All content is in a scroll view that’s usually fixed.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/shinkansen-v1-dynamic-type-landing.jpg&quot; width=&quot;&quot; height=&quot;400&quot; alt=&quot;Landing screen with various Dynamic Type sizes&quot; title=&quot;Landing screen with various Dynamic Type sizes&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Landing screen with various Dynamic Type sizes&lt;/div&gt;&lt;/div&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/shinkansen-v1-dynamic-type-trip.jpg&quot; width=&quot;&quot; height=&quot;400&quot; alt=&quot;Trip screen with various Dynamic Type sizes&quot; title=&quot;Trip screen with various Dynamic Type sizes&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Trip screen with various Dynamic Type sizes&lt;/div&gt;&lt;/div&gt;

&lt;h2 id=&quot;visionkit-camera&quot;&gt;VisionKit Camera&lt;/h2&gt;

&lt;p&gt;I’m using &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;VNDocumentCameraViewController&lt;/code&gt; as the integrated camera view for scanning. The UX is a little weird because there’s no way to limit the input (output?) to one photo. The result is the user can take a bunch of photos of their ticket before they tap “Done” and the app will only read the first.&lt;/p&gt;

&lt;h2 id=&quot;project-stats&quot;&gt;Project stats&lt;/h2&gt;

&lt;h3 id=&quot;by-the-numbers&quot;&gt;By the numbers&lt;/h3&gt;

&lt;ul&gt;
  &lt;li&gt;Development time: &lt;strong&gt;~6 days&lt;/strong&gt; including App Store materials&lt;/li&gt;
  &lt;li&gt;Lines of Swift (excluding tests, static data, etc.): &lt;strong&gt;8,388&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h3 id=&quot;daily-devlog&quot;&gt;Daily Devlog&lt;/h3&gt;

&lt;ul&gt;
  &lt;li&gt;Dec 16, 2025 — Initial layout &amp;amp; OCR foundation&lt;/li&gt;
  &lt;li&gt;Dec 17, 2025 — Live Activities, AlarmKit, failure states&lt;/li&gt;
  &lt;li&gt;Dec 18, 2025 — Bilingual OCR, loading animation, app icon, localization&lt;/li&gt;
  &lt;li&gt;Dec 19, 2025 - Parser rewrite, settings view&lt;/li&gt;
  &lt;li&gt;Dec 20, 2025 — Polish, persistence, supported formats view, App Store prep&lt;/li&gt;
  &lt;li&gt;Dec 23, 2025 — Ticket image modal, App Store materials, v1.0 Release&lt;/li&gt;
&lt;/ul&gt;

&lt;h2 id=&quot;conclusion&quot;&gt;Conclusion&lt;/h2&gt;

&lt;p&gt;Part of the appeal of this idea is that the scope could only creep so much. I honestly didn’t leave much on the TODO list for a version 1.1 besides endless optimization potential for the parser.&lt;/p&gt;

&lt;p&gt;As usual this post was brain-dump style. If there’s any part you connected with and would like me to explore further, feel free to give me a shout.&lt;/p&gt;
</description>
        <pubDate>Thu, 25 Dec 2025 07:41:42 -0600</pubDate>
        <link>https://twocentstudios.com/2025/12/25/shinkansen-live-developing-the-app-for-ios/</link>
        <guid isPermaLink="true">https://twocentstudios.com/2025/12/25/shinkansen-live-developing-the-app-for-ios/</guid>
        
        <category>apple</category>
        
        <category>ios</category>
        
        <category>app</category>
        
        <category>shinkansenlive</category>
        
        
      </item>
    
      <item>
        <title>Shinkansen Live: Scan Your Ticket, Get a Live Activity</title>
        <description>&lt;p&gt;Today I’m releasing my latest iOS app: Shinkansen Live or 新幹線ライブ in Japanese.&lt;/p&gt;

&lt;p&gt;&lt;a href=&quot;https://apps.apple.com/app/id6756808516&quot;&gt;Shinkansen Live on the App Store&lt;/a&gt;&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/shinkansen-v1-app-icon.jpg&quot; width=&quot;&quot; height=&quot;300&quot; alt=&quot;Shinkansen Live app icon&quot; title=&quot;Shinkansen Live app icon&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Shinkansen Live app icon&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;The concept is simple: you scan your Shinkansen ticket or receipt and you can see the details of your trip in a Live Activity on your lock screen and Dynamic Island.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/shinkansen-v1-scan-flow-3panel.jpg&quot; width=&quot;&quot; height=&quot;400&quot; alt=&quot;Scan flow in 3 panels: scanning, ticket, lock screen&quot; title=&quot;Scan flow in 3 panels: scanning, ticket, lock screen&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Scan flow in 3 panels: scanning, ticket, lock screen&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Here’s a quick screen capture of the main flow:&lt;/p&gt;

&lt;video poster=&quot;/images/shinkansen-v1-app-preview-poster.png&quot; controls=&quot;&quot; style=&quot;max-height: 400px;&quot;&gt;
  &lt;source src=&quot;/images/shinkansen-v1-app-preview.mp4&quot; type=&quot;video/mp4&quot; /&gt;
&lt;/video&gt;

&lt;h2 id=&quot;motivation&quot;&gt;Motivation&lt;/h2&gt;

&lt;p&gt;I took a quick Shinkansen trip from Omiya (north Tokyo) to Karuizawa (Nagano) last week to do some co-working with my friends Jens and David. I had pre-purchased a reserved seat with the &lt;a href=&quot;https://www.eki-net.com/en/jreast-train-reservation/Top/Index&quot;&gt;Eki-net&lt;/a&gt;, JR-East’s Shinkansen app (on iOS, it’s a web app wrapper). Similar to when I have a physical ticket (but somehow worse?) I found myself opening the app repeatedly to check my ticket’s listed attributes for:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;departure time: in the 30 minutes or so lead up, before I’d entered the gates.&lt;/li&gt;
  &lt;li&gt;train number: to cross reference and check the platform I should leave from.&lt;/li&gt;
  &lt;li&gt;car number: when it was time to ascend to the platform and look for where on the platform I should line up.&lt;/li&gt;
  &lt;li&gt;seat number: when the train pulled up and I was boarding.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Both of my train apps &lt;a href=&quot;/2024/07/27/eki-bright-tokyo-area-train-timetables/&quot;&gt;Eki Bright&lt;/a&gt; and &lt;a href=&quot;/2025/06/03/eki-live-announcement/&quot;&gt;Eki Live&lt;/a&gt; have Live Activities support that I use frequently. I only ride the Shinkansen a few times a year, but while riding up to Karuizawa that day, I wondered, &lt;strong&gt;couldn’t I just OCR the Shinkansen ticket info from screenshot and stuff it into a Live Activity?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;And so as soon as I arrived at Sawamura Roastery in Karuizawa, I got to work on prototyping a new app. My goal was to have a prototype by the ride home. With some extra polish it ended up taking a few more days of work.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/shinkansen-v1-sawamura-fireplace.jpg&quot; width=&quot;&quot; height=&quot;400&quot; alt=&quot;Co-working vibes at Sawamura Roastery in Karuizawa&quot; title=&quot;Co-working vibes at Sawamura Roastery in Karuizawa&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Co-working vibes at Sawamura Roastery in Karuizawa&lt;/div&gt;&lt;/div&gt;

&lt;h2 id=&quot;features&quot;&gt;Features&lt;/h2&gt;

&lt;p&gt;The structure of the app is essentially a landing screen, a processing screen, and a trip-in-progress screen. The Live Activity requires its own multiple states and layouts of UI. For polish, I needed an error screen, an about screen, and a screen explaining what ticket formats are accepted.&lt;/p&gt;

&lt;h3 id=&quot;supported-input-formats&quot;&gt;Supported input formats&lt;/h3&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/shinkansen-v1-landing-screen.jpg&quot; width=&quot;&quot; height=&quot;400&quot; alt=&quot;Landing screen showing input options&quot; title=&quot;Landing screen showing input options&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Landing screen showing input options&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;My initial scope was just handling screenshots from Eki-net (JR-East’s app) and SmartEX (JR-Central’s app), and in retrospect this probably would have better line to draw in the sand for version 1. However, I added support for scanning physical tickets too since the app seemed like it would be &lt;em&gt;too&lt;/em&gt; specialized without physical tickets, probably the majority use-case.&lt;/p&gt;

&lt;p&gt;And so, you can scan your physical ticket with the camera, choose a screenshot from the Photo Library, paste an image from the system pasteboard, or create an empty ticket if you want.&lt;/p&gt;

&lt;h3 id=&quot;ocr-and-parsing&quot;&gt;OCR and parsing&lt;/h3&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/shinkansen-v1-scanning.jpg&quot; width=&quot;&quot; height=&quot;400&quot; alt=&quot;Scanning a ticket with OCR in progress&quot; title=&quot;Scanning a ticket with OCR in progress&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Scanning a ticket with OCR in progress&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;As of version 1.0, the app uses on device VisionKit to recognize text in the image and custom algorithm to do error recovery and parse out the relevant attributes from the ticket. I’ll discuss the development aspects of this decision in a future post, but for now, I’ll say that the merits of using OCR over multi-modal LLMs are that OCR is very fast, maintains privacy, and is accurate enough for a V1.&lt;/p&gt;

&lt;h3 id=&quot;trip-in-progress&quot;&gt;Trip in-progress&lt;/h3&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/shinkansen-v1-trip-screen.jpg&quot; width=&quot;&quot; height=&quot;400&quot; alt=&quot;Trip in-progress screen showing ticket details&quot; title=&quot;Trip in-progress screen showing ticket details&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Trip in-progress screen showing ticket details&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Once the ticket is scanned and parsed, you land on the trip screen for the remainder of your journey.&lt;/p&gt;

&lt;p&gt;I recreated a facsimile of the legendary Shinkansen ticket. While doing research into ticket formats, it was surprising to see how &lt;em&gt;different&lt;/em&gt; the information layouts are depending on where and by what means they are purchased, but the aesthetic is generally the same.&lt;/p&gt;

&lt;p&gt;For the case of physical tickets, parsing is imperfect, so I wanted to ensure users could recover from minor errors like a missing time or train number. Therefore, all fields are user editable by tapping.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/shinkansen-v1-editing-screen.jpg&quot; width=&quot;&quot; height=&quot;400&quot; alt=&quot;Editing screen with editable ticket fields&quot; title=&quot;Editing screen with editable ticket fields&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Editing screen with editable ticket fields&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;I also include the input image that a user can reference in an expanded view. This makes it easier to double check values and fix mistakes.&lt;/p&gt;

&lt;video poster=&quot;/images/shinkansen-v1-expand-photo-poster.png&quot; controls=&quot;&quot; style=&quot;max-height: 400px;&quot;&gt;
  &lt;source src=&quot;/images/shinkansen-v1-expand-photo.mp4&quot; type=&quot;video/mp4&quot; /&gt;
&lt;/video&gt;

&lt;h3 id=&quot;live-activity&quot;&gt;Live Activity&lt;/h3&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/shinkansen-v1-live-activity-3panel.jpg&quot; width=&quot;&quot; height=&quot;400&quot; alt=&quot;Live Activity in Dynamic Island compact, expanded, and lock screen views&quot; title=&quot;Live Activity in Dynamic Island compact, expanded, and lock screen views&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Live Activity in Dynamic Island compact, expanded, and lock screen views&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Finally, the whole point of all this is to have a functional Live Activity. The Live Activity has a &lt;em&gt;before&lt;/em&gt; and &lt;em&gt;during&lt;/em&gt; layout. Before departure we show the departure time, train number, car number, and seat number (for reserved seats). During the trip, we show the arrival station and time.&lt;/p&gt;

&lt;p&gt;Due to a technical limitation with Live Activities, I use Location Services to monitor for significant location changes in the background, and use that to wake up the app and update the Live Activity when the departure time has passed. On the technical side, this means I don’t need to run a push notification server or do any other networking from the app.&lt;/p&gt;

&lt;h3 id=&quot;arrival-alarm&quot;&gt;Arrival alarm&lt;/h3&gt;

&lt;p&gt;I’m a chronic sufferer of a disease called Scope Creep (this is a joke), so I couldn’t help but add an optional arrival alarm feature. This feature uses the new iOS 26 &lt;a href=&quot;https://developer.apple.com/documentation/AlarmKit&quot;&gt;AlarmKit framework&lt;/a&gt;.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/shinkansen-v1-alarm-2panel.jpg&quot; width=&quot;&quot; height=&quot;400&quot; alt=&quot;Alarm setting in trip screen and full screen alarm notification&quot; title=&quot;Alarm setting in trip screen and full screen alarm notification&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Alarm setting in trip screen and full screen alarm notification&lt;/div&gt;&lt;/div&gt;

&lt;h3 id=&quot;animations-and-transitions&quot;&gt;Animations and transitions&lt;/h3&gt;

&lt;p&gt;I spent a unreasonable amount of time working on the animations and transitions for this app. Since there’s comparatively not a lot of screens or unique transitions to handle, it felt like a good opportunity to push the limits and make the upload experience more delightful. After all, there’s not a &lt;em&gt;ton&lt;/em&gt; of benefit to cost when you consider needing to download an app, and then screenshot or photo your ticket in order to get that slight benefit of not needing to unlock your phone or take your ticket out of your pocket. Hopefully some fun animations add to the motivations to get over that mental hump.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/shinkansen-v1-scanning-transitions.gif&quot; width=&quot;&quot; height=&quot;400&quot; alt=&quot;Scanning transitions and animations&quot; title=&quot;Scanning transitions and animations&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Scanning transitions and animations&lt;/div&gt;&lt;/div&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/shinkansen-v1-image-popup.gif&quot; width=&quot;&quot; height=&quot;400&quot; alt=&quot;Image popup transition&quot; title=&quot;Image popup transition&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Image popup transition&lt;/div&gt;&lt;/div&gt;

&lt;h2 id=&quot;outlook&quot;&gt;Outlook&lt;/h2&gt;

&lt;p&gt;Shinkansen Live is free on v1.0 release. I have no idea whether the App Store listing will get views, whether the listing will convert to downloads, whether the idea will resonate with people to try, and whether any one-time users will keep the app on their devices and remember to use it. I don’t use the Shinkansen enough to estimate this well.&lt;/p&gt;

&lt;p&gt;Regardless, I’m glad the app exists now. I hope it saves at least a few people that little extra friction in an otherwise smooth Shinkansen journey.&lt;/p&gt;
</description>
        <pubDate>Wed, 24 Dec 2025 05:53:39 -0600</pubDate>
        <link>https://twocentstudios.com/2025/12/24/introducing-shinkansen-live-v1/</link>
        <guid isPermaLink="true">https://twocentstudios.com/2025/12/24/introducing-shinkansen-live-v1/</guid>
        
        <category>ios</category>
        
        <category>app</category>
        
        <category>shinkansenlive</category>
        
        
      </item>
    
      <item>
        <title>SwiftUI Group Still(?) Considered Harmful</title>
        <description>&lt;p&gt;A number of years ago, I internalized a SwiftUI axiom after getting burned on what appeared at first as a heisenbug.&lt;/p&gt;

&lt;p&gt;The axiom:&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;Never use &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Group&lt;/code&gt; (with only a few exceptions).&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;TL;DR: I still think this is a useful axiom, although at some point over the last several iOS updates the behavior of &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Group&lt;/code&gt; is &lt;em&gt;less&lt;/em&gt; harmful when applied naively. But there are still some reasons why it’s useful to treat it with caution.&lt;/p&gt;

&lt;h2 id=&quot;group-distributes-its-modifiers-amongst-its-subviews&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Group&lt;/code&gt; distributes its modifiers amongst its subviews&lt;/h2&gt;

&lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Group&lt;/code&gt; is documented as a wrapper &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;View&lt;/code&gt;. From the official docs (emphasis mine):&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;Use a group to collect multiple views into a single instance, without affecting the layout of those views, like an &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;HStack&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;VStack&lt;/code&gt;, or &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Section&lt;/code&gt; would. After creating a group, &lt;strong&gt;any modifier you apply to the group affects all of that group’s members&lt;/strong&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Apple specifically calls out what “affects all of that group’s members” means in the next paragraph, with an accompanying code sample:&lt;/p&gt;

&lt;div class=&quot;language-swift highlighter-rouge&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;kt&quot;&gt;Group&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;isLoggedIn&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
        &lt;span class=&quot;kt&quot;&gt;WelcomeView&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;else&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
        &lt;span class=&quot;kt&quot;&gt;LoginView&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;navigationBarTitle&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;Start&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;blockquote&gt;
  &lt;p&gt;The modifier applies to all members of the group — and not to the group itself. For example, if you apply &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;onAppear(perform:)&lt;/code&gt; to the above group, it applies to all of the views produced by the if &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;isLoggedIn&lt;/code&gt; conditional, and it executes every time &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;isLoggedIn&lt;/code&gt; changes.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This burned me in the past because I had a screen pattern like the (very simplified version) below:&lt;/p&gt;

&lt;div class=&quot;language-swift highlighter-rouge&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;kd&quot;&gt;struct&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;ContentView&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;View&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;kd&quot;&gt;@State&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;var&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;isLoading&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;kc&quot;&gt;true&lt;/span&gt;

    &lt;span class=&quot;k&quot;&gt;var&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;body&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;some&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;View&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
        &lt;span class=&quot;kt&quot;&gt;Group&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
            &lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;isLoading&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
                &lt;span class=&quot;kt&quot;&gt;ProgressView&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;
            &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;else&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
                &lt;span class=&quot;kt&quot;&gt;DataLoadedView&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;
            &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
        &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
        &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;onAppear&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
            &lt;span class=&quot;nf&quot;&gt;fetchData&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;
        &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
    
    &lt;span class=&quot;kd&quot;&gt;private&lt;/span&gt; &lt;span class=&quot;kd&quot;&gt;func&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;fetchData&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
        &lt;span class=&quot;c1&quot;&gt;// make network request&lt;/span&gt;
        &lt;span class=&quot;c1&quot;&gt;// set `isLoading = false` in completion handler&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;p&gt;With &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Group&lt;/code&gt;’s documented behavior, the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;onAppear&lt;/code&gt; modifier is essentially distributed across the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Group&lt;/code&gt;’s views:&lt;/p&gt;

&lt;div class=&quot;language-swift highlighter-rouge&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;kt&quot;&gt;Group&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;isLoading&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
        &lt;span class=&quot;kt&quot;&gt;ProgressView&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;
            &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;onAppear&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
                &lt;span class=&quot;nf&quot;&gt;fetchData&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;
            &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;else&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
        &lt;span class=&quot;kt&quot;&gt;DataLoadedView&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;
            &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;onAppear&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
                &lt;span class=&quot;nf&quot;&gt;fetchData&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;
            &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;p&gt;The bug &lt;em&gt;was&lt;/em&gt; (at the time) that &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;fetchData&lt;/code&gt; will be called twice: once when &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ProgressView&lt;/code&gt; appears, and once again when &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;DataLoadedView&lt;/code&gt; appears.&lt;/p&gt;

&lt;p&gt;Of course, there are many new modifiers and patterns since iOS 13 or 14 or whenever I was bitten by this (probably before the documentation was added). Regardless, after learning about this behavior it sort of makes sense. And so I learned my lesson that &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Group&lt;/code&gt; should not be used in this case.&lt;/p&gt;

&lt;h2 id=&quot;whats-changed-with-group&quot;&gt;What’s changed with &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Group&lt;/code&gt;&lt;/h2&gt;

&lt;p&gt;I’d locked this knowledge away and hadn’t considered it in years. However, coding agents seem to &lt;em&gt;love&lt;/em&gt; to use &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Group&lt;/code&gt; to write the exact buggy code pattern I just illustrated above. I was curious enough to investigate it again.&lt;/p&gt;

&lt;p&gt;Well it turns out that in iOS 26 and maybe even as far back as iOS 15, in most cases &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Group&lt;/code&gt; no longer distributes its &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;onAppear&lt;/code&gt; or &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;task&lt;/code&gt; calls amongst its subviews. Read on for the caveats.&lt;/p&gt;

&lt;p&gt;From my testing, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;onAppear&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;onDisappear&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;task&lt;/code&gt;, and maybe other modifiers &lt;strong&gt;seem to have been special-cased by Apple&lt;/strong&gt; to work at the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Group&lt;/code&gt;-level and &lt;strong&gt;not&lt;/strong&gt; be distributed to subviews like they used to. Note that this means the documentation for &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Group&lt;/code&gt; (that I quoted above) is now incorrect.&lt;/p&gt;

&lt;h3 id=&quot;onappear-and-task&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;onAppear&lt;/code&gt; and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;task&lt;/code&gt;&lt;/h3&gt;

&lt;p&gt;Consider the following &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;View&lt;/code&gt;:&lt;/p&gt;

&lt;div class=&quot;language-swift highlighter-rouge&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;kd&quot;&gt;struct&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;ContentView&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;View&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;kd&quot;&gt;@State&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;var&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;isLeft&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;kc&quot;&gt;true&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;var&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;body&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;some&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;View&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
        &lt;span class=&quot;kt&quot;&gt;Group&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
            &lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;isLeft&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
                &lt;span class=&quot;kt&quot;&gt;Rectangle&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;fill&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;red&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;frame&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;width&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;50&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
                    &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;frame&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;maxWidth&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;infinity&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;alignment&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;leading&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
                    &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;onAppear&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
                        &lt;span class=&quot;nf&quot;&gt;print&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;left&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
                    &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
            &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;else&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
                &lt;span class=&quot;kt&quot;&gt;Rectangle&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;fill&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;blue&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;frame&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;width&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;50&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
                    &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;frame&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;maxWidth&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;infinity&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;alignment&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;trailing&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
                    &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;onAppear&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
                        &lt;span class=&quot;nf&quot;&gt;print&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;right&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
                    &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
            &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
        &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
        &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;onAppear&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
            &lt;span class=&quot;nf&quot;&gt;print&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;group&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
            &lt;span class=&quot;kt&quot;&gt;Task&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
                &lt;span class=&quot;k&quot;&gt;try&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;?&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Task&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;sleep&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;for&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;seconds&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;1&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;))&lt;/span&gt;
                &lt;span class=&quot;n&quot;&gt;isLeft&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;toggle&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;
            &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
        &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;p&gt;According to the docs, this code should loop between the left and right rectangles. However, as of iOS 26 (and as far back as I can test, iOS 15), it runs the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;onAppear&lt;/code&gt; modifier once and stays showing the right blue rectangle.&lt;/p&gt;

&lt;video src=&quot;/images/swiftui-group-onappear-demo.mp4&quot; controls=&quot;&quot; preload=&quot;none&quot; poster=&quot;/images/swiftui-group-onappear-demo-poster.png&quot; width=&quot;400&quot;&gt;&lt;/video&gt;

&lt;p&gt;The console:&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;left
group
right
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;h3 id=&quot;screen-level-views&quot;&gt;Screen-level Views&lt;/h3&gt;

&lt;p&gt;I stumbled on this lonely bug report from June 2020:&lt;/p&gt;

&lt;p&gt;&lt;a href=&quot;https://forums.swift.org/t/inconsistency-in-how-groups-onappear-and-ondisappear-are-called/37111&quot;&gt;Inconsistency in how Group’s onAppear and onDisappear are called - Using Swift - Swift Forums&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;Something that I noticed today and didn’t expect it is that if a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Group&lt;/code&gt; is not the root view of the screen, its &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;onAppear&lt;/code&gt; is called per each child, while for a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Group&lt;/code&gt; that is the root, the method is called once, regardless of the number of its children.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;div class=&quot;language-swift highlighter-rouge&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;kd&quot;&gt;struct&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;ContentView&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;View&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;var&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;body&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;some&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;View&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
        &lt;span class=&quot;kt&quot;&gt;Group&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;c1&quot;&gt;// Group is the root of the screen, onAppear is called once&lt;/span&gt;
            &lt;span class=&quot;kt&quot;&gt;Group&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;  &lt;span class=&quot;c1&quot;&gt;// non-root view, onAppear is called once per each child&lt;/span&gt;
                &lt;span class=&quot;kt&quot;&gt;Color&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;red&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
                &lt;span class=&quot;kt&quot;&gt;Color&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;yellow&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
            &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
            &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;onAppear&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;print&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;.:. onAppear2&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
            &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;onDisappear&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;print&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;.:. onDisappear2&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
            
            &lt;span class=&quot;kt&quot;&gt;Color&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;blue&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
            &lt;span class=&quot;kt&quot;&gt;Color&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;purple&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
        &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
        &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;onAppear&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;print&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;.:. onAppear1&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
        &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;onDisappear&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;print&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;.:. onDisappear1&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;cm&quot;&gt;/* Output:
.:. onAppear1
.:. onAppear2
.:. onAppear2
*/&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;p&gt;My guess is that this was reported during iOS 13, right before iOS 14 beta.&lt;/p&gt;

&lt;p&gt;I tested this code as well, and it turns out the OP’s example no longer prints &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;onAppear2&lt;/code&gt; twice on iOS 15 or iOS 26.&lt;/p&gt;

&lt;h3 id=&quot;list&quot;&gt;List&lt;/h3&gt;

&lt;p&gt;The only case I’ve found (so far) where the documented &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;onAppear&lt;/code&gt; (and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;task&lt;/code&gt;) distributed-across-subviews behavior still exists is when &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Group&lt;/code&gt; is within a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;List&lt;/code&gt;:&lt;/p&gt;

&lt;div class=&quot;language-swift highlighter-rouge&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;kd&quot;&gt;struct&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;ContentView&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;View&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;kd&quot;&gt;@State&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;var&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;isLeft&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;kc&quot;&gt;true&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;var&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;body&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;some&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;View&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
        &lt;span class=&quot;kt&quot;&gt;List&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
            &lt;span class=&quot;kt&quot;&gt;Group&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
                &lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;isLeft&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
                    &lt;span class=&quot;kt&quot;&gt;Rectangle&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;fill&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;red&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;frame&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;width&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;50&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
                        &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;frame&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;maxWidth&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;infinity&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;alignment&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;leading&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
                        &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;onAppear&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
                            &lt;span class=&quot;nf&quot;&gt;print&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;left&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
                        &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
                &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;else&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
                    &lt;span class=&quot;kt&quot;&gt;Rectangle&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;fill&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;blue&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;frame&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;width&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;50&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
                        &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;frame&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;maxWidth&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;infinity&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;alignment&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;trailing&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
                        &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;onAppear&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
                            &lt;span class=&quot;nf&quot;&gt;print&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;right&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
                        &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
                &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
            &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
            &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;onAppear&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
                &lt;span class=&quot;nf&quot;&gt;print&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;group&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
                &lt;span class=&quot;kt&quot;&gt;Task&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
                    &lt;span class=&quot;k&quot;&gt;try&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;?&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Task&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;sleep&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;for&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;seconds&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;1&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;))&lt;/span&gt;
                    &lt;span class=&quot;n&quot;&gt;isLeft&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;toggle&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;
                &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
            &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
        &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;video src=&quot;/images/swiftui-group-list-demo.mp4&quot; controls=&quot;&quot; preload=&quot;none&quot; poster=&quot;/images/swiftui-group-list-demo-poster.png&quot; width=&quot;400&quot;&gt;&lt;/video&gt;

&lt;p&gt;The console:&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;left
group
right
group
left
group
right
... (repeats forever)
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;h3 id=&quot;regular-modifiers-with-group&quot;&gt;Regular modifiers with Group&lt;/h3&gt;

&lt;p&gt;As a check that &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Group&lt;/code&gt; still distributes its modifiers in the general case, I created a simple custom modifier that prints on &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;init&lt;/code&gt; and applied it to the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Group&lt;/code&gt;:&lt;/p&gt;

&lt;div class=&quot;language-swift highlighter-rouge&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;kd&quot;&gt;struct&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;PrintModifier&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;ViewModifier&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;nf&quot;&gt;init&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
        &lt;span class=&quot;nf&quot;&gt;print&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;modifier init&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;

    &lt;span class=&quot;kd&quot;&gt;func&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;body&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;content&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Content&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;-&amp;gt;&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;some&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;View&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;content&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;

&lt;span class=&quot;kd&quot;&gt;struct&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;ContentView&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;View&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;kd&quot;&gt;@State&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;var&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;isLeft&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;kc&quot;&gt;true&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;var&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;body&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;some&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;View&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
        &lt;span class=&quot;kt&quot;&gt;Group&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
            &lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;isLeft&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
                &lt;span class=&quot;kt&quot;&gt;Rectangle&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;fill&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;red&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;frame&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;width&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;50&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
                    &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;frame&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;maxWidth&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;infinity&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;alignment&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;leading&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
                    &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;onAppear&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
                        &lt;span class=&quot;nf&quot;&gt;print&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;left&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
                    &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
            &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;else&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
                &lt;span class=&quot;kt&quot;&gt;Rectangle&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;fill&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;blue&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;frame&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;width&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;50&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
                    &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;frame&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;maxWidth&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;infinity&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;alignment&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;trailing&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
                    &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;onAppear&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
                        &lt;span class=&quot;nf&quot;&gt;print&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;right&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
                    &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
            &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
        &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
        &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;modifier&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;PrintModifier&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;())&lt;/span&gt;
        &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;onAppear&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
            &lt;span class=&quot;nf&quot;&gt;print&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;group&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
            &lt;span class=&quot;kt&quot;&gt;Task&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
                &lt;span class=&quot;k&quot;&gt;try&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;?&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Task&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;sleep&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;for&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;seconds&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;1&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;))&lt;/span&gt;
                &lt;span class=&quot;n&quot;&gt;isLeft&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;toggle&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;
            &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
        &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;p&gt;The console:&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;modifier init
group
left
modifier init
right
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;p&gt;As we can see, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;PrintModifier&lt;/code&gt; is being applied to each subview independently as is documented. We’ve somewhat proven to ourselves that in the general case, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Group&lt;/code&gt; still distributes modifiers.&lt;/p&gt;

&lt;h2 id=&quot;when-is-group-useful&quot;&gt;When is Group useful?&lt;/h2&gt;

&lt;p&gt;When is &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Group&lt;/code&gt; still the right choice?&lt;/p&gt;

&lt;p&gt;Honestly, not very often!&lt;/p&gt;

&lt;h3 id=&quot;distributing-a-lot-of-modifiers-across-sibling-views&quot;&gt;Distributing a lot of modifiers across sibling views&lt;/h3&gt;

&lt;p&gt;If you really really really need to apply one or more modifiers independently to a set of sibling views &lt;strong&gt;and&lt;/strong&gt; you need the sibling views to stay legible to their current parent container, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Group&lt;/code&gt; is the right choice.&lt;/p&gt;

&lt;p&gt;In the below toy example with Form:&lt;/p&gt;

&lt;div class=&quot;language-swift highlighter-rouge&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;kt&quot;&gt;Form&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;kt&quot;&gt;Text&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;Title&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;kt&quot;&gt;Group&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
        &lt;span class=&quot;kt&quot;&gt;Text&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;Subtitle&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
        &lt;span class=&quot;kt&quot;&gt;Text&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;Description&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
    &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;foregroundStyle&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;secondary&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;p&gt;I would prefer simply duplicating the modifier manually:&lt;/p&gt;

&lt;div class=&quot;language-swift highlighter-rouge&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;kt&quot;&gt;Form&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;kt&quot;&gt;Text&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;Title&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;kt&quot;&gt;Text&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;Subtitle&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
        &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;foregroundStyle&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;secondary&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;kt&quot;&gt;Text&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;Description&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
        &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;foregroundStyle&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;secondary&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;p&gt;In my experience, the conditions that lead to &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Group&lt;/code&gt; being useful for this case are exceedingly rare. In a quick search of my current codebase, I have only a couple examples.&lt;/p&gt;

&lt;p&gt;This one just barely makes sense as it applies 4 modifiers to these 2 slightly different conditional subviews. Not quite large enough to justify separating out into named views; not quite small enough to duplicate the modifiers inline.&lt;/p&gt;

&lt;div class=&quot;language-swift highlighter-rouge&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;kt&quot;&gt;Group&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;switch&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;routeOption&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;case&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;departure&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;departureOnly&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
        &lt;span class=&quot;kt&quot;&gt;HStack&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;spacing&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;3&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
            &lt;span class=&quot;kt&quot;&gt;Image&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;systemName&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;arrow.up.right&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;imageScale&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;small&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;bold&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;
            &lt;span class=&quot;kt&quot;&gt;Image&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;systemName&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;train.side.middle.car&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
        &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
        &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;padding&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;horizontal&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;10&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;case&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;arrival&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;arrivalOnly&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
        &lt;span class=&quot;kt&quot;&gt;HStack&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;spacing&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;2&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
            &lt;span class=&quot;kt&quot;&gt;Image&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;systemName&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;train.side.middle.car&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
            &lt;span class=&quot;kt&quot;&gt;Image&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;systemName&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;arrow.down.right&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;imageScale&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;small&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;bold&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;
        &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
        &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;padding&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;horizontal&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;11&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;font&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;caption&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;foregroundStyle&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;secondary&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;padding&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;vertical&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;10&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;background&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;Material&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;ultraThick&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;in&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;RoundedRectangle&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;cornerRadius&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;4&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;h3 id=&quot;overcoming-the-10-subview-limit-no-longer-applies&quot;&gt;&lt;del&gt;Overcoming the 10-subview limit&lt;/del&gt; (no longer applies)&lt;/h3&gt;

&lt;p&gt;Before Swift 5.9’s variadic generics, ViewBuilder could only handle 10 non-enumerated subviews. This example is still in the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Group&lt;/code&gt; docs but no longer applies:&lt;/p&gt;

&lt;div class=&quot;language-swift highlighter-rouge&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;c1&quot;&gt;// No longer applies as of Swift 5.9&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;var&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;body&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;some&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;View&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;kt&quot;&gt;VStack&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
        &lt;span class=&quot;kt&quot;&gt;Group&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
            &lt;span class=&quot;kt&quot;&gt;Text&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;1&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
            &lt;span class=&quot;kt&quot;&gt;Text&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;2&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
            &lt;span class=&quot;kt&quot;&gt;Text&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;3&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
            &lt;span class=&quot;kt&quot;&gt;Text&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;4&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
            &lt;span class=&quot;kt&quot;&gt;Text&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;5&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
            &lt;span class=&quot;kt&quot;&gt;Text&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;6&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
            &lt;span class=&quot;kt&quot;&gt;Text&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;7&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
            &lt;span class=&quot;kt&quot;&gt;Text&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;8&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
            &lt;span class=&quot;kt&quot;&gt;Text&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;9&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
            &lt;span class=&quot;kt&quot;&gt;Text&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;10&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
        &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
        &lt;span class=&quot;kt&quot;&gt;Text&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;11&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
</description>
        <pubDate>Fri, 12 Dec 2025 10:37:39 -0600</pubDate>
        <link>https://twocentstudios.com/2025/12/12/swiftui-group-still-considered-harmful/</link>
        <guid isPermaLink="true">https://twocentstudios.com/2025/12/12/swiftui-group-still-considered-harmful/</guid>
        
        <category>apple</category>
        
        <category>swiftui</category>
        
        <category>ios</category>
        
        
      </item>
    
      <item>
        <title>Comprehensible Later: A Read-it-later App for Language Learners</title>
        <description>&lt;p&gt;This post is a short retrospective on Comprehensible Later, my working-title for a read-it-later iOS app prototype I worked on last week. Although it’s currently in private beta on Test Flight, I want to share the motivation and technical challenges I ran into while working on it.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/comprehensible_later_screens.jpg&quot; width=&quot;&quot; height=&quot;400&quot; alt=&quot;Share Extension presented from Safari, main app article list, and article detail screens&quot; title=&quot;Share Extension presented from Safari, main app article list, and article detail screens&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Share Extension presented from Safari, main app article list, and article detail screens&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;TLDR: Comprehensible Later is an iOS app for saving articles natively written in a language you’re learning, with automatic translation via LLM to a simpler version of your target language. The goal is to give you more interesting things to read at a level you can understand without needing to pause to look up every other word.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/comprehensible_later_japanese_comparison.jpg&quot; width=&quot;&quot; height=&quot;400&quot; alt=&quot;Original level and simple level for a Japanese article&quot; title=&quot;Original level and simple level for a Japanese article&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Original level and simple level for a Japanese article&lt;/div&gt;&lt;/div&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/comprehensible_later_english_comparison.jpg&quot; width=&quot;&quot; height=&quot;400&quot; alt=&quot;Original level and simple level for an English article (from this blog)&quot; title=&quot;Original level and simple level for an English article (from this blog)&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Original level and simple level for an English article (from this blog)&lt;/div&gt;&lt;/div&gt;

&lt;h2 id=&quot;what-is-comprehensible-input&quot;&gt;What is Comprehensible Input&lt;/h2&gt;

&lt;p&gt;&lt;a href=&quot;https://en.wikipedia.org/wiki/Input_hypothesis&quot;&gt;Comprehensible Input&lt;/a&gt; is part of a language acquisition framework first introduced by &lt;a href=&quot;https://www.sdkrashen.com/&quot;&gt;Dr. Stephen D. Krashen&lt;/a&gt;. The framework states that language is separately &lt;em&gt;acquired&lt;/em&gt; and &lt;em&gt;learned&lt;/em&gt;. &lt;em&gt;Acquisition&lt;/em&gt; happens by ensuring ample input (reading or listening) with the important caveat that the input is &lt;em&gt;comprehensible&lt;/em&gt; at the learner’s current level. &lt;em&gt;Learning&lt;/em&gt; happens through comprehensive study of rules and vocabulary. From this &lt;a href=&quot;https://www.dreaming.com/blog-posts/the-og-immersion-method&quot;&gt;summary&lt;/a&gt;:&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;When we receive comprehensible input, the conditions are met for our brain to be able to use its natural ability to acquire language, without having to do anything else. There’s no need to study, review vocabulary, or practice anything. Watching and reading itself results in acquisition.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;In some senses, this method seems intuitive, not in the least since almost all children acquire language skills before they begin formal teaching. In a second-language context, &lt;a href=&quot;https://en.wikipedia.org/wiki/Graded_reader&quot;&gt;graded readers&lt;/a&gt; – books written for various non-native language levels – have existed for over a century. Wikipedia even has a &lt;a href=&quot;https://simple.wikipedia.org/&quot;&gt;simple English&lt;/a&gt; language variant for many common articles. I’ve occasionally used the modern &lt;a href=&quot;https://www.satorireader.com/&quot;&gt;Satori Reader&lt;/a&gt; service for Japanese graded texts.&lt;/p&gt;

&lt;p&gt;But I think the important part is recognizing exactly &lt;em&gt;how basic&lt;/em&gt; you need to make some input in order for it to be understandable, especially at the absolute-beginner level. In a since-removed introductory YouTube video from the creator, he shows a session of an instructor sitting with a zero-level beginner student, pointing at vivid images in a travel magazine and gesturing heavily while explaining the contents very slowly in the target language as the primary means of bootstrapping.&lt;/p&gt;

&lt;p&gt;Language is so multi-dimensional that it’s incredibly time consuming – both as a creator &lt;em&gt;and&lt;/em&gt; a consumer of materials – to get the exact level of material that is both comprehensible but challenging enough to increase your overall ability. Then add another dimension of &lt;em&gt;motivation&lt;/em&gt;: as a reader, how do you find materials with a subject matter that’s interesting to you and will keep you motivated to push through word-after-word, page-after-page, day-after-day?&lt;/p&gt;

&lt;p&gt;This led to a hypothesis:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Graded readers are usually close to the correct difficulty to facilitate learning, but do not have an audience wide enough to support a variety of interesting subject material.&lt;/li&gt;
  &lt;li&gt;Native materials cover an infinite range of interesting topics, but are infeasible to read until the latest stages of language acquisition.&lt;/li&gt;
  &lt;li&gt;One of the most commonly accepted use-cases for LLMs is text summary and translation.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;What if we used LLMs to translate any native article on-demand to the user’s exact target language level?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The barriers to this being feasible are:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Can an LLM properly translate from e.g. Native Japanese to JLPT N4 level Japanese in a “natural” way – where it is simultaneously challenging, comprehensible, and accurate?&lt;/li&gt;
  &lt;li&gt;Can the translation happen fast enough to fit within a user’s desired language-learning workflow?&lt;/li&gt;
  &lt;li&gt;What additional resources are required to facilitate language learning? In-line dictionary lookup? An SRS system? Customized word lists?&lt;/li&gt;
  &lt;li&gt;What unique points are there to each target language that increase the interface complexity? For example, for Japanese learning, should we include furigana for all potentially unknown kanji?&lt;/li&gt;
  &lt;li&gt;Does it also make sense to allow translation from e.g. Native English to simple Japanese (if the target language is Japanese)?&lt;/li&gt;
&lt;/ul&gt;

&lt;h2 id=&quot;specification-for-the-prototype-app&quot;&gt;Specification for the prototype app&lt;/h2&gt;

&lt;p&gt;I’ve used 2025 frontier LLMs to write and translate simple versions of text before, so I’m confident it’s either possible now at a minimally acceptable translation quality or will be in the very near future.&lt;/p&gt;

&lt;p&gt;What I wasn’t confident about is whether it’s cost prohibitive or time prohibitive to use the highest quality reasoning models to do the translation.&lt;/p&gt;

&lt;p&gt;In retrospect, I should have spent at least a little more time doing bench testing on the API versions of various models on a wide array of sample articles. Instead, I took the less (more?) pragmatic route of jumping into the implementation for an app prototype that I could start using ASAP in context, as well as distribute to a few friends.&lt;/p&gt;

&lt;p&gt;My initial thought was that my main source of content would be blog posts and news articles I come across from my everyday feed scrolling. But I also felt I should support translating raw text too, like that from social media posts.&lt;/p&gt;

&lt;p&gt;I considered a Safari Extension to replace existing text on a webpage with the simplified translation, similar to how the built-in translation function in Safari works. But my gut-feeling was that this would be too limiting for language learning use cases. Even reading a text at a simpler level of a target language still takes enough time that it would be better to ensure the user doesn’t feel obligated to read everything at once. Additionally, this wouldn’t work for native text outside of Safari.&lt;/p&gt;

&lt;p&gt;My next thought was a Share Extension. Share Extensions are old iOS technology, but still highly used and useful. In a share extension I could display the translated article content in a dedicated modal and have full control over its presentation and layout.&lt;/p&gt;

&lt;p&gt;However, I also wanted to support the read-it-later use case. Personally, I stumble upon articles when doing feed scrolling sessions when I have a few minutes on the train but don’t necessarily have the time to read the whole article, even in English, at that time. I use Instapaper for read-it-later for English articles and I felt this would be a similarly useful use case to model my app after.&lt;/p&gt;

&lt;p&gt;With that in mind I got to work on the actual prototype with the following initial spec:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;A native app that:
    &lt;ul&gt;
      &lt;li&gt;keeps a list of articles imported from URLs or as raw text.&lt;/li&gt;
      &lt;li&gt;has a detail view that shows both the original and translated versions of the text.&lt;/li&gt;
    &lt;/ul&gt;
  &lt;/li&gt;
  &lt;li&gt;A share extension that:
    &lt;ul&gt;
      &lt;li&gt;immediately processes the shared URL or text and displays it in the share modal.&lt;/li&gt;
      &lt;li&gt;allows the user to optionally save the translated text in the app for later.&lt;/li&gt;
    &lt;/ul&gt;
  &lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Note that as of iOS 18.4, there’s actually &lt;em&gt;another&lt;/em&gt; option for the interface: &lt;a href=&quot;https://developer.apple.com/documentation/TranslationUIProvider/Preparing-your-app-to-be-the-default-translation-app&quot;&gt;TranslationUIProviderExtension&lt;/a&gt;. iOS users can replace Apple’s Translation app with another translation app, meaning the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Translate&lt;/code&gt; menu tooltip can open a third-party app. I have mine set to &lt;a href=&quot;https://www.deepl.com/&quot;&gt;DeepL&lt;/a&gt;. Due to the limitations I’ll discuss later (namely, translation processing time), it doesn’t make sense yet to implement Comprehensible Later as a Translation Extension.&lt;/p&gt;

&lt;h2 id=&quot;implementation&quot;&gt;Implementation&lt;/h2&gt;

&lt;p&gt;I worked through several iterations of a detailed implementation spec with Claude and Codex then set them off to work getting the foundations of the app in place. This wasn’t exactly vibe coding because I specified technologies and packages to use up front and guided their output along the way. But I was still aiming to have the agents create the clay that I’d be molding in a distinct second phase of development.&lt;/p&gt;

&lt;h3 id=&quot;packages&quot;&gt;Packages&lt;/h3&gt;

&lt;p&gt;The key packages that would make this closer to a weekend prototype and not a months-long project were:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;a href=&quot;https://github.com/Ryu0118/swift-readability&quot;&gt;swift-readability&lt;/a&gt; - wrapper for Firefox’s &lt;a href=&quot;https://github.com/mozilla/readability&quot;&gt;reader-view parsing library&lt;/a&gt; for stripping down a full page HTML to its essential content.&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://github.com/mattt/AnyLanguageModel&quot;&gt;AnyLanguageModel&lt;/a&gt; - use any LLM API with Apple’s Foundation Models SDK interface.&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://github.com/gonzalezreal/swift-markdown-ui&quot;&gt;swift-markdown-ui&lt;/a&gt; - display the full Markdown spec in SwiftUI (note: I later replaced this).&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://github.com/steipete/Demark&quot;&gt;Demark&lt;/a&gt; - convert HTML-to-Markdown.&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://github.com/JohnSundell/Ink&quot;&gt;Ink&lt;/a&gt; - convert Markdown-to-HTML.&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://github.com/pointfreeco/sqlite-data&quot;&gt;sqlite-data&lt;/a&gt; - SQLite wrapper for local article storage and observable data layer for the app.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3 id=&quot;data-flow&quot;&gt;Data flow&lt;/h3&gt;

&lt;p&gt;The initial data flow for articles imported via URL was the following:&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;Input URL
    ↓ URLSession
HTML Data
    ↓ String(data:)
HTML String
    ↓ Readability
Clean HTML
    ↓ Demark
Markdown
    ↓ AnyLanguageModel
Translated MD
    ↓ swift-markdown-ui
Display
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;p&gt;The initial flow for raw text:&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;Input Text
    ↓ AnyLanguageModel
Translated MD
    ↓ swift-markdown-ui
Display
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;p&gt;However, due to limitations with the swift-markdown-ui package, the final version of the prototype uses this flow:&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;Input URL
    ↓ URLSession
HTML Data
    ↓ String(data:)
HTML String
    ↓ Readability
Clean HTML
    ↓ Demark
Markdown
    ↓ AnyLanguageModel
Translated MD
    ↓ Ink
HTML
    ↓ WebView
Display
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;p&gt;With the bones of the app architecture and dependencies in place, I began testing and optimizing the data flow.&lt;/p&gt;

&lt;h3 id=&quot;readability&quot;&gt;Readability&lt;/h3&gt;

&lt;p&gt;I found a small bug in the Readability Swift wrapper where the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;baseURL&lt;/code&gt; parameter was inaccessible to the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;URL&lt;/code&gt;-based initializer &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Readability().parse(url:options:)&lt;/code&gt;. This prevented relative image tags from getting properly resolved to a full address. For example, on my website image tags look like &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;/images/example.jpg&lt;/code&gt; and are resolved by my browser automatically to be either &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;https://twocentstudios.com/images/example.jpg&lt;/code&gt; (the real server) or &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;http://localhost:4000/images/example.jpg&lt;/code&gt; (my local machine).&lt;/p&gt;

&lt;p&gt;Luckily, the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;baseURL&lt;/code&gt; parameter was accessible in the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Readability().parse(html:options:baseURL:)&lt;/code&gt; initializer. As a workaround I simply needed to fetch the page data myself with &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;URLSession&lt;/code&gt;.&lt;/p&gt;

&lt;h3 id=&quot;demark&quot;&gt;Demark&lt;/h3&gt;

&lt;p&gt;Demark has two different HTML-&amp;gt;Markdown parsing implementations: heavy-and-accurate or fast-and-inaccurate. Since the HTML is getting pre-processed by &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Readability&lt;/code&gt; in advance of being passed to Demark, I’m using the fast-and-inaccurate version that doesn’t load the full page in a headless &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;WKWebView&lt;/code&gt;.&lt;/p&gt;

&lt;h3 id=&quot;anylanguagemodel&quot;&gt;AnyLanguageModel&lt;/h3&gt;

&lt;p&gt;As of iOS 26, Apple’s local Foundation model is slow, not-ubiquitously available on devices, and (arguably) barely functional for most use cases, especially mine. Within a few years I expect it may be useful. Similarly, my impression is that any other MLX-compatible models runnable on an iOS device are not yet accurate or fast enough for my use case.&lt;/p&gt;

&lt;p&gt;Therefore, I grabbed both an OpenAI and Gemini API key and wired them up to AnyLanguageModel for testing. I ran a few trials with the top-tier, mini, and nano variants and decided on defaulting to the mini variant as a compromise between speed, cost, and accuracy. Specifically, Gemini Flash 2.5 is the current default, but I suspect I could spend several weeks creating and running benchmarks across the dozens of closed and open models.&lt;/p&gt;

&lt;p&gt;&lt;a href=&quot;https://github.com/mattt/AnyLanguageModel&quot;&gt;AnyLanguageModel&lt;/a&gt; made it easy to build a user settings-based model switcher with very little code adjustments required on my side. Technically, Gemini ships an &lt;a href=&quot;https://ai.google.dev/gemini-api/docs/openai&quot;&gt;OpenAI-compatible endpoint&lt;/a&gt; so I could have kept even more of the same codepath. During debugging, I realized that AnyLanguageModel wasn’t passing through the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;instructions&lt;/code&gt; parameter to OpenAI, so I submitted a &lt;a href=&quot;https://github.com/mattt/AnyLanguageModel/pull/20&quot;&gt;quick PR&lt;/a&gt; and Mattt had it merged and version bumped by the next day.&lt;/p&gt;

&lt;p&gt;In a later mini-sprint, I added a full settings screen that allows switching model provider, model, target language, target difficulty, adding custom translation instructions, and even fully rewriting the system prompt. Of course, I would never include all these settings in a production app, but it’s useful for my trusted beta testers to tinker if they so choose.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/comprehensible_later_settings.jpg&quot; width=&quot;&quot; height=&quot;400&quot; alt=&quot;The translation settings screen&quot; title=&quot;The translation settings screen&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;The translation settings screen&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;By default, my (simple) system prompt is:&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;Faithfully translate the native-level input markdown text into the target language with the target difficulty level.&lt;/p&gt;

  &lt;p&gt;Be creative in transforming difficult words into simpler phrases that use vocabulary at the target difficulty level. Combine or split sentences when necessary, but try to preserve paragraph integrity.&lt;/p&gt;

  &lt;p&gt;The output format should be standard Markdown including all supported markdown formatting like image/video tags. Preserve all structure from the input (paragraphs, lists, headings, links, images, videos). DO NOT ADD COMMENTARY.&lt;/p&gt;

  &lt;p&gt;Target language: \(targetLanguage)&lt;br /&gt;
Target difficulty level: \(targetDifficulty)&lt;br /&gt;
Additional notes: \(additionalNotes)&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I’ll discuss my impressions of the effectiveness of this prompt a little later on.&lt;/p&gt;

&lt;p&gt;Something I noticed almost immediately during testing was that requests were taking at minimum 30 seconds and sometimes over 60 seconds to complete. It didn’t really depend on model size either. I found the same performance characteristics for both OpenAI and Gemini APIs direct from first-party servers. I thought it might be the streaming API or perhaps some configuration in AnyLanguageModel I was not in control of, so I switched back to the single-request version. It didn’t help. I also began testing the same prompt and inputs from the API sandbox pages like &lt;a href=&quot;https://platform.openai.com/chat/edit&quot;&gt;OpenAI’s playground&lt;/a&gt; and &lt;a href=&quot;https://aistudio.google.com/u/1/prompts/new_chat&quot;&gt;Google’s AI Studio&lt;/a&gt; and saw basically the same results.&lt;/p&gt;

&lt;p&gt;Although the slow translation speed is a pretty substantial blocker, I felt like, at least temporarily, I could work around it in the UX by leaning into the read-it-later nature of the app. I added support for Apple’s &lt;a href=&quot;https://developer.apple.com/documentation/backgroundtasks&quot;&gt;Background Tasks&lt;/a&gt; API so there was a greater chance that articles added early in the day would be ready to read by the time the user opened the app.&lt;/p&gt;

&lt;h3 id=&quot;app-ui&quot;&gt;App UI&lt;/h3&gt;

&lt;p&gt;With the translation flow in place, I began shaping the app UI.&lt;/p&gt;

&lt;p&gt;The list of articles was simple enough. I held off on adding lots of important, but not urgent, contextual actions like archiving and deleting from the list view.&lt;/p&gt;

&lt;p&gt;I did add both “import from pasteboard” and “import from free text” buttons to the toolbar.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/comprehensible_later_article_list.jpg&quot; width=&quot;&quot; height=&quot;400&quot; alt=&quot;The article list view showing toolbar buttons for importing from pasteboard and free text&quot; title=&quot;The article list view showing toolbar buttons for importing from pasteboard and free text&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;The article list view showing toolbar buttons for importing from pasteboard and free text&lt;/div&gt;&lt;/div&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/comprehensible_later_import_screen.jpg&quot; width=&quot;&quot; height=&quot;400&quot; alt=&quot;The import URL/text screen&quot; title=&quot;The import URL/text screen&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;The import URL/text screen&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;I spent more time on the article detail view. Initially, it displayed the title, import state, and translated article. My focus for adding actions was to facilitate debugging primarily for myself and secondarily for my beta testers. This meant buttons for copying the original article text, copying translated article text, deleting an article, opening the original link, and retrying the translation (with different settings).&lt;/p&gt;

&lt;p&gt;After some initial usage, I realized I wanted to see the original text and the translated text side-by-side so that I could compare the language usage by sentence and paragraph.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/comprehensible_later_article_detail.jpg&quot; width=&quot;&quot; height=&quot;500&quot; alt=&quot;Article detail view with options to display original and translated text&quot; title=&quot;Article detail view with options to display original and translated text&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Article detail view with options to display original and translated text&lt;/div&gt;&lt;/div&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/comprehensible_later_article_actions.jpg&quot; width=&quot;&quot; height=&quot;400&quot; alt=&quot;Supported actions for article detail&quot; title=&quot;Supported actions for article detail&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Supported actions for article detail&lt;/div&gt;&lt;/div&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/comprehensible_later_debug_info.jpg&quot; width=&quot;&quot; height=&quot;400&quot; alt=&quot;Debug info viewer&quot; title=&quot;Debug info viewer&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Debug info viewer&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;However, the most time-consuming and impactful change was the markdown display system. This was a tough decision, but I think ultimately necessary for the first version.&lt;/p&gt;

&lt;p&gt;Originally, I was planning to use &lt;a href=&quot;https://github.com/gonzalezreal/swift-markdown-ui&quot;&gt;swift-markdown-ui&lt;/a&gt; to display the translated markdown text in SwiftUI. This implementation was basically plug-and-play, rendered exactly as I wanted, supported images out of the box, and was performant. However, the &lt;a href=&quot;https://github.com/gonzalezreal/swift-markdown-ui/issues/264&quot;&gt;one fundamental and unsolvable issue&lt;/a&gt; is that &lt;strong&gt;SwiftUI Text only supports paragraph level copy support and does not support character-level or word-level selection&lt;/strong&gt;. For language learning, I absolutely need the ability to select a word and use the context menu tooltip action “Look Up” or “Translate” or “Copy” buttons. &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;swift-markdown-ui&lt;/code&gt; would not be able to support this and I needed to research other solutions.&lt;/p&gt;

&lt;p&gt;I spent nearly a full day researching and experimenting with other Markdown solutions. My second preference was to convert Markdown to AttributedString either &lt;a href=&quot;https://developer.apple.com/documentation/foundation/instantiating-attributed-strings-with-markdown-syntax&quot;&gt;natively&lt;/a&gt; or &lt;a href=&quot;https://github.com/madebywindmill/MarkdownToAttributedString&quot;&gt;with a package&lt;/a&gt;, then display the AttributedString in a &lt;a href=&quot;https://github.com/kevinhermawan/SelectableText&quot;&gt;SwiftUI-wrapped UITextView&lt;/a&gt; with selection enabled but editing disabled. However, both the native and package versions of AttributedString initialization failed at properly respecting whitespace, newlines, and supporting images. My estimation was that it’d take significantly more time for me to grok the full Markdown spec, all the underlying packages, and then implement the required patches than I was willing to spend for a prototype.&lt;/p&gt;

&lt;p&gt;Therefore, I pivoted to using a browser-based target view instead. iOS 26 was blessed with &lt;a href=&quot;https://developer.apple.com/documentation/webkit/webview-swift.struct&quot;&gt;WebView&lt;/a&gt;, a modern SwiftUI-native implementation of the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;UIWebView&lt;/code&gt; and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;WKWebView&lt;/code&gt; UIKit views before it. With a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;WebView&lt;/code&gt; as the new target, I used &lt;a href=&quot;https://github.com/JohnSundell/Ink&quot;&gt;Ink&lt;/a&gt; to convert the LLM output Markdown back to HTML, added a barebones stylesheet, and loaded these contents. I don’t love using a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;WebView&lt;/code&gt; for this use case since it’s comparatively heavy, has plenty of rendering quirks (like occasional white background flashes), and requires a full screen layout. But at the moment it’s the least-worst option.&lt;/p&gt;

&lt;h3 id=&quot;share-extension-and-action-extension&quot;&gt;Share Extension and Action Extension&lt;/h3&gt;

&lt;p&gt;Unfortunately, the slow translation speed meant some of the complexity of creating a fully-featured Share Extension was in vain; it didn’t make sense for the user to wait 30-60 seconds for the share extension to load a preview of the article content like I’d originally planned.&lt;/p&gt;

&lt;p&gt;My initial vision was to load a one-page preview of the translation as quickly as possible. Then, I’d allow the user to tap a button to continue viewing the full translation in line. Or at any time they could tap a button to save the article URL (or raw text) to the main app to read later. I was planning on having an “open in app” button too, but as far as I can tell it’s not supported to open an app directly from a Share Extension.&lt;/p&gt;

&lt;p&gt;I kept the full functionality of the share extension intact in case I can solve the translation speed issue in the future. But as another workaround, I added an Action Extension. An Action Extension appears in the bottom section of the system share sheet. Like a Share Extension it can also present custom UI, however since I already have a Share Extension I made my Action Extension have no UI and immediately save the URL to the app.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/comprehensible_later_share_sheet.jpg&quot; width=&quot;&quot; height=&quot;450&quot; alt=&quot;iOS share sheet showing both the Share Extension and Action Extension for quickly saving articles&quot; title=&quot;iOS share sheet showing both the Share Extension and Action Extension for quickly saving articles&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;iOS share sheet showing both the Share Extension and Action Extension for quickly saving articles&lt;/div&gt;&lt;/div&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/comprehensible_later_share_extension.jpg&quot; width=&quot;&quot; height=&quot;450&quot; alt=&quot;Share Extension with translation complete&quot; title=&quot;Share Extension with translation complete&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Share Extension with translation complete&lt;/div&gt;&lt;/div&gt;

&lt;h3 id=&quot;import-flow&quot;&gt;Import flow&lt;/h3&gt;

&lt;p&gt;App Extensions can share data on device with the main app using an &lt;a href=&quot;https://developer.apple.com/documentation/Xcode/configuring-app-groups&quot;&gt;App Group&lt;/a&gt;. When the user indicates they want to add the URL or raw text to the app, the Extension serializes an &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Article&lt;/code&gt; model to a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;json&lt;/code&gt; and writes a new file to the App Group. The main app monitors the shared App Group directory for new files. When it detects a new file, it adds the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Article&lt;/code&gt; to the app’s SQLite database. If the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Article&lt;/code&gt; already finished translation, it will include the translated markdown and no further processing is necessary. Otherwise, it will be queued for processing.&lt;/p&gt;

&lt;p&gt;I chose not to share the SQLite database between the main app and the extensions because, since the app and extensions are separate processes, there are &lt;a href=&quot;https://swiftpackageindex.com/groue/GRDB.swift/v7.8.0/documentation/grdb/databasesharing&quot;&gt;myriad issues&lt;/a&gt; with using SQLite in this way. Since data sharing is one way (from extension to app) there’s no need to introduce that complexity.&lt;/p&gt;

&lt;p&gt;Adding articles from the main app instance skips the file encoding/decoding step and simply writes a new &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Article&lt;/code&gt; to the database.&lt;/p&gt;

&lt;p&gt;The processing code is admittedly a bit fragile, but in testing has worked well enough that I haven’t felt an immediate need to rewrite it. It uses an &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;enum Status&lt;/code&gt; stored alongside each &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Article&lt;/code&gt; in the database in order to manage the translation queue, including failures. &lt;a href=&quot;https://github.com/pointfreeco/sqlite-data&quot;&gt;SQLiteData&lt;/a&gt; supports observation, so both the article list view and the article detail view are always up to date on an &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Article&lt;/code&gt;’s status.&lt;/p&gt;

&lt;h3 id=&quot;localization&quot;&gt;Localization&lt;/h3&gt;

&lt;p&gt;Localizing a prototype would be something I’d never consider doing before the advent of coding agents. The actual act of translation between a base language and another language is insignificant compared to the amount of additional tooling and operational complexity of introducing localization keys, adding comments, handling interpolation, handling pluralization rules, handling error messages and other strings generated deep in business logic, and handling the indirection involved in looking up the values for the keys. The new &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;xcstrings&lt;/code&gt; file’s autogeneration definitely helps ease the burden. But it’s at least an order of magnitude more work in my opinion.&lt;/p&gt;

&lt;p&gt;All that said, coding agents can automate enough of this work that I added full localization support for Japanese for one of my beta testers who wanted to try the app for converting English to simple English. I’m still cognizant of the ongoing support complexity full localization adds to a prototype, but for now it’s not a decision I regret.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/comprehensible_later_japanese_localization.jpg&quot; width=&quot;&quot; height=&quot;400&quot; alt=&quot;Various screens with Japanese localization&quot; title=&quot;Various screens with Japanese localization&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Various screens with Japanese localization&lt;/div&gt;&lt;/div&gt;

&lt;h3 id=&quot;impressions-so-far&quot;&gt;Impressions so far&lt;/h3&gt;

&lt;p&gt;What I’ve learned so far is that the prompt needs to be more customized to each target language and should probably go as far as including an allow-list of words to use, especially for the most basic target difficulties.&lt;/p&gt;

&lt;p&gt;I’ve found the models have a hard time with native Japanese news articles. Something about the language is just so dense that my first prompt attempt does not push the model to simplify enough.&lt;/p&gt;

&lt;p&gt;Similar to what I’ve found with even commercial apps like Instapaper, a large percentage of sites now have enough paywall or otherwise reader-hostile javascript that it’s not enough to fetch a simple URL directly from the source. I’m not ready to handle the endless, unforgiving work of handling all the edge cases of the open web, so URL fetching is going to be best effort for the foreseeable future.&lt;/p&gt;

&lt;p&gt;The Readability library itself is not perfect at parsing out text from pages that aren’t obviously written as “articles”. This isn’t all that different from the built-in Safari reader mode which isn’t universally supported across the entire web.&lt;/p&gt;

&lt;p&gt;Seeing some of my blog posts in super-simple English was really fun. One of my ongoing goals is to write simpler without giving up my voice, so seeing how an LLM breaks up my sentences and phrases and clauses is enlightening (of course, not at all related to the use case the prototype was built for).&lt;/p&gt;

&lt;p&gt;For Japanese, there’s some unpredictability on how the LLM deals with kanji. Usually it includes kanji as is, but sometimes it will add the reading in parentheses directly after for literally every word. For example, “果物（くだもの）を食べる（たべる）”. Native ruby/furigana support would be ideal, and possibly easier using HTML than &lt;a href=&quot;https://github.com/ApolloZhu/RubyAttribute&quot;&gt;AttributedString&lt;/a&gt;.&lt;/p&gt;

&lt;h2 id=&quot;whats-next&quot;&gt;What’s next?&lt;/h2&gt;

&lt;p&gt;Comprehensible Later is on Test Flight in private beta with myself and a few friends. I’m planning on collecting feedback and evaluating the app’s potential for wider release. It could take another generation or two of LLM. It could take as long as waiting for local models to improve. Or the entire concept could be flawed. I’m not sure yet. But that’s what the prototype is for.&lt;/p&gt;

&lt;p&gt;Regardless of the result, it was of course a good learning experience to see what it’s like to build a read-it-later service for iOS in 2025.&lt;/p&gt;
</description>
        <pubDate>Sat, 15 Nov 2025 05:57:31 -0600</pubDate>
        <link>https://twocentstudios.com/2025/11/15/comprehensible-later-read-it-later-for-language-learners/</link>
        <guid isPermaLink="true">https://twocentstudios.com/2025/11/15/comprehensible-later-read-it-later-for-language-learners/</guid>
        
        <category>apple</category>
        
        <category>ios</category>
        
        <category>comprehensiblelater</category>
        
        <category>app</category>
        
        
      </item>
    
      <item>
        <title>Let&apos;s Write a Train Tracking Algorithm</title>
        <description>&lt;p&gt;I delivered a 20-minute presentation on September 20th at &lt;a href=&quot;https://iosdc.jp/2025/&quot;&gt;iOSDC Japan 2025&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;If you prefer video:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Japanese (conference): &lt;a href=&quot;https://www.youtube.com/watch?v=CdzUxJom3Ps&quot;&gt;YouTube&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;English (post-conference recording): &lt;a href=&quot;https://youtu.be/xBQlipN0pMg&quot;&gt;YouTube&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Other materials:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;a href=&quot;https://github.com/twocentstudios/train-tracker-talk&quot;&gt;GitHub: train-tracker-talk&lt;/a&gt; - Open source code and presentation materials&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;/2025/09/21/i-presented-at-iosdc-2025/&quot;&gt;Blog: I Presented At iOSDC 2025&lt;/a&gt; - More about the conference and presentation context&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://apps.apple.com/app/id6745218674&quot;&gt;App Store: Eki Live&lt;/a&gt; - The app discussed in the presenation&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This post is a deconstructed version of the talk with the slide images above and my speaker notes in English below.&lt;/p&gt;

&lt;hr /&gt;

&lt;p&gt;&lt;img src=&quot;/images/eki-live-presentation-01.jpeg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Lately I’ve been working on an app called &lt;a href=&quot;https://twocentstudios.com/2025/06/03/eki-live-announcement/&quot;&gt;Eki Live&lt;/a&gt;.&lt;/li&gt;
  &lt;li&gt;Today I’m going to talk about a part of that app.&lt;/li&gt;
  &lt;li&gt;So what do I mean by train tracking algorithm?&lt;/li&gt;
  &lt;li&gt;Well, when riding a train, it’s useful to know the upcoming station.&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;p&gt;&lt;img src=&quot;/images/eki-live-presentation-02.jpeg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;On the train, we can see the train information display or listen for announcements.&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;video src=&quot;/images/eki-live-presentation-03.mp4&quot; autoplay=&quot;&quot; controls=&quot;&quot; preload=&quot;true&quot; width=&quot;100%&quot;&gt;&lt;/video&gt;

&lt;ul&gt;
  &lt;li&gt;But would it also be useful to see this information in your Dynamic Island?&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;p&gt;&lt;img src=&quot;/images/eki-live-presentation-04.jpeg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;In my talk, we’ll first review the data prerequisites we’ll need for the algorithm.&lt;/li&gt;
  &lt;li&gt;Then, we’ll write each part of the algorithm, improving it step-by-step.&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;p&gt;&lt;img src=&quot;/images/eki-live-presentation-05.jpeg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;We need two types of data for the train tracking algorithm:&lt;/li&gt;
  &lt;li&gt;Static data that describes the railway system of greater Tokyo.&lt;/li&gt;
  &lt;li&gt;And Live GPS data from the iPhone user.&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;p&gt;&lt;img src=&quot;/images/eki-live-presentation-06.jpeg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Railways are ordered groups of Stations.&lt;/li&gt;
  &lt;li&gt;In this example, we can see that the Minatomirai Line is made up of 6 stations.&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;p&gt;&lt;img src=&quot;/images/eki-live-presentation-07.jpeg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Trains travel in both Directions on a Railway.&lt;/li&gt;
  &lt;li&gt;Coordinates make up the path of a Railway’s physical tracks.&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;p&gt;&lt;img src=&quot;/images/eki-live-presentation-08.jpeg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;This map shows the Railway data we’ll be using.&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;p&gt;&lt;img src=&quot;/images/eki-live-presentation-09.jpeg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;We collect live GPS data from an iPhone using the Core Location framework.&lt;/li&gt;
  &lt;li&gt;We store the data in a local SQLite database.&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;p&gt;&lt;img src=&quot;/images/eki-live-presentation-10.jpeg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;A &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Location&lt;/code&gt; has all data from &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CLLocation&lt;/code&gt;.&lt;/li&gt;
  &lt;li&gt;Latitude, longitude, speed, course, accuracy, etc.&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;p&gt;&lt;img src=&quot;/images/eki-live-presentation-11.jpeg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;A Session is an ordered list of Locations.&lt;/li&gt;
  &lt;li&gt;A Session represents a possible journey.&lt;/li&gt;
  &lt;li&gt;Green is for fast and red is for stopped.&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;p&gt;&lt;img src=&quot;/images/eki-live-presentation-12.jpeg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;I created a macOS app to visualize the raw data.&lt;/li&gt;
  &lt;li&gt;In the left sidebar there is a list of Sessions.&lt;/li&gt;
  &lt;li&gt;In the bottom panel there is a list of ordered Locations for a Session.&lt;/li&gt;
  &lt;li&gt;Clicking on a Location shows its position and course on the map.&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;p&gt;&lt;img src=&quot;/images/eki-live-presentation-13.jpeg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Our goal is to write an algorithm that determines 3 types of information:&lt;/li&gt;
  &lt;li&gt;The Railway, the direction of the train, and the next Station.&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;p&gt;&lt;img src=&quot;/images/eki-live-presentation-14.jpeg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Here is a brief overview of the system.&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;p&gt;&lt;img src=&quot;/images/eki-live-presentation-15.jpeg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;The app channels &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Location&lt;/code&gt; values to the algorithm.&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;p&gt;&lt;img src=&quot;/images/eki-live-presentation-16.jpeg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;The algorithm reads the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Location&lt;/code&gt; and gathers information from its memory.&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;p&gt;&lt;img src=&quot;/images/eki-live-presentation-17.jpeg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;The algorithm updates its understanding of the device’s location in the world.&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;p&gt;&lt;img src=&quot;/images/eki-live-presentation-18.jpeg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;The algorithm calculates a new result set of railway, direction, and station phase.&lt;/li&gt;
  &lt;li&gt;The result is used to update the app UI and Live Activity.&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;p&gt;&lt;img src=&quot;/images/eki-live-presentation-19.jpeg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Let’s start by considering a single &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Location&lt;/code&gt;.&lt;/li&gt;
  &lt;li&gt;I captured this Location while riding the Tokyu Toyoko Line close to Tsunashima Station.&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;p&gt;&lt;img src=&quot;/images/eki-live-presentation-20.jpeg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Can we determine the Railway from just this Location?&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;p&gt;&lt;img src=&quot;/images/eki-live-presentation-21.jpeg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;We &lt;em&gt;do&lt;/em&gt; have coordinates that outline the railway…&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;p&gt;&lt;img src=&quot;/images/eki-live-presentation-22.jpeg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;First, we find the closest &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;RailwayCoordinate&lt;/code&gt; to the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Location&lt;/code&gt; for each Railway.&lt;/li&gt;
  &lt;li&gt;Then, we order the Railways by which &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;RailwayCoordinate&lt;/code&gt; is nearest.&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;p&gt;&lt;img src=&quot;/images/eki-live-presentation-23.jpeg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Here are our results.&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;p&gt;&lt;img src=&quot;/images/eki-live-presentation-24.jpeg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;The closest &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;RailwayCoordinate&lt;/code&gt; is from the Toyoko Line at only 12 meters away.&lt;/li&gt;
  &lt;li&gt;The next closest &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;RailwayCoordinate&lt;/code&gt; is from the Shin-Yokohama Line at 177 meters away.&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;video src=&quot;/images/eki-live-presentation-applause.mp4&quot; autoplay=&quot;&quot; loop=&quot;&quot; preload=&quot;true&quot; width=&quot;100%&quot;&gt;&lt;/video&gt;

&lt;ul&gt;
  &lt;li&gt;We did it!&lt;/li&gt;
  &lt;li&gt;Our algorithm works well for &lt;em&gt;this&lt;/em&gt; case.&lt;/li&gt;
  &lt;li&gt;But…&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;p&gt;&lt;img src=&quot;/images/eki-live-presentation-26.jpeg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Let’s consider another &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Location&lt;/code&gt;.&lt;/li&gt;
  &lt;li&gt;This &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Location&lt;/code&gt; was also captured on the Toyoko Line.&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;p&gt;&lt;img src=&quot;/images/eki-live-presentation-27.jpeg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;But in this section of the railway track, the Toyoko Line and Meguro Line run parallel.&lt;/li&gt;
  &lt;li&gt;It’s not possible to determine whether the correct line is Toyoko or Meguro from just this one &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Location&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;p&gt;&lt;img src=&quot;/images/eki-live-presentation-28.jpeg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;The algorithm needs to use all &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Location&lt;/code&gt;s from the journey.&lt;/li&gt;
  &lt;li&gt;The example journey follows the Toyoko Line for longer than the Meguro Line.&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;p&gt;&lt;img src=&quot;/images/eki-live-presentation-29.jpeg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;First, we convert the distance between the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Location&lt;/code&gt; and the nearest &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;RailwayCoordinate&lt;/code&gt; to a score.&lt;/li&gt;
  &lt;li&gt;The score is high if close and exponentially lower when far.&lt;/li&gt;
  &lt;li&gt;Then, we add the scores over time.&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;p&gt;&lt;img src=&quot;/images/eki-live-presentation-30.jpeg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;The score from Nakameguro to Hiyoshi is now higher for the Toyoko Line than the Meguro Line.&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;video src=&quot;/images/eki-live-presentation-applause.mp4&quot; autoplay=&quot;&quot; loop=&quot;&quot; preload=&quot;true&quot; width=&quot;100%&quot;&gt;&lt;/video&gt;

&lt;ul&gt;
  &lt;li&gt;We did it!&lt;/li&gt;
  &lt;li&gt;Our algorithm works well for this case.&lt;/li&gt;
  &lt;li&gt;But…&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;p&gt;&lt;img src=&quot;/images/eki-live-presentation-32.jpeg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Let’s consider a third &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Location&lt;/code&gt;.&lt;/li&gt;
  &lt;li&gt;This &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Location&lt;/code&gt; was captured on the Keihin-Tohoku Line which runs the east corridor of Tokyo.&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;p&gt;&lt;img src=&quot;/images/eki-live-presentation-33.jpeg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Several lines run parallel in this corridor.&lt;/li&gt;
  &lt;li&gt;The Tokaido Line follows the same track as the Keihin-Tohoku Line&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;p&gt;&lt;img src=&quot;/images/eki-live-presentation-34.jpeg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;But the Tokaido Line skips many stations.&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;p&gt;&lt;img src=&quot;/images/eki-live-presentation-35.jpeg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;If we only compare railway coordinate proximity scores, the scores will be the same.&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;p&gt;&lt;img src=&quot;/images/eki-live-presentation-36.jpeg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Let’s add a small penalty to the score if a station is passed.&lt;/li&gt;
  &lt;li&gt;If a station is passed, that indicates the iPhone may be on a parallel express railway.&lt;/li&gt;
  &lt;li&gt;Let’s also add a small penalty to the score if a train stops between stations.&lt;/li&gt;
  &lt;li&gt;If a train stops between stations, that indicates the iPhone may be on a parallel local railway.&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;p&gt;&lt;img src=&quot;/images/eki-live-presentation-37.jpeg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Using this algorithm, the Keihin-Tohoku score is now slightly larger than the Tokaido score.&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;p&gt;&lt;img src=&quot;/images/eki-live-presentation-38.jpeg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Let’s consider two example trips to better understand penalties.&lt;/li&gt;
  &lt;li&gt;For an example trip 1 that starts at Tokyo station…&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;p&gt;&lt;img src=&quot;/images/eki-live-presentation-39.jpeg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;The train stops at the second Keihin-Tohoku station.&lt;/li&gt;
  &lt;li&gt;The Tokaido score receives a penalty since the stop occurs between stations.&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;p&gt;&lt;img src=&quot;/images/eki-live-presentation-40.jpeg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;As we continue…&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;p&gt;&lt;img src=&quot;/images/eki-live-presentation-41.jpeg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;The Tokaido score receives many penalties.&lt;/li&gt;
  &lt;li&gt;Therefore, the algorithm determines the trip was on the Keihin-Tohoku Line.&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;p&gt;&lt;img src=&quot;/images/eki-live-presentation-42.jpeg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;For an example trip 2 that also starts at Tokyo…&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;p&gt;&lt;img src=&quot;/images/eki-live-presentation-43.jpeg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;The train passes the 2nd Keihin-Tohoku station.&lt;/li&gt;
  &lt;li&gt;And the Keihin-Tohoku score receives a penalty.&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;p&gt;&lt;img src=&quot;/images/eki-live-presentation-44.jpeg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;As we continue…&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;p&gt;&lt;img src=&quot;/images/eki-live-presentation-45.jpeg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;The Keihin-Tohoku score receives many penalties.&lt;/li&gt;
  &lt;li&gt;Therefore, the algorithm determines the trip was on the Tokaido Line.&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;video src=&quot;/images/eki-live-presentation-applause.mp4&quot; autoplay=&quot;&quot; loop=&quot;&quot; preload=&quot;true&quot; width=&quot;100%&quot;&gt;&lt;/video&gt;

&lt;ul&gt;
  &lt;li&gt;We did it!&lt;/li&gt;
  &lt;li&gt;Our algorithm works well for this case.&lt;/li&gt;
  &lt;li&gt;There are many more edge cases.&lt;/li&gt;
  &lt;li&gt;However, let’s continue.&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;p&gt;&lt;img src=&quot;/images/eki-live-presentation-48.jpeg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;For each potential railway, we will determine which direction the train is moving.&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;p&gt;&lt;img src=&quot;/images/eki-live-presentation-49.jpeg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Every railway has 2 directions.&lt;/li&gt;
  &lt;li&gt;We’re used to seeing separate timetables on the departure board at a non-terminal station.&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;p&gt;&lt;img src=&quot;/images/eki-live-presentation-50.jpeg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;For example, the Toyoko Line goes inbound towards Shibuya and outbound towards Yokohama.&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;p&gt;&lt;img src=&quot;/images/eki-live-presentation-51.jpeg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Let’s consider a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Location&lt;/code&gt; captured on the Toyoko Line going inbound to Shibuya.&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;p&gt;&lt;img src=&quot;/images/eki-live-presentation-52.jpeg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Once we have visited two stations, we can compare the temporal order the station visits.&lt;/li&gt;
  &lt;li&gt;If the visit order matches the order of the stations in the database, we say that the iPhone is heading in the “ascending” direction.&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;p&gt;&lt;img src=&quot;/images/eki-live-presentation-53.jpeg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;The iPhone visited Kikuna and then Okurayama.&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;p&gt;&lt;img src=&quot;/images/eki-live-presentation-54.jpeg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;This ordering does not match the database, so we consider it “descending”.&lt;/li&gt;
  &lt;li&gt;In the database, “descending” maps to inbound.&lt;/li&gt;
  &lt;li&gt;Therefore, we know the iPhone is heading inbound to Shibuya.&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;video src=&quot;/images/eki-live-presentation-applause.mp4&quot; autoplay=&quot;&quot; loop=&quot;&quot; preload=&quot;true&quot; width=&quot;100%&quot;&gt;&lt;/video&gt;

&lt;ul&gt;
  &lt;li&gt;We did it!&lt;/li&gt;
  &lt;li&gt;Our algorithm works well for this case.&lt;/li&gt;
  &lt;li&gt;But…&lt;/li&gt;
  &lt;li&gt;It could take 5 minutes to determine the train direction.&lt;/li&gt;
  &lt;li&gt;Can we do better?&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;p&gt;&lt;img src=&quot;/images/eki-live-presentation-56.jpeg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Let’s use the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Location&lt;/code&gt;’s course.&lt;/li&gt;
  &lt;li&gt;Remember that course is included with some &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CLLocation&lt;/code&gt;s by Core Location.&lt;/li&gt;
  &lt;li&gt;Several points moving at a decent speed are required before Core Location adds course to a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CLLocation&lt;/code&gt;.&lt;/li&gt;
  &lt;li&gt;And course itself has its own accuracy value included.&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;p&gt;&lt;img src=&quot;/images/eki-live-presentation-57.jpeg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Core Location provides an estimate of the iPhone’s course in degrees.&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;p&gt;&lt;img src=&quot;/images/eki-live-presentation-58.jpeg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Note that this is &lt;em&gt;not&lt;/em&gt; the iPhone’s orientation using the compass.&lt;/li&gt;
  &lt;li&gt;The course value should be the same regardless of whether the iPhone is in a pocket or held in a hand facing the rear of the train.&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;p&gt;&lt;img src=&quot;/images/eki-live-presentation-59.jpeg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;The course for the example &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Location&lt;/code&gt; is 359.6 degrees.&lt;/li&gt;
  &lt;li&gt;It’s almost directly North.&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;p&gt;&lt;img src=&quot;/images/eki-live-presentation-60.jpeg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;First, we find the 2 closest stations to the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Location&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;p&gt;&lt;img src=&quot;/images/eki-live-presentation-61.jpeg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Next, we calculate the vector between the 2 closest stations for the “ascending” direction in our database.&lt;/li&gt;
  &lt;li&gt;For the Toyoko line, the “ascending” direction is outbound (as mentioned earlier).&lt;/li&gt;
  &lt;li&gt;Therefore the vector goes from Tsunashima to Okurayama.&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;p&gt;&lt;img src=&quot;/images/eki-live-presentation-62.jpeg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;We need to take a quick sidebar to talk about the dot product.&lt;/li&gt;
  &lt;li&gt;Do you remember the dot product from math class?&lt;/li&gt;
  &lt;li&gt;We can compare the direction of unit vectors with the dot product.&lt;/li&gt;
  &lt;li&gt;Two vectors facing the same direction have a positive dot product.&lt;/li&gt;
  &lt;li&gt;Two vectors facing in opposite directions have a negative dot product.&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;p&gt;&lt;img src=&quot;/images/eki-live-presentation-63.jpeg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Next, we calculate the dot product between the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Location&lt;/code&gt;’s course vector and the stations vector.&lt;/li&gt;
  &lt;li&gt;If the dot product is positive, then the railway direction is “ascending”.&lt;/li&gt;
  &lt;li&gt;If the dot product is negative, then the railway direction is “descending”.&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;p&gt;&lt;img src=&quot;/images/eki-live-presentation-65.jpeg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;The dot product is -0.95.&lt;/li&gt;
  &lt;li&gt;It’s negative.&lt;/li&gt;
  &lt;li&gt;Negative means “descending”.&lt;/li&gt;
  &lt;li&gt;And “descending” in our database maps to inbound for the Toyoko Line.&lt;/li&gt;
  &lt;li&gt;Therefore, the iPhone is heading to Shibuya.&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;video src=&quot;/images/eki-live-presentation-applause.mp4&quot; autoplay=&quot;&quot; loop=&quot;&quot; preload=&quot;true&quot; width=&quot;100%&quot;&gt;&lt;/video&gt;

&lt;ul&gt;
  &lt;li&gt;We did it!&lt;/li&gt;
  &lt;li&gt;Our algorithm works well.&lt;/li&gt;
  &lt;li&gt;Let’s move on to the last part of the algorithm.&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;p&gt;&lt;img src=&quot;/images/eki-live-presentation-67.jpeg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Finally, we can determine the next station.&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;p&gt;&lt;img src=&quot;/images/eki-live-presentation-68.jpeg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;The next station is shown on the train information display.&lt;/li&gt;
  &lt;li&gt;We’ll call this the “focus station phase” going forward.&lt;/li&gt;
  &lt;li&gt;This includes the station name (e.g. Kikuna) and its phase (e.g. Next).&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;p&gt;&lt;img src=&quot;/images/eki-live-presentation-69.jpeg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;The display cycles through next, soon, and now phases for each station.&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;p&gt;&lt;img src=&quot;/images/eki-live-presentation-70.jpeg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;On a map, here is where we will show each phase.&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;p&gt;&lt;img src=&quot;/images/eki-live-presentation-71.jpeg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;We calculate the distance &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;d&lt;/code&gt; and direction vector &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;c&lt;/code&gt; from the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Location&lt;/code&gt; to the closest station.&lt;/li&gt;
  &lt;li&gt;We show the closest station &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;S&lt;/code&gt; or the next station in the travel direction &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;S+1&lt;/code&gt; depending on &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;d&lt;/code&gt; and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;c&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;p&gt;&lt;img src=&quot;/images/eki-live-presentation-72.jpeg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;When the closest station is in the travel direction, the phase will be “next”.&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;p&gt;&lt;img src=&quot;/images/eki-live-presentation-73.jpeg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;A &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Location&lt;/code&gt; less than 500m from the station will be “soon”.&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;p&gt;&lt;img src=&quot;/images/eki-live-presentation-74.jpeg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;A &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Location&lt;/code&gt; less than 200m from the station will be “now”.&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;p&gt;&lt;img src=&quot;/images/eki-live-presentation-75.jpeg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Even though the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Location&lt;/code&gt; is within 500m from the closest station, the station is not in the travel direction.&lt;/li&gt;
  &lt;li&gt;Therefore, the phase will be “next” for the next station in the travel direction.&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;video src=&quot;/images/eki-live-presentation-applause.mp4&quot; autoplay=&quot;&quot; loop=&quot;&quot; preload=&quot;true&quot; width=&quot;100%&quot;&gt;&lt;/video&gt;

&lt;ul&gt;
  &lt;li&gt;We did it!&lt;/li&gt;
  &lt;li&gt;Our algorithm works well.&lt;/li&gt;
  &lt;li&gt;But…&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;p&gt;&lt;img src=&quot;/images/eki-live-presentation-77.jpeg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;GPS data is unreliable.&lt;/li&gt;
  &lt;li&gt;Especially within big stations.&lt;/li&gt;
  &lt;li&gt;Especially when not moving.&lt;/li&gt;
  &lt;li&gt;Here is an example &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Location&lt;/code&gt; stopped inside Kawasaki station that has an abysmal 1 km accuracy.&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;p&gt;&lt;img src=&quot;/images/eki-live-presentation-78.jpeg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Let’s create a history of &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Location&lt;/code&gt;s for each station.&lt;/li&gt;
  &lt;li&gt;For each station, let’s categorize each &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Location&lt;/code&gt; according to its distance and direction.&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;p&gt;&lt;img src=&quot;/images/eki-live-presentation-79.jpeg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;In this example, “approaching” points are orange, “visiting” points are green, and the departure point is “red”.&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;p&gt;&lt;img src=&quot;/images/eki-live-presentation-80.jpeg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Focus station algorithm version 2 has 3 steps.&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;p&gt;&lt;img src=&quot;/images/eki-live-presentation-81.jpeg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;In step 1, we categorize a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Location&lt;/code&gt; as “visiting” or “approaching” if it lies within the bounds of a Station.&lt;/li&gt;
  &lt;li&gt;Our rule is that only 1 Station per Railway will store a unique &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Location&lt;/code&gt; in the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;visitingLocations&lt;/code&gt; or &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;approachingLocations&lt;/code&gt; array.&lt;/li&gt;
  &lt;li&gt;Usually, this is not an issue, but some Stations on the same Railway are within 200m of each other.&lt;/li&gt;
  &lt;li&gt;To disambiguate, we always choose the closest Station.&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;p&gt;&lt;img src=&quot;/images/eki-live-presentation-82.jpeg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;If the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Location&lt;/code&gt; is outside the bounds of any Station that already has &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;visitingLocations&lt;/code&gt; or &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;approachingLocations&lt;/code&gt; as non-empty, we set the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;firstDepartureLocation&lt;/code&gt; for that Station.&lt;/li&gt;
  &lt;li&gt;It’s okay for a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Location&lt;/code&gt; to be set as &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;firstDepartureLocation&lt;/code&gt; for Station A while also being in a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;visitingLocations&lt;/code&gt; or &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;approachingLocations&lt;/code&gt; array of Station B.&lt;/li&gt;
  &lt;li&gt;Additionally, there is special handling for the startup case where a railway has no &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Location&lt;/code&gt;s set yet. In this case, we try to find the closest &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Station&lt;/code&gt; opposite the travel direction and set its &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;firstDepartureLocation&lt;/code&gt;.&lt;/li&gt;
  &lt;li&gt;We can then consider that &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Station&lt;/code&gt; the user’s departure station and use it to determine the focus station.&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;p&gt;&lt;img src=&quot;/images/eki-live-presentation-83.jpeg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;In step 2, we use the station history to calculate the phase for each station.&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;p&gt;&lt;img src=&quot;/images/eki-live-presentation-84.jpeg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;This is a departure phase for Minami-Senju station.&lt;/li&gt;
  &lt;li&gt;The &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;StationDirectionalLocationHistory&lt;/code&gt; has only a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;firstDepartureLocation&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;p&gt;&lt;img src=&quot;/images/eki-live-presentation-85.jpeg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;This is an approaching phase for Kita-Senju station.&lt;/li&gt;
  &lt;li&gt;Note: this would still count as an approaching phase even if there were only 1 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Location&lt;/code&gt; in the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;approachingLocations&lt;/code&gt; array.&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;p&gt;&lt;img src=&quot;/images/eki-live-presentation-86.jpeg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;This is a visiting phase.&lt;/li&gt;
  &lt;li&gt;Note: this would still count as a visiting phase even if there were only 1 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Location&lt;/code&gt; in the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;visitingLocations&lt;/code&gt; array.&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;p&gt;&lt;img src=&quot;/images/eki-live-presentation-87.jpeg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;This is a visited phase.&lt;/li&gt;
  &lt;li&gt;You can see the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;firstDepartureLocation&lt;/code&gt; in red.&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;p&gt;&lt;img src=&quot;/images/eki-live-presentation-88.jpeg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;In step 3, we look through the station phase history for all stations to determine the focus station phase.&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;p&gt;&lt;img src=&quot;/images/eki-live-presentation-89a.jpeg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;In an example, when the latest phase for Kawasaki station is visited, then the focus phase is “Next: Kamata”&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;p&gt;&lt;img src=&quot;/images/eki-live-presentation-89b.jpeg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;In another example, when the latest station phase for Musashi-Kosugi station is visited and Motosumiyoshi station is approaching, then the focus phase is “Soon: Motosumiyoshi”&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;p&gt;&lt;img src=&quot;/images/eki-live-presentation-90.jpeg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Using a state machine gives us more stable results.&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;video src=&quot;/images/eki-live-presentation-applause.mp4&quot; autoplay=&quot;&quot; loop=&quot;&quot; preload=&quot;true&quot; width=&quot;100%&quot;&gt;&lt;/video&gt;

&lt;ul&gt;
  &lt;li&gt;We did it!&lt;/li&gt;
  &lt;li&gt;Our algorithm works well…&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;p&gt;&lt;img src=&quot;/images/eki-live-presentation-91.jpeg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;But can we tell the difference between a visited station and a passed station?&lt;/li&gt;
  &lt;li&gt;Remember, we need this information to calculate a potential penalty for the railway score.&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;p&gt;&lt;img src=&quot;/images/eki-live-presentation-92.jpeg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;If the train is stopped within a station’s bounds for more than 20 seconds then we consider it visited.&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;p&gt;&lt;img src=&quot;/images/eki-live-presentation-93.jpeg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;If the train is moving within a station’s bounds for more than 70 seconds then we also consider it visited.&lt;/li&gt;
  &lt;li&gt;This case is for stations with bad GPS reception.&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;p&gt;&lt;img src=&quot;/images/eki-live-presentation-94.jpeg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Otherwise we consider the station as passed.&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;video src=&quot;/images/eki-live-presentation-95a.mp4&quot; controls=&quot;&quot; preload=&quot;false&quot; width=&quot;100%&quot;&gt;&lt;/video&gt;

&lt;ul&gt;
  &lt;li&gt;Now I’d like to demo the SessionViewer macOS app I created.&lt;/li&gt;
  &lt;li&gt;I’ll show a journey from Kannai station to Kawasaki station on the Keihin-Tohoku line.&lt;/li&gt;
  &lt;li&gt;It takes some time for all &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Location&lt;/code&gt;s to be processed by the algorithm (top right).&lt;/li&gt;
  &lt;li&gt;But while it’s processing, I can start playback to see the journey at 10x speed (top right).&lt;/li&gt;
  &lt;li&gt;In the inspector (right sidebar), you can see the algorithm’s results updating.&lt;/li&gt;
  &lt;li&gt;Keihin-Tohoku line has the highest score (top right).&lt;/li&gt;
  &lt;li&gt;The direction is northbound (top right).&lt;/li&gt;
  &lt;li&gt;The latest phase for each station is shown (middle right).&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;video src=&quot;/images/eki-live-presentation-95b.mp4&quot; controls=&quot;&quot; preload=&quot;false&quot; width=&quot;100%&quot;&gt;&lt;/video&gt;

&lt;ul&gt;
  &lt;li&gt;When we reach the last &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Location&lt;/code&gt; in the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Session&lt;/code&gt;, we can see the full Station history (middle right).&lt;/li&gt;
  &lt;li&gt;We can see the phase history for any station by clicking its current phase.&lt;/li&gt;
  &lt;li&gt;When I click on a station, I can see on the map the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Location&lt;/code&gt;s that were used to calculate its phase.&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;p&gt;&lt;img src=&quot;/images/eki-live-presentation-96.jpeg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;The 5 iOS apps I created to collect this data are &lt;a href=&quot;https://github.com/twocentstudios/train-tracker-talk&quot;&gt;open source on GitHub&lt;/a&gt;.&lt;/li&gt;
  &lt;li&gt;The macOS app and algorithm are included as well.&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;p&gt;&lt;img src=&quot;/images/eki-live-presentation-97.jpeg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;The algorithm is still being improved!&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;p&gt;&lt;img src=&quot;/images/eki-live-presentation-98.jpeg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;But if you want to try it, Eki Live is on the &lt;a href=&quot;https://apps.apple.com/app/id6745218674&quot;&gt;App Store&lt;/a&gt; now.&lt;/li&gt;
  &lt;li&gt;The app starts up automatically in the background and shows the next station in the Dynamic Island.&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;p&gt;&lt;img src=&quot;/images/eki-live-presentation-99.jpeg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Thanks for reading this presentation.&lt;/li&gt;
  &lt;li&gt;If you have questions or comments, feel free to reach out.&lt;/li&gt;
&lt;/ul&gt;
</description>
        <pubDate>Mon, 22 Sep 2025 15:00:00 -0500</pubDate>
        <link>https://twocentstudios.com/2025/09/22/lets-write-a-train-tracking-algorithm/</link>
        <guid isPermaLink="true">https://twocentstudios.com/2025/09/22/lets-write-a-train-tracking-algorithm/</guid>
        
        <category>ekilive</category>
        
        <category>apple</category>
        
        <category>ios</category>
        
        <category>presentation</category>
        
        
      </item>
    
      <item>
        <title>I Presented At iOSDC 2025</title>
        <description>&lt;p&gt;I gave a 20-minute presentation at iOSDC 2025 called “Let’s Write a Train Tracking Algorithm”. I’m still gathering up all the presentation materials, but so far:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;a href=&quot;https://speakerdeck.com/twocentstudios/lets-write-a-train-tracking-algorithm&quot;&gt;Speaker Deck: Static slides&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://github.com/twocentstudios/train-tracker-talk&quot;&gt;GitHub: Open source code and presentation materials&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://apps.apple.com/app/id6745218674&quot;&gt;App Store: Eki Live&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://fortee.jp/iosdc-japan-2025/proposal/a5e991ef-fec8-420b-8da8-de1f38c58182&quot;&gt;Fortee: My talk proposal&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;/2025/04/15/train-tracker-checkpoint-devlog/&quot;&gt;Blog: Eki Live Devlog 1&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;/2025/05/29/train-tracker-devlog-02/&quot;&gt;Blog: Eki Live Devlog 2&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is a more behind-the-scenes diary post. I’ll follow up with more details from the actual talk after the conference has ended.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/iosdc25-livestream.jpg&quot; width=&quot;&quot; height=&quot;300&quot; alt=&quot;Me looking especially jpg encoded on the live stream&quot; title=&quot;Me looking especially jpg encoded on the live stream&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Me looking especially jpg encoded on the live stream&lt;/div&gt;&lt;/div&gt;

&lt;h2 id=&quot;about-the-conference&quot;&gt;About the conference&lt;/h2&gt;

&lt;p&gt;iOSDC is a really great conference for both attendees and speakers. This is my first year speaking but I’ve attended the past 3 years. I like that there’s an open proposal system and it draws a wide variety of speakers that are not necessarily on the “circuit”. The rookies lighting talk system is also a fantastic way to create the next generation of great speakers in a supportive environment. I met some cool developers at the speakers’ dinner that got me excited to do my own talk and to see their talks.&lt;/p&gt;

&lt;p&gt;After my talk, there were several great questions during Q&amp;amp;A. And I had several more really interesting conversations with developers at the Ask the Speaker table.&lt;/p&gt;

&lt;p&gt;This was my first conference talk presenting in Japanese. When I created my proposal, I knew I wanted to do it in Japanese. Although I’m sure the majority of iOSDC attendees could have understood the presentation in English (especially simplified), it felt like the right time to challenge myself.&lt;/p&gt;

&lt;p&gt;Writing the talk in Japanese had a significant upside: I had to iterate the spoken lines for each slide several times to their most essential elements. My less expansive vocabulary resulted in a talk that is easier to understand by both developers and non-developers. My inability to improvise made the talk less fluid, but also ensured that my talk was optimized for time and I could learn it down to the word.&lt;/p&gt;

&lt;p&gt;The final version of my presentation was 121 slides in 20 minutes. The slides layout was created in Deck Set. I created about 90 images and 5 videos captured from the maps in my custom apps and in Figma. The demo video was lightly edited in Final Cut Pro.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/iosdc25-deckset.png&quot; width=&quot;&quot; height=&quot;400&quot; alt=&quot;My deck in Deck Set&quot; title=&quot;My deck in Deck Set&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;My deck in Deck Set&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Thanks to everyone who came to the talk and watched online. Special thanks to my friends who gave early feedback on my drafts.&lt;/p&gt;

&lt;p&gt;I’m really happy I had this opportunity. I hope I’ll have something interesting to propose for a talk for next year.&lt;/p&gt;

&lt;h2 id=&quot;how-i-developed-the-presentation-materials&quot;&gt;How I developed the presentation materials&lt;/h2&gt;

&lt;p&gt;At the time of the call for proposals, I’d released Eki Live to the App Store with what I considered “version 2” of the underlying algorithm that determines the user’s railway, the direction, and station and updates it over time.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/eki-live-v1-home-en.jpg&quot; width=&quot;&quot; height=&quot;450&quot; alt=&quot;Eki Live&apos;s home screen, showing railway, direction, and next station&quot; title=&quot;Eki Live&apos;s home screen, showing railway, direction, and next station&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Eki Live&apos;s home screen, showing railway, direction, and next station&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Before version 2, there was of course version 1 of the algorithm. Version 1 was the simplest method that worked for the least difficult train journey scenarios. The app would use the iPhone’s distance from station coordinates to create a visit history for the stations on a railway line. This info was enough to provide an estimate of the railway, direction, and station in many cases. But version 1 of the algorithm had several unfixable flaws:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;The user had to visit at least 2 stations in order for a result to be produced.&lt;/li&gt;
  &lt;li&gt;The algorithm was not differentiating between parallel railways that split at some point.&lt;/li&gt;
  &lt;li&gt;There was no way to differentiate between parallel railways with different station configurations like the Tokaido and Keihin-Tohoku lines.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Although the rest of the app was ready, I didn’t end up releasing version 1 of the tracking algorithm. It didn’t quite feel magical enough.&lt;/p&gt;

&lt;p&gt;I rewrote the algorithm to a version 2 before releasing the app. I spent more time collecting data and more time creating visual debug utility apps to assist in development.&lt;/p&gt;

&lt;p&gt;However, the biggest change to the algorithm was obtaining and cleaning up railway coordinate data. The dataset I used for &lt;a href=&quot;/eki-bright-tokyo-area-train-timetables/&quot;&gt;Eki Bright&lt;/a&gt; only included station (and its coordinates) and railway data. Once I had access to the coordinate data, that opened up a new avenue for being able to estimate a railway instantly and then continue to refine the estimate over time.&lt;/p&gt;

&lt;p&gt;In version 2, I doubled down on a scoring system for each aspect of the overall algorithm. Although my head was in the right place, this introduced far too much complexity for too little benefit. It took much longer than I’d hoped, but I eventually got this version to a state I was reasonably satisfied with and released it as Eki Live v1.0 to the App Store.&lt;/p&gt;

&lt;p&gt;It would be much easier to develop the tracking algorithm assuming access to an infinite stream of accurate GPS coordinates from an iPhone. Unfortunately, this is not the case. The app needs to work within the bounds of the Core Location APIs for monitoring significant location changes so that device battery life can be preserved. The app also needs to &lt;em&gt;stop&lt;/em&gt; tracking a journey when that journey ends. Therefore, there’s two other separate heuristics I needed to iterate on that subtly affect the behavior of the tracking algorithm via the data provided to it.&lt;/p&gt;

&lt;p&gt;When I submitted my talk proposal to iOSDC, I was planning on talking about the entire app. After all, there were so many unique and interesting problems I’d run into in the development process.&lt;/p&gt;

&lt;p&gt;When my proposal was accepted, I started working on my talk with this in goal in mind: more of a high level overview of the app development. In that early draft of the talk, I set out a map for which data-gathering apps I needed to create. A main theme of this draft would be &lt;em&gt;why you should develop throwaway prototype apps to gather data before developing for production&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;Of course, it took a few weeks to develop what became 5 separate prototype apps plus the algorithm viewer app. Along the way, I became less and less satisfied with the prospect of presenting “version 2” of the tracking algorithm to an audience since it had many admitted flaws.&lt;/p&gt;

&lt;p&gt;Once I’d finally created all my prototype apps and updated the talk draft, I realized I was already probably 5-10 minutes over time without talking about the tracking algorithm, UI, or Live Activity. I took a step back and realized that the tracking algorithm made for a more easily digestible story for newcomers to this problem than a bunch of minutia about the self-imposed constraints of data collection.&lt;/p&gt;

&lt;p&gt;So in terms of the talk draft, I essentially started over. This time I focused purely on the tracking algorithm itself, telling a cleaned up story about how it has improved over time. Although I didn’t show any of the several data collection apps I made, they were still useful in collecting real life data to use in examples and to develop the algorithm itself.&lt;/p&gt;

&lt;p&gt;To fit the time constraints, I had to pare down a few examples and gloss over some details. But in spite of this, I was satisfied with the talk being code-free and accessible to even non-developers. It’s a true challenge trying to set up all the background knowledge needed to understand a problem, present the problem, and have the audience understand the solution within the span of 2 or 3 slides. Especially when the solution was something that took me days or weeks to work out.&lt;/p&gt;

&lt;p&gt;The tracking algorithm presented in my talk is version 3. But due to spending the last two weeks writing and practicing the talk, I didn’t have time to actually integrate this version into the Eki Live app! The production version is still using tracking algorithm version 2 and in addition has some iOS 26 bugs (iOS 26 was released this week).&lt;/p&gt;

&lt;p&gt;I still have a lot of work to do before I can (temporarily) put a bow on this project: integrating tracking algorithm version 3 into the app, improving the manual start/stop tracking UI, and recording and uploading my talk in English.&lt;/p&gt;

&lt;p&gt;I wrote two development logs (&lt;a href=&quot;/2025/04/15/train-tracker-checkpoint-devlog/&quot;&gt;Devlog 1&lt;/a&gt; &lt;a href=&quot;/2025/05/29/train-tracker-devlog-02/&quot;&gt;Devlog 2&lt;/a&gt;) about Eki Live that go into further detail. Check them out and free free to reach out.&lt;/p&gt;

</description>
        <pubDate>Sun, 21 Sep 2025 05:14:02 -0500</pubDate>
        <link>https://twocentstudios.com/2025/09/21/i-presented-at-iosdc-2025/</link>
        <guid isPermaLink="true">https://twocentstudios.com/2025/09/21/i-presented-at-iosdc-2025/</guid>
        
        <category>ios</category>
        
        <category>ekilive</category>
        
        <category>presentation</category>
        
        
      </item>
    
      <item>
        <title>3 Swift Concurrency Challenges from the Last 2 Weeks</title>
        <description>&lt;p&gt;I started my Apple platforms development journey a year before &lt;a href=&quot;https://developer.apple.com/documentation/dispatch&quot;&gt;Grand Central Dispatch&lt;/a&gt; was released with iOS 4. I’ve lived through codebase migrations to &lt;a href=&quot;https://nshipster.com/nsoperation/&quot;&gt;NSOperation&lt;/a&gt;. Then through the slew of FRP frameworks (of which I consider a concurrency solution): &lt;a href=&quot;https://github.com/ReactiveCocoa/ReactiveCocoa&quot;&gt;ReactiveCocoa&lt;/a&gt;, &lt;a href=&quot;https://github.com/ReactiveCocoa/ReactiveSwift&quot;&gt;ReactiveSwift&lt;/a&gt;, &lt;a href=&quot;https://github.com/ReactiveX/RxSwift&quot;&gt;RxSwift&lt;/a&gt;, and finally &lt;a href=&quot;https://developer.apple.com/documentation/combine&quot;&gt;Combine&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;My strategy for learning all these paradigms was best described as osmosis while encountering and solving real problems in codebases. Of course, you have to spend time setting breakpoints and patiently stepping through with a debugger to see where all the thread hops are happening. Eventually, I reached the point where I could read code and predict which threads each section would run on. I had 80% of the operators memorized and knew exactly where in the docs to look for the remaining 20%.&lt;/p&gt;

&lt;p&gt;Years in, that kind of confidence has eluded me so far with Swift Concurrency. I cannot yet read a snippet of code and predict what the call stack will look like. I haven’t memorized enough of the syntax to formulate solutions in my head and write it fluently. I don’t have a go-to location in the docs to find the primitive at the tip of my tongue.&lt;/p&gt;

&lt;p&gt;I ask myself, why is my Swift Concurrency upskilling story so different?&lt;/p&gt;

&lt;p&gt;Is the difference that GCD, NSOperation, and the reactive frameworks basically came out of the gate fully baked? Their paradigms may have merits and demerits, but after their first releases, anything new was additive or syntactic sugar. They were born as ugly as they’d always be.&lt;/p&gt;

&lt;p&gt;Is it that my confidence was actually unearned and I never &lt;em&gt;really&lt;/em&gt; understood what was happening in my code? The kind of bugs that Swift Concurrency aims to solve are often so rare that you can go a whole career without being able to recognize the symptoms.&lt;/p&gt;

&lt;p&gt;Is it that Swift Concurrency promises a higher level of abstraction (isolation domains instead of threads), but is so leaky that the programmer now has to understand both the abstraction and the paradigm it’s abstracting?&lt;/p&gt;

&lt;p&gt;With that preface, I want to look at a few examples of Swift Concurrency challenges I’ve encountered recently. Let’s see if these shed any light on why I’m finding it so hard to develop intuition.&lt;/p&gt;

&lt;p&gt;Unlike most of my posts (I hope), these examples are unsolved problems with likely broken code.&lt;/p&gt;

&lt;h2 id=&quot;1-unnotificationcenter&quot;&gt;1. UNNotificationCenter&lt;/h2&gt;

&lt;p&gt;I implemented push notifications in &lt;a href=&quot;/2025/07/25/reintroducing-technicolor-binge-watch-with-friends-over-space-and-time/&quot;&gt;Technicolor&lt;/a&gt;. I already had an overall architecture I was happy with, but elegantly massaging push notification support into it took some effort. I finally ended up with an implementation I thought I understood that still properly handled the multiple delegate callback points across &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;UIApplicationDelegate&lt;/code&gt; via &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;@UIApplicationDelegateAdaptor&lt;/code&gt; and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;UNNotificationCenter&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Besides the push token registration and user permissions, the key functionality of my push notifications wrapper client is to open the screen of the app that corresponds with the type of push notification the user tapped.&lt;/p&gt;

&lt;p&gt;As far as I can tell, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;UNNotificationCenter&lt;/code&gt; has no documented concurrency story. Each developer that sets out to use the framework has to derive what thread each delegate method is called on through trial and error on a real device in production by tapping production push notifications. We haven’t even started talking about Swift Concurrency yet.&lt;/p&gt;

&lt;p&gt;To bring &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;User Notifications&lt;/code&gt; framework into the concurrency world, each developer needs to start from zero, with zero guarantees and zero support from Apple.&lt;/p&gt;

&lt;p&gt;So I started with this (simplified) implementation:&lt;/p&gt;

&lt;div class=&quot;language-swift highlighter-rouge&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;c1&quot;&gt;// Version 1&lt;/span&gt;
&lt;span class=&quot;kd&quot;&gt;final&lt;/span&gt; &lt;span class=&quot;kd&quot;&gt;class&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;PushNotificationDelegateProxy&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;NSObject&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;UNUserNotificationCenterDelegate&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;kd&quot;&gt;@unchecked&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Sendable&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;kd&quot;&gt;private&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;var&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;notificationTapHandler&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;((&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;PushNotificationPayload&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;-&amp;gt;&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Void&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)?&lt;/span&gt;

    &lt;span class=&quot;kd&quot;&gt;func&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;setNotificationTapHandler&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;_&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;handler&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kd&quot;&gt;@escaping&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;PushNotificationPayload&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;-&amp;gt;&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Void&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;notificationTapHandler&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;handler&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;

    &lt;span class=&quot;kd&quot;&gt;func&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;userNotificationCenter&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;_&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;center&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;UNUserNotificationCenter&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;didReceive&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;response&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;UNNotificationResponse&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;as&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;ync&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;userInfo&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;response&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;notification&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;request&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;content&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;userInfo&lt;/span&gt;

        &lt;span class=&quot;k&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;payload&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;c1&quot;&gt;// Decode payload&lt;/span&gt;
        &lt;span class=&quot;nf&quot;&gt;notificationTapHandler&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;?(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;payload&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;

&lt;span class=&quot;kd&quot;&gt;@MainActor&lt;/span&gt; &lt;span class=&quot;kd&quot;&gt;@Observable&lt;/span&gt; &lt;span class=&quot;kd&quot;&gt;final&lt;/span&gt; &lt;span class=&quot;kd&quot;&gt;class&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;PushNotificationSettingsStore&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Identifiable&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;kd&quot;&gt;@ObservationIgnored&lt;/span&gt; &lt;span class=&quot;kd&quot;&gt;@Dependency(\.pushNotificationClient)&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;var&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;pushNotificationClient&lt;/span&gt;

    &lt;span class=&quot;nf&quot;&gt;init&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;pushNotificationClient&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;setDelegate&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;

        &lt;span class=&quot;nf&quot;&gt;conditionallyRegisterForRemoteNotifications&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;

        &lt;span class=&quot;c1&quot;&gt;// Set the notification tap handler&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;pushNotificationClient&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;setNotificationTapHandler&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;weak&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;self&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;payload&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;in&lt;/span&gt;
            &lt;span class=&quot;kt&quot;&gt;Task&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;kd&quot;&gt;@MainActor&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;in&lt;/span&gt;
                &lt;span class=&quot;k&quot;&gt;self&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;?&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;actions&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;notificationReceived&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;payload&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
            &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
        &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;p&gt;In my initial understanding, no matter what thread &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;userNotificationCenter(didReceive:)&lt;/code&gt; called the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;notificationTapHandler&lt;/code&gt; closure, inside it the closure &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;notificationReceived(payload)&lt;/code&gt; would be called on the main thread and could do any UI operations it needed to.&lt;/p&gt;

&lt;p&gt;From what I remember, I confirmed this as working during debug on device. When I was running in production on TestFlight it crashed when I opened a push notification.&lt;/p&gt;

&lt;p&gt;Still unsure as to why (was it that &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;@MainActor&lt;/code&gt;-isolated &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;self&lt;/code&gt; was captured inside a non-isolated closure), I added some code that, although inelegant, would surely fix the problem:&lt;/p&gt;

&lt;div class=&quot;language-swift highlighter-rouge&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;c1&quot;&gt;// Version 2&lt;/span&gt;
&lt;span class=&quot;kd&quot;&gt;final&lt;/span&gt; &lt;span class=&quot;kd&quot;&gt;class&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;PushNotificationDelegateProxy&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;NSObject&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;UNUserNotificationCenterDelegate&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;kd&quot;&gt;@unchecked&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Sendable&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;kd&quot;&gt;private&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;var&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;notificationTapHandler&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;((&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;PushNotificationPayload&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;-&amp;gt;&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Void&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)?&lt;/span&gt;

    &lt;span class=&quot;kd&quot;&gt;func&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;setNotificationTapHandler&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;_&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;handler&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kd&quot;&gt;@escaping&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;PushNotificationPayload&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;-&amp;gt;&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Void&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;notificationTapHandler&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;handler&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;

    &lt;span class=&quot;kd&quot;&gt;func&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;userNotificationCenter&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;_&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;center&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;UNUserNotificationCenter&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;didReceive&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;response&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;UNNotificationResponse&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;as&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;ync&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;userInfo&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;response&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;notification&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;request&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;content&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;userInfo&lt;/span&gt;

        &lt;span class=&quot;k&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;payload&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;c1&quot;&gt;// Decode payload&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;MainActor&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;run&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
            &lt;span class=&quot;nf&quot;&gt;notificationTapHandler&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;?(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;payload&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
        &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;p&gt;Just sprinkle in &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;MainActor.run&lt;/code&gt; everywhere, right? Just like back in the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;DispatchQueue.main&lt;/code&gt; days.&lt;/p&gt;

&lt;p&gt;This also crashed (every time) in production, with the following stack trace:&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;Triggered by Thread:  5

Last Exception Backtrace:
0   CoreFoundation                	0x184a5721c __exceptionPreprocess + 164 (NSException.m:249)
1   libobjc.A.dylib               	0x181ef1abc objc_exception_throw + 88 (objc-exception.mm:356)
2   Foundation                    	0x183d55670 -[NSAssertionHandler handleFailureInMethod:object:file:lineNumber:description:] + 288 (NSException.m:252)
3   UIKitCore                     	0x1883ad4e8 -[UIApplication _performBlockAfterCATransactionCommitSynchronizes:] + 276 (UIApplication.m:3408)
4   UIKitCore                     	0x1883bdecc -[UIApplication _updateStateRestorationArchiveForBackgroundEvent:saveState:exitIfCouldNotRestoreState:updateSnapshot:windowScene:] + 528 (UIApplication.m:12129)
5   UIKitCore                     	0x1883be278 -[UIApplication _updateSnapshotAndStateRestorationWithAction:windowScene:] + 144 (UIApplication.m:12174)
6   technicolortv                 	0x1027d6670 @objc closure #1 in PushNotificationDelegateProxy.userNotificationCenter(_:didReceive:) + 80 (/&amp;lt;compiler-generated&amp;gt;:0)
// ...
12  libswift_Concurrency.dylib    	0x190521241 completeTaskWithClosure(swift::AsyncContext*, swift::SwiftError*) + 1 (Task.cpp:537)
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;p&gt;State restoration somehow got triggered off the main thread? How? I guess I could understand if the problem were that &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;notificationTapHandler&lt;/code&gt; is unsafe to be passed between isolation domains. But that’s not what the crash is saying is it?&lt;/p&gt;

&lt;p&gt;Let’s push the isolation annotations even further:&lt;/p&gt;

&lt;div class=&quot;language-swift highlighter-rouge&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;kd&quot;&gt;extension&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;UNUserNotificationCenter&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kd&quot;&gt;@retroactive&lt;/span&gt; &lt;span class=&quot;kd&quot;&gt;@unchecked&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Sendable&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{}&lt;/span&gt;
&lt;span class=&quot;kd&quot;&gt;extension&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;UNNotificationResponse&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kd&quot;&gt;@retroactive&lt;/span&gt; &lt;span class=&quot;kd&quot;&gt;@unchecked&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Sendable&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{}&lt;/span&gt;

&lt;span class=&quot;kd&quot;&gt;final&lt;/span&gt; &lt;span class=&quot;kd&quot;&gt;class&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;PushNotificationDelegateProxy&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;NSObject&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;UNUserNotificationCenterDelegate&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;kd&quot;&gt;@unchecked&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Sendable&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;kd&quot;&gt;private&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;var&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;notificationTapHandler&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;kd&quot;&gt;@MainActor&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;PushNotificationPayload&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;-&amp;gt;&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Void&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)?&lt;/span&gt;

    &lt;span class=&quot;kd&quot;&gt;func&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;setNotificationTapHandler&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;_&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;handler&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kd&quot;&gt;@escaping&lt;/span&gt; &lt;span class=&quot;kd&quot;&gt;@MainActor&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;PushNotificationPayload&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;-&amp;gt;&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Void&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;notificationTapHandler&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;handler&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;

    &lt;span class=&quot;kd&quot;&gt;@MainActor&lt;/span&gt; &lt;span class=&quot;kd&quot;&gt;func&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;userNotificationCenter&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;_&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;center&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;UNUserNotificationCenter&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;didReceive&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;response&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;UNNotificationResponse&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;as&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;ync&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;userInfo&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;response&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;notification&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;request&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;content&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;userInfo&lt;/span&gt;

        &lt;span class=&quot;k&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;payload&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;c1&quot;&gt;// Decode payload&lt;/span&gt;
        &lt;span class=&quot;nf&quot;&gt;notificationTapHandler&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;?(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;payload&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;p&gt;Let’s try to force &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;userNotificationCenter(didReceive:)&lt;/code&gt; to be called on the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;@MainActor&lt;/code&gt; by lying to the compiler in a bunch of places about sendability.&lt;/p&gt;

&lt;p&gt;As far as I can tell, this no longer crashes although I haven’t had time to thoroughly test it (since it requires multiple deployments to Test Flight and review cycles to extensively QA).&lt;/p&gt;

&lt;p&gt;From this whole weeks-long process, I’ve learned essentially nothing about the proper usage of Swift Concurrency, my mental model is less well-formed than it used to be, and I still have a lingering problem that I cannot practically devote enough time to comprehensively solve right now.&lt;/p&gt;

&lt;p&gt;Presumably no one at Apple is working on the User Notifications framework anymore. Nothing is being added to it. No one is giving it concurrency support. No one &lt;em&gt;has&lt;/em&gt; (over the past decade) or &lt;em&gt;will&lt;/em&gt; document its concurrency story. The best we have is a &lt;a href=&quot;https://stackoverflow.com/questions/73750724/how-can-usernotificationcenter-didreceive-cause-a-crash-even-with-nothing-in&quot;&gt;Stack Overflow post&lt;/a&gt; with no unanimous best practice and no solution.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Update 2025/08/18&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href=&quot;https://mastodon.social/@auramagi/&quot;&gt;Mike Apurin&lt;/a&gt; solved the underlying issue (the fix is the same). From the stack trace, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;completeTaskWithClosure(swift::AsyncContext*, swift::SwiftError*)&lt;/code&gt; is the closure automatically created and called by Swift at the end of the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;async&lt;/code&gt; version of the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;userNotificationCenter(_:didReceive:)&lt;/code&gt; function. Although it’s not documented, the crash leads me to believe this closure must be called on the main thread.&lt;/p&gt;

&lt;p&gt;We have no direct control over what actor the closure runs on; it’s presumably whatever actor the function is isolated to. This means that we can only:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Add &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;@MainActor&lt;/code&gt; to the Delegate class.&lt;/li&gt;
  &lt;li&gt;Add &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;@MainActor&lt;/code&gt; to the function. (the fix we used in the post)&lt;/li&gt;
  &lt;li&gt;Use the non-async version of the function, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;userNotificationCenter(_:didReceive:withCompletionHandler:)&lt;/code&gt; and wrap the completion handler in a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;MainActor.run&lt;/code&gt; closure.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Update 2025/11/18&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Tiziano Coroneo alerted me to a new Swift 6.2 feature that helps address this problem called &lt;a href=&quot;https://github.com/swiftlang/swift-evolution/blob/main/proposals/0470-isolated-conformances.md&quot;&gt;isolated conformances&lt;/a&gt; (further explained by &lt;a href=&quot;https://www.hackingwithswift.com/articles/277/whats-new-in-swift-6-2#:~:text=Global%2Dactor%20isolated%20conformances&quot;&gt;Hacking with Swift&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Tiziano says his declaration now looks like this:&lt;/p&gt;

&lt;div class=&quot;language-swift highlighter-rouge&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;kd&quot;&gt;public&lt;/span&gt; &lt;span class=&quot;kd&quot;&gt;final&lt;/span&gt; &lt;span class=&quot;kd&quot;&gt;class&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;UserNotificationManager&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;NSObject&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;kd&quot;&gt;@MainActor&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;UNUserNotificationCenterDelegate&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;p&gt;My fix is similar but a little more involved since it involves wrapping my class for use with the Swift Dependencies library. I’ll try to document it fully in a future post.&lt;/p&gt;

&lt;h2 id=&quot;2-cmmotionactivitymanager&quot;&gt;2. CMMotionActivityManager&lt;/h2&gt;

&lt;p&gt;&lt;a href=&quot;https://developer.apple.com/documentation/coremotion&quot;&gt;Core Motion&lt;/a&gt; is another neglected framework that not many iOS devs have the pleasure of integrating. It seems to have mostly been “modernized” around iOS 7 when &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;NSOperation&lt;/code&gt; had a small popularity bump. Also when the API best practices for getting permission from the device user were still being tweaked.&lt;/p&gt;

&lt;p&gt;For a current project, I’m using &lt;a href=&quot;https://developer.apple.com/documentation/coremotion/cmmotionactivitymanager&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CMMotionActivityManager&lt;/code&gt;&lt;/a&gt;. It’s a slightly higher-level data source for predicting whether the device is held by a user walking, cycling, driving, standing still, etc. It has two primary APIs:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Request historical data for a time range (up to 1 week ago): &lt;a href=&quot;https://developer.apple.com/documentation/coremotion/cmmotionactivitymanager/queryactivitystarting(from:to:to:withhandler:)&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;queryActivityStarting(from:to:to:)&lt;/code&gt;&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;Get live streaming data while the app is in the foreground: &lt;a href=&quot;https://developer.apple.com/documentation/coremotion/cmmotionactivitymanager/startactivityupdates(to:withhandler:)&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;startActivityUpdates(to:)&lt;/code&gt;&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;queryActivityStarting&lt;/code&gt; returns once to the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;OperationQueue&lt;/code&gt; specified in the parameters. &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;startActivityUpdates&lt;/code&gt; keeps returning values until &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;stopActivityUpdates()&lt;/code&gt; is called.&lt;/p&gt;

&lt;p&gt;I thought each of these would be relatively straightforward to wrap into a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;withCheckedContinuation&lt;/code&gt; and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;AsyncStream&lt;/code&gt;, respectively.&lt;/p&gt;

&lt;div class=&quot;language-swift highlighter-rouge&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;c1&quot;&gt;// Version 1: queryActivityStarting&lt;/span&gt;
&lt;span class=&quot;kt&quot;&gt;Task&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;kd&quot;&gt;@MainActor&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;weak&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;self&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;in&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;guard&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;self&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;else&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;

    &lt;span class=&quot;c1&quot;&gt;// Check motion history for timeout&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;timeout&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;withCheckedContinuation&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;continuation&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;in&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;activityManager&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;queryActivityStarting&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
            &lt;span class=&quot;nv&quot;&gt;from&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;startDate&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
            &lt;span class=&quot;nv&quot;&gt;to&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;endDate&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
            &lt;span class=&quot;nv&quot;&gt;to&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;OperationQueue&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;main&lt;/span&gt;
        &lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;activities&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;error&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;in&lt;/span&gt;
            &lt;span class=&quot;k&quot;&gt;guard&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;activities&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;error&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;==&lt;/span&gt; &lt;span class=&quot;kc&quot;&gt;nil&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;else&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
                &lt;span class=&quot;n&quot;&gt;continuation&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;resume&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;returning&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kc&quot;&gt;nil&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
                &lt;span class=&quot;k&quot;&gt;return&lt;/span&gt;
            &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
            
            &lt;span class=&quot;k&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;value&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;c1&quot;&gt;// do work here to calculate timeout...&lt;/span&gt;
            &lt;span class=&quot;n&quot;&gt;continuation&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;resume&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;returning&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;value&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
        &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
    
    &lt;span class=&quot;c1&quot;&gt;// ...&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;p&gt;My strategy recently has been the same as the Swift team’s: isolate everything on the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;@MainActor&lt;/code&gt; unless there’s a reason not to. So that’s what I did in this case. However, is it correct to await with a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;MainActor&lt;/code&gt;-isolated &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Task&lt;/code&gt; while also having the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;queryActivityStarting&lt;/code&gt; function return on &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;OperationQueue.main&lt;/code&gt;?&lt;/p&gt;

&lt;p&gt;It seemed like it was working fine in debug, but then when I was field testing I was observing what seemed to be deadlocks and the continuation never completing.&lt;/p&gt;

&lt;p&gt;So I’m already having trouble reliably reproducing the bug. I’m not even sure whether the bug is related to concurrency or whether &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;queryActivityStarting&lt;/code&gt; just doesn’t respond the way I’d expect. After all, there’s a note in the docs that could be interpreted a number of ways:&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;This method runs asynchronously, returning immediately and delivering the results to the specified handler block. &lt;strong&gt;A delay of up to several minutes in reported activities is expected.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;In the end, I created this extension that forces &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;@MainActor&lt;/code&gt; at the function level and includes a timeout just in case. It seems to work so far.&lt;/p&gt;

&lt;div class=&quot;language-swift highlighter-rouge&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;kd&quot;&gt;extension&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;CMMotionActivityManager&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;kd&quot;&gt;@MainActor&lt;/span&gt; &lt;span class=&quot;kd&quot;&gt;func&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;activities&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;start&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Date&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;to&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;end&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Date&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;timeout&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;TimeInterval&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;as&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;ync&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;throws&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;-&amp;gt;&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;CMMotionActivity&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]?&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;try&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;withThrowingTaskGroup&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;of&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;CMMotionActivity&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]?&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;self&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;group&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;in&lt;/span&gt;
            &lt;span class=&quot;n&quot;&gt;group&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;addTask&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
                &lt;span class=&quot;k&quot;&gt;try&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;withCheckedThrowingContinuation&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;continuation&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;in&lt;/span&gt;
                    &lt;span class=&quot;k&quot;&gt;self&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;queryActivityStarting&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;from&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;start&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;to&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;end&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;to&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;main&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;activities&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;error&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;in&lt;/span&gt;
                        &lt;span class=&quot;k&quot;&gt;switch&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;activities&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;error&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
                        &lt;span class=&quot;k&quot;&gt;case&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;_&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;err&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;?):&lt;/span&gt;
                            &lt;span class=&quot;n&quot;&gt;continuation&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;resume&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;throwing&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;err&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
                        &lt;span class=&quot;k&quot;&gt;case&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;list&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;?,&lt;/span&gt; &lt;span class=&quot;kc&quot;&gt;nil&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;):&lt;/span&gt;
                            &lt;span class=&quot;n&quot;&gt;continuation&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;resume&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;returning&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;list&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
                        &lt;span class=&quot;k&quot;&gt;default&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
                            &lt;span class=&quot;n&quot;&gt;continuation&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;resume&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;returning&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;[])&lt;/span&gt;
                        &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
                    &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
                &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
            &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;

            &lt;span class=&quot;n&quot;&gt;group&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;addTask&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
                &lt;span class=&quot;k&quot;&gt;try&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;?&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Task&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;sleep&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;for&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;seconds&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;timeout&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;))&lt;/span&gt;
                &lt;span class=&quot;k&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;kc&quot;&gt;nil&lt;/span&gt;
            &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;

            &lt;span class=&quot;k&quot;&gt;guard&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;result&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;try&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;group&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;next&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;else&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
                &lt;span class=&quot;k&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;kc&quot;&gt;nil&lt;/span&gt;
            &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;

            &lt;span class=&quot;n&quot;&gt;group&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;cancelAll&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;
            &lt;span class=&quot;k&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;result&lt;/span&gt;
        &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;

    &lt;span class=&quot;kd&quot;&gt;@MainActor&lt;/span&gt; &lt;span class=&quot;kd&quot;&gt;func&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;activityUpdates&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;-&amp;gt;&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;AsyncStream&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;&amp;lt;&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;CMMotionActivity&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;&amp;gt;&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
        &lt;span class=&quot;kt&quot;&gt;AsyncStream&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;continuation&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;in&lt;/span&gt;
            &lt;span class=&quot;nf&quot;&gt;startActivityUpdates&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;to&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;main&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;activity&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;in&lt;/span&gt;
                &lt;span class=&quot;k&quot;&gt;guard&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;activity&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;else&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
                    &lt;span class=&quot;n&quot;&gt;continuation&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;finish&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;
                    &lt;span class=&quot;k&quot;&gt;return&lt;/span&gt;
                &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
                &lt;span class=&quot;n&quot;&gt;continuation&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;yield&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;activity&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
            &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;

            &lt;span class=&quot;n&quot;&gt;continuation&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;onTermination&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;kd&quot;&gt;@Sendable&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;_&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;in&lt;/span&gt;
                &lt;span class=&quot;k&quot;&gt;self&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;stopActivityUpdates&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;
            &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
        &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;p&gt;Again, I feel as if I’ve learned nothing. I have potentially worse intuition than I started with, and I may have to return to this problem again after I’ve released to production.&lt;/p&gt;

&lt;h2 id=&quot;3-actor-reentrancy&quot;&gt;3. Actor Reentrancy&lt;/h2&gt;

&lt;p&gt;I’ve been working on a meatier problem within &lt;a href=&quot;/2025/06/03/eki-live-announcement/&quot;&gt;Eki Live&lt;/a&gt; for a couple months.&lt;/p&gt;

&lt;p&gt;I have the following pipeline:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Core Location produces &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CLLocation&lt;/code&gt; values up to 1 per second.&lt;/li&gt;
  &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CLLocation&lt;/code&gt;s are passed to an &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;actor RailwayTracker&lt;/code&gt; which processes them using various info from a database and incorporates that data into the actor long-term state.&lt;/li&gt;
  &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;RailwayTracker&lt;/code&gt; returns a value that is displayed in the UI via SwiftUI and can also update a LiveActivity.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Essentially:&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;CLLocationManagerDelegate -&amp;gt; RailwayTracker -&amp;gt; ContentView
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;p&gt;The actual architecture is a bit more complex due to the relationship between instances of classes doing each part of the work. But the overall pipeline design has always felt precarious due to the underlying assumptions that:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Core Location will never produce &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CLLocation&lt;/code&gt; values faster than RailwayTracker can process them.&lt;/li&gt;
  &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;RailwayTracker&lt;/code&gt; will always process inputs in order, serially.&lt;/li&gt;
  &lt;li&gt;SwiftUI will receive outputs from &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;RailwayTracker&lt;/code&gt; no faster than it can display them.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In practice, I was probably breaking at least the first constraint during testing, since I could simulate &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CLLocation&lt;/code&gt;s being produced at 20x real-time speed in a separate macOS app I was using to iterate on the algorithm within &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;RailwayTracker&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Here is a very simplified version of the shape of &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;RailwayTracker&lt;/code&gt;:&lt;/p&gt;

&lt;div class=&quot;language-swift highlighter-rouge&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;kd&quot;&gt;struct&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;RailwayTrackerResult&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;location&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Location&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;value&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Int&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;

&lt;span class=&quot;n&quot;&gt;actor&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;RailwayTracker&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;kd&quot;&gt;private&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;railwayDatabase&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;any&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;DatabaseReader&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)?&lt;/span&gt;
    
    &lt;span class=&quot;kd&quot;&gt;private&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;var&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;runningValues&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;Location&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Int&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;[:]&lt;/span&gt;

    &lt;span class=&quot;nf&quot;&gt;init&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;railwayDatabase&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;any&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;DatabaseReader&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)?)&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;self&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;railwayDatabase&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;railwayDatabase&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;

    &lt;span class=&quot;kd&quot;&gt;func&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;process&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;_&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;input&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Location&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;as&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;ync&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;-&amp;gt;&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;RailwayTrackerResult&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
        &lt;span class=&quot;c1&quot;&gt;// lots of database reads&lt;/span&gt;
        &lt;span class=&quot;c1&quot;&gt;// lots of reads from `runningValues`&lt;/span&gt;
        &lt;span class=&quot;c1&quot;&gt;// lots of updates to `runningValues`&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;RailwayTrackerResult&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;location&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;input&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;value&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;someCalculatedValue&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;

    &lt;span class=&quot;kd&quot;&gt;func&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;reset&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;runningValues&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;[:]&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;p&gt;When originally designing &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;RailwayTracker&lt;/code&gt;, an &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;actor&lt;/code&gt; seemed like the obvious choice. I wanted a separate isolation for its internal state and I knew it’d be doing enough heavy work that it wasn’t feasible to do on the main actor.&lt;/p&gt;

&lt;p&gt;However, I misinterpreted the behavior of &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;actor&lt;/code&gt;, thinking that an actor &lt;em&gt;also&lt;/em&gt; ensured that an instance’s functions would need to complete before they could be called again. In practice, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;actor&lt;/code&gt;s don’t do anything to prevent &lt;a href=&quot;https://mjtsai.com/blog/2024/07/29/actor-reentrancy-in-swift/&quot;&gt;reentrancy&lt;/a&gt;. Meaning that the input locations could be being processed out-of-order by the actor with the database operations being interleaved and there being all kinds of chaos and unspecified behavior.&lt;/p&gt;

&lt;p&gt;While doing some new work on the project, I wanted to take another stab at hardening the entire pipeline using Swift Concurrency.&lt;/p&gt;

&lt;p&gt;I researched the current state of the Apple officially-sanctioned &lt;a href=&quot;https://github.com/apple/swift-async-algorithms&quot;&gt;swift-async-algorithms&lt;/a&gt; package. It seemed to be in committee-hell with no real forward progress in the last 2+ years. There’s less than half of the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Combine&lt;/code&gt; operator API implemented. There’s something called an &lt;a href=&quot;https://github.com/apple/swift-async-algorithms/blob/main/Sources/AsyncAlgorithms/AsyncAlgorithms.docc/Guides/Channel.md&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;AsyncChannel&lt;/code&gt;&lt;/a&gt;. Would that be a good primitive to base my pipeline on?&lt;/p&gt;

&lt;p&gt;I decided on my specification:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;CLLocations should never be dropped.&lt;/li&gt;
  &lt;li&gt;CLLocations should always be fully processed one-by-one in the order they arrive.&lt;/li&gt;
  &lt;li&gt;Pipeline output results should be delivered to one “subscriber” (mixing metaphors here), but I may need multiple in the near future.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I ended up with a generic wrapper abstraction called &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;SerialProcessor&lt;/code&gt;:&lt;/p&gt;

&lt;div class=&quot;language-swift highlighter-rouge&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;kd&quot;&gt;final&lt;/span&gt; &lt;span class=&quot;kd&quot;&gt;class&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;SerialProcessor&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;&amp;lt;&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;Input&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Sendable&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Output&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Sendable&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;&amp;gt;&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;kd&quot;&gt;typealias&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Process&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;kd&quot;&gt;@Sendable&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;Input&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;as&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;ync&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;-&amp;gt;&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Output&lt;/span&gt;

    &lt;span class=&quot;c1&quot;&gt;// Inbound (sync) and outbound (single-consumer) pipes&lt;/span&gt;
    &lt;span class=&quot;kd&quot;&gt;private&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;inPair&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;stream&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;AsyncStream&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;&amp;lt;&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;Input&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;&amp;gt;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;continuation&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;AsyncStream&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;&amp;lt;&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;Input&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;&amp;gt;.&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;Continuation&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;kd&quot;&gt;private&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;outPair&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;stream&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;AsyncStream&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;&amp;lt;&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;Output&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;&amp;gt;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;continuation&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;AsyncStream&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;&amp;lt;&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;Output&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;&amp;gt;.&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;Continuation&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;

    &lt;span class=&quot;kd&quot;&gt;private&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;worker&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Task&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;&amp;lt;&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;Void&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Never&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;&amp;gt;&lt;/span&gt;
    &lt;span class=&quot;kd&quot;&gt;private&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;process&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Process&lt;/span&gt;

    &lt;span class=&quot;c1&quot;&gt;/// Single-consumer stream of `Output`.&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;var&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;results&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;AsyncStream&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;&amp;lt;&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;Output&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;&amp;gt;&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;outPair&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;stream&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;

    &lt;span class=&quot;nf&quot;&gt;init&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
        &lt;span class=&quot;nv&quot;&gt;inputBuffering&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;AsyncStream&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;&amp;lt;&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;Input&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;&amp;gt;.&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;Continuation&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;BufferingPolicy&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;unbounded&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
        &lt;span class=&quot;nv&quot;&gt;outputBuffering&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;AsyncStream&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;&amp;lt;&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;Output&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;&amp;gt;.&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;Continuation&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;BufferingPolicy&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;unbounded&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
        &lt;span class=&quot;nv&quot;&gt;process&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kd&quot;&gt;@escaping&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Process&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;self&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;process&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;process&lt;/span&gt;

        &lt;span class=&quot;k&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;inPair&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;AsyncStream&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;makeStream&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;of&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Input&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;self&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;bufferingPolicy&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;inputBuffering&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;outPair&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;AsyncStream&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;makeStream&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;of&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Output&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;self&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;bufferingPolicy&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;outputBuffering&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;self&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;inPair&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;inPair&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;self&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;outPair&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;outPair&lt;/span&gt;

        &lt;span class=&quot;n&quot;&gt;worker&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Task&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
            &lt;span class=&quot;k&quot;&gt;for&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;input&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;in&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;inPair&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;stream&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
                &lt;span class=&quot;k&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;output&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;process&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;input&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
                &lt;span class=&quot;n&quot;&gt;outPair&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;continuation&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;yield&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;output&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
            &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
            &lt;span class=&quot;n&quot;&gt;outPair&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;continuation&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;finish&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;
        &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;

    &lt;span class=&quot;kd&quot;&gt;deinit&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
        &lt;span class=&quot;nf&quot;&gt;finish&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;

    &lt;span class=&quot;kd&quot;&gt;func&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;submit&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;_&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;input&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Input&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;inPair&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;continuation&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;yield&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;input&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;

    &lt;span class=&quot;kd&quot;&gt;func&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;finish&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;inPair&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;continuation&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;finish&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;worker&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;cancel&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;p&gt;I couldn’t use &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;AsyncChannel&lt;/code&gt; for the input side because its &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;send&lt;/code&gt; function is &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;async&lt;/code&gt; and therefore requires an async context. I don’t have that inside the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CLLocationManagerDelegate&lt;/code&gt; callback function that delivers &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CLLocation&lt;/code&gt;s and creating a new &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Task&lt;/code&gt; for each new &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CLLocation&lt;/code&gt; would break the serial ordering guarantee.&lt;/p&gt;

&lt;p&gt;In the non-realtime system implementation, usage looks like this:&lt;/p&gt;

&lt;div class=&quot;language-swift highlighter-rouge&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;c1&quot;&gt;// Set up the processing&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;railwayTracker&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;RailwayTracker&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;railwayDatabase&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;database&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;serialProcessor&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;SerialProcessor&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
    &lt;span class=&quot;nv&quot;&gt;inputBuffering&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;unbounded&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;nv&quot;&gt;outputBuffering&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;unbounded&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;nv&quot;&gt;process&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;kd&quot;&gt;@Sendable&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;input&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;in&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;railwayTracker&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;process&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;input&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;

&lt;span class=&quot;c1&quot;&gt;// Queue all locations immediately for processing&lt;/span&gt;
&lt;span class=&quot;c1&quot;&gt;// This is in a `Task` so it will run after `serialProcessor.results` is set up&lt;/span&gt;
&lt;span class=&quot;kt&quot;&gt;Task&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;for&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;location&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;in&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;locations&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;serialProcessor&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;submit&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;location&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;

&lt;span class=&quot;c1&quot;&gt;// Cache the results as they arrive&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;for&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;result&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;in&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;serialProcessor&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;results&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;resultsCache&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;result&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;location&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;id&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;result&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;p&gt;In the realtime system version, I believe the implementation will look something like this (although I’m not at this point in the project yet):&lt;/p&gt;

&lt;div class=&quot;language-swift highlighter-rouge&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;c1&quot;&gt;// Set up the processing global&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;railwayTracker&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;RailwayTracker&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;railwayDatabase&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;database&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;serialProcessor&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;SerialProcessor&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
    &lt;span class=&quot;nv&quot;&gt;inputBuffering&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;unbounded&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;nv&quot;&gt;outputBuffering&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;bufferingNewest&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;1&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;),&lt;/span&gt; &lt;span class=&quot;c1&quot;&gt;// for UI, drop late values&lt;/span&gt;
    &lt;span class=&quot;nv&quot;&gt;process&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;kd&quot;&gt;@Sendable&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;input&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;in&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;railwayTracker&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;process&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;input&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;

&lt;span class=&quot;c1&quot;&gt;// Where we receive new locations from the system&lt;/span&gt;
&lt;span class=&quot;kd&quot;&gt;extension&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;LocationManagerDelegate&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kd&quot;&gt;@preconcurrency&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;CLLocationManagerDelegate&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;kd&quot;&gt;func&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;locationManager&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;_&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;manager&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;CLLocationManager&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;didUpdateLocations&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;locations&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;CLLocation&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;])&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    	&lt;span class=&quot;c1&quot;&gt;// Get the shared `serialProcessor` from somewhere global then...&lt;/span&gt;
    	&lt;span class=&quot;k&quot;&gt;for&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;location&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;in&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;locations&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
            &lt;span class=&quot;n&quot;&gt;serialProcessor&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;submit&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;location&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
        &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;

&lt;span class=&quot;c1&quot;&gt;// In some View Model...&lt;/span&gt;
&lt;span class=&quot;kd&quot;&gt;@MainActor&lt;/span&gt; &lt;span class=&quot;kd&quot;&gt;@Observable&lt;/span&gt; &lt;span class=&quot;kd&quot;&gt;final&lt;/span&gt; &lt;span class=&quot;kd&quot;&gt;class&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;ContentViewModel&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;kd&quot;&gt;@ObservationIgnored&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;serialProcessor&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;SerialProcessor&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;&amp;lt;&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;CLLocation&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;RailwayTrackerResult&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;&amp;gt;&lt;/span&gt;
    &lt;span class=&quot;kd&quot;&gt;private(set)&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;var&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;latestResult&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;RailwayTrackerResult&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;?&lt;/span&gt;
    
    &lt;span class=&quot;kd&quot;&gt;func&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;task&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;as&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;ync&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;for&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;result&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;in&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;serialProcessor&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;results&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
            &lt;span class=&quot;n&quot;&gt;latestResult&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;result&lt;/span&gt;
        &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
    
    &lt;span class=&quot;c1&quot;&gt;// ...&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;p&gt;In my testing of the non-realtime setup, the system seemed to work correctly. It buffers inputs as expected, and even drops outputs if I use &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.bufferingNewest(1)&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;I’m still actively working on this problem (and should be doing so right now instead of writing this post).&lt;/p&gt;

&lt;p&gt;There are other libraries like &lt;a href=&quot;https://github.com/sideeffect-io/AsyncExtensions&quot;&gt;AsyncExtensions&lt;/a&gt; that build on the Swift primitives. However, getting my head around the shape of the problems they can solve and what I can accomplish with the primitives has been tough.&lt;/p&gt;

&lt;h2 id=&quot;final-thoughts-and-complaints&quot;&gt;Final thoughts and complaints&lt;/h2&gt;

&lt;p&gt;Swift Concurrency is just such a bummer. We still need to know about lower-level primitives like locks and mutexes and threads. We still need to have working knowledge of past solutions like Grand Central Dispatch in order to interact with our own and Apple’s legacy APIs. Unlike previous solutions, there are fewer primitives for managing serial vs. concurrent processing. It adds a new abstraction layer and several new concepts (isolation, actors, structured/unstructured) that probably will make sense eventually but don’t right now. It adds a dozen new keywords with more essential ones arriving with each new point update. It’s difficult or impossible to find sanctioned sample code that provides a glimpse into what a “best practice” could be. We’re still not safe from runtime crashes.&lt;/p&gt;

&lt;p&gt;I could keep going but I’m tired of complaining. I just want to have the confidence to write my features without having to budget multiple days of field testing and debugging time and reading the Swift forums.&lt;/p&gt;

</description>
        <pubDate>Tue, 12 Aug 2025 06:38:00 -0500</pubDate>
        <link>https://twocentstudios.com/2025/08/12/3-swift-concurrency-challenges-from-the-last-2-weeks/</link>
        <guid isPermaLink="true">https://twocentstudios.com/2025/08/12/3-swift-concurrency-challenges-from-the-last-2-weeks/</guid>
        
        <category>apple</category>
        
        <category>ios</category>
        
        <category>swift</category>
        
        <category>concurrency</category>
        
        
      </item>
    
      <item>
        <title>Full-Stack Swift: The Technical Architecture of Technicolor</title>
        <description>&lt;p&gt;In my &lt;a href=&quot;/2025/07/25/reintroducing-technicolor-binge-watch-with-friends-over-space-and-time/&quot;&gt;previous post about Technicolor&lt;/a&gt; I gave an overview of my hybrid chat app &amp;amp; social network for watching TV shows with friends asynchronously.&lt;/p&gt;

&lt;p&gt;Technicolor is a side-project I’ve been iterating on for over a decade (I’ve only used it with small groups of friends). It started its life as a Ruby on Rails app with browser-only support and has now been reborn as a full-stack Swift-on-server web service and native Apple platforms client app.&lt;/p&gt;

&lt;p&gt;This post explores the front-end and back-end architectures of Technicolor and some observations about developing its full-stack Swift server and client apps intermittently over several years. The project is not yet open source, but I will share code snippets throughout to illustrate parts of the architecture in context.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;: Any benefits of full-stack Swift do not meaningfully outweigh its demerits or opportunity costs for any use case I can imagine (happy to change my mind though). But existing Apple platforms developers may enjoy the familiarity of Swift in a new setting and the technical challenge of server-side development in an academic sense.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/technicolor-architecture-overview.png&quot; width=&quot;1200&quot; height=&quot;&quot; alt=&quot;Technicolor app showing the three main user-facing screens: dashboard for managing active rooms, room interface for timestamped chat during episodes, and profile management screen&quot; title=&quot;Technicolor app showing the three main user-facing screens: dashboard for managing active rooms, room interface for timestamped chat during episodes, and profile management screen&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Technicolor app showing the three main user-facing screens: dashboard for managing active rooms, room interface for timestamped chat during episodes, and profile management screen&lt;/div&gt;&lt;/div&gt;

&lt;h2 id=&quot;table-of-contents&quot;&gt;Table of contents&lt;/h2&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;a href=&quot;#architecture-overview&quot;&gt;Architecture overview&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;#development-experience&quot;&gt;Development experience&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;#shared-api-layer&quot;&gt;Shared API layer&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;#server-side&quot;&gt;Server-side&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;#client-side&quot;&gt;Client-side&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;#lessons-learned&quot;&gt;Lessons learned&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h2 id=&quot;architecture-overview&quot;&gt;Architecture overview&lt;/h2&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;  ┌─────────────────────┐       ┌──────────────────────────┐
  │  Server (tv-vapor)  │       │  Client (Technicolor)    │
  │                     │       │       iOS/macOS          │
  ├─────────────────────┤       ├──────────────────────────┤
  │ • Swift Vapor       │       │ • SwiftUI + Observation  │
  │ • SQLite + Fluent   │       │ • iOS 17+ / macOS 14+    │
  │ • TMDB API Client   │       │ • Mac Catalyst Support   │
  │ • Push Notifications│       │                          │
  │ • Deployed on Fly.io│       │                          │
  └─────────────────────┘       └──────────────────────────┘
                  │                           │
                  └─────────────┬─────────────┘
                                │
                                ▼
                  ┌─────────────────────────────┐
                  │    Shared API Layer         │
                  │       tv-models             │
                  ├─────────────────────────────┤
                  │ • 164 Codable Structures    │
                  │ • Type-Safe Client/Server   │
                  │ • Input/Output DTOs         │
                  └─────────────────────────────┘
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;p&gt;Technicolor has a client-server architecture. The server vends &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;json&lt;/code&gt; data via HTTP requests to clients authenticated with a bearer token.&lt;/p&gt;

&lt;p&gt;The server is written in Swift using the &lt;a href=&quot;https://vapor.codes&quot;&gt;Vapor&lt;/a&gt; web framework. The primary database is SQLite via the &lt;a href=&quot;https://docs.vapor.codes/fluent/overview/&quot;&gt;Fluent&lt;/a&gt; sub-framework. It fetches metadata about TV shows and movies from the &lt;a href=&quot;https://www.themoviedb.org&quot;&gt;TMDB&lt;/a&gt; API and caches the data in SQLite. It’s deployed to a single Machine on PaaS &lt;a href=&quot;https://fly.io&quot;&gt;Fly.io&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The client is written in Swift and SwiftUI and supports iOS and macOS (via Mac Catalyst or Designed for iPad) with one codebase and two targets.&lt;/p&gt;

&lt;p&gt;There is a Swift package called &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;tv-models&lt;/code&gt;, imported by both the client and server, that contains the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Codable&lt;/code&gt; models that form the shared API layer. This shared API layer was the primary motivator for using Swift everywhere.&lt;/p&gt;

&lt;p&gt;The entire project is contained in a Git mono-repo. The sources for the server side, client side, and shared models live in their own individual directories within the project directory. Most configuration files like the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Dockerfile&lt;/code&gt; live in the project directory.&lt;/p&gt;

&lt;p&gt;I’ll explore the development experience, shared API layer, server-side, and client-side aspects in more detail.&lt;/p&gt;

&lt;h2 id=&quot;development-experience&quot;&gt;Development experience&lt;/h2&gt;

&lt;p&gt;The blessing and curse of using full stack Swift is that I can use Xcode for everything. The cons of course are that Xcode can be bloated and buggy. But the pros are that I can explore, develop, and debug the entire codebase in one IDE that’s fine-tuned for Swift.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/technicolor-xcode-full-stack-development.png&quot; width=&quot;1000&quot; height=&quot;&quot; alt=&quot;Xcode workspace and iOS simulator simultaneously running both client and server with live console output from the server&quot; title=&quot;Xcode workspace and iOS simulator simultaneously running both client and server with live console output from the server&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Xcode workspace and iOS simulator simultaneously running both client and server with live console output from the server&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;The specific setup within Xcode is a single &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;xcworkspace&lt;/code&gt; file that contains:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;An &lt;a href=&quot;https://github.com/yonaskolb/XcodeGen&quot;&gt;XcodeGen&lt;/a&gt; generated &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;xcodeproj&lt;/code&gt; file for the iOS/macOS clients.&lt;/li&gt;
  &lt;li&gt;A Swift package for the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;tv-models&lt;/code&gt; shared DTO models.&lt;/li&gt;
  &lt;li&gt;A Swift package for the server-side Vapor project.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Overall, the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;xcworkspace&lt;/code&gt;-based setup worked okay. There were a few times where Swift Package caching needed manual fixing (several wasted hours I won’t be getting back). I’ve also wrestled with an issue where Swift Packages can force-create schemes in the workspace that &lt;a href=&quot;https://www.jessesquires.com/blog/2025/03/10/swiftpm-schemes-in-xcode/&quot;&gt;cannot be ignored&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;I have separate schemes for running the server and client, using separate destinations for running the iOS and macOS clients. Both the server and client are debuggable with all of Xcode’s integrated LLDB support including breakpoints. The console shows logs from server and client.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/technicolor-server-console-output.png&quot; width=&quot;1000&quot; height=&quot;&quot; alt=&quot;Server console output showing Vapor web server startup logs and selector to view logs for the client scheme&quot; title=&quot;Server console output showing Vapor web server startup logs and selector to view logs for the client scheme&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Server console output showing Vapor web server startup logs and selector to view logs for the client scheme&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;It’s certainly powerful to be able to set a breakpoint in a server endpoint handler and the client view model and step through the request and response cycle from both sides.&lt;/p&gt;

&lt;p&gt;Xcode only pre-builds the active target. For example, if I make a change to the shared &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;tv-models&lt;/code&gt; layer while the client target is active, Xcode won’t show me I’ve introduced a compiler error on the server until I switch to the server target and build it. Similarly, if a server source file is visible in the editor window while the client target is active, Xcode will often show a bunch of false-positive errors inline that you need to remember to ignore.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/technicolor-xcode-false-positive-errors.png&quot; width=&quot;800&quot; height=&quot;&quot; alt=&quot;Xcode is confused when the client scheme is selected but server source is visible in the editor&quot; title=&quot;Xcode is confused when the client scheme is selected but server source is visible in the editor&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Xcode is confused when the client scheme is selected but server source is visible in the editor&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;It’s still possible to build and run and test the server from outside Xcode, and I did often during this most recent development cycle with Claude Code. Adding coding agents made the all-in-one Xcode integration experience less impactful than it was a couple years ago.&lt;/p&gt;

&lt;p&gt;I use &lt;a href=&quot;https://github.com/nicklockwood/SwiftFormat&quot;&gt;SwiftFormat&lt;/a&gt; and a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.swiftformat&lt;/code&gt; rules file in the project directory to maintain formatting across all Swift source files.&lt;/p&gt;

&lt;p&gt;My overall takeaway is that the many of the benefits of server-client development inside Xcode don’t outweigh the demerits of the server-side Swift ecosystem’s relative immaturity. It’s a lonely experience using Xcode as the IDE of choice for server-side development, even if the backend is Swift.&lt;/p&gt;

&lt;h2 id=&quot;shared-api-layer&quot;&gt;Shared API layer&lt;/h2&gt;

&lt;p&gt;The &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;tv-model&lt;/code&gt; Swift package contains the handful of &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Codable&lt;/code&gt; struct definitions used by each &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Controller&lt;/code&gt; on the server side and the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;APIClient&lt;/code&gt; on the client side. These are often referred to as data transfer objects or DTOs.&lt;/p&gt;

&lt;p&gt;This essentially enforces a type-safe client-server API. The caveat of course is that model structs and fields are add-only and higher-level API versioning rules apply.&lt;/p&gt;

&lt;p&gt;An example of some shared &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Comment&lt;/code&gt;-related models from &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;tv-models&lt;/code&gt;:&lt;/p&gt;

&lt;div class=&quot;language-swift highlighter-rouge&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;kd&quot;&gt;public&lt;/span&gt; &lt;span class=&quot;kd&quot;&gt;enum&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Comment&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;kd&quot;&gt;struct&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;CreateInput&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Equatable&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Codable&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Sendable&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
        &lt;span class=&quot;kd&quot;&gt;public&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;roomID&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;UUID&lt;/span&gt;
        &lt;span class=&quot;kd&quot;&gt;public&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;content&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;String&lt;/span&gt;
        &lt;span class=&quot;kd&quot;&gt;public&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;seconds&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Int&lt;/span&gt;

        &lt;span class=&quot;kd&quot;&gt;public&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;init&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;roomID&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;UUID&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;content&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;String&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;seconds&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Int&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;*/&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;...&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;*/&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;

    &lt;span class=&quot;kd&quot;&gt;struct&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;CreateOutput&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Equatable&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Codable&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Sendable&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
        &lt;span class=&quot;kd&quot;&gt;public&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;roomID&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;UUID&lt;/span&gt;
        &lt;span class=&quot;kd&quot;&gt;public&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;comment&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Full&lt;/span&gt;

        &lt;span class=&quot;kd&quot;&gt;public&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;init&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;roomID&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;UUID&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;comment&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Full&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;*/&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;...&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;*/&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;

    &lt;span class=&quot;kd&quot;&gt;struct&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Full&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Equatable&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Codable&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Sendable&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
        &lt;span class=&quot;kd&quot;&gt;public&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;id&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;UUID&lt;/span&gt;
        &lt;span class=&quot;kd&quot;&gt;public&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;createdAt&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Date&lt;/span&gt;
        &lt;span class=&quot;kd&quot;&gt;public&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;updatedAt&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Date&lt;/span&gt;
        &lt;span class=&quot;kd&quot;&gt;public&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;content&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;String&lt;/span&gt;
        &lt;span class=&quot;kd&quot;&gt;public&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;seconds&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Int&lt;/span&gt;
        &lt;span class=&quot;kd&quot;&gt;public&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;user&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;User&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;Stub&lt;/span&gt;

        &lt;span class=&quot;kd&quot;&gt;public&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;init&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;id&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;UUID&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;createdAt&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Date&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;updatedAt&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Date&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;content&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;String&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;seconds&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Int&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;user&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;User&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;Stub&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;*/&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;...&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;*/&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;p&gt;For the API endpoint envelope models, my convention is using &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Create&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Edit&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Delete&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Show&lt;/code&gt;, etc. prefixes and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Input&lt;/code&gt; or &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Output&lt;/code&gt; with respect to the server. In other words, the client creates &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Input&lt;/code&gt; models and receives &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Output&lt;/code&gt; models.&lt;/p&gt;

&lt;p&gt;I use &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Full&lt;/code&gt; for models that contain the majority of the database model’s data. I use &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Stub&lt;/code&gt; for smaller subsets. It’s generally worked out well to keep the amount of model sub-types low while having the flexibility to create new ones when it makes sense. It simplifies the client side, allowing model types to be passed between sub-systems (unlike GraphQL which has unique model-types per request).&lt;/p&gt;

&lt;p&gt;On the client-side, these models are mostly used as-is throughout the codebase, even in the View layer.&lt;/p&gt;

&lt;p&gt;On the server-side, Fluent ORM has its own class-based model definitions that are used to interact with the source-of-truth database data. There is a custom encoding/decoding layer for each DTO.&lt;/p&gt;

&lt;div class=&quot;language-swift highlighter-rouge&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;c1&quot;&gt;/// Fluent ORM model&lt;/span&gt;
&lt;span class=&quot;kd&quot;&gt;final&lt;/span&gt; &lt;span class=&quot;kd&quot;&gt;class&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Comment&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Model&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;kd&quot;&gt;@unchecked&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Sendable&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;kd&quot;&gt;static&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;schema&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;comments&quot;&lt;/span&gt;

    &lt;span class=&quot;kd&quot;&gt;@ID(key: .id)&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;var&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;id&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;UUID&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;?&lt;/span&gt;

    &lt;span class=&quot;kd&quot;&gt;@Timestamp(key: &quot;created_at&quot;, on: .create)&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;var&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;createdAt&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Date&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;?&lt;/span&gt;

    &lt;span class=&quot;kd&quot;&gt;@Timestamp(key: &quot;updated_at&quot;, on: .update)&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;var&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;updatedAt&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Date&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;?&lt;/span&gt;

    &lt;span class=&quot;kd&quot;&gt;@Field(key: &quot;content&quot;)&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;var&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;content&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;String&lt;/span&gt;

    &lt;span class=&quot;kd&quot;&gt;@Field(key: &quot;seconds&quot;)&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;var&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;seconds&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Int&lt;/span&gt;

    &lt;span class=&quot;kd&quot;&gt;@Parent(key: &quot;user_id&quot;)&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;var&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;user&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;User&lt;/span&gt;

    &lt;span class=&quot;kd&quot;&gt;@Parent(key: &quot;room_id&quot;)&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;var&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;room&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Room&lt;/span&gt;

    &lt;span class=&quot;nf&quot;&gt;init&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{}&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;

&lt;span class=&quot;c1&quot;&gt;/// Converting a Fluent model to a DTO&lt;/span&gt;
&lt;span class=&quot;kd&quot;&gt;extension&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;TV&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;Comment&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;Full&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;nf&quot;&gt;init&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;_&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;comment&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Comment&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;throws&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;guard&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;createdAt&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;comment&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;createdAt&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
              &lt;span class=&quot;k&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;updatedAt&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;comment&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;updatedAt&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;else&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
            &lt;span class=&quot;k&quot;&gt;throw&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Abort&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;internalServerError&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
        &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;

        &lt;span class=&quot;k&quot;&gt;try&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;self&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;init&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
            &lt;span class=&quot;nv&quot;&gt;id&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;comment&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;requireID&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(),&lt;/span&gt;
            &lt;span class=&quot;nv&quot;&gt;createdAt&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;createdAt&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
            &lt;span class=&quot;nv&quot;&gt;updatedAt&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;updatedAt&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
            &lt;span class=&quot;nv&quot;&gt;content&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;comment&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;content&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
            &lt;span class=&quot;nv&quot;&gt;seconds&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;comment&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;seconds&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
            &lt;span class=&quot;nv&quot;&gt;user&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;TV&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;User&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;Stub&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;comment&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;user&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
        &lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;p&gt;Although the model definitions are shared and type-safe, the endpoint URLs themselves are duplicated across client and server. There is probably a way I could share these as well, but at the moment I haven’t found this to be enough of a maintenance burden or source of bugs that I need to spend time trying to harmonize it.&lt;/p&gt;

&lt;p&gt;My takeaway is that this setup is definitely convenient, but the more popular web frameworks have solved this problem in other ways like using &lt;a href=&quot;https://www.openapis.org&quot;&gt;OpenAPI&lt;/a&gt; and its code generators. Technicolor has some complexity (164 model structures), but nowhere near that of a large scale SaaS or social network.&lt;/p&gt;

&lt;h2 id=&quot;server-side&quot;&gt;Server-side&lt;/h2&gt;

&lt;p&gt;Technicolor began its life back in 2013 as a Ruby on Rails app with a web client. In 2017 I made a brief foray into rewriting the backend in the Elixir language with the Phoenix web framework, but quickly abandoned that effort when Swift Vapor began gaining some popularity.&lt;/p&gt;

&lt;h3 id=&quot;thoughts-on-the-swift-vapor-framework&quot;&gt;Thoughts on the Swift Vapor framework&lt;/h3&gt;

&lt;p&gt;When I first began development on the Technicolor rewrite, Vapor was still using the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;EventLoopFuture&lt;/code&gt; concurrency primitives from &lt;a href=&quot;https://github.com/apple/swift-nio&quot;&gt;SwiftNIO&lt;/a&gt;. &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;EventLoopFuture&lt;/code&gt; felt similar to a FRP framework like &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Combine&lt;/code&gt; or &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;RxSwift&lt;/code&gt;. But in comparison to async await, it was really painful. Swift’s type inference is awful with the amount of mapping closures required for &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;EventLoopFuture&lt;/code&gt;. In these early stages, even simple endpoints took hours to write and test. Compare the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;EventLoopFuture&lt;/code&gt; version of the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Comment&lt;/code&gt; &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;create&lt;/code&gt; func here to the async version in the next section:&lt;/p&gt;

&lt;div class=&quot;language-swift highlighter-rouge&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;kd&quot;&gt;func&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;create&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;req&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Request&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;throws&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;-&amp;gt;&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;EventLoopFuture&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;&amp;lt;&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;TV&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;EmptyOutput&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;&amp;gt;&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;userID&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;try&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;req&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;auth&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;require&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;UserToken&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;self&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;user&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;requireID&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;input&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;try&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;req&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;content&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;decode&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;TV&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;Comment&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;CreateInput&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;self&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;comment&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Comment&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;content&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;input&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;content&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;seconds&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;input&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;seconds&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;userID&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;userID&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;roomID&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;input&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;roomID&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;RoomUser&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;query&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;on&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;req&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;db&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
        &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;filter&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;err&quot;&gt;\&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;err&quot;&gt;$&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;room&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;err&quot;&gt;$&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;id&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;==&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;input&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;roomID&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
        &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;filter&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;err&quot;&gt;\&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;err&quot;&gt;$&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;user&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;err&quot;&gt;$&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;id&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;==&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;userID&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
        &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;first&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;
        &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;unwrap&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;or&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Abort&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;unauthorized&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;))&lt;/span&gt;
        &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;flatMap&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;_&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;in&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;comment&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;create&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;on&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;req&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;db&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
        &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;flatMapThrowing&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;try&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;comment&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;requireID&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
        &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;flatMap&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;newCommentID&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;in&lt;/span&gt;
            &lt;span class=&quot;kt&quot;&gt;Comment&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;query&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;on&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;req&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;db&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
                &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;with&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;err&quot;&gt;\&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;err&quot;&gt;$&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;user&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
                &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;filter&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;err&quot;&gt;\&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;err&quot;&gt;$&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;id&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;==&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;newCommentID&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
                &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;first&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;
                &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;unwrap&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;or&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Abort&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;internalServerError&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;))&lt;/span&gt;
        &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
        &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;flatMapThrowing&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;newComment&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;-&amp;gt;&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;EventLoopFuture&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;&amp;lt;&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;TV&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;Comment&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;Room&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;&amp;gt;&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;in&lt;/span&gt;
            &lt;span class=&quot;k&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;fullComment&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;try&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;TV&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;Comment&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;Full&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;newComment&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
            &lt;span class=&quot;k&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;roomComment&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;TV&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;Comment&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;Room&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;roomID&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;input&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;roomID&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;comment&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;fullComment&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
            &lt;span class=&quot;k&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;RoomUser&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;query&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;on&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;req&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;db&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
                &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;with&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;err&quot;&gt;\&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;err&quot;&gt;$&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;user&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
                &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;field&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;err&quot;&gt;\&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;err&quot;&gt;$&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;user&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;err&quot;&gt;$&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;id&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
                &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;filter&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;err&quot;&gt;\&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;err&quot;&gt;$&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;room&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;err&quot;&gt;$&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;id&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;==&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;input&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;roomID&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
                &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;all&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;
                &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;flatMapThrowing&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;roomUsers&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;-&amp;gt;&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;TV&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;Comment&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;Room&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;in&lt;/span&gt;
                    &lt;span class=&quot;k&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;userIDs&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;roomUsers&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;compactMap&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;err&quot;&gt;\&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;user&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;id&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
                    &lt;span class=&quot;k&quot;&gt;try&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;req&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;webSocketClient&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;send&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;message&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;comment&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;roomComment&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;),&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;to&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;userIDs&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
                    &lt;span class=&quot;k&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;roomComment&lt;/span&gt;
                &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
        &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
        &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;transform&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;to&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;TV&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;EmptyOutput&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;())&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;p&gt;As Swift Concurrency matured, the Vapor team finished their early work on supporting &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;async/await&lt;/code&gt; alongside &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;EventLoopFuture&lt;/code&gt;. I spent days tediously rewriting the existing &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;EventLoopFuture&lt;/code&gt; signal chains. The &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;async&lt;/code&gt; versions looked a lot better, but it was hard won.&lt;/p&gt;

&lt;p&gt;Coming back to the project after a couple years, the Vapor team seems to be stalled in finishing up Swift Strict Concurrency support. Fluent models &lt;a href=&quot;https://blog.vapor.codes/posts/fluent-models-and-sendable/&quot;&gt;must still be declared&lt;/a&gt; &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;@unchecked Sendable&lt;/code&gt;. I’m running Swift 6.0 on the deployment, but with Swift 5 language mode.&lt;/p&gt;

&lt;p&gt;I don’t want to discount the laudable work done by the Vapor core team and community. But going up against the mature frameworks from JS, Ruby, Python, PHP, etc., dealing with the low prioritization of Swift on the server from Apple, and with the overall churn of the Swift language, my take is that using Vapor is the wrong choice if your aim is pragmatism.&lt;/p&gt;

&lt;p&gt;However, like I’ve mentioned so far, there are upsides to using Swift on the server. In theory it’s faster than interpreted languages and the type safety makes refactors safer and obviates the need for massive test suites.&lt;/p&gt;

&lt;h3 id=&quot;routes-and-controllers&quot;&gt;Routes and Controllers&lt;/h3&gt;

&lt;p&gt;Here is a taste of what it looks like to define the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Comment&lt;/code&gt;-related routes.&lt;/p&gt;

&lt;div class=&quot;language-swift highlighter-rouge&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;kd&quot;&gt;struct&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;CommentController&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;RouteCollection&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;kd&quot;&gt;func&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;boot&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;routes&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;RoutesBuilder&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;throws&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;comments&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;routes&lt;/span&gt;
            &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;grouped&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;UserToken&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;authenticator&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;())&lt;/span&gt;
            &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;grouped&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;UserToken&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;guardMiddleware&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;())&lt;/span&gt;
            &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;grouped&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;rooms&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
            &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;grouped&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;comments&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;

        &lt;span class=&quot;n&quot;&gt;comments&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;post&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;create&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;use&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;create&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;comments&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;post&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;delete&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;use&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;delete&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;comments&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;post&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;edit&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;use&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;edit&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
    
    &lt;span class=&quot;c1&quot;&gt;// ...&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;p&gt;This generates 3 POST routes that use middleware to parse out a valid auth token and make it available to the request:&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;/rooms/comments/create
/rooms/comments/delete
/rooms/comments/edit
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;p&gt;My unique convention is to use &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;POST&lt;/code&gt; for all requests, even read-only CRUD operations like &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;show&lt;/code&gt; that would usually be &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;GET&lt;/code&gt;. The reasoning behind this is that it allows me to use the same JSON-encoded HTTP body plumbing for all requests instead of having to selectively encode and decode either or both from the URL query and the HTTP body.&lt;/p&gt;

&lt;p&gt;The actual request/response implementation on the server side (modern async version) looks something like this:&lt;/p&gt;

&lt;div class=&quot;language-swift highlighter-rouge&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;c1&quot;&gt;// CommentController.swift&lt;/span&gt;
&lt;span class=&quot;kd&quot;&gt;func&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;create&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;req&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Request&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;as&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;ync&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;throws&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;-&amp;gt;&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;TV&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;Comment&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;CreateOutput&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;userID&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;try&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;req&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;auth&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;require&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;UserToken&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;self&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;user&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;requireID&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;input&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;try&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;req&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;content&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;decode&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;TV&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;Comment&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;CreateInput&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;self&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;

    &lt;span class=&quot;c1&quot;&gt;// Ensure the User is allowed to post Comments in this Room    &lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;_&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;try&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;RoomUser&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;query&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;on&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;req&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;db&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
        &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;filter&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;err&quot;&gt;\&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;err&quot;&gt;$&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;room&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;err&quot;&gt;$&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;id&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;==&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;input&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;roomID&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
        &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;filter&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;err&quot;&gt;\&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;err&quot;&gt;$&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;user&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;err&quot;&gt;$&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;id&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;==&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;userID&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
        &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;first&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;?&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;!&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Abort&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;unauthorized&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;

    &lt;span class=&quot;c1&quot;&gt;// Prefer using timestamp from content string if it exists&lt;/span&gt;
    &lt;span class=&quot;nf&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;finalContent&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;finalSeconds&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;):&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;String&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Int&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;parsed&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;TimestampParser&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;parseTimestampFromContent&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;input&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;content&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;),&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;parsedSeconds&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;parsed&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;seconds&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;finalContent&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;parsed&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;parsedContent&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;finalSeconds&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;parsedSeconds&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;else&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;finalContent&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;input&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;content&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;finalSeconds&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;input&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;seconds&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;

    &lt;span class=&quot;c1&quot;&gt;// Create the comment in the database&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;comment&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Comment&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;content&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;finalContent&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;seconds&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;finalSeconds&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;userID&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;userID&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;roomID&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;input&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;roomID&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;try&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;comment&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;create&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;on&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;req&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;db&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    
    &lt;span class=&quot;c1&quot;&gt;// Prepare all data needed to populate the DTO&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;newCommentID&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;try&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;comment&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;requireID&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;newComment&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;try&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Comment&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;query&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;on&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;req&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;db&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
        &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;with&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;err&quot;&gt;\&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;err&quot;&gt;$&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;user&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
        &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;filter&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;err&quot;&gt;\&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;err&quot;&gt;$&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;id&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;==&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;newCommentID&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
        &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;first&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;?&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;!&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Abort&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;internalServerError&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;

    &lt;span class=&quot;k&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;fullComment&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;try&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;TV&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;Comment&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;Full&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;newComment&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;output&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;TV&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;Comment&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;CreateOutput&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;roomID&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;input&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;roomID&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;comment&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;fullComment&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;output&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;p&gt;For most CRUD operations, the endpoint implementation is pretty straightforward: parse inputs, specific model authorization, fetch some data, modify and write some data back to the database, convert data to a DTO and return. So much so that coding agents have a pretty easy time interpreting a specification to add or modify existing endpoints.&lt;/p&gt;

&lt;h3 id=&quot;authentication&quot;&gt;Authentication&lt;/h3&gt;

&lt;p&gt;Early on in the project, I decided to roll my own email-identity, bearer token authentication using the primitives provided by &lt;a href=&quot;https://docs.vapor.codes/security/authentication/&quot;&gt;Vapor&lt;/a&gt;. The reasoning was to keep things simple while getting a better understanding of what goes on behind the scenes as (primarily) a client-side developer. Although I haven’t touched this code in years, I still think I have a better understanding of auth frameworks than I did before giving it a go.&lt;/p&gt;

&lt;p&gt;As a quick summary, all requests to Technicolor use &lt;a href=&quot;https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/Authentication#bearer&quot;&gt;bearer token&lt;/a&gt; authorization with the exception of:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Create Account: uses no authentication, but checks against an invite token in the request data to ensure the new user is allowed to create an account.&lt;/li&gt;
  &lt;li&gt;Log In: uses &lt;a href=&quot;https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/Authentication#basic_authentication_scheme&quot;&gt;HTTP Basic authentication&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3 id=&quot;services&quot;&gt;Services&lt;/h3&gt;

&lt;p&gt;A critical decision I made early on that affects the architecture of the server codebase: I’m deploying to a single server with direct access to the SQLite database. As I discussed in &lt;a href=&quot;https://twocentstudios.com/2025/07/02/swift-vapor-fly-io-sqlite-config/&quot;&gt;Configuring Swift Vapor on Fly.io with SQLite&lt;/a&gt;, there are several tradeoffs to this decision with the primary benefit being simplicity.&lt;/p&gt;

&lt;p&gt;This decision works in concert with defining and using &lt;a href=&quot;https://docs.vapor.codes/advanced/services/&quot;&gt;services&lt;/a&gt; in Controllers (request handlers). State within services can be shared between requests if necessary (using proper locking for thread safety) without the additional complexity of worrying about sharing that data across parallel app servers.&lt;/p&gt;

&lt;p&gt;For example, this is how I define and use the client that fetches data from TMDB.&lt;/p&gt;

&lt;div class=&quot;language-swift highlighter-rouge&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;kd&quot;&gt;extension&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Application&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;kd&quot;&gt;private&lt;/span&gt; &lt;span class=&quot;kd&quot;&gt;struct&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;TMDBClientKey&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;StorageKey&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
        &lt;span class=&quot;kd&quot;&gt;typealias&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Value&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;TMDBClient&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;

    &lt;span class=&quot;k&quot;&gt;var&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;tmdbClient&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;TMDBClient&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;get&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
            &lt;span class=&quot;n&quot;&gt;storage&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;TMDBClientKey&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;self&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;!&lt;/span&gt;
        &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;set&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
            &lt;span class=&quot;n&quot;&gt;storage&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;TMDBClientKey&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;self&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;newValue&lt;/span&gt;
        &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;

&lt;span class=&quot;kd&quot;&gt;public&lt;/span&gt; &lt;span class=&quot;kd&quot;&gt;func&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;configure&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;_&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;app&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Application&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;as&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;ync&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;throws&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;c1&quot;&gt;// ...&lt;/span&gt;
    
    &lt;span class=&quot;k&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;tmdbApiKey&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Environment&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;get&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;TMDB_API_KEY&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;??&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;&quot;&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;tmdbClient&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;TMDBClient&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;httpClient&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;app&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;http&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;client&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;shared&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;apiKey&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;tmdbApiKey&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;logger&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;app&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;logger&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;app&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;tmdbClient&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;tmdbClient&lt;/span&gt;

    &lt;span class=&quot;k&quot;&gt;try&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;routes&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;app&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;

&lt;span class=&quot;kd&quot;&gt;final&lt;/span&gt; &lt;span class=&quot;kd&quot;&gt;class&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;TMDBController&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;RouteCollection&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;c1&quot;&gt;// ...&lt;/span&gt;
    
    &lt;span class=&quot;kd&quot;&gt;func&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;search&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;req&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Request&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;as&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;ync&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;throws&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;-&amp;gt;&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;TV&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;TMDBPagedResponse&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;&amp;lt;&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;TV&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;TMDBMultiSearchResult&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;&amp;gt;&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;input&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;try&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;req&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;content&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;decode&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;TV&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;TMDBSearchInput&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;self&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;

        &lt;span class=&quot;k&quot;&gt;guard&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;!&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;input&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;query&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;isEmpty&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;else&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
            &lt;span class=&quot;k&quot;&gt;throw&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Abort&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;badRequest&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;reason&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;Query is required&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
        &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;

        &lt;span class=&quot;k&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;page&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;input&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;page&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;??&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;1&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;tmdbClient&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;req&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;application&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;tmdbClient&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;mediaTypeEnum&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;input&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;mediaType&lt;/span&gt;

        &lt;span class=&quot;k&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;try&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;tmdbClient&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;search&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;query&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;input&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;query&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;mediaType&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;mediaTypeEnum&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;page&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;page&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;h3 id=&quot;testing&quot;&gt;Testing&lt;/h3&gt;

&lt;p&gt;I have a db migration that sets up a very minimal set of test users with hard coded token values to make local development easier.&lt;/p&gt;

&lt;p&gt;In the early stages of the project, I maintained a Paw file (now &lt;a href=&quot;https://paw.cloud/&quot;&gt;RapidAPI&lt;/a&gt;) to facilitate manual testing of server endpoints.&lt;/p&gt;

&lt;p&gt;In this latest sprint, I’ve developed a full Swift Testing test suite with ~150 tests. Especially when endpoints only depend on the local database, the request/response cycle is a lot more straightforward to write automated tests for than what I’m used to on the iOS side.&lt;/p&gt;

&lt;p&gt;The addition of the external TMDB client and caching has made some endpoints a little tricker to test, but overall I feel confident that my test suite is providing value.&lt;/p&gt;

&lt;p&gt;As a quick example, all tests follow the below pattern pretty closely. Over time, I’ve built up a lot of helpers to keep the tests focused, consistent, and easy to read and write.&lt;/p&gt;

&lt;div class=&quot;language-swift highlighter-rouge&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;kd&quot;&gt;@Suite(&quot;CommentController Tests&quot;)&lt;/span&gt;
&lt;span class=&quot;kd&quot;&gt;struct&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;CommentControllerTests&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;

    &lt;span class=&quot;kd&quot;&gt;@Suite(&quot;Comment Creation&quot;)&lt;/span&gt;
    &lt;span class=&quot;kd&quot;&gt;struct&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;CommentCreationTests&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;

        &lt;span class=&quot;kd&quot;&gt;@Test(&quot;Create comment with valid input succeeds&quot;)&lt;/span&gt;
        &lt;span class=&quot;kd&quot;&gt;func&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;createCommentSuccess&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;as&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;ync&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;throws&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
            &lt;span class=&quot;k&quot;&gt;try&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;withApp&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;configure&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;configureTestApp&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;app&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;in&lt;/span&gt;
                &lt;span class=&quot;k&quot;&gt;try&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;withAuthenticatedUser&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;on&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;app&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;user&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;userToken&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;in&lt;/span&gt;
                    &lt;span class=&quot;c1&quot;&gt;// Create a room and add user to it&lt;/span&gt;
                    &lt;span class=&quot;k&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;room&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;try&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;createTestRoom&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;on&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;app&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;db&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;withUsers&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;user&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;])&lt;/span&gt;

                    &lt;span class=&quot;k&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;createInput&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;try&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;TV&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;Comment&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;CreateInput&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
                        &lt;span class=&quot;nv&quot;&gt;roomID&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;room&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;requireID&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(),&lt;/span&gt;
                        &lt;span class=&quot;nv&quot;&gt;content&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;This is a test comment&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
                        &lt;span class=&quot;nv&quot;&gt;seconds&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;120&lt;/span&gt;
                    &lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;

                    &lt;span class=&quot;k&quot;&gt;try&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;app&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;testing&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;authenticatedRequest&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;POST&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;/rooms/comments/create&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;token&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;userToken&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;beforeRequest&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;req&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;in&lt;/span&gt;
                        &lt;span class=&quot;k&quot;&gt;try&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;req&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;content&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;encode&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;createInput&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
                    &lt;span class=&quot;p&quot;&gt;})&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;res&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;as&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;ync&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;throws&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;in&lt;/span&gt;
                        &lt;span class=&quot;cp&quot;&gt;#expect(res.status == .ok)&lt;/span&gt;

                        &lt;span class=&quot;k&quot;&gt;try&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;assertDecodable&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;res&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;as&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;TV&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;Comment&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;CreateOutput&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;self&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;response&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;in&lt;/span&gt;
                            &lt;span class=&quot;cp&quot;&gt;#expect(response.comment.content == &quot;This is a test comment&quot;)&lt;/span&gt;
                            &lt;span class=&quot;cp&quot;&gt;#expect(response.comment.seconds == 120)&lt;/span&gt;
                            &lt;span class=&quot;cp&quot;&gt;#expect(response.comment.user.username == user.username)&lt;/span&gt;
                            &lt;span class=&quot;k&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;roomID&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;try&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;room&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;requireID&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;
                            &lt;span class=&quot;cp&quot;&gt;#expect(response.roomID == roomID)&lt;/span&gt;
                        &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
                    &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
                &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
            &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
        &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;p&gt;All 163 tests run in parallel with a separate copy of the migrated test database and complete on my MacBook Pro in about 12 seconds.&lt;/p&gt;

&lt;h3 id=&quot;deployment&quot;&gt;Deployment&lt;/h3&gt;

&lt;p&gt;I wrote a &lt;a href=&quot;https://twocentstudios.com/2025/07/02/swift-vapor-fly-io-sqlite-config/&quot;&gt;detailed post&lt;/a&gt; about my exact deployment setup, but here I’ll reiterate that I’ve found deployment to be difficult and often an awful time sink. I’d consider setting deployment up from the beginning of Vapor app development and updating periodically as your app grows in complexity.&lt;/p&gt;

&lt;p&gt;Although a default Dockerfile is included in the Vapor new project generation, it can be inscrutable to devops novices like myself. I had particular trouble with ensuring the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;tv-vapor&lt;/code&gt; shared models source directory was available as a &lt;em&gt;sibling&lt;/em&gt; directory in the development repo, but as a &lt;em&gt;subfolder&lt;/em&gt; in the Docker image. It was a lot of slow trial and error.&lt;/p&gt;

&lt;p&gt;As great as the Dockerfile is as a generic recipe for deployment, there are always going to be specifics you need to learn about your actual deployment destination. For me, I’ve kept using Fly.io for whatever reason, but I think any PaaS or VPS is going to have the same learning curve. Fly.io had the same kind of configuration churn I experienced with Swift and Vapor for a side project with years-long breaks in the development cycle.&lt;/p&gt;

&lt;p&gt;One particular gotcha I ran into a few times is accidentally using Swift APIs that are unavailable on Linux. The open source &lt;a href=&quot;https://github.com/swiftlang/swift-foundation&quot;&gt;Foundation&lt;/a&gt; has mostly hit feature parity, but there are some sibling frameworks like &lt;a href=&quot;https://developer.apple.com/documentation/cryptokit&quot;&gt;CryptoKit&lt;/a&gt; that require using an &lt;a href=&quot;https://github.com/apple/swift-crypto&quot;&gt;open source variant&lt;/a&gt;. These were cases that I would unfortunately discover when deploying a new build to Fly.io and it failing with some cryptic error message.&lt;/p&gt;

&lt;p&gt;At the moment I’m YOLO-deploying to the production server after developing on localhost. Before exiting beta I’ll also create a development server.&lt;/p&gt;

&lt;h2 id=&quot;client-side&quot;&gt;Client-side&lt;/h2&gt;

&lt;h3 id=&quot;supported-platforms&quot;&gt;Supported platforms&lt;/h3&gt;

&lt;p&gt;When I first started working on the client side apps several ago, the promise of SwiftUI’s initial pitch of &lt;em&gt;learn-once, apply anywhere&lt;/em&gt; was still optimistic for many of us. I built out the initial structure of the apps under the presumption of high SwiftUI compatibility across iPhone, iPad, and macOS. There were lots of SwiftUI modifier shims, and I made sure to aggressively modularize Views so they could be composed uniquely between platforms.&lt;/p&gt;

&lt;p&gt;Unfortunately, after the first couple sprints I found myself bogged down in a lot of missing and broken APIs, especially on the macOS side. I eventually gave up on native macOS support and switched over to Mac Catalyst. In my most recent sprints, I realized that Designed for iPad actually looks and functions better than Mac Catalyst, while also requiring nearly zero API conditionals between the two platforms. One of my friends is running Technicolor on a macOS virtual machine without ARM64 support, so I can’t drop Mac Catalyst support yet.&lt;/p&gt;

&lt;p&gt;Writing a truly native Mac app and a web client are both on my roadmap, but since this is a side project I’ll probably continue polishing the rough edges of the iOS app during the beta period.&lt;/p&gt;

&lt;p&gt;At the moment Technicolor supports iOS 17+ and macOS 14+.&lt;/p&gt;

&lt;h3 id=&quot;project-setup&quot;&gt;Project setup&lt;/h3&gt;

&lt;p&gt;I use XcodeGen and a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;project.yml&lt;/code&gt; file for maintaining the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;xcodeproj&lt;/code&gt; file referenced by the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;xcworkspace&lt;/code&gt; file. When using coding agents XcodeGen or similar is essentially mandatory.&lt;/p&gt;

&lt;p&gt;I use the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;tv-models&lt;/code&gt; Swift package mentioned earlier.&lt;/p&gt;

&lt;p&gt;I use the &lt;a href=&quot;https://github.com/kishikawakatsumi/KeychainAccess&quot;&gt;KeychainAccess&lt;/a&gt; package for storing the bearer token securely.&lt;/p&gt;

&lt;p&gt;I use several Point-Free libraries: &lt;a href=&quot;https://github.com/pointfreeco/swift-dependencies&quot;&gt;swift-dependencies&lt;/a&gt; and &lt;a href=&quot;https://github.com/pointfreeco/swift-navigation&quot;&gt;swift-navigation&lt;/a&gt;. More on these later.&lt;/p&gt;

&lt;h3 id=&quot;architecture-rules&quot;&gt;Architecture rules&lt;/h3&gt;

&lt;p&gt;My client architecture is relatively templated and consistent across features. This helps me, but also helps coding agents write idiomatic code on the first attempt.&lt;/p&gt;

&lt;p&gt;I use &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;@Observable&lt;/code&gt; Store objects paired with top level SwiftUI &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;View&lt;/code&gt;s for each logical screen.&lt;/p&gt;

&lt;p&gt;All &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Store&lt;/code&gt;s follow this template:&lt;/p&gt;

&lt;div class=&quot;language-swift highlighter-rouge&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;kd&quot;&gt;@MainActor&lt;/span&gt; &lt;span class=&quot;kd&quot;&gt;@Observable&lt;/span&gt; &lt;span class=&quot;kd&quot;&gt;final&lt;/span&gt; &lt;span class=&quot;kd&quot;&gt;class&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;ExampleStore&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;kd&quot;&gt;struct&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;State&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Equatable&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
        &lt;span class=&quot;c1&quot;&gt;// Local feature state&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
    
    &lt;span class=&quot;kd&quot;&gt;@CasePathable&lt;/span&gt;
    &lt;span class=&quot;kd&quot;&gt;enum&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Destination&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
        &lt;span class=&quot;c1&quot;&gt;// Navigation destinations with associated stores&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;case&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;detail&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;DetailStore&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;case&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;settings&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;SettingsStore&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
    
    &lt;span class=&quot;kd&quot;&gt;struct&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Actions&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
        &lt;span class=&quot;c1&quot;&gt;// Callback functions for parent communication&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;var&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;completion&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(()&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;-&amp;gt;&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Void&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;unimplemented&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
    
    &lt;span class=&quot;k&quot;&gt;var&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;state&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;State&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;var&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;destination&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Destination&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;?&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;var&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;actions&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Actions&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;
    
    &lt;span class=&quot;kd&quot;&gt;@ObservationIgnored&lt;/span&gt; &lt;span class=&quot;kd&quot;&gt;@Dependency(\.service)&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;var&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;service&lt;/span&gt;
    &lt;span class=&quot;kd&quot;&gt;private&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;token&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Token&lt;/span&gt;  &lt;span class=&quot;c1&quot;&gt;// Immutable data&lt;/span&gt;
    
    &lt;span class=&quot;nf&quot;&gt;init&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;parameters&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
        &lt;span class=&quot;c1&quot;&gt;// Initialize state and private properties&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
    
    &lt;span class=&quot;kd&quot;&gt;func&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;task&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;as&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;ync&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
        &lt;span class=&quot;c1&quot;&gt;// Startup or reappear   &lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
    
    &lt;span class=&quot;kd&quot;&gt;func&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;showDetailButtonTapped&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;as&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;ync&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
        &lt;span class=&quot;c1&quot;&gt;// User actions from the view layer e.g. pushing a new view&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;destination&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;withDependencies&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;from&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;self&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
            &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;detail&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;DetailStore&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;())&lt;/span&gt;
        &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
    
    &lt;span class=&quot;kd&quot;&gt;private&lt;/span&gt; &lt;span class=&quot;kd&quot;&gt;func&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;helperMethod&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;p&gt;All &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;View&lt;/code&gt;s follow this template:&lt;/p&gt;

&lt;div class=&quot;language-swift highlighter-rouge&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;kd&quot;&gt;struct&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;ExampleView&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;View&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;kd&quot;&gt;@Bindable&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;var&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;store&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;ExampleStore&lt;/span&gt;

    &lt;span class=&quot;k&quot;&gt;var&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;body&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;some&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;View&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
        &lt;span class=&quot;kt&quot;&gt;VStack&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
            &lt;span class=&quot;o&quot;&gt;...&lt;/span&gt;
        &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
        &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;task&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;store&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;task&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
        &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;navigationDestination&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;item&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;err&quot;&gt;$&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;store&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;destination&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;detail&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;err&quot;&gt;$&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;detailStore&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;in&lt;/span&gt;
            &lt;span class=&quot;kt&quot;&gt;DetailView&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;store&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;detailStore&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
        &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
        &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;sheet&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;item&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;err&quot;&gt;$&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;store&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;destination&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;settings&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;err&quot;&gt;$&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;settingsStore&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;in&lt;/span&gt;
            &lt;span class=&quot;kt&quot;&gt;SettingsView&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;store&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;settingsStore&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
        &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;p&gt;Parent-child relationships follow these rules:&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;&lt;strong&gt;Parent Store&lt;/strong&gt;: Creates optional destination property (&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;var destination: Destination?&lt;/code&gt;)&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Parent Store&lt;/strong&gt;: Provides method to create child store (&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;func showChild() { destination = .child(ChildStore()) }&lt;/code&gt;)&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Parent View&lt;/strong&gt;: Uses &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;sheet(item: $store.destination.child)&lt;/code&gt; modifier&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Child View&lt;/strong&gt;: Accepts store as parameter (&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;@Bindable var store: ChildStore&lt;/code&gt; or &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;let store: ChildStore&lt;/code&gt;)&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Child Store&lt;/strong&gt;: Must conform to &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Identifiable&lt;/code&gt; (class identity-based)&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Dependency Injection&lt;/strong&gt;: Use &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;withDependencies(from: self)&lt;/code&gt; only if parent has &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;@Dependency&lt;/code&gt; vars&lt;/li&gt;
&lt;/ol&gt;

&lt;h3 id=&quot;api-client&quot;&gt;API Client&lt;/h3&gt;

&lt;p&gt;The API Client is not quite as streamlined as I’d like, but it’s simple to add new endpoints as needed.&lt;/p&gt;

&lt;p&gt;I use the &lt;a href=&quot;https://github.com/pointfreeco/swift-dependencies&quot;&gt;swift-dependencies&lt;/a&gt; format for the definition.&lt;/p&gt;

&lt;div class=&quot;language-swift highlighter-rouge&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;kd&quot;&gt;@DependencyClient&lt;/span&gt;
&lt;span class=&quot;kd&quot;&gt;struct&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;AppClient&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Sendable&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;var&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;createComment&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kd&quot;&gt;@Sendable&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;_&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;input&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Comment&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;CreateInput&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;_&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;token&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Token&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;as&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;ync&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;-&amp;gt;&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Result&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;&amp;lt;&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;Comment&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;CreateOutput&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Error&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;&amp;gt;&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;_&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;_&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;in&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;failure&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;AppClient&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;Error&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;unknown&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
    &lt;span class=&quot;c1&quot;&gt;// ... one var for each endpoint&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;p&gt;A notable difference from many projects is that AppClient is stateless – it does not hold onto the user’s bearer token or server environment (i.e. localhost or the server URL). Each &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Store&lt;/code&gt; is responsible for accepting the token as a dependency.&lt;/p&gt;

&lt;p&gt;The live client is set up like this, with the static &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;fetch&lt;/code&gt; function doing the heavy lifting of making the properly configured request and serializing the response.&lt;/p&gt;

&lt;div class=&quot;language-swift highlighter-rouge&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;kd&quot;&gt;extension&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;AppClient&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;kd&quot;&gt;static&lt;/span&gt; &lt;span class=&quot;kd&quot;&gt;func&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;live&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;-&amp;gt;&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;Self&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
        &lt;span class=&quot;kt&quot;&gt;Self&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
            &lt;span class=&quot;nv&quot;&gt;createComment&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;input&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;token&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;as&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;ync&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;-&amp;gt;&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Result&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;&amp;lt;&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;Comment&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;CreateOutput&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Error&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;&amp;gt;&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;in&lt;/span&gt;
                &lt;span class=&quot;k&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;request&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;CreateCommentRequest&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;input&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;input&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;token&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;token&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;serverEnvironment&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;token&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;serverEnvironment&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
                &lt;span class=&quot;k&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;fetch&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;request&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
            &lt;span class=&quot;p&quot;&gt;},&lt;/span&gt;
            &lt;span class=&quot;c1&quot;&gt;/// ... all endpoints look similar&lt;/span&gt;
        &lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;p&gt;Most &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Request&lt;/code&gt;s require bearer token authorization and conform to the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;AuthorizedInputRequest&lt;/code&gt; protocol.&lt;/p&gt;

&lt;div class=&quot;language-swift highlighter-rouge&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;kd&quot;&gt;protocol&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;InputRequest&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;FetchRequest&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;as&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;sociatedtype&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Input&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Encodable&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;var&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;authorization&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;AppClient&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;Authorization&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;get&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;var&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;path&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;String&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;get&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;var&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;input&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Input&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;get&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;var&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;serverEnvironment&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;ServerEnvironment&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;get&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;

&lt;span class=&quot;kd&quot;&gt;protocol&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;AuthorizedInputRequest&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;InputRequest&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;var&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;token&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Token&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;get&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;p&gt;An example request configuration:&lt;/p&gt;

&lt;div class=&quot;language-swift highlighter-rouge&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;kd&quot;&gt;struct&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;CreateCommentRequest&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;AuthorizedInputRequest&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;kd&quot;&gt;typealias&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Output&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Comment&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;CreateOutput&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;path&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;/rooms/comments/create&quot;&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;input&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Comment&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;CreateInput&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;token&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Token&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;serverEnvironment&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;ServerEnvironment&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;p&gt;Again, there’s still a lot of boilerplate, but I’ve made my peace with it.&lt;/p&gt;

&lt;p&gt;The specific endpoint can be used as a dependency in the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Store&lt;/code&gt;:&lt;/p&gt;

&lt;div class=&quot;language-swift highlighter-rouge&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;kd&quot;&gt;@MainActor&lt;/span&gt; &lt;span class=&quot;kd&quot;&gt;@Observable&lt;/span&gt; &lt;span class=&quot;kd&quot;&gt;final&lt;/span&gt; &lt;span class=&quot;kd&quot;&gt;class&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;RoomStore&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;c1&quot;&gt;// ...&lt;/span&gt;
    
    &lt;span class=&quot;kd&quot;&gt;@ObservationIgnored&lt;/span&gt; &lt;span class=&quot;kd&quot;&gt;@Dependency(\.appClient.createComment)&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;var&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;createComment&lt;/span&gt;

    &lt;span class=&quot;kd&quot;&gt;func&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;createCommentTapped&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;as&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;ync&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
        &lt;span class=&quot;c1&quot;&gt;// ...&lt;/span&gt;
        
        &lt;span class=&quot;n&quot;&gt;state&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;createMessageState&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;mutating&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;input&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Comment&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;CreateInput&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;roomID&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;roomID&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;content&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;state&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;inputText&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;seconds&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;state&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;inputSeconds&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;result&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;createComment&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;input&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;token&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;guard&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;state&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;createMessageState&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;==&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;mutating&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;else&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
        
        &lt;span class=&quot;k&quot;&gt;switch&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;result&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;case&lt;/span&gt; &lt;span class=&quot;kd&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;success&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;output&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;):&lt;/span&gt;
            &lt;span class=&quot;k&quot;&gt;var&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;newState&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;state&lt;/span&gt;
            &lt;span class=&quot;n&quot;&gt;newState&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;comments&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;output&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;comment&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;id&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;output&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;comment&lt;/span&gt;
            &lt;span class=&quot;n&quot;&gt;newState&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;createMessageState&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;idle&lt;/span&gt;
            &lt;span class=&quot;n&quot;&gt;newState&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;inputText&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;&quot;&lt;/span&gt;
            &lt;span class=&quot;n&quot;&gt;state&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;newState&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;case&lt;/span&gt; &lt;span class=&quot;kd&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;failure&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;error&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;):&lt;/span&gt;
            &lt;span class=&quot;n&quot;&gt;state&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;createMessageState&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;mutationFailed&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;init&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;underlyingError&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;error&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;))&lt;/span&gt;
        &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;h3 id=&quot;root-architecture&quot;&gt;Root architecture&lt;/h3&gt;

&lt;p&gt;The &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;RootView&lt;/code&gt; and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;RootStore&lt;/code&gt; pair handles the boot sequence and handling login and logout.&lt;/p&gt;

&lt;div class=&quot;language-swift highlighter-rouge&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;kd&quot;&gt;struct&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;RootView&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;View&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;kd&quot;&gt;@Bindable&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;var&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;store&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;RootStore&lt;/span&gt;

    &lt;span class=&quot;k&quot;&gt;var&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;body&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;some&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;View&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
        &lt;span class=&quot;kt&quot;&gt;ZStack&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
            &lt;span class=&quot;k&quot;&gt;switch&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;store&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;state&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
            &lt;span class=&quot;k&quot;&gt;case&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;initialized&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
                &lt;span class=&quot;kt&quot;&gt;ProgressView&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;
                    &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;onAppear&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;store&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;onAppear&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
            &lt;span class=&quot;k&quot;&gt;case&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;restoringState&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
                &lt;span class=&quot;kt&quot;&gt;ProgressView&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;
            &lt;span class=&quot;k&quot;&gt;case&lt;/span&gt; &lt;span class=&quot;kd&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;signedOut&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;store&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;):&lt;/span&gt;
                &lt;span class=&quot;kt&quot;&gt;AuthenticationView&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;store&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;store&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
            &lt;span class=&quot;k&quot;&gt;case&lt;/span&gt; &lt;span class=&quot;kd&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;signedIn&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;store&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;):&lt;/span&gt;
                &lt;span class=&quot;kt&quot;&gt;NavigationStack&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
                    &lt;span class=&quot;kt&quot;&gt;RoomsDashboardView&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;store&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;store&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
                &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
            &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
        &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;div class=&quot;language-swift highlighter-rouge&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;kd&quot;&gt;@MainActor&lt;/span&gt; &lt;span class=&quot;kd&quot;&gt;@Observable&lt;/span&gt; &lt;span class=&quot;kd&quot;&gt;final&lt;/span&gt; &lt;span class=&quot;kd&quot;&gt;class&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;RootStore&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;kd&quot;&gt;enum&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;State&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Equatable&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;case&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;initialized&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;case&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;restoringState&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;case&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;signedOut&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;AuthenticationStore&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;case&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;signedIn&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;RoomsDashboardStore&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;

    &lt;span class=&quot;kd&quot;&gt;private(set)&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;var&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;state&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;State&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;initialized&lt;/span&gt;

    &lt;span class=&quot;kd&quot;&gt;@ObservationIgnored&lt;/span&gt; &lt;span class=&quot;kd&quot;&gt;@Dependency(\.tokenLocalStorageClient)&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;var&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;tokenLocalStorageClient&lt;/span&gt;

    &lt;span class=&quot;kd&quot;&gt;func&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;onAppear&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
        &lt;span class=&quot;nf&quot;&gt;restoreState&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;

    &lt;span class=&quot;kd&quot;&gt;private&lt;/span&gt; &lt;span class=&quot;kd&quot;&gt;func&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;restoreState&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;guard&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;case&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;initialized&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;state&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;else&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;assertionFailure&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;unexpected state&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;state&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;restoringState&lt;/span&gt;

        &lt;span class=&quot;k&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;token&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;tokenLocalStorageClient&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;readToken&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;

        &lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;token&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
            &lt;span class=&quot;nf&quot;&gt;transitionToDashboard&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;token&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;token&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
        &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;else&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
            &lt;span class=&quot;nf&quot;&gt;transitionToSignIn&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;
        &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
    
    &lt;span class=&quot;c1&quot;&gt;// ...&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;p&gt;The authenticated vs. unauthenticated domains of the app are fully isolated.&lt;/p&gt;

&lt;h3 id=&quot;room-view&quot;&gt;Room View&lt;/h3&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/technicolor-beta-room-interface.png&quot; width=&quot;600&quot; height=&quot;&quot; alt=&quot;Room interface showing timestamped comments grouped by video timeline position&quot; title=&quot;Room interface showing timestamped comments grouped by video timeline position&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Room interface showing timestamped comments grouped by video timeline position&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;The &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;RoomView&lt;/code&gt; – the async chat room for a TV show episode or movie – is the most complex screen in the app.&lt;/p&gt;

&lt;p&gt;Its usage story is not quite the same as a prototypical chat room. And through my own usage I’m still trying to optimize the micro-decisions in the UX. Should the keyboard dismiss after the user sends a message? When should the main content auto-scroll?&lt;/p&gt;

&lt;p&gt;I store the comments in a dictionary and group and sort them live on changes. This makes handling mutations more straightforward (the current user adds new comment, other user adds new comment, a comment is edited, a comment is deleted, etc.).&lt;/p&gt;

&lt;div class=&quot;language-swift highlighter-rouge&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;kd&quot;&gt;struct&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;State&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Equatable&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;c1&quot;&gt;// ...&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;var&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;comments&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;UUID&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Comment&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;Full&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt;
    &lt;span class=&quot;c1&quot;&gt;// ...&lt;/span&gt;
    
    &lt;span class=&quot;k&quot;&gt;var&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;timestamps&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;RoomViewModel&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;Timestamp&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;secondsGrouped&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Dictionary&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;grouping&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;comments&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;values&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;by&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;$0&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;seconds&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;})&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;timestamps&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;secondsGrouped&lt;/span&gt;
            &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;sorted&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;by&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;$0&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;key&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;&amp;lt;&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;$1&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;key&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;})&lt;/span&gt;
            &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;map&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;group&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;in&lt;/span&gt;
                &lt;span class=&quot;kt&quot;&gt;RoomViewModel&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;Timestamp&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
                    &lt;span class=&quot;nv&quot;&gt;title&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;group&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;key&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;timestamp&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
                    &lt;span class=&quot;nv&quot;&gt;seconds&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;group&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;key&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
                    &lt;span class=&quot;nv&quot;&gt;comments&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;group&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;value&lt;/span&gt;
                        &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;sorted&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;by&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;$0&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;createdAt&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;&amp;lt;&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;$1&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;createdAt&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;})&lt;/span&gt;
                        &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;map&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;comment&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;in&lt;/span&gt;
                            &lt;span class=&quot;kt&quot;&gt;RoomViewModel&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;Comment&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
                                &lt;span class=&quot;nv&quot;&gt;id&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;comment&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;id&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
                                &lt;span class=&quot;nv&quot;&gt;content&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;comment&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;content&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
                                &lt;span class=&quot;nv&quot;&gt;username&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;comment&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;user&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;username&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
                                &lt;span class=&quot;nv&quot;&gt;relativeCreatedAt&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;Self&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;relativeDateFormatter&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;string&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;for&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;comment&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;updatedAt&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;??&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;??? ago&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
                                &lt;span class=&quot;nv&quot;&gt;color&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;comment&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;user&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;identityColor&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
                                &lt;span class=&quot;nv&quot;&gt;belongsToCurrentUser&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;comment&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;user&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;id&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;==&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;meID&lt;/span&gt;
                            &lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
                        &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
                &lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
            &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;timestamps&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;p&gt;Read-only data fetching state in the app is handled by the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;DataState&lt;/code&gt; struct. Mutation state is handled by the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;FallibleMutationState&lt;/code&gt; struct. I intentionally do not store the actual data in this struct (but I do store the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Error&lt;/code&gt;).&lt;/p&gt;

&lt;div class=&quot;language-swift highlighter-rouge&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;kd&quot;&gt;enum&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;DataState&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Equatable&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;case&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;initialized&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;case&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;loading&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;case&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;loaded&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;case&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;reloading&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;case&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;loadingFailed&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;StoreError&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;

&lt;span class=&quot;kd&quot;&gt;enum&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;FallibleMutationState&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Equatable&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;case&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;idle&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;case&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;mutating&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;case&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;mutationFailed&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;StoreError&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;RoomStore.State&lt;/code&gt; handles the following read-only data and mutation data states:&lt;/p&gt;

&lt;div class=&quot;language-swift highlighter-rouge&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;kd&quot;&gt;struct&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;State&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Equatable&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;var&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;dataState&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;DataState&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;var&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;createMessageState&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;FallibleMutationState&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;var&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;editCommentState&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;FallibleMutationState&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;var&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;deleteMessageState&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;FallibleMutationState&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;var&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;updateWatchedState&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;FallibleMutationState&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;var&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;leaveRoomState&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;FallibleMutationState&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;var&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;deleteRoomState&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;FallibleMutationState&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;var&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;isShowingLeaveRoomConfirmation&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Bool&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;var&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;isShowingDeleteRoomConfirmation&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Bool&lt;/span&gt;
    
    &lt;span class=&quot;c1&quot;&gt;// ...&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;p&gt;The available mutations and confirmations start to add up quickly. For this particular View, it might actually be worth it to abstract the current mutation state into its own &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Enum&lt;/code&gt; so that I can more simply enforce only one mutation is happening at a time.&lt;/p&gt;

&lt;h3 id=&quot;dashboard&quot;&gt;Dashboard&lt;/h3&gt;

&lt;p&gt;In my initial designs of Technicolor, the dashboard was a simple list of all Rooms ordered by creation date. There was a lot of burden on each user to keep track of which episodes they’ve watched, periodically check which episodes their friends have watched, and periodically check for replies.&lt;/p&gt;

&lt;p&gt;In this latest iteration of Technicolor, I used some additional state like “Mark as Watched” and some complex SQL queries to make watchlists easier to manage, especially for power users (like me) who are watching several shows with several groups of friends over potentially multiple weeks.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/technicolor-beta-dashboard.png&quot; width=&quot;600&quot; height=&quot;&quot; alt=&quot;Dashboard screen organizing active rooms by TV show and member groups&quot; title=&quot;Dashboard screen organizing active rooms by TV show and member groups&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Dashboard screen organizing active rooms by TV show and member groups&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;The dashboard now groups sections with the following rules:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;TV shows by members&lt;/strong&gt;: if you’re watching the same show with multiple groups, these Rooms will be grouped separately&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;All movies by members&lt;/strong&gt;: if you have a “weekly movie night” with the same set of friends, all those movie Rooms will be grouped into the same section.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;By default, Rooms that are unwatched by at least one member will be included on the dashboard. Once all members have finished watching, the Room will appear for the next 7 days to allow time for further discussion.&lt;/p&gt;

&lt;p&gt;Each scenario has its own archive screen so you can always access past Rooms. There’s also a comprehensive archive screen that ensures you can even find groups that have been archived.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/technicolor-archive-screens-combined.png&quot; width=&quot;1200&quot; height=&quot;&quot; alt=&quot;Various archive screens so all rooms are discoverable&quot; title=&quot;Various archive screens so all rooms are discoverable&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Various archive screens so all rooms are discoverable&lt;/div&gt;&lt;/div&gt;

&lt;h3 id=&quot;timestamp-control&quot;&gt;Timestamp control&lt;/h3&gt;

&lt;p&gt;One of my pet projects within this Technicolor was a custom timestamp adjustment control. It’s still a work in progress but it’s been fun to iterate on.&lt;/p&gt;

&lt;p&gt;The goal of the control is to make it easier to adjust the timestamp (e.g. &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;15:34&lt;/code&gt;) of your comment to the timestamp of the running show. That is, easier than typing the timestamp using the iOS software keyboard. On macOS with a hardware keyboard it’s easy enough to type &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;15:43 this is my comment&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The custom timestamp control works by tapping and dragging up and down. During a drag, moving your touch to the right adjusts the fine-tune to get better accuracy.&lt;/p&gt;

&lt;video src=&quot;/images/technicolor-beta-timestamp-control.mov&quot; controls=&quot;&quot; preload=&quot;none&quot; poster=&quot;/images/technicolor-beta-timestamp-control-poster.png&quot; height=&quot;600&quot;&gt;&lt;/video&gt;

&lt;h3 id=&quot;push-notification-support&quot;&gt;Push notification support&lt;/h3&gt;

&lt;p&gt;One of the reasons I was excited to make native clients for Technicolor was push notification support.&lt;/p&gt;

&lt;p&gt;Once a Room member finishes watching an episode, they tap “Mark as Watched”. This not only updates the Room status for the Dashboard, but it also sends a push notification to the other Room members. If the other members have already watched, this push will be a trigger for the user to check out the new comments. If the other members haven’t watched yet, this push is a good reminder they should watch the episode soon.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/technicolor-push-notifications.jpg&quot; width=&quot;600&quot; height=&quot;&quot; alt=&quot;Various push notifications&quot; title=&quot;Various push notifications&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Various push notifications&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;There are also quality-of-life pushes for when a user accepts your invite and joins Technicolor, when you receive a new friend request, and when a user accepts your friend request.&lt;/p&gt;

&lt;p&gt;There’s still one missing flow: I’d like to add a timeout triggered push notification that sends in the situation that:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;User A has already finished watching an episode&lt;/li&gt;
  &lt;li&gt;User A replies to comments after User B finishes watching&lt;/li&gt;
  &lt;li&gt;30 minutes have passed since the last comment from User A&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This would ensure User B sees the replies from User A, but doesn’t need to get an individual push notification for each comment.&lt;/p&gt;

&lt;h3 id=&quot;deployment-script&quot;&gt;Deployment script&lt;/h3&gt;

&lt;p&gt;Working on indie projects by myself, I’ve generally found it fast enough to do all my deployment to App Store Connect manually (but using a checklist).&lt;/p&gt;

&lt;p&gt;However, for Technicolor I need to build and upload both an iOS and macOS version. This was just enough tedium that I decided to vibe code a deployment bash script that handles the minutiae of deployment.&lt;/p&gt;

&lt;p&gt;It took a very long day of debugging certificate and provisioning profile issues, but eventually I got the script working. This has made it marginally easier to ship new builds to my TestFlight beta testers. The 24-48 hour turnaround of TestFlight App Review is still a drag though.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/technicolor-deployment-script-output.png&quot; width=&quot;&quot; height=&quot;800&quot; alt=&quot;Release automation script output showing the complete deployment workflow with environment validation, version management, build processes, App Store Connect uploads, and GitHub operations&quot; title=&quot;Release automation script output showing the complete deployment workflow with environment validation, version management, build processes, App Store Connect uploads, and GitHub operations&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Release automation script output showing the complete deployment workflow with environment validation, version management, build processes, App Store Connect uploads, and GitHub operations&lt;/div&gt;&lt;/div&gt;

&lt;h2 id=&quot;lessons-learned&quot;&gt;Lessons learned&lt;/h2&gt;

&lt;h4 id=&quot;complexity-of-social-networks&quot;&gt;Complexity of social networks&lt;/h4&gt;

&lt;p&gt;There’s a lot of essential complexity in social networks and chat apps. As a mobile dev that usually works on the client side, it was great experience learning how to manage things like authentication, schema design for social network relationships, and server deployment.&lt;/p&gt;

&lt;h4 id=&quot;standardized-architecture&quot;&gt;Standardized architecture&lt;/h4&gt;

&lt;p&gt;Especially for CRUD apps, it’s incredibly important to find an architecture that makes each feature as templated and boring as possible. Predictable and well-documented features enable coding agents to accelerate development of the features that are necessary but forgettable by users and allow you to focus on the features that make your app unique.&lt;/p&gt;

&lt;h4 id=&quot;choice-of-web-framework&quot;&gt;Choice of web framework&lt;/h4&gt;

&lt;p&gt;It’s hard for me to recommend Swift on the server as a pragmatic choice for a production web service. All of its strengths don’t really make up for how far behind it is in the broader web framework ecosystem. Maybe in another several years if the Swift language has stabilized and the Swift community outside app development grows.&lt;/p&gt;

&lt;h4 id=&quot;long-running-side-projects-using-volatile-technology-stacks&quot;&gt;Long-running side projects using volatile technology stacks&lt;/h4&gt;

&lt;p&gt;I’m glad I finally found 2-3 uninterrupted weeks that I could use to get this project modernized and in a shippable state. As a side project, having a weekend available here and there usually meant that I could only tackle one small feature at a time (I remember spending a coveted 4-day weekend chipping away at getting the first deployment set up). Too much effort was burned rewriting due to language and framework and hosting churn. However, having this project did serve as a useful test bed for experimenting with and immersing myself in new ideas before introducing them into production projects at my day job.&lt;/p&gt;

&lt;h4 id=&quot;qa-as-the-development-bottleneck&quot;&gt;QA as the development bottleneck&lt;/h4&gt;

&lt;p&gt;In this recent sprint, I found QA to be the bottleneck for feature development. Coding agents have significantly compressed the overall time and effort required for the system design and code writing parts, but to &lt;em&gt;actually verify your feature does what it’s supposed to&lt;/em&gt; still means setting up an environment to experience the feature exactly as the user will experience it. That means a feature that only requires booting up the simulator and tapping a button to verify correctness will take 10 or 50 or 100 times less time to validate than installing a build on multiple devices, logging in with different test users, tapping through several screens in a specific order, and verifying the delivery and contents of a push notification. When estimating scope for a feature I want, I now consider the QA burden much more seriously than the one-shot implementation time.&lt;/p&gt;

&lt;h2 id=&quot;conclusion&quot;&gt;Conclusion&lt;/h2&gt;

&lt;p&gt;Even after over a decade of intermittent development, Technicolor is still in its early stages. I hope this post gave you some insight into what its been like as a solo dev working on a full-stack Swift project.&lt;/p&gt;

&lt;p&gt;If you’re considering or working on something similar, or you want more detail on anything I’ve written about in this post, feel free to reach out on &lt;a href=&quot;https://hachyderm.io/@twocentstudios&quot;&gt;Mastodon&lt;/a&gt; or &lt;a href=&quot;https://twitter.com/twocentstudios&quot;&gt;Twitter&lt;/a&gt; or &lt;a href=&quot;mailto:chris@twocentstudios.com&quot;&gt;email&lt;/a&gt;. As of this posting I’m also available for consulting work.&lt;/p&gt;

</description>
        <pubDate>Mon, 04 Aug 2025 07:04:00 -0500</pubDate>
        <link>https://twocentstudios.com/2025/08/04/full-stack-swift-technicolor-technical-architecture/</link>
        <guid isPermaLink="true">https://twocentstudios.com/2025/08/04/full-stack-swift-technicolor-technical-architecture/</guid>
        
        <category>apple</category>
        
        <category>ios</category>
        
        <category>swiftui</category>
        
        <category>vapor</category>
        
        <category>technicolor</category>
        
        
      </item>
    
  </channel>
</rss>
