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

This commit is contained in:
Laan Tungir
2026-07-12 13:43:14 -04:00
parent 8c75c74c38
commit f91ff74138
22 changed files with 2277 additions and 625 deletions
+1 -1
View File
@@ -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)
+1 -1
View File
@@ -1 +1 @@
0.0.13
0.0.14
+265
View File
@@ -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": "<user pubkey hex>",
"tags": [["d", "<directory name>"]],
"content": "<NIP-44 encrypted JSON array of bookmarks>",
"created_at": <unix timestamp>,
...
}
```
### 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 <glib.h>
#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 <dir>").
### 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).
+167
View File
@@ -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 |
-1
View File
@@ -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);
}
+587
View File
@@ -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 <string.h>
#include <stdlib.h>
#include <stdio.h>
#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;
}
+106
View File
@@ -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 <glib.h>
#include <time.h>
/* 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 */
+3 -10
View File
@@ -64,7 +64,6 @@ void cli_print_usage(FILE *fp) {
" --nsigner-device <path> (nsigner) device path / socket / host:port / qube\n"
" --nsigner-service <name> (nsigner) qrexec service name\n"
" --nsigner-index <n> (nsigner) key index (default 0)\n"
" --no-save-identity Do not persist identity to disk\n"
" --login-timeout <ms> 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;
}
-1
View File
@@ -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. */
+175
View File
@@ -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;
}
+54
View File
@@ -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
+81 -135
View File
@@ -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 <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <errno.h>
#include <unistd.h>
#include <stdlib.h>
/* ── 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");
}
+17 -14
View File
@@ -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);
+34 -192
View File
@@ -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 <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <errno.h>
#include <unistd.h>
#include <errno.h>
#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;
}
+20 -33
View File
@@ -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
+31 -5
View File
@@ -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. */
+292
View File
@@ -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(
"<!DOCTYPE html>\n"
"<html><head><meta charset='UTF-8'>\n"
"<title>sovereign browser — Bookmarks</title>\n"
"<style>\n"
" body { font-family: monospace; max-width: 800px; margin: 40px auto;\n"
" padding: 20px; background: #1a1a1a; color: #e0e0e0; }\n"
" h1 { color: #ff4444; border-bottom: 2px solid #ff4444; padding-bottom: 8px; }\n"
" h2 { color: #ff8888; margin-top: 30px; }\n"
" h3 { color: #ffaaaa; margin-top: 20px; margin-bottom: 8px; }\n"
" .bm { display: flex; justify-content: space-between;\n"
" align-items: center; padding: 8px 0;\n"
" border-bottom: 1px solid #333; }\n"
" .bm-url { color: #88ccff; text-decoration: none; word-break: break-all; }\n"
" .bm-url:hover { text-decoration: underline; }\n"
" .bm-title { color: #e0e0e0; font-weight: bold; }\n"
" .bm-meta { color: #888; font-size: 11px; }\n"
" .actions { display: flex; gap: 8px; }\n"
" .btn { background: #333; color: #e0e0e0; border: 1px solid #555;\n"
" border-radius: 4px; padding: 3px 10px; cursor: pointer;\n"
" font-family: monospace; font-size: 12px; text-decoration: none; }\n"
" .btn:hover { background: #444; }\n"
" .btn-del { background: #5a2a2a; border-color: #7a3a3a; }\n"
" .btn-del:hover { background: #6a3a3a; }\n"
" .btn:disabled { opacity: 0.4; cursor: not-allowed; }\n"
" .form { display: flex; gap: 8px; margin: 12px 0; flex-wrap: wrap; }\n"
" .form input, .form select {\n"
" background: #2a2a2a; color: #e0e0e0; border: 1px solid #444;\n"
" border-radius: 4px; padding: 4px 8px; font-family: monospace;\n"
" font-size: 13px; }\n"
" .note { color: #888; font-size: 12px; margin: 10px 0; }\n"
" .empty { color: #666; font-style: italic; padding: 8px 0; }\n"
" .dir-header { display: flex; justify-content: space-between;\n"
" align-items: center; }\n"
"</style>\n"
"</head><body>\n"
"<h1>sovereign browser — Bookmarks</h1>\n");
if (!have_signer) {
g_string_append(html,
"<p class='note'>Read-only mode — sign in to add, edit, or delete "
"bookmarks. Showing cached bookmarks from the local database.</p>\n");
}
/* Add bookmark form (only if signer available). */
if (have_signer) {
g_string_append(html,
"<h2>Add Bookmark</h2>\n"
"<div class='form'>\n"
" <input type='text' id='bm-url' placeholder='URL' size='40'>\n"
" <input type='text' id='bm-title' placeholder='Title' size='25'>\n"
" <select id='bm-dir'>\n");
for (int i = 0; i < dir_count; i++) {
char *esc = g_markup_escape_text(dirs[i].name, -1);
g_string_append_printf(html, " <option value='%s'>%s</option>\n",
esc, esc);
g_free(esc);
}
g_string_append(html,
" </select>\n"
" <button class='btn' onclick='addBookmark()'>Add</button>\n"
"</div>\n"
"<div class='form'>\n"
" <input type='text' id='new-dir' placeholder='New directory name' size='25'>\n"
" <button class='btn' onclick='createDir()'>Create Directory</button>\n"
"</div>\n");
}
/* List directories and bookmarks. */
g_string_append(html, "<h2>Bookmarks</h2>\n");
if (dir_count == 0) {
g_string_append(html,
"<p class='empty'>No bookmarks yet. Click the bookmark button "
"in the toolbar to save a page.</p>\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,
"<div class='dir-header'><h3>%s (%d)</h3>",
dir_esc, dir->count);
if (have_signer && strcmp(dir->name, "General") != 0) {
g_string_append_printf(html,
" <a class='btn btn-del' href='sovereign://bookmarks/deletedir?dir=%s' "
"onclick=\"return confirm('Delete directory \\\"%s\\\"? "
"Bookmarks will be moved to General.')\">Delete Dir</a>",
g_uri_escape_string(dir->name, NULL, FALSE),
dir_esc);
}
g_string_append(html, "</div>\n");
if (dir->count == 0) {
g_string_append(html, "<p class='empty'>(empty)</p>\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,
"<div class='bm'>"
"<div><div class='bm-title'>%s</div>"
"<div><a class='bm-url' href='%s'>%s</a></div>"
"<div class='bm-meta'>Added: %ld</div></div>"
"<div class='actions'>"
"<a class='btn' href='%s'>Open</a>",
title_esc[0] ? title_esc : "(untitled)",
url_display, url_display, bm->added,
url_display);
if (have_signer) {
g_string_append_printf(html,
" <a class='btn btn-del' href='sovereign://bookmarks/delete?dir=%s&url=%s' "
"onclick=\"return confirm('Delete this bookmark?')\">Delete</a>",
dir_for_link, url_esc);
}
g_string_append(html, "</div></div>\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<script>\n"
" function addBookmark() {\n"
" var url = encodeURIComponent(document.getElementById('bm-url').value);\n"
" var title = encodeURIComponent(document.getElementById('bm-title').value);\n"
" var dir = encodeURIComponent(document.getElementById('bm-dir').value);\n"
" if (!url) { alert('URL is required'); return; }\n"
" location.href = 'sovereign://bookmarks/add?dir=' + dir + '&url=' + url + '&title=' + title;\n"
" }\n"
" function createDir() {\n"
" var name = encodeURIComponent(document.getElementById('new-dir').value);\n"
" if (!name) { alert('Directory name is required'); return; }\n"
" location.href = 'sovereign://bookmarks/createdir?name=' + name;\n"
" }\n"
"</script>\n"
"</body></html>\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) {
+20 -5
View File
@@ -13,6 +13,7 @@
#include "relay_fetch.h"
#include "db.h"
#include "settings.h"
#include "bookmarks.h"
#include <string.h>
#include <stdlib.h>
@@ -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]);
}
+47 -65
View File
@@ -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 <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <errno.h>
#include <unistd.h>
#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;
+83 -156
View File
@@ -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 <gtk/gtk.h>
@@ -12,10 +15,6 @@
#include <stdlib.h>
#include <string.h>
#include <strings.h> /* strcasecmp */
#include <sys/stat.h>
#include <sys/types.h>
#include <errno.h>
#include <unistd.h>
/* ── 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) {
+291 -4
View File
@@ -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(
"<b>%s</b>\n<span size='small' color='#888'>%s</span>",
title_esc[0] ? title_esc : "(untitled)", url_esc);
GtkWidget *lbl = gtk_label_new(NULL);
gtk_label_set_markup(GTK_LABEL(lbl), info);
gtk_label_set_line_wrap(GTK_LABEL(lbl), TRUE);
gtk_widget_set_halign(lbl, GTK_ALIGN_START);
gtk_box_pack_start(GTK_BOX(content), lbl, FALSE, FALSE, 8);
/* 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. */
+2 -2
View File
@@ -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 */