v0.0.4 - Implemented full nostr:// URI scheme handler with relay fetching, NIP-11 support, and NIP-21 nostr: URL normalization
This commit is contained in:
Generated
+14
-10
@@ -763,7 +763,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.52.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1957,7 +1957,7 @@ checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "nostr-core"
|
name = "nostr-core"
|
||||||
version = "0.0.2"
|
version = "0.0.3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes",
|
"aes",
|
||||||
"base64",
|
"base64",
|
||||||
@@ -1979,7 +1979,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "nostr-nips"
|
name = "nostr-nips"
|
||||||
version = "0.0.2"
|
version = "0.0.3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes",
|
"aes",
|
||||||
"block-modes",
|
"block-modes",
|
||||||
@@ -2001,7 +2001,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "nostr-relay"
|
name = "nostr-relay"
|
||||||
version = "0.0.2"
|
version = "0.0.3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"nostr-core",
|
"nostr-core",
|
||||||
@@ -2019,7 +2019,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "nostr-services"
|
name = "nostr-services"
|
||||||
version = "0.0.2"
|
version = "0.0.3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"nostr-core",
|
"nostr-core",
|
||||||
"nostr-relay",
|
"nostr-relay",
|
||||||
@@ -2034,11 +2034,12 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "nostr-signer"
|
name = "nostr-signer"
|
||||||
version = "0.0.2"
|
version = "0.0.3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes",
|
"aes",
|
||||||
"cbc",
|
"cbc",
|
||||||
"hex",
|
"hex",
|
||||||
|
"libc",
|
||||||
"nostr-core",
|
"nostr-core",
|
||||||
"rand 0.8.7",
|
"rand 0.8.7",
|
||||||
"serde",
|
"serde",
|
||||||
@@ -2748,7 +2749,7 @@ dependencies = [
|
|||||||
"errno",
|
"errno",
|
||||||
"libc",
|
"libc",
|
||||||
"linux-raw-sys",
|
"linux-raw-sys",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.52.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -3055,7 +3056,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "sovereign-browser"
|
name = "sovereign-browser"
|
||||||
version = "0.0.2"
|
version = "0.0.3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"base64",
|
"base64",
|
||||||
@@ -3220,10 +3221,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"fastrand",
|
"fastrand",
|
||||||
"getrandom 0.4.3",
|
"getrandom 0.3.4",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"rustix",
|
"rustix",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.52.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -3346,7 +3347,9 @@ checksum = "edc5f74e248dc973e0dbb7b74c7e0d6fcc301c694ff50049504004ef4d0cdcd9"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"log",
|
"log",
|
||||||
|
"native-tls",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
"tokio-native-tls",
|
||||||
"tungstenite",
|
"tungstenite",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -3503,6 +3506,7 @@ dependencies = [
|
|||||||
"http",
|
"http",
|
||||||
"httparse",
|
"httparse",
|
||||||
"log",
|
"log",
|
||||||
|
"native-tls",
|
||||||
"rand 0.8.7",
|
"rand 0.8.7",
|
||||||
"sha1",
|
"sha1",
|
||||||
"thiserror 1.0.69",
|
"thiserror 1.0.69",
|
||||||
|
|||||||
+1
-1
@@ -6,7 +6,7 @@ members = [
|
|||||||
|
|
||||||
[package]
|
[package]
|
||||||
name = "sovereign-browser"
|
name = "sovereign-browser"
|
||||||
version = "0.0.3"
|
version = "0.0.4"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
description = "A Linux x86 web browser built on WebKitGTK with Nostr identity"
|
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 pubkey: Option<String>,
|
||||||
pub mnemonic: Option<String>,
|
pub mnemonic: Option<String>,
|
||||||
pub bunker_url: Option<String>,
|
pub bunker_url: Option<String>,
|
||||||
pub nsigner_transport: Option<String>,
|
pub signer_transport: Option<String>,
|
||||||
pub nsigner_device: Option<String>,
|
pub signer_device: Option<String>,
|
||||||
pub nsigner_index: Option<i32>,
|
pub signer_index: Option<i32>,
|
||||||
pub profile: Option<String>,
|
pub profile: Option<String>,
|
||||||
pub no_agent: bool,
|
pub no_agent: bool,
|
||||||
pub no_session: bool,
|
pub no_session: bool,
|
||||||
@@ -63,15 +63,15 @@ pub fn cli_parse() -> CliArgs {
|
|||||||
cli.bunker_url = Some(args[i].clone());
|
cli.bunker_url = Some(args[i].clone());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
"--nsigner" => {
|
"--signer" => {
|
||||||
cli.login_method = Some(KeyStoreMethod::Nsigner);
|
cli.login_method = Some(KeyStoreMethod::Signer);
|
||||||
if i + 1 < args.len() && !args[i + 1].starts_with('-') {
|
if i + 1 < args.len() && !args[i + 1].starts_with('-') {
|
||||||
i += 1;
|
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('-') {
|
if i + 1 < args.len() && !args[i + 1].starts_with('-') {
|
||||||
i += 1;
|
i += 1;
|
||||||
cli.nsigner_index = Some(args[i].parse().unwrap_or(0));
|
cli.signer_index = Some(args[i].parse().unwrap_or(0));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
"--profile" => {
|
"--profile" => {
|
||||||
@@ -109,7 +109,7 @@ pub fn cli_print_help() {
|
|||||||
println!(" -p, --pubkey [KEY] Read-only mode with npub");
|
println!(" -p, --pubkey [KEY] Read-only mode with npub");
|
||||||
println!(" -s, --seed [PHRASE] Login with BIP-39 seed phrase");
|
println!(" -s, --seed [PHRASE] Login with BIP-39 seed phrase");
|
||||||
println!(" -b, --bunker [URL] Login with NIP-46 bunker URL");
|
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!(" --profile [PUBKEY] Use specific profile");
|
||||||
println!(" --no-agent Disable agent server");
|
println!(" --no-agent Disable agent server");
|
||||||
println!(" --no-session Disable session restore");
|
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")
|
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.
|
/// Initialize the database for a given profile.
|
||||||
pub fn db_init(pubkey_hex: &str) -> Result<(), Box<dyn std::error::Error>> {
|
pub fn db_init(pubkey_hex: &str) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
let path = db_path(pubkey_hex);
|
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)?;
|
std::fs::create_dir_all(parent)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
let conn = Connection::open_with_flags(
|
let mut conn = Connection::open_with_flags(
|
||||||
&path,
|
&path,
|
||||||
rusqlite::OpenFlags::SQLITE_OPEN_READ_WRITE
|
rusqlite::OpenFlags::SQLITE_OPEN_READ_WRITE
|
||||||
| rusqlite::OpenFlags::SQLITE_OPEN_CREATE
|
| 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;")?;
|
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
|
// Create tables
|
||||||
conn.execute_batch(
|
conn.execute_batch(
|
||||||
"
|
"
|
||||||
|
|||||||
+73
-16
@@ -21,7 +21,7 @@ pub enum KeyStoreMethod {
|
|||||||
Seed, // BIP-39 mnemonic — private key re-derived
|
Seed, // BIP-39 mnemonic — private key re-derived
|
||||||
Readonly, // npub only — no signing
|
Readonly, // npub only — no signing
|
||||||
Nip46, // bunker:// URL — remote signer session
|
Nip46, // bunker:// URL — remote signer session
|
||||||
Nsigner, // n_signer hardware — device path + index
|
Signer, // remote signer hardware — device path + index
|
||||||
}
|
}
|
||||||
|
|
||||||
impl KeyStoreMethod {
|
impl KeyStoreMethod {
|
||||||
@@ -32,7 +32,7 @@ impl KeyStoreMethod {
|
|||||||
KeyStoreMethod::Seed => "seed",
|
KeyStoreMethod::Seed => "seed",
|
||||||
KeyStoreMethod::Readonly => "readonly",
|
KeyStoreMethod::Readonly => "readonly",
|
||||||
KeyStoreMethod::Nip46 => "nip46",
|
KeyStoreMethod::Nip46 => "nip46",
|
||||||
KeyStoreMethod::Nsigner => "nsigner",
|
KeyStoreMethod::Signer => "signer",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,7 +42,7 @@ impl KeyStoreMethod {
|
|||||||
"seed" => KeyStoreMethod::Seed,
|
"seed" => KeyStoreMethod::Seed,
|
||||||
"readonly" => KeyStoreMethod::Readonly,
|
"readonly" => KeyStoreMethod::Readonly,
|
||||||
"nip46" => KeyStoreMethod::Nip46,
|
"nip46" => KeyStoreMethod::Nip46,
|
||||||
"nsigner" => KeyStoreMethod::Nsigner,
|
"signer" => KeyStoreMethod::Signer,
|
||||||
_ => KeyStoreMethod::None,
|
_ => KeyStoreMethod::None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -61,14 +61,16 @@ pub struct KeyStoreIdentity {
|
|||||||
pub mnemonic: String,
|
pub mnemonic: String,
|
||||||
/// NIP-46 bunker URL (nip46 method only).
|
/// NIP-46 bunker URL (nip46 method only).
|
||||||
pub bunker_url: String,
|
pub bunker_url: String,
|
||||||
/// n_signer transport (nsigner method only).
|
/// Signer transport (signer method only).
|
||||||
pub nsigner_transport: String,
|
pub signer_transport: String,
|
||||||
/// n_signer device path / socket name / host:port / qube.
|
/// Signer device path / socket name / host:port / qube.
|
||||||
pub nsigner_device: String,
|
pub signer_device: String,
|
||||||
|
/// qrexec service name (qrexec transport only).
|
||||||
|
pub signer_service: String,
|
||||||
/// NIP-06 index.
|
/// NIP-06 index.
|
||||||
pub nsigner_index: i32,
|
pub signer_index: i32,
|
||||||
/// Role name.
|
/// Role name.
|
||||||
pub nsigner_role: String,
|
pub signer_role: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for KeyStoreIdentity {
|
impl Default for KeyStoreIdentity {
|
||||||
@@ -79,10 +81,11 @@ impl Default for KeyStoreIdentity {
|
|||||||
privkey_hex: String::new(),
|
privkey_hex: String::new(),
|
||||||
mnemonic: String::new(),
|
mnemonic: String::new(),
|
||||||
bunker_url: String::new(),
|
bunker_url: String::new(),
|
||||||
nsigner_transport: String::new(),
|
signer_transport: String::new(),
|
||||||
nsigner_device: String::new(),
|
signer_device: String::new(),
|
||||||
nsigner_index: 0,
|
signer_service: String::new(),
|
||||||
nsigner_role: "nostr_range".to_string(),
|
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
|
// NIP-46 remote signer — not yet implemented in rust_core_lib
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
KeyStoreMethod::Nsigner => {
|
KeyStoreMethod::Signer => {
|
||||||
// n_signer hardware — not yet implemented in rust_core_lib
|
// Rebuild the remote signer from the recorded transport settings.
|
||||||
None
|
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,
|
KeyStoreMethod::None => None,
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -13,7 +13,7 @@
|
|||||||
//! - `nostr_core` — Core types, crypto, utilities
|
//! - `nostr_core` — Core types, crypto, utilities
|
||||||
//! - `nostr_relay` — Relay networking (WebSocket, HTTP, pool)
|
//! - `nostr_relay` — Relay networking (WebSocket, HTTP, pool)
|
||||||
//! - `nostr_nips` — NIP implementations
|
//! - `nostr_nips` — NIP implementations
|
||||||
//! - `nostr_signer` — Signer abstraction (local + nsigner)
|
//! - `nostr_signer` — Signer abstraction (local + signer)
|
||||||
//! - `nostr_services` — Blossom, Cashu, validator services
|
//! - `nostr_services` — Blossom, Cashu, validator services
|
||||||
//!
|
//!
|
||||||
//! Many functions are defined as part of the port but not yet wired into
|
//! 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.
|
//! Port of `login_dialog.c` / `login_dialog.h` from the C project.
|
||||||
//!
|
//!
|
||||||
//! Presents a modal GTK dialog with notebook tabs for each login method:
|
//! 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.
|
//! "No Login" option to browse without a Nostr identity.
|
||||||
|
|
||||||
use gtk::prelude::*;
|
use gtk::prelude::*;
|
||||||
@@ -53,7 +53,7 @@ struct LoginCtx {
|
|||||||
// NIP-46 screen
|
// NIP-46 screen
|
||||||
nip46_entry: gtk::Entry,
|
nip46_entry: gtk::Entry,
|
||||||
|
|
||||||
// n_signer screen
|
// signer screen
|
||||||
transport_combo: gtk::ComboBoxText,
|
transport_combo: gtk::ComboBoxText,
|
||||||
device_label: gtk::Label,
|
device_label: gtk::Label,
|
||||||
device_entry: gtk::Entry,
|
device_entry: gtk::Entry,
|
||||||
@@ -77,7 +77,7 @@ impl LoginCtx {
|
|||||||
1 => "seed",
|
1 => "seed",
|
||||||
2 => "readonly",
|
2 => "readonly",
|
||||||
3 => "nip46",
|
3 => "nip46",
|
||||||
4 => "nsigner",
|
4 => "signer",
|
||||||
_ => "local",
|
_ => "local",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -323,14 +323,14 @@ fn create_nip46_screen(ctx: &Rc<RefCell<LoginCtx>>) -> gtk::Box {
|
|||||||
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);
|
let box_ = gtk::Box::new(gtk::Orientation::Vertical, 8);
|
||||||
box_.set_margin_top(12);
|
box_.set_margin_top(12);
|
||||||
box_.set_margin_bottom(12);
|
box_.set_margin_bottom(12);
|
||||||
box_.set_margin_start(12);
|
box_.set_margin_start(12);
|
||||||
box_.set_margin_end(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);
|
label.set_halign(gtk::Align::Start);
|
||||||
box_.pack_start(&label, false, false, 0);
|
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);
|
service_box.pack_start(&service_label, false, false, 0);
|
||||||
|
|
||||||
let service_entry = gtk::Entry::new();
|
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_entry.set_width_chars(30);
|
||||||
service_box.pack_start(&service_entry, false, false, 0);
|
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(
|
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_sensitive(false);
|
||||||
hint.set_halign(gtk::Align::Start);
|
hint.set_halign(gtk::Align::Start);
|
||||||
@@ -436,7 +437,7 @@ fn create_nsigner_screen(ctx: &Rc<RefCell<LoginCtx>>) -> gtk::Box {
|
|||||||
}
|
}
|
||||||
1 => {
|
1 => {
|
||||||
device_label_clone.set_text("Socket Name:");
|
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" {
|
if device_entry_clone.text() == "nostr_signer" {
|
||||||
device_entry_clone.set_text("");
|
device_entry_clone.set_text("");
|
||||||
}
|
}
|
||||||
@@ -658,8 +659,16 @@ fn on_login_clicked(ctx: &Rc<RefCell<LoginCtx>>) {
|
|||||||
ctx_borrow.done = true;
|
ctx_borrow.done = true;
|
||||||
ctx_borrow.dialog.response(gtk::ResponseType::Accept);
|
ctx_borrow.dialog.response(gtk::ResponseType::Accept);
|
||||||
}
|
}
|
||||||
"nsigner" => {
|
"signer" => {
|
||||||
let device = ctx_borrow.device_entry.text().to_string();
|
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 role = {
|
||||||
let r = ctx_borrow.role_entry.text().to_string();
|
let r = ctx_borrow.role_entry.text().to_string();
|
||||||
if r.is_empty() {
|
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
|
// Build the role path: m/44'/1237'/N'/0/0
|
||||||
let role_path = format!("m/44'/1237'/{}'/0/0", nostr_index);
|
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 {
|
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 => {
|
1 => {
|
||||||
let s = nostr_signer::nsigner::NsignerSigner::from_transport(
|
// Unix socket — filesystem path (/...) or abstract name
|
||||||
Box::new(nostr_signer::nsigner::UnixTransport::new(&device, 15000)),
|
let s = nostr_signer::signer::SignerSigner::from_transport(
|
||||||
|
Box::new(nostr_signer::signer::UnixTransport::new(
|
||||||
|
&device, 15000,
|
||||||
|
)),
|
||||||
&role,
|
&role,
|
||||||
);
|
);
|
||||||
s.set_role_path(&role_path);
|
s.set_role_path(&role_path);
|
||||||
@@ -690,38 +725,44 @@ fn on_login_clicked(ctx: &Rc<RefCell<LoginCtx>>) {
|
|||||||
}
|
}
|
||||||
2 => {
|
2 => {
|
||||||
// Parse host:port
|
// Parse host:port
|
||||||
if let Some((host, port)) = device.rsplit_once(':') {
|
match device.rsplit_once(':') {
|
||||||
if let Ok(port) = port.parse::<u16>() {
|
Some((host, port)) if port.parse::<u16>().is_ok() => {
|
||||||
let s = nostr_signer::nsigner::NsignerSigner::from_transport(
|
let port = port.parse::<u16>().unwrap();
|
||||||
Box::new(nostr_signer::nsigner::TcpTransport::new(
|
let s = nostr_signer::signer::SignerSigner::from_transport(
|
||||||
|
Box::new(nostr_signer::signer::TcpTransport::new(
|
||||||
host, port, 15000,
|
host, port, 15000,
|
||||||
)),
|
)),
|
||||||
&role,
|
&role,
|
||||||
);
|
);
|
||||||
s.set_role_path(&role_path);
|
s.set_role_path(&role_path);
|
||||||
Some(Arc::new(s))
|
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
|
// Other Qube — Qubes qrexec: qrexec-client-vm <qube> <service>
|
||||||
// the Rust nsigner crate yet.
|
let s = nostr_signer::signer::SignerSigner::from_transport(
|
||||||
ctx_borrow.set_status(
|
Box::new(nostr_signer::signer::QrexecTransport::new(
|
||||||
"This transport is not supported in this build. Use UNIX Socket or TCP.",
|
&device, &service, 15000,
|
||||||
|
)),
|
||||||
|
&role,
|
||||||
);
|
);
|
||||||
return;
|
s.set_role_path(&role_path);
|
||||||
|
Some(Arc::new(s))
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let signer = match signer {
|
let signer = match signer {
|
||||||
Some(s) => s,
|
Some(s) => s,
|
||||||
None => {
|
None => {
|
||||||
|
eprintln!("[login] signer: failed to build transport");
|
||||||
ctx_borrow.set_status(
|
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;
|
return;
|
||||||
}
|
}
|
||||||
@@ -733,31 +774,32 @@ fn on_login_clicked(ctx: &Rc<RefCell<LoginCtx>>) {
|
|||||||
let desc = signer
|
let desc = signer
|
||||||
.last_error()
|
.last_error()
|
||||||
.unwrap_or_else(|| format!("{}", e));
|
.unwrap_or_else(|| format!("{}", e));
|
||||||
|
eprintln!(
|
||||||
|
"[login] signer: get_public_key failed: {} ({:?})",
|
||||||
|
desc, e
|
||||||
|
);
|
||||||
ctx_borrow.set_status(&format!(
|
ctx_borrow.set_status(&format!(
|
||||||
"n_signer error: {} (code {:?}). Try a different key index.",
|
"signer error: {} (code {:?}). Try a different key index.",
|
||||||
desc, e
|
desc, e
|
||||||
));
|
));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let transport_name = match transport_idx {
|
eprintln!("[login] signer: signed in with pubkey {}", pubkey_hex);
|
||||||
1 => "unix",
|
|
||||||
2 => "tcp",
|
|
||||||
_ => "unknown",
|
|
||||||
};
|
|
||||||
|
|
||||||
let identity = KeyStoreIdentity {
|
let identity = KeyStoreIdentity {
|
||||||
method: KeyStoreMethod::Nsigner,
|
method: KeyStoreMethod::Signer,
|
||||||
pubkey_hex: pubkey_hex.clone(),
|
pubkey_hex: pubkey_hex.clone(),
|
||||||
nsigner_transport: transport_name.to_string(),
|
signer_transport: transport_name.to_string(),
|
||||||
nsigner_device: device,
|
signer_device: device,
|
||||||
nsigner_index: nostr_index,
|
signer_service: service,
|
||||||
nsigner_role: role,
|
signer_index: nostr_index,
|
||||||
|
signer_role: role,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
ctx_borrow.result = Some(LoginResult {
|
ctx_borrow.result = Some(LoginResult {
|
||||||
method: KeyStoreMethod::Nsigner,
|
method: KeyStoreMethod::Signer,
|
||||||
signer: Some(signer),
|
signer: Some(signer),
|
||||||
pubkey_hex,
|
pubkey_hex,
|
||||||
identity,
|
identity,
|
||||||
@@ -850,7 +892,7 @@ pub fn login_dialog_run(parent: >k::Window) -> Option<LoginResult> {
|
|||||||
("Seed Phrase", create_seed_screen),
|
("Seed Phrase", create_seed_screen),
|
||||||
("Read-only", create_readonly_screen),
|
("Read-only", create_readonly_screen),
|
||||||
("NIP-46", create_nip46_screen),
|
("NIP-46", create_nip46_screen),
|
||||||
("n_signer", create_nsigner_screen),
|
("signer", create_signer_screen),
|
||||||
];
|
];
|
||||||
for (label, create_fn) in tabs {
|
for (label, create_fn) in tabs {
|
||||||
let screen = create_fn(&ctx);
|
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; }
|
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);
|
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);
|
let _ = key_store::key_store_save_profile_identity(&pubkey_hex, method);
|
||||||
settings::settings_load();
|
settings::settings_load();
|
||||||
shortcuts::shortcuts_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); });
|
std::thread::spawn(move || { relay_fetch::relay_fetch_thread(pk); });
|
||||||
true
|
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");
|
eprintln!("[login] CLI login not supported for this method");
|
||||||
false
|
false
|
||||||
@@ -510,6 +568,9 @@ fn main() {
|
|||||||
notebook.set_show_border(false);
|
notebook.set_show_border(false);
|
||||||
paned.add2(¬ebook);
|
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();
|
tab_manager::tab_manager_init();
|
||||||
let ctx = build_context_for_current_user();
|
let ctx = build_context_for_current_user();
|
||||||
tab_manager::tab_manager_set_main_window(&window, ¬ebook);
|
tab_manager::tab_manager_set_main_window(&window, ¬ebook);
|
||||||
|
|||||||
+10
@@ -136,7 +136,17 @@ fn do_login(parent: >k::Window) -> bool {
|
|||||||
return false;
|
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);
|
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);
|
let _ = key_store::key_store_save_profile_identity(&pubkey_hex, method);
|
||||||
crate::settings::settings_load();
|
crate::settings::settings_load();
|
||||||
crate::shortcuts::shortcuts_load();
|
crate::shortcuts::shortcuts_load();
|
||||||
|
|||||||
+531
-29
@@ -1,43 +1,545 @@
|
|||||||
//! WebKitGTK nostr:// URI scheme handler
|
//! WebKitGTK nostr:// URI scheme handler
|
||||||
//!
|
//!
|
||||||
//! Port of `nostr_scheme.c` / `nostr_scheme.h` from the C project.
|
//! Port of `nostr_scheme.c` / `nostr_scheme.h` from the C project.
|
||||||
|
//! Full-featured asynchronous relay-backed handler for nostr:// URIs.
|
||||||
|
|
||||||
use webkit2gtk::*;
|
use webkit2gtk::*;
|
||||||
use gio::MemoryInputStream;
|
use gio::MemoryInputStream;
|
||||||
|
use glib::Bytes;
|
||||||
|
use std::thread;
|
||||||
|
|
||||||
use crate::nostr_url;
|
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) {
|
pub fn nostr_scheme_register(ctx: &WebContext) {
|
||||||
ctx.register_uri_scheme("nostr", |request| {
|
ctx.register_uri_scheme("nostr", |request| {
|
||||||
handle_nostr_scheme(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> {
|
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 {
|
pub fn nostr_hex_encode32(data: &[u8; 32]) -> String {
|
||||||
hex::encode(data)
|
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 rt = Runtime::new()?;
|
||||||
let result = rt.block_on(async {
|
let result = rt.block_on(async {
|
||||||
use nostr_relay::pool::RelayPool;
|
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
|
// Add relays
|
||||||
for url in filtered.iter().take(MAX_RELAYS) {
|
for url in filtered.iter().take(MAX_RELAYS) {
|
||||||
let _ = pool.add_relay(url.as_str()).await;
|
let _ = pool.add_relay(url.as_str()).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Connect to relays
|
// Connect to relays (each attempt bounded by a timeout)
|
||||||
pool.connect_all().await;
|
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
|
// Query with timeout
|
||||||
let events_result = tokio::time::timeout(
|
let events_result = pool
|
||||||
std::time::Duration::from_millis(RELAY_TIMEOUT_MS),
|
.query_sync(&connected, filter, RELAY_TIMEOUT_MS)
|
||||||
pool.query_sync(&filtered, filter, RELAY_TIMEOUT_MS),
|
.await;
|
||||||
)
|
|
||||||
.await;
|
event_loop.abort();
|
||||||
|
|
||||||
match events_result {
|
match events_result {
|
||||||
Ok(Ok(event_list)) => {
|
Ok(event_list) => {
|
||||||
let count = event_list.len();
|
let count = event_list.len();
|
||||||
println!("[relay] Fetched {} events", count);
|
println!("[relay] Fetched {} events", count);
|
||||||
|
|
||||||
@@ -111,8 +131,8 @@ pub fn relay_fetch_bootstrap(
|
|||||||
pool.disconnect_all().await;
|
pool.disconnect_all().await;
|
||||||
Ok(count as i32)
|
Ok(count as i32)
|
||||||
}
|
}
|
||||||
_ => {
|
Err(e) => {
|
||||||
eprintln!("[relay] Query timed out or failed");
|
eprintln!("[relay] Query failed: {:?}", e);
|
||||||
pool.disconnect_all().await;
|
pool.disconnect_all().await;
|
||||||
Ok(0)
|
Ok(0)
|
||||||
}
|
}
|
||||||
@@ -131,4 +151,12 @@ pub fn relay_fetch_thread(pubkey_hex: String) {
|
|||||||
Ok(count) => println!("[relay] Bootstrap fetch complete: {} events stored", count),
|
Ok(count) => println!("[relay] Bootstrap fetch complete: {} events stored", count),
|
||||||
Err(e) => eprintln!("[relay] Bootstrap fetch error: {}", e),
|
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("about:")
|
||||||
|| input.starts_with("sovereign://")
|
|| input.starts_with("sovereign://")
|
||||||
|| input.starts_with("nostr://")
|
|| input.starts_with("nostr://")
|
||||||
|
|| input.starts_with("nostr:") // NIP-21 standard prefix
|
||||||
|| input.starts_with("fips://")
|
|| input.starts_with("fips://")
|
||||||
|| input.starts_with("tor://")
|
|| input.starts_with("tor://")
|
||||||
|| input.contains('.')
|
|| input.contains('.')
|
||||||
@@ -39,7 +40,8 @@ pub fn normalize_url(input: &str) -> String {
|
|||||||
return trimmed.to_string();
|
return trimmed.to_string();
|
||||||
}
|
}
|
||||||
// If it already has a scheme, return as-is.
|
// 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();
|
return trimmed.to_string();
|
||||||
}
|
}
|
||||||
// Otherwise treat as a bare domain and prepend https://
|
// 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() {
|
if let Some(uri) = req.uri() {
|
||||||
let uri_str = uri.to_string();
|
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 ──────────────
|
// ── .onion HTTP(S) → tor:// rewriting ──────────────
|
||||||
// Detect .onion hosts in http:// or https:// URLs and
|
// Detect .onion hosts in http:// or https:// URLs and
|
||||||
// rewrite to tor:// so the request goes through Tor's
|
// 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);
|
notebook.set_show_tabs(true);
|
||||||
window.add(¬ebook);
|
window.add(¬ebook);
|
||||||
|
|
||||||
// Add a new-tab button as a notebook action widget.
|
// New-tab button (right) + avatar (left) as notebook action widgets.
|
||||||
let new_btn = gtk::Button::new();
|
// The avatar image is tracked so it updates when the picture arrives.
|
||||||
new_btn.set_relief(gtk::ReliefStyle::None);
|
setup_notebook_action_widgets(¬ebook);
|
||||||
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);
|
|
||||||
|
|
||||||
// Create the first tab.
|
// Create the first tab.
|
||||||
tab_manager_new_tab(¬ebook, ctx, url);
|
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).
|
/// Apply the app theme CSS (red accent, adapts to theme_dark).
|
||||||
pub fn tab_manager_apply_theme() {
|
pub fn tab_manager_apply_theme() {
|
||||||
let s = settings::settings_get();
|
let s = settings::settings_get();
|
||||||
@@ -1534,12 +1852,16 @@ pub fn tab_manager_apply_theme() {
|
|||||||
"entry:focus { border-color: #ff0000; }\n\
|
"entry:focus { border-color: #ff0000; }\n\
|
||||||
notebook tab:checked { border-bottom: 2px solid #ff0000; }\n\
|
notebook tab:checked { border-bottom: 2px solid #ff0000; }\n\
|
||||||
button:hover { border-color: #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 {
|
} else {
|
||||||
"entry:focus { border-color: #ff0000; }\n\
|
"entry:focus { border-color: #ff0000; }\n\
|
||||||
notebook tab:checked { border-bottom: 2px solid #ff0000; }\n\
|
notebook tab:checked { border-bottom: 2px solid #ff0000; }\n\
|
||||||
button:hover { border-color: #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());
|
let _ = provider.load_from_data(css.as_bytes());
|
||||||
if let Some(screen) = gdk::Screen::default() {
|
if let Some(screen) = gdk::Screen::default() {
|
||||||
|
|||||||
+2
-2
@@ -1,7 +1,7 @@
|
|||||||
//! Version information for sovereign_browser
|
//! Version information for sovereign_browser
|
||||||
|
|
||||||
/// The current version of sovereign_browser (with leading 'v').
|
/// 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.
|
/// Major version number.
|
||||||
pub const VERSION_MAJOR: u32 = 0;
|
pub const VERSION_MAJOR: u32 = 0;
|
||||||
@@ -10,4 +10,4 @@ pub const VERSION_MAJOR: u32 = 0;
|
|||||||
pub const VERSION_MINOR: u32 = 0;
|
pub const VERSION_MINOR: u32 = 0;
|
||||||
|
|
||||||
/// Patch version number.
|
/// Patch version number.
|
||||||
pub const VERSION_PATCH: u32 = 3;
|
pub const VERSION_PATCH: u32 = 4;
|
||||||
|
|||||||
Reference in New Issue
Block a user