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:
@@ -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)
|
||||
|
||||
@@ -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).
|
||||
@@ -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 |
|
||||
@@ -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
@@ -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
@@ -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 */
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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. */
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 */
|
||||
|
||||
Reference in New Issue
Block a user