feat: harden Flow cold-start/force-quit and polish macOS dictation UX

Fix cold-start overlay recursion that overflowed the main-thread stack when
recording began while the ready overlay was still up; also remove temporary
on-screen Flow DEBUG panels after the orange-mic investigation, and land the
macOS overlay/catalog/layout polish plus related Flow recovery hardening.
This commit is contained in:
Rocky
2026-07-10 12:39:41 +08:00
parent dcb66a9849
commit cdf833935a
104 changed files with 5794 additions and 853 deletions
+51 -10
View File
@@ -4,15 +4,17 @@
// Opens the host app from the keyboard extension.
//
// Reality check (verified against iOS 1826 behaviour):
// `extensionContext.open` is documented for Today widgets only; for a
// keyboard extension it resolves `false`, so we do not use it.
// The deprecated `openURL:` selector hack was disabled in iOS 18
// ("BUG IN CLIENT OF UIKIT migrate to open(_:options:completionHandler:)").
// The still-working path is: walk the responder chain to `UIApplication`
// and call the non-deprecated `open(_:options:completionHandler:)`. This
// requires Full Access and grows less reliable on newer iOS, so we report
// the *real* success from the completion handler instead of assuming it
// worked callers degrade to on-keyboard guidance when it returns false.
// Primary path: walk the responder chain to `UIApplication` and call the
// non-deprecated `open(_:options:completionHandler:)`. This requires Full
// Access and grows less reliable on newer iOS, so we report the *real*
// success from the completion handler instead of assuming it worked.
// Fallback: `extensionContext.open`. Historically documented for Today
// widgets only (and it used to resolve `false` for keyboards), but it is
// the Apple-documented API for extensions to open URLs and ships in
// production keyboards on current iOS worth trying before giving up.
// When both paths fail, callers degrade to on-keyboard guidance.
import UIKit
@@ -27,13 +29,52 @@ enum HostAppLauncher {
while let current = responder {
if let application = current as? UIApplication {
application.open(url, options: [:]) { success in
Task { @MainActor in completion(success) }
Task { @MainActor in
if success {
completion(true)
} else {
openViaExtensionContext(url: url, from: controller, completion: completion)
}
}
}
return
}
responder = current.next
}
// No `UIApplication` in the responder chain cannot open the host app.
completion(false)
// No `UIApplication` in the responder chain try the extension context.
openViaExtensionContext(url: url, from: controller, completion: completion)
}
@MainActor
private static func openViaExtensionContext(
url: URL,
from controller: KeyboardViewController,
completion: @escaping @MainActor (Bool) -> Void
) {
guard let context = controller.extensionContext else {
completion(false)
return
}
// `NSExtensionContext.open` from keyboards has historically been
// flaky about ever invoking its completion on some iOS versions.
// Callers rely on a real answer to fail fast (instead of spinning
// the 30 s start watchdog), so race the callback against a timeout
// and report the first result only.
var didComplete = false
let finish: @MainActor (Bool) -> Void = { success in
guard !didComplete else { return }
didComplete = true
completion(success)
}
Task { @MainActor in
// 1.5 s: long enough for a real open to call back, short enough
// that a dead completion degrades to on-keyboard guidance before
// the user gives up staring at nothing.
try? await Task.sleep(nanoseconds: 1_500_000_000)
finish(false)
}
context.open(url) { success in
Task { @MainActor in finish(success) }
}
}
}