feat: iCloud settings sync and cold-start return redesign

- Add iCloud key-value settings sync (engine/language/polish/Flow prefs);
  API keys stay on-device. New "Sync settings via iCloud" toggle.
- Redesign cold-start handoff: bottom-bar left-to-right swipe guidance,
  auto-dismiss on app switch, tap-anywhere to close, retained return link.
- Harden keyboard->app handoff with host-disconnected hint.
- Include prior Unreleased ASR fixes (route-change crash, fallback warning,
  multi-utterance recognition, local ASR diagnostics).

Release 0.5.0 (build 18).
This commit is contained in:
Rocky
2026-07-07 17:56:01 +08:00
parent bf844caa7f
commit 128aab1b02
42 changed files with 1818 additions and 337 deletions
+85 -14
View File
@@ -1,29 +1,100 @@
// AppURLHandler.swift
// OSGKeyboard · Main App
//
// Captures `sourceApplication` from UIKit open-URL options (scheme D).
// iOS 26 URL handling via the UIScene lifecycle. `application(_:open:options:)`
// and `UIApplication.OpenURLOptionsKey.sourceApplication` are deprecated in
// iOS 26; the only supported way to read `sourceApplication` (scheme D
// host-return whitelist) is `UIOpenURLContext.options.sourceApplication` from a
// scene delegate SwiftUI's `.onOpenURL` does not expose it.
import UIKit
import OSGKeyboardShared
extension Notification.Name {
static let osgKeyboardOpenURL = Notification.Name("osgkeyboard.openURL")
/// Buffers launch/open URLs until the SwiftUI root registers a handler.
///
/// On a cold launch the scene delivers the URL in `scene(_:willConnectTo:)`,
/// which fires *before* the SwiftUI view hierarchy is on screen. Without
/// buffering, that first `osgkeyboard://startflow` (the keyboard app
/// handoff) would be dropped.
@MainActor
final class AppOpenURLRouter {
static let shared = AppOpenURLRouter()
private var handler: ((URL) -> Void)?
private var pending: [URL] = []
private init() {}
/// Register the live handler and flush anything buffered before launch.
func register(_ handler: @escaping (URL) -> Void) {
self.handler = handler
let buffered = pending
pending.removeAll()
buffered.forEach(handler)
}
func route(_ url: URL) {
if let handler {
handler(url)
} else {
pending.append(url)
}
}
}
final class AppURLHandler: NSObject, UIApplicationDelegate {
/// SwiftUI `@main` apps get no scene delegate by default. Attach ours so
/// scene-based URL delivery the only iOS 26 path to `sourceApplication`
/// reaches `AppSceneDelegate`. We deliberately do NOT create a window here;
/// SwiftUI's `WindowGroup` still owns the UI.
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]
configurationForConnecting connectingSceneSession: UISceneSession,
options: UIScene.ConnectionOptions
) -> UISceneConfiguration {
let configuration = UISceneConfiguration(
name: nil,
sessionRole: connectingSceneSession.role
)
return true
configuration.delegateClass = AppSceneDelegate.self
return configuration
}
}
final class AppSceneDelegate: NSObject, UIWindowSceneDelegate {
/// Cold launch: the URL arrives in the connection options.
func scene(
_ scene: UIScene,
willConnectTo session: UISceneSession,
options connectionOptions: UIScene.ConnectionOptions
) {
handle(connectionOptions.urlContexts)
}
/// Warm open while the app is already running or suspended in memory.
func scene(_ scene: UIScene, openURLContexts URLContexts: Set<UIOpenURLContext>) {
handle(URLContexts)
}
private func handle(_ contexts: Set<UIOpenURLContext>) {
// Extract Sendable primitives up front so we never hop a
// non-Sendable `UIOpenURLContext` across the actor boundary.
let items: [(url: URL, source: String?)] = contexts.map {
($0.url, $0.options.sourceApplication)
}
guard !items.isEmpty else { return }
// Scene delegate callbacks are delivered on the main thread.
MainActor.assumeIsolated {
for item in items {
// `sourceApplication` is only non-nil when the caller belongs to
// the same Apple Developer Team (our own keyboard extension)
// exactly what the host-return whitelist relies on.
if let source = item.source {
FlowSessionBridge.setPendingHostBundleId(source)
}
AppOpenURLRouter.shared.route(item.url)
}
}
}
}
+95 -15
View File
@@ -49,6 +49,8 @@ final class FlowSessionManager: ObservableObject {
private var expiryTask: Task<Void, Never>?
private var levelTask: Task<Void, Never>?
private var startTask: Task<Void, Never>?
/// Last recording state the poll loop observed logs only on transition.
private var lastObservedRecordingState: FlowSessionKeys.RecordingState = .idle
private var isUtteranceRecording = false
/// True from `stopped` until the result/error is written back to App Group.
private var isUtteranceProcessing = false
@@ -86,6 +88,8 @@ final class FlowSessionManager: ObservableObject {
isColdStartHandoff = true
}
reconcilePersistedFlowStateBeforeStart()
if isActive {
extendSession(duration: duration)
if coldStart {
@@ -105,6 +109,33 @@ final class FlowSessionManager: ObservableObject {
}
}
/// Clears App Group Flow state left behind when the host process was killed
/// or the device rebooted while the session flag was still set.
private func reconcilePersistedFlowStateBeforeStart() {
if FlowSessionBridge.isHostStale() {
if isActive {
endSession()
} else {
FlowSessionBridge.clearFlowState()
FlowLiveActivityController.endSession()
}
debug("reconciled zombie persisted Flow state")
return
}
guard !isActive else { return }
let orphaned = FlowSessionBridge.recordingState()
switch orphaned {
case .recording, .stopped, .processing:
FlowSessionBridge.setRecordingState(.idle)
FlowSessionBridge.clearPendingTranscription()
debug("cleared orphaned keyboard recording state: \(orphaned.rawValue)")
case .idle, .aborted:
break
}
}
/// 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
@@ -196,6 +227,9 @@ final class FlowSessionManager: ObservableObject {
writeHeartbeatIfActive()
case .background:
setAppForeground(false)
if coldStartContext != nil {
dismissColdStartOverlay()
}
beginBackgroundKeepAlive()
@unknown default:
break
@@ -328,16 +362,13 @@ final class FlowSessionManager: ObservableObject {
return
}
coldStartContext = FlowColdStartContext(
hostEntry: hostEntry,
showReturnAlert: hostEntry != nil
)
coldStartContext = FlowColdStartContext(hostEntry: hostEntry)
}
private func bindSessionASRIfNeeded(force: Bool = false) {
let engineMode = store.engineMode
if !force,
let sessionASR,
sessionASR != nil,
sessionASREngineMode == engineMode {
return
}
@@ -374,6 +405,11 @@ final class FlowSessionManager: ObservableObject {
private func startPolling() {
pollingTask?.cancel()
lastObservedRecordingState = FlowSessionBridge.recordingState()
FlowDiagnostics.log(
"polling started: initialRecordingState=\(lastObservedRecordingState.rawValue) " +
"container=\(AppGroup.containerPathForDiagnostics)"
)
pollingTask = Task { @MainActor [weak self] in
while !Task.isCancelled {
self?.handleKeyboardSignal()
@@ -383,7 +419,17 @@ final class FlowSessionManager: ObservableObject {
}
private func handleKeyboardSignal() {
switch FlowSessionBridge.recordingState() {
let signal = FlowSessionBridge.recordingState()
if signal != lastObservedRecordingState {
// The single most important cross-process signal: proves whether the
// host actually SEES the keyboard's recording state writes.
FlowDiagnostics.log(
"poll observed recordingState \(lastObservedRecordingState.rawValue)\(signal.rawValue) " +
"[rec=\(isUtteranceRecording) proc=\(isUtteranceProcessing) fg=\(isAppForeground)]"
)
lastObservedRecordingState = signal
}
switch signal {
case .recording:
guard !isUtteranceRecording, !isUtteranceProcessing else { return }
beginUtterance()
@@ -431,7 +477,9 @@ final class FlowSessionManager: ObservableObject {
FlowLiveActivityController.update(phase: .recording)
FlowDiagnostics.log(
"beginUtterance engine=\(store.engineMode) " +
"asrType=\(type(of: asr)) pipelined=true max=\(Int(FlowSessionKeys.maxUtteranceDuration))s"
"asrType=\(type(of: asr)) pipelined=true " +
"localCustomLM=\(store.localASRCustomLanguageModelEnabled) " +
"max=\(Int(FlowSessionKeys.maxUtteranceDuration))s"
)
asrTask = Task.detached(priority: .userInitiated) { [weak manager = self] in
@@ -638,12 +686,18 @@ final class FlowSessionManager: ObservableObject {
// so the keyboard can show the "fill in your key" hint
// inline rather than a generic failure message. The raw
// transcript is still delivered no data loss.
let warning = Self.warningFromPolishError(error, engineMode: engineMode) ?? chunkNote
let fallback = Self.makeFallbackDelivery(
rawText: text,
error: error,
engineMode: engineMode,
chunkWarning: chunkNote
)
FlowDiagnostics.log(
"polish failed after \(String(format: "%.1f", Date().timeIntervalSince(polishStarted)))s: " +
"\(error.localizedDescription)"
)
FlowSessionBridge.storeTranscriptionResult(text, polishWarning: warning)
delivered = fallback.text
FlowSessionBridge.storeTranscriptionResult(fallback.text, polishWarning: fallback.polishWarning)
}
SpeechHistoryStore.shared.recordUtterance(
@@ -684,15 +738,41 @@ final class FlowSessionManager: ObservableObject {
/// v0.2.0: surface the local-mode cloud-polish error path with a
/// localised hint ("please fill in your DeepSeek key in Settings")
/// rather than letting the keyboard show a generic network error.
static func makeFallbackDelivery(
rawText: String,
error: Error,
engineMode: String,
chunkWarning: String?
) -> TranscriptionDelivery {
let fallbackText = TranscriptPostProcessor.cleanRawASRFallback(rawText)
let warning = warningFromPolishError(error, engineMode: engineMode)
?? polishDegradedWarning()
?? chunkWarning
return TranscriptionDelivery(text: fallbackText, polishWarning: warning)
}
private static func warningFromPolishError(_ error: Error, engineMode: String) -> String? {
guard let polishError = error as? PolishingService.PolishError,
polishError == .missingAPIKey else {
return nil
if let polishError = error as? PolishingService.PolishError {
switch polishError {
case .missingAPIKey:
if engineMode == "local" {
return SharedL10n.string("flow.warning.localPolishUnavailable")
}
return SharedL10n.string("flow.warning.cloudPolishMissingKey")
case .timeout:
return polishDegradedWarning()
case .noTranscript:
return nil
}
}
if engineMode == "local" {
return AppL10n.string("flow.warning.localPolishUnavailable")
if error is LLMError {
return polishDegradedWarning()
}
return AppL10n.string("flow.warning.cloudPolishMissingKey")
return nil
}
private static func polishDegradedWarning() -> String? {
SharedL10n.string("flow.warning.polishDegraded")
}
private func asrWaitTimeout() -> TimeInterval {
@@ -62,6 +62,12 @@ final class SpeechHistoryStore: ObservableObject {
)
}
func delete(id: UUID) {
guard entries.contains(where: { $0.id == id }) else { return }
entries.removeAll { $0.id == id }
persist()
}
func clearAll() {
entries.removeAll()
persist()
+73 -67
View File
@@ -1,14 +1,13 @@
// FlowColdStartOverlay.swift
// OSGKeyboard · Main App
//
// Minimal cold-start handoff UI: swipe-back guidance and optional return alert.
// Minimal cold-start handoff UI: bottom-bar swipe guidance and optional return link.
import SwiftUI
import OSGKeyboardShared
struct FlowColdStartContext: Equatable {
let hostEntry: HostAppEntry?
var showReturnAlert: Bool
}
struct FlowColdStartOverlay: View {
@@ -18,78 +17,62 @@ struct FlowColdStartOverlay: View {
let onReturnToHost: () -> Void
let onDismiss: () -> Void
@State private var showAlert: Bool
@State private var swipeOffset: CGFloat = 0
init(
context: FlowColdStartContext,
onReturnToHost: @escaping () -> Void,
onDismiss: @escaping () -> Void
) {
self.context = context
self.onReturnToHost = onReturnToHost
self.onDismiss = onDismiss
_showAlert = State(initialValue: context.showReturnAlert)
}
private let homeBarWidth: CGFloat = 134
var body: some View {
ZStack {
palette.background.opacity(0.96)
.ignoresSafeArea()
VStack(spacing: Spacing.xl) {
Image("OSGBrandMark")
.renderingMode(.template)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 64, height: 64)
.foregroundStyle(palette.accent)
.accessibilityHidden(true)
VStack(spacing: 0) {
Spacer()
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))
VStack(spacing: Spacing.xl) {
Image("OSGBrandMark")
.renderingMode(.template)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 64, height: 64)
.foregroundStyle(palette.accent)
.frame(maxWidth: .infinity)
.padding(.vertical, Spacing.md)
.accessibilityHidden(true)
Text("flow.coldStart.title")
.font(TypeStyle.title3)
.foregroundStyle(palette.textPrimary)
.multilineTextAlignment(.center)
Text("flow.coldStart.swipeHint")
.font(TypeStyle.body)
.foregroundStyle(palette.textSecondary)
.multilineTextAlignment(.center)
.padding(.horizontal, Spacing.lg)
if context.hostEntry != nil {
Button(action: onReturnToHost) {
Text(returnButtonTitle)
.font(TypeStyle.body.weight(.semibold))
.foregroundStyle(palette.accent)
}
.buttonStyle(.plain)
}
}
.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"
}
Spacer()
private var alertTitle: String {
AppL10n.string("flow.coldStart.alert.title")
bottomSwipeGuide
.padding(.bottom, Spacing.md)
Text("flow.coldStart.tapToDismiss")
.font(TypeStyle.caption)
.foregroundStyle(palette.textTertiary)
.padding(.bottom, Spacing.xl)
}
}
.contentShape(Rectangle())
.onTapGesture(perform: onDismiss)
}
private var returnButtonTitle: String {
@@ -100,15 +83,38 @@ struct FlowColdStartOverlay: View {
return AppL10n.format("flow.coldStart.return.named", appName)
}
private var swipeHintAnimation: some View {
private var bottomSwipeGuide: some View {
VStack(spacing: Spacing.sm) {
Image(systemName: "chevron.up")
.font(.system(size: 20, weight: .semibold))
Image(systemName: "arrow.down")
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(palette.textTertiary)
RoundedRectangle(cornerRadius: 3, style: .continuous)
.fill(palette.textTertiary.opacity(0.5))
.frame(width: 120, height: 5)
ZStack(alignment: .leading) {
RoundedRectangle(cornerRadius: 2.5, style: .continuous)
.fill(palette.textTertiary.opacity(0.35))
.frame(width: homeBarWidth, height: 5)
Circle()
.fill(palette.accent)
.frame(width: 8, height: 8)
.offset(x: swipeOffset)
}
.frame(width: homeBarWidth, height: 16)
HStack(spacing: Spacing.xs) {
Image(systemName: "arrow.left")
.font(.system(size: 12, weight: .semibold))
Image(systemName: "arrow.right")
.font(.system(size: 12, weight: .semibold))
}
.foregroundStyle(palette.textTertiary)
}
.accessibilityLabel(AppL10n.string("flow.coldStart.swipeAccessibility"))
.onAppear {
swipeOffset = 0
withAnimation(.easeInOut(duration: 1.4).repeatForever(autoreverses: true)) {
swipeOffset = homeBarWidth - 8
}
}
}
}
+41 -34
View File
@@ -32,16 +32,7 @@ struct HistoryView: View {
if store.entries.isEmpty {
emptyState
} else {
ScrollView {
LazyVStack(alignment: .leading, spacing: Spacing.xl) {
ForEach(store.groupedByDay, id: \.day) { group in
daySection(day: group.day, items: group.items)
}
}
.padding(.horizontal, Spacing.lg)
.padding(.vertical, Spacing.md)
.tabBarScrollBottomPadding()
}
list
}
}
.background(palette.background)
@@ -74,6 +65,38 @@ struct HistoryView: View {
}
}
// MARK: - List
private var list: some View {
List {
ForEach(store.groupedByDay, id: \.day) { group in
Section {
ForEach(group.items) { entry in
historyRow(entry)
.listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0))
.listRowBackground(palette.surface)
.listRowSeparatorTint(palette.divider)
}
.onDelete { offsets in
delete(items: group.items, at: offsets)
}
} header: {
Text(Self.dayFormatter.string(from: group.day))
.font(TypeStyle.caption2)
.foregroundStyle(palette.textTertiary)
.textCase(.uppercase)
.tracking(0.5)
}
}
}
.listStyle(.insetGrouped)
.listSectionSpacing(Spacing.lg)
.scrollContentBackground(.hidden)
.background(palette.background)
.contentMargins(.top, Spacing.md, for: .scrollContent)
.tabBarScrollBottomPadding()
}
private var emptyState: some View {
VStack(spacing: Spacing.sm) {
Spacer()
@@ -88,30 +111,6 @@ struct HistoryView: View {
.padding(.horizontal, Spacing.xl)
}
private func daySection(day: Date, items: [SpeechHistoryEntry]) -> some View {
VStack(alignment: .leading, spacing: Spacing.sm) {
Text(Self.dayFormatter.string(from: day))
.font(TypeStyle.caption2)
.foregroundStyle(palette.textTertiary)
.textCase(.uppercase)
.tracking(0.5)
VStack(spacing: 0) {
ForEach(Array(items.enumerated()), id: \.element.id) { index, entry in
historyRow(entry)
if index < items.count - 1 {
Divider().background(palette.divider)
}
}
}
.background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.xl, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: Radius.xl, style: .continuous)
.stroke(palette.divider, lineWidth: 0.5)
)
}
}
private func historyRow(_ entry: SpeechHistoryEntry) -> some View {
VStack(alignment: .leading, spacing: Spacing.xxs) {
Text(Self.timeFormatter.string(from: entry.createdAt))
@@ -126,4 +125,12 @@ struct HistoryView: View {
.padding(Spacing.md)
.frame(maxWidth: .infinity, alignment: .leading)
}
// MARK: - Mutations
private func delete(items: [SpeechHistoryEntry], at offsets: IndexSet) {
for index in offsets {
store.delete(id: items[index].id)
}
}
}
@@ -25,6 +25,8 @@ struct LocalModelsGroup: View {
speechRow
Divider().background(palette.divider)
polishRow
Divider().background(palette.divider)
customLanguageModelDiagnosticRow
}
.background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous))
.overlay(
@@ -63,6 +65,25 @@ struct LocalModelsGroup: View {
.frame(minHeight: SettingsListMetrics.singleLineMinHeight)
}
// MARK: Custom language model diagnostic row
private var customLanguageModelDiagnosticRow: some View {
Toggle(isOn: $config.localASRCustomLanguageModelEnabled) {
VStack(alignment: .leading, spacing: Spacing.xxs) {
Text("settings.localModels.customLM.title")
.font(TypeStyle.body)
.foregroundStyle(palette.textPrimary)
Text("settings.localModels.customLM.subtitle")
.font(TypeStyle.caption2)
.foregroundStyle(palette.textTertiary)
}
}
.tint(palette.accent)
.padding(.horizontal, Spacing.md)
.padding(.vertical, Spacing.sm)
.frame(minHeight: SettingsListMetrics.singleLineMinHeight)
}
// MARK: Helpers
/// Accent badge naming the engine that backs each local-mode row
+11 -6
View File
@@ -38,14 +38,19 @@ struct MainAppRoot: View {
.onAppear {
flowManager.setAppForeground(scenePhase == .active)
flowManager.activateOnForeground()
PersonalDictionaryCloudSync.shared.startObservingExternalChanges()
AppCloudSync.shared.startObservingExternalChanges()
// Registering here also flushes any URL buffered during a cold
// launch (the keyboard app `startflow` handoff arrives via the
// scene delegate before this view is on screen).
AppOpenURLRouter.shared.register { url in
handleIncomingURL(url)
}
Task {
await PersonalDictionaryCloudSync.shared.pullAndMergeIfEnabled()
await AppCloudSync.shared.pullAllIfEnabled()
}
}
.onReceive(NotificationCenter.default.publisher(for: .osgKeyboardOpenURL)) { notification in
guard let url = notification.userInfo?["url"] as? URL else { return }
handleIncomingURL(url)
.onReceive(NotificationCenter.default.publisher(for: .settingsDidSyncFromCloud)) { _ in
config.reloadFromPersistedStorage()
}
.onChange(of: config.hasCompletedOnboarding) { _, done in
if done {
@@ -59,7 +64,7 @@ struct MainAppRoot: View {
flowManager.activateOnForeground()
}
Task {
await PersonalDictionaryCloudSync.shared.pullAndMergeIfEnabled()
await AppCloudSync.shared.pullAllIfEnabled()
}
}
}
@@ -0,0 +1,103 @@
// SettingsICloudSyncRow.swift
// OSGKeyboard · Main App
//
// Settings-row toggle for mirroring user preferences through iCloud KVS.
// API keys remain in Keychain and are never uploaded.
import SwiftUI
import OSGKeyboardShared
@MainActor
struct SettingsICloudSyncRow: View {
@Environment(\.themePalette) private var palette: ThemePalette
@State private var isEnabled: Bool = AppGroupStore().settingsICloudSyncEnabled
@State private var syncErrorMessage: String?
@State private var isApplyingToggle = false
private let store = AppGroupStore()
var body: some View {
VStack(alignment: .leading, spacing: Spacing.xxs) {
Toggle(isOn: toggleBinding) {
VStack(alignment: .leading, spacing: Spacing.xxs) {
Text("settings.appSettings.iCloudSync.title")
.font(TypeStyle.body)
.foregroundStyle(palette.textPrimary)
Text("settings.appSettings.iCloudSync.subtitle")
.font(TypeStyle.caption2)
.foregroundStyle(palette.textTertiary)
.fixedSize(horizontal: false, vertical: true)
}
}
.tint(palette.accent)
.disabled(isApplyingToggle)
if let syncErrorMessage {
Text(syncErrorMessage)
.font(TypeStyle.caption2)
.foregroundStyle(.red)
.fixedSize(horizontal: false, vertical: true)
.padding(.top, Spacing.xxs)
}
}
.padding(.horizontal, Spacing.md)
.padding(.vertical, Spacing.sm)
.frame(minHeight: SettingsListMetrics.singleLineMinHeight, alignment: .leading)
.onAppear { reloadFromStore() }
.onReceive(
NotificationCenter.default.publisher(for: .settingsDidSyncFromCloud)
) { _ in
reloadFromStore()
}
}
private var toggleBinding: Binding<Bool> {
Binding(
get: { isEnabled },
set: { newValue in
guard newValue != isEnabled else { return }
if newValue {
enableSync()
} else {
disableSync()
}
}
)
}
private func reloadFromStore() {
isEnabled = store.settingsICloudSyncEnabled
}
private func enableSync() {
isApplyingToggle = true
syncErrorMessage = nil
Task {
do {
try await SettingsCloudSync.shared.enableSync()
reloadFromStore()
} catch let error as SettingsCloudSyncError {
isEnabled = false
syncErrorMessage = localizedSyncError(error)
} catch {
isEnabled = false
syncErrorMessage = error.localizedDescription
}
isApplyingToggle = false
}
}
private func disableSync() {
SettingsCloudSync.shared.disableSync()
isEnabled = false
syncErrorMessage = nil
}
private func localizedSyncError(_ error: SettingsCloudSyncError) -> String {
switch error {
case .encodeFailed, .decodeFailed:
return AppL10n.string("settings.appSettings.iCloudSync.error.generic")
}
}
}
+4
View File
@@ -182,6 +182,10 @@ struct SettingsView: View {
Divider().background(palette.divider)
cursorDragNavigationToggleRow
Divider().background(palette.divider)
SettingsICloudSyncRow()
}
.background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.xl, style: .continuous))
.overlay(
+10 -6
View File
@@ -368,6 +368,11 @@
"settings.personalDictionary.iCloudSync.error.tooLarge" = "Dictionary is too large to sync via iCloud. Remove some entries and try again.";
"settings.personalDictionary.iCloudSync.error.generic" = "Could not sync your dictionary with iCloud. Try again later.";
"settings.iCloudSync.title" = "iCloud Sync";
"settings.appSettings.iCloudSync.title" = "Sync settings via iCloud";
"settings.appSettings.iCloudSync.subtitle" = "Keep engine, language, polish, and Flow preferences in sync across your devices. API keys stay on each device.";
"settings.appSettings.iCloudSync.error.generic" = "Could not sync settings with iCloud. Try again later.";
/* v0.3.0: Polish intensity */
"settings.polishIntensity.title" = "Polish intensity";
@@ -381,15 +386,14 @@
"settings.flow.inactivity.3h" = "3 hours";
"settings.flow.inactivity.12h" = "12 hours";
"settings.flow.inactivity.24h" = "24 hours";
"settings.localModels.customLM.title" = "Use custom language model";
"settings.localModels.customLM.subtitle" = "Diagnostic switch. Turn off to test pure Apple on-device recognition if local ASR gets stuck or returns no speech.";
/* 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.swipeHint" = "Swipe right along the bar at the bottom to return to your previous app.";
"flow.coldStart.swipeAccessibility" = "Swipe right along the bottom bar to return";
"flow.coldStart.tapToDismiss" = "Tap anywhere to close";
"flow.coldStart.return.named" = "Return to %@";
"flow.coldStart.return.generic" = "Return to app";
+10 -6
View File
@@ -367,6 +367,11 @@
"settings.personalDictionary.iCloudSync.error.tooLarge" = "词库过大,无法通过 iCloud 同步。请删除部分词条后重试。";
"settings.personalDictionary.iCloudSync.error.generic" = "无法与 iCloud 同步词库,请稍后重试。";
"settings.iCloudSync.title" = "iCloud 同步";
"settings.appSettings.iCloudSync.title" = "设置 iCloud 同步";
"settings.appSettings.iCloudSync.subtitle" = "在多台设备间同步引擎、语言、润色和 Flow 偏好。API 密钥仍保留在各设备本地。";
"settings.appSettings.iCloudSync.error.generic" = "无法与 iCloud 同步设置,请稍后重试。";
/* v0.3.0: 润色强度 */
"settings.polishIntensity.title" = "润色强度";
@@ -380,15 +385,14 @@
"settings.flow.inactivity.3h" = "3 小时";
"settings.flow.inactivity.12h" = "12 小时";
"settings.flow.inactivity.24h" = "24 小时";
"settings.localModels.customLM.title" = "使用自定义语言模型";
"settings.localModels.customLM.subtitle" = "诊断开关。本地识别卡住或提示未识别时,可关闭它测试纯 Apple 端侧识别。";
/* 冷启动兜底(方案 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.swipeHint" = "沿屏幕底部的横条,从左向右滑动,返回上一个 App。";
"flow.coldStart.swipeAccessibility" = "沿底部横条从左向右滑动返回";
"flow.coldStart.tapToDismiss" = "点按屏幕关闭";
"flow.coldStart.return.named" = "返回%@";
"flow.coldStart.return.generic" = "返回 App";