Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1454ecbbb3 |
Generated
+14
-10
@@ -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
@@ -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"
|
||||
|
||||
@@ -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.
|
||||
@@ -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(¬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
|
||||
+8
-8
@@ -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");
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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: >k::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
@@ -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<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(¬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);
|
||||
|
||||
+10
@@ -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();
|
||||
|
||||
+531
-29
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 = "<!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(¬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<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: >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<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: >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() {
|
||||
|
||||
+2
-2
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user