270 lines
9.8 KiB
Swift
270 lines
9.8 KiB
Swift
import Foundation
|
|
|
|
public actor SyncEngine {
|
|
private let client: APIClient
|
|
private let config: SyncConfig
|
|
private let localState: LocalSyncState
|
|
private var snapshotId: String?
|
|
|
|
public init(client: APIClient, config: SyncConfig) {
|
|
self.client = client
|
|
self.config = config
|
|
self.localState = LocalSyncState(deviceId: config.deviceId)
|
|
}
|
|
|
|
public func performSync() async throws {
|
|
guard let syncFolder = config.syncFolder else {
|
|
return
|
|
}
|
|
|
|
let folderURL = URL(fileURLWithPath: syncFolder)
|
|
try FileManager.default.createDirectory(at: folderURL, withIntermediateDirectories: true)
|
|
|
|
// 1. Initialize sync
|
|
if snapshotId == nil {
|
|
let initResponse = try await client.syncInit(deviceId: config.deviceId, rootPath: syncFolder)
|
|
self.snapshotId = initResponse.snapshotId
|
|
try await applyInitialServerSnapshot(initResponse.serverSnapshot, baseURL: folderURL)
|
|
try await localState.updateSnapshot(initResponse.serverSnapshot)
|
|
}
|
|
|
|
guard let currentSnapshotId = snapshotId else { return }
|
|
|
|
// 2. Compute local delta
|
|
let localFiles = try scanLocalFolder(folderURL)
|
|
let localDelta = try await localState.computeDelta(localFiles: localFiles)
|
|
let outboundDelta = try addContent(to: localDelta, baseURL: folderURL)
|
|
|
|
// 3. Send local changes and check for server changes
|
|
let deltaResponse = try await client.syncDelta(snapshotId: currentSnapshotId, clientDelta: outboundDelta)
|
|
|
|
// 4. Apply server changes
|
|
try await applyServerDelta(deltaResponse.serverDelta, baseURL: folderURL)
|
|
|
|
// The server returns its current bytes in serverDelta for conflicts.
|
|
// Until the native app has a merge UI, use the server copy locally.
|
|
self.snapshotId = deltaResponse.newSnapshotId ?? currentSnapshotId
|
|
|
|
// 5. Update local state after applying inbound changes
|
|
try await localState.updateSnapshot(scanLocalFolder(folderURL))
|
|
}
|
|
|
|
private func scanLocalFolder(_ folderURL: URL) throws -> [FileEntry] {
|
|
var files: [FileEntry] = []
|
|
let fileManager = FileManager.default
|
|
|
|
guard let enumerator = fileManager.enumerator(
|
|
at: folderURL,
|
|
includingPropertiesForKeys: [.contentModificationDateKey, .fileSizeKey],
|
|
options: [.skipsHiddenFiles]
|
|
) else {
|
|
return files
|
|
}
|
|
|
|
for case let fileURL as URL in enumerator {
|
|
let relativePath = fileURL.path.replacingOccurrences(of: folderURL.path + "/", with: "")
|
|
|
|
// Only sync markdown files for now
|
|
guard fileURL.pathExtension == "md" else { continue }
|
|
|
|
let attributes = try fileManager.attributesOfItem(atPath: fileURL.path)
|
|
let modificationDate = attributes[.modificationDate] as? Date ?? Date()
|
|
let fileSize = attributes[.size] as? Int64 ?? 0
|
|
|
|
// Compute hash
|
|
let content = try Data(contentsOf: fileURL)
|
|
let hash = SHA256.hash(data: content).hexString
|
|
|
|
files.append(FileEntry(
|
|
path: relativePath,
|
|
hash: hash,
|
|
size: fileSize,
|
|
modified: modificationDate
|
|
))
|
|
}
|
|
|
|
return files
|
|
}
|
|
|
|
private func applyServerDelta(_ changes: [Change], baseURL: URL) async throws {
|
|
for change in changes {
|
|
let fileURL = try localFileURL(for: change.path, baseURL: baseURL)
|
|
|
|
switch change.type {
|
|
case "create", "update":
|
|
guard let hash = change.hash else {
|
|
throw SyncEngineError.missingHash(change.path)
|
|
}
|
|
try await writeServerContent(hash: hash, to: fileURL)
|
|
case "delete":
|
|
try? FileManager.default.removeItem(at: fileURL)
|
|
case "rename":
|
|
if let oldPath = change.oldPath {
|
|
let oldURL = try localFileURL(for: oldPath, baseURL: baseURL)
|
|
try FileManager.default.createDirectory(at: fileURL.deletingLastPathComponent(), withIntermediateDirectories: true)
|
|
try? FileManager.default.moveItem(at: oldURL, to: fileURL)
|
|
}
|
|
default:
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
private func applyInitialServerSnapshot(_ files: [FileEntry], baseURL: URL) async throws {
|
|
for file in files {
|
|
let fileURL = try localFileURL(for: file.path, baseURL: baseURL)
|
|
if !FileManager.default.fileExists(atPath: fileURL.path) {
|
|
try await writeServerContent(hash: file.hash, to: fileURL)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func addContent(to changes: [Change], baseURL: URL) throws -> [Change] {
|
|
try changes.map { change in
|
|
guard change.type == "create" || change.type == "update" else {
|
|
return change
|
|
}
|
|
|
|
let fileURL = try localFileURL(for: change.path, baseURL: baseURL)
|
|
let data = try Data(contentsOf: fileURL)
|
|
guard let content = String(data: data, encoding: .utf8) else {
|
|
throw SyncEngineError.invalidUTF8(change.path)
|
|
}
|
|
|
|
return Change(
|
|
type: change.type,
|
|
path: change.path,
|
|
oldPath: change.oldPath,
|
|
hash: change.hash,
|
|
content: content,
|
|
size: change.size,
|
|
modified: change.modified
|
|
)
|
|
}
|
|
}
|
|
|
|
private func writeServerContent(hash: String, to fileURL: URL) async throws {
|
|
let content = try await client.fetchContent(hash: hash)
|
|
guard SHA256.hash(data: content).hexString == hash.lowercased() else {
|
|
throw SyncEngineError.hashMismatch(fileURL.lastPathComponent)
|
|
}
|
|
try FileManager.default.createDirectory(at: fileURL.deletingLastPathComponent(), withIntermediateDirectories: true)
|
|
try content.write(to: fileURL, options: .atomic)
|
|
}
|
|
|
|
private func localFileURL(for path: String, baseURL: URL) throws -> URL {
|
|
let root = baseURL.standardizedFileURL
|
|
let fileURL = root.appendingPathComponent(path).standardizedFileURL
|
|
guard fileURL.path.hasPrefix(root.path + "/"),
|
|
fileURL.pathExtension.lowercased() == "md" else {
|
|
throw SyncEngineError.invalidPath(path)
|
|
}
|
|
return fileURL
|
|
}
|
|
}
|
|
|
|
public enum SyncEngineError: LocalizedError {
|
|
case invalidPath(String)
|
|
case invalidUTF8(String)
|
|
case missingHash(String)
|
|
case hashMismatch(String)
|
|
|
|
public var errorDescription: String? {
|
|
switch self {
|
|
case .invalidPath(let path):
|
|
return "Invalid sync path: \(path)"
|
|
case .invalidUTF8(let path):
|
|
return "Sync only supports UTF-8 markdown files: \(path)"
|
|
case .missingHash(let path):
|
|
return "Server sync response is missing a content hash: \(path)"
|
|
case .hashMismatch(let path):
|
|
return "Downloaded sync content failed hash verification: \(path)"
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - SHA256 Helper
|
|
|
|
import CryptoKit
|
|
|
|
extension SHA256.Digest {
|
|
var hexString: String {
|
|
map { String(format: "%02x", $0) }.joined()
|
|
}
|
|
}
|
|
|
|
// MARK: - Local Sync State
|
|
|
|
public actor LocalSyncState {
|
|
private let deviceId: String
|
|
private var lastSnapshot: [String: FileEntry] = [:] // path -> entry
|
|
|
|
public init(deviceId: String) {
|
|
self.deviceId = deviceId
|
|
}
|
|
|
|
public func updateSnapshot(_ files: [FileEntry]) async throws {
|
|
lastSnapshot = Dictionary(uniqueKeysWithValues: files.map { ($0.path, $0) })
|
|
try persist()
|
|
}
|
|
|
|
public func computeDelta(localFiles: [FileEntry]) async throws -> [Change] {
|
|
var changes: [Change] = []
|
|
let localMap = Dictionary(uniqueKeysWithValues: localFiles.map { ($0.path, $0) })
|
|
|
|
// Check for new or modified files
|
|
for file in localFiles {
|
|
if let last = lastSnapshot[file.path] {
|
|
if last.hash != file.hash {
|
|
changes.append(Change(
|
|
type: "update",
|
|
path: file.path,
|
|
oldPath: nil,
|
|
hash: file.hash,
|
|
size: file.size,
|
|
modified: file.modified
|
|
))
|
|
}
|
|
} else {
|
|
changes.append(Change(
|
|
type: "create",
|
|
path: file.path,
|
|
oldPath: nil,
|
|
hash: file.hash,
|
|
size: file.size,
|
|
modified: file.modified
|
|
))
|
|
}
|
|
}
|
|
|
|
// Check for deleted files
|
|
for (path, _) in lastSnapshot where localMap[path] == nil {
|
|
changes.append(Change(
|
|
type: "delete",
|
|
path: path,
|
|
oldPath: nil,
|
|
hash: nil,
|
|
size: nil,
|
|
modified: nil
|
|
))
|
|
}
|
|
|
|
return changes
|
|
}
|
|
|
|
private func persist() throws {
|
|
// Persist to local JSON file
|
|
let entries = Array(lastSnapshot.values)
|
|
let data = try JSONEncoder().encode(entries)
|
|
let url = stateFileURL()
|
|
try data.write(to: url)
|
|
}
|
|
|
|
private func stateFileURL() -> URL {
|
|
let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
|
|
let dir = appSupport.appendingPathComponent("CairnquireSync", isDirectory: true)
|
|
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
|
return dir.appendingPathComponent("sync_state_\(deviceId).json")
|
|
}
|
|
}
|