95 lines
3.3 KiB
Swift
Executable File
95 lines
3.3 KiB
Swift
Executable File
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.RsyncStudio.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<FileWatchService>.fromOpaque(info).takeUnretainedValue()
|
|
let paths = unsafeBitCast(eventPaths, to: NSArray.self) as? [String] ?? []
|
|
for index in 0..<min(count, paths.count) {
|
|
service.handle(path: paths[index])
|
|
}
|
|
}
|
|
|
|
let flags = FSEventStreamCreateFlags(kFSEventStreamCreateFlagFileEvents | kFSEventStreamCreateFlagUseCFTypes)
|
|
guard let stream = FSEventStreamCreate(
|
|
nil,
|
|
callback,
|
|
&context,
|
|
paths as CFArray,
|
|
FSEventStreamEventId(kFSEventStreamEventIdSinceNow),
|
|
0.4,
|
|
flags
|
|
) else { return }
|
|
|
|
self.stream = stream
|
|
FSEventStreamSetDispatchQueue(stream, queue)
|
|
FSEventStreamStart(stream)
|
|
}
|
|
|
|
private func handle(path: String) {
|
|
let normalized = (path as NSString).standardizingPath
|
|
for (jobID, target) in watchedTargets {
|
|
let targetPath = (target.targetPath as NSString).standardizingPath
|
|
let matches = target.isDirectory
|
|
? (normalized == targetPath || normalized.hasPrefix(targetPath + "/"))
|
|
: normalized == targetPath
|
|
if matches { onChange?(jobID, normalized) }
|
|
}
|
|
}
|
|
}
|