<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Todd Sullivan</title>
    <description>The latest articles on DEV Community by Todd Sullivan (@toddsullivan).</description>
    <link>https://dev.to/toddsullivan</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3895637%2Fc957b85c-53f5-4505-8b53-7e62e06088e9.jpeg</url>
      <title>DEV Community: Todd Sullivan</title>
      <link>https://dev.to/toddsullivan</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/toddsullivan"/>
    <language>en</language>
    <item>
      <title>The iOS 26 Deep-Link Bug That Only Happened When the App Was Already Open</title>
      <dc:creator>Todd Sullivan</dc:creator>
      <pubDate>Tue, 02 Jun 2026 08:04:23 +0000</pubDate>
      <link>https://dev.to/toddsullivan/the-ios-26-deep-link-bug-that-only-happened-when-the-app-was-already-open-g01</link>
      <guid>https://dev.to/toddsullivan/the-ios-26-deep-link-bug-that-only-happened-when-the-app-was-already-open-g01</guid>
      <description>&lt;p&gt;I spent a chunk of this week chasing a bug that looked like auth, then routing, then Supabase, then Expo Router.&lt;/p&gt;

&lt;p&gt;Actual root cause: iOS 26 was receiving the magic link, but the running app never got the warm-start URL in JavaScript.&lt;/p&gt;

&lt;p&gt;Cold start worked:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;app not running&lt;/li&gt;
&lt;li&gt;tap &lt;code&gt;myapp://auth/callback?code=...&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;iOS launches the app&lt;/li&gt;
&lt;li&gt;Expo Router mounts &lt;code&gt;auth/callback&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;PKCE code exchange succeeds&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Warm start failed:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;app already open&lt;/li&gt;
&lt;li&gt;tap the same magic link&lt;/li&gt;
&lt;li&gt;app comes foreground&lt;/li&gt;
&lt;li&gt;no JS &lt;code&gt;Linking&lt;/code&gt; event&lt;/li&gt;
&lt;li&gt;user is still logged out&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;No crash. No useful exception. Just a login flow that works from killed state and fails from running state, which is exactly the kind of mobile bug that eats a day.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Native Part: SceneDelegate Still Matters
&lt;/h2&gt;

&lt;p&gt;On iOS 26, warm-start URLs are delivered through the scene lifecycle:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;scene&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="nv"&gt;scene&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;UIScene&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;openURLContexts&lt;/span&gt; &lt;span class="kt"&gt;URLContexts&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Set&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;UIOpenURLContext&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="kt"&gt;URLContexts&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;forwardURL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Expo app already had an &lt;code&gt;AppDelegate&lt;/code&gt;, but warm-start delivery needed a &lt;code&gt;UIWindowSceneDelegate&lt;/code&gt; as well. The slightly non-obvious bit was how to forward the URL.&lt;/p&gt;

&lt;p&gt;Calling &lt;code&gt;RCTLinkingManager&lt;/code&gt; directly looked reasonable, but it only posts the React Native URL notification. &lt;code&gt;expo-linking&lt;/code&gt; was not listening to that path in this app. Routing through &lt;code&gt;AppDelegate.application(_:open:options:)&lt;/code&gt; let &lt;code&gt;ExpoAppDelegate&lt;/code&gt; forward the URL to all of its subscribers, including Expo's linking module:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;forwardURL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="nv"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;app&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;UIApplication&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;shared&lt;/span&gt;
  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;appDelegate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;delegate&lt;/span&gt; &lt;span class="k"&gt;as?&lt;/span&gt; &lt;span class="kt"&gt;AppDelegate&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;appDelegate&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;application&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;open&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;options&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[:])&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kt"&gt;RCTLinkingManager&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;application&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;open&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;options&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[:])&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That got the URL into JS.&lt;/p&gt;

&lt;p&gt;Then iOS 26 added one more trap.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Key Window Trap
&lt;/h2&gt;

&lt;p&gt;The system log had the clue: &lt;code&gt;key window is null&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The app creates its &lt;code&gt;UIWindow&lt;/code&gt; in &lt;code&gt;didFinishLaunchingWithOptions&lt;/code&gt;, before the &lt;code&gt;UIWindowScene&lt;/code&gt; exists. &lt;code&gt;startReactNative&lt;/code&gt; calls &lt;code&gt;makeKeyAndVisible()&lt;/code&gt;, but at that point the window is not attached to a scene yet.&lt;/p&gt;

&lt;p&gt;On iOS 26, that meant &lt;code&gt;scene:openURLContexts:&lt;/code&gt; never fired for warm-start links.&lt;/p&gt;

&lt;p&gt;The fix was to associate the existing window with the scene and call &lt;code&gt;makeKeyAndVisible()&lt;/code&gt; again after &lt;code&gt;windowScene&lt;/code&gt; is set:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;scene&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="nv"&gt;scene&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;UIScene&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;willConnectTo&lt;/span&gt; &lt;span class="nv"&gt;session&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;UISceneSession&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;options&lt;/span&gt; &lt;span class="nv"&gt;connectionOptions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;UIScene&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="kt"&gt;ConnectionOptions&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;guard&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;windowScene&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;scene&lt;/span&gt; &lt;span class="k"&gt;as?&lt;/span&gt; &lt;span class="kt"&gt;UIWindowScene&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;appDelegate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;UIApplication&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;shared&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;delegate&lt;/span&gt; &lt;span class="k"&gt;as?&lt;/span&gt; &lt;span class="kt"&gt;AppDelegate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
     &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;window&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;appDelegate&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;window&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;window&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;windowScene&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;windowScene&lt;/span&gt;
    &lt;span class="n"&gt;window&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;makeKeyAndVisible&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After that, &lt;code&gt;scene:openURLContexts:&lt;/code&gt; fired reliably. Verified on the iOS 26 simulator: native SceneDelegate log, AppDelegate openURL log, then the JS linking handler.&lt;/p&gt;

&lt;h2&gt;
  
  
  The JS Part: Don't Route During Foreground Animation
&lt;/h2&gt;

&lt;p&gt;Once the URL reached JS, the first instinct was to navigate to &lt;code&gt;/auth/callback&lt;/code&gt; and let that screen do the exchange.&lt;/p&gt;

&lt;p&gt;That was fragile for two reasons.&lt;/p&gt;

&lt;p&gt;First, during warm start the app is still moving through &lt;code&gt;UISceneActivationStateForegroundInactive&lt;/code&gt;. I saw the URL arrive at &lt;code&gt;10:56:09.749&lt;/code&gt;; foreground transition finished at &lt;code&gt;10:56:10.140&lt;/code&gt;. Navigation commands during that window can be silently dropped.&lt;/p&gt;

&lt;p&gt;Second, &lt;code&gt;auth/callback&lt;/code&gt; may already be mounted. A &lt;code&gt;useEffect([])&lt;/code&gt; on that screen will not re-run just because another link arrived.&lt;/p&gt;

