Files
sovereign_browser/src/tab_manager.c
T

3809 lines
160 KiB
C
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/*
* tab_manager.c — multi-tab management for sovereign_browser
*
* Each tab is a GtkBox page containing a per-tab toolbar (hamburger menu +
* URL entry) and a WebKitWebView. All webviews share the same
* WebKitWebContext so the sovereign:// bridge and security settings apply
* to every tab automatically.
*/
#include "tab_manager.h"
#include "settings.h"
#include "nostr_inject.h"
#include "perf_probe.h"
#include "nostr_url.h"
#include "history.h"
#include "bookmarks.h"
#include "search.h"
#include "version.h"
#include "embedded_web_content.h"
#include "agent_snapshot.h" /* agent_js_eval_sync() for clear-and-reload */
#include "db.h"
#include "net_services.h"
#include "agent_chat.h" /* agent_chat_route_input() for ";" URL-bar shortcut */
#include <string.h>
#include <strings.h> /* strcasecmp */
#include <stdlib.h>
#include <libsoup/soup.h>
#include <gdk-pixbuf/gdk-pixbuf.h>
/* Portable case-insensitive substring search (replaces GNU strcasestr). */
static const char *ci_strstr(const char *haystack, const char *needle) {
if (haystack == NULL || needle == NULL) return NULL;
if (*needle == '\0') return haystack;
size_t nlen = strlen(needle);
for (const char *p = haystack; *p; p++) {
if (strncasecmp(p, needle, nlen) == 0) return p;
}
return NULL;
}
/* Recursively collect all bookmarks in the bookmark tree into a GPtrArray
* of `const bookmark_t *` (shallow — pointers are valid until the next
* bookmarks mutation). Used by URL completion and the hamburger menu. */
static void collect_all_bookmarks(const bookmark_node_t *node, GPtrArray *out) {
if (node == NULL) return;
for (int i = 0; i < node->bookmark_count; i++) {
g_ptr_array_add(out, &node->bookmarks[i]);
}
for (int i = 0; i < node->child_count; i++) {
collect_all_bookmarks(&node->children[i], out);
}
}
/* ── External callbacks defined in main.c ─────────────────────────── *
* These handle identity-related menu actions that need access to the
* global app_state_t (signer, pubkey, method). main.c owns that state.
*/
/* Proxy functions defined in main.c that wrap the app_state_t-aware
* callbacks so they match GTK signal handler signatures. */
extern void app_menu_switch_identity_proxy(GtkMenuItem *item, gpointer data);
extern void app_menu_lock_session_proxy(GtkMenuItem *item, gpointer data);
extern void app_menu_logout_proxy(GtkMenuItem *item, gpointer data);
extern void app_menu_security_strip_proxy(GtkCheckMenuItem *item, gpointer data);
extern void app_menu_network_service_proxy(GtkCheckMenuItem *item, gpointer data);
extern void app_menu_nostr_sign_proxy(GtkMenuItem *item, gpointer data);
extern void app_menu_about_proxy(GtkMenuItem *item, gpointer data);
extern void on_menu_settings(GtkMenuItem *item, gpointer data);
extern void on_menu_profile(GtkMenuItem *item, gpointer data);
extern void on_menu_agent(GtkMenuItem *item, gpointer data);
extern void on_menu_fips(GtkMenuItem *item, gpointer data);
extern void on_menu_processes(GtkMenuItem *item, gpointer data);
/* Key press handler defined in main.c — connected to each webview so
* keyboard shortcuts are caught before WebKit consumes the event. */
extern gboolean on_key_press(GtkWidget *widget, GdkEventKey *event,
gpointer data);
/* ── Per-window sidebar state ─────────────────────────────────────── *
* The agent chat sidebar is per-window (not per-tab). Each window has
* a window-level GtkPaned (sidebar | notebook). The sidebar webview is
* lazily created on first toggle. All windows' sidebars show the same
* global chat session (agent_chat_store's g_current_session_id).
* Visibility is independent per-window. */
typedef struct {
GtkWindow *window;
GtkWidget *notebook;
GtkWidget *paned; /* window-level: sidebar | notebook */
GtkWidget *sidebar_container; /* GtkBox for sidebar webview */
WebKitWebView *sidebar_webview; /* lazily created on first toggle */
gboolean sidebar_visible;
} window_state_t;
/* ── Static state ─────────────────────────────────────────────────── */
static GtkWidget *g_notebook = NULL;
static WebKitWebContext *g_ctx = NULL;
static GtkWindow *g_window = NULL;
/* Active window/notebook for MCP get_active_webview().
* When a new window gets focus, these are updated so MCP tools operate
* on the focused window's webview. Defaults to the main window/notebook. */
static GtkWindow *g_active_window = NULL;
static GtkWidget *g_active_notebook = NULL;
/* ── Per-window sidebar state ─────────────────────────────────────── *
* The agent chat sidebar is now per-window (not per-tab). Each window
* has a window-level GtkPaned (sidebar | notebook). The sidebar
* webview is lazily created on first toggle. All windows' sidebars
* show the same global chat session (agent_chat_store's
* g_current_session_id). Visibility is independent per-window.
*
* The window_state_t struct and helper forward declarations are above
* (in the forward declarations section) because on_window_focus_in /
* on_aux_window_destroy — which are defined before the sidebar section —
* need them. */
static window_state_t g_main_window = {0};
static GArray *g_aux_windows = NULL; /* GArray of window_state_t */
static window_state_t *g_active_ws = NULL; /* points to g_main_window or an aux entry */
/* Target notebook for the next tab_create() call.
*
* When non-NULL, tab_create()/tab_manager_new_tab() add the new tab to
* this notebook instead of g_notebook. Used by tab_manager_new_window()
* to place the first tab into the newly created window's notebook.
*
* When NULL, tab_manager_new_tab() falls back to g_active_notebook (the
* focused window's notebook) so Ctrl+T / "New Tab" opens in the active
* window rather than always the main window. */
static GtkWidget *g_target_notebook = NULL;
/* Related view for the next tab_create() call.
*
* When non-NULL, tab_create() creates the webview with
* webkit_web_view_new_with_related_view() so it shares the parent's
* WebProcess and window features (avoids the WindowFeatures assertion
* crash). Used by tab_manager_new_window(). */
static WebKitWebView *g_target_related_view = NULL;
/* Dynamic array of tab_info_t pointers, indexed by notebook page number. */
static tab_info_t **g_tabs = NULL;
static int g_tab_count = 0;
static int g_tab_cap = 0;
/* ── Forward declarations ─────────────────────────────────────────── */
/* Per-window sidebar helpers (defined in the sidebar section at the
* bottom of this file, but needed by on_window_focus_in /
* on_aux_window_destroy which are defined earlier). The window_state_t
* typedef is above, before the static state section. */
static window_state_t *get_active_window_state(void);
static window_state_t *window_state_for_notebook(GtkWidget *notebook);
static tab_info_t *tab_create(const char *url);
static GtkWidget *build_tab_label(tab_info_t *tab);
static GtkWidget *tab_find_notebook(GtkWidget *page);
static int tab_array_add(tab_info_t *tab);
static int tab_array_find(tab_info_t *tab);
static GtkWidget *build_hamburger_menu(tab_info_t *tab);
static char *normalize_url(const char *input);
static void on_tab_close_clicked_proxy_new(GtkMenuItem *item, gpointer data);
static void on_avatar_clicked(GtkButton *btn, gpointer data);
static GtkWidget *tab_manager_new_window(const char *url,
WebKitWebView *related_view);
static gboolean on_notebook_button_press(GtkWidget *widget,
GdkEventButton *event,
gpointer user_data);
static void on_new_tab_clicked(GtkButton *btn, gpointer data);
static void setup_notebook_action_widgets(GtkWidget *notebook,
gboolean is_main);
static void track_avatar_image(GtkWidget *image);
static gboolean on_window_focus_in(GtkWidget *widget,
GdkEventFocus *event,
gpointer user_data);
static void on_aux_window_destroy(GtkWidget *widget,
gpointer user_data);
/* Forward declarations for signal handlers used by tab_manager_new_window
* (which is defined before these handlers in the file). */
static void on_load_changed(WebKitWebView *webview,
WebKitLoadEvent load_event,
gpointer user_data);
static gboolean on_load_failed(WebKitWebView *webview,
WebKitLoadEvent load_event,
gchar *failing_uri,
GError *error,
gpointer data);
static gboolean on_webview_context_menu(WebKitWebView *webview,
WebKitContextMenu *context_menu,
GdkEvent *event,
WebKitHitTestResult *hit_test,
gpointer user_data);
/* ── URL bar completion (search dropdown) ─────────────────────────── *
* Each tab's URL entry has a GtkEntryCompletion backed by a GtkListStore.
* The list store has these columns:
*/
enum {
COMPLETION_COL_DISPLAY = 0, /* visible text in the dropdown */
COMPLETION_COL_URL, /* the URL to navigate to (or NULL for
search suggestions — in that case
the display text is the search query) */
COMPLETION_COL_IS_DIRECT, /* TRUE = direct link, FALSE = search
suggestion */
COMPLETION_COL_COUNT
};
/* Per-tab completion state, attached to the GtkEntry via g_object_set_data. */
typedef struct {
GtkListStore *store;
guint suggest_req_id; /* active async suggestion request, 0=none */
char *last_query; /* last query we fetched suggestions for */
} completion_state_t;
static void on_url_changed(GtkEditable *editable, gpointer user_data);
static gboolean on_completion_match_selected(GtkEntryCompletion *completion,
GtkTreeModel *model,
GtkTreeIter *iter,
gpointer user_data);
static void on_suggestions_received(char **suggestions, gpointer user_data);
static void completion_state_free(completion_state_t *cs);
static void rebuild_completion(const char *query, completion_state_t *cs);
static gboolean on_url_key_press(GtkWidget *widget, GdkEventKey *event,
gpointer user_data);
/* ── URL helper (same logic as main.c's normalize_url) ────────────── *
* If the input looks like a URL (has a scheme, a dot with no spaces,
* is localhost, or is an about: page), it's normalized to a full URL.
* Otherwise, it's treated as a search query and sent to the active
* search engine.
*/
/* Convert fips://host[:port]/path?query#fragment into normal HTTP while
* inserting the mesh DNS suffix after the authority's host component. */
static char *normalize_fips_url(const char *input) {
if (input == NULL || strncmp(input, "fips://", 7) != 0) return NULL;
const char *rest = input + 7;
const char *suffix = strpbrk(rest, "/?#");
size_t authority_len = suffix ? (size_t)(suffix - rest) : strlen(rest);
if (authority_len == 0) return NULL;
char *authority = g_strndup(rest, authority_len);
char *result = NULL;
if (g_str_has_suffix(authority, ".fips"))
result = g_strdup_printf("http://%s%s", authority, suffix ? suffix : "");
else {
char *colon = strrchr(authority, ':');
if (colon && strchr(authority, ':') == colon) {
*colon = '\0';
result = g_strdup_printf("http://%s.fips:%s%s", authority,
colon + 1, suffix ? suffix : "");
} else {
result = g_strdup_printf("http://%s.fips%s", authority,
suffix ? suffix : "");
}
}
g_free(authority);
return result;
}
static gboolean authority_is_onion_host(const char *authority) {
if (authority == NULL || authority[0] == '\0') return FALSE;
char *hostport = g_strdup(authority);
char *host = hostport;
char *at = strrchr(host, '@');
if (at) host = at + 1;
if (host[0] == '[') {
g_free(hostport);
return FALSE;
}
char *colon = strrchr(host, ':');
if (colon && strchr(host, ':') == colon) *colon = '\0';
gboolean is_onion = g_str_has_suffix(host, ".onion");
g_free(hostport);
return is_onion;
}
static gboolean input_is_onion_without_scheme(const char *input) {
if (!input || strstr(input, "://") != NULL || strchr(input, ' ') != NULL)
return FALSE;
const char *end = strpbrk(input, "/?#");
size_t authority_len = end ? (size_t)(end - input) : strlen(input);
if (authority_len == 0) return FALSE;
char *authority = g_strndup(input, authority_len);
gboolean is_onion = authority_is_onion_host(authority);
g_free(authority);
return is_onion;
}
static gboolean uri_is_http_onion(const char *uri, const char **rest_out) {
if (uri == NULL) return FALSE;
const char *rest = NULL;
if (g_str_has_prefix(uri, "http://")) rest = uri + 7;
else if (g_str_has_prefix(uri, "https://")) rest = uri + 8;
else return FALSE;
const char *end = strpbrk(rest, "/?#");
size_t authority_len = end ? (size_t)(end - rest) : strlen(rest);
if (authority_len == 0) return FALSE;
char *authority = g_strndup(rest, authority_len);
gboolean is_onion = authority_is_onion_host(authority);
g_free(authority);
if (is_onion && rest_out) *rest_out = rest;
return is_onion;
}
static char *normalize_url(const char *input) {
if (input == NULL || input[0] == '\0') {
return NULL;
}
if (strncmp(input, "fips://", 7) == 0)
return normalize_fips_url(input);
/* Route bare NIP-19 entities and NIP-21 links through nostr://. Private
* keys are deliberately not normalized into navigable URLs. */
nostr_entity_type_t entity_type = nostr_url_detect(input);
if (entity_type != NOSTR_ENTITY_NONE) {
return nostr_url_normalize(input);
}
/* If it looks like a URL, normalize it. */
if (search_is_url(input)) {
if (strstr(input, "://") != NULL) {
return g_strdup(input);
}
/* Allow about: URLs (e.g. about:blank) without a scheme prefix. */
if (strncmp(input, "about:", 6) == 0) {
return g_strdup(input);
}
/* sovereign:// internal pages. */
if (strncmp(input, "sovereign://", 12) == 0) {
return g_strdup(input);
}
if (input_is_onion_without_scheme(input)) {
return g_strdup_printf("http://%s", input);
}
return g_strdup_printf("https://%s", input);
}
/* Not a URL — treat as a search query. */
return search_build_search_url(input);
}
/* ── URL bar completion (search dropdown) ─────────────────────────── */
#define COMPLETION_MAX_DIRECT 8 /* max history/bookmark results */
#define COMPLETION_MAX_SUGGEST 8 /* max search engine suggestions */
#define COMPLETION_MIN_KEY_LEN 1 /* min chars before dropdown appears */
static void completion_state_free(completion_state_t *cs) {
if (cs == NULL) return;
if (cs->store) g_object_unref(cs->store);
if (cs->suggest_req_id) search_suggest_cancel(cs->suggest_req_id);
g_free(cs->last_query);
g_free(cs);
}
/*
* Extract a short display label from a URL for the dropdown.
* Strips the scheme and leading "www." for readability.
* Returns a newly allocated string.
*/
static char *url_display_label(const char *url, const char *title) {
if (title && title[0] != '\0') {
/* If we have a title, show "title — domain". */
const char *domain = url;
if (strncmp(domain, "https://", 8) == 0) domain += 8;
else if (strncmp(domain, "http://", 7) == 0) domain += 7;
if (strncmp(domain, "www.", 4) == 0) domain += 4;
/* Truncate domain at first / */
const char *slash = strchr(domain, '/');
int dlen = slash ? (int)(slash - domain) : (int)strlen(domain);
return g_strdup_printf("%s — %.*s", title, dlen, domain);
}
/* No title — just show the URL with scheme stripped. */
const char *label = url;
if (strncmp(label, "https://", 8) == 0) label += 8;
else if (strncmp(label, "http://", 7) == 0) label += 7;
return g_strdup(label);
}
/*
* Rebuild the completion list store for the given query.
* Called on every keystroke. Populates direct links (history + bookmarks)
* synchronously, then fires an async request for search engine suggestions.
*/
static void rebuild_completion(const char *query, completion_state_t *cs) {
if (cs == NULL || cs->store == NULL) return;
gtk_list_store_clear(cs->store);
if (query == NULL || query[0] == '\0') return;
/* Cancel any pending suggestion request. */
if (cs->suggest_req_id) {
search_suggest_cancel(cs->suggest_req_id);
cs->suggest_req_id = 0;
}
g_free(cs->last_query);
cs->last_query = g_strdup(query);
/* ── Direct links from history ────────────────────────────────── */
char **urls = NULL;
char **titles = NULL;
int count = 0;
db_history_search(query, &urls, &titles, &count, COMPLETION_MAX_DIRECT);
for (int i = 0; i < count; i++) {
char *label = url_display_label(urls[i], titles[i]);
GtkTreeIter iter;
gtk_list_store_append(cs->store, &iter);
gtk_list_store_set(cs->store, &iter,
COMPLETION_COL_DISPLAY, label,
COMPLETION_COL_URL, urls[i],
COMPLETION_COL_IS_DIRECT, TRUE,
-1);
g_free(label);
g_free(urls[i]);
g_free(titles[i]);
}
g_free(urls);
g_free(titles);
/* ── Direct links from bookmarks ──────────────────────────────── */
GPtrArray *bms = g_ptr_array_new();
collect_all_bookmarks(bookmarks_get_root(), bms);
int bookmark_added = 0;
for (guint i = 0; i < bms->len && bookmark_added < COMPLETION_MAX_DIRECT; i++) {
const bookmark_t *bm = (const bookmark_t *)g_ptr_array_index(bms, i);
/* Case-insensitive substring search. */
if ((bm->url && ci_strstr(bm->url, query)) ||
(bm->title && ci_strstr(bm->title, query))) {
char *label = url_display_label(bm->url, bm->title);
GtkTreeIter iter;
gtk_list_store_append(cs->store, &iter);
gtk_list_store_set(cs->store, &iter,
COMPLETION_COL_DISPLAY, label,
COMPLETION_COL_URL, bm->url,
COMPLETION_COL_IS_DIRECT, TRUE,
-1);
g_free(label);
bookmark_added++;
}
}
g_ptr_array_free(bms, TRUE);
/* ── Domain heuristic ─────────────────────────────────────────── *
* If the query has no spaces and no dots, offer to navigate directly
* to <query>.org and <query>.com. This helps first-time visits to
* known domains without going through the search engine. */
if (strchr(query, ' ') == NULL && strchr(query, '.') == NULL &&
strlen(query) > 0 && strlen(query) < 100) {
/* Only add if we don't already have a direct link matching. */
gboolean have_match = FALSE;
GtkTreeIter iter;
if (gtk_tree_model_get_iter_first(GTK_TREE_MODEL(cs->store), &iter)) {
do {
gchar *row_url = NULL;
gtk_tree_model_get(GTK_TREE_MODEL(cs->store), &iter,
COMPLETION_COL_URL, &row_url, -1);
if (row_url) {
/* Check if the domain part matches the query. */
const char *domain = row_url;
if (strncmp(domain, "https://", 8) == 0) domain += 8;
else if (strncmp(domain, "http://", 7) == 0) domain += 7;
if (strncmp(domain, "www.", 4) == 0) domain += 4;
if (strncasecmp(domain, query, strlen(query)) == 0) {
have_match = TRUE;
}
g_free(row_url);
}
} while (!have_match &&
gtk_tree_model_iter_next(GTK_TREE_MODEL(cs->store), &iter));
}
if (!have_match) {
char *org_url = g_strdup_printf("https://%s.org", query);
char *org_label = g_strdup_printf("🌐 Go to %s.org", query);
gtk_list_store_append(cs->store, &iter);
gtk_list_store_set(cs->store, &iter,
COMPLETION_COL_DISPLAY, org_label,
COMPLETION_COL_URL, org_url,
COMPLETION_COL_IS_DIRECT, TRUE,
-1);
g_free(org_url);
g_free(org_label);
char *com_url = g_strdup_printf("https://%s.com", query);
char *com_label = g_strdup_printf("🌐 Go to %s.com", query);
gtk_list_store_append(cs->store, &iter);
gtk_list_store_set(cs->store, &iter,
COMPLETION_COL_DISPLAY, com_label,
COMPLETION_COL_URL, com_url,
COMPLETION_COL_IS_DIRECT, TRUE,
-1);
g_free(com_url);
g_free(com_label);
}
}
/* ── Fire async search engine suggestions ─────────────────────── *
* The suggestions are appended to the store when the async callback
* fires (on_suggestions_received). We only fetch if the query doesn't
* look like a URL (no point suggesting searches for "wikipedia.org"). */
if (!search_is_url(query)) {
cs->suggest_req_id = search_suggest_fetch_async(
query, on_suggestions_received, cs);
}
}
/*
* Callback for async search engine suggestions.
* Appends the suggestions to the completion store. The user_data is
* the completion_state_t. We check that the query hasn't changed since
* we sent the request (stale results are discarded).
*/
static void on_suggestions_received(char **suggestions, gpointer user_data) {
completion_state_t *cs = (completion_state_t *)user_data;
if (cs == NULL || cs->store == NULL) return;
/* Mark the request as completed. */
cs->suggest_req_id = 0;
if (suggestions == NULL) return;
/* Append search suggestions after the direct links.
* For search suggestions, the URL column stores the search query
* itself (not a URL). The is_direct flag is FALSE, so
* on_completion_match_selected knows to build a search URL from it. */
for (int i = 0; suggestions[i] != NULL && i < COMPLETION_MAX_SUGGEST; i++) {
char *label = g_strdup_printf("🔍 %s", suggestions[i]);
GtkTreeIter iter;
gtk_list_store_append(cs->store, &iter);
gtk_list_store_set(cs->store, &iter,
COMPLETION_COL_DISPLAY, label,
COMPLETION_COL_URL, suggestions[i],
COMPLETION_COL_IS_DIRECT, FALSE,
-1);
g_free(label);
}
}
/*
* Called when the user types in the URL entry.
* Rebuilds the completion dropdown.
*/
static void on_url_changed(GtkEditable *editable, gpointer user_data) {
(void)user_data;
/* Only rebuild the completion dropdown when the user is actively
* typing. If the entry doesn't have focus, the text was set
* programmatically (e.g. by a navigation event updating the URL
* bar) and we shouldn't show the dropdown. */
if (!gtk_widget_has_focus(GTK_WIDGET(editable))) return;
const char *text = gtk_entry_get_text(GTK_ENTRY(editable));
completion_state_t *cs = (completion_state_t *)
g_object_get_data(G_OBJECT(editable), "completion-state");
if (cs == NULL) return;
rebuild_completion(text, cs);
}
/*
* Key press handler for the URL entry.
*
* When the completion dropdown is visible, Tab navigates down and
* Shift+Tab navigates up through the suggestions (like most browsers).
* Without this, Tab would move focus away from the URL bar.
*
* When the dropdown is not visible, Tab behaves normally (moves focus).
*/
static gboolean on_url_key_press(GtkWidget *widget, GdkEventKey *event,
gpointer user_data) {
(void)user_data;
/* Only intercept Tab when the completion popup is visible. */
if (event->keyval != GDK_KEY_Tab && event->keyval != GDK_KEY_ISO_Left_Tab)
return FALSE;
GtkEntry *entry = GTK_ENTRY(widget);
GtkEntryCompletion *completion = gtk_entry_get_completion(entry);
if (completion == NULL) return FALSE;
/* Check if the completion popup is visible. GtkEntryCompletion
* doesn't have a direct "is popup visible" API, but we can check
* if there's a completion selection by looking at the tree model.
* If the store has items, we intercept Tab to navigate. */
completion_state_t *cs = (completion_state_t *)
g_object_get_data(G_OBJECT(entry), "completion-state");
if (cs == NULL || cs->store == NULL) return FALSE;
GtkTreeIter iter;
if (!gtk_tree_model_get_iter_first(GTK_TREE_MODEL(cs->store), &iter))
return FALSE; /* No items in the store — let Tab do its normal thing. */
/* Synthesize a Down or Up arrow key event and forward it to the
* entry. GtkEntryCompletion intercepts arrow keys to navigate
* the dropdown selection. */
GdkEventKey *synth = (GdkEventKey *)gdk_event_new(GDK_KEY_PRESS);
synth->window = g_object_ref(event->window);
synth->send_event = TRUE;
synth->time = event->time;
synth->state = 0; /* No modifiers for the arrow key. */
synth->keyval = (event->state & GDK_SHIFT_MASK)
? GDK_KEY_Up
: GDK_KEY_Down;
synth->length = 0;
synth->string = NULL;
synth->hardware_keycode = 0;
synth->group = 0;
/* Forward the synthetic event to the entry widget. */
gtk_main_do_event((GdkEvent *)synth);
gdk_event_free((GdkEvent *)synth);
return TRUE; /* We handled the Tab — don't move focus. */
}
/*
* Called when the user selects an item from the completion dropdown.
* Navigates to the selected URL (direct link) or builds a search URL
* (for search suggestions).
*/
static gboolean on_completion_match_selected(GtkEntryCompletion *completion,
GtkTreeModel *model,
GtkTreeIter *iter,
gpointer user_data) {
(void)completion;
tab_info_t *tab = (tab_info_t *)user_data;
gchar *display = NULL;
gchar *url = NULL;
gboolean is_direct = FALSE;
gtk_tree_model_get(model, iter,
COMPLETION_COL_DISPLAY, &display,
COMPLETION_COL_URL, &url,
COMPLETION_COL_IS_DIRECT, &is_direct,
-1);
char *navigate_url = NULL;
if (is_direct && url != NULL) {
/* Direct link — navigate to the URL. */
navigate_url = g_strdup(url);
} else if (url != NULL) {
/* Search suggestion — the URL column stores the search query.
* Build a search URL from it. */
navigate_url = search_build_search_url(url);
} else {
/* Fallback: no URL stored. Use the display text as the query. */
navigate_url = search_build_search_url(display);
}
if (navigate_url) {
webkit_web_view_load_uri(tab->webview, navigate_url);
gtk_entry_set_text(GTK_ENTRY(tab->url_entry), navigate_url);
g_free(navigate_url);
}
g_free(display);
g_free(url);
return TRUE; /* We handled it — don't let GTK do default behavior. */
}
/* ── Tab label widget ─────────────────────────────────────────────── */
static void on_tab_close_clicked(GtkButton *btn, gpointer user_data) {
tab_info_t *tab = (tab_info_t *)user_data;
(void)btn;
/* Look up the tab's index in the global g_tabs array by pointer.
* This works for tabs in any window's notebook (the index returned
* by gtk_notebook_page_num only matches g_tabs for the main window). */
int index = tab_array_find(tab);
if (index >= 0) {
tab_manager_close_tab(index);
}
}
/* Notebook-level button-press handler. Catches right-clicks on tab
* labels that GtkNotebook's internal handling would otherwise consume
* before the tab label's own button-press-event handler fires. We find
* which tab was clicked by checking the tab label's allocation against
* the click coordinates, then show the same context menu as
* on_tab_label_button_press. */
static gboolean on_notebook_button_press(GtkWidget *widget,
GdkEventButton *event,
gpointer user_data) {
(void)user_data;
if (widget == NULL) return FALSE;
GtkNotebook *nb = GTK_NOTEBOOK(widget);
if (event->button != 3 && event->button != 2) return FALSE;
gint n_pages = gtk_notebook_get_n_pages(nb);
for (gint i = 0; i < n_pages; i++) {
GtkWidget *page = gtk_notebook_get_nth_page(nb, i);
if (page == NULL) continue;
GtkWidget *label = gtk_notebook_get_tab_label(nb, page);
if (label == NULL) continue;
GtkAllocation alloc;
gtk_widget_get_allocation(label, &alloc);
/* Convert click coords to the label's coordinate space. The
* event coords are relative to the notebook's bin window. */
gint x = (gint)event->x;
gint y = (gint)event->y;
/* Get the label's allocation position relative to the notebook. */
gint lx = alloc.x;
gint ly = alloc.y;
if (x >= lx && x < lx + alloc.width &&
y >= ly && y < ly + alloc.height) {
/* Found the clicked tab. Look up the tab_info_t. */
if (i < g_tab_count && g_tabs[i] != NULL) {
/* Reuse the existing handler by synthesizing a call. */
tab_info_t *tab = g_tabs[i];
int index = tab_array_find(tab);
if (index < 0) return FALSE;
if (event->button == 2) {
const browser_settings_t *s = settings_get();
if (s->middle_click_close) {
tab_manager_close_tab(index);
return TRUE;
}
return FALSE;
}
/* Right-click: show context menu (same as
* on_tab_label_button_press). */
GtkWidget *menu = gtk_menu_new();
GtkWidget *item_new = gtk_menu_item_new_with_label("New Tab");
g_signal_connect(item_new, "activate",
G_CALLBACK(on_tab_close_clicked_proxy_new), NULL);
gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_new);
GtkWidget *item_close = gtk_menu_item_new_with_label("Close Tab");
g_signal_connect_swapped(item_close, "activate",
G_CALLBACK(tab_manager_close_tab),
GINT_TO_POINTER(index));
gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_close);
GtkWidget *item_close_others =
gtk_menu_item_new_with_label("Close Other Tabs");
g_signal_connect_swapped(item_close_others, "activate",
G_CALLBACK(tab_manager_close_others),
GINT_TO_POINTER(index));
gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_close_others);
GtkWidget *item_close_right =
gtk_menu_item_new_with_label("Close Tabs to the Right");
g_signal_connect_swapped(item_close_right, "activate",
G_CALLBACK(tab_manager_close_to_right),
GINT_TO_POINTER(index));
gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_close_right);
gtk_menu_shell_append(GTK_MENU_SHELL(menu),
gtk_separator_menu_item_new());
GtkWidget *item_new_win =
gtk_menu_item_new_with_label("Open in New Window");
g_signal_connect_swapped(item_new_win, "activate",
G_CALLBACK(tab_manager_open_in_new_window),
GINT_TO_POINTER(index));
gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_new_win);
GtkWidget *item_dup = gtk_menu_item_new_with_label("Duplicate Tab");
g_signal_connect_swapped(item_dup, "activate",
G_CALLBACK(tab_manager_duplicate),
GINT_TO_POINTER(index));
gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_dup);
GtkWidget *item_reload = gtk_menu_item_new_with_label("Reload Tab");
g_signal_connect_swapped(item_reload, "activate",
G_CALLBACK(tab_manager_reload),
GINT_TO_POINTER(index));
gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_reload);
gtk_widget_show_all(menu);
gtk_menu_popup_at_pointer(GTK_MENU(menu), (GdkEvent *)event);
return TRUE;
}
}
}
return FALSE;
}
static gboolean on_tab_label_button_press(GtkWidget *widget,
GdkEventButton *event,
gpointer user_data) {
(void)widget;
tab_info_t *tab = (tab_info_t *)user_data;
/* Look up the tab's index in the global g_tabs array by pointer. */
int index = tab_array_find(tab);
if (index < 0) return FALSE;
const browser_settings_t *s = settings_get();
/* Middle-click to close. */
if (event->button == 2 && s->middle_click_close) {
tab_manager_close_tab(index);
return TRUE;
}
/* Right-click context menu. */
if (event->button == 3) {
GtkWidget *menu = gtk_menu_new();
GtkWidget *item_new = gtk_menu_item_new_with_label("New Tab");
g_signal_connect(item_new, "activate",
G_CALLBACK(on_tab_close_clicked_proxy_new), NULL);
gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_new);
GtkWidget *item_close = gtk_menu_item_new_with_label("Close Tab");
g_signal_connect_swapped(item_close, "activate",
G_CALLBACK(tab_manager_close_tab),
GINT_TO_POINTER(index));
gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_close);
GtkWidget *item_close_others =
gtk_menu_item_new_with_label("Close Other Tabs");
g_signal_connect_swapped(item_close_others, "activate",
G_CALLBACK(tab_manager_close_others),
GINT_TO_POINTER(index));
gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_close_others);
GtkWidget *item_close_right =
gtk_menu_item_new_with_label("Close Tabs to the Right");
g_signal_connect_swapped(item_close_right, "activate",
G_CALLBACK(tab_manager_close_to_right),
GINT_TO_POINTER(index));
gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_close_right);
gtk_menu_shell_append(GTK_MENU_SHELL(menu),
gtk_separator_menu_item_new());
GtkWidget *item_new_win =
gtk_menu_item_new_with_label("Open in New Window");
g_signal_connect_swapped(item_new_win, "activate",
G_CALLBACK(tab_manager_open_in_new_window),
GINT_TO_POINTER(index));
gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_new_win);
GtkWidget *item_dup = gtk_menu_item_new_with_label("Duplicate Tab");
g_signal_connect_swapped(item_dup, "activate",
G_CALLBACK(tab_manager_duplicate),
GINT_TO_POINTER(index));
gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_dup);
GtkWidget *item_reload = gtk_menu_item_new_with_label("Reload Tab");
g_signal_connect_swapped(item_reload, "activate",
G_CALLBACK(tab_manager_reload),
GINT_TO_POINTER(index));
gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_reload);
gtk_widget_show_all(menu);
gtk_menu_popup_at_pointer(GTK_MENU(menu), (GdkEvent *)event);
return TRUE;
}
return FALSE;
}
/* Proxy callback for "New Tab" in the context menu — just calls new_tab. */
static void on_tab_close_clicked_proxy_new(GtkMenuItem *item, gpointer data) {
(void)item;
(void)data;
tab_manager_new_tab(NULL);
}
/* ── Favicon handling ─────────────────────────────────────────────── *
* WebKitGTK has a built-in favicon database (WebKitFaviconDatabase) that
* automatically fetches and caches favicons as pages load. It must be
* enabled once on the WebKitWebContext via
* webkit_web_context_set_favicon_database_directory() (done in main.c).
* Once enabled, the webview emits "notify::favicon" when the favicon is
* ready, and webkit_web_view_get_favicon() returns a cairo_surface_t.
*/
static void on_favicon_changed(WebKitWebView *webview, GParamSpec *pspec,
gpointer user_data) {
tab_info_t *tab = (tab_info_t *)user_data;
(void)pspec;
cairo_surface_t *favicon = webkit_web_view_get_favicon(webview);
if (favicon == NULL) return;
int fav_w = cairo_image_surface_get_width(favicon);
int fav_h = cairo_image_surface_get_height(favicon);
if (fav_w <= 0 || fav_h <= 0) return;
GdkPixbuf *raw = gdk_pixbuf_get_from_surface(favicon, 0, 0, fav_w, fav_h);
if (raw == NULL) return;
GdkPixbuf *pixbuf;
if (fav_w > 16 || fav_h > 16) {
pixbuf = gdk_pixbuf_scale_simple(raw, 16, 16, GDK_INTERP_BILINEAR);
g_object_unref(raw);
} else {
pixbuf = raw;
}
gtk_image_set_from_pixbuf(GTK_IMAGE(tab->favicon), pixbuf);
g_object_unref(pixbuf);
g_print("[favicon] Set favicon (%dx%d) for %s\n",
fav_w, fav_h, tab->current_url);
}
/* ── Navigation policy decision ────────────────────────────────────── *
* Handles target="_blank" links and middle-click links by opening them
* in a new tab instead of ignoring them. WebKit fires the "decide-policy"
* signal with a WebKitNavigationPolicyDecision when a link is clicked.
* If the navigation action is a link click with a target that would open
* a new view (WEBKIT_NAVIGATION_TYPE_LINK_CLICKED with a non-current
* browser action), we open a new tab and ignore the default decision.
*
* URL rewriting (fips://, nostr:, .onion → tor://) is deferred to an idle
* callback. Calling webkit_web_view_load_uri() synchronously from inside
* the decide-policy handler starts a new navigation while WebKit is still
* processing the current policy decision — this re-entrancy crashes
* WebKitGTK with a segfault, most reliably when the webview is freshly
* created (e.g. a new tab opened for an onion link). By ignoring the
* current decision first and scheduling the redirect for the next idle
* tick, the policy decision completes cleanly before the new navigation
* begins.
*/
typedef struct {
WebKitWebView *webview;
char *uri; /* URL to load via webkit_web_view_load_uri */
char *html; /* HTML to load via webkit_web_view_load_html (mutually exclusive with uri) */
char *base_uri; /* base URI for load_html */
} deferred_nav_t;
static gboolean deferred_nav_idle(gpointer user_data) {
deferred_nav_t *nav = (deferred_nav_t *)user_data;
if (nav == NULL) return G_SOURCE_REMOVE;
/* The webview may have been destroyed between scheduling the idle and
* running it (e.g. the user closed the tab). Guard with WEBKIT_IS_WEB_VIEW. */
if (nav->webview && WEBKIT_IS_WEB_VIEW(nav->webview)) {
if (nav->html != NULL) {
webkit_web_view_load_html(nav->webview, nav->html,
nav->base_uri ? nav->base_uri : "about:blank");
} else if (nav->uri != NULL) {
webkit_web_view_load_uri(nav->webview, nav->uri);
}
}
g_clear_object(&nav->webview);
g_free(nav->uri);
g_free(nav->html);
g_free(nav->base_uri);
g_free(nav);
return G_SOURCE_REMOVE;
}
/* Schedule a load_uri on the next idle tick. Takes ownership of `uri`. */
static void defer_load_uri(WebKitWebView *webview, char *uri) {
deferred_nav_t *nav = g_new0(deferred_nav_t, 1);
nav->webview = g_object_ref(webview);
nav->uri = uri; /* takes ownership */
g_idle_add(deferred_nav_idle, nav);
}
/* Schedule a load_html on the next idle tick. Takes ownership of `html`
* and `base_uri`. */
static void defer_load_html(WebKitWebView *webview, char *html, char *base_uri) {
deferred_nav_t *nav = g_new0(deferred_nav_t, 1);
nav->webview = g_object_ref(webview);
nav->html = html; /* takes ownership */
nav->base_uri = base_uri; /* takes ownership */
g_idle_add(deferred_nav_idle, nav);
}
static gboolean on_decide_policy(WebKitWebView *webview,
WebKitPolicyDecision *decision,
WebKitPolicyDecisionType type,
gpointer user_data) {
(void)user_data;
if (type != WEBKIT_POLICY_DECISION_TYPE_NAVIGATION_ACTION)
return FALSE;
WebKitNavigationPolicyDecision *nav_decision =
WEBKIT_NAVIGATION_POLICY_DECISION(decision);
WebKitNavigationAction *action =
webkit_navigation_policy_decision_get_navigation_action(nav_decision);
if (action == NULL)
return FALSE;
WebKitURIRequest *request = webkit_navigation_action_get_request(action);
const char *uri = request ? webkit_uri_request_get_uri(request) : NULL;
/* fips:// is a shorthand rather than a custom WebKit URI scheme. This
* catches page links in addition to URL-bar normalization. */
if (uri && strncmp(uri, "fips://", 7) == 0) {
char *normalized = normalize_fips_url(uri);
if (normalized) {
/* Ignore the current decision first, then defer the redirect
* to the next idle tick to avoid re-entrant navigation inside
* the decide-policy handler (which segfaults WebKitGTK). */
webkit_policy_decision_ignore(decision);
defer_load_uri(webview, normalized); /* takes ownership */
return TRUE;
}
}
/* NIP-21 links use nostr:entity rather than nostr://entity. Normalize
* either form so WebKit consistently invokes our registered handler. */
if (uri && strncmp(uri, "nostr:", 6) == 0 &&
strncmp(uri, "nostr://", 8) != 0) {
nostr_entity_type_t entity_type = nostr_url_detect(uri);
if (entity_type == NOSTR_ENTITY_NSEC) {
char *html = g_strdup(
"<!doctype html><meta charset=\"utf-8\"><title>Private key blocked</title>"
"<h1>Navigation blocked</h1><p>Nostr private keys cannot be opened or navigated to.</p>");
char *base_uri = g_strdup("nostr://blocked-private-key");
webkit_policy_decision_ignore(decision);
defer_load_html(webview, html, base_uri); /* takes ownership */
return TRUE;
}
char *normalized = nostr_url_normalize(uri);
if (normalized) {
webkit_policy_decision_ignore(decision);
defer_load_uri(webview, normalized); /* takes ownership */
return TRUE;
}
}
/* Route .onion HTTP(S) traffic through tor:// so only onion addresses
* use Tor routing. Phase 1 intentionally maps both input schemes to
* tor:// and the handler currently fetches using http:// over Tor. */
const char *tor_rest = NULL;
if (uri && uri_is_http_onion(uri, &tor_rest)) {
char *tor_url = g_strdup_printf("tor://%s", tor_rest);
webkit_policy_decision_ignore(decision);
defer_load_uri(webview, tor_url); /* takes ownership */
return TRUE;
}
/* Only intercept link clicks, not form submissions or reloads. */
WebKitNavigationType nav_type =
webkit_navigation_action_get_navigation_type(action);
if (nav_type != WEBKIT_NAVIGATION_TYPE_LINK_CLICKED)
return FALSE;
/* Check if this is a new-tab request (target="_blank" or
* middle-click with modifier). webkit_navigation_action_get_request()
* gives us the URI. If the decision's frame name is not the main
* frame, or the user clicked with middle button / Ctrl, open a tab. */
guint button = webkit_navigation_action_get_mouse_button(action);
GdkModifierType mods = webkit_navigation_action_get_modifiers(action);
/* Middle-click or Ctrl+click opens a new tab. */
if (button == 2 || (mods & GDK_CONTROL_MASK)) {
if (uri && uri[0]) {
tab_manager_new_tab(uri);
}
webkit_policy_decision_ignore(decision);
return TRUE;
}
/* Check for target="_blank" — WebKit requests a new view for these.
* The navigation policy decision has a "frame name" that is non-NULL
* when a target is specified. We can't easily get the frame name from
* the API, but WebKit will call "create" on the webview for new
* windows. That's handled separately by the "create" signal.
* For now, let the default navigation proceed. */
return FALSE;
}
/* ── New webview creation (target="_blank") ────────────────────────── *
* When a page requests a new window (target="_blank", window.open()),
* WebKit fires the "create" signal on the webview. We open the requested
* URI in a new tab in the current window (matching the behavior of other
* tabbed browsers like Brave/Firefox) and return the new tab's webview.
*
* The new webview is created with webkit_web_view_new_with_related_view()
* (via the g_target_related_view mechanism in tab_create) so it shares the
* parent's WebProcess and window features — this avoids the
* std::optional<WindowFeatures> assertion crash that occurs with
* webkit_web_view_new_with_context() for target="_blank" requests.
*/
static GtkWidget *on_create_webview(WebKitWebView *webview,
WebKitNavigationAction *action,
gpointer user_data) {
(void)user_data;
WebKitURIRequest *request = webkit_navigation_action_get_request(action);
const char *uri = webkit_uri_request_get_uri(request);
if (uri && uri[0]) {
/* Set the related view so tab_create() uses
* webkit_web_view_new_with_related_view() — shares the parent's
* WebProcess and avoids the WindowFeatures assertion crash. */
g_target_related_view = webview;
int idx = tab_manager_new_tab(uri);
g_target_related_view = NULL;
if (idx >= 0) {
tab_info_t *tab = tab_manager_get(idx);
if (tab && tab->webview) {
return GTK_WIDGET(tab->webview);
}
}
}
return NULL;
}
/* ── New window creation (real GtkWindow for target="_blank") ──────── *
* Creates a new top-level GtkWindow with its own GtkNotebook and a
* single webview. The webview is created with
* webkit_web_view_new_with_related_view(related_view) when a parent
* webview is available, so it shares the parent's WebProcess and
* window features — this avoids the std::optional<WindowFeatures>
* assertion crash that occurred with webkit_web_view_new_with_context().
*
* The new window's webview gets the same setup as a regular tab:
* WebKitSettings, nostr_inject_setup, key-press-event, decide-policy,
* create, load-changed, etc. The window does NOT quit the app when
* closed — only the main window (in main.c) does that.
*
* Returns the new WebKitWebView widget, or NULL on failure.
*/
/* focus-in-event handler for auxiliary windows: updates the global
* active window/notebook pointers so MCP get_active_webview() resolves
* to the focused window's webview. */
static gboolean on_window_focus_in(GtkWidget *widget,
GdkEventFocus *event,
gpointer user_data) {
(void)event;
GtkWidget *notebook = GTK_WIDGET(user_data);
if (widget == NULL || notebook == NULL) return FALSE;
g_active_window = GTK_WINDOW(widget);
g_active_notebook = notebook;
g_active_ws = window_state_for_notebook(notebook);
if (g_active_ws == NULL) g_active_ws = &g_main_window;
g_print("[windows] Active window changed to %p (notebook %p)\n",
(void *)g_active_window, (void *)g_active_notebook);
return FALSE;
}
/* destroy handler for auxiliary windows: if this was the active window,
* fall back to the main window/notebook so MCP keeps working. Does NOT
* quit the app — only the main window's destroy handler does that.
* Also removes the window's window_state_t from g_aux_windows. */
static void on_aux_window_destroy(GtkWidget *widget, gpointer user_data) {
(void)user_data;
if (g_active_window == GTK_WINDOW(widget)) {
g_active_window = g_window;
g_active_notebook = g_notebook;
g_active_ws = &g_main_window;
g_print("[windows] Active window closed, reverting to main window\n");
}
/* Remove the destroyed window's entry from g_aux_windows. */
if (g_aux_windows != NULL) {
for (guint i = 0; i < g_aux_windows->len; i++) {
window_state_t *ws = &g_array_index(g_aux_windows,
window_state_t, i);
if (ws->window == GTK_WINDOW(widget)) {
g_array_remove_index_fast(g_aux_windows, i);
break;
}
}
}
g_print("[windows] Auxiliary window destroyed: %p\n", (void *)widget);
}
static GtkWidget *tab_manager_new_window(const char *url,
WebKitWebView *related_view) {
if (g_ctx == NULL) return NULL;
/* Create the top-level window. */
GtkWidget *window = gtk_window_new(GTK_WINDOW_TOPLEVEL);
gtk_window_set_title(GTK_WINDOW(window), "sovereign browser");
gtk_window_set_default_size(GTK_WINDOW(window), 1024, 768);
/* Create a notebook for this window. It holds the full tab
* infrastructure (toolbar, URL entry, tab label, favicon, etc.)
* built by tab_create(), so the new window looks and behaves like
* the main window. */
GtkWidget *notebook = gtk_notebook_new();
gtk_notebook_set_scrollable(GTK_NOTEBOOK(notebook), FALSE);
gtk_notebook_set_show_border(GTK_NOTEBOOK(notebook), FALSE);
gtk_notebook_set_show_tabs(GTK_NOTEBOOK(notebook), TRUE);
/* New-tab button + avatar as notebook action widgets, same as the
* main window. The new-tab button is wired to THIS notebook so tabs
* open in this window, not the main window. is_main=FALSE so a
* separate avatar image is created and tracked (the global g_avatar
* stays pointing at the main window's image). */
setup_notebook_action_widgets(notebook, FALSE);
/* Build a window-level GtkPaned: left = sidebar container, right =
* notebook. The sidebar is per-window (not per-tab), so it persists
* across tab switches within this window. Hidden by default. */
GtkWidget *paned = gtk_paned_new(GTK_ORIENTATION_HORIZONTAL);
gtk_widget_set_vexpand(paned, TRUE);
gtk_widget_set_hexpand(paned, TRUE);
GtkWidget *sidebar_container = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0);
gtk_paned_pack1(GTK_PANED(paned), sidebar_container, FALSE, FALSE);
gtk_widget_hide(sidebar_container);
gtk_paned_pack2(GTK_PANED(paned), notebook, TRUE, TRUE);
gtk_paned_set_position(GTK_PANED(paned), 0);
gtk_container_add(GTK_CONTAINER(window), paned);
/* Build a full tab via tab_create(). Set g_target_notebook so the
* tab is added to this window's notebook (not the main notebook),
* and g_target_related_view so the webview is created with
* webkit_web_view_new_with_related_view() (shares the parent's
* WebProcess and window features, avoiding the WindowFeatures
* assertion crash). Restore both globals afterwards. */
g_target_notebook = notebook;
g_target_related_view = (related_view != NULL &&
WEBKIT_IS_WEB_VIEW(related_view))
? related_view : NULL;
tab_info_t *tab = tab_create(url);
g_target_related_view = NULL;
g_target_notebook = NULL;
if (tab == NULL) {
gtk_widget_destroy(window);
return NULL;
}
/* Register the tab in the global g_tabs array and add it to the
* new window's notebook. tab_create() already loaded the URL and
* wired all per-tab signals (load-changed, decide-policy, create,
* context-menu, favicon, url-entry, etc.) with the tab_info_t as
* user_data, so the new window's tab gets the same behavior as a
* main-window tab. */
int index = tab_array_add(tab);
if (index < 0) {
g_free(tab);
gtk_widget_destroy(window);
return NULL;
}
/* Inject the per-tab performance probe (sovereign://processes).
* Skips internal sovereign:// pages at runtime via the preamble. */
perf_probe_setup(tab->webview, index);
const browser_settings_t *s = settings_get();
int page_num = gtk_notebook_append_page(GTK_NOTEBOOK(notebook),
tab->page, tab->tab_label);
gtk_notebook_set_tab_reorderable(GTK_NOTEBOOK(notebook), tab->page,
s->tab_drag_reorder);
/* Show the tab widgets before switching to it. */
gtk_widget_show_all(tab->page);
gtk_widget_show_all(tab->tab_label);
gtk_notebook_set_current_page(GTK_NOTEBOOK(notebook), page_num);
/* Window lifecycle: focus-in updates the active window/notebook
* pointers; destroy reverts to the main window but does NOT quit. */
g_signal_connect(window, "focus-in-event",
G_CALLBACK(on_window_focus_in), notebook);
g_signal_connect(window, "destroy",
G_CALLBACK(on_aux_window_destroy), NULL);
/* Show everything and present the window. */
gtk_widget_show_all(window);
/* Re-hide the sidebar container after show_all — it should only
* appear when the user toggles it. (Same rationale as the main
* window in tab_manager_init.) */
gtk_widget_hide(sidebar_container);
gtk_window_present(GTK_WINDOW(window));
/* Register this window's window_state_t in g_aux_windows. */
if (g_aux_windows == NULL) {
g_aux_windows = g_array_new(FALSE, FALSE, sizeof(window_state_t));
}
window_state_t ws = {0};
ws.window = GTK_WINDOW(window);
ws.notebook = notebook;
ws.paned = paned;
ws.sidebar_container = sidebar_container;
ws.sidebar_webview = NULL;
ws.sidebar_visible = FALSE;
g_array_append_val(g_aux_windows, ws);
/* This new window is now the active window. */
g_active_window = GTK_WINDOW(window);
g_active_notebook = notebook;
g_active_ws = &g_array_index(g_aux_windows, window_state_t,
g_aux_windows->len - 1);
g_print("[windows] Created new window %p (tab %d) for %s\n",
(void *)window, index, url ? url : "(none)");
return GTK_WIDGET(tab->webview);
}
static GtkWidget *build_tab_label(tab_info_t *tab) {
GtkWidget *box = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 4);
/* Make the tab label expand and fill so tabs divide the available
* space evenly across the tab strip. */
gtk_widget_set_hexpand(box, TRUE);
gtk_widget_set_halign(box, GTK_ALIGN_FILL);
gtk_widget_set_size_request(box, 120, -1); /* min width for readability */
/* Favicon — starts empty (no placeholder icon). Updated via the
* notify::favicon signal when a real favicon is available. */
tab->favicon = gtk_image_new();
gtk_widget_set_valign(tab->favicon, GTK_ALIGN_CENTER);
gtk_box_pack_start(GTK_BOX(box), tab->favicon, FALSE, FALSE, 0);
/* Title label — expands to fill, ellipsizes if too long. */
tab->title_label = gtk_label_new("New Tab");
gtk_label_set_ellipsize(GTK_LABEL(tab->title_label), PANGO_ELLIPSIZE_END);
gtk_label_set_max_width_chars(GTK_LABEL(tab->title_label), 20);
gtk_widget_set_hexpand(tab->title_label, TRUE);
gtk_widget_set_valign(tab->title_label, GTK_ALIGN_CENTER);
gtk_box_pack_start(GTK_BOX(box), tab->title_label, TRUE, TRUE, 0);
/* Close button. */
const browser_settings_t *s = settings_get();
if (s->show_tab_close_buttons) {
GtkWidget *close_btn = gtk_button_new();
gtk_button_set_relief(GTK_BUTTON(close_btn), GTK_RELIEF_NONE);
gtk_button_set_image(GTK_BUTTON(close_btn),
gtk_image_new_from_icon_name("window-close-symbolic",
GTK_ICON_SIZE_MENU));
gtk_widget_set_tooltip_text(close_btn, "Close tab");
g_signal_connect(close_btn, "clicked",
G_CALLBACK(on_tab_close_clicked), tab);
/* Connect button-press so right-click on the close button
* also triggers the tab context menu. */
gtk_widget_add_events(close_btn, GDK_BUTTON_PRESS_MASK);
g_signal_connect(close_btn, "button-press-event",
G_CALLBACK(on_tab_label_button_press), tab);
gtk_box_pack_start(GTK_BOX(box), close_btn, FALSE, FALSE, 0);
}
/* Connect button-press for middle-click and right-click on the label.
* We add the event mask and connect the handler to the box AND all
* child widgets (favicon, label, close button), because child widgets
* intercept button-press events before they bubble up to the parent.
* Without this, right-clicking on the label or close button wouldn't
* show the context menu. */
gtk_widget_add_events(box, GDK_BUTTON_PRESS_MASK);
g_signal_connect(box, "button-press-event",
G_CALLBACK(on_tab_label_button_press), tab);
/* Also connect child widgets so right-clicks on them trigger the
* context menu. The handler checks event->button and only acts on
* right-click (button 3) and middle-click (button 2), so normal
* left-clicks on the close button still work. */
gtk_widget_add_events(tab->favicon, GDK_BUTTON_PRESS_MASK);
g_signal_connect(tab->favicon, "button-press-event",
G_CALLBACK(on_tab_label_button_press), tab);
gtk_widget_add_events(tab->title_label, GDK_BUTTON_PRESS_MASK);
g_signal_connect(tab->title_label, "button-press-event",
G_CALLBACK(on_tab_label_button_press), tab);
gtk_widget_show_all(box);
return box;
}
/* ── Per-tab signal handlers ──────────────────────────────────────── */
static void on_url_activate(GtkEntry *entry, gpointer user_data) {
tab_info_t *tab = (tab_info_t *)user_data;
const char *text = gtk_entry_get_text(entry);
/* Agent chat shortcut: "; <message>" routes to the embedded agent. */
if (text[0] == ';') {
const char *msg = text + 1;
/* Skip leading whitespace after the semicolon. */
while (*msg == ' ' || *msg == '\t') msg++;
agent_chat_route_input(*msg ? msg : NULL);
/* Clear the URL bar after sending to agent. */
gtk_entry_set_text(entry, "");
return;
}
nostr_entity_type_t entity_type = nostr_url_detect(text);
if (entity_type == NOSTR_ENTITY_NSEC) {
webkit_web_view_load_html(tab->webview,
"<!doctype html><meta charset=\"utf-8\"><title>Private key blocked</title>"
"<h1>Navigation blocked</h1><p>Nostr private keys cannot be opened or navigated to.</p>",
"nostr://blocked-private-key");
return;
}
char *url = normalize_url(text);
if (url != NULL) {
webkit_web_view_load_uri(tab->webview, url);
g_free(url);
}
}
/* Back button — navigate to the previous page in history. */
static void on_back_clicked(GtkButton *btn, gpointer user_data) {
(void)btn;
tab_info_t *tab = (tab_info_t *)user_data;
if (tab && tab->webview && webkit_web_view_can_go_back(tab->webview)) {
webkit_web_view_go_back(tab->webview);
}
}
/* Forward button — navigate to the next page in history. */
static void on_forward_clicked(GtkButton *btn, gpointer user_data) {
(void)btn;
tab_info_t *tab = (tab_info_t *)user_data;
if (tab && tab->webview && webkit_web_view_can_go_forward(tab->webview)) {
webkit_web_view_go_forward(tab->webview);
}
}
/* Update the per-tab refresh/stop button visual + tooltip to match the
* webview's current loading state. Called from on_load_changed,
* on_load_failed, and on_refresh_clicked so the button always reflects
* the actual WebKit is-loading state for *this* tab only. */
static void tab_refresh_button_update(tab_info_t *tab) {
if (tab == NULL || tab->refresh_btn == NULL || tab->webview == NULL) {
return;
}
gboolean loading = webkit_web_view_is_loading(tab->webview);
if (tab->refresh_shows_stop == loading) return;
tab->refresh_shows_stop = loading;
GtkButton *btn = GTK_BUTTON(tab->refresh_btn);
const gchar *icon_name = loading ? "process-stop-symbolic"
: "view-refresh-symbolic";
gtk_button_set_image(btn,
gtk_image_new_from_icon_name(icon_name, GTK_ICON_SIZE_MENU));
/* Debug log: one line per state transition. G_LOG_LEVEL_INFO keeps it
* quiet in production (g_message would always print). Use g_print
* here for test observability since the rest of the file uses it. */
int index = tab_array_find(tab);
g_print("[tab %d] refresh button -> %s (icon=%s)\n",
index,
loading ? "STOP" : "RELOAD",
icon_name);
if (loading) {
gtk_widget_set_tooltip_text(tab->refresh_btn,
"Stop loading (right-click for hard reload options)");
} else {
gtk_widget_set_tooltip_text(tab->refresh_btn,
"Reload page (right-click for hard reload options)");
}
}
/* Refresh/Stop button — left-click branches on per-tab loading state:
* loading -> webkit_web_view_stop_loading()
* idle -> webkit_web_view_reload()
* The button visual is kept in sync with is_loading via
* tab_refresh_button_update() called from the load-changed/load-failed
* handlers, so this single handler covers both modes. */
static void on_refresh_clicked(GtkButton *btn, gpointer user_data) {
(void)btn;
tab_info_t *tab = (tab_info_t *)user_data;
if (tab == NULL || tab->webview == NULL) return;
if (webkit_web_view_is_loading(tab->webview)) {
g_print("[tab] stop loading requested\n");
webkit_web_view_stop_loading(tab->webview);
/* WebKit fires load-failed (WEBKIT_LOAD_FAILED) for cancellations
* in most cases, which restores the button. Update defensively
* here too in case no signal arrives (e.g. already-finished load
* races) so the UI never gets stuck in the stop state. */
tab_refresh_button_update(tab);
} else {
webkit_web_view_reload(tab->webview);
}
}
/* Hard reload menu item — bypasses cache. */
static void on_hard_reload(GtkMenuItem *item, gpointer user_data) {
(void)item;
tab_info_t *tab = (tab_info_t *)user_data;
if (tab && tab->webview) {
webkit_web_view_reload_bypass_cache(tab->webview);
}
}
/* Clear site data + hard reload menu item. */
static void on_clear_and_reload(GtkMenuItem *item, gpointer user_data) {
(void)item;
tab_info_t *tab = (tab_info_t *)user_data;
if (!tab || !tab->webview) return;
/* Clear localStorage and sessionStorage via JS. */
const char *clear_js =
"(function(){try{localStorage.clear();}catch(e){}"
"try{sessionStorage.clear();}catch(e){}"
"return 'ok';})()";
char *result = agent_js_eval_sync(tab->webview, clear_js, 3000);
g_free(result);
/* Hard reload to bypass cache. */
webkit_web_view_reload_bypass_cache(tab->webview);
}
/* Refresh button — right-click: show dropdown with hard reload options. */
static gboolean on_refresh_button_press(GtkWidget *widget,
GdkEventButton *event,
gpointer user_data) {
tab_info_t *tab = (tab_info_t *)user_data;
(void)widget;
if (event->button == 3) { /* Right-click */
GtkWidget *menu = gtk_menu_new();
GtkWidget *item_reload = gtk_menu_item_new_with_label("Reload");
gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_reload);
g_signal_connect(item_reload, "activate",
G_CALLBACK(on_refresh_clicked), tab);
GtkWidget *item_hard = gtk_menu_item_new_with_label(
"Hard reload (bypass cache)");
gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_hard);
g_signal_connect(item_hard, "activate",
G_CALLBACK(on_hard_reload), tab);
gtk_menu_shell_append(GTK_MENU_SHELL(menu),
gtk_separator_menu_item_new());
GtkWidget *item_clear = gtk_menu_item_new_with_label(
"Clear site data + hard reload");
gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_clear);
g_signal_connect(item_clear, "activate",
G_CALLBACK(on_clear_and_reload), tab);
gtk_widget_show_all(menu);
gtk_menu_popup_at_pointer(GTK_MENU(menu), (GdkEvent *)event);
return TRUE; /* Suppress default button-press handling */
}
return FALSE; /* Let left-click propagate to "clicked" signal */
}
static void on_load_changed(WebKitWebView *webview,
WebKitLoadEvent load_event,
gpointer user_data) {
tab_info_t *tab = (tab_info_t *)user_data;
/* Per-tab reload/stop button: switch to the stop (X) visual as soon
* as a load begins, and back to the reload visual when it ends.
* WEBKIT_LOAD_STARTED is the canonical "loading now" signal; the
* button is also re-synced on FINISHED/FAILED below. This is per-tab
* (each tab has its own refresh_btn) so loading one tab never affects
* another tab's button. */
if (load_event == WEBKIT_LOAD_STARTED) {
tab_refresh_button_update(tab);
}
/* tab is NULL for webviews in auxiliary (new) windows, which don't
* have a tab_info_t. Skip the tab-UI updates (URL bar, favicon, tab
* title) but still record history on load-finished below. */
if (load_event == WEBKIT_LOAD_COMMITTED) {
if (tab == NULL) return;
const gchar *uri = webkit_web_view_get_uri(webview);
if (uri != NULL) {
/* Clear the favicon — the new page's favicon (if any) will
* arrive via the notify::favicon signal. This prevents a
* stale favicon from the previous page lingering. */
gtk_image_clear(GTK_IMAGE(tab->favicon));
/* Show an empty URL bar for blank pages so the user can
* immediately type a URL. Keep current_url as the real URI
* (about:blank) so session save/duplicate still work. */
if (strcmp(uri, "about:blank") == 0) {
gtk_entry_set_text(GTK_ENTRY(tab->url_entry), "");
} else {
gtk_entry_set_text(GTK_ENTRY(tab->url_entry), uri);
}
snprintf(tab->current_url, sizeof(tab->current_url), "%s", uri);
/* Set a temporary title from the URL host while the page loads.
* This ensures the tab shows something meaningful immediately,
* not just "New Tab" or "Loading…". */
const char *host = strstr(uri, "://");
if (host) host += 3;
else host = uri;
/* Strip path — just show the domain. */
char host_buf[256];
snprintf(host_buf, sizeof(host_buf), "%s", host);
char *slash = strchr(host_buf, '/');
if (slash) *slash = '\0';
if (host_buf[0]) {
int index = tab_array_find(tab);
if (index >= 0) {
tab_manager_set_title(index, host_buf);
}
}
}
} else if (load_event == WEBKIT_LOAD_FINISHED) {
const gchar *title = webkit_web_view_get_title(webview);
const gchar *uri = webkit_web_view_get_uri(webview);
/* The title may not be available yet at LOAD_FINISHED — it often
* arrives slightly later via the notify::title signal (handled by
* on_title_changed). Log what we have; the title handler will log
* the real title when it arrives. */
g_print("[loaded] %s\n", uri ? uri : "(null)");
/* Restore the reload button visual now that loading is done. */
tab_refresh_button_update(tab);
/* Update tab title if already available (works for tabs in any
* window's notebook). The notify::title handler covers the case
* where the title arrives after load-finished. */
if (tab != NULL && title && title[0]) {
int index = tab_array_find(tab);
if (index >= 0) {
tab_manager_set_title(index, title);
}
}
/* Add to history (with title for the Recents submenu tooltip).
* This applies to both main-window tabs and auxiliary windows.
* If the title isn't available yet, history_add_titled will use
* the URL; the notify::title handler could update it later if
* needed. */
if (uri != NULL && uri[0] != '\0') {
history_add_titled(uri, (title && title[0]) ? title : NULL);
}
}
}
static gboolean on_load_failed(WebKitWebView *webview,
WebKitLoadEvent load_event,
gchar *failing_uri,
GError *error,
gpointer data) {
(void)webview;
(void)load_event;
tab_info_t *tab = (tab_info_t *)data;
g_print("[failed] %s -- %s\n",
failing_uri ? failing_uri : "(null)",
error ? error->message : "(unknown)");
/* A failed load (including user-initiated cancellation via
* webkit_web_view_stop_loading()) ends the loading phase, so restore
* the reload button visual. This prevents stale stop state when the
* user clicks the X to cancel a load. */
tab_refresh_button_update(tab);
/* Keep the failed URL in the address bar so the user can see what
* failed and edit/retry. Without this, WebKit reverts to about:blank
* and the user loses the URL they typed or clicked. */
if (tab != NULL && failing_uri != NULL && failing_uri[0] != '\0') {
if (strcmp(failing_uri, "about:blank") != 0) {
gtk_entry_set_text(GTK_ENTRY(tab->url_entry), failing_uri);
snprintf(tab->current_url, sizeof(tab->current_url), "%s", failing_uri);
}
/* Set a descriptive tab title from the error so the user sees
* something meaningful in the tab bar instead of "Loading…". */
const char *msg = (error && error->message) ? error->message : "Load failed";
int index = tab_array_find(tab);
if (index >= 0) {
tab_manager_set_title(index, msg);
}
}
return FALSE;
}
/* Called when the webview's "title" property changes. The title often
* arrives slightly after WEBKIT_LOAD_FINISHED, so the load-finished
* handler may log "(none)". This handler picks up the real title as
* soon as it's available and updates the tab title + logs it. */
static void on_title_changed(WebKitWebView *webview,
GParamSpec *pspec,
gpointer user_data) {
(void)pspec;
tab_info_t *tab = (tab_info_t *)user_data;
const gchar *title = webkit_web_view_get_title(webview);
const gchar *uri = webkit_web_view_get_uri(webview);
if (title && title[0]) {
g_print("[title] %s -- %s\n",
uri ? uri : "(null)", title);
if (tab != NULL) {
int index = tab_array_find(tab);
if (index >= 0) {
tab_manager_set_title(index, title);
}
}
}
}
/* ── Web view context menu (right-click) ──────────────────────────── */
/* Callback for "Open Link in New Tab" / "Open Page in New Tab". */
static void on_context_open_in_new_tab(GSimpleAction *action,
GVariant *parameter,
gpointer user_data) {
(void)action;
(void)parameter;
char *url = (char *)user_data;
if (url && url[0]) {
tab_manager_new_tab(url);
}
g_free(url);
}
/* Context menu signal handler for the webview. Appends "Open in New Tab"
* to the default WebKit context menu — either "Open Link in New Tab" if
* right-clicking a link, or "Open Page in New Tab" if right-clicking the
* page background. */
static gboolean on_webview_context_menu(WebKitWebView *webview,
WebKitContextMenu *context_menu,
GdkEvent *event,
WebKitHitTestResult *hit_test,
gpointer user_data) {
(void)webview;
(void)event;
(void)user_data;
/* Determine what was clicked. */
const char *url = NULL;
const char *label = NULL;
int is_link = 0;
if (webkit_hit_test_result_context_is_link(hit_test)) {
url = webkit_hit_test_result_get_link_uri(hit_test);
label = "Open Link in New Tab";
is_link = 1;
} else if (!webkit_hit_test_result_context_is_image(hit_test) &&
!webkit_hit_test_result_context_is_media(hit_test) &&
!webkit_hit_test_result_context_is_editable(hit_test) &&
!webkit_hit_test_result_context_is_selection(hit_test) &&
!webkit_hit_test_result_context_is_scrollbar(hit_test)) {
/* Not a link/image/media/editable/selection/scrollbar — it's the
* page background (document context). */
url = webkit_web_view_get_uri(webview);
label = "Open Page in New Tab";
}
if (url == NULL || url[0] == '\0') {
return FALSE; /* Let the default menu show without our item. */
}
/* Build our custom "Open in New Tab" item. */
GSimpleAction *action = g_simple_action_new("open-in-new-tab", NULL);
char *url_copy = g_strdup(url);
g_signal_connect_data(action, "activate",
G_CALLBACK(on_context_open_in_new_tab), url_copy,
(GClosureNotify)g_free, 0);
WebKitContextMenuItem *item =
webkit_context_menu_item_new_from_gaction(G_ACTION(action), label,
NULL);
/* For links: WebKit's default menu starts with "Open Link" (pos 0)
* then "Open Link in New Window" (pos 1). Insert "Open Link in New
* Tab" at position 1 — between "Open Link" and "Open Link in New
* Window" — so the order is:
* Open Link
* Open Link in New Tab
* Open Link in New Window
*
* For page background: insert at position 0 (top). */
int insert_pos = is_link ? 1 : 0;
webkit_context_menu_insert(context_menu, item, insert_pos);
/* The action is owned by the menu item now. g_object_unref is safe
* because the WebKitContextMenuItem holds its own ref. */
g_object_unref(action);
return FALSE; /* Let WebKit show the menu. */
}
/* ── Hamburger menu callbacks (webview-specific) ──────────────────── */
static void on_menu_reload(GtkMenuItem *item, gpointer data) {
(void)item;
(void)data;
tab_info_t *tab = tab_manager_get_active();
if (tab && tab->webview) {
webkit_web_view_reload_bypass_cache(tab->webview);
}
}
static void on_menu_stop(GtkMenuItem *item, gpointer data) {
(void)item;
(void)data;
tab_info_t *tab = tab_manager_get_active();
if (tab && tab->webview) {
webkit_web_view_stop_loading(tab->webview);
}
}
/* ── Inspector window position/size persistence ────────────────────── *
* When the inspector detaches into its own window, we track the window's
* position and size via configure-event and save to settings. On the
* next show, we restore the saved geometry. */
static gboolean on_inspector_window_configure(GtkWidget *widget,
GdkEventConfigure *event,
gpointer user_data) {
(void)widget;
(void)user_data;
browser_settings_t *bs = settings_get_mutable();
bs->inspector_x = event->x;
bs->inspector_y = event->y;
bs->inspector_w = event->width;
bs->inspector_h = event->height;
settings_save();
return FALSE;
}
/* Find the detached inspector's toplevel GtkWindow and hook configure-event.
* Called when the inspector's WebView is realized (after detach). */
static void on_inspector_webview_realize(GtkWidget *widget,
gpointer user_data) {
(void)user_data;
GtkWidget *toplevel = gtk_widget_get_toplevel(widget);
if (toplevel && GTK_IS_WINDOW(toplevel)) {
/* Restore saved geometry if we have it. */
const browser_settings_t *bs = settings_get();
if (bs->inspector_w > 0 && bs->inspector_h > 0) {
gtk_window_resize(GTK_WINDOW(toplevel),
bs->inspector_w, bs->inspector_h);
}
if (bs->inspector_x >= 0 && bs->inspector_y >= 0) {
gtk_window_move(GTK_WINDOW(toplevel),
bs->inspector_x, bs->inspector_y);
}
/* Track future changes. */
g_signal_connect(toplevel, "configure-event",
G_CALLBACK(on_inspector_window_configure), NULL);
}
}
static void on_inspector_detach(WebKitWebInspector *inspector,
gpointer user_data) {
(void)user_data;
WebKitWebViewBase *insp_view = webkit_web_inspector_get_web_view(inspector);
if (insp_view) {
GtkWidget *w = GTK_WIDGET(insp_view);
/* Hook realize to catch the detached window. */
g_signal_connect(w, "realize",
G_CALLBACK(on_inspector_webview_realize), NULL);
}
}
/* Track whether the inspector is currently shown for the active tab.
* We use a per-tab flag stored in a static hash (inspector state is
* per-webview, but we track it globally for the toggle). */
static gboolean g_inspector_visible = FALSE;
void tab_manager_toggle_inspector(void) {
tab_info_t *tab = tab_manager_get_active();
if (!tab || !tab->webview) return;
WebKitWebInspector *insp = webkit_web_view_get_inspector(tab->webview);
if (!insp) return;
/* Connect signals once (idempotent — WebKitWebInspector is per-webview
* and persists for the webview's lifetime). We check a g_object data
* flag to avoid connecting multiple times. */
if (g_object_get_data(G_OBJECT(insp), "sovereign-inspector-hooked") == NULL) {
g_signal_connect(insp, "detach",
G_CALLBACK(on_inspector_detach), NULL);
g_object_set_data(G_OBJECT(insp), "sovereign-inspector-hooked",
GINT_TO_POINTER(1));
}
if (g_inspector_visible) {
webkit_web_inspector_close(insp);
g_inspector_visible = FALSE;
} else {
webkit_web_inspector_show(insp);
g_inspector_visible = TRUE;
}
}
/* ── Zoom control ──────────────────────────────────────────────────── */
#define ZOOM_MIN 0.25
#define ZOOM_MAX 5.0
#define ZOOM_STEP 1.1
void tab_manager_zoom_in(void) {
tab_info_t *tab = tab_manager_get_active();
if (!tab || !tab->webview) return;
gdouble z = webkit_web_view_get_zoom_level(tab->webview);
z *= ZOOM_STEP;
if (z > ZOOM_MAX) z = ZOOM_MAX;
webkit_web_view_set_zoom_level(tab->webview, z);
}
void tab_manager_zoom_out(void) {
tab_info_t *tab = tab_manager_get_active();
if (!tab || !tab->webview) return;
gdouble z = webkit_web_view_get_zoom_level(tab->webview);
z /= ZOOM_STEP;
if (z < ZOOM_MIN) z = ZOOM_MIN;
webkit_web_view_set_zoom_level(tab->webview, z);
}
void tab_manager_zoom_reset(void) {
tab_info_t *tab = tab_manager_get_active();
if (!tab || !tab->webview) return;
webkit_web_view_set_zoom_level(tab->webview, 1.0);
}
static void on_menu_inspector(GtkMenuItem *item, gpointer data) {
(void)item;
(void)data;
tab_manager_toggle_inspector();
}
static void on_menu_toggle_sidebar(GtkMenuItem *item, gpointer data) {
(void)item;
(void)data;
tab_manager_toggle_sidebar();
}
static void on_menu_open_file(GtkMenuItem *item, gpointer data) {
(void)item;
(void)data;
if (g_window == NULL) return;
GtkWidget *dialog = gtk_file_chooser_dialog_new(
"Open File", g_window, GTK_FILE_CHOOSER_ACTION_OPEN,
"_Cancel", GTK_RESPONSE_CANCEL,
"_Open", GTK_RESPONSE_ACCEPT, NULL);
GtkFileFilter *filter_html = gtk_file_filter_new();
gtk_file_filter_set_name(filter_html, "HTML files");
gtk_file_filter_add_pattern(filter_html, "*.html");
gtk_file_filter_add_pattern(filter_html, "*.htm");
gtk_file_chooser_add_filter(GTK_FILE_CHOOSER(dialog), filter_html);
GtkFileFilter *filter_all = gtk_file_filter_new();
gtk_file_filter_set_name(filter_all, "All files");
gtk_file_filter_add_pattern(filter_all, "*");
gtk_file_chooser_add_filter(GTK_FILE_CHOOSER(dialog), filter_all);
if (gtk_dialog_run(GTK_DIALOG(dialog)) == GTK_RESPONSE_ACCEPT) {
char *filename = gtk_file_chooser_get_filename(GTK_FILE_CHOOSER(dialog));
char *uri = g_strdup_printf("file://%s", filename);
tab_info_t *tab = tab_manager_get_active();
if (tab && tab->webview) {
webkit_web_view_load_uri(tab->webview, uri);
}
g_free(uri);
g_free(filename);
}
gtk_widget_destroy(dialog);
}
static void on_menu_history_item(GtkMenuItem *item, gpointer data) {
(void)data;
tab_info_t *tab = tab_manager_get_active();
if (tab == NULL || tab->webview == NULL || item == NULL) return;
/* The full URL is attached as "history-url" data (the label may be
* truncated for display). */
const char *url = g_object_get_data(G_OBJECT(item), "history-url");
if (url && url[0]) {
char *normalized = normalize_url(url);
if (normalized) {
webkit_web_view_load_uri(tab->webview, normalized);
g_free(normalized);
}
}
}
static void on_menu_history_clear(GtkMenuItem *item, gpointer data) {
(void)item;
(void)data;
history_clear();
g_print("[history] cleared\n");
}
static void on_history_menu_show(GtkWidget *menu, gpointer user_data) {
(void)user_data;
/* Remove all existing items. */
GList *children = gtk_container_get_children(GTK_CONTAINER(menu));
for (GList *l = children; l != NULL; l = l->next) {
gtk_widget_destroy(GTK_WIDGET(l->data));
}
g_list_free(children);
/* Rebuild with current history. */
int hcount = history_count();
if (hcount == 0) {
GtkWidget *empty = gtk_menu_item_new_with_label("(no recent pages)");
gtk_widget_set_sensitive(empty, FALSE);
gtk_menu_shell_append(GTK_MENU_SHELL(menu), empty);
} else {
int show = hcount < 20 ? hcount : 20;
for (int i = 0; i < show; i++) {
char *url = history_get(i);
if (url == NULL) break;
char label[80];
if (strlen(url) > 75) {
snprintf(label, sizeof(label), "%.72s...", url);
} else {
snprintf(label, sizeof(label), "%s", url);
}
GtkWidget *hitem = gtk_menu_item_new_with_label(label);
gtk_widget_set_tooltip_text(hitem, url);
/* Attach the URL to the item so the callback can retrieve it
* and so it's freed when the item is destroyed. */
g_object_set_data_full(G_OBJECT(hitem), "history-url", url,
(GDestroyNotify)g_free);
g_signal_connect(hitem, "activate",
G_CALLBACK(on_menu_history_item), NULL);
gtk_menu_shell_append(GTK_MENU_SHELL(menu), hitem);
}
gtk_menu_shell_append(GTK_MENU_SHELL(menu),
gtk_separator_menu_item_new());
GtkWidget *clear_item = gtk_menu_item_new_with_label("Clear Recents");
g_signal_connect(clear_item, "activate",
G_CALLBACK(on_menu_history_clear), NULL);
gtk_menu_shell_append(GTK_MENU_SHELL(menu), clear_item);
}
gtk_widget_show_all(menu);
}
/* ── Bookmark handlers ─────────────────────────────────────────────── */
/* Called when a bookmark is clicked in the submenu — navigate to it. */
static void on_bookmark_item_clicked(GtkMenuItem *item, gpointer data) {
(void)item;
const char *url = (const char *)data;
if (url == NULL) return;
tab_info_t *tab = tab_manager_get_active();
if (tab && tab->webview) {
webkit_web_view_load_uri(tab->webview, url);
}
}
/* Called when "Manage Bookmarks…" is clicked in the submenu. */
static void on_manage_bookmarks_clicked(GtkMenuItem *item, gpointer data) {
(void)item;
(void)data;
tab_info_t *tab = tab_manager_get_active();
if (tab && tab->webview) {
webkit_web_view_load_uri(tab->webview, "sovereign://bookmarks");
} else {
tab_manager_new_tab("sovereign://bookmarks");
}
}
/* Wrapper for g_free to match GClosureNotify signature (avoids
* -Wcast-function-type warning from g_signal_connect_data). */
static void closure_notify_g_free(gpointer data, GClosure *closure) {
(void)closure;
g_free(data);
}
/* Rebuild the bookmarks submenu each time it's shown (like Recents). */
static void on_bookmarks_menu_show(GtkWidget *menu, gpointer user_data) {
(void)user_data;
/* Remove existing items. */
GList *children = gtk_container_get_children(GTK_CONTAINER(menu));
for (GList *l = children; l != NULL; l = l->next) {
gtk_widget_destroy(GTK_WIDGET(l->data));
}
g_list_free(children);
/* Get current bookmarks (flattened from the tree). */
GPtrArray *bms = g_ptr_array_new();
collect_all_bookmarks(bookmarks_get_root(), bms);
if (bms->len == 0) {
GtkWidget *empty = gtk_menu_item_new_with_label("(no bookmarks)");
gtk_widget_set_sensitive(empty, FALSE);
gtk_menu_shell_append(GTK_MENU_SHELL(menu), empty);
} else {
/* Show up to 15 bookmarks (flattened tree order). */
int shown = 0;
for (guint i = 0; i < bms->len && shown < 15; i++) {
const bookmark_t *bm = (const bookmark_t *)g_ptr_array_index(bms, i);
const char *label_text = (bm->title && bm->title[0]) ? bm->title : bm->url;
char short_label[80];
if (strlen(label_text) > 75) {
snprintf(short_label, sizeof(short_label), "%.72s...",
label_text);
} else {
snprintf(short_label, sizeof(short_label), "%s",
label_text);
}
GtkWidget *bm_item = gtk_menu_item_new_with_label(short_label);
gtk_widget_set_tooltip_text(bm_item, bm->url);
char *url_copy = g_strdup(bm->url);
g_signal_connect_data(bm_item, "activate",
G_CALLBACK(on_bookmark_item_clicked), url_copy,
(GClosureNotify)closure_notify_g_free, 0);
gtk_menu_shell_append(GTK_MENU_SHELL(menu), bm_item);
shown++;
}
}
g_ptr_array_free(bms, TRUE);
gtk_menu_shell_append(GTK_MENU_SHELL(menu),
gtk_separator_menu_item_new());
GtkWidget *manage_item =
gtk_menu_item_new_with_label("Manage Bookmarks…");
g_signal_connect(manage_item, "activate",
G_CALLBACK(on_manage_bookmarks_clicked), NULL);
gtk_menu_shell_append(GTK_MENU_SHELL(menu), manage_item);
gtk_widget_show_all(menu);
}
/* Recursively collect all folder paths in the bookmark tree into `out`.
* The root node itself is skipped (its path is ""). */
static void collect_folder_paths(const bookmark_node_t *node, GPtrArray *out) {
if (node == NULL) return;
for (int i = 0; i < node->child_count; i++) {
const bookmark_node_t *child = &node->children[i];
g_ptr_array_add(out, g_strdup(child->path));
collect_folder_paths(child, out);
}
}
/* Called when the bookmark button (toolbar) is clicked.
* Shows a dialog to pick or create a folder path, then bookmarks the
* current page. The picker lists all existing folder paths (including
* nested ones like "Work/Projects") and lets the user type a new path. */
static void on_bookmark_clicked(GtkButton *btn, gpointer user_data) {
(void)btn;
tab_info_t *tab = (tab_info_t *)user_data;
if (tab == NULL || tab->webview == NULL) return;
/* Get the current URL and title. */
const gchar *url = webkit_web_view_get_uri(tab->webview);
if (url == NULL || url[0] == '\0') {
g_print("[bookmarks] No URL to bookmark\n");
return;
}
/* Skip sovereign:// pages (can't bookmark internal pages). */
if (strncmp(url, "sovereign://", 12) == 0) {
g_print("[bookmarks] Cannot bookmark internal pages\n");
return;
}
/* Get the page title. */
const gchar *title = webkit_web_view_get_title(tab->webview);
if (title == NULL) title = "";
/* Build the folder picker dialog. */
GtkWidget *dialog = gtk_dialog_new_with_buttons(
"Bookmark Page", g_window,
GTK_DIALOG_MODAL | GTK_DIALOG_DESTROY_WITH_PARENT,
"_Cancel", GTK_RESPONSE_CANCEL,
"_Add", GTK_RESPONSE_ACCEPT,
NULL);
gtk_window_set_default_size(GTK_WINDOW(dialog), 350, 150);
GtkWidget *content = gtk_dialog_get_content_area(GTK_DIALOG(dialog));
gtk_container_set_border_width(GTK_CONTAINER(content), 12);
/* Show the URL being bookmarked. */
char *url_esc = g_markup_escape_text(url, -1);
char *title_esc = g_markup_escape_text(title, -1);
char *info = g_strdup_printf(
"<b>%s</b>\n<span size='small' color='#888'>%s</span>",
title_esc[0] ? title_esc : "(untitled)", url_esc);
GtkWidget *lbl = gtk_label_new(NULL);
gtk_label_set_markup(GTK_LABEL(lbl), info);
gtk_label_set_line_wrap(GTK_LABEL(lbl), TRUE);
gtk_widget_set_halign(lbl, GTK_ALIGN_START);
gtk_box_pack_start(GTK_BOX(content), lbl, FALSE, FALSE, 8);
/* Folder combo box (with entry so the user can type a new path). */
GtkWidget *dir_label = gtk_label_new("Folder path:");
gtk_widget_set_halign(dir_label, GTK_ALIGN_START);
gtk_box_pack_start(GTK_BOX(content), dir_label, FALSE, FALSE, 4);
GtkWidget *combo = gtk_combo_box_text_new_with_entry();
GPtrArray *paths = g_ptr_array_new();
const bookmark_node_t *root = bookmarks_get_root();
collect_folder_paths(root, paths);
int default_idx = 0;
for (guint i = 0; i < paths->len; i++) {
const char *p = (const char *)g_ptr_array_index(paths, i);
gtk_combo_box_text_append_text(GTK_COMBO_BOX_TEXT(combo), p);
/* Default to "Bookmarks Bar" (always visible in the toolbar) so
* new users don't have to know the exact folder name. Fall back
* to "General" if "Bookmarks Bar" isn't present for some reason. */
if (strcmp(p, "Bookmarks Bar") == 0) default_idx = (int)i;
else if (strcmp(p, "General") == 0 && default_idx == 0) default_idx = (int)i;
}
gtk_combo_box_set_active(GTK_COMBO_BOX(combo), default_idx);
gtk_box_pack_start(GTK_BOX(content), combo, FALSE, FALSE, 4);
g_free(url_esc);
g_free(title_esc);
g_free(info);
gtk_widget_show_all(dialog);
gint response = gtk_dialog_run(GTK_DIALOG(dialog));
if (response == GTK_RESPONSE_ACCEPT) {
const char *path = gtk_combo_box_text_get_active_text(
GTK_COMBO_BOX_TEXT(combo));
if (path == NULL || path[0] == '\0') path = "General";
char *path_copy = g_strdup(path);
char *url_copy = g_strdup(url);
char *title_copy = g_strdup(title);
int rc = bookmarks_add(path_copy, url_copy, title_copy);
if (rc == 0) {
g_print("[bookmarks] Bookmarked '%s' to '%s'\n", url_copy,
path_copy);
} else {
g_printerr("[bookmarks] Failed to bookmark (no signer?)\n");
}
g_free(path_copy);
g_free(url_copy);
g_free(title_copy);
}
/* Free the paths array. */
for (guint i = 0; i < paths->len; i++) {
g_free(g_ptr_array_index(paths, i));
}
g_ptr_array_free(paths, TRUE);
gtk_widget_destroy(dialog);
}
/* ── Hamburger menu builder ───────────────────────────────────────── */
static void sync_network_menu_toggle(GtkWidget *item, gboolean active) {
g_object_set_data(G_OBJECT(item), "network-toggle-sync", GINT_TO_POINTER(1));
gtk_check_menu_item_set_active(GTK_CHECK_MENU_ITEM(item), active);
g_object_set_data(G_OBJECT(item), "network-toggle-sync", NULL);
}
static void on_hamburger_menu_show(GtkWidget *menu, gpointer data) {
(void)data;
GtkWidget *tor_item = g_object_get_data(G_OBJECT(menu), "tor-toggle");
GtkWidget *fips_item = g_object_get_data(G_OBJECT(menu), "fips-toggle");
const browser_settings_t *settings = settings_get();
if (tor_item)
sync_network_menu_toggle(tor_item, settings->tor_enabled);
if (fips_item)
sync_network_menu_toggle(fips_item, settings->fips_enabled);
}
static GtkWidget *build_hamburger_menu(tab_info_t *tab) {
(void)tab;
GtkWidget *menu = gtk_menu_new();
/* Navigation group. */
GtkWidget *item_open = gtk_menu_item_new_with_label("Open File…");
GtkWidget *item_reload = gtk_menu_item_new_with_label("Reload");
GtkWidget *item_stop = gtk_menu_item_new_with_label("Stop");
g_signal_connect(item_open, "activate", G_CALLBACK(on_menu_open_file), NULL);
g_signal_connect(item_reload, "activate", G_CALLBACK(on_menu_reload), NULL);
g_signal_connect(item_stop, "activate", G_CALLBACK(on_menu_stop), NULL);
gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_open);
gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_reload);
gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_stop);
gtk_menu_shell_append(GTK_MENU_SHELL(menu),
gtk_separator_menu_item_new());
/* Recents submenu. */
GtkWidget *history_menu = gtk_menu_new();
g_signal_connect(history_menu, "show", G_CALLBACK(on_history_menu_show), NULL);
GtkWidget *item_history = gtk_menu_item_new_with_label("Recents");
gtk_menu_item_set_submenu(GTK_MENU_ITEM(item_history), history_menu);
gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_history);
/* Bookmarks submenu. */
GtkWidget *bookmarks_menu = gtk_menu_new();
g_signal_connect(bookmarks_menu, "show",
G_CALLBACK(on_bookmarks_menu_show), NULL);
GtkWidget *item_bookmarks = gtk_menu_item_new_with_label("Bookmarks");
gtk_menu_item_set_submenu(GTK_MENU_ITEM(item_bookmarks), bookmarks_menu);
gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_bookmarks);
gtk_menu_shell_append(GTK_MENU_SHELL(menu),
gtk_separator_menu_item_new());
/* Identity group — delegated to main.c (app_state_t). */
GtkWidget *item_switch = gtk_menu_item_new_with_label("Switch Identity…");
GtkWidget *item_lock = gtk_menu_item_new_with_label("Lock Session");
GtkWidget *item_logout = gtk_menu_item_new_with_label("Logout");
g_signal_connect(item_switch, "activate",
G_CALLBACK(app_menu_switch_identity_proxy), g_window);
g_signal_connect(item_lock, "activate",
G_CALLBACK(app_menu_lock_session_proxy), NULL);
g_signal_connect(item_logout, "activate",
G_CALLBACK(app_menu_logout_proxy), NULL);
gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_switch);
gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_lock);
gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_logout);
gtk_menu_shell_append(GTK_MENU_SHELL(menu),
gtk_separator_menu_item_new());
/* Networking preferences. These are synchronized again whenever the
* menu opens so toggles in one tab are reflected by every tab. */
const browser_settings_t *settings = settings_get();
GtkWidget *item_tor =
gtk_check_menu_item_new_with_label("Tor-routed transport");
GtkWidget *item_fips =
gtk_check_menu_item_new_with_label("FIPS mesh");
gtk_check_menu_item_set_active(GTK_CHECK_MENU_ITEM(item_tor),
settings->tor_enabled);
gtk_check_menu_item_set_active(GTK_CHECK_MENU_ITEM(item_fips),
settings->fips_enabled);
g_signal_connect(item_tor, "toggled",
G_CALLBACK(app_menu_network_service_proxy),
GINT_TO_POINTER(NET_SERVICE_TOR));
g_signal_connect(item_fips, "toggled",
G_CALLBACK(app_menu_network_service_proxy),
GINT_TO_POINTER(NET_SERVICE_FIPS));
gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_tor);
gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_fips);
g_object_set_data(G_OBJECT(menu), "tor-toggle", item_tor);
g_object_set_data(G_OBJECT(menu), "fips-toggle", item_fips);
g_signal_connect(menu, "show", G_CALLBACK(on_hamburger_menu_show), NULL);
/* Security/status group. */
GtkWidget *item_security =
gtk_check_menu_item_new_with_label("Security strip (SOP/CORS/certs)");
gtk_check_menu_item_set_active(GTK_CHECK_MENU_ITEM(item_security), FALSE);
g_signal_connect(item_security, "toggled",
G_CALLBACK(app_menu_security_strip_proxy), NULL);
gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_security);
GtkWidget *item_nostr = gtk_menu_item_new_with_label("Nostr signing status");
g_signal_connect(item_nostr, "activate",
G_CALLBACK(app_menu_nostr_sign_proxy), NULL);
gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_nostr);
gtk_menu_shell_append(GTK_MENU_SHELL(menu),
gtk_separator_menu_item_new());
GtkWidget *item_inspector = gtk_menu_item_new_with_label("Toggle Inspector");
g_signal_connect(item_inspector, "activate",
G_CALLBACK(on_menu_inspector), NULL);
gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_inspector);
GtkWidget *item_sidebar = gtk_menu_item_new_with_label("Toggle Agent Sidebar");
g_signal_connect(item_sidebar, "activate",
G_CALLBACK(on_menu_toggle_sidebar), NULL);
gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_sidebar);
GtkWidget *item_profile = gtk_menu_item_new_with_label("Profile");
g_signal_connect(item_profile, "activate",
G_CALLBACK(on_menu_profile), g_window);
gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_profile);
GtkWidget *item_agent = gtk_menu_item_new_with_label("Agent Setup…");
g_signal_connect(item_agent, "activate",
G_CALLBACK(on_menu_agent), g_window);
gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_agent);
GtkWidget *item_fips_page = gtk_menu_item_new_with_label("FIPS Mesh…");
g_signal_connect(item_fips_page, "activate",
G_CALLBACK(on_menu_fips), g_window);
gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_fips_page);
GtkWidget *item_processes = gtk_menu_item_new_with_label("Processes…");
g_signal_connect(item_processes, "activate",
G_CALLBACK(on_menu_processes), g_window);
gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_processes);
GtkWidget *item_settings = gtk_menu_item_new_with_label("Settings…");
g_signal_connect(item_settings, "activate",
G_CALLBACK(on_menu_settings), g_window);
gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_settings);
GtkWidget *item_about = gtk_menu_item_new_with_label("About");
g_signal_connect(item_about, "activate",
G_CALLBACK(app_menu_about_proxy), g_window);
gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_about);
gtk_widget_show_all(menu);
GtkWidget *button = gtk_menu_button_new();
gtk_menu_button_set_popup(GTK_MENU_BUTTON(button), menu);
gtk_button_set_image(
GTK_BUTTON(button),
gtk_image_new_from_icon_name("open-menu-symbolic",
GTK_ICON_SIZE_BUTTON));
gtk_widget_set_tooltip_text(button, "Menu");
gtk_widget_set_name(button, "hamburger-btn");
gtk_widget_set_size_request(button, 28, 28); /* fixed square */
return button;
}
/* ── Tab array management ─────────────────────────────────────────── */
static int tab_array_add(tab_info_t *tab) {
if (g_tab_count >= g_tab_cap) {
int new_cap = g_tab_cap == 0 ? 8 : g_tab_cap * 2;
tab_info_t **new_arr = g_realloc(g_tabs, new_cap * sizeof(tab_info_t *));
if (new_arr == NULL) return -1;
g_tabs = new_arr;
g_tab_cap = new_cap;
}
g_tabs[g_tab_count] = tab;
return g_tab_count++;
}
static void tab_array_remove(int index) {
if (index < 0 || index >= g_tab_count) return;
/* Free the tab_info_t. Signal handlers are already disconnected by
* tab_manager_close_tab() before the webview is destroyed. */
tab_info_t *tab = g_tabs[index];
if (tab) {
/* The webview and widgets are destroyed by GtkNotebook when the
* page is removed. We just free the struct. */
g_free(tab);
}
/* Shift remaining entries down. */
for (int i = index; i < g_tab_count - 1; i++) {
g_tabs[i] = g_tabs[i + 1];
}
g_tab_count--;
}
/* Find the index of a tab in the global g_tabs array by pointer.
*
* The g_tabs array is a flat list of all open tabs across all windows.
* Notebook page numbers only match g_tabs indices for the main window;
* auxiliary windows have their own page numbering. Internal handlers
* (close button, tab label clicks, load-changed title updates) should
* use this to look up the g_tabs index rather than gtk_notebook_page_num,
* which returns the page number within a single notebook. */
static int tab_array_find(tab_info_t *tab) {
if (tab == NULL) return -1;
for (int i = 0; i < g_tab_count; i++) {
if (g_tabs[i] == tab) return i;
}
return -1;
}
/* ── Tab creation ─────────────────────────────────────────────────── */
/* Return the notebook that the next new tab should be added to.
*
* Priority:
* 1. g_target_notebook — explicitly set by tab_manager_new_window()
* to place the first tab into the new window's notebook.
* 2. g_active_notebook — the currently focused window's notebook, so
* Ctrl+T / "New Tab" opens in the active window.
* 3. g_notebook — the main window's notebook (fallback). */
static GtkWidget *get_effective_notebook(void) {
if (g_target_notebook != NULL) return g_target_notebook;
if (g_active_notebook != NULL) return g_active_notebook;
return g_notebook;
}
/* Find the GtkNotebook that contains the given tab page widget.
*
* Tabs can live in either the main window's notebook (g_notebook) or an
* auxiliary window's notebook (g_active_notebook when it differs). This
* checks the active notebook first, then the main notebook, and returns
* whichever one actually contains the page (gtk_notebook_page_num >= 0).
*
* Returns NULL if the page is not found in either notebook. */
static GtkWidget *tab_find_notebook(GtkWidget *page) {
if (page == NULL) return NULL;
if (g_active_notebook != NULL && g_active_notebook != g_notebook) {
if (gtk_notebook_page_num(GTK_NOTEBOOK(g_active_notebook), page) >= 0)
return g_active_notebook;
}
if (g_notebook != NULL) {
if (gtk_notebook_page_num(GTK_NOTEBOOK(g_notebook), page) >= 0)
return g_notebook;
}
return NULL;
}
/* ── Bookmarks toolbar ─────────────────────────────────────────────── */
/* Forward decl — defined below tab_create. */
static void on_bookmark_bar_clicked(GtkButton *btn, gpointer user_data);
static void bookmark_bar_refresh(tab_info_t *tab);
static void bookmark_bar_refresh_all(void *user_data);
static int g_bookmark_bar_subscribed = 0;
/* Click handler for a bookmark button in the bar. user_data is the URL
* (g_strdup'd; freed via g_object_set_data_full destroy notify). */
static void on_bookmark_bar_clicked(GtkButton *btn, gpointer user_data) {
(void)btn;
const char *url = (const char *)user_data;
if (url == NULL || url[0] == '\0') return;
/* Find the tab that owns this button by walking the bar's parent. */
GtkWidget *bar = gtk_widget_get_parent(GTK_WIDGET(btn));
if (bar == NULL) return;
GtkWidget *page = gtk_widget_get_parent(bar);
if (page == NULL) return;
tab_info_t *target = NULL;
for (int i = 0; i < g_tab_count; i++) {
if (g_tabs[i] && g_tabs[i]->page == page) {
target = g_tabs[i];
break;
}
}
if (target == NULL || target->webview == NULL) return;
webkit_web_view_load_uri(target->webview, url);
if (target->url_entry) {
gtk_entry_set_text(GTK_ENTRY(target->url_entry), url);
}
}
/* Recursively add a folder's bookmarks (and one level of subfolders as
* GtkMenuButton popovers) to a container. */
static void bookmark_bar_add_folder(GtkWidget *container,
const bookmark_node_t *node) {
if (node == NULL) return;
/* Bookmarks in this folder. */
for (int i = 0; i < node->bookmark_count; i++) {
const bookmark_t *bm = &node->bookmarks[i];
const char *label = (bm->title && bm->title[0]) ? bm->title : bm->url;
GtkWidget *btn = gtk_button_new_with_label(label);
gtk_button_set_relief(GTK_BUTTON(btn), GTK_RELIEF_NONE);
gtk_widget_set_tooltip_text(btn, bm->url);
char *url_copy = g_strdup(bm->url);
g_object_set_data_full(G_OBJECT(btn), "bm-url", url_copy,
(GDestroyNotify)g_free);
g_signal_connect(btn, "clicked",
G_CALLBACK(on_bookmark_bar_clicked), url_copy);
gtk_box_pack_start(GTK_BOX(container), btn, FALSE, FALSE, 0);
}
/* Subfolders as menu buttons with popover menus. */
for (int i = 0; i < node->child_count; i++) {
const bookmark_node_t *child = &node->children[i];
GtkWidget *menu = gtk_menu_new();
/* Add the subfolder's bookmarks to the menu. */
for (int j = 0; j < child->bookmark_count; j++) {
const bookmark_t *bm = &child->bookmarks[j];
const char *label = (bm->title && bm->title[0]) ? bm->title : bm->url;
GtkWidget *item = gtk_menu_item_new_with_label(label);
char *url_copy = g_strdup(bm->url);
g_object_set_data_full(G_OBJECT(item), "bm-url", url_copy,
(GDestroyNotify)g_free);
/* We need the tab to load into; defer to click handler which
* finds the tab from the menu's toplevel. For simplicity, use
* the same on_bookmark_bar_clicked — it walks parents. */
g_signal_connect(item, "activate",
G_CALLBACK(on_bookmark_bar_clicked), url_copy);
gtk_menu_shell_append(GTK_MENU_SHELL(menu), item);
}
/* Recurse one level deeper for sub-subfolders (as submenus). */
for (int j = 0; j < child->child_count; j++) {
const bookmark_node_t *grand = &child->children[j];
GtkWidget *submenu = gtk_menu_new();
for (int k = 0; k < grand->bookmark_count; k++) {
const bookmark_t *bm = &grand->bookmarks[k];
const char *label = (bm->title && bm->title[0]) ? bm->title : bm->url;
GtkWidget *item = gtk_menu_item_new_with_label(label);
char *url_copy = g_strdup(bm->url);
g_object_set_data_full(G_OBJECT(item), "bm-url", url_copy,
(GDestroyNotify)g_free);
g_signal_connect(item, "activate",
G_CALLBACK(on_bookmark_bar_clicked), url_copy);
gtk_menu_shell_append(GTK_MENU_SHELL(submenu), item);
}
GtkWidget *sub_item = gtk_menu_item_new_with_label(child->children[j].name);
gtk_menu_item_set_submenu(GTK_MENU_ITEM(sub_item), submenu);
gtk_menu_shell_append(GTK_MENU_SHELL(menu), sub_item);
}
GtkWidget *mb = gtk_menu_button_new();
gtk_button_set_label(GTK_BUTTON(mb), child->name);
gtk_menu_button_set_popup(GTK_MENU_BUTTON(mb), menu);
gtk_widget_set_tooltip_text(mb, child->path);
gtk_box_pack_start(GTK_BOX(container), mb, FALSE, FALSE, 0);
}
}
/* Refresh a single tab's bookmark bar from the current bookmark tree. */
static void bookmark_bar_refresh(tab_info_t *tab) {
if (tab == NULL || tab->bookmark_bar == NULL) return;
/* Clear existing children. */
GList *children = gtk_container_get_children(GTK_CONTAINER(tab->bookmark_bar));
for (GList *l = children; l != NULL; l = l->next) {
gtk_widget_destroy(GTK_WIDGET(l->data));
}
g_list_free(children);
/* Look up the "Bookmarks Bar" folder. It always exists in memory
* (bookmarks_init ensures it), but may be empty until the user adds
* bookmarks to it. Show a friendly hint in that case. */
const bookmark_node_t *bar_node = bookmarks_find("Bookmarks Bar");
if (bar_node == NULL || (bar_node->bookmark_count == 0 && bar_node->child_count == 0)) {
GtkWidget *hint = gtk_label_new(
"Bookmarks Bar is empty — bookmark a page and choose \"Bookmarks Bar\" as the folder");
gtk_widget_set_sensitive(hint, FALSE);
gtk_widget_set_margin_start(hint, 4);
gtk_box_pack_start(GTK_BOX(tab->bookmark_bar), hint, FALSE, FALSE, 0);
gtk_widget_show_all(tab->bookmark_bar);
return;
}
bookmark_bar_add_folder(tab->bookmark_bar, bar_node);
gtk_widget_show_all(tab->bookmark_bar);
}
/* Callback invoked by bookmarks_subscribe_changed: refresh every open tab. */
static void bookmark_bar_refresh_all(void *user_data) {
(void)user_data;
for (int i = 0; i < g_tab_count; i++) {
if (g_tabs[i] != NULL) {
bookmark_bar_refresh(g_tabs[i]);
}
}
}
static tab_info_t *tab_create(const char *url) {
const browser_settings_t *s = settings_get();
tab_info_t *tab = g_new0(tab_info_t, 1);
if (tab == NULL) return NULL;
/* Determine the URL to load. */
const char *load_url = url;
char *default_url = NULL;
if (load_url == NULL || load_url[0] == '\0') {
load_url = s->new_tab_url;
}
default_url = normalize_url(load_url);
if (default_url == NULL) {
default_url = g_strdup(load_url);
}
snprintf(tab->current_url, sizeof(tab->current_url), "%s", default_url);
snprintf(tab->title, sizeof(tab->title), "Loading…");
/* Create the webview. Prefer a related view (set by
* tab_manager_new_window) so the new webview shares the parent's
* WebProcess and window features — this avoids the
* std::optional<WindowFeatures> assertion crash that occurs with
* webkit_web_view_new_with_context() for target="_blank" windows.
* Fall back to the shared context for normal new tabs. */
if (g_target_related_view != NULL &&
WEBKIT_IS_WEB_VIEW(g_target_related_view)) {
tab->webview = WEBKIT_WEB_VIEW(
webkit_web_view_new_with_related_view(g_target_related_view));
} else {
tab->webview = WEBKIT_WEB_VIEW(webkit_web_view_new_with_context(g_ctx));
}
/* Enable developer extras + security settings (same as original main.c). */
WebKitSettings *settings = webkit_web_view_get_settings(tab->webview);
webkit_settings_set_enable_developer_extras(settings, TRUE);
webkit_settings_set_enable_javascript(settings, TRUE);
webkit_settings_set_javascript_can_open_windows_automatically(settings, TRUE);
webkit_settings_set_allow_file_access_from_file_urls(settings, TRUE);
webkit_settings_set_allow_universal_access_from_file_urls(settings, TRUE);
webkit_settings_set_allow_modal_dialogs(settings, TRUE);
/* Enhance native application/json documents at document end. The
* embedded file is not NUL-terminated, so copy it before passing it to
* WebKitUserScript. */
if (s->json_viewer_enabled) {
const embedded_file_t *json_viewer =
get_embedded_file("json-viewer/json-viewer.js");
if (json_viewer != NULL) {
char *source = g_strndup((const char *)json_viewer->data,
json_viewer->size);
WebKitUserContentManager *manager =
webkit_web_view_get_user_content_manager(tab->webview);
WebKitUserScript *script = webkit_user_script_new(
source,
WEBKIT_USER_CONTENT_INJECT_ALL_FRAMES,
WEBKIT_USER_SCRIPT_INJECT_AT_DOCUMENT_END,
NULL,
NULL);
webkit_user_content_manager_add_script(manager, script);
webkit_user_script_unref(script);
g_free(source);
} else {
g_printerr("[json-viewer] Embedded script not found\n");
}
}
/* Inject window.nostr into this webview. */
nostr_inject_setup(tab->webview);
/* Connect the key-press handler to the webview so browser-level
* shortcuts (Ctrl+T, Ctrl+Shift+I, etc.) are caught even when the
* webview has focus. We connect AFTER so the webview still gets
* normal key events for text input, but our handler can intercept
* recognized shortcuts. */
g_signal_connect(G_OBJECT(tab->webview), "key-press-event",
G_CALLBACK(on_key_press), NULL);
/* Build the per-tab page: toolbar on top, webview below. */
tab->page = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0);
GtkWidget *toolbar = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 4);
gtk_widget_set_margin_top(toolbar, 4);
gtk_widget_set_margin_bottom(toolbar, 4);
gtk_widget_set_margin_start(toolbar, 4);
gtk_widget_set_margin_end(toolbar, 4);
gtk_box_pack_start(GTK_BOX(tab->page), toolbar, FALSE, FALSE, 0);
tab->hamburger = build_hamburger_menu(tab);
gtk_box_pack_start(GTK_BOX(toolbar), tab->hamburger, FALSE, FALSE, 0);
/* Refresh/Stop button — left-click reloads (or stops while loading),
* right-click shows a menu with hard reload options (bypass cache,
* clear cookies+reload, etc.). The icon/tooltip are kept in sync with
* the webview's is-loading state by tab_refresh_button_update(), so
* the same button acts as both reload and stop. Stored on
* tab->refresh_btn so the load handlers can update it per-tab. */
GtkWidget *refresh_btn = gtk_button_new();
tab->refresh_btn = refresh_btn;
tab->refresh_shows_stop = FALSE;
gtk_button_set_relief(GTK_BUTTON(refresh_btn), GTK_RELIEF_NONE);
gtk_button_set_image(GTK_BUTTON(refresh_btn),
gtk_image_new_from_icon_name("view-refresh-symbolic",
GTK_ICON_SIZE_MENU));
gtk_widget_set_tooltip_text(refresh_btn,
"Reload page (right-click for hard reload options)");
g_signal_connect(refresh_btn, "clicked",
G_CALLBACK(on_refresh_clicked), tab);
g_signal_connect(refresh_btn, "button-press-event",
G_CALLBACK(on_refresh_button_press), tab);
gtk_box_pack_start(GTK_BOX(toolbar), refresh_btn, FALSE, FALSE, 0);
/* Back button — navigate to previous page. */
GtkWidget *back_btn = gtk_button_new();
gtk_button_set_relief(GTK_BUTTON(back_btn), GTK_RELIEF_NONE);
gtk_button_set_image(GTK_BUTTON(back_btn),
gtk_image_new_from_icon_name("go-previous-symbolic",
GTK_ICON_SIZE_MENU));
gtk_widget_set_tooltip_text(back_btn, "Go back");
g_signal_connect(back_btn, "clicked",
G_CALLBACK(on_back_clicked), tab);
gtk_box_pack_start(GTK_BOX(toolbar), back_btn, FALSE, FALSE, 0);
/* Forward button — navigate to next page. */
GtkWidget *forward_btn = gtk_button_new();
gtk_button_set_relief(GTK_BUTTON(forward_btn), GTK_RELIEF_NONE);
gtk_button_set_image(GTK_BUTTON(forward_btn),
gtk_image_new_from_icon_name("go-next-symbolic",
GTK_ICON_SIZE_MENU));
gtk_widget_set_tooltip_text(forward_btn, "Go forward");
g_signal_connect(forward_btn, "clicked",
G_CALLBACK(on_forward_clicked), tab);
gtk_box_pack_start(GTK_BOX(toolbar), forward_btn, FALSE, FALSE, 0);
tab->url_entry = gtk_entry_new();
/* Show an empty URL bar for new-tab pages (about:blank) so the user
* can immediately type a URL. For real URLs, show the URL. */
if (default_url && strstr(default_url, "about:blank") != NULL) {
gtk_entry_set_text(GTK_ENTRY(tab->url_entry), "");
} else {
gtk_entry_set_text(GTK_ENTRY(tab->url_entry), default_url);
}
gtk_box_pack_start(GTK_BOX(toolbar), tab->url_entry, TRUE, TRUE, 0);
/* ── URL bar completion (search dropdown) ────────────────────── *
* Create a GtkListStore and GtkEntryCompletion for this tab's URL
* entry. The store is populated on each keystroke with direct links
* (history + bookmarks + domain heuristic) and async search engine
* suggestions. */
completion_state_t *cs = g_new0(completion_state_t, 1);
cs->store = gtk_list_store_new(COMPLETION_COL_COUNT,
G_TYPE_STRING, /* display */
G_TYPE_STRING, /* url */
G_TYPE_BOOLEAN);/* is_direct*/
cs->suggest_req_id = 0;
cs->last_query = NULL;
GtkEntryCompletion *completion = gtk_entry_completion_new();
gtk_entry_completion_set_model(completion,
GTK_TREE_MODEL(cs->store));
gtk_entry_completion_set_text_column(completion,
COMPLETION_COL_DISPLAY);
gtk_entry_completion_set_minimum_key_length(completion,
COMPLETION_MIN_KEY_LEN);
/* Use a custom match function so all rows in the store are shown
* (we already filtered them in rebuild_completion). The default
* match function would re-filter by the display column, which we
* don't want. */
gtk_entry_completion_set_match_func(completion,
(GtkEntryCompletionMatchFunc)gtk_true, NULL, NULL);
g_signal_connect(completion, "match-selected",
G_CALLBACK(on_completion_match_selected), tab);
gtk_entry_set_completion(GTK_ENTRY(tab->url_entry), completion);
g_object_unref(completion);
/* Store the completion state on the entry for retrieval in
* on_url_changed. Free it when the entry is destroyed. */
g_object_set_data_full(G_OBJECT(tab->url_entry), "completion-state",
cs, (GDestroyNotify)completion_state_free);
/* Bookmark button — right of the URL entry. Opens a directory picker
* dialog to bookmark the current page. */
GtkWidget *bookmark_btn = gtk_button_new();
gtk_button_set_relief(GTK_BUTTON(bookmark_btn), GTK_RELIEF_NONE);
gtk_button_set_image(GTK_BUTTON(bookmark_btn),
gtk_image_new_from_icon_name("user-bookmarks-symbolic",
GTK_ICON_SIZE_MENU));
gtk_widget_set_tooltip_text(bookmark_btn, "Bookmark this page");
g_signal_connect(bookmark_btn, "clicked",
G_CALLBACK(on_bookmark_clicked), tab);
gtk_box_pack_start(GTK_BOX(toolbar), bookmark_btn, FALSE, FALSE, 0);
/* Bookmarks toolbar — a horizontal bar below the URL toolbar showing
* buttons for the bookmarks in the "Bookmarks Bar" folder, plus
* GtkMenuButton popovers for subfolders. Refreshed whenever bookmarks
* change (see bookmark_bar_refresh_all via bookmarks_subscribe_changed). */
tab->bookmark_bar = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 4);
gtk_widget_set_name(tab->bookmark_bar, "bookmark-bar");
gtk_widget_set_margin_top(tab->bookmark_bar, 2);
gtk_widget_set_margin_bottom(tab->bookmark_bar, 2);
gtk_widget_set_margin_start(tab->bookmark_bar, 4);
gtk_widget_set_margin_end(tab->bookmark_bar, 4);
gtk_box_pack_start(GTK_BOX(tab->page), tab->bookmark_bar, FALSE, FALSE, 0);
bookmark_bar_refresh(tab);
/* Ensure the webview expands vertically to fill the available space.
* Without this, WebKitGTK may only allocate 1px of height on some
* display servers, resulting in a blank page. */
gtk_widget_set_vexpand(GTK_WIDGET(tab->webview), TRUE);
gtk_widget_set_hexpand(GTK_WIDGET(tab->webview), TRUE);
/* The sidebar is now at the window level (window_state_t.paned),
* not per-tab. The webview is packed directly into the tab's page. */
gtk_box_pack_start(GTK_BOX(tab->page), GTK_WIDGET(tab->webview),
TRUE, TRUE, 0);
/* Build the tab label. */
tab->tab_label = build_tab_label(tab);
/* Wire signals. */
g_signal_connect(tab->url_entry, "activate",
G_CALLBACK(on_url_activate), tab);
g_signal_connect(tab->url_entry, "changed",
G_CALLBACK(on_url_changed), tab);
/* key-press-event must be connected BEFORE the default handler so
* we can intercept Tab before GtkEntry processes it. */
g_signal_connect(tab->url_entry, "key-press-event",
G_CALLBACK(on_url_key_press), tab);
g_signal_connect(tab->webview, "load-changed",
G_CALLBACK(on_load_changed), tab);
g_signal_connect(tab->webview, "load-failed",
G_CALLBACK(on_load_failed), tab);
g_signal_connect(tab->webview, "notify::favicon",
G_CALLBACK(on_favicon_changed), tab);
g_signal_connect(tab->webview, "notify::title",
G_CALLBACK(on_title_changed), tab);
g_signal_connect(tab->webview, "context-menu",
G_CALLBACK(on_webview_context_menu), tab);
g_signal_connect(tab->webview, "decide-policy",
G_CALLBACK(on_decide_policy), tab);
g_signal_connect(tab->webview, "create",
G_CALLBACK(on_create_webview), tab);
/* Load the URL. */
webkit_web_view_load_uri(tab->webview, default_url);
g_free(default_url);
return tab;
}
/* ── User avatar (far left of tab bar) ────────────────────────────── *
* Shows the user's Nostr profile picture as a circular avatar on the
* far left of the tab bar, matching the size/shape of the hamburger
* menu button. Falls back to a default avatar icon if no picture is
* available or the download fails.
*/
static GtkWidget *g_avatar = NULL;
/* List of all avatar GtkImage widgets across all windows. When the
* avatar picture is downloaded, every image in this list is updated so
* auxiliary windows show the same avatar as the main window. The main
* window's g_avatar is also kept in this list. */
static GSList *g_avatar_images = NULL;
/* Track all avatar images so they can all be updated when the picture
* arrives. Adds the image to the global list. */
static void track_avatar_image(GtkWidget *image) {
if (image == NULL) return;
g_avatar_images = g_slist_prepend(g_avatar_images, image);
/* Sink a ref so the image stays alive for the list even if the
* button is destroyed before the list is cleaned up. The list is
* never freed during the app lifetime (avatars persist for the
* whole session), so this is a small intentional leak that avoids
* dangling pointers in the download callback. */
g_object_ref_sink(G_OBJECT(image));
}
/* Update every tracked avatar image with the given pixbuf. Used by the
* avatar download idle callback so all windows' avatars stay in sync. */
static void set_all_avatar_pixbufs(GdkPixbuf *pixbuf) {
for (GSList *l = g_avatar_images; l != NULL; l = l->next) {
GtkWidget *img = GTK_WIDGET(l->data);
if (img && GTK_IS_IMAGE(img) && pixbuf) {
/* Each image needs its own pixbuf reference. */
GdkPixbuf *copy = g_object_ref(pixbuf);
gtk_image_set_from_pixbuf(GTK_IMAGE(img), copy);
g_object_unref(copy);
}
}
}
/* Reset every tracked avatar image to the default icon. */
static void set_all_avatar_icons(const char *icon_name) {
for (GSList *l = g_avatar_images; l != NULL; l = l->next) {
GtkWidget *img = GTK_WIDGET(l->data);
if (img && GTK_IS_IMAGE(img)) {
gtk_image_set_from_icon_name(GTK_IMAGE(img), icon_name,
GTK_ICON_SIZE_BUTTON);
}
}
}
/* Scale a pixbuf to fill the given size, center-cropping to preserve
* aspect ratio (like CSS object-fit: cover), then round the corners
* to match the button's border-radius. Returns a new pixbuf (caller
* must unref). */
#define AVATAR_BORDER_RADIUS 4
static GdkPixbuf *make_fitted_pixbuf(GdkPixbuf *src, int size) {
if (src == NULL) return NULL;
int w = gdk_pixbuf_get_width(src);
int h = gdk_pixbuf_get_height(src);
if (w <= 0 || h <= 0) return NULL;
/* Scale so the smaller dimension fills `size`, then center-crop
* the larger dimension. This is object-fit: cover. */
double scale = (double)size / (w < h ? w : h);
int scaled_w = (int)(w * scale + 0.5);
int scaled_h = (int)(h * scale + 0.5);
if (scaled_w < size) scaled_w = size;
if (scaled_h < size) scaled_h = size;
GdkPixbuf *scaled = gdk_pixbuf_scale_simple(src, scaled_w, scaled_h,
GDK_INTERP_BILINEAR);
if (scaled == NULL) return NULL;
/* Center-crop to size×size. */
int xoff = (scaled_w - size) / 2;
int yoff = (scaled_h - size) / 2;
GdkPixbuf *cropped = gdk_pixbuf_new_subpixbuf(scaled, xoff, yoff,
size, size);
g_object_unref(scaled);
if (cropped == NULL) return NULL;
/* Round the corners using a cairo rounded-rectangle clip, matching
* the button's border-radius (AVATAR_BORDER_RADIUS). */
cairo_surface_t *surface = cairo_image_surface_create(
CAIRO_FORMAT_ARGB32, size, size);
cairo_t *cr = cairo_create(surface);
/* Draw a rounded-rectangle path and clip. */
double r = AVATAR_BORDER_RADIUS;
cairo_new_path(cr);
cairo_arc(cr, size - r, r, r, -G_PI_2, 0); /* top-right */
cairo_arc(cr, size - r, size - r, r, 0, G_PI_2); /* bottom-right */
cairo_arc(cr, r, size - r, r, G_PI_2, G_PI); /* bottom-left */
cairo_arc(cr, r, r, r, G_PI, 1.5 * G_PI); /* top-left */
cairo_close_path(cr);
cairo_clip(cr);
/* Paint the pixbuf onto the clipped surface. */
gdk_cairo_set_source_pixbuf(cr, cropped, 0, 0);
cairo_paint(cr);
cairo_destroy(cr);
/* Convert the surface back to a pixbuf. */
GdkPixbuf *result = gdk_pixbuf_get_from_surface(surface, 0, 0,
size, size);
cairo_surface_destroy(surface);
g_object_unref(cropped);
return result;
}
/* Thread data for avatar download. */
typedef struct {
char *url;
int size;
} avatar_fetch_t;
/* Idle callback to set the avatar pixbuf on every tracked avatar image
* (main window + all auxiliary windows) so they all stay in sync. */
static gboolean avatar_set_pixbuf_idle(gpointer data) {
GdkPixbuf *pixbuf = (GdkPixbuf *)data;
if (pixbuf) {
set_all_avatar_pixbufs(pixbuf);
g_object_unref(pixbuf);
}
return G_SOURCE_REMOVE;
}
/* Reworked fetch thread that uses the idle callback properly. */
static gpointer avatar_fetch_thread_v2(gpointer data) {
avatar_fetch_t *af = (avatar_fetch_t *)data;
GdkPixbuf *pixbuf = NULL;
if (g_str_has_prefix(af->url, "file://")) {
const char *path = af->url + 7;
pixbuf = gdk_pixbuf_new_from_file_at_size(path, af->size,
af->size, NULL);
} else if (g_str_has_prefix(af->url, "http://") ||
g_str_has_prefix(af->url, "https://")) {
SoupSession *session = soup_session_new();
SoupMessage *msg = soup_message_new("GET", af->url);
GBytes *bytes = soup_session_send_and_read(session, msg, NULL, NULL);
if (bytes != NULL) {
gsize len = 0;
const guchar *data_ptr = g_bytes_get_data(bytes, &len);
if (data_ptr && len > 0) {
GInputStream *stream = g_memory_input_stream_new_from_data(
data_ptr, len, NULL);
pixbuf = gdk_pixbuf_new_from_stream_at_scale(
stream, af->size, af->size, TRUE, NULL, NULL);
g_object_unref(stream);
}
g_bytes_unref(bytes);
}
g_object_unref(msg);
g_object_unref(session);
}
if (pixbuf != NULL) {
GdkPixbuf *fitted = make_fitted_pixbuf(pixbuf, af->size);
g_object_unref(pixbuf);
if (fitted != NULL) {
g_idle_add(avatar_set_pixbuf_idle, fitted);
}
}
g_free(af->url);
g_free(af);
return NULL;
}
/* Set the avatar from the user's pubkey. Queries the kind 0 profile
* from SQLite and starts a background download of the picture.
* Called from main.c after login. */
void tab_manager_set_avatar(const char *pubkey_hex) {
if (g_avatar == NULL || pubkey_hex == NULL || pubkey_hex[0] == '\0') {
set_all_avatar_icons("avatar-default-symbolic");
return;
}
/* Query the kind 0 profile from SQLite. */
cJSON *kind0 = db_get_latest_event(pubkey_hex, 0);
if (kind0 == NULL) {
set_all_avatar_icons("avatar-default-symbolic");
return;
}
const char *picture = NULL;
cJSON *content = cJSON_GetObjectItemCaseSensitive(kind0, "content");
if (cJSON_IsString(content) && content->valuestring[0]) {
cJSON *meta = cJSON_Parse(content->valuestring);
if (meta) {
cJSON *pic = cJSON_GetObjectItemCaseSensitive(meta, "picture");
if (cJSON_IsString(pic) && pic->valuestring[0]) {
picture = g_strdup(pic->valuestring);
}
cJSON_Delete(meta);
}
}
cJSON_Delete(kind0);
if (picture == NULL) {
set_all_avatar_icons("avatar-default-symbolic");
return;
}
/* Start a background thread to download and process the avatar. */
avatar_fetch_t *af = g_new(avatar_fetch_t, 1);
af->url = g_strdup(picture);
af->size = 28; /* match the fixed button size (28x28) */
g_thread_new("avatar-fetch", avatar_fetch_thread_v2, af);
g_free((char *)picture);
}
/* ── Avatar button — opens the profile page ───────────────────────── */
static void on_avatar_clicked(GtkButton *btn, gpointer data) {
(void)btn;
(void)data;
/* Open sovereign://profile in the active tab, or a new tab if
* none exists. */
tab_info_t *tab = tab_manager_get_active();
if (tab && tab->webview) {
webkit_web_view_load_uri(tab->webview, "sovereign://profile");
} else {
tab_manager_new_tab("sovereign://profile");
}
}
/* ── New tab button ───────────────────────────────────────────────── */
static void on_new_tab_clicked(GtkButton *btn, gpointer data) {
(void)btn;
GtkWidget *nb = GTK_WIDGET(data);
/* If a specific notebook was passed (the button sits in an auxiliary
* window's tab strip), direct the new tab into that notebook by
* setting g_target_notebook for the duration of the call. Otherwise
* fall back to the active window's notebook. */
if (nb != NULL) {
g_target_notebook = nb;
tab_manager_new_tab(NULL);
g_target_notebook = NULL;
} else {
tab_manager_new_tab(NULL);
}
}
/* ── Notebook action-widget setup ─────────────────────────────────── *
* Adds the new-tab button (right end) and avatar button (left end) as
* GtkNotebook action widgets, plus the right-click context-menu handler.
* Used by both the main window's notebook (tab_manager_init) and each
* auxiliary window's notebook (tab_manager_new_window) so every window
* has a working new-tab button that opens tabs in THAT window.
*
* notebook: the GtkNotebook to attach the action widgets to
* is_main: TRUE for the main window — the avatar image is stored in
* the global g_avatar (so tab_manager_set_avatar can find it
* by legacy reference). For auxiliary windows, a separate
* image is created and tracked in g_avatar_images so it
* still gets updated when the picture arrives.
*/
/* ── App theme (GTK CSS) ───────────────────────────────────────────── *
* Applies the sovereign_browser visual theme to the GTK native UI:
* - Red accent (#ff0000) for URL entry focus, tab active/highlight
* - Button scheme matching the client project: black border, white bg,
* red hover border, red active bg (swapped for dark mode)
* Called at init and when the theme_dark setting changes. */
static GtkCssProvider *g_app_theme_provider = NULL;
static void apply_app_theme(void) {
const browser_settings_t *s = settings_get();
int dark = s ? s->theme_dark : 0;
/* Colors: light mode = black-on-white, dark mode = white-on-black.
* Only the tab underline and URL focus ring are overridden — buttons
* and everything else stay with the OS GTK theme to avoid breaking
* the theme's rendering logic. */
const char *fg = dark ? "#ffffff" : "#000000";
char *css = g_strdup_printf(
/* Avatar / hamburger button sizing */
"#avatar-btn, #hamburger-btn {"
" padding: 0px;"
" min-width: 28px; min-height: 28px;"
" border-radius: 4px;"
"}"
"#avatar-btn image, #hamburger-btn image {"
" padding: 0px; margin: 0px;"
"}"
/* ── Red accent for the URL entry focus ring ──────────────── */
"entry:focus {"
" border-color: #ff0000 !important;"
" box-shadow: 0 0 0 1px #ff0000 !important;"
"}"
/* ── Tab active underline: black bar (replaces blue) ────────
* Adwaita renders the active-tab highlight as a box-shadow inset
* on tab:checked. We kill that and draw a solid border-bottom
* in the fg color. !important is needed to beat the theme. */
"notebook tab:checked {"
" box-shadow: none !important;"
" outline: none !important;"
" border-bottom: 3px solid %s !important;"
"}"
/* Tab text: normal (inherit from theme), no red. */
/* ── Bookmark bar buttons — compact padding ───────────────── */
"#bookmark-bar button {"
" padding: 2px 8px;"
"}",
fg /* tab:checked border-bottom color */
);
if (g_app_theme_provider == NULL) {
g_app_theme_provider = gtk_css_provider_new();
/* USER priority (highest) so the tab/entry overrides beat the
* GTK theme. We only override specific properties (tab underline,
* entry focus), so the rest of the theme is untouched. */
gtk_style_context_add_provider_for_screen(
gdk_screen_get_default(),
GTK_STYLE_PROVIDER(g_app_theme_provider),
GTK_STYLE_PROVIDER_PRIORITY_USER);
}
gtk_css_provider_load_from_data(g_app_theme_provider, css, -1, NULL);
g_free(css);
}
static void setup_notebook_action_widgets(GtkWidget *notebook, gboolean is_main) {
g_return_if_fail(notebook != NULL && GTK_IS_NOTEBOOK(notebook));
/* Right-click / middle-click context menu on the tab strip. */
gtk_widget_add_events(notebook, GDK_BUTTON_PRESS_MASK);
g_signal_connect(notebook, "button-press-event",
G_CALLBACK(on_notebook_button_press), NULL);
/* New-tab button at the end of the tab strip. Pass the notebook as
* user_data so the button opens the tab in THIS notebook, not just
* whatever window happens to be active. */
GtkWidget *new_btn = gtk_button_new();
gtk_button_set_relief(GTK_BUTTON(new_btn), GTK_RELIEF_NONE);
gtk_button_set_image(GTK_BUTTON(new_btn),
gtk_image_new_from_icon_name("tab-new-symbolic",
GTK_ICON_SIZE_BUTTON));
gtk_widget_set_tooltip_text(new_btn, "New tab (Ctrl+T)");
g_signal_connect(new_btn, "clicked",
G_CALLBACK(on_new_tab_clicked), notebook);
gtk_widget_show_all(new_btn);
gtk_notebook_set_action_widget(GTK_NOTEBOOK(notebook), new_btn,
GTK_PACK_END);
/* User avatar at the start (far left) of the tab strip. The main
* window's image is stored in g_avatar for backward compatibility;
* auxiliary windows get their own image, tracked in g_avatar_images
* so all windows' avatars update together. */
GtkWidget *avatar_img = gtk_image_new_from_icon_name(
"avatar-default-symbolic", GTK_ICON_SIZE_BUTTON);
if (is_main) {
g_avatar = avatar_img;
}
track_avatar_image(avatar_img);
GtkWidget *avatar_btn = gtk_button_new();
gtk_button_set_relief(GTK_BUTTON(avatar_btn), GTK_RELIEF_NORMAL);
gtk_button_set_image(GTK_BUTTON(avatar_btn), avatar_img);
gtk_widget_set_tooltip_text(avatar_btn, "Your profile");
gtk_widget_set_valign(avatar_btn, GTK_ALIGN_CENTER);
gtk_widget_set_margin_start(avatar_btn, 4);
gtk_widget_set_name(avatar_btn, "avatar-btn");
gtk_widget_set_size_request(avatar_btn, 28, 28); /* fixed square, matches hamburger */
g_signal_connect(avatar_btn, "clicked",
G_CALLBACK(on_avatar_clicked), NULL);
gtk_widget_show_all(avatar_btn);
gtk_notebook_set_action_widget(GTK_NOTEBOOK(notebook), avatar_btn,
GTK_PACK_START);
}
/* ── Public API ───────────────────────────────────────────────────── */
void tab_manager_init(GtkContainer *parent,
WebKitWebContext *ctx,
GtkWindow *window) {
g_ctx = ctx;
g_window = window;
/* Subscribe to bookmark changes so every open tab's bookmark bar
* refreshes when bookmarks are added/moved/deleted/loaded. Registered
* once here; the callback iterates all open tabs. */
if (!g_bookmark_bar_subscribed) {
bookmarks_subscribe_changed(bookmark_bar_refresh_all, NULL);
g_bookmark_bar_subscribed = 1;
}
g_notebook = gtk_notebook_new();
/* Disable scrolling so tabs share the available width evenly instead
* of showing a scrollbar when there are many tabs. */
gtk_notebook_set_scrollable(GTK_NOTEBOOK(g_notebook), FALSE);
gtk_notebook_set_show_border(GTK_NOTEBOOK(g_notebook), FALSE);
gtk_notebook_set_show_tabs(GTK_NOTEBOOK(g_notebook), TRUE);
/* New-tab button + avatar as notebook action widgets. */
setup_notebook_action_widgets(g_notebook, TRUE);
/* CSS: remove internal padding from both buttons so their contents
* fill edge-to-edge. Force both to a fixed square size (28x28) and
* restore rounded corners so the avatar image is clipped to the
* button's rounded shape. Sizes are also set via
* gtk_widget_set_size_request() in C.
*
* Also apply the sovereign_browser app theme: red accent (#ff0000)
* for the URL entry focus ring, tab active/highlight indicators, and
* button hover/active states — matching the client project's button
* scheme (black border, white bg, red hover border, red active bg).
* Adapts to the theme_dark setting (swaps fg/bg). */
apply_app_theme();
/* Build a window-level GtkPaned: left = sidebar container, right =
* notebook. The sidebar is per-window (not per-tab), so it persists
* across tab switches. Hidden by default — shown when the user
* toggles it via Ctrl+Shift+A, the menu item, or the ";" shortcut. */
g_main_window.paned = gtk_paned_new(GTK_ORIENTATION_HORIZONTAL);
gtk_widget_set_vexpand(g_main_window.paned, TRUE);
gtk_widget_set_hexpand(g_main_window.paned, TRUE);
g_main_window.sidebar_container = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0);
gtk_paned_pack1(GTK_PANED(g_main_window.paned),
g_main_window.sidebar_container, FALSE, FALSE);
gtk_widget_hide(g_main_window.sidebar_container);
gtk_paned_pack2(GTK_PANED(g_main_window.paned), g_notebook, TRUE, TRUE);
gtk_paned_set_position(GTK_PANED(g_main_window.paned), 0);
gtk_container_add(parent, g_main_window.paned);
/* Populate the main window's window_state_t. */
g_main_window.window = window;
g_main_window.notebook = g_notebook;
g_main_window.sidebar_webview = NULL;
g_main_window.sidebar_visible = FALSE;
/* The main window/notebook are the default active window/notebook
* for MCP get_active_webview() and keyboard shortcuts (zoom, etc.).
* Updated when any window gains focus (see on_window_focus_in).
* The main window also needs this handler so that when the user
* clicks back to it from an auxiliary window, g_active_notebook
* reverts to the main notebook — otherwise zoom/shortcuts would
* keep targeting the auxiliary window's tab. */
g_signal_connect(window, "focus-in-event",
G_CALLBACK(on_window_focus_in), g_notebook);
g_active_window = window;
g_active_notebook = g_notebook;
g_active_ws = &g_main_window;
tab_manager_apply_settings();
}
void tab_manager_apply_settings(void) {
if (g_notebook == NULL) return;
const browser_settings_t *s = settings_get();
/* Re-apply the GTK app theme (colors adapt to theme_dark). */
apply_app_theme();
gtk_notebook_set_tab_pos(GTK_NOTEBOOK(g_notebook), s->tab_bar_position);
/* Apply drag reorder to all existing tabs. */
for (int i = 0; i < g_tab_count; i++) {
if (g_tabs[i] && g_tabs[i]->page) {
gtk_notebook_set_tab_reorderable(GTK_NOTEBOOK(g_notebook),
g_tabs[i]->page,
s->tab_drag_reorder);
}
}
}
int tab_manager_new_tab(const char *url) {
const browser_settings_t *s = settings_get();
if (g_tab_count >= s->max_tabs) {
g_print("[tabs] Maximum tab count (%d) reached\n", s->max_tabs);
return -1;
}
tab_info_t *tab = tab_create(url);
if (tab == NULL) return -1;
int index = tab_array_add(tab);
if (index < 0) {
g_free(tab);
return -1;
}
/* Inject the per-tab performance probe (sovereign://processes).
* Skips internal sovereign:// pages at runtime via the preamble. */
perf_probe_setup(tab->webview, index);
/* Add to the effective notebook (active window's notebook, or the
* main notebook as fallback). This makes Ctrl+T / "New Tab" open in
* the focused window instead of always the main window. */
GtkWidget *nb = get_effective_notebook();
int page_num = gtk_notebook_append_page(GTK_NOTEBOOK(nb),
tab->page, tab->tab_label);
gtk_notebook_set_tab_reorderable(GTK_NOTEBOOK(nb), tab->page,
s->tab_drag_reorder);
/* Show the tab widgets before switching to it — the page must be
* realized/mapped before it can become the current page and receive
* focus. */
gtk_widget_show_all(tab->page);
gtk_widget_show_all(tab->tab_label);
/* Switch to the new tab (now that it's visible). */
gtk_notebook_set_current_page(GTK_NOTEBOOK(nb), page_num);
/* Give the URL entry keyboard focus so the user can immediately
* type a URL. Select all text so typing replaces the current URL.
* Use gtk_widget_grab_focus() on the entry to ensure it receives
* keyboard input. */
gtk_widget_grab_focus(tab->url_entry);
gtk_editable_select_region(GTK_EDITABLE(tab->url_entry), 0, -1);
g_print("[tabs] Created tab %d, total: %d\n", page_num, g_tab_count);
return page_num;
}
void tab_manager_close_tab(int index) {
if (index < 0 || index >= g_tab_count) return;
tab_info_t *tab = g_tabs[index];
if (tab == NULL) return;
/* Disconnect all signal handlers from the webview BEFORE destroying
* it. During gtk_notebook_remove_page(), the webview is destroyed,
* which can emit signals (notify::title, load-changed, etc.) that
* would fire our handlers with the tab pointer as user_data. If we
* disconnect first, those signals are safely ignored. */
if (tab->webview) {
g_signal_handlers_disconnect_matched(tab->webview,
G_SIGNAL_MATCH_DATA, 0, 0, NULL, NULL, tab);
}
/* Find the notebook this tab belongs to — it may be the main
* window's notebook or an auxiliary window's notebook. */
GtkWidget *nb = tab_find_notebook(tab->page);
if (nb == NULL) nb = g_notebook; /* fallback */
/* Remove from notebook (this destroys the page widget). */
gtk_notebook_remove_page(GTK_NOTEBOOK(nb), index);
/* Remove from our array. */
tab_array_remove(index);
g_print("[tabs] Closed tab %d, remaining: %d\n", index, g_tab_count);
/* If this was an auxiliary window's last tab, close that window.
* The destroy handler (on_aux_window_destroy) reverts the active
* window pointer to the main window. We detect "aux window" by
* checking that the notebook we removed from is not g_notebook and
* now has zero pages. */
if (nb != g_notebook &&
gtk_notebook_get_n_pages(GTK_NOTEBOOK(nb)) == 0) {
GtkWidget *toplevel = gtk_widget_get_toplevel(nb);
if (toplevel != NULL && GTK_IS_WINDOW(toplevel)) {
gtk_window_close(GTK_WINDOW(toplevel));
}
}
/* If the main window has no tabs left, quit the app. */
if (g_tab_count == 0) {
g_print("[tabs] Last tab closed, exiting.\n");
if (g_window) {
gtk_window_close(g_window);
}
}
}
void tab_manager_close_active(void) {
/* Resolve the active tab by widget pointer (works across windows),
* then look up its g_tabs index. Using tab_manager_get_active_index()
* directly would return the active notebook's page number, which only
* matches the g_tabs index for the main window. */
tab_info_t *tab = tab_manager_get_active();
int index = tab_array_find(tab);
if (index >= 0) {
tab_manager_close_tab(index);
}
}
int tab_manager_get_active_index(void) {
/* Use the active window's notebook (defaults to g_notebook via
* get_effective_notebook). The returned page number is only a valid
* g_tabs index for the main window; callers that need the tab_info_t
* should use tab_manager_get_active(), which resolves by widget
* pointer to support auxiliary windows. */
GtkWidget *nb = get_effective_notebook();
if (nb == NULL) return -1;
return gtk_notebook_get_current_page(GTK_NOTEBOOK(nb));
}
void tab_manager_switch_to(int index) {
if (g_notebook == NULL) return;
if (index < 0 || index >= g_tab_count) return;
gtk_notebook_set_current_page(GTK_NOTEBOOK(g_notebook), index);
}
tab_info_t *tab_manager_get_active(void) {
/* Resolve the active tab by looking at the active notebook's current
* page widget and finding the matching tab_info_t in g_tabs by
* pointer. This works across multiple windows: the notebook page
* number only matches the g_tabs index for the main window, so we
* can't rely on it for auxiliary windows. */
GtkWidget *nb = get_effective_notebook();
if (nb == NULL) return NULL;
gint page = gtk_notebook_get_current_page(GTK_NOTEBOOK(nb));
if (page < 0) return NULL;
GtkWidget *page_widget = gtk_notebook_get_nth_page(GTK_NOTEBOOK(nb), page);
if (page_widget == NULL) return NULL;
for (int i = 0; i < g_tab_count; i++) {
if (g_tabs[i] && g_tabs[i]->page == page_widget) return g_tabs[i];
}
return NULL;
}
tab_info_t *tab_manager_get(int index) {
if (index < 0 || index >= g_tab_count) return NULL;
return g_tabs[index];
}
int tab_manager_count(void) {
return g_tab_count;
}
/* Return the main window's notebook widget. Used by main.c's
* delete-event handler to identify which tabs belong to the main window
* (vs auxiliary windows) when closing the main window but keeping aux
* windows alive. */
GtkWidget *tab_manager_get_main_notebook(void) {
return g_notebook;
}
void tab_manager_set_title(int index, const char *title) {
if (index < 0 || index >= g_tab_count) return;
tab_info_t *tab = g_tabs[index];
if (tab == NULL || tab->title_label == NULL) return;
snprintf(tab->title, sizeof(tab->title), "%s",
(title && title[0]) ? title : "(Untitled)");
gtk_label_set_text(GTK_LABEL(tab->title_label), tab->title);
}
const char *tab_manager_get_url(int index) {
if (index < 0 || index >= g_tab_count) return NULL;
tab_info_t *tab = g_tabs[index];
if (tab == NULL) return NULL;
return tab->current_url;
}
void tab_manager_next(void) {
if (g_notebook == NULL || g_tab_count == 0) return;
int cur = gtk_notebook_get_current_page(GTK_NOTEBOOK(g_notebook));
int next = (cur + 1) % g_tab_count;
gtk_notebook_set_current_page(GTK_NOTEBOOK(g_notebook), next);
}
void tab_manager_prev(void) {
if (g_notebook == NULL || g_tab_count == 0) return;
int cur = gtk_notebook_get_current_page(GTK_NOTEBOOK(g_notebook));
int prev = (cur - 1 + g_tab_count) % g_tab_count;
gtk_notebook_set_current_page(GTK_NOTEBOOK(g_notebook), prev);
}
void tab_manager_close_others(int index) {
if (index < 0 || index >= g_tab_count) return;
/* Close tabs to the right first (indices shift as we close). */
tab_manager_close_to_right(index);
/* Close tabs to the left (close from the start so indices stay valid). */
while (index > 0) {
tab_manager_close_tab(0);
index--;
}
}
void tab_manager_close_to_right(int index) {
if (index < 0 || index >= g_tab_count) return;
/* Close from the end so indices don't shift. */
while (g_tab_count > index + 1) {
tab_manager_close_tab(g_tab_count - 1);
}
}
void tab_manager_close_all(void) {
/* Close from the highest index down to 0 so indices stay valid. */
while (g_tab_count > 0) {
tab_manager_close_tab(g_tab_count - 1);
}
}
void tab_manager_duplicate(int index) {
if (index < 0 || index >= g_tab_count) return;
tab_info_t *tab = g_tabs[index];
if (tab == NULL) return;
tab_manager_new_tab(tab->current_url);
}
void tab_manager_open_in_new_window(int index) {
if (index < 0 || index >= g_tab_count) return;
tab_info_t *tab = g_tabs[index];
if (tab == NULL) return;
/* Capture the URL before opening the new window — the original tab
* will be closed below, which frees the tab_info_t. */
char url_buf[TAB_URL_MAX];
snprintf(url_buf, sizeof(url_buf), "%s", tab->current_url);
/* Open the URL in a new window. Pass the original tab's webview as
* the related view so the new window shares the WebProcess. The new
* window takes its own ref on the related view, so it's safe to
* close the original tab afterwards. */
GtkWidget *new_wv = tab_manager_new_window(url_buf, tab->webview);
if (new_wv == NULL) {
/* New window creation failed — keep the original tab open so the
* user doesn't lose the page. */
return;
}
/* Close the original tab — "Open in New Window" moves the tab to a
* new window rather than duplicating it. The index may have shifted
* if tab_manager_new_window added tabs to the array, so re-resolve
* the tab's current index by pointer. */
int cur_index = tab_array_find(tab);
if (cur_index >= 0) {
tab_manager_close_tab(cur_index);
}
}
void tab_manager_new_window_blank(void) {
/* Open a new window with the default new-tab URL. Pass the active
* webview as the related view for WebProcess sharing. */
tab_info_t *tab = tab_manager_get_active();
tab_manager_new_window(NULL, tab ? tab->webview : NULL);
}
void tab_manager_reload(int index) {
if (index < 0 || index >= g_tab_count) return;
tab_info_t *tab = g_tabs[index];
if (tab && tab->webview) {
webkit_web_view_reload_bypass_cache(tab->webview);
}
}
/* ── Agent chat sidebar (per-window) ─────────────────────────────── */
#define SIDEBAR_DEFAULT_WIDTH 280
#define AGENT_CHAT_URL_STR "sovereign://agents/chat"
/* Return the window_state_t for the currently focused window.
* Falls back to the main window if g_active_ws is NULL. */
static window_state_t *get_active_window_state(void) {
if (g_active_ws != NULL) return g_active_ws;
return &g_main_window;
}
/* Return the window_state_t whose notebook matches the given widget.
* Checks the main window first, then auxiliary windows. Returns NULL
* if no match. */
static window_state_t *window_state_for_notebook(GtkWidget *notebook) {
if (notebook == NULL) return NULL;
if (g_main_window.notebook == notebook) return &g_main_window;
if (g_aux_windows != NULL) {
for (guint i = 0; i < g_aux_windows->len; i++) {
window_state_t *ws = &g_array_index(g_aux_windows,
window_state_t, i);
if (ws->notebook == notebook) return ws;
}
}
return NULL;
}
/* Lazily create the sidebar webview for a window. The webview shares
* the same WebKitWebContext as the main webview so the sovereign://
* scheme works. It is packed into the sidebar container (the left pane
* of the window-level GtkPaned). Called the first time the sidebar is
* shown for a window. */
/* Close button callback for the sidebar floating X button. */
static void on_sidebar_close_clicked(GtkButton *btn, gpointer user_data) {
(void)btn;
window_state_t *ws = (window_state_t *)user_data;
if (ws == NULL) return;
if (ws->sidebar_visible) {
gtk_paned_set_position(GTK_PANED(ws->paned), 0);
gtk_widget_hide(ws->sidebar_container);
ws->sidebar_visible = FALSE;
}
}
static void sidebar_create_webview(window_state_t *ws) {
if (ws == NULL || ws->sidebar_webview != NULL) return;
if (g_ctx == NULL) return;
ws->sidebar_webview = WEBKIT_WEB_VIEW(
webkit_web_view_new_with_context(g_ctx));
/* Match the main webview's settings so JS and the sovereign://
* bridge work. */
WebKitSettings *st = webkit_web_view_get_settings(ws->sidebar_webview);
webkit_settings_set_enable_developer_extras(st, TRUE);
webkit_settings_set_enable_javascript(st, TRUE);
webkit_settings_set_allow_file_access_from_file_urls(st, TRUE);
webkit_settings_set_allow_universal_access_from_file_urls(st, TRUE);
/* Inject window.nostr so the chat page's NIP-07 shim works. */
nostr_inject_setup(ws->sidebar_webview);
/* The sidebar webview should NOT create new windows/tabs — links
* in the chat page should navigate within the sidebar, not open
* new browser tabs. We let the default navigation happen inside
* the sidebar webview. */
gtk_widget_set_vexpand(GTK_WIDGET(ws->sidebar_webview), TRUE);
gtk_widget_set_hexpand(GTK_WIDGET(ws->sidebar_webview), TRUE);
/* Use a GtkOverlay to float a close (X) button in the top-right
* corner over the webview, without a separate header bar. This
* saves vertical space — the X sits on the same row as the chat
* page's tab bar (Chat / Conversations / Skills). */
GtkWidget *overlay = gtk_overlay_new();
gtk_widget_set_vexpand(overlay, TRUE);
gtk_widget_set_hexpand(overlay, TRUE);
gtk_container_add(GTK_CONTAINER(overlay),
GTK_WIDGET(ws->sidebar_webview));
/* Floating close button — top-right corner. */
GtkWidget *close_btn = gtk_button_new();
gtk_button_set_relief(GTK_BUTTON(close_btn), GTK_RELIEF_NONE);
gtk_button_set_image(GTK_BUTTON(close_btn),
gtk_image_new_from_icon_name("window-close-symbolic",
GTK_ICON_SIZE_MENU));
gtk_widget_set_tooltip_text(close_btn, "Close sidebar");
gtk_widget_set_halign(close_btn, GTK_ALIGN_END);
gtk_widget_set_valign(close_btn, GTK_ALIGN_START);
gtk_widget_set_margin_top(close_btn, 2);
gtk_widget_set_margin_end(close_btn, 2);
g_signal_connect(close_btn, "clicked",
G_CALLBACK(on_sidebar_close_clicked), ws);
gtk_overlay_add_overlay(GTK_OVERLAY(overlay), close_btn);
/* Pack the overlay into the sidebar container. */
gtk_box_pack_start(GTK_BOX(ws->sidebar_container),
overlay, TRUE, TRUE, 0);
/* Load the chat page. */
webkit_web_view_load_uri(ws->sidebar_webview, AGENT_CHAT_URL_STR);
}
void tab_manager_toggle_sidebar(void) {
window_state_t *ws = get_active_window_state();
if (ws == NULL || ws->paned == NULL) return;
if (ws->sidebar_visible) {
/* Hide the sidebar. Set position to 0 so the sidebar gets no
* space and the divider disappears. Keep the webview alive. */
gtk_paned_set_position(GTK_PANED(ws->paned), 0);
gtk_widget_hide(ws->sidebar_container);
ws->sidebar_visible = FALSE;
} else {
/* Show the sidebar. Create the webview lazily on first show.
* Set position to 280 so the sidebar gets space and the
* divider appears (resizable). */
if (ws->sidebar_webview == NULL) {
sidebar_create_webview(ws);
}
gtk_paned_set_position(GTK_PANED(ws->paned),
SIDEBAR_DEFAULT_WIDTH);
gtk_widget_show_all(ws->sidebar_container);
ws->sidebar_visible = TRUE;
}
}
WebKitWebView *tab_manager_get_main_webview(void) {
tab_info_t *tab = tab_manager_get_active();
if (tab == NULL) return NULL;
/* Always return the main webview, never the sidebar. The sidebar
* is now at the window level (outside the notebook), so
* tab_manager_get_active() — which resolves by notebook page —
* will never return the sidebar. */
return tab->webview;
}
gboolean tab_manager_sidebar_visible(void) {
window_state_t *ws = get_active_window_state();
if (ws == NULL) return FALSE;
return ws->sidebar_visible;
}
void tab_manager_hide_sidebar_after_show_all(void) {
/* gtk_widget_show_all() on the main window recursively shows all
* children, including the sidebar container. Re-hide it so the
* sidebar stays hidden by default — it should only appear when
* the user toggles it. */
if (g_main_window.sidebar_container && !g_main_window.sidebar_visible) {
gtk_widget_hide(g_main_window.sidebar_container);
gtk_paned_set_position(GTK_PANED(g_main_window.paned), 0);
}
}