Files
sovereign_browser/src/main.c
T

1248 lines
49 KiB
C

/*
* sovereign_browser — WebKitGTK + C99
*
* Multi-tab browser: a GTK window with a GtkNotebook tab strip. Each tab
* has its own toolbar (hamburger menu + URL entry) and WebKitWebView. All
* webviews share a single WebKitWebContext so the sovereign:// Nostr bridge,
* security settings, and TLS policy apply to every tab.
*
* On startup, a Nostr login dialog is shown. The user signs in with a local
* key (nsec), seed phrase, read-only npub, NIP-46 remote signer, or n_signer
* hardware. The resulting nostr_signer_t is held globally and backs the
* window.nostr injection in every tab.
*
* Features:
* - Tab creation (Ctrl+T, + button), closing (Ctrl+W, close button,
* middle-click)
* - Tab switching (Ctrl+Tab, Ctrl+Shift+Tab, Ctrl+PageUp/Down)
* - Right-click tab context menu (New, Close, Close Others, Close to
* Right, Duplicate, Reload)
* - Tab drag reordering
* - Session save/restore (configurable in Settings)
* - Settings page (sovereign://settings internal webpage: tabs, agent
* server, security — persisted to disk)
*
* Build: make. Run: ./sovereign_browser [url]
*/
#include <gtk/gtk.h>
#include <webkit2/webkit2.h>
#include <string.h>
#include <stdlib.h>
#include <signal.h>
#include "version.h"
#include "key_store.h"
#include "login_dialog.h"
#include "nostr_bridge.h"
#include "nostr_scheme.h"
#include "tor_scheme.h"
#include "nostr_inject.h"
#include "history.h"
#include "settings.h"
#include "shortcuts.h"
#include "settings_sync.h"
#include "tab_manager.h"
#include "session.h"
#include "agent_server.h"
#include "agent_login.h"
#include "cli.h"
#include "db.h"
#include "profile.h"
#include "relay_fetch.h"
#include "bookmarks.h"
#include "agent_conversations.h"
#include "agent_skills.h"
#include "net_services.h"
#include "webkit_data.h"
#include "web_context.h"
#include "nostr_core/nostr_core.h"
/* ---- Global state --------------------------------------------------- *
* The signer is created at login and held for the lifetime of the session.
* It backs the window.nostr injection in every tab via the sovereign://
* URI scheme bridge.
*/
typedef struct {
nostr_signer_t *signer; /* NULL for read-only mode */
char pubkey_hex[65];
char privkey_hex[65]; /* in-memory only; for HMAC d-tag derivation */
key_store_method_t method;
gboolean readonly; /* TRUE if no signing available */
} app_state_t;
static app_state_t g_state = {0};
/* Forward declarations — defined later in this file. */
static int switch_to_user_db(const char *pubkey_hex);
static int do_login(GtkWindow *parent);
static WebKitWebContext *build_context_for_current_user(void);
static char g_current_profile_db[512];
static GtkWindow *g_window = NULL;
static gboolean g_logged_in = FALSE;
static gboolean g_is_fullscreen = FALSE; /* track fullscreen state (GTK3 has no getter */
/* TRUE once main() has finished initial startup (tab_manager_init has
* run). Used by app_set_signer() to distinguish a first-time login
* (context built later by main()) from a runtime identity switch
* (context was torn down by identity_teardown_web_state and must be
* rebuilt here). */
static gboolean g_post_startup = FALSE;
/* ---- App state accessors (used by agent_login.c) ─────────────────── */
void app_set_signer(nostr_signer_t *signer, const char *pubkey_hex,
const char *privkey_hex,
key_store_method_t method, gboolean readonly) {
g_state.signer = signer;
g_state.method = method;
g_state.readonly = readonly;
if (pubkey_hex) {
strncpy(g_state.pubkey_hex, pubkey_hex, 64);
g_state.pubkey_hex[64] = '\0';
}
if (privkey_hex && privkey_hex[0]) {
strncpy(g_state.privkey_hex, privkey_hex, 64);
g_state.privkey_hex[64] = '\0';
} else {
g_state.privkey_hex[0] = '\0';
}
g_logged_in = TRUE;
/* Update modules that hold a signer reference. */
settings_sync_set_signer(signer,
g_state.pubkey_hex[0] ? g_state.pubkey_hex : NULL);
agent_conversations_set_signer(signer,
g_state.pubkey_hex[0] ? g_state.pubkey_hex : NULL);
/* If this is the first login (we're still on global.db), switch to
* the per-user profile database. If we're already on a per-user db
* (e.g. switching identity at runtime), switch_to_user_db() will
* close it and open the new user's db. */
if (g_state.pubkey_hex[0] != '\0') {
switch_to_user_db(g_state.pubkey_hex);
}
/* If this is a RUNTIME identity switch (past startup) and the
* previous context was torn down by identity_teardown_web_state(),
* rebuild a fresh per-user context for the new identity now. On the
* first login (before main() calls tab_manager_init), g_post_startup
* is FALSE and web_context_get() is NULL, so main() builds the
* context after login — we skip here to avoid a double-build. */
if (g_post_startup && web_context_get() == NULL) {
g_print("[identity] Rebuilding web context for new identity (runtime switch)\n");
if (build_context_for_current_user() != NULL) {
/* Open a fresh tab for the new user so they don't stare at
* an empty window after the teardown closed all tabs. */
tab_manager_new_tab(settings_get()->new_tab_url);
} else {
g_printerr("[identity] Failed to rebuild web context\n");
}
}
}
void app_clear_signer(void) {
if (g_state.signer) {
nostr_signer_free(g_state.signer);
g_state.signer = NULL;
}
g_state.pubkey_hex[0] = '\0';
g_state.privkey_hex[0] = '\0';
g_state.method = KEY_STORE_METHOD_NONE;
g_state.readonly = FALSE;
g_logged_in = FALSE;
settings_sync_set_signer(NULL, NULL);
agent_conversations_set_signer(NULL, NULL);
}
nostr_signer_t *app_get_signer(void) { return g_state.signer; }
const char *app_get_pubkey_hex(void) { return g_state.pubkey_hex; }
key_store_method_t app_get_method(void) { return g_state.method; }
gboolean app_get_readonly(void) { return g_state.readonly; }
/* ---- Menu proxy functions ------------------------------------------- *
* These wrap the app_state_t-aware callbacks so they match GTK signal
* handler signatures and can be called from tab_manager.c's hamburger
* menu builder.
*/
/* ── Web state teardown (Phase 0 of plans/webkit-data-isolation.md) ── *
* Called on logout and identity switch. Closes all tabs, destroys the
* agent chat sidebar webviews (which hold a long-lived WebKitWebView
* with the previous user's sovereign://agents/chat session), and wipes
* all WebKit website data (cookies, cache, localStorage, IndexedDB,
* service workers, favicons) from the shared WebKitWebsiteDataManager.
*
* Without this, the next user inherits the previous user's web session:
* cookies identify you to web pages, localStorage holds per-site state,
* and the live tabs keep the previous user's DOM in memory. See
* plans/webkit-data-isolation.md for the full rationale.
*
* This must run on the GTK main thread. It pumps a nested main loop
* briefly while webkit_website_data_manager_clear() completes.
*/
void identity_teardown_web_state(void) {
/* 1. Close all tabs. This destroys the webviews and drops their
* in-memory DOM/localStorage. Must happen before the context
* teardown so no webviews hold dangling references to the old
* context. Suppress the normal "last tab closed → quit the app"
* behavior so the browser stays alive for the next user; the
* caller opens a fresh tab after the switch. */
tab_manager_set_suppress_quit_on_last_tab(TRUE);
tab_manager_close_all();
tab_manager_set_suppress_quit_on_last_tab(FALSE);
/* 2. Destroy the agent chat sidebar webviews in every window. The
* sidebar webview is outside the notebook so tab_manager_close_all
* does not touch it; it would otherwise keep a reference to the
* old context (and the previous user's chat session) alive. */
tab_manager_destroy_sidebar_webviews();
/* 3. Tear down the per-user WebKitWebContext. This unrefs the
* context and its per-user WebKitWebsiteDataManager. With Phase A
* isolation, each user has their own data manager rooted at
* profiles/<pubkey>/webkit/, so tearing down the context is what
* actually isolates the users — the next build creates a fresh
* data manager for the new user. The Phase 0 webkit_data_clear_all
* wipe is no longer needed for isolation (the data manager is
* per-user), but we keep it as a defense-in-depth safety net in
* case any process-global WebKit state survived the context
* teardown. */
WebKitWebContext *ctx = web_context_get();
if (ctx != NULL) {
/* Defense-in-depth: clear the data manager before tearing down
* the context. This catches any process-global caches that
* outlive the context. */
int rc = webkit_data_clear_all(ctx, FALSE);
if (rc != 0) {
g_printerr("[identity] webkit_data_clear_all returned %d "
"(continuing with context teardown)\n", rc);
}
web_context_teardown();
}
}
void app_menu_switch_identity_proxy(GtkMenuItem *item, gpointer data) {
(void)item;
GtkWindow *window = GTK_WINDOW(data);
if (window == NULL) return;
login_result_t result;
if (login_dialog_run(window, &result) == 0) {
/* Tear down the previous user's web state BEFORE installing the
* new signer, so the new user starts with a clean web session. */
identity_teardown_web_state();
if (g_state.signer) {
nostr_signer_free(g_state.signer);
}
g_state.signer = result.signer;
g_state.method = result.method;
strncpy(g_state.pubkey_hex, result.pubkey_hex, 64);
g_state.pubkey_hex[64] = '\0';
g_state.readonly = (result.method == KEY_STORE_METHOD_READONLY);
nostr_bridge_set_signer(g_state.signer, g_state.pubkey_hex,
g_state.readonly);
/* Switch to the new user's per-user profile database. This
* closes the current browser.db and opens the new user's
* browser.db, then loads their per-user settings. */
if (g_state.pubkey_hex[0] != '\0') {
switch_to_user_db(g_state.pubkey_hex);
}
/* Build a fresh per-user WebKitWebContext for the new identity
* (with its own per-user data manager) and re-register the
* sovereign://, nostr://, tor:// schemes on it. The old context
* was torn down by identity_teardown_web_state() above. */
if (build_context_for_current_user() == NULL) {
g_printerr("[identity] Failed to build context for new user — "
"browser will have no web views\n");
}
/* Open a fresh tab for the new user so they don't stare at an
* empty window after the teardown closed all the previous user's
* tabs. Uses the new user's per-user new_tab_url setting. */
const char *url = settings_get()->new_tab_url;
tab_manager_new_tab(url);
g_print("[identity] switched: method=%d pubkey=%s\n",
g_state.method, g_state.pubkey_hex);
}
}
void app_menu_lock_session_proxy(GtkMenuItem *item, gpointer data) {
(void)item;
(void)data;
if (g_state.signer) {
nostr_signer_free(g_state.signer);
g_state.signer = NULL;
}
g_state.readonly = TRUE;
nostr_bridge_set_signer(NULL, g_state.pubkey_hex, TRUE);
g_print("[identity] session locked (signer cleared, identity preserved)\n");
}
void app_menu_logout_proxy(GtkMenuItem *item, gpointer data) {
(void)item;
GtkWindow *window = GTK_WINDOW(data);
/* Tear down the current user's web state (closes all tabs, destroys
* the sidebar webviews, wipes cookies/cache/localStorage/service
* workers/favicons) BEFORE clearing the signer, so the next login
* starts with a clean web session. See plans/webkit-data-isolation.md. */
identity_teardown_web_state();
if (g_state.signer) {
nostr_signer_free(g_state.signer);
g_state.signer = NULL;
}
g_state.pubkey_hex[0] = '\0';
g_state.privkey_hex[0] = '\0';
g_state.method = KEY_STORE_METHOD_NONE;
g_state.readonly = FALSE;
g_logged_in = FALSE;
settings_sync_set_signer(NULL, NULL);
agent_conversations_set_signer(NULL, NULL);
/* Reset the per-user db tracking so the next login re-opens the
* new user's browser.db rather than assuming we're already on it. */
g_current_profile_db[0] = '\0';
g_print("[identity] logged out\n");
/* Re-show the login dialog so the user (or agent) can log in as a
* different identity. The dialog runs a nested main loop, so the
* agent server stays live and an agent can log in via MCP while the
* dialog is showing. On success, do_login() installs the new signer
* and switch_to_user_db() opens the new user's profile. */
if (window != NULL) {
if (do_login(window) == 0) {
/* Build a fresh per-user WebKitWebContext for the new
* identity. The old context was torn down above. For
* --no-login mode this builds an ephemeral context. */
if (build_context_for_current_user() != NULL) {
/* Open a fresh tab for the new user. */
tab_manager_new_tab(settings_get()->new_tab_url);
} else {
g_printerr("[identity] Failed to build context after re-login\n");
}
}
}
}
void app_menu_security_strip_proxy(GtkCheckMenuItem *item, gpointer data) {
(void)data;
gboolean active = gtk_check_menu_item_get_active(item);
g_print("[menu] security strip: %s\n", active ? "ON" : "OFF");
}
void app_menu_network_service_proxy(GtkCheckMenuItem *item, gpointer data) {
if (g_object_get_data(G_OBJECT(item), "network-toggle-sync") != NULL) return;
net_service_type_t type = (net_service_type_t)GPOINTER_TO_INT(data);
if (type != NET_SERVICE_TOR && type != NET_SERVICE_FIPS) {
g_printerr("[menu.network] Invalid network service type %d\n", (int)type);
return;
}
const char *name = type == NET_SERVICE_TOR ? "Tor" : "FIPS";
gboolean enabled = gtk_check_menu_item_get_active(item);
browser_settings_t *settings = settings_get_mutable();
gboolean *setting = type == NET_SERVICE_TOR
? &settings->tor_enabled : &settings->fips_enabled;
*setting = enabled;
settings_save_user();
int rc = enabled ? net_service_enable(type) : net_service_disable(type);
if (enabled && rc != 0) {
const net_service_t *status = net_service_get_status(type);
g_printerr("[menu.network] Failed to enable %s service: %s; reverting preference\n",
name, status && status->error_msg
? status->error_msg : "synchronous startup failure");
*setting = FALSE;
settings_save_user();
g_object_set_data(G_OBJECT(item), "network-toggle-sync",
GINT_TO_POINTER(1));
gtk_check_menu_item_set_active(item, FALSE);
g_object_set_data(G_OBJECT(item), "network-toggle-sync", NULL);
return;
}
if (rc != 0) {
g_printerr("[menu.network] Failed to disable %s service (preference remains disabled)\n",
name);
} else {
g_print("[menu.network] %s service %s; per-user preference saved\n",
name, enabled ? "enabled" : "disabled");
}
}
void app_menu_nostr_sign_proxy(GtkMenuItem *item, gpointer data) {
(void)item;
(void)data;
if (g_state.signer) {
g_print("[menu] Nostr signing: signer active, pubkey=%s\n",
g_state.pubkey_hex);
} else if (g_state.readonly) {
g_print("[menu] Nostr signing: read-only mode (no signer)\n");
} else {
g_print("[menu] Nostr signing: no signer loaded\n");
}
}
void app_menu_about_proxy(GtkMenuItem *item, gpointer data) {
(void)item;
GtkWidget *window = GTK_WIDGET(data);
if (window == NULL) return;
GtkWidget *dialog = gtk_message_dialog_new(
GTK_WINDOW(window),
GTK_DIALOG_DESTROY_WITH_PARENT,
GTK_MESSAGE_INFO,
GTK_BUTTONS_OK,
"sovereign browser %s\n"
"WebKitGTK + C99 — sovereign identity, not permissioned domains.",
SB_VERSION);
g_signal_connect(dialog, "response", G_CALLBACK(gtk_widget_destroy), NULL);
gtk_widget_show_all(dialog);
}
/* ---- Settings page (internal webpage) ------------------------------- *
* The hamburger-menu "Settings…" item navigates the active tab to the
* sovereign://settings internal page, which renders all preferences
* (tabs, agent server, security) and persists changes via the
* sovereign:// URI scheme bridge in nostr_bridge.c.
*/
/* Hamburger-menu items for sovereign:// internal pages always open in
* a new tab so the user's current page is preserved. */
void on_menu_settings(GtkMenuItem *item, gpointer data) {
(void)item;
(void)data;
tab_manager_new_tab("sovereign://settings");
}
void on_menu_profile(GtkMenuItem *item, gpointer data) {
(void)item;
(void)data;
tab_manager_new_tab("sovereign://profile");
}
/* The hamburger-menu "Agent Setup…" item opens the sovereign://agents
* internal page in a new tab. It renders the agent provider
* configuration UI and a link to open the agent chat. */
void on_menu_agent(GtkMenuItem *item, gpointer data) {
(void)item;
(void)data;
tab_manager_new_tab("sovereign://agents");
}
/* The hamburger-menu "FIPS Mesh…" item opens the sovereign://fips
* internal page in a new tab. It renders the FIPS mesh network
* status + management UI. */
void on_menu_fips(GtkMenuItem *item, gpointer data) {
(void)item;
(void)data;
tab_manager_new_tab("sovereign://fips");
}
/* The hamburger-menu "Processes…" item opens the sovereign://processes
* internal page in a new tab. It renders the process list + per-tab
* performance diagnostics UI. */
void on_menu_processes(GtkMenuItem *item, gpointer data) {
(void)item;
(void)data;
tab_manager_new_tab("sovereign://processes");
}
/* ---- Keyboard shortcuts --------------------------------------------- *
* All browser-level shortcuts are configurable via the shortcuts module.
* The on_key_press handler looks up the action for the pressed key
* combination and dispatches it. Bindings are set on the
* sovereign://settings page (key-capture UI) and persisted to SQLite.
*/
/* Made non-static so tab_manager.c can connect it to each webview.
*
* This handler is connected to two places:
* 1. Each webview's "key-press-event" (in tab_manager.c tab_create())
* 2. The main window's "key-press-event" (in main.c setup)
*
* For the webview connection, we bypass shortcut interception when the
* active page is a sovereign:// internal page — this lets the page's JS
* capture arbitrary key combos (e.g. the settings page's shortcut capture
* UI needs to see Ctrl+key events that would otherwise be intercepted).
*
* For the window connection, we always process shortcuts so that
* browser-level shortcuts (Ctrl+T, Ctrl+N, etc.) work even on
* sovereign:// pages. The window handler is a fallback that fires after
* the webview handler (event propagation: child → parent → window). */
gboolean on_key_press(GtkWidget *widget, GdkEventKey *event,
gpointer data) {
(void)data;
/* Check if this handler is connected to a webview (not the window).
* WEBKIT_IS_WEB_VIEW is true for webview connections, false for the
* window connection. */
gboolean from_webview = WEBKIT_IS_WEB_VIEW(widget);
/* When the active tab is showing a sovereign:// internal page (e.g.
* the settings page's keyboard-shortcut capture), bypass shortcut
* interception on the webview connection only — let the event pass
* through to the web page's JS so it can capture arbitrary key combos.
* The window-level connection still processes shortcuts so Ctrl+T,
* Ctrl+N, etc. work on internal pages. */
if (from_webview) {
tab_info_t *active = tab_manager_get_active();
if (active && active->current_url[0] != '\0' &&
strncmp(active->current_url, "sovereign://", 12) == 0) {
return FALSE;
}
}
int action = shortcuts_lookup(event);
if (action < 0) return FALSE;
switch ((shortcut_action_t)action) {
case SHORTCUT_NEW_TAB:
tab_manager_new_tab(NULL);
return TRUE;
case SHORTCUT_NEW_WINDOW:
tab_manager_new_window_blank();
return TRUE;
case SHORTCUT_CLOSE_TAB:
tab_manager_close_active();
return TRUE;
case SHORTCUT_FOCUS_URL: {
tab_info_t *tab = tab_manager_get_active();
if (tab && tab->url_entry) {
gtk_widget_grab_focus(tab->url_entry);
gtk_editable_select_region(GTK_EDITABLE(tab->url_entry), 0, -1);
}
return TRUE;
}
case SHORTCUT_NEXT_TAB:
tab_manager_next();
return TRUE;
case SHORTCUT_PREV_TAB:
tab_manager_prev();
return TRUE;
case SHORTCUT_NEXT_TAB_PAGEDOWN:
tab_manager_next();
return TRUE;
case SHORTCUT_PREV_TAB_PAGEUP:
tab_manager_prev();
return TRUE;
case SHORTCUT_RELOAD: {
tab_info_t *tab = tab_manager_get_active();
if (tab && tab->webview)
webkit_web_view_reload(tab->webview);
return TRUE;
}
case SHORTCUT_FORCE_RELOAD: {
tab_info_t *tab = tab_manager_get_active();
if (tab && tab->webview)
webkit_web_view_reload_bypass_cache(tab->webview);
return TRUE;
}
case SHORTCUT_GO_BACK: {
tab_info_t *tab = tab_manager_get_active();
if (tab && tab->webview && webkit_web_view_can_go_back(tab->webview))
webkit_web_view_go_back(tab->webview);
return TRUE;
}
case SHORTCUT_GO_FORWARD: {
tab_info_t *tab = tab_manager_get_active();
if (tab && tab->webview && webkit_web_view_can_go_forward(tab->webview))
webkit_web_view_go_forward(tab->webview);
return TRUE;
}
case SHORTCUT_FIND:
/* Phase 2: wire WebKitFindController + find bar.
* For now, log so the user knows the binding fired. */
g_print("[shortcut] Find in page (not yet implemented)\n");
return TRUE;
case SHORTCUT_OPEN_SETTINGS:
on_menu_settings(NULL, NULL);
return TRUE;
case SHORTCUT_OPEN_PROCESSES:
on_menu_processes(NULL, NULL);
return TRUE;
case SHORTCUT_NEW_IDENTITY:
app_menu_switch_identity_proxy(NULL, g_window);
return TRUE;
case SHORTCUT_TOGGLE_FULLSCREEN: {
if (g_is_fullscreen) {
gtk_window_unfullscreen(g_window);
g_is_fullscreen = FALSE;
} else {
gtk_window_fullscreen(g_window);
g_is_fullscreen = TRUE;
}
return TRUE;
}
case SHORTCUT_TOGGLE_INSPECTOR:
tab_manager_toggle_inspector();
return TRUE;
case SHORTCUT_TOGGLE_SIDEBAR:
tab_manager_toggle_sidebar();
return TRUE;
case SHORTCUT_ZOOM_IN:
tab_manager_zoom_in();
return TRUE;
case SHORTCUT_ZOOM_OUT:
tab_manager_zoom_out();
return TRUE;
case SHORTCUT_ZOOM_RESET:
tab_manager_zoom_reset();
return TRUE;
default:
return FALSE;
}
}
/* ---- Agent login callback ------------------------------------------ *
* Called by agent_server when an agent successfully logs in via the
* 'login' tool. The agent can log in at any time — while the GTK login
* dialog is showing, or after the browser is already running. We just
* set the flag; the do_login function checks it after the dialog
* returns and skips the dialog's result if the agent already logged in.
*/
static void agent_login_callback(void) {
g_logged_in = TRUE;
g_print("[login] Agent login detected.\n");
}
/* ---- Window close / destroy ----------------------------------------- *
* The main window uses a delete-event handler to intercept the window
* manager's close request BEFORE the window is destroyed. This lets us
* close the main window's tabs cleanly (updating g_tab_count) and keep
* the app running if auxiliary windows still have tabs. The app only
* quits when the last window is closed (no tabs remain anywhere).
*
* delete-event: runs first. Closes all main-window tabs. If aux windows
* still have tabs, returns TRUE to suppress destroy and hides the
* main window. If no tabs remain, returns FALSE to let destroy
* proceed → on_window_destroy → gtk_main_quit().
* destroy: runs only when the app is truly shutting down. Performs
* session save, net_services_shutdown, agent_server_stop, etc.
*/
/* Guard flag: set while on_window_delete_event is closing the main
* window's tabs. Prevents re-entrancy when tab_manager_close_tab()
* calls gtk_window_close(g_window) after the last tab is closed, which
* would re-emit delete-event. */
static gboolean g_main_window_closing = FALSE;
static gboolean on_window_delete_event(GtkWidget *widget,
GdkEvent *event,
gpointer data) {
(void)event;
(void)data;
/* If we're already in the process of closing (re-entrant call from
* tab_manager_close_tab → gtk_window_close), let the destroy
* proceed. */
if (g_main_window_closing) {
return FALSE;
}
g_main_window_closing = TRUE;
/* Close all tabs that belong to the main window's notebook. We
* identify main-window tabs by checking gtk_notebook_page_num on
* the main notebook. tab_manager_close_tab handles removing from
* the notebook and the g_tabs array. Re-scan each iteration because
* closing shifts indices. */
for (;;) {
gboolean found = FALSE;
GtkWidget *main_nb = tab_manager_get_main_notebook();
if (main_nb == NULL) break;
/* Close from the highest index down so removal doesn't shift
* unprocessed indices. Find the highest-index tab in the main
* notebook and close it. */
for (int i = tab_manager_count() - 1; i >= 0; i--) {
tab_info_t *tab = tab_manager_get(i);
if (tab == NULL || tab->page == NULL) continue;
if (gtk_notebook_page_num(GTK_NOTEBOOK(main_nb),
tab->page) >= 0) {
tab_manager_close_tab(i);
found = TRUE;
break;
}
}
if (!found) break;
}
/* If auxiliary windows still have tabs, keep the app running.
* Suppress the default destroy by returning TRUE, and hide the
* main window. The aux windows continue independently. */
if (tab_manager_count() > 0) {
g_print("[windows] Main window closed but %d tab(s) remain in "
"other window(s) — keeping app alive.\n",
tab_manager_count());
gtk_widget_hide(widget);
g_main_window_closing = FALSE; /* allow re-opening later */
return TRUE; /* suppress destroy */
}
/* No tabs left anywhere — let the destroy proceed, which triggers
* on_window_destroy and quits the app. Keep the guard set so any
* re-entrant delete-event from the destroy path doesn't re-enter. */
return FALSE;
}
static void on_window_destroy(GtkWidget *widget, gpointer data) {
(void)widget;
(void)data;
const browser_settings_t *s = settings_get();
if (s->restore_session) {
/* Save the session for next launch. */
session_save();
} else {
/* Privacy mode: clear session and history on shutdown. */
db_session_clear();
history_clear();
g_print("[shutdown] Cleared session and history (restore_session=off)\n");
}
/* Stop browser-managed Tor/FIPS before tearing down GTK/WebKit. */
net_services_shutdown();
/* Stop the agent server. */
agent_server_stop();
if (g_state.signer) {
nostr_signer_free(g_state.signer);
g_state.signer = NULL;
}
nostr_cleanup();
bookmarks_cleanup();
db_close();
gtk_main_quit();
}
/* ---- Login flow (GTK dialog) ─────────────────────────────────────── *
* Shows the GTK login dialog. The agent server is already running at
* this point, so an agent can call 'login' while the dialog is showing
* (gtk_dialog_run runs a nested main loop that processes WebSocket
* events). After the dialog returns, we check if the agent already
* logged in — if so, we discard the dialog's result.
*/
static int do_login(GtkWindow *parent) {
/* nostr_init() is already called before this function. */
login_result_t result;
if (login_dialog_run(parent, &result) != 0) {
/* Dialog was cancelled. But if the agent logged in while the
* dialog was showing, proceed with the agent's login. */
if (g_logged_in) {
return 0;
}
return -1;
}
/* If the agent already logged in while the dialog was showing,
* discard the dialog's result and use the agent's login. */
if (g_logged_in) {
g_print("[login] Agent login took priority over dialog.\n");
if (result.signer) {
nostr_signer_free(result.signer);
}
return 0;
}
g_state.signer = result.signer;
g_state.method = result.method;
strncpy(g_state.pubkey_hex, result.pubkey_hex, 64);
g_state.pubkey_hex[64] = '\0';
/* Carry the privkey (in-memory only) for HMAC d-tag derivation in the
* bookmarks module. Empty for readonly / nsigner / nip46 methods. */
strncpy(g_state.privkey_hex, result.identity.privkey_hex, 64);
g_state.privkey_hex[64] = '\0';
g_state.readonly = (result.method == KEY_STORE_METHOD_READONLY ||
result.method == KEY_STORE_METHOD_NONE);
g_logged_in = TRUE;
if (result.method == KEY_STORE_METHOD_NONE) {
g_print("[login] No-login mode (browsing without Nostr identity)\n");
} else {
g_print("[login] New identity: method=%d pubkey=%s\n",
g_state.method, g_state.pubkey_hex);
}
return 0;
}
/* ---- Per-user database switch --------------------------------------- *
* After login, switch from the global database to the per-user profile
* database. Creates the profile directory if needed, closes global.db,
* opens the per-user browser.db, and loads per-user settings.
*
* This function is called from:
* - app_set_signer() — when an agent logs in via MCP or CLI
* - app_menu_switch_identity_proxy() — runtime identity switch via menu
* - main() — after the GTK login dialog returns
*
* It is idempotent: if already on the correct per-user db, it's a no-op.
*/
/* Track the currently-open per-user db path so we can skip re-opening
* the same database (e.g. when app_set_signer() is called during the
* login dialog and then main() calls switch_to_user_db() again).
* The actual definition is near the top of this file (forward-declared
* before the menu proxies that reset it on logout). */
static int switch_to_user_db(const char *pubkey_hex) {
if (pubkey_hex == NULL || pubkey_hex[0] == '\0') {
g_printerr("[profile] No pubkey, staying on global.db\n");
return -1;
}
/* Create the profile directory. */
if (profile_ensure_dir(pubkey_hex) != 0) {
g_printerr("[profile] Failed to create profile dir for %s\n",
pubkey_hex);
return -1;
}
/* Open the per-user browser.db (closes global.db first). */
char db_path[512];
profile_get_db_path(pubkey_hex, db_path, sizeof(db_path));
if (db_path[0] == '\0') {
g_printerr("[profile] Failed to get db path for %s\n", pubkey_hex);
return -1;
}
/* Skip if already on this database (idempotent). */
if (g_current_profile_db[0] != '\0' &&
strcmp(g_current_profile_db, db_path) == 0) {
g_print("[profile] Already on per-user db: %s\n", db_path);
return 0;
}
if (db_init_with_path(db_path) != 0) {
g_printerr("[profile] Failed to open per-user db: %s\n", db_path);
return -1;
}
/* Record the current profile db path (for idempotency check). */
snprintf(g_current_profile_db, sizeof(g_current_profile_db),
"%s", db_path);
/* Load per-user settings from the per-user browser.db. This only
* reads per-user keys; global settings already in memory are
* preserved. */
settings_load_user();
/* Save the last-used pubkey to the global identity.json so the
* login dialog can default to this profile next time. */
profile_save_last_pubkey(pubkey_hex);
/* Save public identity info (method, pubkey) to the per-profile
* identity.json. Private keys are never stored. */
key_store_save_profile_identity(pubkey_hex, g_state.method);
g_print("[profile] Switched to per-user db: %s\n", db_path);
return 0;
}
/* ---- Per-user WebKit context build helper --------------------------- *
* Builds a fresh per-user WebKitWebContext (or ephemeral for no-login
* mode), registers the sovereign://, nostr://, tor:// URI schemes on it,
* and updates tab_manager so new tabs use the new context. Called from:
* - main() after login, before creating tabs
* - app_menu_switch_identity_proxy() after tearing down the old context
* - app_menu_logout_proxy() after re-login
*
* The caller must have torn down the previous context (via
* web_context_teardown()) and closed all tabs/sidebar webviews first.
* Returns the new context, or NULL on failure (the caller may fall back
* to a blank window or exit).
*/
static WebKitWebContext *build_context_for_current_user(void) {
WebKitWebContext *web_ctx;
if (g_state.pubkey_hex[0] != '\0') {
web_ctx = web_context_build_for_pubkey(g_state.pubkey_hex);
} else {
/* --no-login / read-only-without-pubkey: ephemeral, no on-disk
* persistence. See plans/webkit-data-isolation.md. */
web_ctx = web_context_build_ephemeral();
}
if (web_ctx == NULL) {
g_printerr("[main] Failed to build WebKit context\n");
return NULL;
}
/* Register the sovereign:// URI scheme for the window.nostr bridge.
* nostr_bridge_set_signer() was already called by the login path,
* so the bridge will use the current signer. */
nostr_bridge_register(web_ctx, g_state.signer, g_state.pubkey_hex,
g_state.readonly);
/* Register nostr:// and tor:// entity-page schemes on this context.
* These are per-context registrations, so they must be re-registered
* on every fresh context. */
nostr_scheme_register(web_ctx);
tor_scheme_register(web_ctx);
/* Point tab_manager at the new context so subsequent new tabs (and
* the sidebar webview) are created from it. */
tab_manager_set_context(web_ctx);
return web_ctx;
}
/* ---- Main ----------------------------------------------------------- */
int main(int argc, char **argv) {
(void)signal(SIGPIPE, SIG_IGN);
/* Parse CLI flags BEFORE gtk_init() so GTK doesn't abort on our
* flags. cli_parse() strips recognized flags from argv and collects
* positional URLs. */
cli_args_t cli;
if (cli_parse(&argc, &argv, &cli) != 0) {
cli_args_free(&cli);
return EXIT_FAILURE;
}
if (cli.want_help) {
cli_print_usage(stdout);
cli_args_free(&cli);
return EXIT_SUCCESS;
}
if (cli.want_version) {
printf("sovereign_browser %s\n", SB_VERSION);
cli_args_free(&cli);
return EXIT_SUCCESS;
}
gtk_init(&argc, &argv);
/* NOTE: key_store_delete_legacy_file() was previously called here
* to delete identity.json from old versions that persisted private
* keys. We no longer call it because identity.json is now used
* legitimately to store the last-used pubkey_hex (no private keys).
* This is a new project with no legacy files to migrate. */
/* Initialize the global database first — global settings and
* shortcuts are stored there. The per-user browser.db is opened
* after login, once we know the user's pubkey. */
db_init_global();
/* Set defaults, then load global settings from global.db. Per-user
* settings are loaded after login from the per-user browser.db. */
{
browser_settings_t *s = settings_get_mutable();
/* settings_load_global() does NOT reset defaults, so we need to
* set them first. We call settings_load() which sets defaults
* and then loads global settings from the currently-open db
* (global.db). The per-user keys won't be found in global.db,
* so their defaults are kept. */
(void)s;
settings_load();
}
/* Load keyboard shortcut bindings from the database (global.db).
* Must come after settings_load() because shortcuts_lookup() checks
* the master ctrl_tab_switch toggle. Shortcuts are global — they
* are the same across all profiles. */
shortcuts_load();
/* History is queried from the SQLite database on demand — no
* separate load step needed. */
/* Apply CLI overrides to the in-memory settings singleton. These
* do not write to disk — they are one-shot overrides for this run. */
{
browser_settings_t *s = settings_get_mutable();
if (cli.new_tab_url) {
snprintf(s->new_tab_url, sizeof(s->new_tab_url), "%s",
cli.new_tab_url);
}
if (cli.max_tabs > 0) {
s->max_tabs = cli.max_tabs;
}
if (cli.session_restore == CLI_TRISTATE_TRUE) {
s->restore_session = TRUE;
} else if (cli.session_restore == CLI_TRISTATE_FALSE) {
s->restore_session = FALSE;
}
if (cli.agent_port > 0) {
s->agent_server_port = cli.agent_port;
}
if (cli.agent_enabled == CLI_TRISTATE_TRUE) {
s->agent_server_enabled = TRUE;
} else if (cli.agent_enabled == CLI_TRISTATE_FALSE) {
s->agent_server_enabled = FALSE;
}
if (cli.login_timeout_ms > 0) {
s->agent_login_timeout_ms = cli.login_timeout_ms;
}
/* --agent-origin: append to allowed origins (comma-separated). */
if (cli.agent_origin_count > 0) {
GString *origins = g_string_new(s->agent_allowed_origins);
for (int i = 0; i < cli.agent_origin_count; i++) {
if (origins->len > 0) g_string_append_c(origins, ',');
g_string_append(origins, cli.agent_origins[i]);
}
snprintf(s->agent_allowed_origins, sizeof(s->agent_allowed_origins),
"%s", origins->str);
g_string_free(origins, TRUE);
}
}
/* Top-level window. */
GtkWidget *window = gtk_window_new(GTK_WINDOW_TOPLEVEL);
gtk_window_set_title(GTK_WINDOW(window), "sovereign browser " SB_VERSION);
gtk_window_set_default_size(GTK_WINDOW(window), 1024, 768);
g_signal_connect(window, "delete-event",
G_CALLBACK(on_window_delete_event), NULL);
g_signal_connect(window, "destroy", G_CALLBACK(on_window_destroy), NULL);
g_signal_connect(window, "key-press-event", G_CALLBACK(on_key_press), NULL);
g_window = GTK_WINDOW(window);
/* Start the agent server before login. The server runs on the
* configured port (default 17777) and is available throughout the
* entire browser lifecycle — an agent can log in at any time, even
* while the GTK login dialog is showing or after the browser is
* already running. If disabled in settings, skip it. */
const browser_settings_t *s = settings_get();
if (s->agent_server_enabled) {
if (agent_server_start(s->agent_server_port) == 0) {
g_print("[agent] Server started on port %d\n",
agent_server_get_port());
} else {
g_printerr("[agent] Failed to start server on port %d\n",
s->agent_server_port);
}
}
/* Initialize nostr_core_lib (needed for both agent and GTK login). */
if (nostr_init() != NOSTR_SUCCESS) {
g_printerr("[login] Failed to initialize nostr_core_lib\n");
agent_server_stop();
cli_args_free(&cli);
return EXIT_SUCCESS;
}
/* Set up the login callback so if an agent calls 'login' while the
* GTK dialog is showing, we can close the dialog and proceed. */
agent_server_set_login_callback(agent_login_callback);
/* If --no-login was given, skip login entirely. The browser works
* as a normal browser without a Nostr identity. window.nostr won't
* be available for sign requests, but all browsing works. */
if (cli.no_login) {
g_logged_in = TRUE;
g_state.readonly = TRUE;
g_print("[login] No-login mode (browsing without Nostr identity)\n");
}
/* If --login-method was given on the command line, perform CLI login
* now and skip the GTK dialog entirely. This reuses agent_login(),
* the same code path as the MCP 'login' tool. */
if (cli.login_method) {
if (cli_login(&cli) != 0) {
g_printerr("[login] CLI login failed, exiting.\n");
agent_server_stop();
nostr_cleanup();
cli_args_free(&cli);
return EXIT_FAILURE;
}
g_logged_in = TRUE;
}
/* Show the GTK login dialog immediately — no blocking wait.
* The dialog runs a nested GTK main loop (gtk_dialog_run) which
* processes WebSocket events, so the agent server is live during
* the dialog. If an agent calls 'login' while the dialog is open,
* the callback fires and we close the dialog.
*
* If the agent logs in before the dialog appears (unlikely but
* possible), skip the dialog entirely. */
if (!g_logged_in) {
if (do_login(g_window) != 0) {
g_print("[login] Cancelled, exiting.\n");
agent_server_stop();
nostr_cleanup();
cli_args_free(&cli);
return EXIT_SUCCESS;
}
}
/* ── Switch to per-user profile database ────────────────────── *
* Now that we know the user's pubkey, close global.db and open the
* per-user browser.db at ~/.sovereign_browser/profiles/<pubkey>/.
* This must happen before relay_fetch, session_restore, bookmarks,
* etc. — all of which read/write the per-user database.
*
* If there's no pubkey (--no-login mode), we stay on global.db.
* Browsing still works; history/session just go to global.db in
* that case (acceptable for the no-identity mode). */
if (g_state.pubkey_hex[0] != '\0') {
if (switch_to_user_db(g_state.pubkey_hex) != 0) {
g_printerr("[profile] Failed to switch to per-user db — "
"continuing with global.db\n");
}
}
/* If the user logged in with a Nostr identity (not --no-login), start
* a background thread to fetch their kind 0/3/10002 events from the
* bootstrap relays. The results are cached in the per-user SQLite
* database. The thread frees the pubkey copy when done. */
if (g_state.pubkey_hex[0] != '\0') {
char *pubkey_copy = g_strdup(g_state.pubkey_hex);
g_thread_new("relay-fetch", relay_fetch_thread, pubkey_copy);
g_print("[relay] Bootstrap fetch thread started for %s\n",
g_state.pubkey_hex);
}
/* Initialize the bookmarks module. Loads cached kind 30003 events
* from SQLite and decrypts them. In no-login/read-only mode, the
* signer is NULL so bookmarks are read-only. */
bookmarks_init(g_state.signer,
g_state.pubkey_hex[0] ? g_state.pubkey_hex : NULL);
/* Initialize the NIP-78 settings sync module. In no-login/read-only
* mode, the signer is NULL so settings are local-only (not synced). */
settings_sync_init(g_state.signer,
g_state.pubkey_hex[0] ? g_state.pubkey_hex : NULL);
/* Initialize the conversation persistence module (kind 30078
* conversations). In no-login/read-only mode, the signer is NULL
* so conversations are not encrypted/synced. */
agent_conversations_init(g_state.signer,
g_state.pubkey_hex[0] ? g_state.pubkey_hex : NULL);
/* Initialize the skills module (kind 31123 public skill events).
* In no-login/read-only mode, the signer is NULL so skills cannot
* be published/deleted (but can still be fetched and selected). */
agent_skills_init(g_state.signer,
g_state.pubkey_hex[0] ? g_state.pubkey_hex : NULL);
/* Set the user's avatar on the tab bar from their kind 0 profile.
* If the profile hasn't been fetched yet (relay fetch is still
* running), this shows the default icon. The avatar will be updated
* when the relay fetch completes and the kind 0 event is stored. */
tab_manager_set_avatar(g_state.pubkey_hex[0] ? g_state.pubkey_hex : NULL);
/* ── Per-user WebKit context (Phase A of webkit-data-isolation.md) ── *
* Build a fresh WebKitWebContext with a per-user
* WebKitWebsiteDataManager rooted at
* ~/.sovereign_browser/profiles/<pubkey>/webkit/ (or an ephemeral
* data manager for --no-login mode). This gives true per-identity
* isolation of cookies, cache, localStorage, IndexedDB, service
* workers, and favicons. The helper also registers the sovereign://,
* nostr://, tor:// URI schemes on the new context and points
* tab_manager at it. */
WebKitWebContext *web_ctx = build_context_for_current_user();
if (web_ctx == NULL) {
g_printerr("[main] Cannot continue without a WebKit context.\n");
agent_server_stop();
nostr_cleanup();
cli_args_free(&cli);
return EXIT_FAILURE;
}
/* Fetch the security manager for the new context — used below to
* wire the sovereign://security page to the first tab's settings. */
WebKitSecurityManager *sec_mgr =
webkit_web_context_get_security_manager(web_ctx);
/* Initialize browser-managed/attached network services after the
* per-user context has been built. net_services_refresh_proxy()
* now uses web_context_get() so proxy settings apply to the
* per-user context. */
net_services_init();
/* Vertical box: the tab manager's notebook fills the window. */
GtkWidget *vbox = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0);
gtk_container_add(GTK_CONTAINER(window), vbox);
/* Initialize the tab manager. */
tab_manager_init(GTK_CONTAINER(vbox), web_ctx, g_window);
/* Mark startup complete. From now on, app_set_signer() (called by
* agent login / switch_identity) will rebuild the per-user web
* context if it was torn down, since main() won't build it again. */
g_post_startup = TRUE;
/* Decide whether to restore the previous session or open CLI URLs.
*
* Precedence:
* 1. --no-session-restore → skip restore, open CLI URLs (or default)
* 2. --url / positional → skip restore, open the given URLs
* 3. --session-restore → force restore
* 4. settings.restore_session (default) → restore if enabled
*
* If restore succeeds, CLI URLs are ignored (the user wanted their
* session back). If restore fails or is skipped, open CLI URLs; if
* none were given, open a single tab with the default new-tab URL. */
gboolean skip_restore = (cli.session_restore == CLI_TRISTATE_FALSE) ||
(cli.url_count > 0);
int restored = skip_restore ? 0 : session_restore();
if (restored == 0) {
if (cli.url_count > 0) {
for (int i = 0; i < cli.url_count; i++) {
tab_manager_new_tab(cli.urls[i]);
}
} else {
const char *url = settings_get()->new_tab_url;
tab_manager_new_tab(url);
}
}
/* Wire the security refs to the first tab's settings (settings are
* per-webview, not per-context). The sovereign://security page uses
* these to read and toggle security features. */
{
tab_info_t *first_tab = tab_manager_get(0);
if (first_tab && first_tab->webview) {
WebKitSettings *settings =
webkit_web_view_get_settings(first_tab->webview);
nostr_bridge_set_security_refs(settings, sec_mgr);
}
}
cli_args_free(&cli);
gtk_widget_show_all(window);
/* Re-hide the sidebar container that show_all revealed. The sidebar
* is per-window (packed in the window-level GtkPaned) and should
* only appear when the user toggles it. */
tab_manager_hide_sidebar_after_show_all();
gtk_main();
return EXIT_SUCCESS;
}