&lt;p&gt;So I moved warm-start exchange into the always-mounted root layout:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;linkSub&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;Linking&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;url&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;parsed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;Linking&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;code&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;parsed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;queryParams&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;code&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;string&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
    &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;parsed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;queryParams&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;code&lt;/span&gt;
    &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;code&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;supabase&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exchangeCodeForSession&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;code&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="k"&gt;catch&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{});&lt;/span&gt;

    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;unsub&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;useAuthStore&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;subscribe&lt;/span&gt;&lt;span class="p"&gt;(({&lt;/span&gt; &lt;span class="nx"&gt;session&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;signingIn&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;session&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;signingIn&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nf"&gt;unsub&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="nx"&gt;router&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;

    &lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;unsub&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The important part is the split:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;native code guarantees warm-start URLs reach Expo linking&lt;/li&gt;
&lt;li&gt;root layout handles every warm-start URL event&lt;/li&gt;
&lt;li&gt;auth callback remains as the cold-start/race fallback&lt;/li&gt;
&lt;li&gt;navigation waits until the auth store has a settled session&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The Takeaway
&lt;/h2&gt;

&lt;p&gt;Deep links are not one flow. They are at least two:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;cold start: launch app into a URL&lt;/li&gt;
&lt;li&gt;warm start: deliver URL into an already-running app&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you only test the cold path, your auth flow can look perfect while the common real-world case is broken.&lt;/p&gt;

&lt;p&gt;For Expo + React Native apps on newer iOS versions, I now treat warm-start deep links as their own integration test: app open, link tapped, native URL delivery observed, JS &lt;code&gt;Linking&lt;/code&gt; event observed, auth state settled, navigation after state settle.&lt;/p&gt;

&lt;p&gt;That is more ceremony than a one-line &lt;code&gt;Linking.addEventListener&lt;/code&gt;, but it is the difference between a demo login flow and a production one.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Source:&lt;/strong&gt; Recent Expo / React Native auth work: iOS 26 warm-start deep-link fix, SceneDelegate URL forwarding, PKCE exchange moved to root layout.&lt;br&gt;
&lt;strong&gt;Tags:&lt;/strong&gt; ios, reactnative, devops, javascript&lt;/p&gt;

</description>
      <category>ios</category>
      <category>reactnative</category>
      <category>devops</category>
      <category>javascript</category>
    </item>
    <item>
      <title>The Silent Code Path: When Your AI Runs on Camera But Not on Gallery</title>
      <dc:creator>Todd Sullivan</dc:creator>
      <pubDate>Fri, 29 May 2026 08:17:16 +0000</pubDate>
      <link>https://dev.to/toddsullivan/the-silent-code-path-when-your-ai-runs-on-camera-but-not-on-gallery-21dn</link>
      <guid>https://dev.to/toddsullivan/the-silent-code-path-when-your-ai-runs-on-camera-but-not-on-gallery-21dn</guid>
      <description>&lt;p&gt;Here's a bug that's easy to miss and harder to debug: your AI runs perfectly on one input path, silently does nothing on another, and there's no error — just missing results.&lt;/p&gt;

&lt;p&gt;I hit this recently in an on-device inspection app. The flow is straightforward: capture a photo (camera or photo library), run AI hazard detection, overlay bounding boxes, trigger violation alerts if needed.&lt;/p&gt;

&lt;p&gt;Camera captures worked great. Gallery picks saved the photo fine. But no bounding boxes appeared, no alerts fired. The AI was just... not running.&lt;/p&gt;

&lt;h3&gt;
  
  
  What Actually Happened
&lt;/h3&gt;

&lt;p&gt;Here's the stripped-down structure:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Camera capture handler&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;handleRequestCapture&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;uri&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;savePhoto&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;uri&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;detectAndSave&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;uri&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// ✅ AI runs&lt;/span&gt;
  &lt;span class="nf"&gt;checkViolationAlerts&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="c1"&gt;// Library pick handler  &lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;handleLibraryPick&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;uri&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;savePhoto&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;uri&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="c1"&gt;// ❌ detectAndSave was never called&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The library path was added later, modelled on the save logic but not the full inference pipeline. No crash. No warning. Just missing detections.&lt;/p&gt;

&lt;p&gt;The fix was six lines — copy the &lt;code&gt;detectAndSave&lt;/code&gt; + alert block into the library handler. But finding it took longer than writing the fix.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why This Pattern Is Easy To Ship
&lt;/h3&gt;

&lt;p&gt;On-device AI inference tends to get wired in during the happy path. You build the camera flow first, the AI gets integrated there, everything works in demos. Then you add the gallery pick as a "nice to have" — and because you're only thinking about the file I/O, you copy the save logic but not the inference call.&lt;/p&gt;

&lt;p&gt;There's no type error. No lint warning. The function name (&lt;code&gt;handleLibraryPick&lt;/code&gt;) doesn't imply inference should happen. The photo saves successfully, so from the app's perspective, nothing broke.&lt;/p&gt;

&lt;h3&gt;
  
  
  Lessons From The Fix
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;1. Treat every input path as a first-class citizen.&lt;/strong&gt;&lt;br&gt;
If AI inference is core to your feature, it should run regardless of how the image arrived. Camera, gallery, deep link, background upload — all of them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Extract inference into a shared pipeline.&lt;/strong&gt;&lt;br&gt;
After the fix I refactored toward a single &lt;code&gt;processPhoto(uri)&lt;/code&gt; function that both handlers call. Now if the pipeline changes, it changes in one place.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;processPhoto&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;uri&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;savePhoto&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;uri&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;detectAndSave&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;uri&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nf"&gt;checkViolationAlerts&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;handleRequestCapture&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;uri&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;processPhoto&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;uri&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;handleLibraryPick&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;uri&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;processPhoto&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;uri&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;3. End-to-end tests that cover input variants.&lt;/strong&gt;&lt;br&gt;
Unit tests on &lt;code&gt;detectAndSave&lt;/code&gt; wouldn't have caught this — the function worked fine, it just wasn't being called. What you need is an integration test that exercises each entry point and asserts that inference results exist.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Visibility into what ran.&lt;/strong&gt;&lt;br&gt;
This is the sneaky part: when AI silently doesn't run, you need observability to know it happened. Adding a log line (&lt;code&gt;AI inference: skipped | ran on &amp;lt;uri&amp;gt;&lt;/code&gt;) to your inference call sites makes this class of bug immediately obvious in development.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Broader Pattern
&lt;/h3&gt;

&lt;p&gt;This isn't unique to AI. Any side-effect that only gets wired to one code path — analytics events, permission checks, cache invalidation — can silently miss inputs added later. But with AI inference it's particularly painful because the failure mode is invisible and the debugging surface is narrow (you're looking at model output, not a thrown error).&lt;/p&gt;

&lt;p&gt;Build the pipeline once, call it everywhere.&lt;/p&gt;

</description>
      <category>mobile</category>
      <category>ai</category>
      <category>reactnative</category>
      <category>debugging</category>
    </item>
    <item>
      <title>Wrapping Apple's LiDAR Room Scanner as a Native Expo Module</title>
      <dc:creator>Todd Sullivan</dc:creator>
      <pubDate>Wed, 27 May 2026 08:08:52 +0000</pubDate>
      <link>https://dev.to/toddsullivan/wrapping-apples-lidar-room-scanner-as-a-native-expo-module-cip</link>
      <guid>https://dev.to/toddsullivan/wrapping-apples-lidar-room-scanner-as-a-native-expo-module-cip</guid>
      <description>&lt;p&gt;Most property and field-service apps still ask users to manually enter room dimensions. Tape measure, pen, back to the app, mistype, repeat. It's tedious and it's lossy.&lt;/p&gt;

&lt;p&gt;I've been building a property inspection app and this week I shipped a feature that replaces manual floor-area entry with a LiDAR scan. Walk around a room for 30 seconds, hit Done — the app returns floor area, wall count, door count, window dimensions, and a confidence score for each surface. No internet connection required. No server round-trips. All on-device.&lt;/p&gt;

&lt;p&gt;Here's how I wired Apple's RoomPlan framework into a React Native / Expo app as a native module.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Basic Idea
&lt;/h2&gt;

&lt;p&gt;Apple's &lt;code&gt;RoomPlan&lt;/code&gt; framework uses the LiDAR sensor on iPhone 12 Pro and later to build a structured 3D model of a room in real time. It gives you back typed objects: floors, walls, doors, windows — each with dimensions and a confidence rating (&lt;code&gt;high&lt;/code&gt;, &lt;code&gt;medium&lt;/code&gt;, &lt;code&gt;low&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;The challenge is surfacing this in a cross-platform Expo app without hacking around the JS/native boundary.&lt;/p&gt;

&lt;h2&gt;
  
  
  Building the Module
&lt;/h2&gt;

&lt;p&gt;Expo's native module API makes this cleaner than the old React Native bridge approach. The module exposes two functions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;isAvailable()&lt;/code&gt; — synchronous check, returns &lt;code&gt;true&lt;/code&gt; on LiDAR + iOS 17+&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;scanRoom()&lt;/code&gt; — async, presents a full-screen capture UI and resolves with structured results
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="kt"&gt;AsyncFunction&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"scanRoom"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;promise&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt;
  &lt;span class="k"&gt;guard&lt;/span&gt; &lt;span class="kt"&gt;RoomCaptureSession&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;isSupported&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;promise&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reject&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"UNSUPPORTED"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"Requires a LiDAR-equipped device"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="c1"&gt;// present RoomCaptureViewController...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The view controller runs &lt;code&gt;captureSession.run(configuration:)&lt;/code&gt;, delegates back through &lt;code&gt;RoomCaptureSessionDelegate&lt;/code&gt;, and when the user hits Done it hands raw &lt;code&gt;CapturedRoomData&lt;/code&gt; to &lt;code&gt;RoomBuilder&lt;/code&gt;. The builder does a cleanup pass (with &lt;code&gt;.beautifyObjects&lt;/code&gt;) and returns a &lt;code&gt;CapturedRoom&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Comes Back
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;floorArea&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;room&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;floors&lt;/span&gt;
  &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;map&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="kt"&gt;Double&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$0&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;dimensions&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="kt"&gt;Double&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$0&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;dimensions&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;y&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reduce&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I map the result into a plain dictionary — area in m², per-surface dimensions, confidence string — and resolve the Expo promise. The JS side gets a typed object:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;RoomPlan&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;scanRoom&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;floorAreaM2&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// e.g. 18.34&lt;/span&gt;
&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;floors&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;confidence&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// "high"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Graceful Degradation
&lt;/h2&gt;

&lt;p&gt;Non-LiDAR devices get &lt;code&gt;isAvailable()&lt;/code&gt; → &lt;code&gt;false&lt;/code&gt; and fall through to a manual input form. No crashes, no confusing errors. The LiDAR path is an enhancement, not a hard dependency.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why On-Device Matters Here
&lt;/h2&gt;

&lt;p&gt;For property data, sending floor plans to a cloud service introduces latency, cost, and privacy concerns. Every scan happening fully on-device means no API bills per-scan, no waiting on a round-trip, and no floor plan leaving the phone until the user explicitly submits their report.&lt;/p&gt;

&lt;p&gt;The practical result: floor area capture went from ~3 minutes (manual) to under 60 seconds with better accuracy than tape-measure entry. On iPhone Pro hardware, LiDAR readings are accurate to within a few centimetres on a normal rectangular room.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Limitation Worth Knowing
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;RoomPlan&lt;/code&gt; requires a LiDAR sensor — iPhone 12 Pro and later, or iPad Pro. Most fieldwork happens on phones. For a consumer app targeting everyone, you need a fallback. For a professional tool where you control (or specify) the hardware, worth requiring it outright.&lt;/p&gt;




&lt;p&gt;If you're building anything involving physical space measurement on iOS, RoomPlan is dramatically underused. Apple ships a full structured-capture pipeline and most apps still ask users to type numbers into a form.&lt;/p&gt;

</description>
      <category>ios</category>
      <category>expo</category>
      <category>javascript</category>
      <category>ai</category>
    </item>
    <item>
      <title>When Your On-Device Model Decides How Often to Ping You</title>
      <dc:creator>Todd Sullivan</dc:creator>
      <pubDate>Mon, 25 May 2026 08:03:36 +0000</pubDate>
      <link>https://dev.to/toddsullivan/when-your-on-device-model-decides-how-often-to-ping-you-10h1</link>
      <guid>https://dev.to/toddsullivan/when-your-on-device-model-decides-how-often-to-ping-you-10h1</guid>
      <description>&lt;p&gt;Most notification systems are dumb. They fire on a fixed schedule decided by a developer who's never met the user. Every day at 9am. No matter what.&lt;/p&gt;

&lt;p&gt;I've been building an iOS health app that does something different: the on-device ML model's training state drives notification frequency. The more the model knows about you, the less it needs to ask.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Problem With Fixed Schedules
&lt;/h2&gt;

&lt;p&gt;For a health tracking app, consistent data collection is everything — it's the training set. Early on, you need daily check-ins to bootstrap the model. But once the model is trained and the user has established patterns, daily prompts become noise. People turn off notifications. You lose signal entirely.&lt;/p&gt;

&lt;p&gt;The naive fix is "let users choose their own frequency." That works until users pick "weekly" in week one when the model is still blind.&lt;/p&gt;

&lt;p&gt;The better fix: make the frequency a function of the data itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Logic
&lt;/h2&gt;

&lt;p&gt;I built a &lt;code&gt;NotificationFrequencyManager&lt;/code&gt; that recomputes check-in frequency after every engagement cycle and after every model training run. Three signals go in:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Data volume&lt;/strong&gt; — how many days of logs exist&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Logging consistency&lt;/strong&gt; — what percentage of the last 14 days has at least one entry (≥70% threshold to step down)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;ML training state&lt;/strong&gt; — is the personalised model untrained, training, or trained?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The output maps to three frequencies:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="kd"&gt;enum&lt;/span&gt; &lt;span class="kt"&gt;NotificationFrequency&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;Codable&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;Comparable&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="n"&gt;high&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;  &lt;span class="c1"&gt;// Daily — new users, untrained model&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="n"&gt;medium&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;  &lt;span class="c1"&gt;// Every 2 days — moderate data, some consistency&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="n"&gt;low&lt;/span&gt;    &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;  &lt;span class="c1"&gt;// Every 4 days — trained model, high consistency&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There's a hard floor at 4 days. Even the most consistent user with a fully trained model gets nudged at least every 4 days. The model still needs new data to stay fresh. Silence would mean drift.&lt;/p&gt;

&lt;h2&gt;
  
  
  Closing the Loop
&lt;/h2&gt;

&lt;p&gt;The clever part is that ML training completion &lt;em&gt;directly triggers&lt;/em&gt; a frequency recalculation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;logs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kt"&gt;DailyLog&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="nv"&gt;trainingState&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;TrainingState&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="kt"&gt;NotificationFrequency&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;frequency&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;compute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;logs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;logs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;trainingState&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;trainingState&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;currentFrequency&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;frequency&lt;/span&gt;
    &lt;span class="n"&gt;storedFrequency&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;frequency&lt;/span&gt;  &lt;span class="c1"&gt;// persisted to App Group UserDefaults&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;frequency&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So the flow is: user logs data → engagement manager runs → model retrains → frequency updates → next notification scheduled at new interval. The app literally quiets down as it learns.&lt;/p&gt;

&lt;h2&gt;
  
  
  Smooth Transitions
&lt;/h2&gt;

&lt;p&gt;One thing I got wrong in early iterations: jumping straight from daily to every-4-days felt jarring in testing. The fix was requiring &lt;em&gt;both&lt;/em&gt; consistency &lt;em&gt;and&lt;/em&gt; data volume thresholds before stepping down. You can't hit &lt;code&gt;low&lt;/code&gt; unless you've been on &lt;code&gt;medium&lt;/code&gt; for at least a week.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Only step from medium → low if model is trained AND consistency is high&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;dataVolume&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;consistency&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.7&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;trainingState&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;trained&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;low&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Why This Matters
&lt;/h2&gt;

&lt;p&gt;The insight generalises well beyond health apps. Any time you have an adaptive ML component, the model's confidence or training state is a signal you can use to drive other app behaviour. Frequency of prompts. Depth of UI. Whether to show explanations or trust the user already gets it.&lt;/p&gt;

&lt;p&gt;The model knowing you means the app behaves differently for you. That's actually what "personalised AI" should mean — not just personalised outputs, but personalised interactions.&lt;/p&gt;

</description>
      <category>ios</category>
      <category>swift</category>
      <category>machinelearning</category>
      <category>mobiledev</category>
    </item>
    <item>
      <title>Training a Personalised ML Model On-Device with CreateMLComponents</title>
      <dc:creator>Todd Sullivan</dc:creator>
      <pubDate>Fri, 22 May 2026 08:02:20 +0000</pubDate>
      <link>https://dev.to/toddsullivan/training-a-personalised-ml-model-on-device-with-createmlcomponents-4be3</link>
      <guid>https://dev.to/toddsullivan/training-a-personalised-ml-model-on-device-with-createmlcomponents-4be3</guid>
      <description>&lt;p&gt;Most on-device AI content focuses on &lt;em&gt;inference&lt;/em&gt; — you ship a pre-trained model in your app bundle and run it locally. That's well-covered ground. What's less talked about is training a personalised model &lt;em&gt;on the device&lt;/em&gt;, from the user's own data, without any server involvement.&lt;/p&gt;

&lt;p&gt;I built exactly that recently — a health tracking app that trains a flare risk predictor from each user's biometric history. Here's how it works and what I learned.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Problem With Generic Models
&lt;/h2&gt;

&lt;p&gt;Predicting health outcomes from biometrics is noisy. A drop in HRV means something different for a 25-year-old athlete than for someone tracking hormonal health. A universal model is mediocre for everyone. A personalised one, trained on your data, is actually useful.&lt;/p&gt;

&lt;p&gt;The constraint: this data is sensitive. Shipping it to a server — even your own — is a non-starter for privacy-first health apps. So the model has to live and train on-device.&lt;/p&gt;

&lt;h2&gt;
  
  
  CreateMLComponents + CoreML
&lt;/h2&gt;

&lt;p&gt;Apple's &lt;code&gt;CreateMLComponents&lt;/code&gt; framework (iOS 16+) lets you train models programmatically at runtime. It's different from the Create ML &lt;em&gt;app&lt;/em&gt; or the older &lt;code&gt;MLDataTable&lt;/code&gt; APIs — it's composable, async, and designed for this kind of on-device training use case.&lt;/p&gt;

&lt;p&gt;The core training loop is straightforward:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;regressor&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;LinearRegressor&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;Double&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;fitted&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;regressor&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fitted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;to&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;examples&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="n"&gt;fitted&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;export&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;to&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;tempURL&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;compiled&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="kt"&gt;MLModel&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;compileModel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;at&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;tempURL&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;examples&lt;/code&gt; is a sequence of &lt;code&gt;AnnotatedFeature&amp;lt;MLShapedArray&amp;lt;Double&amp;gt;, Double&amp;gt;&lt;/code&gt; — features in, score out. The model trains in a background task, exports as an &lt;code&gt;.mlpackage&lt;/code&gt;, compiles to a &lt;code&gt;.mlmodelc&lt;/code&gt;, and gets saved to the App Group container so the widget can read it too.&lt;/p&gt;

&lt;p&gt;Total training time on an iPhone: a few seconds for 30-90 days of daily logs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Feature Engineering Matters More Than Model Choice
&lt;/h2&gt;

&lt;p&gt;With limited data (30-90 rows), the model architecture barely matters. Feature quality does. A few things that made a difference:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cyclical encoding for time.&lt;/strong&gt; Day of week and cycle day aren't linear — day 7 is close to day 1, not far from it. Encoding them as sin/cos pairs prevents the model from treating time as an arbitrary number.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="n"&gt;vec&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;cycleDaySin&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;sin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pi&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="kt"&gt;Double&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cycleDay&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mf"&gt;28.0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;vec&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;cycleDayCos&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;cos&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pi&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="kt"&gt;Double&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cycleDay&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mf"&gt;28.0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Delta features over raw values.&lt;/strong&gt; Absolute HRV of 45ms might be fine for one person and low for another. But a 15% drop from your own 7-day rolling mean is meaningful regardless of baseline. I compute deltas for all continuous biometrics (HRV, resting HR, basal body temperature).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Log-normalise high-variance features.&lt;/strong&gt; Step count varies by an order of magnitude — 800 steps on a sick day, 12,000 on an active one. Log normalisation keeps it from dominating the linear model.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Confidence Gradient
&lt;/h2&gt;

&lt;p&gt;New users have no data, so you can't run the model immediately. I handle this with a &lt;code&gt;TrainingState&lt;/code&gt; enum:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="kd"&gt;enum&lt;/span&gt; &lt;span class="kt"&gt;TrainingState&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="n"&gt;insufficient&lt;/span&gt;   &lt;span class="c1"&gt;// fewer than 30 days of data&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="n"&gt;idle&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="n"&gt;training&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="nf"&gt;trained&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="n"&gt;fallback&lt;/span&gt;       &lt;span class="c1"&gt;// using rule-based scorer&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Under 30 days, a rule-based fallback runs instead — simple thresholds on HRV, deep sleep, and resting HR. It's less accurate but honest about its limitations. The confidence label shown to the user goes from "Building" to "Moderate" to "High" as data accumulates.&lt;/p&gt;

&lt;p&gt;Confidence is capped at a formula: &lt;code&gt;min(1.0, 0.5 + (logCount - 30) / 120.0)&lt;/code&gt;. You hit 100% confidence at 150 days of data. Honest and explainable.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prediction to Notification Pipeline
&lt;/h2&gt;

&lt;p&gt;Once the model runs, high-risk predictions trigger a local notification. No server involved at any stage — data never leaves the device:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;DailyLog history -&amp;gt; FeatureVector -&amp;gt; MLModel.prediction() -&amp;gt; PredictionResult
-&amp;gt; WidgetKit reload + flare warning notification (if high risk)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The compiled model is stored in the App Group container so both the main app and the widget read the same model file.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd Do Differently
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Quantile regression instead of point estimates.&lt;/strong&gt; A flare risk score with a confidence interval is more useful than a precise-sounding number.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Federated fine-tuning&lt;/strong&gt; (for a population-level baseline, if needed later). Right now the model is purely individual — no shared signal at all.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;More aggressive retrain scheduling.&lt;/strong&gt; Currently retrains on app open when new logs exist. Background task scheduling would make it more consistent.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you're building health or fitness apps that need personalised predictions and can't touch a server, &lt;code&gt;CreateMLComponents&lt;/code&gt; is worth a serious look. The API is clean, async throughout, and the trained models drop straight into the standard CoreML inference path.&lt;/p&gt;

</description>
      <category>ios</category>
      <category>swift</category>
      <category>coreml</category>
      <category>machinelearning</category>
    </item>
    <item>
      <title>Running On-Device AI in a React Native App: Real-Time Hazard Detection with CoreML</title>
      <dc:creator>Todd Sullivan</dc:creator>
      <pubDate>Wed, 20 May 2026 08:04:16 +0000</pubDate>
      <link>https://dev.to/toddsullivan/running-on-device-ai-in-a-react-native-app-real-time-hazard-detection-with-coreml-28h4</link>
      <guid>https://dev.to/toddsullivan/running-on-device-ai-in-a-react-native-app-real-time-hazard-detection-with-coreml-28h4</guid>
      <description>&lt;p&gt;I've been building a field inspection app where the core differentiator is this: AI that works with zero internet. No cloud call, no latency, no "sorry, you're in a dead zone." The model runs on the device and that's the whole point.&lt;/p&gt;

&lt;p&gt;This post is about shipping real-time on-device inference in a React Native (Expo) app — what the stack looks like, what actually tripped me up, and what the numbers look like so far.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Setup
&lt;/h2&gt;

&lt;p&gt;Safety inspection tool for construction sites. Inspectors walk a site, capture photos, and the AI flags PPE violations — missing hard hats, high-vis, etc. Construction sites often have zero connectivity. The AI &lt;em&gt;has&lt;/em&gt; to work offline or it's useless.&lt;/p&gt;

&lt;p&gt;Stack:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;React Native / Expo SDK 52&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CoreML&lt;/strong&gt; for inference (iOS)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;YOLOv8s&lt;/strong&gt; converted to &lt;code&gt;.mlpackage&lt;/code&gt; — under 50MB, bundled with the app&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Swift Expo module&lt;/strong&gt; wrapping the CoreML inference pipeline&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The model is under 50MB — you can't ship a 400MB model and expect App Store approval or a sane user experience.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Swift Module Bridge
&lt;/h2&gt;

&lt;p&gt;The tricky part isn't CoreML inference itself — Apple's API is clean. The tricky part is bridging it into React Native without losing your mind.&lt;/p&gt;

&lt;p&gt;I built a native Swift Expo module (&lt;code&gt;PPEDetectorModule&lt;/code&gt;) that:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Loads the &lt;code&gt;.mlpackage&lt;/code&gt; on init&lt;/li&gt;
&lt;li&gt;Accepts a photo URI from JS&lt;/li&gt;
&lt;li&gt;Runs synchronous inference and returns bounding boxes + confidence scores&lt;/li&gt;
&lt;li&gt;Handles model load failures gracefully (falls back to "manual review required")
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;detect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;imageUri&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[[&lt;/span&gt;&lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Any&lt;/span&gt;&lt;span class="p"&gt;]]&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;guard&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;model&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;detector&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;image&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;CIImage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;contentsOf&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;string&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;imageUri&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;results&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;predict&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;image&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;image&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;results&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;map&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;box&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt;
        &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"label"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;box&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;label&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"confidence"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;box&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;confidence&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
         &lt;span class="s"&gt;"x"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;box&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;rect&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;origin&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"y"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;box&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;rect&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;origin&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;y&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
         &lt;span class="s"&gt;"width"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;box&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;rect&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;width&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"height"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;box&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;rect&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;height&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;??&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Results come back as plain JSON. The JS layer renders bounding boxes as an overlay using React Native's &lt;code&gt;Animated&lt;/code&gt; + absolute positioning.&lt;/p&gt;

&lt;h2&gt;
  
  
  Real-Time Viewfinder Mode
&lt;/h2&gt;

&lt;p&gt;Post-capture detection is useful but not magical. For the real "wow" moment I wanted live inference as the inspector points the camera — hazards flagged before the photo is even taken.&lt;/p&gt;

&lt;p&gt;The camera screen runs inference every 750ms against the live feed. At 750ms you get ~1.3fps of AI updates — visually responsive without hammering the CPU.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nf"&gt;useEffect&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;interval&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;setInterval&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cameraRef&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;isDetecting&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;frame&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;cameraRef&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;takePictureAsync&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
        &lt;span class="na"&gt;quality&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;skipProcessing&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
      &lt;span class="p"&gt;});&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;detections&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;PPEDetectorModule&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;detect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;frame&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;uri&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="nf"&gt;setBoxes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;detections&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="mi"&gt;750&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;clearInterval&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;interval&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;isDetecting&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Quality &lt;code&gt;0.3&lt;/code&gt; is intentional — inference accuracy doesn't need 12MP photos, and lower resolution dramatically cuts preprocessing time.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Numbers (Sprint 2, iPhone 14 Pro)
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Inference time (post-capture):&lt;/strong&gt; ~280ms average&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Viewfinder inference:&lt;/strong&gt; ~320ms including frame capture overhead&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Target SLA:&lt;/strong&gt; &amp;lt;500ms — currently green ✅&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Memory footprint:&lt;/strong&gt; ~180MB at peak (well under 512MB budget)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Battery:&lt;/strong&gt; Sprint 2 goal is &amp;lt;5% per shift — not fully measured yet&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Model accuracy is the real open question. In simulator testing against construction site photos, hard-hat detection is solid. Field testing on actual sites is the Sprint 2 gate — target is 90%+ detection on hard-hat violations in real conditions.&lt;/p&gt;

&lt;h2&gt;
  
  
  Freemium Gate
&lt;/h2&gt;

&lt;p&gt;Free tier gets 10 AI detections/month. This can't be bolted on after the fact — it has to be woven into the detection pipeline.&lt;/p&gt;

&lt;p&gt;Every call to &lt;code&gt;detectAndSave&lt;/code&gt; checks an entitlement store before firing inference. If the quota is hit, it emits a paywall event and the UI surfaces an upgrade prompt. RevenueCat handles the StoreKit 2 subscription state.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lesson:&lt;/strong&gt; build the gate into the data layer, not the UI layer. If you gate at the UI, someone will bypass it. Gate at the function that writes to your database.&lt;/p&gt;

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

&lt;p&gt;Tap-to-confirm UX for flagged hazards, App Store Connect subscription products, and the actual field test. Sprint 2 demo is at day 60 — success criteria is 90%+ hard-hat violation detection offline.&lt;/p&gt;

&lt;p&gt;On-device AI is genuinely viable for production mobile apps right now. Models are small enough, hardware is fast enough, and the offline story is compelling in markets where connectivity is unreliable. If you're building in field service, construction, agriculture, or any domain where "no signal" is a real scenario — worth considering CoreML + a bundled model over a cloud API dependency.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>ios</category>
      <category>reactnative</category>
      <category>machinelearning</category>
    </item>
    <item>
      <title>On-Device AI for Construction Safety: Why I'm Skipping the Cloud Entirely</title>
      <dc:creator>Todd Sullivan</dc:creator>
      <pubDate>Mon, 18 May 2026 08:02:04 +0000</pubDate>
      <link>https://dev.to/toddsullivan/on-device-ai-for-construction-safety-why-im-skipping-the-cloud-entirely-4h4e</link>
      <guid>https://dev.to/toddsullivan/on-device-ai-for-construction-safety-why-im-skipping-the-cloud-entirely-4h4e</guid>
      <description>&lt;p&gt;I've been building a construction safety inspection app — GroundCheck — and from day one I made a decision that surprised a few people: no cloud AI. Every hazard detection runs on-device, offline, in under 50 MB.&lt;/p&gt;

&lt;p&gt;Here's why that wasn't just a cost call — it was an engineering call.&lt;/p&gt;




&lt;h2&gt;
  
  
  The problem with cloud AI on a construction site
&lt;/h2&gt;

&lt;p&gt;Construction sites are not Silicon Valley offices. Mid-size commercial builds — the beachhead market I'm targeting — often have patchy LTE at best, and active floors can be dead zones. A safety inspector can't pause a walkthrough because their hazard detection app is waiting on a round-trip to an API.&lt;/p&gt;

&lt;p&gt;When I looked at the alternatives:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Cloud vision APIs&lt;/strong&gt; — fast to build, $0.001–0.003/image at scale, but useless offline and creates a real liability question around who holds footage of an active construction site&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;On-device ML&lt;/strong&gt; — more upfront work, but deterministic latency, zero connectivity dependency, and no data leaves the device&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For safety tooling, determinism matters. If an inspector gets a false negative because the API timed out, that's not a UX bug — it's potentially a serious incident.&lt;/p&gt;




&lt;h2&gt;
  
  
  The model stack: keeping it under 50 MB
&lt;/h2&gt;

&lt;p&gt;The target is YOLOv8s + MobileNetV3, combined under 50 MB. Here's why each choice:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;YOLOv8s&lt;/strong&gt; — the small variant sits around 22 MB as a CoreML model. Fast enough to run on-device without throttling the camera feed. The 's' variant trades some mAP against the nano (which would be faster but misses smaller objects — a real problem when you're detecting things like exposed rebar or missing PPE at distance).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;MobileNetV3&lt;/strong&gt; — classification backbone for finer-grained scene understanding. Once YOLOv8s has found a detection region, MobileNetV3 does the heavy lifting on "is this person wearing a hard hat" vs. "is this person holding a hard hat." Two-stage pipeline, both on-device.&lt;/p&gt;

&lt;p&gt;The total bundle target is &amp;lt;50 MB because that's the threshold where App Store cellular auto-download kicks in. Installers and safety managers shouldn't have to think about it.&lt;/p&gt;




&lt;h2&gt;
  
  
  Offline-first all the way down
&lt;/h2&gt;

&lt;p&gt;The inspection flow is built on Drizzle ORM over expo-sqlite. Everything captures locally first — photos, GPS coordinates, hazard detections, inspection notes. A sync queue handles Supabase replication when connectivity returns.&lt;/p&gt;

&lt;p&gt;This means the app works identically with zero bars as it does on a solid connection. Photo uploads are deferred, sync state is visible in the UI, and no part of the core inspection loop has a network dependency.&lt;/p&gt;

&lt;p&gt;It's more upfront architecture work. But for a product that's supposed to replace $600/month SafetyCulture contracts, "it doesn't work if you're underground" isn't acceptable.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I've learned so far
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;On-device AI is actually quite accessible now.&lt;/strong&gt; CoreML tooling has matured significantly. Converting a YOLOv8 model to CoreML is mostly straightforward via &lt;code&gt;coremltools&lt;/code&gt;; the sharp edges are around input preprocessing and getting confidence thresholds tuned for your domain (construction hazard detection ≠ COCO defaults).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Offline-first is a discipline, not a feature.&lt;/strong&gt; It touches every layer — schema design, UI state, sync conflict resolution. You can't bolt it on after the fact. I scaffolded the sync queue before I wrote a single inspection screen.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The 50 MB budget forces good decisions.&lt;/strong&gt; Model quantization, INT8 where it matters, careful layer pruning. Constraints produce better models than unlimited compute budgets.&lt;/p&gt;




&lt;p&gt;If you're building for any domain where connectivity isn't guaranteed — field work, logistics, healthcare, agriculture — the on-device AI stack has never been more viable. The cloud-first assumption deserves to be challenged.&lt;/p&gt;

</description>
      <category>ios</category>
      <category>machinelearning</category>
      <category>javascript</category>
      <category>showdev</category>
    </item>
    <item>
      <title>I Let Claude Code Do a Performance Review on My iOS App — Here's What It Found</title>
      <dc:creator>Todd Sullivan</dc:creator>
      <pubDate>Fri, 15 May 2026 08:02:17 +0000</pubDate>
      <link>https://dev.to/toddsullivan/claude-code-performance-review-4i40</link>
      <guid>https://dev.to/toddsullivan/claude-code-performance-review-4i40</guid>
      <description>&lt;p&gt;I've been building &lt;a href="https://apps.apple.com/app/herdcount/id6744636821" rel="noopener noreferrer"&gt;HerdCount&lt;/a&gt; — an offline-first iOS app that counts livestock from a photo using YOLOv8n on CoreML. No internet, no account, just the Neural Engine doing its thing.&lt;/p&gt;

&lt;p&gt;The app was working, but after adding a share-card feature (a branded "proof of count" image you can send to buyers or vets), I noticed some jank. Tap Save and the UI would stutter. Scroll through results and frames would drop. Nothing catastrophic, but noticeable.&lt;/p&gt;

&lt;p&gt;Instead of diving into Instruments myself, I dropped Claude Code into the repo with a performance review prompt and watched what happened.&lt;/p&gt;

&lt;h3&gt;
  
  
  What it was asked to do
&lt;/h3&gt;

&lt;p&gt;Simple brief: &lt;em&gt;review the codebase for iOS performance issues, particularly in the Result screen and inference path&lt;/em&gt;. No specific files called out, no hints. Just "here's the code, find what's slow."&lt;/p&gt;

&lt;h3&gt;
  
  
  What it found (and actually fixed)
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;1. Share card rendering on every SwiftUI body rebuild&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The proof-of-count card — a UIGraphicsImageRenderer render of the annotated photo with branding — was being generated inside the view's state updates. Every time SwiftUI rebuilt the body (which it does a &lt;em&gt;lot&lt;/em&gt;), it was re-running 300–500ms of image rendering work.&lt;/p&gt;

&lt;p&gt;Fix: cache the rendered image in the ViewModel, keyed on the things that actually change (detections, count, label, notes). Only re-render when those values change. Obvious in retrospect. Easy to miss when you're building features.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Thumbnail generation blocking the main thread&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Tapping Save triggered a thumbnail generation step before writing to SwiftData. That was happening synchronously on the main actor — hence the stutter on save.&lt;/p&gt;

&lt;p&gt;Fix: &lt;code&gt;Task.detached&lt;/code&gt; with pre-computed &lt;code&gt;Data?&lt;/code&gt; handed off to the model layer, keeping UIKit on the main thread where it needs to be but doing the pixel work on a background thread.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Static formatters vs per-call allocation&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;DateFormatter&lt;/code&gt; and &lt;code&gt;RelativeDateTimeFormatter&lt;/code&gt; were being instantiated per call in a few places — including inside the inference hot path. Each allocation is small, but in &lt;code&gt;VisionService&lt;/code&gt; those run on every frame during detection.&lt;/p&gt;

&lt;p&gt;Fix: promote to static properties. One allocation, reused forever.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Inference path allocations&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;In &lt;code&gt;VisionService&lt;/code&gt;, the observation filtering was a &lt;code&gt;filter&lt;/code&gt; followed by a &lt;code&gt;map&lt;/code&gt; — two passes, two intermediate arrays per inference call. Collapsed to a single &lt;code&gt;compactMap&lt;/code&gt;. In &lt;code&gt;PresetCategory&lt;/code&gt;, label matching used an array literal (&lt;code&gt;["dog", "cat", ...]&lt;/code&gt;) allocated on the heap each call. Replaced with &lt;code&gt;||&lt;/code&gt; comparisons.&lt;/p&gt;

&lt;h3&gt;
  
  
  The PR
&lt;/h3&gt;

&lt;p&gt;Claude committed all of this as a single structured PR with clear commit messages. The diff was clean, the explanations were accurate, and — importantly — it didn't make anything up. It found real issues, measured them correctly (citing the ms ranges from the actual rendering work), and fixed them without introducing regressions.&lt;/p&gt;

&lt;p&gt;The follow-up commits were me fixing a crash it introduced by moving UIKit work off the main actor (classic async/await pitfall — it almost got it right) and a build error from curly quotes in a string literal. Two small misses out of a solid overall review.&lt;/p&gt;

&lt;h3&gt;
  
  
  The meta part
&lt;/h3&gt;

&lt;p&gt;The app itself is an AI app — CoreML + Vision running YOLOv8n on the Neural Engine. I used an LLM to review and improve the code for an on-device ML app. There's something satisfying about that stack: AI tooling improving AI tooling.&lt;/p&gt;

&lt;p&gt;More practically: this kind of review is exactly what Claude Code is good at. Pattern recognition across a codebase — "you're doing this expensive thing unnecessarily" — is tedious to do manually and easy to miss when you're close to the code. Having an external pass that doesn't know what you &lt;em&gt;intended&lt;/em&gt; to write is genuinely useful.&lt;/p&gt;

&lt;p&gt;The Instruments profiler would have found the same things eventually. But this was faster, and it wrote the fix too.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Tags:&lt;/strong&gt; ios, swift, claudecode, ai, performance&lt;br&gt;
&lt;strong&gt;Status:&lt;/strong&gt; published&lt;br&gt;
&lt;strong&gt;Source:&lt;/strong&gt; herdcount-ios PR #1 (claude/performance-review)&lt;/p&gt;

</description>
      <category>ios</category>
      <category>swift</category>
      <category>claudecode</category>
      <category>ai</category>
    </item>
    <item>
      <title>HerdCount is Live on the App Store — From Blog Post to Shipped Product in Two Weeks</title>
      <dc:creator>Todd Sullivan</dc:creator>
      <pubDate>Wed, 13 May 2026 10:31:32 +0000</pubDate>
      <link>https://dev.to/toddsullivan/herdcount-is-live-on-the-app-store-from-blog-post-to-shipped-product-in-two-weeks-1cok</link>
      <guid>https://dev.to/toddsullivan/herdcount-is-live-on-the-app-store-from-blog-post-to-shipped-product-in-two-weeks-1cok</guid>
      <description>&lt;p&gt;Two weeks ago I wrote about &lt;a href="https://dev.to/toddsullivan/building-an-offline-first-livestock-counter-with-yolov8-and-coreml-2d2g"&gt;building an offline-first livestock counter with YOLOv8 and CoreML&lt;/a&gt;. Today it's a real product on the App Store.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://apps.apple.com/gb/app/herdcount/id6765711537" rel="noopener noreferrer"&gt;HerdCount — Count your flock, even offline&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;£3.99. No subscription. No cloud. No account. Pay once, use forever.&lt;/p&gt;

&lt;h2&gt;
  
  
  What It Does
&lt;/h2&gt;

&lt;p&gt;Point your phone at livestock or plants. Tap a button. Get the count.&lt;/p&gt;

&lt;p&gt;HerdCount uses on-device AI (YOLOv8 + CoreML) to detect and count chickens, sheep, cattle, and plants from a single photo — in under a second, with zero internet required.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fapps.apple.com%2Fgb%2Fapp%2Fherdcount%2Fid6765711537" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fapps.apple.com%2Fgb%2Fapp%2Fherdcount%2Fid6765711537" alt="HerdCount" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I Built It
&lt;/h2&gt;

&lt;p&gt;I work with on-device computer vision professionally — building &lt;a href="https://axsy.com" rel="noopener noreferrer"&gt;Axsy Smart Vision&lt;/a&gt;, an AI-powered field inspection platform for Salesforce. Retail planogram detection, product identification, compliance scoring — all running on-device.&lt;/p&gt;

&lt;p&gt;But the agricultural space has a simpler, more immediate problem: &lt;strong&gt;counting animals is tedious and error-prone&lt;/strong&gt;. Farmers do it by eye, multiple times a day. Miss one sheep and you're searching hedgerows at dusk.&lt;/p&gt;

&lt;p&gt;The same on-device ML pipeline I use for retail product detection works beautifully for livestock. So I built it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Technical Stack
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;YOLOv8&lt;/strong&gt; — trained on livestock datasets, converted to CoreML&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;On-device inference&lt;/strong&gt; — runs on the iPhone's Neural Engine, no cloud round-trip&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Offline-first&lt;/strong&gt; — works in fields, barns, anywhere with no signal&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Swift/SwiftUI&lt;/strong&gt; — native iOS, 8.7 MB total&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Export&lt;/strong&gt; — CSV via AirDrop, email, or Files app&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The model handles overlapping animals (tuned IoU threshold at 0.3 rather than the default 0.5) and lets you tap false positives to remove them. Manual +/− adjustment before saving.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Learned Shipping It
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. App Review is opinionated.&lt;/strong&gt; Apple rejected the first submission because the category detection UI wasn't clear enough. Fair feedback — I redesigned it and it's better for it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. The model is the easy part.&lt;/strong&gt; Training YOLOv8 and converting to CoreML took a weekend. The other 90% was UI polish, edge cases, CSV export formatting, and App Store screenshots.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Pricing matters.&lt;/strong&gt; I went with £3.99 one-time purchase. No subscription, no ads, no data collection. Farmers are practical people — they'll pay for a tool that works but won't tolerate dark patterns.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. On-device AI is a real differentiator.&lt;/strong&gt; Every competing app I found requires internet. That's a non-starter for someone standing in a field in rural Wales.&lt;/p&gt;

&lt;h2&gt;
  
  
  From Blog to Product
&lt;/h2&gt;

&lt;p&gt;The Dev.to post about the technical approach got genuine engagement — &lt;a href="https://dev.to/toddsullivan/building-an-offline-first-livestock-counter-with-yolov8-and-coreml-2d2g#comment-37iah"&gt;@gimi5555 asked about NMS strategies for clustered animals&lt;/a&gt;, which led to a good discussion about density estimation as a fallback.&lt;/p&gt;

&lt;p&gt;That conversation validated the approach. Two weeks later, it's a shipped product.&lt;/p&gt;

&lt;p&gt;If you're working with on-device ML and sitting on something useful — ship it. The App Store review process is less scary than it looks, and real users find real problems you'd never catch in development.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;&lt;a href="https://apps.apple.com/gb/app/herdcount/id6765711537" rel="noopener noreferrer"&gt;HerdCount on the App Store →&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Built by &lt;a href="https://sullivanltd.co.uk" rel="noopener noreferrer"&gt;RT Sullivan Consulting&lt;/a&gt;. I write about on-device AI, Salesforce field apps, and shipping real products at &lt;a href="https://dev.to/toddsullivan"&gt;dev.to/toddsullivan&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>ios</category>
      <category>machinelearning</category>
      <category>ai</category>
      <category>swift</category>
    </item>
    <item>
      <title>Shipping to TestFlight Without Fastlane: Raw xcodebuild, Auto-Incrementing Builds, and One Neat Provisioning Trick</title>
      <dc:creator>Todd Sullivan</dc:creator>
      <pubDate>Wed, 13 May 2026 08:01:59 +0000</pubDate>
      <link>https://dev.to/toddsullivan/shipping-to-testflight-without-fastlane-raw-xcodebuild-auto-incrementing-builds-and-one-neat-p2h</link>
      <guid>https://dev.to/toddsullivan/shipping-to-testflight-without-fastlane-raw-xcodebuild-auto-incrementing-builds-and-one-neat-p2h</guid>
      <description>&lt;p&gt;Most iOS CI tutorials reach for Fastlane. It's the default assumption. And Fastlane is fine — but it's also another Ruby toolchain to maintain, another layer of abstraction between you and xcodebuild errors, and another thing that breaks when Xcode updates.&lt;/p&gt;

&lt;p&gt;For a small side project, I wanted zero overhead. So I wrote a release script using plain &lt;code&gt;xcodebuild&lt;/code&gt; and &lt;code&gt;xcrun altool&lt;/code&gt;, and wired it into GitHub Actions. Here's what I learned.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Setup
&lt;/h3&gt;

&lt;p&gt;The app is a no-dependency iOS project (SwiftUI, SwiftData, zero SPM packages). One scheme, one target, distributes via the App Store. The goal: &lt;code&gt;git push&lt;/code&gt; → trigger workflow → build, sign, upload to TestFlight.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Auto-incrementing build numbers for free:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;BUILD_NUMBER&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;1&lt;/span&gt;&lt;span class="k"&gt;:-&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;git &lt;span class="nt"&gt;-C&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$REPO_ROOT&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; rev-list &lt;span class="nt"&gt;--count&lt;/span&gt; HEAD&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's it. Every commit bumps the count. No build number file to commit, no race conditions in CI, no manual tracking. Pass it straight into xcodebuild:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;xcodebuild archive &lt;span class="se"&gt;\&lt;/span&gt;
  ...
  &lt;span class="nv"&gt;CURRENT_PROJECT_VERSION&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$BUILD_NUMBER&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;TestFlight requires monotonically increasing build numbers. Git commit count gives you that automatically. I've seen people use timestamps (too long), semver patch (manual), or a counter file in the repo (merge conflicts). Commit count is cleaner.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Provisioning Problem — and the Fix
&lt;/h3&gt;

&lt;p&gt;This is where most raw-xcodebuild scripts fall apart. The export step (&lt;code&gt;xcodebuild -exportArchive&lt;/code&gt;) needs an &lt;code&gt;ExportOptions.plist&lt;/code&gt; with the exact provisioning profile UUID. But the UUID changes every time you renew the profile.&lt;/p&gt;

&lt;p&gt;The usual answer is "hardcode it in your plist and update manually." That's the kind of thing you forget for six months and then debug for two hours.&lt;/p&gt;

&lt;p&gt;Better approach: extract the UUID from the archive you just built, then inject it at export time.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Pull the embedded profile from the freshly-built archive&lt;/span&gt;
&lt;span class="nv"&gt;EMBEDDED&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$ARCHIVE&lt;/span&gt;&lt;span class="s2"&gt;/Products/Applications/MyApp.app/embedded.mobileprovision"&lt;/span&gt;
&lt;span class="nv"&gt;PROFILE_UUID&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;security cms &lt;span class="nt"&gt;-D&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$EMBEDDED&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | plutil &lt;span class="nt"&gt;-extract&lt;/span&gt; UUID raw -&lt;span class="si"&gt;)&lt;/span&gt;

