Skip to content

[docs] the app cannot ask who was frontmost either, and why the probe was dropped - #329

Open
YJack0000 wants to merge 2 commits into
mainfrom
feature/host-return-frontmost
Open

[docs] the app cannot ask who was frontmost either, and why the probe was dropped#329
YJack0000 wants to merge 2 commits into
mainfrom
feature/host-return-frontmost

Conversation

@YJack0000

Copy link
Copy Markdown
Contributor

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+ HostBundleID returns nil on every call, so begin() fails its
guard let host and 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.frontmostApplication is
unavailable 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

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

  2. frontmostApplication is not an LSApplicationWorkspace method. It does
    not appear in any published header for the class; it is NSWorkspace's
    property, 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.

  3. Even granting both, it is the wrong question. 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 — which would have HostReturn relaunch 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

sourceApplication is nil by documented design whenever the opener's team
identifier 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 EPERM and 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:

  • Keep the microphone window, so there is no round trip to make. KeyboardKit's
    own 2026 pivot was exactly this.
  • Let the destination be chosen rather than detected — a curated/remembered
    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 SwipeBackGuide already says.

Verification

swift build --package-path ios/ParleyKit clean. No code changes remain in this
PR, so there is nothing else to verify — and note that .github/workflows/ci.yml
has paths-ignore: docs/**, so CI will not run on it.

🤖 Generated with Claude Code

YJack0000 and others added 2 commits September 3, 2026 00:18
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.
@github-actions

github-actions Bot commented Sep 2, 2026

Copy link
Copy Markdown

✅ SonarQube Quality Gate passed — pathorsAI_parley

0 open issues on this PR.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant