import Foundation import CoreServices final class FileWatchService { typealias ChangeHandler = @Sendable (UUID, String) -> Void private struct WatchTarget: Equatable { let targetPath: String let streamRoot: String let isDirectory: Bool } private var stream: FSEventStreamRef? private var watchedTargets: [UUID: WatchTarget] = [:] private let queue = DispatchQueue(label: "net.absdev.RsyncManager.fsevents", qos: .utility) var onChange: ChangeHandler? deinit { stop() } func update(jobs: [SyncJob]) { var targets: [UUID: WatchTarget] = [:] for job in jobs where job.isEnabled && job.watchEnabled { let path = (job.effectiveWatchPath as NSString).expandingTildeInPath guard !path.isEmpty else { continue } var isDirectory: ObjCBool = false let exists = FileManager.default.fileExists(atPath: path, isDirectory: &isDirectory) let directory = exists ? isDirectory.boolValue : true let streamRoot = directory ? path : (path as NSString).deletingLastPathComponent targets[job.id] = WatchTarget(targetPath: path, streamRoot: streamRoot, isDirectory: directory) } guard targets != watchedTargets else { return } watchedTargets = targets rebuild() } func stop() { if let stream { FSEventStreamStop(stream) FSEventStreamInvalidate(stream) FSEventStreamRelease(stream) self.stream = nil } } private func rebuild() { stop() let paths = Array(Set(watchedTargets.values.map(\.streamRoot))) guard !paths.isEmpty else { return } var context = FSEventStreamContext( version: 0, info: Unmanaged.passUnretained(self).toOpaque(), retain: nil, release: nil, copyDescription: nil ) let callback: FSEventStreamCallback = { _, info, count, eventPaths, _, _ in guard let info else { return } let service = Unmanaged.fromOpaque(info).takeUnretainedValue() let paths = unsafeBitCast(eventPaths, to: NSArray.self) as? [String] ?? [] for index in 0..