import CoreServices import Foundation // MARK: - Vocabulary /// Why a reload is being asked for — the provenance of a `WatcherEvent.treeChanged`. /// /// The consumer (`BoardStore`, a later milestone) reloads identically for all three; the origin /// exists so the *policy* around a reload can differ — a reconciling sweep re-probes writability /// (02-architecture.md § Write-failure surfacing, "Writability re-probes on every reconciling /// reload"), an app-mediated reload is the one that ends a bracket's read-only lock (§ /// Live-reload resilience), and a foreign reload is the ordinary external-edit case. Every /// reload is a full tree walk producing a value-type snapshot, so no origin is ever *less* safe /// than another — an identical tree swaps in value-equal and costs nothing visible. public enum WatchOrigin: String, Sendable, Equatable { /// The change span was produced under an app bracket (`beginBracket()` … `endBracket()`) — /// the app's own pull-rebase, branch switch, undo restore, or ordinary Writer mutation. case appMediated /// The change arrived from outside the app: an editor, an agent, `git` in a terminal. case foreign /// A reconciliation sweep rather than an observed change: wake-from-sleep, app re-activation, /// an FSEvents flag admitting missed events, or a stream re-creation. "Never trusted blindly" /// (§ Live-reload resilience) — every known blind window ends in one of these. case reconciling /// Precedence when two change spans coalesce into one delivery: `reconciling` outranks /// `appMediated` outranks `foreign`. Higher means "makes the stronger claim about what the /// reload has to cover" — a reconciling reload assumes nothing about the tree, an /// app-mediated one is the tail of an operation the app ran, a foreign one is an observed /// external edit. var precedence: Int { switch self { case .foreign: 0 case .appMediated: 1 case .reconciling: 2 } } /// The origin a coalesced delivery carries: the stronger of the two, never the newer. /// /// This is the *never downgrade* rule — a foreign event landing on a pending app-mediated or /// reconciling delivery does not weaken it. See `FolderWatcher.schedule(_:)` for why the /// resulting blur is accepted rather than engineered away. /// /// Internal rather than `fileprivate`: `BoardStore` coalesces signals that arrive while a reload /// is already running and owes the same never-downgrade guarantee on its side of the handoff. /// Two coalescing points, one rule — the watcher's debounce and the store's pending-reload flag /// must never disagree about which origin a merged span carries. static func merged(_ existing: WatchOrigin?, _ incoming: WatchOrigin) -> WatchOrigin { guard let existing else { return incoming } return existing.precedence >= incoming.precedence ? existing : incoming } } /// What the watcher tells its consumer. Deliberately two cases and no more: the watcher reports /// *that* the tree changed, never *what* changed. Diffing is the loader's job, and a value-type /// snapshot comparison is both cheaper and more trustworthy than trying to reconstruct a tree /// mutation from a stream of paths that may have been coalesced, dropped, or reordered. public enum WatcherEvent: Sendable, Equatable { /// Debounced and coalesced: "the tree changed — reload". One event may stand for hundreds of /// filesystem events; `WatchOrigin` says where the span came from. case treeChanged(WatchOrigin) /// The watched root itself vanished or changed identity (deleted, renamed, moved, or its /// volume unmounted). The stream is already torn down when this arrives; the consumer /// re-resolves its security-scoped bookmark and either calls `reattach(to:)` at the new /// location or enters the vanished-root read-only lock (02-architecture.md § /// Write-failure surfacing). case rootChanged } // MARK: - FolderWatcher /// The board's one watching path: an FSEvents stream over the board root, debounced and /// coalesced into "reload now" (02-architecture.md § Layering ▸ Components). /// /// There is deliberately **nothing else** watching — no `NSMetadataQuery` for iCloud Drive, no /// polling fallback for network volumes. On those warned-against locations FSEvents delivery is /// unreliable and live reload silently degrades, accepted per 07-sync-collab.md's /// no-accommodations stance. This type therefore contains not one line of volume special-casing; /// the only concession is that a stream which cannot be created is a `false` from `start()` /// rather than a crash. /// /// ### The three things this type actually does /// /// 1. **Debounce.** Filesystem churn arrives in bursts — a `git checkout`, an agent writing /// twenty cards, the app's own multi-file move. Events arriving while a delivery is pending /// restart the timer (a trailing debounce), so a burst costs exactly one reload after quiet. /// 2. **Brackets.** Operations the app runs itself suspend delivery for their duration and /// finish with one full reload — half-checked-out trees are never rendered (§ Live-reload /// resilience, "App-initiated git churn is bracketed"). /// 3. **Reconciliation.** Anything admitting a blind window — a missed-events flag, a stream /// re-creation, a wake or activation the consumer reports — degrades to a reload rather than /// trusting the gap. "A silently stale board is structurally excluded." /// /// ### Lifetime contract /// /// `start()` and `stop()` are explicit and the documented contract. `stop()` is idempotent, safe /// to call on a watcher that never started, and guarantees nothing fires afterwards. The C-level /// stream is owned by a small non-isolated handle whose `deinit` invalidates it, so a watcher /// dropped without `stop()` still tears its stream down rather than leaking it — but a dropped /// watcher is not a *quiet* watcher until that release happens, which is why `stop()` stays the /// contract. @MainActor public final class FolderWatcher { // MARK: Configuration /// The path FSEvents was asked to watch, symlink-resolved — and the only form of the root /// this type keeps. FSEvents reports canonical paths (`/private/var/...`), so the prefix /// every incoming path is measured against has to be canonical too, or the `.git` filter /// below would never match on a temp directory, which is exactly where it is tested. The /// consumer owns the board's *identity* (its security-scoped bookmark); the watcher only /// needs somewhere to point. private var watchedPath: String private let debounce: Duration private let latency: TimeInterval private let handler: @MainActor (WatcherEvent) -> Void /// FSEvents callbacks land here before hopping to the main actor. Serial and private: the /// stream's `info` box is released on this queue's tail by FSEvents itself, so an in-flight /// callback can never outlive the box it reads from. private let queue = DispatchQueue(label: "dev.rzen.indie.Kanban.FolderWatcher", qos: .utility) // MARK: State private var handle: StreamHandle? /// Bumped on every stream creation and every teardown. A callback that has already hopped to /// the main actor when `stop()` (or `reattach(to:)`) runs would otherwise deliver an event /// from a stream that no longer exists; the generation it captured no longer matches and it /// is dropped. This is the whole of the "nothing fires after `stop()`" guarantee on the /// inbound side — the outbound side is the cancelled delivery task. private var generation = 0 /// Nesting depth of `beginBracket()`/`endBracket()`. Brackets nest because the operations /// that use them do: an undo restore inside a branch switch is one bracketed span, not two. private var bracketDepth = 0 /// True from a delivered `.rootChanged` until a fresh stream attaches (`start()` or /// `reattach(to:)`). While set, `endBracket()` skips its mandatory reload — the root-change /// path owns recovery (settled, 02-architecture.md § Write-failure surfacing). Deliberately /// **not** the same test as `handle == nil`: a watcher that never got a stream (or whose /// creation failed on some degraded volume) must still deliver post-bracket reloads — /// distrust of FSEvents delivery is the reason the mandatory reload exists at all. private var rootChangeOwnsRecovery = false /// The origin a pending delivery will carry, merged per `WatchOrigin.merged(_:_:)`. Non-nil /// means "a delivery is owed" — which is not the same as "a timer is running": while a /// bracket is open the origin is remembered with no timer armed, and `endBracket()` merges /// its `.appMediated` into it and arms the timer then. private var pendingOrigin: WatchOrigin? /// The debounce, as a cancellable `Task` on the main actor rather than a `Timer` or a /// `DispatchSourceTimer`: the delivery has to happen where the handler and all the state it /// touches already live, and `Task.sleep` gives cancellation for free at exactly the /// granularity a trailing debounce needs — cancel, re-arm, done. private var deliveryTask: Task? // MARK: Init /// - Parameters: /// - root: The board root to watch. Need not exist yet (see `start()`). /// - debounce: Quiet period before a coalesced `.treeChanged` is delivered. The default is /// the reload debounce: long enough to absorb a `git checkout`'s churn and most momentary /// invalid states (§ Live-reload resilience, "the reload debounce already absorbs most /// momentary invalid states before they surface"), short enough that an external edit /// feels live. /// - latency: FSEvents' own coalescing latency, in seconds. Kept small and paired with /// `NoDefer` so the *first* event of a burst arrives promptly; this type's own debounce /// does the real coalescing, where it can be reasoned about and tested. /// - handler: Called on the main actor for every delivered event. public init( root: URL, debounce: Duration = .milliseconds(200), latency: TimeInterval = 0.05, handler: @escaping @MainActor (WatcherEvent) -> Void ) { self.watchedPath = Self.canonicalPath(of: root) self.debounce = debounce self.latency = latency self.handler = handler } deinit { // The stream is released with `self`; `StreamHandle.deinit` stops, invalidates, and // releases it. Nothing main-actor-isolated can be touched from here, and nothing needs // to be: a pending `deliveryTask` holds only a weak reference back and drops its event. } /// Whether a stream is currently attached and delivering. public var isWatching: Bool { handle != nil } // MARK: - Stream lifecycle /// Creates and starts the FSEvents stream. Returns `false` if the stream could not be /// created or started, in which case `isWatching` stays `false` and every other method /// remains safe to call — brackets, `reconcile()`, and `reattach(to:)` all still behave, they /// simply have no filesystem prompting them. /// /// Calling `start()` on an already-watching watcher replaces the stream (the old one is torn /// down first), which is what makes it safe to use as a retry. /// /// **A nonexistent path is not an error to FSEvents**: the stream is created and started /// happily, and with `WatchRoot` the eventual *creation* of that path is reported as a root /// change. `start()` therefore returns `true` for a path that is not there — the honest /// answer, since the stream really is live and really will report the path appearing. Callers /// that need existence must check for it themselves; this type's job is watching, not /// validating. @discardableResult public func start() -> Bool { teardownStream() generation += 1 let generation = self.generation let box = CallbackBox { [weak self] paths, flags in // The C callback runs on `queue`; the state it feeds is main-actor-isolated, so the // hop is unavoidable — and harmless, because the delivery it feeds is debounced // anyway. `weak self` so a watcher released between the callback and the hop simply // has no one to tell. Task { @MainActor in self?.receive(paths: paths, flags: flags, generation: generation) } } var context = FSEventStreamContext( version: 0, info: Unmanaged.passRetained(box).toOpaque(), retain: nil, // FSEvents calls this when the stream is deallocated, which it does only after it is // finished with the callback — so the box outlives every callback that can read it // without a single manual `release` on our side, and without a teardown race. // // It has to be a top-level function, not a closure literal: a closure written inside // this main-actor-isolated method inherits that isolation, and FSEvents runs the // release callback on the stream's dispatch queue during // `_FSEventStreamDeallocate` — which trips the actor's `dispatch_assert_queue` // check and traps. (Found the hard way; the crash is a `SIGTRAP` inside // `swift_task_isCurrentExecutor`, not anything that looks like a memory bug.) release: folderWatcherReleaseCallback, copyDescription: nil ) let flags = UInt32( kFSEventStreamCreateFlagUseCFTypes // paths arrive as CFString, not char* | kFSEventStreamCreateFlagFileEvents // per-path events, so `.git` can be filtered | kFSEventStreamCreateFlagWatchRoot // the root vanishing is itself an event | kFSEventStreamCreateFlagNoDefer // first event of a burst arrives promptly ) guard let stream = FSEventStreamCreate( kCFAllocatorDefault, folderWatcherStreamCallback, &context, [watchedPath] as CFArray, FSEventStreamEventId(kFSEventStreamEventIdSinceNow), latency, flags ) else { // Creation failed, so FSEvents never took ownership of the box: release the retain // taken above by hand, or it leaks. if let info = context.info { Unmanaged.fromOpaque(info).release() } return false } FSEventStreamSetDispatchQueue(stream, queue) guard FSEventStreamStart(stream) else { FSEventStreamInvalidate(stream) FSEventStreamRelease(stream) return false } handle = StreamHandle(stream: stream) // A fresh stream at whatever root we now watch: if a root change had handed recovery to // the root-change path, that handoff is complete. rootChangeOwnsRecovery = false return true } /// Tears the stream down and cancels any pending delivery. Nothing is delivered after this /// returns — not an in-flight FSEvents callback (its generation is stale), not a debounce /// that was seconds from firing (its task is cancelled). /// /// Bracket depth is deliberately *not* reset: `stop()` is about the stream, and an /// unbalanced bracket is the caller's bug to keep, not this type's to paper over. public func stop() { teardownStream() cancelPendingDelivery() pendingOrigin = nil } /// Kills the current stream, attaches a fresh one at `newRoot`, and schedules a reconciling /// reload — "streams die and are recreated, not merely kept", with the reconciling reload /// that every stream re-creation owes (§ Live-reload resilience). /// /// The consumer's path here is the settled rename story (§ Write-failure surfacing): on /// `.rootChanged` it re-resolves its security-scoped bookmark, and if the bookmark resolves /// to a new location the move is absorbed transparently — this call is that absorption. /// /// The reconciling reload is scheduled **whether or not the new stream came up**. If the new /// location is unwatchable, the reload is how the consumer finds out (its own load fails, or /// its writability probe does); silently skipping it would leave a stale board on screen, /// which is the one outcome this design excludes. public func reattach(to newRoot: URL) { stop() watchedPath = Self.canonicalPath(of: newRoot) _ = start() // Reattaching completes the root-change handoff even if the fresh stream failed to come // up (`start()` clears the flag only on success): the reconciling reload below is // promised unconditionally, and mid-bracket it must fold into the post-bracket delivery // rather than be stranded behind `endBracket()`'s root-change skip. rootChangeOwnsRecovery = false schedule(.reconciling) } private func teardownStream() { guard let handle else { return } // Bump before invalidating: a callback that already hopped to the main actor and is // queued behind this call must find its generation stale. generation += 1 handle.invalidate() self.handle = nil } // MARK: - Brackets /// Opens a bracket. Nestable — brackets count, they do not toggle. /// /// While any bracket is open **nothing is delivered**: incoming FSEvents are swallowed and /// any already-armed debounce is disarmed, because a bracketed operation rewrites the tree /// wholesale and a reload landing in the middle of it would render a half-checked-out tree /// (§ Live-reload resilience). /// /// A delivery that was already *owed* when the bracket opened is not thrown away — its origin /// is kept and folds into the post-bracket reload through the ordinary merge. Losing it would /// be harmless for the reload itself (the post-bracket reload is a full tree walk either way) /// but would lose the *origin*, and a `.reconciling` that becomes an `.appMediated` is a /// downgrade of provenance for no gain. public func beginBracket() { bracketDepth += 1 cancelPendingDelivery() } /// Closes a bracket. At depth 0 this schedules the debounced `.treeChanged(.appMediated)` — /// the mandatory single post-bracket reload — even if not one filesystem event was seen /// inside the bracket. The bracket's contract is "finish with one full reload"; making that /// conditional on having observed events would make correctness depend on FSEvents delivery, /// which is precisely the thing this design refuses to trust. /// /// FSEvents produced by the bracketed operation and still in flight when it closes (kernel /// latency does not respect our brackets) simply coalesce into that pending delivery via the /// origin merge, arriving as `.appMediated` rather than as a second `.foreign` reload. /// /// **The one exception: a root change that landed mid-bracket** (settled, 02-architecture.md /// § Write-failure surfacing — "owned by the root-change path, not the bracket"). A /// `.rootChanged` tears the stream down, so with no live stream at close the mandatory /// reload is skipped as a no-op: a rename the consumer absorbed mid-bracket has already /// re-attached by now (its banked reconciling reload delivers here instead, at the /// re-resolved root), and a true vanish raised the read-only lock whose clearance runs a /// reconciling reload of its own — nothing is lost by not failing redundantly. /// /// An unbalanced call — `endBracket()` at depth 0 — is ignored rather than trapping: the /// consumer's brackets wrap `do`/`catch` spans over git operations, and a bug there should /// not take the app down. public func endBracket() { guard bracketDepth > 0 else { return } bracketDepth -= 1 guard bracketDepth == 0 else { return } guard !rootChangeOwnsRecovery else { return } schedule(.appMediated) } // MARK: - Reconciliation /// Schedules a debounced reconciling reload with no filesystem prompt: the "self-reconciling, /// never trusted blindly" rule made callable. The consumer drives it from wake-from-sleep and /// app re-activation; the watcher drives it itself from missed-events flags and stream /// re-creation. /// /// Cheap by construction — an identical tree reloads to a value-equal snapshot and nothing /// visible happens — so callers are meant to be liberal with it. /// /// Respects brackets like everything else: called inside one, it is remembered and delivered /// with the post-bracket reload rather than mid-operation. public func reconcile() { schedule(.reconciling) } // MARK: - Delivery /// Records the origin a pending delivery owes and (re-)arms the debounce. /// /// Two rules live here: /// /// - **Trailing debounce**: every call cancels the armed task and arms a fresh one, so a /// burst of events delivers once, `debounce` after the last of them. /// - **Origin merge**: `reconciling > appMediated > foreign`. A foreign event folding into a /// pending app-mediated (or reconciling) delivery does not downgrade it. **This blur is /// accepted by design**: once two change spans have been coalesced into one reload, the /// reload genuinely covers both, and the honest label for the merged span is the strongest /// claim either half made. The alternative — splitting the delivery to keep origins pure — /// would trade the coalescing that makes bursts affordable for a distinction no consumer /// acts on differently. private func schedule(_ origin: WatchOrigin) { pendingOrigin = WatchOrigin.merged(pendingOrigin, origin) // Inside a bracket the origin is banked but no timer is armed; `endBracket()` arms it. guard bracketDepth == 0 else { cancelPendingDelivery() return } deliveryTask?.cancel() deliveryTask = Task { [weak self, debounce] in try? await Task.sleep(for: debounce) guard !Task.isCancelled else { return } self?.fire() } } private func fire() { deliveryTask = nil // A bracket that opened during the sleep already cancelled this task, but the cancel and // the wake can race; the depth check is the authority. guard bracketDepth == 0, let origin = pendingOrigin else { return } pendingOrigin = nil handler(.treeChanged(origin)) } private func cancelPendingDelivery() { deliveryTask?.cancel() deliveryTask = nil } // MARK: - Inbound events /// The main-actor landing point for one FSEvents callback batch. private func receive(paths: [String], flags: [FSEventStreamEventFlags], generation: Int) { guard generation == self.generation, handle != nil else { return } var sawRootChange = false var sawMissedEvents = false var sawRelevantPath = false for index in paths.indices { let flag = index < flags.count ? flags[index] : 0 if flag & FSEventStreamEventFlags(kFSEventStreamEventFlagRootChanged) != 0 { sawRootChange = true continue } if flag & Self.missedEventsFlags != 0 { sawMissedEvents = true } if !isGitInternal(paths[index]) { sawRelevantPath = true } } if sawRootChange { deliverRootChanged() return } // Brackets swallow everything below this line, missed-events flags included. That is not // a gap: the post-bracket reload is already a full tree walk, which covers any span the // flag was warning about, so `.appMediated` stays the correct label for it (§ Live-reload // resilience — recovery from any blind window is always the same act, reload). guard bracketDepth == 0 else { return } if sawMissedEvents { // `MustScanSubDirs`, a dropped kernel or user queue, wrapped event ids: the stream is // telling us it does not know what happened. Degrade to the reload rather than trust // the gap. Note this bypasses the `.git` filter deliberately — a batch we cannot // trust the contents of cannot be filtered by its contents either. schedule(.reconciling) return } // Nothing but `.git` churn in the whole batch: schedule nothing at all (see // `isGitInternal`). if sawRelevantPath { schedule(.foreign) } } /// The root vanished or changed identity. The stream is torn down (it is watching a path that /// no longer means what it meant) and the event is delivered **immediately** — bypassing both /// the debounce and any open bracket. /// /// Bypassing the bracket is deliberate and is the one place the "nothing fires mid-bracket" /// rule yields: a bracketed operation whose root disappeared underneath it cannot finish, and /// its post-bracket reload would resolve against a path that is gone (with the stream torn /// down here, `endBracket()` skips that reload — the root-change path owns recovery from this /// point, settled). The consumer must know now, so it can re-resolve its bookmark and either /// `reattach(to:)` or enter the vanished-root read-only lock (02-architecture.md /// § Write-failure surfacing). /// /// Any pending debounced delivery is dropped: it was going to report on a tree that no longer /// exists at that path, and whatever the consumer does next — `reattach(to:)` or the lock — /// ends in a reload of its own. private func deliverRootChanged() { teardownStream() cancelPendingDelivery() pendingOrigin = nil rootChangeOwnsRecovery = true handler(.rootChanged) } // MARK: - .git filtering /// Whether `path` lives under any `.git` component inside the board tree (settled, /// 02-architecture.md § Components). /// /// **Why filter at all**: the app auto-commits a couple of seconds after every change /// (06-history-undo.md), so every single edit the user makes is followed by a burst of writes /// to `.git/index`, `.git/objects/…`, and `.git/refs/…` — a second wave of events for a /// change already reloaded, arriving just late enough to miss the debounce and cost a /// redundant full tree walk. None of that churn can alter the rendered tree: the loader walks /// UUID-shaped folders and `index.md` files, and `.git` contains neither. /// /// **Any depth, not just the root's own repo**: a repo nested deeper — a card folder /// containing a clone, a submodule — is a stray (01-storage-format.md) whose internals never /// render either, so its churn is filtered on the same grounds. The nested repo's *working /// files* are ordinary stray paths and still fire like anything else. /// /// **Why it is safe**: this filters `.git`'s *internals*, not git's effects. An external /// `git checkout`, `git pull`, or `git stash` rewrites working-tree files, and those events /// arrive unfiltered in the same batch as the `.git` writes — the batch has a relevant path, /// so it schedules. The one thing lost is the ability to notice a pure-history change (a /// commit that touched no working-tree file), which changes nothing on screen anyway. /// /// A path that does not sit under the watched root at all is *not* filtered — better a /// redundant reload than a missed one. private func isGitInternal(_ path: String) -> Bool { guard path.hasPrefix(watchedPath) else { return false } let relative = path.dropFirst(watchedPath.count) guard relative.hasPrefix("/") else { return false } return relative.split(separator: "/").contains(".git") } // MARK: - Helpers private static let missedEventsFlags = FSEventStreamEventFlags( kFSEventStreamEventFlagMustScanSubDirs | kFSEventStreamEventFlagUserDropped | kFSEventStreamEventFlagKernelDropped | kFSEventStreamEventFlagEventIdsWrapped ) /// FSEvents reports canonical, symlink-resolved paths with no trailing slash; every path /// comparison this type makes has to be against the same shape or it silently never matches. private static func canonicalPath(of url: URL) -> String { let resolved = url.resolvingSymlinksInPath().standardizedFileURL.path return resolved.count > 1 && resolved.hasSuffix("/") ? String(resolved.dropLast()) : resolved } } // MARK: - C-level plumbing /// Owns the `FSEventStreamRef` outside the main actor so that releasing a `FolderWatcher` — from /// wherever the last reference happens to die — still tears the stream down. /// /// `@unchecked Sendable` is honest here rather than a shrug: the handle is created and /// invalidated only on the main actor, and the one call that can happen anywhere else is `deinit` /// — which by definition runs after every other reference is gone, so there is no concurrent /// reader to race with. FSEvents' own stop/invalidate/release are safe to call from any thread. private final class StreamHandle: @unchecked Sendable { private var stream: FSEventStreamRef? init(stream: FSEventStreamRef) { self.stream = stream } /// Idempotent: the second call finds `stream` already nil and does nothing, so an explicit /// `stop()` followed by deallocation is not a double release. func invalidate() { guard let stream else { return } self.stream = nil FSEventStreamStop(stream) FSEventStreamInvalidate(stream) // Drops the last reference, which is what makes FSEvents run the context's release // callback and free the `CallbackBox`. FSEventStreamRelease(stream) } deinit { invalidate() } } /// The stream's `info` payload: a closure and nothing else. /// /// Strict concurrency has no way to reason about a `void *` round-tripping through C, so the box /// keeps the unchecked part as small as it can possibly be — one immutable `@Sendable` closure, /// no mutable state, no reference to the watcher (the closure captures it weakly). Its lifetime /// is FSEvents': retained into `FSEventStreamContext.info`, released by the context's release /// callback when the stream is deallocated. private final class CallbackBox: @unchecked Sendable { let deliver: @Sendable ([String], [FSEventStreamEventFlags]) -> Void init(deliver: @escaping @Sendable ([String], [FSEventStreamEventFlags]) -> Void) { self.deliver = deliver } } /// Balances the `Unmanaged.passRetained` that put the box into `FSEventStreamContext.info`. /// Top-level and therefore non-isolated by construction — see the comment at the call site for /// why that matters. private func folderWatcherReleaseCallback(_ pointer: UnsafeRawPointer?) { guard let pointer else { return } Unmanaged.fromOpaque(pointer).release() } /// The `@convention(c)` trampoline. Captures nothing (it cannot), unpacks the `UseCFTypes` path /// array and the parallel flags array, and hands both to the box. private func folderWatcherStreamCallback( _ stream: ConstFSEventStreamRef, _ info: UnsafeMutableRawPointer?, _ numEvents: Int, _ eventPaths: UnsafeMutableRawPointer, _ eventFlags: UnsafePointer, _ eventIds: UnsafePointer ) { guard let info, numEvents > 0 else { return } let box = Unmanaged.fromOpaque(info).takeUnretainedValue() // With `kFSEventStreamCreateFlagUseCFTypes` this is a CFArray of CFString — the documented // shape, and the reason that flag is set: no manual `char *` decoding, no encoding guesswork // on paths. guard let paths = unsafeBitCast(eventPaths, to: NSArray.self) as? [String] else { return } let flags = (0..