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:
@@ -0,0 +1,70 @@
|
||||
// AppCloudSync.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// Single entry point for iCloud KVS sync in the main app: preferences
|
||||
// toggles, settings payload, and personal dictionary.
|
||||
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
public final class AppCloudSync {
|
||||
public static let shared = AppCloudSync()
|
||||
|
||||
private let kvs: UbiquitousKeyValueStoreing
|
||||
private let makeStore: () -> AppGroupStore
|
||||
private let settingsSync: SettingsCloudSync
|
||||
private let dictionarySync: PersonalDictionaryCloudSync
|
||||
private var externalChangeObserver: NSObjectProtocol?
|
||||
|
||||
public init(
|
||||
kvs: UbiquitousKeyValueStoreing = NSUbiquitousKeyValueStore.default,
|
||||
makeStore: @escaping () -> AppGroupStore = { AppGroupStore() },
|
||||
settingsSync: SettingsCloudSync? = nil,
|
||||
dictionarySync: PersonalDictionaryCloudSync? = nil
|
||||
) {
|
||||
self.kvs = kvs
|
||||
self.makeStore = makeStore
|
||||
self.settingsSync = settingsSync ?? SettingsCloudSync(kvs: kvs, makeStore: makeStore)
|
||||
self.dictionarySync = dictionarySync ?? PersonalDictionaryCloudSync(kvs: kvs, makeStore: makeStore)
|
||||
}
|
||||
|
||||
public func startObservingExternalChanges() {
|
||||
guard externalChangeObserver == nil else { return }
|
||||
externalChangeObserver = NotificationCenter.default.addObserver(
|
||||
forName: NSUbiquitousKeyValueStore.didChangeExternallyNotification,
|
||||
object: nil,
|
||||
queue: .main
|
||||
) { [weak self] _ in
|
||||
guard let self else { return }
|
||||
Task { @MainActor in
|
||||
await self.pullAllIfEnabled()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public func stopObservingExternalChanges() {
|
||||
if let externalChangeObserver {
|
||||
NotificationCenter.default.removeObserver(externalChangeObserver)
|
||||
self.externalChangeObserver = nil
|
||||
}
|
||||
}
|
||||
|
||||
/// Launch / foreground: refresh KVS toggles, then pull payloads.
|
||||
public func pullAllIfEnabled() async {
|
||||
let store = makeStore()
|
||||
ICloudSyncPreferences.migrateLegacyTogglesIfNeeded(kvs: kvs, store: store)
|
||||
|
||||
let toggles = ICloudSyncPreferences.load(from: kvs, store: store)
|
||||
ICloudSyncPreferences.cacheToAppGroup(
|
||||
settingsEnabled: toggles.settings,
|
||||
dictionaryEnabled: toggles.dictionary,
|
||||
store: store
|
||||
)
|
||||
|
||||
await settingsSync.pullAndMergeIfEnabled()
|
||||
await dictionarySync.pullAndMergeIfEnabled()
|
||||
}
|
||||
|
||||
public var settingsSyncService: SettingsCloudSync { settingsSync }
|
||||
public var dictionarySyncService: PersonalDictionaryCloudSync { dictionarySync }
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
// ICloudSyncPreferences.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// iCloud KVS is the source of truth for cross-device sync toggles
|
||||
// (scheme A). App Group UserDefaults keeps a local cache so the
|
||||
// keyboard extension and offline UI can read the last-known state.
|
||||
|
||||
import Foundation
|
||||
|
||||
public enum ICloudSyncPreferences {
|
||||
public static let settingsEnabledKey = "iCloudSync.settingsEnabled"
|
||||
public static let dictionaryEnabledKey = "personalDictionary.syncEnabled"
|
||||
|
||||
/// Read sync toggles from KVS, falling back to the App Group cache
|
||||
/// when a key has not been uploaded yet.
|
||||
public static func load(from kvs: UbiquitousKeyValueStoreing, store: AppGroupStore) -> (settings: Bool, dictionary: Bool) {
|
||||
let settings = kvs.object(forKey: settingsEnabledKey) as? Bool
|
||||
?? store.settingsICloudSyncEnabled
|
||||
let dictionary = kvs.object(forKey: dictionaryEnabledKey) as? Bool
|
||||
?? store.personalDictionaryICloudSyncEnabled
|
||||
return (settings, dictionary)
|
||||
}
|
||||
|
||||
/// Mirror KVS toggles into the App Group cache.
|
||||
public static func cacheToAppGroup(
|
||||
settingsEnabled: Bool,
|
||||
dictionaryEnabled: Bool,
|
||||
store: AppGroupStore
|
||||
) {
|
||||
store.setSettingsICloudSyncEnabled(settingsEnabled)
|
||||
store.setPersonalDictionaryICloudSyncEnabled(dictionaryEnabled)
|
||||
}
|
||||
|
||||
public static func pushSettingsEnabled(_ enabled: Bool, kvs: UbiquitousKeyValueStoreing) {
|
||||
kvs.set(enabled, forKey: settingsEnabledKey)
|
||||
_ = kvs.synchronize()
|
||||
}
|
||||
|
||||
public static func pushDictionaryEnabled(_ enabled: Bool, kvs: UbiquitousKeyValueStoreing) {
|
||||
kvs.set(enabled, forKey: dictionaryEnabledKey)
|
||||
_ = kvs.synchronize()
|
||||
}
|
||||
|
||||
/// One-time migration: upload locally cached toggles when KVS has no value yet.
|
||||
public static func migrateLegacyTogglesIfNeeded(kvs: UbiquitousKeyValueStoreing, store: AppGroupStore) {
|
||||
if kvs.object(forKey: dictionaryEnabledKey) == nil {
|
||||
pushDictionaryEnabled(store.personalDictionaryICloudSyncEnabled, kvs: kvs)
|
||||
}
|
||||
if kvs.object(forKey: settingsEnabledKey) == nil {
|
||||
pushSettingsEnabled(store.settingsICloudSyncEnabled, kvs: kvs)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
// SettingsCloudSync.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// Mirrors user-facing app settings through iCloud KVS. API keys stay
|
||||
// in Keychain and are never uploaded.
|
||||
|
||||
import Foundation
|
||||
|
||||
public extension Notification.Name {
|
||||
/// Posted after remote settings are applied to the App Group cache.
|
||||
static let settingsDidSyncFromCloud = Notification.Name(
|
||||
"com.osgkeyboard.settings.didSyncFromCloud"
|
||||
)
|
||||
}
|
||||
|
||||
public enum SettingsCloudSyncError: Error, Equatable, Sendable {
|
||||
case encodeFailed
|
||||
case decodeFailed
|
||||
}
|
||||
|
||||
@MainActor
|
||||
public final class SettingsCloudSync {
|
||||
public static let shared = SettingsCloudSync()
|
||||
|
||||
public static let kvsKey = "appSettings.v1"
|
||||
|
||||
private let kvs: UbiquitousKeyValueStoreing
|
||||
private let makeStore: () -> AppGroupStore
|
||||
|
||||
public init(
|
||||
kvs: UbiquitousKeyValueStoreing = NSUbiquitousKeyValueStore.default,
|
||||
makeStore: @escaping () -> AppGroupStore = { AppGroupStore() }
|
||||
) {
|
||||
self.kvs = kvs
|
||||
self.makeStore = makeStore
|
||||
}
|
||||
|
||||
public func pullAndMergeIfEnabled() async {
|
||||
let store = makeStore()
|
||||
guard store.settingsICloudSyncEnabled else { return }
|
||||
await pullAndMerge(store: store)
|
||||
}
|
||||
|
||||
public func pushLocalIfEnabled() async throws {
|
||||
let store = makeStore()
|
||||
guard store.settingsICloudSyncEnabled else { return }
|
||||
let local = SyncedAppSettings.from(configuration: store.configurationSnapshot())
|
||||
try push(local)
|
||||
}
|
||||
|
||||
public func enableSync() async throws {
|
||||
let store = makeStore()
|
||||
ICloudSyncPreferences.pushSettingsEnabled(true, kvs: kvs)
|
||||
ICloudSyncPreferences.cacheToAppGroup(
|
||||
settingsEnabled: true,
|
||||
dictionaryEnabled: store.personalDictionaryICloudSyncEnabled,
|
||||
store: store
|
||||
)
|
||||
|
||||
let local = SyncedAppSettings.from(configuration: store.configurationSnapshot())
|
||||
let remote = loadRemote() ?? local
|
||||
let merged = SyncedAppSettings.merge(local: local, remote: remote)
|
||||
apply(merged, to: store, postNotification: false)
|
||||
try push(merged)
|
||||
NotificationCenter.default.post(name: .settingsDidSyncFromCloud, object: nil)
|
||||
}
|
||||
|
||||
public func disableSync() {
|
||||
let store = makeStore()
|
||||
ICloudSyncPreferences.pushSettingsEnabled(false, kvs: kvs)
|
||||
store.setSettingsICloudSyncEnabled(false)
|
||||
}
|
||||
|
||||
public func pullAndMerge(store: AppGroupStore) async {
|
||||
guard store.settingsICloudSyncEnabled else { return }
|
||||
guard let remote = loadRemote() else { return }
|
||||
|
||||
let local = SyncedAppSettings.from(
|
||||
configuration: store.configurationSnapshot(),
|
||||
updatedAt: store.settingsCloudUpdatedAt ?? .distantPast
|
||||
)
|
||||
let merged = SyncedAppSettings.merge(local: local, remote: remote)
|
||||
guard merged != local else { return }
|
||||
|
||||
apply(merged, to: store, postNotification: true)
|
||||
}
|
||||
|
||||
public func push(_ settings: SyncedAppSettings) throws {
|
||||
let encoder = JSONEncoder()
|
||||
encoder.dateEncodingStrategy = .iso8601
|
||||
guard let data = try? encoder.encode(settings) else {
|
||||
throw SettingsCloudSyncError.encodeFailed
|
||||
}
|
||||
kvs.set(data, forKey: Self.kvsKey)
|
||||
_ = kvs.synchronize()
|
||||
}
|
||||
|
||||
public func loadRemote() -> SyncedAppSettings? {
|
||||
guard let data = kvs.data(forKey: Self.kvsKey) else { return nil }
|
||||
return try? decode(data)
|
||||
}
|
||||
|
||||
public func decode(_ data: Data) throws -> SyncedAppSettings {
|
||||
let decoder = JSONDecoder()
|
||||
decoder.dateDecodingStrategy = .iso8601
|
||||
guard let settings = try? decoder.decode(SyncedAppSettings.self, from: data) else {
|
||||
throw SettingsCloudSyncError.decodeFailed
|
||||
}
|
||||
return settings
|
||||
}
|
||||
|
||||
private func apply(
|
||||
_ settings: SyncedAppSettings,
|
||||
to store: AppGroupStore,
|
||||
postNotification: Bool
|
||||
) {
|
||||
var config = store.configurationSnapshot()
|
||||
settings.applying(to: &config)
|
||||
store.saveConfiguration(config, settingsCloudUpdatedAt: settings.updatedAt)
|
||||
if postNotification {
|
||||
AppGroupConfigDarwin.postConfigChanged()
|
||||
NotificationCenter.default.post(name: .settingsDidSyncFromCloud, object: nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension AppGroupStore {
|
||||
func configurationSnapshot() -> AppGroupConfiguration {
|
||||
AppGroupConfiguration.load(fromAvailable: defaults)
|
||||
}
|
||||
|
||||
func saveConfiguration(_ configuration: AppGroupConfiguration, settingsCloudUpdatedAt: Date) {
|
||||
let config = configuration
|
||||
config.save(to: defaults)
|
||||
defaults.set(settingsCloudUpdatedAt.timeIntervalSince1970, forKey: AppGroupConfiguration.Keys.settingsCloudUpdatedAt)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user