Files
OSGKeyboard/OSGKeyboardExt/Services/HostAppLauncher.swift
T
Rocky cdf833935a 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.
2026-07-10 12:39:41 +08:00

81 lines
3.3 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// HostAppLauncher.swift
// OSGKeyboard · Keyboard Extension
//
// Opens the host app from the keyboard extension.
//
// Reality check (verified against iOS 1826 behaviour):
// • The deprecated `openURL:` selector hack was disabled in iOS 18
// ("BUG IN CLIENT OF UIKIT … migrate to open(_:options:completionHandler:)").
// • 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
enum HostAppLauncher {
@MainActor
static func open(
url: URL,
from controller: KeyboardViewController,
completion: @escaping @MainActor (Bool) -> Void
) {
var responder: UIResponder? = controller
while let current = responder {
if let application = current as? UIApplication {
application.open(url, options: [:]) { success in
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 — 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) }
}
}
}