iOS: device sync (Swift port of the Android engine) behind the sync_service channel
PROPFIND walk / diff / GET / PUT mirror with conflicts, per-account state, BGTaskScheduler background runs, and conflict notifications with Keep local / Use server. Reviewed by Codex against the Kotlin original; its data-loss findings are fixed and each has a regression test (fake-server end-to-end tests, 68 Swift tests total): truncated/unusable PROPFIND answers are never a manifest, unreadable state aborts the run, server paths can't leave the mirror, case/Unicode collisions are skipped, atomic downloads, a locally edited file deleted on the server is kept, '/' scope matching, serialised removal, tracked/cancellable background runs, per-account conflicts, and sign-out vs background-off credentials (cancel gains a 'forget' flag). Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5.5
parent
1634d34341
commit
8805bafb97
@@ -0,0 +1,120 @@
|
||||
import Foundation
|
||||
import Security
|
||||
|
||||
/// Every account's sync settings and credentials, kept in the Keychain (the
|
||||
/// auth header is a secret) as one JSON item. A background run - which may
|
||||
/// start while the app isn't running - reads these to know what to sync and
|
||||
/// as whom, and a conflict notification's action looks the account up here
|
||||
/// instead of carrying credentials in the notification itself.
|
||||
///
|
||||
/// `kSecAttrAccessibleAfterFirstUnlock`, not "when unlocked": a background
|
||||
/// task can fire with the phone locked.
|
||||
///
|
||||
/// All accounts live in one item, so a write must never be built on a
|
||||
/// failed read: an unreadable item makes `upsert`/`remove` do nothing (and
|
||||
/// say so) rather than save a map holding only one account, and an update
|
||||
/// replaces the item in place instead of deleting it first.
|
||||
final class SyncConfigStore {
|
||||
private struct KeychainError: Error { let status: OSStatus }
|
||||
|
||||
private let service: String
|
||||
private let account = "configs"
|
||||
private let lock = NSLock()
|
||||
|
||||
init(service: String = "dev.ayushya.noo.sync-configs") {
|
||||
self.service = service
|
||||
}
|
||||
|
||||
/// Empty if there are none - or if the item can't be read, in which case
|
||||
/// nothing runs in the background until it can.
|
||||
func all() -> [SyncAccountConfig] {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
return Array(((try? load()) ?? [:]).values).sorted { $0.accountId < $1.accountId }
|
||||
}
|
||||
|
||||
func config(for accountId: String) -> SyncAccountConfig? {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
return (try? load())?[accountId]
|
||||
}
|
||||
|
||||
/// False if it couldn't be stored (the previous contents are untouched).
|
||||
@discardableResult
|
||||
func upsert(_ config: SyncAccountConfig) -> Bool {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
do {
|
||||
var configs = try load()
|
||||
configs[config.accountId] = config
|
||||
try save(configs)
|
||||
return true
|
||||
} catch {
|
||||
NSLog("[Sync] couldn't store config for %@: %@", config.accountId, String(describing: error))
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func remove(accountId: String) -> Bool {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
do {
|
||||
var configs = try load()
|
||||
guard configs.removeValue(forKey: accountId) != nil else { return true }
|
||||
try save(configs)
|
||||
return true
|
||||
} catch {
|
||||
NSLog("[Sync] couldn't remove config for %@: %@", accountId, String(describing: error))
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func removeAll() {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
SecItemDelete(baseQuery() as CFDictionary)
|
||||
}
|
||||
|
||||
// MARK: - Keychain
|
||||
|
||||
private func baseQuery() -> [String: Any] {
|
||||
[
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: account,
|
||||
]
|
||||
}
|
||||
|
||||
/// `[:]` only when nothing is stored yet; any other failure throws.
|
||||
private func load() throws -> [String: SyncAccountConfig] {
|
||||
var query = baseQuery()
|
||||
query[kSecReturnData as String] = true
|
||||
query[kSecMatchLimit as String] = kSecMatchLimitOne
|
||||
var result: AnyObject?
|
||||
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
||||
if status == errSecItemNotFound { return [:] }
|
||||
guard status == errSecSuccess, let data = result as? Data else { throw KeychainError(status: status) }
|
||||
return try JSONDecoder().decode([String: SyncAccountConfig].self, from: data)
|
||||
}
|
||||
|
||||
private func save(_ configs: [String: SyncAccountConfig]) throws {
|
||||
guard !configs.isEmpty else {
|
||||
let status = SecItemDelete(baseQuery() as CFDictionary)
|
||||
if status != errSecSuccess && status != errSecItemNotFound { throw KeychainError(status: status) }
|
||||
return
|
||||
}
|
||||
let data = try JSONEncoder().encode(configs) // before touching the item
|
||||
let status = SecItemUpdate(
|
||||
baseQuery() as CFDictionary, [kSecValueData as String: data] as CFDictionary)
|
||||
if status == errSecItemNotFound {
|
||||
var query = baseQuery()
|
||||
query[kSecValueData as String] = data
|
||||
query[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock
|
||||
let added = SecItemAdd(query as CFDictionary, nil)
|
||||
if added != errSecSuccess { throw KeychainError(status: added) }
|
||||
} else if status != errSecSuccess {
|
||||
throw KeychainError(status: status)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user