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") } }