sync app filled in
This commit is contained in:
103
apps/macos-sync/Sources/CairnquireSync/Sync/FolderWatcher.swift
Normal file
103
apps/macos-sync/Sources/CairnquireSync/Sync/FolderWatcher.swift
Normal file
@@ -0,0 +1,103 @@
|
||||
import Foundation
|
||||
import CoreServices
|
||||
|
||||
public protocol FolderWatcherDelegate: AnyObject {
|
||||
func folderWatcher(_ watcher: FolderWatcher, didDetectChanges paths: [String])
|
||||
}
|
||||
|
||||
public class FolderWatcher {
|
||||
public weak var delegate: FolderWatcherDelegate?
|
||||
|
||||
private var stream: FSEventStreamRef?
|
||||
private let folderPath: String
|
||||
private var debounceTimer: Timer?
|
||||
private var pendingPaths: Set<String> = []
|
||||
private let queue = DispatchQueue(label: "com.cairnquire.folderwatcher")
|
||||
|
||||
public init(folderPath: String) {
|
||||
self.folderPath = folderPath
|
||||
}
|
||||
|
||||
public func start() {
|
||||
stop()
|
||||
|
||||
let pathsToWatch = [folderPath as CFString]
|
||||
var context = FSEventStreamContext(
|
||||
version: 0,
|
||||
info: Unmanaged.passUnretained(self).toOpaque(),
|
||||
retain: nil,
|
||||
release: nil,
|
||||
copyDescription: nil
|
||||
)
|
||||
|
||||
let callback: FSEventStreamCallback = { (streamRef, clientCallBackInfo, numEvents, eventPaths, eventFlags, eventIds) in
|
||||
let watcher = Unmanaged<FolderWatcher>.fromOpaque(clientCallBackInfo!).takeUnretainedValue()
|
||||
watcher.handleEvents(numEvents: numEvents, eventPaths: eventPaths, eventFlags: eventFlags)
|
||||
}
|
||||
|
||||
stream = FSEventStreamCreate(
|
||||
kCFAllocatorDefault,
|
||||
callback,
|
||||
&context,
|
||||
pathsToWatch as CFArray,
|
||||
FSEventStreamEventId(kFSEventStreamEventIdSinceNow),
|
||||
0.5, // latency in seconds
|
||||
FSEventStreamCreateFlags(kFSEventStreamCreateFlagFileEvents | kFSEventStreamCreateFlagUseCFTypes)
|
||||
)
|
||||
|
||||
if let stream = stream {
|
||||
FSEventStreamSetDispatchQueue(stream, queue)
|
||||
FSEventStreamStart(stream)
|
||||
}
|
||||
}
|
||||
|
||||
public func stop() {
|
||||
if let stream = stream {
|
||||
FSEventStreamStop(stream)
|
||||
FSEventStreamInvalidate(stream)
|
||||
FSEventStreamRelease(stream)
|
||||
self.stream = nil
|
||||
}
|
||||
}
|
||||
|
||||
private func handleEvents(numEvents: Int, eventPaths: UnsafeMutableRawPointer, eventFlags: UnsafePointer<FSEventStreamEventFlags>) {
|
||||
guard let paths = Unmanaged<CFArray>.fromOpaque(eventPaths).takeUnretainedValue() as? [String] else { return }
|
||||
|
||||
var changedPaths: [String] = []
|
||||
for i in 0..<numEvents {
|
||||
let path = paths[i]
|
||||
let flags = eventFlags[i]
|
||||
|
||||
// Filter for relevant file events
|
||||
if flags & UInt32(kFSEventStreamEventFlagItemCreated) != 0 ||
|
||||
flags & UInt32(kFSEventStreamEventFlagItemModified) != 0 ||
|
||||
flags & UInt32(kFSEventStreamEventFlagItemRemoved) != 0 ||
|
||||
flags & UInt32(kFSEventStreamEventFlagItemRenamed) != 0 {
|
||||
|
||||
// Only track markdown files
|
||||
if path.hasSuffix(".md") {
|
||||
changedPaths.append(path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
guard !changedPaths.isEmpty else { return }
|
||||
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self = self else { return }
|
||||
|
||||
self.pendingPaths.formUnion(changedPaths)
|
||||
self.debounceTimer?.invalidate()
|
||||
self.debounceTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: false) { [weak self] _ in
|
||||
guard let self = self else { return }
|
||||
let paths = Array(self.pendingPaths)
|
||||
self.pendingPaths.removeAll()
|
||||
self.delegate?.folderWatcher(self, didDetectChanges: paths)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
deinit {
|
||||
stop()
|
||||
}
|
||||
}
|
||||
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