<?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: nr666_dev</title>
    <description>The latest articles on DEV Community by nr666_dev (@nr666_dev).</description>
    <link>https://dev.to/nr666_dev</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.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4139693%2F0e8e20a1-2553-4f53-bae2-b37267bc1f01.JPG</url>
      <title>DEV Community: nr666_dev</title>
      <link>https://dev.to/nr666_dev</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/nr666_dev"/>
    <language>en</language>
    <item>
      <title>An iOS keyboard extension cannot learn the host app's bundle ID. The containing app can.</title>
      <dc:creator>nr666_dev</dc:creator>
      <pubDate>Wed, 23 Sep 2026 15:10:19 +0000</pubDate>
      <link>https://dev.to/nr666_dev/an-ios-keyboard-extension-cannot-learn-the-host-apps-bundle-id-the-containing-app-can-4o9a</link>
      <guid>https://dev.to/nr666_dev/an-ios-keyboard-extension-cannot-learn-the-host-apps-bundle-id-the-containing-app-can-4o9a</guid>
      <description>&lt;p&gt;I spent six and a half hours last night looking for a value in the wrong process. Here is the map, so you don't have to.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The problem.&lt;/strong&gt; I write a custom keyboard for my own dictation app. You tap a mic button in the keyboard, the containing app wakes up in the background, records, transcribes, and then has to &lt;strong&gt;send you back to the app you came from&lt;/strong&gt;. To do that it needs the host app's bundle identifier — &lt;code&gt;com.getupnote.ios&lt;/code&gt;, or whatever you were typing into.&lt;/p&gt;

&lt;p&gt;The commercial app I am rebuilding does this. I have its URL from a device log:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;typeless://main/home?action=onStartVoiceRecording&amp;amp;hostAppBundleId=com.getupnote.ios
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So it is possible. The question was how.&lt;/p&gt;




&lt;h2&gt;
  
  
  Eight dead ends inside the extension
&lt;/h2&gt;

&lt;p&gt;Every one of these was tried on a real device — an iPhone 17 Pro on iOS 26.5.2 — with a build, an install, and a log dump per attempt. I pressed Archive in Xcode 25 times.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What I read&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;_UIViewServiceViewControllerOperator._hostBundleID&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;NSNull&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;_UIHostedWindow.__hostBundleIdentifier&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;nil&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;_hostSDKVersion&lt;/code&gt; on the same object&lt;/td&gt;
&lt;td&gt;&lt;code&gt;nil&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;proc_pidpath(hostPID)&lt;/code&gt; — in the extension &lt;strong&gt;and&lt;/strong&gt; in the containing app&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;EPERM&lt;/code&gt; both&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;sysctl(KERN_PROC_PID)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;fails&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;_hostAuditToken&lt;/code&gt; → &lt;code&gt;SecTaskCreateWithAuditToken&lt;/code&gt; → &lt;code&gt;SecTaskCopySigningIdentifier&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;nil&lt;/code&gt;; entitlement reads give &lt;code&gt;NSPOSIXErrorDomain code=1&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;FBSScene._identity&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;PseudoScene:&amp;lt;UUID&amp;gt;&lt;/code&gt; — the extension's &lt;em&gt;own&lt;/em&gt; pseudo-scene, not the host's&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;BSProcessHandle._bundleID&lt;/code&gt; / &lt;code&gt;._name&lt;/code&gt; reached over the XPC graph&lt;/td&gt;
&lt;td&gt;both &lt;code&gt;nil&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The audit token is genuine, by the way — &lt;code&gt;val.5&lt;/code&gt; equals &lt;code&gt;_hostPID&lt;/code&gt;, and &lt;code&gt;_hostPID&lt;/code&gt; is not my own pid. The kernel hands you a real token for a process you are not allowed to ask about.&lt;/p&gt;

&lt;p&gt;The shape repeats: &lt;strong&gt;the field exists, and it is empty.&lt;/strong&gt; That is what took me so long. Each empty field looked like a near miss, so I kept going one ivar deeper.&lt;/p&gt;




&lt;h2&gt;
  
  
  The measurement that ended it
&lt;/h2&gt;

&lt;p&gt;At 1am I stopped writing code and re-read a 550,000-line device log I had captured two weeks earlier, when I ran my keyboard and the commercial one on the same phone.&lt;/p&gt;

&lt;p&gt;I counted lines per process.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Log line&lt;/th&gt;
&lt;th&gt;Their keyboard extension&lt;/th&gt;
&lt;th&gt;&lt;strong&gt;My keyboard extension&lt;/strong&gt;&lt;/th&gt;
&lt;th&gt;My containing app&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;_UIKeyboardChangedInformation&lt;/code&gt; received&lt;/td&gt;
&lt;td&gt;209&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;4&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;arbiterclient&lt;/code&gt; connections&lt;/td&gt;
&lt;td&gt;34&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;RX keyboardChanged&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;17&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;iOS broadcasts "the keyboard now belongs to X" to processes that register as &lt;strong&gt;keyboard arbiter clients&lt;/strong&gt;. Ordinary apps are on that list. Keyboard extensions are not. Their extension is on it; mine is not. (I tested making mine a client — a hidden &lt;code&gt;UITextField&lt;/code&gt; becoming first responder, with and without an empty &lt;code&gt;inputView&lt;/code&gt;. Captured a fresh device log archive with &lt;code&gt;sudo log collect --device-name … --last 10m&lt;/code&gt; and counted again: still &lt;strong&gt;0&lt;/strong&gt;. That route is closed, and now I have the instrument to say so rather than guess.)&lt;/p&gt;

