Spaces:
Paused
Paused
| import CoreServices | |
| import Foundation | |
| final class ConfigFileWatcher: @unchecked Sendable { | |
| private let url: URL | |
| private let queue: DispatchQueue | |
| private var stream: FSEventStreamRef? | |
| private var pending = false | |
| private let onChange: () -> Void | |
| private let watchedDir: URL | |
| private let targetPath: String | |
| private let targetName: String | |
| init(url: URL, onChange: @escaping () -> Void) { | |
| self.url = url | |
| self.queue = DispatchQueue(label: "ai.openclaw.configwatcher") | |
| self.onChange = onChange | |
| self.watchedDir = url.deletingLastPathComponent() | |
| self.targetPath = url.path | |
| self.targetName = url.lastPathComponent | |
| } | |
| deinit { | |
| self.stop() | |
| } | |
| func start() { | |
| guard self.stream == nil else { return } | |
| let retainedSelf = Unmanaged.passRetained(self) | |
| var context = FSEventStreamContext( | |
| version: 0, | |
| info: retainedSelf.toOpaque(), | |
| retain: nil, | |
| release: { pointer in | |
| guard let pointer else { return } | |
| Unmanaged<ConfigFileWatcher>.fromOpaque(pointer).release() | |
| }, | |
| copyDescription: nil) | |
| let paths = [self.watchedDir.path] as CFArray | |
| let flags = FSEventStreamCreateFlags( | |
| kFSEventStreamCreateFlagFileEvents | | |
| kFSEventStreamCreateFlagUseCFTypes | | |
| kFSEventStreamCreateFlagNoDefer) | |
| guard let stream = FSEventStreamCreate( | |
| kCFAllocatorDefault, | |
| Self.callback, | |
| &context, | |
| paths, | |
| FSEventStreamEventId(kFSEventStreamEventIdSinceNow), | |
| 0.05, | |
| flags) | |
| else { | |
| retainedSelf.release() | |
| return | |
| } | |
| self.stream = stream | |
| FSEventStreamSetDispatchQueue(stream, self.queue) | |
| if FSEventStreamStart(stream) == false { | |
| self.stream = nil | |
| FSEventStreamSetDispatchQueue(stream, nil) | |
| FSEventStreamInvalidate(stream) | |
| FSEventStreamRelease(stream) | |
| } | |
| } | |
| func stop() { | |
| guard let stream = self.stream else { return } | |
| self.stream = nil | |
| FSEventStreamStop(stream) | |
| FSEventStreamSetDispatchQueue(stream, nil) | |
| FSEventStreamInvalidate(stream) | |
| FSEventStreamRelease(stream) | |
| } | |
| } | |
| extension ConfigFileWatcher { | |
| private static let callback: FSEventStreamCallback = { _, info, numEvents, eventPaths, eventFlags, _ in | |
| guard let info else { return } | |
| let watcher = Unmanaged<ConfigFileWatcher>.fromOpaque(info).takeUnretainedValue() | |
| watcher.handleEvents( | |
| numEvents: numEvents, | |
| eventPaths: eventPaths, | |
| eventFlags: eventFlags) | |
| } | |
| private func handleEvents( | |
| numEvents: Int, | |
| eventPaths: UnsafeMutableRawPointer?, | |
| eventFlags: UnsafePointer<FSEventStreamEventFlags>?) | |
| { | |
| guard numEvents > 0 else { return } | |
| guard eventFlags != nil else { return } | |
| guard self.matchesTarget(eventPaths: eventPaths) else { return } | |
| if self.pending { return } | |
| self.pending = true | |
| self.queue.asyncAfter(deadline: .now() + 0.12) { [weak self] in | |
| guard let self else { return } | |
| self.pending = false | |
| self.onChange() | |
| } | |
| } | |
| private func matchesTarget(eventPaths: UnsafeMutableRawPointer?) -> Bool { | |
| guard let eventPaths else { return true } | |
| let paths = unsafeBitCast(eventPaths, to: NSArray.self) | |
| for case let path as String in paths { | |
| if path == self.targetPath { return true } | |
| if path.hasSuffix("/\(self.targetName)") { return true } | |
| if path == self.watchedDir.path { return true } | |
| } | |
| return false | |
| } | |
| } | |