krz/orgstar

A native macOS editor for org-mode files. editor org-mode swift

Sources/OrgWorkspace/FSEventsWatcher.swift

85c633a83a4744bef9dde8b52462f9c2a1c037e4
orgstar/Sources/OrgWorkspace/FSEventsWatcher.swift history · blame · raw

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