feat: add support for logging in via the web extension #41
41
Cargo.lock
generated
41
Cargo.lock
generated
@@ -493,6 +493,12 @@ version = "0.5.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c59bdb34bc650a32731b31bd8f0829cc15d24a708ee31559e0bb34f2bc320cba"
|
checksum = "c59bdb34bc650a32731b31bd8f0829cc15d24a708ee31559e0bb34f2bc320cba"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "atomic-destructor"
|
||||||
|
version = "0.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7d919cb60ba95c87ba42777e9e246c4e8d658057299b437b7512531ce0a09a23"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "atomic-waker"
|
name = "atomic-waker"
|
||||||
version = "1.1.2"
|
version = "1.1.2"
|
||||||
@@ -839,6 +845,21 @@ dependencies = [
|
|||||||
"cfg_aliases",
|
"cfg_aliases",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "browser-signer-proxy"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"atomic-destructor",
|
||||||
|
"event-listener",
|
||||||
|
"nostr",
|
||||||
|
"opaquerr",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"smol",
|
||||||
|
"tracing",
|
||||||
|
"uuid",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bstr"
|
name = "bstr"
|
||||||
version = "1.13.0"
|
version = "1.13.0"
|
||||||
@@ -4206,7 +4227,7 @@ checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8"
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "nostr"
|
name = "nostr"
|
||||||
version = "0.45.0-alpha.8"
|
version = "0.45.0-alpha.8"
|
||||||
source = "git+https://github.com/rust-nostr/nostr#83cb556fa59259eb30f3df63f153f42aab77519b"
|
source = "git+https://github.com/rust-nostr/nostr#d0a1d67d3c9e5cf9710807a6a414c155a5f47215"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes",
|
"aes",
|
||||||
"base64",
|
"base64",
|
||||||
@@ -4231,7 +4252,7 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "nostr-blossom"
|
name = "nostr-blossom"
|
||||||
version = "0.45.0-alpha.8"
|
version = "0.45.0-alpha.8"
|
||||||
source = "git+https://github.com/rust-nostr/nostr#83cb556fa59259eb30f3df63f153f42aab77519b"
|
source = "git+https://github.com/rust-nostr/nostr#d0a1d67d3c9e5cf9710807a6a414c155a5f47215"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64",
|
"base64",
|
||||||
"bitcoin_hashes",
|
"bitcoin_hashes",
|
||||||
@@ -4244,7 +4265,7 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "nostr-connect"
|
name = "nostr-connect"
|
||||||
version = "0.45.0-alpha.8"
|
version = "0.45.0-alpha.8"
|
||||||
source = "git+https://github.com/rust-nostr/nostr#83cb556fa59259eb30f3df63f153f42aab77519b"
|
source = "git+https://github.com/rust-nostr/nostr#d0a1d67d3c9e5cf9710807a6a414c155a5f47215"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-utility",
|
"async-utility",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
@@ -4258,7 +4279,7 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "nostr-database"
|
name = "nostr-database"
|
||||||
version = "0.45.0-alpha.8"
|
version = "0.45.0-alpha.8"
|
||||||
source = "git+https://github.com/rust-nostr/nostr#83cb556fa59259eb30f3df63f153f42aab77519b"
|
source = "git+https://github.com/rust-nostr/nostr#d0a1d67d3c9e5cf9710807a6a414c155a5f47215"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"btreecap",
|
"btreecap",
|
||||||
"flatbuffers",
|
"flatbuffers",
|
||||||
@@ -4269,7 +4290,7 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "nostr-gossip"
|
name = "nostr-gossip"
|
||||||
version = "0.45.0-alpha.8"
|
version = "0.45.0-alpha.8"
|
||||||
source = "git+https://github.com/rust-nostr/nostr#83cb556fa59259eb30f3df63f153f42aab77519b"
|
source = "git+https://github.com/rust-nostr/nostr#d0a1d67d3c9e5cf9710807a6a414c155a5f47215"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"nostr",
|
"nostr",
|
||||||
"opaquerr",
|
"opaquerr",
|
||||||
@@ -4278,7 +4299,7 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "nostr-gossip-memory"
|
name = "nostr-gossip-memory"
|
||||||
version = "0.45.0-alpha.8"
|
version = "0.45.0-alpha.8"
|
||||||
source = "git+https://github.com/rust-nostr/nostr#83cb556fa59259eb30f3df63f153f42aab77519b"
|
source = "git+https://github.com/rust-nostr/nostr#d0a1d67d3c9e5cf9710807a6a414c155a5f47215"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"indexmap",
|
"indexmap",
|
||||||
"lru",
|
"lru",
|
||||||
@@ -4290,7 +4311,7 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "nostr-lmdb"
|
name = "nostr-lmdb"
|
||||||
version = "0.45.0-alpha.8"
|
version = "0.45.0-alpha.8"
|
||||||
source = "git+https://github.com/rust-nostr/nostr#83cb556fa59259eb30f3df63f153f42aab77519b"
|
source = "git+https://github.com/rust-nostr/nostr#d0a1d67d3c9e5cf9710807a6a414c155a5f47215"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-utility",
|
"async-utility",
|
||||||
"flume 0.12.0",
|
"flume 0.12.0",
|
||||||
@@ -4304,7 +4325,7 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "nostr-memory"
|
name = "nostr-memory"
|
||||||
version = "0.45.0-alpha.8"
|
version = "0.45.0-alpha.8"
|
||||||
source = "git+https://github.com/rust-nostr/nostr#83cb556fa59259eb30f3df63f153f42aab77519b"
|
source = "git+https://github.com/rust-nostr/nostr#d0a1d67d3c9e5cf9710807a6a414c155a5f47215"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"btreecap",
|
"btreecap",
|
||||||
"nostr",
|
"nostr",
|
||||||
@@ -4315,7 +4336,7 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "nostr-sdk"
|
name = "nostr-sdk"
|
||||||
version = "0.45.0-alpha.8"
|
version = "0.45.0-alpha.8"
|
||||||
source = "git+https://github.com/rust-nostr/nostr#83cb556fa59259eb30f3df63f153f42aab77519b"
|
source = "git+https://github.com/rust-nostr/nostr#d0a1d67d3c9e5cf9710807a6a414c155a5f47215"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-utility",
|
"async-utility",
|
||||||
"async-wsocket",
|
"async-wsocket",
|
||||||
@@ -6682,6 +6703,7 @@ name = "state"
|
|||||||
version = "1.0.0-beta5"
|
version = "1.0.0-beta5"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
|
"browser-signer-proxy",
|
||||||
"common",
|
"common",
|
||||||
"flume 0.11.1",
|
"flume 0.11.1",
|
||||||
"futures",
|
"futures",
|
||||||
@@ -9003,6 +9025,7 @@ name = "workspace"
|
|||||||
version = "1.0.0-beta5"
|
version = "1.0.0-beta5"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
|
"browser-signer-proxy",
|
||||||
"chat",
|
"chat",
|
||||||
"chat_ui",
|
"chat_ui",
|
||||||
"common",
|
"common",
|
||||||
|
|||||||
19
crates/browser-signer-proxy/Cargo.toml
Normal file
19
crates/browser-signer-proxy/Cargo.toml
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
[package]
|
||||||
|
name = "browser-signer-proxy"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition.workspace = true
|
||||||
|
description = "Nostr browser signer (NIP-07) proxy using smol async runtime"
|
||||||
|
license = "MIT"
|
||||||
|
repository = "https://github.com/nostrdevkit/nostr"
|
||||||
|
publish = false
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
atomic-destructor = "0.2"
|
||||||
|
event-listener = "5"
|
||||||
|
nostr.workspace = true
|
||||||
|
opaquerr = "0.1"
|
||||||
|
serde.workspace = true
|
||||||
|
serde_json.workspace = true
|
||||||
|
smol.workspace = true
|
||||||
|
tracing = { version = "0.1", features = ["std"] }
|
||||||
|
uuid = { version = "1.23", features = ["serde", "v4"] }
|
||||||
55
crates/browser-signer-proxy/README.md
Normal file
55
crates/browser-signer-proxy/README.md
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
# browser-signer-proxy
|
||||||
|
|
||||||
|
Proxy to use Nostr Browser signer ([NIP-07](https://github.com/nostr-protocol/nips/blob/master/07.md)) in native applications.
|
||||||
|
|
||||||
|
This is a re-implementation of [`nostr-browser-signer-proxy`](https://github.com/nostrdevkit/nostr/tree/master/signer/nostr-browser-signer-proxy)
|
||||||
|
using the [`smol`](https://github.com/smol-rs/smol) async runtime instead of tokio.
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
This crate provides a local HTTP proxy that communicates with a NIP-07 browser extension
|
||||||
|
(e.g., Alby, nos2x) running in a browser tab. Native applications can use this proxy to
|
||||||
|
request public keys, sign events, and perform NIP-04/NIP-44 encryption/decryption through
|
||||||
|
the browser extension.
|
||||||
|
|
||||||
|
The HTTP server is implemented with a minimal, dependency-free approach using `smol::net::TcpListener`
|
||||||
|
and manual HTTP/1.1 parsing — avoiding heavy HTTP framework dependencies entirely.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use browser_signer_proxy::prelude::*;
|
||||||
|
|
||||||
|
async fn example() -> Result<(), Error> {
|
||||||
|
// Create the proxy with default options (localhost:7400)
|
||||||
|
let proxy = BrowserSignerProxy::new(BrowserSignerProxyOptions::default());
|
||||||
|
|
||||||
|
// Open the proxy URL in a browser
|
||||||
|
webbrowser::open(&proxy.url())?;
|
||||||
|
|
||||||
|
// Start the proxy server
|
||||||
|
proxy.start().await?;
|
||||||
|
|
||||||
|
// Use it as an async Nostr signer
|
||||||
|
let public_key = proxy.get_public_key_async().await?;
|
||||||
|
println!("Connected with public key: {public_key}");
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Differences from the tokio-based version
|
||||||
|
|
||||||
|
| Feature | tokio (original) | smol (this crate) |
|
||||||
|
|---|---|---|
|
||||||
|
| Async runtime | `tokio` | `smol` |
|
||||||
|
| HTTP server | `hyper` | `smol::net::TcpListener` + manual HTTP/1.1 |
|
||||||
|
| Mutex | `tokio::sync::Mutex` | `smol::lock::Mutex` |
|
||||||
|
| Shutdown signal | `tokio::sync::Notify` | `event_listener::Event` |
|
||||||
|
| Request-response channel | `tokio::sync::oneshot` | `smol::channel::bounded(1)` |
|
||||||
|
| Timeout | `tokio::time::timeout` | `smol::future::or` + `smol::Timer` |
|
||||||
|
| Task spawning | `tokio::spawn` | `smol::spawn` |
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
This project is distributed under the MIT software license.
|
||||||
189
crates/browser-signer-proxy/index.html
Normal file
189
crates/browser-signer-proxy/index.html
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Coop — Web Signer Proxy</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Nunito:wght@800;900&display=swap" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--brand: #F8FF37;
|
||||||
|
--ink: #111111;
|
||||||
|
--ink-soft: #333333;
|
||||||
|
--muted: #666666;
|
||||||
|
--paper: #FFFFFF;
|
||||||
|
--edge: rgba(17, 17, 17, 0.14);
|
||||||
|
--radius-sm: 1rem;
|
||||||
|
--radius-md: 1.5rem;
|
||||||
|
--radius-lg: 2.5rem;
|
||||||
|
--green: #2E8B57;
|
||||||
|
--red: #D32F2F;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html, body {
|
||||||
|
margin: 0;
|
||||||
|
min-height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||||
|
color: var(--ink);
|
||||||
|
background: var(--brand);
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: var(--paper);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: 2.5rem;
|
||||||
|
max-width: 440px;
|
||||||
|
width: 100%;
|
||||||
|
box-shadow: 0 8px 0 rgba(17, 17, 17, 0.12), 0 2px 20px rgba(17, 17, 17, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.6rem;
|
||||||
|
margin-bottom: 1.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo__mark {
|
||||||
|
width: 2.5rem;
|
||||||
|
height: 2.5rem;
|
||||||
|
background: var(--ink);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-family: "Nunito", system-ui, sans-serif;
|
||||||
|
font-weight: 900;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
color: var(--brand);
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo__text {
|
||||||
|
font-family: "Nunito", system-ui, sans-serif;
|
||||||
|
font-weight: 900;
|
||||||
|
font-size: 1.3rem;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heading {
|
||||||
|
font-family: "Nunito", system-ui, sans-serif;
|
||||||
|
font-weight: 900;
|
||||||
|
font-size: 1.6rem;
|
||||||
|
letter-spacing: -0.03em;
|
||||||
|
line-height: 1.15;
|
||||||
|
margin: 0 0 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
color: var(--ink-soft);
|
||||||
|
margin: 0 0 1.75rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 1rem 1.25rem;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
transition: background 300ms ease, color 300ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status--checking {
|
||||||
|
background: rgba(17, 17, 17, 0.05);
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status--connected {
|
||||||
|
background: rgba(46, 139, 87, 0.1);
|
||||||
|
color: var(--green);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status--error {
|
||||||
|
background: rgba(211, 47, 47, 0.08);
|
||||||
|
color: var(--red);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status__dot {
|
||||||
|
width: 0.7rem;
|
||||||
|
height: 0.7rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status--checking .status__dot {
|
||||||
|
background: var(--muted);
|
||||||
|
animation: pulse 1.2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status--connected .status__dot {
|
||||||
|
background: var(--green);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status--error .status__dot {
|
||||||
|
background: var(--red);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% { opacity: 0.4; transform: scale(0.85); }
|
||||||
|
50% { opacity: 1; transform: scale(1); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
padding-top: 1.25rem;
|
||||||
|
border-top: 1px solid var(--edge);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--muted);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint strong {
|
||||||
|
color: var(--ink-soft);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="card">
|
||||||
|
<div class="logo">
|
||||||
|
<div class="logo__mark">C</div>
|
||||||
|
<span class="logo__text">Coop</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 class="heading">Web Signer</h1>
|
||||||
|
<p class="subtitle">
|
||||||
|
This page connects the app to your Nostr Web Signer extension so you can sign in and use Coop securely.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div id="nip07-status" class="status status--checking">
|
||||||
|
<div class="status__dot"></div>
|
||||||
|
<span id="nip07-status-text">Checking extension…</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="hint">
|
||||||
|
<strong>Keep this tab open</strong> while using the app — it automatically handles sign-in requests in the background.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="proxy.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
156
crates/browser-signer-proxy/proxy.js
Normal file
156
crates/browser-signer-proxy/proxy.js
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
let isPolling = false;
|
||||||
|
|
||||||
|
async function pollForRequests() {
|
||||||
|
if (isPolling) return;
|
||||||
|
isPolling = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/pending');
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
console.log('Polled for requests, got:', data);
|
||||||
|
|
||||||
|
// Process any new requests
|
||||||
|
if (data.requests && data.requests.length > 0) {
|
||||||
|
console.log(`Processing ${data.requests.length} requests`);
|
||||||
|
for (const request of data.requests) {
|
||||||
|
await handleNip07Request(request);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Polling error:', error);
|
||||||
|
updateStatus('Error: ' + error.message, 'error');
|
||||||
|
}
|
||||||
|
|
||||||
|
isPolling = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleNip07Request(request) {
|
||||||
|
console.log('Handling request:', request);
|
||||||
|
|
||||||
|
try {
|
||||||
|
let result;
|
||||||
|
|
||||||
|
if (!window.nostr) {
|
||||||
|
throw new Error('NIP-07 extension not available');
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (request.method) {
|
||||||
|
case 'get_public_key':
|
||||||
|
console.log('Calling nostr.getPublicKey()');
|
||||||
|
result = await window.nostr.getPublicKey();
|
||||||
|
console.log('Got public key:', result);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'sign_event':
|
||||||
|
console.log('Calling nostr.signEvent() with:', request.params);
|
||||||
|
result = await window.nostr.signEvent(request.params);
|
||||||
|
console.log('Got signed event:', result);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'nip04_encrypt':
|
||||||
|
console.log('Calling nostr.nip04.encrypt()');
|
||||||
|
result = await window.nostr.nip04.encrypt(
|
||||||
|
request.params.public_key,
|
||||||
|
request.params.content
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'nip04_decrypt':
|
||||||
|
console.log('Calling nostr.nip04.decrypt()');
|
||||||
|
result = await window.nostr.nip04.decrypt(
|
||||||
|
request.params.public_key,
|
||||||
|
request.params.content
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'nip44_encrypt':
|
||||||
|
console.log('Calling nostr.nip44.encrypt()');
|
||||||
|
result = await window.nostr.nip44.encrypt(
|
||||||
|
request.params.public_key,
|
||||||
|
request.params.content
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'nip44_decrypt':
|
||||||
|
console.log('Calling nostr.nip44.decrypt()');
|
||||||
|
result = await window.nostr.nip44.decrypt(
|
||||||
|
request.params.public_key,
|
||||||
|
request.params.content
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
|
||||||
|
|
||||||
|
default:
|
||||||
|
throw new Error(`Unknown method: ${request.method}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send response back to server
|
||||||
|
const responsePayload = {
|
||||||
|
id: request.id,
|
||||||
|
result: result,
|
||||||
|
error: null
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log('Sending response:', responsePayload);
|
||||||
|
|
||||||
|
await fetch('/api/response', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(responsePayload)
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('Response sent successfully');
|
||||||
|
updateStatus('Request processed successfully', 'connected');
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error handling request:', error);
|
||||||
|
|
||||||
|
// Send error response back to server
|
||||||
|
const errorPayload = {
|
||||||
|
id: request.id,
|
||||||
|
result: null,
|
||||||
|
error: error.message
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log('Sending error response:', errorPayload);
|
||||||
|
|
||||||
|
await fetch('/api/response', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(errorPayload)
|
||||||
|
});
|
||||||
|
|
||||||
|
updateStatus('Error: ' + error.message, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateStatus(message, state) {
|
||||||
|
const container = document.getElementById('nip07-status');
|
||||||
|
const textEl = document.getElementById('nip07-status-text');
|
||||||
|
if (container && textEl) {
|
||||||
|
container.className = 'status status--' + state;
|
||||||
|
textEl.textContent = message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start polling when page loads
|
||||||
|
window.addEventListener('load', () => {
|
||||||
|
console.log('NIP-07 Proxy loaded');
|
||||||
|
|
||||||
|
// Check if NIP-07 extension is available
|
||||||
|
if (window.nostr) {
|
||||||
|
console.log('NIP-07 extension detected');
|
||||||
|
updateStatus('Connected — ready', 'connected');
|
||||||
|
} else {
|
||||||
|
console.log('NIP-07 extension not found');
|
||||||
|
updateStatus('No NIP-07 extension found', 'error');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start polling every 500 ms
|
||||||
|
setInterval(pollForRequests, 500);
|
||||||
|
});
|
||||||
51
crates/browser-signer-proxy/src/error.rs
Normal file
51
crates/browser-signer-proxy/src/error.rs
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
// Copyright (c) 2022-2023 Yuki Kishimoto
|
||||||
|
// Copyright (c) 2023-2025 Rust Nostr Developers
|
||||||
|
// Distributed under the MIT software license
|
||||||
|
|
||||||
|
//! Error types for the browser signer proxy.
|
||||||
|
|
||||||
|
opaquerr::define_kind! {
|
||||||
|
/// Nostr browser signer proxy error kind.
|
||||||
|
pub ErrorKind {
|
||||||
|
/// Nostr protocol error.
|
||||||
|
Protocol => "nostr protocol error",
|
||||||
|
/// I/O error.
|
||||||
|
IO => "I/O error",
|
||||||
|
/// JSON error.
|
||||||
|
Json => "JSON error",
|
||||||
|
/// The operation timed out.
|
||||||
|
Timeout => "timeout",
|
||||||
|
/// The operation cannot be completed in the current state.
|
||||||
|
State => "invalid state",
|
||||||
|
/// Anything not covered by the stable categories above.
|
||||||
|
Other => "other error",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
opaquerr::define_error! {
|
||||||
|
/// Nostr browser signer proxy error.
|
||||||
|
pub Error(ErrorKind)
|
||||||
|
|
||||||
|
from {
|
||||||
|
nostr::error::Error => ErrorKind::Protocol,
|
||||||
|
std::io::Error => ErrorKind::IO,
|
||||||
|
serde_json::Error => ErrorKind::Json,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Error {
|
||||||
|
pub(crate) fn generic<S>(message: S) -> Self
|
||||||
|
where
|
||||||
|
S: Into<String>,
|
||||||
|
{
|
||||||
|
Self::new(ErrorKind::Other, message.into())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn timeout() -> Self {
|
||||||
|
Self::simple(ErrorKind::Timeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn shutdown() -> Self {
|
||||||
|
Self::with_static_message(ErrorKind::State, "server is shutdown")
|
||||||
|
}
|
||||||
|
}
|
||||||
764
crates/browser-signer-proxy/src/lib.rs
Normal file
764
crates/browser-signer-proxy/src/lib.rs
Normal file
@@ -0,0 +1,764 @@
|
|||||||
|
use std::collections::HashMap;
|
||||||
|
use std::future::Future;
|
||||||
|
use std::net::{IpAddr, Ipv4Addr, SocketAddr, SocketAddrV4};
|
||||||
|
use std::pin::Pin;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||||
|
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
use atomic_destructor::{AtomicDestroyer, AtomicDestructor};
|
||||||
|
use event_listener::Event as ShutdownEvent;
|
||||||
|
use nostr::prelude::*;
|
||||||
|
use serde::de::DeserializeOwned;
|
||||||
|
use serde::{Deserialize, Serialize, Serializer};
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
use smol::channel;
|
||||||
|
use smol::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader};
|
||||||
|
use smol::lock::Mutex;
|
||||||
|
use smol::net::{TcpListener, TcpStream};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
mod error;
|
||||||
|
pub mod prelude;
|
||||||
|
|
||||||
|
pub use self::error::Error;
|
||||||
|
|
||||||
|
const DEFAULT_HTML: &str = include_str!("../index.html");
|
||||||
|
const JS: &str = include_str!("../proxy.js");
|
||||||
|
|
||||||
|
type PendingResponseMap = HashMap<Uuid, channel::Sender<Result<Value, String>>>;
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct Message {
|
||||||
|
id: Uuid,
|
||||||
|
error: Option<String>,
|
||||||
|
result: Option<Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Message {
|
||||||
|
fn into_result(self) -> Result<Value, String> {
|
||||||
|
if let Some(error) = self.error {
|
||||||
|
Err(error)
|
||||||
|
} else {
|
||||||
|
Ok(self.result.unwrap_or(Value::Null))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
enum RequestMethod {
|
||||||
|
GetPublicKey,
|
||||||
|
SignEvent,
|
||||||
|
Nip04Encrypt,
|
||||||
|
Nip04Decrypt,
|
||||||
|
Nip44Encrypt,
|
||||||
|
Nip44Decrypt,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RequestMethod {
|
||||||
|
fn as_str(&self) -> &str {
|
||||||
|
match self {
|
||||||
|
Self::GetPublicKey => "get_public_key",
|
||||||
|
Self::SignEvent => "sign_event",
|
||||||
|
Self::Nip04Encrypt => "nip04_encrypt",
|
||||||
|
Self::Nip04Decrypt => "nip04_decrypt",
|
||||||
|
Self::Nip44Encrypt => "nip44_encrypt",
|
||||||
|
Self::Nip44Decrypt => "nip44_decrypt",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Serialize for RequestMethod {
|
||||||
|
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||||
|
where
|
||||||
|
S: Serializer,
|
||||||
|
{
|
||||||
|
serializer.serialize_str(self.as_str())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
struct RequestData {
|
||||||
|
id: Uuid,
|
||||||
|
method: RequestMethod,
|
||||||
|
params: Value,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RequestData {
|
||||||
|
#[inline]
|
||||||
|
fn new(method: RequestMethod, params: Value) -> Self {
|
||||||
|
Self {
|
||||||
|
id: Uuid::new_v4(),
|
||||||
|
method,
|
||||||
|
params,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct Requests<'a> {
|
||||||
|
requests: &'a [RequestData],
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> Requests<'a> {
|
||||||
|
#[inline]
|
||||||
|
fn new(requests: &'a [RequestData]) -> Self {
|
||||||
|
Self { requests }
|
||||||
|
}
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
fn len(&self) -> usize {
|
||||||
|
self.requests.len()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Params for NIP-04 and NIP-44 encryption/decryption
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct CryptoParams<'a> {
|
||||||
|
public_key: &'a PublicKey,
|
||||||
|
content: &'a str,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> CryptoParams<'a> {
|
||||||
|
#[inline]
|
||||||
|
fn new(public_key: &'a PublicKey, content: &'a str) -> Self {
|
||||||
|
Self {
|
||||||
|
public_key,
|
||||||
|
content,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct ProxyState {
|
||||||
|
/// Requests waiting to be picked up by browser
|
||||||
|
pub outgoing_requests: Mutex<Vec<RequestData>>,
|
||||||
|
/// Map of request ID to response sender
|
||||||
|
pub pending_responses: Mutex<PendingResponseMap>,
|
||||||
|
/// Last time the client asked for the pending requests
|
||||||
|
pub last_pending_request: Arc<AtomicU64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Configuration options for [`BrowserSignerProxy`].
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct BrowserSignerProxyOptions {
|
||||||
|
/// Request timeout for the signer extension. Default is 30 seconds.
|
||||||
|
pub timeout: Duration,
|
||||||
|
/// Proxy server IP address and port. Default is `127.0.0.1:7400`.
|
||||||
|
pub addr: SocketAddr,
|
||||||
|
/// Custom HTML page.
|
||||||
|
// NOTE: not `Option` to move it between threads without reference counter
|
||||||
|
pub custom_html: &'static str,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
struct InnerBrowserSignerProxy {
|
||||||
|
/// Configuration options for the proxy
|
||||||
|
options: BrowserSignerProxyOptions,
|
||||||
|
/// Internal state of the proxy including request queues
|
||||||
|
state: Arc<ProxyState>,
|
||||||
|
/// Notification trigger for graceful shutdown
|
||||||
|
shutdown: Arc<ShutdownEvent>,
|
||||||
|
/// Flag to indicate if the server is shutdown
|
||||||
|
is_shutdown: Arc<AtomicBool>,
|
||||||
|
/// Flag indicating if the server is started
|
||||||
|
is_started: Arc<AtomicBool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AtomicDestroyer for InnerBrowserSignerProxy {
|
||||||
|
fn on_destroy(&self) {
|
||||||
|
self.shutdown();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl InnerBrowserSignerProxy {
|
||||||
|
#[inline]
|
||||||
|
fn is_shutdown(&self) -> bool {
|
||||||
|
self.is_shutdown.load(Ordering::SeqCst)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn shutdown(&self) {
|
||||||
|
// Mark the server as shutdown
|
||||||
|
self.is_shutdown.store(true, Ordering::SeqCst);
|
||||||
|
|
||||||
|
// Notify all waiters that the proxy is shutting down
|
||||||
|
self.shutdown.notify(usize::MAX);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Nostr Browser Signer Proxy
|
||||||
|
///
|
||||||
|
/// Proxy to use Nostr Browser signer (NIP-07) in native applications.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct BrowserSignerProxy {
|
||||||
|
inner: AtomicDestructor<InnerBrowserSignerProxy>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for BrowserSignerProxyOptions {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
timeout: Duration::from_secs(30),
|
||||||
|
// 7 for NIP-07 and 400 because the NIP title is 40 bytes :)
|
||||||
|
addr: SocketAddr::V4(SocketAddrV4::new(Ipv4Addr::LOCALHOST, 7400)),
|
||||||
|
custom_html: "",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BrowserSignerProxyOptions {
|
||||||
|
/// Sets the timeout duration.
|
||||||
|
pub const fn timeout(mut self, timeout: Duration) -> Self {
|
||||||
|
self.timeout = timeout;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets the IP address.
|
||||||
|
pub const fn ip_addr(mut self, new_ip: IpAddr) -> Self {
|
||||||
|
self.addr = SocketAddr::new(new_ip, self.addr.port());
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets the port number.
|
||||||
|
pub const fn port(mut self, new_port: u16) -> Self {
|
||||||
|
self.addr = SocketAddr::new(self.addr.ip(), new_port);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets a custom html page.
|
||||||
|
///
|
||||||
|
/// The page must include `/proxy.js` script (`<script src="/proxy.js"></script>`)
|
||||||
|
/// which will handle communication with the server and update the element
|
||||||
|
/// with id `nip07-proxy-status` with the status.
|
||||||
|
pub const fn custom_html_page(mut self, custom_html: &'static str) -> Self {
|
||||||
|
self.custom_html = custom_html;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BrowserSignerProxy {
|
||||||
|
/// Construct a new browser signer proxy
|
||||||
|
pub fn new(options: BrowserSignerProxyOptions) -> Self {
|
||||||
|
let state = ProxyState {
|
||||||
|
outgoing_requests: Mutex::new(Vec::new()),
|
||||||
|
pending_responses: Mutex::new(HashMap::new()),
|
||||||
|
last_pending_request: Arc::new(AtomicU64::new(0)),
|
||||||
|
};
|
||||||
|
|
||||||
|
Self {
|
||||||
|
inner: AtomicDestructor::new(InnerBrowserSignerProxy {
|
||||||
|
options,
|
||||||
|
state: Arc::new(state),
|
||||||
|
shutdown: Arc::new(ShutdownEvent::new()),
|
||||||
|
is_shutdown: Arc::new(AtomicBool::new(false)),
|
||||||
|
is_started: Arc::new(AtomicBool::new(false)),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Indicates whether the server is currently running.
|
||||||
|
#[inline]
|
||||||
|
pub fn is_started(&self) -> bool {
|
||||||
|
self.inner.is_started.load(Ordering::SeqCst)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Checks if there is an open browser tab ready to respond to requests by
|
||||||
|
/// verifying the time since the last pending request.
|
||||||
|
#[inline]
|
||||||
|
pub fn is_session_active(&self) -> bool {
|
||||||
|
current_time() - self.inner.state.last_pending_request.load(Ordering::SeqCst) < 2
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the signer proxy webpage URL
|
||||||
|
#[inline]
|
||||||
|
pub fn url(&self) -> String {
|
||||||
|
format!("http://{}", self.inner.options.addr)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Start the proxy server.
|
||||||
|
///
|
||||||
|
/// If this is not called explicitly, the server will be automatically
|
||||||
|
/// started on the first interaction with the signer.
|
||||||
|
pub async fn start(&self) -> Result<(), Error> {
|
||||||
|
// Ensure is not shutdown
|
||||||
|
if self.inner.is_shutdown() {
|
||||||
|
return Err(Error::shutdown());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark the proxy as started and check if was already started
|
||||||
|
let is_started: bool = self.inner.is_started.swap(true, Ordering::SeqCst);
|
||||||
|
|
||||||
|
// Immediately return if already started
|
||||||
|
if is_started {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let listener: TcpListener = match TcpListener::bind(self.inner.options.addr).await {
|
||||||
|
Ok(listener) => listener,
|
||||||
|
Err(e) => {
|
||||||
|
// Undo the started flag if binding fails
|
||||||
|
self.inner.is_started.store(false, Ordering::SeqCst);
|
||||||
|
return Err(Error::from(e));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let addr: SocketAddr = self.inner.options.addr;
|
||||||
|
let state: Arc<ProxyState> = self.inner.state.clone();
|
||||||
|
let custom_html: &'static str = self.inner.options.custom_html;
|
||||||
|
let shutdown: Arc<ShutdownEvent> = self.inner.shutdown.clone();
|
||||||
|
|
||||||
|
smol::spawn(async move {
|
||||||
|
tracing::info!("Starting proxy server on {addr}");
|
||||||
|
|
||||||
|
loop {
|
||||||
|
// Race between accepting a new connection and shutdown signal
|
||||||
|
let shutdown_listener = shutdown.listen();
|
||||||
|
|
||||||
|
enum AcceptEvent {
|
||||||
|
Connection(Result<(TcpStream, SocketAddr), std::io::Error>),
|
||||||
|
Shutdown,
|
||||||
|
}
|
||||||
|
|
||||||
|
let event = smol::future::or(
|
||||||
|
async { AcceptEvent::Connection(listener.accept().await) },
|
||||||
|
async {
|
||||||
|
shutdown_listener.await;
|
||||||
|
AcceptEvent::Shutdown
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
match event {
|
||||||
|
AcceptEvent::Connection(Ok((stream, _))) => {
|
||||||
|
let state: Arc<ProxyState> = state.clone();
|
||||||
|
let shutdown: Arc<ShutdownEvent> = shutdown.clone();
|
||||||
|
|
||||||
|
smol::spawn(async move {
|
||||||
|
let shutdown_listener = shutdown.listen();
|
||||||
|
|
||||||
|
smol::future::or(
|
||||||
|
async {
|
||||||
|
handle_connection(stream, state, custom_html).await;
|
||||||
|
},
|
||||||
|
async {
|
||||||
|
shutdown_listener.await;
|
||||||
|
tracing::debug!(
|
||||||
|
"Closing connection, proxy server is shutting down."
|
||||||
|
);
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
})
|
||||||
|
.detach();
|
||||||
|
}
|
||||||
|
AcceptEvent::Connection(Err(e)) => {
|
||||||
|
tracing::error!("Failed to accept connection: {e}");
|
||||||
|
}
|
||||||
|
AcceptEvent::Shutdown => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tracing::info!("Proxy server shut down.");
|
||||||
|
})
|
||||||
|
.detach();
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
async fn store_pending_response(&self, id: Uuid, tx: channel::Sender<Result<Value, String>>) {
|
||||||
|
let mut pending_responses = self.inner.state.pending_responses.lock().await;
|
||||||
|
pending_responses.insert(id, tx);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
async fn store_outgoing_request(&self, request: RequestData) {
|
||||||
|
let mut outgoing_requests = self.inner.state.outgoing_requests.lock().await;
|
||||||
|
outgoing_requests.push(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn request<T>(&self, method: RequestMethod, params: Value) -> Result<T, Error>
|
||||||
|
where
|
||||||
|
T: DeserializeOwned,
|
||||||
|
{
|
||||||
|
// Start the proxy if not already started
|
||||||
|
self.start().await?;
|
||||||
|
|
||||||
|
// Construct the request
|
||||||
|
let request: RequestData = RequestData::new(method, params);
|
||||||
|
|
||||||
|
// Create a bounded channel of size 1 as a oneshot replacement
|
||||||
|
let (tx, rx) = channel::bounded::<Result<Value, String>>(1);
|
||||||
|
|
||||||
|
// Store the response sender
|
||||||
|
self.store_pending_response(request.id, tx).await;
|
||||||
|
|
||||||
|
// Add to outgoing requests queue
|
||||||
|
self.store_outgoing_request(request).await;
|
||||||
|
|
||||||
|
// Wait for response with timeout
|
||||||
|
let response = race_timeout(self.inner.options.timeout, rx.recv()).await;
|
||||||
|
|
||||||
|
match response {
|
||||||
|
Ok(Ok(res)) => Ok(serde_json::from_value(res)?),
|
||||||
|
Ok(Err(error)) => Err(Error::generic(error)),
|
||||||
|
Err(TimeoutError) => Err(Error::timeout()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
async fn _get_public_key(&self) -> Result<PublicKey, Error> {
|
||||||
|
self.request(RequestMethod::GetPublicKey, json!({})).await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
async fn _sign_event(&self, event: UnsignedEvent) -> Result<Event, Error> {
|
||||||
|
let event: Event = self
|
||||||
|
.request(RequestMethod::SignEvent, serde_json::to_value(event)?)
|
||||||
|
.await?;
|
||||||
|
event.verify()?;
|
||||||
|
Ok(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
async fn _nip04_encrypt(&self, public_key: &PublicKey, content: &str) -> Result<String, Error> {
|
||||||
|
let params = CryptoParams::new(public_key, content);
|
||||||
|
self.request(RequestMethod::Nip04Encrypt, serde_json::to_value(params)?)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
async fn _nip04_decrypt(&self, public_key: &PublicKey, content: &str) -> Result<String, Error> {
|
||||||
|
let params = CryptoParams::new(public_key, content);
|
||||||
|
self.request(RequestMethod::Nip04Decrypt, serde_json::to_value(params)?)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
async fn _nip44_encrypt(&self, public_key: &PublicKey, content: &str) -> Result<String, Error> {
|
||||||
|
let params = CryptoParams::new(public_key, content);
|
||||||
|
self.request(RequestMethod::Nip44Encrypt, serde_json::to_value(params)?)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
async fn _nip44_decrypt(&self, public_key: &PublicKey, content: &str) -> Result<String, Error> {
|
||||||
|
let params = CryptoParams::new(public_key, content);
|
||||||
|
self.request(RequestMethod::Nip44Decrypt, serde_json::to_value(params)?)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AsyncGetPublicKey for BrowserSignerProxy {
|
||||||
|
type Error = Error;
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
fn get_public_key_async(
|
||||||
|
&self,
|
||||||
|
) -> Pin<Box<dyn Future<Output = Result<PublicKey, Self::Error>> + Send + '_>> {
|
||||||
|
Box::pin(async move { self._get_public_key().await })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AsyncSignEvent for BrowserSignerProxy {
|
||||||
|
type Error = Error;
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
fn sign_event_async(
|
||||||
|
&self,
|
||||||
|
unsigned: UnsignedEvent,
|
||||||
|
) -> Pin<Box<dyn Future<Output = Result<Event, Self::Error>> + Send + '_>> {
|
||||||
|
Box::pin(async move { self._sign_event(unsigned).await })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AsyncNip04 for BrowserSignerProxy {
|
||||||
|
type Error = Error;
|
||||||
|
|
||||||
|
fn nip04_encrypt_async<'a>(
|
||||||
|
&'a self,
|
||||||
|
public_key: &'a PublicKey,
|
||||||
|
content: &'a str,
|
||||||
|
) -> Pin<Box<dyn Future<Output = Result<String, Self::Error>> + Send + 'a>> {
|
||||||
|
Box::pin(async move { self._nip04_encrypt(public_key, content).await })
|
||||||
|
}
|
||||||
|
|
||||||
|
fn nip04_decrypt_async<'a>(
|
||||||
|
&'a self,
|
||||||
|
public_key: &'a PublicKey,
|
||||||
|
encrypted_content: &'a str,
|
||||||
|
) -> Pin<Box<dyn Future<Output = Result<String, Self::Error>> + Send + 'a>> {
|
||||||
|
Box::pin(async move { self._nip04_decrypt(public_key, encrypted_content).await })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AsyncNip44 for BrowserSignerProxy {
|
||||||
|
type Error = Error;
|
||||||
|
|
||||||
|
fn nip44_encrypt_async<'a>(
|
||||||
|
&'a self,
|
||||||
|
public_key: &'a PublicKey,
|
||||||
|
content: &'a str,
|
||||||
|
) -> Pin<Box<dyn Future<Output = Result<String, Self::Error>> + Send + 'a>> {
|
||||||
|
Box::pin(async move { self._nip44_encrypt(public_key, content).await })
|
||||||
|
}
|
||||||
|
|
||||||
|
fn nip44_decrypt_async<'a>(
|
||||||
|
&'a self,
|
||||||
|
public_key: &'a PublicKey,
|
||||||
|
payload: &'a str,
|
||||||
|
) -> Pin<Box<dyn Future<Output = Result<String, Self::Error>> + Send + 'a>> {
|
||||||
|
Box::pin(async move { self._nip44_decrypt(public_key, payload).await })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Minimal HTTP server ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Handle a single HTTP connection.
|
||||||
|
async fn handle_connection(stream: TcpStream, state: Arc<ProxyState>, custom_html: &'static str) {
|
||||||
|
let mut reader = BufReader::new(stream);
|
||||||
|
|
||||||
|
// Read the request line
|
||||||
|
let mut request_line = String::new();
|
||||||
|
if reader.read_line(&mut request_line).await.is_err() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let request_line = request_line.trim_end().to_string();
|
||||||
|
|
||||||
|
// Parse method, path, and HTTP version from request line
|
||||||
|
let parts: Vec<&str> = request_line.split_whitespace().collect();
|
||||||
|
if parts.len() < 2 {
|
||||||
|
send_response(&mut reader, 400, "Bad Request", "", "").await;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let method = parts[0].to_uppercase();
|
||||||
|
let path = parts[1].to_string();
|
||||||
|
|
||||||
|
// Read headers until empty line
|
||||||
|
let mut headers = Vec::new();
|
||||||
|
let mut content_length: usize = 0;
|
||||||
|
loop {
|
||||||
|
let mut line = String::new();
|
||||||
|
if reader.read_line(&mut line).await.is_err() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let line = line.trim_end().to_string();
|
||||||
|
if line.is_empty() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if let Some(value) = line.strip_prefix("content-length:") {
|
||||||
|
content_length = value.trim().parse().unwrap_or(0);
|
||||||
|
} else if let Some(value) = line.strip_prefix("Content-Length:") {
|
||||||
|
content_length = value.trim().parse().unwrap_or(0);
|
||||||
|
}
|
||||||
|
headers.push(line);
|
||||||
|
}
|
||||||
|
|
||||||
|
match (method.as_str(), path.as_str()) {
|
||||||
|
// Serve the HTML proxy page
|
||||||
|
("GET", "/") => {
|
||||||
|
let html = if custom_html.is_empty() {
|
||||||
|
DEFAULT_HTML
|
||||||
|
} else {
|
||||||
|
custom_html
|
||||||
|
};
|
||||||
|
send_response(&mut reader, 200, "OK", "text/html", html).await;
|
||||||
|
}
|
||||||
|
// Serve the JS proxy script
|
||||||
|
("GET", "/proxy.js") => {
|
||||||
|
send_response(&mut reader, 200, "OK", "application/javascript", JS).await;
|
||||||
|
}
|
||||||
|
// Browser polls this endpoint to get pending requests
|
||||||
|
("GET", "/api/pending") => {
|
||||||
|
state
|
||||||
|
.last_pending_request
|
||||||
|
.store(current_time(), Ordering::SeqCst);
|
||||||
|
|
||||||
|
let mut outgoing = state.outgoing_requests.lock().await;
|
||||||
|
|
||||||
|
let requests = Requests::new(&outgoing);
|
||||||
|
let json = match serde_json::to_string(&requests) {
|
||||||
|
Ok(j) => j,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("Failed to serialize pending requests: {e}");
|
||||||
|
send_response(&mut reader, 500, "Internal Server Error", "", "").await;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
tracing::debug!("Sending {} pending requests to browser", requests.len());
|
||||||
|
|
||||||
|
// Clear the outgoing requests after sending them
|
||||||
|
outgoing.clear();
|
||||||
|
|
||||||
|
send_response_cors_json(&mut reader, 200, "OK", &json).await;
|
||||||
|
}
|
||||||
|
// Receive response from browser extension
|
||||||
|
("POST", "/api/response") => {
|
||||||
|
let mut body_bytes = vec![0u8; content_length];
|
||||||
|
if content_length > 0 && reader.read_exact(&mut body_bytes).await.is_err() {
|
||||||
|
send_response(&mut reader, 400, "Bad Request", "", "").await;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let message: Message = match serde_json::from_slice(&body_bytes) {
|
||||||
|
Ok(json) => json,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("Failed to parse response body: {e}");
|
||||||
|
send_response(&mut reader, 400, "Invalid JSON", "", "").await;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
tracing::debug!("Received response from browser: {message:?}");
|
||||||
|
|
||||||
|
let id: Uuid = message.id;
|
||||||
|
let mut pending = state.pending_responses.lock().await;
|
||||||
|
|
||||||
|
match pending.remove(&id) {
|
||||||
|
Some(sender) => {
|
||||||
|
// Use try_send since we already hold the lock
|
||||||
|
let _ = sender.try_send(message.into_result());
|
||||||
|
tracing::info!("Forwarded response for request {id}");
|
||||||
|
}
|
||||||
|
None => tracing::warn!("No pending request found for {id}"),
|
||||||
|
}
|
||||||
|
|
||||||
|
send_response_cors(&mut reader, 200, "OK", "text/plain", "OK").await;
|
||||||
|
}
|
||||||
|
// CORS preflight
|
||||||
|
("OPTIONS", _) => {
|
||||||
|
let response = "HTTP/1.1 200 OK\r\n\
|
||||||
|
Access-Control-Allow-Origin: *\r\n\
|
||||||
|
Access-Control-Allow-Methods: GET, POST, OPTIONS\r\n\
|
||||||
|
Access-Control-Allow-Headers: Content-Type\r\n\
|
||||||
|
Content-Length: 0\r\n\
|
||||||
|
Connection: close\r\n\
|
||||||
|
\r\n";
|
||||||
|
let _ = reader.get_mut().write_all(response.as_bytes()).await;
|
||||||
|
let _ = reader.get_mut().flush().await;
|
||||||
|
}
|
||||||
|
// 404 - not found
|
||||||
|
_ => {
|
||||||
|
send_response(&mut reader, 404, "Not Found", "", "").await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write an HTTP response to the stream.
|
||||||
|
async fn send_response(
|
||||||
|
stream: &mut (impl AsyncWriteExt + Unpin),
|
||||||
|
status: u16,
|
||||||
|
status_text: &str,
|
||||||
|
content_type: &str,
|
||||||
|
body: &str,
|
||||||
|
) {
|
||||||
|
let mut response = format!("HTTP/1.1 {status} {status_text}\r\n");
|
||||||
|
|
||||||
|
if !content_type.is_empty() {
|
||||||
|
response.push_str(&format!("Content-Type: {content_type}\r\n"));
|
||||||
|
}
|
||||||
|
|
||||||
|
response.push_str(&format!("Content-Length: {}\r\n", body.len()));
|
||||||
|
response.push_str("Access-Control-Allow-Origin: *\r\n");
|
||||||
|
response.push_str("Connection: close\r\n");
|
||||||
|
response.push_str("\r\n");
|
||||||
|
response.push_str(body);
|
||||||
|
|
||||||
|
let _ = stream.write_all(response.as_bytes()).await;
|
||||||
|
let _ = stream.flush().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write a response with CORS headers and JSON content type.
|
||||||
|
async fn send_response_cors_json(
|
||||||
|
stream: &mut (impl AsyncWriteExt + Unpin),
|
||||||
|
status: u16,
|
||||||
|
status_text: &str,
|
||||||
|
body: &str,
|
||||||
|
) {
|
||||||
|
let mut response = format!("HTTP/1.1 {status} {status_text}\r\n");
|
||||||
|
response.push_str("Content-Type: application/json\r\n");
|
||||||
|
response.push_str(&format!("Content-Length: {}\r\n", body.len()));
|
||||||
|
response.push_str("Access-Control-Allow-Origin: *\r\n");
|
||||||
|
response.push_str("Connection: close\r\n");
|
||||||
|
response.push_str("\r\n");
|
||||||
|
response.push_str(body);
|
||||||
|
|
||||||
|
let _ = stream.write_all(response.as_bytes()).await;
|
||||||
|
let _ = stream.flush().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write a response with CORS headers.
|
||||||
|
async fn send_response_cors(
|
||||||
|
stream: &mut (impl AsyncWriteExt + Unpin),
|
||||||
|
status: u16,
|
||||||
|
status_text: &str,
|
||||||
|
content_type: &str,
|
||||||
|
body: &str,
|
||||||
|
) {
|
||||||
|
let mut response = format!("HTTP/1.1 {status} {status_text}\r\n");
|
||||||
|
|
||||||
|
if !content_type.is_empty() {
|
||||||
|
response.push_str(&format!("Content-Type: {content_type}\r\n"));
|
||||||
|
}
|
||||||
|
|
||||||
|
response.push_str(&format!("Content-Length: {}\r\n", body.len()));
|
||||||
|
response.push_str("Access-Control-Allow-Origin: *\r\n");
|
||||||
|
response.push_str("Connection: close\r\n");
|
||||||
|
response.push_str("\r\n");
|
||||||
|
response.push_str(body);
|
||||||
|
|
||||||
|
let _ = stream.write_all(response.as_bytes()).await;
|
||||||
|
let _ = stream.flush().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Timeout helper ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// An error indicating that an operation timed out.
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct TimeoutError;
|
||||||
|
|
||||||
|
/// Races a channel receive against a duration.
|
||||||
|
///
|
||||||
|
/// Returns the channel value on success, or [`TimeoutError`] if the duration
|
||||||
|
/// elapses first or the channel is closed.
|
||||||
|
async fn race_timeout<T>(
|
||||||
|
duration: Duration,
|
||||||
|
recv: impl Future<Output = Result<T, channel::RecvError>>,
|
||||||
|
) -> Result<T, TimeoutError> {
|
||||||
|
enum Event<T> {
|
||||||
|
Value(T),
|
||||||
|
ChannelClosed,
|
||||||
|
Timeout,
|
||||||
|
}
|
||||||
|
|
||||||
|
let event = smol::future::or(
|
||||||
|
async {
|
||||||
|
match recv.await {
|
||||||
|
Ok(value) => Event::Value(value),
|
||||||
|
Err(_) => Event::ChannelClosed,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async {
|
||||||
|
smol::Timer::after(duration).await;
|
||||||
|
Event::Timeout
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
match event {
|
||||||
|
Event::Value(value) => Ok(value),
|
||||||
|
Event::ChannelClosed | Event::Timeout => Err(TimeoutError),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Utility ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Gets the current time in seconds since the Unix epoch (1970-01-01). If the
|
||||||
|
/// time is before the epoch, returns 0.
|
||||||
|
#[inline]
|
||||||
|
fn current_time() -> u64 {
|
||||||
|
SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.map(|d| d.as_secs())
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
14
crates/browser-signer-proxy/src/prelude.rs
Normal file
14
crates/browser-signer-proxy/src/prelude.rs
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
// Copyright (c) 2022-2023 Yuki Kishimoto
|
||||||
|
// Copyright (c) 2023-2025 Rust Nostr Developers
|
||||||
|
// Distributed under the MIT software license
|
||||||
|
|
||||||
|
//! Prelude
|
||||||
|
|
||||||
|
#![allow(unknown_lints)]
|
||||||
|
#![allow(ambiguous_glob_reexports)]
|
||||||
|
#![doc(hidden)]
|
||||||
|
|
||||||
|
pub use nostr::prelude::*;
|
||||||
|
|
||||||
|
pub use crate::error::{Error, ErrorKind};
|
||||||
|
pub use crate::*;
|
||||||
@@ -28,6 +28,7 @@ mime_guess = "2.0.4"
|
|||||||
nostr-memory.workspace = true
|
nostr-memory.workspace = true
|
||||||
|
|
||||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||||
|
browser-signer-proxy = { path = "../browser-signer-proxy" }
|
||||||
nostr-lmdb.workspace = true
|
nostr-lmdb.workspace = true
|
||||||
smol.workspace = true
|
smol.workspace = true
|
||||||
gpui_tokio.workspace = true
|
gpui_tokio.workspace = true
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
use anyhow::{Error, anyhow};
|
use anyhow::{Error, anyhow};
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
use browser_signer_proxy::prelude::*;
|
||||||
use common::config_dir;
|
use common::config_dir;
|
||||||
use gpui::{App, AppContext, Context, Entity, EventEmitter, Global, Task, Window};
|
use gpui::{App, AppContext, Context, Entity, EventEmitter, Global, Task, Window};
|
||||||
|
use gpui_tokio::Tokio;
|
||||||
use instant::Duration;
|
use instant::Duration;
|
||||||
use nostr_connect::prelude::*;
|
use nostr_connect::prelude::*;
|
||||||
use nostr_gossip_memory::prelude::*;
|
use nostr_gossip_memory::prelude::*;
|
||||||
@@ -262,6 +265,11 @@ impl NostrRegistry {
|
|||||||
this.set_signer(signer, cx);
|
this.set_signer(signer, cx);
|
||||||
cx.notify();
|
cx.notify();
|
||||||
})?;
|
})?;
|
||||||
|
} else if content == "proxy" {
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
this.update(cx, |this, cx| {
|
||||||
|
this.connect_proxy(cx);
|
||||||
|
})?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ => {
|
_ => {
|
||||||
@@ -307,6 +315,81 @@ impl NostrRegistry {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Start the browser proxy
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
pub fn connect_proxy(&mut self, cx: &mut Context<Self>) {
|
||||||
|
let proxy = BrowserSignerProxy::new(BrowserSignerProxyOptions::default());
|
||||||
|
let (tx, rx) = flume::bounded::<String>(1);
|
||||||
|
|
||||||
|
self.tasks.push(Tokio::spawn_result(cx, {
|
||||||
|
let proxy = proxy.clone();
|
||||||
|
async move {
|
||||||
|
// Start the proxy and get the web url
|
||||||
|
proxy.start().await?;
|
||||||
|
// Notify GPUI
|
||||||
|
let url = proxy.url();
|
||||||
|
tx.send(url).ok();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
self.tasks.push(Tokio::spawn_result(cx, {
|
||||||
|
let proxy = proxy.clone();
|
||||||
|
async move {
|
||||||
|
loop {
|
||||||
|
if proxy.is_session_active() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
smol::Timer::after(Duration::from_secs(1)).await;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
self.tasks.push(cx.spawn({
|
||||||
|
let proxy = proxy.clone();
|
||||||
|
async move |this, cx| {
|
||||||
|
while let Ok(url) = rx.recv_async().await {
|
||||||
|
this.update(cx, |this, cx| {
|
||||||
|
let save = cx.write_credentials(USER_KEYRING, "proxy", b"proxy");
|
||||||
|
cx.background_spawn(async move { save.await.ok() }).detach();
|
||||||
|
cx.open_url(&url);
|
||||||
|
this.set_signer(proxy.clone(), cx);
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Monitor the session, if the browser disconnects, notify user to reconnect
|
||||||
|
self.tasks.push(cx.spawn({
|
||||||
|
let proxy = proxy.clone();
|
||||||
|
let executor = cx.background_executor().clone();
|
||||||
|
async move |this, cx| {
|
||||||
|
// Wait for the signer to be confirmed (timeout is 30s)
|
||||||
|
executor.timer(Duration::from_secs(30)).await;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
executor.timer(Duration::from_secs(5)).await;
|
||||||
|
if !proxy.is_session_active() {
|
||||||
|
_ = this.update(cx, |this, cx| {
|
||||||
|
// Only notify if this proxy is still the active signer
|
||||||
|
if this.current_user.is_some() {
|
||||||
|
this.signer.swap_inner(Keys::generate());
|
||||||
|
this.current_user = None;
|
||||||
|
cx.emit(StateEvent::NoSigner);
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
/// Get the public key of a NIP-05 address
|
/// Get the public key of a NIP-05 address
|
||||||
pub fn query_address(&self, addr: Nip05Address, cx: &App) -> Task<Result<PublicKey, Error>> {
|
pub fn query_address(&self, addr: Nip05Address, cx: &App) -> Task<Result<PublicKey, Error>> {
|
||||||
let client = self.client();
|
let client = self.client();
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ pub fn v_flex() -> Div {
|
|||||||
|
|
||||||
/// Returns a `Div` as divider.
|
/// Returns a `Div` as divider.
|
||||||
pub fn divider(cx: &App) -> Div {
|
pub fn divider(cx: &App) -> Div {
|
||||||
div().my_2().w_full().h_px().bg(cx.theme().border_variant)
|
div().my_1().w_full().h_px().bg(cx.theme().border_variant)
|
||||||
}
|
}
|
||||||
|
|
||||||
macro_rules! font_weight {
|
macro_rules! font_weight {
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ gpui.workspace = true
|
|||||||
nostr-sdk.workspace = true
|
nostr-sdk.workspace = true
|
||||||
instant.workspace = true
|
instant.workspace = true
|
||||||
nostr-connect.workspace = true
|
nostr-connect.workspace = true
|
||||||
|
browser-signer-proxy = { path = "../browser-signer-proxy" }
|
||||||
|
|
||||||
anyhow.workspace = true
|
anyhow.workspace = true
|
||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ use state::{CoopAuthUrlHandler, NostrRegistry, USER_KEYRING};
|
|||||||
use theme::ActiveTheme;
|
use theme::ActiveTheme;
|
||||||
use ui::button::{Button, ButtonVariants};
|
use ui::button::{Button, ButtonVariants};
|
||||||
use ui::input::{Input, InputEvent, InputState};
|
use ui::input::{Input, InputEvent, InputState};
|
||||||
use ui::{Disableable, StyledExt, WindowExtension, v_flex};
|
use ui::{Disableable, StyledExt, WindowExtension, divider, v_flex};
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct ImportIdentity {
|
pub struct ImportIdentity {
|
||||||
@@ -164,6 +164,14 @@ impl ImportIdentity {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
fn proxy(&mut self, cx: &mut Context<Self>) {
|
||||||
|
let nostr = NostrRegistry::global(cx);
|
||||||
|
nostr.update(cx, |this, cx| {
|
||||||
|
this.connect_proxy(cx);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
fn set_loading(&mut self, status: bool, cx: &mut Context<Self>) {
|
fn set_loading(&mut self, status: bool, cx: &mut Context<Self>) {
|
||||||
self.loading = status;
|
self.loading = status;
|
||||||
cx.notify();
|
cx.notify();
|
||||||
@@ -199,10 +207,13 @@ impl ImportIdentity {
|
|||||||
|
|
||||||
impl Render for ImportIdentity {
|
impl Render for ImportIdentity {
|
||||||
fn render(&mut self, _window: &mut gpui::Window, cx: &mut Context<Self>) -> impl IntoElement {
|
fn render(&mut self, _window: &mut gpui::Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
const MSG: &str = "Coop won't store your identity key on the local device. You need to re-login again in the next session. You can use Nostr Connect for persistent login.";
|
const BUNKER_WARN: &str = "Nostr Connect will usually take more time to get all your messages. Please keep your session open until you see all your messages.";
|
||||||
|
const KEY_WARN: &str = "Coop won't store your identity key on the local device. You need to re-login again in the next session. You can use Nostr Connect for persistent login.";
|
||||||
|
|
||||||
|
let is_wasm = cfg!(target_arch = "wasm32");
|
||||||
let require_password = self.key_input.read(cx).value().starts_with("ncryptsec1");
|
let require_password = self.key_input.read(cx).value().starts_with("ncryptsec1");
|
||||||
let key_warning = self.key_input.read(cx).value().starts_with("nsec1") || require_password;
|
let key_warning = self.key_input.read(cx).value().starts_with("nsec1") || require_password;
|
||||||
|
let bunker_warning = self.key_input.read(cx).value().starts_with("bunker://");
|
||||||
|
|
||||||
v_flex()
|
v_flex()
|
||||||
.size_full()
|
.size_full()
|
||||||
@@ -227,13 +238,20 @@ impl Render for ImportIdentity {
|
|||||||
.child(Input::new(&self.pass_input)),
|
.child(Input::new(&self.pass_input)),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
.when(bunker_warning, |this| {
|
||||||
|
this.child(
|
||||||
|
div()
|
||||||
|
.text_xs()
|
||||||
|
.text_color(cx.theme().text_warning)
|
||||||
|
.child(div().child(BUNKER_WARN)),
|
||||||
|
)
|
||||||
|
})
|
||||||
.when(key_warning, |this| {
|
.when(key_warning, |this| {
|
||||||
this.child(
|
this.child(
|
||||||
div()
|
div()
|
||||||
.text_xs()
|
.text_xs()
|
||||||
.text_color(cx.theme().text_warning)
|
.text_color(cx.theme().text_warning)
|
||||||
.child(div().font_semibold().child("Warning"))
|
.child(div().child(KEY_WARN)),
|
||||||
.child(div().child(MSG)),
|
|
||||||
)
|
)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -248,6 +266,19 @@ impl Render for ImportIdentity {
|
|||||||
this.login(window, cx);
|
this.login(window, cx);
|
||||||
})),
|
})),
|
||||||
)
|
)
|
||||||
|
.child(divider(cx))
|
||||||
|
.when(!is_wasm, |this| {
|
||||||
|
this.child(
|
||||||
|
Button::new("proxy")
|
||||||
|
.label("Connect via Web Extension (Experimental)")
|
||||||
|
.ghost_alt()
|
||||||
|
.loading(self.loading)
|
||||||
|
.disabled(self.loading)
|
||||||
|
.on_click(cx.listener(move |this, _ev, _window, cx| {
|
||||||
|
this.proxy(cx);
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
})
|
||||||
.when_some(self.error.read(cx).as_ref(), |this, error| {
|
.when_some(self.error.read(cx).as_ref(), |this, error| {
|
||||||
this.child(
|
this.child(
|
||||||
div()
|
div()
|
||||||
|
|||||||
Reference in New Issue
Block a user