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:
@@ -163,6 +163,10 @@ public final class FlowContinuousCapture {
|
||||
private var didInstallTap = false
|
||||
private var isRunning = false
|
||||
|
||||
private var routeObserver: NSObjectProtocol?
|
||||
private var interruptionObserver: NSObjectProtocol?
|
||||
private let log = Logger(subsystem: "com.osgkeyboard.shared", category: "FlowCapture")
|
||||
|
||||
public init() {}
|
||||
|
||||
public var running: Bool { isRunning }
|
||||
@@ -170,7 +174,16 @@ public final class FlowContinuousCapture {
|
||||
/// Configure `.playAndRecord`, install a permanent input tap, start the engine.
|
||||
public func start() throws {
|
||||
guard !isRunning else { return }
|
||||
try activateEngine()
|
||||
isRunning = true
|
||||
installSessionObservers()
|
||||
}
|
||||
|
||||
/// Bring up the audio session + engine for the *current* hardware route.
|
||||
/// Reused for route-change / interruption recovery, so it always rebuilds
|
||||
/// the tap against the live hardware format (which changes when the user
|
||||
/// plugs in AirPods or a wired headset mid-session).
|
||||
private func activateEngine() throws {
|
||||
let session = AVAudioSession.sharedInstance()
|
||||
do {
|
||||
try session.setCategory(
|
||||
@@ -204,23 +217,22 @@ public final class FlowContinuousCapture {
|
||||
throw StartError.converterCreateFailed
|
||||
}
|
||||
|
||||
if !didInstallTap {
|
||||
let utteranceFlag = isUtteranceActive
|
||||
let relay = streamRelay
|
||||
let preroll = prerollStore
|
||||
let levels = levelStore
|
||||
let tap = Self.makeAudioTapBlock(
|
||||
converter: converter,
|
||||
targetFormat: targetFormat,
|
||||
hwFormat: hwFormat,
|
||||
utteranceFlag: utteranceFlag,
|
||||
levelStore: levels,
|
||||
prerollStore: preroll,
|
||||
streamRelay: relay
|
||||
)
|
||||
inputNode.installTap(onBus: 0, bufferSize: 4096, format: hwFormat, block: tap)
|
||||
didInstallTap = true
|
||||
// Rebuild the tap so its bound hardware format matches the new route.
|
||||
if didInstallTap {
|
||||
inputNode.removeTap(onBus: 0)
|
||||
didInstallTap = false
|
||||
}
|
||||
let tap = Self.makeAudioTapBlock(
|
||||
converter: converter,
|
||||
targetFormat: targetFormat,
|
||||
hwFormat: hwFormat,
|
||||
utteranceFlag: isUtteranceActive,
|
||||
levelStore: levelStore,
|
||||
prerollStore: prerollStore,
|
||||
streamRelay: streamRelay
|
||||
)
|
||||
inputNode.installTap(onBus: 0, bufferSize: 4096, format: hwFormat, block: tap)
|
||||
didInstallTap = true
|
||||
|
||||
audioEngine.prepare()
|
||||
do {
|
||||
@@ -228,11 +240,11 @@ public final class FlowContinuousCapture {
|
||||
} catch {
|
||||
throw StartError.engineStartFailed(error.localizedDescription)
|
||||
}
|
||||
isRunning = true
|
||||
}
|
||||
|
||||
/// Tear down the engine and release the audio session.
|
||||
public func stop() {
|
||||
removeSessionObservers()
|
||||
isUtteranceActive.withLock { $0 = false }
|
||||
streamRelay.finish()
|
||||
|
||||
@@ -266,6 +278,98 @@ public final class FlowContinuousCapture {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Route / interruption recovery
|
||||
|
||||
private func installSessionObservers() {
|
||||
let center = NotificationCenter.default
|
||||
if routeObserver == nil {
|
||||
routeObserver = center.addObserver(
|
||||
forName: AVAudioSession.routeChangeNotification,
|
||||
object: nil,
|
||||
queue: .main
|
||||
) { [weak self] note in
|
||||
// Extract Sendable primitives here (Notification isn't Sendable)
|
||||
// before hopping onto the main actor.
|
||||
let reasonRaw = note.userInfo?[AVAudioSessionRouteChangeReasonKey] as? UInt
|
||||
MainActor.assumeIsolated { self?.handleRouteChange(reasonRaw: reasonRaw) }
|
||||
}
|
||||
}
|
||||
if interruptionObserver == nil {
|
||||
interruptionObserver = center.addObserver(
|
||||
forName: AVAudioSession.interruptionNotification,
|
||||
object: nil,
|
||||
queue: .main
|
||||
) { [weak self] note in
|
||||
let typeRaw = note.userInfo?[AVAudioSessionInterruptionTypeKey] as? UInt
|
||||
let optionsRaw = note.userInfo?[AVAudioSessionInterruptionOptionKey] as? UInt
|
||||
MainActor.assumeIsolated {
|
||||
self?.handleInterruption(typeRaw: typeRaw, optionsRaw: optionsRaw)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func removeSessionObservers() {
|
||||
let center = NotificationCenter.default
|
||||
if let routeObserver { center.removeObserver(routeObserver) }
|
||||
if let interruptionObserver { center.removeObserver(interruptionObserver) }
|
||||
routeObserver = nil
|
||||
interruptionObserver = nil
|
||||
}
|
||||
|
||||
private func handleRouteChange(reasonRaw: UInt?) {
|
||||
guard isRunning else { return }
|
||||
guard let reasonRaw,
|
||||
let reason = AVAudioSession.RouteChangeReason(rawValue: reasonRaw) else { return }
|
||||
// Only rebuild for real device swaps (plugging / unplugging a headset
|
||||
// or AirPods). Ignore `.categoryChange`, which we trigger ourselves.
|
||||
switch reason {
|
||||
case .oldDeviceUnavailable, .newDeviceAvailable:
|
||||
log.info("Audio route changed (\(reasonRaw, privacy: .public)) — rebuilding engine")
|
||||
rebuildEngine()
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
private func handleInterruption(typeRaw: UInt?, optionsRaw: UInt?) {
|
||||
guard let typeRaw,
|
||||
let type = AVAudioSession.InterruptionType(rawValue: typeRaw) else { return }
|
||||
switch type {
|
||||
case .began:
|
||||
// The system already paused our engine; wait for `.ended`.
|
||||
log.info("Audio interruption began")
|
||||
case .ended:
|
||||
guard isRunning else { return }
|
||||
let shouldResume: Bool
|
||||
if let optionsRaw {
|
||||
shouldResume = AVAudioSession.InterruptionOptions(rawValue: optionsRaw).contains(.shouldResume)
|
||||
} else {
|
||||
shouldResume = true
|
||||
}
|
||||
if shouldResume {
|
||||
log.info("Audio interruption ended — resuming capture")
|
||||
rebuildEngine()
|
||||
}
|
||||
@unknown default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
/// Stop and rebuild the engine against the current route, keeping
|
||||
/// `isRunning` intact so the session survives the swap transparently.
|
||||
private func rebuildEngine() {
|
||||
guard isRunning else { return }
|
||||
if audioEngine.isRunning {
|
||||
audioEngine.stop()
|
||||
}
|
||||
do {
|
||||
try activateEngine()
|
||||
} catch {
|
||||
log.error("Engine rebuild failed: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
}
|
||||
|
||||
/// Begin forwarding downsampled buffers to ASR for one utterance.
|
||||
public func beginUtterance() -> AsyncStream<AudioBufferSnapshot> {
|
||||
let (stream, continuation) = AsyncStream<AudioBufferSnapshot>.makeStream()
|
||||
|
||||
@@ -156,6 +156,153 @@ public enum HostAppURLRegistry {
|
||||
displayNameKey: "hostApp.xiaohongshu",
|
||||
returnURLString: "xhsdiscover://",
|
||||
tier: 4
|
||||
),
|
||||
// Tier 2 (cont.) — global IM / calls
|
||||
HostAppEntry(
|
||||
bundleId: "org.whispersystems.signal",
|
||||
displayNameKey: "hostApp.signal",
|
||||
returnURLString: "sgnl://",
|
||||
tier: 2
|
||||
),
|
||||
HostAppEntry(
|
||||
bundleId: "com.iwilab.KakaoTalk",
|
||||
displayNameKey: "hostApp.kakaotalk",
|
||||
returnURLString: "kakaotalk://",
|
||||
tier: 2
|
||||
),
|
||||
HostAppEntry(
|
||||
bundleId: "com.viber",
|
||||
displayNameKey: "hostApp.viber",
|
||||
returnURLString: "viber://",
|
||||
tier: 2
|
||||
),
|
||||
HostAppEntry(
|
||||
bundleId: "com.vng.zaloapp",
|
||||
displayNameKey: "hostApp.zalo",
|
||||
returnURLString: "zalo://",
|
||||
tier: 2
|
||||
),
|
||||
HostAppEntry(
|
||||
bundleId: "com.skype.skype",
|
||||
displayNameKey: "hostApp.skype",
|
||||
returnURLString: "skype://",
|
||||
tier: 2
|
||||
),
|
||||
HostAppEntry(
|
||||
bundleId: "us.zoom.videomeetings",
|
||||
displayNameKey: "hostApp.zoom",
|
||||
returnURLString: "zoomus://",
|
||||
tier: 2
|
||||
),
|
||||
// Tier 3 (cont.) — notes / mail / browser
|
||||
HostAppEntry(
|
||||
bundleId: "com.culturedcode.ThingsiPhone",
|
||||
displayNameKey: "hostApp.things",
|
||||
returnURLString: "things://",
|
||||
tier: 3
|
||||
),
|
||||
HostAppEntry(
|
||||
bundleId: "com.todoist.ios",
|
||||
displayNameKey: "hostApp.todoist",
|
||||
returnURLString: "todoist://",
|
||||
tier: 3
|
||||
),
|
||||
HostAppEntry(
|
||||
bundleId: "com.evernote.iPhone.Evernote",
|
||||
displayNameKey: "hostApp.evernote",
|
||||
returnURLString: "evernote://",
|
||||
tier: 3
|
||||
),
|
||||
HostAppEntry(
|
||||
bundleId: "com.microsoft.onenote",
|
||||
displayNameKey: "hostApp.onenote",
|
||||
returnURLString: "onenote://",
|
||||
tier: 3
|
||||
),
|
||||
HostAppEntry(
|
||||
bundleId: "com.readdle.smartemail",
|
||||
displayNameKey: "hostApp.spark",
|
||||
returnURLString: "readdle-spark://",
|
||||
tier: 3
|
||||
),
|
||||
HostAppEntry(
|
||||
bundleId: "org.mozilla.ios.Firefox",
|
||||
displayNameKey: "hostApp.firefox",
|
||||
returnURLString: "firefox://",
|
||||
tier: 3
|
||||
),
|
||||
HostAppEntry(
|
||||
bundleId: "com.microsoft.msedge",
|
||||
displayNameKey: "hostApp.edge",
|
||||
returnURLString: "microsoft-edge://",
|
||||
tier: 3
|
||||
),
|
||||
// Tier 4 (cont.) — global / China social
|
||||
HostAppEntry(
|
||||
bundleId: "com.facebook.Facebook",
|
||||
displayNameKey: "hostApp.facebook",
|
||||
returnURLString: "fb://",
|
||||
tier: 4
|
||||
),
|
||||
HostAppEntry(
|
||||
bundleId: "com.burbn.instagram",
|
||||
displayNameKey: "hostApp.instagram",
|
||||
returnURLString: "instagram://",
|
||||
tier: 4
|
||||
),
|
||||
HostAppEntry(
|
||||
bundleId: "com.atebits.Tweetie2",
|
||||
displayNameKey: "hostApp.x",
|
||||
returnURLString: "twitter://",
|
||||
tier: 4
|
||||
),
|
||||
HostAppEntry(
|
||||
bundleId: "com.burbn.barcelona",
|
||||
displayNameKey: "hostApp.threads",
|
||||
returnURLString: "barcelona://",
|
||||
tier: 4
|
||||
),
|
||||
HostAppEntry(
|
||||
bundleId: "com.toyopagroup.picaboo",
|
||||
displayNameKey: "hostApp.snapchat",
|
||||
returnURLString: "snapchat://",
|
||||
tier: 4
|
||||
),
|
||||
HostAppEntry(
|
||||
bundleId: "com.reddit.Reddit",
|
||||
displayNameKey: "hostApp.reddit",
|
||||
returnURLString: "reddit://",
|
||||
tier: 4
|
||||
),
|
||||
HostAppEntry(
|
||||
bundleId: "pinterest",
|
||||
displayNameKey: "hostApp.pinterest",
|
||||
returnURLString: "pinterest://",
|
||||
tier: 4
|
||||
),
|
||||
HostAppEntry(
|
||||
bundleId: "com.zhihu.ios",
|
||||
displayNameKey: "hostApp.zhihu",
|
||||
returnURLString: "zhihu://",
|
||||
tier: 4
|
||||
),
|
||||
HostAppEntry(
|
||||
bundleId: "tv.danmaku.bili",
|
||||
displayNameKey: "hostApp.bilibili",
|
||||
returnURLString: "bilibili://",
|
||||
tier: 4
|
||||
),
|
||||
HostAppEntry(
|
||||
bundleId: "com.ss.iphone.ugc.Aweme",
|
||||
displayNameKey: "hostApp.douyin",
|
||||
returnURLString: "snssdk1128://",
|
||||
tier: 4
|
||||
),
|
||||
HostAppEntry(
|
||||
bundleId: "com.zhiliaoapp.musically",
|
||||
displayNameKey: "hostApp.tiktok",
|
||||
returnURLString: "tiktok://",
|
||||
tier: 4
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
Reference in New Issue
Block a user