feat(flow): implement ABCD session policy and host return whitelist
- Scheme A: on-demand session start, inactivity-based expiry, handoff auto-recording - Scheme B: cold-start overlay with swipe guidance and return alert - Scheme C+D: HostAppURLRegistry (20 apps) and sourceApplication capture - Settings: skip app switch toggle and inactivity duration picker - Add LSApplicationQueriesSchemes for canOpenURL checks Co-authored-by: Rocky <hkgood@users.noreply.github.com>
This commit is contained in:
@@ -34,6 +34,30 @@
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
<key>LSApplicationQueriesSchemes</key>
|
||||
<array>
|
||||
<string>weixin</string>
|
||||
<string>mqq</string>
|
||||
<string>wxwork</string>
|
||||
<string>dingtalk</string>
|
||||
<string>lark</string>
|
||||
<string>tg</string>
|
||||
<string>whatsapp</string>
|
||||
<string>line</string>
|
||||
<string>fb-messenger</string>
|
||||
<string>slack</string>
|
||||
<string>msteams</string>
|
||||
<string>discord</string>
|
||||
<string>notion</string>
|
||||
<string>bear</string>
|
||||
<string>obsidian</string>
|
||||
<string>drafts5</string>
|
||||
<string>googlegmail</string>
|
||||
<string>ms-outlook</string>
|
||||
<string>googlechrome</string>
|
||||
<string>sinaweibo</string>
|
||||
<string>xhsdiscover</string>
|
||||
</array>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||
<key>ITSAppUsesNonExemptEncryption</key>
|
||||
|
||||
@@ -6,6 +6,8 @@ import OSGKeyboardShared
|
||||
|
||||
@main
|
||||
struct OSGKeyboardApp: App {
|
||||
@UIApplicationDelegateAdaptor(AppURLHandler.self) private var appURLHandler
|
||||
|
||||
init() {
|
||||
MaterialIconsFont.registerIfNeeded()
|
||||
if AppGroup.isAvailable {
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
// AppURLHandler.swift
|
||||
// OSGKeyboard · Main App
|
||||
//
|
||||
// Captures `sourceApplication` from UIKit open-URL options (scheme D).
|
||||
|
||||
import UIKit
|
||||
import OSGKeyboardShared
|
||||
|
||||
extension Notification.Name {
|
||||
static let osgKeyboardOpenURL = Notification.Name("osgkeyboard.openURL")
|
||||
}
|
||||
|
||||
final class AppURLHandler: NSObject, UIApplicationDelegate {
|
||||
func application(
|
||||
_ application: UIApplication,
|
||||
open url: URL,
|
||||
options: [UIApplication.OpenURLOptionsKey: Any] = [:]
|
||||
) -> Bool {
|
||||
if let source = options[.sourceApplication] as? String {
|
||||
FlowSessionBridge.setPendingHostBundleId(source)
|
||||
}
|
||||
NotificationCenter.default.post(
|
||||
name: .osgKeyboardOpenURL,
|
||||
object: nil,
|
||||
userInfo: ["url": url]
|
||||
)
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,8 @@ final class FlowSessionManager: ObservableObject {
|
||||
@Published private(set) var sessionExpiresAt: Date?
|
||||
/// Non-nil when continuous capture failed or permissions are missing.
|
||||
@Published private(set) var sessionWarning: String?
|
||||
/// Cold-start handoff overlay state (scheme B).
|
||||
@Published var coldStartContext: FlowColdStartContext?
|
||||
|
||||
private let capture = FlowContinuousCapture()
|
||||
private let store = AppGroupStore()
|
||||
@@ -61,6 +63,8 @@ final class FlowSessionManager: ObservableObject {
|
||||
/// True while the host app scene is `.active` — drives foreground renewal.
|
||||
private var isAppForeground = false
|
||||
private var backgroundTaskID: UIBackgroundTaskIdentifier = .invalid
|
||||
/// True while handling a keyboard-initiated `startflow` cold start.
|
||||
private var isColdStartHandoff = false
|
||||
|
||||
init() {
|
||||
Task { @MainActor [weak self] in
|
||||
@@ -71,42 +75,65 @@ final class FlowSessionManager: ObservableObject {
|
||||
// MARK: - Public
|
||||
|
||||
/// Starts a Flow session: permissions → continuous capture → App Group active.
|
||||
func startSession(duration: TimeInterval = FlowSessionKeys.defaultSessionDuration) {
|
||||
func startSession(duration: TimeInterval? = nil, coldStart: Bool = false) {
|
||||
guard AppGroup.isAvailable else {
|
||||
debug("cannot start flow session: App Group unavailable")
|
||||
return
|
||||
}
|
||||
|
||||
if coldStart {
|
||||
isColdStartHandoff = true
|
||||
}
|
||||
|
||||
if isActive {
|
||||
extendSession(duration: duration)
|
||||
if coldStart {
|
||||
Task { @MainActor [weak self] in
|
||||
self?.handleColdStartAfterSessionReady()
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
startTask?.cancel()
|
||||
startTask = Task { @MainActor [weak self] in
|
||||
await self?.startSessionAsync(duration: duration)
|
||||
self?.handleColdStartAfterSessionReady()
|
||||
}
|
||||
}
|
||||
|
||||
/// Called on launch / foreground when onboarding is complete.
|
||||
func autoStartIfNeeded() {
|
||||
/// Restores an in-flight session after relaunch; does not auto-start a new one.
|
||||
func restoreSessionIfNeeded() {
|
||||
guard AppGroup.isAvailable else { return }
|
||||
guard !isActive, !isStarting else { return }
|
||||
|
||||
guard AppPermissions.flowRequirementsMet else {
|
||||
sessionWarning = permissionWarningMessage()
|
||||
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()
|
||||
return
|
||||
}
|
||||
|
||||
startSession()
|
||||
}
|
||||
|
||||
func dismissColdStartOverlay() {
|
||||
coldStartContext = nil
|
||||
isColdStartHandoff = false
|
||||
}
|
||||
|
||||
func returnToPendingHostFromColdStart() {
|
||||
_ = HostReturnService.openPendingHostIfPossible()
|
||||
dismissColdStartOverlay()
|
||||
}
|
||||
|
||||
/// Reattach capture when the host app was killed but the session has not expired.
|
||||
func bootstrapFromStorageIfNeeded() async {
|
||||
guard AppGroup.isAvailable, !isActive else { return }
|
||||
@@ -165,6 +192,7 @@ final class FlowSessionManager: ObservableObject {
|
||||
guard isActive else { return }
|
||||
debug("Flow session ended")
|
||||
|
||||
dismissColdStartOverlay()
|
||||
startTask?.cancel()
|
||||
startTask = nil
|
||||
pollingTask?.cancel()
|
||||
@@ -205,18 +233,16 @@ final class FlowSessionManager: ObservableObject {
|
||||
lastFinal = ""
|
||||
}
|
||||
|
||||
func extendSession(duration: TimeInterval = FlowSessionKeys.defaultSessionDuration) {
|
||||
FlowSessionBridge.extendSession(by: duration)
|
||||
sessionExpiresAt = Date().addingTimeInterval(duration)
|
||||
scheduleExpiry(after: duration)
|
||||
func extendSession(duration: TimeInterval? = nil) {
|
||||
let resolved = duration ?? FlowSessionPolicy.sessionDuration()
|
||||
FlowSessionBridge.extendSession(by: resolved)
|
||||
sessionExpiresAt = Date().addingTimeInterval(resolved)
|
||||
scheduleExpiry(after: resolved)
|
||||
}
|
||||
|
||||
/// Called from `OSGKeyboardApp` when `scenePhase` changes.
|
||||
func setAppForeground(_ foreground: Bool) {
|
||||
isAppForeground = foreground
|
||||
if foreground, isActive {
|
||||
renewSessionIfNeededWhileForeground()
|
||||
}
|
||||
}
|
||||
|
||||
/// Full scene lifecycle — keeps Flow + ASR alive across app switches.
|
||||
@@ -294,25 +320,29 @@ final class FlowSessionManager: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
/// Extend the session before it expires while the host app stays in foreground.
|
||||
private func renewSessionIfNeededWhileForeground() {
|
||||
guard isActive, isAppForeground else { return }
|
||||
guard let remaining = FlowSessionBridge.remainingSessionDuration() else { return }
|
||||
let threshold = FlowSessionKeys.defaultSessionDuration * 0.25
|
||||
guard remaining < threshold else { return }
|
||||
extendSession()
|
||||
debug("Flow session renewed in foreground (\(Int(threshold))s threshold)")
|
||||
/// Extend expiry after utterance completion based on the inactivity policy.
|
||||
private func touchSessionActivity() {
|
||||
guard isActive else { return }
|
||||
FlowSessionBridge.touchLastActivity()
|
||||
if let expires = FlowSessionBridge.sessionExpiresAt() {
|
||||
sessionExpiresAt = Date(timeIntervalSince1970: expires)
|
||||
let remaining = expires - Date().timeIntervalSince1970
|
||||
if remaining > 0 {
|
||||
scheduleExpiry(after: remaining)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Session start
|
||||
|
||||
private func startSessionAsync(duration: TimeInterval) async {
|
||||
private func startSessionAsync(duration: TimeInterval?) async {
|
||||
isStarting = true
|
||||
sessionWarning = nil
|
||||
defer { isStarting = false }
|
||||
|
||||
guard AppPermissions.flowRequirementsMet else {
|
||||
sessionWarning = permissionWarningMessage()
|
||||
isColdStartHandoff = false
|
||||
return
|
||||
}
|
||||
|
||||
@@ -321,29 +351,46 @@ final class FlowSessionManager: ObservableObject {
|
||||
} catch {
|
||||
let message = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription
|
||||
sessionWarning = message
|
||||
isColdStartHandoff = false
|
||||
debug("continuous capture failed: \(message)")
|
||||
return
|
||||
}
|
||||
|
||||
FlowSessionBridge.markSessionActive(duration: duration)
|
||||
let resolvedDuration = duration ?? FlowSessionPolicy.sessionDuration()
|
||||
FlowSessionBridge.markSessionActive(duration: resolvedDuration)
|
||||
FlowSessionDarwin.postSessionChanged()
|
||||
isActive = true
|
||||
ScreenWakeLock.acquire()
|
||||
sessionExpiresAt = Date().addingTimeInterval(duration)
|
||||
sessionExpiresAt = Date().addingTimeInterval(resolvedDuration)
|
||||
|
||||
startHeartbeat()
|
||||
startPolling()
|
||||
startLevelPublishing()
|
||||
scheduleExpiry(after: duration)
|
||||
scheduleExpiry(after: resolvedDuration)
|
||||
|
||||
// v0.2.0: iOS `SpeechAnalyzer` needs no warm-up; just refresh
|
||||
// the cached ASR service in case the user flipped engines
|
||||
// while the session was idle.
|
||||
bindSessionASRIfNeeded()
|
||||
scheduleASRWarmup()
|
||||
FlowLiveActivityController.startSession()
|
||||
|
||||
debug("Flow session started (\(Int(duration))s), continuous capture running")
|
||||
debug("Flow session started (\(Int(resolvedDuration))s inactivity window), continuous capture running")
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func handleColdStartAfterSessionReady() {
|
||||
guard isColdStartHandoff, isActive else { return }
|
||||
|
||||
let hostEntry = HostReturnService.pendingHostEntry()
|
||||
let skipSwitch = FlowSessionPolicy.skipAppSwitch()
|
||||
|
||||
if skipSwitch, hostEntry != nil, HostReturnService.openPendingHostIfPossible() {
|
||||
dismissColdStartOverlay()
|
||||
return
|
||||
}
|
||||
|
||||
coldStartContext = FlowColdStartContext(
|
||||
hostEntry: hostEntry,
|
||||
showReturnAlert: hostEntry != nil
|
||||
)
|
||||
}
|
||||
|
||||
private func bindSessionASRIfNeeded(force: Bool = false) {
|
||||
@@ -575,6 +622,7 @@ final class FlowSessionManager: ObservableObject {
|
||||
isUtteranceProcessing = false
|
||||
FlowSessionBridge.setRecordingState(.idle)
|
||||
FlowLiveActivityController.update(phase: .idle)
|
||||
touchSessionActivity()
|
||||
}
|
||||
|
||||
let asrWait = asrWaitTimeout()
|
||||
@@ -740,7 +788,6 @@ final class FlowSessionManager: ObservableObject {
|
||||
heartbeatTask = Task { @MainActor [weak self] in
|
||||
while !Task.isCancelled {
|
||||
FlowSessionBridge.writeHeartbeat()
|
||||
self?.renewSessionIfNeededWhileForeground()
|
||||
try? await Task.sleep(nanoseconds: 1_000_000_000)
|
||||
guard self?.isActive == true else { break }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
// HostReturnService.swift
|
||||
// OSGKeyboard · Main App
|
||||
//
|
||||
// Opens a whitelisted host-app URL after a cold-start Flow handoff.
|
||||
|
||||
import UIKit
|
||||
import OSGKeyboardShared
|
||||
|
||||
enum HostReturnService {
|
||||
/// Attempts to return to the pending host app. Clears the pending bundle id on success.
|
||||
@MainActor
|
||||
static func openPendingHostIfPossible() -> Bool {
|
||||
let bundleId = FlowSessionBridge.pendingHostBundleId()
|
||||
guard let entry = HostAppURLRegistry.lookup(bundleId: bundleId),
|
||||
let url = entry.returnURL else {
|
||||
return false
|
||||
}
|
||||
guard UIApplication.shared.canOpenURL(url) else {
|
||||
return false
|
||||
}
|
||||
UIApplication.shared.open(url, options: [:]) { success in
|
||||
if success {
|
||||
FlowSessionBridge.clearPendingHostBundleId()
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
@MainActor
|
||||
static func openHost(entry: HostAppEntry) -> Bool {
|
||||
guard let url = entry.returnURL, UIApplication.shared.canOpenURL(url) else {
|
||||
return false
|
||||
}
|
||||
UIApplication.shared.open(url, options: [:]) { success in
|
||||
if success {
|
||||
FlowSessionBridge.clearPendingHostBundleId()
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
@MainActor
|
||||
static func pendingHostEntry() -> HostAppEntry? {
|
||||
HostAppURLRegistry.lookup(bundleId: FlowSessionBridge.pendingHostBundleId())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
// FlowColdStartOverlay.swift
|
||||
// OSGKeyboard · Main App
|
||||
//
|
||||
// Minimal cold-start handoff UI: swipe-back guidance and optional return alert.
|
||||
|
||||
import SwiftUI
|
||||
import OSGKeyboardShared
|
||||
|
||||
struct FlowColdStartContext: Equatable {
|
||||
let hostEntry: HostAppEntry?
|
||||
var showReturnAlert: Bool
|
||||
}
|
||||
|
||||
struct FlowColdStartOverlay: View {
|
||||
@Environment(\.themePalette) private var palette: ThemePalette
|
||||
|
||||
let context: FlowColdStartContext
|
||||
let onReturnToHost: () -> Void
|
||||
let onDismiss: () -> Void
|
||||
|
||||
@State private var showAlert: Bool
|
||||
|
||||
init(
|
||||
context: FlowColdStartContext,
|
||||
onReturnToHost: @escaping () -> Void,
|
||||
onDismiss: @escaping () -> Void
|
||||
) {
|
||||
self.context = context
|
||||
self.onReturnToHost = onReturnToHost
|
||||
self.onDismiss = onDismiss
|
||||
_showAlert = State(initialValue: context.showReturnAlert)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
palette.background.opacity(0.96)
|
||||
.ignoresSafeArea()
|
||||
|
||||
VStack(spacing: Spacing.xl) {
|
||||
Image(systemName: "waveform.circle.fill")
|
||||
.font(.system(size: 56))
|
||||
.foregroundStyle(palette.accent)
|
||||
.accessibilityHidden(true)
|
||||
|
||||
Text("flow.coldStart.title")
|
||||
.font(TypeStyle.title3)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
Text(swipeHintKey)
|
||||
.font(TypeStyle.body)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal, Spacing.lg)
|
||||
|
||||
swipeHintAnimation
|
||||
.padding(.top, Spacing.md)
|
||||
|
||||
Button(action: onDismiss) {
|
||||
Text("flow.coldStart.dismiss")
|
||||
.font(TypeStyle.body.weight(.semibold))
|
||||
.foregroundStyle(palette.accent)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, Spacing.md)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.padding(.horizontal, Spacing.xl)
|
||||
.padding(.top, Spacing.lg)
|
||||
}
|
||||
.padding(Spacing.xl)
|
||||
}
|
||||
.alert(alertTitle, isPresented: $showAlert) {
|
||||
if context.hostEntry != nil {
|
||||
Button(returnButtonTitle, action: onReturnToHost)
|
||||
}
|
||||
Button("flow.coldStart.dismiss", role: .cancel, action: onDismiss)
|
||||
} message: {
|
||||
Text("flow.coldStart.alert.message")
|
||||
}
|
||||
}
|
||||
|
||||
private var swipeHintKey: LocalizedStringKey {
|
||||
context.hostEntry == nil
|
||||
? "flow.coldStart.swipeHint"
|
||||
: "flow.coldStart.swipeHint.withSystemBack"
|
||||
}
|
||||
|
||||
private var alertTitle: String {
|
||||
AppL10n.string("flow.coldStart.alert.title")
|
||||
}
|
||||
|
||||
private var returnButtonTitle: String {
|
||||
guard let entry = context.hostEntry else {
|
||||
return AppL10n.string("flow.coldStart.return.generic")
|
||||
}
|
||||
let appName = AppL10n.string(entry.displayNameKey)
|
||||
return AppL10n.format("flow.coldStart.return.named", appName)
|
||||
}
|
||||
|
||||
private var swipeHintAnimation: some View {
|
||||
VStack(spacing: Spacing.sm) {
|
||||
Image(systemName: "chevron.up")
|
||||
.font(.system(size: 20, weight: .semibold))
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
RoundedRectangle(cornerRadius: 3, style: .continuous)
|
||||
.fill(palette.textTertiary.opacity(0.5))
|
||||
.frame(width: 120, height: 5)
|
||||
}
|
||||
.accessibilityLabel(AppL10n.string("flow.coldStart.swipeAccessibility"))
|
||||
}
|
||||
}
|
||||
@@ -106,9 +106,6 @@ struct HomeView: View {
|
||||
private func refreshPermissionStatuses() {
|
||||
micStatus = AppPermissions.micStatus
|
||||
speechStatus = AppPermissions.speechStatus
|
||||
if AppPermissions.flowRequirementsMet {
|
||||
flowManager.autoStartIfNeeded()
|
||||
}
|
||||
}
|
||||
|
||||
private func handlePermissionGuidanceAction() {
|
||||
|
||||
@@ -24,31 +24,44 @@ struct MainAppRoot: View {
|
||||
}
|
||||
.environment(\.locale, config.uiLanguage.swiftUILocale)
|
||||
.environmentObject(flowManager)
|
||||
.overlay {
|
||||
if let context = flowManager.coldStartContext {
|
||||
FlowColdStartOverlay(
|
||||
context: context,
|
||||
onReturnToHost: { flowManager.returnToPendingHostFromColdStart() },
|
||||
onDismiss: { flowManager.dismissColdStartOverlay() }
|
||||
)
|
||||
.transition(.opacity)
|
||||
}
|
||||
}
|
||||
.animation(.easeInOut(duration: 0.2), value: flowManager.coldStartContext != nil)
|
||||
.onAppear {
|
||||
flowManager.setAppForeground(scenePhase == .active)
|
||||
flowManager.restoreSessionIfNeeded()
|
||||
}
|
||||
.onOpenURL { url in
|
||||
guard url.scheme == "osgkeyboard" else { return }
|
||||
switch url.host {
|
||||
case "startflow":
|
||||
flowManager.startSession()
|
||||
default:
|
||||
break
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: .osgKeyboardOpenURL)) { notification in
|
||||
guard let url = notification.userInfo?["url"] as? URL else { return }
|
||||
handleIncomingURL(url)
|
||||
}
|
||||
.onChange(of: config.hasCompletedOnboarding) { _, done in
|
||||
if done {
|
||||
flowManager.autoStartIfNeeded()
|
||||
flowManager.warmupAfterOnboardingIfNeeded()
|
||||
}
|
||||
}
|
||||
.onChange(of: scenePhase) { _, phase in
|
||||
flowManager.handleScenePhase(phase)
|
||||
guard phase == .active, config.hasCompletedOnboarding else { return }
|
||||
if flowManager.isActive {
|
||||
flowManager.extendSession()
|
||||
} else {
|
||||
flowManager.autoStartIfNeeded()
|
||||
}
|
||||
flowManager.restoreSessionIfNeeded()
|
||||
}
|
||||
}
|
||||
|
||||
private func handleIncomingURL(_ url: URL) {
|
||||
guard url.scheme == "osgkeyboard" else { return }
|
||||
switch url.host {
|
||||
case "startflow":
|
||||
flowManager.startSession(coldStart: true)
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,6 +48,5 @@ struct MainTabView: View {
|
||||
// Keep home card/input/tab layout fixed when system keyboard appears.
|
||||
// Let the keyboard overlay the content instead of pushing it.
|
||||
.ignoresSafeArea(.keyboard, edges: .bottom)
|
||||
.onAppear { flowManager.autoStartIfNeeded() }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,6 +40,7 @@ struct SettingsView: View {
|
||||
ScrollView {
|
||||
VStack(spacing: Spacing.md) {
|
||||
languageAndPolishSection
|
||||
flowSessionSection
|
||||
engineSection
|
||||
// v0.2.1: hide provider/api card when the
|
||||
// local engine is active regardless of the
|
||||
@@ -101,6 +102,44 @@ struct SettingsView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Flow session
|
||||
|
||||
private var flowSessionSection: some View {
|
||||
VStack(alignment: .leading, spacing: SettingsListMetrics.sectionLabelSpacing) {
|
||||
sectionHeader("settings.flow.title")
|
||||
VStack(spacing: 0) {
|
||||
Toggle(isOn: $config.flowSkipAppSwitch) {
|
||||
VStack(alignment: .leading, spacing: Spacing.xxs) {
|
||||
Text("settings.flow.skipAppSwitch.title")
|
||||
.font(TypeStyle.body)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
Text("settings.flow.skipAppSwitch.subtitle")
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
}
|
||||
}
|
||||
.tint(palette.accent)
|
||||
.padding(.horizontal, Spacing.md)
|
||||
.padding(.vertical, Spacing.sm)
|
||||
.frame(minHeight: SettingsListMetrics.singleLineMinHeight)
|
||||
|
||||
Divider().background(palette.divider)
|
||||
|
||||
FlowInactivityPickerRow(
|
||||
selection: Binding(
|
||||
get: { config.flowInactivityDuration },
|
||||
set: { config.flowInactivityDuration = $0 }
|
||||
)
|
||||
)
|
||||
}
|
||||
.background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.xl, style: .continuous))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: Radius.xl, style: .continuous)
|
||||
.stroke(palette.divider, lineWidth: 0.5)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Engine
|
||||
|
||||
private var engineSection: some View {
|
||||
@@ -434,6 +473,31 @@ private struct AppLanguagePickerRow: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Flow inactivity picker row
|
||||
|
||||
private struct FlowInactivityPickerRow: View {
|
||||
@Binding var selection: FlowInactivityDuration
|
||||
|
||||
private var options: [(id: String, label: String)] {
|
||||
FlowInactivityDuration.allCases.map { duration in
|
||||
(duration.rawValue, AppL10n.string(duration.labelKey))
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
PickerRow(
|
||||
title: AppL10n.string("settings.flow.inactivity.title"),
|
||||
options: options,
|
||||
selection: Binding(
|
||||
get: { selection.rawValue },
|
||||
set: { newValue in
|
||||
selection = FlowInactivityDuration(rawValue: newValue) ?? .default
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Handedness picker row
|
||||
|
||||
private struct HandednessPickerRow: View {
|
||||
|
||||
@@ -360,3 +360,48 @@
|
||||
|
||||
/* v0.3.0: Polish intensity */
|
||||
"settings.polishIntensity.title" = "Polish intensity";
|
||||
|
||||
/* Flow session policy */
|
||||
"settings.flow.title" = "Voice session";
|
||||
"settings.flow.skipAppSwitch.title" = "Skip app switch";
|
||||
"settings.flow.skipAppSwitch.subtitle" = "After a cold start, try to return to the app you came from.";
|
||||
"settings.flow.inactivity.title" = "End session after inactivity";
|
||||
"settings.flow.inactivity.10m" = "10 minutes";
|
||||
"settings.flow.inactivity.30m" = "30 minutes";
|
||||
"settings.flow.inactivity.3h" = "3 hours";
|
||||
"settings.flow.inactivity.12h" = "12 hours";
|
||||
"settings.flow.inactivity.24h" = "24 hours";
|
||||
|
||||
/* Cold-start handoff (scheme B) */
|
||||
"flow.coldStart.title" = "Voice is ready";
|
||||
"flow.coldStart.swipeHint" = "Swipe up from the bottom edge to return to your previous app.";
|
||||
"flow.coldStart.swipeHint.withSystemBack" = "Tap ‹ in the top-left or swipe up from the bottom to return.";
|
||||
"flow.coldStart.dismiss" = "Got it";
|
||||
"flow.coldStart.swipeAccessibility" = "Swipe up from the bottom to return";
|
||||
"flow.coldStart.alert.title" = "Voice is ready";
|
||||
"flow.coldStart.alert.message" = "You can return to continue typing, or swipe up from the bottom.";
|
||||
"flow.coldStart.return.named" = "Return to %@";
|
||||
"flow.coldStart.return.generic" = "Return to app";
|
||||
|
||||
/* Host app display names (scheme C whitelist) */
|
||||
"hostApp.wechat" = "WeChat";
|
||||
"hostApp.qq" = "QQ";
|
||||
"hostApp.wecom" = "WeCom";
|
||||
"hostApp.dingtalk" = "DingTalk";
|
||||
"hostApp.lark" = "Lark";
|
||||
"hostApp.telegram" = "Telegram";
|
||||
"hostApp.whatsapp" = "WhatsApp";
|
||||
"hostApp.line" = "LINE";
|
||||
"hostApp.messenger" = "Messenger";
|
||||
"hostApp.slack" = "Slack";
|
||||
"hostApp.teams" = "Microsoft Teams";
|
||||
"hostApp.discord" = "Discord";
|
||||
"hostApp.notion" = "Notion";
|
||||
"hostApp.bear" = "Bear";
|
||||
"hostApp.obsidian" = "Obsidian";
|
||||
"hostApp.drafts" = "Drafts";
|
||||
"hostApp.gmail" = "Gmail";
|
||||
"hostApp.outlook" = "Outlook";
|
||||
"hostApp.chrome" = "Chrome";
|
||||
"hostApp.weibo" = "Weibo";
|
||||
"hostApp.xiaohongshu" = "RED";
|
||||
|
||||
@@ -359,3 +359,48 @@
|
||||
|
||||
/* v0.3.0: 润色档位 */
|
||||
"settings.polishIntensity.title" = "润色档位";
|
||||
|
||||
/* Flow 会话策略 */
|
||||
"settings.flow.title" = "语音会话";
|
||||
"settings.flow.skipAppSwitch.title" = "跳过应用切换";
|
||||
"settings.flow.skipAppSwitch.subtitle" = "冷启动完成后,尝试自动返回来源 App。";
|
||||
"settings.flow.inactivity.title" = "无活动后结束会话";
|
||||
"settings.flow.inactivity.10m" = "10 分钟";
|
||||
"settings.flow.inactivity.30m" = "30 分钟";
|
||||
"settings.flow.inactivity.3h" = "3 小时";
|
||||
"settings.flow.inactivity.12h" = "12 小时";
|
||||
"settings.flow.inactivity.24h" = "24 小时";
|
||||
|
||||
/* 冷启动兜底(方案 B) */
|
||||
"flow.coldStart.title" = "语音已就绪";
|
||||
"flow.coldStart.swipeHint" = "从屏幕底部边缘向上滑动,返回上一个 App。";
|
||||
"flow.coldStart.swipeHint.withSystemBack" = "点左上角 ‹ 或从底部向上滑动返回。";
|
||||
"flow.coldStart.dismiss" = "知道了";
|
||||
"flow.coldStart.swipeAccessibility" = "从底部向上滑动返回";
|
||||
"flow.coldStart.alert.title" = "语音已就绪";
|
||||
"flow.coldStart.alert.message" = "可返回继续输入,或从底部向上滑动返回。";
|
||||
"flow.coldStart.return.named" = "返回%@";
|
||||
"flow.coldStart.return.generic" = "返回 App";
|
||||
|
||||
/* 宿主 App 显示名(方案 C 白名单) */
|
||||
"hostApp.wechat" = "微信";
|
||||
"hostApp.qq" = "QQ";
|
||||
"hostApp.wecom" = "企业微信";
|
||||
"hostApp.dingtalk" = "钉钉";
|
||||
"hostApp.lark" = "飞书";
|
||||
"hostApp.telegram" = "Telegram";
|
||||
"hostApp.whatsapp" = "WhatsApp";
|
||||
"hostApp.line" = "LINE";
|
||||
"hostApp.messenger" = "Messenger";
|
||||
"hostApp.slack" = "Slack";
|
||||
"hostApp.teams" = "Microsoft Teams";
|
||||
"hostApp.discord" = "Discord";
|
||||
"hostApp.notion" = "Notion";
|
||||
"hostApp.bear" = "Bear";
|
||||
"hostApp.obsidian" = "Obsidian";
|
||||
"hostApp.drafts" = "Drafts";
|
||||
"hostApp.gmail" = "Gmail";
|
||||
"hostApp.outlook" = "Outlook";
|
||||
"hostApp.chrome" = "Chrome";
|
||||
"hostApp.weibo" = "微博";
|
||||
"hostApp.xiaohongshu" = "小红书";
|
||||
|
||||
Reference in New Issue
Block a user