sync app filled in

This commit is contained in:
2026-06-01 10:53:28 -04:00
parent ebe0920f89
commit fb0673a1c5
36 changed files with 4527 additions and 98 deletions

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