diff options
Diffstat (limited to 'src/macos/Sources/FileWatcher.swift')
| -rw-r--r-- | src/macos/Sources/FileWatcher.swift | 122 |
1 files changed, 122 insertions, 0 deletions
diff --git a/src/macos/Sources/FileWatcher.swift b/src/macos/Sources/FileWatcher.swift new file mode 100644 index 00000000..1fee2fd8 --- /dev/null +++ b/src/macos/Sources/FileWatcher.swift @@ -0,0 +1,122 @@ +// Per-path live reload for the native app. One source watches the file for +// in-place writes; another watches its parent because editors normally save by +// renaming a new inode over the old one. The file source is rearmed after a +// quiet edge so it follows that replacement rather than the unlinked old inode. + +import Darwin +import Dispatch +import Foundation + +private final class WatchedPath { + let pane: UInt8 + let generation: UInt32 + private let path: String + private let directorySource: DispatchSourceFileSystemObject + private var fileSource: DispatchSourceFileSystemObject? + private var active = true + private var eventSerial: UInt64 = 0 + + init?(pane: UInt8, generation: UInt32, path: String) { + let directory = URL(fileURLWithPath: path) + .deletingLastPathComponent().path + let descriptor = directory.withCString { + Darwin.open($0, O_EVTONLY | O_CLOEXEC) + } + guard descriptor >= 0 else { return nil } + + self.pane = pane + self.generation = generation + self.path = path + directorySource = DispatchSource.makeFileSystemObjectSource( + fileDescriptor: descriptor, + eventMask: [.write, .delete, .rename, .extend, .attrib, .link, .revoke], + queue: .main) + directorySource.setCancelHandler { _ = Darwin.close(descriptor) } + directorySource.setEventHandler { [weak self] in self?.pathChanged() } + reopenFileSource() + directorySource.activate() + } + + /// The directory source catches rename-over saves, while the file source + /// catches programs which truncate/write the existing inode. Reopen the + /// latter after every quiet edge because an atomic save leaves it attached + /// to the unlinked old inode forever otherwise. + private func reopenFileSource() { + if let old = fileSource { old.cancel() } + fileSource = nil + guard active else { return } + let descriptor = path.withCString { Darwin.open($0, O_EVTONLY | O_CLOEXEC) } + guard descriptor >= 0 else { return } + let next = DispatchSource.makeFileSystemObjectSource( + fileDescriptor: descriptor, + eventMask: [.write, .delete, .rename, .extend, .attrib, .link, .revoke], + queue: .main) + next.setCancelHandler { _ = Darwin.close(descriptor) } + next.setEventHandler { [weak self] in self?.pathChanged() } + fileSource = next + next.activate() + } + + /// Vnode sources can emit several edges for one save. Reset a small quiet + /// period on every edge; generation plus exact Zig-side identity checks + /// remain authoritative, so this delay is latency control rather than + /// correctness state. + private func pathChanged() { + guard active else { return } + eventSerial &+= 1 + let scheduled = eventSerial + DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(45)) { [weak self] in + guard let self, self.active, self.eventSerial == scheduled else { return } + self.reopenFileSource() + pardes_watch_changed(self.pane, self.generation) + } + } + + func cancel() { + guard active else { return } + active = false + eventSerial &+= 1 + if let old = fileSource { old.cancel() } + fileSource = nil + directorySource.cancel() + } + + deinit { cancel() } +} + +private final class FileWatchRegistry { + static let shared = FileWatchRegistry() + private var slots: [UInt8: WatchedPath] = [:] + + func set(pane: UInt8, generation: UInt32, path: String?) { + if let old = slots.removeValue(forKey: pane) { old.cancel() } + guard let path, + let watched = WatchedPath( + pane: pane, generation: generation, path: path) + else { return } + slots[pane] = watched + } +} + +/// Called only by libpardes's main-thread effect drain. A nil pointer stops the +/// slot. The byte count avoids a second sentinel copy in Zig; Foundation paths +/// are Unicode, so malformed UTF-8 is rejected rather than watched under a +/// replacement spelling that could name a different file. +@_cdecl("pardes_host_watch_file") +func pardesHostWatchFile( + _ pane: UInt8, + _ generation: UInt32, + _ path: UnsafePointer<UInt8>?, + _ pathLength: Int +) { + guard let path, pathLength > 0 else { + FileWatchRegistry.shared.set(pane: pane, generation: generation, path: nil) + return + } + let bytes = UnsafeBufferPointer(start: path, count: pathLength) + guard let value = String(bytes: bytes, encoding: .utf8) else { + FileWatchRegistry.shared.set(pane: pane, generation: generation, path: nil) + return + } + FileWatchRegistry.shared.set(pane: pane, generation: generation, path: value) +} |
