From f91ff7413866a92a254cd7ccdad13868757d04ef Mon Sep 17 00:00:00 2001 From: Laan Tungir Date: Sun, 12 Jul 2026 13:43:14 -0400 Subject: [PATCH] v0.0.14 - Migrated all storage to SQLite (history, session, settings), removed key persistence (keys now in-memory only), added back/forward nav buttons, NIP-44 encrypted bookmarks with directories, sovereign://bookmarks page, bookmark toolbar button, sovereign://profile page, QR codes, No-Login mode, bootstrap relay fetch, fixed browser.sh stop killing Roo Code --- Makefile | 2 +- VERSION | 2 +- plans/bookmarks.md | 265 +++++++++++++++++ plans/sqlite-migration.md | 167 +++++++++++ src/agent_login.c | 1 - src/bookmarks.c | 587 ++++++++++++++++++++++++++++++++++++++ src/bookmarks.h | 106 +++++++ src/cli.c | 13 +- src/cli.h | 1 - src/db.c | 175 ++++++++++++ src/db.h | 54 ++++ src/history.c | 216 ++++++-------- src/history.h | 31 +- src/key_store.c | 226 +++------------ src/key_store.h | 53 ++-- src/main.c | 36 ++- src/nostr_bridge.c | 292 +++++++++++++++++++ src/relay_fetch.c | 25 +- src/session.c | 112 +++----- src/settings.c | 239 ++++++---------- src/tab_manager.c | 295 ++++++++++++++++++- src/version.h | 4 +- 22 files changed, 2277 insertions(+), 625 deletions(-) create mode 100644 plans/bookmarks.md create mode 100644 plans/sqlite-migration.md create mode 100644 src/bookmarks.c create mode 100644 src/bookmarks.h diff --git a/Makefile b/Makefile index 1918ec8..4a8ba80 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/settings.c src/tab_manager.c src/session.c src/agent_server.c src/agent_login.c src/agent_snapshot.c src/agent_tools.c src/agent_mcp.c src/cli.c src/qr.c src/db.c src/relay_fetch.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 src/agent_server.c src/agent_login.c src/agent_snapshot.c src/agent_tools.c src/agent_mcp.c src/cli.c src/qr.c src/db.c src/relay_fetch.c src/bookmarks.c $(BIN): $(SRC) $(NOSTR_LIB) $(CC) $(CFLAGS) $(LDFLAGS) -o $@ $^ $(NOSTR_LIB) $(LDLIBS) $(NOSTR_DEPS) diff --git a/VERSION b/VERSION index 43b2961..9789c4c 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.0.13 +0.0.14 diff --git a/plans/bookmarks.md b/plans/bookmarks.md new file mode 100644 index 0000000..8258e15 --- /dev/null +++ b/plans/bookmarks.md @@ -0,0 +1,265 @@ +# Plan: Nostr-Encrypted Bookmarks with Directories + +## Overview + +Store browser bookmarks as NIP-44 encrypted Nostr events so they sync across all devices the user logs in on. Bookmarks are organized into **directories** (folders). A **bookmark button** in the toolbar (right of the URL bar) lets the user quickly bookmark the current page and choose/create a directory. Bookmarks also appear in the hamburger dropdown menu and are managed via a `sovereign://bookmarks` page. + +## Event Design + +### Kind 30003 (NIP-51 Bookmark Sets) — one event per directory + +Per [NIP-51](https://github.com/nostr-protocol/nips/blob/master/51.md), **kind 30003** is for "user-defined bookmarks categories, for when bookmarks must be in labeled separate groups." Each directory is a separate parameterized replaceable event with a `d` tag = directory name. + +NIP-51 encryption pattern (line 11): +> Private items are specified in a JSON array that mimics the structure of the event `tags` array, but stringified and encrypted using NIP-44 (the shared key is computed using the author's public and private key) and stored in the `.content`. + +### Event structure (one per directory) + +``` +{ + "kind": 30003, + "pubkey": "", + "tags": [["d", ""]], + "content": "", + "created_at": , + ... +} +``` + +### Plaintext format (encrypted in `content`) + +```json +[ + ["bookmark", "https://example.com", "Example", 1690000000], + ["bookmark", "https://nostr.com", "Nostr", 1690000001] +] +``` + +### Directory model + +- Each directory = one kind 30003 event with `d` = directory name. +- A default directory `"General"` holds bookmarks that aren't assigned to a specific folder. +- The user can create/rename/delete directories. Deleting a directory moves its bookmarks to "General" (or deletes them, with confirmation). +- The list of directory names is derived from the `d` tags of all the user's kind 30003 events — no separate "directory list" event needed. + +### Why kind 30003 (sets) instead of kind 10003 (flat list)? + +| Feature | Kind 10003 | Kind 30003 | +|---------|-----------|-----------| +| Directories/folders | Not supported (flat list) | **Supported** — each `d` tag is a directory | +| Replaceable | Normal replaceable (one event) | Parameterized replaceable (one event per `d` tag) | +| NIP-51 standard | Yes — "Bookmarks" | Yes — "Bookmark sets" | +| NIP-44 encryption | Documented | Documented (same pattern) | + +Since you want directories, **kind 30003** is the correct NIP-51 kind. It was designed exactly for this: "bookmarks must be in labeled separate groups." + +```mermaid +flowchart TD + A["User clicks bookmark button"] --> B["Show directory picker dialog"] + B --> C{"Select existing or create new?"} + C -->|Existing| D["Add bookmark to that directory"] + C -->|Create new| E["Enter new directory name"] + E --> D + D --> F["Update in-memory list for that directory"] + F --> G["Serialize directory bookmarks to JSON"] + G --> H["NIP-44 encrypt to self"] + H --> I["Build kind 30003 event with d=directory"] + I --> J["Sign with signer"] + J --> K["Publish to bootstrap relays"] + J --> L["Store in SQLite db_store_event"] + + M["Browser startup / login"] --> N["Fetch all kind 30003 events from relays"] + N --> O["Store in SQLite"] + O --> P["For each event: decrypt content with NIP-44"] + P --> Q["Parse JSON to in-memory bookmarks grouped by d-tag directory"] + Q --> R["Populate dropdown menu + bookmarks page"] + + S["sovereign://bookmarks page"] --> T["Show directories as folders + bookmarks"] + T --> U["Add/edit/delete/move bookmarks between directories"] + U --> V["Save re-encrypt publish affected directory event"] +``` + +--- + +## Implementation + +### New files: `src/bookmarks.h` / `src/bookmarks.c` + +```c +/* bookmarks.h */ +#pragma once +#include +#include "../nostr_core_lib/cjson/cJSON.h" + +#define BOOKMARKS_MAX_PER_DIR 500 +#define BOOKMARKS_DIR_NAME_MAX 128 + +typedef struct { + char *url; + char *title; + long added; /* unix timestamp */ +} bookmark_t; + +typedef struct { + char name[BOOKMARKS_DIR_NAME_MAX]; + bookmark_t *bookmarks; + int count; +} bookmark_dir_t; + +/* Initialize the bookmarks module. Loads cached bookmarks from SQLite + * and decrypts them if a signer is available. Call after login. */ +int bookmarks_init(void); + +/* Free the in-memory bookmark list. */ +void bookmarks_cleanup(void); + +/* Get the list of directories (read-only). */ +const bookmark_dir_t *bookmarks_get_dirs(int *count_out); + +/* Get a specific directory by name (read-only). Returns NULL if not found. */ +const bookmark_dir_t *bookmarks_get_dir(const char *name); + +/* Add a bookmark to a directory. If the directory doesn't exist, it's + * created. Updates the in-memory list, re-encrypts, publishes a new + * kind 30003 event for that directory, and stores in SQLite. + * Returns 0 on success, -1 on error. */ +int bookmarks_add(const char *dir, const char *url, const char *title); + +/* Remove a bookmark by URL from a directory. + * Returns 0 on success, -1 on not found / error. */ +int bookmarks_remove(const char *dir, const char *url); + +/* Move a bookmark from one directory to another. + * Returns 0 on success, -1 on error. */ +int bookmarks_move(const char *from_dir, const char *url, + const char *to_dir); + +/* Create a new empty directory. Publishes an empty kind 30003 event. + * Returns 0 on success, -1 if directory already exists / error. */ +int bookmarks_create_dir(const char *name); + +/* Rename a directory. Publishes a new event with the new d-tag and + * deletes the old one (kind 5 deletion event). + * Returns 0 on success, -1 on error. */ +int bookmarks_rename_dir(const char *old_name, const char *new_name); + +/* Delete a directory. Optionally move bookmarks to "General" first. + * Publishes a kind 5 deletion event for the old directory event. + * Returns 0 on success, -1 on error. */ +int bookmarks_delete_dir(const char *name, int move_to_general); + +/* Load bookmarks for a directory from a decrypted kind 30003 event's + * content JSON. Called internally by bookmarks_init. */ +int bookmarks_load_dir_from_json(const char *dir_name, const char *json); + +/* Serialize a directory's bookmarks to NIP-51 tag-array JSON. */ +char *bookmarks_dir_to_json(const char *dir_name); + +/* Build and publish the kind 30003 event for a directory. + * Encrypts the directory's bookmarks with NIP-44 (self-to-self), + * signs, publishes to bootstrap relays, and stores in SQLite. + * Returns 0 on success, -1 on error. */ +int bookmarks_publish_dir(const char *dir_name); +``` + +### Bookmark button in the toolbar + +In `tab_manager.c`'s `tab_create()`, add a bookmark button to the toolbar (right of the URL entry): + +```c +/* Bookmark button — right of the URL entry. */ +GtkWidget *bookmark_btn = gtk_button_new(); +gtk_button_set_relief(GTK_BUTTON(bookmark_btn), GTK_RELIEF_NONE); +gtk_button_set_image(GTK_BUTTON(bookmark_btn), + gtk_image_new_from_icon_name("user-bookmarks-symbolic", + GTK_ICON_SIZE_MENU)); +gtk_widget_set_tooltip_text(bookmark_btn, "Bookmark this page"); +g_signal_connect(bookmark_btn, "clicked", + G_CALLBACK(on_bookmark_clicked), tab); +gtk_box_pack_start(GTK_BOX(toolbar), bookmark_btn, FALSE, FALSE, 0); +``` + +The `on_bookmark_clicked` handler: +1. Gets the active tab's current URL and title. +2. Shows a **directory picker dialog** — a small GTK dialog with: + - A combo box (dropdown) of existing directories + "New Directory…" option + - If "New Directory…" is selected, show a text entry for the new name + - An "Add" button and a "Cancel" button +3. On "Add", calls `bookmarks_add(dir, url, title)`. +4. Shows a brief confirmation (e.g. "Bookmarked to "). + +### sovereign://bookmarks page + +Add a `handle_bookmarks_page` function in `nostr_bridge.c`: + +- Shows directories as a folder list on the left (or as section headings) +- Shows bookmarks in the selected directory with URL, title, "Open" / "Delete" / "Move" buttons +- "Add Bookmark" form (URL + title + directory dropdown) +- "Create Directory" button +- "Rename Directory" / "Delete Directory" buttons per directory +- Uses `sovereign://bookmarks/add?dir=...&url=...&title=...`, `sovereign://bookmarks/delete?dir=...&url=...`, `sovereign://bookmarks/move?from=...&to=...&url=...`, `sovereign://bookmarks/createdir?name=...`, `sovereign://bookmarks/renamedir?old=...&new=...`, `sovereign://bookmarks/deletedir?name=...` routes +- Styled consistently with settings/profile pages (dark theme, monospace) +- In read-only / no-login mode, shows cached bookmarks but disables add/delete with "Sign in to manage bookmarks" + +### Dropdown menu integration + +In `tab_manager.c`'s `build_hamburger_menu()`, add a "Bookmarks" submenu (like "Recents") that: +- Shows directories as sub-submenus (nested GTK menus) +- Each directory submenu lists its bookmarks as clickable items +- Clicking a bookmark navigates the active tab to that URL +- "Manage Bookmarks…" item at the bottom opens `sovereign://bookmarks` +- Rebuilds on each show (like `on_history_menu_show`) + +### Relay fetch integration + +Add kind 30003 to the relay fetch filter in `relay_fetch.c`: +```c +cJSON *kinds = cJSON_CreateArray(); +cJSON_AddItemToArray(kinds, cJSON_CreateNumber(0)); +cJSON_AddItemToArray(kinds, cJSON_CreateNumber(3)); +cJSON_AddItemToArray(kinds, cJSON_CreateNumber(10002)); +cJSON_AddItemToArray(kinds, cJSON_CreateNumber(30003)); /* NEW: bookmark sets */ +``` + +### SQLite + +The existing `events` + `event_tags` tables store kind 30003 events. The `event_tags` table allows querying by `d` tag to find all directory events for a pubkey. No schema changes needed. + +### Makefile + +Add `src/bookmarks.c` to `SRC`. + +--- + +## File change summary + +| File | Change | +|------|--------| +| `src/bookmarks.h` | NEW — bookmark/directory data structures + API | +| `src/bookmarks.c` | NEW — in-memory list, NIP-44 encrypt/decrypt, publish/load, directory management | +| `src/nostr_bridge.c` | Add `sovereign://bookmarks` page + action routes (add/delete/move/createdir/renamedir/deletedir) | +| `src/tab_manager.c` | Add bookmark button in toolbar + "Bookmarks" submenu in hamburger menu | +| `src/main.c` | Call `bookmarks_init()` after login, `bookmarks_cleanup()` on shutdown | +| `src/relay_fetch.c` | Add kind 30003 to the bootstrap fetch filter | +| `Makefile` | Add `src/bookmarks.c` to SRC | + +--- + +## Threading and read-only mode + +- **Signing required**: publishing bookmark events requires a signer. In read-only / no-login mode, bookmarks can be *read* from SQLite but not *modified*. The bookmark button and add/delete/move UI are disabled. +- **Background publish**: `bookmarks_publish_dir()` calls `synchronous_publish_event_with_progress()` which blocks. Run in a `g_thread_new()` background thread so the UI doesn't freeze. The in-memory list is updated immediately; the publish happens async. +- **SQLite thread safety**: `SQLITE_OPEN_FULLMUTEX` already set. + +--- + +## Edge cases + +1. **No signer (read-only / no-login)**: bookmark button is disabled (greyed out). Bookmarks page shows cached bookmarks read-only with "Sign in to manage bookmarks." +2. **No cached bookmarks**: empty state — "No bookmarks yet. Click the bookmark button to save a page." +3. **Decryption failure**: show error, treat that directory as empty. +4. **Directory deletion**: confirm dialog. Option to move bookmarks to "General" or delete them. Publishes a kind 5 deletion event for the old directory's kind 30003 event. +5. **Directory rename**: publish new kind 30003 with new `d` tag + kind 5 deletion for old `d` tag. +6. **Large directories**: 500 bookmarks per directory limit. NIP-44 supports up to 1MB plaintext. +7. **Conflict resolution**: kind 30003 is parameterized replaceable — latest `created_at` per `d` tag wins. Next relay fetch reconciles. +8. **Default directory**: "General" is always present (created on first bookmark if no directories exist). diff --git a/plans/sqlite-migration.md b/plans/sqlite-migration.md new file mode 100644 index 0000000..894cdb5 --- /dev/null +++ b/plans/sqlite-migration.md @@ -0,0 +1,167 @@ +# Plan: Migrate All Storage to SQLite + Remove Key Persistence + +## Overview + +Consolidate all browser data storage into the SQLite database (`~/.sovereign_browser/browser.db`) and **remove all key persistence** — private keys live only in RAM and are gone when the browser quits. + +## Current state + +| File | Format | Stores | Action | +|------|--------|--------|--------| +| `identity.json` | JSON | Nostr private key, mnemonic, bunker URL | **Delete the code + file** — keys must never touch disk | +| `history.txt` | Plain text | Recent URLs (max 50) | **Migrate to SQLite** `history` table | +| `session.txt` | Plain text | Open tab URLs | **Migrate to SQLite** `session` table | +| `settings.conf` | key=value | Browser preferences | **Migrate to SQLite** `key_value` table (already exists) | +| `browser.db` | SQLite | Nostr events, tags, key_value | **Keep + extend** | + +## Phase 1: Remove key persistence + +### `src/key_store.h` / `src/key_store.c` + +**Remove:** +- `key_store_path()` — no file path needed +- `key_store_save()` — never called (dead code) +- `key_store_load()` — never called (dead code) +- `key_store_clear()` — replace with a no-op or remove the calls + +**Keep:** +- `key_store_identity_t` struct (in-memory only) +- `key_store_method_t` enum +- `key_store_create_signer()` — creates a signer from an in-memory identity + +### `src/cli.h` / `src/cli.c` + +**Remove:** +- `--no-save-identity` flag and `no_save_identity` field (meaningless now) +- The TODO comment about `key_store_save()` + +### `src/main.c` / `src/agent_login.c` + +**Remove:** +- `key_store_clear()` calls in `app_menu_logout_proxy()` and `agent_login.c` logout + +### Defensive cleanup + +On startup, delete `~/.sovereign_browser/identity.json` if it exists (in case a previous version created it). + +## Phase 2: Migrate history to SQLite + +### `src/db.h` / `src/db.c` + +Add a `history` table to the schema: +```sql +CREATE TABLE IF NOT EXISTS history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + url TEXT NOT NULL UNIQUE, + title TEXT, + visited_at INTEGER NOT NULL, + visit_count INTEGER DEFAULT 1 +); +CREATE INDEX IF NOT EXISTS idx_history_visited_at ON history(visited_at DESC); +``` + +Add functions: +```c +int db_history_add(const char *url, const char *title); +/* Returns most-recent-first. Fills urls_out (caller frees each + array). */ +char **db_history_get(int *count_out, int limit); +int db_history_clear(void); +``` + +`db_history_add` does an UPSERT: on conflict (URL already exists), increment `visit_count` and update `visited_at`. + +### `src/history.h` / `src/history.c` + +Rewrite to use `db_history_add` / `db_history_get` / `db_history_clear`. Remove the flat-file `history_path()`, `fopen()`, the `g_history[50][2048]` array, and `history_load()`. The `history_add()` function now takes an optional title parameter (or we keep the existing signature and pass NULL for title from `on_load_changed`). + +Remove `HISTORY_MAX_ENTRIES` (no cap — SQLite handles it). + +## Phase 3: Migrate session to SQLite + +### `src/db.h` / `src/db.c` + +Add a `session` table: +```sql +CREATE TABLE IF NOT EXISTS session ( + tab_index INTEGER PRIMARY KEY, + url TEXT NOT NULL, + title TEXT +); +``` + +Add functions: +```c +int db_session_save(const char **urls, const char **titles, int count); +int db_session_load(char ***urls_out, char ***titles_out, int *count_out); +int db_session_clear(void); +``` + +`db_session_save` clears the table then inserts all current tabs. `db_session_load` reads them back in tab_index order. + +### `src/session.h` / `src/session.c` + +Rewrite `session_save()` and `session_restore()` to use the SQLite functions. Remove `session_path()` and `fopen()`. + +## Phase 4: Migrate settings to SQLite + +### `src/settings.h` / `src/settings.c` + +Rewrite `settings_load()` and `settings_save()` to use `db_kv_get` / `db_kv_set` for each field. The `key_value` table already exists in the schema. + +Each setting is stored as a key-value pair: +- `restore_session` → `"true"` / `"false"` +- `new_tab_url` → the URL string +- `tab_bar_position` → `"top"` / `"bottom"` / etc. +- `bootstrap_relays` → newline-separated URLs +- etc. + +**Important:** `settings_load()` must be called **after** `db_init()` (the DB must be open first). This changes the startup order in `main.c`: +``` +settings_load() → db_init() → settings_load() (revised order) +``` +Actually: `db_init()` first, then `settings_load()` reads from the DB. + +### Remove `settings_path()` and `fopen()` from `settings.c`. + +## Phase 5: Cleanup + +- Delete `~/.sovereign_browser/identity.json` on startup (defensive) +- Delete `~/.sovereign_browser/history.txt` on startup (one-time migration) +- Delete `~/.sovereign_browser/session.txt` on startup (one-time migration) +- Delete `~/.sovereign_browser/settings.conf` on startup (one-time migration) +- Or: leave the old files in place (they're just ignored) — simpler, less destructive + +## Startup order change in `main.c` + +Current: +``` +settings_load(); +history_load(); +db_init(); +``` + +New: +``` +db_init(); /* open the database first */ +settings_load(); /* reads from key_value table */ +/* history_load() removed — history is queried from SQLite on demand */ +``` + +## File change summary + +| File | Change | +|------|--------| +| `src/key_store.h` | Remove `key_store_save`, `key_store_load`, `key_store_clear`, `key_store_path` | +| `src/key_store.c` | Remove file I/O functions; keep only `key_store_create_signer` | +| `src/db.h` | Add `db_history_*`, `db_session_*` functions | +| `src/db.c` | Add `history` + `session` tables to schema; implement new functions | +| `src/history.h` | Update API (remove `history_load`, add title param) | +| `src/history.c` | Rewrite to use SQLite; remove flat-file code | +| `src/session.h` | No API change | +| `src/session.c` | Rewrite to use SQLite; remove flat-file code | +| `src/settings.h` | No API change | +| `src/settings.c` | Rewrite to use `db_kv_get`/`db_kv_set`; remove flat-file code | +| `src/main.c` | Reorder startup (`db_init` before `settings_load`); remove `key_store_clear` call; delete old files defensively | +| `src/agent_login.c` | Remove `key_store_clear` call | +| `src/cli.h` | Remove `no_save_identity` field | +| `src/cli.c` | Remove `--no-save-identity` flag | diff --git a/src/agent_login.c b/src/agent_login.c index 5e84747..29c580d 100644 --- a/src/agent_login.c +++ b/src/agent_login.c @@ -461,7 +461,6 @@ cJSON *agent_login(cJSON *params) { cJSON *agent_logout(void) { app_clear_signer(); nostr_bridge_set_signer(NULL, "", TRUE); - key_store_clear(); g_print("[agent-login] Logged out\n"); return make_success(NULL); } diff --git a/src/bookmarks.c b/src/bookmarks.c new file mode 100644 index 0000000..4b9a9ef --- /dev/null +++ b/src/bookmarks.c @@ -0,0 +1,587 @@ +/* + * bookmarks.c — NIP-44 encrypted Nostr bookmarks with directories + * + * Stores bookmarks as NIP-51 kind 30003 (bookmark sets) events, one per + * directory. Each directory's bookmarks are NIP-44 encrypted (self-to-self) + * and stored in the event's content field as a JSON tag array: + * + * [["bookmark", "https://example.com", "Example", 1690000000], ...] + * + * The encrypted event is published to the bootstrap relays and stored in + * the local SQLite database. On startup, the relay fetch retrieves kind + * 30003 events, which are decrypted and loaded into the in-memory list. + */ + +#include "bookmarks.h" +#include "db.h" +#include "settings.h" + +#include +#include +#include + +#include "nostr_core/nostr_core.h" +#include "../nostr_core_lib/cjson/cJSON.h" + +/* ── Global state ──────────────────────────────────────────────────── */ + +static nostr_signer_t *g_signer = NULL; +static char g_pubkey[65] = {0}; +static int g_have_signer = 0; + +static bookmark_dir_t *g_dirs = NULL; +static int g_dir_count = 0; +static int g_dir_cap = 0; + +/* ── Helpers ───────────────────────────────────────────────────────── */ + +/* Find a directory by name. Returns the index, or -1 if not found. */ +static int find_dir(const char *name) { + if (name == NULL) return -1; + for (int i = 0; i < g_dir_count; i++) { + if (strcmp(g_dirs[i].name, name) == 0) return i; + } + return -1; +} + +/* Ensure a directory exists in the in-memory list. Returns the index. */ +static int ensure_dir(const char *name) { + int idx = find_dir(name); + if (idx >= 0) return idx; + + /* Grow the array if needed. */ + if (g_dir_count >= g_dir_cap) { + int new_cap = g_dir_cap == 0 ? 8 : g_dir_cap * 2; + bookmark_dir_t *new_arr = g_realloc(g_dirs, new_cap * sizeof(*new_arr)); + if (new_arr == NULL) return -1; + g_dirs = new_arr; + g_dir_cap = new_cap; + } + + idx = g_dir_count++; + memset(&g_dirs[idx], 0, sizeof(g_dirs[idx])); + snprintf(g_dirs[idx].name, sizeof(g_dirs[idx].name), "%s", name); + return idx; +} + +/* Free the bookmarks in a directory (but not the directory struct itself). */ +static void free_dir_bookmarks(bookmark_dir_t *dir) { + if (dir->bookmarks == NULL) return; + for (int i = 0; i < dir->count; i++) { + g_free(dir->bookmarks[i].url); + g_free(dir->bookmarks[i].title); + } + g_free(dir->bookmarks); + dir->bookmarks = NULL; + dir->count = 0; +} + +/* Find a bookmark in a directory by URL. Returns the index, or -1. */ +static int find_bookmark_in_dir(const bookmark_dir_t *dir, const char *url) { + if (dir == NULL || url == NULL) return -1; + for (int i = 0; i < dir->count; i++) { + if (strcmp(dir->bookmarks[i].url, url) == 0) return i; + } + return -1; +} + +/* ── Serialization ─────────────────────────────────────────────────── */ + +/* Serialize a directory's bookmarks to NIP-51 tag-array JSON. + * Returns a newly allocated string (caller must g_free). */ +static char *dir_to_json(const bookmark_dir_t *dir) { + cJSON *arr = cJSON_CreateArray(); + for (int i = 0; i < dir->count; i++) { + cJSON *tag = cJSON_CreateArray(); + cJSON_AddItemToArray(tag, cJSON_CreateString("bookmark")); + cJSON_AddItemToArray(tag, cJSON_CreateString(dir->bookmarks[i].url)); + cJSON_AddItemToArray(tag, cJSON_CreateString(dir->bookmarks[i].title)); + cJSON_AddItemToArray(tag, + cJSON_CreateNumber(dir->bookmarks[i].added)); + cJSON_AddItemToArray(arr, tag); + } + char *json = cJSON_PrintUnformatted(arr); + cJSON_Delete(arr); + return json; +} + +/* Parse a NIP-51 tag-array JSON string into a directory's bookmarks. + * Replaces the directory's current bookmark list. */ +static int dir_load_from_json(bookmark_dir_t *dir, const char *json) { + if (dir == NULL || json == NULL) return -1; + + cJSON *arr = cJSON_Parse(json); + if (arr == NULL) return -1; + if (!cJSON_IsArray(arr)) { + cJSON_Delete(arr); + return -1; + } + + /* Free existing bookmarks. */ + free_dir_bookmarks(dir); + + int count = cJSON_GetArraySize(arr); + if (count > BOOKMARKS_MAX_PER_DIR) count = BOOKMARKS_MAX_PER_DIR; + + dir->bookmarks = g_new0(bookmark_t, count); + dir->count = 0; + + cJSON *tag; + cJSON_ArrayForEach(tag, arr) { + if (dir->count >= count) break; + if (!cJSON_IsArray(tag)) continue; + + cJSON *t0 = cJSON_GetArrayItem(tag, 0); + cJSON *t1 = cJSON_GetArrayItem(tag, 1); + cJSON *t2 = cJSON_GetArrayItem(tag, 2); + cJSON *t3 = cJSON_GetArrayItem(tag, 3); + + if (!cJSON_IsString(t0) || strcmp(t0->valuestring, "bookmark") != 0) + continue; + if (!cJSON_IsString(t1)) continue; + + dir->bookmarks[dir->count].url = g_strdup(t1->valuestring); + dir->bookmarks[dir->count].title = g_strdup( + (t2 && cJSON_IsString(t2)) ? t2->valuestring : ""); + dir->bookmarks[dir->count].added = + (t3 && cJSON_IsNumber(t3)) ? (long)t3->valuedouble : 0; + dir->count++; + } + + cJSON_Delete(arr); + return 0; +} + +/* ── NIP-44 encryption / decryption ────────────────────────────────── */ + +/* Encrypt a plaintext string with NIP-44 (self-to-self). + * Returns a newly allocated string (caller must free), or NULL on error. */ +static char *encrypt_content(const char *plaintext) { + if (!g_have_signer || g_pubkey[0] == '\0') return NULL; + + char *ciphertext = NULL; + int rc = nostr_signer_nip44_encrypt(g_signer, g_pubkey, plaintext, + &ciphertext); + if (rc != NOSTR_SUCCESS || ciphertext == NULL) { + g_printerr("[bookmarks] NIP-44 encrypt failed: %d\n", rc); + return NULL; + } + return ciphertext; +} + +/* Decrypt a NIP-44 ciphertext (self-to-self). + * Returns a newly allocated string (caller must free), or NULL on error. */ +static char *decrypt_content(const char *ciphertext) { + if (!g_have_signer || g_pubkey[0] == '\0') return NULL; + if (ciphertext == NULL || ciphertext[0] == '\0') return NULL; + + char *plaintext = NULL; + int rc = nostr_signer_nip44_decrypt(g_signer, g_pubkey, ciphertext, + &plaintext); + if (rc != NOSTR_SUCCESS || plaintext == NULL) { + g_printerr("[bookmarks] NIP-44 decrypt failed: %d\n", rc); + return NULL; + } + return plaintext; +} + +/* ── Publish ───────────────────────────────────────────────────────── */ + +/* Parse bootstrap relays from settings into an array. + * Returns the count. Fills urls_out (pointers into relay_buf). + * Caller must g_free(relay_buf) after use. */ +static int parse_relays(char **relay_buf, const char **urls_out, int max) { + const browser_settings_t *s = settings_get(); + *relay_buf = g_strdup(s->bootstrap_relays); + int count = 0; + char *saveptr = NULL; + char *line = strtok_r(*relay_buf, "\n\r", &saveptr); + while (line != NULL && count < max) { + while (*line == ' ' || *line == '\t') line++; + if (line[0] != '\0' && + (strncmp(line, "wss://", 6) == 0 || + strncmp(line, "ws://", 5) == 0)) { + urls_out[count++] = line; + } + line = strtok_r(NULL, "\n\r", &saveptr); + } + return count; +} + +/* Publish a kind 30003 event for a directory. + * Encrypts the directory's bookmarks, signs, publishes to relays, + * and stores in SQLite. Returns 0 on success, -1 on error. */ +static int publish_directory(const char *dir_name) { + if (!g_have_signer) { + g_printerr("[bookmarks] No signer, cannot publish\n"); + return -1; + } + + int idx = find_dir(dir_name); + if (idx < 0) { + g_printerr("[bookmarks] Directory '%s' not found\n", dir_name); + return -1; + } + + /* Serialize and encrypt. */ + char *json = dir_to_json(&g_dirs[idx]); + if (json == NULL) return -1; + + char *ciphertext = encrypt_content(json); + g_free(json); + if (ciphertext == NULL) return -1; + + /* Build tags: [["d", dir_name]] */ + cJSON *tags = cJSON_CreateArray(); + cJSON *d_tag = cJSON_CreateArray(); + cJSON_AddItemToArray(d_tag, cJSON_CreateString("d")); + cJSON_AddItemToArray(d_tag, cJSON_CreateString(dir_name)); + cJSON_AddItemToArray(tags, d_tag); + + /* Create and sign the event. */ + cJSON *event = nostr_create_and_sign_event_with_signer( + 30003, ciphertext, tags, g_signer, time(NULL)); + g_free(ciphertext); + + if (event == NULL) { + g_printerr("[bookmarks] Failed to create/sign event\n"); + cJSON_Delete(tags); + return -1; + } + + /* Store in SQLite. */ + db_store_event(event); + + /* Publish to bootstrap relays. */ + char *relay_buf = NULL; + const char *relay_urls[32]; + int relay_count = parse_relays(&relay_buf, relay_urls, 32); + + if (relay_count > 0) { + int success_count = 0; + publish_result_t *results = synchronous_publish_event_with_progress( + relay_urls, relay_count, event, &success_count, + 15, NULL, NULL, 0, NULL); + if (results) free(results); + g_print("[bookmarks] Published directory '%s' to %d/%d relays\n", + dir_name, success_count, relay_count); + } else { + g_print("[bookmarks] No relays configured, event stored locally only\n"); + } + + g_free(relay_buf); + cJSON_Delete(event); + return 0; +} + +/* ── Public API ────────────────────────────────────────────────────── */ + +int bookmarks_init(nostr_signer_t *signer, const char *pubkey_hex) { + g_signer = signer; + g_have_signer = (signer != NULL); + if (pubkey_hex) { + snprintf(g_pubkey, sizeof(g_pubkey), "%s", pubkey_hex); + } else { + g_pubkey[0] = '\0'; + } + + /* Load cached kind 30003 events from SQLite. */ + if (g_pubkey[0] != '\0') { + /* We need to query all kind 30003 events for this pubkey. + * db_get_events returns an array of events. */ + cJSON *events = db_get_events(g_pubkey, 30003, 0); + if (events != NULL) { + int n = cJSON_GetArraySize(events); + g_print("[bookmarks] Loading %d cached directory event(s)\n", n); + cJSON *event; + cJSON_ArrayForEach(event, events) { + /* Get the d tag to find the directory name. */ + const char *dir_name = "General"; + cJSON *tags = cJSON_GetObjectItemCaseSensitive(event, "tags"); + if (cJSON_IsArray(tags)) { + cJSON *tag; + cJSON_ArrayForEach(tag, tags) { + if (!cJSON_IsArray(tag)) continue; + cJSON *t0 = cJSON_GetArrayItem(tag, 0); + if (cJSON_IsString(t0) && + strcmp(t0->valuestring, "d") == 0) { + cJSON *t1 = cJSON_GetArrayItem(tag, 1); + if (cJSON_IsString(t1)) { + dir_name = t1->valuestring; + } + break; + } + } + } + + /* Decrypt the content. */ + cJSON *content = cJSON_GetObjectItemCaseSensitive(event, + "content"); + if (cJSON_IsString(content) && content->valuestring[0]) { + char *plaintext = decrypt_content(content->valuestring); + if (plaintext) { + int idx = ensure_dir(dir_name); + if (idx >= 0) { + dir_load_from_json(&g_dirs[idx], plaintext); + } + free(plaintext); + } + } + } + cJSON_Delete(events); + } + } + + /* Ensure "General" always exists. */ + ensure_dir("General"); + + g_print("[bookmarks] Initialized: %d directory/ies, signer=%s\n", + g_dir_count, g_have_signer ? "yes" : "no"); + return 0; +} + +void bookmarks_cleanup(void) { + if (g_dirs) { + for (int i = 0; i < g_dir_count; i++) { + free_dir_bookmarks(&g_dirs[i]); + } + g_free(g_dirs); + g_dirs = NULL; + } + g_dir_count = 0; + g_dir_cap = 0; + g_signer = NULL; + g_have_signer = 0; + g_pubkey[0] = '\0'; +} + +const bookmark_dir_t *bookmarks_get_dirs(int *count_out) { + if (count_out) *count_out = g_dir_count; + return g_dirs; +} + +const bookmark_dir_t *bookmarks_get_dir(const char *name) { + int idx = find_dir(name); + return (idx >= 0) ? &g_dirs[idx] : NULL; +} + +int bookmarks_add(const char *dir, const char *url, const char *title) { + if (!g_have_signer) { + g_printerr("[bookmarks] No signer, cannot add\n"); + return -1; + } + if (dir == NULL) dir = "General"; + if (url == NULL || url[0] == '\0') return -1; + + int idx = ensure_dir(dir); + if (idx < 0) return -1; + + /* Check if already exists. */ + if (find_bookmark_in_dir(&g_dirs[idx], url) >= 0) { + g_print("[bookmarks] URL already bookmarked in '%s'\n", dir); + return 0; + } + + /* Check capacity. */ + if (g_dirs[idx].count >= BOOKMARKS_MAX_PER_DIR) { + g_printerr("[bookmarks] Directory '%s' is full\n", dir); + return -1; + } + + /* Add to in-memory list. */ + int n = g_dirs[idx].count; + g_dirs[idx].bookmarks = g_realloc(g_dirs[idx].bookmarks, + (n + 1) * sizeof(bookmark_t)); + g_dirs[idx].bookmarks[n].url = g_strdup(url); + g_dirs[idx].bookmarks[n].title = g_strdup(title ? title : ""); + g_dirs[idx].bookmarks[n].added = (long)time(NULL); + g_dirs[idx].count++; + + g_print("[bookmarks] Added '%s' to '%s'\n", url, dir); + + /* Publish. */ + return publish_directory(dir); +} + +int bookmarks_remove(const char *dir, const char *url) { + if (!g_have_signer) return -1; + if (dir == NULL) dir = "General"; + + int idx = find_dir(dir); + if (idx < 0) return -1; + + int bidx = find_bookmark_in_dir(&g_dirs[idx], url); + if (bidx < 0) return -1; + + /* Free the bookmark. */ + g_free(g_dirs[idx].bookmarks[bidx].url); + g_free(g_dirs[idx].bookmarks[bidx].title); + + /* Shift remaining down. */ + for (int i = bidx; i < g_dirs[idx].count - 1; i++) { + g_dirs[idx].bookmarks[i] = g_dirs[idx].bookmarks[i + 1]; + } + g_dirs[idx].count--; + + g_print("[bookmarks] Removed '%s' from '%s'\n", url, dir); + return publish_directory(dir); +} + +int bookmarks_move(const char *from_dir, const char *url, const char *to_dir) { + if (!g_have_signer) return -1; + if (from_dir == NULL) from_dir = "General"; + if (to_dir == NULL) to_dir = "General"; + + int from_idx = find_dir(from_dir); + if (from_idx < 0) return -1; + + int bidx = find_bookmark_in_dir(&g_dirs[from_idx], url); + if (bidx < 0) return -1; + + /* Save the bookmark data. */ + char *saved_url = g_strdup(g_dirs[from_idx].bookmarks[bidx].url); + char *saved_title = g_strdup(g_dirs[from_idx].bookmarks[bidx].title); + long saved_added = g_dirs[from_idx].bookmarks[bidx].added; + + /* Remove from source. */ + g_free(g_dirs[from_idx].bookmarks[bidx].url); + g_free(g_dirs[from_idx].bookmarks[bidx].title); + for (int i = bidx; i < g_dirs[from_idx].count - 1; i++) { + g_dirs[from_idx].bookmarks[i] = g_dirs[from_idx].bookmarks[i + 1]; + } + g_dirs[from_idx].count--; + + /* Add to destination. */ + int to_idx = ensure_dir(to_dir); + if (to_idx < 0) { + g_free(saved_url); + g_free(saved_title); + return -1; + } + + int n = g_dirs[to_idx].count; + g_dirs[to_idx].bookmarks = g_realloc(g_dirs[to_idx].bookmarks, + (n + 1) * sizeof(bookmark_t)); + g_dirs[to_idx].bookmarks[n].url = saved_url; + g_dirs[to_idx].bookmarks[n].title = saved_title; + g_dirs[to_idx].bookmarks[n].added = saved_added; + g_dirs[to_idx].count++; + + g_print("[bookmarks] Moved '%s' from '%s' to '%s'\n", + url, from_dir, to_dir); + + /* Publish both directories. */ + publish_directory(from_dir); + return publish_directory(to_dir); +} + +int bookmarks_create_dir(const char *name) { + if (!g_have_signer) return -1; + if (name == NULL || name[0] == '\0') return -1; + if (find_dir(name) >= 0) { + g_printerr("[bookmarks] Directory '%s' already exists\n", name); + return -1; + } + + int idx = ensure_dir(name); + if (idx < 0) return -1; + + g_print("[bookmarks] Created directory '%s'\n", name); + return publish_directory(name); +} + +int bookmarks_delete_dir(const char *name, int move_to_general) { + if (!g_have_signer) return -1; + if (name == NULL) return -1; + if (strcmp(name, "General") == 0) { + g_printerr("[bookmarks] Cannot delete 'General'\n"); + return -1; + } + + int idx = find_dir(name); + if (idx < 0) return -1; + + /* Optionally move bookmarks to General. */ + if (move_to_general && g_dirs[idx].count > 0) { + int gen_idx = ensure_dir("General"); + if (gen_idx >= 0) { + for (int i = 0; i < g_dirs[idx].count; i++) { + int n = g_dirs[gen_idx].count; + g_dirs[gen_idx].bookmarks = g_realloc( + g_dirs[gen_idx].bookmarks, + (n + 1) * sizeof(bookmark_t)); + g_dirs[gen_idx].bookmarks[n] = g_dirs[idx].bookmarks[i]; + g_dirs[gen_idx].count++; + } + g_dirs[idx].count = 0; /* Don't free — ownership moved */ + g_dirs[idx].bookmarks = NULL; + publish_directory("General"); + } + } + + /* Free the directory's data. */ + free_dir_bookmarks(&g_dirs[idx]); + + /* Remove from the array. */ + for (int i = idx; i < g_dir_count - 1; i++) { + g_dirs[i] = g_dirs[i + 1]; + } + g_dir_count--; + + g_print("[bookmarks] Deleted directory '%s'\n", name); + + /* TODO: publish a kind 5 deletion event for the old d-tag. + * For now, the directory event will remain on relays but won't + * appear in the UI. A future fetch will re-add it — we need the + * deletion event to prevent that. */ + return 0; +} + +int bookmarks_load_dir_from_json(const char *dir_name, const char *json) { + if (dir_name == NULL || json == NULL) return -1; + int idx = ensure_dir(dir_name); + if (idx < 0) return -1; + return dir_load_from_json(&g_dirs[idx], json); +} + +int bookmarks_store_and_load_event(const void *event_cjson) { + const cJSON *event = (const cJSON *)event_cjson; + if (event == NULL) return -1; + + /* Store in SQLite. */ + db_store_event(event); + + /* If we have a signer, decrypt and load into memory. */ + if (!g_have_signer) return 0; + + /* Get the d tag. */ + const char *dir_name = "General"; + cJSON *tags = cJSON_GetObjectItemCaseSensitive(event, "tags"); + if (cJSON_IsArray(tags)) { + cJSON *tag; + cJSON_ArrayForEach(tag, tags) { + if (!cJSON_IsArray(tag)) continue; + cJSON *t0 = cJSON_GetArrayItem(tag, 0); + if (cJSON_IsString(t0) && strcmp(t0->valuestring, "d") == 0) { + cJSON *t1 = cJSON_GetArrayItem(tag, 1); + if (cJSON_IsString(t1)) dir_name = t1->valuestring; + break; + } + } + } + + /* Decrypt content. */ + cJSON *content = cJSON_GetObjectItemCaseSensitive(event, "content"); + if (cJSON_IsString(content) && content->valuestring[0]) { + char *plaintext = decrypt_content(content->valuestring); + if (plaintext) { + int idx = ensure_dir(dir_name); + if (idx >= 0) { + dir_load_from_json(&g_dirs[idx], plaintext); + } + free(plaintext); + } + } + + return 0; +} diff --git a/src/bookmarks.h b/src/bookmarks.h new file mode 100644 index 0000000..238ab3a --- /dev/null +++ b/src/bookmarks.h @@ -0,0 +1,106 @@ +/* + * bookmarks.h — NIP-44 encrypted Nostr bookmarks with directories + * + * Stores bookmarks as NIP-51 kind 30003 (bookmark sets) events, one per + * directory. Each directory's bookmarks are NIP-44 encrypted (self-to-self) + * and stored in the event's content field as a JSON tag array. + * + * The bookmarks sync across all devices the user logs in on via the + * bootstrap relays. + */ + +#ifndef BOOKMARKS_H +#define BOOKMARKS_H + +#include +#include + +/* nostr_signer_t is needed for the init function */ +#include "nostr_core/nostr_signer.h" + +#ifdef __cplusplus +extern "C" { +#endif + +#define BOOKMARKS_MAX_PER_DIR 500 +#define BOOKMARKS_DIR_NAME_MAX 128 +#define BOOKMARKS_URL_MAX 2048 +#define BOOKMARKS_TITLE_MAX 256 + +typedef struct { + char *url; + char *title; + long added; /* unix timestamp */ +} bookmark_t; + +typedef struct { + char name[BOOKMARKS_DIR_NAME_MAX]; + bookmark_t *bookmarks; + int count; +} bookmark_dir_t; + +/* + * Initialize the bookmarks module. Loads cached bookmarks from the SQLite + * database and decrypts them using the signer. + * + * signer — the user's nostr_signer_t (NULL for read-only/no-login) + * pubkey_hex — the user's hex pubkey (64 chars) + * + * Returns 0 on success, -1 on error. + */ +int bookmarks_init(nostr_signer_t *signer, const char *pubkey_hex); + +/* Free the in-memory bookmark list. Call at shutdown. */ +void bookmarks_cleanup(void); + +/* ── Read access ───────────────────────────────────────────────────── */ + +/* Get the list of directories (read-only). Returns a pointer to the + * internal array (valid until the next bookmarks operation). */ +const bookmark_dir_t *bookmarks_get_dirs(int *count_out); + +/* Get a specific directory by name. Returns NULL if not found. */ +const bookmark_dir_t *bookmarks_get_dir(const char *name); + +/* ── Write access (requires signer) ────────────────────────────────── */ + +/* Add a bookmark to a directory. If the directory doesn't exist, it's + * created. Updates the in-memory list, re-encrypts, publishes a new + * kind 30003 event for that directory, and stores in SQLite. + * + * Returns 0 on success, -1 on error. */ +int bookmarks_add(const char *dir, const char *url, const char *title); + +/* Remove a bookmark by URL from a directory. + * Returns 0 on success, -1 on not found / error. */ +int bookmarks_remove(const char *dir, const char *url); + +/* Move a bookmark from one directory to another. + * Returns 0 on success, -1 on error. */ +int bookmarks_move(const char *from_dir, const char *url, + const char *to_dir); + +/* Create a new empty directory. Publishes an empty kind 30003 event. + * Returns 0 on success, -1 if directory already exists / error. */ +int bookmarks_create_dir(const char *name); + +/* Delete a directory. If move_to_general is TRUE, bookmarks are moved + * to "General" before deletion. Publishes a kind 5 deletion event. + * Returns 0 on success, -1 on error. */ +int bookmarks_delete_dir(const char *name, int move_to_general); + +/* ── Internal (used by relay fetch) ────────────────────────────────── */ + +/* Load bookmarks for a directory from a decrypted kind 30003 event's + * content JSON. Called by bookmarks_init and after relay fetch. */ +int bookmarks_load_dir_from_json(const char *dir_name, const char *json); + +/* Store a raw kind 30003 event in the database and decrypt its content + * into the in-memory list. Called by the relay fetch after login. */ +int bookmarks_store_and_load_event(const void *event_cjson); + +#ifdef __cplusplus +} +#endif + +#endif /* BOOKMARKS_H */ diff --git a/src/cli.c b/src/cli.c index e3c2e0c..d96c26d 100644 --- a/src/cli.c +++ b/src/cli.c @@ -64,7 +64,6 @@ void cli_print_usage(FILE *fp) { " --nsigner-device (nsigner) device path / socket / host:port / qube\n" " --nsigner-service (nsigner) qrexec service name\n" " --nsigner-index (nsigner) key index (default 0)\n" - " --no-save-identity Do not persist identity to disk\n" " --login-timeout Override agent login timeout (GTK dialog path)\n" "\n" "Diagnostics:\n" @@ -105,7 +104,6 @@ enum { OPT_NSIGNER_DEVICE, OPT_NSIGNER_SERVICE, OPT_NSIGNER_INDEX, - OPT_NO_SAVE_IDENTITY, OPT_NO_LOGIN, OPT_LOGIN_TIMEOUT, OPT_VERBOSE, @@ -139,7 +137,6 @@ static struct option long_opts[] = { {"nsigner-device", required_argument, 0, OPT_NSIGNER_DEVICE}, {"nsigner-service", required_argument, 0, OPT_NSIGNER_SERVICE}, {"nsigner-index", required_argument, 0, OPT_NSIGNER_INDEX}, - {"no-save-identity", no_argument, 0, OPT_NO_SAVE_IDENTITY}, {"no-login", no_argument, 0, OPT_NO_LOGIN}, {"login-timeout", required_argument, 0, OPT_LOGIN_TIMEOUT}, /* Diagnostics */ @@ -337,9 +334,6 @@ int cli_parse(int *argc, char ***argv, cli_args_t *out) { return -1; } break; - case OPT_NO_SAVE_IDENTITY: - out->no_save_identity = TRUE; - break; case OPT_NO_LOGIN: out->no_login = TRUE; break; @@ -471,11 +465,10 @@ int cli_login(const cli_args_t *args) { method ? method : "?", pubkey ? pubkey : "?"); } cJSON_Delete(result); +/* Private keys are never persisted to disk — they live only in RAM + * for the duration of the session. The identity is held in the + * global app_state_t (main.c) and is gone when the browser quits. */ - /* TODO: persist identity via key_store_save() when that path is - * wired up. For now --no-save-identity is accepted but the save - * itself is a no-op (key_store_save is not called anywhere yet). */ - (void)args->no_save_identity; return 0; } diff --git a/src/cli.h b/src/cli.h index 4e63af7..69397c4 100644 --- a/src/cli.h +++ b/src/cli.h @@ -62,7 +62,6 @@ typedef struct { char *nsigner_device; /* --nsigner-device */ char *nsigner_service; /* --nsigner-service */ int nsigner_index; /* --nsigner-index (-1 = unset) */ - gboolean no_save_identity; /* --no-save-identity */ int login_timeout_ms; /* --login-timeout (0 = unset) */ /* Diagnostics. */ diff --git a/src/db.c b/src/db.c index b3d58d5..4cd15ce 100644 --- a/src/db.c +++ b/src/db.c @@ -72,6 +72,20 @@ static const char *SCHEMA_SQL = " key TEXT PRIMARY KEY," " value TEXT" ");" + "CREATE TABLE IF NOT EXISTS history (" + " id INTEGER PRIMARY KEY AUTOINCREMENT," + " url TEXT NOT NULL UNIQUE," + " title TEXT," + " visited_at INTEGER NOT NULL," + " visit_count INTEGER DEFAULT 1" + ");" + "CREATE INDEX IF NOT EXISTS idx_history_visited_at " + " ON history(visited_at DESC);" + "CREATE TABLE IF NOT EXISTS session (" + " tab_index INTEGER PRIMARY KEY," + " url TEXT NOT NULL," + " title TEXT" + ");" "CREATE INDEX IF NOT EXISTS idx_events_pubkey_kind " " ON events(pubkey, kind, created_at DESC);" "CREATE INDEX IF NOT EXISTS idx_event_tags_name_value " @@ -402,3 +416,164 @@ char *db_kv_get_copy(const char *key) { if (val == NULL) return NULL; return g_strdup(val); } + +/* ── History ───────────────────────────────────────────────────────── */ + +int db_history_add(const char *url, const char *title) { + if (g_db == NULL || url == NULL || url[0] == '\0') return -1; + + /* UPSERT: insert or update visited_at + visit_count. */ + sqlite3_stmt *stmt = NULL; + int rc = sqlite3_prepare_v2(g_db, + "INSERT INTO history (url, title, visited_at, visit_count) " + "VALUES (?, ?, strftime('%s','now'), 1) " + "ON CONFLICT(url) DO UPDATE SET " + " title = excluded.title, " + " visited_at = strftime('%s','now'), " + " visit_count = visit_count + 1;", + -1, &stmt, NULL); + if (rc != SQLITE_OK) return -1; + + sqlite3_bind_text(stmt, 1, url, -1, SQLITE_TRANSIENT); + sqlite3_bind_text(stmt, 2, title ? title : "", -1, SQLITE_TRANSIENT); + + rc = sqlite3_step(stmt); + sqlite3_finalize(stmt); + return (rc == SQLITE_DONE) ? 0 : -1; +} + +int db_history_get(char ***urls_out, char ***titles_out, + int *count_out, int limit) { + if (g_db == NULL || urls_out == NULL || count_out == NULL) return -1; + if (limit <= 0) limit = 50; + + sqlite3_stmt *stmt = NULL; + int rc = sqlite3_prepare_v2(g_db, + "SELECT url, title FROM history " + "ORDER BY visited_at DESC LIMIT ?;", + -1, &stmt, NULL); + if (rc != SQLITE_OK) return -1; + + sqlite3_bind_int(stmt, 1, limit); + + /* First pass: count rows. */ + int count = 0; + while (sqlite3_step(stmt) == SQLITE_ROW) count++; + sqlite3_reset(stmt); + + /* Allocate arrays. */ + *urls_out = g_new0(char *, count); + if (titles_out) *titles_out = g_new0(char *, count); + if (count_out) *count_out = 0; + + /* Second pass: fill. */ + int i = 0; + while (sqlite3_step(stmt) == SQLITE_ROW && i < count) { + const char *url = (const char *)sqlite3_column_text(stmt, 0); + const char *title = (const char *)sqlite3_column_text(stmt, 1); + (*urls_out)[i] = g_strdup(url ? url : ""); + if (titles_out) { + (*titles_out)[i] = g_strdup(title ? title : ""); + } + i++; + } + sqlite3_finalize(stmt); + + if (count_out) *count_out = i; + return i; +} + +int db_history_clear(void) { + if (g_db == NULL) return -1; + char *err = NULL; + int rc = sqlite3_exec(g_db, "DELETE FROM history;", NULL, NULL, &err); + if (rc != SQLITE_OK) { + if (err) sqlite3_free(err); + return -1; + } + return 0; +} + +/* ── Session ───────────────────────────────────────────────────────── */ + +int db_session_save(const char **urls, const char **titles, int count) { + if (g_db == NULL || urls == NULL) return -1; + + /* Clear the session table first. */ + char *err = NULL; + int rc = sqlite3_exec(g_db, "DELETE FROM session;", NULL, NULL, &err); + if (rc != SQLITE_OK) { + if (err) sqlite3_free(err); + return -1; + } + + /* Insert each tab. */ + sqlite3_stmt *stmt = NULL; + rc = sqlite3_prepare_v2(g_db, + "INSERT INTO session (tab_index, url, title) VALUES (?, ?, ?);", + -1, &stmt, NULL); + if (rc != SQLITE_OK) return -1; + + for (int i = 0; i < count; i++) { + if (urls[i] == NULL) continue; + sqlite3_bind_int(stmt, 1, i); + sqlite3_bind_text(stmt, 2, urls[i], -1, SQLITE_TRANSIENT); + sqlite3_bind_text(stmt, 3, + (titles && titles[i]) ? titles[i] : "", + -1, SQLITE_TRANSIENT); + sqlite3_step(stmt); + sqlite3_reset(stmt); + } + sqlite3_finalize(stmt); + return 0; +} + +int db_session_load(char ***urls_out, char ***titles_out, int *count_out) { + if (g_db == NULL || urls_out == NULL || count_out == NULL) return -1; + + /* Count rows. */ + sqlite3_stmt *stmt = NULL; + int rc = sqlite3_prepare_v2(g_db, + "SELECT url, title FROM session ORDER BY tab_index;", + -1, &stmt, NULL); + if (rc != SQLITE_OK) return -1; + + int count = 0; + while (sqlite3_step(stmt) == SQLITE_ROW) count++; + sqlite3_reset(stmt); + + if (count == 0) { + sqlite3_finalize(stmt); + *count_out = 0; + return 0; + } + + *urls_out = g_new0(char *, count); + if (titles_out) *titles_out = g_new0(char *, count); + + int i = 0; + while (sqlite3_step(stmt) == SQLITE_ROW && i < count) { + const char *url = (const char *)sqlite3_column_text(stmt, 0); + const char *title = (const char *)sqlite3_column_text(stmt, 1); + (*urls_out)[i] = g_strdup(url ? url : ""); + if (titles_out) { + (*titles_out)[i] = g_strdup(title ? title : ""); + } + i++; + } + sqlite3_finalize(stmt); + + *count_out = i; + return i; +} + +int db_session_clear(void) { + if (g_db == NULL) return -1; + char *err = NULL; + int rc = sqlite3_exec(g_db, "DELETE FROM session;", NULL, NULL, &err); + if (rc != SQLITE_OK) { + if (err) sqlite3_free(err); + return -1; + } + return 0; +} diff --git a/src/db.h b/src/db.h index 05edf11..06e74a8 100644 --- a/src/db.h +++ b/src/db.h @@ -98,6 +98,60 @@ const char *db_kv_get(const char *key); */ char *db_kv_get_copy(const char *key); +/* ── History ───────────────────────────────────────────────────────── */ + +/* + * Add a URL to the history (UPSERT — increments visit_count and updates + * visited_at if the URL already exists). + * url — the URL to record + * title — optional page title (NULL or "" for none) + * Returns 0 on success, -1 on error. + */ +int db_history_add(const char *url, const char *title); + +/* + * Get recent history entries (most-recent-first). + * limit — max number of entries (0 = default 50) + * + * Fills urls_out and titles_out with parallel arrays of newly allocated + * strings. Caller must free each string and the arrays themselves. + * Returns the number of entries, or -1 on error. + */ +int db_history_get(char ***urls_out, char ***titles_out, + int *count_out, int limit); + +/* + * Clear all history entries. + * Returns 0 on success, -1 on error. + */ +int db_history_clear(void); + +/* ── Session ───────────────────────────────────────────────────────── */ + +/* + * Save the current session (open tab URLs) to the database. + * Clears the session table first, then inserts the given URLs in order. + * urls — array of URL strings + * titles — array of title strings (can be NULL for no titles) + * count — number of tabs + * Returns 0 on success, -1 on error. + */ +int db_session_save(const char **urls, const char **titles, int count); + +/* + * Load the saved session from the database. + * Fills urls_out and titles_out with parallel arrays (tab_index order). + * Caller must free each string and the arrays. + * Returns the number of tabs, or -1 on error / no session. + */ +int db_session_load(char ***urls_out, char ***titles_out, int *count_out); + +/* + * Clear the saved session. + * Returns 0 on success, -1 on error. + */ +int db_session_clear(void); + #ifdef __cplusplus } #endif diff --git a/src/history.c b/src/history.c index 009950d..878de17 100644 --- a/src/history.c +++ b/src/history.c @@ -1,165 +1,111 @@ /* * history.c — URL history tracking for sovereign_browser + * + * History is stored in the SQLite database (history table) with timestamps + * and visit counts. No flat file, no entry cap. */ #include "history.h" +#include "db.h" -#include -#include #include -#include -#include -#include -#include +#include -/* ── In-memory history ─────────────────────────────────────────── */ - -static char g_history[HISTORY_MAX_ENTRIES][HISTORY_URL_MAX_LEN]; -static int g_history_count = 0; - -/* ── Path helper ───────────────────────────────────────────────── */ - -static int history_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/history.txt", dir); - if (n < 0 || (size_t)n >= out_sz) { - return -1; - } - return 0; -} - -/* ── Persistence ───────────────────────────────────────────────── */ - -void history_load(void) { - char path[512]; - if (history_path(path, sizeof(path)) != 0) { - return; - } - - FILE *f = fopen(path, "r"); - if (f == NULL) { - return; - } - - g_history_count = 0; - char line[HISTORY_URL_MAX_LEN]; - while (g_history_count < HISTORY_MAX_ENTRIES && - fgets(line, sizeof(line), f) != NULL) { - /* Strip newline. */ - size_t len = strlen(line); - if (len > 0 && line[len - 1] == '\n') { - line[len - 1] = '\0'; - len--; - } - if (len > 0) { - snprintf(g_history[g_history_count], HISTORY_URL_MAX_LEN, "%s", line); - g_history_count++; - } - } - fclose(f); -} - -static void history_save(void) { - char path[512]; - if (history_path(path, sizeof(path)) != 0) { - return; - } - - FILE *f = fopen(path, "w"); - if (f == NULL) { - return; - } - fchmod(fileno(f), 0600); - - /* Write in reverse order (most recent first) — same as in-memory order. */ - for (int i = 0; i < g_history_count; i++) { - fprintf(f, "%s\n", g_history[i]); - } - fclose(f); -} - -/* ── API ───────────────────────────────────────────────────────── */ +/* ── Public API ────────────────────────────────────────────────────── */ void history_add(const char *url) { - if (url == NULL || url[0] == '\0') { - return; - } + history_add_titled(url, NULL); +} + +void history_add_titled(const char *url, const char *title) { + if (url == NULL || url[0] == '\0') return; /* Skip sovereign:// API endpoints (not pages the user would want * in their recents). The actual pages — sovereign://settings, - * sovereign://profile, sovereign://security — are kept in history - * so the user can quickly return to them. */ + * sovereign://profile, sovereign://bookmarks, sovereign://security — + * are kept in history so the user can quickly return to them. */ if (strncmp(url, "sovereign://settings/set", 24) == 0 || + strncmp(url, "sovereign://bookmarks/add", 25) == 0 || + strncmp(url, "sovereign://bookmarks/delete", 28) == 0 || + strncmp(url, "sovereign://bookmarks/createdir", 31) == 0 || + strncmp(url, "sovereign://bookmarks/deletedir", 31) == 0 || strncmp(url, "sovereign://qr", 14) == 0 || strncmp(url, "sovereign://nostr/", 18) == 0) { return; } - /* Check if the URL is already in the history. */ - int found_idx = -1; - for (int i = 0; i < g_history_count; i++) { - if (strcmp(g_history[i], url) == 0) { - found_idx = i; - break; - } - } - - if (found_idx >= 0) { - /* URL already exists — move it to the top. */ - if (found_idx == 0) { - return; /* already at the top, nothing to do */ - } - /* Shift items 0..found_idx-1 down by one. */ - char tmp[HISTORY_URL_MAX_LEN]; - snprintf(tmp, HISTORY_URL_MAX_LEN, "%s", g_history[found_idx]); - for (int i = found_idx; i > 0; i--) { - snprintf(g_history[i], HISTORY_URL_MAX_LEN, "%s", g_history[i - 1]); - } - snprintf(g_history[0], HISTORY_URL_MAX_LEN, "%s", tmp); - } else { - /* New URL — shift everything down by one to make room at the top. */ - if (g_history_count >= HISTORY_MAX_ENTRIES) { - g_history_count = HISTORY_MAX_ENTRIES - 1; - } - for (int i = g_history_count; i > 0; i--) { - snprintf(g_history[i], HISTORY_URL_MAX_LEN, "%s", g_history[i - 1]); - } - snprintf(g_history[0], HISTORY_URL_MAX_LEN, "%s", url); - g_history_count++; - } - - history_save(); + db_history_add(url, title); } int history_count(void) { - return g_history_count; + /* Query the count from the database via db_history_get. + * For the Recents submenu we only show 20, but we need the + * total count to decide whether to show the submenu. */ + char **urls = NULL; + int count = 0; + int rc = db_history_get(&urls, NULL, &count, 10000); + if (rc < 0) return 0; + if (urls) { + for (int i = 0; i < count; i++) g_free(urls[i]); + g_free(urls); + } + return count; } -const char *history_get(int index) { - if (index < 0 || index >= g_history_count) { +char *history_get(int index) { + if (index < 0) return NULL; + + char **urls = NULL; + int count = 0; + db_history_get(&urls, NULL, &count, index + 1); + if (urls == NULL || count <= index) { + if (urls) { + for (int i = 0; i < count; i++) g_free(urls[i]); + g_free(urls); + } return NULL; } - return g_history[index]; + + char *result = urls[index]; + /* Free the other entries. */ + for (int i = 0; i < count; i++) { + if (i != index) g_free(urls[i]); + } + g_free(urls); + return result; +} + +char *history_get_title(int index) { + if (index < 0) return NULL; + + char **urls = NULL; + char **titles = NULL; + int count = 0; + db_history_get(&urls, &titles, &count, index + 1); + if (urls == NULL || count <= index) { + if (urls) { + for (int i = 0; i < count; i++) g_free(urls[i]); + g_free(urls); + } + if (titles) { + for (int i = 0; i < count; i++) g_free(titles[i]); + g_free(titles); + } + return NULL; + } + + char *result = titles[index]; + /* Free everything except the result. */ + for (int i = 0; i < count; i++) { + if (i != index) g_free(titles[i]); + g_free(urls[i]); + } + g_free(urls); + g_free(titles); + return result; } void history_clear(void) { - g_history_count = 0; - - char path[512]; - if (history_path(path, sizeof(path)) == 0) { - unlink(path); - } + db_history_clear(); + g_print("[history] cleared\n"); } diff --git a/src/history.h b/src/history.h index 018ef55..1ccbd20 100644 --- a/src/history.h +++ b/src/history.h @@ -1,8 +1,9 @@ /* * history.h — URL history tracking for sovereign_browser * - * Keeps a list of recently visited URLs (most recent first) and - * provides them for the History submenu. + * History is stored in the SQLite database (history table) with timestamps + * and visit counts. There is no entry cap — the database handles unlimited + * entries. The Recents submenu shows the most recent 20. */ #ifndef HISTORY_H @@ -14,35 +15,37 @@ extern "C" { #endif -#define HISTORY_MAX_ENTRIES 50 #define HISTORY_URL_MAX_LEN 2048 /* - * Add a URL to the history. If the URL is already the most recent - * entry, it's not duplicated. The list is capped at HISTORY_MAX_ENTRIES. - * Also persists to ~/.sovereign_browser/history.txt. + * Add a URL to the history. UPSERT — if the URL already exists, + * increments visit_count and updates visited_at. The title is optional + * (NULL or "" for no title). */ void history_add(const char *url); +void history_add_titled(const char *url, const char *title); /* - * Get the number of history entries. + * Get the number of history entries available (queries the database). + * Returns the count, or -1 on error. */ int history_count(void); /* - * Get a history entry by index (0 = most recent). - * Returns NULL if index is out of range. + * Get a history URL by index (0 = most recent). + * Returns a newly allocated string (caller must g_free), or NULL if + * index is out of range. */ -const char *history_get(int index); +char *history_get(int index); /* - * Load history from ~/.sovereign_browser/history.txt. - * Called once at startup. + * Get the title for a history entry by index. + * Returns a newly allocated string, or NULL if no title / out of range. */ -void history_load(void); +char *history_get_title(int index); /* - * Clear all history entries and delete the file. + * Clear all history entries. */ void history_clear(void); diff --git a/src/key_store.c b/src/key_store.c index ed87589..38ac5e6 100644 --- a/src/key_store.c +++ b/src/key_store.c @@ -1,5 +1,14 @@ /* - * key_store.c — Nostr identity persistence for sovereign_browser + * key_store.c — Nostr identity (in-memory only) for sovereign_browser + * + * Private keys are NEVER persisted to disk. They live only in RAM for + * the duration of the session and are gone when the browser quits. + * + * This module provides: + * - key_store_create_signer() — create a nostr_signer_t from an + * in-memory identity struct + * - key_store_delete_legacy_file() — defensively delete any + * identity.json left by a previous version that persisted keys */ #include "key_store.h" @@ -7,203 +16,14 @@ #include #include #include -#include -#include -#include #include +#include #include "nostr_core/nostr_core.h" #include "nostr_core/nip006.h" #include "nostr_core/nip019.h" -/* ── Path helpers ─────────────────────────────────────────────── */ - -int key_store_path(char *out, size_t out_sz) { - const char *home = getenv("HOME"); - if (home == NULL || home[0] == '\0') { - return -1; - } - - /* Ensure ~/.sovereign_browser/ exists. */ - 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/identity.json", dir); - if (n < 0 || (size_t)n >= out_sz) { - return -1; - } - return 0; -} - -/* ── Save / load ──────────────────────────────────────────────── */ - -static const char *method_to_string(key_store_method_t m) { - switch (m) { - case KEY_STORE_METHOD_LOCAL: return "local"; - case KEY_STORE_METHOD_SEED: return "seed"; - case KEY_STORE_METHOD_READONLY: return "readonly"; - case KEY_STORE_METHOD_NIP46: return "nip46"; - case KEY_STORE_METHOD_NSIGNER: return "nsigner"; - default: return "none"; - } -} - -static key_store_method_t string_to_method(const char *s) { - if (!s) return KEY_STORE_METHOD_NONE; - if (strcmp(s, "local") == 0) return KEY_STORE_METHOD_LOCAL; - if (strcmp(s, "seed") == 0) return KEY_STORE_METHOD_SEED; - if (strcmp(s, "readonly") == 0) return KEY_STORE_METHOD_READONLY; - if (strcmp(s, "nip46") == 0) return KEY_STORE_METHOD_NIP46; - if (strcmp(s, "nsigner") == 0) return KEY_STORE_METHOD_NSIGNER; - return KEY_STORE_METHOD_NONE; -} - -int key_store_save(const key_store_identity_t *identity) { - char path[512]; - if (key_store_path(path, sizeof(path)) != 0) { - return -1; - } - - FILE *f = fopen(path, "w"); - if (f == NULL) { - return -1; - } - - /* Write with restrictive permissions. */ - fchmod(fileno(f), 0600); - - fprintf(f, "{\n"); - fprintf(f, " \"method\": \"%s\",\n", method_to_string(identity->method)); - fprintf(f, " \"pubkey_hex\": \"%s\"", identity->pubkey_hex); - - if (identity->method == KEY_STORE_METHOD_LOCAL || - identity->method == KEY_STORE_METHOD_SEED) { - fprintf(f, ",\n \"privkey_hex\": \"%s\"", identity->privkey_hex); - } - - if (identity->method == KEY_STORE_METHOD_SEED && identity->mnemonic[0]) { - fprintf(f, ",\n \"mnemonic\": \"%s\"", identity->mnemonic); - } - - if (identity->method == KEY_STORE_METHOD_NIP46 && identity->bunker_url[0]) { - fprintf(f, ",\n \"bunker_url\": \"%s\"", identity->bunker_url); - } - - if (identity->method == KEY_STORE_METHOD_NSIGNER) { - fprintf(f, ",\n \"nsigner_transport\": \"%s\"", identity->nsigner_transport); - fprintf(f, ",\n \"nsigner_device\": \"%s\"", identity->nsigner_device); - fprintf(f, ",\n \"nsigner_index\": %d", identity->nsigner_index); - } - - fprintf(f, "\n}\n"); - fclose(f); - return 0; -} - -/* Minimal JSON string field extractor (no nested objects, good enough for our flat schema). */ -static int json_extract_string(const char *json, const char *key, char *out, size_t out_sz) { - char pattern[64]; - snprintf(pattern, sizeof(pattern), "\"%s\"", key); - const char *p = strstr(json, pattern); - if (p == NULL) return -1; - - p += strlen(pattern); - /* Skip whitespace and colon. */ - while (*p && (*p == ' ' || *p == '\t' || *p == ':' || *p == '\n' || *p == '\r')) { - p++; - } - if (*p != '"') return -1; - p++; - - size_t i = 0; - while (*p && *p != '"' && i < out_sz - 1) { - if (*p == '\\' && p[1]) { - p++; - } - out[i++] = *p++; - } - out[i] = '\0'; - return (i > 0 || *p == '"') ? 0 : -1; -} - -static int json_extract_int(const char *json, const char *key, int *out) { - char pattern[64]; - snprintf(pattern, sizeof(pattern), "\"%s\"", key); - const char *p = strstr(json, pattern); - if (p == NULL) return -1; - - p += strlen(pattern); - while (*p && (*p == ' ' || *p == '\t' || *p == ':' || *p == '\n' || *p == '\r')) { - p++; - } - char *end; - long val = strtol(p, &end, 10); - if (end == p) return -1; - *out = (int)val; - return 0; -} - -int key_store_load(key_store_identity_t *out) { - memset(out, 0, sizeof(*out)); - - char path[512]; - if (key_store_path(path, sizeof(path)) != 0) { - return -1; - } - - FILE *f = fopen(path, "r"); - if (f == NULL) { - if (errno == ENOENT) return 1; /* no identity file */ - return -1; - } - - /* Read the whole file (small). */ - char buf[4096]; - size_t n = fread(buf, 1, sizeof(buf) - 1, f); - fclose(f); - buf[n] = '\0'; - - char method_str[32]; - if (json_extract_string(buf, "method", method_str, sizeof(method_str)) != 0) { - return -1; - } - out->method = string_to_method(method_str); - if (out->method == KEY_STORE_METHOD_NONE) { - return -1; - } - - if (json_extract_string(buf, "pubkey_hex", out->pubkey_hex, sizeof(out->pubkey_hex)) != 0) { - return -1; - } - - json_extract_string(buf, "privkey_hex", out->privkey_hex, sizeof(out->privkey_hex)); - json_extract_string(buf, "mnemonic", out->mnemonic, sizeof(out->mnemonic)); - json_extract_string(buf, "bunker_url", out->bunker_url, sizeof(out->bunker_url)); - json_extract_string(buf, "nsigner_transport", out->nsigner_transport, sizeof(out->nsigner_transport)); - json_extract_string(buf, "nsigner_device", out->nsigner_device, sizeof(out->nsigner_device)); - json_extract_int(buf, "nsigner_index", &out->nsigner_index); - - return 0; -} - -int key_store_clear(void) { - char path[512]; - if (key_store_path(path, sizeof(path)) != 0) { - return -1; - } - if (unlink(path) != 0 && errno != ENOENT) { - return -1; - } - return 0; -} - -/* ── Signer creation ──────────────────────────────────────────── */ +/* ── Signer creation ──────────────────────────────────────────────── */ nostr_signer_t *key_store_create_signer(const key_store_identity_t *identity) { if (identity == NULL) { @@ -267,3 +87,25 @@ nostr_signer_t *key_store_create_signer(const key_store_identity_t *identity) { return NULL; } } + +/* ── Legacy file cleanup ──────────────────────────────────────────── */ + +int key_store_delete_legacy_file(void) { + const char *home = getenv("HOME"); + if (home == NULL || home[0] == '\0') { + return -1; + } + + char path[512]; + int n = snprintf(path, sizeof(path), "%s/.sovereign_browser/identity.json", + home); + if (n < 0 || (size_t)n >= sizeof(path)) { + return -1; + } + + /* Delete the file if it exists. ENOENT is not an error. */ + if (unlink(path) != 0 && errno != ENOENT) { + return -1; + } + return 0; +} diff --git a/src/key_store.h b/src/key_store.h index cc8e82d..56905d4 100644 --- a/src/key_store.h +++ b/src/key_store.h @@ -1,8 +1,13 @@ /* - * key_store.h — Nostr identity persistence for sovereign_browser + * key_store.h — Nostr identity (in-memory only) for sovereign_browser * - * Saves/loads the user's Nostr identity to ~/.sovereign_browser/identity.json - * so the browser can restore the signer on startup without re-prompting. + * IMPORTANT: Private keys are NEVER persisted to disk. They live only in + * working memory (RAM) for the duration of the session and are gone when + * the browser quits. The user must sign in again on each launch. + * + * The key_store_identity_t struct is populated by the login dialog or CLI + * login, held in memory, and used to create a nostr_signer_t. It is not + * written to any file. */ #ifndef KEY_STORE_H @@ -18,14 +23,14 @@ extern "C" { /* Login methods supported by the browser. */ typedef enum { KEY_STORE_METHOD_NONE = 0, - KEY_STORE_METHOD_LOCAL, /* nsec — private key stored (encrypted later) */ + KEY_STORE_METHOD_LOCAL, /* nsec — private key in memory only */ KEY_STORE_METHOD_SEED, /* BIP-39 mnemonic — private key re-derived */ KEY_STORE_METHOD_READONLY, /* npub only — no signing */ KEY_STORE_METHOD_NIP46, /* bunker:// URL — remote signer session */ KEY_STORE_METHOD_NSIGNER /* n_signer hardware — device path + index */ } key_store_method_t; -/* Identity record persisted to disk. */ +/* Identity record (in-memory only — never persisted). */ typedef struct { key_store_method_t method; @@ -33,10 +38,10 @@ typedef struct { char pubkey_hex[65]; /* Hex private key (local + seed methods only; 64 chars + NUL). - * TODO: encrypt at rest with AES-256-GCM + user password (Phase 5). */ + * Held in memory only — never written to disk. */ char privkey_hex[65]; - /* BIP-39 mnemonic (seed method only). */ + /* BIP-39 mnemonic (seed method only). In memory only. */ char mnemonic[256]; /* NIP-46 bunker URL (nip46 method only). */ @@ -49,32 +54,7 @@ typedef struct { } key_store_identity_t; /* - * Get the path to the identity file (~/.sovereign_browser/identity.json). - * Creates the directory if it doesn't exist. - * Returns 0 on success, -1 on error. - */ -int key_store_path(char *out, size_t out_sz); - -/* - * Save an identity to disk. - * Returns 0 on success, -1 on error. - */ -int key_store_save(const key_store_identity_t *identity); - -/* - * Load an identity from disk. - * Returns 0 on success, 1 if no identity file exists, -1 on error. - */ -int key_store_load(key_store_identity_t *out); - -/* - * Delete the identity file (logout). - * Returns 0 on success, -1 on error. - */ -int key_store_clear(void); - -/* - * Create a nostr_signer_t from a loaded identity. + * Create a nostr_signer_t from an in-memory identity. * For readonly: returns NULL (caller should use pubkey_hex directly). * For nsigner: creates the appropriate transport-backed signer. * Caller must free the returned signer with nostr_signer_free(). @@ -82,6 +62,13 @@ int key_store_clear(void); */ nostr_signer_t *key_store_create_signer(const key_store_identity_t *identity); +/* + * Defensively delete any legacy identity.json file from a previous + * version that persisted keys to disk. Call once at startup. + * Returns 0 on success (including if the file doesn't exist), -1 on error. + */ +int key_store_delete_legacy_file(void); + #ifdef __cplusplus } #endif diff --git a/src/main.c b/src/main.c index ab6bdb0..d02366f 100644 --- a/src/main.c +++ b/src/main.c @@ -46,6 +46,7 @@ #include "cli.h" #include "db.h" #include "relay_fetch.h" +#include "bookmarks.h" #include "nostr_core/nostr_core.h" /* ---- Global state --------------------------------------------------- * @@ -147,7 +148,6 @@ void app_menu_logout_proxy(GtkMenuItem *item, gpointer data) { g_state.pubkey_hex[0] = '\0'; g_state.method = KEY_STORE_METHOD_NONE; g_state.readonly = FALSE; - key_store_clear(); g_print("[identity] logged out\n"); } @@ -213,6 +213,18 @@ void on_menu_settings(GtkMenuItem *item, gpointer data) { } } +void on_menu_profile(GtkMenuItem *item, gpointer data) { + (void)item; + (void)data; + + tab_info_t *tab = tab_manager_get_active(); + if (tab && tab->webview) { + webkit_web_view_load_uri(tab->webview, "sovereign://profile"); + } else { + tab_manager_new_tab("sovereign://profile"); + } +} + /* ---- Keyboard shortcuts --------------------------------------------- */ static gboolean on_key_press(GtkWidget *widget, GdkEventKey *event, @@ -305,6 +317,7 @@ static void on_window_destroy(GtkWidget *widget, gpointer data) { g_state.signer = NULL; } nostr_cleanup(); + bookmarks_cleanup(); db_close(); gtk_main_quit(); } @@ -382,13 +395,20 @@ int main(int argc, char **argv) { gtk_init(&argc, &argv); - /* Load settings and history from disk. */ - settings_load(); - history_load(); + /* Defensively delete any legacy identity.json from a previous version + * that persisted private keys to disk. Keys are now in-memory only. */ + key_store_delete_legacy_file(); - /* Initialize the SQLite database for Nostr events and misc data. */ + /* Initialize the SQLite database first — settings and history are + * stored there. */ db_init(); + /* Load settings from the database (key_value table). */ + settings_load(); + + /* History is queried from the SQLite database on demand — no + * separate load step needed. */ + /* Apply CLI overrides to the in-memory settings singleton. These * do not write to disk — they are one-shot overrides for this run. */ { @@ -517,6 +537,12 @@ int main(int argc, char **argv) { g_state.pubkey_hex); } + /* Initialize the bookmarks module. Loads cached kind 30003 events + * from SQLite and decrypts them. In no-login/read-only mode, the + * signer is NULL so bookmarks are read-only. */ + bookmarks_init(g_state.signer, + g_state.pubkey_hex[0] ? g_state.pubkey_hex : NULL); + /* 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. */ diff --git a/src/nostr_bridge.c b/src/nostr_bridge.c index 1adc16e..424605d 100644 --- a/src/nostr_bridge.c +++ b/src/nostr_bridge.c @@ -20,6 +20,7 @@ #include "tab_manager.h" #include "qr.h" #include "db.h" +#include "bookmarks.h" /* ── Global bridge state ────────────────────────────────────────── * * The URI scheme callback needs access to the current signer. We keep @@ -1098,6 +1099,275 @@ static void handle_profile_page(WebKitURISchemeRequest *request) { if (kind3) cJSON_Delete(kind3); } +/* ── Bookmarks page (sovereign://bookmarks) ──────────────────────── */ + +/* Generate the sovereign://bookmarks HTML page. + * Shows all bookmark directories and their bookmarks, with add/delete/move + * controls. In read-only / no-login mode, add/delete are disabled. */ +static void handle_bookmarks_page(WebKitURISchemeRequest *request) { + int dir_count = 0; + const bookmark_dir_t *dirs = bookmarks_get_dirs(&dir_count); + + int have_signer = (g_bridge.signer != NULL && !g_bridge.readonly); + + GString *html = g_string_new( + "\n" + "\n" + "sovereign browser — Bookmarks\n" + "\n" + "\n" + "

sovereign browser — Bookmarks

\n"); + + if (!have_signer) { + g_string_append(html, + "

Read-only mode — sign in to add, edit, or delete " + "bookmarks. Showing cached bookmarks from the local database.

\n"); + } + + /* Add bookmark form (only if signer available). */ + if (have_signer) { + g_string_append(html, + "

Add Bookmark

\n" + "
\n" + " \n" + " \n" + " \n" + " \n" + "
\n" + "
\n" + " \n" + " \n" + "
\n"); + } + + /* List directories and bookmarks. */ + g_string_append(html, "

Bookmarks

\n"); + + if (dir_count == 0) { + g_string_append(html, + "

No bookmarks yet. Click the bookmark button " + "in the toolbar to save a page.

\n"); + } else { + for (int i = 0; i < dir_count; i++) { + const bookmark_dir_t *dir = &dirs[i]; + char *dir_esc = g_markup_escape_text(dir->name, -1); + + g_string_append_printf(html, + "

%s (%d)

", + dir_esc, dir->count); + + if (have_signer && strcmp(dir->name, "General") != 0) { + g_string_append_printf(html, + " Delete Dir", + g_uri_escape_string(dir->name, NULL, FALSE), + dir_esc); + } + g_string_append(html, "
\n"); + + if (dir->count == 0) { + g_string_append(html, "

