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:
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user