/* * 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 #include /* strcasecmp */ #include #include #include /* 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 .org and .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( "Private key blocked" "

Navigation blocked

Nostr private keys cannot be opened or navigated to.

"); 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 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 * 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: "; " 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, "Private key blocked" "

Navigation blocked

Nostr private keys cannot be opened or navigated to.

", "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( "%s\n%s", 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 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); } }