From 528712f6b56d76dee125d5c041e358c044d6884d Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Fri, 3 Apr 2026 14:26:20 +0100 Subject: [PATCH 1/4] feat(api): add cross-platform window controls and create_ex --- bindings/rust/build.rs | 13 + bindings/rust/src/lib.rs | 85 ++++- cli/src/main.zig | 134 +++++++- docs/developer/ABI-FFI-README.adoc | 5 +- docs/gossamer-conf-reference.adoc | 36 +++ src/core/Shell.eph | 58 +++- src/core/Tray.eph | 38 ++- src/interface/abi/Foreign.idr | 111 ++++++- src/interface/abi/Layout.idr | 20 +- src/interface/abi/Types.idr | 53 +++- src/interface/ffi/src/csp.zig | 17 +- src/interface/ffi/src/main.zig | 326 ++++++++++++++++++-- src/interface/ffi/src/tray.zig | 42 ++- src/interface/ffi/src/webview_android.zig | 40 +++ src/interface/ffi/src/webview_cocoa.zig | 114 ++++++- src/interface/ffi/src/webview_gtk.zig | 75 ++++- src/interface/ffi/src/webview_ios.zig | 47 ++- src/interface/ffi/src/webview_win32.zig | 104 ++++++- src/interface/ffi/test/display_test.zig | 82 ++++- src/interface/ffi/test/integration_test.zig | 34 +- 20 files changed, 1346 insertions(+), 88 deletions(-) create mode 100644 bindings/rust/build.rs diff --git a/bindings/rust/build.rs b/bindings/rust/build.rs new file mode 100644 index 0000000..e4889d5 --- /dev/null +++ b/bindings/rust/build.rs @@ -0,0 +1,13 @@ +fn main() { + let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").unwrap(); + let lib_path = std::path::Path::new(&manifest_dir).join("../../src/interface/ffi/zig-out/lib"); + + // Tell cargo to look for libgossamer in the ffi build output directory + println!("cargo:rustc-link-search=native={}", lib_path.display()); + println!("cargo:rustc-link-lib=gossamer"); + + // Also link system dependencies required by WebKitGTK (via gossamer) + println!("cargo:rustc-link-lib=gtk-3"); + println!("cargo:rustc-link-lib=gdk-3"); + println!("cargo:rustc-link-lib=webkit2gtk-4.1"); +} diff --git a/bindings/rust/src/lib.rs b/bindings/rust/src/lib.rs index b5df087..7153691 100644 --- a/bindings/rust/src/lib.rs +++ b/bindings/rust/src/lib.rs @@ -41,6 +41,7 @@ use std::ffi::{c_char, c_int, c_void, CStr, CString}; // ============================================================================= #[link(name = "gossamer")] +#[allow(dead_code)] extern "C" { fn gossamer_create( title: *const c_char, @@ -51,11 +52,31 @@ extern "C" { fullscreen: u8, ) -> u64; + fn gossamer_create_ex( + title: *const c_char, + width: u32, + height: u32, + min_width: u32, + min_height: u32, + max_width: u32, + max_height: u32, + resizable: u8, + decorations: u8, + fullscreen: u8, + visible: u8, + ) -> u64; + fn gossamer_load_html(handle: u64, html: *const c_char) -> c_int; fn gossamer_navigate(handle: u64, url: *const c_char) -> c_int; fn gossamer_eval(handle: u64, js: *const c_char) -> c_int; fn gossamer_set_title(handle: u64, title: *const c_char) -> c_int; fn gossamer_resize(handle: u64, width: u32, height: u32) -> c_int; + fn gossamer_show(handle: u64) -> c_int; + fn gossamer_hide(handle: u64) -> c_int; + fn gossamer_minimize(handle: u64) -> c_int; + fn gossamer_maximize(handle: u64) -> c_int; + fn gossamer_restore(handle: u64) -> c_int; + fn gossamer_request_close(handle: u64) -> c_int; fn gossamer_run(handle: u64); fn gossamer_destroy(handle: u64); @@ -82,7 +103,9 @@ extern "C" { fn gossamer_version() -> *const c_char; fn gossamer_last_error() -> *const c_char; + #[allow(dead_code)] fn gossamer_tray_create(tooltip: *const c_char) -> u64; + #[allow(dead_code)] fn gossamer_tray_add_menu_item( tray: u64, label: *const c_char, @@ -276,9 +299,14 @@ pub struct WindowConfig { pub title: String, pub width: u32, pub height: u32, + pub min_width: Option, + pub min_height: Option, + pub max_width: Option, + pub max_height: Option, pub resizable: bool, pub decorations: bool, pub fullscreen: bool, + pub visible: bool, } impl Default for WindowConfig { @@ -287,9 +315,14 @@ impl Default for WindowConfig { title: "Gossamer App".to_string(), width: 800, height: 600, + min_width: None, + min_height: None, + max_width: None, + max_height: None, resizable: true, decorations: true, fullscreen: false, + visible: true, } } } @@ -323,6 +356,14 @@ impl App { }) } + /// Get the raw Gossamer window handle. + /// + /// This is useful for compatibility layers that need to call lower-level + /// FFI helpers such as event emission or tray/window controls. + pub fn raw_handle(&self) -> u64 { + self.handle + } + /// Create a new Gossamer application with full window configuration. pub fn with_config(config: WindowConfig) -> Result { let title = @@ -330,13 +371,18 @@ impl App { // SAFETY: FFI call to create a webview window let handle = unsafe { - gossamer_create( + gossamer_create_ex( title.as_ptr(), config.width, config.height, + config.min_width.unwrap_or(0), + config.min_height.unwrap_or(0), + config.max_width.unwrap_or(0), + config.max_height.unwrap_or(0), config.resizable as u8, config.decorations as u8, config.fullscreen as u8, + config.visible as u8, ) }; @@ -489,6 +535,43 @@ impl App { check_result(unsafe { gossamer_resize(self.handle, width, height) }) } + /// Show the window. + pub fn show(&self) -> Result<(), Error> { + // SAFETY: handle is valid + check_result(unsafe { gossamer_show(self.handle) }) + } + + /// Hide the window. + pub fn hide(&self) -> Result<(), Error> { + // SAFETY: handle is valid + check_result(unsafe { gossamer_hide(self.handle) }) + } + + /// Minimize the window. + pub fn minimize(&self) -> Result<(), Error> { + // SAFETY: handle is valid + check_result(unsafe { gossamer_minimize(self.handle) }) + } + + /// Maximize the window. + pub fn maximize(&self) -> Result<(), Error> { + // SAFETY: handle is valid + check_result(unsafe { gossamer_maximize(self.handle) }) + } + + /// Restore the window from minimized or maximized state. + pub fn restore(&self) -> Result<(), Error> { + // SAFETY: handle is valid + check_result(unsafe { gossamer_restore(self.handle) }) + } + + /// Request that the window close, while keeping the app handle alive + /// until normal teardown runs. + pub fn request_close(&self) -> Result<(), Error> { + // SAFETY: handle is valid + check_result(unsafe { gossamer_request_close(self.handle) }) + } + /// Apply a Content-Security-Policy to the webview. /// /// Injects a `` tag. diff --git a/cli/src/main.zig b/cli/src/main.zig index 5932f2b..fcda686 100644 --- a/cli/src/main.zig +++ b/cli/src/main.zig @@ -46,6 +46,20 @@ extern fn gossamer_create( fullscreen: u8, ) ?*anyopaque; +extern fn gossamer_create_ex( + title: [*:0]const u8, + width: u32, + height: u32, + min_width: u32, + min_height: u32, + max_width: u32, + max_height: u32, + resizable: u8, + decorations: u8, + fullscreen: u8, + visible: u8, +) ?*anyopaque; + extern fn gossamer_navigate(handle: u64, url: [*:0]const u8) c_int; extern fn gossamer_load_html(handle: u64, html: [*:0]const u8) c_int; extern fn gossamer_channel_open(handle: u64) u64; @@ -60,6 +74,18 @@ extern fn gossamer_last_error() ?[*:0]const u8; extern fn gossamer_set_title(handle: u64, title: [*:0]const u8) c_int; extern fn gossamer_set_csp(handle: u64, csp: [*:0]const u8) c_int; extern fn gossamer_emit(handle: u64, event_name: [*:0]const u8, payload_json: [*:0]const u8) c_int; +extern fn gossamer_groove_discover() u32; +extern fn gossamer_groove_status(target_id: u32) u32; + +const AppMode = enum { + gui, + panel_host, + headless, + cli, + tui, +}; + +const PANLL_GROOVE_ID: u32 = 4; //============================================================================== // Shell-exec IPC handler for OPSM runtime commands @@ -189,9 +215,15 @@ const Config = struct { title: []const u8 = "Gossamer App", width: u32 = 800, height: u32 = 600, + min_width: ?u32 = null, + min_height: ?u32 = null, + max_width: ?u32 = null, + max_height: ?u32 = null, resizable: bool = true, fullscreen: bool = false, decorations: bool = true, + mode: AppMode = .gui, + visible: bool = true, /// Content-Security-Policy directive from gossamer.conf.json security.csp. /// When non-null, injected as a CSP tag after the IPC bridge is set up. csp: ?[]const u8 = null, @@ -213,9 +245,15 @@ fn parseConfig(json_str: []const u8) Config { config.title = extractStringField(json_str, "title") orelse config.title; config.width = extractIntField(json_str, "width") orelse config.width; config.height = extractIntField(json_str, "height") orelse config.height; + config.min_width = extractIntField(json_str, "minWidth"); + config.min_height = extractIntField(json_str, "minHeight"); + config.max_width = extractIntField(json_str, "maxWidth"); + config.max_height = extractIntField(json_str, "maxHeight"); config.resizable = extractBoolField(json_str, "resizable") orelse config.resizable; config.fullscreen = extractBoolField(json_str, "fullscreen") orelse config.fullscreen; config.decorations = extractBoolField(json_str, "decorations") orelse config.decorations; + config.visible = extractBoolField(json_str, "visible") orelse config.visible; + config.mode = parseAppMode(extractStringField(json_str, "mode")) orelse config.mode; // Parse security.csp — the field is "csp":"..." inside the "security" block. // extractStringField finds the first match, which works since "csp" only appears in security. config.csp = extractStringField(json_str, "csp"); @@ -265,6 +303,30 @@ fn extractBoolField(json: []const u8, key: []const u8) ?bool { return null; } +fn parseAppMode(value: ?[]const u8) ?AppMode { + const mode = value orelse return null; + if (std.mem.eql(u8, mode, "gui")) return .gui; + if (std.mem.eql(u8, mode, "panel-host") or std.mem.eql(u8, mode, "panel_host")) return .panel_host; + if (std.mem.eql(u8, mode, "headless")) return .headless; + if (std.mem.eql(u8, mode, "cli")) return .cli; + if (std.mem.eql(u8, mode, "tui")) return .tui; + return null; +} + +fn modeName(mode: AppMode) []const u8 { + return switch (mode) { + .gui => "gui", + .panel_host => "panel-host", + .headless => "headless", + .cli => "cli", + .tui => "tui", + }; +} + +fn modeSupportsWindow(mode: AppMode) bool { + return mode == .gui or mode == .panel_host; +} + //============================================================================== // Shell command execution //============================================================================== @@ -290,6 +352,16 @@ fn runShellCommandBackground(allocator: std.mem.Allocator, command: []const u8) return child; } +fn announcePanelHostMode() void { + const discovered = gossamer_groove_discover(); + const panll_status = gossamer_groove_status(PANLL_GROOVE_ID); + if (panll_status == 2 or panll_status == 3) { + out(" \x1b[32m✓\x1b[0m PanLL groove detected ({d} groove(s) available)\n", .{discovered}); + } else { + out(" \x1b[33m!\x1b[0m PanLL not detected ({d} groove(s) available)\n", .{discovered}); + } +} + //============================================================================== // Commands //============================================================================== @@ -298,6 +370,15 @@ fn cmdDev(allocator: std.mem.Allocator, config: Config, config_data: []const u8) out("\n \x1b[36mGossamer\x1b[0m v{s}\n", .{std.mem.span(gossamer_version())}); out(" \x1b[2m{s}\x1b[0m\n\n", .{config.product_name}); + if (!modeSupportsWindow(config.mode)) { + switch (config.mode) { + .headless => out(" \x1b[33m!\x1b[0m Headless mode selected; skipping webview launch.\n\n", .{}), + .cli, .tui => out(" \x1b[33m!\x1b[0m Terminal mode is not implemented yet; skipping webview launch.\n\n", .{}), + else => {}, + } + return; + } + var dev_proc: ?std.process.Child = null; if (config.before_dev_command) |cmd| { out(" \x1b[33m→\x1b[0m Running: {s}\n", .{cmd}); @@ -314,13 +395,22 @@ fn cmdDev(allocator: std.mem.Allocator, config: Config, config_data: []const u8) const title_z = try allocator.dupeZ(u8, config.title); defer allocator.free(title_z); - const handle_ptr = gossamer_create( + if (config.mode == .panel_host) { + announcePanelHostMode(); + } + + const handle_ptr = gossamer_create_ex( title_z, config.width, config.height, + config.min_width orelse 0, + config.min_height orelse 0, + config.max_width orelse 0, + config.max_height orelse 0, if (config.resizable) 1 else 0, if (config.decorations) 1 else 0, if (config.fullscreen) 1 else 0, + if (config.visible) 1 else 0, ); if (handle_ptr == null) { @@ -408,6 +498,15 @@ fn cmdBuild(allocator: std.mem.Allocator, config: Config) !void { fn cmdRun(allocator: std.mem.Allocator, config: Config) !void { out("\n \x1b[36mGossamer\x1b[0m run\n", .{}); + if (!modeSupportsWindow(config.mode)) { + switch (config.mode) { + .headless => out(" \x1b[33m!\x1b[0m Headless mode selected; skipping webview launch.\n\n", .{}), + .cli, .tui => out(" \x1b[33m!\x1b[0m Terminal mode is not implemented yet; skipping webview launch.\n\n", .{}), + else => {}, + } + return; + } + var path_buf: [4096]u8 = undefined; const index_path = std.fmt.bufPrint(&path_buf, "{s}/index.html", .{config.frontend_dist}) catch return error.PathTooLong; @@ -420,11 +519,22 @@ fn cmdRun(allocator: std.mem.Allocator, config: Config) !void { const title_z = try allocator.dupeZ(u8, config.title); defer allocator.free(title_z); - const handle_ptr = gossamer_create( - title_z, config.width, config.height, + if (config.mode == .panel_host) { + announcePanelHostMode(); + } + + const handle_ptr = gossamer_create_ex( + title_z, + config.width, + config.height, + config.min_width orelse 0, + config.min_height orelse 0, + config.max_width orelse 0, + config.max_height orelse 0, if (config.resizable) 1 else 0, if (config.decorations) 1 else 0, if (config.fullscreen) 1 else 0, + if (config.visible) 1 else 0, ); if (handle_ptr == null) return error.WebviewCreateFailed; const handle = @intFromPtr(handle_ptr.?); @@ -453,7 +563,19 @@ fn cmdInfo(config: Config) void { out(" Identifier: {s}\n", .{config.identifier}); out(" Frontend: {s}\n", .{config.frontend_dist}); out(" Dev URL: {s}\n", .{config.dev_url}); + out(" Mode: {s}\n", .{modeName(config.mode)}); out(" Window: {d}x{d}\n", .{ config.width, config.height }); + if (config.min_width != null or config.min_height != null) { + out(" Min size: {d}x{d}\n", .{ config.min_width orelse 0, config.min_height orelse 0 }); + } else { + out(" Min size: unconstrained\n", .{}); + } + if (config.max_width != null or config.max_height != null) { + out(" Max size: {d}x{d}\n", .{ config.max_width orelse 0, config.max_height orelse 0 }); + } else { + out(" Max size: unconstrained\n", .{}); + } + out(" Visible: {s}\n", .{if (config.visible) "true" else "false"}); out(" Gossamer: {s}\n", .{std.mem.span(gossamer_version())}); out(" Build: {s}\n\n", .{std.mem.span(gossamer_build_info())}); } @@ -584,9 +706,15 @@ fn cmdInit() !void { \\ "title": "My Gossamer App", \\ "width": 800, \\ "height": 600, + \\ "minWidth": null, + \\ "minHeight": null, + \\ "maxWidth": null, + \\ "maxHeight": null, \\ "resizable": true, \\ "decorations": true + \\ "visible": true \\ }], + \\ "mode": "gui", \\ "security": { "capabilities": ["filesystem", "network"] }, \\ "ipc": { "protocol": "json", "bridgeInjection": true } \\ }, diff --git a/docs/developer/ABI-FFI-README.adoc b/docs/developer/ABI-FFI-README.adoc index 48a8bda..2b4a2b0 100644 --- a/docs/developer/ABI-FFI-README.adoc +++ b/docs/developer/ABI-FFI-README.adoc @@ -177,6 +177,9 @@ export fn gossamer_create( ) ?*GossamerHandle { ... } ``` +`gossamer_create_ex` extends the same launch path with min/max size bounds and +initial visibility. `gossamer_create` remains as the compatibility shim. + === 2. Compile-Time Platform Dispatch No runtime overhead for platform selection: @@ -345,7 +348,7 @@ zig build test-integration ```idris -- Compile-time verification — these are checked by the compiler resultSize : HasSize Result 4 -- ✓ -windowConfigSize : HasSize WindowConfig 32 -- ✓ +windowConfigSize : HasSize WindowConfig 48 -- ✓ resultCompliant : CABICompliant Result 4 4 -- ✓ ``` diff --git a/docs/gossamer-conf-reference.adoc b/docs/gossamer-conf-reference.adoc index a711391..dd97191 100644 --- a/docs/gossamer-conf-reference.adoc +++ b/docs/gossamer-conf-reference.adoc @@ -153,6 +153,40 @@ invocations with `deno task`: == App Configuration +=== Presentation Mode + +`app.mode` selects the presentation layer the launcher should use. + +[cols="1,1,1,3", options="header"] +|=== +| Value | Meaning | Current behavior | Notes + +| `gui` +| Standalone webview window. +| Default. +| Launches the normal desktop shell. + +| `panel-host` +| GUI shell that can participate in PanLL/groove discovery. +| Probes the groove layer and falls back to GUI if PanLL is absent. +| Useful when you want the same app to join the wider panel workspace. + +| `headless` +| No native window. +| Skips webview launch. +| Intended for server-style or backend-only runs. + +| `cli` +| Reserved terminal/command-line presentation. +| Not implemented yet. +| Parsed now so the launcher can evolve without a config break. + +| `tui` +| Reserved text UI presentation. +| Not implemented yet. +| Same as `cli`, but for terminal rendering. +|=== + === Windows Each entry in `app.windows` defines a window created at launch. @@ -722,9 +756,11 @@ A full configuration for PanLL migrated from Tauri to Gossamer: "resizable": true, "fullscreen": false, "decorations": true, + "visible": true, "transparent": false, "center": true }], + "mode": "panel-host", "security": { "csp": null, "capabilities": ["filesystem", "network", "shell", "clipboard"], diff --git a/src/core/Shell.eph b/src/core/Shell.eph index 272fd3f..8d825a6 100644 --- a/src/core/Shell.eph +++ b/src/core/Shell.eph @@ -20,38 +20,68 @@ fn create(title: String, width: I32, height: I32): I64 = // Load HTML content into the webview. Borrows the handle. // FFI: gossamer_load_html(handle, html) -> result -fn loadHTML(handle: I64, html: String): I32 = - __ffi("gossamer_load_html", handle, html) +fn loadHTML(h: I64, html: String): I32 = + __ffi("gossamer_load_html", h, html) // Navigate to a URL. Borrows the handle. // FFI: gossamer_navigate(handle, url) -> result -fn navigate(handle: I64, url: String): I32 = - __ffi("gossamer_navigate", handle, url) +fn navigate(h: I64, url: String): I32 = + __ffi("gossamer_navigate", h, url) // Evaluate JavaScript in the webview. Borrows the handle. // FFI: gossamer_eval(handle, js) -> result -fn eval(handle: I64, js: String): I32 = - __ffi("gossamer_eval", handle, js) +fn eval(h: I64, js: String): I32 = + __ffi("gossamer_eval", h, js) // Set the window title. Borrows the handle. // FFI: gossamer_set_title(handle, title) -> result -fn setTitle(handle: I64, title: String): I32 = - __ffi("gossamer_set_title", handle, title) +fn setTitle(h: I64, title: String): I32 = + __ffi("gossamer_set_title", h, title) // Run the event loop. CONSUMES the handle — window is destroyed after. // FFI: gossamer_run(handle) -> void -fn run(handle: I64): () = - __ffi("gossamer_run", handle) +fn run(h: I64): () = + __ffi("gossamer_run", h) // Resize the webview window. Borrows the handle. // FFI: gossamer_resize(handle, width, height) -> result -fn resize(handle: I64, width: I32, height: I32): I32 = - __ffi("gossamer_resize", handle, width, height) +fn resize(h: I64, width: I32, height: I32): I32 = + __ffi("gossamer_resize", h, width, height) + +// Show the webview window. Borrows the handle. +// FFI: gossamer_show(handle) -> result +fn show(h: I64): I32 = + __ffi("gossamer_show", h) + +// Hide the webview window. Borrows the handle. +// FFI: gossamer_hide(handle) -> result +fn hide(h: I64): I32 = + __ffi("gossamer_hide", h) + +// Minimize the webview window. Borrows the handle. +// FFI: gossamer_minimize(handle) -> result +fn minimize(h: I64): I32 = + __ffi("gossamer_minimize", h) + +// Maximize the webview window. Borrows the handle. +// FFI: gossamer_maximize(handle) -> result +fn maximize(h: I64): I32 = + __ffi("gossamer_maximize", h) + +// Restore the webview window. Borrows the handle. +// FFI: gossamer_restore(handle) -> result +fn restore(h: I64): I32 = + __ffi("gossamer_restore", h) + +// Request that the window close. Borrows the handle. +// FFI: gossamer_request_close(handle) -> result +fn requestClose(h: I64): I32 = + __ffi("gossamer_request_close", h) // Destroy the webview without running. CONSUMES the handle. // FFI: gossamer_destroy(handle) -> void -fn destroy(handle: I64): () = - __ffi("gossamer_destroy", handle) +fn destroy(h: I64): () = + __ffi("gossamer_destroy", h) // Get the last error message from the FFI layer. // Returns a pointer to the error string, or 0 if no error. diff --git a/src/core/Tray.eph b/src/core/Tray.eph index 62fb392..de6f521 100644 --- a/src/core/Tray.eph +++ b/src/core/Tray.eph @@ -27,47 +27,53 @@ fn create(tooltip: String): I64 = // Destroy the tray icon. CONSUMES the handle. // FFI: gossamer_tray_destroy(handle) -> void -fn destroy(handle: I64): () = - __ffi("gossamer_tray_destroy", handle) +fn destroy(h: I64): () = + __ffi("gossamer_tray_destroy", h) // Add an item to the tray right-click context menu. BORROWS the handle. // item_id is passed to the callback when the item is clicked. // FFI: gossamer_tray_add_item(handle, label, item_id) -> result -fn addItem(handle: I64, label: String, itemId: I32): I32 = - __ffi("gossamer_tray_add_item", handle, label, itemId) +fn addItem(h: I64, label: String, itemId: I32): I32 = + __ffi("gossamer_tray_add_item", h, label, itemId) // Add a separator line to the context menu. BORROWS the handle. // FFI: gossamer_tray_add_separator(handle) -> result -fn addSeparator(handle: I64): I32 = - __ffi("gossamer_tray_add_separator", handle) +fn addSeparator(h: I64): I32 = + __ffi("gossamer_tray_add_separator", h) // Set the callback invoked when a menu item is clicked. // The callback receives the item_id of the clicked item. // FFI: gossamer_tray_set_callback(handle, callback) -> result -fn setCallback(handle: I64, callback: I64): I32 = - __ffi("gossamer_tray_set_callback", handle, callback) +fn setCallback(h: I64, callback: I64): I32 = + __ffi("gossamer_tray_set_callback", h, callback) // Set the tray icon by theme icon name. BORROWS the handle. // Standard names: "network-server", "network-offline", // "dialog-information", "dialog-warning", "dialog-error" // FFI: gossamer_tray_set_icon(handle, icon_name) -> result -fn setIcon(handle: I64, iconName: String): I32 = - __ffi("gossamer_tray_set_icon", handle, iconName) +fn setIcon(h: I64, iconName: String): I32 = + __ffi("gossamer_tray_set_icon", h, iconName) // Set the tray icon from a file path. BORROWS the handle. // FFI: gossamer_tray_set_icon_from_file(handle, path) -> result -fn setIconFromFile(handle: I64, path: String): I32 = - __ffi("gossamer_tray_set_icon_from_file", handle, path) +fn setIconFromFile(h: I64, path: String): I32 = + __ffi("gossamer_tray_set_icon_from_file", h, path) // Update the tooltip text. BORROWS the handle. // FFI: gossamer_tray_set_tooltip(handle, tooltip) -> result -fn setTooltip(handle: I64, tooltip: String): I32 = - __ffi("gossamer_tray_set_tooltip", handle, tooltip) +fn setTooltip(h: I64, tooltip: String): I32 = + __ffi("gossamer_tray_set_tooltip", h, tooltip) // Show or hide the tray icon. BORROWS the handle. // FFI: gossamer_tray_set_visible(handle, visible) -> result -fn setVisible(handle: I64, visible: I32): I32 = - __ffi("gossamer_tray_set_visible", handle, visible) +fn setVisible(h: I64, visible: I32): I32 = + __ffi("gossamer_tray_set_visible", h, visible) + +// Attach a main window handle so left-click toggles show/hide. +// Passing 0 detaches the window and restores the menu callback fallback. +// FFI: gossamer_tray_set_window(tray, window) -> result +fn setWindow(h: I64, window: I64): I32 = + __ffi("gossamer_tray_set_window", h, window) // Show a desktop notification (does not require a tray handle). // Routes through notify-send → xdg-desktop-portal → KDE notification daemon. diff --git a/src/interface/abi/Foreign.idr b/src/interface/abi/Foreign.idr index efaa507..b90c179 100644 --- a/src/interface/abi/Foreign.idr +++ b/src/interface/abi/Foreign.idr @@ -52,13 +52,19 @@ splitOnNewline s = -- Webview Lifecycle -------------------------------------------------------------------------------- -||| Create a new webview window. +||| Legacy create for backwards compatibility. ||| Returns a pointer to the webview handle, or 0 on failure. ||| MUST be called from the main thread. export %foreign "C:gossamer_create, libgossamer" prim__create : String -> Bits32 -> Bits32 -> Bits32 -> Bits32 -> Bits32 -> PrimIO Bits64 +||| Create a new webview window with launch-time size constraints and visibility. +||| min/max values use 0 as the "unset" sentinel. +export +%foreign "C:gossamer_create_ex, libgossamer" +prim__createEx : String -> Bits32 -> Bits32 -> Bits32 -> Bits32 -> Bits32 -> Bits32 -> Bits32 -> Bits32 -> Bits32 -> Bits32 -> PrimIO Bits64 + ||| Safe wrapper for webview creation. ||| ||| Requires a MainThreadProof witness (provided by the framework entry point). @@ -73,8 +79,10 @@ create cfg = do let resizable_flag : Bits32 = if cfg.resizable then 1 else 0 let decorations_flag : Bits32 = if cfg.decorations then 1 else 0 let fullscreen_flag : Bits32 = if cfg.fullscreen then 1 else 0 - ptr <- primIO (prim__create cfg.title cfg.width cfg.height - resizable_flag decorations_flag fullscreen_flag) + let visible_flag : Bits32 = if cfg.visible then 1 else 0 + ptr <- primIO (prim__createEx cfg.title cfg.width cfg.height + cfg.minWidth cfg.minHeight cfg.maxWidth cfg.maxHeight + resizable_flag decorations_flag fullscreen_flag visible_flag) case createWebview ptr of Nothing => pure (Left WebviewUnavailable) Just wv => pure (Right wv) @@ -165,6 +173,103 @@ resize wv w h = do Just err => pure (wv, Left err) Nothing => pure (wv, Left Error) +||| Show the webview window. +||| BORROWING operation. +export +%foreign "C:gossamer_show, libgossamer" +prim__show : Bits64 -> PrimIO Bits32 + +||| Safe wrapper for showing the window. +export +show : WebviewHandle -> IO (WebviewHandle, Either Result ()) +show wv = do + code <- primIO (prim__show (webviewPtr wv)) + case resultFromInt code of + Just Ok => pure (wv, Right ()) + Just err => pure (wv, Left err) + Nothing => pure (wv, Left Error) + +||| Hide the webview window. +||| BORROWING operation. +export +%foreign "C:gossamer_hide, libgossamer" +prim__hide : Bits64 -> PrimIO Bits32 + +||| Safe wrapper for hiding the window. +export +hide : WebviewHandle -> IO (WebviewHandle, Either Result ()) +hide wv = do + code <- primIO (prim__hide (webviewPtr wv)) + case resultFromInt code of + Just Ok => pure (wv, Right ()) + Just err => pure (wv, Left err) + Nothing => pure (wv, Left Error) + +||| Minimize the webview window. +||| BORROWING operation. +export +%foreign "C:gossamer_minimize, libgossamer" +prim__minimize : Bits64 -> PrimIO Bits32 + +||| Safe wrapper for minimizing the window. +export +minimize : WebviewHandle -> IO (WebviewHandle, Either Result ()) +minimize wv = do + code <- primIO (prim__minimize (webviewPtr wv)) + case resultFromInt code of + Just Ok => pure (wv, Right ()) + Just err => pure (wv, Left err) + Nothing => pure (wv, Left Error) + +||| Maximize the webview window. +||| BORROWING operation. +export +%foreign "C:gossamer_maximize, libgossamer" +prim__maximize : Bits64 -> PrimIO Bits32 + +||| Safe wrapper for maximizing the window. +export +maximize : WebviewHandle -> IO (WebviewHandle, Either Result ()) +maximize wv = do + code <- primIO (prim__maximize (webviewPtr wv)) + case resultFromInt code of + Just Ok => pure (wv, Right ()) + Just err => pure (wv, Left err) + Nothing => pure (wv, Left Error) + +||| Restore the webview window from minimized or maximized state. +||| BORROWING operation. +export +%foreign "C:gossamer_restore, libgossamer" +prim__restore : Bits64 -> PrimIO Bits32 + +||| Safe wrapper for restoring the window. +export +restore : WebviewHandle -> IO (WebviewHandle, Either Result ()) +restore wv = do + code <- primIO (prim__restore (webviewPtr wv)) + case resultFromInt code of + Just Ok => pure (wv, Right ()) + Just err => pure (wv, Left err) + Nothing => pure (wv, Left Error) + +||| Request that the webview window close. +||| BORROWING operation: returns the handle for later cleanup, but the +||| window becomes logically closed and further borrowing operations fail. +export +%foreign "C:gossamer_request_close, libgossamer" +prim__requestClose : Bits64 -> PrimIO Bits32 + +||| Safe wrapper for a close request. +export +requestClose : WebviewHandle -> IO (WebviewHandle, Either Result ()) +requestClose wv = do + code <- primIO (prim__requestClose (webviewPtr wv)) + case resultFromInt code of + Just Ok => pure (wv, Right ()) + Just err => pure (wv, Left err) + Nothing => pure (wv, Left Error) + ||| Run the webview event loop. Blocks until the window is closed. ||| CONSUMING operation: the handle is destroyed after this returns. ||| The caller loses ownership — using the handle after this is a type error. diff --git a/src/interface/abi/Layout.idr b/src/interface/abi/Layout.idr index cdebc8f..1728bef 100644 --- a/src/interface/abi/Layout.idr +++ b/src/interface/abi/Layout.idr @@ -136,8 +136,9 @@ resultFitsInBits32 CapabilityDenied = Oh -------------------------------------------------------------------------------- ||| WindowConfig field specifications for C ABI layout calculation. -||| Fields: title (ptr), width (u32), height (u32), resizable (bool/u8), -||| decorations (bool/u8), fullscreen (bool/u8) +||| Fields: title (ptr), width (u32), height (u32), min/max bounds (u32), +||| resizable (bool/u8), decorations (bool/u8), fullscreen (bool/u8), +||| visible (bool/u8) ||| ||| Note: In the FFI, title is passed as a C string pointer, not embedded ||| in the struct. The struct layout here is for documentation and @@ -148,17 +149,24 @@ windowConfigFields = [ MkFieldSpec "title_ptr" 8 8 -- Pointer to title string , MkFieldSpec "width" 4 4 -- Bits32 , MkFieldSpec "height" 4 4 -- Bits32 + , MkFieldSpec "minWidth" 4 4 -- Bits32 (0 = unconstrained) + , MkFieldSpec "minHeight" 4 4 -- Bits32 (0 = unconstrained) + , MkFieldSpec "maxWidth" 4 4 -- Bits32 (0 = unconstrained) + , MkFieldSpec "maxHeight" 4 4 -- Bits32 (0 = unconstrained) , MkFieldSpec "resizable" 4 4 -- Bits32 (bool as C int for alignment) , MkFieldSpec "decorations" 4 4 -- Bits32 , MkFieldSpec "fullscreen" 4 4 -- Bits32 + , MkFieldSpec "visible" 4 4 -- Bits32 (bool as C int for alignment) ] -||| WindowConfig total size: 32 bytes (8-byte aligned). +||| WindowConfig total size: 48 bytes (8-byte aligned). ||| title_ptr(0..7) + width(8..11) + height(12..15) -||| + resizable(16..19) + decorations(20..23) + fullscreen(24..27) -||| + 4 bytes padding for 8-byte alignment = 32 bytes +||| + minWidth(16..19) + minHeight(20..23) + maxWidth(24..27) +||| + maxHeight(28..31) + resizable(32..35) + decorations(36..39) +||| + fullscreen(40..43) + visible(44..47) +||| + no trailing padding needed = 48 bytes public export -windowConfigSize : HasSize WindowConfig 32 +windowConfigSize : HasSize WindowConfig 48 windowConfigSize = SizeProof ||| WindowConfig alignment: 8 bytes (due to pointer field). diff --git a/src/interface/abi/Types.idr b/src/interface/abi/Types.idr index d96c9d7..5c0ad28 100644 --- a/src/interface/abi/Types.idr +++ b/src/interface/abi/Types.idr @@ -209,6 +209,42 @@ webviewPtr (MkWebview ptr) = ptr -- Window Configuration (Plain Value) -------------------------------------------------------------------------------- +||| Presentation mode for a Gossamer application. +||| +||| Gui: standalone webview window. +||| PanelHost: GUI shell that can participate in PanLL/groove discovery. +||| Headless: no native window. +||| Cli/Tui: reserved for future terminal-backed presentation layers. +public export +data AppMode = Gui | PanelHost | Headless | Cli | Tui + +public export +Eq AppMode where + Gui == Gui = True + PanelHost == PanelHost = True + Headless == Headless = True + Cli == Cli = True + Tui == Tui = True + _ == _ = False + +public export +appModeToString : AppMode -> String +appModeToString Gui = "gui" +appModeToString PanelHost = "panel-host" +appModeToString Headless = "headless" +appModeToString Cli = "cli" +appModeToString Tui = "tui" + +public export +appModeFromString : String -> Maybe AppMode +appModeFromString "gui" = Just Gui +appModeFromString "panel-host" = Just PanelHost +appModeFromString "panel_host" = Just PanelHost +appModeFromString "headless" = Just Headless +appModeFromString "cli" = Just Cli +appModeFromString "tui" = Just Tui +appModeFromString _ = Nothing + ||| Window configuration — a plain value, not a resource. ||| Can be freely copied, stored, serialised. public export @@ -220,23 +256,38 @@ record WindowConfig where width : Bits32 ||| Initial window height in pixels height : Bits32 + ||| Minimum window width in pixels (0 = unconstrained) + minWidth : Bits32 + ||| Minimum window height in pixels (0 = unconstrained) + minHeight : Bits32 + ||| Maximum window width in pixels (0 = unconstrained) + maxWidth : Bits32 + ||| Maximum window height in pixels (0 = unconstrained) + maxHeight : Bits32 ||| Whether the window can be resized resizable : Bool ||| Whether to show window decorations (title bar, borders) decorations : Bool ||| Whether the window starts in fullscreen mode fullscreen : Bool + ||| Whether the window starts visible + visible : Bool -||| Default window configuration: 800x600, resizable, decorated. +||| Default window configuration: 800x600, resizable, decorated, visible. public export defaultConfig : WindowConfig defaultConfig = MkWindowConfig { title = "Gossamer" , width = 800 , height = 600 + , minWidth = 0 + , minHeight = 0 + , maxWidth = 0 + , maxHeight = 0 , resizable = True , decorations = True , fullscreen = False + , visible = True } -------------------------------------------------------------------------------- diff --git a/src/interface/ffi/src/csp.zig b/src/interface/ffi/src/csp.zig index 28850f1..5521be6 100644 --- a/src/interface/ffi/src/csp.zig +++ b/src/interface/ffi/src/csp.zig @@ -69,6 +69,11 @@ export fn gossamer_set_csp(handle_ptr: u64, csp: [*:0]const u8) main.Result { return .@"error"; } + if (handle.closed) { + main.setError("Webview already closed"); + return .already_consumed; + } + const allocator = std.heap.c_allocator; const csp_slice = std.mem.span(csp); @@ -151,6 +156,11 @@ export fn gossamer_emit( return .@"error"; } + if (handle.closed) { + main.setError("Webview already closed"); + return .already_consumed; + } + const allocator = std.heap.c_allocator; const event_slice = std.mem.span(event_name); const payload_slice = std.mem.span(payload_json); @@ -210,8 +220,11 @@ fn emitIdleCallback(user_data: ?*anyopaque) callconv(.c) c_int { const ctx: *EmitContext = @ptrCast(@alignCast(user_data orelse return 0)); const allocator = ctx.allocator; - // Evaluate the JS on the webview (now safe — we are on the GTK thread) - platform.eval(&ctx.handle.webview, ctx.js) catch {}; + // Evaluate the JS on the webview (now safe — we are on the GTK thread). + // Skip if the window has already been closed. + if (ctx.handle.initialized and !ctx.handle.closed) { + platform.eval(&ctx.handle.webview, ctx.js) catch {}; + } // Clean up allocator.free(ctx.js); diff --git a/src/interface/ffi/src/main.zig b/src/interface/ffi/src/main.zig index 0ca947c..29ffc17 100644 --- a/src/interface/ffi/src/main.zig +++ b/src/interface/ffi/src/main.zig @@ -14,6 +14,8 @@ const std = @import("std"); const builtin = @import("builtin"); +extern fn gossamer_tray_clear_window() void; + // Static Site Generator FFI functions (gossamer_ssg_*). // Imported here to ensure all exports are included in the shared library. comptime { @@ -122,6 +124,12 @@ pub const GossamerHandle = struct { initialized: bool, /// Whether the event loop has been started running: bool, + /// Whether the window has been logically closed. + /// Borrowing operations reject closed handles, but cleanup still owns + /// the final resource release path. + closed: bool, + /// Whether the window is currently shown to the user. + visible: bool, /// Allocator used for this handle (for cleanup) allocator: std.mem.Allocator, /// IPC callback bindings (name -> callback + user data) @@ -148,6 +156,37 @@ pub const BindingEntry = struct { run_async: bool = false, }; +/// Borrowed-window guard used by window operations that should fail once the +/// window has been closed. +fn requireOpen(handle: *GossamerHandle) ?Result { + if (!handle.initialized) { + setError("Webview not initialized"); + return .@"error"; + } + + if (handle.closed) { + setError("Webview already closed"); + return .already_consumed; + } + + return null; +} + +fn validateWindowConstraints( + min_width: u32, + min_height: u32, + max_width: u32, + max_height: u32, +) bool { + if (min_width != 0 and max_width != 0 and min_width > max_width) { + return false; + } + if (min_height != 0 and max_height != 0 and min_height > max_height) { + return false; + } + return true; +} + //============================================================================== // Async IPC Inflight Tracking //============================================================================== @@ -240,21 +279,26 @@ pub const ChannelHandle = struct { // Webview Lifecycle //============================================================================== -/// Create a new webview window. -/// -/// Returns a pointer to GossamerHandle, or null on failure. -/// Must be called from the main/UI thread. -/// -/// Matches: Gossamer.ABI.Foreign.prim__create -export fn gossamer_create( +/// Internal helper used by both compatibility and config-driven create calls. +fn createHandle( title: [*:0]const u8, width: u32, height: u32, + min_width: u32, + min_height: u32, + max_width: u32, + max_height: u32, resizable: u8, decorations: u8, fullscreen: u8, + visible: u8, ) ?*GossamerHandle { clearError(); + if (!validateWindowConstraints(min_width, min_height, max_width, max_height)) { + setError("Invalid window size constraints"); + return null; + } + const allocator = std.heap.c_allocator; const handle = allocator.create(GossamerHandle) catch { @@ -266,9 +310,14 @@ export fn gossamer_create( title, width, height, + min_width, + min_height, + max_width, + max_height, resizable != 0, decorations != 0, fullscreen != 0, + visible != 0, ) catch { setError("Failed to create platform webview"); allocator.destroy(handle); @@ -279,6 +328,8 @@ export fn gossamer_create( .webview = webview_state, .initialized = true, .running = false, + .closed = false, + .visible = visible != 0, .allocator = allocator, .bindings = std.StringHashMap(BindingEntry).init(allocator), }; @@ -287,6 +338,57 @@ export fn gossamer_create( return handle; } +/// Create a new webview window using the legacy 6-argument ABI. +/// +/// Returns a pointer to GossamerHandle, or null on failure. +/// Must be called from the main/UI thread. +/// +/// Matches: Gossamer.ABI.Foreign.prim__create +export fn gossamer_create( + title: [*:0]const u8, + width: u32, + height: u32, + resizable: u8, + decorations: u8, + fullscreen: u8, +) ?*GossamerHandle { + return createHandle(title, width, height, 0, 0, 0, 0, resizable, decorations, fullscreen, 1); +} + +/// Create a new webview window with launch-time size constraints and visibility. +/// +/// min/max values use 0 as the "unset" sentinel. +/// visible uses 0/1 to control whether the window starts hidden. +/// +/// Matches: Gossamer.ABI.Foreign.prim__createEx +export fn gossamer_create_ex( + title: [*:0]const u8, + width: u32, + height: u32, + min_width: u32, + min_height: u32, + max_width: u32, + max_height: u32, + resizable: u8, + decorations: u8, + fullscreen: u8, + visible: u8, +) ?*GossamerHandle { + return createHandle( + title, + width, + height, + min_width, + min_height, + max_width, + max_height, + resizable, + decorations, + fullscreen, + visible, + ); +} + /// Load HTML content into the webview. /// /// Matches: Gossamer.ABI.Foreign.prim__loadHTML @@ -297,9 +399,8 @@ export fn gossamer_load_html(handle_ptr: u64, html: [*:0]const u8) Result { return .null_pointer; }; - if (!handle.initialized) { - setError("Webview not initialized"); - return .@"error"; + if (requireOpen(handle)) |err| { + return err; } platform.loadHTML(&handle.webview, html) catch { @@ -321,9 +422,8 @@ export fn gossamer_navigate(handle_ptr: u64, url: [*:0]const u8) Result { return .null_pointer; }; - if (!handle.initialized) { - setError("Webview not initialized"); - return .@"error"; + if (requireOpen(handle)) |err| { + return err; } platform.navigate(&handle.webview, url) catch { @@ -345,9 +445,8 @@ export fn gossamer_eval(handle_ptr: u64, js: [*:0]const u8) Result { return .null_pointer; }; - if (!handle.initialized) { - setError("Webview not initialized"); - return .@"error"; + if (requireOpen(handle)) |err| { + return err; } platform.eval(&handle.webview, js) catch { @@ -369,9 +468,8 @@ export fn gossamer_set_title(handle_ptr: u64, title: [*:0]const u8) Result { return .null_pointer; }; - if (!handle.initialized) { - setError("Webview not initialized"); - return .@"error"; + if (requireOpen(handle)) |err| { + return err; } platform.setTitle(&handle.webview, title) catch { @@ -393,9 +491,8 @@ export fn gossamer_resize(handle_ptr: u64, width: u32, height: u32) Result { return .null_pointer; }; - if (!handle.initialized) { - setError("Webview not initialized"); - return .@"error"; + if (requireOpen(handle)) |err| { + return err; } platform.resize(&handle.webview, width, height) catch { @@ -416,6 +513,10 @@ export fn gossamer_run(handle_ptr: u64) void { const handle = ptrFromU64(handle_ptr) orelse return; if (!handle.initialized) return; + if (handle.closed) { + cleanup(handle); + return; + } handle.running = true; platform.run(&handle.webview); @@ -424,6 +525,157 @@ export fn gossamer_run(handle_ptr: u64) void { cleanup(handle); } +/// Show the webview window. +/// +/// Matches: Gossamer.ABI.Foreign.prim__show +pub export fn gossamer_show(handle_ptr: u64) Result { + clearError(); + const handle = ptrFromU64(handle_ptr) orelse { + setError("Null webview handle"); + return .null_pointer; + }; + + if (requireOpen(handle)) |err| { + return err; + } + + platform.show(&handle.webview) catch { + setError("Failed to show window"); + return .@"error"; + }; + + handle.visible = true; + clearError(); + return .ok; +} + +/// Hide the webview window. +/// +/// Matches: Gossamer.ABI.Foreign.prim__hide +pub export fn gossamer_hide(handle_ptr: u64) Result { + clearError(); + const handle = ptrFromU64(handle_ptr) orelse { + setError("Null webview handle"); + return .null_pointer; + }; + + if (requireOpen(handle)) |err| { + return err; + } + + platform.hide(&handle.webview) catch { + setError("Failed to hide window"); + return .@"error"; + }; + + handle.visible = false; + clearError(); + return .ok; +} + +/// Minimize the webview window. +/// +/// Matches: Gossamer.ABI.Foreign.prim__minimize +export fn gossamer_minimize(handle_ptr: u64) Result { + clearError(); + const handle = ptrFromU64(handle_ptr) orelse { + setError("Null webview handle"); + return .null_pointer; + }; + + if (requireOpen(handle)) |err| { + return err; + } + + platform.minimize(&handle.webview) catch { + setError("Failed to minimize window"); + return .@"error"; + }; + + handle.visible = false; + clearError(); + return .ok; +} + +/// Maximize the webview window. +/// +/// Matches: Gossamer.ABI.Foreign.prim__maximize +export fn gossamer_maximize(handle_ptr: u64) Result { + clearError(); + const handle = ptrFromU64(handle_ptr) orelse { + setError("Null webview handle"); + return .null_pointer; + }; + + if (requireOpen(handle)) |err| { + return err; + } + + platform.maximize(&handle.webview) catch { + setError("Failed to maximize window"); + return .@"error"; + }; + + handle.visible = true; + clearError(); + return .ok; +} + +/// Restore the webview window from a minimized or maximized state. +/// +/// Matches: Gossamer.ABI.Foreign.prim__restore +pub export fn gossamer_restore(handle_ptr: u64) Result { + clearError(); + const handle = ptrFromU64(handle_ptr) orelse { + setError("Null webview handle"); + return .null_pointer; + }; + + if (requireOpen(handle)) |err| { + return err; + } + + platform.restore(&handle.webview) catch { + setError("Failed to restore window"); + return .@"error"; + }; + + handle.visible = true; + clearError(); + return .ok; +} + +/// Request that the webview window close. +/// +/// This performs the user-visible close action but does not free the +/// surrounding handle. Cleanup still runs once the event loop exits or the +/// owner calls destroy(). +/// +/// Matches: Gossamer.ABI.Foreign.prim__requestClose +export fn gossamer_request_close(handle_ptr: u64) Result { + clearError(); + const handle = ptrFromU64(handle_ptr) orelse { + setError("Null webview handle"); + return .null_pointer; + }; + + if (requireOpen(handle)) |err| { + return err; + } + + handle.closed = true; + platform.requestClose(&handle.webview) catch { + handle.closed = false; + setError("Failed to close window"); + return .@"error"; + }; + + handle.visible = false; + gossamer_tray_clear_window(); + clearError(); + return .ok; +} + /// Destroy the webview without running the event loop. /// Alternative to gossamer_run for cases where you need teardown only. /// @@ -454,6 +706,11 @@ export fn gossamer_channel_open(handle_ptr: u64) u64 { return 0; } + if (handle.closed) { + setError("Webview already closed"); + return 0; + } + const allocator = std.heap.c_allocator; const channel = allocator.create(ChannelHandle) catch { setError("Failed to allocate channel"); @@ -578,6 +835,16 @@ export fn gossamer_channel_bind( return .invalid_param; }; + if (!channel.parent.initialized) { + setError("Webview not initialized"); + return .@"error"; + } + + if (channel.parent.closed) { + setError("Webview already closed"); + return .already_consumed; + } + // Duplicate the name string — caller may free it after this returns const name_slice = std.mem.span(name); const duped_name = channel.allocator.dupeZ(u8, name_slice) catch { @@ -633,6 +900,16 @@ export fn gossamer_channel_bind_async( return .invalid_param; }; + if (!channel.parent.initialized) { + setError("Webview not initialized"); + return .@"error"; + } + + if (channel.parent.closed) { + setError("Webview already closed"); + return .already_consumed; + } + // Duplicate the name string — caller may free it after this returns const name_slice = std.mem.span(name); const duped_name = channel.allocator.dupeZ(u8, name_slice) catch { @@ -1013,6 +1290,8 @@ fn u64FromPtr(ptr: anytype) u64 { fn cleanup(handle: *GossamerHandle) void { if (!handle.initialized) return; + gossamer_tray_clear_window(); + // Destroy platform webview platform.destroy(&handle.webview); @@ -1020,6 +1299,9 @@ fn cleanup(handle: *GossamerHandle) void { handle.bindings.deinit(); handle.initialized = false; + handle.running = false; + handle.closed = true; + handle.visible = false; handle.allocator.destroy(handle); } diff --git a/src/interface/ffi/src/tray.zig b/src/interface/ffi/src/tray.zig index 4340516..b6f5ade 100644 --- a/src/interface/ffi/src/tray.zig +++ b/src/interface/ffi/src/tray.zig @@ -14,6 +14,7 @@ // Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) const std = @import("std"); +const main = @import("main.zig"); const c = @cImport({ @cInclude("gtk/gtk.h"); @@ -32,6 +33,8 @@ pub const TrayHandle = struct { status_icon: *c.GtkStatusIcon, /// Right-click context menu menu: *c.GtkWidget, + /// Attached main window, if any. + window: ?*main.GossamerHandle, /// Allocator allocator: std.mem.Allocator, /// Callback for menu item activation (item_id -> void) @@ -88,6 +91,7 @@ export fn gossamer_tray_create(tooltip: [*:0]const u8) u64 { handle.* = .{ .status_icon = status_icon, .menu = menu, + .window = null, .allocator = allocator, .menu_callback = null, .menu_item_count = 0, @@ -126,6 +130,7 @@ export fn gossamer_tray_destroy(handle_ptr: u64) void { c.g_object_unref(@ptrCast(handle.status_icon)); c.gtk_widget_destroy(handle.menu); + handle.window = null; handle.visible = false; if (global_tray == handle) global_tray = null; handle.allocator.destroy(handle); @@ -217,6 +222,29 @@ export fn gossamer_tray_set_visible(handle_ptr: u64, visible: u32) u32 { return 0; } +/// 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 { + const handle = trayFromU64(handle_ptr) orelse return 1; + if (window_ptr == 0) { + handle.window = null; + return 0; + } + + const window = main.ptrFromU64(window_ptr) orelse return 2; + if (!window.initialized or window.closed) return 2; + handle.window = window; + return 0; +} + +/// Clear any attached main window from the singleton tray. +/// Used when the main window is being destroyed. +export fn gossamer_tray_clear_window() void { + if (global_tray) |tray| { + tray.window = null; + } +} + //============================================================================== // Notifications //============================================================================== @@ -284,9 +312,19 @@ fn onTrayActivate( _: ?*c.GtkStatusIcon, _: ?*anyopaque, ) callconv(.c) void { - // In PanLL integration, this would toggle the main window. - // For now, invoke menu item 0 (typically "Open PanLL"). if (global_tray) |tray| { + if (tray.window) |window| { + if (window.initialized and !window.closed) { + const handle_ptr = @intFromPtr(window); + if (window.visible) { + _ = main.gossamer_hide(handle_ptr); + } else { + _ = main.gossamer_restore(handle_ptr); + } + return; + } + } + if (tray.menu_callback) |cb| { cb(0); // item_id 0 = "Open PanLL" } diff --git a/src/interface/ffi/src/webview_android.zig b/src/interface/ffi/src/webview_android.zig index a7c32a0..92fa97c 100644 --- a/src/interface/ffi/src/webview_android.zig +++ b/src/interface/ffi/src/webview_android.zig @@ -92,16 +92,26 @@ pub fn create( title: [*:0]const u8, width: u32, height: u32, + min_width: u32, + min_height: u32, + max_width: u32, + max_height: u32, resizable: bool, decorations: bool, fullscreen: bool, + visible: bool, ) PlatformError!WebviewState { _ = title; // Set via Activity.setTitle after creation _ = width; // Android fills the Activity _ = height; + _ = min_width; + _ = min_height; + _ = max_width; + _ = max_height; _ = resizable; _ = decorations; _ = fullscreen; + _ = visible; // Check if JNI references have been provided by the Java launcher const env = android_jni_env orelse return PlatformError.JniInitFailed; @@ -223,6 +233,36 @@ pub fn resize(_: *WebviewState, _: u32, _: u32) PlatformError!void { // Android WebView fills the Activity — resize is not applicable } +/// Window visibility/state controls are not supported on Android. +pub fn show(_: *WebviewState) PlatformError!void { + return PlatformError.OperationFailed; +} + +/// Window visibility/state controls are not supported on Android. +pub fn hide(_: *WebviewState) PlatformError!void { + return PlatformError.OperationFailed; +} + +/// Window visibility/state controls are not supported on Android. +pub fn minimize(_: *WebviewState) PlatformError!void { + return PlatformError.OperationFailed; +} + +/// Window visibility/state controls are not supported on Android. +pub fn maximize(_: *WebviewState) PlatformError!void { + return PlatformError.OperationFailed; +} + +/// Window visibility/state controls are not supported on Android. +pub fn restore(_: *WebviewState) PlatformError!void { + return PlatformError.OperationFailed; +} + +/// Requesting close is not supported on Android from the native shell layer. +pub fn requestClose(_: *WebviewState) PlatformError!void { + return PlatformError.OperationFailed; +} + /// Run the event loop. /// On Android, the Java runtime owns the event loop. This function blocks /// by polling the shutdown flag, which is set when Activity.onDestroy fires. diff --git a/src/interface/ffi/src/webview_cocoa.zig b/src/interface/ffi/src/webview_cocoa.zig index 3d1d2de..ebb282f 100644 --- a/src/interface/ffi/src/webview_cocoa.zig +++ b/src/interface/ffi/src/webview_cocoa.zig @@ -53,6 +53,12 @@ fn msgSendBool(target: ?*anyopaque, sel: c.SEL, val: bool) void { func(target, sel, if (val) @as(c.BOOL, 1) else @as(c.BOOL, 0)); } +/// Helper: send a message returning a BOOL. +fn msgSendBoolRet(target: ?*anyopaque, sel: c.SEL) bool { + const func: *const fn (?*anyopaque, c.SEL) callconv(.c) c.BOOL = @ptrCast(&objc_msgSend); + return func(target, sel) != 0; +} + /// Create an NSString from a C string. fn nsString(str: [*:0]const u8) ?*anyopaque { const cls = c.objc_getClass("NSString") orelse return null; @@ -89,9 +95,14 @@ pub fn create( title: [*:0]const u8, width: u32, height: u32, + min_width: u32, + min_height: u32, + max_width: u32, + max_height: u32, resizable: bool, decorations: bool, fullscreen: bool, + visible: bool, ) PlatformError!WebviewState { // 1. [NSApplication sharedApplication] const nsapp_cls = c.objc_getClass("NSApplication") orelse return PlatformError.CocoaInitFailed; @@ -144,6 +155,24 @@ pub fn create( const ns_title = nsString(title) orelse return PlatformError.WindowCreateFailed; msgSendVoid1(window, set_title_sel, ns_title); + // Apply launch-time size constraints. + if (min_width != 0 or min_height != 0) { + const set_min_sel = c.sel_registerName("setContentMinSize:") orelse return PlatformError.WindowCreateFailed; + const size_func: *const fn (?*anyopaque, c.SEL, f64, f64) callconv(.c) void = @ptrCast(&objc_msgSend); + size_func(window, set_min_sel, @floatFromInt(min_width), @floatFromInt(min_height)); + } + if (max_width != 0 or max_height != 0) { + const set_max_sel = c.sel_registerName("setContentMaxSize:") orelse return PlatformError.WindowCreateFailed; + const size_func: *const fn (?*anyopaque, c.SEL, f64, f64) callconv(.c) void = @ptrCast(&objc_msgSend); + const max_default: f64 = @floatFromInt(std.math.maxInt(u32)); + size_func( + window, + set_max_sel, + if (max_width != 0) @floatFromInt(max_width) else max_default, + if (max_height != 0) @floatFromInt(max_height) else max_default, + ); + } + // Center the window const center_sel = c.sel_registerName("center") orelse return PlatformError.WindowCreateFailed; msgSendVoid(window, center_sel); @@ -176,13 +205,15 @@ pub fn create( const set_content_sel = c.sel_registerName("setContentView:") orelse return PlatformError.WebviewCreateFailed; msgSendVoid1(window, set_content_sel, webview); - // 6. Show window - const show_sel = c.sel_registerName("makeKeyAndOrderFront:") orelse return PlatformError.WebviewCreateFailed; - msgSendVoid1(window, show_sel, null); + if (visible) { + // 6. Show window + const show_sel = c.sel_registerName("makeKeyAndOrderFront:") orelse return PlatformError.WebviewCreateFailed; + msgSendVoid1(window, show_sel, null); - // Activate the application - const activate_sel = c.sel_registerName("activateIgnoringOtherApps:") orelse return PlatformError.CocoaInitFailed; - msgSendBool(app, activate_sel, true); + // Activate the application + const activate_sel = c.sel_registerName("activateIgnoringOtherApps:") orelse return PlatformError.CocoaInitFailed; + msgSendBool(app, activate_sel, true); + } if (fullscreen) { const fs_sel = c.sel_registerName("toggleFullScreen:") orelse return PlatformError.WindowCreateFailed; @@ -254,6 +285,77 @@ pub fn resize(state: *WebviewState, width: u32, height: u32) PlatformError!void func(window, sel, @floatFromInt(width), @floatFromInt(height)); } +/// Show the window and activate the application. +pub fn show(state: *WebviewState) PlatformError!void { + const window = state.window orelse return PlatformError.OperationFailed; + + const app_cls = c.objc_getClass("NSApplication") orelse return PlatformError.CocoaInitFailed; + const shared_sel = c.sel_registerName("sharedApplication") orelse return PlatformError.CocoaInitFailed; + const app = msgSend(@ptrCast(app_cls), shared_sel) orelse return PlatformError.CocoaInitFailed; + + const activate_sel = c.sel_registerName("activateIgnoringOtherApps:") orelse return PlatformError.CocoaInitFailed; + msgSendBool(app, activate_sel, true); + + const show_sel = c.sel_registerName("makeKeyAndOrderFront:") orelse return PlatformError.OperationFailed; + msgSendVoid1(window, show_sel, null); +} + +/// Hide the window. +pub fn hide(state: *WebviewState) PlatformError!void { + const window = state.window orelse return PlatformError.OperationFailed; + const sel = c.sel_registerName("orderOut:") orelse return PlatformError.OperationFailed; + msgSendVoid1(window, sel, null); +} + +/// Minimize the window. +pub fn minimize(state: *WebviewState) PlatformError!void { + const window = state.window orelse return PlatformError.OperationFailed; + const sel = c.sel_registerName("miniaturize:") orelse return PlatformError.OperationFailed; + msgSendVoid1(window, sel, null); +} + +/// Maximize the window. +pub fn maximize(state: *WebviewState) PlatformError!void { + const window = state.window orelse return PlatformError.OperationFailed; + const is_zoomed_sel = c.sel_registerName("isZoomed") orelse return PlatformError.OperationFailed; + if (!msgSendBoolRet(window, is_zoomed_sel)) { + const zoom_sel = c.sel_registerName("zoom:") orelse return PlatformError.OperationFailed; + msgSendVoid1(window, zoom_sel, null); + } +} + +/// Restore the window from minimized or maximized state. +pub fn restore(state: *WebviewState) PlatformError!void { + const window = state.window orelse return PlatformError.OperationFailed; + + const is_miniaturized_sel = c.sel_registerName("isMiniaturized") orelse return PlatformError.OperationFailed; + if (msgSendBoolRet(window, is_miniaturized_sel)) { + const deminiaturize_sel = c.sel_registerName("deminiaturize:") orelse return PlatformError.OperationFailed; + msgSendVoid1(window, deminiaturize_sel, null); + } + + const is_zoomed_sel = c.sel_registerName("isZoomed") orelse return PlatformError.OperationFailed; + if (msgSendBoolRet(window, is_zoomed_sel)) { + const zoom_sel = c.sel_registerName("zoom:") orelse return PlatformError.OperationFailed; + msgSendVoid1(window, zoom_sel, null); + } + + try show(state); +} + +/// Request that the window close. +pub fn requestClose(state: *WebviewState) PlatformError!void { + if (state.cocoa_initialized) { + if (state.window) |window| { + const close_sel = c.sel_registerName("close") orelse return PlatformError.OperationFailed; + msgSendVoid(window, close_sel); + } + state.window = null; + state.webview = null; + state.cocoa_initialized = false; + } +} + /// Run the Cocoa event loop. Blocks until the application terminates. pub fn run(_: *WebviewState) void { const nsapp_cls = c.objc_getClass("NSApplication") orelse return; diff --git a/src/interface/ffi/src/webview_gtk.zig b/src/interface/ffi/src/webview_gtk.zig index 5a4f8b4..2c5c5be 100644 --- a/src/interface/ffi/src/webview_gtk.zig +++ b/src/interface/ffi/src/webview_gtk.zig @@ -56,9 +56,14 @@ pub fn create( title: [*:0]const u8, width: u32, height: u32, + min_width: u32, + min_height: u32, + max_width: u32, + max_height: u32, resizable: bool, decorations: bool, fullscreen: bool, + visible: bool, ) PlatformError!WebviewState { // Initialise GTK (safe to call multiple times) if (c.gtk_init_check(null, null) == 0) { @@ -80,6 +85,31 @@ pub fn create( c.gtk_window_set_resizable(@ptrCast(window), @intFromBool(resizable)); c.gtk_window_set_decorated(@ptrCast(window), @intFromBool(decorations)); + if (min_width != 0 or min_height != 0 or max_width != 0 or max_height != 0) { + var geometry: c.GdkGeometry = std.mem.zeroes(c.GdkGeometry); + var hints: u32 = 0; + + geometry.min_width = if (min_width != 0) @intCast(min_width) else 0; + geometry.min_height = if (min_height != 0) @intCast(min_height) else 0; + geometry.max_width = if (max_width != 0) @intCast(max_width) else std.math.maxInt(c_int); + geometry.max_height = if (max_height != 0) @intCast(max_height) else std.math.maxInt(c_int); + + if (min_width != 0 or min_height != 0) { + hints |= @as(u32, @intCast(c.GDK_HINT_MIN_SIZE)); + } + if (max_width != 0 or max_height != 0) { + hints |= @as(u32, @intCast(c.GDK_HINT_MAX_SIZE)); + } + + const hint_mask: c.GdkWindowHints = @bitCast(hints); + c.gtk_window_set_geometry_hints( + @ptrCast(window), + null, + &geometry, + hint_mask, + ); + } + if (fullscreen) { c.gtk_window_fullscreen(@ptrCast(window)); } @@ -103,8 +133,10 @@ pub fn create( 0, ); - // Show everything - c.gtk_widget_show_all(window); + // Show everything unless the window should start hidden. + if (visible) { + c.gtk_widget_show_all(window); + } return WebviewState{ .window = window, @@ -155,6 +187,41 @@ pub fn resize(state: *WebviewState, width: u32, height: u32) PlatformError!void ); } +/// Show the webview window. +pub fn show(state: *WebviewState) PlatformError!void { + c.gtk_widget_show_all(state.window); +} + +/// Hide the webview window. +pub fn hide(state: *WebviewState) PlatformError!void { + c.gtk_widget_hide(state.window); +} + +/// Minimize the webview window. +pub fn minimize(state: *WebviewState) PlatformError!void { + c.gtk_window_iconify(@ptrCast(state.window)); +} + +/// Maximize the webview window. +pub fn maximize(state: *WebviewState) PlatformError!void { + c.gtk_window_maximize(@ptrCast(state.window)); +} + +/// Restore the webview window from minimized or maximized state. +pub fn restore(state: *WebviewState) PlatformError!void { + c.gtk_window_deiconify(@ptrCast(state.window)); + c.gtk_window_unmaximize(@ptrCast(state.window)); + c.gtk_widget_show_all(state.window); +} + +/// Request that the GTK window close. +pub fn requestClose(state: *WebviewState) PlatformError!void { + if (state.gtk_initialized) { + c.gtk_widget_destroy(state.window); + state.gtk_initialized = false; + } +} + /// Run the GTK main event loop. Blocks until the window is closed. pub fn run(_: *WebviewState) void { c.gtk_main(); @@ -170,7 +237,9 @@ pub fn destroy(state: *WebviewState) void { // Signal handler: called when the GTK window is destroyed. fn onWindowDestroy(_: ?*c.GtkWidget, _: ?*anyopaque) callconv(.c) void { - c.gtk_main_quit(); + if (c.gtk_main_level() > 0) { + c.gtk_main_quit(); + } } //============================================================================== diff --git a/src/interface/ffi/src/webview_ios.zig b/src/interface/ffi/src/webview_ios.zig index 6834754..a0e88c2 100644 --- a/src/interface/ffi/src/webview_ios.zig +++ b/src/interface/ffi/src/webview_ios.zig @@ -88,13 +88,22 @@ pub fn create( title: [*:0]const u8, width: u32, height: u32, + min_width: u32, + min_height: u32, + max_width: u32, + max_height: u32, resizable: bool, decorations: bool, fullscreen: bool, + visible: bool, ) PlatformError!WebviewState { _ = title; // iOS doesn't have window titles _ = width; // iOS uses full screen _ = height; + _ = min_width; + _ = min_height; + _ = max_width; + _ = max_height; _ = resizable; // Always full screen _ = decorations; // No window decorations _ = fullscreen; // Always full screen @@ -150,9 +159,11 @@ pub fn create( const set_root_sel = c.sel_registerName("setRootViewController:") orelse return PlatformError.WindowCreateFailed; msgSendVoid1(window, set_root_sel, vc); - // [window makeKeyAndVisible] - const visible_sel = c.sel_registerName("makeKeyAndVisible") orelse return PlatformError.WindowCreateFailed; - msgSendVoid(window, visible_sel); + if (visible) { + // [window makeKeyAndVisible] + const visible_sel = c.sel_registerName("makeKeyAndVisible") orelse return PlatformError.WindowCreateFailed; + msgSendVoid(window, visible_sel); + } return WebviewState{ .window = window, @@ -210,6 +221,36 @@ pub fn resize(_: *WebviewState, _: u32, _: u32) PlatformError!void { // iOS apps always fill the screen } +/// Window visibility/state controls are not supported on iOS. +pub fn show(_: *WebviewState) PlatformError!void { + return PlatformError.OperationFailed; +} + +/// Window visibility/state controls are not supported on iOS. +pub fn hide(_: *WebviewState) PlatformError!void { + return PlatformError.OperationFailed; +} + +/// Window visibility/state controls are not supported on iOS. +pub fn minimize(_: *WebviewState) PlatformError!void { + return PlatformError.OperationFailed; +} + +/// Window visibility/state controls are not supported on iOS. +pub fn maximize(_: *WebviewState) PlatformError!void { + return PlatformError.OperationFailed; +} + +/// Window visibility/state controls are not supported on iOS. +pub fn restore(_: *WebviewState) PlatformError!void { + return PlatformError.OperationFailed; +} + +/// Requesting close is not supported on iOS from the native shell layer. +pub fn requestClose(_: *WebviewState) PlatformError!void { + return PlatformError.OperationFailed; +} + /// Run the UIKit event loop. /// On iOS, UIApplicationMain is called from the C main() function. /// This is typically a no-op since the run loop is managed by UIKit. diff --git a/src/interface/ffi/src/webview_win32.zig b/src/interface/ffi/src/webview_win32.zig index 4d57cf8..57d27ae 100644 --- a/src/interface/ffi/src/webview_win32.zig +++ b/src/interface/ffi/src/webview_win32.zig @@ -115,15 +115,42 @@ const WNDCLASSEXW = extern struct { const WM_DESTROY: u32 = 0x0002; const WM_SIZE: u32 = 0x0005; +const WM_GETMINMAXINFO: u32 = 0x0024; const WS_OVERLAPPEDWINDOW: u32 = 0x00CF0000; const WS_VISIBLE: u32 = 0x10000000; const CW_USEDEFAULT: i32 = @as(i32, @bitCast(@as(u32, 0x80000000))); +const SW_HIDE: i32 = 0; const SW_SHOW: i32 = 5; +const SW_MINIMIZE: i32 = 6; +const SW_RESTORE: i32 = 9; +const SW_MAXIMIZE: i32 = 3; const COINIT_APARTMENTTHREADED: u32 = 0x2; const WAIT_OBJECT_0: u32 = 0; const INFINITE: u32 = 0xFFFFFFFF; const S_OK: HRESULT = 0; +const POINT = extern struct { + x: i32, + y: i32, +}; + +const MINMAXINFO = extern struct { + ptReserved: POINT, + ptMaxSize: POINT, + ptMaxPosition: POINT, + ptMinTrackSize: POINT, + ptMaxTrackSize: POINT, +}; + +const WindowConstraints = struct { + min_width: u32 = 0, + min_height: u32 = 0, + max_width: u32 = 0, + max_height: u32 = 0, +}; + +threadlocal var window_constraints: WindowConstraints = .{}; + //============================================================================== // WebView2 COM Interface Definitions (Vtable-based) //============================================================================== @@ -301,6 +328,24 @@ fn controllerVtbl(ptr: *anyopaque) *const ICoreWebView2ControllerVtbl { /// Window procedure callback. fn wndProc(hwnd: HWND, msg: u32, wParam: WPARAM, lParam: LPARAM) callconv(.c) LRESULT { switch (msg) { + WM_GETMINMAXINFO => { + const mmi: *MINMAXINFO = @ptrFromInt(@as(usize, @intCast(lParam))); + + if (window_constraints.min_width != 0) { + mmi.ptMinTrackSize.x = @intCast(window_constraints.min_width); + } + if (window_constraints.min_height != 0) { + mmi.ptMinTrackSize.y = @intCast(window_constraints.min_height); + } + if (window_constraints.max_width != 0) { + mmi.ptMaxTrackSize.x = @intCast(window_constraints.max_width); + } + if (window_constraints.max_height != 0) { + mmi.ptMaxTrackSize.y = @intCast(window_constraints.max_height); + } + + return 0; + }, WM_DESTROY => { PostQuitMessage(0); return 0; @@ -323,9 +368,14 @@ pub fn create( title: [*:0]const u8, width: u32, height: u32, + min_width: u32, + min_height: u32, + max_width: u32, + max_height: u32, resizable: bool, decorations: bool, fullscreen: bool, + visible: bool, ) PlatformError!WebviewState { _ = resizable; _ = fullscreen; @@ -355,6 +405,16 @@ pub fn create( if (decorations) { style |= WS_OVERLAPPEDWINDOW; } + if (visible) { + style |= WS_VISIBLE; + } + + window_constraints = .{ + .min_width = min_width, + .min_height = min_height, + .max_width = max_width, + .max_height = max_height, + }; // Create the window const hwnd = CreateWindowExW( @@ -372,7 +432,9 @@ pub fn create( null, ) orelse return PlatformError.WindowCreateFailed; - _ = ShowWindow(hwnd, SW_SHOW); + if (visible) { + _ = ShowWindow(hwnd, SW_SHOW); + } // Load WebView2Loader.dll dynamically. // This DLL is part of the Microsoft Edge WebView2 Runtime. @@ -525,6 +587,46 @@ pub fn resize(state: *WebviewState, width: u32, height: u32) PlatformError!void } } +/// Show the window. +pub fn show(state: *WebviewState) PlatformError!void { + const hwnd = state.hwnd orelse return PlatformError.OperationFailed; + _ = ShowWindow(hwnd, SW_SHOW); +} + +/// Hide the window. +pub fn hide(state: *WebviewState) PlatformError!void { + const hwnd = state.hwnd orelse return PlatformError.OperationFailed; + _ = ShowWindow(hwnd, SW_HIDE); +} + +/// Minimize the window. +pub fn minimize(state: *WebviewState) PlatformError!void { + const hwnd = state.hwnd orelse return PlatformError.OperationFailed; + _ = ShowWindow(hwnd, SW_MINIMIZE); +} + +/// Maximize the window. +pub fn maximize(state: *WebviewState) PlatformError!void { + const hwnd = state.hwnd orelse return PlatformError.OperationFailed; + _ = ShowWindow(hwnd, SW_MAXIMIZE); +} + +/// Restore the window. +pub fn restore(state: *WebviewState) PlatformError!void { + const hwnd = state.hwnd orelse return PlatformError.OperationFailed; + _ = ShowWindow(hwnd, SW_RESTORE); +} + +/// Request that the window close. +pub fn requestClose(state: *WebviewState) PlatformError!void { + if (state.hwnd) |hwnd| { + if (DestroyWindow(hwnd) == 0) { + return PlatformError.OperationFailed; + } + state.hwnd = null; + } +} + /// Run the Win32 message loop. Blocks until the window is closed. pub fn run(_: *WebviewState) void { var msg: MSG = std.mem.zeroes(MSG); diff --git a/src/interface/ffi/test/display_test.zig b/src/interface/ffi/test/display_test.zig index 56aec1b..b0eccb4 100644 --- a/src/interface/ffi/test/display_test.zig +++ b/src/interface/ffi/test/display_test.zig @@ -36,11 +36,31 @@ extern fn gossamer_create( fullscreen: u8, ) ?*gossamer.GossamerHandle; +extern fn gossamer_create_ex( + title: [*:0]const u8, + width: u32, + height: u32, + min_width: u32, + min_height: u32, + max_width: u32, + max_height: u32, + resizable: u8, + decorations: u8, + fullscreen: u8, + visible: u8, +) ?*gossamer.GossamerHandle; + extern fn gossamer_load_html(handle_ptr: u64, html: [*:0]const u8) Result; extern fn gossamer_navigate(handle_ptr: u64, url: [*:0]const u8) Result; extern fn gossamer_eval(handle_ptr: u64, js: [*:0]const u8) Result; extern fn gossamer_set_title(handle_ptr: u64, title: [*:0]const u8) Result; extern fn gossamer_resize(handle_ptr: u64, width: u32, height: u32) Result; +extern fn gossamer_show(handle_ptr: u64) Result; +extern fn gossamer_hide(handle_ptr: u64) Result; +extern fn gossamer_minimize(handle_ptr: u64) Result; +extern fn gossamer_maximize(handle_ptr: u64) Result; +extern fn gossamer_restore(handle_ptr: u64) Result; +extern fn gossamer_request_close(handle_ptr: u64) Result; extern fn gossamer_destroy(handle_ptr: u64) void; extern fn gossamer_run(handle_ptr: u64) void; @@ -272,6 +292,64 @@ test "display: resize live webview returns ok" { gossamer_destroy(ptr); } +test "display: create_ex supports launch-time constraints and hidden start" { + if (!displayAvailable()) { + std.debug.print("SKIP: no display server available\n", .{}); + return; + } + + const handle = gossamer_create_ex( + "Constrained", + 640, + 480, + 320, + 240, + 1280, + 960, + 1, + 1, + 0, + 0, + ) orelse { + std.debug.print("SKIP: gossamer_create_ex returned null\n", .{}); + return; + }; + const ptr = handleToU64(handle); + + const result = gossamer_load_html(ptr, "

Hidden

"); + try testing.expectEqual(Result.ok, result); + try testing.expectEqual(Result.ok, gossamer_show(ptr)); + try testing.expectEqual(Result.ok, gossamer_hide(ptr)); + + gossamer_destroy(ptr); +} + +test "display: window state controls return ok" { + if (!displayAvailable()) { + std.debug.print("SKIP: no display server available\n", .{}); + return; + } + + const handle = gossamer_create("Window State", 400, 300, 1, 1, 0) orelse { + std.debug.print("SKIP: gossamer_create returned null\n", .{}); + return; + }; + const ptr = handleToU64(handle); + + try testing.expectEqual(Result.ok, gossamer_show(ptr)); + try testing.expectEqual(Result.ok, gossamer_hide(ptr)); + try testing.expectEqual(Result.ok, gossamer_show(ptr)); + try testing.expectEqual(Result.ok, gossamer_minimize(ptr)); + try testing.expectEqual(Result.ok, gossamer_restore(ptr)); + try testing.expectEqual(Result.ok, gossamer_maximize(ptr)); + try testing.expectEqual(Result.ok, gossamer_restore(ptr)); + try testing.expectEqual(Result.ok, gossamer_request_close(ptr)); + try testing.expectEqual(Result.already_consumed, gossamer_show(ptr)); + + // The handle remains valid for final teardown even after request_close. + gossamer_destroy(ptr); +} + //============================================================================== // IPC Channel Tests (with real webview) //============================================================================== @@ -418,7 +496,7 @@ test "display: version string is valid with display initialised" { const ver = gossamer_version(); const ver_str = std.mem.span(ver); - try testing.expectEqualStrings("0.1.0", ver_str); + try testing.expectEqualStrings("0.3.0", ver_str); } test "display: build info contains Gossamer and version" { @@ -436,7 +514,7 @@ test "display: build info contains Gossamer and version" { const info = gossamer_build_info(); const info_str = std.mem.span(info); try testing.expect(std.mem.indexOf(u8, info_str, "Gossamer") != null); - try testing.expect(std.mem.indexOf(u8, info_str, "0.1.0") != null); + try testing.expect(std.mem.indexOf(u8, info_str, "0.3.0") != null); } //============================================================================== diff --git a/src/interface/ffi/test/integration_test.zig b/src/interface/ffi/test/integration_test.zig index b5a78ec..84ec209 100644 --- a/src/interface/ffi/test/integration_test.zig +++ b/src/interface/ffi/test/integration_test.zig @@ -47,7 +47,7 @@ test "version string is semantic version format" { const ver_str = std.mem.span(ver); // Must be "X.Y.Z" format - try testing.expectEqualStrings("0.1.0", ver_str); + try testing.expectEqualStrings("0.3.0", ver_str); } test "build info string contains version" { @@ -56,7 +56,7 @@ test "build info string contains version" { // Build info should mention Gossamer and the version try testing.expect(std.mem.indexOf(u8, info_str, "Gossamer") != null); - try testing.expect(std.mem.indexOf(u8, info_str, "0.1.0") != null); + try testing.expect(std.mem.indexOf(u8, info_str, "0.3.0") != null); } //============================================================================== @@ -88,6 +88,36 @@ test "resize with null handle returns null_pointer" { try testing.expectEqual(Result.null_pointer, result); } +test "show with null handle returns null_pointer" { + const result = gossamer.gossamer_show(0); + try testing.expectEqual(Result.null_pointer, result); +} + +test "hide with null handle returns null_pointer" { + const result = gossamer.gossamer_hide(0); + try testing.expectEqual(Result.null_pointer, result); +} + +test "minimize with null handle returns null_pointer" { + const result = gossamer.gossamer_minimize(0); + try testing.expectEqual(Result.null_pointer, result); +} + +test "maximize with null handle returns null_pointer" { + const result = gossamer.gossamer_maximize(0); + try testing.expectEqual(Result.null_pointer, result); +} + +test "restore with null handle returns null_pointer" { + const result = gossamer.gossamer_restore(0); + try testing.expectEqual(Result.null_pointer, result); +} + +test "request_close with null handle returns null_pointer" { + const result = gossamer.gossamer_request_close(0); + try testing.expectEqual(Result.null_pointer, result); +} + test "run with null handle is safe (no-op)" { // gossamer_run(0) must not crash — just return gossamer.gossamer_run(0); From 17023685b899ceaeae3d586b6afcfba0d4cac904 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Fri, 3 Apr 2026 14:26:52 +0100 Subject: [PATCH 2/4] fix: make Rust doctests compile and init template valid JSON --- bindings/rust/src/lib.rs | 24 +++++++++++++++++++++--- cli/src/main.zig | 2 +- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/bindings/rust/src/lib.rs b/bindings/rust/src/lib.rs index 7153691..3e52779 100644 --- a/bindings/rust/src/lib.rs +++ b/bindings/rust/src/lib.rs @@ -416,11 +416,16 @@ impl App { /// # Example /// /// ```rust,no_run + /// # use gossamer_rs::{App, Error}; + /// # fn main() -> Result<(), Error> { + /// let mut app = App::new("Example", 800, 600)?; /// app.command("load_document", |payload| { /// let path = payload["path"].as_str().ok_or("missing path")?; /// let content = std::fs::read_to_string(path).map_err(|e| e.to_string())?; /// Ok(serde_json::json!({ "content": content })) /// }); + /// # Ok(()) + /// # } /// ``` pub fn command(&mut self, name: &str, handler: F) where @@ -462,12 +467,15 @@ impl App { /// # Example /// /// ```rust,no_run + /// # use gossamer_rs::{App, Error}; + /// # fn main() -> Result<(), Error> { + /// let mut app = App::new("Example", 800, 600)?; /// app.command_async("fetch_data", |payload| { /// let url = payload["url"].as_str().ok_or("missing url")?; - /// let body = ureq::get(url).call().map_err(|e| e.to_string())? - /// .into_string().map_err(|e| e.to_string())?; - /// Ok(serde_json::json!({ "body": body })) + /// Ok(serde_json::json!({ "requested": url, "status": "queued" })) /// }); + /// # Ok(()) + /// # } /// ``` pub fn command_async(&mut self, name: &str, handler: F) where @@ -580,7 +588,12 @@ impl App { /// # Example /// /// ```rust,no_run + /// # use gossamer_rs::{App, Error}; + /// # fn main() -> Result<(), Error> { + /// let app = App::new("Example", 800, 600)?; /// app.set_csp("default-src 'self'; script-src 'self' 'unsafe-inline'")?; + /// # Ok(()) + /// # } /// ``` pub fn set_csp(&self, csp: &str) -> Result<(), Error> { let csp_c = CString::new(csp).map_err(|e| Error::InvalidString(e.to_string()))?; @@ -599,7 +612,12 @@ impl App { /// # Example /// /// ```rust,no_run + /// # use gossamer_rs::{App, Error}; + /// # fn main() -> Result<(), Error> { + /// let app = App::new("Example", 800, 600)?; /// app.emit("file_changed", r#"{"path":"/tmp/foo.txt"}"#)?; + /// # Ok(()) + /// # } /// ``` pub fn emit(&self, event_name: &str, payload_json: &str) -> Result<(), Error> { let event_c = diff --git a/cli/src/main.zig b/cli/src/main.zig index fcda686..234f885 100644 --- a/cli/src/main.zig +++ b/cli/src/main.zig @@ -711,7 +711,7 @@ fn cmdInit() !void { \\ "maxWidth": null, \\ "maxHeight": null, \\ "resizable": true, - \\ "decorations": true + \\ "decorations": true, \\ "visible": true \\ }], \\ "mode": "gui", From 50f319ea84cbaf472887fc6eec5086528244a5ac Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Fri, 3 Apr 2026 14:32:27 +0100 Subject: [PATCH 3/4] fix(ci): repair broken GitHub Action pins --- .github/workflows/e2e.yml | 2 +- .github/workflows/hypatia-scan.yml | 2 +- .github/workflows/static-analysis-gate.yml | 8 ++++---- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 4f638eb..2a85f2a 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -52,7 +52,7 @@ jobs: fi - name: Install Zig - uses: goto-bus-stop/setup-zig@7ab2955eb728f5440978d7b4f723a50dea1f3608 # v2 + uses: goto-bus-stop/setup-zig@abea47f85e598557f500fa1fd2ab7464fcb39406 # v2 with: version: 0.15.0 diff --git a/.github/workflows/hypatia-scan.yml b/.github/workflows/hypatia-scan.yml index 54b088c..4ce2d90 100644 --- a/.github/workflows/hypatia-scan.yml +++ b/.github/workflows/hypatia-scan.yml @@ -79,7 +79,7 @@ jobs: echo "- Medium: $MEDIUM" >> $GITHUB_STEP_SUMMARY - name: Upload findings artifact - uses: actions/upload-artifact@65c79d7f54e76e4e3c7a8f34db0f4ac8b515c478 # v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: hypatia-findings path: hypatia-findings.json diff --git a/.github/workflows/static-analysis-gate.yml b/.github/workflows/static-analysis-gate.yml index ee5f4cd..fdf7851 100644 --- a/.github/workflows/static-analysis-gate.yml +++ b/.github/workflows/static-analysis-gate.yml @@ -112,7 +112,7 @@ jobs: echo "Skipped: panic-attack not available in this environment." >> "$GITHUB_STEP_SUMMARY" - name: Upload panic-attack findings - uses: actions/upload-artifact@65c79d7f54e76e4e3c7a8f34db0f4ac8b515c478 # v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: panic-attack-findings path: panic-attack-findings.json @@ -225,7 +225,7 @@ jobs: echo "Skipped: Hypatia scanner not available in this environment." >> "$GITHUB_STEP_SUMMARY" - name: Upload hypatia findings - uses: actions/upload-artifact@65c79d7f54e76e4e3c7a8f34db0f4ac8b515c478 # v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: hypatia-findings path: hypatia-findings.json @@ -315,7 +315,7 @@ jobs: echo "Skipped: panic-attack not available in this environment." >> "$GITHUB_STEP_SUMMARY" - name: Upload bridge report - uses: actions/upload-artifact@65c79d7f54e76e4e3c7a8f34db0f4ac8b515c478 # v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: bridge-report path: bridge-report.json @@ -413,7 +413,7 @@ jobs: echo "low=$LOW" >> "$GITHUB_OUTPUT" - name: Upload unified findings (fleet scanner picks these up) - uses: actions/upload-artifact@65c79d7f54e76e4e3c7a8f34db0f4ac8b515c478 # v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: unified-findings path: findings/unified-findings.json From a926689999dcebe5fae3f2cce947110364518a0f Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Fri, 3 Apr 2026 14:34:43 +0100 Subject: [PATCH 4/4] fix(ci): use available Zig 0.15.2 in E2E workflow --- .github/workflows/e2e.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 2a85f2a..fad0ef0 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -54,7 +54,7 @@ jobs: - name: Install Zig uses: goto-bus-stop/setup-zig@abea47f85e598557f500fa1fd2ab7464fcb39406 # v2 with: - version: 0.15.0 + version: 0.15.2 - name: Install GTK/WebKit dev headers run: sudo apt-get update && sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.1-dev