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
@@ -0,0 +1,16 @@
{
"images" : [
{
"filename" : "OSGLogo.svg",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"preserves-vector-representation" : true,
"template-rendering-intent" : "template"
}
}
@@ -0,0 +1,7 @@
<svg width="1024" height="1024" viewBox="0 0 1024 1024" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M627.511 512.419C627.511 448.783 576.127 397.197 512.741 397.197C449.355 397.197 397.971 448.783 397.971 512.419C397.971 576.054 449.355 627.641 512.741 627.641V637.837C443.746 637.837 387.814 581.686 387.814 512.419C387.814 443.152 443.746 387 512.741 387C581.736 387 637.668 443.152 637.668 512.419C637.668 581.686 581.736 637.837 512.741 637.837V627.641C576.127 627.641 627.511 576.054 627.511 512.419Z" fill="white"/>
<path d="M56.2632 512.419C56.2632 581.686 112.195 637.837 181.19 637.837C250.185 637.837 306.117 581.686 306.117 512.419C306.117 443.152 250.185 387 181.19 387C112.195 387 56.2632 443.152 56.2632 512.419Z" fill="white"/>
<path d="M399.618 562.094L399.618 552.257L512.74 552.257L512.74 562.094L399.618 562.094Z" fill="white"/>
<path d="M512.741 483.398V473.561H625.864V483.398H512.741Z" fill="white"/>
<path d="M843.308 387.981C910.987 387.981 966.093 441.8 968.171 508.975H843.308V518.812H968.096C965.014 585.066 910.324 637.836 843.308 637.836C774.313 637.836 718.381 581.904 718.381 512.909C718.381 443.914 774.312 387.981 843.308 387.981ZM968.234 518.812H968.096C968.187 516.855 968.234 514.888 968.234 512.909C968.234 511.593 968.211 510.281 968.171 508.975H968.234V518.812Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

+32 -8
View File
@@ -34,6 +34,12 @@
</array>
</dict>
</array>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>LSApplicationCategoryType</key>
<string>public.app-category.utilities</string>
<key>LSApplicationQueriesSchemes</key>
<array>
<string>weixin</string>
@@ -57,13 +63,31 @@
<string>googlechrome</string>
<string>sinaweibo</string>
<string>xhsdiscover</string>
<string>sgnl</string>
<string>kakaotalk</string>
<string>viber</string>
<string>zalo</string>
<string>skype</string>
<string>zoomus</string>
<string>things</string>
<string>todoist</string>
<string>evernote</string>
<string>onenote</string>
<string>readdle-spark</string>
<string>firefox</string>
<string>microsoft-edge</string>
<string>fb</string>
<string>instagram</string>
<string>twitter</string>
<string>barcelona</string>
<string>snapchat</string>
<string>reddit</string>
<string>pinterest</string>
<string>zhihu</string>
<string>bilibili</string>
<string>snssdk1128</string>
<string>tiktok</string>
</array>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>LSApplicationCategoryType</key>
<string>public.app-category.utilities</string>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
@@ -73,6 +97,8 @@
<string>OSGKeyboard uses the microphone for voice dictation and keeps a background audio session active while a voice session is running.</string>
<key>NSSpeechRecognitionUsageDescription</key>
<string>OSGKeyboard uses on-device speech recognition to transcribe your voice. Audio is processed on your device and is not uploaded for transcription.</string>
<key>NSSupportsLiveActivities</key>
<true/>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
@@ -82,8 +108,6 @@
<array>
<string>audio</string>
</array>
<key>NSSupportsLiveActivities</key>
<true/>
<key>UILaunchScreen</key>
<dict>
<key>UIColorName</key>
@@ -8,14 +8,28 @@ import ActivityKit
import Foundation
import OSGKeyboardShared
@MainActor
enum FlowLiveActivityController {
private static var currentActivity: Activity<FlowActivityAttributes>?
nonisolated(unsafe) private static var currentActivity: Activity<FlowActivityAttributes>?
/// If the host app is force-quit its `endSession()` never runs, orphaning
/// the Live Activity. A `staleDate` lets the system grey it out and become
/// willing to reclaim it without our process refreshed on every update
/// so a genuinely active, in-use session never looks stale.
private static let staleWindow: TimeInterval = 60 * 60
private static func freshContent(
phase: FlowActivityAttributes.ContentState.Phase
) -> ActivityContent<FlowActivityAttributes.ContentState> {
ActivityContent(
state: FlowActivityAttributes.ContentState(phase: phase),
staleDate: Date().addingTimeInterval(staleWindow)
)
}
/// Begin showing OSGKeyboard in the Dynamic Island for an active Flow session.
static func startSession() {
guard ActivityAuthorizationInfo().areActivitiesEnabled else {
debug("Live Activity disabled in Settings")
FlowDiagnostics.log("Live Activity disabled in Settings")
return
}
@@ -26,25 +40,21 @@ enum FlowLiveActivityController {
return
}
let state = FlowActivityAttributes.ContentState(phase: .idle)
let content = ActivityContent(state: state, staleDate: nil)
do {
currentActivity = try Activity.request(
attributes: FlowActivityAttributes(),
content: content,
content: freshContent(phase: .idle),
pushType: nil
)
debug("Live Activity started")
FlowDiagnostics.log("Live Activity started")
} catch {
debug("Live Activity start failed: \(error.localizedDescription)")
FlowDiagnostics.log("Live Activity start failed: \(error.localizedDescription)")
}
}
static func update(phase: FlowActivityAttributes.ContentState.Phase) {
guard let activity = currentActivity else { return }
let state = FlowActivityAttributes.ContentState(phase: phase)
let content = ActivityContent(state: state, staleDate: nil)
let content = freshContent(phase: phase)
Task {
await activity.update(content)
}
@@ -60,17 +70,19 @@ enum FlowLiveActivityController {
currentActivity = nil
Task {
await activity.end(nil, dismissalPolicy: .immediate)
debug("Live Activity ended")
FlowDiagnostics.log("Live Activity ended")
}
}
/// Host relaunch can leave orphan activities; clear them before starting anew.
private static func endStaleActivities() {
for activity in Activity<FlowActivityAttributes>.activities {
Task {
let staleActivities = Activity<FlowActivityAttributes>.activities
currentActivity = nil
guard !staleActivities.isEmpty else { return }
Task {
for activity in staleActivities {
await activity.end(nil, dismissalPolicy: .immediate)
}
}
currentActivity = nil
}
}
+12 -71
View File
@@ -67,9 +67,10 @@ final class FlowSessionManager: ObservableObject {
private var isColdStartHandoff = false
init() {
Task { @MainActor [weak self] in
await self?.bootstrapFromStorageIfNeeded()
}
// Sessions are (re)started explicitly on app foreground via
// `activateOnForeground()`. We deliberately do NOT silently reattach a
// stored session here after a force-quit that would resurrect capture
// (and keep a stale Live Activity alive) without the user re-opening.
}
// MARK: - Public
@@ -95,6 +96,8 @@ final class FlowSessionManager: ObservableObject {
return
}
guard !isStarting else { return }
startTask?.cancel()
startTask = Task { @MainActor [weak self] in
await self?.startSessionAsync(duration: duration)
@@ -102,23 +105,15 @@ final class FlowSessionManager: ObservableObject {
}
}
/// Restores an in-flight session after relaunch; does not auto-start a new one.
func restoreSessionIfNeeded() {
/// Auto-start (or renew) the Flow session on every app foreground when
/// permissions allow the "always auto-open, no off switch" policy. Also
/// clears any orphaned Live Activity a previously force-quit process left
/// behind (its `endSession()` could not run at kill time).
func activateOnForeground() {
guard AppGroup.isAvailable else { return }
guard !isActive, !isStarting else { return }
let markedActive = AppGroup.defaults.bool(forKey: FlowSessionKeys.flowSessionActive)
if markedActive, FlowSessionBridge.remainingSessionDuration() != nil {
Task { await bootstrapFromStorageIfNeeded() }
}
}
/// One-shot warmup after onboarding when permissions are already granted.
func warmupAfterOnboardingIfNeeded() {
guard AppGroup.isAvailable else { return }
guard !isActive, !isStarting else { return }
guard AppPermissions.flowRequirementsMet else {
sessionWarning = permissionWarningMessage()
FlowLiveActivityController.endSession()
return
}
startSession()
@@ -134,60 +129,6 @@ final class FlowSessionManager: ObservableObject {
dismissColdStartOverlay()
}
/// Reattach capture when the host app was killed but the session has not expired.
func bootstrapFromStorageIfNeeded() async {
guard AppGroup.isAvailable, !isActive else { return }
let markedActive = AppGroup.defaults.bool(forKey: FlowSessionKeys.flowSessionActive)
guard markedActive, let remaining = FlowSessionBridge.remainingSessionDuration(), remaining > 0 else {
if markedActive {
FlowSessionBridge.markSessionInactive()
}
return
}
guard AppPermissions.flowRequirementsMet else {
sessionWarning = permissionWarningMessage()
return
}
isStarting = true
sessionWarning = nil
defer { isStarting = false }
do {
try capture.start()
} catch {
let message = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription
sessionWarning = message
FlowSessionBridge.markSessionInactive()
debug("bootstrap capture failed: \(message)")
return
}
FlowSessionBridge.writeHeartbeat()
FlowSessionDarwin.postSessionChanged()
isActive = true
ScreenWakeLock.acquire()
if let expires = FlowSessionBridge.sessionExpiresAt() {
sessionExpiresAt = Date(timeIntervalSince1970: expires)
}
startHeartbeat()
startPolling()
startLevelPublishing()
scheduleExpiry(after: remaining)
// v0.2.0: iOS `SpeechAnalyzer` needs no warm-up. We still
// re-bind the cached `sessionASR` so a config flip mid-session
// (e.g. switching from cloud to local) is honoured.
bindSessionASRIfNeeded()
scheduleASRWarmup()
FlowLiveActivityController.startSession()
debug("Flow session restored (\(Int(remaining))s remaining)")
}
func endSession() {
guard isActive else { return }
debug("Flow session ended")
+5 -2
View File
@@ -37,8 +37,11 @@ struct FlowColdStartOverlay: View {
.ignoresSafeArea()
VStack(spacing: Spacing.xl) {
Image(systemName: "waveform.circle.fill")
.font(.system(size: 56))
Image("OSGBrandMark")
.renderingMode(.template)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 64, height: 64)
.foregroundStyle(palette.accent)
.accessibilityHidden(true)
+19
View File
@@ -36,6 +36,13 @@ struct HomeView: View {
micStatus != .granted || speechStatus != .granted
}
/// Can the user start a session right now from the Home footer? Only when
/// nothing is live/starting and permissions are already granted (otherwise
/// the permission guidance card is the correct call to action).
private var canManuallyStartSession: Bool {
!sessionIsLive && !needsPermissionSetup
}
private var shouldShowKeyboardHint: Bool {
!keyboardHintDismissed
&& !KeyboardSetupBridge.isReadyForOnboardingSkip
@@ -198,6 +205,18 @@ struct HomeView: View {
}
.buttonStyle(.plain)
.padding(.leading, Spacing.xs)
} else if canManuallyStartSession {
// Sessions auto-start on foreground, but after a manual stop /
// expiry the user needs a reliable, no-jump way back in.
Button {
flowManager.activateOnForeground()
} label: {
Text("home.flow.startShort")
.font(TypeStyle.caption2)
.foregroundStyle(palette.accent)
}
.buttonStyle(.plain)
.padding(.leading, Spacing.xs)
}
}
.fixedSize(horizontal: true, vertical: false)
+3 -3
View File
@@ -37,7 +37,7 @@ struct MainAppRoot: View {
.animation(.easeInOut(duration: 0.2), value: flowManager.coldStartContext != nil)
.onAppear {
flowManager.setAppForeground(scenePhase == .active)
flowManager.restoreSessionIfNeeded()
flowManager.activateOnForeground()
}
.onReceive(NotificationCenter.default.publisher(for: .osgKeyboardOpenURL)) { notification in
guard let url = notification.userInfo?["url"] as? URL else { return }
@@ -45,13 +45,13 @@ struct MainAppRoot: View {
}
.onChange(of: config.hasCompletedOnboarding) { _, done in
if done {
flowManager.warmupAfterOnboardingIfNeeded()
flowManager.activateOnForeground()
}
}
.onChange(of: scenePhase) { _, phase in
flowManager.handleScenePhase(phase)
guard phase == .active, config.hasCompletedOnboarding else { return }
flowManager.restoreSessionIfNeeded()
flowManager.activateOnForeground()
}
}
+25
View File
@@ -296,6 +296,7 @@
"home.flow.start" = "Start voice session";
"home.flow.end" = "End voice session";
"home.flow.endShort" = "End";
"home.flow.startShort" = "Start";
"home.preview.label" = "Try typing";
"home.preview.placeholder" = "Tap to type and test…";
"home.stats.dictationDuration" = "Dictation time";
@@ -405,3 +406,27 @@
"hostApp.chrome" = "Chrome";
"hostApp.weibo" = "Weibo";
"hostApp.xiaohongshu" = "RED";
"hostApp.signal" = "Signal";
"hostApp.kakaotalk" = "KakaoTalk";
"hostApp.viber" = "Viber";
"hostApp.zalo" = "Zalo";
"hostApp.skype" = "Skype";
"hostApp.zoom" = "Zoom";
"hostApp.things" = "Things";
"hostApp.todoist" = "Todoist";
"hostApp.evernote" = "Evernote";
"hostApp.onenote" = "OneNote";
"hostApp.spark" = "Spark";
"hostApp.firefox" = "Firefox";
"hostApp.edge" = "Edge";
"hostApp.facebook" = "Facebook";
"hostApp.instagram" = "Instagram";
"hostApp.x" = "X";
"hostApp.threads" = "Threads";
"hostApp.snapchat" = "Snapchat";
"hostApp.reddit" = "Reddit";
"hostApp.pinterest" = "Pinterest";
"hostApp.zhihu" = "Zhihu";
"hostApp.bilibili" = "Bilibili";
"hostApp.douyin" = "Douyin";
"hostApp.tiktok" = "TikTok";
@@ -295,6 +295,7 @@
"home.flow.start" = "启动语音会话";
"home.flow.end" = "结束语音会话";
"home.flow.endShort" = "结束";
"home.flow.startShort" = "开启";
"home.preview.label" = "输入测试";
"home.preview.placeholder" = "点这里试试键盘";
"home.stats.dictationDuration" = "听写时长";
@@ -404,3 +405,27 @@
"hostApp.chrome" = "Chrome";
"hostApp.weibo" = "微博";
"hostApp.xiaohongshu" = "小红书";
"hostApp.signal" = "Signal";
"hostApp.kakaotalk" = "KakaoTalk";
"hostApp.viber" = "Viber";
"hostApp.zalo" = "Zalo";
"hostApp.skype" = "Skype";
"hostApp.zoom" = "Zoom";
"hostApp.things" = "Things";
"hostApp.todoist" = "Todoist";
"hostApp.evernote" = "印象笔记";
"hostApp.onenote" = "OneNote";
"hostApp.spark" = "Spark";
"hostApp.firefox" = "Firefox";
"hostApp.edge" = "Edge";
"hostApp.facebook" = "Facebook";
"hostApp.instagram" = "Instagram";
"hostApp.x" = "X";
"hostApp.threads" = "Threads";
"hostApp.snapchat" = "Snapchat";
"hostApp.reddit" = "Reddit";
"hostApp.pinterest" = "Pinterest";
"hostApp.zhihu" = "知乎";
"hostApp.bilibili" = "哔哩哔哩";
"hostApp.douyin" = "抖音";
"hostApp.tiktok" = "TikTok";