Files
RSync-Studio/Rsync Studio/Services/FileWatchService.swift
T
2026-09-08 20:06:58 +01:00

95 lines
3.3 KiB
Swift

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