4231 lines
180 KiB
C
4231 lines
180 KiB
C
/*
|
||
* 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 "site_downloader.h"
|
||
|
||
#include <string.h>
|
||
#include <strings.h> /* strcasecmp */
|
||
#include <stdlib.h>
|
||
#include <libsoup/soup.h>
|
||
#include <gdk-pixbuf/gdk-pixbuf.h>
|
||
|
||
/* Portable case-insensitive substring search (replaces GNU strcasestr). */
|
||
static const char *ci_strstr(const char *haystack, const char *needle) {
|
||
if (haystack == NULL || needle == NULL) return NULL;
|
||
if (*needle == '\0') return haystack;
|
||
size_t nlen = strlen(needle);
|
||
for (const char *p = haystack; *p; p++) {
|
||
if (strncasecmp(p, needle, nlen) == 0) return p;
|
||
}
|
||
return NULL;
|
||
}
|
||
|
||
/* Recursively collect all bookmarks in the bookmark tree into a GPtrArray
|
||
* of `const bookmark_t *` (shallow — pointers are valid until the next
|
||
* bookmarks mutation). Used by URL completion and the hamburger menu. */
|
||
static void collect_all_bookmarks(const bookmark_node_t *node, GPtrArray *out) {
|
||
if (node == NULL) return;
|
||
for (int i = 0; i < node->bookmark_count; i++) {
|
||
g_ptr_array_add(out, &node->bookmarks[i]);
|
||
}
|
||
for (int i = 0; i < node->child_count; i++) {
|
||
collect_all_bookmarks(&node->children[i], out);
|
||
}
|
||
}
|
||
|
||
/* ── External callbacks defined in main.c ─────────────────────────── *
|
||
* These handle identity-related menu actions that need access to the
|
||
* global app_state_t (signer, pubkey, method). main.c owns that state.
|
||
*/
|
||
/* Proxy functions defined in main.c that wrap the app_state_t-aware
|
||
* callbacks so they match GTK signal handler signatures. */
|
||
extern void app_menu_switch_identity_proxy(GtkMenuItem *item, gpointer data);
|
||
extern void app_menu_lock_session_proxy(GtkMenuItem *item, gpointer data);
|
||
extern void app_menu_logout_proxy(GtkMenuItem *item, gpointer data);
|
||
extern void app_menu_security_strip_proxy(GtkCheckMenuItem *item, gpointer data);
|
||
extern void app_menu_network_service_proxy(GtkCheckMenuItem *item, gpointer data);
|
||
extern void app_menu_nostr_sign_proxy(GtkMenuItem *item, gpointer data);
|
||
extern void app_menu_about_proxy(GtkMenuItem *item, gpointer data);
|
||
extern void on_menu_settings(GtkMenuItem *item, gpointer data);
|
||
extern void on_menu_profile(GtkMenuItem *item, gpointer data);
|
||
extern void on_menu_agent(GtkMenuItem *item, gpointer data);
|
||
extern void on_menu_fips(GtkMenuItem *item, gpointer data);
|
||
extern void on_menu_processes(GtkMenuItem *item, gpointer data);
|
||
|
||
/* Key press handler defined in main.c — connected to each webview so
|
||
* keyboard shortcuts are caught before WebKit consumes the event. */
|
||
extern gboolean on_key_press(GtkWidget *widget, GdkEventKey *event,
|
||
gpointer data);
|
||
|
||
/* ── Per-window sidebar state ─────────────────────────────────────── *
|
||
* The agent chat sidebar is per-window (not per-tab). Each window has
|
||
* a window-level GtkPaned (sidebar | notebook). The sidebar webview is
|
||
* lazily created on first toggle. All windows' sidebars show the same
|
||
* global chat session (agent_chat_store's g_current_session_id).
|
||
* Visibility is independent per-window. */
|
||
|
||
typedef struct {
|
||
GtkWindow *window;
|
||
GtkWidget *notebook;
|
||
GtkWidget *paned; /* window-level: sidebar | notebook */
|
||
GtkWidget *sidebar_container; /* GtkBox for sidebar webview */
|
||
WebKitWebView *sidebar_webview; /* lazily created on first toggle */
|
||
gboolean sidebar_visible;
|
||
gboolean toolbars_visible; /* per-window menu-bar (tab strip +
|
||
* toolbars + bookmark bars) visibility */
|
||
} 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() and on_create_webview(). */
|
||
static WebKitWebView *g_target_related_view = NULL;
|
||
|
||
/* When TRUE, tab_create() skips the explicit webkit_web_view_load_uri()
|
||
* call. This is only needed in the "create" signal path (on_create_webview),
|
||
* where WebKit already has a pending NavigationAction with WindowFeatures
|
||
* that it will apply to the returned webview itself — an explicit load
|
||
* here would race with WebKit's own navigation setup and trigger the
|
||
* std::optional<WindowFeatures> assertion crash.
|
||
*
|
||
* tab_manager_new_window() does NOT set this: it creates a fresh webview
|
||
* with no pending WebKit navigation, so it MUST load the URL explicitly. */
|
||
static gboolean g_target_skip_load = FALSE;
|
||
|
||
/* 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;
|
||
|
||
/* When TRUE, closing the last tab of the main window does NOT quit the
|
||
* app. Used by identity_teardown_web_state() so it can close all tabs
|
||
* (to drop the previous user's live webviews) without exiting the
|
||
* browser during a logout / identity switch. The caller re-opens a
|
||
* fresh tab for the new user afterwards. */
|
||
static gboolean g_suppress_quit_on_last_tab = FALSE;
|
||
|
||
/* ── 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_tab_download_website(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);
|
||
static void on_notebook_switch_page(GtkNotebook *notebook,
|
||
GtkWidget *page,
|
||
guint page_num,
|
||
gpointer user_data);
|
||
/* Forward declarations for signal handlers used by tab_manager_new_window
|
||
* (which is defined before these handlers in the file). */
|
||
static void on_load_changed(WebKitWebView *webview,
|
||
WebKitLoadEvent load_event,
|
||
gpointer user_data);
|
||
static gboolean on_load_failed(WebKitWebView *webview,
|
||
WebKitLoadEvent load_event,
|
||
gchar *failing_uri,
|
||
GError *error,
|
||
gpointer data);
|
||
static gboolean on_webview_context_menu(WebKitWebView *webview,
|
||
WebKitContextMenu *context_menu,
|
||
GdkEvent *event,
|
||
WebKitHitTestResult *hit_test,
|
||
gpointer user_data);
|
||
|
||
/* ── URL bar completion (search dropdown) ─────────────────────────── *
|
||
* Each tab's URL entry has a GtkEntryCompletion backed by a GtkListStore.
|
||
* The list store has these columns:
|
||
*/
|
||
enum {
|
||
COMPLETION_COL_DISPLAY = 0, /* visible text in the dropdown */
|
||
COMPLETION_COL_URL, /* the URL to navigate to (or NULL for
|
||
search suggestions — in that case
|
||
the display text is the search query) */
|
||
COMPLETION_COL_IS_DIRECT, /* TRUE = direct link, FALSE = search
|
||
suggestion */
|
||
COMPLETION_COL_COUNT
|
||
};
|
||
|
||
/* Per-tab completion state, attached to the GtkEntry via g_object_set_data. */
|
||
typedef struct {
|
||
GtkListStore *store;
|
||
guint suggest_req_id; /* active async suggestion request, 0=none */
|
||
char *last_query; /* last query we fetched suggestions for */
|
||
} completion_state_t;
|
||
|
||
static void on_url_changed(GtkEditable *editable, gpointer user_data);
|
||
static gboolean on_completion_match_selected(GtkEntryCompletion *completion,
|
||
GtkTreeModel *model,
|
||
GtkTreeIter *iter,
|
||
gpointer user_data);
|
||
static void on_suggestions_received(char **suggestions, gpointer user_data);
|
||
static void completion_state_free(completion_state_t *cs);
|
||
static void rebuild_completion(const char *query, completion_state_t *cs);
|
||
static gboolean on_url_key_press(GtkWidget *widget, GdkEventKey *event,
|
||
gpointer user_data);
|
||
|
||
/* ── URL helper (same logic as main.c's normalize_url) ────────────── *
|
||
* If the input looks like a URL (has a scheme, a dot with no spaces,
|
||
* is localhost, or is an about: page), it's normalized to a full URL.
|
||
* Otherwise, it's treated as a search query and sent to the active
|
||
* search engine.
|
||
*/
|
||
|
||
/* Convert fips://host[:port]/path?query#fragment into normal HTTP while
|
||
* inserting the mesh DNS suffix after the authority's host component. */
|
||
static char *normalize_fips_url(const char *input) {
|
||
if (input == NULL || strncmp(input, "fips://", 7) != 0) return NULL;
|
||
const char *rest = input + 7;
|
||
const char *suffix = strpbrk(rest, "/?#");
|
||
size_t authority_len = suffix ? (size_t)(suffix - rest) : strlen(rest);
|
||
if (authority_len == 0) return NULL;
|
||
char *authority = g_strndup(rest, authority_len);
|
||
char *result = NULL;
|
||
if (g_str_has_suffix(authority, ".fips"))
|
||
result = g_strdup_printf("http://%s%s", authority, suffix ? suffix : "");
|
||
else {
|
||
char *colon = strrchr(authority, ':');
|
||
if (colon && strchr(authority, ':') == colon) {
|
||
*colon = '\0';
|
||
result = g_strdup_printf("http://%s.fips:%s%s", authority,
|
||
colon + 1, suffix ? suffix : "");
|
||
} else {
|
||
result = g_strdup_printf("http://%s.fips%s", authority,
|
||
suffix ? suffix : "");
|
||
}
|
||
}
|
||
g_free(authority);
|
||
return result;
|
||
}
|
||
|
||
static gboolean authority_is_onion_host(const char *authority) {
|
||
if (authority == NULL || authority[0] == '\0') return FALSE;
|
||
|
||
char *hostport = g_strdup(authority);
|
||
char *host = hostport;
|
||
|
||
char *at = strrchr(host, '@');
|
||
if (at) host = at + 1;
|
||
|
||
if (host[0] == '[') {
|
||
g_free(hostport);
|
||
return FALSE;
|
||
}
|
||
|
||
char *colon = strrchr(host, ':');
|
||
if (colon && strchr(host, ':') == colon) *colon = '\0';
|
||
|
||
gboolean is_onion = g_str_has_suffix(host, ".onion");
|
||
g_free(hostport);
|
||
return is_onion;
|
||
}
|
||
|
||
static gboolean input_is_onion_without_scheme(const char *input) {
|
||
if (!input || strstr(input, "://") != NULL || strchr(input, ' ') != NULL)
|
||
return FALSE;
|
||
|
||
const char *end = strpbrk(input, "/?#");
|
||
size_t authority_len = end ? (size_t)(end - input) : strlen(input);
|
||
if (authority_len == 0) return FALSE;
|
||
|
||
char *authority = g_strndup(input, authority_len);
|
||
gboolean is_onion = authority_is_onion_host(authority);
|
||
g_free(authority);
|
||
return is_onion;
|
||
}
|
||
|
||
static gboolean uri_is_http_onion(const char *uri, const char **rest_out) {
|
||
if (uri == NULL) return FALSE;
|
||
|
||
const char *rest = NULL;
|
||
if (g_str_has_prefix(uri, "http://")) rest = uri + 7;
|
||
else if (g_str_has_prefix(uri, "https://")) rest = uri + 8;
|
||
else return FALSE;
|
||
|
||
const char *end = strpbrk(rest, "/?#");
|
||
size_t authority_len = end ? (size_t)(end - rest) : strlen(rest);
|
||
if (authority_len == 0) return FALSE;
|
||
|
||
char *authority = g_strndup(rest, authority_len);
|
||
gboolean is_onion = authority_is_onion_host(authority);
|
||
g_free(authority);
|
||
|
||
if (is_onion && rest_out) *rest_out = rest;
|
||
return is_onion;
|
||
}
|
||
|
||
static char *normalize_url(const char *input) {
|
||
if (input == NULL || input[0] == '\0') {
|
||
return NULL;
|
||
}
|
||
|
||
if (strncmp(input, "fips://", 7) == 0)
|
||
return normalize_fips_url(input);
|
||
|
||
/* Route bare NIP-19 entities and NIP-21 links through nostr://. Private
|
||
* keys are deliberately not normalized into navigable URLs. */
|
||
nostr_entity_type_t entity_type = nostr_url_detect(input);
|
||
if (entity_type != NOSTR_ENTITY_NONE) {
|
||
return nostr_url_normalize(input);
|
||
}
|
||
|
||
/* If it looks like a URL, normalize it. */
|
||
if (search_is_url(input)) {
|
||
if (strstr(input, "://") != NULL) {
|
||
return g_strdup(input);
|
||
}
|
||
/* Allow about: URLs (e.g. about:blank) without a scheme prefix. */
|
||
if (strncmp(input, "about:", 6) == 0) {
|
||
return g_strdup(input);
|
||
}
|
||
/* sovereign:// internal pages. */
|
||
if (strncmp(input, "sovereign://", 12) == 0) {
|
||
return g_strdup(input);
|
||
}
|
||
if (input_is_onion_without_scheme(input)) {
|
||
return g_strdup_printf("http://%s", input);
|
||
}
|
||
return g_strdup_printf("https://%s", input);
|
||
}
|
||
/* Not a URL — treat as a search query. */
|
||
return search_build_search_url(input);
|
||
}
|
||
|
||
/* ── URL bar completion (search dropdown) ─────────────────────────── */
|
||
|
||
#define COMPLETION_MAX_DIRECT 8 /* max history/bookmark results */
|
||
#define COMPLETION_MAX_SUGGEST 8 /* max search engine suggestions */
|
||
#define COMPLETION_MIN_KEY_LEN 1 /* min chars before dropdown appears */
|
||
|
||
static void completion_state_free(completion_state_t *cs) {
|
||
if (cs == NULL) return;
|
||
if (cs->store) g_object_unref(cs->store);
|
||
if (cs->suggest_req_id) search_suggest_cancel(cs->suggest_req_id);
|
||
g_free(cs->last_query);
|
||
g_free(cs);
|
||
}
|
||
|
||
/*
|
||
* Extract a short display label from a URL for the dropdown.
|
||
* Strips the scheme and leading "www." for readability.
|
||
* Returns a newly allocated string.
|
||
*/
|
||
static char *url_display_label(const char *url, const char *title) {
|
||
if (title && title[0] != '\0') {
|
||
/* If we have a title, show "title — domain". */
|
||
const char *domain = url;
|
||
if (strncmp(domain, "https://", 8) == 0) domain += 8;
|
||
else if (strncmp(domain, "http://", 7) == 0) domain += 7;
|
||
if (strncmp(domain, "www.", 4) == 0) domain += 4;
|
||
/* Truncate domain at first / */
|
||
const char *slash = strchr(domain, '/');
|
||
int dlen = slash ? (int)(slash - domain) : (int)strlen(domain);
|
||
return g_strdup_printf("%s — %.*s", title, dlen, domain);
|
||
}
|
||
/* No title — just show the URL with scheme stripped. */
|
||
const char *label = url;
|
||
if (strncmp(label, "https://", 8) == 0) label += 8;
|
||
else if (strncmp(label, "http://", 7) == 0) label += 7;
|
||
return g_strdup(label);
|
||
}
|
||
|
||
/*
|
||
* Rebuild the completion list store for the given query.
|
||
* Called on every keystroke. Populates direct links (history + bookmarks)
|
||
* synchronously, then fires an async request for search engine suggestions.
|
||
*/
|
||
static void rebuild_completion(const char *query, completion_state_t *cs) {
|
||
if (cs == NULL || cs->store == NULL) return;
|
||
|
||
gtk_list_store_clear(cs->store);
|
||
|
||
if (query == NULL || query[0] == '\0') return;
|
||
|
||
/* Cancel any pending suggestion request. */
|
||
if (cs->suggest_req_id) {
|
||
search_suggest_cancel(cs->suggest_req_id);
|
||
cs->suggest_req_id = 0;
|
||
}
|
||
g_free(cs->last_query);
|
||
cs->last_query = g_strdup(query);
|
||
|
||
/* ── Direct links from history ────────────────────────────────── */
|
||
char **urls = NULL;
|
||
char **titles = NULL;
|
||
int count = 0;
|
||
db_history_search(query, &urls, &titles, &count, COMPLETION_MAX_DIRECT);
|
||
|
||
for (int i = 0; i < count; i++) {
|
||
char *label = url_display_label(urls[i], titles[i]);
|
||
GtkTreeIter iter;
|
||
gtk_list_store_append(cs->store, &iter);
|
||
gtk_list_store_set(cs->store, &iter,
|
||
COMPLETION_COL_DISPLAY, label,
|
||
COMPLETION_COL_URL, urls[i],
|
||
COMPLETION_COL_IS_DIRECT, TRUE,
|
||
-1);
|
||
g_free(label);
|
||
g_free(urls[i]);
|
||
g_free(titles[i]);
|
||
}
|
||
g_free(urls);
|
||
g_free(titles);
|
||
|
||
/* ── Direct links from bookmarks ──────────────────────────────── */
|
||
GPtrArray *bms = g_ptr_array_new();
|
||
collect_all_bookmarks(bookmarks_get_root(), bms);
|
||
int bookmark_added = 0;
|
||
for (guint i = 0; i < bms->len && bookmark_added < COMPLETION_MAX_DIRECT; i++) {
|
||
const bookmark_t *bm = (const bookmark_t *)g_ptr_array_index(bms, i);
|
||
/* Case-insensitive substring search. */
|
||
if ((bm->url && ci_strstr(bm->url, query)) ||
|
||
(bm->title && ci_strstr(bm->title, query))) {
|
||
char *label = url_display_label(bm->url, bm->title);
|
||
GtkTreeIter iter;
|
||
gtk_list_store_append(cs->store, &iter);
|
||
gtk_list_store_set(cs->store, &iter,
|
||
COMPLETION_COL_DISPLAY, label,
|
||
COMPLETION_COL_URL, bm->url,
|
||
COMPLETION_COL_IS_DIRECT, TRUE,
|
||
-1);
|
||
g_free(label);
|
||
bookmark_added++;
|
||
}
|
||
}
|
||
g_ptr_array_free(bms, TRUE);
|
||
|
||
/* ── Domain heuristic ─────────────────────────────────────────── *
|
||
* If the query has no spaces and no dots, offer to navigate directly
|
||
* to <query>.org and <query>.com. This helps first-time visits to
|
||
* known domains without going through the search engine. */
|
||
if (strchr(query, ' ') == NULL && strchr(query, '.') == NULL &&
|
||
strlen(query) > 0 && strlen(query) < 100) {
|
||
/* Only add if we don't already have a direct link matching. */
|
||
gboolean have_match = FALSE;
|
||
GtkTreeIter iter;
|
||
if (gtk_tree_model_get_iter_first(GTK_TREE_MODEL(cs->store), &iter)) {
|
||
do {
|
||
gchar *row_url = NULL;
|
||
gtk_tree_model_get(GTK_TREE_MODEL(cs->store), &iter,
|
||
COMPLETION_COL_URL, &row_url, -1);
|
||
if (row_url) {
|
||
/* Check if the domain part matches the query. */
|
||
const char *domain = row_url;
|
||
if (strncmp(domain, "https://", 8) == 0) domain += 8;
|
||
else if (strncmp(domain, "http://", 7) == 0) domain += 7;
|
||
if (strncmp(domain, "www.", 4) == 0) domain += 4;
|
||
if (strncasecmp(domain, query, strlen(query)) == 0) {
|
||
have_match = TRUE;
|
||
}
|
||
g_free(row_url);
|
||
}
|
||
} while (!have_match &&
|
||
gtk_tree_model_iter_next(GTK_TREE_MODEL(cs->store), &iter));
|
||
}
|
||
|
||
if (!have_match) {
|
||
char *org_url = g_strdup_printf("https://%s.org", query);
|
||
char *org_label = g_strdup_printf("🌐 Go to %s.org", query);
|
||
gtk_list_store_append(cs->store, &iter);
|
||
gtk_list_store_set(cs->store, &iter,
|
||
COMPLETION_COL_DISPLAY, org_label,
|
||
COMPLETION_COL_URL, org_url,
|
||
COMPLETION_COL_IS_DIRECT, TRUE,
|
||
-1);
|
||
g_free(org_url);
|
||
g_free(org_label);
|
||
|
||
char *com_url = g_strdup_printf("https://%s.com", query);
|
||
char *com_label = g_strdup_printf("🌐 Go to %s.com", query);
|
||
gtk_list_store_append(cs->store, &iter);
|
||
gtk_list_store_set(cs->store, &iter,
|
||
COMPLETION_COL_DISPLAY, com_label,
|
||
COMPLETION_COL_URL, com_url,
|
||
COMPLETION_COL_IS_DIRECT, TRUE,
|
||
-1);
|
||
g_free(com_url);
|
||
g_free(com_label);
|
||
}
|
||
}
|
||
|
||
/* ── Fire async search engine suggestions ─────────────────────── *
|
||
* The suggestions are appended to the store when the async callback
|
||
* fires (on_suggestions_received). We only fetch if the query doesn't
|
||
* look like a URL (no point suggesting searches for "wikipedia.org"). */
|
||
if (!search_is_url(query)) {
|
||
cs->suggest_req_id = search_suggest_fetch_async(
|
||
query, on_suggestions_received, cs);
|
||
}
|
||
}
|
||
|
||
/*
|
||
* Callback for async search engine suggestions.
|
||
* Appends the suggestions to the completion store. The user_data is
|
||
* the completion_state_t. We check that the query hasn't changed since
|
||
* we sent the request (stale results are discarded).
|
||
*/
|
||
static void on_suggestions_received(char **suggestions, gpointer user_data) {
|
||
completion_state_t *cs = (completion_state_t *)user_data;
|
||
if (cs == NULL || cs->store == NULL) return;
|
||
|
||
/* Mark the request as completed. */
|
||
cs->suggest_req_id = 0;
|
||
|
||
if (suggestions == NULL) return;
|
||
|
||
/* Append search suggestions after the direct links.
|
||
* For search suggestions, the URL column stores the search query
|
||
* itself (not a URL). The is_direct flag is FALSE, so
|
||
* on_completion_match_selected knows to build a search URL from it. */
|
||
for (int i = 0; suggestions[i] != NULL && i < COMPLETION_MAX_SUGGEST; i++) {
|
||
char *label = g_strdup_printf("🔍 %s", suggestions[i]);
|
||
GtkTreeIter iter;
|
||
gtk_list_store_append(cs->store, &iter);
|
||
gtk_list_store_set(cs->store, &iter,
|
||
COMPLETION_COL_DISPLAY, label,
|
||
COMPLETION_COL_URL, suggestions[i],
|
||
COMPLETION_COL_IS_DIRECT, FALSE,
|
||
-1);
|
||
g_free(label);
|
||
}
|
||
}
|
||
|
||
/*
|
||
* Called when the user types in the URL entry.
|
||
* Rebuilds the completion dropdown.
|
||
*/
|
||
static void on_url_changed(GtkEditable *editable, gpointer user_data) {
|
||
(void)user_data;
|
||
/* Only rebuild the completion dropdown when the user is actively
|
||
* typing. If the entry doesn't have focus, the text was set
|
||
* programmatically (e.g. by a navigation event updating the URL
|
||
* bar) and we shouldn't show the dropdown. */
|
||
if (!gtk_widget_has_focus(GTK_WIDGET(editable))) return;
|
||
|
||
const char *text = gtk_entry_get_text(GTK_ENTRY(editable));
|
||
completion_state_t *cs = (completion_state_t *)
|
||
g_object_get_data(G_OBJECT(editable), "completion-state");
|
||
if (cs == NULL) return;
|
||
rebuild_completion(text, cs);
|
||
}
|
||
|
||
/*
|
||
* Key press handler for the URL entry.
|
||
*
|
||
* When the completion dropdown is visible, Tab navigates down and
|
||
* Shift+Tab navigates up through the suggestions (like most browsers).
|
||
* Without this, Tab would move focus away from the URL bar.
|
||
*
|
||
* When the dropdown is not visible, Tab behaves normally (moves focus).
|
||
*/
|
||
static gboolean on_url_key_press(GtkWidget *widget, GdkEventKey *event,
|
||
gpointer user_data) {
|
||
(void)user_data;
|
||
|
||
/* Only intercept Tab when the completion popup is visible. */
|
||
if (event->keyval != GDK_KEY_Tab && event->keyval != GDK_KEY_ISO_Left_Tab)
|
||
return FALSE;
|
||
|
||
GtkEntry *entry = GTK_ENTRY(widget);
|
||
GtkEntryCompletion *completion = gtk_entry_get_completion(entry);
|
||
if (completion == NULL) return FALSE;
|
||
|
||
/* Check if the completion popup is visible. GtkEntryCompletion
|
||
* doesn't have a direct "is popup visible" API, but we can check
|
||
* if there's a completion selection by looking at the tree model.
|
||
* If the store has items, we intercept Tab to navigate. */
|
||
completion_state_t *cs = (completion_state_t *)
|
||
g_object_get_data(G_OBJECT(entry), "completion-state");
|
||
if (cs == NULL || cs->store == NULL) return FALSE;
|
||
|
||
GtkTreeIter iter;
|
||
if (!gtk_tree_model_get_iter_first(GTK_TREE_MODEL(cs->store), &iter))
|
||
return FALSE; /* No items in the store — let Tab do its normal thing. */
|
||
|
||
/* Synthesize a Down or Up arrow key event and forward it to the
|
||
* entry. GtkEntryCompletion intercepts arrow keys to navigate
|
||
* the dropdown selection. */
|
||
GdkEventKey *synth = (GdkEventKey *)gdk_event_new(GDK_KEY_PRESS);
|
||
synth->window = g_object_ref(event->window);
|
||
synth->send_event = TRUE;
|
||
synth->time = event->time;
|
||
synth->state = 0; /* No modifiers for the arrow key. */
|
||
synth->keyval = (event->state & GDK_SHIFT_MASK)
|
||
? GDK_KEY_Up
|
||
: GDK_KEY_Down;
|
||
synth->length = 0;
|
||
synth->string = NULL;
|
||
synth->hardware_keycode = 0;
|
||
synth->group = 0;
|
||
|
||
/* Forward the synthetic event to the entry widget. */
|
||
gtk_main_do_event((GdkEvent *)synth);
|
||
gdk_event_free((GdkEvent *)synth);
|
||
|
||
return TRUE; /* We handled the Tab — don't move focus. */
|
||
}
|
||
|
||
/*
|
||
* Called when the user selects an item from the completion dropdown.
|
||
* Navigates to the selected URL (direct link) or builds a search URL
|
||
* (for search suggestions).
|
||
*/
|
||
static gboolean on_completion_match_selected(GtkEntryCompletion *completion,
|
||
GtkTreeModel *model,
|
||
GtkTreeIter *iter,
|
||
gpointer user_data) {
|
||
(void)completion;
|
||
tab_info_t *tab = (tab_info_t *)user_data;
|
||
|
||
gchar *display = NULL;
|
||
gchar *url = NULL;
|
||
gboolean is_direct = FALSE;
|
||
gtk_tree_model_get(model, iter,
|
||
COMPLETION_COL_DISPLAY, &display,
|
||
COMPLETION_COL_URL, &url,
|
||
COMPLETION_COL_IS_DIRECT, &is_direct,
|
||
-1);
|
||
|
||
char *navigate_url = NULL;
|
||
if (is_direct && url != NULL) {
|
||
/* Direct link — navigate to the URL. */
|
||
navigate_url = g_strdup(url);
|
||
} else if (url != NULL) {
|
||
/* Search suggestion — the URL column stores the search query.
|
||
* Build a search URL from it. */
|
||
navigate_url = search_build_search_url(url);
|
||
} else {
|
||
/* Fallback: no URL stored. Use the display text as the query. */
|
||
navigate_url = search_build_search_url(display);
|
||
}
|
||
|
||
if (navigate_url) {
|
||
webkit_web_view_load_uri(tab->webview, navigate_url);
|
||
gtk_entry_set_text(GTK_ENTRY(tab->url_entry), navigate_url);
|
||
g_free(navigate_url);
|
||
}
|
||
|
||
g_free(display);
|
||
g_free(url);
|
||
return TRUE; /* We handled it — don't let GTK do default behavior. */
|
||
}
|
||
|
||
/* ── Tab label widget ─────────────────────────────────────────────── */
|
||
|
||
static void on_tab_close_clicked(GtkButton *btn, gpointer user_data) {
|
||
tab_info_t *tab = (tab_info_t *)user_data;
|
||
(void)btn;
|
||
/* Look up the tab's index in the global g_tabs array by pointer.
|
||
* This works for tabs in any window's notebook (the index returned
|
||
* by gtk_notebook_page_num only matches g_tabs for the main window). */
|
||
int index = tab_array_find(tab);
|
||
if (index >= 0) {
|
||
tab_manager_close_tab(index);
|
||
}
|
||
}
|
||
|
||
/* Notebook-level button-press handler. Catches right-clicks on tab
|
||
* labels that GtkNotebook's internal handling would otherwise consume
|
||
* before the tab label's own button-press-event handler fires. We find
|
||
* which tab was clicked by checking the tab label's allocation against
|
||
* the click coordinates, then show the same context menu as
|
||
* on_tab_label_button_press. */
|
||
static gboolean on_notebook_button_press(GtkWidget *widget,
|
||
GdkEventButton *event,
|
||
gpointer user_data) {
|
||
(void)user_data;
|
||
if (widget == NULL) return FALSE;
|
||
GtkNotebook *nb = GTK_NOTEBOOK(widget);
|
||
if (event->button != 3 && event->button != 2) return FALSE;
|
||
|
||
gint n_pages = gtk_notebook_get_n_pages(nb);
|
||
for (gint i = 0; i < n_pages; i++) {
|
||
GtkWidget *page = gtk_notebook_get_nth_page(nb, i);
|
||
if (page == NULL) continue;
|
||
GtkWidget *label = gtk_notebook_get_tab_label(nb, page);
|
||
if (label == NULL) continue;
|
||
|
||
GtkAllocation alloc;
|
||
gtk_widget_get_allocation(label, &alloc);
|
||
|
||
/* Convert click coords to the label's coordinate space. The
|
||
* event coords are relative to the notebook's bin window. */
|
||
gint x = (gint)event->x;
|
||
gint y = (gint)event->y;
|
||
|
||
/* Get the label's allocation position relative to the notebook. */
|
||
gint lx = alloc.x;
|
||
gint ly = alloc.y;
|
||
|
||
if (x >= lx && x < lx + alloc.width &&
|
||
y >= ly && y < ly + alloc.height) {
|
||
/* Found the clicked tab. Look up the tab_info_t. */
|
||
if (i < g_tab_count && g_tabs[i] != NULL) {
|
||
/* Reuse the existing handler by synthesizing a call. */
|
||
tab_info_t *tab = g_tabs[i];
|
||
int index = tab_array_find(tab);
|
||
if (index < 0) return FALSE;
|
||
|
||
if (event->button == 2) {
|
||
const browser_settings_t *s = settings_get();
|
||
if (s->middle_click_close) {
|
||
tab_manager_close_tab(index);
|
||
return TRUE;
|
||
}
|
||
return FALSE;
|
||
}
|
||
|
||
/* Right-click: show context menu (same as
|
||
* on_tab_label_button_press). */
|
||
GtkWidget *menu = gtk_menu_new();
|
||
|
||
GtkWidget *item_new = gtk_menu_item_new_with_label("New Tab");
|
||
g_signal_connect(item_new, "activate",
|
||
G_CALLBACK(on_tab_close_clicked_proxy_new), NULL);
|
||
gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_new);
|
||
|
||
GtkWidget *item_close = gtk_menu_item_new_with_label("Close Tab");
|
||
g_signal_connect_swapped(item_close, "activate",
|
||
G_CALLBACK(tab_manager_close_tab),
|
||
GINT_TO_POINTER(index));
|
||
gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_close);
|
||
|
||
GtkWidget *item_close_others =
|
||
gtk_menu_item_new_with_label("Close Other Tabs");
|
||
g_signal_connect_swapped(item_close_others, "activate",
|
||
G_CALLBACK(tab_manager_close_others),
|
||
GINT_TO_POINTER(index));
|
||
gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_close_others);
|
||
|
||
GtkWidget *item_close_right =
|
||
gtk_menu_item_new_with_label("Close Tabs to the Right");
|
||
g_signal_connect_swapped(item_close_right, "activate",
|
||
G_CALLBACK(tab_manager_close_to_right),
|
||
GINT_TO_POINTER(index));
|
||
gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_close_right);
|
||
|
||
gtk_menu_shell_append(GTK_MENU_SHELL(menu),
|
||
gtk_separator_menu_item_new());
|
||
|
||
GtkWidget *item_new_win =
|
||
gtk_menu_item_new_with_label("Open in New Window");
|
||
g_signal_connect_swapped(item_new_win, "activate",
|
||
G_CALLBACK(tab_manager_open_in_new_window),
|
||
GINT_TO_POINTER(index));
|
||
gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_new_win);
|
||
|
||
GtkWidget *item_dup = gtk_menu_item_new_with_label("Duplicate Tab");
|
||
g_signal_connect_swapped(item_dup, "activate",
|
||
G_CALLBACK(tab_manager_duplicate),
|
||
GINT_TO_POINTER(index));
|
||
gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_dup);
|
||
|
||
GtkWidget *item_reload = gtk_menu_item_new_with_label("Reload Tab");
|
||
g_signal_connect_swapped(item_reload, "activate",
|
||
G_CALLBACK(tab_manager_reload),
|
||
GINT_TO_POINTER(index));
|
||
gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_reload);
|
||
|
||
gtk_menu_shell_append(GTK_MENU_SHELL(menu),
|
||
gtk_separator_menu_item_new());
|
||
|
||
GtkWidget *item_dl =
|
||
gtk_menu_item_new_with_label("Download Website");
|
||
g_signal_connect(item_dl, "activate",
|
||
G_CALLBACK(on_tab_download_website), tab);
|
||
gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_dl);
|
||
|
||
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_menu_shell_append(GTK_MENU_SHELL(menu),
|
||
gtk_separator_menu_item_new());
|
||
|
||
GtkWidget *item_dl =
|
||
gtk_menu_item_new_with_label("Download Website");
|
||
g_signal_connect(item_dl, "activate",
|
||
G_CALLBACK(on_tab_download_website), tab);
|
||
gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_dl);
|
||
|
||
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);
|
||
}
|
||
|
||
/* "Download Website" — crawl the tab's current URL for offline viewing.
|
||
* The tab_info_t is passed as data; we read its current_url and hand off
|
||
* to site_downloader_start, which shows a progress dialog and opens the
|
||
* offline copy in a new tab when done. */
|
||
static void on_tab_download_website(GtkMenuItem *item, gpointer data) {
|
||
(void)item;
|
||
tab_info_t *tab = (tab_info_t *)data;
|
||
if (tab == NULL) return;
|
||
if (tab->current_url[0] == '\0') return;
|
||
GtkWindow *parent = g_active_window ? g_active_window : g_window;
|
||
site_downloader_start(tab->current_url, parent);
|
||
}
|
||
|
||
/* ── Favicon handling ─────────────────────────────────────────────── *
|
||
* WebKitGTK has a built-in favicon database (WebKitFaviconDatabase) that
|
||
* automatically fetches and caches favicons as pages load. It must be
|
||
* enabled once on the WebKitWebContext via
|
||
* webkit_web_context_set_favicon_database_directory() (done in main.c).
|
||
* Once enabled, the webview emits "notify::favicon" when the favicon is
|
||
* ready, and webkit_web_view_get_favicon() returns a cairo_surface_t.
|
||
*/
|
||
|
||
static void on_favicon_changed(WebKitWebView *webview, GParamSpec *pspec,
|
||
gpointer user_data) {
|
||
tab_info_t *tab = (tab_info_t *)user_data;
|
||
(void)pspec;
|
||
|
||
cairo_surface_t *favicon = webkit_web_view_get_favicon(webview);
|
||
if (favicon == NULL) return;
|
||
|
||
int fav_w = cairo_image_surface_get_width(favicon);
|
||
int fav_h = cairo_image_surface_get_height(favicon);
|
||
if (fav_w <= 0 || fav_h <= 0) return;
|
||
|
||
GdkPixbuf *raw = gdk_pixbuf_get_from_surface(favicon, 0, 0, fav_w, fav_h);
|
||
if (raw == NULL) return;
|
||
|
||
GdkPixbuf *pixbuf;
|
||
if (fav_w > 16 || fav_h > 16) {
|
||
pixbuf = gdk_pixbuf_scale_simple(raw, 16, 16, GDK_INTERP_BILINEAR);
|
||
g_object_unref(raw);
|
||
} else {
|
||
pixbuf = raw;
|
||
}
|
||
|
||
gtk_image_set_from_pixbuf(GTK_IMAGE(tab->favicon), pixbuf);
|
||
g_object_unref(pixbuf);
|
||
g_print("[favicon] Set favicon (%dx%d) for %s\n",
|
||
fav_w, fav_h, tab->current_url);
|
||
}
|
||
|
||
/* ── Navigation policy decision ────────────────────────────────────── *
|
||
* Handles target="_blank" links and middle-click links by opening them
|
||
* in a new tab instead of ignoring them. WebKit fires the "decide-policy"
|
||
* signal with a WebKitNavigationPolicyDecision when a link is clicked.
|
||
* If the navigation action is a link click with a target that would open
|
||
* a new view (WEBKIT_NAVIGATION_TYPE_LINK_CLICKED with a non-current
|
||
* browser action), we open a new tab and ignore the default decision.
|
||
*
|
||
* URL rewriting (fips://, nostr:, .onion → tor://) is deferred to an idle
|
||
* callback. Calling webkit_web_view_load_uri() synchronously from inside
|
||
* the decide-policy handler starts a new navigation while WebKit is still
|
||
* processing the current policy decision — this re-entrancy crashes
|
||
* WebKitGTK with a segfault, most reliably when the webview is freshly
|
||
* created (e.g. a new tab opened for an onion link). By ignoring the
|
||
* current decision first and scheduling the redirect for the next idle
|
||
* tick, the policy decision completes cleanly before the new navigation
|
||
* begins.
|
||
*/
|
||
|
||
typedef struct {
|
||
WebKitWebView *webview;
|
||
char *uri; /* URL to load via webkit_web_view_load_uri */
|
||
char *html; /* HTML to load via webkit_web_view_load_html (mutually exclusive with uri) */
|
||
char *base_uri; /* base URI for load_html */
|
||
} deferred_nav_t;
|
||
|
||
static gboolean deferred_nav_idle(gpointer user_data) {
|
||
deferred_nav_t *nav = (deferred_nav_t *)user_data;
|
||
if (nav == NULL) return G_SOURCE_REMOVE;
|
||
|
||
/* The webview may have been destroyed between scheduling the idle and
|
||
* running it (e.g. the user closed the tab). Guard with WEBKIT_IS_WEB_VIEW. */
|
||
if (nav->webview && WEBKIT_IS_WEB_VIEW(nav->webview)) {
|
||
if (nav->html != NULL) {
|
||
webkit_web_view_load_html(nav->webview, nav->html,
|
||
nav->base_uri ? nav->base_uri : "about:blank");
|
||
} else if (nav->uri != NULL) {
|
||
webkit_web_view_load_uri(nav->webview, nav->uri);
|
||
}
|
||
}
|
||
|
||
g_clear_object(&nav->webview);
|
||
g_free(nav->uri);
|
||
g_free(nav->html);
|
||
g_free(nav->base_uri);
|
||
g_free(nav);
|
||
return G_SOURCE_REMOVE;
|
||
}
|
||
|
||
/* Schedule a load_uri on the next idle tick. Takes ownership of `uri`. */
|
||
static void defer_load_uri(WebKitWebView *webview, char *uri) {
|
||
deferred_nav_t *nav = g_new0(deferred_nav_t, 1);
|
||
nav->webview = g_object_ref(webview);
|
||
nav->uri = uri; /* takes ownership */
|
||
g_idle_add(deferred_nav_idle, nav);
|
||
}
|
||
|
||
/* Schedule a load_html on the next idle tick. Takes ownership of `html`
|
||
* and `base_uri`. */
|
||
static void defer_load_html(WebKitWebView *webview, char *html, char *base_uri) {
|
||
deferred_nav_t *nav = g_new0(deferred_nav_t, 1);
|
||
nav->webview = g_object_ref(webview);
|
||
nav->html = html; /* takes ownership */
|
||
nav->base_uri = base_uri; /* takes ownership */
|
||
g_idle_add(deferred_nav_idle, nav);
|
||
}
|
||
|
||
static gboolean on_decide_policy(WebKitWebView *webview,
|
||
WebKitPolicyDecision *decision,
|
||
WebKitPolicyDecisionType type,
|
||
gpointer user_data) {
|
||
(void)user_data;
|
||
|
||
if (type != WEBKIT_POLICY_DECISION_TYPE_NAVIGATION_ACTION)
|
||
return FALSE;
|
||
|
||
WebKitNavigationPolicyDecision *nav_decision =
|
||
WEBKIT_NAVIGATION_POLICY_DECISION(decision);
|
||
WebKitNavigationAction *action =
|
||
webkit_navigation_policy_decision_get_navigation_action(nav_decision);
|
||
if (action == NULL)
|
||
return FALSE;
|
||
|
||
WebKitURIRequest *request = webkit_navigation_action_get_request(action);
|
||
const char *uri = request ? webkit_uri_request_get_uri(request) : NULL;
|
||
|
||
/* fips:// is a shorthand rather than a custom WebKit URI scheme. This
|
||
* catches page links in addition to URL-bar normalization. */
|
||
if (uri && strncmp(uri, "fips://", 7) == 0) {
|
||
char *normalized = normalize_fips_url(uri);
|
||
if (normalized) {
|
||
/* Ignore the current decision first, then defer the redirect
|
||
* to the next idle tick to avoid re-entrant navigation inside
|
||
* the decide-policy handler (which segfaults WebKitGTK). */
|
||
webkit_policy_decision_ignore(decision);
|
||
defer_load_uri(webview, normalized); /* takes ownership */
|
||
return TRUE;
|
||
}
|
||
}
|
||
|
||
/* NIP-21 links use nostr:entity rather than nostr://entity. Normalize
|
||
* either form so WebKit consistently invokes our registered handler. */
|
||
if (uri && strncmp(uri, "nostr:", 6) == 0 &&
|
||
strncmp(uri, "nostr://", 8) != 0) {
|
||
nostr_entity_type_t entity_type = nostr_url_detect(uri);
|
||
if (entity_type == NOSTR_ENTITY_NSEC) {
|
||
char *html = g_strdup(
|
||
"<!doctype html><meta charset=\"utf-8\"><title>Private key blocked</title>"
|
||
"<h1>Navigation blocked</h1><p>Nostr private keys cannot be opened or navigated to.</p>");
|
||
char *base_uri = g_strdup("nostr://blocked-private-key");
|
||
webkit_policy_decision_ignore(decision);
|
||
defer_load_html(webview, html, base_uri); /* takes ownership */
|
||
return TRUE;
|
||
}
|
||
|
||
char *normalized = nostr_url_normalize(uri);
|
||
if (normalized) {
|
||
webkit_policy_decision_ignore(decision);
|
||
defer_load_uri(webview, normalized); /* takes ownership */
|
||
return TRUE;
|
||
}
|
||
}
|
||
|
||
/* Route .onion HTTP(S) traffic through tor:// so only onion addresses
|
||
* use Tor routing. Phase 1 intentionally maps both input schemes to
|
||
* tor:// and the handler currently fetches using http:// over Tor. */
|
||
const char *tor_rest = NULL;
|
||
if (uri && uri_is_http_onion(uri, &tor_rest)) {
|
||
char *tor_url = g_strdup_printf("tor://%s", tor_rest);
|
||
webkit_policy_decision_ignore(decision);
|
||
defer_load_uri(webview, tor_url); /* takes ownership */
|
||
return TRUE;
|
||
}
|
||
|
||
/* Only intercept link clicks, not form submissions or reloads. */
|
||
WebKitNavigationType nav_type =
|
||
webkit_navigation_action_get_navigation_type(action);
|
||
if (nav_type != WEBKIT_NAVIGATION_TYPE_LINK_CLICKED)
|
||
return FALSE;
|
||
|
||
/* Check if this is a new-tab request (target="_blank" or
|
||
* middle-click with modifier). webkit_navigation_action_get_request()
|
||
* gives us the URI. If the decision's frame name is not the main
|
||
* frame, or the user clicked with middle button / Ctrl, open a tab. */
|
||
guint button = webkit_navigation_action_get_mouse_button(action);
|
||
GdkModifierType mods = webkit_navigation_action_get_modifiers(action);
|
||
|
||
/* Middle-click or Ctrl+click opens a new tab. */
|
||
if (button == 2 || (mods & GDK_CONTROL_MASK)) {
|
||
if (uri && uri[0]) {
|
||
tab_manager_new_tab(uri);
|
||
}
|
||
webkit_policy_decision_ignore(decision);
|
||
return TRUE;
|
||
}
|
||
|
||
/* Check for target="_blank" — WebKit requests a new view for these.
|
||
* The navigation policy decision has a "frame name" that is non-NULL
|
||
* when a target is specified. We can't easily get the frame name from
|
||
* the API, but WebKit will call "create" on the webview for new
|
||
* windows. That's handled separately by the "create" signal.
|
||
* For now, let the default navigation proceed. */
|
||
return FALSE;
|
||
}
|
||
|
||
/* ── New webview creation (target="_blank") ────────────────────────── *
|
||
* When a page requests a new window (target="_blank", window.open()),
|
||
* WebKit fires the "create" signal on the webview. We open the requested
|
||
* URI in a new tab in the current window (matching the behavior of other
|
||
* tabbed browsers like Brave/Firefox) and return the new tab's webview.
|
||
*
|
||
* The new webview is created with webkit_web_view_new_with_related_view()
|
||
* (via the g_target_related_view mechanism in tab_create) so it shares the
|
||
* parent's WebProcess and window features — this avoids the
|
||
* std::optional<WindowFeatures> assertion crash that occurs with
|
||
* webkit_web_view_new_with_context() for target="_blank" requests.
|
||
*/
|
||
|
||
static GtkWidget *on_create_webview(WebKitWebView *webview,
|
||
WebKitNavigationAction *action,
|
||
gpointer user_data) {
|
||
(void)user_data;
|
||
|
||
WebKitURIRequest *request = webkit_navigation_action_get_request(action);
|
||
const char *uri = webkit_uri_request_get_uri(request);
|
||
|
||
if (uri && uri[0]) {
|
||
/* Set the related view so tab_create() uses
|
||
* webkit_web_view_new_with_related_view() — shares the parent's
|
||
* WebProcess and avoids the WindowFeatures assertion crash. Also
|
||
* set g_target_skip_load: WebKit already has a pending
|
||
* NavigationAction with WindowFeatures that it will apply to the
|
||
* returned webview itself, so tab_create() must NOT call
|
||
* webkit_web_view_load_uri() (that would race with WebKit's own
|
||
* navigation setup and trigger the std::optional<WindowFeatures>
|
||
* assertion crash). */
|
||
g_target_related_view = webview;
|
||
g_target_skip_load = TRUE;
|
||
int idx = tab_manager_new_tab(uri);
|
||
g_target_skip_load = FALSE;
|
||
g_target_related_view = NULL;
|
||
|
||
if (idx >= 0) {
|
||
tab_info_t *tab = tab_manager_get(idx);
|
||
if (tab && tab->webview) {
|
||
return GTK_WIDGET(tab->webview);
|
||
}
|
||
}
|
||
}
|
||
|
||
return NULL;
|
||
}
|
||
|
||
/* ── New window creation (real GtkWindow for target="_blank") ──────── *
|
||
* Creates a new top-level GtkWindow with its own GtkNotebook and a
|
||
* single webview. The webview is created with
|
||
* webkit_web_view_new_with_related_view(related_view) when a parent
|
||
* webview is available, so it shares the parent's WebProcess and
|
||
* window features — this avoids the std::optional<WindowFeatures>
|
||
* assertion crash that occurred with webkit_web_view_new_with_context().
|
||
*
|
||
* The new window's webview gets the same setup as a regular tab:
|
||
* WebKitSettings, nostr_inject_setup, key-press-event, decide-policy,
|
||
* create, load-changed, etc. The window does NOT quit the app when
|
||
* closed — only the main window (in main.c) does that.
|
||
*
|
||
* Returns the new WebKitWebView widget, or NULL on failure.
|
||
*/
|
||
|
||
/* focus-in-event handler for auxiliary windows: updates the global
|
||
* active window/notebook pointers so MCP get_active_webview() resolves
|
||
* to the focused window's webview. */
|
||
static gboolean on_window_focus_in(GtkWidget *widget,
|
||
GdkEventFocus *event,
|
||
gpointer user_data) {
|
||
(void)event;
|
||
GtkWidget *notebook = GTK_WIDGET(user_data);
|
||
if (widget == NULL || notebook == NULL) return FALSE;
|
||
|
||
g_active_window = GTK_WINDOW(widget);
|
||
g_active_notebook = notebook;
|
||
g_active_ws = window_state_for_notebook(notebook);
|
||
if (g_active_ws == NULL) g_active_ws = &g_main_window;
|
||
g_print("[windows] Active window changed to %p (notebook %p)\n",
|
||
(void *)g_active_window, (void *)g_active_notebook);
|
||
return FALSE;
|
||
}
|
||
|
||
/* destroy handler for auxiliary windows: if this was the active window,
|
||
* fall back to the main window/notebook so MCP keeps working. Does NOT
|
||
* quit the app — only the main window's destroy handler does that.
|
||
* Also removes the window's window_state_t from g_aux_windows. */
|
||
static void on_aux_window_destroy(GtkWidget *widget, gpointer user_data) {
|
||
(void)user_data;
|
||
if (g_active_window == GTK_WINDOW(widget)) {
|
||
g_active_window = g_window;
|
||
g_active_notebook = g_notebook;
|
||
g_active_ws = &g_main_window;
|
||
g_print("[windows] Active window closed, reverting to main window\n");
|
||
}
|
||
/* Remove the destroyed window's entry from g_aux_windows. */
|
||
if (g_aux_windows != NULL) {
|
||
for (guint i = 0; i < g_aux_windows->len; i++) {
|
||
window_state_t *ws = &g_array_index(g_aux_windows,
|
||
window_state_t, i);
|
||
if (ws->window == GTK_WINDOW(widget)) {
|
||
g_array_remove_index_fast(g_aux_windows, i);
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
g_print("[windows] Auxiliary window destroyed: %p\n", (void *)widget);
|
||
}
|
||
|
||
static GtkWidget *tab_manager_new_window(const char *url,
|
||
WebKitWebView *related_view) {
|
||
if (g_ctx == NULL) return NULL;
|
||
|
||
/* Create the top-level window. */
|
||
GtkWidget *window = gtk_window_new(GTK_WINDOW_TOPLEVEL);
|
||
gtk_window_set_title(GTK_WINDOW(window), "sovereign browser");
|
||
gtk_window_set_default_size(GTK_WINDOW(window), 1024, 768);
|
||
|
||
/* Create a notebook for this window. It holds the full tab
|
||
* infrastructure (toolbar, URL entry, tab label, favicon, etc.)
|
||
* built by tab_create(), so the new window looks and behaves like
|
||
* the main window. */
|
||
GtkWidget *notebook = gtk_notebook_new();
|
||
gtk_notebook_set_scrollable(GTK_NOTEBOOK(notebook), FALSE);
|
||
gtk_notebook_set_show_border(GTK_NOTEBOOK(notebook), FALSE);
|
||
gtk_notebook_set_show_tabs(GTK_NOTEBOOK(notebook), TRUE);
|
||
|
||
/* New-tab button + avatar as notebook action widgets, same as the
|
||
* main window. The new-tab button is wired to THIS notebook so tabs
|
||
* open in this window, not the main window. is_main=FALSE so a
|
||
* separate avatar image is created and tracked (the global g_avatar
|
||
* stays pointing at the main window's image). */
|
||
setup_notebook_action_widgets(notebook, FALSE);
|
||
|
||
/* Grab focus on the webview when a tab is switched to (same as the
|
||
* main notebook) so wheel/scroll events work in auxiliary windows. */
|
||
g_signal_connect(notebook, "switch-page",
|
||
G_CALLBACK(on_notebook_switch_page), NULL);
|
||
|
||
/* 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.
|
||
* Gated on the perf_probe_enabled setting: the probe patches
|
||
* addEventListener/setTimeout/setInterval/XHR and runs a permanent
|
||
* rAF loop, which adds enough main-thread overhead to break
|
||
* scrolling on listener-heavy pages. Default OFF. */
|
||
if (settings_get()->perf_probe_enabled) {
|
||
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;
|
||
ws.toolbars_visible = TRUE;
|
||
g_array_append_val(g_aux_windows, ws);
|
||
|
||
/* This new window is now the active window. */
|
||
g_active_window = GTK_WINDOW(window);
|
||
g_active_notebook = notebook;
|
||
g_active_ws = &g_array_index(g_aux_windows, window_state_t,
|
||
g_aux_windows->len - 1);
|
||
|
||
g_print("[windows] Created new window %p (tab %d) for %s\n",
|
||
(void *)window, index, url ? url : "(none)");
|
||
return GTK_WIDGET(tab->webview);
|
||
}
|
||
|
||
static GtkWidget *build_tab_label(tab_info_t *tab) {
|
||
GtkWidget *box = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 4);
|
||
|
||
/* Make the tab label expand and fill so tabs divide the available
|
||
* space evenly across the tab strip. */
|
||
gtk_widget_set_hexpand(box, TRUE);
|
||
gtk_widget_set_halign(box, GTK_ALIGN_FILL);
|
||
gtk_widget_set_size_request(box, 120, -1); /* min width for readability */
|
||
|
||
/* Favicon — starts empty (no placeholder icon). Updated via the
|
||
* notify::favicon signal when a real favicon is available. */
|
||
tab->favicon = gtk_image_new();
|
||
gtk_widget_set_valign(tab->favicon, GTK_ALIGN_CENTER);
|
||
gtk_box_pack_start(GTK_BOX(box), tab->favicon, FALSE, FALSE, 0);
|
||
|
||
/* Title label — expands to fill, ellipsizes if too long. */
|
||
tab->title_label = gtk_label_new("New Tab");
|
||
gtk_label_set_ellipsize(GTK_LABEL(tab->title_label), PANGO_ELLIPSIZE_END);
|
||
gtk_label_set_max_width_chars(GTK_LABEL(tab->title_label), 20);
|
||
gtk_widget_set_hexpand(tab->title_label, TRUE);
|
||
gtk_widget_set_valign(tab->title_label, GTK_ALIGN_CENTER);
|
||
gtk_box_pack_start(GTK_BOX(box), tab->title_label, TRUE, TRUE, 0);
|
||
|
||
/* Close button. */
|
||
const browser_settings_t *s = settings_get();
|
||
if (s->show_tab_close_buttons) {
|
||
GtkWidget *close_btn = gtk_button_new();
|
||
gtk_button_set_relief(GTK_BUTTON(close_btn), GTK_RELIEF_NONE);
|
||
gtk_button_set_image(GTK_BUTTON(close_btn),
|
||
gtk_image_new_from_icon_name("window-close-symbolic",
|
||
GTK_ICON_SIZE_MENU));
|
||
gtk_widget_set_tooltip_text(close_btn, "Close tab");
|
||
g_signal_connect(close_btn, "clicked",
|
||
G_CALLBACK(on_tab_close_clicked), tab);
|
||
/* Connect button-press so right-click on the close button
|
||
* also triggers the tab context menu. */
|
||
gtk_widget_add_events(close_btn, GDK_BUTTON_PRESS_MASK);
|
||
g_signal_connect(close_btn, "button-press-event",
|
||
G_CALLBACK(on_tab_label_button_press), tab);
|
||
gtk_box_pack_start(GTK_BOX(box), close_btn, FALSE, FALSE, 0);
|
||
}
|
||
|
||
/* Connect button-press for middle-click and right-click on the label.
|
||
* We add the event mask and connect the handler to the box AND all
|
||
* child widgets (favicon, label, close button), because child widgets
|
||
* intercept button-press events before they bubble up to the parent.
|
||
* Without this, right-clicking on the label or close button wouldn't
|
||
* show the context menu. */
|
||
gtk_widget_add_events(box, GDK_BUTTON_PRESS_MASK);
|
||
g_signal_connect(box, "button-press-event",
|
||
G_CALLBACK(on_tab_label_button_press), tab);
|
||
/* Also connect child widgets so right-clicks on them trigger the
|
||
* context menu. The handler checks event->button and only acts on
|
||
* right-click (button 3) and middle-click (button 2), so normal
|
||
* left-clicks on the close button still work. */
|
||
gtk_widget_add_events(tab->favicon, GDK_BUTTON_PRESS_MASK);
|
||
g_signal_connect(tab->favicon, "button-press-event",
|
||
G_CALLBACK(on_tab_label_button_press), tab);
|
||
gtk_widget_add_events(tab->title_label, GDK_BUTTON_PRESS_MASK);
|
||
g_signal_connect(tab->title_label, "button-press-event",
|
||
G_CALLBACK(on_tab_label_button_press), tab);
|
||
|
||
gtk_widget_show_all(box);
|
||
return box;
|
||
}
|
||
|
||
/* ── Per-tab signal handlers ──────────────────────────────────────── */
|
||
|
||
static void on_url_activate(GtkEntry *entry, gpointer user_data) {
|
||
tab_info_t *tab = (tab_info_t *)user_data;
|
||
const char *text = gtk_entry_get_text(entry);
|
||
|
||
/* Agent chat shortcut: "; <message>" routes to the embedded agent. */
|
||
if (text[0] == ';') {
|
||
const char *msg = text + 1;
|
||
/* Skip leading whitespace after the semicolon. */
|
||
while (*msg == ' ' || *msg == '\t') msg++;
|
||
agent_chat_route_input(*msg ? msg : NULL);
|
||
/* Clear the URL bar after sending to agent. */
|
||
gtk_entry_set_text(entry, "");
|
||
return;
|
||
}
|
||
|
||
nostr_entity_type_t entity_type = nostr_url_detect(text);
|
||
if (entity_type == NOSTR_ENTITY_NSEC) {
|
||
webkit_web_view_load_html(tab->webview,
|
||
"<!doctype html><meta charset=\"utf-8\"><title>Private key blocked</title>"
|
||
"<h1>Navigation blocked</h1><p>Nostr private keys cannot be opened or navigated to.</p>",
|
||
"nostr://blocked-private-key");
|
||
return;
|
||
}
|
||
|
||
char *url = normalize_url(text);
|
||
if (url != NULL) {
|
||
webkit_web_view_load_uri(tab->webview, url);
|
||
g_free(url);
|
||
}
|
||
}
|
||
|
||
/* Back button — navigate to the previous page in history. */
|
||
static void on_back_clicked(GtkButton *btn, gpointer user_data) {
|
||
(void)btn;
|
||
tab_info_t *tab = (tab_info_t *)user_data;
|
||
if (tab && tab->webview && webkit_web_view_can_go_back(tab->webview)) {
|
||
webkit_web_view_go_back(tab->webview);
|
||
}
|
||
}
|
||
|
||
/* Forward button — navigate to the next page in history. */
|
||
static void on_forward_clicked(GtkButton *btn, gpointer user_data) {
|
||
(void)btn;
|
||
tab_info_t *tab = (tab_info_t *)user_data;
|
||
if (tab && tab->webview && webkit_web_view_can_go_forward(tab->webview)) {
|
||
webkit_web_view_go_forward(tab->webview);
|
||
}
|
||
}
|
||
|
||
/* Update the per-tab refresh/stop button visual + tooltip to match the
|
||
* webview's current loading state. Called from on_load_changed,
|
||
* on_load_failed, and on_refresh_clicked so the button always reflects
|
||
* the actual WebKit is-loading state for *this* tab only. */
|
||
static void tab_refresh_button_update(tab_info_t *tab) {
|
||
if (tab == NULL || tab->refresh_btn == NULL || tab->webview == NULL) {
|
||
return;
|
||
}
|
||
gboolean loading = webkit_web_view_is_loading(tab->webview);
|
||
if (tab->refresh_shows_stop == loading) return;
|
||
tab->refresh_shows_stop = loading;
|
||
|
||
GtkButton *btn = GTK_BUTTON(tab->refresh_btn);
|
||
const gchar *icon_name = loading ? "process-stop-symbolic"
|
||
: "view-refresh-symbolic";
|
||
gtk_button_set_image(btn,
|
||
gtk_image_new_from_icon_name(icon_name, GTK_ICON_SIZE_MENU));
|
||
|
||
/* Debug log: one line per state transition. G_LOG_LEVEL_INFO keeps it
|
||
* quiet in production (g_message would always print). Use g_print
|
||
* here for test observability since the rest of the file uses it. */
|
||
int index = tab_array_find(tab);
|
||
g_print("[tab %d] refresh button -> %s (icon=%s)\n",
|
||
index,
|
||
loading ? "STOP" : "RELOAD",
|
||
icon_name);
|
||
|
||
if (loading) {
|
||
gtk_widget_set_tooltip_text(tab->refresh_btn,
|
||
"Stop loading (right-click for hard reload options)");
|
||
} else {
|
||
gtk_widget_set_tooltip_text(tab->refresh_btn,
|
||
"Reload page (right-click for hard reload options)");
|
||
}
|
||
}
|
||
|
||
/* Refresh/Stop button — left-click branches on per-tab loading state:
|
||
* loading -> webkit_web_view_stop_loading()
|
||
* idle -> webkit_web_view_reload()
|
||
* The button visual is kept in sync with is_loading via
|
||
* tab_refresh_button_update() called from the load-changed/load-failed
|
||
* handlers, so this single handler covers both modes. */
|
||
static void on_refresh_clicked(GtkButton *btn, gpointer user_data) {
|
||
(void)btn;
|
||
tab_info_t *tab = (tab_info_t *)user_data;
|
||
if (tab == NULL || tab->webview == NULL) return;
|
||
|
||
if (webkit_web_view_is_loading(tab->webview)) {
|
||
g_print("[tab] stop loading requested\n");
|
||
webkit_web_view_stop_loading(tab->webview);
|
||
/* WebKit fires load-failed (WEBKIT_LOAD_FAILED) for cancellations
|
||
* in most cases, which restores the button. Update defensively
|
||
* here too in case no signal arrives (e.g. already-finished load
|
||
* races) so the UI never gets stuck in the stop state. */
|
||
tab_refresh_button_update(tab);
|
||
} else {
|
||
webkit_web_view_reload(tab->webview);
|
||
}
|
||
}
|
||
|
||
/* Hard reload menu item — bypasses cache. */
|
||
static void on_hard_reload(GtkMenuItem *item, gpointer user_data) {
|
||
(void)item;
|
||
tab_info_t *tab = (tab_info_t *)user_data;
|
||
if (tab && tab->webview) {
|
||
webkit_web_view_reload_bypass_cache(tab->webview);
|
||
}
|
||
}
|
||
|
||
/* Clear site data + hard reload menu item. */
|
||
static void on_clear_and_reload(GtkMenuItem *item, gpointer user_data) {
|
||
(void)item;
|
||
tab_info_t *tab = (tab_info_t *)user_data;
|
||
if (!tab || !tab->webview) return;
|
||
|
||
/* Clear localStorage and sessionStorage via JS. */
|
||
const char *clear_js =
|
||
"(function(){try{localStorage.clear();}catch(e){}"
|
||
"try{sessionStorage.clear();}catch(e){}"
|
||
"return 'ok';})()";
|
||
char *result = agent_js_eval_sync(tab->webview, clear_js, 3000);
|
||
g_free(result);
|
||
|
||
/* Hard reload to bypass cache. */
|
||
webkit_web_view_reload_bypass_cache(tab->webview);
|
||
}
|
||
|
||
/* Refresh button — right-click: show dropdown with hard reload options. */
|
||
static gboolean on_refresh_button_press(GtkWidget *widget,
|
||
GdkEventButton *event,
|
||
gpointer user_data) {
|
||
tab_info_t *tab = (tab_info_t *)user_data;
|
||
(void)widget;
|
||
|
||
if (event->button == 3) { /* Right-click */
|
||
GtkWidget *menu = gtk_menu_new();
|
||
|
||
GtkWidget *item_reload = gtk_menu_item_new_with_label("Reload");
|
||
gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_reload);
|
||
g_signal_connect(item_reload, "activate",
|
||
G_CALLBACK(on_refresh_clicked), tab);
|
||
|
||
GtkWidget *item_hard = gtk_menu_item_new_with_label(
|
||
"Hard reload (bypass cache)");
|
||
gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_hard);
|
||
g_signal_connect(item_hard, "activate",
|
||
G_CALLBACK(on_hard_reload), tab);
|
||
|
||
gtk_menu_shell_append(GTK_MENU_SHELL(menu),
|
||
gtk_separator_menu_item_new());
|
||
|
||
GtkWidget *item_clear = gtk_menu_item_new_with_label(
|
||
"Clear site data + hard reload");
|
||
gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_clear);
|
||
g_signal_connect(item_clear, "activate",
|
||
G_CALLBACK(on_clear_and_reload), tab);
|
||
|
||
gtk_widget_show_all(menu);
|
||
gtk_menu_popup_at_pointer(GTK_MENU(menu), (GdkEvent *)event);
|
||
return TRUE; /* Suppress default button-press handling */
|
||
}
|
||
|
||
return FALSE; /* Let left-click propagate to "clicked" signal */
|
||
}
|
||
|
||
static void on_load_changed(WebKitWebView *webview,
|
||
WebKitLoadEvent load_event,
|
||
gpointer user_data) {
|
||
tab_info_t *tab = (tab_info_t *)user_data;
|
||
|
||
/* Per-tab reload/stop button: switch to the stop (X) visual as soon
|
||
* as a load begins, and back to the reload visual when it ends.
|
||
* WEBKIT_LOAD_STARTED is the canonical "loading now" signal; the
|
||
* button is also re-synced on FINISHED/FAILED below. This is per-tab
|
||
* (each tab has its own refresh_btn) so loading one tab never affects
|
||
* another tab's button. */
|
||
if (load_event == WEBKIT_LOAD_STARTED) {
|
||
tab_refresh_button_update(tab);
|
||
}
|
||
|
||
/* tab is NULL for webviews in auxiliary (new) windows, which don't
|
||
* have a tab_info_t. Skip the tab-UI updates (URL bar, favicon, tab
|
||
* title) but still record history on load-finished below. */
|
||
if (load_event == WEBKIT_LOAD_COMMITTED) {
|
||
if (tab == NULL) return;
|
||
const gchar *uri = webkit_web_view_get_uri(webview);
|
||
if (uri != NULL) {
|
||
/* Clear the favicon — the new page's favicon (if any) will
|
||
* arrive via the notify::favicon signal. This prevents a
|
||
* stale favicon from the previous page lingering. */
|
||
gtk_image_clear(GTK_IMAGE(tab->favicon));
|
||
|
||
/* Show an empty URL bar for blank pages so the user can
|
||
* immediately type a URL. Keep current_url as the real URI
|
||
* (about:blank) so session save/duplicate still work. */
|
||
if (strcmp(uri, "about:blank") == 0) {
|
||
gtk_entry_set_text(GTK_ENTRY(tab->url_entry), "");
|
||
} else {
|
||
gtk_entry_set_text(GTK_ENTRY(tab->url_entry), uri);
|
||
}
|
||
snprintf(tab->current_url, sizeof(tab->current_url), "%s", uri);
|
||
|
||
/* Set a temporary title from the URL host while the page loads.
|
||
* This ensures the tab shows something meaningful immediately,
|
||
* not just "New Tab" or "Loading…". */
|
||
const char *host = strstr(uri, "://");
|
||
if (host) host += 3;
|
||
else host = uri;
|
||
/* Strip path — just show the domain. */
|
||
char host_buf[256];
|
||
snprintf(host_buf, sizeof(host_buf), "%s", host);
|
||
char *slash = strchr(host_buf, '/');
|
||
if (slash) *slash = '\0';
|
||
if (host_buf[0]) {
|
||
int index = tab_array_find(tab);
|
||
if (index >= 0) {
|
||
tab_manager_set_title(index, host_buf);
|
||
}
|
||
}
|
||
}
|
||
} else if (load_event == WEBKIT_LOAD_FINISHED) {
|
||
const gchar *title = webkit_web_view_get_title(webview);
|
||
const gchar *uri = webkit_web_view_get_uri(webview);
|
||
/* The title may not be available yet at LOAD_FINISHED — it often
|
||
* arrives slightly later via the notify::title signal (handled by
|
||
* on_title_changed). Log what we have; the title handler will log
|
||
* the real title when it arrives. */
|
||
g_print("[loaded] %s\n", uri ? uri : "(null)");
|
||
|
||
/* Restore the reload button visual now that loading is done. */
|
||
tab_refresh_button_update(tab);
|
||
|
||
/* Update tab title if already available (works for tabs in any
|
||
* window's notebook). The notify::title handler covers the case
|
||
* where the title arrives after load-finished. */
|
||
if (tab != NULL && title && title[0]) {
|
||
int index = tab_array_find(tab);
|
||
if (index >= 0) {
|
||
tab_manager_set_title(index, title);
|
||
}
|
||
}
|
||
|
||
/* Add to history (with title for the Recents submenu tooltip).
|
||
* This applies to both main-window tabs and auxiliary windows.
|
||
* If the title isn't available yet, history_add_titled will use
|
||
* the URL; the notify::title handler could update it later if
|
||
* needed. */
|
||
if (uri != NULL && uri[0] != '\0') {
|
||
history_add_titled(uri, (title && title[0]) ? title : NULL);
|
||
}
|
||
|
||
}
|
||
}
|
||
|
||
static gboolean on_load_failed(WebKitWebView *webview,
|
||
WebKitLoadEvent load_event,
|
||
gchar *failing_uri,
|
||
GError *error,
|
||
gpointer data) {
|
||
(void)webview;
|
||
(void)load_event;
|
||
tab_info_t *tab = (tab_info_t *)data;
|
||
g_print("[failed] %s -- %s\n",
|
||
failing_uri ? failing_uri : "(null)",
|
||
error ? error->message : "(unknown)");
|
||
|
||
/* A failed load (including user-initiated cancellation via
|
||
* webkit_web_view_stop_loading()) ends the loading phase, so restore
|
||
* the reload button visual. This prevents stale stop state when the
|
||
* user clicks the X to cancel a load. */
|
||
tab_refresh_button_update(tab);
|
||
|
||
/* Keep the failed URL in the address bar so the user can see what
|
||
* failed and edit/retry. Without this, WebKit reverts to about:blank
|
||
* and the user loses the URL they typed or clicked. */
|
||
if (tab != NULL && failing_uri != NULL && failing_uri[0] != '\0') {
|
||
if (strcmp(failing_uri, "about:blank") != 0) {
|
||
gtk_entry_set_text(GTK_ENTRY(tab->url_entry), failing_uri);
|
||
snprintf(tab->current_url, sizeof(tab->current_url), "%s", failing_uri);
|
||
}
|
||
|
||
/* Set a descriptive tab title from the error so the user sees
|
||
* something meaningful in the tab bar instead of "Loading…". */
|
||
const char *msg = (error && error->message) ? error->message : "Load failed";
|
||
int index = tab_array_find(tab);
|
||
if (index >= 0) {
|
||
tab_manager_set_title(index, msg);
|
||
}
|
||
}
|
||
|
||
return FALSE;
|
||
}
|
||
|
||
/* Called when the webview's "title" property changes. The title often
|
||
* arrives slightly after WEBKIT_LOAD_FINISHED, so the load-finished
|
||
* handler may log "(none)". This handler picks up the real title as
|
||
* soon as it's available and updates the tab title + logs it. */
|
||
static void on_title_changed(WebKitWebView *webview,
|
||
GParamSpec *pspec,
|
||
gpointer user_data) {
|
||
(void)pspec;
|
||
tab_info_t *tab = (tab_info_t *)user_data;
|
||
const gchar *title = webkit_web_view_get_title(webview);
|
||
const gchar *uri = webkit_web_view_get_uri(webview);
|
||
|
||
if (title && title[0]) {
|
||
g_print("[title] %s -- %s\n",
|
||
uri ? uri : "(null)", title);
|
||
|
||
if (tab != NULL) {
|
||
int index = tab_array_find(tab);
|
||
if (index >= 0) {
|
||
tab_manager_set_title(index, title);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/* ── Web view context menu (right-click) ──────────────────────────── */
|
||
|
||
/* Context passed to on_context_open_in_new_window(): the link URL and the
|
||
* webview that was right-clicked (used as the related view so the new
|
||
* window shares the parent's WebProcess). */
|
||
struct new_window_ctx {
|
||
char *url;
|
||
WebKitWebView *related_view;
|
||
};
|
||
|
||
/* 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);
|
||
}
|
||
|
||
/* Idle callback that actually creates the new window. We defer the
|
||
* tab_manager_new_window() call out of the GAction "activate" handler
|
||
* because that runs inside the GtkMenu's signal emission / GSource
|
||
* teardown. Creating a new top-level window (which calls
|
||
* gtk_widget_show_all + gtk_window_present and thus processes pending
|
||
* events) from within that handler caused GLib-CRITICAL
|
||
* g_source_ref/g_source_unref assertion failures as the menu's own
|
||
* GSources were being released concurrently. Running on the next idle
|
||
* tick lets the menu fully close first. */
|
||
static gboolean on_idle_open_in_new_window(gpointer user_data) {
|
||
struct new_window_ctx *ctx = (struct new_window_ctx *)user_data;
|
||
if (ctx && ctx->url && ctx->url[0]) {
|
||
tab_manager_new_window(ctx->url, ctx->related_view);
|
||
}
|
||
if (ctx) {
|
||
g_free(ctx->url);
|
||
g_slice_free(struct new_window_ctx, ctx);
|
||
}
|
||
return G_SOURCE_REMOVE; /* run once */
|
||
}
|
||
|
||
/* Callback for "Open Link in New Window". Opens the link URL in a brand
|
||
* new top-level GtkWindow (via tab_manager_new_window) instead of a new
|
||
* tab. The related view (the webview that was right-clicked) is passed so
|
||
* the new window shares the parent's WebProcess. The actual window
|
||
* creation is deferred to an idle handler (see on_idle_open_in_new_window)
|
||
* to avoid GLib-CRITICAL source-refcount warnings from creating the
|
||
* window inside the menu's activate callback. */
|
||
static void on_context_open_in_new_window(GSimpleAction *action,
|
||
GVariant *parameter,
|
||
gpointer user_data) {
|
||
(void)action;
|
||
(void)parameter;
|
||
/* Steal the ctx and defer to idle — do NOT free it here; the idle
|
||
* callback owns and frees it. */
|
||
struct new_window_ctx *ctx = (struct new_window_ctx *)user_data;
|
||
if (ctx) {
|
||
g_idle_add(on_idle_open_in_new_window, ctx);
|
||
}
|
||
}
|
||
|
||
/* 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). We replace that default
|
||
* "Open Link in New Window" item with our own that actually opens a
|
||
* real top-level GtkWindow (WebKit's default fires the "create"
|
||
* signal, which our on_create_webview handler routes to a new *tab*,
|
||
* so without this replacement "Open in New Window" would wrongly open
|
||
* a tab). We then insert "Open Link in New Tab" at position 1, so the
|
||
* final order is:
|
||
* Open Link
|
||
* Open Link in New Tab
|
||
* Open Link in New Window (ours — real new window)
|
||
*
|
||
* For page background: insert the "Open Page in New Tab" item at
|
||
* position 0 (top). */
|
||
int insert_pos;
|
||
if (is_link) {
|
||
/* Find and remove WebKit's stock "Open Link in New Window" item
|
||
* so we can replace it with our own below. */
|
||
guint n = webkit_context_menu_get_n_items(context_menu);
|
||
for (guint i = 0; i < n; i++) {
|
||
WebKitContextMenuItem *it =
|
||
webkit_context_menu_get_item_at_position(context_menu, i);
|
||
if (it == NULL) continue;
|
||
if (webkit_context_menu_item_get_stock_action(it) ==
|
||
WEBKIT_CONTEXT_MENU_ACTION_OPEN_LINK_IN_NEW_WINDOW) {
|
||
webkit_context_menu_remove(context_menu, it);
|
||
break;
|
||
}
|
||
}
|
||
insert_pos = 1;
|
||
} else {
|
||
insert_pos = 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);
|
||
|
||
/* For links: also add our own "Open Link in New Window" item that
|
||
* opens a real top-level GtkWindow. Insert it right after the
|
||
* "Open Link in New Tab" item we just added (position 2). */
|
||
if (is_link) {
|
||
GSimpleAction *win_action =
|
||
g_simple_action_new("open-in-new-window", NULL);
|
||
struct new_window_ctx *ctx = g_slice_new(struct new_window_ctx);
|
||
ctx->url = g_strdup(url);
|
||
ctx->related_view = webview; /* share WebProcess with parent */
|
||
g_signal_connect(win_action, "activate",
|
||
G_CALLBACK(on_context_open_in_new_window), ctx);
|
||
|
||
WebKitContextMenuItem *win_item =
|
||
webkit_context_menu_item_new_from_gaction(
|
||
G_ACTION(win_action), "Open Link in New Window", NULL);
|
||
webkit_context_menu_insert(context_menu, win_item, 2);
|
||
g_object_unref(win_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;
|
||
/* Parent the dialog to the currently focused window (g_active_window)
|
||
* rather than always the main window (g_window), so the file chooser
|
||
* appears over the window the user invoked it from. g_active_window is
|
||
* kept up to date by on_window_focus_in() whenever a window gains
|
||
* focus; fall back to g_window if it hasn't been set yet. */
|
||
GtkWindow *parent = g_active_window ? g_active_window : g_window;
|
||
if (parent == NULL) return;
|
||
|
||
GtkWidget *dialog = gtk_file_chooser_dialog_new(
|
||
"Open File", parent, 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.
|
||
* Always opens in a new tab so the user's current page is preserved. */
|
||
static void on_manage_bookmarks_clicked(GtkMenuItem *item, gpointer data) {
|
||
(void)item;
|
||
(void)data;
|
||
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. Parent it to the focused window
|
||
* (g_active_window) so it appears over the window the user invoked
|
||
* it from, falling back to the main window if unset. */
|
||
GtkWindow *bm_parent = g_active_window ? g_active_window : g_window;
|
||
GtkWidget *dialog = gtk_dialog_new_with_buttons(
|
||
"Bookmark Page", bm_parent,
|
||
GTK_DIALOG_MODAL | GTK_DIALOG_DESTROY_WITH_PARENT,
|
||
"_Cancel", GTK_RESPONSE_CANCEL,
|
||
"_Add", GTK_RESPONSE_ACCEPT,
|
||
NULL);
|
||
gtk_window_set_default_size(GTK_WINDOW(dialog), 350, 150);
|
||
|
||
GtkWidget *content = gtk_dialog_get_content_area(GTK_DIALOG(dialog));
|
||
gtk_container_set_border_width(GTK_CONTAINER(content), 12);
|
||
|
||
/* Show the URL being bookmarked. */
|
||
char *url_esc = g_markup_escape_text(url, -1);
|
||
char *title_esc = g_markup_escape_text(title, -1);
|
||
char *info = g_strdup_printf(
|
||
"<b>%s</b>\n<span size='small' color='#888'>%s</span>",
|
||
title_esc[0] ? title_esc : "(untitled)", url_esc);
|
||
GtkWidget *lbl = gtk_label_new(NULL);
|
||
gtk_label_set_markup(GTK_LABEL(lbl), info);
|
||
gtk_label_set_line_wrap(GTK_LABEL(lbl), TRUE);
|
||
gtk_widget_set_halign(lbl, GTK_ALIGN_START);
|
||
gtk_box_pack_start(GTK_BOX(content), lbl, FALSE, FALSE, 8);
|
||
|
||
/* Folder combo box (with entry so the user can type a new path). */
|
||
GtkWidget *dir_label = gtk_label_new("Folder path:");
|
||
gtk_widget_set_halign(dir_label, GTK_ALIGN_START);
|
||
gtk_box_pack_start(GTK_BOX(content), dir_label, FALSE, FALSE, 4);
|
||
|
||
GtkWidget *combo = gtk_combo_box_text_new_with_entry();
|
||
GPtrArray *paths = g_ptr_array_new();
|
||
const bookmark_node_t *root = bookmarks_get_root();
|
||
collect_folder_paths(root, paths);
|
||
int default_idx = 0;
|
||
for (guint i = 0; i < paths->len; i++) {
|
||
const char *p = (const char *)g_ptr_array_index(paths, i);
|
||
gtk_combo_box_text_append_text(GTK_COMBO_BOX_TEXT(combo), p);
|
||
/* Default to "Bookmarks Bar" (always visible in the toolbar) so
|
||
* new users don't have to know the exact folder name. Fall back
|
||
* to "General" if "Bookmarks Bar" isn't present for some reason. */
|
||
if (strcmp(p, "Bookmarks Bar") == 0) default_idx = (int)i;
|
||
else if (strcmp(p, "General") == 0 && default_idx == 0) default_idx = (int)i;
|
||
}
|
||
gtk_combo_box_set_active(GTK_COMBO_BOX(combo), default_idx);
|
||
gtk_box_pack_start(GTK_BOX(content), combo, FALSE, FALSE, 4);
|
||
|
||
g_free(url_esc);
|
||
g_free(title_esc);
|
||
g_free(info);
|
||
|
||
gtk_widget_show_all(dialog);
|
||
|
||
gint response = gtk_dialog_run(GTK_DIALOG(dialog));
|
||
if (response == GTK_RESPONSE_ACCEPT) {
|
||
const char *path = gtk_combo_box_text_get_active_text(
|
||
GTK_COMBO_BOX_TEXT(combo));
|
||
if (path == NULL || path[0] == '\0') path = "General";
|
||
|
||
char *path_copy = g_strdup(path);
|
||
char *url_copy = g_strdup(url);
|
||
char *title_copy = g_strdup(title);
|
||
|
||
int rc = bookmarks_add(path_copy, url_copy, title_copy);
|
||
if (rc == 0) {
|
||
g_print("[bookmarks] Bookmarked '%s' to '%s'\n", url_copy,
|
||
path_copy);
|
||
} else {
|
||
g_printerr("[bookmarks] Failed to bookmark (no signer?)\n");
|
||
}
|
||
|
||
g_free(path_copy);
|
||
g_free(url_copy);
|
||
g_free(title_copy);
|
||
}
|
||
|
||
/* Free the paths array. */
|
||
for (guint i = 0; i < paths->len; i++) {
|
||
g_free(g_ptr_array_index(paths, i));
|
||
}
|
||
g_ptr_array_free(paths, TRUE);
|
||
|
||
gtk_widget_destroy(dialog);
|
||
}
|
||
|
||
/* ── Hamburger menu builder ───────────────────────────────────────── */
|
||
|
||
static void sync_network_menu_toggle(GtkWidget *item, gboolean active) {
|
||
g_object_set_data(G_OBJECT(item), "network-toggle-sync", GINT_TO_POINTER(1));
|
||
gtk_check_menu_item_set_active(GTK_CHECK_MENU_ITEM(item), active);
|
||
g_object_set_data(G_OBJECT(item), "network-toggle-sync", NULL);
|
||
}
|
||
|
||
static void on_hamburger_menu_show(GtkWidget *menu, gpointer data) {
|
||
(void)data;
|
||
GtkWidget *tor_item = g_object_get_data(G_OBJECT(menu), "tor-toggle");
|
||
GtkWidget *fips_item = g_object_get_data(G_OBJECT(menu), "fips-toggle");
|
||
const browser_settings_t *settings = settings_get();
|
||
|
||
if (tor_item)
|
||
sync_network_menu_toggle(tor_item, settings->tor_enabled);
|
||
if (fips_item)
|
||
sync_network_menu_toggle(fips_item, settings->fips_enabled);
|
||
}
|
||
|
||
static GtkWidget *build_hamburger_menu(tab_info_t *tab) {
|
||
(void)tab;
|
||
GtkWidget *menu = gtk_menu_new();
|
||
|
||
/* Navigation group. */
|
||
GtkWidget *item_open = gtk_menu_item_new_with_label("Open File…");
|
||
GtkWidget *item_reload = gtk_menu_item_new_with_label("Reload");
|
||
GtkWidget *item_stop = gtk_menu_item_new_with_label("Stop");
|
||
g_signal_connect(item_open, "activate", G_CALLBACK(on_menu_open_file), NULL);
|
||
g_signal_connect(item_reload, "activate", G_CALLBACK(on_menu_reload), NULL);
|
||
g_signal_connect(item_stop, "activate", G_CALLBACK(on_menu_stop), NULL);
|
||
gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_open);
|
||
gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_reload);
|
||
gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_stop);
|
||
|
||
gtk_menu_shell_append(GTK_MENU_SHELL(menu),
|
||
gtk_separator_menu_item_new());
|
||
|
||
/* Recents submenu. */
|
||
GtkWidget *history_menu = gtk_menu_new();
|
||
g_signal_connect(history_menu, "show", G_CALLBACK(on_history_menu_show), NULL);
|
||
GtkWidget *item_history = gtk_menu_item_new_with_label("Recents");
|
||
gtk_menu_item_set_submenu(GTK_MENU_ITEM(item_history), history_menu);
|
||
gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_history);
|
||
|
||
/* Bookmarks submenu. */
|
||
GtkWidget *bookmarks_menu = gtk_menu_new();
|
||
g_signal_connect(bookmarks_menu, "show",
|
||
G_CALLBACK(on_bookmarks_menu_show), NULL);
|
||
GtkWidget *item_bookmarks = gtk_menu_item_new_with_label("Bookmarks");
|
||
gtk_menu_item_set_submenu(GTK_MENU_ITEM(item_bookmarks), bookmarks_menu);
|
||
gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_bookmarks);
|
||
|
||
gtk_menu_shell_append(GTK_MENU_SHELL(menu),
|
||
gtk_separator_menu_item_new());
|
||
|
||
/* Identity group — delegated to main.c (app_state_t). */
|
||
GtkWidget *item_switch = gtk_menu_item_new_with_label("Switch Identity…");
|
||
GtkWidget *item_lock = gtk_menu_item_new_with_label("Lock Session");
|
||
GtkWidget *item_logout = gtk_menu_item_new_with_label("Logout");
|
||
g_signal_connect(item_switch, "activate",
|
||
G_CALLBACK(app_menu_switch_identity_proxy), g_window);
|
||
g_signal_connect(item_lock, "activate",
|
||
G_CALLBACK(app_menu_lock_session_proxy), NULL);
|
||
g_signal_connect(item_logout, "activate",
|
||
G_CALLBACK(app_menu_logout_proxy), g_window);
|
||
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_NORMAL);
|
||
gtk_style_context_add_class(gtk_widget_get_style_context(btn),
|
||
"bookmark-bar-btn");
|
||
gtk_widget_set_name(btn, "bookmark-bar-btn");
|
||
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_relief(GTK_BUTTON(mb), GTK_RELIEF_NORMAL);
|
||
gtk_style_context_add_class(gtk_widget_get_style_context(mb),
|
||
"bookmark-bar-btn");
|
||
gtk_widget_set_name(mb, "bookmark-bar-menu-btn");
|
||
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]);
|
||
}
|
||
}
|
||
}
|
||
|
||
/* Grab GTK focus on the webview when the user clicks anywhere in it.
|
||
* Also called from the notebook "switch-page" handler so the webview
|
||
* gets focus whenever its tab becomes active.
|
||
*
|
||
* Without this, the webview frequently doesn't have GTK focus (the URL
|
||
* entry grabs it on new-tab, toolbar buttons steal it, etc.), and
|
||
* WebKitGTK then drops mouse-wheel scroll events because the webview
|
||
* isn't the GTK focus widget — the scrollbar still works (native GDK
|
||
* scroll) but the wheel doesn't. This is especially visible on pages
|
||
* like Jumble that have focusable sidebar elements. */
|
||
static void webview_grab_focus(WebKitWebView *webview) {
|
||
if (webview == NULL) return;
|
||
GtkWidget *w = GTK_WIDGET(webview);
|
||
if (!gtk_widget_has_focus(w)) {
|
||
gtk_widget_grab_focus(w);
|
||
}
|
||
}
|
||
|
||
static gboolean on_webview_button_press(GtkWidget *widget,
|
||
GdkEventButton *event,
|
||
gpointer user_data) {
|
||
(void)event;
|
||
(void)user_data;
|
||
if (widget != NULL && !gtk_widget_has_focus(widget)) {
|
||
gtk_widget_grab_focus(widget);
|
||
}
|
||
return FALSE;
|
||
}
|
||
|
||
/* notebook "switch-page" handler: grab focus on the newly-selected tab's
|
||
* webview so wheel/scroll events work without the user having to click
|
||
* the page first. The page_num is the notebook page index; we map it to
|
||
* our tab array via the tab_info_t stored on the page widget. */
|
||
static void on_notebook_switch_page(GtkNotebook *notebook,
|
||
GtkWidget *page,
|
||
guint page_num,
|
||
gpointer user_data) {
|
||
(void)notebook;
|
||
(void)page_num;
|
||
(void)user_data;
|
||
if (page == NULL) return;
|
||
/* The page widget is the tab_info_t->page GtkBox. The webview is
|
||
* packed into it as a child. Find the webview by walking children. */
|
||
tab_info_t *tab = (tab_info_t *)g_object_get_data(G_OBJECT(page),
|
||
"tab-info");
|
||
if (tab != NULL && tab->webview != NULL) {
|
||
webview_grab_focus(tab->webview);
|
||
}
|
||
}
|
||
|
||
static tab_info_t *tab_create(const char *url) {
|
||
const browser_settings_t *s = settings_get();
|
||
|
||
tab_info_t *tab = g_new0(tab_info_t, 1);
|
||
if (tab == NULL) return NULL;
|
||
|
||
/* Determine the URL to load. */
|
||
const char *load_url = url;
|
||
char *default_url = NULL;
|
||
if (load_url == NULL || load_url[0] == '\0') {
|
||
load_url = s->new_tab_url;
|
||
}
|
||
default_url = normalize_url(load_url);
|
||
if (default_url == NULL) {
|
||
default_url = g_strdup(load_url);
|
||
}
|
||
|
||
snprintf(tab->current_url, sizeof(tab->current_url), "%s", default_url);
|
||
snprintf(tab->title, sizeof(tab->title), "Loading…");
|
||
|
||
/* Create the webview. Prefer a related view (set by
|
||
* tab_manager_new_window) so the new webview shares the parent's
|
||
* WebProcess and window features — this avoids the
|
||
* std::optional<WindowFeatures> assertion crash that occurs with
|
||
* webkit_web_view_new_with_context() for target="_blank" windows.
|
||
* Fall back to the shared context for normal new tabs. */
|
||
if (g_target_related_view != NULL &&
|
||
WEBKIT_IS_WEB_VIEW(g_target_related_view)) {
|
||
tab->webview = WEBKIT_WEB_VIEW(
|
||
webkit_web_view_new_with_related_view(g_target_related_view));
|
||
} else {
|
||
tab->webview = WEBKIT_WEB_VIEW(webkit_web_view_new_with_context(g_ctx));
|
||
}
|
||
|
||
/* Enable developer extras + security settings (same as original main.c). */
|
||
WebKitSettings *settings = webkit_web_view_get_settings(tab->webview);
|
||
webkit_settings_set_enable_developer_extras(settings, TRUE);
|
||
webkit_settings_set_enable_javascript(settings, TRUE);
|
||
webkit_settings_set_javascript_can_open_windows_automatically(settings, TRUE);
|
||
webkit_settings_set_allow_file_access_from_file_urls(settings, TRUE);
|
||
webkit_settings_set_allow_universal_access_from_file_urls(settings, TRUE);
|
||
webkit_settings_set_allow_modal_dialogs(settings, TRUE);
|
||
|
||
/* Enhance native application/json documents at document end. The
|
||
* embedded file is not NUL-terminated, so copy it before passing it to
|
||
* WebKitUserScript. */
|
||
if (s->json_viewer_enabled) {
|
||
const embedded_file_t *json_viewer =
|
||
get_embedded_file("json-viewer/json-viewer.js");
|
||
if (json_viewer != NULL) {
|
||
char *source = g_strndup((const char *)json_viewer->data,
|
||
json_viewer->size);
|
||
WebKitUserContentManager *manager =
|
||
webkit_web_view_get_user_content_manager(tab->webview);
|
||
WebKitUserScript *script = webkit_user_script_new(
|
||
source,
|
||
WEBKIT_USER_CONTENT_INJECT_ALL_FRAMES,
|
||
WEBKIT_USER_SCRIPT_INJECT_AT_DOCUMENT_END,
|
||
NULL,
|
||
NULL);
|
||
webkit_user_content_manager_add_script(manager, script);
|
||
webkit_user_script_unref(script);
|
||
g_free(source);
|
||
} else {
|
||
g_printerr("[json-viewer] Embedded script not found\n");
|
||
}
|
||
}
|
||
|
||
/* Inject window.nostr into this webview. */
|
||
nostr_inject_setup(tab->webview);
|
||
|
||
/* Connect the key-press handler to the webview so browser-level
|
||
* shortcuts (Ctrl+T, Ctrl+Shift+I, etc.) are caught even when the
|
||
* webview has focus. We connect AFTER so the webview still gets
|
||
* normal key events for text input, but our handler can intercept
|
||
* recognized shortcuts. */
|
||
g_signal_connect(G_OBJECT(tab->webview), "key-press-event",
|
||
G_CALLBACK(on_key_press), NULL);
|
||
|
||
/* Grab GTK focus on button-press so the webview receives wheel/scroll
|
||
* events. Without this, clicking a focusable element inside the page
|
||
* (e.g. a sidebar button) can leave GTK focus on a sibling widget (URL
|
||
* entry, toolbar button), and WebKitGTK then drops mouse-wheel events
|
||
* because the webview isn't the GTK focus widget — the scrollbar still
|
||
* works (it's a native GDK scroll on the widget) but the wheel doesn't.
|
||
* Grabbing focus on click fixes wheel scrolling on pages like Jumble
|
||
* that have focusable sidebar elements. We return FALSE so WebKit still
|
||
* receives the click normally. */
|
||
g_signal_connect(G_OBJECT(tab->webview), "button-press-event",
|
||
G_CALLBACK(on_webview_button_press), NULL);
|
||
|
||
/* Build the per-tab page: toolbar on top, webview below. */
|
||
tab->page = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0);
|
||
/* Store the tab_info_t pointer on the page widget so the notebook
|
||
* "switch-page" handler (on_notebook_switch_page) can find the webview
|
||
* to grab focus. Uses a weak ref (no destroy notify) — the tab_info_t
|
||
* is freed separately in tab_manager_close_tab. */
|
||
g_object_set_data(G_OBJECT(tab->page), "tab-info", tab);
|
||
|
||
GtkWidget *toolbar = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 4);
|
||
gtk_widget_set_name(toolbar, "main-toolbar");
|
||
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);
|
||
tab->toolbar = toolbar;
|
||
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);
|
||
|
||
/* Apply the active window's menu-bar visibility so a tab opened in a
|
||
* hidden-chrome window is also hidden. The tab strip itself is
|
||
* per-notebook (gtk_notebook_set_show_tabs), already set by the
|
||
* toggle; here we only sync the per-tab toolbar + bookmark bar. */
|
||
{
|
||
window_state_t *ws = get_active_window_state();
|
||
gboolean vis = ws ? ws->toolbars_visible : TRUE;
|
||
gtk_widget_set_visible(tab->toolbar, vis);
|
||
gtk_widget_set_visible(tab->bookmark_bar, vis);
|
||
}
|
||
|
||
/* 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.
|
||
*
|
||
* IMPORTANT: When this webview is being created in response to the
|
||
* "create" signal (target="_blank" / window.open()), g_target_related_view
|
||
* is set. In that case WebKit already has a pending NavigationAction with
|
||
* WindowFeatures that it will apply to the returned webview itself. If we
|
||
* call webkit_web_view_load_uri() here first, our load races with WebKit's
|
||
* own navigation setup and the std::optional<WindowFeatures> never gets
|
||
* engaged — leading to the assertion crash:
|
||
* _M_get() const: Assertion 'this->_M_is_engaged()' failed.
|
||
* So we skip the explicit load and let WebKit drive the navigation. The
|
||
* tab's URL bar / title were already initialized from default_url above. */
|
||
if (!g_target_skip_load) {
|
||
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;
|
||
/* Always open sovereign://profile in a new tab so the user's current
|
||
* page is preserved. */
|
||
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;"
|
||
"}"
|
||
/* ── Header chrome background: force white ─────────────────── */
|
||
"notebook, notebook header, notebook > header,"
|
||
"notebook > header > tabs, notebook > header > tabs > tab,"
|
||
"#main-toolbar, #bookmark-bar {"
|
||
" background: #ffffff;"
|
||
" color: #000000;"
|
||
"}"
|
||
"#main-toolbar entry {"
|
||
" background: #ffffff;"
|
||
" color: #000000;"
|
||
"}"
|
||
/* ── Red accent for the URL entry focus ring ──────────────── */
|
||
"entry:focus {"
|
||
" border-color: #ff0000;"
|
||
" box-shadow: 0 0 0 1px #ff0000;"
|
||
"}"
|
||
/* ── 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;"
|
||
" outline: none;"
|
||
" border-bottom: 3px solid %s;"
|
||
"}"
|
||
/* Tab text: normal (inherit from theme), no red. */
|
||
/* ── Bookmark bar buttons — explicit WEB.md interaction model ─
|
||
* Rest: white background + black 1px border + black text.
|
||
* Hover: border turns red (background stays white).
|
||
* Active/pressed: background turns red + white text.
|
||
* Use !important at USER priority to beat Adwaita states. */
|
||
"#bookmark-bar-btn, #bookmark-bar-menu-btn,"
|
||
"#bookmark-bar-menu-btn button,"
|
||
"#bookmark-bar .bookmark-bar-btn,"
|
||
"#bookmark-bar .bookmark-bar-btn:backdrop,"
|
||
"#bookmark-bar .bookmark-bar-btn button,"
|
||
"#bookmark-bar .bookmark-bar-btn button:backdrop {"
|
||
" padding: 2px 8px;"
|
||
" background: #ffffff;"
|
||
" color: #000000;"
|
||
" border: 1px solid #000000;"
|
||
" border-radius: 4px;"
|
||
" box-shadow: none;"
|
||
"}"
|
||
"#bookmark-bar-btn:hover, #bookmark-bar-menu-btn:hover,"
|
||
"#bookmark-bar-menu-btn button:hover,"
|
||
"#bookmark-bar .bookmark-bar-btn:hover,"
|
||
"#bookmark-bar .bookmark-bar-btn button:hover {"
|
||
" border-color: #ff0000;"
|
||
"}"
|
||
"#bookmark-bar-btn:active, #bookmark-bar-btn:checked,"
|
||
"#bookmark-bar-menu-btn:active, #bookmark-bar-menu-btn:checked,"
|
||
"#bookmark-bar-menu-btn button:active,"
|
||
"#bookmark-bar-menu-btn button:checked,"
|
||
"#bookmark-bar .bookmark-bar-btn:active,"
|
||
"#bookmark-bar .bookmark-bar-btn:checked,"
|
||
"#bookmark-bar .bookmark-bar-btn button:active,"
|
||
"#bookmark-bar .bookmark-bar-btn button:checked {"
|
||
" background: #ff0000;"
|
||
" color: #ffffff;"
|
||
" border-color: #ff0000;"
|
||
"}",
|
||
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_set_context(WebKitWebContext *ctx) {
|
||
/* The caller must have closed all tabs and sidebar webviews that
|
||
* reference the old context before calling this, otherwise those
|
||
* webviews will hold dangling context references. */
|
||
g_ctx = ctx;
|
||
g_print("[tab-manager] Context updated (%p)\n", (void *)g_ctx);
|
||
}
|
||
|
||
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;
|
||
g_main_window.toolbars_visible = TRUE;
|
||
|
||
/* 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);
|
||
/* Grab focus on the webview when a tab is switched to, so wheel/scroll
|
||
* events work without the user having to click the page first. */
|
||
g_signal_connect(g_notebook, "switch-page",
|
||
G_CALLBACK(on_notebook_switch_page), NULL);
|
||
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.
|
||
* Gated on the perf_probe_enabled setting (see comment above). */
|
||
if (settings_get()->perf_probe_enabled) {
|
||
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 (page %d), total: %d\n",
|
||
index, page_num, g_tab_count);
|
||
|
||
/* Return the g_tabs array index (NOT the notebook page_num).
|
||
*
|
||
* The notebook page number only matches the g_tabs index for the
|
||
* main window; auxiliary windows have their own page numbering, and
|
||
* even in the main window the two can diverge after tabs are closed
|
||
* and reopened. The only caller that uses the return value
|
||
* (on_create_webview, for the WebKit "create" signal) passes it to
|
||
* tab_manager_get(), which expects a g_tabs index. Returning
|
||
* page_num there caused tab_manager_get() to resolve the WRONG tab
|
||
* — an already-existing webview with no pending WindowFeatures —
|
||
* which WebKit then tried to apply WindowFeatures to, triggering:
|
||
* std::optional<WindowFeatures>::_M_get(): Assertion
|
||
* 'this->_M_is_engaged()' failed. */
|
||
return index;
|
||
}
|
||
|
||
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 — unless a
|
||
* caller has suppressed the quit (e.g. identity_teardown_web_state
|
||
* closes all tabs to wipe the previous user's live webviews but
|
||
* wants the browser to keep running for the next user). */
|
||
if (g_tab_count == 0) {
|
||
if (g_suppress_quit_on_last_tab) {
|
||
g_print("[tabs] Last tab closed, but quit suppressed (identity switch in progress).\n");
|
||
} else {
|
||
g_print("[tabs] Last tab closed, exiting.\n");
|
||
if (g_window) {
|
||
gtk_window_close(g_window);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
void tab_manager_set_suppress_quit_on_last_tab(gboolean suppress) {
|
||
g_suppress_quit_on_last_tab = suppress;
|
||
}
|
||
|
||
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);
|
||
}
|
||
}
|
||
|
||
/* Destroy the sidebar webview in a single window_state_t.
|
||
* The sidebar container widget itself is kept; the webview will be
|
||
* recreated lazily on the next tab_manager_toggle_sidebar() call. */
|
||
static void window_state_destroy_sidebar(window_state_t *ws) {
|
||
if (ws == NULL) return;
|
||
if (ws->sidebar_webview == NULL) {
|
||
/* Still mark it hidden so the next toggle re-creates it. */
|
||
ws->sidebar_visible = FALSE;
|
||
return;
|
||
}
|
||
|
||
/* Destroy the webview widget. gtk_widget_destroy() drops the
|
||
* container's reference and unrefs the WebKitWebView. */
|
||
GtkWidget *wv = GTK_WIDGET(ws->sidebar_webview);
|
||
/* Detach from the sidebar container so the container is reusable. */
|
||
GtkWidget *parent = gtk_widget_get_parent(wv);
|
||
if (parent != NULL) {
|
||
gtk_container_remove(GTK_CONTAINER(parent), wv);
|
||
} else {
|
||
gtk_widget_destroy(wv);
|
||
}
|
||
|
||
ws->sidebar_webview = NULL;
|
||
ws->sidebar_visible = FALSE;
|
||
|
||
/* Hide the sidebar container so it doesn't show empty space. */
|
||
if (ws->sidebar_container != NULL) {
|
||
gtk_widget_hide(ws->sidebar_container);
|
||
}
|
||
if (ws->paned != NULL) {
|
||
gtk_paned_set_position(GTK_PANED(ws->paned), 0);
|
||
}
|
||
}
|
||
|
||
void tab_manager_destroy_sidebar_webviews(void) {
|
||
window_state_destroy_sidebar(&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);
|
||
window_state_destroy_sidebar(ws);
|
||
}
|
||
}
|
||
/* If the active window's sidebar was showing, the active_ws pointer
|
||
* is still valid (we only nulled the webview, not the struct). */
|
||
}
|
||
|
||
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;
|
||
}
|
||
}
|
||
|
||
void tab_manager_toggle_toolbars(void) {
|
||
window_state_t *ws = get_active_window_state();
|
||
if (ws == NULL || ws->notebook == NULL) return;
|
||
|
||
ws->toolbars_visible = !ws->toolbars_visible;
|
||
gboolean vis = ws->toolbars_visible;
|
||
|
||
/* Tab strip (GtkNotebook header) — per-notebook. */
|
||
gtk_notebook_set_show_tabs(GTK_NOTEBOOK(ws->notebook), vis);
|
||
|
||
/* Per-tab toolbar + bookmark bar, but only for tabs that belong to
|
||
* this window's notebook (aux windows have their own notebook). */
|
||
for (int i = 0; i < g_tab_count; i++) {
|
||
tab_info_t *tab = g_tabs[i];
|
||
if (tab == NULL || tab->page == NULL) continue;
|
||
GtkWidget *nb = tab_find_notebook(tab->page);
|
||
if (nb != ws->notebook) continue;
|
||
if (tab->toolbar) gtk_widget_set_visible(tab->toolbar, vis);
|
||
if (tab->bookmark_bar) gtk_widget_set_visible(tab->bookmark_bar, vis);
|
||
}
|
||
}
|
||
|
||
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);
|
||
}
|
||
}
|