&lt;span class="c"&gt;# Copy it into the Provisioning Profiles directory (xcodebuild looks here)&lt;/span&gt;
&lt;span class="nb"&gt;cp&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$EMBEDDED&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$HOME&lt;/span&gt;&lt;span class="s2"&gt;/Library/MobileDevice/Provisioning Profiles/&lt;/span&gt;&lt;span class="nv"&gt;$PROFILE_UUID&lt;/span&gt;&lt;span class="s2"&gt;.mobileprovision"&lt;/span&gt;

&lt;span class="c"&gt;# Write a temp ExportOptions with the exact UUID from *this* archive&lt;/span&gt;
&lt;span class="nb"&gt;cp&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$EXPORT_OPTIONS&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$EXPORT_OPTIONS_TMP&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
plutil &lt;span class="nt"&gt;-replace&lt;/span&gt; &lt;span class="s2"&gt;"provisioningProfiles.com.example.myapp"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-string&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$PROFILE_UUID&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$EXPORT_OPTIONS_TMP&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

&lt;span class="c"&gt;# Now export using that temp plist&lt;/span&gt;
xcodebuild &lt;span class="nt"&gt;-exportArchive&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-archivePath&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$ARCHIVE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-exportPath&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$EXPORT_DIR&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-exportOptionsPlist&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$EXPORT_OPTIONS_TMP&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The profile UUID in your ExportOptions is always current, because it came from the archive itself. Renew the cert, re-download the profile, and nothing breaks.&lt;/p&gt;

