[docs] the app cannot ask who was frontmost either, and why the probe was dropped - #329
Open
YJack0000 wants to merge 2 commits into
Open
[docs] the app cannot ask who was frontmost either, and why the probe was dropped#329YJack0000 wants to merge 2 commits into
YJack0000 wants to merge 2 commits into
Conversation
On iOS 26.4 and later `HostBundleID` returns nil on every call — Apple emptied both `_hostBundleID` and `_hostApplicationBundleIdentifier` — so `begin()` fails its `guard let host` and the jump back to the host app is never even attempted. Confirmed on a user's iPadOS 26.5 device: no auto-return at all. Worse for us than for the user, every session now reads as `.skip(.noHost)`, which is indistinguishable from a device that cannot do the jump, so the ledger built to find that out learns nothing. The lead was a sentence of Apple's own: DTS saying `LSApplicationWorkspace.frontmostApplication` is unavailable *from an extension*, and the container app not being an extension. It is the process that is running when `parley://dictate` is delivered, so the question was whether the same call is answered on that side of the App Group boundary. `HostFrontmost` asks it, and `begin()` uses the answer only when the keyboard's own read came back empty — the existing path is untouched and still preferred, because it is the only source that is about this session rather than about the device at this instant. Having then gone looking for the rest of that sentence, the lead does not survive, and the second half of this change is writing that down where the next person will find it rather than repeating the week. DTS was asked both questions separately — can a keyboard identify its host, and can the container app identify which app hosted the extension that opened it — and answered "No" to each (forum thread 826851, June 2026, tested on 26.4.2). FB22247647 is open and unscheduled, and the engineer who answered called host identity "an obvious privacy concern" while sketching a replacement that returns the user without naming the app. `frontmostApplication` is not in any published `LSApplicationWorkspace` header either; it is `NSWorkspace`'s property, on macOS. `sourceApplication` is nil across teams by documented design and reports our own container when the opener is ours. SpringBoardServices and FrontBoardServices frontmost queries are declared only in 2010–2014 headers. KeyboardKit's equivalent has returned nil since the 26.4 betas and still does on iOS 27 betas; Wispr Flow's support docs now tell users to swipe. So this ships as a probe with a low prior, not as a fix, and the code is arranged so its three outcomes stay distinguishable on hardware instead of collapsing into "no host": nothing responds (the selector is not there, which is the prediction), nil (it is there and refuses), or our own bundle id (it works and we asked too late, because by then Parley is what LaunchServices considers frontmost). That last one is why the exclusion set is a parameter rather than an afterthought — answering with Parley's own id would have `HostReturn` relaunch Parley from Parley. Both sources are read before `launch()`, because that answer decays with every millisecond the app spends coming forward. It runs behind the switches that already exist for this path: the remote kill switch, the per-OS override, and the ledger. Nothing new is on by default that was not already on. The safety rules are `HostBundleID`'s, for `HostBundleID`'s reasons. Symbols assembled from fragments so no literal lands in the binary; `NSClassFromString` and `responds(to:)` before any `perform`; `class_getInstanceVariable` before the single KVC read, because `value(forKey:)` on an undeclared key raises an Objective-C exception Swift cannot catch. One rule is new, and it is here precisely because these selector names are guesses at names rather than reads of a header: `method_copyReturnType` is checked too. `responds(to:)` says only that a method exists, and `perform` treats every result as an object pointer, so a same-named method returning an integer would put a scalar through `takeUnretainedValue()` and crash the probe that was written not to. SpringBoardServices is deliberately absent. `SBSCopyFrontmostApplicationDisplayIdentifier` needs a `dlopen` of a private framework by path, which is the most legible thing a static scan can find, and its prototype differs between eras of the SDK — some headers return the string, others return an error code and write the string through an out-parameter. Calling a C function through the wrong prototype is how a probe designed to degrade to nil becomes a crash. The workspace is injected for the same reason `HostBundleID.resolve` takes an `NSObject`: the dangerous half is then exercisable on a machine that has no LaunchServices at all. Twenty-one tests cover it, and the ones that matter are the ones asserting that an unfamiliar class, a missing ivar and a scalar-returning selector all come back nil rather than taking the process down. None of this has run on a device. It cannot be, from here. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… was dropped The previous commit implemented `HostFrontmost` on the premise that DTS's "unavailable from an extension" left room for the container app, which is not an extension. It is dropped here; only the record of why survives. Three findings, in ascending order of how decisive they are: - The same DTS thread answers a second question — whether the container app can identify which app hosted the extension that opened it — and the answer is also "No". The premise was already refuted in the source it came from. - `frontmostApplication` is not an `LSApplicationWorkspace` method at all. It is `NSWorkspace`'s, on macOS. The likeliest outcome was never a refusal but `responds(to:)` returning false. - Even granting both, the question is the wrong one. By the time `parley://dictate` is delivered, Parley is what LaunchServices considers frontmost. The honest answer to "who is in front" is us; what the jump needs is who was in front a moment ago, and nothing reachable from a sandboxed app records that. The probe could only ever have returned our own bundle id and had `HostReturn` relaunch Parley from Parley. Against a payoff of roughly nothing: more private-API surface in a keyboard that already asks for Full Access, on a path App Review templates name explicitly. So the implementation was written, verified crash-safe, and left out of the build. It stays in this branch's history if the ground ever shifts. What is left is what the category converged on: keep the microphone window so there is no round trip to make, and let the destination be chosen rather than detected. Wispr Flow's docs now tell users to swipe back; KeyboardKit ships a curated list plus asking the user.
✅ SonarQube Quality Gate passed — pathorsAI_parley0 open issues on this PR. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Outcome: a negative result, recorded. No behaviour changes.
The net diff is one design-doc section. A probe was implemented, verified
crash-safe, and then deliberately left out of the build — it survives in this
branch's history (
7fb5308) if the ground ever shifts.The question
On iOS 26.4+
HostBundleIDreturnsnilon every call, sobegin()fails itsguard let hostand the jump is never attempted. Every session is.skip(.noHost)— indistinguishable from a device that cannot do it at all.The lead: Apple DTS said
LSApplicationWorkspace.frontmostApplicationisunavailable from an extension, and the container app is not an extension.
So — could the app ask who was frontmost, and skip the keyboard entirely?
Three findings, in ascending order of how decisive they are
The premise was already refuted in its own source. Developer forums
thread 826851 (June 2026, tested on 26.4.2) asks DTS two questions: can a
keyboard identify its host, and can the container app identify which app
hosted the extension that opened it. Both answers are "No". FB22247647 is
open and unscheduled. The same engineer calls host identity "an obvious
privacy concern" and sketches a replacement that returns the user without
naming the app — the strongest available signal that no future API hands
over a bundle id at all.
frontmostApplicationis not anLSApplicationWorkspacemethod. It doesnot appear in any published header for the class; it is
NSWorkspace'sproperty, on macOS. This was never "blocked in extensions, maybe open in
apps" — there is nothing there to block. The likeliest outcome of the probe
was
responds(to:)returning false.Even granting both, it is the wrong question. By the time
parley://dictateis delivered, Parley is what LaunchServices considersfrontmost. The honest answer to "who is in front" is us. What the jump
needs is who was in front a moment ago, and nothing reachable from a
sandboxed app records that. The probe could only ever have returned our own
bundle id — which would have
HostReturnrelaunch Parley from Parley.Why it was dropped rather than shipped behind a flag
The payoff was roughly nothing, and the cost is not nothing: more private-API
surface, in a keyboard that already requests Full Access, on a code path App
Review rejection templates name explicitly. The remote kill switch from #321
would have covered the risk, but a switch is not a reason to add a thing that
was never going to work.
Also checked, also dead
sourceApplicationisnilby documented design whenever the opener's teamidentifier differs from ours — so every host that matters — and reports our own
container when the opener is our keyboard. SpringBoardServices and
FrontBoardServices frontmost queries are declared only in headers from 2010–2014
with no report of working this decade; neighbouring services probed on 26.4.2
returned
EPERMand RunningBoard errors.One loose thread, flagged rather than resolved
The forum OP reports WeChat Keyboard and Typeless still round-tripping on 26.4
and producing a "← WeChat" back affordance, and nobody — including DTS —
explained how. That is not evidence a method exists: cached ids from before the
26.4 upgrade, curated URL-scheme lists, and misattributed system swipe-back all
explain it equally well. Noted so the next person knows it was seen and not
dismissed.
What this leaves
What the category converged on, and what Parley already has half of:
own 2026 pivot was exactly this.
list of apps you dictate into. Worse UX, but it works and it does not break
every time Apple closes a hole.
Wispr Flow's support docs now instruct users to swipe right on the bottom bar,
which is what
SwipeBackGuidealready says.Verification
swift build --package-path ios/ParleyKitclean. No code changes remain in thisPR, so there is nothing else to verify — and note that
.github/workflows/ci.ymlhas
paths-ignore: docs/**, so CI will not run on it.🤖 Generated with Claude Code