From 1454ecbbb37901d630c9573230c3bfc483629eec Mon Sep 17 00:00:00 2001 From: Laan Tungir Date: Thu, 27 Aug 2026 09:35:55 -0400 Subject: [PATCH] v0.0.4 - Implemented full nostr:// URI scheme handler with relay fetching, NIP-11 support, and NIP-21 nostr: URL normalization --- Cargo.lock | 24 +- Cargo.toml | 2 +- VERSION | 2 +- plans/nsigner-qrexec-login-fix.md | 131 +++++++ plans/tab-bar-avatar.md | 134 +++++++ src/cli.rs | 16 +- src/db.rs | 130 ++++++- src/key_store.rs | 89 ++++- src/lib.rs | 2 +- src/login_dialog.rs | 118 +++++-- src/main.rs | 61 ++++ src/menu.rs | 10 + src/nostr_scheme.rs | 560 ++++++++++++++++++++++++++++-- src/nostr_url.rs | 59 +++- src/relay_fetch.rs | 50 ++- src/search.rs | 4 +- src/tab_manager.rs | 354 ++++++++++++++++++- src/version.rs | 4 +- 18 files changed, 1614 insertions(+), 136 deletions(-) create mode 100644 plans/nsigner-qrexec-login-fix.md create mode 100644 plans/tab-bar-avatar.md diff --git a/Cargo.lock b/Cargo.lock index 1a13c81..ead8d83 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -763,7 +763,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -1957,7 +1957,7 @@ checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8" [[package]] name = "nostr-core" -version = "0.0.2" +version = "0.0.3" dependencies = [ "aes", "base64", @@ -1979,7 +1979,7 @@ dependencies = [ [[package]] name = "nostr-nips" -version = "0.0.2" +version = "0.0.3" dependencies = [ "aes", "block-modes", @@ -2001,7 +2001,7 @@ dependencies = [ [[package]] name = "nostr-relay" -version = "0.0.2" +version = "0.0.3" dependencies = [ "futures-util", "nostr-core", @@ -2019,7 +2019,7 @@ dependencies = [ [[package]] name = "nostr-services" -version = "0.0.2" +version = "0.0.3" dependencies = [ "nostr-core", "nostr-relay", @@ -2034,11 +2034,12 @@ dependencies = [ [[package]] name = "nostr-signer" -version = "0.0.2" +version = "0.0.3" dependencies = [ "aes", "cbc", "hex", + "libc", "nostr-core", "rand 0.8.7", "serde", @@ -2748,7 +2749,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -3055,7 +3056,7 @@ dependencies = [ [[package]] name = "sovereign-browser" -version = "0.0.2" +version = "0.0.3" dependencies = [ "anyhow", "base64", @@ -3220,10 +3221,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.3", + "getrandom 0.3.4", "once_cell", "rustix", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -3346,7 +3347,9 @@ checksum = "edc5f74e248dc973e0dbb7b74c7e0d6fcc301c694ff50049504004ef4d0cdcd9" dependencies = [ "futures-util", "log", + "native-tls", "tokio", + "tokio-native-tls", "tungstenite", ] @@ -3503,6 +3506,7 @@ dependencies = [ "http", "httparse", "log", + "native-tls", "rand 0.8.7", "sha1", "thiserror 1.0.69", diff --git a/Cargo.toml b/Cargo.toml index a97e4f1..0015ba8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,7 +6,7 @@ members = [ [package] name = "sovereign-browser" -version = "0.0.3" +version = "0.0.4" edition = "2021" license = "MIT" description = "A Linux x86 web browser built on WebKitGTK with Nostr identity" diff --git a/VERSION b/VERSION index bcab45a..81340c7 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.0.3 +0.0.4 diff --git a/plans/nsigner-qrexec-login-fix.md b/plans/nsigner-qrexec-login-fix.md new file mode 100644 index 0000000..bd3ce1e --- /dev/null +++ b/plans/nsigner-qrexec-login-fix.md @@ -0,0 +1,131 @@ +# n_signer Remote-Qube Login Fix + +## Problem + +Signing in via the n_signer tab with the "Other Qube" transport silently fails, while the +equivalent CLI works: + +``` +signer-client --qrexec nostr_signer:qubes.SignerRpc --role nostr_range --path "m/44'/1237'/0'/0/0" get-public-key +``` + +## Root Causes + +1. **qrexec is rejected outright.** `on_login_clicked()` in `src/login_dialog.rs` returns + "This transport is not supported in this build" for the Other Qube transport (which is + the dialog default), even though `QrexecTransport` exists in the nostr-signer crate. + The Service field is never read by the handler at all. +2. **Wrong wire protocol in the nostr-signer crate.** `NsignerClient::call()` sends + `params` as a JSON *object* with no request `id`. The signer server's dispatcher + requires `params` to be an *array* (options object as the last element) and rejects + objects with `INVALID_REQUEST`. Trait methods also use wrong verb names: + `get_public_key` instead of `nostr_get_public_key`, `nip04_encrypt` instead of + `nostr_nip04_encrypt`, etc. Even UNIX/TCP logins can never work against this server. +3. **Errors are swallowed.** All io errors map to `NostrError::NetworkFailed` discarding + the real message; `qrexec-client-vm` stderr is sent to `Stdio::null()` (the working + client inherits it); error responses are parsed as strings when the server sends + `{"code":N,"message":"..."}` objects. + +## Wire Protocol — verified against /home/user/lt/signer/src/dispatcher.rs + +Request: `{"id":"1","method":...,"params":[...]}` — params MUST be an array; the options +object is the LAST array element. + +Response: `{"id":"...","result":...}` or `{"id":"...","error":{"code":N,"message":"..."}}` + +| Operation | Method | Params | +|------------------|-------------------------|---------------------------------| +| Nostr pubkey | `nostr_get_public_key` | `[ {role, role_path} ]` | +| Sign event | `nostr_sign_event` | `[ event_json, opts ]` | +| NIP-04 enc/dec | `nostr_nip04_encrypt` / `nostr_nip04_decrypt` | `[ peer_hex, text, opts ]` | +| NIP-44 enc/dec | `nostr_nip44_encrypt` / `nostr_nip44_decrypt` | `[ peer_hex, text, opts ]` | +| Info | `get_info` | `[]` | +| Alg pubkey | `get_public_key` | `[ {algorithm, index} ]` | +| Alg sign | `sign` | `[ msg_hex, opts ]` | +| Alg verify | `verify` | `[ msg_hex, sig_hex, opts ]` | +| X25519 ECDH | `derive_shared_secret` | `[ peer_hex, opts ]` | +| HMAC derive | `derive` | `[ data, opts with index ]` | +| ML-KEM | `encapsulate` / `decapsulate` | `[ hex, opts ]` | +| OTP | `encrypt` / `decrypt` | `[ text, opts with algorithm otp ]` | + +`opts` = `{"role":"...","role_path":"..."}` for nostr verbs; +`{"algorithm":"...","index":N}` for algorithm verbs. + +## Request Flow + +```mermaid +sequenceDiagram + participant D as Login Dialog + participant S as NsignerSigner + participant Q as QrexecTransport + participant V as qrexec-client-vm + participant B as signer bridge on nostr_signer qube + participant U as signer unix socket + + D->>S: from_transport + set_role_path + D->>S: get_public_key + S->>Q: call nostr_get_public_key + Q->>V: spawn qrexec-client-vm nostr_signer qubes.SignerRpc + V->>B: qrexec connection + B->>U: preamble qrexec_source + B->>U: framed JSON-RPC request + U-->>B: framed response + B-->>V: framed response + V-->>Q: stdout + Q-->>S: response JSON + S-->>D: pubkey hex +``` + +One request per qrexec connection — the crate's reconnect-per-call model is correct. + +## Changes + +### nostr_core_lib_rust/signer/src/nsigner.rs + +- `NsignerClient::call()`: take `Vec` params; add an id counter; parse error + objects (message + code) into `last_error`; `eprintln!` diagnostics on send/recv/parse + failures. +- `NostrSigner` impl on `NsignerSigner`: use `nostr_*` verbs with a selector-options + helper that always includes `role` and includes `role_path` when set. +- Algorithm methods: correct verb names (`derive_shared_secret`, `encrypt`/`decrypt` + with algorithm otp), array params, structured result parsing (`result.signature`, + `result.shared_secret`, `result.valid`, `result.digest`, ...). Adjust + `ml_dsa_verify` to server semantics (server verifies against its own derived key). +- `QrexecTransport`: inherit stderr from `qrexec-client-vm`; `eprintln!` the real + spawn/io error messages. +- `UnixTransport`: abstract-socket support via libc (mirroring the reference client's + `connect_abstract_unix`) so the UNIX Socket option works against this server. +- Update unit tests to assert the corrected wire protocol. + +### sovereign_browser_rust/src/login_dialog.rs + +- Service default `qubes.NsignerRpc` → `qubes.SignerRpc`. +- Read the service field; wire `QrexecTransport` for Other Qube (transport index 3) + and `SerialTransport` for USB Serial (index 0). +- `eprintln!` diagnostics: connection parameters, failures with `last_error`, success + with pubkey. + +### sovereign_browser_rust/src/key_store.rs + +- Add `nsigner_service` field to `KeyStoreIdentity`. +- Implement the `Nsigner` arm of `key_store_create_signer()`. + +### sovereign_browser_rust/src/main.rs and src/menu.rs + +- Print sign-in result (method + pubkey) to console in `do_login()`. + +## Verification + +1. `cargo test` in nostr_core_lib_rust/signer. +2. `cargo build` in sovereign_browser_rust. +3. Manual: launch browser → n_signer tab → defaults → Sign In. Console shows the + connection parameters and any errors; pubkey matches the CLI output + `8ff74724...`. + +## Known Limitations + +- qrexec reads have no timeout (`ChildStdout` is blocking) — a hung service freezes the + dialog; same limitation as the reference client. +- The sign-in RPC runs on the GTK main thread (pre-existing design). +- The crate's auth-envelope format does not match the server's NIP-42 event envelope; + it is unused (browser never calls `set_auth`, server auth is off) — out of scope. diff --git a/plans/tab-bar-avatar.md b/plans/tab-bar-avatar.md new file mode 100644 index 0000000..491c40d --- /dev/null +++ b/plans/tab-bar-avatar.md @@ -0,0 +1,134 @@ +# Tab Bar Avatar — Port from C Implementation + +## C Implementation Reference (sovereign_browser/src/tab_manager.c) + +### Layout + +The tab bar (GtkNotebook header) has two **action widgets**: + +- **Avatar button** — far LEFT (`GTK_PACK_START`), opens `sovereign://profile` in a new tab +- **New-tab button** — far RIGHT (`GTK_PACK_END`), opens a new tab + +### The Alignment Recipe (the hard-won part) + +The avatar must visually align with the hamburger button in the toolbar *below* it. Both +are fixed 28×28 squares with 4px rounded corners: + +**Widget construction (avatar):** +- `gtk_button_new()` with `GTK_RELIEF_NORMAL` (has border, unlike new-tab's `RELIEF_NONE`) +- Image: `avatar-default-symbolic` at `GTK_ICON_SIZE_BUTTON` +- `valign = GTK_ALIGN_CENTER`, `margin_start = 4` +- `size_request(28, 28)` — fixed square, matches hamburger +- Widget name: `avatar-btn` + +**Widget construction (hamburger, already ported):** +- `gtk_menu_button_new()`, image `open-menu-symbolic` at `GTK_ICON_SIZE_BUTTON` +- `size_request(28, 28)`, widget name `hamburger-btn` + +**CSS (applied at USER priority via `add_provider_for_screen`):** +```css +#avatar-btn, #hamburger-btn { + padding: 0px; + min-width: 28px; min-height: 28px; + border-radius: 4px; +} +#avatar-btn image, #hamburger-btn image { + padding: 0px; margin: 0px; +} +``` +This kills GTK's default button padding so the 28×28 request is honored exactly, and +zeroes the image's padding/margin so the icon centers precisely. + +### Avatar Image Processing (`make_fitted_pixbuf`) + +The downloaded picture is processed to exactly fill the button: +1. Scale so the *smaller* dimension fills 28px (bilinear) — object-fit: cover +2. Center-crop to 28×28 +3. Round the corners with a cairo rounded-rect clip (radius 4, matching the button's + border-radius) so the image is clipped to the button's shape + +### Fetch Flow + +- `tab_manager_set_avatar(pubkey)` — called after login AND after the relay bootstrap + fetch completes (the kind 0 event may only exist then) +- Queries kind 0 from SQLite, extracts `picture` from content JSON +- Falls back to `avatar-default-symbolic` icon when: no pubkey, no kind 0, no picture + field, or download failure +- Background thread downloads (http/https via soup, file:// direct), processes, then + `g_idle_add` back to the main thread +- **Multi-window sync**: all avatar images (main + auxiliary windows) are tracked in a + global list and updated together + +## Current Rust State + +- Main notebook (main.rs:513) has **no action widgets at all** — no new-tab button, no avatar +- Aux windows (tab_manager.rs:912) have only the new-tab button +- Hamburger button exists (tab_manager.rs:1344) with name `hamburger-btn`, 28×28 +- Theme CSS (tab_manager.rs:1530) has no avatar/hamburger sizing rules +- No avatar code exists + +## Implementation Plan + +### 1. `src/tab_manager.rs` — avatar module + +- Global `G_AVATAR_IMAGES: Mutex>` (tracked across windows) +- `setup_notebook_action_widgets(notebook)` — adds avatar (Start) + new-tab (End); + called for main notebook and each aux window +- `tab_manager_set_avatar(pubkey_hex: Option<&str>)`: + - None/empty → reset all to `avatar-default-symbolic` + - Query kind 0 via `db::db_query_events(&[0], ...)`, parse `picture` from content + - Spawn thread: download (reqwest blocking), `make_fitted_pixbuf`, then + `glib::idle_add` to set pixbuf on all tracked images +- `make_fitted_pixbuf(src: &gdk_pixbuf::Pixbuf, size: i32) -> Option`: + cover-scale, center-crop, cairo rounded-corner clip (radius 4) +- Avatar click → `tab_manager_open_internal("sovereign://profile")` + +### 2. `src/tab_manager.rs` — theme CSS + +Add to `tab_manager_apply_theme()`: +```css +#avatar-btn, #hamburger-btn { padding: 0px; min-width: 28px; min-height: 28px; border-radius: 4px; } +#avatar-btn image, #hamburger-btn image { padding: 0px; margin: 0px; } +``` + +### 3. `src/main.rs` — wire up main notebook + +After notebook creation (line ~517): call `tab_manager::setup_notebook_action_widgets(¬ebook)` + +### 4. `src/tab_manager.rs` — wire up aux windows + +In `tab_manager_new_window()`: replace the inline new-tab button with +`setup_notebook_action_widgets(¬ebook)` + +### 5. Avatar refresh triggers + +- `main.rs` / `menu.rs` `do_login()` after successful login: `tab_manager_set_avatar(Some(&pubkey_hex))` +- `relay_fetch.rs` `relay_fetch_thread()` after fetch completes: schedule + `tab_manager_set_avatar` on the main thread via `glib::idle_add_once` (the kind 0 + event may only be in the DB after the fetch) + +## Widget Tree + +```mermaid +flowchart TD + Window --> Paned + Paned --> Notebook + Notebook --> HeaderTabs[Notebook header] + HeaderTabs --> AvatarBtn[avatar-btn 28x28 PackType Start] + HeaderTabs --> Tabs[tab labels] + HeaderTabs --> NewTabBtn[new-tab btn PackType End] + Notebook --> TabPage[tab page Box] + TabPage --> Toolbar + Toolbar --> HamburgerBtn[hamburger-btn 28x28] + Toolbar --> UrlEntry[URL entry] + TabPage --> WebView +``` + +## Verification + +1. `cargo build` +2. Launch, sign in as WSB → avatar shows default icon immediately, updates to the + profile picture after the relay fetch completes +3. Visual check: avatar (tab bar, left) aligns with hamburger (toolbar, left) — same + size, same corner radius, same left edge (margin_start 4) +4. Click avatar → sovereign://profile opens in a new tab diff --git a/src/cli.rs b/src/cli.rs index cd24aad..f636c6e 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -14,9 +14,9 @@ pub struct CliArgs { pub pubkey: Option, pub mnemonic: Option, pub bunker_url: Option, - pub nsigner_transport: Option, - pub nsigner_device: Option, - pub nsigner_index: Option, + pub signer_transport: Option, + pub signer_device: Option, + pub signer_index: Option, pub profile: Option, pub no_agent: bool, pub no_session: bool, @@ -63,15 +63,15 @@ pub fn cli_parse() -> CliArgs { cli.bunker_url = Some(args[i].clone()); } } - "--nsigner" => { - cli.login_method = Some(KeyStoreMethod::Nsigner); + "--signer" => { + cli.login_method = Some(KeyStoreMethod::Signer); if i + 1 < args.len() && !args[i + 1].starts_with('-') { i += 1; - cli.nsigner_device = Some(args[i].clone()); + cli.signer_device = Some(args[i].clone()); } if i + 1 < args.len() && !args[i + 1].starts_with('-') { i += 1; - cli.nsigner_index = Some(args[i].parse().unwrap_or(0)); + cli.signer_index = Some(args[i].parse().unwrap_or(0)); } } "--profile" => { @@ -109,7 +109,7 @@ pub fn cli_print_help() { println!(" -p, --pubkey [KEY] Read-only mode with npub"); println!(" -s, --seed [PHRASE] Login with BIP-39 seed phrase"); println!(" -b, --bunker [URL] Login with NIP-46 bunker URL"); - println!(" --nsigner [DEVICE] [IDX] Login with n_signer hardware"); + println!(" --signer [DEVICE] [IDX] Login with remote signer"); println!(" --profile [PUBKEY] Use specific profile"); println!(" --no-agent Disable agent server"); println!(" --no-session Disable session restore"); diff --git a/src/db.rs b/src/db.rs index 3f29ef8..0280bc7 100644 --- a/src/db.rs +++ b/src/db.rs @@ -24,6 +24,130 @@ fn db_path(pubkey_hex: &str) -> PathBuf { db_dir().join("profiles").join(pubkey_hex).join("browser.db") } +/// Check whether `table` has a column named `column`. +fn has_column(conn: &Connection, table: &str, column: &str) -> Result> { + Ok(conn.query_row( + &format!( + "SELECT COUNT(*) > 0 FROM pragma_table_info('{}') WHERE name = ?1", + table + ), + params![column], + |row| row.get(0), + )?) +} + +/// Migrate a legacy `history` table to the current schema. +/// +/// The C project's schema was `(id, url UNIQUE, title, visited_at, +/// visit_count)`; the current schema is `(id, url, title, visit_count, +/// last_visit_at, created_at)`. `CREATE TABLE IF NOT EXISTS` skips the +/// existing legacy table, so index creation on `last_visit_at` fails and +/// `db_init` aborts. This runs before schema creation and upgrades the +/// table in place, preserving rows. +fn migrate_legacy_history(conn: &mut Connection) -> Result<(), Box> { + let has_history: bool = conn.query_row( + "SELECT COUNT(*) > 0 FROM sqlite_master WHERE type='table' AND name='history'", + [], + |row| row.get(0), + )?; + if !has_history { + return Ok(()); + } + if has_column(conn, "history", "last_visit_at")? { + return Ok(()); // Already the current schema. + } + let legacy_visited_at = has_column(conn, "history", "visited_at")?; + + let tx = conn.transaction()?; + if legacy_visited_at { + // C-project schema: map visited_at → last_visit_at/created_at. + println!("[db] Migrating legacy history table (visited_at) to current schema"); + tx.execute_batch( + " + CREATE TABLE history_new ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + url TEXT NOT NULL, + title TEXT NOT NULL DEFAULT '', + visit_count INTEGER NOT NULL DEFAULT 1, + last_visit_at INTEGER NOT NULL, + created_at INTEGER NOT NULL + ); + INSERT INTO history_new (url, title, visit_count, last_visit_at, created_at) + SELECT url, COALESCE(title, ''), COALESCE(visit_count, 1), + visited_at, visited_at + FROM history; + DROP TABLE history; + ALTER TABLE history_new RENAME TO history; + ", + )?; + } else { + // Unknown legacy schema: keep the data in a backup, start fresh. + println!("[db] Unknown legacy history schema — backing up and recreating"); + tx.execute_batch( + " + ALTER TABLE history RENAME TO history_legacy_backup; + CREATE TABLE history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + url TEXT NOT NULL, + title TEXT NOT NULL DEFAULT '', + visit_count INTEGER NOT NULL DEFAULT 1, + last_visit_at INTEGER NOT NULL, + created_at INTEGER NOT NULL + ); + ", + )?; + } + tx.commit()?; + Ok(()) +} + +/// Migrate a legacy `events` table to the current schema. +/// +/// The C project's schema was `(id, pubkey, kind, created_at, content, sig, +/// raw_json, fetched_at)`; the current schema replaces `raw_json` with +/// `tags` and adds `relay_url`. `CREATE TABLE IF NOT EXISTS` skips the +/// existing legacy table, so queries selecting `tags`/`relay_url` fail +/// (and callers swallow the error, returning empty results). This runs +/// before schema creation and upgrades the table in place, preserving rows. +fn migrate_legacy_events(conn: &mut Connection) -> Result<(), Box> { + let has_events: bool = conn.query_row( + "SELECT COUNT(*) > 0 FROM sqlite_master WHERE type='table' AND name='events'", + [], + |row| row.get(0), + )?; + if !has_events { + return Ok(()); + } + if has_column(conn, "events", "tags")? { + return Ok(()); // Already the current schema. + } + + println!("[db] Migrating legacy events table (raw_json) to current schema"); + let tx = conn.transaction()?; + tx.execute_batch( + " + CREATE TABLE events_new ( + id TEXT PRIMARY KEY, + pubkey TEXT NOT NULL, + kind INTEGER NOT NULL, + created_at INTEGER NOT NULL, + content TEXT NOT NULL, + tags TEXT NOT NULL DEFAULT '[]', + sig TEXT, + relay_url TEXT, + fetched_at INTEGER NOT NULL + ); + INSERT INTO events_new (id, pubkey, kind, created_at, content, tags, sig, relay_url, fetched_at) + SELECT id, pubkey, kind, created_at, COALESCE(content, ''), '[]', sig, NULL, fetched_at + FROM events; + DROP TABLE events; + ALTER TABLE events_new RENAME TO events; + ", + )?; + tx.commit()?; + Ok(()) +} + /// Initialize the database for a given profile. pub fn db_init(pubkey_hex: &str) -> Result<(), Box> { let path = db_path(pubkey_hex); @@ -31,7 +155,7 @@ pub fn db_init(pubkey_hex: &str) -> Result<(), Box> { std::fs::create_dir_all(parent)?; } - let conn = Connection::open_with_flags( + let mut conn = Connection::open_with_flags( &path, rusqlite::OpenFlags::SQLITE_OPEN_READ_WRITE | rusqlite::OpenFlags::SQLITE_OPEN_CREATE @@ -40,6 +164,10 @@ pub fn db_init(pubkey_hex: &str) -> Result<(), Box> { conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;")?; + // Upgrade legacy schemas before CREATE TABLE IF NOT EXISTS runs. + migrate_legacy_history(&mut conn)?; + migrate_legacy_events(&mut conn)?; + // Create tables conn.execute_batch( " diff --git a/src/key_store.rs b/src/key_store.rs index bac2f88..f50a1f0 100644 --- a/src/key_store.rs +++ b/src/key_store.rs @@ -21,7 +21,7 @@ pub enum KeyStoreMethod { Seed, // BIP-39 mnemonic — private key re-derived Readonly, // npub only — no signing Nip46, // bunker:// URL — remote signer session - Nsigner, // n_signer hardware — device path + index + Signer, // remote signer hardware — device path + index } impl KeyStoreMethod { @@ -32,7 +32,7 @@ impl KeyStoreMethod { KeyStoreMethod::Seed => "seed", KeyStoreMethod::Readonly => "readonly", KeyStoreMethod::Nip46 => "nip46", - KeyStoreMethod::Nsigner => "nsigner", + KeyStoreMethod::Signer => "signer", } } @@ -42,7 +42,7 @@ impl KeyStoreMethod { "seed" => KeyStoreMethod::Seed, "readonly" => KeyStoreMethod::Readonly, "nip46" => KeyStoreMethod::Nip46, - "nsigner" => KeyStoreMethod::Nsigner, + "signer" => KeyStoreMethod::Signer, _ => KeyStoreMethod::None, } } @@ -61,14 +61,16 @@ pub struct KeyStoreIdentity { pub mnemonic: String, /// NIP-46 bunker URL (nip46 method only). pub bunker_url: String, - /// n_signer transport (nsigner method only). - pub nsigner_transport: String, - /// n_signer device path / socket name / host:port / qube. - pub nsigner_device: String, + /// Signer transport (signer method only). + pub signer_transport: String, + /// Signer device path / socket name / host:port / qube. + pub signer_device: String, + /// qrexec service name (qrexec transport only). + pub signer_service: String, /// NIP-06 index. - pub nsigner_index: i32, + pub signer_index: i32, /// Role name. - pub nsigner_role: String, + pub signer_role: String, } impl Default for KeyStoreIdentity { @@ -79,10 +81,11 @@ impl Default for KeyStoreIdentity { privkey_hex: String::new(), mnemonic: String::new(), bunker_url: String::new(), - nsigner_transport: String::new(), - nsigner_device: String::new(), - nsigner_index: 0, - nsigner_role: "nostr_range".to_string(), + signer_transport: String::new(), + signer_device: String::new(), + signer_service: String::new(), + signer_index: 0, + signer_role: "nostr_range".to_string(), } } } @@ -109,9 +112,63 @@ pub fn key_store_create_signer(identity: &KeyStoreIdentity) -> Option { - // n_signer hardware — not yet implemented in rust_core_lib - None + KeyStoreMethod::Signer => { + // Rebuild the remote signer from the recorded transport settings. + let role = identity.signer_role.clone(); + let role_path = format!("m/44'/1237'/{}'/0/0", identity.signer_index); + let timeout_ms: u64 = 15000; + let signer = match identity.signer_transport.as_str() { + "serial" => Some(nostr_signer::signer::SignerSigner::from_transport( + Box::new(nostr_signer::signer::SerialTransport::new( + &identity.signer_device, + 115200, + timeout_ms, + )), + &role, + )), + "unix" => Some(nostr_signer::signer::SignerSigner::from_transport( + Box::new(nostr_signer::signer::UnixTransport::new( + &identity.signer_device, + timeout_ms, + )), + &role, + )), + "tcp" => { + let device = &identity.signer_device; + match device.rsplit_once(':') { + Some((host, port)) if port.parse::().is_ok() => { + let port = port.parse::().unwrap(); + Some(nostr_signer::signer::SignerSigner::from_transport( + Box::new(nostr_signer::signer::TcpTransport::new( + host, port, timeout_ms, + )), + &role, + )) + } + _ => None, + } + } + "qrexec" => { + let service = if identity.signer_service.is_empty() { + "qubes.SignerRpc" + } else { + &identity.signer_service + }; + Some(nostr_signer::signer::SignerSigner::from_transport( + Box::new(nostr_signer::signer::QrexecTransport::new( + &identity.signer_device, + service, + timeout_ms, + )), + &role, + )) + } + _ => None, + }; + signer.map(|s| { + s.set_role_path(&role_path); + Arc::new(s) as Arc + }) } KeyStoreMethod::None => None, } diff --git a/src/lib.rs b/src/lib.rs index 00e632d..aa79537 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -13,7 +13,7 @@ //! - `nostr_core` — Core types, crypto, utilities //! - `nostr_relay` — Relay networking (WebSocket, HTTP, pool) //! - `nostr_nips` — NIP implementations -//! - `nostr_signer` — Signer abstraction (local + nsigner) +//! - `nostr_signer` — Signer abstraction (local + signer) //! - `nostr_services` — Blossom, Cashu, validator services //! //! Many functions are defined as part of the port but not yet wired into diff --git a/src/login_dialog.rs b/src/login_dialog.rs index 86d43cd..b898f55 100644 --- a/src/login_dialog.rs +++ b/src/login_dialog.rs @@ -3,7 +3,7 @@ //! Port of `login_dialog.c` / `login_dialog.h` from the C project. //! //! Presents a modal GTK dialog with notebook tabs for each login method: -//! Local Key, Seed Phrase, Read-only, NIP-46, and n_signer. Also offers a +//! Local Key, Seed Phrase, Read-only, NIP-46, and signer. Also offers a //! "No Login" option to browse without a Nostr identity. use gtk::prelude::*; @@ -53,7 +53,7 @@ struct LoginCtx { // NIP-46 screen nip46_entry: gtk::Entry, - // n_signer screen + // signer screen transport_combo: gtk::ComboBoxText, device_label: gtk::Label, device_entry: gtk::Entry, @@ -77,7 +77,7 @@ impl LoginCtx { 1 => "seed", 2 => "readonly", 3 => "nip46", - 4 => "nsigner", + 4 => "signer", _ => "local", } } @@ -323,14 +323,14 @@ fn create_nip46_screen(ctx: &Rc>) -> gtk::Box { box_ } -fn create_nsigner_screen(ctx: &Rc>) -> gtk::Box { +fn create_signer_screen(ctx: &Rc>) -> gtk::Box { let box_ = gtk::Box::new(gtk::Orientation::Vertical, 8); box_.set_margin_top(12); box_.set_margin_bottom(12); box_.set_margin_start(12); box_.set_margin_end(12); - let label = gtk::Label::new(Some("Connect to n_signer hardware signer:")); + let label = gtk::Label::new(Some("Connect to remote signer:")); label.set_halign(gtk::Align::Start); box_.pack_start(&label, false, false, 0); @@ -374,7 +374,8 @@ fn create_nsigner_screen(ctx: &Rc>) -> gtk::Box { service_box.pack_start(&service_label, false, false, 0); let service_entry = gtk::Entry::new(); - service_entry.set_text("qubes.NsignerRpc"); + service_entry.set_text("qubes.SignerRpc"); + service_entry.set_placeholder_text(Some("qubes.SignerRpc")); service_entry.set_width_chars(30); service_box.pack_start(&service_entry, false, false, 0); @@ -410,7 +411,7 @@ fn create_nsigner_screen(ctx: &Rc>) -> gtk::Box { }); let hint = gtk::Label::new(Some( - "n_signer is a foreground, RAM-only hardware signer. Your private key never leaves the device.", + "signer is a foreground, RAM-only hardware signer. Your private key never leaves the device.", )); hint.set_sensitive(false); hint.set_halign(gtk::Align::Start); @@ -436,7 +437,7 @@ fn create_nsigner_screen(ctx: &Rc>) -> gtk::Box { } 1 => { device_label_clone.set_text("Socket Name:"); - device_entry_clone.set_placeholder_text(Some("nsigner")); + device_entry_clone.set_placeholder_text(Some("signer")); if device_entry_clone.text() == "nostr_signer" { device_entry_clone.set_text(""); } @@ -658,8 +659,16 @@ fn on_login_clicked(ctx: &Rc>) { ctx_borrow.done = true; ctx_borrow.dialog.response(gtk::ResponseType::Accept); } - "nsigner" => { + "signer" => { let device = ctx_borrow.device_entry.text().to_string(); + let service = { + let s = ctx_borrow.service_entry.text().to_string(); + if s.is_empty() { + "qubes.SignerRpc".to_string() + } else { + s + } + }; let role = { let r = ctx_borrow.role_entry.text().to_string(); if r.is_empty() { @@ -679,10 +688,36 @@ fn on_login_clicked(ctx: &Rc>) { // Build the role path: m/44'/1237'/N'/0/0 let role_path = format!("m/44'/1237'/{}'/0/0", nostr_index); + let transport_name = match transport_idx { + 0 => "serial", + 1 => "unix", + 2 => "tcp", + _ => "qrexec", + }; + + eprintln!( + "[login] signer: transport={} device={} service={} role={} path={}", + transport_name, device, service, role, role_path + ); + let signer: Option> = match transport_idx { + 0 => { + // USB CDC-ACM serial device (e.g. /dev/ttyACM0) + let s = nostr_signer::signer::SignerSigner::from_transport( + Box::new(nostr_signer::signer::SerialTransport::new( + &device, 115200, 15000, + )), + &role, + ); + s.set_role_path(&role_path); + Some(Arc::new(s)) + } 1 => { - let s = nostr_signer::nsigner::NsignerSigner::from_transport( - Box::new(nostr_signer::nsigner::UnixTransport::new(&device, 15000)), + // Unix socket — filesystem path (/...) or abstract name + let s = nostr_signer::signer::SignerSigner::from_transport( + Box::new(nostr_signer::signer::UnixTransport::new( + &device, 15000, + )), &role, ); s.set_role_path(&role_path); @@ -690,38 +725,44 @@ fn on_login_clicked(ctx: &Rc>) { } 2 => { // Parse host:port - if let Some((host, port)) = device.rsplit_once(':') { - if let Ok(port) = port.parse::() { - let s = nostr_signer::nsigner::NsignerSigner::from_transport( - Box::new(nostr_signer::nsigner::TcpTransport::new( + match device.rsplit_once(':') { + Some((host, port)) if port.parse::().is_ok() => { + let port = port.parse::().unwrap(); + let s = nostr_signer::signer::SignerSigner::from_transport( + Box::new(nostr_signer::signer::TcpTransport::new( host, port, 15000, )), &role, ); s.set_role_path(&role_path); Some(Arc::new(s)) - } else { - None } - } else { - None + _ => { + eprintln!("[login] signer: invalid host:port '{}'", device); + ctx_borrow.set_status("Enter a valid host:port (e.g. 127.0.0.1:7777)."); + return; + } } } _ => { - // USB Serial and Other Qube (qrexec) are not supported by - // the Rust nsigner crate yet. - ctx_borrow.set_status( - "This transport is not supported in this build. Use UNIX Socket or TCP.", + // Other Qube — Qubes qrexec: qrexec-client-vm + let s = nostr_signer::signer::SignerSigner::from_transport( + Box::new(nostr_signer::signer::QrexecTransport::new( + &device, &service, 15000, + )), + &role, ); - return; + s.set_role_path(&role_path); + Some(Arc::new(s)) } }; let signer = match signer { Some(s) => s, None => { + eprintln!("[login] signer: failed to build transport"); ctx_borrow.set_status( - "Failed to connect to n_signer. Check the device/path/qube.", + "Failed to connect to signer. Check the device/path/qube.", ); return; } @@ -733,31 +774,32 @@ fn on_login_clicked(ctx: &Rc>) { let desc = signer .last_error() .unwrap_or_else(|| format!("{}", e)); + eprintln!( + "[login] signer: get_public_key failed: {} ({:?})", + desc, e + ); ctx_borrow.set_status(&format!( - "n_signer error: {} (code {:?}). Try a different key index.", + "signer error: {} (code {:?}). Try a different key index.", desc, e )); return; } }; - let transport_name = match transport_idx { - 1 => "unix", - 2 => "tcp", - _ => "unknown", - }; + eprintln!("[login] signer: signed in with pubkey {}", pubkey_hex); let identity = KeyStoreIdentity { - method: KeyStoreMethod::Nsigner, + method: KeyStoreMethod::Signer, pubkey_hex: pubkey_hex.clone(), - nsigner_transport: transport_name.to_string(), - nsigner_device: device, - nsigner_index: nostr_index, - nsigner_role: role, + signer_transport: transport_name.to_string(), + signer_device: device, + signer_service: service, + signer_index: nostr_index, + signer_role: role, ..Default::default() }; ctx_borrow.result = Some(LoginResult { - method: KeyStoreMethod::Nsigner, + method: KeyStoreMethod::Signer, signer: Some(signer), pubkey_hex, identity, @@ -850,7 +892,7 @@ pub fn login_dialog_run(parent: >k::Window) -> Option { ("Seed Phrase", create_seed_screen), ("Read-only", create_readonly_screen), ("NIP-46", create_nip46_screen), - ("n_signer", create_nsigner_screen), + ("signer", create_signer_screen), ]; for (label, create_fn) in tabs { let screen = create_fn(&ctx); diff --git a/src/main.rs b/src/main.rs index 2738bbc..828d0c9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -98,7 +98,18 @@ fn do_login(parent: >k::Window) -> bool { if switch_to_user_db(&pubkey_hex) != 0 { return false; } + println!( + "[login] signed in via {} as {}{}", + method.as_str(), + pubkey_hex, + if readonly { " (read-only)" } else { "" } + ); app_set_signer(login_result.signer, &pubkey_hex, &privkey_hex, method, readonly); + + // Set the tab-bar avatar from the kind 0 profile. If the profile + // hasn't been fetched yet, this shows the default icon; the + // relay fetch thread refreshes it when the kind 0 event lands. + tab_manager::tab_manager_set_avatar(Some(&pubkey_hex)); let _ = key_store::key_store_save_profile_identity(&pubkey_hex, method); settings::settings_load(); shortcuts::shortcuts_load(); @@ -225,6 +236,53 @@ fn do_cli_login(args: &cli::CliArgs) -> bool { std::thread::spawn(move || { relay_fetch::relay_fetch_thread(pk); }); true } + key_store::KeyStoreMethod::Signer => { + // Remote signer via qrexec by default: --signer [QUBE] [IDX]. + // The qube defaults to nostr_signer, service qubes.SignerRpc, + // role nostr_range — matching the login dialog defaults. + let device = args.signer_device.clone().unwrap_or_else(|| "nostr_signer".to_string()); + let index = args.signer_index.unwrap_or(0); + let service = "qubes.SignerRpc"; + let role = "nostr_range"; + let role_path = format!("m/44'/1237'/{}'/0/0", index); + + eprintln!( + "[login] signer: transport=qrexec device={} service={} role={} path={}", + device, service, role, role_path + ); + + let s = nostr_signer::signer::SignerSigner::from_transport( + Box::new(nostr_signer::signer::QrexecTransport::new( + &device, service, 15000, + )), + role, + ); + s.set_role_path(&role_path); + + let pubkey_hex = match s.get_public_key() { + Ok(pk) => pk.to_hex(), + Err(e) => { + let desc = s.last_error().unwrap_or_else(|| format!("{}", e)); + eprintln!("[login] signer: get_public_key failed: {} ({:?})", desc, e); + return false; + } + }; + eprintln!("[login] signer: signed in with pubkey {}", pubkey_hex); + + let signer: Option> = Some(Arc::new(s)); + if switch_to_user_db(&pubkey_hex) != 0 { return false; } + app_set_signer(signer, &pubkey_hex, "", key_store::KeyStoreMethod::Signer, false); + let _ = key_store::key_store_save_profile_identity(&pubkey_hex, key_store::KeyStoreMethod::Signer); + settings::settings_load(); + shortcuts::shortcuts_load(); + + // Set the tab-bar avatar from the kind 0 profile. + tab_manager::tab_manager_set_avatar(Some(&pubkey_hex)); + + let pk = pubkey_hex.clone(); + std::thread::spawn(move || { relay_fetch::relay_fetch_thread(pk); }); + true + } _ => { eprintln!("[login] CLI login not supported for this method"); false @@ -510,6 +568,9 @@ fn main() { notebook.set_show_border(false); paned.add2(¬ebook); + // New-tab button (right) + user avatar (left) as notebook action widgets. + tab_manager::setup_notebook_action_widgets(¬ebook); + tab_manager::tab_manager_init(); let ctx = build_context_for_current_user(); tab_manager::tab_manager_set_main_window(&window, ¬ebook); diff --git a/src/menu.rs b/src/menu.rs index 758115c..3e7e5a4 100644 --- a/src/menu.rs +++ b/src/menu.rs @@ -136,7 +136,17 @@ fn do_login(parent: >k::Window) -> bool { return false; } + println!( + "[login] signed in via {} as {}{}", + method.as_str(), + pubkey_hex, + if readonly { " (read-only)" } else { "" } + ); app_set_signer(login_result.signer, &pubkey_hex, &privkey_hex, method, readonly); + + // Refresh the tab-bar avatar (the kind 0 profile may now be in + // the per-user database). + crate::tab_manager::tab_manager_set_avatar(Some(&pubkey_hex)); let _ = key_store::key_store_save_profile_identity(&pubkey_hex, method); crate::settings::settings_load(); crate::shortcuts::shortcuts_load(); diff --git a/src/nostr_scheme.rs b/src/nostr_scheme.rs index c668178..b10ae72 100644 --- a/src/nostr_scheme.rs +++ b/src/nostr_scheme.rs @@ -1,43 +1,545 @@ //! WebKitGTK nostr:// URI scheme handler //! //! Port of `nostr_scheme.c` / `nostr_scheme.h` from the C project. +//! Full-featured asynchronous relay-backed handler for nostr:// URIs. use webkit2gtk::*; use gio::MemoryInputStream; +use glib::Bytes; +use std::thread; use crate::nostr_url; +use crate::settings; +use crate::db; + +const MAX_FETCH_RELAYS: usize = 8; +const FETCH_TIMEOUT_SECONDS: u64 = 8; +const FETCH_EVENT_LIMIT: usize = 20; +const NIP11_MAX_BYTES: usize = 1024 * 1024; + +/// Job state for async nostr:// handling +struct SchemeJob { + entity: String, + bootstrap_relays: String, + user_pubkey: Option, + decoded: nostr_url::NostrDecodedEntity, + entity_relays: Vec, + user_relays: Vec, + default_relays: Vec, + attempted_relays: Vec, + events: Vec, + errors: Vec, + nip11: Option, + timed_out: bool, + response_json: Option, +} + +impl SchemeJob { + fn new(entity: String, decoded: nostr_url::NostrDecodedEntity) -> Self { + let settings = settings::settings_get(); + let user_pubkey = { + let pk = crate::menu::app_get_pubkey_hex(); + if pk.is_empty() { None } else { Some(pk) } + }; + + SchemeJob { + entity, + bootstrap_relays: settings.bootstrap_relays.clone(), + user_pubkey, + decoded, + entity_relays: Vec::new(), + user_relays: Vec::new(), + default_relays: Vec::new(), + attempted_relays: Vec::new(), + events: Vec::new(), + errors: Vec::new(), + nip11: None, + timed_out: false, + response_json: None, + } + } +} + +fn valid_relay_url(url: &str) -> bool { + url.starts_with("wss://") || url.starts_with("ws://") +} + +fn add_unique_relay(relays: &mut Vec, url: &str) { + if !valid_relay_url(url) { + return; + } + let trimmed = url.trim().trim_end_matches('/'); + if !trimmed.is_empty() && !relays.iter().any(|r| r == trimmed) { + relays.push(trimmed.to_string()); + } +} + +fn parse_bootstrap_relays(job: &mut SchemeJob) { + for line in job.bootstrap_relays.split(&['\r', '\n'][..]) { + let trimmed = line.trim(); + if !trimmed.is_empty() { + add_unique_relay(&mut job.default_relays, trimmed); + } + } +} + +fn parse_user_relays(job: &mut SchemeJob) { + let pubkey = match &job.user_pubkey { + Some(pk) if !pk.is_empty() => pk, + _ => return, + }; + + // Query kind 10002 (NIP-65 relay list) from local database + if let Ok(events) = db::db_query_events(&[10002], Some(&[pubkey.clone()]), None, None, Some(1)) { + if let Some(event) = events.first() { + if let Some(tags) = event.get("tags").and_then(|t| t.as_array()) { + for tag in tags { + if let Some(tag_arr) = tag.as_array() { + if tag_arr.len() >= 2 { + if let (Some(name), Some(url)) = (tag_arr[0].as_str(), tag_arr[1].as_str()) { + if name == "r" { + // NIP-65: no marker means both; "read" is readable; "write" is not + let marker = tag_arr.get(2).and_then(|m| m.as_str()); + if marker.is_none() || marker == Some("read") { + add_unique_relay(&mut job.user_relays, url); + } + } + } + } + } + } + } + } + } +} + +fn fallback_relays(job: &SchemeJob) -> &Vec { + if !job.user_relays.is_empty() { + &job.user_relays + } else { + &job.default_relays + } +} + +fn record_attempted(job: &mut SchemeJob, relays: &[String]) { + for relay in relays { + if job.attempted_relays.len() >= MAX_FETCH_RELAYS { + break; + } + add_unique_relay(&mut job.attempted_relays, relay); + } +} + +fn build_filter(job: &SchemeJob) -> Option { + use nostr_core::types::Filter; + + match job.decoded.entity_type { + nostr_url::NostrEntityType::Npub | nostr_url::NostrEntityType::Nprofile => { + let pubkey = job.decoded.pubkey.as_ref()?; + let pubkey_hex = hex::encode(pubkey); + Some( + Filter::new() + .authors(vec![pubkey_hex]) + .kinds(vec![0]) + .limit(1) + ) + } + nostr_url::NostrEntityType::Note | nostr_url::NostrEntityType::Nevent => { + let event_id = job.decoded.event_id.as_ref()?; + let id_hex = hex::encode(event_id); + Some( + Filter::new() + .ids(vec![id_hex]) + .limit(1) + ) + } + nostr_url::NostrEntityType::Naddr => { + let pubkey = job.decoded.pubkey.as_ref()?; + let kind = job.decoded.kind?; + let _identifier = job.decoded.identifier.as_ref()?; + let pubkey_hex = hex::encode(pubkey); + // Note: The Filter struct doesn't have a d_tags field, so we need to + // construct the filter manually with serde_json for the #d tag. + // For now, we'll use authors + kinds and filter by d-tag in post-processing. + Some( + Filter::new() + .authors(vec![pubkey_hex]) + .kinds(vec![kind as u64]) + .limit(20) // Fetch more to filter by d-tag + ) + } + _ => None, + } +} + +fn query_relay_stage(job: &mut SchemeJob, relays: &[String]) -> usize { + if relays.is_empty() || job.attempted_relays.len() >= MAX_FETCH_RELAYS { + return 0; + } + + let remaining = MAX_FETCH_RELAYS - job.attempted_relays.len(); + let urls: Vec = relays + .iter() + .filter(|r| !job.attempted_relays.contains(r)) + .take(remaining) + .cloned() + .collect(); + + if urls.is_empty() { + return 0; + } + + record_attempted(job, &urls); + + let filter = match build_filter(job) { + Some(f) => f, + None => return 0, + }; + + // Use tokio runtime for async relay operations + let rt = match tokio::runtime::Runtime::new() { + Ok(rt) => rt, + Err(e) => { + job.errors.push(serde_json::json!({ + "stage": "relay_query", + "message": format!("Failed to create tokio runtime: {}", e) + })); + return 0; + } + }; + + let result = rt.block_on(async { + use nostr_relay::pool::{RelayPool, ReconnectConfig}; + use std::sync::Arc; + + let pool = Arc::new(RelayPool::new(Some(ReconnectConfig::default()))); + + // Add relays + for url in &urls { + let _ = pool.add_relay(url.as_str()).await; + } + + // Connect with timeout + pool.connect_all_with_timeout(FETCH_TIMEOUT_SECONDS * 1000).await; + + let connected = pool.connected_relay_urls().await; + if connected.is_empty() { + return Err("No relays connected".to_string()); + } + + // Spawn event loop + let pool_for_loop = pool.clone(); + let event_loop = tokio::spawn(async move { + pool_for_loop.run(100).await; + }); + + // Query + let events_result = pool + .query_sync(&connected, filter, FETCH_TIMEOUT_SECONDS * 1000) + .await; + + event_loop.abort(); + pool.disconnect_all().await; + + events_result.map_err(|e| format!("Query failed: {:?}", e)) + }); + + match result { + Ok(event_list) => { + let mut added = 0; + for event in event_list.iter().take(FETCH_EVENT_LIMIT) { + // For naddr, filter by d-tag identifier + if job.decoded.entity_type == nostr_url::NostrEntityType::Naddr { + if let Some(identifier) = &job.decoded.identifier { + let has_matching_d_tag = event.tags.iter().any(|tag| { + tag.0.len() >= 2 && tag.0[0] == "d" && tag.0[1] == *identifier + }); + if !has_matching_d_tag { + continue; + } + } + } + + let tags_json = serde_json::to_string(&event.tags).unwrap_or_default(); + let event_json = serde_json::json!({ + "id": event.id.as_ref().map(|id| id.to_hex()).unwrap_or_default(), + "pubkey": event.pubkey.to_hex(), + "kind": event.kind.as_u64(), + "created_at": event.created_at, + "content": event.content, + "tags": serde_json::from_str::(&tags_json).unwrap_or(serde_json::Value::Array(vec![])), + "sig": event.sig.as_ref().map(|s| s.to_hex()), + }); + job.events.push(event_json); + added += 1; + } + added + } + Err(e) => { + job.errors.push(serde_json::json!({ + "stage": if relays == job.entity_relays { "entity_hints" } else { "fallback" }, + "message": e + })); + 0 + } + } +} + +fn fetch_nip11(job: &mut SchemeJob) { + let relay_url = match &job.decoded.relay_url { + Some(url) => url, + None => return, + }; + + let http_url = if relay_url.starts_with("wss://") { + format!("https://{}", &relay_url[6..]) + } else if relay_url.starts_with("ws://") { + format!("http://{}", &relay_url[5..]) + } else { + return; + }; + + // Use reqwest blocking client for NIP-11 fetch + let client = match reqwest::blocking::Client::builder() + .timeout(std::time::Duration::from_secs(FETCH_TIMEOUT_SECONDS)) + .connect_timeout(std::time::Duration::from_secs(4)) + .build() + { + Ok(c) => c, + Err(e) => { + job.errors.push(serde_json::json!({ + "stage": "nip11", + "relay": relay_url, + "message": format!("Failed to create HTTP client: {}", e) + })); + return; + } + }; + + let response = client + .get(&http_url) + .header("Accept", "application/nostr+json") + .header("User-Agent", "sovereign_browser/nostr-uri") + .send(); + + match response { + Ok(resp) => { + let status = resp.status().as_u16(); + if status >= 200 && status < 300 { + // Read body with size limit + match resp.bytes() { + Ok(bytes) => { + if bytes.len() > NIP11_MAX_BYTES { + job.errors.push(serde_json::json!({ + "stage": "nip11", + "relay": relay_url, + "http_status": status, + "message": "NIP-11 response exceeded size limit" + })); + job.timed_out = true; + } else if let Ok(json) = serde_json::from_slice::(&bytes) { + job.nip11 = Some(json); + } else { + job.errors.push(serde_json::json!({ + "stage": "nip11", + "relay": relay_url, + "http_status": status, + "message": "NIP-11 response was not valid JSON" + })); + } + } + Err(e) => { + job.errors.push(serde_json::json!({ + "stage": "nip11", + "relay": relay_url, + "http_status": status, + "message": format!("Failed to read response body: {}", e) + })); + } + } + } else { + job.errors.push(serde_json::json!({ + "stage": "nip11", + "relay": relay_url, + "http_status": status, + "message": format!("HTTP error: {}", status) + })); + } + } + Err(e) => { + let is_timeout = e.is_timeout(); + job.errors.push(serde_json::json!({ + "stage": "nip11", + "relay": relay_url, + "message": format!("Request failed: {}", e) + })); + if is_timeout { + job.timed_out = true; + } + } + } +} + +fn build_response(job: &mut SchemeJob) { + let event_count = job.events.len(); + + let response = if event_count == 1 { + job.events.remove(0) + } else if event_count > 1 { + serde_json::Value::Array(job.events.drain(..).collect()) + } else if job.decoded.entity_type == nostr_url::NostrEntityType::Nrelay && job.nip11.is_some() { + job.nip11.take().unwrap() + } else { + serde_json::json!({ + "error": "No events found", + "entity": job.entity + }) + }; + + job.response_json = Some(response.to_string()); +} + +fn scheme_worker(mut job: SchemeJob) -> SchemeJob { + parse_bootstrap_relays(&mut job); + parse_user_relays(&mut job); + + // Add relay hints from decoded entity + for hint in &job.decoded.relay_hints { + add_unique_relay(&mut job.entity_relays, hint); + } + + if job.decoded.entity_type == nostr_url::NostrEntityType::Nrelay { + fetch_nip11(&mut job); + } else { + let mut found = 0; + if !job.entity_relays.is_empty() { + let entity_relays = job.entity_relays.clone(); + found = query_relay_stage(&mut job, &entity_relays); + } + if found == 0 { + let fallback = fallback_relays(&job).clone(); + query_relay_stage(&mut job, &fallback); + } + if job.attempted_relays.is_empty() { + job.errors.push(serde_json::json!({ + "stage": "relay_selection", + "message": "No usable relay URLs available" + })); + } + } + + build_response(&mut job); + job +} + +fn respond_json(request: &URISchemeRequest, json: &str) { + let bytes = Bytes::from_owned(json.as_bytes().to_vec()); + let stream = MemoryInputStream::from_bytes(&bytes); + request.finish(&stream, json.len() as i64, Some("application/json")); +} + +fn respond_error(request: &URISchemeRequest, entity: Option<&str>, message: &str) { + let mut root = serde_json::json!({ + "error": message + }); + if let Some(e) = entity { + root["entity"] = serde_json::Value::String(e.to_string()); + } + respond_json(request, &root.to_string()); +} + +fn respond_helper_apps(request: &URISchemeRequest, config: &str) { + let mut apps = Vec::new(); + for item in config.split(',') { + let parts: Vec<&str> = item.splitn(2, '|').collect(); + if parts.len() == 2 && !parts[0].is_empty() && !parts[1].is_empty() { + apps.push(serde_json::json!({ + "name": parts[0], + "url_template": parts[1] + })); + } + } + respond_json(request, &serde_json::Value::Array(apps).to_string()); +} + +fn extract_entity(uri: &str) -> Option { + let start = nostr_url::nostr_url_entity(uri)?; + let start = start.trim_start_matches('/'); + let end = start.find(&['/', '?', '#'][..]).unwrap_or(start.len()); + if end == 0 { + None + } else { + Some(start[..end].to_string()) + } +} + +fn handle_nostr_scheme(request: &URISchemeRequest) { + let uri = request.uri().unwrap_or_default(); + let entity = extract_entity(&uri); + + // Check for helper-apps endpoint + let path = entity.as_ref().and_then(|_e| { + uri.strip_prefix("nostr://") + .and_then(|rest| rest.find('/').map(|i| &rest[i..])) + }); + + let is_helper_apps = entity.as_deref() == Some("helper-apps") + || path.map(|p| p.starts_with("/helper-apps") && + (p.len() == 12 || p.chars().nth(12).map(|c| c == '?' || c == '#').unwrap_or(true))) + .unwrap_or(false); + + if is_helper_apps { + let _settings = settings::settings_get(); + // Note: The C version uses settings->nostr_helper_apps, but the Rust settings + // doesn't have this field yet. Using empty string for now. + respond_helper_apps(request, ""); + return; + } + + // Decode the entity + let entity_str = match &entity { + Some(e) => e, + None => { + respond_error(request, None, "Invalid or unsupported Nostr entity"); + return; + } + }; + + let decoded = match nostr_url::nostr_url_decode(entity_str) { + Ok(d) => d, + Err(e) => { + respond_error(request, Some(entity_str), &format!("{:?}", e)); + return; + } + }; + + // Create job and run in thread + let job = SchemeJob::new(entity_str.clone(), decoded); + + // Use glib's MainContext channel to send the result back to the main thread + let (tx, rx) = glib::MainContext::channel(glib::Priority::default()); + + thread::spawn(move || { + let completed_job = scheme_worker(job); + let _ = tx.send(completed_job); + }); + + // Attach the receiver to the main context to handle the response + let request_clone = request.clone(); + rx.attach(None, move |completed_job| { + let json = completed_job.response_json.unwrap_or_else(|| { + serde_json::json!({ + "error": "Internal error", + "entity": completed_job.entity + }).to_string() + }); + respond_json(&request_clone, &json); + glib::ControlFlow::Break + }); +} pub fn nostr_scheme_register(ctx: &WebContext) { ctx.register_uri_scheme("nostr", |request| { handle_nostr_scheme(request); }); } - -fn handle_nostr_scheme(request: &URISchemeRequest) { - let uri = request.uri().unwrap_or_default(); - let entity_str = nostr_url::nostr_url_entity(&uri).unwrap_or(&uri); - - match nostr_url::nostr_url_decode(entity_str) { - Ok(entity) => { - let html = format!( - r#"Nostr Entity -

