v0.0.45 - Release: nested bookmark tree view with HMAC d tags, bookmarks toolbar, tarball cleanup trap, install next-steps fix
This commit is contained in:
7
.gitignore
vendored
7
.gitignore
vendored
@@ -7,6 +7,13 @@ sovereign_browser
|
||||
*.dll
|
||||
build/
|
||||
|
||||
# release tarballs (created by increment_and_push.sh -r, cleaned up by trap)
|
||||
*.tar.gz
|
||||
|
||||
# compiled test binaries
|
||||
tests/test_bookmarks_tree
|
||||
tests/test_nostr_url
|
||||
|
||||
# editor / OS
|
||||
*.swp
|
||||
.DS_Store
|
||||
|
||||
@@ -27,6 +27,22 @@ print_success() { echo -e "${GREEN}[SUCCESS]${NC} $1" >&2; }
|
||||
print_warning() { echo -e "${YELLOW}[WARNING]${NC} $1" >&2; }
|
||||
print_error() { echo -e "${RED}[ERROR]${NC} $1" >&2; }
|
||||
|
||||
# --- Cleanup trap -------------------------------------------------------
|
||||
# Any temp files registered here are removed on exit, including when the
|
||||
# script is interrupted (Ctrl+C) or aborted by `set -e`. This prevents
|
||||
# leftover sovereign_browser-*.tar.gz files from accumulating in the
|
||||
# project root when a release run is interrupted.
|
||||
CLEANUP_FILES=()
|
||||
cleanup_on_exit() {
|
||||
for f in "${CLEANUP_FILES[@]}"; do
|
||||
[[ -n "$f" && -f "$f" ]] && rm -f "$f"
|
||||
done
|
||||
}
|
||||
trap cleanup_on_exit EXIT INT TERM
|
||||
register_cleanup() {
|
||||
[[ -n "$1" ]] && CLEANUP_FILES+=("$1")
|
||||
}
|
||||
|
||||
# --- Config -------------------------------------------------------------
|
||||
|
||||
# Gitea repo for release uploads (derived from the origin remote).
|
||||
@@ -354,6 +370,10 @@ if [[ "$RELEASE_MODE" == true ]]; then
|
||||
|
||||
local_tarball=""
|
||||
local_tarball=$(create_source_tarball || true)
|
||||
# Register the tarball for cleanup so it's removed on exit even if a
|
||||
# later step fails or the script is interrupted. The trap also catches
|
||||
# the case where create_gitea_release aborts via `set -e`.
|
||||
register_cleanup "$local_tarball"
|
||||
|
||||
release_id=""
|
||||
release_id=$(create_gitea_release || true)
|
||||
@@ -362,8 +382,8 @@ if [[ "$RELEASE_MODE" == true ]]; then
|
||||
upload_release_assets "$release_id" "$BIN_NAME" "$local_tarball"
|
||||
fi
|
||||
|
||||
# Clean up the tarball (the binary is gitignored already).
|
||||
[[ -n "$local_tarball" && -f "$local_tarball" ]] && rm -f "$local_tarball"
|
||||
# The tarball is removed by the cleanup_on_exit trap registered above,
|
||||
# so no manual rm is needed here. The binary is gitignored already.
|
||||
|
||||
print_success "Release flow completed: $NEW_VERSION"
|
||||
else
|
||||
|
||||
@@ -606,8 +606,9 @@ main() {
|
||||
print_success "sovereign_browser installed."
|
||||
cat >&2 <<EOF
|
||||
${GREEN}[SUCCESS]${NC} Next steps:
|
||||
Run: sovereign-browser start --login-method generate
|
||||
Or: sovereign-browser start --login-method generate --url https://example.com
|
||||
Run: sovereign-browser
|
||||
Or: sovereign-browser --login-method generate
|
||||
Or: sovereign-browser --login-method generate --url https://example.com
|
||||
Data dir: ~/.sovereign_browser/
|
||||
Logs: sovereign-browser log
|
||||
Stop: sovereign-browser stop
|
||||
|
||||
261
plans/bookmarks-tree-view.md
Normal file
261
plans/bookmarks-tree-view.md
Normal file
@@ -0,0 +1,261 @@
|
||||
# Plan: Nested Bookmark Tree View + Bookmarks Toolbar
|
||||
|
||||
## Goal
|
||||
|
||||
1. Support **nested folders** for bookmarks (e.g. `Work/Projects/Secret`) while staying fully NIP-51 kind 30003 compliant.
|
||||
2. Render the [`sovereign://bookmarks`](www/bookmarks.html:1) page as an **expandable/collapsible tree view**.
|
||||
3. Add a **bookmarks toolbar** (a horizontal bar of folder/bookmark buttons) below the URL bar in [`tab_manager.c`](src/tab_manager.c:2564).
|
||||
|
||||
## NIP-51 Compliance + Privacy — How Nesting Works
|
||||
|
||||
NIP-51 kind 30003 is parameterized replaceable: each event has a `d` tag that is an **opaque identifier string** used by relays to decide which event replaces which. We need the `d` tag to be:
|
||||
|
||||
1. **Opaque** — relays must not learn the folder path (e.g. `Work/Projects/Secret`), because that leaks the user's organizational structure.
|
||||
2. **Deterministic** — the same path must always produce the same `d` so re-publishing replaces the prior event instead of accumulating duplicates.
|
||||
|
||||
Encryption (NIP-4 or NIP-44) cannot satisfy (2): both use a random IV/nonce per call, so encrypting the same plaintext twice yields different ciphertexts. That would break NIP-33 replaceability.
|
||||
|
||||
The right tool is a **MAC**: `d = HMAC-SHA256(key, path)` is deterministic and one-way. The HMAC key is derived from the user's Nostr privkey so it is per-user, never published, and automatically available on every device the user logs in on:
|
||||
|
||||
```
|
||||
hmac_key = HMAC-SHA256(privkey_hex, "sovereign-browser/bookmarks-folder-id-v1")
|
||||
d = HMAC-SHA256(hmac_key, path) → 64 hex chars
|
||||
```
|
||||
|
||||
### Event layout (one per folder)
|
||||
|
||||
```
|
||||
{
|
||||
"kind": 30003,
|
||||
"pubkey": "<user pubkey hex>",
|
||||
"tags": [["d", "<HMAC-SHA256(hmac_key, path) hex>"]],
|
||||
"content": "<NIP-44 encrypted JSON>",
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
The NIP-44 encrypted `content` plaintext is:
|
||||
|
||||
```json
|
||||
{"path": "Work/Projects/Secret", "bookmarks": [
|
||||
["bookmark", "https://example.com", "Example", 1690000000]
|
||||
]}
|
||||
```
|
||||
|
||||
The real path lives **inside the encrypted content** — relays see only the HMAC hash and opaque ciphertext. The `d` tag is used by relays purely for replaceability; the client never interprets it.
|
||||
|
||||
### Tree reconstruction (client-side)
|
||||
|
||||
1. Fetch all `kind=30003` events for the user's pubkey.
|
||||
2. For each event: NIP-44 decrypt the content, read `path` from the plaintext JSON.
|
||||
3. Split `path` on `/` and walk/create the trie (`node_ensure_path`).
|
||||
4. Attach the decrypted `bookmarks` array to that leaf node.
|
||||
|
||||
Intermediate (empty) folders are represented by empty-bookmarks events for that path, so folder state — including empty folders — is preserved across devices.
|
||||
|
||||
### Why this is safe and nostr-ish
|
||||
- The `d` tag is defined by NIP-33 as an arbitrary string identifier; a 64-char hex HMAC fits cleanly.
|
||||
- Content encryption (NIP-44 self-to-self) is unchanged from the existing implementation in [`src/bookmarks.c`](src/bookmarks.c:163).
|
||||
- HMAC-SHA256 is a one-line standard primitive; the key is derived from the user's Nostr privkey, so no extra credentials are needed.
|
||||
- Relays learn nothing about folder names, structure, or even folder count (they see only opaque hashes).
|
||||
- Existing flat directories (no slashes) load as root-level folders — no migration needed for the in-memory model. Legacy events with plaintext `d = "General"` will be detected on load (HMAC hex strings are 64 chars of `[0-9a-f]`; legacy names are not), decrypted, and re-published in the new HMAC-`d` format; the old events get a kind 5 deletion.
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A["kind 30003 events in SQLite/relays"] --> B["NIP-44 decrypt each content"]
|
||||
B --> C["Read path from plaintext JSON"]
|
||||
C --> D["Split path on / to get segments"]
|
||||
D --> E["Build trie of folders via node_ensure_path"]
|
||||
E --> F["Attach decrypted bookmarks to leaf node"]
|
||||
F --> G["Render tree on sovereign://bookmarks page"]
|
||||
G --> H["User adds/moves/renames/deletes"]
|
||||
H --> I["Compute HMAC d tag for affected path(s)"]
|
||||
I --> J["NIP-44 encrypt new content and publish kind 30003"]
|
||||
I --> K["Emit kind 5 deletion for old d tags if renamed/moved/deleted"]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation
|
||||
|
||||
### 1. Data model — [`src/bookmarks.h`](src/bookmarks.h:36) / [`src/bookmarks.c`](src/bookmarks.c:32)
|
||||
|
||||
The in-memory model changes from a flat `bookmark_dir_t[]` to a **tree**.
|
||||
|
||||
#### New struct
|
||||
|
||||
```c
|
||||
typedef struct bookmark_node {
|
||||
char *name; /* last segment of path, e.g. "Secret" */
|
||||
char *path; /* full path, e.g. "Work/Projects/Secret" */
|
||||
bookmark_t *bookmarks;
|
||||
int bookmark_count;
|
||||
struct bookmark_node *children; /* array of child nodes */
|
||||
int child_count;
|
||||
int child_cap;
|
||||
} bookmark_node_t;
|
||||
```
|
||||
|
||||
The root is a single `bookmark_node_t` with `name = ""`, `path = ""`, no bookmarks, and children = top-level folders.
|
||||
|
||||
#### New / changed API
|
||||
|
||||
Keep the existing public functions working (they now operate on paths, not flat names):
|
||||
|
||||
- `bookmarks_add(const char *path, const char *url, const char *title)` — `path` may contain slashes; intermediate nodes are created as empty events if they don't exist.
|
||||
- `bookmarks_remove(const char *path, const char *url)`
|
||||
- `bookmarks_move(const char *from_path, const char *url, const char *to_path)`
|
||||
- `bookmarks_create_dir(const char *path)` — publishes an empty kind 30003 event with `d = HMAC-SHA256(hmac_key, path)`.
|
||||
- `bookmarks_rename_dir(const char *old_path, const char *new_path)` — re-publishes the moved subtree: for the renamed node and every descendant, publish a new event with the new HMAC `d` tag and a kind 5 deletion for the old HMAC `d` tag.
|
||||
- `bookmarks_delete_dir(const char *path, int move_to_general)` — recursively delete the subtree (kind 5 deletion for each HMAC `d` tag in the subtree); optionally move bookmarks to `General`.
|
||||
- `const bookmark_node_t *bookmarks_get_root(void)` — returns the root node for traversal (replaces `bookmarks_get_dirs`).
|
||||
- `const bookmark_node_t *bookmarks_find(const char *path)` — lookup by path.
|
||||
|
||||
#### Internal helpers
|
||||
|
||||
- `static bookmark_node_t *node_find_child(bookmark_node_t *parent, const char *name)`
|
||||
- `static bookmark_node_t *node_ensure_path(bookmark_node_t *root, const char *path)` — walks/creates the trie.
|
||||
- `static void node_free(bookmark_node_t *node)` — recursive free.
|
||||
- `static void compute_hmac_key(unsigned char out[32])` — `HMAC-SHA256(privkey, "sovereign-browser/bookmarks-folder-id-v1")`. The privkey is obtained from the signer (add a `nostr_signer_get_privkey_hex` accessor if not already present, or compute the HMAC inside the signer to avoid exposing the raw privkey).
|
||||
- `static char *path_to_d_tag(const char *path)` — `HMAC-SHA256(hmac_key, path)` → 64-char hex string. Caller frees.
|
||||
- `static int publish_path(const char *path)` — replaces `publish_directory`; builds `d = path_to_d_tag(path)`, encrypts `{"path": path, "bookmarks": [...]}` with NIP-44, signs, publishes, stores in SQLite.
|
||||
- `static int delete_event_for_path(const char *path)` — publishes a kind 5 deletion event referencing the kind 30003 event for `d = path_to_d_tag(path)`.
|
||||
|
||||
#### Loading
|
||||
|
||||
`bookmarks_init` already iterates all kind 30003 events for the pubkey. Change the loop to:
|
||||
1. Read the `d` tag.
|
||||
2. If the `d` tag is **not** 64 hex chars (i.e. a legacy plaintext directory name), treat it as a legacy event: decrypt content as the old flat `[[bookmark,...]]` array, use the `d` tag string as the path, then re-publish in the new HMAC-`d` format and emit a kind 5 deletion for the old event id.
|
||||
3. If the `d` tag **is** 64 hex chars, decrypt the content and read `path` from the plaintext JSON `{"path": ..., "bookmarks": [...]}`.
|
||||
4. `node_ensure_path(root, path)`.
|
||||
5. Load bookmarks into that leaf.
|
||||
|
||||
### 2. JSON endpoint — [`src/nostr_bridge.c`](src/nostr_bridge.c:3715) `handle_bookmarks_list_json`
|
||||
|
||||
Change the JSON shape from a flat `dirs[]` array to a nested `tree` object:
|
||||
|
||||
```json
|
||||
{
|
||||
"have_signer": true,
|
||||
"tree": {
|
||||
"name": "",
|
||||
"path": "",
|
||||
"bookmarks": [],
|
||||
"children": [
|
||||
{ "name": "General", "path": "General", "bookmarks": [...], "children": [] },
|
||||
{ "name": "Work", "path": "Work", "bookmarks": [], "children": [
|
||||
{ "name": "Projects", "path": "Work/Projects", "bookmarks": [...], "children": [] }
|
||||
]}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Add a small recursive helper `node_to_json(const bookmark_node_t *node, cJSON *parent)`.
|
||||
|
||||
### 3. Web UI — [`www/bookmarks.html`](www/bookmarks.html:1), [`www/bookmarks.js`](www/bookmarks.js:1), [`www/bookmarks.css`](www/bookmarks.css:1)
|
||||
|
||||
#### HTML
|
||||
- Replace the flat `#bm-list` div with a `<div id="bm-tree">` container.
|
||||
- Add an "Add Folder" button next to "Add Bookmark" that prompts for a path (with a parent-picker).
|
||||
- Add a path breadcrumb / parent selector in the add form.
|
||||
|
||||
#### JS
|
||||
- `renderTree(data.tree)` — recursively render nested `<ul class="tree-node">` with:
|
||||
- A folder row: expand/collapse caret, folder icon, name, count, "Add Bookmark Here", "New Subfolder", "Rename", "Delete" buttons.
|
||||
- A bookmark list under the folder (collapsible).
|
||||
- State: keep a `Set` of expanded paths in `localStorage` so expand state persists.
|
||||
- Clicking a folder caret toggles `expanded` class on the child `<ul>`.
|
||||
- "New Subfolder" prompts for a name and navigates to `sovereign://bookmarks/createdir?name=<parent_path>/<new_name>`.
|
||||
- "Move" action on each bookmark: prompt for destination path, navigate to `sovereign://bookmarks/move?from=<dir>&to=<new_dir>&url=<url>`.
|
||||
- "Add Bookmark Here" pre-fills the add form's directory field with the folder's path.
|
||||
|
||||
#### CSS
|
||||
- Add `.tree-node`, `.tree-children`, `.caret`, `.caret.expanded`, `.folder-row`, `.folder-icon` rules to [`www/bookmarks.css`](www/bookmarks.css:1).
|
||||
- Caret uses a CSS triangle that rotates 90° when expanded.
|
||||
- Indent `.tree-children` by `20px` per level.
|
||||
- Reuse existing `.bm`, `.btn`, `.btn-del` classes for bookmark rows and buttons.
|
||||
|
||||
### 4. Bookmarks toolbar — [`src/tab_manager.c`](src/tab_manager.c:2564)
|
||||
|
||||
Add a **bookmarks toolbar** below the URL toolbar. This is a horizontal `GtkBox` that shows buttons for the bookmarks in a configurable "Bookmarks Bar" folder (default path: `Bookmarks Bar`), plus a dropdown for any folders.
|
||||
|
||||
#### Layout
|
||||
|
||||
```
|
||||
[ hamburger ][back][fwd][refresh][ === URL entry === ][bookmark btn]
|
||||
[ 📁 Bookmarks Bar: ][example.com][nostr.com][▾ Folders ▾]
|
||||
[ ============ WebKitWebView ============ ]
|
||||
```
|
||||
|
||||
#### Implementation
|
||||
|
||||
1. In `tab_create()`, after packing `toolbar` into `tab->page`, create:
|
||||
```c
|
||||
tab->bookmark_bar = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 4);
|
||||
gtk_widget_set_margin_top(tab->bookmark_bar, 2);
|
||||
gtk_widget_set_margin_bottom(tab->bookmark_bar, 2);
|
||||
gtk_widget_set_margin_start(tab->bookmark_bar, 4);
|
||||
gtk_widget_set_margin_end(tab->bookmark_bar, 4);
|
||||
gtk_box_pack_start(GTK_BOX(tab->page), tab->bookmark_bar, FALSE, FALSE, 0);
|
||||
```
|
||||
2. Add a `GtkToggleButton` "Bookmarks Bar" toggle (icon `user-bookmarks-symbolic`) on the left of the URL toolbar that shows/hides `tab->bookmark_bar` (persist visibility in settings).
|
||||
3. `bookmark_bar_refresh(tab)` — clears and repopulates `tab->bookmark_bar`:
|
||||
- Looks up the "Bookmarks Bar" node via `bookmarks_find("Bookmarks Bar")`.
|
||||
- For each bookmark in that node, adds a `GtkButton` labeled with the bookmark title (or URL). Clicking loads the URL in `tab->webview`.
|
||||
- For each child folder, adds a `GtkMenuButton` with a popover listing that folder's bookmarks (and subfolders, one level deep).
|
||||
4. Call `bookmark_bar_refresh(tab)` after: tab creation, `bookmarks_add`/`remove`/`move`/`delete_dir` (via a global "bookmarks changed" notification — see below).
|
||||
5. Add a "Bookmark this page to Bookmarks Bar" quick action: right-click on the bookmark button → menu item "Add to Bookmarks Bar" that calls `bookmarks_add("Bookmarks Bar", url, title)` directly without the directory picker dialog.
|
||||
|
||||
#### Cross-tab refresh
|
||||
|
||||
Bookmarks can change from any tab (or from relay fetch). Add a lightweight notification:
|
||||
- `bookmarks_subscribe_changed(void (*cb)(void *user_data), void *user_data)` in [`src/bookmarks.h`](src/bookmarks.h:51).
|
||||
- `tab_manager.c` registers a callback on startup that iterates all open tabs and calls `bookmark_bar_refresh(tab)` for each.
|
||||
- `bookmarks_add`/`remove`/`move`/`create_dir`/`delete_dir`/`rename_dir` and the relay-fetch load path invoke the registered callbacks after mutating state.
|
||||
|
||||
### 5. Routes — [`src/nostr_bridge.c`](src/nostr_bridge.c:3993)
|
||||
|
||||
Existing routes already accept `dir=` query params. They continue to work — `dir` is now interpreted as a **path** (URL-encoded, may contain `%2F`). Add one new route:
|
||||
|
||||
- `sovereign://bookmarks/move?from=<path>&to=<path>&url=<url>` — calls `bookmarks_move`.
|
||||
|
||||
Update `handle_bookmarks_add`/`delete`/`createdir`/`deletedir` to pass the path straight through to the renamed API functions (no behavioral change beyond accepting slashes).
|
||||
|
||||
### 6. History filter — [`src/history.c`](src/history.c:27)
|
||||
|
||||
Already excludes `sovereign://bookmarks/{add,delete,createdir,deletedir}`. Add `sovereign://bookmarks/move` to the skip list.
|
||||
|
||||
### 7. Tests
|
||||
|
||||
- Add `tests/test_bookmarks_tree.c` (or extend existing tests) covering:
|
||||
- `node_ensure_path` creates intermediate nodes.
|
||||
- `path_to_d_tag` is deterministic (same path + key → same hex) and 64 hex chars.
|
||||
- `path_to_d_tag` is opaque (different paths → uncorrelated hashes; no prefix leakage).
|
||||
- `bookmarks_add("Work/Projects/Secret", ...)` publishes an event whose `d` tag is `HMAC-SHA256(hmac_key, "Work/Projects/Secret")` (not the plaintext path) and whose decrypted content contains `"path": "Work/Projects/Secret"`.
|
||||
- `bookmarks_rename_dir("Work", "Personal")` re-publishes `Personal`, `Personal/Projects`, `Personal/Projects/Secret` (new HMAC `d` tags, new encrypted content) and emits kind 5 deletions for the old HMAC `d` tags.
|
||||
- `bookmarks_delete_dir("Work", 0)` emits kind 5 deletions for the whole subtree's HMAC `d` tags.
|
||||
- Loading a mix of events with HMAC `d` tags reconstructs the tree from the decrypted `path` field, not from the `d` tag.
|
||||
- Legacy event with plaintext `d = "General"` is detected, loaded, re-published with an HMAC `d` tag, and the old event is marked for kind 5 deletion.
|
||||
- Manual test: `./browser.sh restart --login-method generate --url sovereign://bookmarks` and exercise the tree UI.
|
||||
|
||||
---
|
||||
|
||||
## Migration
|
||||
|
||||
Existing flat directories (no slashes) load as root-level folders — no migration needed. The "General" default folder continues to work as a root node.
|
||||
|
||||
## Files touched
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| [`src/bookmarks.h`](src/bookmarks.h:1) | Replace `bookmark_dir_t` with `bookmark_node_t`; tree API; change callback. |
|
||||
| [`src/bookmarks.c`](src/bookmarks.c:1) | Rewrite in-memory model as a trie; path-based publish/delete; rename/delete recursion. |
|
||||
| [`src/nostr_bridge.c`](src/nostr_bridge.c:3715) | `handle_bookmarks_list_json` emits nested `tree`; add `move` route. |
|
||||
| [`www/bookmarks.html`](www/bookmarks.html:1) | Tree container, folder add form. |
|
||||
| [`www/bookmarks.js`](www/bookmarks.js:1) | Recursive `renderTree`, expand/collapse, move dialog. |
|
||||
| [`www/bookmarks.css`](www/bookmarks.css:1) | Tree node / caret / indentation styles. |
|
||||
| [`src/tab_manager.c`](src/tab_manager.c:2564) | Bookmarks toolbar widget, refresh on change. |
|
||||
| [`src/history.c`](src/history.c:27) | Skip `sovereign://bookmarks/move`. |
|
||||
| `tests/test_bookmarks_tree.c` | New test for tree operations. |
|
||||
| [`plans/bookmarks-tree-view.md`](plans/bookmarks-tree-view.md:1) | This plan. |
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -20,6 +20,7 @@
|
||||
* We access it via extern functions that main.c provides.
|
||||
*/
|
||||
extern void app_set_signer(nostr_signer_t *signer, const char *pubkey_hex,
|
||||
const char *privkey_hex,
|
||||
key_store_method_t method, gboolean readonly);
|
||||
extern void app_clear_signer(void);
|
||||
extern nostr_signer_t *app_get_signer(void);
|
||||
@@ -131,13 +132,18 @@ static cJSON *login_local(cJSON *params) {
|
||||
if (derive_pubkey_hex(privkey, pubkey_hex) != 0) {
|
||||
return make_error("DERIVE_FAILED", "Failed to derive public key");
|
||||
}
|
||||
char privkey_hex_out[65];
|
||||
for (int i = 0; i < 32; i++) {
|
||||
snprintf(privkey_hex_out + i * 2, 3, "%02x", privkey[i]);
|
||||
}
|
||||
privkey_hex_out[64] = '\0';
|
||||
|
||||
nostr_signer_t *signer = nostr_signer_local(privkey);
|
||||
if (signer == NULL) {
|
||||
return make_error("SIGNER_FAILED", "Failed to create signer");
|
||||
}
|
||||
|
||||
app_set_signer(signer, pubkey_hex, KEY_STORE_METHOD_LOCAL, FALSE);
|
||||
app_set_signer(signer, pubkey_hex, privkey_hex_out, KEY_STORE_METHOD_LOCAL, FALSE);
|
||||
nostr_bridge_set_signer(signer, pubkey_hex, FALSE);
|
||||
|
||||
g_print("[agent-login] Local key: pubkey=%s\n", pubkey_hex);
|
||||
@@ -169,13 +175,18 @@ static cJSON *login_generate(cJSON *params) {
|
||||
if (nostr_key_to_bech32(privkey, "nsec", nsec) != 0) {
|
||||
nsec[0] = '\0';
|
||||
}
|
||||
char privkey_hex[65];
|
||||
for (int i = 0; i < 32; i++) {
|
||||
snprintf(privkey_hex + i * 2, 3, "%02x", privkey[i]);
|
||||
}
|
||||
privkey_hex[64] = '\0';
|
||||
|
||||
nostr_signer_t *signer = nostr_signer_local(privkey);
|
||||
if (signer == NULL) {
|
||||
return make_error("SIGNER_FAILED", "Failed to create signer");
|
||||
}
|
||||
|
||||
app_set_signer(signer, pubkey_hex, KEY_STORE_METHOD_LOCAL, FALSE);
|
||||
app_set_signer(signer, pubkey_hex, privkey_hex, KEY_STORE_METHOD_LOCAL, FALSE);
|
||||
nostr_bridge_set_signer(signer, pubkey_hex, FALSE);
|
||||
|
||||
g_print("[agent-login] Generated key: pubkey=%s\n", pubkey_hex);
|
||||
@@ -206,13 +217,18 @@ static cJSON *login_seed(cJSON *params) {
|
||||
snprintf(pubkey_hex + i * 2, 3, "%02x", pubkey[i]);
|
||||
}
|
||||
pubkey_hex[64] = '\0';
|
||||
char privkey_hex[65];
|
||||
for (int i = 0; i < 32; i++) {
|
||||
snprintf(privkey_hex + i * 2, 3, "%02x", privkey[i]);
|
||||
}
|
||||
privkey_hex[64] = '\0';
|
||||
|
||||
nostr_signer_t *signer = nostr_signer_local(privkey);
|
||||
if (signer == NULL) {
|
||||
return make_error("SIGNER_FAILED", "Failed to create signer");
|
||||
}
|
||||
|
||||
app_set_signer(signer, pubkey_hex, KEY_STORE_METHOD_SEED, FALSE);
|
||||
app_set_signer(signer, pubkey_hex, privkey_hex, KEY_STORE_METHOD_SEED, FALSE);
|
||||
nostr_bridge_set_signer(signer, pubkey_hex, FALSE);
|
||||
|
||||
g_print("[agent-login] Seed phrase: pubkey=%s\n", pubkey_hex);
|
||||
@@ -254,8 +270,8 @@ static cJSON *login_readonly(cJSON *params) {
|
||||
return make_error("MISSING_PARAM", "Provide 'npub' or 'pubkey_hex'");
|
||||
}
|
||||
|
||||
/* Read-only: no signer created. */
|
||||
app_set_signer(NULL, pubkey_hex, KEY_STORE_METHOD_READONLY, TRUE);
|
||||
/* Read-only: no signer created. No privkey available. */
|
||||
app_set_signer(NULL, pubkey_hex, NULL, KEY_STORE_METHOD_READONLY, TRUE);
|
||||
nostr_bridge_set_signer(NULL, pubkey_hex, TRUE);
|
||||
|
||||
g_print("[agent-login] Read-only: pubkey=%s\n", pubkey_hex);
|
||||
@@ -290,7 +306,13 @@ static cJSON *login_nip46(cJSON *params) {
|
||||
return make_error("SIGNER_FAILED", "Failed to create client signer");
|
||||
}
|
||||
|
||||
app_set_signer(signer, pubkey_hex, KEY_STORE_METHOD_NIP46, FALSE);
|
||||
/* NIP-46: the client_privkey is a throwaway session key for the bunker
|
||||
* protocol, not the user's identity key. The user's actual signing key
|
||||
* lives on the remote bunker. We do NOT use it for bookmark HMAC d tags
|
||||
* (the bunker signer handles NIP-44 content encryption; d-tag derivation
|
||||
* would require the user's real privkey, which we don't have). Bookmarks
|
||||
* can still be loaded from the db in read-only fashion. */
|
||||
app_set_signer(signer, pubkey_hex, NULL, KEY_STORE_METHOD_NIP46, FALSE);
|
||||
nostr_bridge_set_signer(signer, pubkey_hex, FALSE);
|
||||
|
||||
g_print("[agent-login] NIP-46: remote signer pubkey=%s\n", pubkey_hex);
|
||||
@@ -374,7 +396,9 @@ static cJSON *login_nsigner(cJSON *params) {
|
||||
|
||||
sigprocmask(SIG_SETMASK, &old_set, NULL);
|
||||
|
||||
app_set_signer(signer, pubkey_hex, KEY_STORE_METHOD_NSIGNER, FALSE);
|
||||
/* n_signer: the privkey never leaves the hardware device, so we can't
|
||||
* derive HMAC d tags client-side. Bookmarks fall back to read-only. */
|
||||
app_set_signer(signer, pubkey_hex, NULL, KEY_STORE_METHOD_NSIGNER, FALSE);
|
||||
nostr_bridge_set_signer(signer, pubkey_hex, FALSE);
|
||||
|
||||
g_print("[agent-login] n_signer: transport=%s index=%d pubkey=%s\n",
|
||||
|
||||
1100
src/bookmarks.c
1100
src/bookmarks.c
File diff suppressed because it is too large
Load Diff
129
src/bookmarks.h
129
src/bookmarks.h
@@ -1,9 +1,22 @@
|
||||
/*
|
||||
* bookmarks.h — NIP-44 encrypted Nostr bookmarks with directories
|
||||
* bookmarks.h — NIP-44 encrypted Nostr bookmarks with nested folders
|
||||
*
|
||||
* 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.
|
||||
* folder. Folders may be nested arbitrarily (e.g. "Work/Projects/Secret").
|
||||
*
|
||||
* Privacy design:
|
||||
* - The `d` tag is HMAC-SHA256(hmac_key, path) — a deterministic, opaque
|
||||
* 64-hex-char identifier. Relays learn nothing about folder names or
|
||||
* structure (not even the folder count). Determinism preserves NIP-33
|
||||
* replaceability: re-publishing the same path replaces the prior event.
|
||||
* Encryption (NIP-04 / NIP-44) cannot be used for the `d` tag because
|
||||
* both use a random IV/nonce per call, which would break replaceability.
|
||||
* - The real path and the bookmark list live inside the NIP-44 encrypted
|
||||
* `content` as JSON: {"path": "Work/Projects/Secret", "bookmarks": [...]}
|
||||
* - The HMAC key is derived from the user's Nostr privkey:
|
||||
* hmac_key = HMAC-SHA256(privkey_bytes, "sovereign-browser/bookmarks-folder-id-v1")
|
||||
* It is per-user, never published, and automatically available on every
|
||||
* device the user logs in on.
|
||||
*
|
||||
* The bookmarks sync across all devices the user logs in on via the
|
||||
* bootstrap relays.
|
||||
@@ -22,10 +35,15 @@
|
||||
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
|
||||
#define BOOKMARKS_MAX_PER_NODE 500
|
||||
#define BOOKMARKS_NAME_MAX 128
|
||||
#define BOOKMARKS_PATH_MAX 1024
|
||||
#define BOOKMARKS_URL_MAX 2048
|
||||
#define BOOKMARKS_TITLE_MAX 256
|
||||
|
||||
/* Label mixed into the HMAC key derivation so d-tag namespaces are isolated
|
||||
* per application and can be rotated independently if ever needed. */
|
||||
#define BOOKMARKS_HMAC_KEY_LABEL "sovereign-browser/bookmarks-folder-id-v1"
|
||||
|
||||
typedef struct {
|
||||
char *url;
|
||||
@@ -33,70 +51,97 @@ typedef struct {
|
||||
long added; /* unix timestamp */
|
||||
} bookmark_t;
|
||||
|
||||
typedef struct {
|
||||
char name[BOOKMARKS_DIR_NAME_MAX];
|
||||
bookmark_t *bookmarks;
|
||||
int count;
|
||||
} bookmark_dir_t;
|
||||
/* A node in the bookmark tree. The root node has name="" and path="".
|
||||
* Each node corresponds to a folder path; bookmarks live at any node. */
|
||||
typedef struct bookmark_node {
|
||||
char *name; /* last path segment, "" for root */
|
||||
char *path; /* full path, "" for root */
|
||||
bookmark_t *bookmarks;
|
||||
int bookmark_count;
|
||||
struct bookmark_node *children; /* array of child nodes */
|
||||
int child_count;
|
||||
int child_cap;
|
||||
} bookmark_node_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)
|
||||
* signer — the user's nostr_signer_t (NULL for read-only/no-login)
|
||||
* pubkey_hex — the user's hex pubkey (64 chars) or NULL
|
||||
* privkey_hex — the user's hex privkey (64 chars) or NULL. Used to derive
|
||||
* the HMAC key for opaque d tags. NULL in read-only mode
|
||||
* (existing events can still be loaded from the db, but
|
||||
* no new events can be published).
|
||||
*
|
||||
* Returns 0 on success, -1 on error.
|
||||
*/
|
||||
int bookmarks_init(nostr_signer_t *signer, const char *pubkey_hex);
|
||||
int bookmarks_init(nostr_signer_t *signer,
|
||||
const char *pubkey_hex,
|
||||
const char *privkey_hex);
|
||||
|
||||
/* Free the in-memory bookmark list. Call at shutdown. */
|
||||
/* Free the in-memory bookmark tree. 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 the root node of the bookmark tree (read-only). Returns NULL if
|
||||
* bookmarks_init has not been called. The returned pointer is valid until
|
||||
* the next bookmarks mutation. */
|
||||
const bookmark_node_t *bookmarks_get_root(void);
|
||||
|
||||
/* Get a specific directory by name. Returns NULL if not found. */
|
||||
const bookmark_dir_t *bookmarks_get_dir(const char *name);
|
||||
/* Find a node by path. Returns NULL if not found. */
|
||||
const bookmark_node_t *bookmarks_find(const char *path);
|
||||
|
||||
/* ── Write access (requires signer) ────────────────────────────────── */
|
||||
/* ── Write access (requires signer + privkey) ──────────────────────── */
|
||||
|
||||
/* 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.
|
||||
/* Add a bookmark to a folder path. If the folder (or any intermediate
|
||||
* folder) doesn't exist, it is created. Updates the in-memory tree,
|
||||
* re-encrypts, publishes a new kind 30003 event for that path, and stores
|
||||
* in SQLite.
|
||||
*
|
||||
* Returns 0 on success, -1 on error. */
|
||||
int bookmarks_add(const char *dir, const char *url, const char *title);
|
||||
int bookmarks_add(const char *path, const char *url, const char *title);
|
||||
|
||||
/* Remove a bookmark by URL from a directory.
|
||||
/* Remove a bookmark by URL from a folder path.
|
||||
* Returns 0 on success, -1 on not found / error. */
|
||||
int bookmarks_remove(const char *dir, const char *url);
|
||||
int bookmarks_remove(const char *path, const char *url);
|
||||
|
||||
/* Move a bookmark from one directory to another.
|
||||
/* Move a bookmark from one folder to another.
|
||||
* Returns 0 on success, -1 on error. */
|
||||
int bookmarks_move(const char *from_dir, const char *url,
|
||||
const char *to_dir);
|
||||
int bookmarks_move(const char *from_path, const char *url,
|
||||
const char *to_path);
|
||||
|
||||
/* 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);
|
||||
/* Create a new empty folder at the given path. Parent folders are created
|
||||
* if missing. Publishes an empty kind 30003 event.
|
||||
* Returns 0 on success, -1 if the folder already exists / error. */
|
||||
int bookmarks_create_dir(const char *path);
|
||||
|
||||
/* Delete a directory. If move_to_general is TRUE, bookmarks are moved
|
||||
* to "General" before deletion. Publishes a kind 5 deletion event.
|
||||
/* Rename a folder. Re-publishes the renamed node and every descendant with
|
||||
* new HMAC d tags and new encrypted content; emits kind 5 deletions for the
|
||||
* old d tags.
|
||||
* Returns 0 on success, -1 on error. */
|
||||
int bookmarks_delete_dir(const char *name, int move_to_general);
|
||||
int bookmarks_rename_dir(const char *old_path, const char *new_path);
|
||||
|
||||
/* Delete a folder. If move_to_general is TRUE, the folder's bookmarks (and
|
||||
* its descendants' bookmarks) are moved to "General" before deletion.
|
||||
* Emits kind 5 deletion events for every d tag in the subtree.
|
||||
* Returns 0 on success, -1 on error. */
|
||||
int bookmarks_delete_dir(const char *path, int move_to_general);
|
||||
|
||||
/* ── Change notification ───────────────────────────────────────────── */
|
||||
|
||||
/* Callback invoked after any mutation (add/remove/move/create/rename/delete
|
||||
* and after relay-fetch loads new events). Used by the bookmarks toolbar to
|
||||
* refresh every open tab. */
|
||||
typedef void (*bookmarks_changed_cb)(void *user_data);
|
||||
|
||||
void bookmarks_subscribe_changed(bookmarks_changed_cb cb, void *user_data);
|
||||
|
||||
/* ── 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. */
|
||||
* into the in-memory tree. Called by the relay fetch after login. */
|
||||
int bookmarks_store_and_load_event(const void *event_cjson);
|
||||
|
||||
#ifdef __cplusplus
|
||||
|
||||
@@ -29,6 +29,8 @@ void history_add_titled(const char *url, const char *title) {
|
||||
strncmp(url, "sovereign://bookmarks/delete", 28) == 0 ||
|
||||
strncmp(url, "sovereign://bookmarks/createdir", 31) == 0 ||
|
||||
strncmp(url, "sovereign://bookmarks/deletedir", 31) == 0 ||
|
||||
strncmp(url, "sovereign://bookmarks/move", 26) == 0 ||
|
||||
strncmp(url, "sovereign://bookmarks/renamedir", 31) == 0 ||
|
||||
strncmp(url, "sovereign://qr", 14) == 0 ||
|
||||
strncmp(url, "sovereign://nostr/", 18) == 0) {
|
||||
return;
|
||||
|
||||
16
src/main.c
16
src/main.c
@@ -66,6 +66,7 @@
|
||||
typedef struct {
|
||||
nostr_signer_t *signer; /* NULL for read-only mode */
|
||||
char pubkey_hex[65];
|
||||
char privkey_hex[65]; /* in-memory only; for HMAC d-tag derivation */
|
||||
key_store_method_t method;
|
||||
gboolean readonly; /* TRUE if no signing available */
|
||||
} app_state_t;
|
||||
@@ -81,6 +82,7 @@ static gboolean g_is_fullscreen = FALSE; /* track fullscreen state (GTK3 has
|
||||
/* ---- App state accessors (used by agent_login.c) ─────────────────── */
|
||||
|
||||
void app_set_signer(nostr_signer_t *signer, const char *pubkey_hex,
|
||||
const char *privkey_hex,
|
||||
key_store_method_t method, gboolean readonly) {
|
||||
g_state.signer = signer;
|
||||
g_state.method = method;
|
||||
@@ -89,6 +91,12 @@ void app_set_signer(nostr_signer_t *signer, const char *pubkey_hex,
|
||||
strncpy(g_state.pubkey_hex, pubkey_hex, 64);
|
||||
g_state.pubkey_hex[64] = '\0';
|
||||
}
|
||||
if (privkey_hex && privkey_hex[0]) {
|
||||
strncpy(g_state.privkey_hex, privkey_hex, 64);
|
||||
g_state.privkey_hex[64] = '\0';
|
||||
} else {
|
||||
g_state.privkey_hex[0] = '\0';
|
||||
}
|
||||
g_logged_in = TRUE;
|
||||
|
||||
/* Update modules that hold a signer reference. */
|
||||
@@ -112,6 +120,7 @@ void app_clear_signer(void) {
|
||||
g_state.signer = NULL;
|
||||
}
|
||||
g_state.pubkey_hex[0] = '\0';
|
||||
g_state.privkey_hex[0] = '\0';
|
||||
g_state.method = KEY_STORE_METHOD_NONE;
|
||||
g_state.readonly = FALSE;
|
||||
g_logged_in = FALSE;
|
||||
@@ -649,6 +658,10 @@ static int do_login(GtkWindow *parent) {
|
||||
g_state.method = result.method;
|
||||
strncpy(g_state.pubkey_hex, result.pubkey_hex, 64);
|
||||
g_state.pubkey_hex[64] = '\0';
|
||||
/* Carry the privkey (in-memory only) for HMAC d-tag derivation in the
|
||||
* bookmarks module. Empty for readonly / nsigner / nip46 methods. */
|
||||
strncpy(g_state.privkey_hex, result.identity.privkey_hex, 64);
|
||||
g_state.privkey_hex[64] = '\0';
|
||||
g_state.readonly = (result.method == KEY_STORE_METHOD_READONLY ||
|
||||
result.method == KEY_STORE_METHOD_NONE);
|
||||
g_logged_in = TRUE;
|
||||
@@ -943,7 +956,8 @@ int main(int argc, char **argv) {
|
||||
* 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);
|
||||
g_state.pubkey_hex[0] ? g_state.pubkey_hex : NULL,
|
||||
g_state.privkey_hex[0] ? g_state.privkey_hex : NULL);
|
||||
|
||||
/* Initialize the NIP-78 settings sync module. In no-login/read-only
|
||||
* mode, the signer is NULL so settings are local-only (not synced). */
|
||||
|
||||
@@ -1508,140 +1508,12 @@ static void handle_profile_page(WebKitURISchemeRequest *request) {
|
||||
|
||||
/* ── 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);
|
||||
|
||||
char *head = sovereign_page_head("Bookmarks");
|
||||
GString *html = g_string_new(head);
|
||||
g_free(head);
|
||||
g_string_append(html,
|
||||
"<h1>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");
|
||||
|
||||
char *foot = sovereign_page_foot();
|
||||
g_string_append(html, foot);
|
||||
g_free(foot);
|
||||
|
||||
char *html_str = g_string_free(html, FALSE);
|
||||
respond_html(request, html_str);
|
||||
g_free(html_str);
|
||||
}
|
||||
/* The sovereign://bookmarks HTML page is now served from the embedded file
|
||||
* www/bookmarks.html (see the route dispatch in handle_uri_scheme_request).
|
||||
* The legacy inline HTML generator was removed when the bookmarks data model
|
||||
* changed from flat directories to a nested tree. The tree is rendered
|
||||
* client-side by www/bookmarks.js, which fetches sovereign://bookmarks/list
|
||||
* (JSON) — see handle_bookmarks_list_json above. */
|
||||
|
||||
/* Handle sovereign://bookmarks/add?dir=...&url=...&title=... */
|
||||
static void handle_bookmarks_add(WebKitURISchemeRequest *request,
|
||||
@@ -1746,6 +1618,64 @@ static void handle_bookmarks_deletedir(WebKitURISchemeRequest *request,
|
||||
g_free(dir);
|
||||
}
|
||||
|
||||
/* Handle sovereign://bookmarks/move?from=...&to=...&url=...
|
||||
* Moves a bookmark from one folder path to another. */
|
||||
static void handle_bookmarks_move(WebKitURISchemeRequest *request,
|
||||
const char *query) {
|
||||
char *from = query_param(query, "from");
|
||||
char *to = query_param(query, "to");
|
||||
char *url = query_param(query, "url");
|
||||
|
||||
if (!url || url[0] == '\0') {
|
||||
respond_error_json(request, -1, "Missing url parameter");
|
||||
g_free(from); g_free(to); g_free(url);
|
||||
return;
|
||||
}
|
||||
|
||||
int rc = bookmarks_move(from ? from : "General",
|
||||
url,
|
||||
to ? to : "General");
|
||||
if (rc != 0) {
|
||||
respond_error_json(request, -1, "Failed to move bookmark");
|
||||
} else {
|
||||
cJSON *result = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(result, "status", "moved");
|
||||
char *json = cJSON_PrintUnformatted(result);
|
||||
cJSON_Delete(result);
|
||||
respond_json(request, json);
|
||||
free(json);
|
||||
}
|
||||
g_free(from); g_free(to); g_free(url);
|
||||
}
|
||||
|
||||
/* Handle sovereign://bookmarks/renamedir?old=...&new=...
|
||||
* Renames a folder (and its entire subtree) to a new path. */
|
||||
static void handle_bookmarks_renamedir(WebKitURISchemeRequest *request,
|
||||
const char *query) {
|
||||
char *old_path = query_param(query, "old");
|
||||
char *new_path = query_param(query, "new");
|
||||
|
||||
if (!old_path || old_path[0] == '\0' ||
|
||||
!new_path || new_path[0] == '\0') {
|
||||
respond_error_json(request, -1, "Missing old or new parameter");
|
||||
g_free(old_path); g_free(new_path);
|
||||
return;
|
||||
}
|
||||
|
||||
int rc = bookmarks_rename_dir(old_path, new_path);
|
||||
if (rc != 0) {
|
||||
respond_error_json(request, -1, "Failed to rename directory");
|
||||
} else {
|
||||
cJSON *result = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(result, "status", "renamed");
|
||||
char *json = cJSON_PrintUnformatted(result);
|
||||
cJSON_Delete(result);
|
||||
respond_json(request, json);
|
||||
free(json);
|
||||
}
|
||||
g_free(old_path); g_free(new_path);
|
||||
}
|
||||
|
||||
/* ── Agent pages (sovereign://agents) ───────────────────────────── */
|
||||
|
||||
/* Generate the sovereign://agents provider config HTML page.
|
||||
@@ -3710,38 +3640,53 @@ static void handle_fips_directory_json(WebKitURISchemeRequest *request) {
|
||||
free(json);
|
||||
}
|
||||
|
||||
/* Handle sovereign://bookmarks/list — return all bookmark directories
|
||||
* and their bookmarks as JSON. Used by www/bookmarks.js. */
|
||||
/* Recursively serialize a bookmark_node_t into a cJSON object. */
|
||||
static cJSON *bookmark_node_to_json(const bookmark_node_t *node) {
|
||||
cJSON *obj = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(obj, "name", node->name ? node->name : "");
|
||||
cJSON_AddStringToObject(obj, "path", node->path ? node->path : "");
|
||||
cJSON_AddNumberToObject(obj, "count", node->bookmark_count);
|
||||
|
||||
cJSON *bms = cJSON_CreateArray();
|
||||
for (int j = 0; j < node->bookmark_count; j++) {
|
||||
const bookmark_t *bm = &node->bookmarks[j];
|
||||
cJSON *bobj = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(bobj, "url", bm->url);
|
||||
cJSON_AddStringToObject(bobj, "title", bm->title);
|
||||
cJSON_AddNumberToObject(bobj, "added", (double)bm->added);
|
||||
cJSON_AddItemToArray(bms, bobj);
|
||||
}
|
||||
cJSON_AddItemToObject(obj, "bookmarks", bms);
|
||||
|
||||
cJSON *children = cJSON_CreateArray();
|
||||
for (int i = 0; i < node->child_count; i++) {
|
||||
cJSON_AddItemToArray(children,
|
||||
bookmark_node_to_json(&node->children[i]));
|
||||
}
|
||||
cJSON_AddItemToObject(obj, "children", children);
|
||||
return obj;
|
||||
}
|
||||
|
||||
/* Handle sovereign://bookmarks/list — return the bookmark tree as JSON.
|
||||
* Used by www/bookmarks.js. Shape:
|
||||
* {"have_signer": bool, "tree": {name,path,bookmarks[],children[]}} */
|
||||
static void handle_bookmarks_list_json(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);
|
||||
|
||||
cJSON *root = cJSON_CreateObject();
|
||||
cJSON_AddBoolToObject(root, "have_signer", have_signer);
|
||||
const bookmark_node_t *root = bookmarks_get_root();
|
||||
|
||||
cJSON *dirs_arr = cJSON_CreateArray();
|
||||
for (int i = 0; i < dir_count; i++) {
|
||||
const bookmark_dir_t *dir = &dirs[i];
|
||||
cJSON *dobj = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(dobj, "name", dir->name);
|
||||
cJSON_AddNumberToObject(dobj, "count", dir->count);
|
||||
cJSON *bms = cJSON_CreateArray();
|
||||
for (int j = 0; j < dir->count; j++) {
|
||||
const bookmark_t *bm = &dir->bookmarks[j];
|
||||
cJSON *bobj = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(bobj, "url", bm->url);
|
||||
cJSON_AddStringToObject(bobj, "title", bm->title);
|
||||
cJSON_AddNumberToObject(bobj, "added", (double)bm->added);
|
||||
cJSON_AddItemToArray(bms, bobj);
|
||||
}
|
||||
cJSON_AddItemToObject(dobj, "bookmarks", bms);
|
||||
cJSON_AddItemToArray(dirs_arr, dobj);
|
||||
cJSON *root_obj = cJSON_CreateObject();
|
||||
cJSON_AddBoolToObject(root_obj, "have_signer", have_signer);
|
||||
|
||||
if (root) {
|
||||
cJSON *tree = bookmark_node_to_json(root);
|
||||
cJSON_AddItemToObject(root_obj, "tree", tree);
|
||||
} else {
|
||||
cJSON_AddNullToObject(root_obj, "tree");
|
||||
}
|
||||
cJSON_AddItemToObject(root, "dirs", dirs_arr);
|
||||
|
||||
char *json = cJSON_PrintUnformatted(root);
|
||||
cJSON_Delete(root);
|
||||
char *json = cJSON_PrintUnformatted(root_obj);
|
||||
cJSON_Delete(root_obj);
|
||||
respond_json(request, json);
|
||||
free(json);
|
||||
}
|
||||
@@ -3996,20 +3941,28 @@ static void on_sovereign_scheme(WebKitURISchemeRequest *request,
|
||||
handle_bookmarks_list_json(request);
|
||||
return;
|
||||
}
|
||||
if (strncmp(uri, "sovereign://bookmarks/add?", 25) == 0) {
|
||||
handle_bookmarks_add(request, uri + 25);
|
||||
if (strncmp(uri, "sovereign://bookmarks/add?", 26) == 0) {
|
||||
handle_bookmarks_add(request, uri + 26);
|
||||
return;
|
||||
}
|
||||
if (strncmp(uri, "sovereign://bookmarks/delete?", 28) == 0) {
|
||||
handle_bookmarks_delete(request, uri + 28);
|
||||
if (strncmp(uri, "sovereign://bookmarks/delete?", 29) == 0) {
|
||||
handle_bookmarks_delete(request, uri + 29);
|
||||
return;
|
||||
}
|
||||
if (strncmp(uri, "sovereign://bookmarks/createdir?", 31) == 0) {
|
||||
handle_bookmarks_createdir(request, uri + 31);
|
||||
if (strncmp(uri, "sovereign://bookmarks/createdir?", 32) == 0) {
|
||||
handle_bookmarks_createdir(request, uri + 32);
|
||||
return;
|
||||
}
|
||||
if (strncmp(uri, "sovereign://bookmarks/deletedir?", 31) == 0) {
|
||||
handle_bookmarks_deletedir(request, uri + 31);
|
||||
if (strncmp(uri, "sovereign://bookmarks/deletedir?", 32) == 0) {
|
||||
handle_bookmarks_deletedir(request, uri + 32);
|
||||
return;
|
||||
}
|
||||
if (strncmp(uri, "sovereign://bookmarks/move?", 27) == 0) {
|
||||
handle_bookmarks_move(request, uri + 27);
|
||||
return;
|
||||
}
|
||||
if (strncmp(uri, "sovereign://bookmarks/renamedir?", 32) == 0) {
|
||||
handle_bookmarks_renamedir(request, uri + 32);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -38,6 +38,19 @@ static const char *ci_strstr(const char *haystack, const char *needle) {
|
||||
return NULL;
|
||||
}
|
||||
|
||||
/* Recursively collect all bookmarks in the bookmark tree into a GPtrArray
|
||||
* of `const bookmark_t *` (shallow — pointers are valid until the next
|
||||
* bookmarks mutation). Used by URL completion and the hamburger menu. */
|
||||
static void collect_all_bookmarks(const bookmark_node_t *node, GPtrArray *out) {
|
||||
if (node == NULL) return;
|
||||
for (int i = 0; i < node->bookmark_count; i++) {
|
||||
g_ptr_array_add(out, &node->bookmarks[i]);
|
||||
}
|
||||
for (int i = 0; i < node->child_count; i++) {
|
||||
collect_all_bookmarks(&node->children[i], out);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── External callbacks defined in main.c ─────────────────────────── *
|
||||
* These handle identity-related menu actions that need access to the
|
||||
* global app_state_t (signer, pubkey, method). main.c owns that state.
|
||||
@@ -417,28 +430,27 @@ static void rebuild_completion(const char *query, completion_state_t *cs) {
|
||||
g_free(titles);
|
||||
|
||||
/* ── Direct links from bookmarks ──────────────────────────────── */
|
||||
int dir_count = 0;
|
||||
const bookmark_dir_t *dirs = bookmarks_get_dirs(&dir_count);
|
||||
GPtrArray *bms = g_ptr_array_new();
|
||||
collect_all_bookmarks(bookmarks_get_root(), bms);
|
||||
int bookmark_added = 0;
|
||||
for (int d = 0; d < dir_count && bookmark_added < COMPLETION_MAX_DIRECT; d++) {
|
||||
for (int b = 0; b < dirs[d].count && bookmark_added < COMPLETION_MAX_DIRECT; b++) {
|
||||
const bookmark_t *bm = &dirs[d].bookmarks[b];
|
||||
/* Case-insensitive substring search. */
|
||||
if ((bm->url && ci_strstr(bm->url, query)) ||
|
||||
(bm->title && ci_strstr(bm->title, query))) {
|
||||
char *label = url_display_label(bm->url, bm->title);
|
||||
GtkTreeIter iter;
|
||||
gtk_list_store_append(cs->store, &iter);
|
||||
gtk_list_store_set(cs->store, &iter,
|
||||
COMPLETION_COL_DISPLAY, label,
|
||||
COMPLETION_COL_URL, bm->url,
|
||||
COMPLETION_COL_IS_DIRECT, TRUE,
|
||||
-1);
|
||||
g_free(label);
|
||||
bookmark_added++;
|
||||
}
|
||||
for (guint i = 0; i < bms->len && bookmark_added < COMPLETION_MAX_DIRECT; i++) {
|
||||
const bookmark_t *bm = (const bookmark_t *)g_ptr_array_index(bms, i);
|
||||
/* Case-insensitive substring search. */
|
||||
if ((bm->url && ci_strstr(bm->url, query)) ||
|
||||
(bm->title && ci_strstr(bm->title, query))) {
|
||||
char *label = url_display_label(bm->url, bm->title);
|
||||
GtkTreeIter iter;
|
||||
gtk_list_store_append(cs->store, &iter);
|
||||
gtk_list_store_set(cs->store, &iter,
|
||||
COMPLETION_COL_DISPLAY, label,
|
||||
COMPLETION_COL_URL, bm->url,
|
||||
COMPLETION_COL_IS_DIRECT, TRUE,
|
||||
-1);
|
||||
g_free(label);
|
||||
bookmark_added++;
|
||||
}
|
||||
}
|
||||
g_ptr_array_free(bms, TRUE);
|
||||
|
||||
/* ── Domain heuristic ─────────────────────────────────────────── *
|
||||
* If the query has no spaces and no dots, offer to navigate directly
|
||||
@@ -2063,57 +2075,39 @@ static void on_bookmarks_menu_show(GtkWidget *menu, gpointer user_data) {
|
||||
}
|
||||
g_list_free(children);
|
||||
|
||||
/* Get current bookmarks. */
|
||||
int dir_count = 0;
|
||||
const bookmark_dir_t *dirs = bookmarks_get_dirs(&dir_count);
|
||||
/* Get current bookmarks (flattened from the tree). */
|
||||
GPtrArray *bms = g_ptr_array_new();
|
||||
collect_all_bookmarks(bookmarks_get_root(), bms);
|
||||
|
||||
int total_bookmarks = 0;
|
||||
for (int i = 0; i < dir_count; i++) {
|
||||
total_bookmarks += dirs[i].count;
|
||||
}
|
||||
|
||||
if (total_bookmarks == 0) {
|
||||
if (bms->len == 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). */
|
||||
/* Show up to 15 bookmarks (flattened tree order). */
|
||||
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++;
|
||||
for (guint i = 0; i < bms->len && shown < 15; i++) {
|
||||
const bookmark_t *bm = (const bookmark_t *)g_ptr_array_index(bms, i);
|
||||
const char *label_text = (bm->title && 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);
|
||||
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++;
|
||||
}
|
||||
}
|
||||
g_ptr_array_free(bms, TRUE);
|
||||
|
||||
gtk_menu_shell_append(GTK_MENU_SHELL(menu),
|
||||
gtk_separator_menu_item_new());
|
||||
@@ -2126,9 +2120,21 @@ static void on_bookmarks_menu_show(GtkWidget *menu, gpointer user_data) {
|
||||
gtk_widget_show_all(menu);
|
||||
}
|
||||
|
||||
/* Recursively collect all folder paths in the bookmark tree into `out`.
|
||||
* The root node itself is skipped (its path is ""). */
|
||||
static void collect_folder_paths(const bookmark_node_t *node, GPtrArray *out) {
|
||||
if (node == NULL) return;
|
||||
for (int i = 0; i < node->child_count; i++) {
|
||||
const bookmark_node_t *child = &node->children[i];
|
||||
g_ptr_array_add(out, g_strdup(child->path));
|
||||
collect_folder_paths(child, out);
|
||||
}
|
||||
}
|
||||
|
||||
/* Called when the bookmark button (toolbar) is clicked.
|
||||
* Shows a dialog to pick or create a directory, then bookmarks the
|
||||
* current page. */
|
||||
* Shows a dialog to pick or create a folder path, then bookmarks the
|
||||
* current page. The picker lists all existing folder paths (including
|
||||
* nested ones like "Work/Projects") and lets the user type a new path. */
|
||||
static void on_bookmark_clicked(GtkButton *btn, gpointer user_data) {
|
||||
(void)btn;
|
||||
tab_info_t *tab = (tab_info_t *)user_data;
|
||||
@@ -2151,7 +2157,7 @@ static void on_bookmark_clicked(GtkButton *btn, gpointer user_data) {
|
||||
const gchar *title = webkit_web_view_get_title(tab->webview);
|
||||
if (title == NULL) title = "";
|
||||
|
||||
/* Build the directory picker dialog. */
|
||||
/* Build the folder picker dialog. */
|
||||
GtkWidget *dialog = gtk_dialog_new_with_buttons(
|
||||
"Bookmark Page", g_window,
|
||||
GTK_DIALOG_MODAL | GTK_DIALOG_DESTROY_WITH_PARENT,
|
||||
@@ -2175,22 +2181,24 @@ static void on_bookmark_clicked(GtkButton *btn, gpointer user_data) {
|
||||
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:");
|
||||
/* Folder combo box (with entry so the user can type a new path). */
|
||||
GtkWidget *dir_label = gtk_label_new("Folder path:");
|
||||
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. */
|
||||
GPtrArray *paths = g_ptr_array_new();
|
||||
const bookmark_node_t *root = bookmarks_get_root();
|
||||
collect_folder_paths(root, paths);
|
||||
int default_idx = 0;
|
||||
for (int i = 0; i < dir_count; i++) {
|
||||
if (strcmp(dirs[i].name, "General") == 0) { default_idx = i; break; }
|
||||
for (guint i = 0; i < paths->len; i++) {
|
||||
const char *p = (const char *)g_ptr_array_index(paths, i);
|
||||
gtk_combo_box_text_append_text(GTK_COMBO_BOX_TEXT(combo), p);
|
||||
/* Default to "Bookmarks Bar" (always visible in the toolbar) so
|
||||
* new users don't have to know the exact folder name. Fall back
|
||||
* to "General" if "Bookmarks Bar" isn't present for some reason. */
|
||||
if (strcmp(p, "Bookmarks Bar") == 0) default_idx = (int)i;
|
||||
else if (strcmp(p, "General") == 0 && default_idx == 0) default_idx = (int)i;
|
||||
}
|
||||
gtk_combo_box_set_active(GTK_COMBO_BOX(combo), default_idx);
|
||||
gtk_box_pack_start(GTK_BOX(content), combo, FALSE, FALSE, 4);
|
||||
@@ -2203,34 +2211,33 @@ static void on_bookmark_clicked(GtkButton *btn, gpointer user_data) {
|
||||
|
||||
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(
|
||||
const char *path = gtk_combo_box_text_get_active_text(
|
||||
GTK_COMBO_BOX_TEXT(combo));
|
||||
if (dir_name == NULL || dir_name[0] == '\0') {
|
||||
dir_name = "General";
|
||||
}
|
||||
if (path == NULL || path[0] == '\0') path = "General";
|
||||
|
||||
char *dir_copy = g_strdup(dir_name);
|
||||
char *path_copy = g_strdup(path);
|
||||
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);
|
||||
int rc = bookmarks_add(path_copy, url_copy, title_copy);
|
||||
if (rc == 0) {
|
||||
g_print("[bookmarks] Bookmarked '%s' to '%s'\n", url_copy,
|
||||
dir_copy);
|
||||
path_copy);
|
||||
} else {
|
||||
g_printerr("[bookmarks] Failed to bookmark (no signer?)\n");
|
||||
}
|
||||
|
||||
g_free(dir_copy);
|
||||
g_free(path_copy);
|
||||
g_free(url_copy);
|
||||
g_free(title_copy);
|
||||
}
|
||||
|
||||
/* Free the paths array. */
|
||||
for (guint i = 0; i < paths->len; i++) {
|
||||
g_free(g_ptr_array_index(paths, i));
|
||||
}
|
||||
g_ptr_array_free(paths, TRUE);
|
||||
|
||||
gtk_widget_destroy(dialog);
|
||||
}
|
||||
|
||||
@@ -2479,6 +2486,150 @@ static GtkWidget *tab_find_notebook(GtkWidget *page) {
|
||||
return NULL;
|
||||
}
|
||||
|
||||
/* ── Bookmarks toolbar ─────────────────────────────────────────────── */
|
||||
|
||||
/* Forward decl — defined below tab_create. */
|
||||
static void on_bookmark_bar_clicked(GtkButton *btn, gpointer user_data);
|
||||
static void bookmark_bar_refresh(tab_info_t *tab);
|
||||
static void bookmark_bar_refresh_all(void *user_data);
|
||||
static int g_bookmark_bar_subscribed = 0;
|
||||
|
||||
/* Click handler for a bookmark button in the bar. user_data is the URL
|
||||
* (g_strdup'd; freed via g_object_set_data_full destroy notify). */
|
||||
static void on_bookmark_bar_clicked(GtkButton *btn, gpointer user_data) {
|
||||
(void)btn;
|
||||
const char *url = (const char *)user_data;
|
||||
if (url == NULL || url[0] == '\0') return;
|
||||
|
||||
/* Find the tab that owns this button by walking the bar's parent. */
|
||||
GtkWidget *bar = gtk_widget_get_parent(GTK_WIDGET(btn));
|
||||
if (bar == NULL) return;
|
||||
GtkWidget *page = gtk_widget_get_parent(bar);
|
||||
if (page == NULL) return;
|
||||
|
||||
tab_info_t *target = NULL;
|
||||
for (int i = 0; i < g_tab_count; i++) {
|
||||
if (g_tabs[i] && g_tabs[i]->page == page) {
|
||||
target = g_tabs[i];
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (target == NULL || target->webview == NULL) return;
|
||||
|
||||
webkit_web_view_load_uri(target->webview, url);
|
||||
if (target->url_entry) {
|
||||
gtk_entry_set_text(GTK_ENTRY(target->url_entry), url);
|
||||
}
|
||||
}
|
||||
|
||||
/* Recursively add a folder's bookmarks (and one level of subfolders as
|
||||
* GtkMenuButton popovers) to a container. */
|
||||
static void bookmark_bar_add_folder(GtkWidget *container,
|
||||
const bookmark_node_t *node) {
|
||||
if (node == NULL) return;
|
||||
|
||||
/* Bookmarks in this folder. */
|
||||
for (int i = 0; i < node->bookmark_count; i++) {
|
||||
const bookmark_t *bm = &node->bookmarks[i];
|
||||
const char *label = (bm->title && bm->title[0]) ? bm->title : bm->url;
|
||||
GtkWidget *btn = gtk_button_new_with_label(label);
|
||||
gtk_button_set_relief(GTK_BUTTON(btn), GTK_RELIEF_NONE);
|
||||
gtk_widget_set_tooltip_text(btn, bm->url);
|
||||
char *url_copy = g_strdup(bm->url);
|
||||
g_object_set_data_full(G_OBJECT(btn), "bm-url", url_copy,
|
||||
(GDestroyNotify)g_free);
|
||||
g_signal_connect(btn, "clicked",
|
||||
G_CALLBACK(on_bookmark_bar_clicked), url_copy);
|
||||
gtk_box_pack_start(GTK_BOX(container), btn, FALSE, FALSE, 0);
|
||||
}
|
||||
|
||||
/* Subfolders as menu buttons with popover menus. */
|
||||
for (int i = 0; i < node->child_count; i++) {
|
||||
const bookmark_node_t *child = &node->children[i];
|
||||
GtkWidget *menu = gtk_menu_new();
|
||||
|
||||
/* Add the subfolder's bookmarks to the menu. */
|
||||
for (int j = 0; j < child->bookmark_count; j++) {
|
||||
const bookmark_t *bm = &child->bookmarks[j];
|
||||
const char *label = (bm->title && bm->title[0]) ? bm->title : bm->url;
|
||||
GtkWidget *item = gtk_menu_item_new_with_label(label);
|
||||
char *url_copy = g_strdup(bm->url);
|
||||
g_object_set_data_full(G_OBJECT(item), "bm-url", url_copy,
|
||||
(GDestroyNotify)g_free);
|
||||
/* We need the tab to load into; defer to click handler which
|
||||
* finds the tab from the menu's toplevel. For simplicity, use
|
||||
* the same on_bookmark_bar_clicked — it walks parents. */
|
||||
g_signal_connect(item, "activate",
|
||||
G_CALLBACK(on_bookmark_bar_clicked), url_copy);
|
||||
gtk_menu_shell_append(GTK_MENU_SHELL(menu), item);
|
||||
}
|
||||
/* Recurse one level deeper for sub-subfolders (as submenus). */
|
||||
for (int j = 0; j < child->child_count; j++) {
|
||||
const bookmark_node_t *grand = &child->children[j];
|
||||
GtkWidget *submenu = gtk_menu_new();
|
||||
for (int k = 0; k < grand->bookmark_count; k++) {
|
||||
const bookmark_t *bm = &grand->bookmarks[k];
|
||||
const char *label = (bm->title && bm->title[0]) ? bm->title : bm->url;
|
||||
GtkWidget *item = gtk_menu_item_new_with_label(label);
|
||||
char *url_copy = g_strdup(bm->url);
|
||||
g_object_set_data_full(G_OBJECT(item), "bm-url", url_copy,
|
||||
(GDestroyNotify)g_free);
|
||||
g_signal_connect(item, "activate",
|
||||
G_CALLBACK(on_bookmark_bar_clicked), url_copy);
|
||||
gtk_menu_shell_append(GTK_MENU_SHELL(submenu), item);
|
||||
}
|
||||
GtkWidget *sub_item = gtk_menu_item_new_with_label(child->children[j].name);
|
||||
gtk_menu_item_set_submenu(GTK_MENU_ITEM(sub_item), submenu);
|
||||
gtk_menu_shell_append(GTK_MENU_SHELL(menu), sub_item);
|
||||
}
|
||||
|
||||
GtkWidget *mb = gtk_menu_button_new();
|
||||
gtk_button_set_label(GTK_BUTTON(mb), child->name);
|
||||
gtk_menu_button_set_popup(GTK_MENU_BUTTON(mb), menu);
|
||||
gtk_widget_set_tooltip_text(mb, child->path);
|
||||
gtk_box_pack_start(GTK_BOX(container), mb, FALSE, FALSE, 0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Refresh a single tab's bookmark bar from the current bookmark tree. */
|
||||
static void bookmark_bar_refresh(tab_info_t *tab) {
|
||||
if (tab == NULL || tab->bookmark_bar == NULL) return;
|
||||
|
||||
/* Clear existing children. */
|
||||
GList *children = gtk_container_get_children(GTK_CONTAINER(tab->bookmark_bar));
|
||||
for (GList *l = children; l != NULL; l = l->next) {
|
||||
gtk_widget_destroy(GTK_WIDGET(l->data));
|
||||
}
|
||||
g_list_free(children);
|
||||
|
||||
/* Look up the "Bookmarks Bar" folder. It always exists in memory
|
||||
* (bookmarks_init ensures it), but may be empty until the user adds
|
||||
* bookmarks to it. Show a friendly hint in that case. */
|
||||
const bookmark_node_t *bar_node = bookmarks_find("Bookmarks Bar");
|
||||
if (bar_node == NULL || (bar_node->bookmark_count == 0 && bar_node->child_count == 0)) {
|
||||
GtkWidget *hint = gtk_label_new(
|
||||
"Bookmarks Bar is empty — bookmark a page and choose \"Bookmarks Bar\" as the folder");
|
||||
gtk_widget_set_sensitive(hint, FALSE);
|
||||
gtk_widget_set_margin_start(hint, 4);
|
||||
gtk_box_pack_start(GTK_BOX(tab->bookmark_bar), hint, FALSE, FALSE, 0);
|
||||
gtk_widget_show_all(tab->bookmark_bar);
|
||||
return;
|
||||
}
|
||||
|
||||
bookmark_bar_add_folder(tab->bookmark_bar, bar_node);
|
||||
gtk_widget_show_all(tab->bookmark_bar);
|
||||
}
|
||||
|
||||
/* Callback invoked by bookmarks_subscribe_changed: refresh every open tab. */
|
||||
static void bookmark_bar_refresh_all(void *user_data) {
|
||||
(void)user_data;
|
||||
for (int i = 0; i < g_tab_count; i++) {
|
||||
if (g_tabs[i] != NULL) {
|
||||
bookmark_bar_refresh(g_tabs[i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static tab_info_t *tab_create(const char *url) {
|
||||
const browser_settings_t *s = settings_get();
|
||||
|
||||
@@ -2672,6 +2823,18 @@ static tab_info_t *tab_create(const char *url) {
|
||||
G_CALLBACK(on_bookmark_clicked), tab);
|
||||
gtk_box_pack_start(GTK_BOX(toolbar), bookmark_btn, FALSE, FALSE, 0);
|
||||
|
||||
/* Bookmarks toolbar — a horizontal bar below the URL toolbar showing
|
||||
* buttons for the bookmarks in the "Bookmarks Bar" folder, plus
|
||||
* GtkMenuButton popovers for subfolders. Refreshed whenever bookmarks
|
||||
* change (see bookmark_bar_refresh_all via bookmarks_subscribe_changed). */
|
||||
tab->bookmark_bar = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 4);
|
||||
gtk_widget_set_margin_top(tab->bookmark_bar, 2);
|
||||
gtk_widget_set_margin_bottom(tab->bookmark_bar, 2);
|
||||
gtk_widget_set_margin_start(tab->bookmark_bar, 4);
|
||||
gtk_widget_set_margin_end(tab->bookmark_bar, 4);
|
||||
gtk_box_pack_start(GTK_BOX(tab->page), tab->bookmark_bar, FALSE, FALSE, 0);
|
||||
bookmark_bar_refresh(tab);
|
||||
|
||||
/* 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. */
|
||||
@@ -3040,6 +3203,14 @@ void tab_manager_init(GtkContainer *parent,
|
||||
g_ctx = ctx;
|
||||
g_window = window;
|
||||
|
||||
/* Subscribe to bookmark changes so every open tab's bookmark bar
|
||||
* refreshes when bookmarks are added/moved/deleted/loaded. Registered
|
||||
* once here; the callback iterates all open tabs. */
|
||||
if (!g_bookmark_bar_subscribed) {
|
||||
bookmarks_subscribe_changed(bookmark_bar_refresh_all, NULL);
|
||||
g_bookmark_bar_subscribed = 1;
|
||||
}
|
||||
|
||||
g_notebook = gtk_notebook_new();
|
||||
/* Disable scrolling so tabs share the available width evenly instead
|
||||
* of showing a scrollbar when there are many tabs. */
|
||||
|
||||
@@ -29,6 +29,7 @@ typedef struct {
|
||||
GtkWidget *title_label; /* child of tab_label */
|
||||
GtkWidget *favicon; /* favicon image in tab_label */
|
||||
GtkWidget *refresh_btn; /* toolbar reload/stop button (per-tab) */
|
||||
GtkWidget *bookmark_bar; /* bookmarks toolbar (below URL toolbar) */
|
||||
gboolean refresh_shows_stop; /* cached visual state, avoids redundant updates */
|
||||
char current_url[TAB_URL_MAX];
|
||||
char title[TAB_TITLE_MAX];
|
||||
|
||||
@@ -11,9 +11,9 @@
|
||||
#ifndef SOVEREIGN_BROWSER_VERSION_H
|
||||
#define SOVEREIGN_BROWSER_VERSION_H
|
||||
|
||||
#define SB_VERSION "v0.0.44"
|
||||
#define SB_VERSION "v0.0.45"
|
||||
#define SB_VERSION_MAJOR 0
|
||||
#define SB_VERSION_MINOR 0
|
||||
#define SB_VERSION_PATCH 44
|
||||
#define SB_VERSION_PATCH 45
|
||||
|
||||
#endif /* SOVEREIGN_BROWSER_VERSION_H */
|
||||
|
||||
144
tests/test_bookmarks_tree.c
Normal file
144
tests/test_bookmarks_tree.c
Normal file
@@ -0,0 +1,144 @@
|
||||
/*
|
||||
* test_bookmarks_tree.c — unit tests for the bookmark tree/HMAC d-tag logic
|
||||
*
|
||||
* Tests the pure pieces that don't require a signer, database, or relay
|
||||
* connection:
|
||||
* - HMAC-SHA256 d-tag derivation is deterministic (same key + path → same hex)
|
||||
* - HMAC-SHA256 d-tag derivation is opaque (different paths → uncorrelated)
|
||||
* - is_hmac_d_tag correctly distinguishes 64-hex HMAC tags from legacy
|
||||
* plaintext directory names
|
||||
*
|
||||
* The full trie operations (add/remove/move/rename/delete) require a signer
|
||||
* and database, so they are exercised via the browser's MCP tools at runtime
|
||||
* rather than in this unit test.
|
||||
*
|
||||
* Build (standalone, links against nostr_core_lib):
|
||||
* cc -std=c99 -Wall -Wextra -g -I./nostr_core_lib \
|
||||
* tests/test_bookmarks_tree.c \
|
||||
* nostr_core_lib/libnostr_core_x64.a \
|
||||
* -lsecp256k1 -lssl -lcrypto -lm -o tests/test_bookmarks_tree
|
||||
* ./tests/test_bookmarks_tree
|
||||
*/
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include "../nostr_core_lib/nostr_core/nostr_core.h"
|
||||
|
||||
static int failures = 0;
|
||||
static int passes = 0;
|
||||
|
||||
#define CHECK(cond, msg) do { \
|
||||
if (cond) { passes++; } \
|
||||
else { failures++; fprintf(stderr, "FAIL: %s\n", msg); } \
|
||||
} while (0)
|
||||
|
||||
/* Mirror of BOOKMARKS_HMAC_KEY_LABEL in src/bookmarks.c. */
|
||||
#define LABEL "sovereign-browser/bookmarks-folder-id-v1"
|
||||
|
||||
/* Mirror of path_to_d_tag + compute_hmac_key in src/bookmarks.c. */
|
||||
static char *path_to_d_tag(const unsigned char *privkey, const char *path) {
|
||||
unsigned char hmac_key[32];
|
||||
if (nostr_hmac_sha256(privkey, 32,
|
||||
(const unsigned char *)LABEL, strlen(LABEL),
|
||||
hmac_key) != 0) {
|
||||
return NULL;
|
||||
}
|
||||
unsigned char mac[32];
|
||||
if (nostr_hmac_sha256(hmac_key, 32,
|
||||
(const unsigned char *)path, strlen(path),
|
||||
mac) != 0) {
|
||||
return NULL;
|
||||
}
|
||||
char *hex = malloc(65);
|
||||
if (hex == NULL) return NULL;
|
||||
nostr_bytes_to_hex(mac, 32, hex);
|
||||
hex[64] = '\0';
|
||||
return hex;
|
||||
}
|
||||
|
||||
/* Mirror of is_hmac_d_tag in src/bookmarks.c. */
|
||||
static int is_hmac_d_tag(const char *s) {
|
||||
if (s == NULL || strlen(s) != 64) return 0;
|
||||
for (const char *p = s; *p; p++) {
|
||||
if (!((*p >= '0' && *p <= '9') || (*p >= 'a' && *p <= 'f')))
|
||||
return 0;
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
int main(void) {
|
||||
/* Use a fixed test privkey (32 bytes of 0x01..0x20). */
|
||||
unsigned char privkey[32];
|
||||
for (int i = 0; i < 32; i++) privkey[i] = (unsigned char)(i + 1);
|
||||
|
||||
/* ── Determinism: same path → same d tag ────────────────────────── */
|
||||
char *d1 = path_to_d_tag(privkey, "Work/Projects/Secret");
|
||||
char *d2 = path_to_d_tag(privkey, "Work/Projects/Secret");
|
||||
CHECK(d1 != NULL, "path_to_d_tag returned NULL");
|
||||
CHECK(d2 != NULL, "path_to_d_tag returned NULL (2nd call)");
|
||||
CHECK(d1 && d2 && strcmp(d1, d2) == 0,
|
||||
"Same path should produce the same d tag (determinism)");
|
||||
free(d1);
|
||||
free(d2);
|
||||
|
||||
/* ── Opaqueness: different paths → different d tags ─────────────── */
|
||||
char *da = path_to_d_tag(privkey, "Work");
|
||||
char *db = path_to_d_tag(privkey, "Work/Projects");
|
||||
char *dc = path_to_d_tag(privkey, "Personal");
|
||||
CHECK(da && db && strcmp(da, db) != 0,
|
||||
"Different paths should produce different d tags (no prefix leakage)");
|
||||
CHECK(da && dc && strcmp(da, dc) != 0,
|
||||
"Different paths should produce different d tags (2)");
|
||||
/* The d tag should NOT contain the plaintext path. */
|
||||
CHECK(da && strstr(da, "Work") == NULL,
|
||||
"d tag should not leak the plaintext path");
|
||||
CHECK(db && strstr(db, "Work") == NULL,
|
||||
"d tag should not leak the plaintext path (child)");
|
||||
free(da);
|
||||
free(db);
|
||||
free(dc);
|
||||
|
||||
/* ── d tag is 64 lowercase hex chars ────────────────────────────── */
|
||||
char *d = path_to_d_tag(privkey, "General");
|
||||
CHECK(d != NULL, "path_to_d_tag returned NULL for General");
|
||||
CHECK(d && strlen(d) == 64, "d tag should be 64 hex chars");
|
||||
CHECK(d && is_hmac_d_tag(d), "d tag should pass is_hmac_d_tag");
|
||||
free(d);
|
||||
|
||||
/* ── is_hmac_d_tag distinguishes legacy names ───────────────────── */
|
||||
CHECK(is_hmac_d_tag("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"),
|
||||
"64 lowercase hex should be recognized as an HMAC d tag");
|
||||
CHECK(!is_hmac_d_tag("General"),
|
||||
"Legacy plaintext 'General' should NOT be recognized as an HMAC d tag");
|
||||
CHECK(!is_hmac_d_tag("Work/Projects"),
|
||||
"Legacy plaintext path with slash should NOT be recognized as an HMAC d tag");
|
||||
CHECK(!is_hmac_d_tag(""),
|
||||
"Empty string should NOT be recognized as an HMAC d tag");
|
||||
CHECK(!is_hmac_d_tag(NULL),
|
||||
"NULL should NOT be recognized as an HMAC d tag");
|
||||
/* Uppercase hex is not produced by nostr_bytes_to_hex (lowercase), so
|
||||
* treat it as non-HMAC (legacy). */
|
||||
CHECK(!is_hmac_d_tag("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"),
|
||||
"Uppercase hex should NOT be recognized as an HMAC d tag (we emit lowercase)");
|
||||
|
||||
/* ── Different privkeys → different d tags for the same path ────── */
|
||||
unsigned char privkey2[32];
|
||||
for (int i = 0; i < 32; i++) privkey2[i] = (unsigned char)(255 - i);
|
||||
char *e1 = path_to_d_tag(privkey, "General");
|
||||
char *e2 = path_to_d_tag(privkey2, "General");
|
||||
CHECK(e1 && e2 && strcmp(e1, e2) != 0,
|
||||
"Different privkeys should produce different d tags for the same path");
|
||||
free(e1);
|
||||
free(e2);
|
||||
|
||||
/* ── Empty path is allowed (root) ───────────────────────────────── */
|
||||
char *root_d = path_to_d_tag(privkey, "");
|
||||
CHECK(root_d != NULL, "path_to_d_tag should handle empty path");
|
||||
CHECK(root_d && strlen(root_d) == 64,
|
||||
"Empty path d tag should still be 64 hex chars");
|
||||
free(root_d);
|
||||
|
||||
printf("bookmarks tree tests: %d passed, %d failed\n", passes, failures);
|
||||
return failures == 0 ? 0 : 1;
|
||||
}
|
||||
@@ -4,5 +4,68 @@
|
||||
* sovereign://sovereign-base.css. */
|
||||
@import url("sovereign://sovereign-base.css");
|
||||
|
||||
/* Bookmarks-specific tweaks (most layout is already in base .bm/.form/.dir-header). */
|
||||
#bm-dir { min-width: 160px; }
|
||||
/* ── Tree view ─────────────────────────────────────────────────────── */
|
||||
|
||||
.tree-node {
|
||||
margin: 2px 0;
|
||||
}
|
||||
|
||||
.folder-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.caret {
|
||||
display: inline-block;
|
||||
width: 14px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
color: var(--primary);
|
||||
font-size: 10px;
|
||||
transition: transform 0.15s ease;
|
||||
transform: rotate(0deg);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.caret.expanded {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.folder-icon {
|
||||
font-size: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.folder-name {
|
||||
font-weight: bold;
|
||||
color: var(--primary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.folder-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
margin-left: auto;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* Smaller buttons in folder actions so the row stays compact. */
|
||||
.folder-actions .btn {
|
||||
padding: 3px 10px;
|
||||
font-size: 11px;
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.tree-children {
|
||||
border-left: 1px dashed var(--border);
|
||||
padding-left: 4px;
|
||||
}
|
||||
|
||||
/* Reuse .bm / .bm-title / .bm-url / .bm-meta / .actions / .btn / .btn-del
|
||||
* from the base theme for bookmark rows. */
|
||||
|
||||
#bm-path, #new-path { min-width: 200px; }
|
||||
|
||||
@@ -19,17 +19,17 @@
|
||||
<div class="form">
|
||||
<input type="text" id="bm-url" placeholder="URL" size="40">
|
||||
<input type="text" id="bm-title" placeholder="Title" size="25">
|
||||
<select id="bm-dir"></select>
|
||||
<input type="text" id="bm-path" placeholder="Folder path (e.g. Work/Projects)" size="25">
|
||||
<button class="btn" onclick="addBookmark()">Add</button>
|
||||
</div>
|
||||
<div class="form">
|
||||
<input type="text" id="new-dir" placeholder="New directory name" size="25">
|
||||
<button class="btn" onclick="createDir()">Create Directory</button>
|
||||
<input type="text" id="new-path" placeholder="New folder path (e.g. Work/Projects/Secret)" size="35">
|
||||
<button class="btn" onclick="createDir()">Create Folder</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2>Bookmarks</h2>
|
||||
<div id="bm-list"></div>
|
||||
<div id="bm-tree"></div>
|
||||
|
||||
<script src="sovereign://bookmarks.js"></script>
|
||||
</body>
|
||||
|
||||
283
www/bookmarks.js
283
www/bookmarks.js
@@ -1,8 +1,13 @@
|
||||
/* Bookmarks page — sovereign://bookmarks
|
||||
*
|
||||
* Fetches the bookmark directory list from sovereign://bookmarks/list
|
||||
* (JSON) and renders it. Add/delete/create-dir use the existing
|
||||
* sovereign://bookmarks/{add,delete,createdir,deletedir} endpoints.
|
||||
* Fetches the bookmark tree from sovereign://bookmarks/list (JSON) and
|
||||
* renders it as an expandable/collapsible tree. Folders may be nested
|
||||
* arbitrarily (e.g. "Work/Projects/Secret"). Add/delete/create/move/rename
|
||||
* use the sovereign://bookmarks/{add,delete,createdir,deletedir,move,renamedir}
|
||||
* endpoints.
|
||||
*
|
||||
* Expand/collapse state is persisted in localStorage so the user's
|
||||
* expanded folders survive page reloads.
|
||||
*/
|
||||
function sovereignGet(url) {
|
||||
return new Promise(function(resolve, reject) {
|
||||
@@ -24,75 +29,237 @@ function esc(s) {
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
function renderBookmarks(data) {
|
||||
/* ── Expand/collapse state (persisted in localStorage) ─────────────── */
|
||||
|
||||
var EXPANDED_KEY = 'sovereign_bookmarks_expanded';
|
||||
function getExpanded() {
|
||||
try {
|
||||
var raw = localStorage.getItem(EXPANDED_KEY);
|
||||
return raw ? JSON.parse(raw) : {};
|
||||
} catch (e) { return {}; }
|
||||
}
|
||||
function setExpanded(path, isExpanded) {
|
||||
var state = getExpanded();
|
||||
if (isExpanded) state[path] = true;
|
||||
else delete state[path];
|
||||
try { localStorage.setItem(EXPANDED_KEY, JSON.stringify(state)); }
|
||||
catch (e) { /* ignore quota errors */ }
|
||||
}
|
||||
|
||||
/* ── Tree rendering ────────────────────────────────────────────────── */
|
||||
|
||||
function renderTree(data) {
|
||||
var haveSigner = !!data.have_signer;
|
||||
document.getElementById('readonly-note').style.display =
|
||||
haveSigner ? 'none' : '';
|
||||
document.getElementById('add-form').style.display =
|
||||
haveSigner ? '' : 'none';
|
||||
|
||||
/* Populate the directory <select> for the add form. */
|
||||
var sel = document.getElementById('bm-dir');
|
||||
sel.innerHTML = '';
|
||||
(data.dirs || []).forEach(function(d) {
|
||||
var opt = document.createElement('option');
|
||||
opt.value = d.name;
|
||||
opt.textContent = d.name;
|
||||
sel.appendChild(opt);
|
||||
});
|
||||
|
||||
var c = document.getElementById('bm-list');
|
||||
var dirs = data.dirs || [];
|
||||
if (dirs.length === 0) {
|
||||
var c = document.getElementById('bm-tree');
|
||||
var tree = data.tree;
|
||||
if (!tree || !tree.children || tree.children.length === 0) {
|
||||
c.innerHTML =
|
||||
'<p class="empty">No bookmarks yet. Click the bookmark button ' +
|
||||
'in the toolbar to save a page.</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
var html = '';
|
||||
dirs.forEach(function(dir) {
|
||||
html += '<div class="dir-header"><h3>' + esc(dir.name) +
|
||||
' (' + dir.count + ')</h3>';
|
||||
if (haveSigner && dir.name !== 'General') {
|
||||
var enc = encodeURIComponent(dir.name);
|
||||
html += ' <a class="btn btn-del" href="sovereign://bookmarks/deletedir?dir=' +
|
||||
enc + '" onclick="return confirm(\'Delete directory "' +
|
||||
esc(dir.name) + '"? Bookmarks will be moved to General.\')">Delete Dir</a>';
|
||||
}
|
||||
html += '</div>';
|
||||
|
||||
if (dir.count === 0) {
|
||||
html += '<p class="empty">(empty)</p>';
|
||||
} else {
|
||||
(dir.bookmarks || []).forEach(function(bm) {
|
||||
var urlEnc = encodeURIComponent(bm.url);
|
||||
var dirEnc = encodeURIComponent(dir.name);
|
||||
var title = bm.title && bm.title.length ? bm.title : '(untitled)';
|
||||
html += '<div class="bm"><div>';
|
||||
html += '<div class="bm-title">' + esc(title) + '</div>';
|
||||
html += '<div><a class="bm-url" href="' + esc(bm.url) + '">' +
|
||||
esc(bm.url) + '</a></div>';
|
||||
html += '<div class="bm-meta">Added: ' + esc(bm.added) + '</div></div>';
|
||||
html += '<div class="actions">';
|
||||
html += '<a class="btn" href="' + esc(bm.url) + '">Open</a>';
|
||||
if (haveSigner) {
|
||||
html += ' <a class="btn btn-del" href="sovereign://bookmarks/delete?dir=' +
|
||||
dirEnc + '&url=' + urlEnc +
|
||||
'" onclick="return confirm(\'Delete this bookmark?\')">Delete</a>';
|
||||
}
|
||||
html += '</div></div>';
|
||||
});
|
||||
}
|
||||
c.innerHTML = '';
|
||||
(tree.children || []).forEach(function(child) {
|
||||
c.appendChild(renderNode(child, haveSigner, 0));
|
||||
});
|
||||
c.innerHTML = html;
|
||||
}
|
||||
|
||||
function renderNode(node, haveSigner, depth) {
|
||||
/* A folder row + its bookmark list + its children (recursive). */
|
||||
var expandedState = getExpanded();
|
||||
var isExpanded = !!expandedState[node.path];
|
||||
|
||||
var wrap = document.createElement('div');
|
||||
wrap.className = 'tree-node';
|
||||
|
||||
/* Folder row. */
|
||||
var row = document.createElement('div');
|
||||
row.className = 'folder-row';
|
||||
|
||||
var caret = document.createElement('span');
|
||||
caret.className = 'caret' + (isExpanded ? ' expanded' : '');
|
||||
caret.textContent = '\u25B6'; /* right-pointing triangle */
|
||||
caret.onclick = function() {
|
||||
var nowExpanded = caret.classList.toggle('expanded');
|
||||
childList.style.display = nowExpanded ? '' : 'none';
|
||||
setExpanded(node.path, nowExpanded);
|
||||
};
|
||||
row.appendChild(caret);
|
||||
|
||||
var icon = document.createElement('span');
|
||||
icon.className = 'folder-icon';
|
||||
icon.textContent = '\u{1F4C1}'; /* folder emoji */
|
||||
row.appendChild(icon);
|
||||
|
||||
var name = document.createElement('span');
|
||||
name.className = 'folder-name';
|
||||
name.textContent = node.name + ' (' + node.count + ')';
|
||||
row.appendChild(name);
|
||||
|
||||
/* Per-folder actions. */
|
||||
var actions = document.createElement('span');
|
||||
actions.className = 'folder-actions';
|
||||
if (haveSigner) {
|
||||
var addHere = document.createElement('a');
|
||||
addHere.className = 'btn';
|
||||
addHere.href = '#';
|
||||
addHere.textContent = 'Add Here';
|
||||
addHere.onclick = function(e) {
|
||||
e.preventDefault();
|
||||
document.getElementById('bm-path').value = node.path;
|
||||
document.getElementById('bm-url').focus();
|
||||
};
|
||||
actions.appendChild(addHere);
|
||||
|
||||
var newSub = document.createElement('a');
|
||||
newSub.className = 'btn';
|
||||
newSub.href = '#';
|
||||
newSub.textContent = 'New Subfolder';
|
||||
newSub.onclick = function(e) {
|
||||
e.preventDefault();
|
||||
var sub = prompt('New subfolder name under "' + node.path + '/":');
|
||||
if (sub && sub.trim()) {
|
||||
var newPath = node.path + '/' + sub.trim();
|
||||
location.href = 'sovereign://bookmarks/createdir?name=' +
|
||||
encodeURIComponent(newPath);
|
||||
}
|
||||
};
|
||||
actions.appendChild(newSub);
|
||||
|
||||
if (node.path !== 'General' && node.path !== 'Bookmarks Bar') {
|
||||
var rename = document.createElement('a');
|
||||
rename.className = 'btn';
|
||||
rename.href = '#';
|
||||
rename.textContent = 'Rename';
|
||||
rename.onclick = function(e) {
|
||||
e.preventDefault();
|
||||
var newName = prompt('Rename folder "' + node.path + '" to:',
|
||||
node.path);
|
||||
if (newName && newName.trim() && newName.trim() !== node.path) {
|
||||
location.href = 'sovereign://bookmarks/renamedir?old=' +
|
||||
encodeURIComponent(node.path) + '&new=' +
|
||||
encodeURIComponent(newName.trim());
|
||||
}
|
||||
};
|
||||
actions.appendChild(rename);
|
||||
|
||||
var del = document.createElement('a');
|
||||
del.className = 'btn btn-del';
|
||||
del.href = '#';
|
||||
del.textContent = 'Delete';
|
||||
del.onclick = function(e) {
|
||||
e.preventDefault();
|
||||
if (confirm('Delete folder "' + node.path + '"? ' +
|
||||
'Bookmarks will be moved to General.')) {
|
||||
location.href = 'sovereign://bookmarks/deletedir?dir=' +
|
||||
encodeURIComponent(node.path);
|
||||
}
|
||||
};
|
||||
actions.appendChild(del);
|
||||
}
|
||||
}
|
||||
row.appendChild(actions);
|
||||
|
||||
wrap.appendChild(row);
|
||||
|
||||
/* Child list: bookmarks + subfolders. */
|
||||
var childList = document.createElement('div');
|
||||
childList.className = 'tree-children';
|
||||
childList.style.display = isExpanded ? '' : 'none';
|
||||
childList.style.marginLeft = '20px';
|
||||
|
||||
/* Bookmarks in this folder. */
|
||||
(node.bookmarks || []).forEach(function(bm) {
|
||||
childList.appendChild(renderBookmark(bm, node, haveSigner));
|
||||
});
|
||||
|
||||
/* Subfolders. */
|
||||
(node.children || []).forEach(function(child) {
|
||||
childList.appendChild(renderNode(child, haveSigner, depth + 1));
|
||||
});
|
||||
|
||||
wrap.appendChild(childList);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function renderBookmark(bm, folder, haveSigner) {
|
||||
var row = document.createElement('div');
|
||||
row.className = 'bm';
|
||||
|
||||
var info = document.createElement('div');
|
||||
var title = document.createElement('div');
|
||||
title.className = 'bm-title';
|
||||
title.textContent = (bm.title && bm.title.length) ? bm.title : '(untitled)';
|
||||
info.appendChild(title);
|
||||
|
||||
var urlLink = document.createElement('div');
|
||||
var a = document.createElement('a');
|
||||
a.className = 'bm-url';
|
||||
a.href = bm.url;
|
||||
a.textContent = bm.url;
|
||||
urlLink.appendChild(a);
|
||||
info.appendChild(urlLink);
|
||||
|
||||
var meta = document.createElement('div');
|
||||
meta.className = 'bm-meta';
|
||||
meta.textContent = 'Added: ' + bm.added;
|
||||
info.appendChild(meta);
|
||||
|
||||
row.appendChild(info);
|
||||
|
||||
if (haveSigner) {
|
||||
var actions = document.createElement('div');
|
||||
actions.className = 'actions';
|
||||
|
||||
var moveBtn = document.createElement('a');
|
||||
moveBtn.className = 'btn';
|
||||
moveBtn.href = '#';
|
||||
moveBtn.textContent = 'Move';
|
||||
moveBtn.onclick = function(e) {
|
||||
e.preventDefault();
|
||||
var dest = prompt('Move bookmark to folder path:', folder.path);
|
||||
if (dest && dest.trim() && dest.trim() !== folder.path) {
|
||||
location.href = 'sovereign://bookmarks/move?from=' +
|
||||
encodeURIComponent(folder.path) + '&to=' +
|
||||
encodeURIComponent(dest.trim()) + '&url=' +
|
||||
encodeURIComponent(bm.url);
|
||||
}
|
||||
};
|
||||
actions.appendChild(moveBtn);
|
||||
|
||||
var delBtn = document.createElement('a');
|
||||
delBtn.className = 'btn btn-del';
|
||||
delBtn.href = '#';
|
||||
delBtn.textContent = 'Delete';
|
||||
delBtn.onclick = function(e) {
|
||||
e.preventDefault();
|
||||
if (confirm('Delete this bookmark?')) {
|
||||
location.href = 'sovereign://bookmarks/delete?dir=' +
|
||||
encodeURIComponent(folder.path) + '&url=' +
|
||||
encodeURIComponent(bm.url);
|
||||
}
|
||||
};
|
||||
actions.appendChild(delBtn);
|
||||
|
||||
row.appendChild(actions);
|
||||
}
|
||||
|
||||
return row;
|
||||
}
|
||||
|
||||
/* ── Fetch + actions ───────────────────────────────────────────────── */
|
||||
|
||||
function fetchBookmarks() {
|
||||
sovereignGet('sovereign://bookmarks/list?_=' + Date.now())
|
||||
.then(function(data) { renderBookmarks(data); })
|
||||
.then(function(data) { renderTree(data); })
|
||||
.catch(function(e) {
|
||||
document.getElementById('bm-list').innerHTML =
|
||||
document.getElementById('bm-tree').innerHTML =
|
||||
'<p class="empty">Failed to load bookmarks: ' + esc(e.message) + '</p>';
|
||||
});
|
||||
}
|
||||
@@ -100,15 +267,15 @@ function fetchBookmarks() {
|
||||
function addBookmark() {
|
||||
var url = encodeURIComponent(document.getElementById('bm-url').value);
|
||||
var title = encodeURIComponent(document.getElementById('bm-title').value);
|
||||
var dir = encodeURIComponent(document.getElementById('bm-dir').value);
|
||||
var path = encodeURIComponent(document.getElementById('bm-path').value);
|
||||
if (!url) { alert('URL is required'); return; }
|
||||
location.href = 'sovereign://bookmarks/add?dir=' + dir + '&url=' + url +
|
||||
location.href = 'sovereign://bookmarks/add?dir=' + path + '&url=' + url +
|
||||
'&title=' + title;
|
||||
}
|
||||
|
||||
function createDir() {
|
||||
var name = encodeURIComponent(document.getElementById('new-dir').value);
|
||||
if (!name) { alert('Directory name is required'); return; }
|
||||
var name = encodeURIComponent(document.getElementById('new-path').value);
|
||||
if (!name) { alert('Folder path is required'); return; }
|
||||
location.href = 'sovereign://bookmarks/createdir?name=' + name;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user