Sources/OrgWorkspace/FSEventsWatcher.swift
72 lines · 2954 bytes
1#if os(macOS)
2import CoreServices
3import Foundation
4
5/// FSEvents with file-level events. Delivers batches on a private queue.
6public final class FSEventsWatcher: @unchecked Sendable {
7 private let paths: [String]
8 private let latency: CFTimeInterval
9 private let handler: @Sendable ([WatchEvent]) -> Void
10 private let queue = DispatchQueue(label: "orgstar.fsevents")
11 private var stream: FSEventStreamRef?
12
13 public init(roots: [URL], latency: CFTimeInterval = 0.3, handler: @escaping @Sendable ([WatchEvent]) -> Void) {
14 paths = roots.map(\.path)
15 self.latency = latency
16 self.handler = handler
17 }
18
19 deinit {
20 stop()
21 }
22
23 public func start() {
24 guard stream == nil else { return }
25 var context = FSEventStreamContext(
26 version: 0, info: Unmanaged.passUnretained(self).toOpaque(), retain: nil, release: nil, copyDescription: nil
27 )
28 let flags = FSEventStreamCreateFlags(
29 kFSEventStreamCreateFlagFileEvents | kFSEventStreamCreateFlagUseCFTypes
30 | kFSEventStreamCreateFlagNoDefer | kFSEventStreamCreateFlagWatchRoot
31 )
32 let callback: FSEventStreamCallback = { _, info, count, eventPaths, eventFlags, _ in
33 guard let info else { return }
34 let watcher = Unmanaged<FSEventsWatcher>.fromOpaque(info).takeUnretainedValue()
35 let paths = unsafeBitCast(eventPaths, to: NSArray.self) as? [String] ?? []
36 watcher.deliver(paths: paths, flags: Array(UnsafeBufferPointer(start: eventFlags, count: count)))
37 }
38 guard let stream = FSEventStreamCreate(
39 nil, callback, &context, paths as CFArray, FSEventStreamEventId(kFSEventStreamEventIdSinceNow), latency, flags
40 ) else { return }
41 FSEventStreamSetDispatchQueue(stream, queue)
42 FSEventStreamStart(stream)
43 self.stream = stream
44 }
45
46 public func stop() {
47 guard let stream else { return }
48 FSEventStreamStop(stream)
49 FSEventStreamInvalidate(stream)
50 FSEventStreamRelease(stream)
51 self.stream = nil
52 }
53
54 private func deliver(paths eventPaths: [String], flags: [FSEventStreamEventFlags]) {
55 let rescanFlags = FSEventStreamEventFlags(
56 kFSEventStreamEventFlagMustScanSubDirs | kFSEventStreamEventFlagUserDropped
57 | kFSEventStreamEventFlagKernelDropped | kFSEventStreamEventFlagRootChanged
58 )
59 var events: [WatchEvent] = []
60 for (path, flag) in zip(eventPaths, flags) {
61 let url = URL(fileURLWithPath: path).standardizedFileURL
62 if flag & rescanFlags != 0 {
63 let root = paths.first { path.hasPrefix($0) } ?? path
64 events.append(.rescan(URL(fileURLWithPath: root).standardizedFileURL))
65 } else {
66 events.append(.changed(url))
67 }
68 }
69 if !events.isEmpty { handler(events) }
70 }
71}
72#endif