Nostr Entity

Type: {}

{}{}{}{}{}"#, - entity.entity_type, - entity.pubkey.map(|pk| format!("

Pubkey: {}

", hex::encode(pk))).unwrap_or_default(), - entity.event_id.map(|eid| format!("

Event ID: {}

", hex::encode(eid))).unwrap_or_default(), - entity.kind.map(|k| format!("

Kind: {}

", k)).unwrap_or_default(), - entity.identifier.as_ref().map(|id| format!("

Identifier: {}

", id)).unwrap_or_default(), - entity.relay_url.as_ref().map(|url| format!("

Relay: {}

", url)).unwrap_or_default(), - ); - let body = html.into_bytes(); - let stream = MemoryInputStream::from_bytes(&glib::Bytes::from_owned(body.clone())); - request.finish(&stream, body.len() as i64, Some("text/html")); - } - Err(_) => { - let html = format!("

Nostr URI

Raw: {}

", entity_str); - let body = html.into_bytes(); - let stream = MemoryInputStream::from_bytes(&glib::Bytes::from_owned(body.clone())); - request.finish(&stream, body.len() as i64, Some("text/html")); - } - } -} diff --git a/src/nostr_url.rs b/src/nostr_url.rs index 610c881..af24124 100644 --- a/src/nostr_url.rs +++ b/src/nostr_url.rs @@ -144,9 +144,66 @@ pub fn nostr_url_normalize(input: &str) -> String { } pub fn nostr_url_entity(input: &str) -> Option<&str> { - if let Some(stripped) = input.strip_prefix("nostr:") { Some(stripped) } else { Some(input) } + if let Some(stripped) = input.strip_prefix("nostr://") { + Some(stripped) + } else if let Some(stripped) = input.strip_prefix("nostr:") { + Some(stripped) + } else { + Some(input) + } } pub fn nostr_hex_encode32(data: &[u8; 32]) -> String { hex::encode(data) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_nostr_url_detect() { + assert_eq!(nostr_url_detect("npub1abc"), NostrEntityType::Npub); + assert_eq!(nostr_url_detect("nsec1abc"), NostrEntityType::Nsec); + assert_eq!(nostr_url_detect("note1abc"), NostrEntityType::Note); + assert_eq!(nostr_url_detect("nevent1abc"), NostrEntityType::Nevent); + assert_eq!(nostr_url_detect("naddr1abc"), NostrEntityType::Naddr); + assert_eq!(nostr_url_detect("nprofile1abc"), NostrEntityType::Nprofile); + assert_eq!(nostr_url_detect("nrelay1abc"), NostrEntityType::Nrelay); + assert_eq!(nostr_url_detect("unknown"), NostrEntityType::None); + } + + #[test] + fn test_nostr_url_entity() { + assert_eq!(nostr_url_entity("nostr:npub1abc"), Some("npub1abc")); + assert_eq!(nostr_url_entity("nostr://npub1abc"), Some("npub1abc")); + assert_eq!(nostr_url_entity("npub1abc"), Some("npub1abc")); + } + + #[test] + fn test_nostr_hex_encode32() { + let data = [0u8; 32]; + assert_eq!(nostr_hex_encode32(&data), "0000000000000000000000000000000000000000000000000000000000000000"); + + let data = [0xffu8; 32]; + assert_eq!(nostr_hex_encode32(&data), "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"); + } + + #[test] + fn test_decode_npub() { + // Test with a known valid npub + // npub1sg6plzptd64u62a878hep2kev88swjh3tw00gjsfl8f237lmu63q0uf63m + // corresponds to pubkey: 82341f2f8f1b1e1e6c0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e + let result = nostr_url_decode("npub1sg6plzptd64u62a878hep2kev88swjh3tw00gjsfl8f237lmu63q0uf63m"); + assert!(result.is_ok()); + let entity = result.unwrap(); + assert_eq!(entity.entity_type, NostrEntityType::Npub); + assert!(entity.pubkey.is_some()); + } + + #[test] + fn test_decode_invalid() { + let result = nostr_url_decode("invalid"); + assert!(result.is_err()); + } +} diff --git a/src/relay_fetch.rs b/src/relay_fetch.rs index bfc1c5f..7c81d7f 100644 --- a/src/relay_fetch.rs +++ b/src/relay_fetch.rs @@ -69,26 +69,46 @@ pub fn relay_fetch_bootstrap( let rt = Runtime::new()?; let result = rt.block_on(async { use nostr_relay::pool::RelayPool; + use std::sync::Arc; - let pool = RelayPool::new(Some(ReconnectConfig::default())); + let pool = Arc::new(RelayPool::new(Some(ReconnectConfig::default()))); // Add relays for url in filtered.iter().take(MAX_RELAYS) { let _ = pool.add_relay(url.as_str()).await; } - // Connect to relays - pool.connect_all().await; + // Connect to relays (each attempt bounded by a timeout) + pool.connect_all_with_timeout(10_000).await; + + // Only query relays that actually connected — a FullSet subscription + // waits for EOSE from every relay it is given, so including a relay + // that failed to connect would stall the query until its timeout. + let connected = pool.connected_relay_urls().await; + if connected.is_empty() { + eprintln!("[relay] No relays connected — skipping query"); + return Ok(0); + } + println!("[relay] {} of {} relay(s) connected", connected.len(), filtered.len()); + + // Spawn the pool event loop — it reads incoming WebSocket messages + // and dispatches EVENT/EOSE to subscriptions. Without it, query_sync + // never receives anything and always times out. The task is cancelled + // automatically when this runtime is dropped at the end of the fetch. + let pool_for_loop = pool.clone(); + let event_loop = tokio::spawn(async move { + pool_for_loop.run(100).await; + }); // Query with timeout - let events_result = tokio::time::timeout( - std::time::Duration::from_millis(RELAY_TIMEOUT_MS), - pool.query_sync(&filtered, filter, RELAY_TIMEOUT_MS), - ) - .await; + let events_result = pool + .query_sync(&connected, filter, RELAY_TIMEOUT_MS) + .await; + + event_loop.abort(); match events_result { - Ok(Ok(event_list)) => { + Ok(event_list) => { let count = event_list.len(); println!("[relay] Fetched {} events", count); @@ -111,8 +131,8 @@ pub fn relay_fetch_bootstrap( pool.disconnect_all().await; Ok(count as i32) } - _ => { - eprintln!("[relay] Query timed out or failed"); + Err(e) => { + eprintln!("[relay] Query failed: {:?}", e); pool.disconnect_all().await; Ok(0) } @@ -131,4 +151,12 @@ pub fn relay_fetch_thread(pubkey_hex: String) { Ok(count) => println!("[relay] Bootstrap fetch complete: {} events stored", count), Err(e) => eprintln!("[relay] Bootstrap fetch error: {}", e), } + + // Refresh the user's avatar now that the kind 0 profile may be available + // in the database. This must run on the main thread because it touches + // GTK widgets. + let pubkey_for_avatar = pubkey_hex.clone(); + glib::idle_add_once(move || { + crate::tab_manager::tab_manager_set_avatar(Some(&pubkey_for_avatar)); + }); } diff --git a/src/search.rs b/src/search.rs index 4a76ca8..ad14245 100644 --- a/src/search.rs +++ b/src/search.rs @@ -26,6 +26,7 @@ pub fn search_is_url(input: &str) -> bool { || input.starts_with("about:") || input.starts_with("sovereign://") || input.starts_with("nostr://") + || input.starts_with("nostr:") // NIP-21 standard prefix || input.starts_with("fips://") || input.starts_with("tor://") || input.contains('.') @@ -39,7 +40,8 @@ pub fn normalize_url(input: &str) -> String { return trimmed.to_string(); } // If it already has a scheme, return as-is. - if trimmed.contains("://") || trimmed.starts_with("about:") { + // Note: nostr: (NIP-21) is a valid scheme without // + if trimmed.contains("://") || trimmed.starts_with("about:") || trimmed.starts_with("nostr:") { return trimmed.to_string(); } // Otherwise treat as a bare domain and prepend https:// diff --git a/src/tab_manager.rs b/src/tab_manager.rs index b96c211..0df0618 100644 --- a/src/tab_manager.rs +++ b/src/tab_manager.rs @@ -539,6 +539,34 @@ pub fn tab_manager_new_tab(notebook: >k::Notebook, ctx: &WebContext, url: Opti if let Some(uri) = req.uri() { let uri_str = uri.to_string(); + // ── NIP-21 nostr: → nostr:// normalization ───────── + // NIP-21 links use nostr:entity rather than nostr://entity. + // Normalize either form so WebKit consistently invokes our + // registered handler. Mirrors tab_manager.c:1089. + if uri_str.starts_with("nostr:") && !uri_str.starts_with("nostr://") { + let entity_type = crate::nostr_url::nostr_url_detect(&uri_str); + if entity_type == crate::nostr_url::NostrEntityType::Nsec { + // Block private keys + decision.ignore(); + let wv = wv_for_onion.clone(); + glib::idle_add_local_once(move || { + let html = "Private key blocked\ +

Navigation blocked

Nostr private keys cannot be opened or navigated to.

"; + wv.load_html(html, Some("nostr://blocked-private-key")); + }); + return true; + } + + // Normalize nostr:entity to nostr://entity + let normalized = format!("nostr://{}", &uri_str[6..]); + decision.ignore(); + let wv = wv_for_onion.clone(); + glib::idle_add_local_once(move || { + wv.load_uri(&normalized); + }); + return true; + } + // ── .onion HTTP(S) → tor:// rewriting ────────────── // Detect .onion hosts in http:// or https:// URLs and // rewrite to tor:// so the request goes through Tor's @@ -920,20 +948,9 @@ pub fn tab_manager_new_window(url: Option<&str>, ctx: &WebContext) { notebook.set_show_tabs(true); window.add(¬ebook); - // Add a new-tab button as a notebook action widget. - let new_btn = gtk::Button::new(); - new_btn.set_relief(gtk::ReliefStyle::None); - new_btn.set_image(Some(>k::Image::from_icon_name( - Some("tab-new-symbolic"), - gtk::IconSize::Button, - ))); - new_btn.set_tooltip_text(Some("New tab (Ctrl+T)")); - let notebook_clone = notebook.clone(); - let ctx_clone = ctx.clone(); - new_btn.connect_clicked(move |_| { - tab_manager_new_tab(¬ebook_clone, &ctx_clone, None); - }); - notebook.set_action_widget(&new_btn, gtk::PackType::End); + // New-tab button (right) + avatar (left) as notebook action widgets. + // The avatar image is tracked so it updates when the picture arrives. + setup_notebook_action_widgets(¬ebook); // Create the first tab. tab_manager_new_tab(¬ebook, ctx, url); @@ -1526,6 +1543,307 @@ pub fn tab_manager_close_all() { } } +// ── User avatar (far left of tab bar) ────────────────────────────────────── +// +// Port of the C implementation (tab_manager.c:3306). Shows the user's Nostr +// profile picture as a rounded avatar on the far left of the tab bar, +// matching the size/shape of the hamburger menu button in the toolbar below. +// Falls back to a default avatar icon if no picture is available or the +// download fails. + +/// All avatar GtkImage widgets across all windows. When the avatar picture +/// is downloaded, every image in this list is updated so auxiliary windows +/// show the same avatar as the main window. Stored in ThreadGuards (GTK +/// widgets are not Send). +static G_AVATAR_IMAGES: Lazy>>> = + Lazy::new(|| Mutex::new(Vec::new())); + +/// Avatar button size — fixed square, matches the hamburger button. +const AVATAR_SIZE: i32 = 28; +/// Corner radius matching the button's CSS border-radius. +const AVATAR_BORDER_RADIUS: f64 = 4.0; + +/// Track an avatar image so it gets updated when the picture arrives. +fn track_avatar_image(image: >k::Image) { + G_AVATAR_IMAGES + .lock() + .unwrap() + .push(glib::thread_guard::ThreadGuard::new(image.clone())); +} + +/// Update every tracked avatar image with the given pixbuf. +fn set_all_avatar_pixbufs(pixbuf: &gdk_pixbuf::Pixbuf) { + let images = G_AVATAR_IMAGES.lock().unwrap(); + for guard in images.iter() { + let img = guard.get_ref(); + img.set_from_pixbuf(Some(pixbuf)); + } +} + +/// Reset every tracked avatar image to the default icon. +fn set_all_avatar_icons(icon_name: &str) { + let images = G_AVATAR_IMAGES.lock().unwrap(); + for guard in images.iter() { + let img = guard.get_ref(); + img.set_from_icon_name(Some(icon_name), gtk::IconSize::Button); + } +} + +/// Scale a pixbuf to fill the given size, center-cropping to preserve aspect +/// ratio (like CSS object-fit: cover), then round the corners to match the +/// button's border-radius. Port of the C `make_fitted_pixbuf`. +fn make_fitted_pixbuf(src: &gdk_pixbuf::Pixbuf, size: i32) -> Option { + let w = src.width(); + let h = src.height(); + if w <= 0 || h <= 0 { + return None; + } + + // Scale so the smaller dimension fills `size`, then center-crop the + // larger dimension. This is object-fit: cover. + let scale = size as f64 / (w.min(h)) as f64; + let mut scaled_w = (w as f64 * scale + 0.5) as i32; + let mut scaled_h = (h as f64 * scale + 0.5) as i32; + if scaled_w < size { + scaled_w = size; + } + if scaled_h < size { + scaled_h = size; + } + + let scaled = src.scale_simple(scaled_w, scaled_h, gdk_pixbuf::InterpType::Bilinear)?; + + // Center-crop to size×size. + let xoff = (scaled_w - size) / 2; + let yoff = (scaled_h - size) / 2; + let cropped = gdk_pixbuf::Pixbuf::new( + gdk_pixbuf::Colorspace::Rgb, + true, + 8, + size, + size, + )?; + // copy_area(src_x, src_y, width, height, dest, dest_x, dest_y) + scaled.copy_area(xoff, yoff, size, size, &cropped, 0, 0); + + // Round the corners using a cairo rounded-rectangle clip, matching the + // button's border-radius. + let mut surface = cairo::ImageSurface::create(cairo::Format::ARgb32, size, size).ok()?; + { + let cr = cairo::Context::new(&surface).ok()?; + + let r = AVATAR_BORDER_RADIUS; + let s = size as f64; + cr.new_path(); + cr.arc(s - r, r, r, -std::f64::consts::FRAC_PI_2, 0.0); // top-right + cr.arc(s - r, s - r, r, 0.0, std::f64::consts::FRAC_PI_2); // bottom-right + cr.arc(r, s - r, r, std::f64::consts::FRAC_PI_2, std::f64::consts::PI); // bottom-left + cr.arc(r, r, r, std::f64::consts::PI, 1.5 * std::f64::consts::PI); // top-left + cr.close_path(); + cr.clip(); + + // Paint the cropped pixbuf onto the clipped surface. + use gdk::prelude::GdkContextExt; + cr.set_source_pixbuf(&cropped, 0.0, 0.0); + let _ = cr.paint(); + } + // Drop the context before reading the surface data — surface.data() + // requires exclusive access and fails with NonExclusive while the + // context still references the surface. + surface.flush(); + + // Convert the surface back to a pixbuf: copy the ARGB32 data out, then + // byte-swap to RGBA and build the pixbuf from the raw bytes. + let stride = surface.stride() as usize; + let mut rgba: Vec = Vec::with_capacity((size as usize) * (size as usize) * 4); + { + let data = surface.data().ok()?; + for y in 0..size as usize { + for x in 0..size as usize { + let src_idx = y * stride + x * 4; + let b = data[src_idx]; + let g = data[src_idx + 1]; + let r_ch = data[src_idx + 2]; + let a = data[src_idx + 3]; + rgba.extend_from_slice(&[r_ch, g, b, a]); + } + } + } + + gdk_pixbuf::Pixbuf::from_bytes( + &glib::Bytes::from_owned(rgba), + gdk_pixbuf::Colorspace::Rgb, + true, + 8, + size, + size, + size * 4, + ) + .into() +} + +/// Set the avatar from the user's pubkey. Queries the kind 0 profile from +/// SQLite and starts a background download of the picture. Called after +/// login and after the relay bootstrap fetch completes. +pub fn tab_manager_set_avatar(pubkey_hex: Option<&str>) { + let _pubkey_hex = match pubkey_hex { + Some(p) if !p.is_empty() => p, + _ => { + println!("[avatar] no pubkey — resetting to default icon"); + set_all_avatar_icons("avatar-default-symbolic"); + return; + } + }; + + // Query the kind 0 profile from SQLite. + let events = crate::db::db_query_events(&[0], None, None, None, Some(1)) + .unwrap_or_default(); + let kind0 = match events.first() { + Some(e) => e, + None => { + println!("[avatar] no kind 0 event in DB — default icon"); + set_all_avatar_icons("avatar-default-symbolic"); + return; + } + }; + + // Extract the picture URL from the kind 0 content JSON. + let picture = kind0 + .get("content") + .and_then(|c| c.as_str()) + .and_then(|c| serde_json::from_str::(c).ok()) + .and_then(|meta| { + meta.get("picture") + .and_then(|p| p.as_str()) + .filter(|p| !p.is_empty()) + .map(|p| p.to_string()) + }); + + let picture = match picture { + Some(p) => p, + None => { + println!("[avatar] kind 0 has no picture — default icon"); + set_all_avatar_icons("avatar-default-symbolic"); + return; + } + }; + println!("[avatar] fetching picture: {}", picture); + + // Start a background thread to download the picture bytes. Pixbuf + // creation and processing happen on the main thread via the idle + // callback (gdk_pixbuf types are not Send). + std::thread::spawn(move || { + let bytes: Option> = if picture.starts_with("file://") { + std::fs::read(&picture[7..]).ok() + } else if picture.starts_with("http://") || picture.starts_with("https://") { + match reqwest::blocking::get(&picture) { + Ok(resp) => match resp.bytes() { + Ok(b) => Some(b.to_vec()), + Err(e) => { + eprintln!("[avatar] download read failed: {}", e); + None + } + }, + Err(e) => { + eprintln!("[avatar] download failed: {}", e); + None + } + } + } else { + eprintln!("[avatar] unsupported picture URL scheme: {}", picture); + None + }; + + let bytes = match bytes { + Some(b) if !b.is_empty() => b, + _ => { + eprintln!("[avatar] no picture bytes"); + return; + } + }; + println!("[avatar] downloaded {} bytes", bytes.len()); + + // Decode, fit, and set the avatar on the main thread. + glib::idle_add_once(move || { + let loader = gdk_pixbuf::PixbufLoader::new(); + if loader.write(&bytes).is_err() { + eprintln!("[avatar] pixbuf decode failed"); + return; + } + let _ = loader.close(); + match loader.pixbuf() { + None => eprintln!("[avatar] decoded pixbuf is None"), + Some(pb) => match make_fitted_pixbuf(&pb, AVATAR_SIZE) { + None => eprintln!("[avatar] make_fitted_pixbuf returned None"), + Some(fitted) => { + println!("[avatar] setting pixbuf on {} tracked image(s)", { + G_AVATAR_IMAGES.lock().unwrap().len() + }); + set_all_avatar_pixbufs(&fitted); + } + }, + } + }); + }); +} + +/// Add the new-tab button (right end) and avatar button (left end) as +/// notebook action widgets. Port of the C `setup_notebook_action_widgets`. +/// Used by the main window's notebook and each auxiliary window's notebook. +pub fn setup_notebook_action_widgets(notebook: >k::Notebook) { + // New-tab button at the end of the tab strip. + let new_btn = gtk::Button::new(); + new_btn.set_relief(gtk::ReliefStyle::None); + new_btn.set_image(Some(>k::Image::from_icon_name( + Some("tab-new-symbolic"), + gtk::IconSize::Button, + ))); + new_btn.set_tooltip_text(Some("New tab (Ctrl+T)")); + let notebook_clone = notebook.clone(); + new_btn.connect_clicked(move |_| { + if let Some(wv) = tab_manager_get_active_webview(¬ebook_clone) { + if let Some(ctx) = wv.web_context() { + tab_manager_new_tab(¬ebook_clone, &ctx, None); + } + } + }); + // Explicit show — the C code calls gtk_widget_show_all() on the button + // before attaching it; action widgets are not reliably shown by the + // window's show_all(). + new_btn.show_all(); + notebook.set_action_widget(&new_btn, gtk::PackType::End); + + // User avatar at the start (far left) of the tab strip. Fixed 28×28 + // square matching the hamburger button, with the alignment-critical + // properties from the C implementation: valign CENTER, margin_start 4, + // RELIEF_NORMAL, and the CSS rules in tab_manager_apply_theme that zero + // the padding so the size request is honored exactly. + let avatar_img = gtk::Image::from_icon_name( + Some("avatar-default-symbolic"), + gtk::IconSize::Button, + ); + track_avatar_image(&avatar_img); + + let avatar_btn = gtk::Button::new(); + avatar_btn.set_relief(gtk::ReliefStyle::Normal); + avatar_btn.set_image(Some(&avatar_img)); + avatar_btn.set_tooltip_text(Some("Your profile")); + avatar_btn.set_valign(gtk::Align::Center); + avatar_btn.set_margin_start(4); + avatar_btn.set_widget_name("avatar-btn"); + avatar_btn.set_size_request(AVATAR_SIZE, AVATAR_SIZE); + avatar_btn.connect_clicked(|_| { + // Always open sovereign://profile in a new tab so the user's + // current page is preserved. + tab_manager_open_internal("sovereign://profile"); + }); + // Explicit show — same as the C implementation. + avatar_btn.show_all(); + notebook.set_action_widget(&avatar_btn, gtk::PackType::Start); + + println!("[tab-manager] Notebook action widgets attached (avatar + new-tab)"); +} + /// Apply the app theme CSS (red accent, adapts to theme_dark). pub fn tab_manager_apply_theme() { let s = settings::settings_get(); @@ -1534,12 +1852,16 @@ pub fn tab_manager_apply_theme() { "entry:focus { border-color: #ff0000; }\n\ notebook tab:checked { border-bottom: 2px solid #ff0000; }\n\ button:hover { border-color: #ff0000; }\n\ - button:active { background: #ff0000; color: #ffffff; }\n" + button:active { background: #ff0000; color: #ffffff; }\n\ + #avatar-btn, #hamburger-btn { padding: 0px; min-width: 28px; min-height: 28px; border-radius: 4px; }\n\ + #avatar-btn image, #hamburger-btn image { padding: 0px; margin: 0px; }\n" } else { "entry:focus { border-color: #ff0000; }\n\ notebook tab:checked { border-bottom: 2px solid #ff0000; }\n\ button:hover { border-color: #ff0000; }\n\ - button:active { background: #ff0000; color: #ffffff; }\n" + button:active { background: #ff0000; color: #ffffff; }\n\ + #avatar-btn, #hamburger-btn { padding: 0px; min-width: 28px; min-height: 28px; border-radius: 4px; }\n\ + #avatar-btn image, #hamburger-btn image { padding: 0px; margin: 0px; }\n" }; let _ = provider.load_from_data(css.as_bytes()); if let Some(screen) = gdk::Screen::default() { diff --git a/src/version.rs b/src/version.rs index 4aff79f..ae1f4ae 100644 --- a/src/version.rs +++ b/src/version.rs @@ -1,7 +1,7 @@ //! Version information for sovereign_browser /// The current version of sovereign_browser (with leading 'v'). -pub const VERSION: &str = "v0.0.3"; +pub const VERSION: &str = "v0.0.4"; /// Major version number. pub const VERSION_MAJOR: u32 = 0; @@ -10,4 +10,4 @@ pub const VERSION_MAJOR: u32 = 0; pub const VERSION_MINOR: u32 = 0; /// Patch version number. -pub const VERSION_PATCH: u32 = 3; +pub const VERSION_PATCH: u32 = 4;