1use std::path::PathBuf;
11use std::sync::{Arc, Mutex, OnceLock, LazyLock, RwLock};
12use std::sync::atomic::{AtomicU64, Ordering};
13use std::ops::{Deref, DerefMut};
14use std::collections::HashMap;
15
16use serde::{Deserialize, Serialize};
17
18pub mod settings;
19pub mod schema;
20pub mod profiles;
21pub mod id_cache;
22pub mod events;
23pub mod chats;
24pub mod wrappers;
25pub mod nip17_keys;
26pub mod community;
27
28pub use settings::{
29 get_sql_setting, set_sql_setting, get_pkey, set_pkey, get_seed, set_seed, remove_setting,
30 get_signer_type, set_signer_type,
31 get_bunker_url, set_bunker_url,
32 get_bunker_remote_pubkey, set_bunker_remote_pubkey,
33 commit_bunker_account_setup,
34};
35
36static APP_DATA_DIR: OnceLock<PathBuf> = OnceLock::new();
41
42pub fn set_app_data_dir(path: PathBuf) {
43 let _ = APP_DATA_DIR.set(path);
44}
45
46pub fn get_app_data_dir() -> Result<&'static PathBuf, String> {
47 APP_DATA_DIR.get().ok_or_else(|| "App data directory not initialized".to_string())
48}
49
50static DOWNLOAD_DIR_OVERRIDE: OnceLock<PathBuf> = OnceLock::new();
55
56pub fn set_download_dir(path: PathBuf) {
60 let _ = DOWNLOAD_DIR_OVERRIDE.set(path);
61}
62
63pub fn get_download_dir() -> PathBuf {
70 if let Some(installed) = DOWNLOAD_DIR_OVERRIDE.get() {
71 return installed.clone();
72 }
73 #[cfg(any(target_os = "macos", target_os = "linux"))]
74 {
75 if let Ok(home) = std::env::var("HOME") {
76 return PathBuf::from(home).join("Downloads/vector");
77 }
78 }
79 #[cfg(target_os = "windows")]
80 {
81 if let Ok(profile) = std::env::var("USERPROFILE") {
82 return PathBuf::from(profile).join("Downloads").join("vector");
83 }
84 }
85 if let Ok(data_dir) = get_app_data_dir() {
87 return data_dir.join("vector_downloads");
88 }
89 PathBuf::from("/tmp/vector_downloads")
90}
91
92static CURRENT_ACCOUNT: LazyLock<Arc<RwLock<Option<String>>>> = LazyLock::new(|| Arc::new(RwLock::new(None)));
97const ACTIVE_ACCOUNT_FILE: &str = "active_account";
102
103fn is_valid_npub(s: &str) -> bool {
105 if s.len() != 63 || !s.starts_with("npub1") {
106 return false;
107 }
108 s.bytes().skip(5).all(|c| matches!(c,
109 b'q' | b'p' | b'z' | b'r' | b'y' | b'9' | b'x' | b'8' |
110 b'g' | b'f' | b'2' | b't' | b'v' | b'd' | b'w' | b'0' |
111 b's' | b'3' | b'j' | b'n' | b'5' | b'4' | b'k' | b'h' |
112 b'c' | b'e' | b'6' | b'm' | b'u' | b'a' | b'7' | b'l'
113 ))
114}
115
116pub fn get_current_account() -> Result<String, String> {
117 CURRENT_ACCOUNT.read().unwrap()
118 .as_ref().cloned()
119 .ok_or_else(|| "No active account".to_string())
120}
121
122pub fn set_current_account(npub: String) -> Result<(), String> {
129 *CURRENT_ACCOUNT.write().unwrap() = Some(npub.clone());
130 let _ = write_active_account_file(&npub);
131 Ok(())
132}
133
134pub fn clear_current_account_in_memory() {
138 *CURRENT_ACCOUNT.write().unwrap() = None;
139}
140
141pub fn read_active_account_file() -> Result<Option<String>, String> {
145 let app_data = match get_app_data_dir() {
146 Ok(p) => p,
147 Err(_) => return Ok(None),
148 };
149 read_active_account_file_in(app_data)
150}
151
152pub fn write_active_account_file(npub: &str) -> Result<(), String> {
154 let app_data = get_app_data_dir()?.clone();
155 write_active_account_file_in(&app_data, npub)
156}
157
158pub fn clear_active_account_file() -> Result<(), String> {
160 let app_data = get_app_data_dir()?;
161 clear_active_account_file_in(app_data)
162}
163
164pub fn list_account_npubs() -> Result<Vec<String>, String> {
168 let app_data = get_app_data_dir()?;
169 Ok(list_account_npubs_in(app_data))
170}
171
172const MARKER_MAX_BYTES: u64 = 256;
179
180fn read_active_account_file_in(app_data: &std::path::Path) -> Result<Option<String>, String> {
181 use std::io::Read;
182
183 let path = app_data.join(ACTIVE_ACCOUNT_FILE);
184 if !path.exists() {
185 return Ok(None);
186 }
187 if let Ok(meta) = std::fs::metadata(&path) {
191 if meta.len() > MARKER_MAX_BYTES {
192 return Ok(None);
193 }
194 } else {
195 return Ok(None);
196 }
197 let mut buf = String::new();
198 let file = match std::fs::File::open(&path) {
199 Ok(f) => f,
200 Err(_) => return Ok(None),
201 };
202 if file.take(MARKER_MAX_BYTES).read_to_string(&mut buf).is_err() {
203 return Ok(None);
204 }
205 let npub = buf.trim().to_string();
206 if !is_valid_npub(&npub) {
207 return Ok(None);
208 }
209 match std::fs::symlink_metadata(app_data.join(&npub)) {
216 Ok(meta) if meta.file_type().is_dir() && !meta.file_type().is_symlink() => {}
217 _ => return Ok(None),
218 }
219 Ok(Some(npub))
220}
221
222fn write_active_account_file_in(app_data: &std::path::Path, npub: &str) -> Result<(), String> {
223 if !is_valid_npub(npub) {
224 return Err(format!("Invalid npub format: {}", npub));
225 }
226 if !app_data.exists() {
227 std::fs::create_dir_all(app_data)
228 .map_err(|e| format!("Failed to create app data dir: {}", e))?;
229 }
230 match std::fs::symlink_metadata(app_data.join(npub)) {
236 Ok(meta) if meta.file_type().is_dir() && !meta.file_type().is_symlink() => {}
237 _ => return Err(format!("Account directory missing or invalid: {}", npub)),
238 }
239 let tmp = app_data.join(format!("{}.tmp", ACTIVE_ACCOUNT_FILE));
240 let final_path = app_data.join(ACTIVE_ACCOUNT_FILE);
241
242 let mut payload = String::with_capacity(npub.len() + 1);
245 payload.push_str(npub);
246 payload.push('\n');
247
248 if let Err(e) = std::fs::write(&tmp, payload.as_bytes()) {
249 let _ = std::fs::remove_file(&tmp);
250 return Err(format!("Failed to write active account temp file: {}", e));
251 }
252
253 let mut last_err = None;
256 for attempt in 0..3 {
257 match std::fs::rename(&tmp, &final_path) {
258 Ok(_) => return Ok(()),
259 Err(e) => {
260 last_err = Some(e);
261 if attempt < 2 {
262 std::thread::sleep(std::time::Duration::from_millis(50));
263 }
264 }
265 }
266 }
267
268 let _ = std::fs::remove_file(&tmp);
270 Err(format!(
271 "Failed to rename active account file: {}",
272 last_err.map(|e| e.to_string()).unwrap_or_default()
273 ))
274}
275
276fn clear_active_account_file_in(app_data: &std::path::Path) -> Result<(), String> {
277 let path = app_data.join(ACTIVE_ACCOUNT_FILE);
278 if path.exists() {
279 std::fs::remove_file(&path)
280 .map_err(|e| format!("Failed to remove active account file: {}", e))?;
281 }
282 Ok(())
283}
284
285fn list_account_npubs_in(app_data: &std::path::Path) -> Vec<String> {
286 let mut out = Vec::new();
287 if let Ok(entries) = std::fs::read_dir(app_data) {
288 for entry in entries.flatten() {
289 if entry.file_type().map_or(false, |ft| ft.is_dir()) {
290 let name = entry.file_name().to_string_lossy().to_string();
291 if is_valid_npub(&name) {
292 out.push(name);
293 }
294 }
295 }
296 }
297 out
298}
299
300#[cfg(test)]
301mod active_account_tests {
302 use super::*;
303 use std::fs;
304 use tempfile::TempDir;
305
306 const VALID_A: &str = "npub16ye7evyevwnl0fc9hujsxf9zym72e063awn0pvde0huvpyec5nyq4dg4wn";
308 const VALID_B: &str = "npub12w73tzcqgpr2pcy4el5x60d2emeud4cyeeayynzqgg2fefzgytaqm4ktz3";
309
310 fn touch_account_dir(base: &std::path::Path, npub: &str) {
311 fs::create_dir_all(base.join(npub)).unwrap();
312 }
313
314 #[test]
315 fn npub_validator_accepts_canonical_form() {
316 assert!(is_valid_npub(VALID_A));
317 assert!(is_valid_npub(VALID_B));
318 }
319
320 #[test]
321 fn npub_validator_rejects_wrong_length() {
322 assert!(!is_valid_npub("npub1abc"));
323 assert!(!is_valid_npub(&format!("{}x", VALID_A)));
324 assert!(!is_valid_npub(""));
325 }
326
327 #[test]
328 fn npub_validator_rejects_missing_prefix() {
329 let body = &VALID_A[5..];
330 assert!(!is_valid_npub(&format!("nsec1{}", body)));
331 assert!(!is_valid_npub(&format!("xxxx1{}", body)));
332 }
333
334 #[test]
335 fn npub_validator_rejects_non_bech32_chars() {
336 for bad in ['1', 'b', 'i', 'o', 'B', 'I', 'O', '!', '*', ' '] {
338 let mut s = String::from(VALID_A);
339 s.replace_range(10..11, &bad.to_string());
340 assert!(!is_valid_npub(&s), "should reject character {:?}", bad);
341 }
342 }
343
344 #[test]
345 fn write_then_read_round_trips() {
346 let tmp = TempDir::new().unwrap();
347 touch_account_dir(tmp.path(), VALID_A);
348
349 write_active_account_file_in(tmp.path(), VALID_A).unwrap();
350 assert_eq!(
351 read_active_account_file_in(tmp.path()).unwrap(),
352 Some(VALID_A.to_string())
353 );
354 }
355
356 #[test]
357 fn write_rejects_invalid_npub() {
358 let tmp = TempDir::new().unwrap();
359 let err = write_active_account_file_in(tmp.path(), "npub1nope").unwrap_err();
360 assert!(err.contains("Invalid"));
361 assert!(!tmp.path().join(ACTIVE_ACCOUNT_FILE).exists());
363 assert!(!tmp.path().join(format!("{}.tmp", ACTIVE_ACCOUNT_FILE)).exists());
364 }
365
366 #[test]
367 fn write_rejects_missing_account_dir() {
368 let tmp = TempDir::new().unwrap();
372 let err = write_active_account_file_in(tmp.path(), VALID_A).unwrap_err();
373 assert!(err.contains("missing or invalid"),
374 "expected account-dir-missing error, got: {}", err);
375 assert!(!tmp.path().join(ACTIVE_ACCOUNT_FILE).exists());
377 assert!(!tmp.path().join(format!("{}.tmp", ACTIVE_ACCOUNT_FILE)).exists());
378 }
379
380 #[test]
381 fn write_rejects_symlinked_account_dir() {
382 let tmp = TempDir::new().unwrap();
387 let target = TempDir::new().unwrap();
388 let link = tmp.path().join(VALID_A);
389 #[cfg(unix)]
390 {
391 std::os::unix::fs::symlink(target.path(), &link).unwrap();
392 let err = write_active_account_file_in(tmp.path(), VALID_A).unwrap_err();
393 assert!(err.contains("missing or invalid"),
394 "expected symlink rejection, got: {}", err);
395 }
396 #[cfg(not(unix))]
399 let _ = (target, link);
400 }
401
402 #[test]
403 fn read_returns_none_when_marker_missing() {
404 let tmp = TempDir::new().unwrap();
405 assert_eq!(read_active_account_file_in(tmp.path()).unwrap(), None);
406 }
407
408 #[test]
409 fn read_returns_none_when_marker_is_garbage() {
410 let tmp = TempDir::new().unwrap();
411 fs::write(tmp.path().join(ACTIVE_ACCOUNT_FILE), b"not-an-npub\n").unwrap();
412 assert_eq!(read_active_account_file_in(tmp.path()).unwrap(), None);
413 }
414
415 #[test]
416 fn read_returns_none_when_account_dir_missing() {
417 let tmp = TempDir::new().unwrap();
420 fs::write(tmp.path().join(ACTIVE_ACCOUNT_FILE), VALID_A).unwrap();
421 assert_eq!(read_active_account_file_in(tmp.path()).unwrap(), None);
422 }
423
424 #[test]
425 fn read_returns_none_when_marker_oversized() {
426 let tmp = TempDir::new().unwrap();
430 let payload = vec![b'x'; (MARKER_MAX_BYTES + 1024) as usize];
431 fs::write(tmp.path().join(ACTIVE_ACCOUNT_FILE), &payload).unwrap();
432 assert_eq!(read_active_account_file_in(tmp.path()).unwrap(), None);
433 }
434
435 #[test]
436 fn read_trims_whitespace() {
437 let tmp = TempDir::new().unwrap();
438 touch_account_dir(tmp.path(), VALID_A);
439 fs::write(
440 tmp.path().join(ACTIVE_ACCOUNT_FILE),
441 format!(" {}\n", VALID_A),
442 ).unwrap();
443 assert_eq!(
444 read_active_account_file_in(tmp.path()).unwrap(),
445 Some(VALID_A.to_string())
446 );
447 }
448
449 #[test]
450 fn read_handles_crlf_line_endings() {
451 let tmp = TempDir::new().unwrap();
452 touch_account_dir(tmp.path(), VALID_A);
453 fs::write(
454 tmp.path().join(ACTIVE_ACCOUNT_FILE),
455 format!("{}\r\n", VALID_A),
456 ).unwrap();
457 assert_eq!(
458 read_active_account_file_in(tmp.path()).unwrap(),
459 Some(VALID_A.to_string())
460 );
461 }
462
463 #[test]
464 fn npub_validator_rejects_uppercase_prefix() {
465 let upper = format!("NPUB1{}", &VALID_A[5..]);
466 assert!(!is_valid_npub(&upper));
467 }
468
469 #[test]
470 fn write_then_read_round_trips_with_newline() {
471 let tmp = TempDir::new().unwrap();
474 touch_account_dir(tmp.path(), VALID_A);
475 write_active_account_file_in(tmp.path(), VALID_A).unwrap();
476
477 let raw = fs::read_to_string(tmp.path().join(ACTIVE_ACCOUNT_FILE)).unwrap();
478 assert!(raw.ends_with('\n'));
479
480 assert_eq!(
481 read_active_account_file_in(tmp.path()).unwrap(),
482 Some(VALID_A.to_string())
483 );
484 }
485
486 #[test]
487 fn write_overwrites_previous_marker_atomically() {
488 let tmp = TempDir::new().unwrap();
489 touch_account_dir(tmp.path(), VALID_A);
490 touch_account_dir(tmp.path(), VALID_B);
491
492 write_active_account_file_in(tmp.path(), VALID_A).unwrap();
493 write_active_account_file_in(tmp.path(), VALID_B).unwrap();
494
495 assert_eq!(
496 read_active_account_file_in(tmp.path()).unwrap(),
497 Some(VALID_B.to_string())
498 );
499 assert!(!tmp.path().join(format!("{}.tmp", ACTIVE_ACCOUNT_FILE)).exists());
501 }
502
503 #[test]
504 fn clear_removes_marker_and_is_idempotent() {
505 let tmp = TempDir::new().unwrap();
506 touch_account_dir(tmp.path(), VALID_A);
507 write_active_account_file_in(tmp.path(), VALID_A).unwrap();
508 assert!(tmp.path().join(ACTIVE_ACCOUNT_FILE).exists());
509
510 clear_active_account_file_in(tmp.path()).unwrap();
511 assert!(!tmp.path().join(ACTIVE_ACCOUNT_FILE).exists());
512
513 clear_active_account_file_in(tmp.path()).unwrap();
515 }
516
517 #[test]
518 fn list_npubs_finds_valid_dirs_only() {
519 let tmp = TempDir::new().unwrap();
520 touch_account_dir(tmp.path(), VALID_A);
521 touch_account_dir(tmp.path(), VALID_B);
522 fs::create_dir_all(tmp.path().join("npub1tooshort")).unwrap();
524 fs::create_dir_all(tmp.path().join("not-an-npub-dir")).unwrap();
525 fs::create_dir_all(tmp.path().join("tor")).unwrap();
526 fs::write(tmp.path().join(ACTIVE_ACCOUNT_FILE), VALID_A).unwrap();
527
528 let mut found = list_account_npubs_in(tmp.path());
529 found.sort();
530 let mut expected = vec![VALID_A.to_string(), VALID_B.to_string()];
531 expected.sort();
532 assert_eq!(found, expected);
533 }
534
535 #[test]
536 fn list_npubs_skips_dirs_containing_invalid_chars() {
537 let tmp = TempDir::new().unwrap();
538 let mut bogus = String::from(VALID_A);
540 bogus.replace_range(10..11, "b");
541 fs::create_dir_all(tmp.path().join(&bogus)).unwrap();
542
543 let found = list_account_npubs_in(tmp.path());
544 assert!(found.is_empty(), "found unexpected entries: {:?}", found);
545 }
546
547 #[test]
548 fn write_creates_app_data_dir_if_missing() {
549 let tmp = TempDir::new().unwrap();
550 let nested = tmp.path().join("does/not/exist/yet");
551 std::fs::create_dir_all(&nested).unwrap();
555 touch_account_dir(&nested, VALID_A);
556 write_active_account_file_in(&nested, VALID_A).unwrap();
557 assert!(nested.join(ACTIVE_ACCOUNT_FILE).exists());
558 }
559}
560
561static DB_READ_POOL: LazyLock<Arc<Mutex<Vec<rusqlite::Connection>>>> =
569 LazyLock::new(|| Arc::new(Mutex::new(Vec::new())));
570
571static DB_WRITE_CONN: LazyLock<Arc<Mutex<Option<rusqlite::Connection>>>> =
572 LazyLock::new(|| Arc::new(Mutex::new(None)));
573
574static POOL_GENERATION: AtomicU64 = AtomicU64::new(0);
587
588#[inline]
589fn current_pool_generation() -> u64 {
590 POOL_GENERATION.load(Ordering::Acquire)
591}
592
593#[inline]
594fn bump_pool_generation() -> u64 {
595 POOL_GENERATION.fetch_add(1, Ordering::AcqRel).wrapping_add(1)
597}
598
599pub struct ConnectionGuard {
601 conn: Option<rusqlite::Connection>,
602 generation: u64,
603}
604
605impl ConnectionGuard {
606 fn new(conn: rusqlite::Connection, generation: u64) -> Self {
607 Self { conn: Some(conn), generation }
608 }
609}
610
611impl Deref for ConnectionGuard {
612 type Target = rusqlite::Connection;
613 fn deref(&self) -> &Self::Target { self.conn.as_ref().expect("Connection already taken") }
614}
615
616impl DerefMut for ConnectionGuard {
617 fn deref_mut(&mut self) -> &mut Self::Target { self.conn.as_mut().expect("Connection already taken") }
618}
619
620impl Drop for ConnectionGuard {
621 fn drop(&mut self) {
622 if let Some(conn) = self.conn.take() {
623 if self.generation == current_pool_generation() {
627 if let Ok(mut pool) = DB_READ_POOL.lock() {
628 pool.push(conn);
629 }
630 }
631 }
632 }
633}
634
635pub struct WriteConnectionGuard {
637 conn: Option<rusqlite::Connection>,
638 generation: u64,
639}
640
641impl WriteConnectionGuard {
642 fn new(conn: rusqlite::Connection, generation: u64) -> Self {
643 Self { conn: Some(conn), generation }
644 }
645}
646
647impl Deref for WriteConnectionGuard {
648 type Target = rusqlite::Connection;
649 fn deref(&self) -> &Self::Target { self.conn.as_ref().expect("Write connection already taken") }
650}
651
652impl DerefMut for WriteConnectionGuard {
653 fn deref_mut(&mut self) -> &mut Self::Target { self.conn.as_mut().expect("Write connection already taken") }
654}
655
656impl Drop for WriteConnectionGuard {
657 fn drop(&mut self) {
658 if let Some(conn) = self.conn.take() {
659 if self.generation == current_pool_generation() {
664 if let Ok(mut slot) = DB_WRITE_CONN.lock() {
665 if slot.is_none() {
666 *slot = Some(conn);
667 }
668 }
669 }
670 }
671 }
672}
673
674pub fn account_dir(npub: &str) -> Result<PathBuf, String> {
688 if !is_valid_npub(npub) {
689 return Err(format!("Invalid npub format: {}", npub));
690 }
691 Ok(get_app_data_dir()?.join(npub))
692}
693
694fn get_current_db_path() -> Result<PathBuf, String> {
695 let npub = get_current_account()?;
696 Ok(account_dir(&npub)?.join("vector.db"))
697}
698
699fn create_connection(path: &PathBuf) -> Result<rusqlite::Connection, String> {
700 let conn = rusqlite::Connection::open(path)
701 .map_err(|e| format!("Failed to open database: {}", e))?;
702
703 conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL; PRAGMA foreign_keys=ON; PRAGMA busy_timeout=5000; PRAGMA cache_size=-1000;")
705 .map_err(|e| format!("Failed to set pragmas: {}", e))?;
706
707 Ok(conn)
708}
709
710pub fn get_db_connection_guard_static() -> Result<ConnectionGuard, String> {
712 let generation = current_pool_generation();
713 if let Ok(mut pool) = DB_READ_POOL.lock() {
715 if let Some(conn) = pool.pop() {
716 return Ok(ConnectionGuard::new(conn, generation));
717 }
718 }
719 let path = get_current_db_path()?;
721 let conn = create_connection(&path)?;
722 Ok(ConnectionGuard::new(conn, generation))
723}
724
725#[cfg(test)]
731pub(crate) static DB_TEST_GUARD: std::sync::Mutex<()> = std::sync::Mutex::new(());
732
733pub fn get_write_connection_guard_static() -> Result<WriteConnectionGuard, String> {
735 let generation = current_pool_generation();
736 let mut write_slot = DB_WRITE_CONN.lock().unwrap();
737 if let Some(conn) = write_slot.take() {
738 return Ok(WriteConnectionGuard::new(conn, generation));
739 }
740 drop(write_slot);
741
742 let path = get_current_db_path()?;
743 let conn = create_connection(&path)?;
744 Ok(WriteConnectionGuard::new(conn, generation))
745}
746
747pub fn init_database(npub: &str) -> Result<(), String> {
753 let profile_dir = account_dir(npub)?;
754
755 if !profile_dir.exists() {
756 std::fs::create_dir_all(&profile_dir)
757 .map_err(|e| format!("Failed to create profile directory: {}", e))?;
758 }
759
760 let db_path = profile_dir.join("vector.db");
761 let mut conn = create_connection(&db_path)?;
762 conn.execute_batch(schema::SQL_SCHEMA)
763 .map_err(|e| format!("Failed to create schema: {}", e))?;
764
765 schema::run_migrations(&mut conn)?;
767
768 let mls_dir = profile_dir.join("mls");
774 if mls_dir.exists() {
775 match std::fs::remove_dir_all(&mls_dir) {
776 Ok(()) => crate::log_info!("[db] purged orphaned MLS store for account"),
777 Err(e) => crate::log_warn!("[db] could not purge orphaned MLS store: {}", e),
778 }
779 }
780
781 bump_pool_generation();
785
786 if let Ok(mut pool) = DB_READ_POOL.lock() {
788 pool.clear();
789 for _ in 0..4 {
790 if let Ok(c) = create_connection(&db_path) {
791 pool.push(c);
792 }
793 }
794 }
795
796 let write_conn = create_connection(&db_path)?;
798 *DB_WRITE_CONN.lock().unwrap() = Some(write_conn);
799
800 #[cfg(feature = "tor")]
806 {
807 let enabled = create_connection(&db_path)
808 .ok()
809 .and_then(|c| {
810 c.query_row(
811 "SELECT value FROM settings WHERE key = 'tor_enabled'",
812 [],
813 |row| row.get::<_, String>(0),
814 )
815 .ok()
816 })
817 .map(|v| v == "1" || v == "true")
818 .unwrap_or(false);
819 crate::tor::set_tor_enabled_pref(enabled);
820 }
821
822 Ok(())
823}
824
825pub fn close_database() {
830 bump_pool_generation();
831 if let Ok(mut pool) = DB_READ_POOL.lock() {
832 pool.clear();
833 }
834 *DB_WRITE_CONN.lock().unwrap() = None;
835}
836
837pub fn get_accounts() -> Result<Vec<String>, String> {
839 let app_data = get_app_data_dir()?;
840 let mut accounts = Vec::new();
841
842 if let Ok(entries) = std::fs::read_dir(app_data) {
843 for entry in entries.flatten() {
844 if entry.file_type().map_or(false, |ft| ft.is_dir()) {
845 let name = entry.file_name().to_string_lossy().to_string();
846 if name.starts_with("npub1") {
847 if entry.path().join("vector.db").exists() {
849 accounts.push(name);
850 }
851 }
852 }
853 }
854 }
855
856 Ok(accounts)
857}
858
859pub fn get_profile_directory(npub: &str) -> Result<PathBuf, String> {
861 if !npub.starts_with("npub1") {
862 return Err(format!("Invalid npub format: {}", npub));
863 }
864 let dir = account_dir(npub)?;
865 if !dir.exists() {
866 std::fs::create_dir_all(&dir)
867 .map_err(|e| format!("Failed to create profile directory: {}", e))?;
868 }
869 Ok(dir)
870}
871
872pub fn get_database_path(npub: &str) -> Result<PathBuf, String> {
874 Ok(get_profile_directory(npub)?.join("vector.db"))
875}
876
877static CHAT_ID_CACHE: LazyLock<Arc<RwLock<HashMap<String, i64>>>> =
882 LazyLock::new(|| Arc::new(RwLock::new(HashMap::new())));
883
884static USER_ID_CACHE: LazyLock<Arc<RwLock<HashMap<String, i64>>>> =
885 LazyLock::new(|| Arc::new(RwLock::new(HashMap::new())));
886
887pub fn clear_id_caches() {
888 CHAT_ID_CACHE.write().unwrap().clear();
889 USER_ID_CACHE.write().unwrap().clear();
890 id_cache::clear_id_caches();
895}
896
897pub fn get_or_create_chat_id(conn: &rusqlite::Connection, identifier: &str, chat_type: i32) -> Result<i64, String> {
899 if let Some(&id) = CHAT_ID_CACHE.read().unwrap().get(identifier) {
901 return Ok(id);
902 }
903
904 let result: Option<i64> = conn.query_row(
906 "SELECT id FROM chats WHERE chat_identifier = ?1",
907 rusqlite::params![identifier],
908 |row| row.get(0),
909 ).ok();
910
911 if let Some(id) = result {
912 CHAT_ID_CACHE.write().unwrap().insert(identifier.to_string(), id);
913 return Ok(id);
914 }
915
916 let now = std::time::SystemTime::now()
918 .duration_since(std::time::UNIX_EPOCH).unwrap().as_secs() as i64;
919 conn.execute(
920 "INSERT INTO chats (chat_identifier, chat_type, participants, created_at) VALUES (?1, ?2, '', ?3)",
921 rusqlite::params![identifier, chat_type, now],
922 ).map_err(|e| format!("Failed to create chat: {}", e))?;
923
924 let id = conn.last_insert_rowid();
925 CHAT_ID_CACHE.write().unwrap().insert(identifier.to_string(), id);
926 Ok(id)
927}
928
929pub fn get_or_create_user_id(conn: &rusqlite::Connection, npub: &str) -> Result<i64, String> {
931 if let Some(&id) = USER_ID_CACHE.read().unwrap().get(npub) {
932 return Ok(id);
933 }
934
935 let result: Option<i64> = conn.query_row(
936 "SELECT id FROM profiles WHERE npub = ?1",
937 rusqlite::params![npub],
938 |row| row.get(0),
939 ).ok();
940
941 if let Some(id) = result {
942 USER_ID_CACHE.write().unwrap().insert(npub.to_string(), id);
943 return Ok(id);
944 }
945
946 conn.execute(
947 "INSERT OR IGNORE INTO profiles (npub) VALUES (?1)",
948 rusqlite::params![npub],
949 ).map_err(|e| format!("Failed to create user: {}", e))?;
950
951 let id = conn.query_row(
952 "SELECT id FROM profiles WHERE npub = ?1",
953 rusqlite::params![npub],
954 |row| row.get(0),
955 ).map_err(|e| format!("Failed to get user id: {}", e))?;
956
957 USER_ID_CACHE.write().unwrap().insert(npub.to_string(), id);
958 Ok(id)
959}
960
961#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
966#[repr(u8)]
967pub enum SystemEventType {
968 MemberLeft = 0,
969 MemberJoined = 1,
970 MemberRemoved = 2,
971 WallpaperChanged = 3,
972}
973
974impl SystemEventType {
975 pub fn display_message(&self, display_name: &str) -> String {
976 match self {
977 SystemEventType::MemberLeft => format!("{} has left", display_name),
978 SystemEventType::MemberJoined => format!("{} has joined", display_name),
979 SystemEventType::MemberRemoved => format!("{} was removed", display_name),
980 SystemEventType::WallpaperChanged => format!("{} changed the wallpaper", display_name),
981 }
982 }
983
984 pub fn as_u8(&self) -> u8 { *self as u8 }
985}
986
987#[cfg(test)]
988mod pool_generation_tests {
989 use super::*;
990 use tempfile::TempDir;
991
992 fn fake_conn() -> rusqlite::Connection {
996 rusqlite::Connection::open_in_memory().unwrap()
997 }
998
999 #[test]
1000 fn close_database_bumps_generation() {
1001 let before = current_pool_generation();
1002 close_database();
1003 let after = current_pool_generation();
1004 assert!(after > before, "close_database must advance POOL_GENERATION");
1005 }
1006
1007 #[test]
1008 fn init_database_bumps_generation() {
1009 let before = current_pool_generation();
1014 let bumped = bump_pool_generation();
1015 assert_eq!(bumped, before.wrapping_add(1));
1016 assert_eq!(current_pool_generation(), bumped);
1017 }
1018
1019 #[test]
1020 fn stale_read_guard_does_not_return_to_pool_after_generation_bump() {
1021 let _tmp = TempDir::new().unwrap(); let pool_size_before = DB_READ_POOL.lock().unwrap().len();
1028
1029 let stale_generation = current_pool_generation();
1030 let guard = ConnectionGuard::new(fake_conn(), stale_generation);
1031
1032 bump_pool_generation();
1034
1035 drop(guard);
1036
1037 let pool_size_after = DB_READ_POOL.lock().unwrap().len();
1038 assert_eq!(
1039 pool_size_after, pool_size_before,
1040 "stale read guard must not re-enter the pool"
1041 );
1042 }
1043
1044 #[test]
1045 fn fresh_read_guard_returns_to_pool() {
1046 let pool_size_before = DB_READ_POOL.lock().unwrap().len();
1047
1048 let generation = current_pool_generation();
1049 let guard = ConnectionGuard::new(fake_conn(), generation);
1050
1051 drop(guard);
1053
1054 let pool_size_after = DB_READ_POOL.lock().unwrap().len();
1055 assert_eq!(
1056 pool_size_after,
1057 pool_size_before + 1,
1058 "fresh read guard should be returned to the pool"
1059 );
1060
1061 DB_READ_POOL.lock().unwrap().pop();
1064 }
1065
1066 #[test]
1067 fn stale_write_guard_does_not_overwrite_fresh_slot() {
1068 let stale_generation = current_pool_generation();
1071 let stale_guard = WriteConnectionGuard::new(fake_conn(), stale_generation);
1072
1073 bump_pool_generation();
1074
1075 let fresh_conn = fake_conn();
1077 *DB_WRITE_CONN.lock().unwrap() = Some(fresh_conn);
1078
1079 drop(stale_guard);
1080
1081 assert!(
1084 DB_WRITE_CONN.lock().unwrap().is_some(),
1085 "write slot must keep the freshly installed connection"
1086 );
1087
1088 *DB_WRITE_CONN.lock().unwrap() = None;
1090 }
1091
1092 #[test]
1093 fn stale_write_guard_does_not_fill_empty_slot() {
1094 let stale_generation = current_pool_generation();
1099 let stale_guard = WriteConnectionGuard::new(fake_conn(), stale_generation);
1100
1101 bump_pool_generation();
1102 *DB_WRITE_CONN.lock().unwrap() = None;
1103
1104 drop(stale_guard);
1105
1106 assert!(
1107 DB_WRITE_CONN.lock().unwrap().is_none(),
1108 "stale write guard must not fill an empty slot"
1109 );
1110 }
1111}