&lt;h3&gt;
  
  
  GitHub Actions Signing
&lt;/h3&gt;

&lt;p&gt;For CI, the certificate lives in a secret as a base64-encoded &lt;code&gt;.p12&lt;/code&gt;. The workflow decodes it into a temporary keychain:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;security create-keychain &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$KEYCHAIN_PASS&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; build.keychain
security import /tmp/cert.p12 &lt;span class="nt"&gt;-k&lt;/span&gt; build.keychain &lt;span class="nt"&gt;-P&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$P12_PASSWORD&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-T&lt;/span&gt; /usr/bin/codesign
security set-key-partition-list &lt;span class="nt"&gt;-S&lt;/span&gt; apple-tool:,apple: &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-k&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$KEYCHAIN_PASS&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; build.keychain
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;-T /usr/bin/codesign&lt;/code&gt; flag is critical — without it, the keychain will prompt for a password interactively mid-build, which hangs CI forever. The &lt;code&gt;set-key-partition-list&lt;/code&gt; step is what makes it work without prompts.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Full Flow
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;workflow_dispatch
  → checkout (full depth for commit count)
  → import cert into ephemeral keychain
  → write App Store Connect API key
  → ./scripts/release.sh
      → xcodebuild archive
      → extract profile UUID from archive
      → inject UUID into ExportOptions
      → xcodebuild -exportArchive
      → xcrun altool --upload-app
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;About 8-12 minutes wall clock on a &lt;code&gt;macos-26&lt;/code&gt; runner. No Ruby, no gems, no Fastlane plugins.&lt;/p&gt;

