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:
Ayushya Amitabh
2026-10-06 21:01:08 -04:00
co-authored by Claude Sonnet 5.5
parent 1634d34341
commit 8805bafb97
14 changed files with 2834 additions and 10 deletions
@@ -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)
}
}
}