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>
121 lines
4.1 KiB
Swift
121 lines
4.1 KiB
Swift
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)
|
|
}
|
|
}
|
|
}
|