&lt;h3&gt;
  
  
  When Fastlane Still Makes Sense
&lt;/h3&gt;

&lt;p&gt;If you're managing multiple targets, schemes, environments, or a team with custom lanes — Fastlane earns its complexity. But for a single-target indie app? Raw xcodebuild is readable, debuggable, and requires no maintenance beyond "Xcode updated, did the flags change?"&lt;/p&gt;

&lt;p&gt;The full script is about 70 lines of bash. That's the whole pipeline.&lt;/p&gt;

</description>
      <category>ios</category>
      <category>xcode</category>
      <category>cicd</category>
      <category>devops</category>
    </item>
    <item>
      <title>Building Personalised On-Device ML for Women's Health: No Cloud, No Population Averages</title>
      <dc:creator>Todd Sullivan</dc:creator>
      <pubDate>Mon, 11 May 2026 13:22:24 +0000</pubDate>
      <link>https://dev.to/toddsullivan/building-personalised-on-device-ml-for-womens-health-no-cloud-no-population-averages-4j03</link>
      <guid>https://dev.to/toddsullivan/building-personalised-on-device-ml-for-womens-health-no-cloud-no-population-averages-4j03</guid>
      <description>&lt;p&gt;Most health AI is built on population data. Your symptoms are averaged against thousands of other people, and you get a generalised prediction that fits nobody perfectly.&lt;/p&gt;

