Skip to content

[feature] the jump back to the host app is decided at runtime, and can be switched off remotely - #321

Merged
Lanznx merged 1 commit into
mainfrom
feature/host-return-remote-flag
Sep 2, 2026
Merged

Lanznx merged 1 commit into
mainfrom
feature/host-return-remote-flag

Conversation

@YJack0000

Copy link
Copy Markdown
Contributor

What

HostReturn refused to run at all on iOS 26.4+, from a compiled-in
#available(iOS 26.4, *) gate. This replaces that gate with a runtime policy
that has a remote kill switch and learns from what actually happens on the
device.

Why

The gate was written when the first reports said Apple closed the host-return
path in 26.4. Keyboards in the same category are observably still returning
users to their host app on iPadOS 26.5
, so the gate was switching off
something that works.

The wrong number is the smaller half of the problem. The larger half is that a
compiled-in answer cannot be corrected: for a path built on undocumented
internals, whose ground truth moves with every iOS point update in both
directions, a build-time constant is the wrong shape of answer entirely — and it
takes an App Store release to change.

How

HostReturnPolicy.decide answers "attempt the jump?" from three inputs, none of
which is a prediction:

Input What it is for
FeatureFlags.HostReturn.enabled Kill switch. Outranks everything, including a per-OS opt-in.
FeatureFlags.HostReturn.byOSVersion Turn one iOS off — or back on, which is the only way to re-arm devices that have given up locally.
HostReturnLedger What this device learned by trying. Two consecutive failures on one OS build and it stops.

Flags are fetched by CloudClient.featureFlags() and cached in the App Group by
FeatureFlagStore, so the keyboard extension reads the same answer the app acts
on. The ledger is stamped with the full OS build string, so an OS update wipes
the count and the device tries again by itself
— exactly the moment the answer
might have changed.

HostReturn.attemptAndVerify is where the learning happens: it fires the launch,
polls for up to 1.2 s to see whether the app actually got backgrounded, and
records the verdict.

Optimistic, then honest

returnableHost is set the moment the jump is attempted, so DictationView
shows the hand-off shape immediately; it is cleared again if the app is still
foregrounded when the grace period expires, and the swipe-back guidance takes
over. That 1.2 s is the whole cost of being wrong, it is only paid while the
ledger is undecided, and it buys the one thing a version check never could — a
device that finds out.

Why the kill switch ships in this PR rather than after it

The path this governs is private API. The realistic failure mode is not a bug,
it is App Review objecting under 2.5.1 to a build that is already out. A switch
that requires a release to reach is not a switch. This one is a server-side
field change.

Fail-safe

Nothing here can break dictation:

  • every read is synchronous and comes from the local cache; the network only
    ever updates that cache, out of band;
  • a 404 from GET /v1/flags is folded into "no opinion", not into an error,
    so until the endpoint is published every device runs on compiled defaults and
    nothing about dictation changes;
  • a corrupt cache, a missing App Group, or a flag document that only mentions
    some future feature all decode to the compiled defaults rather than throwing —
    a throw here is indistinguishable from "the server is down", which would
    freeze the fleet on whatever it last saw.

Server-side follow-up

GET /v1/flags does not exist yet. Publishing it is the only remaining step to
gain the switch; no client release is needed. Shape:

{ "hostReturn": { "enabled": true, "byOSVersion": { "26.4": false }, "failureBudget": 2 } }

Testing

  • swift build on ParleyKit is clean, no warnings.
  • 27 new assertions across HostReturnPolicyTests, HostReturnLedgerTests,
    FeatureFlagsTests, FeatureFlagStoreTests — decision precedence, the budget
    and its clamp, the OS-update reset, and every degradation path.
  • bunx tsc --noEmit clean; bunx vitest run 285/285 (no TypeScript touched
    here).

⚠️ swift test and the iOS app target were not run locally. This machine has
Command Line Tools but no Xcode, so XCTest cannot link and the app target cannot
build. The ParleyKit logic was instead verified by compiling the sources into a
standalone harness asserting the same 27 behaviours (all pass), and the five
modified app-target files were checked with swiftc -parse. CI is the first
real build of the app target and of the XCTest suite
— worth watching before
merge.

Not verified, and cannot be from here

Whether the jump actually lands, on any given iOS. That is the point of the
ledger: this stops the codebase from guessing and starts it measuring. The first
real signal will be devices on 26.5 either returning or not.

🤖 Generated with Claude Code

…n be switched off remotely

`HostReturn` refused to run at all on iOS 26.4+, from a compiled-in
`#available(iOS 26.4, *)` gate written when the first reports said Apple had
closed the path. Keyboards in the same category are observably still returning
users to their host app on iPadOS 26.5, so the gate was switching off something
that works — and because it was compiled in, no amount of evidence could
correct it without an App Store release.

The number was probably wrong. More importantly, a number is the wrong shape of
answer for a private-API path whose ground truth moves with every iOS point
update, in both directions.

`HostReturnPolicy` replaces it with three inputs, none of which is a prediction:

- **A remote flag.** `FeatureFlags.HostReturn`, fetched by `CloudClient` and
  cached in the App Group by `FeatureFlagStore`. `enabled: false` is a kill
  switch that outranks everything else, and `byOSVersion` turns one iOS off —
  or back on, which is the only way to re-arm devices that have given up
  locally. Shipping the switch in the same change as the private-API path is
  the point: a 2.5.1 objection can now be answered in minutes rather than in a
  release.
- **Evidence the device collected about itself.** `attemptAndVerify` fires the
  launch and then watches whether the app actually got backgrounded, recording
  the outcome in `HostReturnLedger`. Two consecutive failures and the device
  stops trying.
- **The OS version as a key, never as a threshold.** The ledger is stamped with
  the full OS build string, so an update wipes the count and the device tries
  again by itself — exactly when the answer might have changed.

The dictation screen is optimistic and then honest: `returnableHost` is set the
moment the jump is attempted and cleared again if the app is still foregrounded
1.2 s later, at which point the swipe-back guidance takes over. That 1.2 s is
the entire cost of being wrong, it is only paid while the ledger is undecided,
and it buys what a version check never could — a device that finds out.

Fail-safe throughout. Every read is synchronous and local; the network only
updates the cache. A 404 from `GET /v1/flags` is folded into "no opinion"
rather than an error, so until the endpoint is published every device runs on
the compiled defaults and nothing about dictation changes.
@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.

@Lanznx
Lanznx merged commit 6c64825 into main Sep 2, 2026
1 check passed
Lanznx added a commit that referenced this pull request Sep 2, 2026
Ships #321. 1.6.1 carried a compiled-in `#available(iOS 26.4, *)` gate that
refused the jump back to the host app on the newest iOS — a number written from
early reports, on devices where the path observably still works. It is now a
runtime decision made from a remote flag, per-device evidence, and the OS build
as a key rather than a threshold, so the answer can be corrected without a
release and a device re-tries by itself after an iOS update.

Build 17 because App Store Connect has already seen 16.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
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.

2 participants