feat: harden Flow cold-start/force-quit and polish macOS dictation UX
Fix cold-start overlay recursion that overflowed the main-thread stack when recording began while the ready overlay was still up; also remove temporary on-screen Flow DEBUG panels after the orange-mic investigation, and land the macOS overlay/catalog/layout polish plus related Flow recovery hardening.
This commit is contained in:
@@ -39,20 +39,48 @@ public final class AppCloudSync {
|
||||
?? SpeechHistoryCloudSync(kvs: kvs, makeStore: makeStore, historyDefaults: historyDefaults)
|
||||
}
|
||||
|
||||
/// Serializes external-change pulls: KVS posts change notifications in
|
||||
/// bursts (one per key at times), and overlapping pull-merge-apply runs
|
||||
/// can interleave their read/write phases. `wantsAnotherPull` coalesces
|
||||
/// every burst into at most one trailing re-pull.
|
||||
private var isPulling = false
|
||||
private var wantsAnotherPull = false
|
||||
|
||||
public func startObservingExternalChanges() {
|
||||
guard externalChangeObserver == nil else { return }
|
||||
externalChangeObserver = NotificationCenter.default.addObserver(
|
||||
forName: NSUbiquitousKeyValueStore.didChangeExternallyNotification,
|
||||
object: nil,
|
||||
queue: .main
|
||||
) { [weak self] _ in
|
||||
) { [weak self] note in
|
||||
guard let self else { return }
|
||||
// Distinguish WHY the store changed. `.accountChange` means the
|
||||
// user switched iCloud accounts — the incoming values belong to a
|
||||
// DIFFERENT account and must not be merged into this one's data
|
||||
// (deleted-entry resurrection, foreign history, wrong settings).
|
||||
let reason = note.userInfo?[NSUbiquitousKeyValueStoreChangeReasonKey] as? Int
|
||||
if reason == NSUbiquitousKeyValueStoreAccountChange {
|
||||
return
|
||||
}
|
||||
Task { @MainActor in
|
||||
await self.pullAllIfEnabled()
|
||||
await self.pullAllCoalesced()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func pullAllCoalesced() async {
|
||||
guard !isPulling else {
|
||||
wantsAnotherPull = true
|
||||
return
|
||||
}
|
||||
isPulling = true
|
||||
defer { isPulling = false }
|
||||
repeat {
|
||||
wantsAnotherPull = false
|
||||
await pullAllIfEnabled()
|
||||
} while wantsAnotherPull
|
||||
}
|
||||
|
||||
public func stopObservingExternalChanges() {
|
||||
if let externalChangeObserver {
|
||||
NotificationCenter.default.removeObserver(externalChangeObserver)
|
||||
@@ -79,18 +107,27 @@ public final class AppCloudSync {
|
||||
}
|
||||
|
||||
/// Low-risk manual sync: pull remote changes, merge, then push local state.
|
||||
/// Each push runs independently — one payload failing must not abort the
|
||||
/// others (a too-large history would otherwise also kill the dictionary
|
||||
/// push). The first error is rethrown after every push has been tried.
|
||||
public func syncNow() async throws {
|
||||
let store = makeStore()
|
||||
await pullAllIfEnabled()
|
||||
|
||||
var firstError: Error?
|
||||
func attempt(_ body: () async throws -> Void) async {
|
||||
do { try await body() } catch { if firstError == nil { firstError = error } }
|
||||
}
|
||||
|
||||
if store.settingsICloudSyncEnabled {
|
||||
try await settingsSync.pushLocalIfEnabled()
|
||||
try await usageStatisticsSync.pushLocalIfEnabled()
|
||||
try await speechHistorySync.pushLocalIfEnabled()
|
||||
await attempt { try await settingsSync.pushLocalIfEnabled() }
|
||||
await attempt { try await usageStatisticsSync.pushLocalIfEnabled() }
|
||||
await attempt { try await speechHistorySync.pushLocalIfEnabled() }
|
||||
}
|
||||
if store.personalDictionaryICloudSyncEnabled {
|
||||
try await dictionarySync.pushLocalIfEnabled(store.personalDictionary)
|
||||
await attempt { try await dictionarySync.pushLocalIfEnabled(store.personalDictionary) }
|
||||
}
|
||||
if let firstError { throw firstError }
|
||||
}
|
||||
|
||||
public var settingsSyncService: SettingsCloudSync { settingsSync }
|
||||
|
||||
@@ -24,8 +24,12 @@ public final class SpeechHistoryCloudSync {
|
||||
|
||||
public static let kvsKey = SyncedSpeechHistory.kvsKey
|
||||
public static let legacyKVSKey = SyncedSpeechHistory.legacyKVSKey
|
||||
/// Stay below the ~1 MB per-key KVS limit.
|
||||
public static let maxPayloadBytes = 900_000
|
||||
/// The 1 MB iCloud KVS quota is for the WHOLE store, not per key.
|
||||
/// History and the personal dictionary must fit together (plus settings
|
||||
/// and usage stats) — once the store exceeds 1 MB, KVS rejects writes
|
||||
/// for ALL keys with `QuotaViolation` and every sync silently stops.
|
||||
/// Budget: ~400 KB history + ~400 KB dictionary + headroom for the rest.
|
||||
public static let maxPayloadBytes = 400_000
|
||||
|
||||
private let kvs: UbiquitousKeyValueStoreing
|
||||
private let makeStore: () -> AppGroupStore
|
||||
@@ -50,8 +54,16 @@ public final class SpeechHistoryCloudSync {
|
||||
public func pushLocalIfEnabled() async throws {
|
||||
let store = makeStore()
|
||||
guard store.settingsICloudSyncEnabled else { return }
|
||||
let local = SpeechHistoryStorage.load(from: historyDefaults())
|
||||
try push(local)
|
||||
// Read-merge-write: pushing the local view verbatim would overwrite
|
||||
// entries another device added since our last pull (KVS is
|
||||
// last-writer-wins with no server-side merge).
|
||||
let defaults = historyDefaults()
|
||||
let local = SpeechHistoryStorage.load(from: defaults)
|
||||
let merged = loadRemote().map { SyncedSpeechHistory.merge(local: local, remote: $0) } ?? local
|
||||
if merged != local {
|
||||
apply(merged, to: defaults, postNotification: true)
|
||||
}
|
||||
try push(merged)
|
||||
}
|
||||
|
||||
/// Called when settings sync is first enabled to union local + remote history.
|
||||
@@ -81,11 +93,34 @@ public final class SpeechHistoryCloudSync {
|
||||
}
|
||||
|
||||
public func push(_ history: SyncedSpeechHistory) throws {
|
||||
let data = try encode(history)
|
||||
let data = try encodeFittingBudget(history)
|
||||
kvs.set(data, forKey: Self.kvsKey)
|
||||
_ = kvs.synchronize()
|
||||
}
|
||||
|
||||
/// Encode, dropping the oldest entries until the payload fits the KVS
|
||||
/// budget. Without this, a history that once fit under the old 900 KB
|
||||
/// cap (300 long dictations easily exceed 400 KB) would make EVERY push
|
||||
/// throw forever — automatic pushes are fire-and-forget, so sync would
|
||||
/// just silently die with no way back short of clearing all history.
|
||||
/// Only the *uploaded* copy is trimmed; local history keeps its full
|
||||
/// 300 entries.
|
||||
func encodeFittingBudget(_ history: SyncedSpeechHistory) throws -> Data {
|
||||
var payload = history
|
||||
while true {
|
||||
do {
|
||||
return try encode(payload)
|
||||
} catch SpeechHistoryCloudSyncError.payloadTooLarge {
|
||||
guard payload.entries.count > 1 else { throw SpeechHistoryCloudSyncError.payloadTooLarge(byteCount: 0) }
|
||||
// Drop the oldest ~10% per pass; entries are kept
|
||||
// newest-first by the store, so trim from the tail.
|
||||
let sorted = payload.entries.sorted { $0.createdAt > $1.createdAt }
|
||||
let keep = max(1, sorted.count - max(1, sorted.count / 10))
|
||||
payload.entries = Array(sorted.prefix(keep))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public func loadRemote() -> SyncedSpeechHistory? {
|
||||
if let data = kvs.data(forKey: Self.kvsKey) {
|
||||
return try? decode(data)
|
||||
|
||||
@@ -45,8 +45,16 @@ public final class UsageStatisticsCloudSync {
|
||||
public func pushLocalIfEnabled() async throws {
|
||||
let store = makeStore()
|
||||
guard store.settingsICloudSyncEnabled else { return }
|
||||
// Read-merge-write: this fires after every utterance, so pushing the
|
||||
// local view verbatim would clobber counter slices another device
|
||||
// advanced since our last pull (KVS is last-writer-wins). The
|
||||
// G-Counter merge makes the push commutative instead.
|
||||
let local = SyncedUsageStatisticsStorage.load(from: store.defaults)
|
||||
try push(local)
|
||||
let merged = loadRemote().map { SyncedUsageStatisticsV2.merge(local: local, remote: $0) } ?? local
|
||||
if merged != local {
|
||||
apply(merged, to: store.defaults, postNotification: true)
|
||||
}
|
||||
try push(merged)
|
||||
}
|
||||
|
||||
/// Called when settings sync is first enabled to union local + remote totals.
|
||||
|
||||
Reference in New Issue
Block a user