&lt;p&gt;I took a different approach with Menopause Intelligence — an iOS app I've been building that predicts high-symptom days for women in perimenopause and menopause.&lt;/p&gt;

&lt;p&gt;The entire model runs on-device, trained on the individual user's own data. No cloud, no population averages, no third-party data sharing.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem with cloud-based health AI
&lt;/h2&gt;

&lt;p&gt;Population models work when you want average answers. But perimenopause is deeply individual. Two women with identical ages and similar symptom profiles can have completely different biometric triggers.&lt;/p&gt;

&lt;p&gt;The app's job is to tell a user &lt;em&gt;her&lt;/em&gt; patterns — not what typically happens to women like her.&lt;/p&gt;

&lt;h2&gt;
  
  
  The ML pipeline
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Features:&lt;/strong&gt; Seven signals per day, all from HealthKit/Apple Watch:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Basal body temperature delta vs 7-day mean&lt;/li&gt;
&lt;li&gt;HRV (raw + delta from personal rolling average)&lt;/li&gt;
&lt;li&gt;Sleep efficiency and deep sleep %&lt;/li&gt;
&lt;li&gt;REM sleep %&lt;/li&gt;
&lt;li&gt;Resting heart rate&lt;/li&gt;
&lt;li&gt;Cycle day (if logged)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Key design decision:&lt;/strong&gt; We use &lt;em&gt;deltas from the user's personal baseline&lt;/em&gt;, not absolute values. A resting HR of 62 bpm means different things for different people. What matters is whether it's elevated for &lt;em&gt;you&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Label:&lt;/strong&gt; Composite symptom severity score for day D+1 (hot flashes, brain fog, fatigue, mood)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Model:&lt;/strong&gt; CoreML + CreateML Components. Runs via a silent weekly background task (BGProcessingTask). The app retriggers training automatically as new data accumulates.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cold start:&lt;/strong&gt; The first 30 days use a rule-based weighted scorer as a fallback. Not as accurate, but keeps the app useful while data accumulates.&lt;/p&gt;

