From 33a53363f1bb7db90af01e3762f40b2668c4e7af Mon Sep 17 00:00:00 2001 From: hyperpolymath <6759885+hyperpolymath@users.noreply.github.com> Date: Wed, 20 May 2026 08:28:35 +0100 Subject: [PATCH] refactor(cli,ffi): relocate file_watcher to libgossamer with C-ABI exports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move cli/src/file_watcher.zig → src/interface/ffi/src/file_watcher.zig so any libgossamer consumer can use the hot-reload watcher — the native Zig CLI (today), the planned Ephapax-wasm CLI behind a host launcher, or third-party embedders. Changes: • git-mv the source file; update its header to reflect the new home. • Add two C-ABI exports at the bottom of the relocated file: - gossamer_watcher_start(handle, config_json, frontend_dist) dupes both buffers into watcher-owned memory so callers may free their inputs immediately on return. - gossamer_watcher_stop(opaque_handle) blocks on thread join, then frees the owned buffers + state. • Extend WatcherState with owned_json / owned_frontend_dist fields so the slices stored in WatchConfig outlive any caller-provided buffer when started via the C boundary. Zig-native start() leaves both null (back-compat — caller manages lifetime). • Wire src/interface/ffi/src/main.zig to pick up the new module via the existing comptime { _ = @import(...) } pattern. • cli/src/main.zig: drop the local @import("file_watcher.zig"), add extern decls for the two new exports, replace the call sites in cmdDev() with the C-ABI calls. Verified: • zig ast-check on all 3 touched files — zero parse errors. • Compile-link to GTK3/WebKitGTK requires those system dev libs which aren't installed in this WSL env (pre-existing limitation, not a regression); CI will run the full build. Phase 14a.2 of the gossamer cli (cli/) port from native Zig to typed-wasm Ephapax. Unblocks task #15 by ensuring the watcher API is reachable from any future wasm-host launcher. Co-Authored-By: Claude Opus 4.7 (1M context) --- cli/src/main.zig | 40 +++++----- .../interface/ffi}/src/file_watcher.zig | 79 ++++++++++++++++++- src/interface/ffi/src/main.zig | 9 +++ 3 files changed, 107 insertions(+), 21 deletions(-) rename {cli => src/interface/ffi}/src/file_watcher.zig (85%) diff --git a/cli/src/main.zig b/cli/src/main.zig index 852b534..20687db 100644 --- a/cli/src/main.zig +++ b/cli/src/main.zig @@ -15,7 +15,6 @@ // Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) const std = @import("std"); -const file_watcher = @import("file_watcher.zig"); //============================================================================== // I/O helpers (Zig 0.15 — File.writeAll + fmt.bufPrint) @@ -133,6 +132,11 @@ extern fn gossamer_groove_connect_typed(target_id: u32, groove_type: c_int, ttl: extern fn gossamer_groove_disconnect_typed(target_id: u32) c_int; extern fn gossamer_groove_query_type(target_id: u32) c_int; +// Hot-reload file watcher (relocated from cli/src/file_watcher.zig to +// src/interface/ffi/src/file_watcher.zig — now part of libgossamer). +extern fn gossamer_watcher_start(handle: u64, config_json: [*:0]const u8, frontend_dist: [*:0]const u8) ?*anyopaque; +extern fn gossamer_watcher_stop(opaque_handle: ?*anyopaque) void; + const AppMode = enum { gui, panel_host, @@ -981,26 +985,22 @@ fn cmdDev(allocator: std.mem.Allocator, config: Config, config_data: []const u8) out(" \x1b[32m✓\x1b[0m Loading: {s}\n\n", .{config.dev_url}); _ = gossamer_navigate(handle, url_z); - // Start the hot-reload file watcher. - // Parses the optional `build.watch` section from gossamer.conf.json, - // falling back to watching `frontendDist` with default extensions. - const watch_config = file_watcher.parseWatchConfig(config_data, config.frontend_dist); - var watcher: ?file_watcher.WatcherHandle = null; - if (watch_config.path_count > 0) { - watcher = file_watcher.start(handle, watch_config) catch blk: { - out(" \x1b[33m!\x1b[0m Hot reload watcher failed to start\n", .{}); - break :blk null; - }; - if (watcher != null) { - out(" \x1b[32m✓\x1b[0m Hot reload watcher active", .{}); - out(" ({d} path(s), debounce {d}ms)\n", .{ watch_config.path_count, watch_config.debounce_ms }); - } - } - defer { - if (watcher) |w| { - file_watcher.stop(w); - } + // Start the hot-reload file watcher via libgossamer's C-ABI exports. + // libgossamer parses the optional `build.watch` section from + // gossamer.conf.json itself, falling back to watching `frontendDist` + // with default extensions. + const config_json_z = try allocator.dupeZ(u8, config_data); + defer allocator.free(config_json_z); + const frontend_z = try allocator.dupeZ(u8, config.frontend_dist); + defer allocator.free(frontend_z); + + const watcher: ?*anyopaque = gossamer_watcher_start(handle, config_json_z, frontend_z); + if (watcher == null) { + out(" \x1b[33m!\x1b[0m Hot reload watcher failed to start\n", .{}); + } else { + out(" \x1b[32m✓\x1b[0m Hot reload watcher active\n", .{}); } + defer gossamer_watcher_stop(watcher); gossamer_run(handle); out("\n \x1b[2mWindow closed.\x1b[0m\n", .{}); diff --git a/cli/src/file_watcher.zig b/src/interface/ffi/src/file_watcher.zig similarity index 85% rename from cli/src/file_watcher.zig rename to src/interface/ffi/src/file_watcher.zig index 44a6bae..f620960 100644 --- a/cli/src/file_watcher.zig +++ b/src/interface/ffi/src/file_watcher.zig @@ -1,9 +1,15 @@ -// Gossamer CLI — File Watcher for Hot Reload +// Gossamer libgossamer — File Watcher for Hot Reload // // Polling-based file watcher that monitors directories for changes to // frontend assets (.html, .js, .css, .res.js). When a change is detected, // it schedules a webview reload on the GTK main thread via g_idle_add(). // +// Lives in the FFI layer (rather than in cli/) so any libgossamer +// consumer — the legacy native Zig CLI, the future Ephapax-wasm CLI +// behind a host launcher, third-party embedders — can use the same +// hot-reload path. Exposed to C as gossamer_watcher_start / +// gossamer_watcher_stop (see C-ABI exports at the end of this file). +// // Design: // - Runs on a dedicated std.Thread, separate from the GTK event loop. // - Polls watched directories every `poll_interval_ms` milliseconds. @@ -150,6 +156,14 @@ const WatcherState = struct { /// Thread handle for join on shutdown. thread: ?std.Thread = null, + + /// Caller-owned buffer copies kept alive for the lifetime of the + /// watcher. The WatchConfig holds slices into these. Non-null only + /// when the watcher was started via the C-ABI `gossamer_watcher_start` + /// export (which dupes its inputs); the Zig-native `start()` path + /// leaves them null because the caller manages buffer lifetime. + owned_json: ?[]u8 = null, + owned_frontend_dist: ?[]u8 = null, }; //============================================================================== @@ -188,6 +202,8 @@ pub fn stop(watcher: WatcherHandle) void { if (watcher.thread) |t| { t.join(); } + if (watcher.owned_json) |j| std.heap.c_allocator.free(j); + if (watcher.owned_frontend_dist) |fd| std.heap.c_allocator.free(fd); std.heap.c_allocator.destroy(watcher); } @@ -498,3 +514,64 @@ fn extractSimpleInt(json: []const u8, key: []const u8) ?u32 { if (i == num_start) return null; return std.fmt.parseInt(u32, after[num_start..i], 10) catch null; } + +//============================================================================== +// C ABI — exported as gossamer_watcher_* in libgossamer +//============================================================================== + +/// Start the hot-reload file watcher. +/// +/// Both `config_json` (the full gossamer.conf.json content, used to extract +/// the optional `"watch"` block) and `frontend_dist` (used as the fallback +/// watch path) are copied into watcher-owned memory, so callers may free +/// their buffers immediately after this returns. +/// +/// Returns an opaque pointer to be passed to gossamer_watcher_stop, or +/// null if the watcher could not be started. +export fn gossamer_watcher_start( + handle: u64, + config_json: [*:0]const u8, + frontend_dist: [*:0]const u8, +) ?*anyopaque { + const json_in = std.mem.span(config_json); + const fd_in = std.mem.span(frontend_dist); + + const json_copy = std.heap.c_allocator.dupe(u8, json_in) catch return null; + const fd_copy = std.heap.c_allocator.dupe(u8, fd_in) catch { + std.heap.c_allocator.free(json_copy); + return null; + }; + + const cfg = parseWatchConfig(json_copy, fd_copy); + + const state = std.heap.c_allocator.create(WatcherState) catch { + std.heap.c_allocator.free(json_copy); + std.heap.c_allocator.free(fd_copy); + return null; + }; + state.* = .{ + .handle = handle, + .config = cfg, + .owned_json = json_copy, + .owned_frontend_dist = fd_copy, + }; + + scanAllPaths(state); + + state.thread = std.Thread.spawn(.{}, watcherThreadFn, .{state}) catch { + std.heap.c_allocator.free(json_copy); + std.heap.c_allocator.free(fd_copy); + std.heap.c_allocator.destroy(state); + return null; + }; + return @ptrCast(state); +} + +/// Stop the watcher started by gossamer_watcher_start. Blocks until the +/// poll thread exits (bounded by one poll interval) and frees all +/// watcher-owned resources. Safe to call with null (no-op). +export fn gossamer_watcher_stop(opaque_handle: ?*anyopaque) void { + const p = opaque_handle orelse return; + const state: *WatcherState = @alignCast(@ptrCast(p)); + stop(state); +} diff --git a/src/interface/ffi/src/main.zig b/src/interface/ffi/src/main.zig index 8532ba5..586e5e5 100644 --- a/src/interface/ffi/src/main.zig +++ b/src/interface/ffi/src/main.zig @@ -74,6 +74,15 @@ comptime { _ = @import("plugin.zig"); } +// Hot-reload file watcher (gossamer_watcher_start, gossamer_watcher_stop). +// Polling watcher with g_idle_add marshalling to the GTK main thread. +// Relocated from cli/src/file_watcher.zig so any libgossamer consumer — +// native Zig CLI, future Ephapax-wasm CLI behind a host launcher, or +// third-party embedders — can use the same hot-reload path. +comptime { + _ = @import("file_watcher.zig"); +} + // Version information — bump on each release const VERSION = "0.3.0"; const BUILD_INFO = "Gossamer " ++ VERSION ++ " built with Zig " ++ @import("builtin").zig_version_string;