DEV Community

nr666_dev
nr666_dev

Posted on Originally published at allrebuilt.substack.com AI-assisted

An iOS keyboard extension cannot learn the host app's bundle ID. The containing app can.

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.

The problem. 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 send you back to the app you came from. To do that it needs the host app's bundle identifier — com.getupnote.ios, or whatever you were typing into.

The commercial app I am rebuilding does this. I have its URL from a device log:

typeless://main/home?action=onStartVoiceRecording&hostAppBundleId=com.getupnote.ios
Enter fullscreen mode Exit fullscreen mode

So it is possible. The question was how.


Eight dead ends inside the extension

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.

What I read Result
_UIViewServiceViewControllerOperator._hostBundleID NSNull
_UIHostedWindow.__hostBundleIdentifier nil
_hostSDKVersion on the same object nil
proc_pidpath(hostPID) — in the extension and in the containing app EPERM both
sysctl(KERN_PROC_PID) fails
_hostAuditToken → SecTaskCreateWithAuditToken → SecTaskCopySigningIdentifier nil; entitlement reads give NSPOSIXErrorDomain code=1
FBSScene._identity PseudoScene:<UUID> — the extension's own pseudo-scene, not the host's
BSProcessHandle._bundleID / ._name reached over the XPC graph both nil

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

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


The measurement that ended it

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.

I counted lines per process.

Log line Their keyboard extension My keyboard extension My containing app
_UIKeyboardChangedInformation received 209 0 4
arbiterclient connections 34 0 0
RX keyboardChanged 17 0 2

iOS broadcasts "the keyboard now belongs to X" to processes that register as keyboard arbiter clients. 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 UITextField becoming first responder, with and without an empty inputView. Captured a fresh device log archive with sudo log collect --device-name … --last 10m and counted again: still 0. That route is closed, and now I have the instrument to say so rather than guess.)

But look at the third column. My containing app already receives it. Four times, during a single cold launch.

I had spent six hours in a process where the value does not exist, while it was arriving in the process next door.


Where it actually is

In the containing app, reachable from UIApplication.shared:

windowScene
 └ _registeredComponents["_UIKeyboardSceneDelegateSceneComponentKey"]   UIKeyboardSceneDelegate
    └ _containerWindow._editingOverlayViewController._parentViewController._hosting
       └ _hostingItems[]._controllerDelegate                            _UIRemoteKeyboards
          └ _currentState                                               _UIKeyboardChangedInformation
             └ _sourceBundleIdentifier                                  "com.getupnote.ios"
Enter fullscreen mode Exit fullscreen mode

A generic sweep that walks object-typed ivars with object_getIvar — never calling a method — finds it in 13 milliseconds, about 900 nodes deep into the graph.

Three things cost me an extra hour here, and they are the interesting part:

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

What works is KVO, which is public API and needs no swizzling:

remoteKeyboards.addObserver(watcher, forKeyPath: "currentState",
                            options: [.new, .initial], context: nil)
Enter fullscreen mode Exit fullscreen mode

.initial 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.


The numbers

Same cold launch, both methods running side by side:

Method Time to answer Value
KVO on _currentState 0.75 s after the mic tap, 13 ms of CPU com.getupnote.ios
Scanning my own OSLogStore for source bundle 5,464 ms (and it had to re-scan once) com.getupnote.ios

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

So the fix is not "make the log scan faster." The information was never late. Reading it was.


What I have not done

I have not switched over.

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

So the build now running on my phone computes both answers on every mic tap and logs one line:

🔔照合 KVO=com.getupnote.ios(0.8秒前) / ログ=com.getupnote.ios → ✅一致
Enter fullscreen mode Exit fullscreen mode

When enough of those agree, the slow path goes away. Until then it stays, and the fast one just watches.


If you are doing this

  • The host's identity is not in your keyboard extension. Eight routes, all empty. Stop digging there.
  • It is in your containing app, as _UIRemoteKeyboards._currentState._sourceBundleIdentifier.
  • It is transient — observe it, don't poll it.
  • 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.
  • Before you spend a night searching a process, count lines per process in a log you already have. That one command reframed the entire question, and it cost nothing.

Top comments (0)