use std::path::PathBuf;
use std::sync::{Arc, Mutex, OnceLock, LazyLock, RwLock};
use std::ops::{Deref, DerefMut};
use serde::{Deserialize, Serialize};
pub mod settings;
pub mod schema;
pub mod profiles;
pub mod id_cache;
pub mod events;
pub mod attachments;
pub mod chats;
pub mod wrappers;
pub mod nip17_keys;
pub mod community;
pub mod bots;
pub use settings::{
get_sql_setting, set_sql_setting, advance_u64_setting, get_pkey, set_pkey, get_seed, set_seed, remove_setting,
get_signer_type, set_signer_type,
get_bunker_url, set_bunker_url,
get_bunker_remote_pubkey, set_bunker_remote_pubkey,
commit_bunker_account_setup,
get_nip55_user_pubkey, set_nip55_user_pubkey,
get_nip55_signer_package, set_nip55_signer_package,
commit_nip55_account_setup,
};
static APP_DATA_DIR: OnceLock<PathBuf> = OnceLock::new();
pub fn set_app_data_dir(path: PathBuf) {
let _ = APP_DATA_DIR.set(path);
}
#[cfg(test)]
pub(crate) fn shared_test_data_dir() -> &'static std::path::Path {
static DIR: OnceLock<tempfile::TempDir> = OnceLock::new();
DIR.get_or_init(|| tempfile::tempdir().expect("test data dir")).path()
}
pub fn get_app_data_dir() -> Result<&'static PathBuf, String> {
APP_DATA_DIR.get().ok_or_else(|| "App data directory not initialized".to_string())
}
static APP_VERSION: OnceLock<String> = OnceLock::new();
pub fn set_app_version(version: impl Into<String>) {
let _ = APP_VERSION.set(version.into());
}
static DOWNLOAD_DIR_OVERRIDE: OnceLock<PathBuf> = OnceLock::new();
pub fn set_download_dir(path: PathBuf) {
let _ = DOWNLOAD_DIR_OVERRIDE.set(path);
}
pub fn get_download_dir() -> PathBuf {
if let Some(installed) = DOWNLOAD_DIR_OVERRIDE.get() {
return installed.clone();
}
#[cfg(any(target_os = "macos", target_os = "linux"))]
{
if let Ok(home) = std::env::var("HOME") {
return PathBuf::from(home).join("Downloads/vector");
}
}
#[cfg(target_os = "windows")]
{
if let Ok(profile) = std::env::var("USERPROFILE") {
return PathBuf::from(profile).join("Downloads").join("vector");
}
}
if let Ok(data_dir) = get_app_data_dir() {
return data_dir.join("vector_downloads");
}
PathBuf::from("/tmp/vector_downloads")
}
static CURRENT_ACCOUNT: LazyLock<Arc<RwLock<Option<String>>>> = LazyLock::new(|| Arc::new(RwLock::new(None)));
const ACTIVE_ACCOUNT_FILE: &str = "active_account";
fn is_valid_npub(s: &str) -> bool {
if s.len() != 63 || !s.starts_with("npub1") {
return false;
}
s.bytes().skip(5).all(|c| matches!(c,
b'q' | b'p' | b'z' | b'r' | b'y' | b'9' | b'x' | b'8' |
b'g' | b'f' | b'2' | b't' | b'v' | b'd' | b'w' | b'0' |
b's' | b'3' | b'j' | b'n' | b'5' | b'4' | b'k' | b'h' |
b'c' | b'e' | b'6' | b'm' | b'u' | b'a' | b'7' | b'l'
))
}
pub fn get_current_account() -> Result<String, String> {
CURRENT_ACCOUNT.read().unwrap()
.as_ref().cloned()
.ok_or_else(|| "No active account".to_string())
}
pub fn set_current_account(npub: String) -> Result<(), String> {
*CURRENT_ACCOUNT.write().unwrap() = Some(npub.clone());
let _ = write_active_account_file(&npub);
Ok(())
}
pub fn clear_current_account_in_memory() {
*CURRENT_ACCOUNT.write().unwrap() = None;
}
pub fn read_active_account_file() -> Result<Option<String>, String> {
let app_data = match get_app_data_dir() {
Ok(p) => p,
Err(_) => return Ok(None),
};
read_active_account_file_in(app_data)
}
pub fn write_active_account_file(npub: &str) -> Result<(), String> {
let app_data = get_app_data_dir()?.clone();
write_active_account_file_in(&app_data, npub)
}
pub fn clear_active_account_file() -> Result<(), String> {
let app_data = get_app_data_dir()?;
clear_active_account_file_in(app_data)
}
pub fn list_account_npubs() -> Result<Vec<String>, String> {
let app_data = get_app_data_dir()?;
Ok(list_account_npubs_in(app_data))
}
const MARKER_MAX_BYTES: u64 = 256;
fn read_active_account_file_in(app_data: &std::path::Path) -> Result<Option<String>, String> {
use std::io::Read;
let path = app_data.join(ACTIVE_ACCOUNT_FILE);
if !path.exists() {
return Ok(None);
}
if let Ok(meta) = std::fs::metadata(&path) {
if meta.len() > MARKER_MAX_BYTES {
return Ok(None);
}
} else {
return Ok(None);
}
let mut buf = String::new();
let file = match std::fs::File::open(&path) {
Ok(f) => f,
Err(_) => return Ok(None),
};
if file.take(MARKER_MAX_BYTES).read_to_string(&mut buf).is_err() {
return Ok(None);
}
let npub = buf.trim().to_string();
if !is_valid_npub(&npub) {
return Ok(None);
}
match std::fs::symlink_metadata(app_data.join(&npub)) {
Ok(meta) if meta.file_type().is_dir() && !meta.file_type().is_symlink() => {}
_ => return Ok(None),
}
Ok(Some(npub))
}
fn write_active_account_file_in(app_data: &std::path::Path, npub: &str) -> Result<(), String> {
if !is_valid_npub(npub) {
return Err(format!("Invalid npub format: {}", npub));
}
if !app_data.exists() {
std::fs::create_dir_all(app_data)
.map_err(|e| format!("Failed to create app data dir: {}", e))?;
}
match std::fs::symlink_metadata(app_data.join(npub)) {
Ok(meta) if meta.file_type().is_dir() && !meta.file_type().is_symlink() => {}
_ => return Err(format!("Account directory missing or invalid: {}", npub)),
}
let tmp = app_data.join(format!("{}.tmp", ACTIVE_ACCOUNT_FILE));
let final_path = app_data.join(ACTIVE_ACCOUNT_FILE);
let mut payload = String::with_capacity(npub.len() + 1);
payload.push_str(npub);
payload.push('\n');
if let Err(e) = std::fs::write(&tmp, payload.as_bytes()) {
let _ = std::fs::remove_file(&tmp);
return Err(format!("Failed to write active account temp file: {}", e));
}
let mut last_err = None;
for attempt in 0..3 {
match std::fs::rename(&tmp, &final_path) {
Ok(_) => return Ok(()),
Err(e) => {
last_err = Some(e);
if attempt < 2 {
std::thread::sleep(std::time::Duration::from_millis(50));
}
}
}
}
let _ = std::fs::remove_file(&tmp);
Err(format!(
"Failed to rename active account file: {}",
last_err.map(|e| e.to_string()).unwrap_or_default()
))
}
fn clear_active_account_file_in(app_data: &std::path::Path) -> Result<(), String> {
let path = app_data.join(ACTIVE_ACCOUNT_FILE);
if path.exists() {
std::fs::remove_file(&path)
.map_err(|e| format!("Failed to remove active account file: {}", e))?;
}
Ok(())
}
fn list_account_npubs_in(app_data: &std::path::Path) -> Vec<String> {
let mut out = Vec::new();
if let Ok(entries) = std::fs::read_dir(app_data) {
for entry in entries.flatten() {
if entry.file_type().map_or(false, |ft| ft.is_dir()) {
let name = entry.file_name().to_string_lossy().to_string();
if is_valid_npub(&name) {
out.push(name);
}
}
}
}
out
}
#[cfg(test)]
mod active_account_tests {
use super::*;
use std::fs;
use tempfile::TempDir;
const VALID_A: &str = "npub16ye7evyevwnl0fc9hujsxf9zym72e063awn0pvde0huvpyec5nyq4dg4wn";
const VALID_B: &str = "npub12w73tzcqgpr2pcy4el5x60d2emeud4cyeeayynzqgg2fefzgytaqm4ktz3";
fn touch_account_dir(base: &std::path::Path, npub: &str) {
fs::create_dir_all(base.join(npub)).unwrap();
}
#[test]
fn npub_validator_accepts_canonical_form() {
assert!(is_valid_npub(VALID_A));
assert!(is_valid_npub(VALID_B));
}
#[test]
fn npub_validator_rejects_wrong_length() {
assert!(!is_valid_npub("npub1abc"));
assert!(!is_valid_npub(&format!("{}x", VALID_A)));
assert!(!is_valid_npub(""));
}
#[test]
fn npub_validator_rejects_missing_prefix() {
let body = &VALID_A[5..];
assert!(!is_valid_npub(&format!("nsec1{}", body)));
assert!(!is_valid_npub(&format!("xxxx1{}", body)));
}
#[test]
fn npub_validator_rejects_non_bech32_chars() {
for bad in ['1', 'b', 'i', 'o', 'B', 'I', 'O', '!', '*', ' '] {
let mut s = String::from(VALID_A);
s.replace_range(10..11, &bad.to_string());
assert!(!is_valid_npub(&s), "should reject character {:?}", bad);
}
}
#[test]
fn write_then_read_round_trips() {
let tmp = TempDir::new().unwrap();
touch_account_dir(tmp.path(), VALID_A);
write_active_account_file_in(tmp.path(), VALID_A).unwrap();
assert_eq!(
read_active_account_file_in(tmp.path()).unwrap(),
Some(VALID_A.to_string())
);
}
#[test]
fn write_rejects_invalid_npub() {
let tmp = TempDir::new().unwrap();
let err = write_active_account_file_in(tmp.path(), "npub1nope").unwrap_err();
assert!(err.contains("Invalid"));
assert!(!tmp.path().join(ACTIVE_ACCOUNT_FILE).exists());
assert!(!tmp.path().join(format!("{}.tmp", ACTIVE_ACCOUNT_FILE)).exists());
}
#[test]
fn write_rejects_missing_account_dir() {
let tmp = TempDir::new().unwrap();
let err = write_active_account_file_in(tmp.path(), VALID_A).unwrap_err();
assert!(err.contains("missing or invalid"),
"expected account-dir-missing error, got: {}", err);
assert!(!tmp.path().join(ACTIVE_ACCOUNT_FILE).exists());
assert!(!tmp.path().join(format!("{}.tmp", ACTIVE_ACCOUNT_FILE)).exists());
}
#[test]
fn write_rejects_symlinked_account_dir() {
let tmp = TempDir::new().unwrap();
let target = TempDir::new().unwrap();
let link = tmp.path().join(VALID_A);
#[cfg(unix)]
{
std::os::unix::fs::symlink(target.path(), &link).unwrap();
let err = write_active_account_file_in(tmp.path(), VALID_A).unwrap_err();
assert!(err.contains("missing or invalid"),
"expected symlink rejection, got: {}", err);
}
#[cfg(not(unix))]
let _ = (target, link);
}
#[test]
fn read_returns_none_when_marker_missing() {
let tmp = TempDir::new().unwrap();
assert_eq!(read_active_account_file_in(tmp.path()).unwrap(), None);
}
#[test]
fn read_returns_none_when_marker_is_garbage() {
let tmp = TempDir::new().unwrap();
fs::write(tmp.path().join(ACTIVE_ACCOUNT_FILE), b"not-an-npub\n").unwrap();
assert_eq!(read_active_account_file_in(tmp.path()).unwrap(), None);
}
#[test]
fn read_returns_none_when_account_dir_missing() {
let tmp = TempDir::new().unwrap();
fs::write(tmp.path().join(ACTIVE_ACCOUNT_FILE), VALID_A).unwrap();
assert_eq!(read_active_account_file_in(tmp.path()).unwrap(), None);
}
#[test]
fn read_returns_none_when_marker_oversized() {
let tmp = TempDir::new().unwrap();
let payload = vec![b'x'; (MARKER_MAX_BYTES + 1024) as usize];
fs::write(tmp.path().join(ACTIVE_ACCOUNT_FILE), &payload).unwrap();
assert_eq!(read_active_account_file_in(tmp.path()).unwrap(), None);
}
#[test]
fn read_trims_whitespace() {
let tmp = TempDir::new().unwrap();
touch_account_dir(tmp.path(), VALID_A);
fs::write(
tmp.path().join(ACTIVE_ACCOUNT_FILE),
format!(" {}\n", VALID_A),
).unwrap();
assert_eq!(
read_active_account_file_in(tmp.path()).unwrap(),
Some(VALID_A.to_string())
);
}
#[test]
fn read_handles_crlf_line_endings() {
let tmp = TempDir::new().unwrap();
touch_account_dir(tmp.path(), VALID_A);
fs::write(
tmp.path().join(ACTIVE_ACCOUNT_FILE),
format!("{}\r\n", VALID_A),
).unwrap();
assert_eq!(
read_active_account_file_in(tmp.path()).unwrap(),
Some(VALID_A.to_string())
);
}
#[test]
fn npub_validator_rejects_uppercase_prefix() {
let upper = format!("NPUB1{}", &VALID_A[5..]);
assert!(!is_valid_npub(&upper));
}
#[test]
fn write_then_read_round_trips_with_newline() {
let tmp = TempDir::new().unwrap();
touch_account_dir(tmp.path(), VALID_A);
write_active_account_file_in(tmp.path(), VALID_A).unwrap();
let raw = fs::read_to_string(tmp.path().join(ACTIVE_ACCOUNT_FILE)).unwrap();
assert!(raw.ends_with('\n'));
assert_eq!(
read_active_account_file_in(tmp.path()).unwrap(),
Some(VALID_A.to_string())
);
}
#[test]
fn write_overwrites_previous_marker_atomically() {
let tmp = TempDir::new().unwrap();
touch_account_dir(tmp.path(), VALID_A);
touch_account_dir(tmp.path(), VALID_B);
write_active_account_file_in(tmp.path(), VALID_A).unwrap();
write_active_account_file_in(tmp.path(), VALID_B).unwrap();
assert_eq!(
read_active_account_file_in(tmp.path()).unwrap(),
Some(VALID_B.to_string())
);
assert!(!tmp.path().join(format!("{}.tmp", ACTIVE_ACCOUNT_FILE)).exists());
}
#[test]
fn clear_removes_marker_and_is_idempotent() {
let tmp = TempDir::new().unwrap();
touch_account_dir(tmp.path(), VALID_A);
write_active_account_file_in(tmp.path(), VALID_A).unwrap();
assert!(tmp.path().join(ACTIVE_ACCOUNT_FILE).exists());
clear_active_account_file_in(tmp.path()).unwrap();
assert!(!tmp.path().join(ACTIVE_ACCOUNT_FILE).exists());
clear_active_account_file_in(tmp.path()).unwrap();
}
#[test]
fn list_npubs_finds_valid_dirs_only() {
let tmp = TempDir::new().unwrap();
touch_account_dir(tmp.path(), VALID_A);
touch_account_dir(tmp.path(), VALID_B);
fs::create_dir_all(tmp.path().join("npub1tooshort")).unwrap();
fs::create_dir_all(tmp.path().join("not-an-npub-dir")).unwrap();
fs::create_dir_all(tmp.path().join("tor")).unwrap();
fs::write(tmp.path().join(ACTIVE_ACCOUNT_FILE), VALID_A).unwrap();
let mut found = list_account_npubs_in(tmp.path());
found.sort();
let mut expected = vec![VALID_A.to_string(), VALID_B.to_string()];
expected.sort();
assert_eq!(found, expected);
}
#[test]
fn list_npubs_skips_dirs_containing_invalid_chars() {
let tmp = TempDir::new().unwrap();
let mut bogus = String::from(VALID_A);
bogus.replace_range(10..11, "b");
fs::create_dir_all(tmp.path().join(&bogus)).unwrap();
let found = list_account_npubs_in(tmp.path());
assert!(found.is_empty(), "found unexpected entries: {:?}", found);
}
#[test]
fn write_creates_app_data_dir_if_missing() {
let tmp = TempDir::new().unwrap();
let nested = tmp.path().join("does/not/exist/yet");
std::fs::create_dir_all(&nested).unwrap();
touch_account_dir(&nested, VALID_A);
write_active_account_file_in(&nested, VALID_A).unwrap();
assert!(nested.join(ACTIVE_ACCOUNT_FILE).exists());
}
}
pub struct Session {
id: u64,
db_path: Option<PathBuf>,
read_pool: Mutex<Vec<rusqlite::Connection>>,
write_conn: Mutex<Option<rusqlite::Connection>>,
chat_state: Arc<tokio::sync::Mutex<crate::state::ChatState>>,
scoped: RwLock<std::collections::HashMap<std::any::TypeId, Arc<dyn std::any::Any + Send + Sync>>>,
stopped: SessionStop,
}
#[derive(Default)]
struct SessionStop {
flag: std::sync::atomic::AtomicBool,
wake: tokio::sync::Notify,
}
impl Session {
fn empty() -> Arc<Self> {
Arc::new(Session {
id: next_session_id(),
db_path: None,
read_pool: Mutex::new(Vec::new()),
write_conn: Mutex::new(None),
chat_state: Arc::new(tokio::sync::Mutex::new(crate::state::ChatState::new())),
scoped: RwLock::new(std::collections::HashMap::new()),
stopped: SessionStop::default(),
})
}
fn bound(db_path: PathBuf) -> Arc<Self> {
Arc::new(Session {
id: next_session_id(),
db_path: Some(db_path),
read_pool: Mutex::new(Vec::new()),
write_conn: Mutex::new(None),
chat_state: Arc::new(tokio::sync::Mutex::new(crate::state::ChatState::new())),
scoped: RwLock::new(std::collections::HashMap::new()),
stopped: SessionStop::default(),
})
}
fn rebound(&self, db_path: PathBuf) -> Arc<Self> {
Arc::new(Session {
id: self.id,
db_path: Some(db_path),
read_pool: Mutex::new(Vec::new()),
write_conn: Mutex::new(None),
chat_state: self.chat_state.clone(),
scoped: RwLock::new(self.scoped.read().unwrap_or_else(|e| e.into_inner()).clone()),
stopped: SessionStop::default(),
})
}
pub fn scoped<K: 'static, T: Default + Send + Sync + 'static>(self: &Arc<Self>) -> Arc<T> {
let key = std::any::TypeId::of::<(K, T)>();
if let Some(existing) = self.scoped.read().unwrap_or_else(|e| e.into_inner()).get(&key) {
return existing.clone().downcast::<T>().expect("keyed by its own TypeId");
}
let mut map = self.scoped.write().unwrap_or_else(|e| e.into_inner());
map.entry(key)
.or_insert_with(|| Arc::new(T::default()) as Arc<dyn std::any::Any + Send + Sync>)
.clone()
.downcast::<T>()
.expect("keyed by its own TypeId")
}
pub fn id(&self) -> u64 {
self.id
}
pub fn stopped(&self) -> bool {
self.stopped.flag.load(std::sync::atomic::Ordering::Acquire)
}
pub async fn on_stop(&self) {
loop {
let waiting = self.stopped.wake.notified();
if self.stopped() {
return;
}
waiting.await;
if self.stopped() {
return;
}
}
}
fn stop(&self) {
self.stopped.flag.store(true, std::sync::atomic::Ordering::Release);
self.stopped.wake.notify_waiters();
}
pub fn is_live(&self) -> bool {
self.id == CURRENT_SESSION.read().unwrap_or_else(|e| e.into_inner()).id
}
pub fn chat_state(&self) -> Arc<tokio::sync::Mutex<crate::state::ChatState>> {
self.chat_state.clone()
}
fn path(&self) -> Result<PathBuf, String> {
match &self.db_path {
Some(p) => Ok(p.clone()),
None => get_current_db_path(),
}
}
pub fn acquire_read(self: &Arc<Self>) -> Result<ConnectionGuard, String> {
if let Ok(mut pool) = self.read_pool.lock() {
if let Some(conn) = pool.pop() {
return Ok(ConnectionGuard::new(conn, self.clone()));
}
}
let conn = create_connection(&self.path()?)?;
Ok(ConnectionGuard::new(conn, self.clone()))
}
pub fn acquire_write(self: &Arc<Self>) -> Result<WriteConnectionGuard, String> {
{
let mut slot = self.write_conn.lock().unwrap_or_else(|e| e.into_inner());
if let Some(conn) = slot.take() {
return Ok(WriteConnectionGuard::new(conn, self.clone()));
}
}
let conn = create_connection(&self.path()?)?;
Ok(WriteConnectionGuard::new(conn, self.clone()))
}
}
static CURRENT_SESSION: LazyLock<RwLock<Arc<Session>>> = LazyLock::new(|| RwLock::new(Session::empty()));
tokio::task_local! {
static TASK_SESSION: Arc<Session>;
}
pub fn current_session() -> Arc<Session> {
TASK_SESSION
.try_with(Arc::clone)
.unwrap_or_else(|_| CURRENT_SESSION.read().unwrap_or_else(|e| e.into_inner()).clone())
}
pub fn scoped<F: std::future::Future>(fut: F) -> impl std::future::Future<Output = F::Output> {
TASK_SESSION.scope(current_session(), Box::pin(fut))
}
pub fn scoped_result<T, E, F>(fut: F) -> impl std::future::Future<Output = Result<T, E>>
where
F: std::future::Future<Output = Result<T, E>>,
E: From<String>,
{
let session = current_session();
let id = session.id;
let bound = TASK_SESSION.scope(session, Box::pin(fut));
async move {
let out = bound.await;
if id != CURRENT_SESSION.read().unwrap_or_else(|e| e.into_inner()).id {
return Err(E::from("account changed during the operation".to_string()));
}
out
}
}
pub fn with_session<F: std::future::Future>(
session: Arc<Session>,
fut: F,
) -> impl std::future::Future<Output = F::Output> {
TASK_SESSION.scope(session, Box::pin(fut))
}
pub fn spawn_bound<F>(fut: F) -> tokio::task::JoinHandle<F::Output>
where
F: std::future::Future + Send + 'static,
F::Output: Send + 'static,
{
let session = current_session();
tokio::spawn(TASK_SESSION.scope(session, fut))
}
impl std::fmt::Debug for Session {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Session").field("id", &self.id).field("db", &self.db_path).finish()
}
}
pub fn session_stopped() -> bool {
current_session().stopped()
}
pub fn current_session_id() -> u64 {
current_session().id
}
pub fn session_is_live() -> bool {
current_session().id == CURRENT_SESSION.read().unwrap_or_else(|e| e.into_inner()).id
}
fn next_session_id() -> u64 {
static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
}
fn replace_session() {
install(Session::empty());
}
fn install(next: Arc<Session>) {
let mut current = CURRENT_SESSION.write().unwrap_or_else(|e| e.into_inner());
if current.id != next.id {
current.stop();
}
*current = next;
}
pub struct ConnectionGuard {
conn: Option<rusqlite::Connection>,
session: Arc<Session>,
}
impl ConnectionGuard {
fn new(conn: rusqlite::Connection, session: Arc<Session>) -> Self {
Self { conn: Some(conn), session }
}
}
impl Deref for ConnectionGuard {
type Target = rusqlite::Connection;
fn deref(&self) -> &Self::Target { self.conn.as_ref().expect("Connection already taken") }
}
impl DerefMut for ConnectionGuard {
fn deref_mut(&mut self) -> &mut Self::Target { self.conn.as_mut().expect("Connection already taken") }
}
impl Drop for ConnectionGuard {
fn drop(&mut self) {
if let Some(conn) = self.conn.take() {
if let Ok(mut pool) = self.session.read_pool.lock() {
pool.push(conn);
}
}
}
}
pub struct WriteConnectionGuard {
conn: Option<rusqlite::Connection>,
session: Arc<Session>,
}
impl WriteConnectionGuard {
fn new(conn: rusqlite::Connection, session: Arc<Session>) -> Self {
Self { conn: Some(conn), session }
}
}
impl Deref for WriteConnectionGuard {
type Target = rusqlite::Connection;
fn deref(&self) -> &Self::Target { self.conn.as_ref().expect("Write connection already taken") }
}
impl DerefMut for WriteConnectionGuard {
fn deref_mut(&mut self) -> &mut Self::Target { self.conn.as_mut().expect("Write connection already taken") }
}
impl Drop for WriteConnectionGuard {
fn drop(&mut self) {
if let Some(conn) = self.conn.take() {
if let Ok(mut slot) = self.session.write_conn.lock() {
if slot.is_none() {
*slot = Some(conn);
}
}
}
}
}
pub fn account_dir(npub: &str) -> Result<PathBuf, String> {
if !is_valid_npub(npub) {
return Err(format!("Invalid npub format: {}", npub));
}
Ok(get_app_data_dir()?.join(npub))
}
fn get_current_db_path() -> Result<PathBuf, String> {
let npub = get_current_account()?;
Ok(account_dir(&npub)?.join("vector.db"))
}
fn create_connection(path: &PathBuf) -> Result<rusqlite::Connection, String> {
const OPEN_RETRIES: u32 = 4;
let mut last_err = String::new();
for attempt in 0..OPEN_RETRIES {
match open_connection(path) {
Ok(conn) => return Ok(conn),
Err(e) if e.contains("locked") || e.contains("busy") => {
last_err = e;
std::thread::sleep(std::time::Duration::from_millis(50 * u64::from(attempt + 1)));
}
Err(e) => return Err(e),
}
}
Err(last_err)
}
fn open_connection(path: &PathBuf) -> Result<rusqlite::Connection, String> {
let conn = rusqlite::Connection::open(path)
.map_err(|e| format!("Failed to open database: {}", e))?;
conn.execute_batch("PRAGMA busy_timeout=5000;")
.map_err(|e| format!("Failed to set busy_timeout: {}", e))?;
conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL; PRAGMA foreign_keys=ON; PRAGMA cache_size=-16000; PRAGMA temp_store=MEMORY;")
.map_err(|e| format!("Failed to set pragmas: {}", e))?;
Ok(conn)
}
pub fn get_db_connection_guard_static() -> Result<ConnectionGuard, String> {
current_session().acquire_read()
}
#[cfg(test)]
pub(crate) static DB_TEST_GUARD: std::sync::Mutex<()> = std::sync::Mutex::new(());
pub fn get_write_connection_guard_static() -> Result<WriteConnectionGuard, String> {
current_session().acquire_write()
}
const LAST_APP_VERSION_KEY: &str = "last_app_version";
#[derive(Debug, Clone, serde::Serialize)]
pub struct DowngradeBlock {
pub db_schema: u32,
pub supported_schema: u32,
pub last_app_version: Option<String>,
}
impl std::fmt::Display for DowngradeBlock {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "This account was last opened by a newer version of Vector")?;
if let Some(version) = &self.last_app_version {
write!(f, " ({version})")?;
}
write!(
f,
". Its database is at schema {} and this build only understands {}. \
Opening it would corrupt your messages, so Vector has stopped. \
Reinstall the newer version to continue.",
self.db_schema, self.supported_schema
)
}
}
fn downgrade_block(conn: &rusqlite::Connection) -> Option<DowngradeBlock> {
let db_schema = schema::applied_migration_high_water(conn);
if db_schema <= schema::HIGHEST_MIGRATION_ID {
return None;
}
Some(DowngradeBlock {
db_schema,
supported_schema: schema::HIGHEST_MIGRATION_ID,
last_app_version: conn
.query_row(
"SELECT value FROM settings WHERE key = ?1",
rusqlite::params![LAST_APP_VERSION_KEY],
|row| row.get::<_, String>(0),
)
.ok(),
})
}
pub fn inspect_downgrade(npub: &str) -> Result<Option<DowngradeBlock>, String> {
let db_path = account_dir(npub)?.join("vector.db");
if !db_path.exists() {
return Ok(None);
}
let conn = create_connection(&db_path)?;
Ok(downgrade_block(&conn))
}
pub fn init_database(npub: &str) -> Result<(), String> {
let profile_dir = account_dir(npub)?;
if !profile_dir.exists() {
std::fs::create_dir_all(&profile_dir)
.map_err(|e| format!("Failed to create profile directory: {}", e))?;
}
let db_path = profile_dir.join("vector.db");
let mut conn = create_connection(&db_path)?;
if let Some(block) = downgrade_block(&conn) {
return Err(block.to_string());
}
conn.execute_batch(schema::SQL_SCHEMA)
.map_err(|e| format!("Failed to create schema: {}", e))?;
schema::run_migrations(&mut conn)?;
if let Some(version) = APP_VERSION.get() {
let _ = conn.execute(
"INSERT OR REPLACE INTO settings (key, value) VALUES (?1, ?2)",
rusqlite::params![LAST_APP_VERSION_KEY, version],
);
}
let _ = conn.execute_batch("PRAGMA optimize=0x10002;");
{
let mut stmt = conn.prepare("SELECT event_id FROM deleted_messages")
.map_err(|e| format!("tombstone seed prepare: {}", e))?;
let ids: Vec<String> = stmt.query_map([], |row| row.get::<_, String>(0))
.map_err(|e| format!("tombstone seed query: {}", e))?
.filter_map(|r| r.ok())
.collect();
drop(stmt);
crate::state::seed_message_tombstones(ids);
}
let mls_dir = profile_dir.join("mls");
if mls_dir.exists() {
match std::fs::remove_dir_all(&mls_dir) {
Ok(()) => crate::log_info!("[db] purged orphaned MLS store for account"),
Err(e) => crate::log_warn!("[db] could not purge orphaned MLS store: {}", e),
}
}
let session = {
let next = {
let current = CURRENT_SESSION.read().unwrap_or_else(|e| e.into_inner());
match current.db_path.as_deref() {
Some(p) if p != db_path => Session::bound(db_path.clone()),
_ => current.rebound(db_path.clone()),
}
};
install(next.clone());
next
};
if let Ok(mut pool) = session.read_pool.lock() {
for _ in 0..4 {
if let Ok(c) = create_connection(&db_path) {
pool.push(c);
}
}
}
let write_conn = create_connection(&db_path)?;
*session.write_conn.lock().unwrap_or_else(|e| e.into_inner()) = Some(write_conn);
#[cfg(feature = "tor")]
{
let enabled = create_connection(&db_path)
.ok()
.and_then(|c| {
c.query_row(
"SELECT value FROM settings WHERE key = 'tor_enabled'",
[],
|row| row.get::<_, String>(0),
)
.ok()
})
.map(|v| v == "1" || v == "true")
.unwrap_or(false);
crate::tor::set_tor_enabled_pref(enabled);
}
Ok(())
}
pub fn close_database() {
replace_session();
}
pub fn optimize_database() {
let session = current_session();
let guard = session.write_conn.lock().unwrap_or_else(|e| e.into_inner());
if let Some(conn) = guard.as_ref() {
let _ = conn.execute_batch("PRAGMA optimize;");
}
}
pub fn get_accounts() -> Result<Vec<String>, String> {
let app_data = get_app_data_dir()?;
let mut accounts = Vec::new();
if let Ok(entries) = std::fs::read_dir(app_data) {
for entry in entries.flatten() {
if entry.file_type().map_or(false, |ft| ft.is_dir()) {
let name = entry.file_name().to_string_lossy().to_string();
if name.starts_with("npub1") {
if entry.path().join("vector.db").exists() {
accounts.push(name);
}
}
}
}
}
Ok(accounts)
}
pub fn get_profile_directory(npub: &str) -> Result<PathBuf, String> {
if !npub.starts_with("npub1") {
return Err(format!("Invalid npub format: {}", npub));
}
let dir = account_dir(npub)?;
if !dir.exists() {
std::fs::create_dir_all(&dir)
.map_err(|e| format!("Failed to create profile directory: {}", e))?;
}
Ok(dir)
}
pub fn get_database_path(npub: &str) -> Result<PathBuf, String> {
Ok(get_profile_directory(npub)?.join("vector.db"))
}
pub fn clear_id_caches() {
id_cache::clear_id_caches();
community::clear_banlist_cache();
community::clear_channel_community_cache();
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[repr(u8)]
pub enum SystemEventType {
MemberLeft = 0,
MemberJoined = 1,
MemberRemoved = 2,
WallpaperChanged = 3,
}
impl SystemEventType {
pub fn display_message(&self, display_name: &str) -> String {
match self {
SystemEventType::MemberLeft => format!("{} has left", display_name),
SystemEventType::MemberJoined => format!("{} has joined", display_name),
SystemEventType::MemberRemoved => format!("{} was removed", display_name),
SystemEventType::WallpaperChanged => format!("{} changed the wallpaper", display_name),
}
}
pub fn as_u8(&self) -> u8 { *self as u8 }
}
#[cfg(test)]
mod pool_generation_tests {
use super::*;
fn fake_conn() -> rusqlite::Connection {
rusqlite::Connection::open_in_memory().unwrap()
}
#[test]
fn close_database_installs_a_fresh_session() {
let _guard = DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
let before = current_session();
close_database();
let after = current_session();
assert!(
!Arc::ptr_eq(&before, &after),
"close_database must install a NEW session — the old one is what in-flight guards return to"
);
}
#[test]
fn a_guard_returns_its_connection_to_its_own_session() {
let session = Session::empty();
drop(ConnectionGuard::new(fake_conn(), session.clone()));
assert_eq!(session.read_pool.lock().unwrap().len(), 1, "the connection goes home");
}
#[test]
fn a_guard_outstanding_across_a_swap_returns_to_the_session_it_came_from() {
let old = Session::empty();
let new_session = Session::empty();
let guard = ConnectionGuard::new(fake_conn(), old.clone());
drop(guard);
assert_eq!(old.read_pool.lock().unwrap().len(), 1, "returned to the session it was taken from");
assert_eq!(new_session.read_pool.lock().unwrap().len(), 0, "never reachable from the new account");
}
#[test]
fn a_pool_miss_after_a_swap_opens_the_sessions_own_database() {
let dir = tempfile::tempdir().unwrap();
let path_a = dir.path().join("a.db");
let path_b = dir.path().join("b.db");
let session_a = Session::bound(path_a.clone());
let _unrelated = Session::bound(path_b.clone());
let guard = session_a.acquire_read().expect("acquire against the held session");
let opened = guard.path().expect("a file-backed connection").to_string();
assert!(
opened.ends_with("a.db") && !opened.ends_with("b.db"),
"a miss opens the session's OWN database, never the incoming account's (opened {opened})"
);
assert!(!Arc::ptr_eq(&session_a, ¤t_session()), "the live session really did move on");
}
async fn bound_to<F: std::future::Future>(session: Arc<Session>, fut: F) -> F::Output {
TASK_SESSION.scope(session, fut).await
}
#[test]
fn per_account_tasks_are_spawned_bound_to_their_account() {
crate::spawn_audit::assert_all_spawns_bound(std::path::Path::new(env!("CARGO_MANIFEST_DIR")), &[]);
}
#[tokio::test]
async fn a_bound_task_keeps_its_account_across_a_swap() {
let dir = tempfile::tempdir().unwrap();
let a = Session::bound(dir.path().join("a.db"));
let seen = bound_to(a.clone(), async {
tokio::task::yield_now().await;
current_session()
})
.await;
assert!(Arc::ptr_eq(&seen, &a), "the task still sees the account it started under");
assert!(!Arc::ptr_eq(&seen, ¤t_session()), "the live account is not reachable from it");
}
#[tokio::test]
async fn a_bound_tasks_chat_writes_cannot_reach_the_new_account() {
use crate::chat::{Chat, ChatType};
let dir = tempfile::tempdir().unwrap();
let a = Session::bound(dir.path().join("a.db"));
let live_before = current_session().chat_state().lock().await.chats.len();
bound_to(a.clone(), async {
tokio::task::yield_now().await;
crate::state::STATE.lock().await.chats.push(Chat::new("a-chat".into(), ChatType::DirectMessage, Vec::new()));
})
.await;
assert_eq!(
a.chat_state().lock().await.chats.len(),
1,
"it landed in the state of the account the task began under"
);
assert_eq!(
current_session().chat_state().lock().await.chats.len(),
live_before,
"and nothing reached the account on screen"
);
}
#[tokio::test]
async fn a_bound_task_cannot_publish_through_the_new_accounts_client() {
use nostr_sdk::prelude::*;
let dir = tempfile::tempdir().unwrap();
let a = Session::bound(dir.path().join("a.db"));
let a_identity = Keys::generate().public_key();
let seen = bound_to(a.clone(), async move {
crate::state::set_my_public_key(a_identity);
tokio::task::yield_now().await;
crate::state::my_public_key()
})
.await;
assert_eq!(seen, Some(a_identity), "the task signs as the account it began under");
assert_ne!(crate::state::my_public_key(), Some(a_identity), "and the live account is someone else");
}
#[test]
fn binding_an_unbound_session_to_a_database_keeps_what_it_holds() {
let dir = tempfile::tempdir().unwrap();
let staging = Session::empty();
let held = staging.scoped::<Session, Mutex<u8>>();
*held.lock().unwrap() = 7;
let promoted = staging.rebound(dir.path().join("a.db"));
assert_eq!(*promoted.scoped::<Session, Mutex<u8>>().lock().unwrap(), 7, "the login survives being bound");
assert!(promoted.db_path.is_some(), "and it now has a database");
assert_eq!(promoted.id, staging.id, "and it is still the same account, so its tasks keep painting");
}
#[tokio::test]
async fn a_bound_task_paints_nothing_into_the_account_on_screen() {
let dir = tempfile::tempdir().unwrap();
let previous = Session::bound(dir.path().join("previous.db"));
assert!(session_is_live(), "work for the account on screen paints");
let painted = bound_to(previous, async {
tokio::task::yield_now().await;
session_is_live()
})
.await;
assert!(!painted, "a task bound to another account paints nothing");
assert!(session_is_live(), "and the account now on screen still does");
}
#[tokio::test]
async fn switching_accounts_tells_the_previous_one_to_stop() {
let _serialized = DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
let previous = current_session();
assert!(!previous.stopped(), "running work is not told to stop");
let waiter = { let p = previous.clone(); tokio::spawn(async move { p.on_stop().await }) };
tokio::task::yield_now().await;
assert!(!waiter.is_finished(), "nothing to report while the account is current");
close_database();
assert!(previous.stopped(), "the outgoing account is told to stop");
assert!(!current_session().stopped(), "the incoming one is not");
tokio::time::timeout(std::time::Duration::from_secs(5), waiter)
.await
.expect("on_stop resolves on the switch")
.expect("without panicking");
}
#[test]
fn re_initialising_the_same_account_does_not_tell_it_to_stop() {
let dir = tempfile::tempdir().unwrap();
let staging = Session::empty();
let promoted = staging.rebound(dir.path().join("a.db"));
assert!(!staging.stopped(), "binding a session is not switching away from it");
assert_eq!(promoted.id, staging.id);
}
#[test]
fn binding_a_future_does_not_embed_it() {
let fat = async {
let block = [0u8; 8192];
tokio::task::yield_now().await;
block[0]
};
let fat_size = std::mem::size_of_val(&fat);
assert!(fat_size >= 8192, "the body really is large ({fat_size})");
let bound = scoped(fat);
let bound_size = std::mem::size_of_val(&bound);
assert!(
bound_size < 1024,
"binding must cost a pointer, not a copy of the body \
(body {fat_size} bytes, bound {bound_size})"
);
}
#[test]
fn a_dropped_session_closes_its_pool() {
let session = Session::empty();
drop(ConnectionGuard::new(fake_conn(), session.clone()));
assert_eq!(Arc::strong_count(&session), 1, "the guard released its reference");
drop(session); }
#[test]
fn a_stale_write_guard_cannot_clobber_the_new_accounts_connection() {
let old = Session::empty();
let new_session = Session::empty();
let stale_guard = WriteConnectionGuard::new(fake_conn(), old.clone());
*new_session.write_conn.lock().unwrap() = Some(fake_conn());
drop(stale_guard);
assert!(
new_session.write_conn.lock().unwrap().is_some(),
"the new account's write connection is untouched"
);
assert!(
old.write_conn.lock().unwrap().is_some(),
"the stale guard returned to its own session's slot"
);
}
#[test]
fn a_new_accounts_empty_write_slot_stays_empty() {
let old = Session::empty();
let new_session = Session::empty();
let stale_guard = WriteConnectionGuard::new(fake_conn(), old.clone());
drop(stale_guard);
assert!(
new_session.write_conn.lock().unwrap().is_none(),
"the new account's slot is untouched by the previous account's guard"
);
}
}
#[cfg(test)]
mod downgrade_tests {
use super::*;
fn test_account() -> (tempfile::TempDir, std::sync::MutexGuard<'static, ()>, String) {
let guard = DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
close_database();
clear_id_caches();
let tmp = tempfile::tempdir().unwrap();
static N: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(61_000);
let n = N.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
const B: &[u8] = b"qpzry9x8gf2tvdw0s3jn54khce6mua7l";
let mut acct = String::from("npub1");
let mut v = n as usize;
for _ in 0..58 {
acct.push(B[v % 32] as char);
v = v / 32 + 7;
}
set_app_data_dir(crate::db::shared_test_data_dir().to_path_buf());
set_current_account(acct.clone()).unwrap();
(tmp, guard, acct)
}
#[tokio::test]
async fn re_initialising_the_same_account_keeps_its_loaded_state() {
use crate::chat::{Chat, ChatType};
let (_dir, _lock, acct) = test_account();
init_database(&acct).unwrap();
crate::state::STATE.lock().await.chats.push(Chat::new("kept".into(), ChatType::DirectMessage, Vec::new()));
init_database(&acct).unwrap();
assert_eq!(crate::state::STATE.lock().await.chats.len(), 1, "same account, same in-memory state");
}
#[test]
fn an_equal_schema_opens_normally() {
let (_tmp, _guard, acct) = test_account();
init_database(&acct).unwrap();
assert!(inspect_downgrade(&acct).unwrap().is_none());
init_database(&acct).unwrap();
assert!(inspect_downgrade(&acct).unwrap().is_none());
}
#[test]
fn a_missing_database_is_not_a_downgrade() {
let (_tmp, _guard, acct) = test_account();
assert!(inspect_downgrade(&acct).unwrap().is_none());
assert!(!account_dir(&acct).unwrap().join("vector.db").exists());
}
#[test]
fn a_newer_schema_blocks_the_open_and_names_the_build() {
let (_tmp, _guard, acct) = test_account();
init_database(&acct).unwrap();
let db_path = account_dir(&acct).unwrap().join("vector.db");
{
let conn = create_connection(&db_path).unwrap();
conn.execute(
"INSERT OR REPLACE INTO schema_migrations (id, applied_at) VALUES (?1, 0)",
rusqlite::params![schema::HIGHEST_MIGRATION_ID + 1],
)
.unwrap();
conn.execute(
"INSERT OR REPLACE INTO settings (key, value) VALUES (?1, ?2)",
rusqlite::params![LAST_APP_VERSION_KEY, "9.9.9"],
)
.unwrap();
}
close_database();
let block = inspect_downgrade(&acct)
.unwrap()
.expect("a higher migration id must read as a downgrade");
assert_eq!(block.db_schema, schema::HIGHEST_MIGRATION_ID + 1);
assert_eq!(block.supported_schema, schema::HIGHEST_MIGRATION_ID);
assert_eq!(block.last_app_version.as_deref(), Some("9.9.9"));
let err = init_database(&acct).unwrap_err();
assert!(err.contains("9.9.9"), "must name the newer build: {err}");
}
#[test]
fn a_blocked_open_writes_nothing() {
let (_tmp, _guard, acct) = test_account();
init_database(&acct).unwrap();
let db_path = account_dir(&acct).unwrap().join("vector.db");
{
let conn = create_connection(&db_path).unwrap();
conn.execute(
"INSERT OR REPLACE INTO schema_migrations (id, applied_at) VALUES (?1, 0)",
rusqlite::params![schema::HIGHEST_MIGRATION_ID + 5],
)
.unwrap();
conn.execute("DROP TABLE IF EXISTS settings", []).unwrap();
}
close_database();
assert!(init_database(&acct).is_err());
let conn = create_connection(&db_path).unwrap();
let exists: bool = conn
.query_row(
"SELECT 1 FROM sqlite_master WHERE type='table' AND name='settings'",
[],
|_| Ok(true),
)
.unwrap_or(false);
assert!(!exists, "a blocked open must not write to the database");
}
}