summaryrefslogtreecommitdiff
path: root/src/macos/Sources/FileWatcher.swift
diff options
context:
space:
mode:
Diffstat (limited to 'src/macos/Sources/FileWatcher.swift')
-rw-r--r--src/macos/Sources/FileWatcher.swift122
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)
+}