const std = @import("std"); const types = @import("../types.zig"); const Handler = types.Handler; const EventType = types.EventType; const ObjectType = types.ObjectType; pub const watches_recursively = true; // FSEventStreamCreate watches the entire subtree pub const detects_file_modifications = true; pub const emits_close_events = false; pub const emits_rename_for_files = false; pub const emits_rename_for_dirs = false; pub const emits_subtree_created_on_movein = true; handler: *Handler, stream: ?*anyopaque, // FSEventStreamRef queue: ?*anyopaque, // dispatch_queue_t ctx: ?*CallbackContext, // heap-allocated, freed after stream is stopped watches: std.StringArrayHashMapUnmanaged(void), // owned paths // last_seen_event_id is used to resume stream without missing events on // rebuild; no atomic needed - arm() only reads this after stop_stream(), // which calls FSEventStreamInvalidate and drains the GCD queue first last_seen_event_id: ?u64, const threaded = false; // callback fires on GCD thread; no FW_event needed const kFSEventStreamCreateFlagNoDefer: u32 = 0x00000002; const kFSEventStreamCreateFlagFileEvents: u32 = 0x00000010; const kFSEventStreamEventFlagItemCreated: u32 = 0x00000100; const kFSEventStreamEventFlagItemRemoved: u32 = 0x00000200; const kFSEventStreamEventFlagItemRenamed: u32 = 0x00000800; const kFSEventStreamEventFlagItemModified: u32 = 0x00001000; const kFSEventStreamEventFlagItemIsDir: u32 = 0x00020000; const kFSEventStreamEventIdSinceNow: u64 = 0xFFFFFFFFFFFFFFFF; const kCFStringEncodingUTF8: u32 = 0x08000100; // Mirror of FSEventStreamContext (Apple SDK struct; version must be 0). const FSEventStreamContext = extern struct { version: isize = 0, info: ?*anyopaque = null, retain: ?*anyopaque = null, release: ?*anyopaque = null, copy_description: ?*anyopaque = null, }; const cf = struct { pub extern "c" fn CFStringCreateWithBytesNoCopy( alloc: ?*anyopaque, bytes: [*]const u8, numBytes: isize, encoding: u32, isExternalRepresentation: u8, contentsDeallocator: ?*anyopaque, ) ?*anyopaque; pub extern "c" fn CFArrayCreate( allocator: ?*anyopaque, values: [*]const ?*anyopaque, numValues: isize, callBacks: ?*anyopaque, ) ?*anyopaque; pub extern "c" fn CFRelease(cf: *anyopaque) void; pub extern "c" fn FSEventStreamCreate( allocator: ?*anyopaque, callback: *const anyopaque, context: *const FSEventStreamContext, pathsToWatch: *anyopaque, sinceWhen: u64, latency: f64, flags: u32, ) ?*anyopaque; pub extern "c" fn FSEventStreamSetDispatchQueue(stream: *anyopaque, queue: *anyopaque) void; pub extern "c" fn FSEventStreamStart(stream: *anyopaque) u8; pub extern "c" fn FSEventStreamStop(stream: *anyopaque) void; pub extern "c" fn FSEventStreamInvalidate(stream: *anyopaque) void; pub extern "c" fn FSEventStreamRelease(stream: *anyopaque) void; pub extern "c" fn dispatch_queue_create(label: [*:0]const u8, attr: ?*anyopaque) *anyopaque; pub extern "c" fn dispatch_release(obj: *anyopaque) void; pub extern "c" var kCFAllocatorNull: *anyopaque; }; const CallbackContext = struct { handler: *Handler, // Snapshot of the watched root paths at arm() time, used to filter out // spurious events for the root directories themselves that FSEvents // sometimes delivers as historical events at stream start. watched_roots: []const []const u8, // owned slice of owned strings last_event_id: *?u64, // points to FSEvents.last_seen_event_id }; pub fn init(handler: *Handler) error{}!@This() { return .{ .handler = handler, .stream = null, .queue = null, .ctx = null, .watches = .empty, .last_seen_event_id = null, }; } fn stop_stream(self: *@This(), allocator: std.mem.Allocator) void { if (self.stream) |s| { cf.FSEventStreamStop(s); cf.FSEventStreamInvalidate(s); cf.FSEventStreamRelease(s); self.stream = null; } if (self.queue) |q| { cf.dispatch_release(q); self.queue = null; } if (self.ctx) |c| { for (c.watched_roots) |r| allocator.free(r); allocator.free(c.watched_roots); allocator.destroy(c); self.ctx = null; } } pub fn deinit(self: *@This(), allocator: std.mem.Allocator) void { self.stop_stream(allocator); var it = self.watches.iterator(); while (it.next()) |entry| allocator.free(entry.key_ptr.*); self.watches.deinit(allocator); } pub fn arm(self: *@This(), allocator: std.mem.Allocator) error{ OutOfMemory, ArmFailed }!void { if (self.stream != null) return; if (self.watches.count() == 0) return; // no paths yet; will arm on first add_watch var cf_strings: std.ArrayListUnmanaged(?*anyopaque) = .empty; defer cf_strings.deinit(allocator); var it = self.watches.iterator(); while (it.next()) |entry| { const path = entry.key_ptr.*; const s = cf.CFStringCreateWithBytesNoCopy( null, path.ptr, @intCast(path.len), kCFStringEncodingUTF8, 0, cf.kCFAllocatorNull, ) orelse continue; cf_strings.append(allocator, s) catch { cf.CFRelease(s); break; }; } defer for (cf_strings.items) |s| cf.CFRelease(s.?); const paths_array = cf.CFArrayCreate( null, cf_strings.items.ptr, @intCast(cf_strings.items.len), null, ) orelse return error.ArmFailed; defer cf.CFRelease(paths_array); // Snapshot watched root paths so the callback can filter them out. const roots = try allocator.alloc([]const u8, self.watches.count()); errdefer allocator.free(roots); var ri: usize = 0; errdefer for (roots[0..ri]) |r| allocator.free(r); var wit2 = self.watches.iterator(); while (wit2.next()) |entry| { roots[ri] = try allocator.dupe(u8, entry.key_ptr.*); ri += 1; } const ctx = try allocator.create(CallbackContext); errdefer allocator.destroy(ctx); ctx.* = .{ .handler = self.handler, .watched_roots = roots, .last_event_id = &self.last_seen_event_id }; // FSEventStreamCreate copies the context struct; stack allocation is fine. const stream_ctx = FSEventStreamContext{ .version = 0, .info = ctx }; const stream = cf.FSEventStreamCreate( null, @ptrCast(&callback), &stream_ctx, paths_array, self.last_seen_event_id orelse kFSEventStreamEventIdSinceNow, 0.1, kFSEventStreamCreateFlagNoDefer | kFSEventStreamCreateFlagFileEvents, ) orelse return error.ArmFailed; const queue = cf.dispatch_queue_create("nightwatch", null); cf.FSEventStreamSetDispatchQueue(stream, queue); // Stream and queue must be torn down together in the right order; a single // errdefer block covering both is cleaner than two separate errdefers. errdefer { cf.FSEventStreamInvalidate(stream); cf.FSEventStreamRelease(stream); cf.dispatch_release(queue); } if (cf.FSEventStreamStart(stream) == 0) return error.ArmFailed; self.stream = stream; self.queue = queue; self.ctx = ctx; } fn callback( _: *anyopaque, info: ?*anyopaque, num_events: usize, event_paths: *anyopaque, event_flags: [*]const u32, event_ids: [*]const u64, ) callconv(.c) void { const ctx: *CallbackContext = @ptrCast(@alignCast(info orelse return)); const paths: [*][*:0]const u8 = @ptrCast(@alignCast(event_paths)); outer: for (0..num_events) |i| { const path = std.mem.sliceTo(paths[i], 0); const flags = event_flags[i]; const eid = event_ids[i]; if (ctx.last_event_id.* == null or eid > ctx.last_event_id.*.?) ctx.last_event_id.* = eid; // Skip events for the watched root dirs themselves; FSEvents often // delivers spurious historical events for them at stream start. for (ctx.watched_roots) |root| { if (std.mem.eql(u8, path, root)) continue :outer; } // FSEvents coalesces operations, so multiple flags may be set on // a single event. Emit one change call per applicable flag so // callers see all relevant event types (e.g. created + modified). const ot: ObjectType = if (flags & kFSEventStreamEventFlagItemIsDir != 0) .dir else .file; // Handler errors are silently ignored: this callback runs on a GCD // dispatch thread managed by the OS, so there is no way to propagate // an error back to the caller. Stopping the stream from inside the // callback would require a separate signal channel and is not worth // the complexity; the stream will keep delivering future events. // FSEvents coalesces multiple operations into a single callback with // multiple flags set. Processing all flags independently produces // spurious duplicate events (e.g. ItemRemoved|ItemRenamed -> two // deletes; ItemCreated|ItemRemoved -> spurious create before delete). // // Priority chain: Removed > Renamed > Modified > Created. // Modified beats Created because FSEvents sets ItemCreated on writes to // existing files when O_CREAT is used (e.g. `echo > file`), producing // spurious create events. When both are set, the file already exists, // so Modified is the accurate description. // // Exception: ItemRenamed|ItemModified on an existing path emits both // created and modified, because a rename-in followed by a write can be // coalesced by FSEvents into a single callback. if (flags & kFSEventStreamEventFlagItemRemoved != 0) { ctx.handler.change(path, .deleted, ot) catch {}; } else if (flags & kFSEventStreamEventFlagItemRenamed != 0) { // FSEvents fires ItemRenamed for both sides of a rename unpaired. // Normalize to created/deleted based on whether the path still exists, // so move-in appears as created and move-out as deleted on all platforms. const exists = if (std.fs.accessAbsolute(path, .{})) |_| true else |_| false; ctx.handler.change(path, if (exists) .created else .deleted, ot) catch {}; // If a directory was moved in from outside the watched tree, FSEvents // only fires for the directory itself - not for its pre-existing contents. // Walk the subtree and emit individual created events for each entry. if (exists and ot == .dir) emit_subtree_created(ctx.handler, path); // If a write was coalesced with a move-in, also emit the modify. if (exists and flags & kFSEventStreamEventFlagItemModified != 0) { ctx.handler.change(path, .modified, ot) catch {}; } } else if (flags & kFSEventStreamEventFlagItemModified != 0) { ctx.handler.change(path, .modified, ot) catch {}; } else if (flags & kFSEventStreamEventFlagItemCreated != 0) { ctx.handler.change(path, .created, ot) catch {}; } } } // Walk dir_path recursively, emitting 'created' events for all contents. // Called when a directory is moved into the watched tree: FSEvents fires // ItemRenamed only for the directory itself, not for its pre-existing contents. // Uses only stack storage so it can be called from the GCD callback thread. fn emit_subtree_created(handler: *Handler, dir_path: []const u8) void { var dir = std.fs.openDirAbsolute(dir_path, .{ .iterate = true }) catch return; defer dir.close(); var iter = dir.iterate(); while (iter.next() catch return) |entry| { const ot: ObjectType = switch (entry.kind) { .file => .file, .directory => .dir, else => continue, }; var path_buf: [std.fs.max_path_bytes]u8 = undefined; const full_path = std.fmt.bufPrint(&path_buf, "{s}/{s}", .{ dir_path, entry.name }) catch continue; handler.change(full_path, .created, ot) catch {}; if (ot == .dir) emit_subtree_created(handler, full_path); } } pub fn add_watch(self: *@This(), allocator: std.mem.Allocator, path: []const u8) error{ WatchFailed, OutOfMemory }!void { if (self.watches.contains(path)) return; const owned = try allocator.dupe(u8, path); errdefer allocator.free(owned); try self.watches.put(allocator, owned, {}); self.stop_stream(allocator); self.arm(allocator) catch return error.WatchFailed; } pub fn remove_watch(self: *@This(), allocator: std.mem.Allocator, path: []const u8) error{WatchFailed}!void { if (self.watches.fetchSwapRemove(path)) |entry| allocator.free(entry.key); self.stop_stream(allocator); self.arm(allocator) catch return error.WatchFailed; }