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