diff --git a/.editorconfig b/.editorconfig index bcdbb4d..5a7372f 100644 --- a/.editorconfig +++ b/.editorconfig @@ -1,4 +1,4 @@ -# RSR-template-repo - Editor Configuration +# gossamer - Editor Configuration # https://editorconfig.org root = true diff --git a/.github/workflows/abi-typecheck.yml b/.github/workflows/abi-typecheck.yml index 7649f70..4638c3a 100644 --- a/.github/workflows/abi-typecheck.yml +++ b/.github/workflows/abi-typecheck.yml @@ -13,16 +13,20 @@ on: branches: ['**'] paths: - 'src/interface/**' + - 'tests/idris2/**' - 'gossamer-abi.ipkg' - 'gossamer-groove.ipkg' + - 'gossamer-abi-tests.ipkg' - 'scripts/check-abi-decoupling.sh' - '.github/workflows/abi-typecheck.yml' push: branches: [main, master] paths: - 'src/interface/**' + - 'tests/idris2/**' - 'gossamer-abi.ipkg' - 'gossamer-groove.ipkg' + - 'gossamer-abi-tests.ipkg' - 'scripts/check-abi-decoupling.sh' - '.github/workflows/abi-typecheck.yml' @@ -47,7 +51,7 @@ jobs: run: ./scripts/check-abi-decoupling.sh abi-typecheck: - name: idris2 --typecheck (shell + groove) + name: idris2 typecheck + test (shell + groove) runs-on: ubuntu-latest needs: abi-structure steps: @@ -74,3 +78,8 @@ jobs: - name: Typecheck the groove protocol package (depends on the shell) run: idris2 --typecheck gossamer-groove.ipkg + + - name: Build & run the ABI runtime test suite + run: | + idris2 --build gossamer-abi-tests.ipkg + ./build/exec/gossamer-abi-tests diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 374ca5e..f8d0b46 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -101,6 +101,11 @@ jobs: if: steps.changes.outputs.run == 'true' run: bash tests/e2e.sh + - name: Run FFI/ABI integration tests (headless) + if: steps.changes.outputs.run == 'true' + working-directory: src/interface/ffi + run: zig build test-integration + - name: Skip E2E for docs-only changes if: steps.changes.outputs.run != 'true' run: echo "Docs-only change set; skipping E2E." diff --git a/.machine_readable/ai/AI.a2ml b/.machine_readable/ai/AI.a2ml index c4da8df..6015ca1 100644 --- a/.machine_readable/ai/AI.a2ml +++ b/.machine_readable/ai/AI.a2ml @@ -2,7 +2,7 @@ # AI Assistant Instructions ## Repository Focus -- `rsr-template-repo` is treated as a Rhodium Standard Repository; obey the Rhodium policies and keep `.machine_readable/` authoritative. +- `gossamer` is treated as a Rhodium Standard Repository; obey the Rhodium policies and keep `.machine_readable/` authoritative. - All machine-readable content lives under `.machine_readable/` — state files (a2ml), bot directives, and contractiles. - Prefer to keep generated files out of source control, and regenerate them with the documented commands before committing. diff --git a/.machine_readable/bot_directives/methodology.a2ml b/.machine_readable/bot_directives/methodology.a2ml index 754f357..97272f9 100644 --- a/.machine_readable/bot_directives/methodology.a2ml +++ b/.machine_readable/bot_directives/methodology.a2ml @@ -55,7 +55,7 @@ perfective = 10 # % for SPDX headers, doc updates, formatting, style # Customise this per project — the template default is generic. [methodology.unique-strength] -description = "{{PROJECT_UNIQUE_STRENGTH}}" +description = "Compiler-proven desktop resource safety: webview handles, IPC message shapes, and permissions are enforced by the Idris2 linear-type ABI and the Ephapax region calculus at compile time — leaked handles and permission bypasses are compile errors, not runtime incidents. No garbage collector; deterministic region-based cleanup." deepen-not-broaden = true # ============================================================================ diff --git a/.well-known/groove/manifest.json b/.well-known/groove/manifest.json index ecd141f..6156ef4 100644 --- a/.well-known/groove/manifest.json +++ b/.well-known/groove/manifest.json @@ -1,7 +1,7 @@ { "groove_version": "1", "service_id": "gossamer", - "service_version": "0.1.0", + "service_version": "0.3.1", "capabilities": { "panel-ui": { "type": "panel-ui", diff --git a/src/interface/ffi/build.zig b/src/interface/ffi/build.zig index ee83897..d63a0a2 100644 --- a/src/interface/ffi/build.zig +++ b/src/interface/ffi/build.zig @@ -170,6 +170,34 @@ pub fn build(b: *std.Build) void { const display_test_step = b.step("test-display", "Run Gossamer display integration tests (requires X11/Wayland/Xvfb)"); display_test_step.dependOn(&run_display_tests.step); + // --- FFI/ABI integration tests (headless; no display server) --- + // test/integration_test.zig exercises the exported C API surface (result + // codes, capability lifecycle, IPC, groove, filesystem, ssg, plugin) against + // the Idris2 ABI. It imports the `gossamer` module (main.zig re-exports the + // submodules) rather than ../src/*.zig, which Zig 0.15 forbids from a + // test/-rooted module. GTK/WebKit dev libs are needed to compile main.zig, + // but no display server is required to run. + // zig build test-integration + const integration_test_module = b.createModule(.{ + .root_source_file = b.path("test/integration_test.zig"), + .target = target, + .optimize = optimize, + .link_libc = true, + .imports = &.{ + .{ .name = "gossamer", .module = gossamer_module }, + }, + }); + + linkPlatformLibs(integration_test_module, os, abi); + + const integration_tests = b.addTest(.{ + .root_module = integration_test_module, + }); + + const run_integration_tests = b.addRunArtifact(integration_tests); + const integration_test_step = b.step("test-integration", "Run Gossamer FFI/ABI integration tests (headless)"); + integration_test_step.dependOn(&run_integration_tests.step); + // --- Android component host logic tests (host-runnable; pure Zig, no NDK) --- // The JNI binding and the Service/Receiver/Widget hosts are pure Zig, so // their registry/dispatch/JSON/directive logic runs on the host via diff --git a/src/interface/ffi/src/clipboard.zig b/src/interface/ffi/src/clipboard.zig index 14d586f..66dab0a 100644 --- a/src/interface/ffi/src/clipboard.zig +++ b/src/interface/ffi/src/clipboard.zig @@ -150,7 +150,7 @@ const backend = switch (builtin.os.tag) { /// Null-safety: returns -1 (invalid_param) if buf is null or buf_len is 0. /// /// Matches ABI: Gossamer.ABI.Types.ResourceKind.Clipboard (kind = 3) -export fn gossamer_clipboard_read(buf: ?[*]u8, buf_len: usize) callconv(.c) c_int { +pub export fn gossamer_clipboard_read(buf: ?[*]u8, buf_len: usize) callconv(.c) c_int { if (buf == null or buf_len == 0) { setError("Clipboard read: null buffer or zero length"); return -1; @@ -167,7 +167,7 @@ export fn gossamer_clipboard_read(buf: ?[*]u8, buf_len: usize) callconv(.c) c_in /// Null-safety: returns invalid_param if text is null. /// /// Matches ABI: Gossamer.ABI.Types.ResourceKind.Clipboard (kind = 3) -export fn gossamer_clipboard_write(text: ?[*:0]const u8) callconv(.c) c_int { +pub export fn gossamer_clipboard_write(text: ?[*:0]const u8) callconv(.c) c_int { if (text == null) { setError("Clipboard write: null text pointer"); return @intFromEnum(Result.invalid_param); diff --git a/src/interface/ffi/src/csp.zig b/src/interface/ffi/src/csp.zig index cc230de..08606f7 100644 --- a/src/interface/ffi/src/csp.zig +++ b/src/interface/ffi/src/csp.zig @@ -66,7 +66,7 @@ const c = @cImport({ /// /// Thread safety: if called from a non-GTK thread, uses g_idle_add to marshal /// the JS evaluation onto the GTK main thread. -export fn gossamer_set_csp(handle_ptr: u64, csp: [*:0]const u8) main.Result { +pub export fn gossamer_set_csp(handle_ptr: u64, csp: [*:0]const u8) main.Result { main.clearError(); const handle = main.ptrFromU64(handle_ptr) orelse { main.setError("Null webview handle"); @@ -149,7 +149,7 @@ const EmitContext = struct { /// window.__gossamer_on("file_changed", function(data) { /// console.log("Changed:", data.path); /// }); -export fn gossamer_emit( +pub export fn gossamer_emit( handle_ptr: u64, event_name: [*:0]const u8, payload_json: [*:0]const u8, @@ -233,7 +233,7 @@ export fn gossamer_emit( /// Thread safety: uses g_idle_add exactly like gossamer_emit. Safe from any thread. /// /// Matches: Gossamer.ABI.Foreign.prim__emitBinary -export fn gossamer_emit_binary( +pub export fn gossamer_emit_binary( handle_ptr: u64, event_name: [*:0]const u8, data: [*]const u8, diff --git a/src/interface/ffi/src/dialog.zig b/src/interface/ffi/src/dialog.zig index 0f69854..318ab00 100644 --- a/src/interface/ffi/src/dialog.zig +++ b/src/interface/ffi/src/dialog.zig @@ -211,7 +211,7 @@ fn runFileChooserDialog( /// gossamer_dialog_free_path), or 0 if the user cancelled. /// /// Matches: Gossamer.ABI.Foreign.prim__dialogOpen -export fn gossamer_dialog_open(title: [*:0]const u8, filters: [*:0]const u8) u64 { +pub export fn gossamer_dialog_open(title: [*:0]const u8, filters: [*:0]const u8) u64 { clearError(); return runFileChooserDialog( title, @@ -232,7 +232,7 @@ export fn gossamer_dialog_open(title: [*:0]const u8, filters: [*:0]const u8) u64 /// gossamer_dialog_free_path), or 0 if the user cancelled. /// /// Matches: Gossamer.ABI.Foreign.prim__dialogSave -export fn gossamer_dialog_save(title: [*:0]const u8, filters: [*:0]const u8) u64 { +pub export fn gossamer_dialog_save(title: [*:0]const u8, filters: [*:0]const u8) u64 { clearError(); return runFileChooserDialog( title, @@ -252,7 +252,7 @@ export fn gossamer_dialog_save(title: [*:0]const u8, filters: [*:0]const u8) u64 /// gossamer_dialog_free_path), or 0 if the user cancelled. /// /// Matches: Gossamer.ABI.Foreign.prim__dialogOpenDirectory -export fn gossamer_dialog_open_directory(title: [*:0]const u8) u64 { +pub export fn gossamer_dialog_open_directory(title: [*:0]const u8) u64 { clearError(); return runFileChooserDialog( title, @@ -274,7 +274,7 @@ export fn gossamer_dialog_open_directory(title: [*:0]const u8) u64 { /// Returns 0 if the user cancelled or an error occurred. /// /// Matches: Gossamer.ABI.Foreign.prim__dialogOpenMultiple -export fn gossamer_dialog_open_multiple(title: [*:0]const u8, filters: [*:0]const u8) u64 { +pub export fn gossamer_dialog_open_multiple(title: [*:0]const u8, filters: [*:0]const u8) u64 { clearError(); if (!ensureGtkInit()) { setError("GTK not initialised — cannot show file dialog"); @@ -376,7 +376,7 @@ export fn gossamer_dialog_open_multiple(title: [*:0]const u8, filters: [*:0]cons /// to match — using the wrong free will corrupt the heap. /// /// Matches: Gossamer.ABI.Foreign.prim__dialogFreePath -export fn gossamer_dialog_free_path(path_ptr: u64) void { +pub export fn gossamer_dialog_free_path(path_ptr: u64) void { if (path_ptr == 0) return; std.c.free(@ptrFromInt(@as(usize, @intCast(path_ptr)))); } diff --git a/src/interface/ffi/src/filesystem.zig b/src/interface/ffi/src/filesystem.zig index 82ce55f..e1ded8a 100644 --- a/src/interface/ffi/src/filesystem.zig +++ b/src/interface/ffi/src/filesystem.zig @@ -21,7 +21,7 @@ const main = @import("main.zig"); /// Validates the capability token is active and of type FileSystem (kind=0). /// /// Returns null on error (check gossamer_last_error). -export fn gossamer_fs_read_text( +pub export fn gossamer_fs_read_text( path: [*:0]const u8, cap_token: u64, ) ?[*:0]u8 { @@ -68,7 +68,7 @@ export fn gossamer_fs_read_text( /// Validates the capability token is active and of type FileSystem (kind=0). /// /// Returns Result (0=ok, 1=error, 10=capability_denied). -export fn gossamer_fs_write_text( +pub export fn gossamer_fs_write_text( path: [*:0]const u8, contents: [*:0]const u8, cap_token: u64, @@ -106,7 +106,7 @@ export fn gossamer_fs_write_text( /// Validates the capability token is active and of type FileSystem (kind=0). /// /// Returns 1 (exists), 0 (does not exist), or 0xFFFFFFFF on error. -export fn gossamer_fs_exists( +pub export fn gossamer_fs_exists( path: [*:0]const u8, cap_token: u64, ) u32 { @@ -130,7 +130,7 @@ export fn gossamer_fs_exists( /// Returns a null-terminated C string (caller frees), or null on error. /// /// Validates the capability token is active and of type FileSystem (kind=0). -export fn gossamer_fs_list_dir( +pub export fn gossamer_fs_list_dir( path: [*:0]const u8, cap_token: u64, ) ?[*:0]u8 { @@ -182,7 +182,7 @@ export fn gossamer_fs_list_dir( /// Remove a file. Directories are not removed (use gossamer_fs_remove_dir). /// /// Validates the capability token is active and of type FileSystem (kind=0). -export fn gossamer_fs_remove( +pub export fn gossamer_fs_remove( path: [*:0]const u8, cap_token: u64, ) main.Result { @@ -207,7 +207,7 @@ export fn gossamer_fs_remove( /// Succeeds when the directory already exists. /// /// Validates the capability token is active and of type FileSystem (kind=0). -export fn gossamer_fs_mkdir_p( +pub export fn gossamer_fs_mkdir_p( path: [*:0]const u8, cap_token: u64, ) main.Result { @@ -244,7 +244,7 @@ export fn gossamer_fs_mkdir_p( /// gossamer_fs_mkdir_p first if needed. /// /// Validates the capability token is active and of type FileSystem (kind=0). -export fn gossamer_fs_copy_file( +pub export fn gossamer_fs_copy_file( src: [*:0]const u8, dst: [*:0]const u8, cap_token: u64, diff --git a/src/interface/ffi/src/groove.zig b/src/interface/ffi/src/groove.zig index 275ca5f..577fcd8 100644 --- a/src/interface/ffi/src/groove.zig +++ b/src/interface/ffi/src/groove.zig @@ -304,7 +304,7 @@ fn hasCapability(idx: usize, name: []const u8) bool { /// Discover all groove targets by probing well-known ports. /// Returns the number of successfully connected grooves. -export fn gossamer_groove_discover() callconv(.c) u32 { +pub export fn gossamer_groove_discover() callconv(.c) u32 { var count: u32 = 0; for (0..TARGET_COUNT) |i| { probeTarget(i); @@ -316,7 +316,7 @@ export fn gossamer_groove_discover() callconv(.c) u32 { } /// Get the status of a specific groove target. -export fn gossamer_groove_status(target_id: u32) callconv(.c) u32 { +pub export fn gossamer_groove_status(target_id: u32) callconv(.c) u32 { if (target_id >= TARGET_COUNT) return 0; return @intFromEnum(grooves[target_id].status); } @@ -324,7 +324,7 @@ export fn gossamer_groove_status(target_id: u32) callconv(.c) u32 { /// Get the manifest JSON for a connected groove target. /// Returns a pointer to a null-terminated string (thread-local buffer). /// Returns pointer to empty string if not connected. -export fn gossamer_groove_manifest(target_id: u32) callconv(.c) [*:0]const u8 { +pub export fn gossamer_groove_manifest(target_id: u32) callconv(.c) [*:0]const u8 { if (target_id >= TARGET_COUNT) return ""; const state = &grooves[target_id]; if (state.status == .not_found or state.manifest_len == 0) return ""; @@ -337,7 +337,7 @@ export fn gossamer_groove_manifest(target_id: u32) callconv(.c) [*:0]const u8 { /// Find which groove target provides a given capability. /// Returns the target index, or 0xFFFFFFFF if none. -export fn gossamer_groove_find_capability(cap_name: [*:0]const u8) callconv(.c) u32 { +pub export fn gossamer_groove_find_capability(cap_name: [*:0]const u8) callconv(.c) u32 { // Determine string length. var len: usize = 0; while (cap_name[len] != 0 and len < MAX_CAP_NAME) : (len += 1) {} @@ -357,7 +357,7 @@ export fn gossamer_groove_find_capability(cap_name: [*:0]const u8) callconv(.c) /// Check if two groove targets are compatible for composition. /// Two targets are compatible if each offers what the other consumes. /// Returns 1 if compatible, 0 if not. -export fn gossamer_groove_check_compat(target_a: u32, target_b: u32) callconv(.c) u32 { +pub export fn gossamer_groove_check_compat(target_a: u32, target_b: u32) callconv(.c) u32 { if (target_a >= TARGET_COUNT or target_b >= TARGET_COUNT) return 0; const a = &grooves[target_a]; const b = &grooves[target_b]; @@ -424,7 +424,7 @@ pub export fn gossamer_groove_send(target_id: u32, msg_ptr: [*:0]const u8) callc /// Receive a pending message from a grooved service. /// Uses HTTP GET to /.well-known/groove/recv on the target port. /// Returns a pointer to the response body (thread-local buffer). -export fn gossamer_groove_recv(target_id: u32) callconv(.c) [*:0]const u8 { +pub export fn gossamer_groove_recv(target_id: u32) callconv(.c) [*:0]const u8 { if (target_id >= TARGET_COUNT) return ""; if (grooves[target_id].status == .not_found) return ""; @@ -459,7 +459,7 @@ export fn gossamer_groove_recv(target_id: u32) callconv(.c) [*:0]const u8 { /// Get a JSON summary of all groove connections. /// Format: [{"id":0,"service":"burble","status":2,"port":6473,"caps":3}, ...] -export fn gossamer_groove_summary() callconv(.c) [*:0]const u8 { +pub export fn gossamer_groove_summary() callconv(.c) [*:0]const u8 { var pos: usize = 0; out_buf[pos] = '['; pos += 1; @@ -490,13 +490,13 @@ export fn gossamer_groove_summary() callconv(.c) [*:0]const u8 { } /// Disconnect a specific groove target. -export fn gossamer_groove_disconnect(target_id: u32) callconv(.c) void { +pub export fn gossamer_groove_disconnect(target_id: u32) callconv(.c) void { if (target_id >= TARGET_COUNT) return; grooves[target_id] = .{}; } /// Disconnect all groove targets. -export fn gossamer_groove_disconnect_all() callconv(.c) void { +pub export fn gossamer_groove_disconnect_all() callconv(.c) void { for (&grooves) |*g| { g.* = .{}; } diff --git a/src/interface/ffi/src/main.zig b/src/interface/ffi/src/main.zig index dcd1c92..32463a7 100644 --- a/src/interface/ffi/src/main.zig +++ b/src/interface/ffi/src/main.zig @@ -17,6 +17,22 @@ const std = @import("std"); const builtin = @import("builtin"); +// Submodule re-exports — expose the FFI submodules so the integration test +// suite (test/integration_test.zig) can reach their exported functions through +// the `gossamer` module. Zig 0.15 forbids a test/-rooted module from importing +// ../src/*.zig directly, so the tests import "gossamer" and read e.g. +// `gossamer.groove`. These also force each submodule's `export fn`s into the +// shared library (the sibling `comptime { _ = @import("…"); }` guards below are +// now redundant but kept for locality). +pub const ssg = @import("ssg.zig"); +pub const filesystem = @import("filesystem.zig"); +pub const groove = @import("groove.zig"); +pub const csp = @import("csp.zig"); +pub const plugin = @import("plugin.zig"); +pub const clipboard = @import("clipboard.zig"); +pub const dialog = @import("dialog.zig"); +pub const tray = @import("tray.zig"); + extern fn gossamer_tray_clear_window() void; // Static Site Generator FFI functions (gossamer_ssg_*). @@ -466,7 +482,7 @@ fn createHandle( /// Must be called from the main/UI thread. /// /// Matches: Gossamer.ABI.Foreign.prim__create -export fn gossamer_create( +pub export fn gossamer_create( title: [*:0]const u8, width: u32, height: u32, @@ -483,7 +499,7 @@ export fn gossamer_create( /// visible uses 0/1 to control whether the window starts hidden. /// /// Matches: Gossamer.ABI.Foreign.prim__createEx -export fn gossamer_create_ex( +pub export fn gossamer_create_ex( title: [*:0]const u8, width: u32, height: u32, @@ -514,7 +530,7 @@ export fn gossamer_create_ex( /// Load HTML content into the webview. /// /// Matches: Gossamer.ABI.Foreign.prim__loadHTML -export fn gossamer_load_html(handle_ptr: u64, html: [*:0]const u8) Result { +pub export fn gossamer_load_html(handle_ptr: u64, html: [*:0]const u8) Result { clearError(); const handle = ptrFromU64(handle_ptr) orelse { setError("Null webview handle"); @@ -537,7 +553,7 @@ export fn gossamer_load_html(handle_ptr: u64, html: [*:0]const u8) Result { /// Navigate the webview to a URL. /// /// Matches: Gossamer.ABI.Foreign.prim__navigate -export fn gossamer_navigate(handle_ptr: u64, url: [*:0]const u8) Result { +pub export fn gossamer_navigate(handle_ptr: u64, url: [*:0]const u8) Result { clearError(); const handle = ptrFromU64(handle_ptr) orelse { setError("Null webview handle"); @@ -560,7 +576,7 @@ export fn gossamer_navigate(handle_ptr: u64, url: [*:0]const u8) Result { /// Evaluate JavaScript in the webview context. /// /// Matches: Gossamer.ABI.Foreign.prim__eval -export fn gossamer_eval(handle_ptr: u64, js: [*:0]const u8) Result { +pub export fn gossamer_eval(handle_ptr: u64, js: [*:0]const u8) Result { clearError(); const handle = ptrFromU64(handle_ptr) orelse { setError("Null webview handle"); @@ -583,7 +599,7 @@ export fn gossamer_eval(handle_ptr: u64, js: [*:0]const u8) Result { /// Set the window title. /// /// Matches: Gossamer.ABI.Foreign.prim__setTitle -export fn gossamer_set_title(handle_ptr: u64, title: [*:0]const u8) Result { +pub export fn gossamer_set_title(handle_ptr: u64, title: [*:0]const u8) Result { clearError(); const handle = ptrFromU64(handle_ptr) orelse { setError("Null webview handle"); @@ -607,7 +623,7 @@ export fn gossamer_set_title(handle_ptr: u64, title: [*:0]const u8) Result { /// Rejected when guard mode is locked or read_only. /// /// Matches: Gossamer.ABI.Foreign.prim__resize -export fn gossamer_resize(handle_ptr: u64, width: u32, height: u32) Result { +pub export fn gossamer_resize(handle_ptr: u64, width: u32, height: u32) Result { clearError(); const handle = ptrFromU64(handle_ptr) orelse { setError("Null webview handle"); @@ -634,7 +650,7 @@ export fn gossamer_resize(handle_ptr: u64, width: u32, height: u32) Result { /// After this returns, the handle is CONSUMED — the webview is destroyed. /// /// Matches: Gossamer.ABI.Foreign.prim__run -export fn gossamer_run(handle_ptr: u64) void { +pub export fn gossamer_run(handle_ptr: u64) void { clearError(); const handle = ptrFromU64(handle_ptr) orelse return; @@ -703,7 +719,7 @@ pub export fn gossamer_hide(handle_ptr: u64) Result { /// Rejected when guard mode is locked or read_only. /// /// Matches: Gossamer.ABI.Foreign.prim__minimize -export fn gossamer_minimize(handle_ptr: u64) Result { +pub export fn gossamer_minimize(handle_ptr: u64) Result { clearError(); const handle = ptrFromU64(handle_ptr) orelse { setError("Null webview handle"); @@ -731,7 +747,7 @@ export fn gossamer_minimize(handle_ptr: u64) Result { /// Rejected when guard mode is locked or read_only. /// /// Matches: Gossamer.ABI.Foreign.prim__maximize -export fn gossamer_maximize(handle_ptr: u64) Result { +pub export fn gossamer_maximize(handle_ptr: u64) Result { clearError(); const handle = ptrFromU64(handle_ptr) orelse { setError("Null webview handle"); @@ -792,7 +808,7 @@ pub export fn gossamer_restore(handle_ptr: u64) Result { /// owner calls destroy(). /// /// Matches: Gossamer.ABI.Foreign.prim__requestClose -export fn gossamer_request_close(handle_ptr: u64) Result { +pub export fn gossamer_request_close(handle_ptr: u64) Result { clearError(); const handle = ptrFromU64(handle_ptr) orelse { setError("Null webview handle"); @@ -823,7 +839,7 @@ export fn gossamer_request_close(handle_ptr: u64) Result { /// Alternative to gossamer_run for cases where you need teardown only. /// /// Matches: Gossamer.ABI.Foreign.prim__destroy -export fn gossamer_destroy(handle_ptr: u64) void { +pub export fn gossamer_destroy(handle_ptr: u64) void { clearError(); const handle = ptrFromU64(handle_ptr) orelse return; cleanup(handle); @@ -877,7 +893,7 @@ const READONLY_OVERLAY_REMOVE = /// any → same: no-op (returns ok) /// /// Matches: Gossamer.ABI.Foreign.prim__guardSet -export fn gossamer_guard_set(handle_ptr: u64, mode: c_int) Result { +pub export fn gossamer_guard_set(handle_ptr: u64, mode: c_int) Result { clearError(); const handle = ptrFromU64(handle_ptr) orelse { setError("Null webview handle"); @@ -927,7 +943,7 @@ export fn gossamer_guard_set(handle_ptr: u64, mode: c_int) Result { /// Returns: 0=free, 1=locked, 2=read_only, or -1 on error. /// /// Matches: Gossamer.ABI.Foreign.prim__guardGet -export fn gossamer_guard_get(handle_ptr: u64) c_int { +pub export fn gossamer_guard_get(handle_ptr: u64) c_int { const handle = ptrFromU64(handle_ptr) orelse return -1; return @intFromEnum(handle.guard); } @@ -948,7 +964,7 @@ var next_window_id: u32 = 1; /// Register a handle in the global window registry. /// Returns the assigned window ID (>0), or 0 on failure. -export fn gossamer_registry_add(handle_ptr: u64) u32 { +pub export fn gossamer_registry_add(handle_ptr: u64) u32 { clearError(); const handle = ptrFromU64(handle_ptr) orelse { setError("Null handle"); @@ -974,7 +990,7 @@ export fn gossamer_registry_add(handle_ptr: u64) u32 { } /// Remove a handle from the registry. -export fn gossamer_registry_remove(handle_ptr: u64) void { +pub export fn gossamer_registry_remove(handle_ptr: u64) void { const handle = ptrFromU64(handle_ptr) orelse return; registry_mutex.lock(); @@ -990,7 +1006,7 @@ export fn gossamer_registry_remove(handle_ptr: u64) void { } /// Count of live registered windows. -export fn gossamer_registry_count() u32 { +pub export fn gossamer_registry_count() u32 { registry_mutex.lock(); defer registry_mutex.unlock(); @@ -1040,7 +1056,7 @@ var next_group_id: u32 = 1; /// Create a new window group with an optional label. /// Returns group ID (>0), or 0 on failure. -export fn gossamer_group_create(label: ?[*:0]const u8) u32 { +pub export fn gossamer_group_create(label: ?[*:0]const u8) u32 { clearError(); groups_mutex.lock(); defer groups_mutex.unlock(); @@ -1068,7 +1084,7 @@ export fn gossamer_group_create(label: ?[*:0]const u8) u32 { } /// Add a window (by ID) to a group. -export fn gossamer_group_add(group_id: u32, window_id: u32) Result { +pub export fn gossamer_group_add(group_id: u32, window_id: u32) Result { clearError(); groups_mutex.lock(); defer groups_mutex.unlock(); @@ -1100,7 +1116,7 @@ export fn gossamer_group_add(group_id: u32, window_id: u32) Result { } /// Remove a window from a group. -export fn gossamer_group_remove(group_id: u32, window_id: u32) Result { +pub export fn gossamer_group_remove(group_id: u32, window_id: u32) Result { clearError(); groups_mutex.lock(); defer groups_mutex.unlock(); @@ -1130,7 +1146,7 @@ export fn gossamer_group_remove(group_id: u32, window_id: u32) Result { } /// Destroy a group (does not destroy the windows, just the grouping). -export fn gossamer_group_destroy(group_id: u32) void { +pub export fn gossamer_group_destroy(group_id: u32) void { groups_mutex.lock(); defer groups_mutex.unlock(); @@ -1147,7 +1163,7 @@ export fn gossamer_group_destroy(group_id: u32) void { /// Apply an operation to all windows in a group. /// op: 0=minimize, 1=maximize, 2=restore, 3=show, 4=hide, 5=close -export fn gossamer_group_apply(group_id: u32, op: u32) Result { +pub export fn gossamer_group_apply(group_id: u32, op: u32) Result { clearError(); groups_mutex.lock(); const g = findGroup(group_id) orelse { @@ -1192,7 +1208,7 @@ fn findGroup(group_id: u32) ?*WindowGroup { //============================================================================== /// Raise the window to the front of the z-order (pull to front). -export fn gossamer_raise(handle_ptr: u64) Result { +pub export fn gossamer_raise(handle_ptr: u64) Result { clearError(); const handle = ptrFromU64(handle_ptr) orelse { setError("Null webview handle"); @@ -1213,7 +1229,7 @@ export fn gossamer_raise(handle_ptr: u64) Result { } /// Lower the window to the bottom of the z-order (push to back). -export fn gossamer_lower(handle_ptr: u64) Result { +pub export fn gossamer_lower(handle_ptr: u64) Result { clearError(); const handle = ptrFromU64(handle_ptr) orelse { setError("Null webview handle"); @@ -1256,7 +1272,7 @@ export fn gossamer_lower(handle_ptr: u64) Result { // Delivered via __gossamer_emit() in each target webview's JS context. /// Broadcast an event to all registered windows. -export fn gossamer_broadcast(event_name: [*:0]const u8, payload_json: [*:0]const u8) u32 { +pub export fn gossamer_broadcast(event_name: [*:0]const u8, payload_json: [*:0]const u8) u32 { registry_mutex.lock(); defer registry_mutex.unlock(); @@ -1277,7 +1293,7 @@ export fn gossamer_broadcast(event_name: [*:0]const u8, payload_json: [*:0]const } /// Send an event to a specific window by its registry ID. -export fn gossamer_send_to(target_id: u32, event_name: [*:0]const u8, payload_json: [*:0]const u8) Result { +pub export fn gossamer_send_to(target_id: u32, event_name: [*:0]const u8, payload_json: [*:0]const u8) Result { clearError(); registry_mutex.lock(); const handle = registryLookup(target_id); @@ -1319,7 +1335,7 @@ fn emitToHandle(handle: *GossamerHandle, event: []const u8, payload: []const u8) /// Auto-arrange all registered windows using the given strategy. /// strategy: 0=tile_h, 1=tile_v, 2=cascade, 3=grid -export fn gossamer_arrange(strategy: u32) Result { +pub export fn gossamer_arrange(strategy: u32) Result { clearError(); registry_mutex.lock(); @@ -1439,7 +1455,7 @@ fn getTransmuteSlot(handle: *GossamerHandle) ?usize { /// tui/cli → gui: Restore webview, reload last HTML /// /// Matches: Gossamer.ABI.Foreign.prim__transmute -export fn gossamer_transmute(handle_ptr: u64, mode: c_int) Result { +pub export fn gossamer_transmute(handle_ptr: u64, mode: c_int) Result { clearError(); const handle = ptrFromU64(handle_ptr) orelse { setError("Null handle"); @@ -1609,7 +1625,7 @@ export fn gossamer_transmute(handle_ptr: u64, mode: c_int) Result { } /// Get the current transmute mode for a window. -export fn gossamer_transmute_get(handle_ptr: u64) c_int { +pub export fn gossamer_transmute_get(handle_ptr: u64) c_int { const handle = ptrFromU64(handle_ptr) orelse return -1; const slot = getTransmuteSlot(handle) orelse return -1; return @intFromEnum(transmute_modes[slot]); @@ -1641,7 +1657,7 @@ var activity_levels: [MAX_WINDOWS]ActivityLevel = [_]ActivityLevel{.realtime} ** /// Set the activity level for a window. /// /// Matches: Gossamer.ABI.Foreign.prim__activitySet -export fn gossamer_activity_set(handle_ptr: u64, level: c_int) Result { +pub export fn gossamer_activity_set(handle_ptr: u64, level: c_int) Result { clearError(); const handle = ptrFromU64(handle_ptr) orelse { setError("Null handle"); @@ -1720,7 +1736,7 @@ export fn gossamer_activity_set(handle_ptr: u64, level: c_int) Result { } /// Get the current activity level for a window. -export fn gossamer_activity_get(handle_ptr: u64) c_int { +pub export fn gossamer_activity_get(handle_ptr: u64) c_int { const handle = ptrFromU64(handle_ptr) orelse return -1; const slot = getTransmuteSlot(handle) orelse return -1; return @intFromEnum(activity_levels[slot]); @@ -1739,7 +1755,7 @@ export fn gossamer_activity_get(handle_ptr: u64) c_int { // Toggle via gossamer_debug_toggle() or Ctrl+Shift+D from JS. /// Inject the debug drawer into the webview. -export fn gossamer_debug_open(handle_ptr: u64) Result { +pub export fn gossamer_debug_open(handle_ptr: u64) Result { clearError(); const handle = ptrFromU64(handle_ptr) orelse { setError("Null handle"); @@ -1794,7 +1810,7 @@ export fn gossamer_debug_open(handle_ptr: u64) Result { } /// Close the debug drawer. -export fn gossamer_debug_close(handle_ptr: u64) Result { +pub export fn gossamer_debug_close(handle_ptr: u64) Result { clearError(); const handle = ptrFromU64(handle_ptr) orelse { setError("Null handle"); @@ -1815,7 +1831,7 @@ export fn gossamer_debug_close(handle_ptr: u64) Result { } /// Toggle the debug drawer. -export fn gossamer_debug_toggle(handle_ptr: u64) Result { +pub export fn gossamer_debug_toggle(handle_ptr: u64) Result { clearError(); const handle = ptrFromU64(handle_ptr) orelse { setError("Null handle"); @@ -1871,7 +1887,7 @@ var groove_connections: [MAX_GROOVE_CONNECTIONS]GrooveConnection = [_]GrooveConn /// Establish a typed groove connection. /// type_: 0=hard, 1=soft /// ttl: for soft grooves, auto-disconnect after N seconds (0=manual) -export fn gossamer_groove_connect_typed(target_id: u32, groove_type: c_int, ttl: u32) Result { +pub export fn gossamer_groove_connect_typed(target_id: u32, groove_type: c_int, ttl: u32) Result { clearError(); const gt = std.meta.intToEnum(GrooveType, groove_type) catch { setError("Invalid groove type (0=hard, 1=soft)"); @@ -1895,7 +1911,7 @@ export fn gossamer_groove_connect_typed(target_id: u32, groove_type: c_int, ttl: } /// Disconnect a typed groove. For soft grooves, this wipes all shared state. -export fn gossamer_groove_disconnect_typed(target_id: u32) Result { +pub export fn gossamer_groove_disconnect_typed(target_id: u32) Result { clearError(); for (&groove_connections) |*gc| { if (gc.active and gc.target_id == target_id) { @@ -1913,7 +1929,7 @@ export fn gossamer_groove_disconnect_typed(target_id: u32) Result { /// Query groove type for a connected target. /// Returns: 0=hard, 1=soft, -1=not connected -export fn gossamer_groove_query_type(target_id: u32) c_int { +pub export fn gossamer_groove_query_type(target_id: u32) c_int { for (groove_connections) |gc| { if (gc.active and gc.target_id == target_id) { return @intFromEnum(gc.groove_type); @@ -1936,7 +1952,7 @@ export fn gossamer_groove_query_type(target_id: u32) c_int { /// Dock a groove service into the window frame. /// url: the HTTP endpoint to load in the dock panel (e.g. "http://localhost:6473/.well-known/groove") /// width: width of the dock panel in pixels (0 = default 300) -export fn gossamer_groove_dock(handle_ptr: u64, url: [*:0]const u8, width: u32) Result { +pub export fn gossamer_groove_dock(handle_ptr: u64, url: [*:0]const u8, width: u32) Result { clearError(); const handle = ptrFromU64(handle_ptr) orelse { setError("Null handle"); @@ -1960,7 +1976,7 @@ export fn gossamer_groove_dock(handle_ptr: u64, url: [*:0]const u8, width: u32) } /// Remove the docked groove panel. -export fn gossamer_groove_undock(handle_ptr: u64) Result { +pub export fn gossamer_groove_undock(handle_ptr: u64) Result { clearError(); const handle = ptrFromU64(handle_ptr) orelse { setError("Null handle"); @@ -1986,7 +2002,7 @@ export fn gossamer_groove_undock(handle_ptr: u64) Result { /// Returns a pointer to ChannelHandle, or 0 on failure. /// /// Matches: Gossamer.ABI.Foreign.prim__channelOpen -export fn gossamer_channel_open(handle_ptr: u64) u64 { +pub export fn gossamer_channel_open(handle_ptr: u64) u64 { clearError(); const handle = ptrFromU64(handle_ptr) orelse { setError("Null webview handle"); @@ -2134,7 +2150,7 @@ export fn gossamer_channel_open(handle_ptr: u64) u64 { /// the JSON-encoded payload and its return value is sent back to JS. /// /// Matches: Gossamer.ABI.Foreign.prim__channelBind -export fn gossamer_channel_bind( +pub export fn gossamer_channel_bind( channel_ptr: u64, name: [*:0]const u8, callback: ?*const fn ([*:0]const u8, ?*anyopaque) callconv(.c) [*:0]const u8, @@ -2199,7 +2215,7 @@ export fn gossamer_channel_bind( /// Maximum inflight async calls: 256 (MAX_INFLIGHT_ASYNC). /// /// Matches: Gossamer.ABI.Foreign.prim__channelBindAsync -export fn gossamer_channel_bind_async( +pub export fn gossamer_channel_bind_async( channel_ptr: u64, name: [*:0]const u8, callback: ?*const fn ([*:0]const u8, ?*anyopaque) callconv(.c) [*:0]const u8, @@ -2257,7 +2273,7 @@ export fn gossamer_channel_bind_async( /// Close the IPC channel. Consumes the channel handle. /// /// Matches: Gossamer.ABI.Foreign.prim__channelClose -export fn gossamer_channel_close(channel_ptr: u64) void { +pub export fn gossamer_channel_close(channel_ptr: u64) void { clearError(); const raw_ptr = @as(?*ChannelHandle, @ptrFromInt(@as(usize, @intCast(channel_ptr)))) orelse return; raw_ptr.open = false; @@ -2266,7 +2282,7 @@ export fn gossamer_channel_close(channel_ptr: u64) void { /// Query the number of currently inflight async IPC calls. /// Returns 0..256. Useful for diagnostics and back-pressure monitoring. -export fn gossamer_async_inflight_count() u32 { +pub export fn gossamer_async_inflight_count() u32 { return async_ipc.inflightCount(); } @@ -2445,7 +2461,7 @@ pub export fn gossamer_cap_resource_kind(token: u64) u32 { /// Revoke a capability token. Consumes it — future checks will fail. /// /// Matches: Gossamer.ABI.Foreign.prim__capRevoke -export fn gossamer_cap_revoke(token: u64) void { +pub export fn gossamer_cap_revoke(token: u64) void { clearError(); if (token == 0) return; @@ -2526,7 +2542,7 @@ threadlocal var error_buf: [ERROR_BUF_SIZE]u8 = undefined; /// function on this thread. Callers must copy if they need to keep it. /// /// Matches: Gossamer.ABI.Foreign.prim__lastError -export fn gossamer_last_error() ?[*:0]const u8 { +pub export fn gossamer_last_error() ?[*:0]const u8 { const err = last_error orelse return null; // Clear immediately — consume-on-read prevents stale errors last_error = null; @@ -2543,12 +2559,12 @@ export fn gossamer_last_error() ?[*:0]const u8 { //============================================================================== /// Get the library version string. -export fn gossamer_version() [*:0]const u8 { +pub export fn gossamer_version() [*:0]const u8 { return VERSION; } /// Get build information string. -export fn gossamer_build_info() [*:0]const u8 { +pub export fn gossamer_build_info() [*:0]const u8 { return BUILD_INFO; } @@ -2562,7 +2578,7 @@ export fn gossamer_build_info() [*:0]const u8 { /// Platform identifier string. /// Returns one of: "linux", "macos", "windows", "freebsd", "openbsd", /// "netbsd", "ios", or "unknown". -export fn gossamer_platform() [*:0]const u8 { +pub export fn gossamer_platform() [*:0]const u8 { return switch (builtin.os.tag) { .linux => "linux", .macos => "macos", @@ -2577,7 +2593,7 @@ export fn gossamer_platform() [*:0]const u8 { /// CPU architecture string. /// Returns one of: "x86_64", "aarch64", "riscv64", "wasm32", or "unknown". -export fn gossamer_arch() [*:0]const u8 { +pub export fn gossamer_arch() [*:0]const u8 { return switch (builtin.cpu.arch) { .x86_64 => "x86_64", .aarch64 => "aarch64", @@ -2589,7 +2605,7 @@ export fn gossamer_arch() [*:0]const u8 { /// Webview engine name for the current platform. /// Returns one of: "webkitgtk", "wkwebview", "webview2", or "none". -export fn gossamer_webview_engine() [*:0]const u8 { +pub export fn gossamer_webview_engine() [*:0]const u8 { return switch (builtin.os.tag) { .linux, .freebsd, .openbsd, .netbsd => "webkitgtk", .macos, .ios => "wkwebview", @@ -2600,7 +2616,7 @@ export fn gossamer_webview_engine() [*:0]const u8 { /// Whether the current platform is a desktop platform (not mobile/embedded). /// Returns 1 for desktop, 0 for mobile/other. -export fn gossamer_is_desktop() u8 { +pub export fn gossamer_is_desktop() u8 { return switch (builtin.os.tag) { .linux, .macos, .windows, .freebsd, .openbsd, .netbsd => 1, else => 0, @@ -2643,7 +2659,7 @@ const PLATFORM_JSON = blk: { "\",\"desktop\":" ++ desktop ++ "}"; }; -export fn gossamer_platform_json() [*:0]const u8 { +pub export fn gossamer_platform_json() [*:0]const u8 { return PLATFORM_JSON; } diff --git a/src/interface/ffi/src/ssg.zig b/src/interface/ffi/src/ssg.zig index 62b4bbe..c465492 100644 --- a/src/interface/ffi/src/ssg.zig +++ b/src/interface/ffi/src/ssg.zig @@ -31,7 +31,7 @@ const ByteList = std.ArrayList(u8); /// Returns null on failure (file not found, read error, OOM). /// /// FFI for: gossamer_ssg_read_file(path: String): String -export fn gossamer_ssg_read_file(path: [*:0]const u8) ?[*:0]u8 { +pub export fn gossamer_ssg_read_file(path: [*:0]const u8) ?[*:0]u8 { const path_slice = mem.span(path); const file = fs.cwd().openFile(path_slice, .{}) catch return null; @@ -56,7 +56,7 @@ export fn gossamer_ssg_read_file(path: [*:0]const u8) ?[*:0]u8 { /// Returns 0 on success, 1 on failure. /// /// FFI for: gossamer_ssg_write_file(path: String, content: String): I32 -export fn gossamer_ssg_write_file(path: [*:0]const u8, content: [*:0]const u8) c_int { +pub export fn gossamer_ssg_write_file(path: [*:0]const u8, content: [*:0]const u8) c_int { const path_slice = mem.span(path); const content_slice = mem.span(content); @@ -77,7 +77,7 @@ export fn gossamer_ssg_write_file(path: [*:0]const u8, content: [*:0]const u8) c /// Returns empty string if directory doesn't exist or has no matches. /// /// FFI for: gossamer_ssg_list_files(dir: String, extension: String): String -export fn gossamer_ssg_list_files(dir: [*:0]const u8, extension: [*:0]const u8) ?[*:0]u8 { +pub export fn gossamer_ssg_list_files(dir: [*:0]const u8, extension: [*:0]const u8) ?[*:0]u8 { const dir_slice = mem.span(dir); const ext_slice = mem.span(extension); @@ -122,7 +122,7 @@ export fn gossamer_ssg_list_files(dir: [*:0]const u8, extension: [*:0]const u8) /// Returns the front matter block (without delimiters), or empty string if none. /// /// FFI for: gossamer_ssg_parse_front_matter(content: String): String -export fn gossamer_ssg_parse_front_matter(content: [*:0]const u8) ?[*:0]u8 { +pub export fn gossamer_ssg_parse_front_matter(content: [*:0]const u8) ?[*:0]u8 { const slice = mem.span(content); // Front matter must start with "---\n" at the beginning. @@ -152,7 +152,7 @@ export fn gossamer_ssg_parse_front_matter(content: [*:0]const u8) ?[*:0]u8 { /// If no front matter, returns the entire content. /// /// FFI for: gossamer_ssg_parse_body(content: String): String -export fn gossamer_ssg_parse_body(content: [*:0]const u8) ?[*:0]u8 { +pub export fn gossamer_ssg_parse_body(content: [*:0]const u8) ?[*:0]u8 { const slice = mem.span(content); // No front matter — return entire content. @@ -196,7 +196,7 @@ export fn gossamer_ssg_parse_body(content: [*:0]const u8) ?[*:0]u8 { /// Lines starting with ``` are treated as
 blocks
 ///
 /// FFI for: gossamer_ssg_md_to_html(markdown: String): String
