Skip to content

Repository files navigation

KeyboardHostBundleID

Best-effort host app Bundle ID resolution from an iOS keyboard extension, using an arbiter hook on iOS 26.4+ and a legacy resolver on earlier systems.

Swift 5.9 Platform iOS 15+ SPM CocoaPods License MIT

Language: English · 简体中文


Problem

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.

What this library does

KeyboardHostBundleID gives you one unified call for these two strategies. Private API availability and callback delivery vary by system version; callers must handle nil:

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"
        }
    }

    override func viewWillDisappear(_ animated: Bool) {
        super.viewWillDisappear(animated)
        KeyboardHost.invalidateCache()
    }
}

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.

Installation

Swift Package Manager

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"])
]

CocoaPods

target 'YourKeyboardExtension' do
  pod 'KeyboardHostBundleID', '~> 1.0'
end

Manually

Drag 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).

Usage

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)
        }
    }

    override func viewWillDisappear(_ animated: Bool) {
        super.viewWillDisappear(animated)
        KeyboardHost.invalidateCache()
    }

    /// Historical observation for diagnostics. May belong to a previous app;
    /// do not use it to select a return-to-host destination.
    func lastObservedHost() -> String? {
        KeyboardHost.lastCapturedHostBundleId
    }
}

Tuning the synchronous wait

Call resolve(from:runloopWaitSeconds:) on the main thread while the keyboard's view is attached to a window, for example in viewDidAppear.

On iOS 26.4+, every call snapshots the capture generation, pings the arbiter, and waits up to the requested runloop interval (default 0.05s). Only a valid destination observed after that snapshot can be returned. If no new callback arrives, resolution returns nil, even when a previous Bundle ID is cached. The legacy path is used only on systems earlier than iOS 26.4.

Pass 0 to skip the wait; this can only return a callback delivered synchronously during the ping, and will otherwise return nil. Increasing the wait does not guarantee a callback. A nil result must not be replaced with lastCapturedHostBundleId when selecting a return-to-host destination, because that property is explicitly historical.

Call KeyboardHost.invalidateCache() when the keyboard disappears or your host session ends. It also cancels queries already waiting at that point. If you save IDs outside this library, clear or scope those values to the same host session too.

How it works (iOS 26.4+)

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:

  1. ObjC +load installer. The hook ships as an ObjC class; +load runs at dyld image-load time, strictly before any Swift runtime init and before UIKit's first arbiter dispatch. Using Swift's static let/singleton initialization would be too late — the first keyboard-focus event fires on launch and would be missed.

  2. Force +[_UIKeyboardArbiterClient enabled] to return YES. iOS 26.4 gates the destination-changed dispatch on this class method. Inside a keyboard extension process it returns NO by default and the callback chain is skipped entirely. method_setImplementation swaps it to an always-YES implementation so our trampoline gets called.

  3. Swizzle the instance method. class_getInstanceMethod + method_setImplementation replaces queue_keyboardChanged:onComplete: with a trampoline that reads _sourceBundleIdentifier off the change argument (not self) via KVC. Every event advances a lock-protected generation; valid IDs replace the cache, while empty/invalid values or KVC failures clear it. The original IMP is still called.

  4. Active ping for on-demand reads. KeyboardHost.resolve invokes +[_UIKeyboardArbiterClient automaticSharedArbiterClient] -> checkConnection on every modern-path query and spins a short runloop. The ping is a request, not a guarantee of a destination event. The result is read atomically with its generation so a cache left over from an earlier query cannot satisfy this call.

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 ., and must not equal the current extension's own bundle. com.apple.* is allowed because real system apps use that namespace too; a prefix alone cannot identify an internal arbiter identity.

Legacy path (iOS 16 ~ 26.3)

LegacyHostResolver.resolve(from:) is the classic chain, slightly hardened:

  1. inputVC.parent.value(forKey: "_hostPID")
  2. PKService.defaultService().personalities[bundleID][pid].connection._xpcConnection
  3. xpc_connection_copy_bundle_id(xpcConnection) via dlsym on libc.dylib

Every link returns nil silently on failure — callers handle nil.

Requirements

  • iOS 15.0+ (the library itself; the iOS 26.4 swizzle is gated at runtime)
  • Xcode 15+
  • Swift 5.9+

Known limitations

  • 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 UserDefaults yourself — the library intentionally does not assume an App Group identifier.
  • The active-ping path spins the current runloop briefly and can process lifecycle changes. Avoid frequent calls on latency-sensitive paths. With runloopWaitSeconds: 0, asynchronous captures cannot satisfy the current query.
  • resolve returns nil off the main thread or when the keyboard's view is not attached to a window. The view attachment is checked again after the wait.
  • A fresh arbiter event is a best-effort observation, not an authenticated host identity. The private API provides no public request/response correlation guarantee. Missing callbacks, late events, and future OS changes still require real-device testing.
  • iOS 27 system-app reports exposed an overly broad com.apple.* filter, which has been removed. This correction does not establish compatibility with every iOS 27 build or system app.

FAQ

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? On iOS 26.4+, resolve never falls back to a previously cached destination: it needs a new valid event during the current call, and returns nil if the query is invalidated or the view detaches during its wait. lastCapturedHostBundleId remains a historical accessor and can refer to a previous host. UIKit's private event semantics are not a guarantee of identity; test app-switch flows on your supported OS builds.

Why not put everything in Swift? The hook uses ObjC +load so it can be installed when its image loads, rather than waiting for the first access to a Swift singleton.

Regression checks

Run bash scripts/test-regressions.sh on macOS with Xcode installed. It compiles the actual ObjC hook and Swift resolver against a fake arbiter and a minimal view-attachment double. It checks app switches, system bundle IDs, missing/invalid events, lifecycle invalidation, callback forwarding, and zero-wait behavior. These deterministic checks do not exercise the real iOS private API.

Also build Example/KeyboardHostBundleIDExample.xcodeproj with the iOS SDK and test the keyboard on a device: third-party app A → B, third-party app → Notes/App Store → third-party app, dismissal/reopening, and rapid switching while the extension process is reused. Record the exact iOS version/build when reporting failures.

License

MIT. See LICENSE.

Contributing

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.

About

Resolve the host app's Bundle ID from an iOS keyboard extension — works on iOS 26.4+ with automatic fallback for iOS 16–26.3. 第三方键盘获取宿主 App Bundle ID 方案。

Topics

Resources

Stars

17 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages