Sources/OrgWorkspace/FolderPresenter.swift
66 lines · 2587 bytes
1import Foundation
2
3public enum WatchEvent: Sendable, Equatable {
4 case changed(URL)
5 /// Events were dropped or coalesced for this folder, or a root moved: rescan it.
6 case rescan(URL)
7}
8
9/// Watches a folder as an `NSFilePresenter`: told of coordinated writes inside it, which is
10/// how iCloud and File Provider syncing (Möbius Sync for Syncthing) change files on iOS, where
11/// there are no FSEvents. Events arrive in batches after `latency`.
12public final class FolderPresenter: NSObject, NSFilePresenter, @unchecked Sendable {
13 public let presentedItemURL: URL?
14 public let presentedItemOperationQueue = OperationQueue()
15 private let latency: TimeInterval
16 private let handler: @Sendable ([WatchEvent]) -> Void
17 private let lock = NSLock()
18 private var pending: [WatchEvent] = []
19 private var scheduled = false
20
21 public init(root: URL, latency: TimeInterval = 0.3, handler: @escaping @Sendable ([WatchEvent]) -> Void) {
22 presentedItemURL = root
23 self.latency = latency
24 self.handler = handler
25 presentedItemOperationQueue.maxConcurrentOperationCount = 1
26 super.init()
27 }
28
29 public func start() { NSFileCoordinator.addFilePresenter(self) }
30
31 public func stop() { NSFileCoordinator.removeFilePresenter(self) }
32
33 private func add(_ event: WatchEvent) {
34 let schedule: Bool = lock.withLock {
35 if !pending.contains(event) { pending.append(event) }
36 defer { scheduled = true }
37 return !scheduled
38 }
39 guard schedule else { return }
40 DispatchQueue.global().asyncAfter(deadline: .now() + latency) { [self] in
41 let batch: [WatchEvent] = lock.withLock {
42 defer {
43 pending = []
44 scheduled = false
45 }
46 return pending
47 }
48 handler(batch)
49 }
50 }
51
52 public func presentedSubitemDidChange(at url: URL) { add(.changed(url)) }
53 public func presentedSubitemDidAppear(at url: URL) { add(.changed(url)) }
54 public func presentedSubitem(at oldURL: URL, didMoveTo newURL: URL) {
55 add(.changed(oldURL))
56 add(.changed(newURL))
57 }
58 public func accommodatePresentedSubitemDeletion(at url: URL, completionHandler: @escaping @Sendable ((any Error)?) -> Void) {
59 add(.changed(url))
60 completionHandler(nil)
61 }
62 public func presentedItemDidChange() {
63 if let presentedItemURL { add(.rescan(presentedItemURL)) }
64 }
65 public func presentedItemDidMove(to newURL: URL) { add(.rescan(newURL)) }
66}