Get the host app's Bundle ID from an iOS keyboard extension — fully working on iOS 26.4+, with an automatic fallback for iOS 16 ~ 26.3.
Language: English · 简体中文
If you ship a third-party iOS keyboard (UIInputViewController), you probably want to know which app the keyboard is currently embedded in — to tailor the UI, log analytics, or route deep-links back to the host.
For years the standard trick was:
parent._hostPID → PKService.defaultService.personalities[bundleID][pid].connection._xpcConnection
→ xpc_connection_copy_bundle_id(...)
This breaks on iOS 26.4. Apple refactored the private surface; _hostPID or the PKService personalities dictionary no longer yields a usable XPC connection, and xpc_connection_copy_bundle_id returns an empty string. Every keyboard relying on the old chain silently loses the host Bundle ID after the user upgrades.
KeyboardHostBundleID gives you one unified call that Just Works from iOS 16 all the way through iOS 26.4+:
import KeyboardHostBundleID
class MyKeyboardVC: UIInputViewController {
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
if let hostBundleId = KeyboardHost.resolve(from: self) {
print("Host app:", hostBundleId) // e.g. "com.apple.MobileSMS"
}
}
}Internally it dispatches to the right strategy automatically:
| iOS range | Strategy |
|---|---|
| iOS 26.4+ | Swizzles _UIKeyboardArbiterClientInputDestination and reads _sourceBundleIdentifier off the callback |
| iOS 16 – 26.3 | Legacy PKService + xpc_connection_copy_bundle_id chain |
No configuration. No app-group wiring. Drop it in and call one function.
In Xcode: File → Add Packages… and paste:
https://github.com/editorss/KeyboardHostBundleID.git
Add the KeyboardHostBundleID library product to your keyboard extension target (not just the main app).
Or in Package.swift:
dependencies: [
.package(url: "https://github.com/editorss/KeyboardHostBundleID.git", from: "1.0.0")
],
targets: [
.target(name: "YourKeyboardExtension", dependencies: ["KeyboardHostBundleID"])
]target 'YourKeyboardExtension' do
pod 'KeyboardHostBundleID', '~> 1.0'
endDrag Sources/KBHostArbiterHookObjC/ and Sources/KeyboardHostBundleID/ into your keyboard-extension target. Make sure the header KBHostArbiterHook.h is exposed via your bridging header (or a module map).
import KeyboardHostBundleID
class KeyboardViewController: UIInputViewController {
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
// Primary call. Returns the host app's Bundle ID or nil.
if let bid = KeyboardHost.resolve(from: self) {
configureUI(forHost: bid)
}
}
/// Cheap, side-effect-free read of whatever the swizzle has captured so
/// far. Use when you're polling or don't want to spin the runloop.
func currentHostIfKnown() -> String? {
KeyboardHost.lastCapturedHostBundleId
}
}resolve(from:runloopWaitSeconds:) accepts an optional runloop wait (default 0.05s) used after pinging the arbiter, giving the swizzle a chance to fire before we fall back to the legacy path. Pass 0 to skip the wait entirely.
The iOS 26.4 keyboard arbiter dispatches host-change events through
-[_UIKeyboardArbiterClientInputDestination queue_keyboardChanged:onComplete:].
The change argument carries a KVC key _sourceBundleIdentifier with the host app's Bundle ID.
The library installs itself with four careful moves:
-
ObjC
+loadinstaller. The hook ships as an ObjC class;+loadruns at dyld image-load time, strictly before any Swift runtime init and before UIKit's first arbiter dispatch. Using Swift'sstatic let/singleton initialization would be too late — the first keyboard-focus event fires on launch and would be missed. -
Force
+[_UIKeyboardArbiterClient enabled]to returnYES. iOS 26.4 gates the destination-changed dispatch on this class method. Inside a keyboard extension process it returnsNOby default and the callback chain is skipped entirely.method_setImplementationswaps it to an always-YES implementation so our trampoline gets called. -
Swizzle the instance method.
class_getInstanceMethod+method_setImplementationreplacesqueue_keyboardChanged:onComplete:with a trampoline that reads_sourceBundleIdentifieroff thechangeargument (notself) via KVC, caches it under anos_unfair_lock, and forwards to the original IMP. -
Active ping for on-demand reads. If you need the value synchronously before the arbiter has dispatched in the current process, call
KBHostArbiterHook.activeArbiterCheck(). It invokes+[_UIKeyboardArbiterClient automaticSharedArbiterClient] -> checkConnectionto nudge the arbiter, then spin a short runloop (done for you insideKeyboardHost.resolve).
All private symbol names (_UIKeyboardArbiterClient, queue_keyboardChanged:onComplete:, _sourceBundleIdentifier, …) are resolved at runtime via NSClassFromString / NSSelectorFromString so they never appear as static references.
Bundle IDs are sanity-checked before being cached: non-empty, must contain ., must not equal the current extension's own bundle, must not be com.apple.*.
LegacyHostResolver.resolve(from:) is the classic chain, slightly hardened:
inputVC.parent.value(forKey: "_hostPID")PKService.defaultService().personalities[bundleID][pid].connection._xpcConnectionxpc_connection_copy_bundle_id(xpcConnection)viadlsymonlibc.dylib
Every link returns nil silently on failure — callers handle nil.
- iOS 15.0+ (the library itself; the iOS 26.4 swizzle is gated at runtime)
- Xcode 15+
- Swift 5.9+
- Uses private APIs — Apple could change or remove them at any time. Re-test on every iOS beta.
- Single-process cache. If you need to share the captured Bundle ID with your main app or with other processes, write it into your App Group
UserDefaultsyourself — the library intentionally does not assume an App Group identifier. - The active-ping path spins the current runloop briefly. Avoid calling
resolveon a latency-sensitive path unless you passrunloopWaitSeconds: 0. KeyboardHost.resolve(from:)returnsnilif invoked before the extension has aparent(previewDidLoad). Call it fromviewDidAppearor later.
Does this work in the App Store? Using private APIs is always "at your own risk." Major apps (including a number of popular Chinese keyboards) have shipped similar techniques for years, but Apple's review is ultimately discretionary. The library does not rely on any single private symbol being present — every runtime lookup is guarded and fails silently.
Will it ever return the wrong Bundle ID?
The swizzle captures the most recent destination change reported by UIKit's arbiter, which is exactly the host your keyboard is currently attached to. The validation filter rejects your own bundle and Apple internal identities. That said, if you call resolve from a weird lifecycle moment (e.g. while the keyboard is being torn down), you may read a stale value.
Why not put everything in Swift?
Swift class initialization is triggered lazily on first use, which is too late for UIKit's initial arbiter dispatch. ObjC +load is the only mechanism that runs before the arbiter fires.
MIT. See LICENSE.
Issues and PRs welcome — especially reports confirming behavior on new iOS betas. Please include your exact iOS version and whether the iOS 26.4+ path or the legacy path succeeded.