use std::sync::atomic::{AtomicU8, Ordering};
use std::sync::{LazyLock, RwLock};
use std::time::Duration;
use nostr_sdk::prelude::*;
use nostr_connect::prelude::{AuthUrlHandler, NostrConnect, NostrConnectURI};
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
#[repr(u8)]
pub enum SignerKind {
Local = 0,
Bunker = 1,
}
impl SignerKind {
#[inline]
pub fn as_setting_str(self) -> &'static str {
match self {
SignerKind::Local => "local",
SignerKind::Bunker => "bunker",
}
}
#[inline]
pub fn from_setting_str(s: &str) -> Self {
match s {
"bunker" => SignerKind::Bunker,
_ => SignerKind::Local,
}
}
}
static SIGNER_KIND: AtomicU8 = AtomicU8::new(SignerKind::Local as u8);
#[inline]
pub fn signer_kind() -> SignerKind {
match SIGNER_KIND.load(Ordering::Acquire) {
1 => SignerKind::Bunker,
_ => SignerKind::Local,
}
}
#[inline]
pub fn set_signer_kind(kind: SignerKind) {
SIGNER_KIND.store(kind as u8, Ordering::Release);
}
#[inline]
pub fn is_bunker() -> bool {
signer_kind() == SignerKind::Bunker
}
pub static BUNKER_SIGNER: LazyLock<RwLock<Option<NostrConnect>>> =
LazyLock::new(|| RwLock::new(None));
#[inline]
pub fn bunker_signer() -> Option<NostrConnect> {
BUNKER_SIGNER.read().ok().and_then(|g| g.as_ref().cloned())
}
#[inline]
pub fn set_bunker_signer(signer: NostrConnect) {
if let Ok(mut g) = BUNKER_SIGNER.write() {
*g = Some(signer);
}
}
#[inline]
pub fn take_bunker_signer() -> Option<NostrConnect> {
BUNKER_SIGNER.write().ok().and_then(|mut g| g.take())
}
pub fn parse_bunker_relays(bunker_url: &str) -> Vec<String> {
match NostrConnectURI::parse(bunker_url) {
Ok(NostrConnectURI::Bunker { relays, .. }) => {
relays.into_iter().map(|r| r.to_string()).collect()
}
_ => Vec::new(),
}
}
pub fn parse_bunker_remote_pubkey(bunker_url: &str) -> Result<String, String> {
let uri = NostrConnectURI::parse(bunker_url)
.map_err(|e| format!("Invalid bunker URL: {}", e))?;
match uri {
NostrConnectURI::Bunker { remote_signer_public_key, .. } => {
Ok(remote_signer_public_key.to_hex().to_ascii_lowercase())
}
NostrConnectURI::Client { .. } => {
Err("Client-initiated URIs not supported here; use a bunker:// URL".into())
}
}
}
pub const VECTOR_APP_NAME: &str = "Vector";
pub const VECTOR_APP_URL: &str = "https://vectorapp.io";
pub const VECTOR_APP_ICON: &str = "https://raw.githubusercontent.com/VectorPrivacy/Vector/master/src-tauri/icons/icon.png";
pub const VECTOR_NIP46_PERMS: &[&str] = &[
"get_public_key",
"sign_event",
"nip04_encrypt",
"nip04_decrypt",
"nip44_encrypt",
"nip44_decrypt",
];
pub fn vector_metadata() -> NostrConnectMetadata {
let mut md = NostrConnectMetadata::new(VECTOR_APP_NAME);
if let Ok(url) = Url::parse(VECTOR_APP_URL) {
md = md.url(url);
}
if let Ok(icon) = Url::parse(VECTOR_APP_ICON) {
md = md.icons(vec![icon]);
}
md
}
pub fn build_nostrconnect_uri(
client_pubkey: PublicKey,
relays: Vec<RelayUrl>,
) -> NostrConnectURI {
NostrConnectURI::Client {
public_key: client_pubkey,
relays,
metadata: vector_metadata(),
}
}
pub fn build_nostrconnect_session(
client_keys: Keys,
relays: Vec<RelayUrl>,
timeout: Duration,
) -> Result<(NostrConnect, String), String> {
let uri = build_nostrconnect_uri(client_keys.public_key, relays);
let mut uri_string = uri.to_string();
let perms = VECTOR_NIP46_PERMS.join(",");
if !perms.is_empty() {
uri_string.push_str("&perms=");
uri_string.push_str(&perms);
}
let mut nc = NostrConnect::new(uri, client_keys, timeout, None)
.map_err(|e| format!("Bunker init failed: {}", e))?;
nc.auth_url_handler(VectorAuthUrlHandler);
Ok((nc, uri_string))
}
pub fn build_bunker_signer(
bunker_url: &str,
client_keys: Keys,
timeout: Duration,
) -> Result<NostrConnect, String> {
let uri = NostrConnectURI::parse(bunker_url)
.map_err(|e| format!("Invalid bunker URL: {}", e))?;
NostrConnect::new(uri, client_keys, timeout, None)
.map_err(|e| format!("Bunker init failed: {}", e))
}
pub async fn prewarm_bunker(signer: &NostrConnect) -> Result<PublicKey, String> {
signer
.get_public_key()
.await
.map_err(|e| format!("Bunker prewarm failed: {}", e))
}
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
#[repr(u8)]
pub enum BunkerConnectionState {
Idle = 0,
Connecting = 1,
Online = 2,
Offline = 3,
}
impl BunkerConnectionState {
pub fn as_label(self) -> &'static str {
match self {
BunkerConnectionState::Idle => "idle",
BunkerConnectionState::Connecting => "connecting",
BunkerConnectionState::Online => "online",
BunkerConnectionState::Offline => "offline",
}
}
}
static BUNKER_STATE: AtomicU8 = AtomicU8::new(BunkerConnectionState::Idle as u8);
#[inline]
pub fn bunker_state() -> BunkerConnectionState {
match BUNKER_STATE.load(Ordering::Acquire) {
1 => BunkerConnectionState::Connecting,
2 => BunkerConnectionState::Online,
3 => BunkerConnectionState::Offline,
_ => BunkerConnectionState::Idle,
}
}
pub fn set_bunker_state(new_state: BunkerConnectionState) {
let prev = BUNKER_STATE.swap(new_state as u8, Ordering::AcqRel);
if prev == new_state as u8 {
return;
}
crate::traits::emit_event_json(
"bunker_state",
serde_json::json!({ "state": new_state.as_label() }),
);
}
#[derive(Debug, Clone)]
pub struct WatchedBunkerSigner {
inner: NostrConnect,
session: crate::state::SessionGuard,
}
impl WatchedBunkerSigner {
pub fn new(inner: NostrConnect) -> Self {
Self { inner, session: crate::state::SessionGuard::capture() }
}
#[inline]
fn flip(&self, state: BunkerConnectionState) {
if self.session.is_valid() {
set_bunker_state(state);
}
}
#[cfg(test)]
pub(crate) fn session_generation_for_test(&self) -> u64 {
self.session.generation()
}
}
impl NostrSigner for WatchedBunkerSigner {
fn backend(&self) -> SignerBackend<'_> {
self.inner.backend()
}
fn get_public_key<'a>(&'a self) -> BoxedFuture<'a, Result<PublicKey, SignerError>> {
Box::pin(async move {
match self.inner.get_public_key().await {
Ok(pk) => {
self.flip(BunkerConnectionState::Online);
Ok(pk)
}
Err(e) => {
self.flip(BunkerConnectionState::Offline);
Err(e)
}
}
})
}
fn sign_event<'a>(&'a self, unsigned: UnsignedEvent) -> BoxedFuture<'a, Result<Event, SignerError>> {
Box::pin(async move {
match self.inner.sign_event(unsigned).await {
Ok(event) => {
self.flip(BunkerConnectionState::Online);
Ok(event)
}
Err(e) => {
self.flip(BunkerConnectionState::Offline);
Err(e)
}
}
})
}
fn nip04_encrypt<'a>(
&'a self,
public_key: &'a PublicKey,
content: &'a str,
) -> BoxedFuture<'a, Result<String, SignerError>> {
Box::pin(async move {
match self.inner.nip04_encrypt(public_key, content).await {
Ok(s) => { self.flip(BunkerConnectionState::Online); Ok(s) }
Err(e) => { self.flip(BunkerConnectionState::Offline); Err(e) }
}
})
}
fn nip04_decrypt<'a>(
&'a self,
public_key: &'a PublicKey,
content: &'a str,
) -> BoxedFuture<'a, Result<String, SignerError>> {
Box::pin(async move {
match self.inner.nip04_decrypt(public_key, content).await {
Ok(s) => { self.flip(BunkerConnectionState::Online); Ok(s) }
Err(e) => { self.flip(BunkerConnectionState::Offline); Err(e) }
}
})
}
fn nip44_encrypt<'a>(
&'a self,
public_key: &'a PublicKey,
content: &'a str,
) -> BoxedFuture<'a, Result<String, SignerError>> {
Box::pin(async move {
match self.inner.nip44_encrypt(public_key, content).await {
Ok(s) => { self.flip(BunkerConnectionState::Online); Ok(s) }
Err(e) => { self.flip(BunkerConnectionState::Offline); Err(e) }
}
})
}
fn nip44_decrypt<'a>(
&'a self,
public_key: &'a PublicKey,
content: &'a str,
) -> BoxedFuture<'a, Result<String, SignerError>> {
Box::pin(async move {
match self.inner.nip44_decrypt(public_key, content).await {
Ok(s) => { self.flip(BunkerConnectionState::Online); Ok(s) }
Err(e) => { self.flip(BunkerConnectionState::Offline); Err(e) }
}
})
}
}
#[derive(Debug, Clone, Default)]
pub struct VectorAuthUrlHandler;
impl AuthUrlHandler for VectorAuthUrlHandler {
fn on_auth_url<'a>(&'a self, auth_url: Url) -> BoxedFuture<'a, Result<()>> {
Box::pin(async move {
crate::traits::emit_event_json(
"bunker_auth_url",
serde_json::json!({ "url": auth_url.to_string() }),
);
Ok(())
})
}
}
pub async fn attempt_bunker_login(
bunker_url: &str,
client_keys: Keys,
timeout: Duration,
) -> Result<PublicKey, String> {
set_bunker_state(BunkerConnectionState::Connecting);
let mut nc = match build_bunker_signer(bunker_url, client_keys, timeout) {
Ok(nc) => nc,
Err(e) => {
set_bunker_state(BunkerConnectionState::Offline);
return Err(e);
}
};
nc.auth_url_handler(VectorAuthUrlHandler);
match prewarm_bunker(&nc).await {
Ok(remote_pk) => {
if let Some(old) = take_bunker_signer() {
tokio::spawn(async move { let _ = old.shutdown().await; });
}
set_bunker_signer(nc);
set_bunker_state(BunkerConnectionState::Online);
Ok(remote_pk)
}
Err(e) => {
set_bunker_state(BunkerConnectionState::Offline);
Err(e)
}
}
}
pub fn drain_bunker_state() -> Option<NostrConnect> {
set_signer_kind(SignerKind::Local);
set_bunker_state(BunkerConnectionState::Idle);
take_bunker_signer()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn setting_roundtrip() {
assert_eq!(SignerKind::from_setting_str("local"), SignerKind::Local);
assert_eq!(SignerKind::from_setting_str("bunker"), SignerKind::Bunker);
assert_eq!(SignerKind::Local.as_setting_str(), "local");
assert_eq!(SignerKind::Bunker.as_setting_str(), "bunker");
assert_eq!(SignerKind::from_setting_str(""), SignerKind::Local);
assert_eq!(SignerKind::from_setting_str("garbage"), SignerKind::Local);
}
#[test]
fn atomic_state_round_trips_and_drains() {
set_signer_kind(SignerKind::Local);
set_bunker_state(BunkerConnectionState::Idle);
set_signer_kind(SignerKind::Bunker);
assert_eq!(signer_kind(), SignerKind::Bunker);
assert!(is_bunker());
set_signer_kind(SignerKind::Local);
assert_eq!(signer_kind(), SignerKind::Local);
assert!(!is_bunker());
set_signer_kind(SignerKind::Bunker);
set_bunker_state(BunkerConnectionState::Online);
let drained = drain_bunker_state();
assert!(drained.is_none());
assert_eq!(signer_kind(), SignerKind::Local);
assert_eq!(bunker_state(), BunkerConnectionState::Idle);
let drained_again = drain_bunker_state();
assert!(drained_again.is_none());
assert_eq!(signer_kind(), SignerKind::Local);
assert_eq!(bunker_state(), BunkerConnectionState::Idle);
}
#[test]
fn bunker_state_label_covers_all_variants() {
assert_eq!(BunkerConnectionState::Idle.as_label(), "idle");
assert_eq!(BunkerConnectionState::Connecting.as_label(), "connecting");
assert_eq!(BunkerConnectionState::Online.as_label(), "online");
assert_eq!(BunkerConnectionState::Offline.as_label(), "offline");
}
#[test]
fn parse_bunker_relays_returns_relays_from_bunker_uri() {
let signer_keys = Keys::generate();
let r1 = RelayUrl::parse("wss://relay1.example").unwrap();
let r2 = RelayUrl::parse("wss://relay2.example").unwrap();
let uri = NostrConnectURI::Bunker {
remote_signer_public_key: signer_keys.public_key,
relays: vec![r1.clone(), r2.clone()],
secret: None,
};
let relays = parse_bunker_relays(&uri.to_string());
assert_eq!(relays.len(), 2);
assert!(relays.iter().any(|r| r.contains("relay1.example")));
assert!(relays.iter().any(|r| r.contains("relay2.example")));
}
#[test]
fn parse_bunker_relays_returns_empty_on_invalid_input() {
assert!(parse_bunker_relays("").is_empty());
assert!(parse_bunker_relays("not a url").is_empty());
assert!(parse_bunker_relays("http://example.com").is_empty());
let client_keys = Keys::generate();
let relay = RelayUrl::parse("wss://relay.example").unwrap();
let client_uri = build_nostrconnect_uri(client_keys.public_key, vec![relay]);
assert!(parse_bunker_relays(&client_uri.to_string()).is_empty(),
"client URI must not surface as a bunker relay list");
}
#[test]
fn parse_bunker_remote_pubkey_invalid_url() {
assert!(parse_bunker_remote_pubkey("not a url").is_err());
assert!(parse_bunker_remote_pubkey("").is_err());
assert!(parse_bunker_remote_pubkey("http://example.com").is_err());
}
#[test]
fn parse_bunker_remote_pubkey_rejects_client_uri() {
let client_keys = Keys::generate();
let relay = RelayUrl::parse("wss://relay.example").unwrap();
let uri = build_nostrconnect_uri(client_keys.public_key, vec![relay]);
let err = parse_bunker_remote_pubkey(&uri.to_string())
.expect_err("client URI must be rejected");
assert!(err.contains("Client-initiated"), "unexpected error: {}", err);
}
#[test]
fn parse_bunker_remote_pubkey_normalizes_lowercase() {
let signer_keys = Keys::generate();
let relay = RelayUrl::parse("wss://relay.example").unwrap();
let uri = NostrConnectURI::Bunker {
remote_signer_public_key: signer_keys.public_key,
relays: vec![relay],
secret: None,
};
let parsed = parse_bunker_remote_pubkey(&uri.to_string())
.expect("valid bunker URI");
assert_eq!(parsed, signer_keys.public_key.to_hex().to_ascii_lowercase());
assert_eq!(parsed, parsed.to_ascii_lowercase(),
"callers may compare with == — output must already be lowercase");
}
#[test]
fn vector_metadata_carries_app_name_and_icon() {
let md = vector_metadata();
let json = serde_json::to_string(&md).expect("metadata serializes");
assert!(json.contains(VECTOR_APP_NAME),
"metadata must include app name for the signer's approval prompt; got {}", json);
assert!(json.contains("vectorapp.io"),
"metadata must reference the app URL for the signer's 'More info' link");
}
#[test]
fn nip46_perms_list_excludes_get_private_key() {
for perm in VECTOR_NIP46_PERMS {
assert!(!perm.contains("get_private_key"),
"VECTOR_NIP46_PERMS must never include get_private_key (found: {})", perm);
assert!(!perm.contains("private_key"),
"perm string looks dangerous: {}", perm);
}
}
#[test]
fn build_nostrconnect_session_appends_perms_query_param() {
let client_keys = Keys::generate();
let relay = RelayUrl::parse("wss://relay.example").unwrap();
let (_nc, uri) = build_nostrconnect_session(
client_keys,
vec![relay],
std::time::Duration::from_secs(1),
).expect("session builds");
assert!(uri.contains("perms="),
"URI must carry perms query param so signers can scope the pairing; got: {}", uri);
for perm in VECTOR_NIP46_PERMS {
assert!(uri.contains(perm),
"URI missing permission '{}': {}", perm, uri);
}
assert!(!uri.contains("get_private_key"),
"URI must never request get_private_key: {}", uri);
}
#[test]
fn build_nostrconnect_session_rejects_empty_uri() {
let client_keys = Keys::generate();
let session = build_nostrconnect_session(
client_keys,
vec![],
std::time::Duration::from_secs(1),
);
let _ = session;
}
#[test]
fn watched_signer_session_gate_and_state_transitions() {
use crate::state::{bump_session_generation, current_session_generation};
let client_keys = Keys::generate();
let relay = RelayUrl::parse("wss://relay.example").unwrap();
let signer_keys = Keys::generate();
let uri = NostrConnectURI::Bunker {
remote_signer_public_key: signer_keys.public_key,
relays: vec![relay],
secret: None,
};
let nc = NostrConnect::new(
uri,
client_keys,
std::time::Duration::from_secs(1),
None,
).expect("NostrConnect builds");
let gen_before = current_session_generation();
let watched = WatchedBunkerSigner::new(nc);
assert_eq!(watched.session_generation_for_test(), gen_before,
"WatchedBunkerSigner must capture the live session generation at construction");
set_bunker_state(BunkerConnectionState::Idle);
watched.flip(BunkerConnectionState::Online);
assert_eq!(bunker_state(), BunkerConnectionState::Online,
"flip with valid session must update bunker_state");
bump_session_generation();
set_bunker_state(BunkerConnectionState::Online);
watched.flip(BunkerConnectionState::Offline);
assert_eq!(bunker_state(), BunkerConnectionState::Online,
"flip with stale session must be a no-op");
set_bunker_state(BunkerConnectionState::Idle);
}
}