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:
Cursor Agent
2026-07-06 07:38:26 +00:00
parent 6489340b2e
commit 13cd7b30f9
25 changed files with 936 additions and 77 deletions
+4
View File
@@ -10,8 +10,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- **Flow Live Activity**: Dynamic Island shows the OSGKeyboard brand mark during an active voice session (ActivityKit widget extension). / **Flow 灵动岛 Live Activity**:语音会话期间在灵动岛显示 OSGKeyboard 品牌标识(ActivityKit 小组件扩展)。
- **Xiaomi MiMo cloud provider**: preset for the cloud engine with `mimo-v2.5` polish via `api.xiaomimimo.com` (on-device ASR, same pipeline as other online providers). / **小米 MiMo 云端引擎**:云端引擎新增预设,经 `api.xiaomimimo.com` 使用 `mimo-v2.5` 润色(端侧 ASR,与其他在线服务相同管线)。
- **Flow session policy (A)**: on-demand session start from the keyboard; inactivity-based expiry (10m24h, default 12h) reset after each utterance; handoff auto-starts recording. / **Flow 会话策略(A**:键盘按需启动会话;无活动超时(10 分钟–24 小时,默认 12 小时)每句结束后重置;交接完成后自动开始录音。
- **Skip app switch (B+C)**: settings toggle (default on) plus cold-start overlay with swipe-back guidance and optional “Return to [App]” alert. / **跳过应用切换(B+C**:设置开关(默认开)+ 冷启动极简页(右滑引导 + 可选「返回 [App]」弹窗)。
- **Host return whitelist (C+D)**: `HostAppURLRegistry` with 20 high-frequency host apps; `sourceApplication` capture on `startflow`. / **宿主回跳白名单(C+D**`HostAppURLRegistry` 覆盖 20 个高频宿主 App`startflow` 时记录 `sourceApplication`
### Changed
- **Keyboard language label**: `PrimaryLanguage` set to `mis` so Settings no longer shows a misleading “English” subtitle under OSGKeyboard. / **键盘语言标签**`PrimaryLanguage` 设为 `mis`,系统设置中 OSGKeyboard 下不再显示误导性的「英文」副标题。
- **Flow ASR pipelining**: shorter first chunk (2.5s) and 5s follow-ups so short utterances start on-device recognition while still recording; session-level ASR warmup and format cache reuse; live partials mirrored to the keyboard transcript line. / **Flow ASR 流水线**:首块 2.5 秒、后续 5 秒,短句录音期间即开始端侧识别;会话级 ASR 预热与格式缓存复用;实时 partial 同步到键盘转写行。
## [0.4.0] - 2026-07-05
+24
View File
@@ -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>
+2
View File
@@ -6,6 +6,8 @@ import OSGKeyboardShared
@main
struct OSGKeyboardApp: App {
@UIApplicationDelegateAdaptor(AppURLHandler.self) private var appURLHandler
init() {
MaterialIconsFont.registerIfNeeded()
if AppGroup.isAvailable {
+29
View File
@@ -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
}
}
+80 -33
View File
@@ -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"))
}
}
-3
View File
@@ -106,9 +106,6 @@ struct HomeView: View {
private func refreshPermissionStatuses() {
micStatus = AppPermissions.micStatus
speechStatus = AppPermissions.speechStatus
if AppPermissions.flowRequirementsMet {
flowManager.autoStartIfNeeded()
}
}
private func handlePermissionGuidanceAction() {
+27 -14
View File
@@ -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
}
}
}
-1
View File
@@ -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() }
}
}
+64
View File
@@ -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 {
+45
View File
@@ -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" = "小红书";
@@ -36,8 +36,6 @@ final class KeyboardFlowCoordinator {
private var wasFlowSessionActive = false
private var flowSessionMonitorTask: Task<Void, Never>?
private var isAwaitingFlowResult = false
private var lastFlowAutoStartAttempt: TimeInterval = 0
private static let flowAutoStartCooldown: TimeInterval = 20
init(
state: KeyboardState,
@@ -95,10 +93,6 @@ final class KeyboardFlowCoordinator {
}
}
wasFlowSessionActive = active
if !active {
maybeAutoStartFlowSession()
}
}
func toggleRecording() {
@@ -224,18 +218,6 @@ final class KeyboardFlowCoordinator {
}
}
private func maybeAutoStartFlowSession() {
guard !FlowSessionBridge.isSessionActive() else { return }
guard !isPendingFlowStart, !isFlowRecording, !isAwaitingFlowResult else { return }
guard hasFullAccess(), AppGroup.isAvailable else { return }
guard case .idle = state.phase else { return }
let now = Date().timeIntervalSince1970
guard now - lastFlowAutoStartAttempt >= Self.flowAutoStartCooldown else { return }
lastFlowAutoStartAttempt = now
beginFlowStart()
}
private func showFlowSessionExpiredHint() {
let message = ExtL10n.string("keyboard.flow.sessionExpired")
state.phase = .error(.flowSessionExpired, message: message)
@@ -332,9 +314,9 @@ final class KeyboardFlowCoordinator {
flowStartDeadline = 0
stopFlowWatchdog()
state.lastTranscript = ""
state.phase = .idle
refreshSessionState()
debug("completeFlowStartHandoff")
startFlowRecording()
debug("completeFlowStartHandoff → auto startFlowRecording")
}
private func startFlowLevelWatchdog() {
@@ -30,6 +30,10 @@ public struct AppGroupConfiguration: Sendable, Equatable {
public static let detectedAppContext = "config.detectedAppContext"
public static let detectedAppContextAt = "config.detectedAppContextAt"
public static let personalDictionary = "config.personalDictionary.v1"
/// When true, the host app auto-returns to the source app after a cold-start handoff.
public static let flowSkipAppSwitch = "config.flowSkipAppSwitch"
/// Raw `FlowInactivityDuration` value; session expires after this idle window.
public static let flowInactivityDuration = "config.flowInactivityDuration"
}
// MARK: - Stored fields
@@ -49,6 +53,10 @@ public struct AppGroupConfiguration: Sendable, Equatable {
public var cursorDragNavigationEnabled: Bool
public var polishIntensity: PolishIntensity
public var personalDictionary: PersonalDictionary
/// Auto-return to the host app after `startflow` cold start (default on).
public var flowSkipAppSwitch: Bool
/// Idle timeout before the Flow session ends; resets on each utterance.
public var flowInactivityDuration: FlowInactivityDuration
// MARK: - Derived
@@ -145,7 +153,16 @@ public struct AppGroupConfiguration: Sendable, Equatable {
return defaults.bool(forKey: Keys.cursorDragNavigationEnabled)
}(),
polishIntensity: resolvePolishIntensity(from: defaults),
personalDictionary: decodePersonalDictionary(from: defaults)
personalDictionary: decodePersonalDictionary(from: defaults),
flowSkipAppSwitch: {
if defaults.object(forKey: Keys.flowSkipAppSwitch) == nil {
return true
}
return defaults.bool(forKey: Keys.flowSkipAppSwitch)
}(),
flowInactivityDuration: FlowInactivityDuration.fromStored(
defaults.string(forKey: Keys.flowInactivityDuration)
)
)
let preset = LLMProvider.provider(id: config.providerId)
@@ -193,6 +210,8 @@ public struct AppGroupConfiguration: Sendable, Equatable {
defaults.set(handednessPreference.rawValue, forKey: Keys.handednessPreference)
defaults.set(cursorDragNavigationEnabled, forKey: Keys.cursorDragNavigationEnabled)
defaults.set(polishIntensity.rawValue, forKey: Keys.polishIntensity)
defaults.set(flowSkipAppSwitch, forKey: Keys.flowSkipAppSwitch)
defaults.set(flowInactivityDuration.rawValue, forKey: Keys.flowInactivityDuration)
Self.encodePersonalDictionary(personalDictionary, to: defaults)
}
@@ -0,0 +1,46 @@
// FlowInactivityDuration.swift
// OSGKeyboard · Shared
//
// User-selectable Flow session inactivity timeout. The timer resets after
// each completed utterance (and on session start).
import Foundation
public enum FlowInactivityDuration: String, CaseIterable, Identifiable, Sendable, Codable {
case tenMinutes = "10m"
case thirtyMinutes = "30m"
case threeHours = "3h"
case twelveHours = "12h"
case twentyFourHours = "24h"
public var id: String { rawValue }
public static let `default`: FlowInactivityDuration = .twelveHours
public var timeInterval: TimeInterval {
switch self {
case .tenMinutes: return 10 * 60
case .thirtyMinutes: return 30 * 60
case .threeHours: return 3 * 60 * 60
case .twelveHours: return 12 * 60 * 60
case .twentyFourHours: return 24 * 60 * 60
}
}
public var labelKey: String {
switch self {
case .tenMinutes: return "settings.flow.inactivity.10m"
case .thirtyMinutes: return "settings.flow.inactivity.30m"
case .threeHours: return "settings.flow.inactivity.3h"
case .twelveHours: return "settings.flow.inactivity.12h"
case .twentyFourHours: return "settings.flow.inactivity.24h"
}
}
public static func fromStored(_ raw: String?) -> FlowInactivityDuration {
guard let raw, let value = FlowInactivityDuration(rawValue: raw) else {
return .default
}
return value
}
}
@@ -171,6 +171,25 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
}
}
/// When enabled, the host app tries to return to the source app after a cold-start handoff.
@Published public var flowSkipAppSwitch: Bool {
didSet {
guard !isApplyingConfiguration, flowSkipAppSwitch != configuration.flowSkipAppSwitch else { return }
configuration.flowSkipAppSwitch = flowSkipAppSwitch
persistConfiguration()
}
}
/// Idle window before an active Flow session expires; resets on each utterance.
@Published public var flowInactivityDuration: FlowInactivityDuration {
didSet {
guard !isApplyingConfiguration,
flowInactivityDuration != configuration.flowInactivityDuration else { return }
configuration.flowInactivityDuration = flowInactivityDuration
persistConfiguration()
}
}
public var isConfigured: Bool {
// Local engine uses on-device ASR + built-in DeepSeek polish and
// does not need a user API key. Cloud needs base URL, key, and model.
@@ -218,6 +237,8 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
handednessPreference = configuration.handednessPreference
cursorDragNavigationEnabled = configuration.cursorDragNavigationEnabled
polishIntensity = configuration.polishIntensity
flowSkipAppSwitch = configuration.flowSkipAppSwitch
flowInactivityDuration = configuration.flowInactivityDuration
isApplyingConfiguration = false
}
@@ -39,13 +39,16 @@ public enum FlowSessionBridge {
// MARK: - Session lifecycle (host app)
public static func markSessionActive(
duration: TimeInterval = FlowSessionKeys.defaultSessionDuration,
duration: TimeInterval? = nil,
defaults: UserDefaults? = nil
) {
let store = resolvedDefaults(defaults)
let expires = Date().timeIntervalSince1970 + duration
let resolvedDuration = duration ?? FlowSessionPolicy.sessionDuration(defaults: store)
let now = Date().timeIntervalSince1970
let expires = now + resolvedDuration
store.set(true, forKey: FlowSessionKeys.flowSessionActive)
store.set(expires, forKey: FlowSessionKeys.flowSessionExpires)
store.set(now, forKey: FlowSessionKeys.lastActivityAt)
writeHeartbeat(defaults: store)
setRecordingState(.idle, defaults: store)
clearTranscription(defaults: store)
@@ -69,16 +72,49 @@ public enum FlowSessionBridge {
}
public static func extendSession(
by duration: TimeInterval = FlowSessionKeys.defaultSessionDuration,
by duration: TimeInterval? = nil,
defaults: UserDefaults? = nil
) {
let store = resolvedDefaults(defaults)
let expires = Date().timeIntervalSince1970 + duration
let resolvedDuration = duration ?? FlowSessionPolicy.sessionDuration(defaults: store)
let expires = Date().timeIntervalSince1970 + resolvedDuration
store.set(true, forKey: FlowSessionKeys.flowSessionActive)
store.set(expires, forKey: FlowSessionKeys.flowSessionExpires)
flush(store)
}
/// Resets the inactivity timer after utterance completion or explicit activity.
public static func touchLastActivity(defaults: UserDefaults? = nil) {
let store = resolvedDefaults(defaults)
let now = Date().timeIntervalSince1970
let duration = FlowSessionPolicy.sessionDuration(defaults: store)
store.set(now, forKey: FlowSessionKeys.lastActivityAt)
store.set(now + duration, forKey: FlowSessionKeys.flowSessionExpires)
store.set(true, forKey: FlowSessionKeys.flowSessionActive)
flush(store)
}
// MARK: - Host return (scheme D)
public static func setPendingHostBundleId(_ bundleId: String?, defaults: UserDefaults? = nil) {
let store = resolvedDefaults(defaults)
if let bundleId, !bundleId.isEmpty {
store.set(bundleId, forKey: FlowSessionKeys.pendingHostBundleId)
} else {
store.removeObject(forKey: FlowSessionKeys.pendingHostBundleId)
}
flush(store)
}
public static func pendingHostBundleId(defaults: UserDefaults? = nil) -> String? {
let store = resolvedDefaults(defaults)
return store.string(forKey: FlowSessionKeys.pendingHostBundleId)
}
public static func clearPendingHostBundleId(defaults: UserDefaults? = nil) {
setPendingHostBundleId(nil, defaults: defaults)
}
// MARK: - Session validity (keyboard)
/// True when the session contract is still valid (not expired).
@@ -280,6 +316,8 @@ public enum FlowSessionBridge {
store.removeObject(forKey: FlowSessionKeys.transcriptionLanguage)
clearTranscription(defaults: store)
store.removeObject(forKey: FlowSessionKeys.audioLevels)
store.removeObject(forKey: FlowSessionKeys.pendingHostBundleId)
store.removeObject(forKey: FlowSessionKeys.lastActivityAt)
flush(store)
}
@@ -21,11 +21,15 @@ public enum FlowSessionKeys {
/// Structured kind paired with `transcriptionError` for keyboard UI.
public static let transcriptionErrorKind = "flow.transcriptionErrorKind"
public static let audioLevels = "flow.audioLevels"
/// Bundle id of the app that opened `osgkeyboard://startflow` (scheme D).
public static let pendingHostBundleId = "flow.pendingHostBundleId"
/// Wall-clock timestamp of the last utterance completion or session start.
public static let lastActivityAt = "flow.lastActivityAt"
/// Heartbeat older than this while the host is foreground likely killed.
public static let heartbeatStaleInterval: TimeInterval = 3
/// Default Flow session length when started from the keyboard.
/// Legacy fixed session length prefer `FlowSessionPolicy.sessionDuration()`.
public static let defaultSessionDuration: TimeInterval = 480
/// Maximum duration for a single keyboard utterance (3.5 minutes).
@@ -0,0 +1,39 @@
// FlowSessionPolicy.swift
// OSGKeyboard · Shared
//
// Reads Flow session behaviour preferences from the App Group.
import Foundation
public enum FlowSessionPolicy {
public static func skipAppSwitch(defaults: UserDefaults? = nil) -> Bool {
let store = resolvedDefaults(defaults)
if store.object(forKey: AppGroupConfiguration.Keys.flowSkipAppSwitch) == nil {
return true
}
return store.bool(forKey: AppGroupConfiguration.Keys.flowSkipAppSwitch)
}
public static func inactivityDuration(defaults: UserDefaults? = nil) -> FlowInactivityDuration {
let store = resolvedDefaults(defaults)
return FlowInactivityDuration.fromStored(
store.string(forKey: AppGroupConfiguration.Keys.flowInactivityDuration)
)
}
public static func sessionDuration(defaults: UserDefaults? = nil) -> TimeInterval {
inactivityDuration(defaults: defaults).timeInterval
}
private static func resolvedDefaults(_ defaults: UserDefaults?) -> UserDefaults {
if let defaults { return defaults }
guard let available = AppGroup.defaultsIfAvailable else {
#if DEBUG
fatalError("App Group unavailable — inject UserDefaults in tests.")
#else
fatalError("App Group unavailable.")
#endif
}
return available
}
}
@@ -0,0 +1,182 @@
// HostAppURLRegistry.swift
// OSGKeyboard · Shared
//
// Public URL-scheme whitelist for returning to a known host app after a
// cold-start Flow session handoff. One bundle id maps to one preferred URL.
import Foundation
public struct HostAppEntry: Sendable, Equatable {
public let bundleId: String
public let displayNameKey: String
public let returnURLString: String
public let tier: Int
public var returnURL: URL? {
URL(string: returnURLString)
}
public init(bundleId: String, displayNameKey: String, returnURLString: String, tier: Int) {
self.bundleId = bundleId
self.displayNameKey = displayNameKey
self.returnURLString = returnURLString
self.tier = tier
}
}
public enum HostAppURLRegistry {
/// Curated whitelist for high-frequency host apps (IM, work, notes).
public static let entries: [HostAppEntry] = [
// Tier 1 China IM / work
HostAppEntry(
bundleId: "com.tencent.xin",
displayNameKey: "hostApp.wechat",
returnURLString: "weixin://",
tier: 1
),
HostAppEntry(
bundleId: "com.tencent.mqq",
displayNameKey: "hostApp.qq",
returnURLString: "mqq://",
tier: 1
),
HostAppEntry(
bundleId: "com.tencent.wework",
displayNameKey: "hostApp.wecom",
returnURLString: "wxwork://",
tier: 1
),
HostAppEntry(
bundleId: "com.laiwang.DingTalk",
displayNameKey: "hostApp.dingtalk",
returnURLString: "dingtalk://",
tier: 1
),
HostAppEntry(
bundleId: "com.bytedance.ee.lark",
displayNameKey: "hostApp.lark",
returnURLString: "lark://",
tier: 1
),
// Tier 2 global IM / collaboration
HostAppEntry(
bundleId: "ph.telegra.Telegraph",
displayNameKey: "hostApp.telegram",
returnURLString: "tg://",
tier: 2
),
HostAppEntry(
bundleId: "net.whatsapp.WhatsApp",
displayNameKey: "hostApp.whatsapp",
returnURLString: "whatsapp://",
tier: 2
),
HostAppEntry(
bundleId: "jp.naver.line",
displayNameKey: "hostApp.line",
returnURLString: "line://",
tier: 2
),
HostAppEntry(
bundleId: "com.facebook.Messenger",
displayNameKey: "hostApp.messenger",
returnURLString: "fb-messenger://",
tier: 2
),
HostAppEntry(
bundleId: "com.tinyspeck.chatlyio",
displayNameKey: "hostApp.slack",
returnURLString: "slack://",
tier: 2
),
HostAppEntry(
bundleId: "com.microsoft.skype.teams",
displayNameKey: "hostApp.teams",
returnURLString: "msteams://",
tier: 2
),
HostAppEntry(
bundleId: "com.hammerandchisel.discord",
displayNameKey: "hostApp.discord",
returnURLString: "discord://",
tier: 2
),
// Tier 3 notes / mail / browser
HostAppEntry(
bundleId: "notion.id",
displayNameKey: "hostApp.notion",
returnURLString: "notion://",
tier: 3
),
HostAppEntry(
bundleId: "net.shinyfrog.bear",
displayNameKey: "hostApp.bear",
returnURLString: "bear://",
tier: 3
),
HostAppEntry(
bundleId: "md.obsidian",
displayNameKey: "hostApp.obsidian",
returnURLString: "obsidian://",
tier: 3
),
HostAppEntry(
bundleId: "com.agiletortoise.Drafts5",
displayNameKey: "hostApp.drafts",
returnURLString: "drafts5://",
tier: 3
),
HostAppEntry(
bundleId: "com.google.Gmail",
displayNameKey: "hostApp.gmail",
returnURLString: "googlegmail://",
tier: 3
),
HostAppEntry(
bundleId: "com.microsoft.Office.Outlook",
displayNameKey: "hostApp.outlook",
returnURLString: "ms-outlook://",
tier: 3
),
HostAppEntry(
bundleId: "com.google.chrome.ios",
displayNameKey: "hostApp.chrome",
returnURLString: "googlechrome://",
tier: 3
),
// Tier 4 China social
HostAppEntry(
bundleId: "com.sina.weibo",
displayNameKey: "hostApp.weibo",
returnURLString: "sinaweibo://",
tier: 4
),
HostAppEntry(
bundleId: "com.xingin.discover",
displayNameKey: "hostApp.xiaohongshu",
returnURLString: "xhsdiscover://",
tier: 4
)
]
private static let byBundleId: [String: HostAppEntry] = {
Dictionary(uniqueKeysWithValues: entries.map { ($0.bundleId, $0) })
}()
public static func lookup(bundleId: String?) -> HostAppEntry? {
guard let bundleId, !bundleId.isEmpty else { return nil }
return byBundleId[bundleId]
}
/// URL schemes declared in `LSApplicationQueriesSchemes` for `canOpenURL`.
public static var querySchemes: [String] {
Array(
Set(
entries.compactMap { entry -> String? in
guard let url = entry.returnURL, let scheme = url.scheme else { return nil }
return scheme
}
)
).sorted()
}
}
@@ -30,6 +30,8 @@ final class AppGroupConfigurationTests: XCTestCase {
XCTAssertTrue(config.cursorDragNavigationEnabled)
XCTAssertEqual(config.polishIntensity, .default)
XCTAssertTrue(config.personalDictionary.entries.isEmpty)
XCTAssertTrue(config.flowSkipAppSwitch)
XCTAssertEqual(config.flowInactivityDuration, .twelveHours)
}
func testSaveAndLoadRoundTrip() {
@@ -49,6 +51,8 @@ final class AppGroupConfigurationTests: XCTestCase {
config.handednessPreference = .right
config.cursorDragNavigationEnabled = false
config.polishIntensity = .light
config.flowSkipAppSwitch = false
config.flowInactivityDuration = .thirtyMinutes
config.save(to: defaults)
let loaded = AppGroupConfiguration.load(fromAvailable: defaults)
@@ -66,6 +70,8 @@ final class AppGroupConfigurationTests: XCTestCase {
XCTAssertEqual(loaded.handednessPreference, .right)
XCTAssertFalse(loaded.cursorDragNavigationEnabled)
XCTAssertEqual(loaded.polishIntensity, .light)
XCTAssertFalse(loaded.flowSkipAppSwitch)
XCTAssertEqual(loaded.flowInactivityDuration, .thirtyMinutes)
}
func testTranslationEnabledDerivedFromTargetLocale() {
@@ -0,0 +1,46 @@
// FlowSessionPolicyTests.swift
// OSGKeyboardTests
import XCTest
@testable import OSGKeyboardShared
final class FlowSessionPolicyTests: XCTestCase {
private func makeDefaults() -> UserDefaults {
let suite = "group.com.osgkeyboard.shared.tests.policy.\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: suite)!
defaults.removePersistentDomain(forName: suite)
return defaults
}
func testSkipAppSwitchDefaultsToTrue() {
let defaults = makeDefaults()
XCTAssertTrue(FlowSessionPolicy.skipAppSwitch(defaults: defaults))
}
func testInactivityDurationDefaultsToTwelveHours() {
let defaults = makeDefaults()
XCTAssertEqual(FlowSessionPolicy.inactivityDuration(defaults: defaults), .twelveHours)
XCTAssertEqual(FlowSessionPolicy.sessionDuration(defaults: defaults), 12 * 60 * 60)
}
func testTouchLastActivityExtendsExpiry() {
let defaults = makeDefaults()
defaults.set(FlowInactivityDuration.tenMinutes.rawValue, forKey: AppGroupConfiguration.Keys.flowInactivityDuration)
FlowSessionBridge.markSessionActive(defaults: defaults)
let staleExpiry = Date().timeIntervalSince1970 + 30
defaults.set(staleExpiry, forKey: FlowSessionKeys.flowSessionExpires)
FlowSessionBridge.touchLastActivity(defaults: defaults)
let refreshed = FlowSessionBridge.sessionExpiresAt(defaults: defaults) ?? 0
XCTAssertGreaterThan(refreshed, staleExpiry)
}
func testPendingHostBundleIdRoundTrip() {
let defaults = makeDefaults()
FlowSessionBridge.setPendingHostBundleId("com.tencent.xin", defaults: defaults)
XCTAssertEqual(FlowSessionBridge.pendingHostBundleId(defaults: defaults), "com.tencent.xin")
FlowSessionBridge.clearPendingHostBundleId(defaults: defaults)
XCTAssertNil(FlowSessionBridge.pendingHostBundleId(defaults: defaults))
}
}
@@ -0,0 +1,28 @@
// HostAppURLRegistryTests.swift
// OSGKeyboardTests
import XCTest
@testable import OSGKeyboardShared
final class HostAppURLRegistryTests: XCTestCase {
func testWeChatLookup() {
let entry = HostAppURLRegistry.lookup(bundleId: "com.tencent.xin")
XCTAssertEqual(entry?.returnURLString, "weixin://")
XCTAssertEqual(entry?.displayNameKey, "hostApp.wechat")
}
func testUnknownBundleReturnsNil() {
XCTAssertNil(HostAppURLRegistry.lookup(bundleId: "com.apple.MobileSMS"))
XCTAssertNil(HostAppURLRegistry.lookup(bundleId: nil))
XCTAssertNil(HostAppURLRegistry.lookup(bundleId: ""))
}
func testQuerySchemesIncludesWeChat() {
XCTAssertTrue(HostAppURLRegistry.querySchemes.contains("weixin"))
}
func testEntriesHaveUniqueBundleIds() {
let ids = HostAppURLRegistry.entries.map(\.bundleId)
XCTAssertEqual(Set(ids).count, ids.count)
}
}
+22
View File
@@ -118,6 +118,28 @@ targets:
- CFBundleURLName: com.osgkeyboard.ios.settings
CFBundleURLSchemes:
- osgkeyboard
LSApplicationQueriesSchemes:
- weixin
- mqq
- wxwork
- dingtalk
- lark
- tg
- whatsapp
- line
- fb-messenger
- slack
- msteams
- discord
- notion
- bear
- obsidian
- drafts5
- googlegmail
- ms-outlook
- googlechrome
- sinaweibo
- xhsdiscover
# All network calls are HTTPS (NSAppTransportSecurity above);
# nothing in the app uses non-exempt encryption. Declaring
# `ITSAppUsesNonExemptEncryption: false` lets us skip the