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,294 @@
import Foundation
/// Credentials for one account's WebDAV root.
struct SyncCredentials {
let serverUrl: String
let username: String
let authHeader: String
}
/// The WebDAV calls sync needs: PROPFIND (a folder's children, or one item),
/// a recursive walk, and GET/PUT of a single file. Foreground
/// `URLSession` work - sync runs inside the app or a `BGTask`, not on a
/// background session, so a run can walk, diff and decide in one go.
///
/// Authenticates by each request's own `Authorization` header only (no cookies,
/// no credential cache): two accounts can share a server, and one account's
/// session must never answer for another.
final class DavSyncClient {
private let session: URLSession
init(session: URLSession = DavSyncClient.makeSession()) {
self.session = session
}
static func makeSession() -> URLSession {
let config = URLSessionConfiguration.ephemeral
config.httpShouldSetCookies = false
config.httpCookieAcceptPolicy = .never
config.urlCredentialStorage = nil
config.requestCachePolicy = .reloadIgnoringLocalCacheData
config.timeoutIntervalForRequest = 30
config.timeoutIntervalForResource = 15 * 60
return URLSession(configuration: config)
}
// MARK: - PROPFIND
private static let propfindBody = """
<?xml version="1.0" encoding="utf-8" ?>
<d:propfind xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns">
<d:prop>
<d:getlastmodified/>
<d:getcontentlength/>
<d:resourcetype/>
<d:getetag/>
<oc:fileid/>
</d:prop>
</d:propfind>
"""
/// Depth-1 PROPFIND of [path]: its direct children only. A clean 404 (the
/// folder is genuinely gone) is an empty list; anything else that goes wrong
/// throws `SyncRemoteUnavailable`.
func propfindChildren(_ creds: SyncCredentials, path: String) async throws -> [SyncRemoteEntry] {
try await propfind(creds, path: path, depth: "1", skipSelf: true)
}
/// Depth-0 PROPFIND of [path] itself - nil on a clean 404.
func propfindSelf(_ creds: SyncCredentials, path: String) async throws -> SyncRemoteEntry? {
try await propfind(creds, path: path, depth: "0", skipSelf: false).first
}
private func propfind(
_ creds: SyncCredentials, path: String, depth: String, skipSelf: Bool
) async throws -> [SyncRemoteEntry] {
var clean = path.trimmingCharacters(in: .whitespaces)
if !clean.hasPrefix("/") { clean = "/" + clean }
guard var url = WebDAV.fileURL(serverUrl: creds.serverUrl, username: creds.username, remotePath: clean)
else { throw SyncRemoteUnavailable(message: "Invalid server address.") }
// Collections are addressed with a trailing slash.
if depth == "1", let slashed = URL(string: url.absoluteString + "/") { url = slashed }
var request = URLRequest(url: url)
request.httpMethod = "PROPFIND"
request.setValue(depth, forHTTPHeaderField: "Depth")
request.setValue("application/xml", forHTTPHeaderField: "Content-Type")
request.setValue(creds.authHeader, forHTTPHeaderField: "Authorization")
request.httpBody = Self.propfindBody.data(using: .utf8)
let data: Data
let status: Int
do {
let (body, response) = try await session.data(for: request)
data = body
status = (response as? HTTPURLResponse)?.statusCode ?? 0
} catch {
try Self.rethrowIfCancelled(error)
throw SyncRemoteUnavailable(message: "PROPFIND \(url.path) failed: \(error.localizedDescription)")
}
if status == 404 { return [] } // genuinely gone
guard status == 207 else {
throw SyncRemoteUnavailable(message: "PROPFIND \(url.path) -> \(status)")
}
return try DavSyncParser.entries(from: data, username: creds.username, requestedPath: clean, skipSelf: skipSelf)
}
/// Recursively walks [root] (Depth-1 PROPFINDs, breadth-first) into a flat
/// manifest. Throws if *any* level fails - a partial manifest would look
/// like the missing part had been deleted on the server.
func walk(_ creds: SyncCredentials, root: String) async throws -> [SyncRemoteEntry] {
var result: [SyncRemoteEntry] = []
var queue = [root]
var index = 0
while index < queue.count {
try Task.checkCancellation()
let children = try await propfindChildren(creds, path: queue[index])
index += 1
for child in children {
result.append(child)
if child.isFolder { queue.append(child.path) }
}
}
return result
}
// MARK: - GET / PUT
/// Downloads [remotePath] to [destination]. Written to a temp file first and
/// moved into place, so an interrupted download never leaves a truncated
/// file where a good one used to be. False on any failure.
func download(_ creds: SyncCredentials, remotePath: String, to destination: URL) async throws -> Bool {
guard let url = WebDAV.fileURL(serverUrl: creds.serverUrl, username: creds.username, remotePath: remotePath)
else { return false }
var request = URLRequest(url: url)
request.setValue(creds.authHeader, forHTTPHeaderField: "Authorization")
do {
let (temp, response) = try await session.download(for: request)
guard let status = (response as? HTTPURLResponse)?.statusCode, (200..<300).contains(status) else {
try? FileManager.default.removeItem(at: temp)
return false
}
try Task.checkCancellation()
let fm = FileManager.default
try fm.createDirectory(at: destination.deletingLastPathComponent(), withIntermediateDirectories: true)
var isDirectory: ObjCBool = false
if fm.fileExists(atPath: destination.path, isDirectory: &isDirectory) {
// Never replace a folder with a file, and replace a file atomically:
// the old copy stays until the new one is fully in place.
guard !isDirectory.boolValue else {
try? fm.removeItem(at: temp)
return false
}
_ = try fm.replaceItemAt(destination, withItemAt: temp)
} else {
try fm.moveItem(at: temp, to: destination)
}
return true
} catch {
try Self.rethrowIfCancelled(error)
return false
}
}
/// PUTs [source] to [remotePath]. False on any failure.
func upload(_ creds: SyncCredentials, remotePath: String, from source: URL) async throws -> Bool {
guard let url = WebDAV.fileURL(serverUrl: creds.serverUrl, username: creds.username, remotePath: remotePath)
else { return false }
var request = URLRequest(url: url)
request.httpMethod = "PUT"
request.setValue(creds.authHeader, forHTTPHeaderField: "Authorization")
do {
let (_, response) = try await session.upload(for: request, fromFile: source)
guard let status = (response as? HTTPURLResponse)?.statusCode else { return false }
return (200..<300).contains(status)
} catch {
try Self.rethrowIfCancelled(error)
return false
}
}
/// A cancelled run must stop, not be mistaken for a flaky network.
private static func rethrowIfCancelled(_ error: Error) throws {
if error is CancellationError || (error as? URLError)?.code == .cancelled || Task.isCancelled {
throw CancellationError()
}
}
}
/// Parses a PROPFIND multistatus into [SyncRemoteEntry]s. Pure, so it's
/// unit-tested.
enum DavSyncParser {
private struct Raw {
var href = ""
var etag = ""
var modified = ""
var length = ""
var fileId = ""
var isCollection = false
}
private final class Delegate: NSObject, XMLParserDelegate {
var responses: [Raw] = []
var sawMultistatus = false
private var current: Raw?
private var text = ""
func parser(
_ parser: XMLParser, didStartElement elementName: String, namespaceURI: String?,
qualifiedName qName: String?, attributes attributeDict: [String: String] = [:]
) {
text = ""
if elementName == "multistatus" { sawMultistatus = true }
if elementName == "response" { current = Raw() }
if elementName == "collection" { current?.isCollection = true }
}
func parser(_ parser: XMLParser, foundCharacters string: String) {
text += string
}
func parser(
_ parser: XMLParser, didEndElement elementName: String, namespaceURI: String?,
qualifiedName qName: String?
) {
let value = text.trimmingCharacters(in: .whitespacesAndNewlines)
switch elementName {
case "href": current?.href = value
case "getetag": current?.etag = value
case "getlastmodified": current?.modified = value
case "getcontentlength": current?.length = value
case "fileid": current?.fileId = value
case "response":
if let current { responses.append(current) }
current = nil
default: break
}
text = ""
}
}
private static let httpDate: DateFormatter = {
let f = DateFormatter()
f.locale = Locale(identifier: "en_US_POSIX")
f.timeZone = TimeZone(secondsFromGMT: 0)
f.dateFormat = "EEE, dd MMM yyyy HH:mm:ss zzz"
return f
}()
/// Parses a PROPFIND multistatus. Throws `SyncRemoteUnavailable` for
/// anything that isn't a complete, plausible answer - malformed or
/// truncated XML, no `multistatus` envelope, no responses at all (a 207
/// always has at least the requested resource), or an href that isn't
/// under this account's files root or tries to climb out of it. A partial
/// manifest would look like the missing part was deleted on the server
/// (and an empty one like the whole path was), so the caller must treat
/// those as "couldn't reach the server", never as data.
static func entries(
from xml: Data, username: String, requestedPath: String, skipSelf: Bool
) throws -> [SyncRemoteEntry] {
let delegate = Delegate()
let parser = XMLParser(data: xml)
parser.shouldProcessNamespaces = true
parser.delegate = delegate
guard parser.parse(), delegate.sawMultistatus, !delegate.responses.isEmpty else {
throw SyncRemoteUnavailable(message: "Unusable PROPFIND answer")
}
let marker = "/remote.php/dav/files/\(username)"
let selfPath = trimTrailingSlashes(requestedPath)
var entries: [SyncRemoteEntry] = []
for raw in delegate.responses {
let decoded = raw.href.removingPercentEncoding ?? raw.href
guard let range = decoded.range(of: marker) else {
throw SyncRemoteUnavailable(message: "PROPFIND href outside the account's files root")
}
let hrefPath = String(decoded[range.upperBound...])
// A well-formed path continues the marker at a component boundary and
// never contains a dot component.
guard hrefPath.isEmpty || hrefPath.hasPrefix("/"),
!hrefPath.split(separator: "/").contains(where: { $0 == ".." || $0 == "." })
else {
throw SyncRemoteUnavailable(message: "PROPFIND href has an unexpected path")
}
let normalized = trimTrailingSlashes(hrefPath.isEmpty ? "/" : hrefPath)
if skipSelf && normalized == selfPath { continue } // the folder itself, not a child
entries.append(
SyncRemoteEntry(
path: normalized.isEmpty ? "/" : normalized,
fileId: raw.fileId.isEmpty ? (normalized.isEmpty ? "/" : normalized) : raw.fileId,
etag: raw.etag.trimmingCharacters(in: CharacterSet(charactersIn: "\"")),
lastModified: httpDate.date(from: raw.modified).map { Int64($0.timeIntervalSince1970 * 1000) } ?? 0,
size: Int64(raw.length) ?? 0,
isFolder: raw.isCollection))
}
return entries
}
private static func trimTrailingSlashes(_ path: String) -> String {
var p = path
while p.hasSuffix("/") { p.removeLast() }
return p
}
}
@@ -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)
}
}
}
@@ -0,0 +1,448 @@
import BackgroundTasks
import Network
import UIKit
import UserNotifications
/// Whether the network currently allows a sync run: any connection normally,
/// but with "Wi-Fi only" nothing metered (cellular, a phone's hotspot) -
/// iOS's equivalent of Android's `NetworkType.UNMETERED` work constraint,
/// which a `BGTask` doesn't offer.
enum NetworkGate {
static func allows(wifiOnly: Bool) async -> Bool {
let monitor = NWPathMonitor()
let queue = DispatchQueue(label: "dev.ayushya.noo.network-gate")
return await withCheckedContinuation { continuation in
var answered = false // only touched on `queue`
monitor.pathUpdateHandler = { path in
guard !answered else { return }
answered = true
monitor.cancel()
let connected = path.status == .satisfied
continuation.resume(returning: connected && (!wifiOnly || !path.isExpensive))
}
monitor.start(queue: queue)
}
}
}
/// Runs an action at most once - for completing a `BGTask`, which must be
/// told it's done exactly one time whether the work finished or the system's
/// time ran out first.
final class OnceGate {
private let lock = NSLock()
private var done = false
private let action: (Bool) -> Void
init(_ action: @escaping (Bool) -> Void) {
self.action = action
}
func finish(_ success: Bool) {
lock.lock()
if done {
lock.unlock()
return
}
done = true
lock.unlock()
action(success)
}
}
/// Runs sync for the app: the "Sync now" / in-app refresh runs the Dart side
/// asks for, the periodic background runs iOS grants through
/// `BGTaskScheduler`, conflict resolution, and the notifications around them.
/// The iOS counterpart of Android's `SyncWorker` scheduling + `MainActivity`'s
/// `sync_service` handlers; the actual walk/diff/transfer is `SyncRunner`.
///
/// Every run - foreground or background - goes through `startRun`, so one
/// per-account record knows what's running, "Sync now" can supersede it and a
/// quiet check can leave it alone, and signing out or removing a path can
/// cancel it. Runs are also serialised against each other (and against
/// removal) by one mutex, and carry the account's *generation*: cancelling or
/// removing bumps it, so a run that was only queued behind another never
/// starts for an account that's gone in the meantime.
///
/// iOS decides *when* background work runs (typically while charging, on
/// Wi-Fi, learned from how you use the phone) - `intervalMinutes` is only the
/// earliest it may start, not a schedule, and a run is cut off when the
/// system's time budget ends (the runner saves its state as it goes).
final class SyncCoordinator {
static let shared = SyncCoordinator()
static let refreshTaskId = "dev.ayushya.noo.sync.refresh"
static let processingTaskId = "dev.ayushya.noo.sync.processing"
static let conflictCategory = "dev.ayushya.noo.sync.conflict"
static let keepLocalAction = "dev.ayushya.noo.sync.keepLocal"
static let useServerAction = "dev.ayushya.noo.sync.useServer"
let store: SyncStore
let bus: SyncStatusBus
let configs: SyncConfigStore
private let runner: SyncRunner
/// One run at a time, app-wide (see `AsyncMutex`).
private let mutex = AsyncMutex()
private let lock = NSLock()
private var running: [String: (token: UUID, task: Task<Void, Never>)] = [:]
private var generations: [String: Int] = [:]
init(
store: SyncStore = .shared, bus: SyncStatusBus = SyncStatusBus(),
configs: SyncConfigStore = SyncConfigStore(), client: DavSyncClient = DavSyncClient()
) {
self.store = store
self.bus = bus
self.configs = configs
self.runner = SyncRunner(client: client, store: store, bus: bus)
}
// MARK: - Settings from Dart
/// Brings the background job in line with [config] (called whenever the
/// synced paths, account or network setting changes). A config with no
/// folders means this account has nothing to sync.
func reschedule(_ config: SyncAccountConfig) {
if config.folders.isEmpty {
cancel(accountId: config.accountId, forget: true)
return
}
configs.upsert(config)
scheduleBackgroundWork()
}
/// [forget] true: the account was removed or signed out - stop its run and
/// drop its settings and credentials. [forget] false: only background sync
/// was turned off (manual refresh is still possible) - the credentials stay,
/// since a conflict notification's actions and the next "Sync now" need
/// them, and a run in progress is left to finish.
func cancel(accountId: String, forget: Bool = true) {
if forget {
cancelRun(accountId: accountId, invalidateQueued: true)
configs.remove(accountId: accountId)
} else if var config = configs.config(for: accountId) {
config.intervalMinutes = nil
configs.upsert(config)
}
scheduleBackgroundWork()
}
/// A one-off run. [force] is an explicit "Sync now" / newly added path:
/// a full walk, a summary notification, and it supersedes any run already
/// going for this account; without it (pull-to-refresh, foreground timer,
/// app resume) it's a quiet check that never interrupts a run in progress.
/// [wifiOnly] non-nil means "respect the Wi-Fi-only setting".
func syncNow(_ config: SyncAccountConfig, force: Bool, wifiOnly: Bool?) {
if config.folders.isEmpty { return }
// Keep the background settings this call doesn't carry.
var stored = config
if let existing = configs.config(for: config.accountId) {
stored.intervalMinutes = existing.intervalMinutes
stored.wifiOnly = existing.wifiOnly
}
configs.upsert(stored)
startRun(stored.withNotify(config.notify), force: force, wifiOnly: wifiOnly)
}
/// Stops [accountId]'s run, if any. [invalidateQueued] also stops runs that
/// are only waiting their turn from ever starting.
private func cancelRun(accountId: String, invalidateQueued: Bool) {
lock.lock()
if invalidateQueued { generations[accountId, default: 0] += 1 }
running[accountId]?.task.cancel()
lock.unlock()
}
private func generation(of accountId: String) -> Int {
lock.lock()
defer { lock.unlock() }
return generations[accountId] ?? 0
}
/// Starts a tracked run, or - for a non-forced one when this account is
/// already running - returns nil and leaves the current run alone.
@discardableResult
private func startRun(_ config: SyncAccountConfig, force: Bool, wifiOnly: Bool?) -> Task<Void, Never>? {
lock.lock()
if let existing = running[config.accountId] {
if force {
existing.task.cancel()
} else {
lock.unlock()
return nil
}
}
let token = UUID()
let generation = generations[config.accountId] ?? 0
let task = Task { [weak self] in
await self?.execute(config, force: force, wifiOnly: wifiOnly, generation: generation)
self?.finished(accountId: config.accountId, token: token)
}
running[config.accountId] = (token, task)
lock.unlock()
return task
}
private func finished(accountId: String, token: UUID) {
lock.lock()
if running[accountId]?.token == token { running[accountId] = nil }
lock.unlock()
}
/// Turning sync off for [path]: stop this account's run first and do the
/// removal under the same mutex as runs - a run still holding the old
/// state would otherwise write it back over the removal (and a transfer
/// finishing after it would leave a file the state doesn't know about).
func removeLocalSync(accountId: String, path: String) async {
cancelRun(accountId: accountId, invalidateQueued: true)
_ = try? await mutex.withLock {
store.removeLocalSync(accountId: accountId, path: path)
}
}
/// The map Dart's `SyncStatusSnapshot.fromMap` reads. [accountIdOverride]
/// is the account Dart is asking about: the bus only learns an account once
/// a run has happened in this process, so on a fresh start the durable
/// per-file synced state would otherwise be reported as empty.
func statusMap(_ status: SyncStatusBus.Status, accountIdOverride: String? = nil) -> [String: Any] {
let accountId = accountIdOverride ?? status.accountId
// Live syncing state belongs to whichever account last synced.
let busMatches = status.accountId == nil || status.accountId == accountId
let synced = accountId.map { Array(((try? store.loadState(accountId: $0)) ?? [:]).keys) } ?? []
let missing = accountId.map { Array(store.loadMissingRoots(accountId: $0)) } ?? []
return [
"accountId": accountId as Any,
"syncing": busMatches && status.syncing,
"syncingFileIds": busMatches ? Array(status.syncingFileIds) : [],
"syncedFileIds": synced,
"missingRoots": missing,
// Conflicts are per account (a file id is only unique within one).
"conflicts": status.conflicts.filter { $0.accountId == accountId }.map {
[
"accountId": $0.accountId, "fileId": $0.fileId, "remotePath": $0.remotePath,
"relPath": $0.relPath, "name": $0.name,
]
},
]
}
// MARK: - Running
/// One account's pass, serialised against every other run.
private func execute(
_ config: SyncAccountConfig, force: Bool, wifiOnly: Bool?, generation: Int
) async {
// A few extra seconds if the app is backgrounded mid-run; if even those
// run out, stop this account's run so it saves and ends cleanly.
let box = BackgroundTaskBox()
box.id = await MainActor.run {
UIApplication.shared.beginBackgroundTask(withName: "noo.sync") { [weak self] in
self?.cancelRun(accountId: config.accountId, invalidateQueued: false)
UIApplication.shared.endBackgroundTask(box.id)
}
}
defer { Task { @MainActor in UIApplication.shared.endBackgroundTask(box.id) } }
do {
try await mutex.withLock {
try Task.checkCancellation()
// Gone (signed out / removed) while this was queued?
guard self.generation(of: config.accountId) == generation,
self.configs.config(for: config.accountId) != nil
else { return }
if let wifiOnly, await !NetworkGate.allows(wifiOnly: wifiOnly) { return }
let summary = await runner.run(config, force: force)
if summary.changedAnything && (force || config.notify) {
SyncNotifications.summary(summary, username: config.username, accountId: config.accountId)
}
SyncNotifications.conflicts(summary.conflicts, username: config.username)
}
} catch {
// Cancelled while waiting for its turn - nothing ran.
}
}
/// Resolves a conflict from a notification action, using the stored
/// credentials for its account.
func resolveConflict(_ conflict: SyncConflict, resolution: String, creds: SyncCredentials) async -> Bool {
(try? await mutex.withLock {
await runner.resolveConflict(conflict, resolution: resolution, creds: creds)
}) ?? false
}
// MARK: - Background tasks
/// Registers the two `BGTask` handlers. Must run before the app finishes
/// launching (see `AppDelegate`).
func registerBackgroundTasks() {
for id in [Self.refreshTaskId, Self.processingTaskId] {
BGTaskScheduler.shared.register(forTaskWithIdentifier: id, using: nil) { [weak self] task in
self?.handleBackground(task)
}
}
}
/// Asks iOS for the next background run: a short app-refresh slot and a
/// longer processing slot (the system picks when, e.g. overnight on power).
/// Nothing is requested when no account has background sync turned on.
func scheduleBackgroundWork() {
let scheduler = BGTaskScheduler.shared
let due = backgroundConfigs()
guard let minutes = due.compactMap(\.intervalMinutes).min() else {
scheduler.cancel(taskRequestWithIdentifier: Self.refreshTaskId)
scheduler.cancel(taskRequestWithIdentifier: Self.processingTaskId)
return
}
// Same floor as Android's periodic work.
let earliest = Date(timeIntervalSinceNow: TimeInterval(max(minutes, 15) * 60))
let refresh = BGAppRefreshTaskRequest(identifier: Self.refreshTaskId)
refresh.earliestBeginDate = earliest
let processing = BGProcessingTaskRequest(identifier: Self.processingTaskId)
processing.earliestBeginDate = earliest
processing.requiresNetworkConnectivity = true
processing.requiresExternalPower = false
for request in [refresh, processing] as [BGTaskRequest] {
do { try scheduler.submit(request) } catch {
NSLog("[Sync] couldn't schedule %@: %@", request.identifier, String(describing: error))
}
}
}
private func backgroundConfigs() -> [SyncAccountConfig] {
configs.all().filter { $0.intervalMinutes != nil && !$0.folders.isEmpty }
}
/// A background slot: runs every account that has background sync on, each
/// through the normal tracked path (so it can be cancelled by sign-out, and
/// a run already going is left alone). The task is reported complete
/// exactly once - when the work ends or when the system's time does.
private func handleBackground(_ task: BGTask) {
scheduleBackgroundWork() // line up the next one before this one runs
let gate = OnceGate { task.setTaskCompleted(success: $0) }
let accountIds = backgroundConfigs().map(\.accountId)
let work = Task { [weak self] in
guard let self else { return gate.finish(false) }
for config in self.backgroundConfigs() {
if Task.isCancelled { break }
await self.startRun(config, force: false, wifiOnly: config.wifiOnly)?.value
}
gate.finish(!Task.isCancelled)
}
// The system's time is up: stop the work and the runs it started (each
// saves what it has) and report.
task.expirationHandler = { [weak self] in
work.cancel()
for id in accountIds { self?.cancelRun(accountId: id, invalidateQueued: false) }
gate.finish(false)
}
}
// MARK: - Notifications
/// Registers the conflict notification's actions. Call once at launch.
func registerNotificationCategories() {
let keep = UNNotificationAction(identifier: Self.keepLocalAction, title: "Keep local", options: [])
let server = UNNotificationAction(identifier: Self.useServerAction, title: "Use server", options: [])
let category = UNNotificationCategory(
identifier: Self.conflictCategory, actions: [keep, server], intentIdentifiers: [], options: [])
UNUserNotificationCenter.current().setNotificationCategories([category])
}
/// Handles a tap on a conflict notification's action. Returns whether the
/// response was ours (so `AppDelegate` knows not to pass it on).
@MainActor
func handleNotificationResponse(
_ response: UNNotificationResponse, completion: @escaping () -> Void
) -> Bool {
let content = response.notification.request.content
guard content.categoryIdentifier == Self.conflictCategory else { return false }
let resolution: String
switch response.actionIdentifier {
case Self.keepLocalAction: resolution = "local"
case Self.useServerAction: resolution = "server"
default:
completion() // plain tap: just opens the app
return true
}
let info = content.userInfo
guard let accountId = info["accountId"] as? String,
let fileId = info["fileId"] as? String,
let remotePath = info["remotePath"] as? String,
let relPath = info["relPath"] as? String,
let config = configs.config(for: accountId)
else {
completion()
return true
}
let conflict = SyncConflict(
accountId: accountId, fileId: fileId, remotePath: remotePath, relPath: relPath,
name: (relPath as NSString).lastPathComponent)
let creds = SyncCredentials(serverUrl: config.serverUrl, username: config.username, authHeader: config.authHeader)
let box = BackgroundTaskBox()
box.id = UIApplication.shared.beginBackgroundTask(withName: "noo.sync.resolve") {
completion()
UIApplication.shared.endBackgroundTask(box.id)
}
Task {
let ok = await self.resolveConflict(conflict, resolution: resolution, creds: creds)
if ok {
UNUserNotificationCenter.current().removeDeliveredNotifications(
withIdentifiers: [response.notification.request.identifier])
}
completion()
await MainActor.run { UIApplication.shared.endBackgroundTask(box.id) }
}
return true
}
}
/// Holds a `UIBackgroundTaskIdentifier` so an expiration handler (created
/// before the identifier exists) can end the task it belongs to.
final class BackgroundTaskBox {
var id: UIBackgroundTaskIdentifier = .invalid
}
extension SyncAccountConfig {
func withNotify(_ notify: Bool) -> SyncAccountConfig {
var copy = self
copy.notify = notify
return copy
}
}
/// The local notifications sync posts: a summary of what changed, and one per
/// conflict (which always posts - it needs a decision) with Keep local / Use
/// server actions.
enum SyncNotifications {
static func summary(_ summary: SyncRunSummary, username: String, accountId: String) {
var parts: [String] = []
if summary.downloaded > 0 { parts.append("\(summary.downloaded) updated") }
if summary.uploaded > 0 { parts.append("\(summary.uploaded) uploaded") }
if summary.deleted > 0 { parts.append("\(summary.deleted) removed") }
let content = UNMutableNotificationContent()
content.title = "Noo sync · \(username)"
content.body = parts.joined(separator: ", ")
// One per account, so a newer summary replaces an older one in place.
post(content, id: "sync.summary.\(accountId)")
}
static func conflicts(_ conflicts: [SyncConflict], username: String) {
for conflict in conflicts {
let content = UNMutableNotificationContent()
content.title = "Sync conflict: \(conflict.name)"
content.body = "\(username) - changed both on this device and on the server."
content.categoryIdentifier = SyncCoordinator.conflictCategory
content.userInfo = [
"accountId": conflict.accountId, "fileId": conflict.fileId,
"remotePath": conflict.remotePath, "relPath": conflict.relPath,
]
post(content, id: "sync.conflict.\(conflict.accountId).\(conflict.relPath)")
}
}
private static func post(_ content: UNMutableNotificationContent, id: String) {
UNUserNotificationCenter.current().add(UNNotificationRequest(identifier: id, content: content, trigger: nil))
}
}
+195
View File
@@ -0,0 +1,195 @@
import Foundation
/// A local file's size and modification time (ms), the pair sync compares
/// against what it recorded.
struct LocalStat: Equatable {
let size: Int64
let mtimeMs: Int64
}
enum LocalFS {
/// nil if [url] doesn't exist.
static func stat(_ url: URL) -> LocalStat? {
guard let attrs = try? FileManager.default.attributesOfItem(atPath: url.path) else { return nil }
let size = (attrs[.size] as? NSNumber)?.int64Value ?? 0
let mtime = (attrs[.modificationDate] as? Date).map { Int64($0.timeIntervalSince1970 * 1000) } ?? 0
return LocalStat(size: size, mtimeMs: mtime)
}
static func relative(_ path: String) -> String {
path.hasPrefix("/") ? String(path.dropFirst()) : path
}
static func trimTrailingSlashes(_ path: String) -> String {
var p = path
while p.hasSuffix("/") { p.removeLast() }
return p
}
/// [rel] under [root], or nil if it isn't safely inside it: empty, with a
/// `.`/`..` component, or resolving outside [root]. Every path that came
/// from the server goes through this before anything is read, written or
/// deleted, so a hostile or buggy server can't point sync at other files.
static func resolve(_ rel: String, in root: URL) -> URL? {
let parts = rel.split(separator: "/", omittingEmptySubsequences: true)
guard !parts.isEmpty, !parts.contains(where: { $0 == ".." || $0 == "." }) else { return nil }
let url = root.appendingPathComponent(rel)
let rootPath = root.standardizedFileURL.path
guard url.standardizedFileURL.path.hasPrefix(rootPath + "/") else { return nil }
return url
}
}
/// The decisions of a sync pass, kept free of networking so they can be
/// unit-tested. A direct port of Android's `SyncEngine.diffFolder` and its
/// helpers - the same rules, so a device behaves the same on either platform.
enum SyncDiff {
/// Diffs one synced path's remote manifest against the recorded state.
/// Callers own actually doing the GET/PUT/delete and updating state.
///
/// - A file with no recorded state is simply pulled.
/// - Server changed and device changed -> conflict (never silently overwrite).
/// - Only the server changed -> download. Only the device changed -> upload.
/// - Recorded but missing on the device -> pull it back (it's inside a path
/// the user asked to sync, so a vanished copy is repaired, not propagated).
/// - Recorded for this path but absent from a *successful* listing ->
/// deleted on the server, so the local copy goes too. (An unreachable
/// server never gets here - the caller skips the path instead.)
static func diffFolder(
entries: [SyncRemoteEntry],
state: [String: SyncFileState],
priorFolderFileIds: Set<String>,
syncRoot: URL
) -> [SyncAction] {
var actions: [SyncAction] = []
var seen = Set<String>()
for entry in entries where !entry.isFolder {
seen.insert(entry.fileId)
let rel = LocalFS.relative(entry.path)
let local = LocalFS.stat(syncRoot.appendingPathComponent(rel))
guard let prior = state[entry.fileId] else {
actions.append(.download(entry))
continue
}
let serverChanged = entry.etag != prior.etag
let localChanged = local.map { $0.mtimeMs != prior.localMTime || $0.size != prior.size } ?? false
if local == nil {
actions.append(.download(entry))
} else if serverChanged && localChanged {
actions.append(.conflict(entry, relPath: rel))
} else if serverChanged {
actions.append(.download(entry))
} else if localChanged {
actions.append(.upload(relPath: rel, fileId: entry.fileId))
}
}
for fileId in priorFolderFileIds where !seen.contains(fileId) {
guard let prior = state[fileId] else { continue }
// Deleted on the server. If it was also edited here since the last sync
// that edit is the only copy left - keep it (untracked) instead of
// deleting it along with the rest.
if let local = LocalFS.stat(syncRoot.appendingPathComponent(prior.relPath)),
local.mtimeMs != prior.localMTime || local.size != prior.size
{
actions.append(.orphan(relPath: prior.relPath, fileId: fileId))
} else {
actions.append(.delete(relPath: prior.relPath, fileId: fileId))
}
}
return actions
}
/// The recorded files that belong to the synced [path]: everything for the
/// root ("/"), else the file itself or anything under it - matched on a
/// path-component boundary, so "/Docs" never claims "/Docs2" or
/// "/Documents".
static func priorFileIds(state: [String: SyncFileState], path: String) -> Set<String> {
let rootRel = LocalFS.relative(LocalFS.trimTrailingSlashes(path))
return Set(
state.filter {
rootRel.isEmpty || $0.value.relPath == rootRel || $0.value.relPath.hasPrefix(rootRel + "/")
}.keys)
}
/// Local paths (as spelled by the server) that more than one remote entry
/// would land on: "a.txt" and "A.txt" on a case-insensitive volume, or two
/// canonically-equivalent Unicode spellings. Downloading either would
/// overwrite the other, so callers skip them.
static func collidingRelPaths(_ entries: [SyncRemoteEntry]) -> Set<String> {
var byKey: [String: [String]] = [:]
for entry in entries {
let rel = LocalFS.relative(entry.path)
byKey[rel.precomposedStringWithCanonicalMapping.lowercased(), default: []].append(rel)
}
// Compared as raw bytes: Swift's own `String` equality already treats
// canonically equivalent spellings as the same string, which is exactly
// the distinction the server preserves and the disk may not.
return Set(byKey.values.filter { Set($0.map { Array($0.utf8) }).count > 1 }.flatMap { $0 })
}
/// True if every file in [fileIds] is still on disk exactly as recorded
/// (same size and mtime): nothing local to upload and nothing missing to
/// re-download. Pure local stat calls, no network.
static func localMatchesState(
state: [String: SyncFileState], fileIds: Set<String>, syncRoot: URL
) -> Bool {
fileIds.allSatisfy { id in
guard let s = state[id], let local = LocalFS.stat(syncRoot.appendingPathComponent(s.relPath)) else {
return false
}
return local.size == s.size && local.mtimeMs == s.localMTime
}
}
/// Creates a local directory for every remote folder, so empty folders
/// exist on the device too.
static func mirrorFolders(
entries: [SyncRemoteEntry], syncRoot: URL, rootRel: String, collisions: Set<String> = []
) {
let fm = FileManager.default
if !rootRel.isEmpty, let dir = LocalFS.resolve(rootRel, in: syncRoot) {
try? fm.createDirectory(at: dir, withIntermediateDirectories: true)
}
for entry in entries where entry.isFolder {
let rel = entry.path.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
// Skip anything unsafe or that would share a local folder with another
// remote folder (a/A on a case-insensitive volume).
guard !collisions.contains(rel), let dir = LocalFS.resolve(rel, in: syncRoot) else { continue }
try? fm.createDirectory(at: dir, withIntermediateDirectories: true)
}
}
/// Removes local directories under [rootRel] that no longer exist on the
/// server ([remoteFolders], relative paths). Only *empty* directories go:
/// by the time this runs the diff has already deleted the files that were
/// inside a deleted server folder, while a directory that still holds
/// something (a file this device created that hasn't synced) is left alone.
/// Never removes the sync root itself.
static func pruneRemovedFolders(syncRoot: URL, rootRel: String, remoteFolders: Set<String>) {
let fm = FileManager.default
let top = rootRel.isEmpty ? syncRoot : syncRoot.appendingPathComponent(rootRel)
var isDir: ObjCBool = false
guard fm.fileExists(atPath: top.path, isDirectory: &isDir), isDir.boolValue else { return }
func prune(_ dir: URL, rel: String) {
let children = (try? fm.contentsOfDirectory(at: dir, includingPropertiesForKeys: [.isDirectoryKey])) ?? []
for child in children {
let childIsDir = (try? child.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) ?? false
if childIsDir {
prune(child, rel: rel.isEmpty ? child.lastPathComponent : rel + "/" + child.lastPathComponent)
}
}
guard !rel.isEmpty, !remoteFolders.contains(rel),
let remaining = try? fm.contentsOfDirectory(atPath: dir.path), remaining.isEmpty
else { return }
// rmdir, not removeItem: it refuses a directory that isn't empty, so a
// file that appeared since the check survives.
rmdir(dir.path)
}
prune(top, rel: rootRel)
}
}
@@ -0,0 +1,99 @@
import Foundation
// The iOS port of Android's `SyncEngine.kt`/`SyncWorker.kt` data model. Times
// are milliseconds since the epoch, like the Kotlin side, so the persisted
// state means the same thing on both platforms.
/// One file or folder the server reported in a PROPFIND.
struct SyncRemoteEntry: Equatable {
/// Path relative to the user's files root, no trailing slash ("/Docs/a.txt").
let path: String
let fileId: String
let etag: String
let lastModified: Int64
let size: Int64
let isFolder: Bool
}
/// What the last successful sync recorded for a file - the baseline a later
/// pass diffs the server and the device against.
struct SyncFileState: Codable, Equatable {
let relPath: String
let etag: String
let lastModified: Int64
let size: Int64
let localMTime: Int64
}
/// A configured sync root's etag at the end of its last clean, complete walk.
/// Nextcloud propagates a change to any descendant up through every ancestor's
/// etag, so an unchanged root etag means nothing beneath it changed.
struct SyncRootMarker: Codable, Equatable {
let etag: String
let fullWalkAt: Int64
}
enum SyncAction: Equatable {
case download(SyncRemoteEntry)
case upload(relPath: String, fileId: String)
case delete(relPath: String, fileId: String)
case conflict(SyncRemoteEntry, relPath: String)
/// Deleted on the server, but edited on this device since the last sync:
/// the edited copy is the only one left, so it's kept (and simply no longer
/// tracked) instead of being deleted with the rest. Android deletes it.
case orphan(relPath: String, fileId: String)
}
/// A file changed both on the device and on the server since the last sync.
struct SyncConflict: Codable, Equatable {
let accountId: String
let fileId: String
let remotePath: String
let relPath: String
let name: String
}
/// Everything a sync run (foreground or background) needs for one account.
/// Persisted in the Keychain - it carries the auth header.
struct SyncAccountConfig: Codable, Equatable {
let accountId: String
let serverUrl: String
let username: String
let authHeader: String
/// Remote paths (files or folders) to mirror.
var folders: [String]
var wifiOnly: Bool
/// Minutes between background syncs; nil = no background sync (manual and
/// in-app refresh only).
var intervalMinutes: Int?
/// Whether automatic runs may post a summary notification.
var notify: Bool
}
struct SyncRunSummary: Equatable {
var downloaded = 0
var uploaded = 0
var deleted = 0
var conflicts: [SyncConflict] = []
var changedAnything: Bool { downloaded > 0 || uploaded > 0 || deleted > 0 }
}
/// The server couldn't be reached or answered with an error (anything but a
/// clean 404). Distinct from "the path is genuinely gone": treating a network
/// blip as an empty listing made every previously synced file look deleted
/// server-side, and the diff then deleted the local copies. Callers skip the
/// affected path for this run instead.
struct SyncRemoteUnavailable: Error, CustomStringConvertible {
let message: String
var description: String { message }
}
/// The recorded sync state exists but couldn't be read (corrupt, or an I/O
/// error). Treating that as "nothing recorded yet" would make every local
/// file look untracked and overwrite local edits with the server's copy, so
/// a run that hits it does nothing at all.
struct SyncStateUnreadable: Error, CustomStringConvertible {
let accountId: String
var description: String { "Sync state for \(accountId) is unreadable" }
}
@@ -0,0 +1,392 @@
import Foundation
/// A mutex for `async` code: one holder at a time, waiters resume in order.
/// (An `actor` isn't enough - it lets other calls in at every `await`.) Held
/// for the whole of any run that reads-then-rewrites a sync state map, which
/// is also what keeps `SyncStatusBus` - it tracks one account at a time -
/// coherent when several accounts are due.
///
/// Waiting is cancellable: a task that's cancelled while queued (a background
/// run whose time budget expired, a superseded "Sync now") leaves the queue
/// at once with `CancellationError` instead of sitting behind a long run.
final class AsyncMutex {
private let lock = NSLock()
private var locked = false
private var waiters: [(id: UUID, continuation: CheckedContinuation<Void, Error>)] = []
func withLock<T>(_ body: () async throws -> T) async throws -> T {
try await acquire()
defer { release() }
return try await body()
}
private func acquire() async throws {
let id = UUID()
try await withTaskCancellationHandler {
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
lock.lock()
if Task.isCancelled {
lock.unlock()
continuation.resume(throwing: CancellationError())
} else if !locked {
locked = true
lock.unlock()
continuation.resume()
} else {
waiters.append((id, continuation))
lock.unlock()
}
}
} onCancel: {
lock.lock()
if let index = waiters.firstIndex(where: { $0.id == id }) {
let waiter = waiters.remove(at: index)
lock.unlock()
waiter.continuation.resume(throwing: CancellationError())
} else {
lock.unlock()
}
}
}
private func release() {
lock.lock()
if waiters.isEmpty {
locked = false
lock.unlock()
} else {
let next = waiters.removeFirst()
lock.unlock()
next.continuation.resume()
}
}
}
/// Live sync status for the app: whether a run is going, which files are
/// transferring, and unresolved conflicts. In-memory only - what actually
/// answers "is this file synced" is the on-disk state; this carries just the
/// transient parts. The iOS twin of Android's `SyncStatusBus`, except that
/// conflicts are kept per account (a file id is only unique within one).
final class SyncStatusBus {
struct Status: Equatable {
var accountId: String?
var syncing = false
var syncingFileIds: Set<String> = []
/// Every account's unresolved conflicts - readers filter by account.
var conflicts: [SyncConflict] = []
}
private let lock = NSLock()
private var current = Status()
/// Called (on whatever thread changed it) after every update.
var onChange: ((Status) -> Void)?
func snapshot() -> Status {
lock.lock()
defer { lock.unlock() }
return current
}
func setSyncing(accountId: String, _ syncing: Bool) {
update {
$0.accountId = accountId
$0.syncing = syncing
if !syncing { $0.syncingFileIds = [] }
}
}
func markFileSyncing(accountId: String, fileId: String, _ syncing: Bool) {
update {
$0.accountId = accountId
if syncing { $0.syncingFileIds.insert(fileId) } else { $0.syncingFileIds.remove(fileId) }
}
}
func addConflicts(accountId: String, _ new: [SyncConflict]) {
guard !new.isEmpty else { return }
update {
$0.accountId = accountId
for conflict in new
where !$0.conflicts.contains(where: { $0.accountId == conflict.accountId && $0.fileId == conflict.fileId }) {
$0.conflicts.append(conflict)
}
}
}
func removeConflict(accountId: String, fileId: String) {
update {
$0.accountId = accountId
$0.conflicts.removeAll { $0.accountId == accountId && $0.fileId == fileId }
}
}
private func update(_ mutate: (inout Status) -> Void) {
lock.lock()
mutate(&current)
let snapshot = current
lock.unlock()
onChange?(snapshot)
}
}
/// One account's sync pass: walk each configured path, diff it against the
/// recorded state, transfer, and record the result. The iOS port of Android's
/// `SyncWorker.performSync` (and `ConflictResolveWorker`), with the same
/// safeguards - and a few more, found in review:
///
/// - If the server can't be reached for a path - or answers with something
/// that isn't a complete, plausible listing - that path is skipped for this
/// run, never diffed against an empty or partial listing, which would look
/// like files were deleted and wipe the local copies.
/// - If the recorded state can't be read the run does nothing at all (it
/// would otherwise treat every local file as untracked and overwrite local
/// edits with the server's copies).
/// - Paths the server sends are checked to stay inside the mirror, and two
/// remote files that would land on the same local file are both skipped.
/// - A file deleted on the server but edited here is kept, not deleted.
/// - A root's etag is only remembered once its whole path synced cleanly, and
/// only if the state it relies on was actually saved.
struct SyncRunner {
let client: DavSyncClient
let store: SyncStore
let bus: SyncStatusBus
var now: () -> Int64 = { Int64(Date().timeIntervalSince1970 * 1000) }
/// Even when a root's etag hasn't changed, walk it in full at least this
/// often: etag propagation isn't reliable everywhere (external storage
/// mounts in particular), so this bounds how stale a mirror can get.
static let fullWalkMaxAgeMs: Int64 = 6 * 60 * 60 * 1000
/// State is flushed to disk after this many transfers.
static let saveEvery = 20
/// [force] is an explicit "Sync now" / newly added path: always a full
/// walk, never trusting the root-etag shortcut.
func run(_ config: SyncAccountConfig, force: Bool) async -> SyncRunSummary {
var summary = SyncRunSummary()
guard !config.folders.isEmpty else { return summary }
let accountId = config.accountId
let creds = SyncCredentials(serverUrl: config.serverUrl, username: config.username, authHeader: config.authHeader)
let syncRoot = store.syncRoot(accountId: accountId)
var state: [String: SyncFileState]
do {
state = try store.loadState(accountId: accountId)
} catch {
NSLog("[Sync] %@ - skipping this run", String(describing: error))
return summary
}
var markers = store.loadRootMarkers(accountId: accountId)
var sinceSave = 0
bus.setSyncing(accountId: accountId, true)
defer {
// State first, then the markers that depend on it, then announce.
if store.saveState(accountId: accountId, state) {
markers = markers.filter { config.folders.contains($0.key) }
store.saveRootMarkers(accountId: accountId, markers)
} else {
NSLog("[Sync] couldn't save state for %@; not recording this run's markers", accountId)
}
bus.setSyncing(accountId: accountId, false)
}
func transferred() {
sinceSave += 1
if sinceSave >= Self.saveEvery {
if !store.saveState(accountId: accountId, state) { NSLog("[Sync] periodic state save failed") }
sinceSave = 0
}
}
for path in config.folders {
if Task.isCancelled { break }
do {
// A configured path can be a file or a folder - check which before
// deciding whether to walk it or just diff the single item.
let selfEntry = try await client.propfindSelf(creds, path: path)
let priorIds = SyncDiff.priorFileIds(state: state, path: path)
// Cheap "did anything change?" check: an unchanged root etag means
// nothing beneath it changed on the server, and if every local file
// is also still as recorded there's nothing to upload or re-download
// either - skip the walk (one Depth-0 PROPFIND instead of one per
// subfolder).
if !force, let root = selfEntry, root.isFolder, let marker = markers[path],
marker.etag == root.etag,
now() - marker.fullWalkAt < Self.fullWalkMaxAgeMs,
!priorIds.isEmpty,
SyncDiff.localMatchesState(state: state, fileIds: priorIds, syncRoot: syncRoot)
{
continue
}
// The server says this synced path no longer exists (a clean 404, not
// a network error): flag it so the app drops it from its synced list.
store.setRootMissing(accountId: accountId, path: path, missing: selfEntry == nil)
var entries: [SyncRemoteEntry] = []
if let root = selfEntry {
if root.isFolder {
entries = try await client.walk(creds, root: path)
} else {
entries = [root]
}
}
var pathClean = true
let collisions = SyncDiff.collidingRelPaths(entries)
let actions = SyncDiff.diffFolder(
entries: entries, state: state, priorFolderFileIds: priorIds, syncRoot: syncRoot)
for action in actions {
try Task.checkCancellation()
switch action {
case .download(let entry):
let rel = LocalFS.relative(entry.path)
guard !collisions.contains(rel), let dest = LocalFS.resolve(rel, in: syncRoot) else {
NSLog("[Sync] not downloading %@: unsafe or colliding local path", rel)
pathClean = false
continue
}
bus.markFileSyncing(accountId: accountId, fileId: entry.fileId, true)
let ok = try await client.download(creds, remotePath: entry.path, to: dest)
bus.markFileSyncing(accountId: accountId, fileId: entry.fileId, false)
if ok {
let local = LocalFS.stat(dest)
state[entry.fileId] = SyncFileState(
relPath: rel, etag: entry.etag, lastModified: entry.lastModified,
size: local?.size ?? entry.size, localMTime: local?.mtimeMs ?? 0)
summary.downloaded += 1
transferred()
} else {
pathClean = false
}
case .upload(let rel, let fileId):
guard let local = LocalFS.resolve(rel, in: syncRoot) else {
pathClean = false
continue
}
bus.markFileSyncing(accountId: accountId, fileId: fileId, true)
var ok = false
if FileManager.default.fileExists(atPath: local.path) {
ok = try await client.upload(creds, remotePath: "/" + rel, from: local)
}
bus.markFileSyncing(accountId: accountId, fileId: fileId, false)
if ok, let stat = LocalFS.stat(local) {
// Our own PUT changed the file's etag; record the new one, or
// the next pass would see "server changed" and re-download it.
// If it can't be read back, keep the old one and let the root
// be walked for real next time.
let fresh = (try? await client.propfindSelf(creds, path: "/" + rel)) ?? nil
if fresh == nil { pathClean = false }
state[fileId] = SyncFileState(
relPath: rel, etag: fresh?.etag ?? state[fileId]?.etag ?? "",
lastModified: stat.mtimeMs, size: stat.size, localMTime: stat.mtimeMs)
summary.uploaded += 1
transferred()
} else {
pathClean = false
}
case .delete(let rel, let fileId):
// Only counts as a removal if a local copy existed and went;
// dropping stale state for a file never on disk isn't news.
if let local = LocalFS.resolve(rel, in: syncRoot),
FileManager.default.fileExists(atPath: local.path),
(try? FileManager.default.removeItem(at: local)) != nil
{
summary.deleted += 1
}
state[fileId] = nil
case .orphan(_, let fileId):
// Deleted on the server but edited here: the edit stays on the
// device, no longer tracked.
state[fileId] = nil
case .conflict(let entry, let rel):
summary.conflicts.append(
SyncConflict(
accountId: accountId, fileId: entry.fileId, remotePath: entry.path, relPath: rel,
name: (rel as NSString).lastPathComponent))
pathClean = false
}
}
// Mirror the folder structure itself: empty folders created on the
// server appear on the device, and folders deleted on the server
// (whose files the diff has already removed) don't linger.
if selfEntry == nil || selfEntry?.isFolder == true {
let rootRel = path.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
if selfEntry != nil {
SyncDiff.mirrorFolders(entries: entries, syncRoot: syncRoot, rootRel: rootRel, collisions: collisions)
}
var remoteFolders = Set(
entries.filter(\.isFolder).map { $0.path.trimmingCharacters(in: CharacterSet(charactersIn: "/")) })
if selfEntry != nil { remoteFolders.insert(rootRel) }
SyncDiff.pruneRemovedFolders(syncRoot: syncRoot, rootRel: rootRel, remoteFolders: remoteFolders)
}
if let root = selfEntry, root.isFolder, pathClean {
markers[path] = SyncRootMarker(etag: root.etag, fullWalkAt: now())
} else {
markers[path] = nil
}
} catch is CancellationError {
markers[path] = nil // cut short: make the next run walk it for real
break
} catch {
// Couldn't reach the server for this path (or it answered with
// nothing usable): leave its state and marker alone and try again
// next run.
NSLog("[Sync] skipping %@ this run: %@", path, String(describing: error))
}
}
if !summary.conflicts.isEmpty {
bus.addConflicts(accountId: accountId, summary.conflicts)
}
return summary
}
/// Applies the user's choice for one conflicted file - `local` uploads the
/// device's copy over the server's, `server` downloads the server's over
/// the device's - then refreshes the recorded etag so the next pass doesn't
/// immediately re-flag it. Returns whether it worked.
func resolveConflict(
_ conflict: SyncConflict, resolution: String, creds: SyncCredentials
) async -> Bool {
// An unreadable baseline must not be overwritten with a one-file one.
guard var state = try? store.loadState(accountId: conflict.accountId),
let local = LocalFS.resolve(conflict.relPath, in: store.syncRoot(accountId: conflict.accountId))
else { return false }
var ok = false
do {
switch resolution {
case "local":
if FileManager.default.fileExists(atPath: local.path) {
ok = try await client.upload(creds, remotePath: conflict.remotePath, from: local)
}
case "server":
ok = try await client.download(creds, remotePath: conflict.remotePath, to: local)
default:
break
}
} catch {
return false
}
guard ok else { return false }
let fresh = (try? await client.propfindSelf(creds, path: conflict.remotePath)) ?? nil
let stat = LocalFS.stat(local)
state[conflict.fileId] = SyncFileState(
relPath: conflict.relPath, etag: fresh?.etag ?? "", lastModified: fresh?.lastModified ?? 0,
size: stat?.size ?? 0, localMTime: stat?.mtimeMs ?? 0)
store.saveState(accountId: conflict.accountId, state)
bus.removeConflict(accountId: conflict.accountId, fileId: conflict.fileId)
return true
}
}
@@ -0,0 +1,175 @@
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
}
}
}