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:
Rocky
2026-07-10 12:39:41 +08:00
parent dcb66a9849
commit cdf833935a
104 changed files with 5794 additions and 853 deletions
@@ -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 }