feat(flow): harden host handoff, session UX, and Live Activity brand

- fix(keyboard): drop unusable extensionContext.open; report the real
  result of UIApplication.open and cancel the start watchdog immediately
  on failure with clear "open OSGKeyboard" guidance instead of a 30s spin
- feat(capture): recover from audio route changes (AirPods/wired headset
  plug-unplug) and phone-call interruptions by rebuilding the engine/tap
  against the live hardware format
- feat(home): add a "Start" button in the Home footer when a session is
  inactive and permissions are granted (no-jump manual restart)
- feat(hostapps): expand the return whitelist to 46 apps (Signal, Zoom,
  Instagram, X, Douyin, Zhihu, Bilibili, Things, Firefox, ...) with
  en/zh-Hans names and synced LSApplicationQueriesSchemes
- feat(island): use a "ready" checkmark instead of a mic glyph while idle
  and set a staleDate so an orphaned Live Activity fades after a force-quit
- feat(coldstart): use the template OSG brand mark instead of a system
  waveform glyph in the cold-start overlay
- perf(keyboard): rely on Darwin notifications as the primary session
  signal and drop the fallback poll from 1s to 3s
- fix(keyboard): remove the redundant "Start" text button next to the
  inactive-session hint on the keyboard
- docs: correct the per-take cap to 210s (3.5 min) and document that
  force-quitting no longer resurrects the session (tracker + READMEs)
This commit is contained in:
Rocky
2026-07-06 17:56:10 +08:00
parent 6a01b6cf71
commit 9ef0196d52
24 changed files with 560 additions and 197 deletions
+18 -26
View File
@@ -1,9 +1,18 @@
// HostAppLauncher.swift
// OSGKeyboard · Keyboard Extension
//
// Opens the host app via URL using extension-safe strategies:
// 1. `extensionContext.open` (official)
// 2. Responder-chain `UIApplication.open` (TypeWhisper pattern)
// 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.
import UIKit
@@ -14,34 +23,17 @@ enum HostAppLauncher {
from controller: KeyboardViewController,
completion: @escaping @MainActor (Bool) -> Void
) {
if let context = controller.extensionContext {
context.open(url) { success in
Task { @MainActor in
if success {
completion(true)
return
}
completion(openViaResponderChain(url, from: controller))
}
}
return
}
completion(openViaResponderChain(url, from: controller))
}
@MainActor
private static func openViaResponderChain(
_ url: URL,
from controller: KeyboardViewController
) -> Bool {
var responder: UIResponder? = controller
while let current = responder {
if let application = current as? UIApplication {
application.open(url, options: [:]) { _ in }
return true
application.open(url, options: [:]) { success in
Task { @MainActor in completion(success) }
}
return
}
responder = current.next
}
return false
// No `UIApplication` in the responder chain cannot open the host app.
completion(false)
}
}
@@ -61,12 +61,18 @@ final class KeyboardFlowCoordinator {
isPendingFlowStart || isFlowRecording || isAwaitingFlowResult
}
/// Session/transcription changes are pushed in real time by Darwin
/// notifications (see `KeyboardConfigSync.installDarwinObservers`), so this
/// loop is only a low-frequency safety net for coalesced/dropped Darwin
/// signals hence 3 s rather than 1 Hz to save battery while idle.
private static let sessionMonitorIntervalNs: UInt64 = 3_000_000_000
func startSessionMonitor() {
flowSessionMonitorTask?.cancel()
flowSessionMonitorTask = Task { @MainActor [weak self] in
while !Task.isCancelled {
self?.refreshSessionState()
try? await Task.sleep(nanoseconds: 1_000_000_000)
try? await Task.sleep(nanoseconds: Self.sessionMonitorIntervalNs)
}
}
}
@@ -168,8 +174,14 @@ final class KeyboardFlowCoordinator {
debug("openHostApp path=\(path) success=\(success)")
guard !success else { return }
// The open genuinely failed (iOS blocked it / no Full Access). Don't
// let the 30s watchdog spin cancel the pending start immediately and
// guide the user to open OSGKeyboard manually.
if path == "startflow", isPendingFlowStart {
state.lastTranscript = ExtL10n.string("keyboard.flow.manualOpenHost")
isPendingFlowStart = false
flowStartDeadline = 0
stopFlowWatchdog()
showManualOpenHint(path: "startflow")
return
}
+4 -15
View File
@@ -142,8 +142,7 @@ public struct KeyboardRootView: View {
micDisabled: state.micDisabled,
micDisabledHint: state.micDisabledHint,
cursorDragHintActive: state.cursorDragActive,
openSettings: state.openSettings,
startFlowSession: state.startFlowSession
openSettings: state.openSettings
)
.frame(height: KeyboardLayoutMetrics.transcriptLineHeight)
}
@@ -336,7 +335,6 @@ private struct TranscriptLine: View {
let micDisabledHint: String
let cursorDragHintActive: Bool
let openSettings: () -> Void
let startFlowSession: () -> Void
var body: some View {
ZStack {
@@ -366,18 +364,9 @@ private struct TranscriptLine: View {
.font(TypeStyle.caption)
.foregroundStyle(palette.textTertiary)
} else {
HStack(spacing: 6) {
ExtL10n.text("keyboard.flow.sessionInactive")
.font(TypeStyle.caption)
.foregroundStyle(palette.textTertiary)
Button(action: startFlowSession) {
ExtL10n.text("keyboard.flow.start")
.font(TypeStyle.caption)
.foregroundStyle(palette.accent)
}
.buttonStyle(.plain)
.accessibilityHint(ExtL10n.text("keyboard.flow.startA11y"))
}
ExtL10n.text("keyboard.flow.sessionInactive")
.font(TypeStyle.caption)
.foregroundStyle(palette.textTertiary)
}
case .requestingPermissions:
HStack(spacing: 6) {