Files
noo/ios/Runner/Native/SyncEngine/SyncStore.swift
T
Ayushya AmitabhandClaude Sonnet 5.5 8805bafb97 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>
2026-10-06 21:01:08 -04:00

176 lines
6.8 KiB
Swift

import Foundation
/// The on-device side of sync: the mirrored files and the per-account sync
/// state. Laid out under one base directory (Application Support, which is
/// also where Dart's `SyncService.baseDirectory()` looks for the mirror):
///
/// <base>/sync/<accountId>/... the mirrored files
/// <base>/sync-state/<accountId>/*.json state, root markers, missing roots
///
/// Plain JSON files rather than `UserDefaults`: the state map grows with the
/// number of synced files. Not thread-safe by itself - the sync runner holds
/// one lock around anything that reads-then-rewrites it (see `AsyncMutex`).
final class SyncStore {
/// Bumped whenever a walk starts doing something new (e.g. mirroring empty
/// folders) so markers recorded by an older version - which would make the
/// next run skip the walk - are ignored once. Same value as Android.
static let rootsVersion = 2
static let shared = SyncStore(base: defaultBase)
static var defaultBase: URL {
FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
}
let base: URL
private let fileManager = FileManager.default
init(base: URL) {
self.base = base
}
// MARK: - Locations
/// The account's mirror folder, created on demand. Marked excluded from
/// iCloud/device backups: it's a re-downloadable copy of server data.
func syncRoot(accountId: String) -> URL {
let top = base.appendingPathComponent("sync", isDirectory: true)
let root = top.appendingPathComponent(accountId, isDirectory: true)
try? fileManager.createDirectory(at: root, withIntermediateDirectories: true)
var values = URLResourceValues()
values.isExcludedFromBackup = true
var topMutable = top
try? topMutable.setResourceValues(values)
return root
}
private func stateDirectory(accountId: String) -> URL {
let dir = base.appendingPathComponent("sync-state", isDirectory: true)
.appendingPathComponent(accountId, isDirectory: true)
try? fileManager.createDirectory(at: dir, withIntermediateDirectories: true)
return dir
}
private func file(_ accountId: String, _ name: String) -> URL {
stateDirectory(accountId: accountId).appendingPathComponent(name)
}
// MARK: - Per-file state
/// The recorded baseline - empty if nothing has been recorded yet, and
/// *throws* if there is a file that can't be read or decoded (see
/// `SyncStateUnreadable`).
func loadState(accountId: String) throws -> [String: SyncFileState] {
let url = file(accountId, "state.json")
guard fileManager.fileExists(atPath: url.path) else { return [:] }
do {
return try JSONDecoder().decode([String: SyncFileState].self, from: Data(contentsOf: url))
} catch {
throw SyncStateUnreadable(accountId: accountId)
}
}
/// False if the state couldn't be written - callers must not then record
/// anything that assumes it was.
@discardableResult
func saveState(accountId: String, _ state: [String: SyncFileState]) -> Bool {
write(state, to: file(accountId, "state.json"))
}
// MARK: - Root markers
private struct MarkersFile: Codable {
let version: Int
let markers: [String: SyncRootMarker]
}
func loadRootMarkers(accountId: String) -> [String: SyncRootMarker] {
guard let stored = read(MarkersFile.self, from: file(accountId, "roots.json")),
stored.version == Self.rootsVersion
else { return [:] }
return stored.markers
}
@discardableResult
func saveRootMarkers(accountId: String, _ markers: [String: SyncRootMarker]) -> Bool {
write(MarkersFile(version: Self.rootsVersion, markers: markers), to: file(accountId, "roots.json"))
}
// MARK: - Roots the server says are gone
func loadMissingRoots(accountId: String) -> Set<String> {
Set(read([String].self, from: file(accountId, "missing.json")) ?? [])
}
func setRootMissing(accountId: String, path: String, missing: Bool) {
var current = loadMissingRoots(accountId: accountId)
let changed = missing ? current.insert(path).inserted : (current.remove(path) != nil)
if changed { _ = write(Array(current).sorted(), to: file(accountId, "missing.json")) }
}
// MARK: - Removing a path's mirror
/// Deletes [path]'s local mirror (a file, or a whole folder's worth) and
/// forgets its entries - called when the user turns sync off for that path.
/// Without clearing the state too, a later re-add would see the (now
/// missing) local file as "deleted, server unchanged" and not pull it back.
func removeLocalSync(accountId: String, path: String) {
var cleanPath = path.trimmingCharacters(in: .whitespaces)
if cleanPath.hasPrefix("/") { cleanPath.removeFirst() }
while cleanPath.hasSuffix("/") { cleanPath.removeLast() }
let prefix = cleanPath.isEmpty ? "" : cleanPath + "/"
// An unreadable baseline is left alone rather than overwritten with a
// partial one; the next run reports it and does nothing.
if var state = try? loadState(accountId: accountId) {
// The sync root itself ("/") covers every recorded file.
for (fileId, entry) in state
where cleanPath.isEmpty || entry.relPath == cleanPath || entry.relPath.hasPrefix(prefix) {
state[fileId] = nil
}
saveState(accountId: accountId, state)
}
setRootMissing(accountId: accountId, path: "/" + cleanPath, missing: false)
setRootMissing(accountId: accountId, path: cleanPath, missing: false)
// A root marker for this path (or an ancestor/descendant that also covers
// some of what was just deleted) would make the next background pass
// think nothing changed and skip re-downloading.
let removedPath = "/" + cleanPath
var markers = loadRootMarkers(accountId: accountId)
let stale = markers.keys.filter { key in
var k = key
while k.hasSuffix("/") { k.removeLast() }
return k.isEmpty || removedPath == "/" || k == removedPath || k.hasPrefix(removedPath + "/")
|| removedPath.hasPrefix(k + "/")
}
if !stale.isEmpty {
stale.forEach { markers[$0] = nil }
saveRootMarkers(accountId: accountId, markers)
}
let root = syncRoot(accountId: accountId)
let target = cleanPath.isEmpty ? root : root.appendingPathComponent(cleanPath)
if fileManager.fileExists(atPath: target.path) { try? fileManager.removeItem(at: target) }
if cleanPath.isEmpty { _ = syncRoot(accountId: accountId) }
}
// MARK: - Helpers
private func read<T: Decodable>(_ type: T.Type, from url: URL) -> T? {
guard let data = try? Data(contentsOf: url) else { return nil }
return try? JSONDecoder().decode(T.self, from: data)
}
private func write<T: Encodable>(_ value: T, to url: URL) -> Bool {
guard let data = try? JSONEncoder().encode(value) else { return false }
do {
try data.write(to: url, options: .atomic)
return true
} catch {
return false
}
}
}