&lt;h2&gt;
  
  
  The data architecture
&lt;/h2&gt;

&lt;p&gt;Everything is local:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;HealthKit → DailyLog (SwiftData) → Feature engineering → CoreML inference
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No backend. No analytics SDK. CloudKit sync between devices uses end-to-end encryption. Health data never touches our servers — because we don't have any.&lt;/p&gt;

&lt;p&gt;This isn't just a privacy stance. It's architecturally simpler and removes a whole category of compliance risk. For a health app in this category, "no backend" is a feature you can market.&lt;/p&gt;

&lt;h2&gt;
  
  
  The feedback loop
&lt;/h2&gt;

&lt;p&gt;User-reported symptoms feed back into the next training cycle. Every hot flash logged, every mood entry — they sharpen the model for that specific user.&lt;/p&gt;

&lt;p&gt;This is the same feedback pattern I've used in other on-device vision work: user corrections become training data. The model gets more accurate over time for the individual, not just better at the general case.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I've learned building personalised on-device ML
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Minimum data is a real UX problem.&lt;/strong&gt; 30 days before predictions activate feels long to a user who downloaded the app because she's struggling now. You have to be honest about why, and give her something useful in the meantime.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Baseline drift matters.&lt;/strong&gt; A user's "normal" changes over the course of perimenopause. The rolling average window needs to adapt — a fixed 7-day mean becomes stale if someone's baseline HRV is trending down over months.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Privacy is the product.&lt;/strong&gt; In women's health, trust is everything. "Your data never leaves your device" isn't a footnote — it's the headline. It changes the conversation with users who've been burned by other health apps.&lt;/p&gt;