&lt;p&gt;But look at the third column. &lt;strong&gt;My containing app already receives it.&lt;/strong&gt; Four times, during a single cold launch.&lt;/p&gt;

&lt;p&gt;I had spent six hours in a process where the value does not exist, while it was arriving in the process next door.&lt;/p&gt;




&lt;h2&gt;
  
  
  Where it actually is
&lt;/h2&gt;

&lt;p&gt;In the containing app, reachable from &lt;code&gt;UIApplication.shared&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;windowScene
 └ _registeredComponents["_UIKeyboardSceneDelegateSceneComponentKey"]   UIKeyboardSceneDelegate
    └ _containerWindow._editingOverlayViewController._parentViewController._hosting
       └ _hostingItems[]._controllerDelegate                            _UIRemoteKeyboards
          └ _currentState                                               _UIKeyboardChangedInformation
             └ _sourceBundleIdentifier                                  "com.getupnote.ios"
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A generic sweep that walks object-typed ivars with &lt;code&gt;object_getIvar&lt;/code&gt; — never calling a method — finds it in &lt;strong&gt;13 milliseconds&lt;/strong&gt;, about 900 nodes deep into the graph.&lt;/p&gt;

&lt;p&gt;Three things cost me an extra hour here, and they are the interesting part:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Timing.&lt;/strong&gt; I ran the sweep at &lt;code&gt;openURL&lt;/code&gt; time. The value arrives ~1 second later. At t=0 there is nothing to find.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;_currentState&lt;/code&gt; is transient.&lt;/strong&gt; It is set while a keyboard-change notification is processed and cleared after. Polling every 250 ms for 6 seconds — 25 samples — found it &lt;strong&gt;zero times&lt;/strong&gt; in one run and twice in another.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The public keyboard notifications never fire.&lt;/strong&gt; &lt;code&gt;keyboardWillShowNotification&lt;/code&gt; and friends: not one, ever. My app does not raise a keyboard itself, so there is nothing local to notify about.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;What works is &lt;strong&gt;KVO&lt;/strong&gt;, which is public API and needs no swizzling:&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;remoteKeyboards&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addObserver&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;watcher&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;forKeyPath&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"currentState"&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="o"&gt;.&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;initial&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="nv"&gt;context&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;nil&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;.initial&lt;/code&gt; means you also get whatever is already there when you attach. Keep a strong reference to the observed object so it cannot be deallocated out from under the observation, and you have removed the classic KVO crash.&lt;/p&gt;




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

&lt;p&gt;Same cold launch, both methods running side by side:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Method&lt;/th&gt;
&lt;th&gt;Time to answer&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;KVO on &lt;code&gt;_currentState&lt;/code&gt;&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;0.75 s after the mic tap&lt;/strong&gt;, 13 ms of CPU&lt;/td&gt;
&lt;td&gt;&lt;code&gt;com.getupnote.ios&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Scanning my own &lt;code&gt;OSLogStore&lt;/code&gt; for &lt;code&gt;source bundle&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;5,464 ms&lt;/strong&gt; (and it had to re-scan once)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;com.getupnote.ios&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The log-scanning route is what I shipped weeks ago. It works, but on a freshly rebooted phone &lt;code&gt;getEntries&lt;/code&gt; alone took &lt;strong&gt;14.3 s&lt;/strong&gt;, and the first entry &lt;strong&gt;30.9 s&lt;/strong&gt; — against an 11 s deadline. That is why returning to the previous app sometimes just didn't happen.&lt;/p&gt;

&lt;p&gt;So the fix is not "make the log scan faster." The information was never late. &lt;strong&gt;Reading it was.&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  What I have not done
&lt;/h2&gt;

&lt;p&gt;I have not switched over.&lt;/p&gt;

&lt;p&gt;Months ago I wrote down a rule for this project: &lt;strong&gt;sending you back to the wrong app is worse than not sending you back at all.&lt;/strong&gt; If the return fails, I press one button. If it teleports me into a different app mid-sentence, my work is broken.&lt;/p&gt;

&lt;p&gt;So the build now running on my phone computes both answers on every mic tap and logs one line:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;🔔照合 KVO=com.getupnote.ios(0.8秒前) / ログ=com.getupnote.ios → ✅一致
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When enough of those agree, the slow path goes away. Until then it stays, and the fast one just watches.&lt;/p&gt;




&lt;h2&gt;
  
  
  If you are doing this
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;The host's identity is &lt;strong&gt;not&lt;/strong&gt; in your keyboard extension. Eight routes, all empty. Stop digging there.&lt;/li&gt;
&lt;li&gt;It &lt;strong&gt;is&lt;/strong&gt; in your containing app, as &lt;code&gt;_UIRemoteKeyboards._currentState._sourceBundleIdentifier&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;It is transient — observe it, don't poll it.&lt;/li&gt;
&lt;li&gt;Everything above is private API and can break in any iOS release. This is a personal replacement for an app I was paying for, not something I ship to you.&lt;/li&gt;
&lt;li&gt;Before you spend a night searching a process, &lt;strong&gt;count lines per process in a log you already have.&lt;/strong&gt; That one command reframed the entire question, and it cost nothing.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>ios</category>
      <category>swift</category>
      <category>debugging</category>
      <category>reverseengineering</category>
    </item>
  </channel>
</rss>
