sync app filled in
This commit is contained in:
269
apps/macos-sync/Sources/CairnquireSync/Sync/SyncEngine.swift
Normal file
269
apps/macos-sync/Sources/CairnquireSync/Sync/SyncEngine.swift
Normal file
@@ -0,0 +1,269 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user