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