&lt;h2&gt;
  
  
  The stack
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;UI:&lt;/strong&gt; SwiftUI (iOS 17+)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Data:&lt;/strong&gt; SwiftData + CloudKit&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Biometrics:&lt;/strong&gt; HealthKit&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Prediction:&lt;/strong&gt; CoreML + CreateML Components&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Subscriptions:&lt;/strong&gt; StoreKit 2&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Watch:&lt;/strong&gt; watchOS companion + WidgetKit&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;More on this as it gets closer to launch.&lt;/p&gt;

</description>
      <category>ios</category>
      <category>machinelearning</category>
      <category>ai</category>
      <category>swift</category>
    </item>
    <item>
      <title>The Fastlane gym Export Options Trap (and Why Your Provisioning Profile Is Being Silently Ignored)</title>
      <dc:creator>Todd Sullivan</dc:creator>
      <pubDate>Mon, 11 May 2026 08:01:51 +0000</pubDate>
      <link>https://dev.to/toddsullivan/the-fastlane-gym-export-options-trap-and-why-your-provisioning-profile-is-being-silently-ignored-5caf</link>
      <guid>https://dev.to/toddsullivan/the-fastlane-gym-export-options-trap-and-why-your-provisioning-profile-is-being-silently-ignored-5caf</guid>
      <description>&lt;p&gt;Spent a few hours last week debugging a CI failure that had no right to be as subtle as it was. The build archived fine, but &lt;code&gt;exportArchive&lt;/code&gt; kept dying with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;error: exportArchive: requires a provisioning profile with the App Groups feature.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The frustrating part: the AppStore provisioning profile was correct. I had just renewed it, decrypted it on the runner, and confirmed the App Group entitlement was in there. The keychain had it. So why was xcodebuild not finding it?&lt;/p&gt;

&lt;h2&gt;
  
  
  The Trap
&lt;/h2&gt;

&lt;p&gt;The Fastlane &lt;code&gt;gym&lt;/code&gt; action accepts &lt;code&gt;export_options:&lt;/code&gt; in two forms:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;A &lt;strong&gt;path&lt;/strong&gt; to an existing &lt;code&gt;.plist&lt;/code&gt; file&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;Hash&lt;/strong&gt; of options it will write to a temp plist&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I was passing a Hash — and inside that Hash I had a &lt;code&gt;plist:&lt;/code&gt; key pointing to my own plist file, thinking gym would merge or defer to it. It does not.&lt;/p&gt;

&lt;p&gt;When you pass a Hash, gym writes &lt;em&gt;that Hash&lt;/em&gt; to a temp plist and hands it directly to xcodebuild. The &lt;code&gt;plist:&lt;/code&gt; key inside the Hash is &lt;strong&gt;not&lt;/strong&gt; special — xcodebuild does not recognise it, ignores it silently, and you end up with a minimal plist that has no &lt;code&gt;provisioningProfiles&lt;/code&gt; key at all.&lt;/p&gt;

&lt;p&gt;The temp plist gym generated looked like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;dict&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;key&amp;gt;&lt;/span&gt;method&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;string&amp;gt;&lt;/span&gt;app-store&lt;span class="nt"&gt;&amp;lt;/string&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;key&amp;gt;&lt;/span&gt;uploadSymbols&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;true/&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;key&amp;gt;&lt;/span&gt;plist&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;string&amp;gt;&lt;/span&gt;RELEASE_exportOptionsPlist_Store.plist&lt;span class="nt"&gt;&amp;lt;/string&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/dict&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No &lt;code&gt;provisioningProfiles&lt;/code&gt;. Under manual signing, xcodebuild fell back to automatic profile resolution at export time — which on a clean GitHub Actions runner cannot find the app-group-bearing profile you carefully installed. Build fails. Misleading error. Whole thing looks like a profile problem when the profile was never consulted.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Fix
&lt;/h2&gt;

&lt;p&gt;Pass &lt;code&gt;export_options:&lt;/code&gt; as a &lt;strong&gt;path string&lt;/strong&gt;, not a Hash:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="n"&gt;gym&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="ss"&gt;scheme: &lt;/span&gt;&lt;span class="s2"&gt;"MyApp"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="ss"&gt;configuration: &lt;/span&gt;&lt;span class="s2"&gt;"Release"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="ss"&gt;export_options: &lt;/span&gt;&lt;span class="s2"&gt;"./fastlane/RELEASE_exportOptionsPlist_Store.plist"&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Your plist should include explicit &lt;code&gt;provisioningProfiles&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;key&amp;gt;&lt;/span&gt;provisioningProfiles&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;dict&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;key&amp;gt;&lt;/span&gt;com.example.myapp&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;string&amp;gt;&lt;/span&gt;MyApp AppStore Profile&lt;span class="nt"&gt;&amp;lt;/string&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/dict&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Gym passes the path straight to &lt;code&gt;xcodebuild -exportOptionsPlist&lt;/code&gt;. Your file is read. No temp plist, no silent key stripping.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why This Catches People Out
&lt;/h2&gt;

&lt;p&gt;The Hash form is in basically every Fastlane tutorial. It looks clean. Gym does not warn you when it discards unrecognised keys. The only signal is in verbose gym output — if you compare the temp plist it writes against what you expected, the &lt;code&gt;provisioningProfiles&lt;/code&gt; block is missing.&lt;/p&gt;

&lt;p&gt;App Groups make the failure mode worse because they require an exact profile match. Without entitlements like App Groups, xcodebuild automatic selection might accidentally find something usable. With App Groups, it always fails hard.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Do Now
&lt;/h2&gt;

&lt;p&gt;For any iOS app with entitlements — App Groups, Push Notifications, iCloud, anything — I keep an explicit &lt;code&gt;export_options.plist&lt;/code&gt; checked into the repo and pass it as a path. The Hash form is fine for a basic app. The moment signing gets complicated, you want the plist under version control and gym out of the business of generating it.&lt;/p&gt;

&lt;p&gt;One less thing the CI runner has to figure out on its own.&lt;/p&gt;

</description>
      <category>ios</category>
      <category>fastlane</category>
      <category>devops</category>
      <category>xcode</category>
    </item>
  </channel>
</rss>
