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,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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user