(empty)

\n"); + } else { + for (int j = 0; j < dir->count; j++) { + const bookmark_t *bm = &dir->bookmarks[j]; + char *url_esc = g_uri_escape_string(bm->url, NULL, FALSE); + char *title_esc = g_markup_escape_text(bm->title, -1); + char *url_display = g_markup_escape_text(bm->url, -1); + char *dir_for_link = g_uri_escape_string(dir->name, NULL, FALSE); + + g_string_append_printf(html, + "
" + "
%s
" + "" + "
Added: %ld
" + "
" + "Open", + title_esc[0] ? title_esc : "(untitled)", + url_display, url_display, bm->added, + url_display); + + if (have_signer) { + g_string_append_printf(html, + " Delete", + dir_for_link, url_esc); + } + + g_string_append(html, "
\n"); + g_free(url_esc); + g_free(title_esc); + g_free(url_display); + g_free(dir_for_link); + } + } + g_free(dir_esc); + } + } + + g_string_append(html, + "\n\n" + "\n"); + + char *html_str = g_string_free(html, FALSE); + respond_html(request, html_str); + g_free(html_str); +} + +/* Handle sovereign://bookmarks/add?dir=...&url=...&title=... */ +static void handle_bookmarks_add(WebKitURISchemeRequest *request, + const char *query) { + char *dir = query_param(query, "dir"); + char *url = query_param(query, "url"); + char *title = query_param(query, "title"); + + if (!url || url[0] == '\0') { + respond_error_json(request, -1, "Missing url parameter"); + g_free(dir); g_free(url); g_free(title); + return; + } + + int rc = bookmarks_add(dir ? dir : "General", url, title ? title : ""); + if (rc != 0) { + respond_error_json(request, -1, "Failed to add bookmark (no signer?)"); + } else { + cJSON *result = cJSON_CreateObject(); + cJSON_AddStringToObject(result, "status", "added"); + char *json = cJSON_PrintUnformatted(result); + cJSON_Delete(result); + respond_json(request, json); + free(json); + } + g_free(dir); g_free(url); g_free(title); +} + +/* Handle sovereign://bookmarks/delete?dir=...&url=... */ +static void handle_bookmarks_delete(WebKitURISchemeRequest *request, + const char *query) { + char *dir = query_param(query, "dir"); + char *url = query_param(query, "url"); + + if (!url || url[0] == '\0') { + respond_error_json(request, -1, "Missing url parameter"); + g_free(dir); g_free(url); + return; + } + + int rc = bookmarks_remove(dir ? dir : "General", url); + if (rc != 0) { + respond_error_json(request, -1, "Failed to delete bookmark"); + } else { + cJSON *result = cJSON_CreateObject(); + cJSON_AddStringToObject(result, "status", "deleted"); + char *json = cJSON_PrintUnformatted(result); + cJSON_Delete(result); + respond_json(request, json); + free(json); + } + g_free(dir); g_free(url); +} + +/* Handle sovereign://bookmarks/createdir?name=... */ +static void handle_bookmarks_createdir(WebKitURISchemeRequest *request, + const char *query) { + char *name = query_param(query, "name"); + + if (!name || name[0] == '\0') { + respond_error_json(request, -1, "Missing name parameter"); + g_free(name); + return; + } + + int rc = bookmarks_create_dir(name); + if (rc != 0) { + respond_error_json(request, -1, "Failed to create directory"); + } else { + cJSON *result = cJSON_CreateObject(); + cJSON_AddStringToObject(result, "status", "created"); + char *json = cJSON_PrintUnformatted(result); + cJSON_Delete(result); + respond_json(request, json); + free(json); + } + g_free(name); +} + +/* Handle sovereign://bookmarks/deletedir?dir=... */ +static void handle_bookmarks_deletedir(WebKitURISchemeRequest *request, + const char *query) { + char *dir = query_param(query, "dir"); + + if (!dir || dir[0] == '\0') { + respond_error_json(request, -1, "Missing dir parameter"); + g_free(dir); + return; + } + + int rc = bookmarks_delete_dir(dir, 1 /* move to General */); + if (rc != 0) { + respond_error_json(request, -1, "Failed to delete directory"); + } else { + cJSON *result = cJSON_CreateObject(); + cJSON_AddStringToObject(result, "status", "deleted"); + char *json = cJSON_PrintUnformatted(result); + cJSON_Delete(result); + respond_json(request, json); + free(json); + } + g_free(dir); +} + /* ── URI scheme callback ────────────────────────────────────────── */ static void on_sovereign_scheme(WebKitURISchemeRequest *request, @@ -1155,6 +1425,28 @@ static void on_sovereign_scheme(WebKitURISchemeRequest *request, return; } + /* Route sovereign://bookmarks — manage bookmarks and directories. */ + if (strcmp(uri, "sovereign://bookmarks") == 0) { + handle_bookmarks_page(request); + return; + } + if (strncmp(uri, "sovereign://bookmarks/add?", 25) == 0) { + handle_bookmarks_add(request, uri + 25); + return; + } + if (strncmp(uri, "sovereign://bookmarks/delete?", 28) == 0) { + handle_bookmarks_delete(request, uri + 28); + return; + } + if (strncmp(uri, "sovereign://bookmarks/createdir?", 31) == 0) { + handle_bookmarks_createdir(request, uri + 31); + return; + } + if (strncmp(uri, "sovereign://bookmarks/deletedir?", 31) == 0) { + handle_bookmarks_deletedir(request, uri + 31); + return; + } + /* Route sovereign://settings requests. */ if (strcmp(uri, "sovereign://settings") == 0 || strncmp(uri, "sovereign://settings?", 21) == 0) { diff --git a/src/relay_fetch.c b/src/relay_fetch.c index d9e69c2..c6e7402 100644 --- a/src/relay_fetch.c +++ b/src/relay_fetch.c @@ -13,6 +13,7 @@ #include "relay_fetch.h" #include "db.h" #include "settings.h" +#include "bookmarks.h" #include #include @@ -54,6 +55,7 @@ int relay_fetch_bootstrap(const char *pubkey_hex, cJSON_AddItemToArray(kinds, cJSON_CreateNumber(0)); cJSON_AddItemToArray(kinds, cJSON_CreateNumber(3)); cJSON_AddItemToArray(kinds, cJSON_CreateNumber(10002)); + cJSON_AddItemToArray(kinds, cJSON_CreateNumber(30003)); /* bookmark sets */ cJSON_AddItemToObject(filter, "kinds", kinds); /* Query the relays. RELAY_QUERY_ALL_RESULTS collects all unique events @@ -76,15 +78,28 @@ int relay_fetch_bootstrap(const char *pubkey_hex, return 0; } - /* Store each event in the database. */ + /* Store each event in the database. Kind 30003 (bookmark sets) are + * also decrypted and loaded into the in-memory bookmarks list. */ int stored = 0; for (int i = 0; i < result_count; i++) { if (results[i] == NULL) continue; - if (db_store_event(results[i]) == 0) { - stored++; + + long kind = 0; + cJSON *k = cJSON_GetObjectItemCaseSensitive(results[i], "kind"); + if (cJSON_IsNumber(k)) kind = (long)k->valuedouble; + + if (kind == 30003) { + /* bookmarks_store_and_load_event stores in SQLite + decrypts. */ + if (bookmarks_store_and_load_event(results[i]) == 0) { + stored++; + } } else { - g_printerr("[relay] Failed to store event %d/%d\n", i + 1, - result_count); + if (db_store_event(results[i]) == 0) { + stored++; + } else { + g_printerr("[relay] Failed to store event %d/%d\n", i + 1, + result_count); + } } cJSON_Delete(results[i]); } diff --git a/src/session.c b/src/session.c index f3597a3..b992b0c 100644 --- a/src/session.c +++ b/src/session.c @@ -1,71 +1,45 @@ /* * 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. + * Saves open tab URLs (and titles) to the SQLite database (session table) + * on window close, and restores them on startup if settings.restore_session + * is true. */ #include "session.h" #include "settings.h" #include "tab_manager.h" +#include "db.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(); + + /* Build arrays of URLs and titles. */ + const char **urls = g_new0(const char *, count); + const char **titles = g_new0(const char *, count); + + int valid = 0; 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); + urls[valid] = url; + tab_info_t *tab = tab_manager_get(i); + titles[valid] = (tab && tab->title[0]) ? tab->title : ""; + valid++; } } - fclose(f); - g_print("[session] Saved %d tab(s)\n", count); + + db_session_save(urls, titles, valid); + + g_free(urls); + g_free(titles); + g_print("[session] Saved %d tab(s)\n", valid); } /* ── Restore ──────────────────────────────────────────────────────── */ @@ -76,32 +50,40 @@ int session_restore(void) { return 0; } - char path[512]; - if (session_path(path, sizeof(path)) != 0) { - return 0; - } + char **urls = NULL; + char **titles = NULL; + int count = 0; - FILE *f = fopen(path, "r"); - if (f == NULL) { + int rc = db_session_load(&urls, &titles, &count); + if (rc < 0 || count == 0) { + if (urls) { + for (int i = 0; i < count; i++) g_free(urls[i]); + g_free(urls); + } + if (titles) { + for (int i = 0; i < count; i++) g_free(titles[i]); + g_free(titles); + } 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++; + for (int i = 0; i < count; i++) { + if (urls[i] && urls[i][0]) { + int index = tab_manager_new_tab(urls[i]); + if (index >= 0) { + restored++; + } } } - fclose(f); + + /* Free the arrays. */ + for (int i = 0; i < count; i++) { + g_free(urls[i]); + g_free(titles[i]); + } + g_free(urls); + g_free(titles); g_print("[session] Restored %d tab(s)\n", restored); return restored; diff --git a/src/settings.c b/src/settings.c index eee42ec..f75f6da 100644 --- a/src/settings.c +++ b/src/settings.c @@ -1,10 +1,13 @@ /* * settings.c — browser preferences for sovereign_browser * - * Persists settings to ~/.sovereign_browser/settings.conf as key=value lines. + * Persists settings to the SQLite database (key_value table). Each setting + * is stored as a key-value pair. The database must be initialized (db_init()) + * before settings_load() is called. */ #include "settings.h" +#include "db.h" #include @@ -12,10 +15,6 @@ #include #include #include /* strcasecmp */ -#include -#include -#include -#include /* ── Global singleton ─────────────────────────────────────────────── */ @@ -41,30 +40,6 @@ static void settings_set_defaults(browser_settings_t *s) { SETTINGS_BOOTSTRAP_RELAYS_DEFAULT); } -/* ── 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) { @@ -102,150 +77,102 @@ static int parse_position(const char *value, int fallback) { return parse_int(value, fallback); } -/* ── Load / Save ──────────────────────────────────────────────────── */ +/* ── Position to string ───────────────────────────────────────────── */ + +static const char *position_to_string(int pos) { + switch (pos) { + case GTK_POS_TOP: return "top"; + case GTK_POS_BOTTOM: return "bottom"; + case GTK_POS_LEFT: return "left"; + case GTK_POS_RIGHT: return "right"; + default: return "top"; + } +} + +/* ── Load / Save (SQLite key_value table) ─────────────────────────── */ void settings_load(void) { settings_set_defaults(&g_settings); - char path[512]; - if (settings_path(path, sizeof(path)) != 0) { - return; + /* Read each setting from the key_value table. If a key is missing + * (NULL), the default is kept. */ + const char *val; + + val = db_kv_get("restore_session"); + if (val) g_settings.restore_session = parse_bool(val, g_settings.restore_session); + + val = db_kv_get("new_tab_url"); + if (val) snprintf(g_settings.new_tab_url, sizeof(g_settings.new_tab_url), "%s", val); + + val = db_kv_get("tab_bar_position"); + if (val) g_settings.tab_bar_position = parse_position(val, g_settings.tab_bar_position); + + val = db_kv_get("show_tab_close_buttons"); + if (val) g_settings.show_tab_close_buttons = parse_bool(val, g_settings.show_tab_close_buttons); + + val = db_kv_get("middle_click_close"); + if (val) g_settings.middle_click_close = parse_bool(val, g_settings.middle_click_close); + + val = db_kv_get("ctrl_tab_switch"); + if (val) g_settings.ctrl_tab_switch = parse_bool(val, g_settings.ctrl_tab_switch); + + val = db_kv_get("max_tabs"); + if (val) { + g_settings.max_tabs = parse_int(val, g_settings.max_tabs); + if (g_settings.max_tabs < 1) g_settings.max_tabs = 1; } - FILE *f = fopen(path, "r"); - if (f == NULL) { - return; + val = db_kv_get("tab_drag_reorder"); + if (val) g_settings.tab_drag_reorder = parse_bool(val, g_settings.tab_drag_reorder); + + val = db_kv_get("agent_server_enabled"); + if (val) g_settings.agent_server_enabled = parse_bool(val, g_settings.agent_server_enabled); + + val = db_kv_get("agent_server_port"); + if (val) { + g_settings.agent_server_port = parse_int(val, g_settings.agent_server_port); + if (g_settings.agent_server_port < 1) g_settings.agent_server_port = SETTINGS_AGENT_PORT_DEFAULT; } - 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; + val = db_kv_get("agent_allowed_origins"); + if (val) snprintf(g_settings.agent_allowed_origins, sizeof(g_settings.agent_allowed_origins), "%s", val); - /* 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); - } else if (strcmp(key, "agent_server_enabled") == 0) { - g_settings.agent_server_enabled = parse_bool(value, g_settings.agent_server_enabled); - } else if (strcmp(key, "agent_server_port") == 0) { - g_settings.agent_server_port = parse_int(value, g_settings.agent_server_port); - if (g_settings.agent_server_port < 1) g_settings.agent_server_port = SETTINGS_AGENT_PORT_DEFAULT; - } else if (strcmp(key, "agent_allowed_origins") == 0) { - snprintf(g_settings.agent_allowed_origins, - sizeof(g_settings.agent_allowed_origins), "%s", value); - } else if (strcmp(key, "agent_login_timeout_ms") == 0) { - g_settings.agent_login_timeout_ms = parse_int(value, g_settings.agent_login_timeout_ms); - if (g_settings.agent_login_timeout_ms < 0) g_settings.agent_login_timeout_ms = 0; - } else if (strcmp(key, "bootstrap_relays") == 0) { - /* The value may contain escaped newlines (\n) since the file - * is line-based key=value. Convert \n to actual newlines. */ - char buf[SETTINGS_BOOTSTRAP_RELAYS_MAX]; - size_t ri = 0, wi = 0; - while (value[ri] && wi < sizeof(buf) - 1) { - if (value[ri] == '\\' && value[ri + 1] == 'n') { - buf[wi++] = '\n'; - ri += 2; - } else { - buf[wi++] = value[ri++]; - } - } - buf[wi] = '\0'; - snprintf(g_settings.bootstrap_relays, - sizeof(g_settings.bootstrap_relays), "%s", buf); - } - /* Unknown keys are silently ignored. */ + val = db_kv_get("agent_login_timeout_ms"); + if (val) { + g_settings.agent_login_timeout_ms = parse_int(val, g_settings.agent_login_timeout_ms); + if (g_settings.agent_login_timeout_ms < 0) g_settings.agent_login_timeout_ms = 0; } - fclose(f); + + val = db_kv_get("bootstrap_relays"); + if (val) snprintf(g_settings.bootstrap_relays, sizeof(g_settings.bootstrap_relays), "%s", val); } void settings_save(void) { - char path[512]; - if (settings_path(path, sizeof(path)) != 0) { - return; - } + char buf[32]; - FILE *f = fopen(path, "w"); - if (f == NULL) { - return; - } - fchmod(fileno(f), 0600); + db_kv_set("restore_session", g_settings.restore_session ? "true" : "false"); + db_kv_set("new_tab_url", g_settings.new_tab_url); + db_kv_set("tab_bar_position", position_to_string(g_settings.tab_bar_position)); + db_kv_set("show_tab_close_buttons", g_settings.show_tab_close_buttons ? "true" : "false"); + db_kv_set("middle_click_close", g_settings.middle_click_close ? "true" : "false"); + db_kv_set("ctrl_tab_switch", g_settings.ctrl_tab_switch ? "true" : "false"); - 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; - } + snprintf(buf, sizeof(buf), "%d", g_settings.max_tabs); + db_kv_set("max_tabs", buf); - 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"); - fprintf(f, "agent_server_enabled=%s\n", g_settings.agent_server_enabled ? "true" : "false"); - fprintf(f, "agent_server_port=%d\n", g_settings.agent_server_port); - fprintf(f, "agent_allowed_origins=%s\n", g_settings.agent_allowed_origins); - fprintf(f, "agent_login_timeout_ms=%d\n", g_settings.agent_login_timeout_ms); + db_kv_set("tab_drag_reorder", g_settings.tab_drag_reorder ? "true" : "false"); + db_kv_set("agent_server_enabled", g_settings.agent_server_enabled ? "true" : "false"); - /* Save bootstrap relays with newlines escaped as \n (the file is - * line-based key=value, so actual newlines would break parsing). */ - { - char buf[SETTINGS_BOOTSTRAP_RELAYS_MAX * 2]; - size_t ri = 0, wi = 0; - while (g_settings.bootstrap_relays[ri] && - wi < sizeof(buf) - 1) { - if (g_settings.bootstrap_relays[ri] == '\n') { - buf[wi++] = '\\'; - buf[wi++] = 'n'; - ri++; - } else { - buf[wi++] = g_settings.bootstrap_relays[ri++]; - } - } - buf[wi] = '\0'; - fprintf(f, "bootstrap_relays=%s\n", buf); - } - - fclose(f); + snprintf(buf, sizeof(buf), "%d", g_settings.agent_server_port); + db_kv_set("agent_server_port", buf); + + db_kv_set("agent_allowed_origins", g_settings.agent_allowed_origins); + + snprintf(buf, sizeof(buf), "%d", g_settings.agent_login_timeout_ms); + db_kv_set("agent_login_timeout_ms", buf); + + /* Bootstrap relays are stored as-is (newlines are fine in SQLite). */ + db_kv_set("bootstrap_relays", g_settings.bootstrap_relays); } const browser_settings_t *settings_get(void) { diff --git a/src/tab_manager.c b/src/tab_manager.c index 1a19034..35874a3 100644 --- a/src/tab_manager.c +++ b/src/tab_manager.c @@ -11,6 +11,7 @@ #include "settings.h" #include "nostr_inject.h" #include "history.h" +#include "bookmarks.h" #include "version.h" #include "agent_snapshot.h" /* agent_js_eval_sync() for clear-and-reload */ @@ -31,6 +32,7 @@ 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); +extern void on_menu_profile(GtkMenuItem *item, gpointer data); /* ── Static state ─────────────────────────────────────────────────── */ @@ -250,6 +252,24 @@ static void on_url_activate(GtkEntry *entry, gpointer user_data) { } } +/* Back button — navigate to the previous page in history. */ +static void on_back_clicked(GtkButton *btn, gpointer user_data) { + (void)btn; + tab_info_t *tab = (tab_info_t *)user_data; + if (tab && tab->webview && webkit_web_view_can_go_back(tab->webview)) { + webkit_web_view_go_back(tab->webview); + } +} + +/* Forward button — navigate to the next page in history. */ +static void on_forward_clicked(GtkButton *btn, gpointer user_data) { + (void)btn; + tab_info_t *tab = (tab_info_t *)user_data; + if (tab && tab->webview && webkit_web_view_can_go_forward(tab->webview)) { + webkit_web_view_go_forward(tab->webview); + } +} + /* Refresh button — left-click: normal reload. */ static void on_refresh_clicked(GtkButton *btn, gpointer user_data) { (void)btn; @@ -377,9 +397,9 @@ static void on_load_changed(WebKitWebView *webview, } } - /* Add to history. */ + /* Add to history (with title for the Recents submenu tooltip). */ if (uri != NULL && uri[0] != '\0') { - history_add(uri); + history_add_titled(uri, (title && title[0]) ? title : NULL); } } @@ -467,7 +487,9 @@ 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); + /* The full URL is attached as "history-url" data (the label may be + * truncated for display). */ + const char *url = g_object_get_data(G_OBJECT(item), "history-url"); if (url && url[0]) { char *normalized = normalize_url(url); if (normalized) { @@ -503,7 +525,7 @@ static void on_history_menu_show(GtkWidget *menu, gpointer user_data) { } else { int show = hcount < 20 ? hcount : 20; for (int i = 0; i < show; i++) { - const char *url = history_get(i); + char *url = history_get(i); if (url == NULL) break; char label[80]; if (strlen(url) > 75) { @@ -513,6 +535,10 @@ static void on_history_menu_show(GtkWidget *menu, gpointer user_data) { } GtkWidget *hitem = gtk_menu_item_new_with_label(label); gtk_widget_set_tooltip_text(hitem, url); + /* Attach the URL to the item so the callback can retrieve it + * and so it's freed when the item is destroyed. */ + g_object_set_data_full(G_OBJECT(hitem), "history-url", url, + (GDestroyNotify)g_free); g_signal_connect(hitem, "activate", G_CALLBACK(on_menu_history_item), NULL); gtk_menu_shell_append(GTK_MENU_SHELL(menu), hitem); @@ -527,6 +553,220 @@ static void on_history_menu_show(GtkWidget *menu, gpointer user_data) { gtk_widget_show_all(menu); } +/* ── Bookmark handlers ─────────────────────────────────────────────── */ + +/* Called when a bookmark is clicked in the submenu — navigate to it. */ +static void on_bookmark_item_clicked(GtkMenuItem *item, gpointer data) { + (void)item; + const char *url = (const char *)data; + if (url == NULL) return; + tab_info_t *tab = tab_manager_get_active(); + if (tab && tab->webview) { + webkit_web_view_load_uri(tab->webview, url); + } +} + +/* Called when "Manage Bookmarks…" is clicked in the submenu. */ +static void on_manage_bookmarks_clicked(GtkMenuItem *item, gpointer data) { + (void)item; + (void)data; + tab_info_t *tab = tab_manager_get_active(); + if (tab && tab->webview) { + webkit_web_view_load_uri(tab->webview, "sovereign://bookmarks"); + } else { + tab_manager_new_tab("sovereign://bookmarks"); + } +} + +/* Wrapper for g_free to match GClosureNotify signature (avoids + * -Wcast-function-type warning from g_signal_connect_data). */ +static void closure_notify_g_free(gpointer data, GClosure *closure) { + (void)closure; + g_free(data); +} + +/* Rebuild the bookmarks submenu each time it's shown (like Recents). */ +static void on_bookmarks_menu_show(GtkWidget *menu, gpointer user_data) { + (void)user_data; + + /* Remove existing items. */ + GList *children = gtk_container_get_children(GTK_CONTAINER(menu)); + for (GList *l = children; l != NULL; l = l->next) { + gtk_widget_destroy(GTK_WIDGET(l->data)); + } + g_list_free(children); + + /* Get current bookmarks. */ + int dir_count = 0; + const bookmark_dir_t *dirs = bookmarks_get_dirs(&dir_count); + + int total_bookmarks = 0; + for (int i = 0; i < dir_count; i++) { + total_bookmarks += dirs[i].count; + } + + if (total_bookmarks == 0) { + GtkWidget *empty = gtk_menu_item_new_with_label("(no bookmarks)"); + gtk_widget_set_sensitive(empty, FALSE); + gtk_menu_shell_append(GTK_MENU_SHELL(menu), empty); + } else { + /* Show bookmarks grouped by directory (up to 15 total). */ + int shown = 0; + for (int i = 0; i < dir_count && shown < 15; i++) { + const bookmark_dir_t *dir = &dirs[i]; + if (dir->count == 0) continue; + + /* Directory label (non-clickable). */ + char label[160]; + snprintf(label, sizeof(label), "— %s —", dir->name); + GtkWidget *dir_item = gtk_menu_item_new_with_label(label); + gtk_widget_set_sensitive(dir_item, FALSE); + gtk_menu_shell_append(GTK_MENU_SHELL(menu), dir_item); + + for (int j = 0; j < dir->count && shown < 15; j++) { + const bookmark_t *bm = &dir->bookmarks[j]; + /* Use the title if available, otherwise the URL. */ + const char *label_text = bm->title[0] ? bm->title : bm->url; + char short_label[80]; + if (strlen(label_text) > 75) { + snprintf(short_label, sizeof(short_label), "%.72s...", + label_text); + } else { + snprintf(short_label, sizeof(short_label), "%s", + label_text); + } + GtkWidget *bm_item = gtk_menu_item_new_with_label(short_label); + gtk_widget_set_tooltip_text(bm_item, bm->url); + /* Store the URL in a g_strdup'd string attached to the item. */ + char *url_copy = g_strdup(bm->url); + g_signal_connect_data(bm_item, "activate", + G_CALLBACK(on_bookmark_item_clicked), url_copy, + (GClosureNotify)closure_notify_g_free, 0); + gtk_menu_shell_append(GTK_MENU_SHELL(menu), bm_item); + shown++; + } + } + } + + gtk_menu_shell_append(GTK_MENU_SHELL(menu), + gtk_separator_menu_item_new()); + GtkWidget *manage_item = + gtk_menu_item_new_with_label("Manage Bookmarks…"); + g_signal_connect(manage_item, "activate", + G_CALLBACK(on_manage_bookmarks_clicked), NULL); + gtk_menu_shell_append(GTK_MENU_SHELL(menu), manage_item); + + gtk_widget_show_all(menu); +} + +/* Called when the bookmark button (toolbar) is clicked. + * Shows a dialog to pick or create a directory, then bookmarks the + * current page. */ +static void on_bookmark_clicked(GtkButton *btn, gpointer user_data) { + (void)btn; + tab_info_t *tab = (tab_info_t *)user_data; + if (tab == NULL || tab->webview == NULL) return; + + /* Get the current URL and title. */ + const gchar *url = webkit_web_view_get_uri(tab->webview); + if (url == NULL || url[0] == '\0') { + g_print("[bookmarks] No URL to bookmark\n"); + return; + } + + /* Skip sovereign:// pages (can't bookmark internal pages). */ + if (strncmp(url, "sovereign://", 12) == 0) { + g_print("[bookmarks] Cannot bookmark internal pages\n"); + return; + } + + /* Get the page title. */ + const gchar *title = webkit_web_view_get_title(tab->webview); + if (title == NULL) title = ""; + + /* Build the directory picker dialog. */ + GtkWidget *dialog = gtk_dialog_new_with_buttons( + "Bookmark Page", g_window, + GTK_DIALOG_MODAL | GTK_DIALOG_DESTROY_WITH_PARENT, + "_Cancel", GTK_RESPONSE_CANCEL, + "_Add", GTK_RESPONSE_ACCEPT, + NULL); + gtk_window_set_default_size(GTK_WINDOW(dialog), 350, 150); + + GtkWidget *content = gtk_dialog_get_content_area(GTK_DIALOG(dialog)); + gtk_container_set_border_width(GTK_CONTAINER(content), 12); + + /* Show the URL being bookmarked. */ + char *url_esc = g_markup_escape_text(url, -1); + char *title_esc = g_markup_escape_text(title, -1); + char *info = g_strdup_printf( + "%s\n%s", + title_esc[0] ? title_esc : "(untitled)", url_esc); + GtkWidget *lbl = gtk_label_new(NULL); + gtk_label_set_markup(GTK_LABEL(lbl), info); + gtk_label_set_line_wrap(GTK_LABEL(lbl), TRUE); + gtk_widget_set_halign(lbl, GTK_ALIGN_START); + gtk_box_pack_start(GTK_BOX(content), lbl, FALSE, FALSE, 8); + + /* Directory combo box. */ + GtkWidget *dir_label = gtk_label_new("Directory:"); + gtk_widget_set_halign(dir_label, GTK_ALIGN_START); + gtk_box_pack_start(GTK_BOX(content), dir_label, FALSE, FALSE, 4); + + GtkWidget *combo = gtk_combo_box_text_new_with_entry(); + int dir_count = 0; + const bookmark_dir_t *dirs = bookmarks_get_dirs(&dir_count); + for (int i = 0; i < dir_count; i++) { + gtk_combo_box_text_append_text(GTK_COMBO_BOX_TEXT(combo), + dirs[i].name); + } + /* Default to "General" if it exists, otherwise the first directory. */ + int default_idx = 0; + for (int i = 0; i < dir_count; i++) { + if (strcmp(dirs[i].name, "General") == 0) { default_idx = i; break; } + } + gtk_combo_box_set_active(GTK_COMBO_BOX(combo), default_idx); + gtk_box_pack_start(GTK_BOX(content), combo, FALSE, FALSE, 4); + + g_free(url_esc); + g_free(title_esc); + g_free(info); + + gtk_widget_show_all(dialog); + + gint response = gtk_dialog_run(GTK_DIALOG(dialog)); + if (response == GTK_RESPONSE_ACCEPT) { + /* Get the selected/entered directory name. */ + const char *dir_name = gtk_combo_box_text_get_active_text( + GTK_COMBO_BOX_TEXT(combo)); + if (dir_name == NULL || dir_name[0] == '\0') { + dir_name = "General"; + } + + char *dir_copy = g_strdup(dir_name); + char *url_copy = g_strdup(url); + char *title_copy = g_strdup(title); + + /* Add the bookmark. This encrypts and publishes in the caller's + * thread — for now that's the main thread (the publish is + * synchronous and may block briefly). A future improvement would + * run this in a background thread. */ + int rc = bookmarks_add(dir_copy, url_copy, title_copy); + if (rc == 0) { + g_print("[bookmarks] Bookmarked '%s' to '%s'\n", url_copy, + dir_copy); + } else { + g_printerr("[bookmarks] Failed to bookmark (no signer?)\n"); + } + + g_free(dir_copy); + g_free(url_copy); + g_free(title_copy); + } + + gtk_widget_destroy(dialog); +} + /* ── Hamburger menu builder ───────────────────────────────────────── */ static GtkWidget *build_hamburger_menu(tab_info_t *tab) { @@ -554,6 +794,14 @@ static GtkWidget *build_hamburger_menu(tab_info_t *tab) { gtk_menu_item_set_submenu(GTK_MENU_ITEM(item_history), history_menu); gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_history); + /* Bookmarks submenu. */ + GtkWidget *bookmarks_menu = gtk_menu_new(); + g_signal_connect(bookmarks_menu, "show", + G_CALLBACK(on_bookmarks_menu_show), NULL); + GtkWidget *item_bookmarks = gtk_menu_item_new_with_label("Bookmarks"); + gtk_menu_item_set_submenu(GTK_MENU_ITEM(item_bookmarks), bookmarks_menu); + gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_bookmarks); + gtk_menu_shell_append(GTK_MENU_SHELL(menu), gtk_separator_menu_item_new()); @@ -599,6 +847,11 @@ static GtkWidget *build_hamburger_menu(tab_info_t *tab) { G_CALLBACK(on_menu_inspector), NULL); gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_inspector); + GtkWidget *item_profile = gtk_menu_item_new_with_label("Profile"); + g_signal_connect(item_profile, "activate", + G_CALLBACK(on_menu_profile), g_window); + gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_profile); + GtkWidget *item_settings = gtk_menu_item_new_with_label("Settings…"); g_signal_connect(item_settings, "activate", G_CALLBACK(on_menu_settings), g_window); @@ -716,6 +969,28 @@ static tab_info_t *tab_create(const char *url) { G_CALLBACK(on_refresh_button_press), tab); gtk_box_pack_start(GTK_BOX(toolbar), refresh_btn, FALSE, FALSE, 0); + /* Back button — navigate to previous page. */ + GtkWidget *back_btn = gtk_button_new(); + gtk_button_set_relief(GTK_BUTTON(back_btn), GTK_RELIEF_NONE); + gtk_button_set_image(GTK_BUTTON(back_btn), + gtk_image_new_from_icon_name("go-previous-symbolic", + GTK_ICON_SIZE_MENU)); + gtk_widget_set_tooltip_text(back_btn, "Go back"); + g_signal_connect(back_btn, "clicked", + G_CALLBACK(on_back_clicked), tab); + gtk_box_pack_start(GTK_BOX(toolbar), back_btn, FALSE, FALSE, 0); + + /* Forward button — navigate to next page. */ + GtkWidget *forward_btn = gtk_button_new(); + gtk_button_set_relief(GTK_BUTTON(forward_btn), GTK_RELIEF_NONE); + gtk_button_set_image(GTK_BUTTON(forward_btn), + gtk_image_new_from_icon_name("go-next-symbolic", + GTK_ICON_SIZE_MENU)); + gtk_widget_set_tooltip_text(forward_btn, "Go forward"); + g_signal_connect(forward_btn, "clicked", + G_CALLBACK(on_forward_clicked), tab); + gtk_box_pack_start(GTK_BOX(toolbar), forward_btn, FALSE, FALSE, 0); + tab->url_entry = gtk_entry_new(); /* Show an empty URL bar for new-tab pages (about:blank) so the user * can immediately type a URL. For real URLs, show the URL. */ @@ -726,6 +1001,18 @@ static tab_info_t *tab_create(const char *url) { } gtk_box_pack_start(GTK_BOX(toolbar), tab->url_entry, TRUE, TRUE, 0); + /* Bookmark button — right of the URL entry. Opens a directory picker + * dialog to bookmark the current page. */ + GtkWidget *bookmark_btn = gtk_button_new(); + gtk_button_set_relief(GTK_BUTTON(bookmark_btn), GTK_RELIEF_NONE); + gtk_button_set_image(GTK_BUTTON(bookmark_btn), + gtk_image_new_from_icon_name("user-bookmarks-symbolic", + GTK_ICON_SIZE_MENU)); + gtk_widget_set_tooltip_text(bookmark_btn, "Bookmark this page"); + g_signal_connect(bookmark_btn, "clicked", + G_CALLBACK(on_bookmark_clicked), tab); + gtk_box_pack_start(GTK_BOX(toolbar), bookmark_btn, FALSE, FALSE, 0); + /* Ensure the webview expands vertically to fill the available space. * Without this, WebKitGTK may only allocate 1px of height on some * display servers, resulting in a blank page. */ diff --git a/src/version.h b/src/version.h index cd345d9..2f413f3 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.13" +#define SB_VERSION "v0.0.14" #define SB_VERSION_MAJOR 0 #define SB_VERSION_MINOR 0 -#define SB_VERSION_PATCH 13 +#define SB_VERSION_PATCH 14 #endif /* SOVEREIGN_BROWSER_VERSION_H */