From 0160b70dc2404afafa0d9a97b77aaf6eeb647e15 Mon Sep 17 00:00:00 2001 From: Laan Tungir Date: Sat, 11 Jul 2026 20:17:56 -0400 Subject: [PATCH] v0.0.4 - Implemented browser tabs with GtkNotebook, per-tab toolbars, session restore, settings dialog, and keyboard shortcuts. Made Other Qube (qrexec) the default n_signer transport. --- Makefile | 2 +- README.md | 55 ++- VERSION | 2 +- docs/architecture.md | 7 +- plans/browser-tabs.md | 249 ++++++++++++++ src/login_dialog.c | 24 +- src/main.c | 728 +++++++++++++++++---------------------- src/session.c | 108 ++++++ src/session.h | 35 ++ src/settings.c | 201 +++++++++++ src/settings.h | 61 ++++ src/tab_manager.c | 783 ++++++++++++++++++++++++++++++++++++++++++ src/tab_manager.h | 142 ++++++++ src/version.h | 4 +- 14 files changed, 1965 insertions(+), 436 deletions(-) create mode 100644 plans/browser-tabs.md create mode 100644 src/session.c create mode 100644 src/session.h create mode 100644 src/settings.c create mode 100644 src/settings.h create mode 100644 src/tab_manager.c create mode 100644 src/tab_manager.h diff --git a/Makefile b/Makefile index 67853ca..41bee7c 100644 --- a/Makefile +++ b/Makefile @@ -17,7 +17,7 @@ NOSTR_LIB = ./nostr_core_lib/libnostr_core_x64.a NOSTR_DEPS = -lsecp256k1 -lssl -lcrypto -lcurl -lz -ldl -lpthread -lm BIN := sovereign_browser -SRC := src/main.c src/key_store.c src/login_dialog.c src/nostr_bridge.c src/nostr_inject.c src/history.c +SRC := src/main.c src/key_store.c src/login_dialog.c src/nostr_bridge.c src/nostr_inject.c src/history.c src/settings.c src/tab_manager.c src/session.c $(BIN): $(SRC) $(NOSTR_LIB) $(CC) $(CFLAGS) $(LDFLAGS) -o $@ $^ $(NOSTR_LIB) $(LDLIBS) $(NOSTR_DEPS) diff --git a/README.md b/README.md index 614a5e9..9109192 100644 --- a/README.md +++ b/README.md @@ -41,8 +41,9 @@ because trust moves to the layer where it belongs: your keys. ## Current status -Working proof-of-concept: a C99 + WebKitGTK window with a URL bar that loads -real HTTPS pages. Verified loading `https://laantungir.net` cleanly. See +Working browser: a C99 + WebKitGTK window with multi-tab support, a URL bar +per tab, Nostr login, and `window.nostr` injection. Verified loading +`https://laantungir.net` cleanly. See [`docs/webkit-poc-findings.md`](docs/webkit-poc-findings.md) for the friction report from the POC phase (and the Servo fallback exploration). @@ -125,15 +126,53 @@ Supported methods: See [`plans/nostr-login-integration.md`](plans/nostr-login-integration.md) for the full implementation plan. +### Browser tabs + +The browser supports multiple tabs via a `GtkNotebook`. Each tab has its own +toolbar (hamburger menu + URL entry) and `WebKitWebView`. All tabs share a +single `WebKitWebContext`, so the `sovereign://` Nostr bridge, security +settings, and TLS policy apply to every tab automatically. + +**Tab features:** + +- New tab: `Ctrl+T` or the `+` button at the end of the tab strip +- Close tab: `Ctrl+W`, per-tab close button, or middle-click on the tab label +- Switch tabs: `Ctrl+Tab` / `Ctrl+Shift+Tab`, `Ctrl+PageUp` / `Ctrl+PageDown` +- Focus URL bar: `Ctrl+L` +- Right-click tab context menu: New Tab, Close Tab, Close Other Tabs, Close + Tabs to the Right, Duplicate Tab, Reload Tab +- Drag tabs to reorder +- Tab titles update from page load +- Session save/restore: open tabs are saved on exit and restored on next + launch (configurable in Settings) + +**Settings dialog** (hamburger menu → Settings…): + +- Restore session on startup (default: on) +- New tab URL (default: `https://example.com`) +- Tab bar position: Top / Bottom / Left / Right (default: Top) +- Show tab close buttons (default: on) +- Middle-click to close tab (default: on) +- Ctrl+Tab to switch tabs (default: on) +- Maximum tabs (default: 50) +- Allow drag to reorder tabs (default: on) + +Settings are persisted to `~/.sovereign_browser/settings.conf`. Session state +is saved to `~/.sovereign_browser/session.txt`. + +See [`plans/browser-tabs.md`](plans/browser-tabs.md) for the full +implementation plan. + ## Roadmap 1. ✅ Base browser (WebKitGTK + C99, loads pages) -2. ⏳ Nostr login (GTK dialog → nostr_core_lib → nostr_signer_t) -3. ⏳ `window.nostr` injection (`sovereign://` URI scheme bridge) -4. ⏳ Security strip (disable SOP/CORS, accept any cert, shared context) -5. ⏳ FIPS URI scheme (`fips://` / `*.fips` via WebKitGTK scheme handler) -6. ⏳ `nostr://` content scheme -7. Future: agent runtime (didactyl hosting), multi-window, FIPS-distributed +2. ✅ Browser tabs (GtkNotebook, per-tab toolbar, session restore, settings) +3. ⏳ Nostr login (GTK dialog → nostr_core_lib → nostr_signer_t) +4. ⏳ `window.nostr` injection (`sovereign://` URI scheme bridge) +5. ⏳ Security strip (disable SOP/CORS, accept any cert, shared context) +6. ⏳ FIPS URI scheme (`fips://` / `*.fips` via WebKitGTK scheme handler) +7. ⏳ `nostr://` content scheme +8. Future: agent runtime (didactyl hosting), multi-window, FIPS-distributed ## License diff --git a/VERSION b/VERSION index bcab45a..81340c7 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.0.3 +0.0.4 diff --git a/docs/architecture.md b/docs/architecture.md index 5c2686b..9de1709 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -275,8 +275,11 @@ flowchart LR ### Layers -1. **Host application (C99).** Owns the window, URL bar, tabs, key store, and - the request router. This is where the "reckless" policy lives. +1. **Host application (C99).** Owns the window, tab strip (`GtkNotebook`), + per-tab toolbars (URL bar + hamburger menu), key store, and the request + router. This is where the "reckless" policy lives. Tab management is in + `tab_manager.c`, user preferences in `settings.c`, and session save/restore + in `session.c`. 2. **Request router.** Inspects every outgoing URL: - `http://` / `https://` → engine's normal network stack (with CORS / diff --git a/plans/browser-tabs.md b/plans/browser-tabs.md new file mode 100644 index 0000000..845f3aa --- /dev/null +++ b/plans/browser-tabs.md @@ -0,0 +1,249 @@ +# Browser Tabs Implementation Plan + +## Overview + +Add full browser tab support to sovereign_browser. Each tab gets its own +toolbar (URL bar + hamburger menu) and WebKitWebView. All tabs share a single +`WebKitWebContext`, so the `sovereign://` Nostr bridge, security settings, and +TLS policy apply automatically to every tab. Tab behavior is configurable via +a new settings module. + +## Architecture + +```mermaid +flowchart TB + subgraph Window + Notebook[GtkNotebook — tab strip] + NewTabBtn[New Tab Button +] + end + + subgraph TabPage + Toolbar[Toolbar: hamburger + URL entry] + Webview[WebKitWebView] + end + + Notebook -->|page 0| TabPage + Notebook -->|page 1| TabPage + Notebook -->|page N| TabPage + + TabPage -->|shared| Ctx[WebKitWebContext — sovereign bridge, security, TLS] + Ctx --> NostrBridge[nostr_bridge.c] + Ctx --> NostrInject[nostr_inject.c — per-webview] + + TabManager[tab_manager.c] -->|manages| Notebook + Settings[settings.c] -->|configures| TabManager + Session[session.c] -->|save/restore| TabManager +``` + +### Key design decisions + +1. **Per-tab toolbar.** Each notebook page is a vertical `GtkBox` containing + the toolbar (hamburger + URL entry) on top and the `WebKitWebView` below. + This is simpler than a shared toolbar that has to swap signal handlers on + tab switch, and it matches the user's preference. + +2. **Shared `WebKitWebContext`.** All `WebKitWebView` instances are created + from the same context. The `sovereign://` URI scheme registration, security + manager flags, and TLS error policy are set once on the context and apply + to all tabs. No per-tab re-registration needed. + +3. **Per-webview `nostr_inject_setup()`.** The `window.nostr` injection uses + `WebKitUserContentManager`, which is per-webview. Each new tab calls + `nostr_inject_setup()` on its own webview. + +4. **`tab_manager.c` owns the `GtkNotebook`.** It provides functions to + create, close, switch, and query tabs. Each tab is tracked in a struct + holding the webview, URL entry, hamburger button, and tab label widget. + +5. **Settings module.** A new `settings.c`/`settings.h` persists user + preferences to `~/.sovereign_browser/settings.conf` (simple key=value + text file). Configurable options: + + | Setting | Default | Description | + |---------|---------|-------------| + | `restore_session` | `true` | Restore open tabs on next launch | + | `new_tab_url` | `https://example.com` | URL loaded in new tabs | + | `tab_bar_position` | `top` | `top` / `bottom` / `left` / `right` | + | `show_tab_close_buttons` | `true` | Show per-tab close button | + | `middle_click_close` | `true` | Middle-click on tab closes it | + | `ctrl_tab_switch` | `true` | Ctrl+Tab cycles tabs | + | `max_tabs` | `50` | Maximum simultaneous tabs | + | `tab_drag_reorder` | `true` | Allow drag to reorder tabs | + +6. **Session module.** `session.c`/`session.h` saves the list of open tab URLs + (one per line) to `~/.sovereign_browser/session.txt` on window destroy, and + restores them on startup if `settings.restore_session` is true. + +## New files + +### `src/settings.h` / `src/settings.c` + +```c +typedef struct { + gboolean restore_session; + char new_tab_url[512]; + int tab_bar_position; /* GTK_POS_TOP, etc. */ + gboolean show_tab_close_buttons; + gboolean middle_click_close; + gboolean ctrl_tab_switch; + int max_tabs; + gboolean tab_drag_reorder; +} browser_settings_t; + +void settings_load(browser_settings_t *out); +void settings_save(const browser_settings_t *s); +const browser_settings_t *settings_get(void); /* global singleton */ +``` + +### `src/tab_manager.h` / `src/tab_manager.c` + +```c +typedef struct { + WebKitWebView *webview; + GtkWidget *url_entry; + GtkWidget *hamburger; + GtkWidget *tab_label; /* composite: icon + title + close btn */ + char current_url[2048]; + char title[256]; +} tab_info_t; + +void tab_manager_init(GtkContainer *parent, WebKitWebContext *ctx); +int tab_manager_new_tab(const char *url); +void tab_manager_close_tab(int index); +void tab_manager_close_active(void); +int tab_manager_get_active_index(void); +tab_info_t *tab_manager_get_active(void); +tab_info_t *tab_manager_get(int index); +int tab_manager_count(void); +void tab_manager_set_title(int index, const char *title); +``` + +### `src/session.h` / `src/session.c` + +```c +void session_save(void); /* called on window destroy */ +int session_restore(void); /* called at startup, returns tab count created */ +``` + +## Modified files + +### `src/main.c` — major refactor + +- Remove the single `webview` / `url_entry` / `toolbar` creation. +- Create `WebKitWebContext` once, configure security + `sovereign://` bridge + on it (existing code moves here, unchanged logic). +- Call `tab_manager_init()` with the window's vbox and the shared context. +- The hamburger menu builder moves into `tab_manager.c` (or a shared helper) + since it now needs to reference the per-tab webview, not a global. +- All menu callbacks (`on_menu_reload`, `on_menu_stop`, `on_menu_inspector`, + `on_menu_open_file`, `on_menu_history_item`) fetch the active tab's webview + via `tab_manager_get_active()` instead of receiving it as a parameter. +- `on_load_changed` and `on_load_failed` are connected per-tab inside + `tab_manager_new_tab()` and update the per-tab URL entry and tab title. +- `on_url_activate` loads the URL into the active tab's webview. +- Keyboard shortcuts: a `key-press-event` handler on the main window checks + for Ctrl+T / Ctrl+W / Ctrl+Tab / Ctrl+Shift+Tab / Ctrl+PageUp / Ctrl+PageDown. +- `on_window_destroy` calls `session_save()` before cleanup. + +### `src/nostr_inject.c` — no changes + +`nostr_inject_setup()` already takes a `WebKitWebView*` and is called +per-webview. The tab manager calls it for each new tab. + +### `src/nostr_bridge.c` — no changes + +The bridge is registered on the shared `WebKitWebContext`. All webviews on +that context automatically get the `sovereign://` scheme handler. + +### `src/history.c` — no changes + +History is already global. All tabs contribute to and read from the same +history list. + +### `Makefile` + +Add `src/settings.c src/tab_manager.c src/session.c` to `SRC`. + +## Tab UI layout + +``` +┌──────────────────────────────────────────────────────┐ +│ [Tab 1 ×] [Tab 2 ×] [Tab 3 ×] [+] │ ← GtkNotebook tab strip +├──────────────────────────────────────────────────────┤ +│ [☰] https://example.com____________ │ ← per-tab toolbar +├──────────────────────────────────────────────────────┤ +│ │ +│ WebKitWebView │ ← per-tab webview +│ │ +└──────────────────────────────────────────────────────┘ +``` + +### Tab label widget (composite) + +Each tab label is a horizontal `GtkBox`: +``` +[favicon] [Title text…] [×] +``` +- Favicon: placeholder icon for now (can be wired to WebKit favicon later) +- Title: `GtkLabel` with ellipsize end, max width ~150px +- Close button: `GtkButton` with `window-close-symbolic` icon, only shown if + `settings.show_tab_close_buttons` is true + +### Right-click context menu on tab label + +- New Tab +- Close Tab +- Close Other Tabs +- Close Tabs to the Right +- Duplicate Tab +- Reload Tab + +### Drag reordering + +`GtkNotebook` supports drag reordering natively via +`gtk_notebook_set_tab_reorderable(notebook, child, TRUE)`. Enabled when +`settings.tab_drag_reorder` is true. + +## Keyboard shortcuts + +| Shortcut | Action | +|----------|--------| +| Ctrl+T | New tab | +| Ctrl+W | Close active tab | +| Ctrl+Tab | Next tab | +| Ctrl+Shift+Tab | Previous tab | +| Ctrl+PageUp | Previous tab | +| Ctrl+PageDown | Next tab | +| Ctrl+L | Focus URL bar of active tab | + +## Session restore + +On startup, if `settings.restore_session` is true and +`~/.sovereign_browser/session.txt` exists and is non-empty, each line is +loaded as a tab URL. If the file is missing or empty, a single tab is opened +with the start URL (from command-line arg or `settings.new_tab_url`). + +On window destroy, before `gtk_main_quit()`, the list of open tab URLs is +written to `~/.sovereign_browser/session.txt`. + +## Settings UI + +A new menu item "Settings…" in the hamburger menu opens a native GTK dialog +(not a web page, to keep it simple and avoid bootstrapping a +`sovereign://settings` renderer). The dialog has checkboxes and a text entry +for each configurable option. On "OK", `settings_save()` is called and +applicable changes (like tab bar position) are applied live. + +## Implementation order + +1. **settings.c/h** — foundation, no UI yet, just load/save + global singleton +2. **tab_manager.c/h** — core notebook management, per-tab page creation, + tab label widget, new/close/switch +3. **session.c/h** — save/restore using tab_manager API +4. **main.c refactor** — wire up tab_manager, move context setup, rewire + callbacks to use active tab +5. **Tab interactions** — middle-click close, right-click menu, drag reorder +6. **Keyboard shortcuts** — window-level key-press handler +7. **Session save/restore** — integrate into startup and destroy +8. **Settings dialog** — GTK dialog wired to settings module +9. **Makefile + docs** — build and documentation updates diff --git a/src/login_dialog.c b/src/login_dialog.c index 63ff9d1..c1d4429 100644 --- a/src/login_dialog.c +++ b/src/login_dialog.c @@ -732,7 +732,7 @@ static void on_transport_changed(GtkComboBox *combo, gpointer user_data) { gtk_widget_hide(enum_btn); gtk_widget_hide(service_box); break; - case 3: /* Qubes qrexec */ + case 3: /* Other Qube (Qubes qrexec) */ gtk_label_set_text(GTK_LABEL(device_label), "Target Qube:"); gtk_entry_set_placeholder_text(GTK_ENTRY(device_entry), "nostr_signer"); /* Set default value so the user doesn't need to type it. */ @@ -954,21 +954,26 @@ static GtkWidget *create_nsigner_screen(login_ctx_t *ctx) { gtk_combo_box_text_append_text(GTK_COMBO_BOX_TEXT(transport_combo), "USB Serial"); gtk_combo_box_text_append_text(GTK_COMBO_BOX_TEXT(transport_combo), "UNIX Socket"); gtk_combo_box_text_append_text(GTK_COMBO_BOX_TEXT(transport_combo), "TCP"); - gtk_combo_box_text_append_text(GTK_COMBO_BOX_TEXT(transport_combo), "Qubes qrexec"); - gtk_combo_box_set_active(GTK_COMBO_BOX(transport_combo), 0); + gtk_combo_box_text_append_text(GTK_COMBO_BOX_TEXT(transport_combo), "Other Qube"); + gtk_combo_box_set_active(GTK_COMBO_BOX(transport_combo), 3); gtk_box_pack_start(GTK_BOX(transport_box), transport_combo, FALSE, FALSE, 0); - /* Device path entry — label and placeholder adapt to transport type. */ - GtkWidget *device_label = gtk_label_new("Device Path:"); + /* Device path entry — label and placeholder adapt to transport type. + * Default transport is "Other Qube" (qrexec), so initial UI reflects + * that. The on_transport_changed callback updates these when the user + * switches transport. */ + GtkWidget *device_label = gtk_label_new("Target Qube:"); gtk_widget_set_halign(device_label, GTK_ALIGN_START); gtk_box_pack_start(GTK_BOX(box), device_label, FALSE, FALSE, 0); GtkWidget *device_entry = gtk_entry_new(); - gtk_entry_set_placeholder_text(GTK_ENTRY(device_entry), "/dev/ttyACM0"); + gtk_entry_set_text(GTK_ENTRY(device_entry), "nostr_signer"); + gtk_entry_set_placeholder_text(GTK_ENTRY(device_entry), "nostr_signer"); gtk_entry_set_width_chars(GTK_ENTRY(device_entry), 40); gtk_box_pack_start(GTK_BOX(box), device_entry, FALSE, FALSE, 0); - /* Enumerate serial devices button (only shown for serial transport). */ + /* Enumerate serial devices button (only shown for serial transport). + * Hidden by default since qrexec is the default transport. */ GtkWidget *enum_btn = gtk_button_new_with_label("Detect Serial Devices"); #if defined(NOSTR_ENABLE_NSIGNER_CLIENT) g_signal_connect(enum_btn, "clicked", G_CALLBACK(on_detect_serial), device_entry); @@ -976,6 +981,7 @@ static GtkWidget *create_nsigner_screen(login_ctx_t *ctx) { gtk_widget_set_sensitive(enum_btn, FALSE); #endif gtk_box_pack_start(GTK_BOX(box), enum_btn, FALSE, FALSE, 0); + gtk_widget_hide(enum_btn); /* Service name field (only shown for qrexec transport). */ GtkWidget *service_box = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 8); @@ -989,8 +995,8 @@ static GtkWidget *create_nsigner_screen(login_ctx_t *ctx) { gtk_entry_set_width_chars(GTK_ENTRY(service_entry), 30); gtk_box_pack_start(GTK_BOX(service_box), service_entry, FALSE, FALSE, 0); - /* Initially hide the service field (serial is the default transport). */ - gtk_widget_hide(service_box); + /* Show the service field by default (qrexec is the default transport). */ + gtk_widget_show_all(service_box); /* Connect transport change callback to update the UI dynamically. */ g_signal_connect(transport_combo, "changed", diff --git a/src/main.c b/src/main.c index 0ec98ab..1e6e205 100644 --- a/src/main.c +++ b/src/main.c @@ -1,15 +1,25 @@ /* * sovereign_browser — WebKitGTK + C99 * - * Minimal browser: a GTK window with a URL entry, a hamburger menu, and a - * WebKitWebView. Type a URL, press Enter, the page loads. The hamburger - * menu to the left of the URL bar exposes roadmap actions (reload, security - * strip toggle, FIPS, Nostr signing) as placeholders to wire up next. + * 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 will be used - * to inject window.nostr into every page (Phase 2). + * 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 dialog (tab preferences, persisted to disk) * * Build: make. Run: ./sovereign_browser [url] */ @@ -27,11 +37,15 @@ #include "nostr_bridge.h" #include "nostr_inject.h" #include "history.h" +#include "settings.h" +#include "tab_manager.h" +#include "session.h" #include "nostr_core/nostr_core.h" /* ---- Global state --------------------------------------------------- * * The signer is created at login and held for the lifetime of the session. - * Phase 2 will use it to back the window.nostr injection. + * It backs the window.nostr injection in every tab via the sovereign:// + * URI scheme bridge. */ typedef struct { @@ -42,57 +56,21 @@ typedef struct { } app_state_t; static app_state_t g_state = {0}; +static GtkWindow *g_window = NULL; -/* ---- Menu action callbacks ------------------------------------------- */ +/* ---- 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. + */ -static void on_menu_reload(GtkMenuItem *item, WebKitWebView *webview) { - (void)item; - if (webview != NULL) { - webkit_web_view_reload_bypass_cache(webview); - } -} - -static void on_menu_stop(GtkMenuItem *item, WebKitWebView *webview) { - (void)item; - if (webview != NULL) { - webkit_web_view_stop_loading(webview); - } -} - -static void on_menu_security_strip(GtkMenuItem *item, gpointer data) { - (void)data; - gboolean active = gtk_check_menu_item_get_active(GTK_CHECK_MENU_ITEM(item)); - g_print("[menu] security strip: %s\n", active ? "ON" : "OFF"); -} - -static void on_menu_fips(GtkMenuItem *item, gpointer data) { - (void)item; - (void)data; - g_print("[menu] FIPS URI scheme: not yet implemented (roadmap)\n"); -} - -static void on_menu_nostr_sign(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"); - } -} - -/* Show the login dialog again (switch identity / re-auth). */ -static void on_menu_switch_identity(GtkMenuItem *item, gpointer data) { +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) { - /* Free the old signer. */ if (g_state.signer) { nostr_signer_free(g_state.signer); } @@ -102,7 +80,6 @@ static void on_menu_switch_identity(GtkMenuItem *item, gpointer data) { g_state.pubkey_hex[64] = '\0'; g_state.readonly = (result.method == KEY_STORE_METHOD_READONLY); - /* Update the bridge so window.nostr uses the new signer. */ nostr_bridge_set_signer(g_state.signer, g_state.pubkey_hex, g_state.readonly); @@ -111,7 +88,19 @@ static void on_menu_switch_identity(GtkMenuItem *item, gpointer data) { } } -static void on_menu_logout(GtkMenuItem *item, gpointer data) { +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; (void)data; if (g_state.signer) { @@ -125,12 +114,36 @@ static void on_menu_logout(GtkMenuItem *item, gpointer data) { g_print("[identity] logged out\n"); } -static void on_menu_about(GtkMenuItem *item, gpointer data) { +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_fips_proxy(GtkMenuItem *item, gpointer data) { + (void)item; + (void)data; + g_print("[menu] FIPS URI scheme: not yet implemented (roadmap)\n"); +} + +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; - } + if (window == NULL) return; + GtkWidget *dialog = gtk_message_dialog_new( GTK_WINDOW(window), GTK_DIALOG_DESTROY_WITH_PARENT, @@ -143,306 +156,240 @@ static void on_menu_about(GtkMenuItem *item, gpointer data) { gtk_widget_show_all(dialog); } -/* Toggle the WebKit Web Inspector. */ -static void on_menu_inspector(GtkMenuItem *item, gpointer data) { - (void)item; - WebKitWebView *webview = WEBKIT_WEB_VIEW(data); - if (webview == NULL) return; +/* ---- Settings dialog ------------------------------------------------ */ - WebKitWebInspector *inspector = webkit_web_view_get_inspector(webview); - if (inspector == NULL) return; +static void on_settings_dialog_response(GtkDialog *dialog, gint response_id, + gpointer user_data) { + (void)user_data; + if (response_id == GTK_RESPONSE_ACCEPT) { + browser_settings_t *s = settings_get_mutable(); - /* Just show the inspector — WebKitGTK handles the toggle. */ - webkit_web_inspector_show(inspector); -} + /* Read the widgets by name from the dialog's content area. */ + GtkWidget *content = gtk_dialog_get_content_area(dialog); + GList *children = gtk_container_get_children(GTK_CONTAINER(content)); + for (GList *l = children; l != NULL; l = l->next) { + GtkWidget *child = GTK_WIDGET(l->data); + const char *name = gtk_widget_get_name(child); + if (name == NULL) continue; -/* History item callback: load the URL from the menu item label. */ -static void on_menu_history_item(GtkMenuItem *item, gpointer data) { - WebKitWebView *webview = WEBKIT_WEB_VIEW(data); - if (webview == NULL || item == NULL) return; - const char *url = gtk_menu_item_get_label(item); - if (url && url[0]) { - webkit_web_view_load_uri(webview, url); - } -} - -/* Clear history callback. */ -static void on_menu_history_clear(GtkMenuItem *item, gpointer data) { - (void)item; - (void)data; - history_clear(); - g_print("[history] cleared\n"); -} - -/* Rebuild the history submenu when it's shown (so it's always current). */ -static void on_history_menu_show(GtkWidget *menu, gpointer user_data) { - WebKitWebView *webview = WEBKIT_WEB_VIEW(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++) { - const 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); + if (strcmp(name, "restore_session") == 0) { + s->restore_session = gtk_toggle_button_get_active( + GTK_TOGGLE_BUTTON(child)); + } else if (strcmp(name, "new_tab_url") == 0) { + const char *text = gtk_entry_get_text(GTK_ENTRY(child)); + snprintf(s->new_tab_url, sizeof(s->new_tab_url), "%s", text); + } else if (strcmp(name, "show_tab_close_buttons") == 0) { + s->show_tab_close_buttons = gtk_toggle_button_get_active( + GTK_TOGGLE_BUTTON(child)); + } else if (strcmp(name, "middle_click_close") == 0) { + s->middle_click_close = gtk_toggle_button_get_active( + GTK_TOGGLE_BUTTON(child)); + } else if (strcmp(name, "ctrl_tab_switch") == 0) { + s->ctrl_tab_switch = gtk_toggle_button_get_active( + GTK_TOGGLE_BUTTON(child)); + } else if (strcmp(name, "tab_drag_reorder") == 0) { + s->tab_drag_reorder = gtk_toggle_button_get_active( + GTK_TOGGLE_BUTTON(child)); + } else if (strcmp(name, "max_tabs") == 0) { + const char *text = gtk_entry_get_text(GTK_ENTRY(child)); + s->max_tabs = atoi(text); + if (s->max_tabs < 1) s->max_tabs = 1; + } else if (strcmp(name, "tab_bar_position") == 0) { + gint active = gtk_combo_box_get_active(GTK_COMBO_BOX(child)); + switch (active) { + case 0: s->tab_bar_position = GTK_POS_TOP; break; + case 1: s->tab_bar_position = GTK_POS_BOTTOM; break; + case 2: s->tab_bar_position = GTK_POS_LEFT; break; + case 3: s->tab_bar_position = GTK_POS_RIGHT; break; + } } - GtkWidget *hitem = gtk_menu_item_new_with_label(label); - gtk_widget_set_tooltip_text(hitem, url); - g_signal_connect(hitem, "activate", - G_CALLBACK(on_menu_history_item), webview); - 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); - } + g_list_free(children); - gtk_widget_show_all(menu); + settings_save(); + tab_manager_apply_settings(); + g_print("[settings] Saved\n"); + } + gtk_widget_destroy(GTK_WIDGET(dialog)); } -/* Open a local file via file chooser dialog. */ -static void on_menu_open_file(GtkMenuItem *item, gpointer data) { +void on_menu_settings(GtkMenuItem *item, gpointer data) { (void)item; GtkWindow *window = GTK_WINDOW(data); if (window == NULL) return; - GtkWidget *dialog = gtk_file_chooser_dialog_new( - "Open File", - window, - GTK_FILE_CHOOSER_ACTION_OPEN, + const browser_settings_t *s = settings_get(); + + GtkWidget *dialog = gtk_dialog_new_with_buttons( + "Settings", window, + GTK_DIALOG_MODAL | GTK_DIALOG_DESTROY_WITH_PARENT, "_Cancel", GTK_RESPONSE_CANCEL, - "_Open", GTK_RESPONSE_ACCEPT, + "_OK", GTK_RESPONSE_ACCEPT, NULL); + gtk_window_set_default_size(GTK_WINDOW(dialog), 400, 350); - 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); + GtkWidget *content = gtk_dialog_get_content_area(GTK_DIALOG(dialog)); + gtk_container_set_border_width(GTK_CONTAINER(content), 12); + GtkBox *vbox = GTK_BOX(content); - 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); + /* Restore session checkbox. */ + GtkWidget *cb_restore = gtk_check_button_new_with_label( + "Restore session on startup"); + gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(cb_restore), + s->restore_session); + gtk_widget_set_name(cb_restore, "restore_session"); + gtk_box_pack_start(vbox, cb_restore, FALSE, FALSE, 4); - 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); - /* Get the web view from the window's data. */ - WebKitWebView *webview = g_object_get_data(G_OBJECT(window), "webview"); - if (webview) { - webkit_web_view_load_uri(webview, uri); - } - g_free(uri); - g_free(filename); + /* New tab URL entry. */ + GtkWidget *lbl_url = gtk_label_new("New tab URL:"); + gtk_widget_set_halign(lbl_url, GTK_ALIGN_START); + gtk_box_pack_start(vbox, lbl_url, FALSE, FALSE, 4); + GtkWidget *entry_url = gtk_entry_new(); + gtk_entry_set_text(GTK_ENTRY(entry_url), s->new_tab_url); + gtk_widget_set_name(entry_url, "new_tab_url"); + gtk_box_pack_start(vbox, entry_url, FALSE, FALSE, 4); + + /* Tab bar position combo. */ + GtkWidget *lbl_pos = gtk_label_new("Tab bar position:"); + gtk_widget_set_halign(lbl_pos, GTK_ALIGN_START); + gtk_box_pack_start(vbox, lbl_pos, FALSE, FALSE, 4); + GtkWidget *combo_pos = gtk_combo_box_text_new(); + gtk_combo_box_text_append(GTK_COMBO_BOX_TEXT(combo_pos), NULL, "Top"); + gtk_combo_box_text_append(GTK_COMBO_BOX_TEXT(combo_pos), NULL, "Bottom"); + gtk_combo_box_text_append(GTK_COMBO_BOX_TEXT(combo_pos), NULL, "Left"); + gtk_combo_box_text_append(GTK_COMBO_BOX_TEXT(combo_pos), NULL, "Right"); + int pos_active = 0; + switch (s->tab_bar_position) { + case GTK_POS_TOP: pos_active = 0; break; + case GTK_POS_BOTTOM: pos_active = 1; break; + case GTK_POS_LEFT: pos_active = 2; break; + case GTK_POS_RIGHT: pos_active = 3; break; } - gtk_widget_destroy(dialog); + gtk_combo_box_set_active(GTK_COMBO_BOX(combo_pos), pos_active); + gtk_widget_set_name(combo_pos, "tab_bar_position"); + gtk_box_pack_start(vbox, combo_pos, FALSE, FALSE, 4); + + /* Show tab close buttons. */ + GtkWidget *cb_close_btn = gtk_check_button_new_with_label( + "Show tab close buttons"); + gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(cb_close_btn), + s->show_tab_close_buttons); + gtk_widget_set_name(cb_close_btn, "show_tab_close_buttons"); + gtk_box_pack_start(vbox, cb_close_btn, FALSE, FALSE, 4); + + /* Middle-click to close. */ + GtkWidget *cb_mid_click = gtk_check_button_new_with_label( + "Middle-click to close tab"); + gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(cb_mid_click), + s->middle_click_close); + gtk_widget_set_name(cb_mid_click, "middle_click_close"); + gtk_box_pack_start(vbox, cb_mid_click, FALSE, FALSE, 4); + + /* Ctrl+Tab switching. */ + GtkWidget *cb_ctrl_tab = gtk_check_button_new_with_label( + "Ctrl+Tab to switch tabs"); + gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(cb_ctrl_tab), + s->ctrl_tab_switch); + gtk_widget_set_name(cb_ctrl_tab, "ctrl_tab_switch"); + gtk_box_pack_start(vbox, cb_ctrl_tab, FALSE, FALSE, 4); + + /* Tab drag reordering. */ + GtkWidget *cb_drag = gtk_check_button_new_with_label( + "Allow drag to reorder tabs"); + gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(cb_drag), + s->tab_drag_reorder); + gtk_widget_set_name(cb_drag, "tab_drag_reorder"); + gtk_box_pack_start(vbox, cb_drag, FALSE, FALSE, 4); + + /* Max tabs. */ + GtkWidget *lbl_max = gtk_label_new("Maximum tabs:"); + gtk_widget_set_halign(lbl_max, GTK_ALIGN_START); + gtk_box_pack_start(vbox, lbl_max, FALSE, FALSE, 4); + GtkWidget *entry_max = gtk_entry_new(); + char max_str[16]; + snprintf(max_str, sizeof(max_str), "%d", s->max_tabs); + gtk_entry_set_text(GTK_ENTRY(entry_max), max_str); + gtk_widget_set_name(entry_max, "max_tabs"); + gtk_box_pack_start(vbox, entry_max, FALSE, FALSE, 4); + + g_signal_connect(dialog, "response", + G_CALLBACK(on_settings_dialog_response), NULL); + + gtk_widget_show_all(dialog); } -/* Lock session: clear the signer but keep the saved identity. - * The user will need to re-authenticate (switch identity) to sign again. */ -static void on_menu_lock_session(GtkMenuItem *item, gpointer data) { - (void)item; +/* ---- Keyboard shortcuts --------------------------------------------- */ + +static gboolean on_key_press(GtkWidget *widget, GdkEventKey *event, + gpointer data) { + (void)widget; (void)data; - if (g_state.signer) { - nostr_signer_free(g_state.signer); - g_state.signer = NULL; + + const browser_settings_t *s = settings_get(); + guint mods = event->state & gtk_accelerator_get_default_mod_mask(); + + /* Ctrl+T — new tab. */ + if ((mods & GDK_CONTROL_MASK) && event->keyval == GDK_KEY_t) { + tab_manager_new_tab(NULL); + return TRUE; } - g_state.readonly = TRUE; /* no signing until re-auth */ - nostr_bridge_set_signer(NULL, g_state.pubkey_hex, TRUE); - g_print("[identity] session locked (signer cleared, identity preserved)\n"); -} -/* Build the hamburger menu. */ -static GtkWidget *build_hamburger_menu(GtkWidget *window, - WebKitWebView *webview) { - 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), - window); - g_signal_connect(item_reload, "activate", G_CALLBACK(on_menu_reload), - webview); - g_signal_connect(item_stop, "activate", G_CALLBACK(on_menu_stop), webview); - 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 — rebuilt dynamically each time it's shown. */ - GtkWidget *history_menu = gtk_menu_new(); - g_signal_connect(history_menu, "show", G_CALLBACK(on_history_menu_show), - webview); - - 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); - - gtk_menu_shell_append(GTK_MENU_SHELL(menu), - gtk_separator_menu_item_new()); - - /* Identity group — show current identity status. */ - char identity_label[80]; - const char *method_name = "none"; - switch (g_state.method) { - case KEY_STORE_METHOD_LOCAL: method_name = "local"; break; - case KEY_STORE_METHOD_SEED: method_name = "seed"; break; - case KEY_STORE_METHOD_READONLY: method_name = "readonly"; break; - case KEY_STORE_METHOD_NIP46: method_name = "nip46"; break; - case KEY_STORE_METHOD_NSIGNER: method_name = "nsigner"; break; - default: break; + /* Ctrl+W — close active tab. */ + if ((mods & GDK_CONTROL_MASK) && event->keyval == GDK_KEY_w) { + tab_manager_close_active(); + return TRUE; } - if (g_state.pubkey_hex[0]) { - /* Show first 8 chars of pubkey for identification. */ - char short_pubkey[12]; - memcpy(short_pubkey, g_state.pubkey_hex, 8); - short_pubkey[8] = '\0'; - snprintf(identity_label, sizeof(identity_label), - "Identity: %s… (%s)", short_pubkey, method_name); - } else { - snprintf(identity_label, sizeof(identity_label), "Identity: none"); - } - GtkWidget *item_identity = gtk_menu_item_new_with_label(identity_label); - gtk_widget_set_sensitive(item_identity, FALSE); - gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_identity); - 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(on_menu_switch_identity), - window); - g_signal_connect(item_lock, "activate", G_CALLBACK(on_menu_lock_session), NULL); - g_signal_connect(item_logout, "activate", G_CALLBACK(on_menu_logout), NULL); - gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_switch); - gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_lock); - gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_logout); - - gtk_menu_shell_append(GTK_MENU_SHELL(menu), - gtk_separator_menu_item_new()); - - /* Roadmap 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(on_menu_security_strip), NULL); - gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_security); - - GtkWidget *item_fips = gtk_menu_item_new_with_label("FIPS URI scheme…"); - GtkWidget *item_nostr = gtk_menu_item_new_with_label("Nostr signing status"); - g_signal_connect(item_fips, "activate", G_CALLBACK(on_menu_fips), NULL); - g_signal_connect(item_nostr, "activate", G_CALLBACK(on_menu_nostr_sign), - NULL); - gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_fips); - 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), - webview); - gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_inspector); - - GtkWidget *item_about = gtk_menu_item_new_with_label("About"); - g_signal_connect(item_about, "activate", G_CALLBACK(on_menu_about), - 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"); - return button; -} - -/* Normalize a bare string into a loadable URL. */ -static char *normalize_url(const char *input) { - if (input == NULL || input[0] == '\0') { - return NULL; - } - if (strstr(input, "://") != NULL) { - return g_strdup(input); - } - return g_strdup_printf("https://%s", input); -} - -static void on_url_activate(GtkEntry *entry, WebKitWebView *webview) { - const char *text = gtk_entry_get_text(entry); - char *url = normalize_url(text); - if (url != NULL) { - webkit_web_view_load_uri(webview, url); - g_free(url); - } -} - -static void on_load_changed(WebKitWebView *webview, - WebKitLoadEvent load_event, - GtkEntry *url_entry) { - if (load_event == WEBKIT_LOAD_COMMITTED) { - const gchar *uri = webkit_web_view_get_uri(webview); - if (uri != NULL) { - gtk_entry_set_text(url_entry, uri); + /* Ctrl+L — focus URL bar of active tab. */ + if ((mods & GDK_CONTROL_MASK) && event->keyval == GDK_KEY_l) { + 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); } - } 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); - g_print("[loaded] %s -- title: %s\n", - uri ? uri : "(null)", - (title && title[0]) ? title : "(none)"); - /* Add to history on successful page load. */ - if (uri != NULL && uri[0] != '\0') { - history_add(uri); + return TRUE; + } + + /* Ctrl+Tab / Ctrl+Shift+Tab — cycle tabs. */ + if (s->ctrl_tab_switch && (mods & GDK_CONTROL_MASK)) { + if (event->keyval == GDK_KEY_Tab) { + if (mods & GDK_SHIFT_MASK) { + tab_manager_prev(); + } else { + tab_manager_next(); + } + return TRUE; + } + if (event->keyval == GDK_KEY_ISO_Left_Tab) { + tab_manager_prev(); + return TRUE; } } -} -static gboolean on_load_failed(WebKitWebView *webview, - WebKitLoadEvent load_event, - gchar *failing_uri, - GError *error, - gpointer data) { - (void)webview; - (void)load_event; - (void)data; - g_print("[failed] %s -- %s\n", - failing_uri ? failing_uri : "(null)", - error ? error->message : "(unknown)"); + /* Ctrl+PageDown — next tab. */ + if ((mods & GDK_CONTROL_MASK) && event->keyval == GDK_KEY_Page_Down) { + tab_manager_next(); + return TRUE; + } + + /* Ctrl+PageUp — previous tab. */ + if ((mods & GDK_CONTROL_MASK) && event->keyval == GDK_KEY_Page_Up) { + tab_manager_prev(); + return TRUE; + } + return FALSE; } +/* ---- Window destroy ------------------------------------------------- */ + static void on_window_destroy(GtkWidget *widget, gpointer data) { (void)widget; (void)data; + + /* Save the session before cleanup. */ + session_save(); + if (g_state.signer) { nostr_signer_free(g_state.signer); g_state.signer = NULL; @@ -451,22 +398,17 @@ static void on_window_destroy(GtkWidget *widget, gpointer data) { gtk_main_quit(); } -/* ---- Login flow ------------------------------------------------------ * - * Try to restore a saved identity. If none, show the login dialog. - * Returns 0 on success (signer or readonly loaded), -1 if user cancelled. - */ +/* ---- Login flow ----------------------------------------------------- */ + static int do_login(GtkWindow *parent) { - /* Initialize nostr_core_lib. */ if (nostr_init() != NOSTR_SUCCESS) { g_printerr("[login] Failed to initialize nostr_core_lib\n"); return -1; } - /* Show the login dialog every time — no plaintext key persistence. - * Encrypted key storage will be added in a future phase. */ login_result_t result; if (login_dialog_run(parent, &result) != 0) { - return -1; /* cancelled */ + return -1; } g_state.signer = result.signer; @@ -480,127 +422,87 @@ static int do_login(GtkWindow *parent) { return 0; } -/* ---- Main ------------------------------------------------------------ */ +/* ---- Main ----------------------------------------------------------- */ int main(int argc, char **argv) { - /* Ignore SIGPIPE — n_signer transports (qrexec, unix, tcp) may close - * pipes abruptly, and we don't want that to kill the browser. */ (void)signal(SIGPIPE, SIG_IGN); gtk_init(&argc, &argv); - /* Load URL history from disk. */ + /* Load settings and history from disk. */ + settings_load(); history_load(); - const char *start_url = (argc > 1) ? argv[1] : "https://example.com"; - char *normalized = normalize_url(start_url); + const char *start_url = (argc > 1) ? argv[1] : NULL; /* 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, "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); /* Login before showing the browser. */ - if (do_login(GTK_WINDOW(window)) != 0) { - /* User cancelled login — exit without gtk_main_quit (we haven't - * entered gtk_main yet, so calling gtk_main_quit would crash). */ + if (do_login(g_window) != 0) { g_print("[login] Cancelled, exiting.\n"); nostr_cleanup(); return EXIT_SUCCESS; } - /* Vertical box: toolbar on top, web view below. */ - GtkWidget *vbox = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0); - gtk_container_add(GTK_CONTAINER(window), vbox); + /* Use the default WebKitWebContext — it comes with proper networking + * (cookies, cache, soup session) that a freshly created context lacks. + * All tabs will create webviews from this shared context. */ + WebKitWebContext *web_ctx = webkit_web_context_get_default(); - WebKitWebView *webview = WEBKIT_WEB_VIEW(webkit_web_view_new()); - - /* ── Security strip: disable web security restrictions ─────── - * The "reckless browser" thesis: identity and transport are handled - * at a different layer (Nostr keys + FIPS mesh), so the browser's - * own security sandbox (CORS, SOP, TLS cert enforcement, mixed - * content blocking) gets in the way and is deliberately removed. - */ - - /* Enable developer extras (Web Inspector) for debugging. */ - WebKitSettings *settings = webkit_web_view_get_settings(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); - - /* Allow file:// pages to access other file:// resources and make - * cross-origin requests (needed for our test page to call - * sovereign://). */ - webkit_settings_set_allow_file_access_from_file_urls(settings, TRUE); - webkit_settings_set_allow_universal_access_from_file_urls(settings, TRUE); - - /* Disable mixed content blocking — allow HTTP resources on HTTPS - * pages (FIPS/nostr:// content may not use TLS). */ - webkit_settings_set_allow_modal_dialogs(settings, TRUE); - - /* Get the web context for scheme + security manager registration. */ - WebKitWebContext *web_ctx = webkit_web_view_get_context(webview); - - /* Register sovereign:// as a secure, local, CORS-enabled scheme so - * that fetch() from any page can call it without cross-origin - * restrictions. */ - WebKitSecurityManager *sec_mgr = webkit_web_context_get_security_manager(web_ctx); + /* ── Security strip: disable web security restrictions ─────── */ + WebKitSecurityManager *sec_mgr = + webkit_web_context_get_security_manager(web_ctx); webkit_security_manager_register_uri_scheme_as_secure(sec_mgr, "sovereign"); webkit_security_manager_register_uri_scheme_as_local(sec_mgr, "sovereign"); - webkit_security_manager_register_uri_scheme_as_cors_enabled(sec_mgr, "sovereign"); - - /* Also register file:// as secure so local pages can call our bridge. */ + webkit_security_manager_register_uri_scheme_as_cors_enabled(sec_mgr, + "sovereign"); webkit_security_manager_register_uri_scheme_as_secure(sec_mgr, "file"); webkit_security_manager_register_uri_scheme_as_local(sec_mgr, "file"); /* Accept any TLS certificate (FIPS uses Noise IK, not TLS CAs). */ - WebKitWebsiteDataManager *data_mgr = webkit_web_context_get_website_data_manager(web_ctx); + WebKitWebsiteDataManager *data_mgr = + webkit_web_context_get_website_data_manager(web_ctx); webkit_website_data_manager_set_tls_errors_policy( data_mgr, WEBKIT_TLS_ERRORS_POLICY_IGNORE); - /* Register the sovereign:// URI scheme for the window.nostr bridge - * and browser-internal pages (sovereign://security). */ + /* Register the sovereign:// URI scheme for the window.nostr bridge. */ nostr_bridge_register(web_ctx, g_state.signer, g_state.pubkey_hex, g_state.readonly); - nostr_bridge_set_security_refs(settings, sec_mgr); - /* Inject window.nostr into every page before page scripts run. */ - nostr_inject_setup(webview); + /* 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); - /* Toolbar: [hamburger] [url entry], horizontal. */ - GtkWidget *toolbar = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 4); - gtk_widget_set_margin_top(toolbar, 4); - gtk_widget_set_margin_bottom(toolbar, 4); - gtk_widget_set_margin_start(toolbar, 4); - gtk_widget_set_margin_end(toolbar, 4); - gtk_box_pack_start(GTK_BOX(vbox), toolbar, FALSE, FALSE, 0); + /* Initialize the tab manager. */ + tab_manager_init(GTK_CONTAINER(vbox), web_ctx, g_window); - GtkWidget *hamburger = build_hamburger_menu(window, webview); - gtk_box_pack_start(GTK_BOX(toolbar), hamburger, FALSE, FALSE, 0); + /* Try to restore the previous session. If nothing was restored, + * open a single tab with the start URL or the default new-tab URL. */ + int restored = session_restore(); + if (restored == 0) { + const char *url = start_url; + if (url == NULL || url[0] == '\0') { + url = settings_get()->new_tab_url; + } + tab_manager_new_tab(url); + } - GtkWidget *url_entry = gtk_entry_new(); - gtk_entry_set_text(GTK_ENTRY(url_entry), - normalized ? normalized : start_url); - gtk_box_pack_start(GTK_BOX(toolbar), url_entry, TRUE, TRUE, 0); - - /* Store webview reference on the window for menu callbacks. */ - g_object_set_data(G_OBJECT(window), "webview", webview); - - gtk_box_pack_start(GTK_BOX(vbox), GTK_WIDGET(webview), TRUE, TRUE, 0); - - /* Wire signals. */ - g_signal_connect(url_entry, "activate", G_CALLBACK(on_url_activate), - webview); - g_signal_connect(webview, "load-changed", G_CALLBACK(on_load_changed), - url_entry); - g_signal_connect(webview, "load-failed", G_CALLBACK(on_load_failed), - NULL); - - /* Load the start URL. */ - if (normalized != NULL) { - webkit_web_view_load_uri(webview, normalized); - g_free(normalized); + /* 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); + } } gtk_widget_show_all(window); diff --git a/src/session.c b/src/session.c new file mode 100644 index 0000000..f3597a3 --- /dev/null +++ b/src/session.c @@ -0,0 +1,108 @@ +/* + * session.c — tab session save/restore for sovereign_browser + * + * Saves open tab URLs to ~/.sovereign_browser/session.txt (one per line) + * and restores them on startup if settings.restore_session is true. + */ + +#include "session.h" +#include "settings.h" +#include "tab_manager.h" + +#include +#include +#include +#include +#include +#include +#include + +#define SESSION_URL_MAX 2048 + +/* ── Path helper ──────────────────────────────────────────────────── */ + +static int session_path(char *out, size_t out_sz) { + const char *home = getenv("HOME"); + if (home == NULL || home[0] == '\0') { + return -1; + } + + char dir[512]; + int n = snprintf(dir, sizeof(dir), "%s/.sovereign_browser", home); + if (n < 0 || (size_t)n >= sizeof(dir)) { + return -1; + } + if (mkdir(dir, 0700) != 0 && errno != EEXIST) { + return -1; + } + + n = snprintf(out, out_sz, "%s/session.txt", dir); + if (n < 0 || (size_t)n >= out_sz) { + return -1; + } + return 0; +} + +/* ── Save ─────────────────────────────────────────────────────────── */ + +void session_save(void) { + char path[512]; + if (session_path(path, sizeof(path)) != 0) { + return; + } + + FILE *f = fopen(path, "w"); + if (f == NULL) { + return; + } + fchmod(fileno(f), 0600); + + int count = tab_manager_count(); + for (int i = 0; i < count; i++) { + const char *url = tab_manager_get_url(i); + if (url != NULL && url[0] != '\0') { + fprintf(f, "%s\n", url); + } + } + fclose(f); + g_print("[session] Saved %d tab(s)\n", count); +} + +/* ── Restore ──────────────────────────────────────────────────────── */ + +int session_restore(void) { + const browser_settings_t *s = settings_get(); + if (!s->restore_session) { + return 0; + } + + char path[512]; + if (session_path(path, sizeof(path)) != 0) { + return 0; + } + + FILE *f = fopen(path, "r"); + if (f == NULL) { + return 0; + } + + int restored = 0; + char line[SESSION_URL_MAX]; + while (fgets(line, sizeof(line), f) != NULL) { + /* Strip newline. */ + size_t len = strlen(line); + while (len > 0 && (line[len - 1] == '\n' || line[len - 1] == '\r')) { + line[--len] = '\0'; + } + if (len == 0) continue; + + int index = tab_manager_new_tab(line); + if (index >= 0) { + restored++; + } + } + fclose(f); + + g_print("[session] Restored %d tab(s)\n", restored); + return restored; +} diff --git a/src/session.h b/src/session.h new file mode 100644 index 0000000..ccfbfa5 --- /dev/null +++ b/src/session.h @@ -0,0 +1,35 @@ +/* + * session.h — tab session save/restore for sovereign_browser + * + * Saves the list of open tab URLs to ~/.sovereign_browser/session.txt + * on window close, and restores them on startup if settings.restore_session + * is true. + */ + +#ifndef SESSION_H +#define SESSION_H + +#ifdef __cplusplus +extern "C" { +#endif + +/* + * Save the current set of open tab URLs to disk. + * Called on window destroy (before gtk_main_quit). + */ +void session_save(void); + +/* + * Restore the saved session by creating a tab for each URL in + * ~/.sovereign_browser/session.txt. + * + * Returns the number of tabs created, or 0 if no session was restored + * (file missing, empty, or restore_session setting is false). + */ +int session_restore(void); + +#ifdef __cplusplus +} +#endif + +#endif /* SESSION_H */ diff --git a/src/settings.c b/src/settings.c new file mode 100644 index 0000000..ff3419e --- /dev/null +++ b/src/settings.c @@ -0,0 +1,201 @@ +/* + * settings.c — browser preferences for sovereign_browser + * + * Persists settings to ~/.sovereign_browser/settings.conf as key=value lines. + */ + +#include "settings.h" + +#include + +#include +#include +#include +#include /* strcasecmp */ +#include +#include +#include +#include + +/* ── Global singleton ─────────────────────────────────────────────── */ + +static browser_settings_t g_settings; + +/* ── Defaults ─────────────────────────────────────────────────────── */ + +static void settings_set_defaults(browser_settings_t *s) { + s->restore_session = TRUE; + snprintf(s->new_tab_url, sizeof(s->new_tab_url), "%s", + SETTINGS_NEW_TAB_URL_DEFAULT); + s->tab_bar_position = GTK_POS_TOP; + s->show_tab_close_buttons = TRUE; + s->middle_click_close = TRUE; + s->ctrl_tab_switch = TRUE; + s->max_tabs = SETTINGS_MAX_TABS_DEFAULT; + s->tab_drag_reorder = TRUE; +} + +/* ── Path helper ──────────────────────────────────────────────────── */ + +static int settings_path(char *out, size_t out_sz) { + const char *home = getenv("HOME"); + if (home == NULL || home[0] == '\0') { + return -1; + } + + char dir[512]; + int n = snprintf(dir, sizeof(dir), "%s/.sovereign_browser", home); + if (n < 0 || (size_t)n >= sizeof(dir)) { + return -1; + } + if (mkdir(dir, 0700) != 0 && errno != EEXIST) { + return -1; + } + + n = snprintf(out, out_sz, "%s/settings.conf", dir); + if (n < 0 || (size_t)n >= out_sz) { + return -1; + } + return 0; +} + +/* ── Parsing helpers ──────────────────────────────────────────────── */ + +static gboolean parse_bool(const char *value, gboolean fallback) { + if (value == NULL) return fallback; + if (strcasecmp(value, "true") == 0 || + strcasecmp(value, "yes") == 0 || + strcasecmp(value, "1") == 0 || + strcasecmp(value, "on") == 0) { + return TRUE; + } + if (strcasecmp(value, "false") == 0 || + strcasecmp(value, "no") == 0 || + strcasecmp(value, "0") == 0 || + strcasecmp(value, "off") == 0) { + return FALSE; + } + return fallback; +} + +static int parse_int(const char *value, int fallback) { + if (value == NULL || value[0] == '\0') return fallback; + char *end = NULL; + long v = strtol(value, &end, 10); + if (end == value) return fallback; + return (int)v; +} + +static int parse_position(const char *value, int fallback) { + if (value == NULL) return fallback; + if (strcasecmp(value, "top") == 0) return GTK_POS_TOP; + if (strcasecmp(value, "bottom") == 0) return GTK_POS_BOTTOM; + if (strcasecmp(value, "left") == 0) return GTK_POS_LEFT; + if (strcasecmp(value, "right") == 0) return GTK_POS_RIGHT; + /* Allow numeric GTK_POS_* values too. */ + return parse_int(value, fallback); +} + +/* ── Load / Save ──────────────────────────────────────────────────── */ + +void settings_load(void) { + settings_set_defaults(&g_settings); + + char path[512]; + if (settings_path(path, sizeof(path)) != 0) { + return; + } + + FILE *f = fopen(path, "r"); + if (f == NULL) { + return; + } + + char line[1024]; + while (fgets(line, sizeof(line), f) != NULL) { + /* Strip newline. */ + size_t len = strlen(line); + while (len > 0 && (line[len - 1] == '\n' || line[len - 1] == '\r')) { + line[--len] = '\0'; + } + if (len == 0 || line[0] == '#') continue; + + /* Split key=value. */ + char *eq = strchr(line, '='); + if (eq == NULL) continue; + *eq = '\0'; + char *key = line; + char *value = eq + 1; + + /* Trim whitespace from key. */ + while (*key == ' ' || *key == '\t') key++; + char *kend = key + strlen(key); + while (kend > key && (kend[-1] == ' ' || kend[-1] == '\t')) *--kend = '\0'; + + /* Trim whitespace from value. */ + while (*value == ' ' || *value == '\t') value++; + + if (strcmp(key, "restore_session") == 0) { + g_settings.restore_session = parse_bool(value, g_settings.restore_session); + } else if (strcmp(key, "new_tab_url") == 0) { + snprintf(g_settings.new_tab_url, sizeof(g_settings.new_tab_url), + "%s", value); + } else if (strcmp(key, "tab_bar_position") == 0) { + g_settings.tab_bar_position = parse_position(value, g_settings.tab_bar_position); + } else if (strcmp(key, "show_tab_close_buttons") == 0) { + g_settings.show_tab_close_buttons = parse_bool(value, g_settings.show_tab_close_buttons); + } else if (strcmp(key, "middle_click_close") == 0) { + g_settings.middle_click_close = parse_bool(value, g_settings.middle_click_close); + } else if (strcmp(key, "ctrl_tab_switch") == 0) { + g_settings.ctrl_tab_switch = parse_bool(value, g_settings.ctrl_tab_switch); + } else if (strcmp(key, "max_tabs") == 0) { + g_settings.max_tabs = parse_int(value, g_settings.max_tabs); + if (g_settings.max_tabs < 1) g_settings.max_tabs = 1; + } else if (strcmp(key, "tab_drag_reorder") == 0) { + g_settings.tab_drag_reorder = parse_bool(value, g_settings.tab_drag_reorder); + } + /* Unknown keys are silently ignored. */ + } + fclose(f); +} + +void settings_save(void) { + char path[512]; + if (settings_path(path, sizeof(path)) != 0) { + return; + } + + FILE *f = fopen(path, "w"); + if (f == NULL) { + return; + } + fchmod(fileno(f), 0600); + + const char *pos_str = "top"; + switch (g_settings.tab_bar_position) { + case GTK_POS_TOP: pos_str = "top"; break; + case GTK_POS_BOTTOM: pos_str = "bottom"; break; + case GTK_POS_LEFT: pos_str = "left"; break; + case GTK_POS_RIGHT: pos_str = "right"; break; + } + + fprintf(f, "# sovereign_browser settings\n"); + fprintf(f, "restore_session=%s\n", g_settings.restore_session ? "true" : "false"); + fprintf(f, "new_tab_url=%s\n", g_settings.new_tab_url); + fprintf(f, "tab_bar_position=%s\n", pos_str); + fprintf(f, "show_tab_close_buttons=%s\n", g_settings.show_tab_close_buttons ? "true" : "false"); + fprintf(f, "middle_click_close=%s\n", g_settings.middle_click_close ? "true" : "false"); + fprintf(f, "ctrl_tab_switch=%s\n", g_settings.ctrl_tab_switch ? "true" : "false"); + fprintf(f, "max_tabs=%d\n", g_settings.max_tabs); + fprintf(f, "tab_drag_reorder=%s\n", g_settings.tab_drag_reorder ? "true" : "false"); + + fclose(f); +} + +const browser_settings_t *settings_get(void) { + return &g_settings; +} + +browser_settings_t *settings_get_mutable(void) { + return &g_settings; +} diff --git a/src/settings.h b/src/settings.h new file mode 100644 index 0000000..effa5eb --- /dev/null +++ b/src/settings.h @@ -0,0 +1,61 @@ +/* + * settings.h — browser preferences for sovereign_browser + * + * Persists user-configurable settings to ~/.sovereign_browser/settings.conf + * as simple key=value lines. A global singleton is loaded at startup and + * accessed via settings_get(). + */ + +#ifndef SETTINGS_H +#define SETTINGS_H + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +#define SETTINGS_NEW_TAB_URL_DEFAULT "https://example.com" +#define SETTINGS_NEW_TAB_URL_MAX 512 +#define SETTINGS_MAX_TABS_DEFAULT 50 + +typedef struct { + gboolean restore_session; /* restore open tabs on next launch */ + char new_tab_url[SETTINGS_NEW_TAB_URL_MAX]; + int tab_bar_position; /* GTK_POS_TOP / BOTTOM / LEFT / RIGHT */ + gboolean show_tab_close_buttons; /* show per-tab close button */ + gboolean middle_click_close; /* middle-click on tab closes it */ + gboolean ctrl_tab_switch; /* Ctrl+Tab cycles tabs */ + int max_tabs; /* maximum simultaneous tabs */ + gboolean tab_drag_reorder; /* allow drag to reorder tabs */ +} browser_settings_t; + +/* + * Load settings from disk into the global singleton. + * If the file doesn't exist or a key is missing, defaults are used. + * Call once at startup. + */ +void settings_load(void); + +/* + * Save the current global settings to disk. + */ +void settings_save(void); + +/* + * Get a pointer to the global settings singleton. + * Always valid after settings_load(). + */ +const browser_settings_t *settings_get(void); + +/* + * Get a mutable pointer to the global settings. + * Used by the settings dialog to modify values; call settings_save() after. + */ +browser_settings_t *settings_get_mutable(void); + +#ifdef __cplusplus +} +#endif + +#endif /* SETTINGS_H */ diff --git a/src/tab_manager.c b/src/tab_manager.c new file mode 100644 index 0000000..4c52086 --- /dev/null +++ b/src/tab_manager.c @@ -0,0 +1,783 @@ +/* + * 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 "history.h" +#include "version.h" + +#include +#include + +/* ── 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_fips_proxy(GtkMenuItem *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); + +/* ── Static state ─────────────────────────────────────────────────── */ + +static GtkWidget *g_notebook = NULL; +static WebKitWebContext *g_ctx = NULL; +static GtkWindow *g_window = NULL; + +/* Dynamic array of tab_info_t pointers, indexed by notebook page number. */ +static tab_info_t **g_tabs = NULL; +static int g_tab_count = 0; +static int g_tab_cap = 0; + +/* ── Forward declarations ─────────────────────────────────────────── */ + +static tab_info_t *tab_create(const char *url); +static GtkWidget *build_tab_label(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); + +/* ── URL helper (same logic as main.c's normalize_url) ────────────── */ + +static char *normalize_url(const char *input) { + if (input == NULL || input[0] == '\0') { + return NULL; + } + if (strstr(input, "://") != NULL) { + return g_strdup(input); + } + return g_strdup_printf("https://%s", input); +} + +/* ── 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; + int index = gtk_notebook_page_num(GTK_NOTEBOOK(g_notebook), tab->page); + if (index >= 0) { + tab_manager_close_tab(index); + } +} + +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; + int index = gtk_notebook_page_num(GTK_NOTEBOOK(g_notebook), tab->page); + + 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_dup = gtk_menu_item_new_with_label("Duplicate Tab"); + g_signal_connect_swapped(item_dup, "activate", + G_CALLBACK(tab_manager_duplicate), + GINT_TO_POINTER(index)); + gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_dup); + + GtkWidget *item_reload = gtk_menu_item_new_with_label("Reload Tab"); + g_signal_connect_swapped(item_reload, "activate", + G_CALLBACK(tab_manager_reload), + GINT_TO_POINTER(index)); + gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_reload); + + gtk_widget_show_all(menu); + gtk_menu_popup_at_pointer(GTK_MENU(menu), (GdkEvent *)event); + return TRUE; + } + + return FALSE; +} + +/* Proxy callback for "New Tab" in the context menu — just calls new_tab. */ +static void on_tab_close_clicked_proxy_new(GtkMenuItem *item, gpointer data) { + (void)item; + (void)data; + tab_manager_new_tab(NULL); +} + +static GtkWidget *build_tab_label(tab_info_t *tab) { + GtkWidget *box = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 4); + + /* Favicon placeholder icon. */ + GtkWidget *icon = gtk_image_new_from_icon_name("text-html", + GTK_ICON_SIZE_MENU); + gtk_box_pack_start(GTK_BOX(box), icon, FALSE, FALSE, 0); + + /* Title label. */ + 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_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); + gtk_box_pack_start(GTK_BOX(box), close_btn, FALSE, FALSE, 0); + } + + /* Connect button-press for middle-click and right-click on the label. */ + gtk_widget_add_events(box, GDK_BUTTON_PRESS_MASK); + g_signal_connect(box, "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); + char *url = normalize_url(text); + if (url != NULL) { + webkit_web_view_load_uri(tab->webview, url); + g_free(url); + } +} + +static void on_load_changed(WebKitWebView *webview, + WebKitLoadEvent load_event, + gpointer user_data) { + tab_info_t *tab = (tab_info_t *)user_data; + + if (load_event == WEBKIT_LOAD_COMMITTED) { + const gchar *uri = webkit_web_view_get_uri(webview); + if (uri != NULL) { + gtk_entry_set_text(GTK_ENTRY(tab->url_entry), uri); + snprintf(tab->current_url, sizeof(tab->current_url), "%s", uri); + } + } 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); + g_print("[loaded] %s -- title: %s\n", + uri ? uri : "(null)", + (title && title[0]) ? title : "(none)"); + + /* Update tab title. */ + if (title && title[0]) { + int index = gtk_notebook_page_num(GTK_NOTEBOOK(g_notebook), + tab->page); + if (index >= 0) { + tab_manager_set_title(index, title); + } + } + + /* Add to history. */ + if (uri != NULL && uri[0] != '\0') { + history_add(uri); + } + } +} + +static gboolean on_load_failed(WebKitWebView *webview, + WebKitLoadEvent load_event, + gchar *failing_uri, + GError *error, + gpointer data) { + (void)webview; + (void)load_event; + (void)data; + g_print("[failed] %s -- %s\n", + failing_uri ? failing_uri : "(null)", + error ? error->message : "(unknown)"); + return FALSE; +} + +/* ── 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); + } +} + +static void on_menu_inspector(GtkMenuItem *item, gpointer data) { + (void)item; + (void)data; + tab_info_t *tab = tab_manager_get_active(); + if (tab && tab->webview) { + WebKitWebInspector *insp = webkit_web_view_get_inspector(tab->webview); + if (insp) webkit_web_inspector_show(insp); + } +} + +static void on_menu_open_file(GtkMenuItem *item, gpointer data) { + (void)item; + (void)data; + if (g_window == NULL) return; + + GtkWidget *dialog = gtk_file_chooser_dialog_new( + "Open File", g_window, GTK_FILE_CHOOSER_ACTION_OPEN, + "_Cancel", GTK_RESPONSE_CANCEL, + "_Open", GTK_RESPONSE_ACCEPT, NULL); + + GtkFileFilter *filter_html = gtk_file_filter_new(); + gtk_file_filter_set_name(filter_html, "HTML files"); + gtk_file_filter_add_pattern(filter_html, "*.html"); + gtk_file_filter_add_pattern(filter_html, "*.htm"); + gtk_file_chooser_add_filter(GTK_FILE_CHOOSER(dialog), filter_html); + + GtkFileFilter *filter_all = gtk_file_filter_new(); + gtk_file_filter_set_name(filter_all, "All files"); + gtk_file_filter_add_pattern(filter_all, "*"); + gtk_file_chooser_add_filter(GTK_FILE_CHOOSER(dialog), filter_all); + + if (gtk_dialog_run(GTK_DIALOG(dialog)) == GTK_RESPONSE_ACCEPT) { + char *filename = gtk_file_chooser_get_filename(GTK_FILE_CHOOSER(dialog)); + char *uri = g_strdup_printf("file://%s", filename); + tab_info_t *tab = tab_manager_get_active(); + if (tab && tab->webview) { + webkit_web_view_load_uri(tab->webview, uri); + } + g_free(uri); + g_free(filename); + } + gtk_widget_destroy(dialog); +} + +static void on_menu_history_item(GtkMenuItem *item, gpointer data) { + (void)data; + tab_info_t *tab = tab_manager_get_active(); + if (tab == NULL || tab->webview == NULL || item == NULL) return; + const char *url = gtk_menu_item_get_label(item); + 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++) { + const 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); + 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); +} + +/* ── Hamburger menu builder ───────────────────────────────────────── */ + +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); + + gtk_menu_shell_append(GTK_MENU_SHELL(menu), + gtk_separator_menu_item_new()); + + /* Identity group — delegated to main.c (app_state_t). */ + GtkWidget *item_switch = gtk_menu_item_new_with_label("Switch Identity…"); + GtkWidget *item_lock = gtk_menu_item_new_with_label("Lock Session"); + GtkWidget *item_logout = gtk_menu_item_new_with_label("Logout"); + g_signal_connect(item_switch, "activate", + G_CALLBACK(app_menu_switch_identity_proxy), g_window); + g_signal_connect(item_lock, "activate", + G_CALLBACK(app_menu_lock_session_proxy), NULL); + g_signal_connect(item_logout, "activate", + G_CALLBACK(app_menu_logout_proxy), NULL); + gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_switch); + gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_lock); + gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_logout); + + gtk_menu_shell_append(GTK_MENU_SHELL(menu), + gtk_separator_menu_item_new()); + + /* Roadmap 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_fips = gtk_menu_item_new_with_label("FIPS URI scheme…"); + GtkWidget *item_nostr = gtk_menu_item_new_with_label("Nostr signing status"); + g_signal_connect(item_fips, "activate", + G_CALLBACK(app_menu_fips_proxy), NULL); + g_signal_connect(item_nostr, "activate", + G_CALLBACK(app_menu_nostr_sign_proxy), NULL); + gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_fips); + 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_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"); + 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. */ + 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--; +} + +/* ── Tab creation ─────────────────────────────────────────────────── */ + +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 from the shared context. */ + 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); + + /* Inject window.nostr into this webview. */ + nostr_inject_setup(tab->webview); + + /* Build the per-tab page: toolbar on top, webview below. */ + tab->page = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0); + + GtkWidget *toolbar = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 4); + gtk_widget_set_margin_top(toolbar, 4); + gtk_widget_set_margin_bottom(toolbar, 4); + gtk_widget_set_margin_start(toolbar, 4); + gtk_widget_set_margin_end(toolbar, 4); + gtk_box_pack_start(GTK_BOX(tab->page), toolbar, FALSE, FALSE, 0); + + tab->hamburger = build_hamburger_menu(tab); + gtk_box_pack_start(GTK_BOX(toolbar), tab->hamburger, FALSE, FALSE, 0); + + tab->url_entry = gtk_entry_new(); + gtk_entry_set_text(GTK_ENTRY(tab->url_entry), default_url); + gtk_box_pack_start(GTK_BOX(toolbar), tab->url_entry, TRUE, TRUE, 0); + + 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->webview, "load-changed", + G_CALLBACK(on_load_changed), tab); + g_signal_connect(tab->webview, "load-failed", + G_CALLBACK(on_load_failed), NULL); + + /* Load the URL. */ + webkit_web_view_load_uri(tab->webview, default_url); + g_free(default_url); + + return tab; +} + +/* ── New tab button ───────────────────────────────────────────────── */ + +static void on_new_tab_clicked(GtkButton *btn, gpointer data) { + (void)btn; + (void)data; + tab_manager_new_tab(NULL); +} + +/* ── Public API ───────────────────────────────────────────────────── */ + +void tab_manager_init(GtkContainer *parent, + WebKitWebContext *ctx, + GtkWindow *window) { + g_ctx = ctx; + g_window = window; + + g_notebook = gtk_notebook_new(); + gtk_notebook_set_scrollable(GTK_NOTEBOOK(g_notebook), TRUE); + gtk_notebook_set_show_border(GTK_NOTEBOOK(g_notebook), FALSE); + + /* New-tab button as an action widget at the end of the tab strip. */ + 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), NULL); + gtk_widget_show_all(new_btn); + gtk_notebook_set_action_widget(GTK_NOTEBOOK(g_notebook), new_btn, + GTK_PACK_END); + + gtk_container_add(parent, g_notebook); + + tab_manager_apply_settings(); +} + +void tab_manager_apply_settings(void) { + if (g_notebook == NULL) return; + const browser_settings_t *s = settings_get(); + + 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; + } + + /* Add to notebook. */ + int page_num = gtk_notebook_append_page(GTK_NOTEBOOK(g_notebook), + tab->page, tab->tab_label); + gtk_notebook_set_tab_reorderable(GTK_NOTEBOOK(g_notebook), tab->page, + s->tab_drag_reorder); + + /* Switch to the new tab. */ + gtk_notebook_set_current_page(GTK_NOTEBOOK(g_notebook), page_num); + + /* Show everything. */ + gtk_widget_show_all(tab->page); + gtk_widget_show_all(tab->tab_label); + + g_print("[tabs] Created tab %d, total: %d\n", page_num, g_tab_count); + return page_num; +} + +void tab_manager_close_tab(int index) { + if (index < 0 || index >= g_tab_count) return; + + tab_info_t *tab = g_tabs[index]; + if (tab == NULL) return; + + /* Remove from notebook (this destroys the page widget). */ + gtk_notebook_remove_page(GTK_NOTEBOOK(g_notebook), index); + + /* Remove from our array. */ + tab_array_remove(index); + + g_print("[tabs] Closed tab %d, remaining: %d\n", index, g_tab_count); + + /* If no tabs left, quit the app. */ + if (g_tab_count == 0) { + g_print("[tabs] Last tab closed, exiting.\n"); + if (g_window) { + gtk_window_close(g_window); + } + } +} + +void tab_manager_close_active(void) { + int index = tab_manager_get_active_index(); + if (index >= 0) { + tab_manager_close_tab(index); + } +} + +int tab_manager_get_active_index(void) { + if (g_notebook == NULL) return -1; + return gtk_notebook_get_current_page(GTK_NOTEBOOK(g_notebook)); +} + +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) { + int index = tab_manager_get_active_index(); + if (index < 0 || index >= g_tab_count) return NULL; + return g_tabs[index]; +} + +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; +} + +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_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_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); + } +} diff --git a/src/tab_manager.h b/src/tab_manager.h new file mode 100644 index 0000000..dd9be1b --- /dev/null +++ b/src/tab_manager.h @@ -0,0 +1,142 @@ +/* + * tab_manager.h — multi-tab management for sovereign_browser + * + * Wraps a GtkNotebook and manages per-tab state: each tab has its own + * toolbar (hamburger menu + URL entry) and WebKitWebView. All webviews + * share a single WebKitWebContext so the sovereign:// bridge, security + * settings, and TLS policy apply to every tab. + */ + +#ifndef TAB_MANAGER_H +#define TAB_MANAGER_H + +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +#define TAB_TITLE_MAX 256 +#define TAB_URL_MAX 2048 + +typedef struct { + WebKitWebView *webview; + GtkWidget *url_entry; + GtkWidget *hamburger; + GtkWidget *page; /* the vertical box (toolbar + webview) */ + GtkWidget *tab_label; /* composite label widget for the tab strip */ + GtkWidget *title_label; /* child of tab_label */ + char current_url[TAB_URL_MAX]; + char title[TAB_TITLE_MAX]; +} tab_info_t; + +/* + * Initialize the tab manager. Creates a GtkNotebook, adds it to parent, + * and stores the shared web context for creating new webviews. + * + * parent: the GtkContainer (typically a GtkBox) to add the notebook to + * ctx: the shared WebKitWebContext for all tabs + * window: the top-level GtkWindow (for menu dialogs, file choosers, etc.) + */ +void tab_manager_init(GtkContainer *parent, + WebKitWebContext *ctx, + GtkWindow *window); + +/* + * Create a new tab and load the given URL (or the default new-tab URL + * if url is NULL). Returns the tab index, or -1 on failure (e.g. max + * tabs reached). + */ +int tab_manager_new_tab(const char *url); + +/* + * Close the tab at the given index. If it's the last tab, the window + * destroy handler will fire (caller is responsible for quitting). + */ +void tab_manager_close_tab(int index); + +/* + * Close the currently active tab. + */ +void tab_manager_close_active(void); + +/* + * Get the index of the currently active tab. + */ +int tab_manager_get_active_index(void); + +/* + * Switch to the tab at the given index. + */ +void tab_manager_switch_to(int index); + +/* + * Get the tab_info_t for the active tab, or NULL if no tabs exist. + */ +tab_info_t *tab_manager_get_active(void); + +/* + * Get the tab_info_t for the tab at the given index, or NULL. + */ +tab_info_t *tab_manager_get(int index); + +/* + * Get the number of open tabs. + */ +int tab_manager_count(void); + +/* + * Update the title of the tab at the given index. Called from the + * load-changed signal handler when the page title becomes available. + */ +void tab_manager_set_title(int index, const char *title); + +/* + * Get the URL of the tab at the given index (for session save). + * Returns NULL if index is out of range. + */ +const char *tab_manager_get_url(int index); + +/* + * Cycle to the next tab (wraps around). Used by Ctrl+Tab / Ctrl+PageDown. + */ +void tab_manager_next(void); + +/* + * Cycle to the previous tab (wraps around). Used by Ctrl+Shift+Tab / + * Ctrl+PageUp. + */ +void tab_manager_prev(void); + +/* + * Close all tabs except the one at the given index. + */ +void tab_manager_close_others(int index); + +/* + * Close all tabs to the right of the given index. + */ +void tab_manager_close_to_right(int index); + +/* + * Duplicate the tab at the given index (open a new tab with the same URL). + */ +void tab_manager_duplicate(int index); + +/* + * Reload the tab at the given index. + */ +void tab_manager_reload(int index); + +/* + * Apply current settings (tab bar position, drag reorder, close buttons). + * Call after settings_change() or at init. + */ +void tab_manager_apply_settings(void); + +#ifdef __cplusplus +} +#endif + +#endif /* TAB_MANAGER_H */ diff --git a/src/version.h b/src/version.h index 8cead50..884964c 100644 --- a/src/version.h +++ b/src/version.h @@ -11,9 +11,9 @@ #ifndef SOVEREIGN_BROWSER_VERSION_H #define SOVEREIGN_BROWSER_VERSION_H -#define SB_VERSION "v0.0.3" +#define SB_VERSION "v0.0.4" #define SB_VERSION_MAJOR 0 #define SB_VERSION_MINOR 0 -#define SB_VERSION_PATCH 3 +#define SB_VERSION_PATCH 4 #endif /* SOVEREIGN_BROWSER_VERSION_H */