-export fn gossamer_ssg_md_to_html(markdown: [*:0]const u8) ?[*:0]u8 {
+pub export fn gossamer_ssg_md_to_html(markdown: [*:0]const u8) ?[*:0]u8 {
     const input = mem.span(markdown);
     var out: ByteList = .empty;
     defer out.deinit(alloc);
@@ -384,7 +384,7 @@ fn appendInlineMarkdown(out: *ByteList, text: []const u8) !void {
 /// newline-separated "key=value" string.
 ///
 /// FFI for: gossamer_ssg_template_substitute(template: String, vars: String): String
-export fn gossamer_ssg_template_substitute(
+pub export fn gossamer_ssg_template_substitute(
     template: [*:0]const u8,
     vars: [*:0]const u8,
 ) ?[*:0]u8 {
@@ -453,7 +453,7 @@ export fn gossamer_ssg_template_substitute(
 /// Returns 0 on success, 1 on failure.
 ///
 /// FFI for: gossamer_ssg_build_site(contentDir: String, templateFile: String, outDir: String): I32
-export fn gossamer_ssg_build_site(
+pub export fn gossamer_ssg_build_site(
     content_dir: [*:0]const u8,
     template_file: [*:0]const u8,
     out_dir: [*:0]const u8,
diff --git a/src/interface/ffi/src/tray.zig b/src/interface/ffi/src/tray.zig
index 80ad80e..cd31a18 100644
--- a/src/interface/ffi/src/tray.zig
+++ b/src/interface/ffi/src/tray.zig
@@ -64,7 +64,7 @@ var global_tray: ?*TrayHandle = null;
 /// Menu items are added via gossamer_tray_add_item.
 ///
 /// Matches: Gossamer.ABI.Foreign.prim__trayCreate
-export fn gossamer_tray_create(tooltip: [*:0]const u8) u64 {
+pub export fn gossamer_tray_create(tooltip: [*:0]const u8) u64 {
     const allocator = std.heap.c_allocator;
 
     // Ensure GTK is initialised
@@ -126,7 +126,7 @@ export fn gossamer_tray_create(tooltip: [*:0]const u8) u64 {
 }
 
 /// Destroy the system tray icon. CONSUMES the handle.
-export fn gossamer_tray_destroy(handle_ptr: u64) void {
+pub export fn gossamer_tray_destroy(handle_ptr: u64) void {
     const handle = trayFromU64(handle_ptr) orelse return;
 
     c.gtk_status_icon_set_visible(handle.status_icon, 0);
@@ -149,7 +149,7 @@ export fn gossamer_tray_destroy(handle_ptr: u64) void {
 /// Returns: 0 on success, non-zero on failure.
 ///
 /// When the item is clicked, the menu_callback is invoked with item_id.
-export fn gossamer_tray_add_item(handle_ptr: u64, label: [*:0]const u8, item_id: u32) u32 {
+pub export fn gossamer_tray_add_item(handle_ptr: u64, label: [*:0]const u8, item_id: u32) u32 {
     const handle = trayFromU64(handle_ptr) orelse return 1;
 
     if (handle.menu_item_count >= MAX_MENU_ITEMS) return 2;
@@ -174,7 +174,7 @@ export fn gossamer_tray_add_item(handle_ptr: u64, label: [*:0]const u8, item_id:
 }
 
 /// Add a separator to the tray context menu.
-export fn gossamer_tray_add_separator(handle_ptr: u64) u32 {
+pub export fn gossamer_tray_add_separator(handle_ptr: u64) u32 {
     const handle = trayFromU64(handle_ptr) orelse return 1;
 
     const sep = c.gtk_separator_menu_item_new() orelse return 2;
@@ -187,7 +187,7 @@ export fn gossamer_tray_add_separator(handle_ptr: u64) u32 {
 /// Set the callback function for menu item activation.
 ///
 /// The callback receives the item_id (u32) of the activated menu item.
-export fn gossamer_tray_set_callback(handle_ptr: u64, callback: ?*const fn (u32) callconv(.c) void) u32 {
+pub export fn gossamer_tray_set_callback(handle_ptr: u64, callback: ?*const fn (u32) callconv(.c) void) u32 {
     const handle = trayFromU64(handle_ptr) orelse return 1;
     handle.menu_callback = callback;
     return 0;
@@ -197,28 +197,28 @@ export fn gossamer_tray_set_callback(handle_ptr: u64, callback: ?*const fn (u32)
 ///
 /// Standard names: "dialog-information", "dialog-warning", "dialog-error",
 /// "network-server", "network-offline", "preferences-system-network"
-export fn gossamer_tray_set_icon(handle_ptr: u64, icon_name: [*:0]const u8) u32 {
+pub export fn gossamer_tray_set_icon(handle_ptr: u64, icon_name: [*:0]const u8) u32 {
     const handle = trayFromU64(handle_ptr) orelse return 1;
     c.gtk_status_icon_set_from_icon_name(handle.status_icon, icon_name);
     return 0;
 }
 
 /// Set the tray icon from a file path.
-export fn gossamer_tray_set_icon_from_file(handle_ptr: u64, path: [*:0]const u8) u32 {
+pub export fn gossamer_tray_set_icon_from_file(handle_ptr: u64, path: [*:0]const u8) u32 {
     const handle = trayFromU64(handle_ptr) orelse return 1;
     c.gtk_status_icon_set_from_file(handle.status_icon, path);
     return 0;
 }
 
 /// Update the tray tooltip text.
-export fn gossamer_tray_set_tooltip(handle_ptr: u64, tooltip: [*:0]const u8) u32 {
+pub export fn gossamer_tray_set_tooltip(handle_ptr: u64, tooltip: [*:0]const u8) u32 {
     const handle = trayFromU64(handle_ptr) orelse return 1;
     c.gtk_status_icon_set_tooltip_text(handle.status_icon, tooltip);
     return 0;
 }
 
 /// Show or hide the tray icon.
-export fn gossamer_tray_set_visible(handle_ptr: u64, visible: u32) u32 {
+pub export fn gossamer_tray_set_visible(handle_ptr: u64, visible: u32) u32 {
     const handle = trayFromU64(handle_ptr) orelse return 1;
     c.gtk_status_icon_set_visible(handle.status_icon, @intCast(visible));
     handle.visible = visible != 0;
@@ -227,7 +227,7 @@ export fn gossamer_tray_set_visible(handle_ptr: u64, visible: u32) u32 {
 
 /// Attach a main window handle to the tray so left-click can toggle it.
 /// Passing 0 detaches the current window.
-export fn gossamer_tray_set_window(handle_ptr: u64, window_ptr: u64) u32 {
+pub export fn gossamer_tray_set_window(handle_ptr: u64, window_ptr: u64) u32 {
     const handle = trayFromU64(handle_ptr) orelse return 1;
     if (window_ptr == 0) {
         handle.window = null;
@@ -242,7 +242,7 @@ export fn gossamer_tray_set_window(handle_ptr: u64, window_ptr: u64) u32 {
 
 /// Clear any attached main window from the singleton tray.
 /// Used when the main window is being destroyed.
-export fn gossamer_tray_clear_window() void {
+pub export fn gossamer_tray_clear_window() void {
     if (global_tray) |tray| {
         tray.window = null;
     }
@@ -259,7 +259,7 @@ export fn gossamer_tray_clear_window() void {
 ///
 /// Args: title (cstr), body (cstr)
 /// Returns: Result (0=ok, 1=error)
-export fn gossamer_notify(title: [*:0]const u8, body: [*:0]const u8) u32 {
+pub export fn gossamer_notify(title: [*:0]const u8, body: [*:0]const u8) u32 {
     // Use notify-send as a simple, reliable approach that works on all
     // Linux desktops including KDE Plasma on Wayland via Fedora Atomic.
     // This avoids linking libnotify and works with the notification portal.
diff --git a/src/interface/ffi/test/integration_test.zig b/src/interface/ffi/test/integration_test.zig
index bd867b1..e899b81 100644
--- a/src/interface/ffi/test/integration_test.zig
+++ b/src/interface/ffi/test/integration_test.zig
@@ -19,15 +19,25 @@ const std = @import("std");
 const testing = std.testing;
 
 // Primary module — all main.zig exports accessible via gossamer.*
-const gossamer = @import("../src/main.zig");
+// Imported as the named "gossamer" module (wired in build.zig): Zig 0.15
+// forbids a test/-rooted module from importing ../src/*.zig directly.
+const gossamer = @import("gossamer");
 const Result = gossamer.Result;
 
-// Sub-module imports — functions exported from these modules are reachable
-// through the shared library; for test builds we import directly.
-const csp_mod = @import("../src/csp.zig");
-const fs_mod = @import("../src/filesystem.zig");
-const ssg_mod = @import("../src/ssg.zig");
-const groove_mod = @import("../src/groove.zig");
+// Sub-modules reached through the gossamer module's re-exports (main.zig).
+const csp_mod = gossamer.csp;
+const fs_mod = gossamer.filesystem;
+const ssg_mod = gossamer.ssg;
+const groove_mod = gossamer.groove;
+
+// Test helper: a byte-zeroed GossamerHandle (invalid — null internal pointer).
+// Zig 0.15's std.mem.zeroes rejects the struct's non-nullable pointer field, so
+// we zero the raw bytes instead — same intent: a handle that must be rejected.
+fn zeroedHandle() gossamer.GossamerHandle {
+    var h: gossamer.GossamerHandle = undefined;
+    @memset(std.mem.asBytes(&h), 0);
+    return h;
+}
 
 //==============================================================================
 // Result Code Alignment Tests
@@ -48,10 +58,12 @@ test "result codes match Idris2 ABI (Types.idr resultToInt)" {
     try testing.expectEqual(@as(c_int, 10), @intFromEnum(Result.capability_denied));
 }
 
-test "result enum has exactly 11 variants" {
-    // Ensures no accidental additions/removals without updating Types.idr
+test "result enum has exactly 12 variants" {
+    // Ensures no accidental additions/removals without updating Types.idr.
+    // 12 variants (0..=11): ok..guard_locked — matches Types.idr resultToInt,
+    // verified with idris2 --typecheck of the ABI package.
     const fields = @typeInfo(Result).@"enum".fields;
-    try testing.expectEqual(@as(usize, 11), fields.len);
+    try testing.expectEqual(@as(usize, 12), fields.len);
 }
 
 //==============================================================================
@@ -179,7 +191,7 @@ test "cap_grant returns non-zero token for valid resource kinds" {
 
 test "cap_grant rejects invalid resource kind" {
     // Resource kinds > 5 are invalid (Types.idr has 6 constructors: 0-5)
-    const CAP_ERROR = @import("../src/main.zig").CAP_ERROR;
+    const CAP_ERROR = gossamer.CAP_ERROR;
     try testing.expectEqual(CAP_ERROR, gossamer.gossamer_cap_grant(6));
     try testing.expectEqual(CAP_ERROR, gossamer.gossamer_cap_grant(255));
 }
@@ -254,22 +266,24 @@ test "last_error contains meaningful message" {
 //==============================================================================
 
 test "tray_create returns 0 (not yet implemented)" {
-    const tray = gossamer.gossamer_tray_create("Test");
+    const tray = gossamer.tray.gossamer_tray_create("Test");
     try testing.expectEqual(@as(u64, 0), tray);
 }
 
-test "notify returns error (not yet implemented)" {
-    const result = gossamer.gossamer_notify("Title", "Body");
-    try testing.expectEqual(Result.@"error", result);
+test "notify returns error when no notification daemon is reachable" {
+    // gossamer_notify returns u32 (0 = ok, 1 = error). In a headless test
+    // environment (no notify-send / notification portal) it returns error.
+    const result = gossamer.tray.gossamer_notify("Title", "Body");
+    try testing.expectEqual(@as(u32, 1), result); // 1 = Result.@"error"
 }
 
 test "dialog_open returns 0 (not yet implemented)" {
-    const dialog = gossamer.gossamer_dialog_open("Open", "*");
+    const dialog = gossamer.dialog.gossamer_dialog_open("Open", "*");
     try testing.expectEqual(@as(u64, 0), dialog);
 }
 
 test "dialog_save returns 0 (not yet implemented)" {
-    const dialog = gossamer.gossamer_dialog_save("Save", "*");
+    const dialog = gossamer.dialog.gossamer_dialog_save("Save", "*");
     try testing.expectEqual(@as(u64, 0), dialog);
 }
 
@@ -278,18 +292,18 @@ test "dialog_save returns 0 (not yet implemented)" {
 //==============================================================================
 
 test "clipboard_write rejects null pointer" {
-    const result = gossamer.gossamer_clipboard_write(null);
+    const result = gossamer.clipboard.gossamer_clipboard_write(null);
     try testing.expectEqual(@as(c_int, @intFromEnum(Result.invalid_param)), result);
 }
 
 test "clipboard_read rejects null buffer" {
-    const result = gossamer.gossamer_clipboard_read(null, 256);
+    const result = gossamer.clipboard.gossamer_clipboard_read(null, 256);
     try testing.expectEqual(@as(c_int, -1), result);
 }
 
 test "clipboard_read rejects zero length" {
     var buf: [1]u8 = undefined;
-    const result = gossamer.gossamer_clipboard_read(&buf, 0);
+    const result = gossamer.clipboard.gossamer_clipboard_read(&buf, 0);
     try testing.expectEqual(@as(c_int, -1), result);
 }
 
@@ -298,7 +312,7 @@ test "clipboard_read with valid buffer and no display returns -1 or 0+" {
     // With a display, we get 0 (empty clipboard) or >0 (clipboard has text).
     // Either way it must not crash.
     var buf: [256]u8 = undefined;
-    const result = gossamer.gossamer_clipboard_read(&buf, buf.len);
+    const result = gossamer.clipboard.gossamer_clipboard_read(&buf, buf.len);
     try testing.expect(result >= -1);
 }
 
@@ -306,7 +320,7 @@ test "clipboard_write with valid text and no display returns error or ok" {
     // In headless CI, GTK init will fail so we get error.
     // With a display, we get ok.
     // Either way it must not crash.
-    const result = gossamer.gossamer_clipboard_write("gossamer clipboard test");
+    const result = gossamer.clipboard.gossamer_clipboard_write("gossamer clipboard test");
     try testing.expect(result == @intFromEnum(Result.ok) or result == @intFromEnum(Result.@"error"));
 }
 
@@ -349,7 +363,7 @@ test "guard_set invalid mode on fake handle returns invalid_param" {
     // touching platform state, so invalid_param is returned without a crash.
     // We pass a raw stack address; the handle check calls ptrFromU64 which only
     // verifies non-zero, so we must supply a real initialized struct.
-    var fake_handle = std.mem.zeroes(gossamer.GossamerHandle);
+    var fake_handle = zeroedHandle();
     fake_handle.initialized = true;
     fake_handle.closed = false;
     fake_handle.guard = .free;
@@ -386,7 +400,7 @@ test "registry_count returns a value in 0..64" {
 
 test "registry_count monotonicity: add increases count" {
     // Create a fake initialized handle to add to the registry
-    var fake_handle = std.mem.zeroes(gossamer.GossamerHandle);
+    var fake_handle = zeroedHandle();
     fake_handle.initialized = true;
     fake_handle.closed = false;
     fake_handle.allocator = std.heap.c_allocator;
@@ -585,7 +599,7 @@ test "transmute mode ordinals match ABI spec" {
 
 test "transmute with invalid mode on fake handle returns invalid_param" {
     // Supply a real initialized handle struct to trigger the mode validation path
-    var fake_handle = std.mem.zeroes(gossamer.GossamerHandle);
+    var fake_handle = zeroedHandle();
     fake_handle.initialized = true;
     fake_handle.closed = false;
     fake_handle.allocator = std.heap.c_allocator;
@@ -623,7 +637,7 @@ test "activity level ordinals match ABI spec" {
 }
 
 test "activity_set with invalid level on fake handle returns invalid_param" {
-    var fake_handle = std.mem.zeroes(gossamer.GossamerHandle);
+    var fake_handle = zeroedHandle();
     fake_handle.initialized = true;
     fake_handle.closed = false;
     fake_handle.allocator = std.heap.c_allocator;
@@ -1508,7 +1522,7 @@ test "boundary: registry count never exceeds 64 after multiple add/remove cycles
     // Register and deregister multiple fake handles in a loop
     var handles: [8]gossamer.GossamerHandle = undefined;
     for (&handles) |*h| {
-        h.* = std.mem.zeroes(gossamer.GossamerHandle);
+        h.* = zeroedHandle();
         h.initialized = true;
         h.closed = false;
         h.allocator = std.heap.c_allocator;
@@ -1536,7 +1550,7 @@ test "boundary: registry count never exceeds 64 after multiple add/remove cycles
 // Plugin System Tests (plugin.zig)
 //==============================================================================
 
-const plugin_mod = @import("../src/plugin.zig");
+const plugin_mod = gossamer.plugin;
 
 test "plugin_load with null handle returns 0" {
     plugin_mod.resetForTesting();
@@ -1555,7 +1569,7 @@ test "plugin_load with nonexistent library path returns 0" {
     plugin_mod.resetForTesting();
 
     // Create a stack-allocated fake handle with initialized=true, closed=false
-    var fake_handle = std.mem.zeroes(gossamer.GossamerHandle);
+    var fake_handle = zeroedHandle();
     fake_handle.initialized = true;
     fake_handle.closed = false;
     fake_handle.allocator = std.heap.c_allocator;