Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 20 additions & 20 deletions cli/src/main.zig
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@
// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk>

const std = @import("std");
const file_watcher = @import("file_watcher.zig");

//==============================================================================
// I/O helpers (Zig 0.15 — File.writeAll + fmt.bufPrint)
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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", .{});
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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,
};

//==============================================================================
Expand Down Expand Up @@ -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);
}

Expand Down Expand Up @@ -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);
}
9 changes: 9 additions & 0 deletions src/interface/ffi/src/main.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading