1 Commits
18 changed files with 1614 additions and 136 deletions
Generated
+14 -10
View File
@@ -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",
+1 -1
View File
@@ -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"
+1 -1
View File
@@ -1 +1 @@
0.0.3
0.0.4
+131
View File
@@ -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<Value>` 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.
+134
View File
@@ -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<Vec<gtk::Image>>` (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<Pixbuf>`:
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(&notebook)`
### 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(&notebook)`
### 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
+8 -8
View File
@@ -14,9 +14,9 @@ pub struct CliArgs {
pub pubkey: Option<String>,
pub mnemonic: Option<String>,
pub bunker_url: Option<String>,
pub nsigner_transport: Option<String>,
pub nsigner_device: Option<String>,
pub nsigner_index: Option<i32>,
pub signer_transport: Option<String>,
pub signer_device: Option<String>,
pub signer_index: Option<i32>,
pub profile: Option<String>,
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");
+129 -1
View File
@@ -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<bool, Box<dyn std::error::Error>> {
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<dyn std::error::Error>> {
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<dyn std::error::Error>> {
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<dyn std::error::Error>> {
let path = db_path(pubkey_hex);
@@ -31,7 +155,7 @@ pub fn db_init(pubkey_hex: &str) -> Result<(), Box<dyn std::error::Error>> {
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<dyn std::error::Error>> {
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(
"
+73 -16
View File
@@ -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<Arc<dyn No
// NIP-46 remote signer — not yet implemented in rust_core_lib
None
}
KeyStoreMethod::Nsigner => {
// 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::<u16>().is_ok() => {
let port = port.parse::<u16>().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<dyn NostrSigner>
})
}
KeyStoreMethod::None => None,
}
+1 -1
View File
@@ -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
+80 -38
View File
@@ -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<RefCell<LoginCtx>>) -> gtk::Box {
box_
}
fn create_nsigner_screen(ctx: &Rc<RefCell<LoginCtx>>) -> gtk::Box {
fn create_signer_screen(ctx: &Rc<RefCell<LoginCtx>>) -> 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<RefCell<LoginCtx>>) -> 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<RefCell<LoginCtx>>) -> 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<RefCell<LoginCtx>>) -> 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<RefCell<LoginCtx>>) {
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<RefCell<LoginCtx>>) {
// 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<Arc<dyn NostrSigner>> = 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<RefCell<LoginCtx>>) {
}
2 => {
// Parse host:port
if let Some((host, port)) = device.rsplit_once(':') {
if let Ok(port) = port.parse::<u16>() {
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::<u16>().is_ok() => {
let port = port.parse::<u16>().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 <qube> <service>
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<RefCell<LoginCtx>>) {
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: &gtk::Window) -> Option<LoginResult> {
("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);
+61
View File
@@ -98,7 +98,18 @@ fn do_login(parent: &gtk::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<Arc<dyn NostrSigner>> = 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(&notebook);
// New-tab button (right) + user avatar (left) as notebook action widgets.
tab_manager::setup_notebook_action_widgets(&notebook);
tab_manager::tab_manager_init();
let ctx = build_context_for_current_user();
tab_manager::tab_manager_set_main_window(&window, &notebook);
+10
View File
@@ -136,7 +136,17 @@ fn do_login(parent: &gtk::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();
+531 -29
View File
@@ -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<String>,
decoded: nostr_url::NostrDecodedEntity,
entity_relays: Vec<String>,
user_relays: Vec<String>,
default_relays: Vec<String>,
attempted_relays: Vec<String>,
events: Vec<serde_json::Value>,
errors: Vec<serde_json::Value>,
nip11: Option<serde_json::Value>,
timed_out: bool,
response_json: Option<String>,
}
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<String>, 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<String> {
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<nostr_core::types::Filter> {
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<String> = 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::<serde_json::Value>(&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::<serde_json::Value>(&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<String> {
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#"<!DOCTYPE html><html><head><title>Nostr Entity</title></head><body>
<h1>Nostr Entity</h1><p>Type: {}</p>{}{}{}{}{}</body></html>"#,
entity.entity_type,
entity.pubkey.map(|pk| format!("<p>Pubkey: <code>{}</code></p>", hex::encode(pk))).unwrap_or_default(),
entity.event_id.map(|eid| format!("<p>Event ID: <code>{}</code></p>", hex::encode(eid))).unwrap_or_default(),
entity.kind.map(|k| format!("<p>Kind: {}</p>", k)).unwrap_or_default(),
entity.identifier.as_ref().map(|id| format!("<p>Identifier: <code>{}</code></p>", id)).unwrap_or_default(),
entity.relay_url.as_ref().map(|url| format!("<p>Relay: <code>{}</code></p>", 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!("<html><body><h1>Nostr URI</h1><p>Raw: <code>{}</code></p></body></html>", 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"));
}
}
}
+58 -1
View File
@@ -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());
}
}
+39 -11
View File
@@ -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));
});
}
+3 -1
View File
@@ -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://
+338 -16
View File
@@ -539,6 +539,34 @@ pub fn tab_manager_new_tab(notebook: &gtk::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 = "<!doctype html><meta charset=\"utf-8\"><title>Private key blocked</title>\
<h1>Navigation blocked</h1><p>Nostr private keys cannot be opened or navigated to.</p>";
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(&notebook);
// 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(&gtk::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(&notebook_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(&notebook);
// Create the first tab.
tab_manager_new_tab(&notebook, 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<Mutex<Vec<glib::thread_guard::ThreadGuard<gtk::Image>>>> =
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: &gtk::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<gdk_pixbuf::Pixbuf> {
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<u8> = 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::<serde_json::Value>(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<Vec<u8>> = 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: &gtk::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(&gtk::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(&notebook_clone) {
if let Some(ctx) = wv.web_context() {
tab_manager_new_tab(&notebook_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() {
+2 -2
View File
@@ -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;