1use std::path::PathBuf;
11use std::sync::{Arc, Mutex, OnceLock, LazyLock, RwLock};
12use std::sync::atomic::{AtomicU64, Ordering};
13use std::ops::{Deref, DerefMut};
14
15use serde::{Deserialize, Serialize};
16
17pub mod settings;
18pub mod schema;
19pub mod profiles;
20pub mod id_cache;
21pub mod events;
22pub mod attachments;
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, advance_u64_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 get_nip55_user_pubkey, set_nip55_user_pubkey,
36 get_nip55_signer_package, set_nip55_signer_package,
37 commit_nip55_account_setup,
38};
39
40static APP_DATA_DIR: OnceLock<PathBuf> = OnceLock::new();
45
46pub fn set_app_data_dir(path: PathBuf) {
47 let _ = APP_DATA_DIR.set(path);
48}
49
50pub fn get_app_data_dir() -> Result<&'static PathBuf, String> {
51 APP_DATA_DIR.get().ok_or_else(|| "App data directory not initialized".to_string())
52}
53
54static DOWNLOAD_DIR_OVERRIDE: OnceLock<PathBuf> = OnceLock::new();
59
60pub fn set_download_dir(path: PathBuf) {
64 let _ = DOWNLOAD_DIR_OVERRIDE.set(path);
65}
66
67pub fn get_download_dir() -> PathBuf {
74 if let Some(installed) = DOWNLOAD_DIR_OVERRIDE.get() {
75 return installed.clone();
76 }
77 #[cfg(any(target_os = "macos", target_os = "linux"))]
78 {
79 if let Ok(home) = std::env::var("HOME") {
80 return PathBuf::from(home).join("Downloads/vector");
81 }
82 }
83 #[cfg(target_os = "windows")]
84 {
85 if let Ok(profile) = std::env::var("USERPROFILE") {
86 return PathBuf::from(profile).join("Downloads").join("vector");
87 }
88 }
89 if let Ok(data_dir) = get_app_data_dir() {
91 return data_dir.join("vector_downloads");
92 }
93 PathBuf::from("/tmp/vector_downloads")
94}
95
96static CURRENT_ACCOUNT: LazyLock<Arc<RwLock<Option<String>>>> = LazyLock::new(|| Arc::new(RwLock::new(None)));
101const ACTIVE_ACCOUNT_FILE: &str = "active_account";
106
107fn is_valid_npub(s: &str) -> bool {
109 if s.len() != 63 || !s.starts_with("npub1") {
110 return false;
111 }
112 s.bytes().skip(5).all(|c| matches!(c,
113 b'q' | b'p' | b'z' | b'r' | b'y' | b'9' | b'x' | b'8' |
114 b'g' | b'f' | b'2' | b't' | b'v' | b'd' | b'w' | b'0' |
115 b's' | b'3' | b'j' | b'n' | b'5' | b'4' | b'k' | b'h' |
116 b'c' | b'e' | b'6' | b'm' | b'u' | b'a' | b'7' | b'l'
117 ))
118}
119
120pub fn get_current_account() -> Result<String, String> {
121 CURRENT_ACCOUNT.read().unwrap()
122 .as_ref().cloned()
123 .ok_or_else(|| "No active account".to_string())
124}
125
126pub fn set_current_account(npub: String) -> Result<(), String> {
133 *CURRENT_ACCOUNT.write().unwrap() = Some(npub.clone());
134 let _ = write_active_account_file(&npub);
135 Ok(())
136}
137
138pub fn clear_current_account_in_memory() {
142 *CURRENT_ACCOUNT.write().unwrap() = None;
143}
144
145pub fn read_active_account_file() -> Result<Option<String>, String> {
149 let app_data = match get_app_data_dir() {
150 Ok(p) => p,
151 Err(_) => return Ok(None),
152 };
153 read_active_account_file_in(app_data)
154}
155
156pub fn write_active_account_file(npub: &str) -> Result<(), String> {
158 let app_data = get_app_data_dir()?.clone();
159 write_active_account_file_in(&app_data, npub)
160}
161
162pub fn clear_active_account_file() -> Result<(), String> {
164 let app_data = get_app_data_dir()?;
165 clear_active_account_file_in(app_data)
166}
167
168pub fn list_account_npubs() -> Result<Vec<String>, String> {
172 let app_data = get_app_data_dir()?;
173 Ok(list_account_npubs_in(app_data))
174}
175
176const MARKER_MAX_BYTES: u64 = 256;
183
184fn read_active_account_file_in(app_data: &std::path::Path) -> Result<Option<String>, String> {
185 use std::io::Read;
186
187 let path = app_data.join(ACTIVE_ACCOUNT_FILE);
188 if !path.exists() {
189 return Ok(None);
190 }
191 if let Ok(meta) = std::fs::metadata(&path) {
195 if meta.len() > MARKER_MAX_BYTES {
196 return Ok(None);
197 }
198 } else {
199 return Ok(None);
200 }
201 let mut buf = String::new();
202 let file = match std::fs::File::open(&path) {
203 Ok(f) => f,
204 Err(_) => return Ok(None),
205 };
206 if file.take(MARKER_MAX_BYTES).read_to_string(&mut buf).is_err() {
207 return Ok(None);
208 }
209 let npub = buf.trim().to_string();
210 if !is_valid_npub(&npub) {
211 return Ok(None);
212 }
213 match std::fs::symlink_metadata(app_data.join(&npub)) {
220 Ok(meta) if meta.file_type().is_dir() && !meta.file_type().is_symlink() => {}
221 _ => return Ok(None),
222 }
223 Ok(Some(npub))
224}
225
226fn write_active_account_file_in(app_data: &std::path::Path, npub: &str) -> Result<(), String> {
227 if !is_valid_npub(npub) {
228 return Err(format!("Invalid npub format: {}", npub));
229 }
230 if !app_data.exists() {
231 std::fs::create_dir_all(app_data)
232 .map_err(|e| format!("Failed to create app data dir: {}", e))?;
233 }
234 match std::fs::symlink_metadata(app_data.join(npub)) {
240 Ok(meta) if meta.file_type().is_dir() && !meta.file_type().is_symlink() => {}
241 _ => return Err(format!("Account directory missing or invalid: {}", npub)),
242 }
243 let tmp = app_data.join(format!("{}.tmp", ACTIVE_ACCOUNT_FILE));
244 let final_path = app_data.join(ACTIVE_ACCOUNT_FILE);
245
246 let mut payload = String::with_capacity(npub.len() + 1);
249 payload.push_str(npub);
250 payload.push('\n');
251
252 if let Err(e) = std::fs::write(&tmp, payload.as_bytes()) {
253 let _ = std::fs::remove_file(&tmp);
254 return Err(format!("Failed to write active account temp file: {}", e));
255 }
256
257 let mut last_err = None;
260 for attempt in 0..3 {
261 match std::fs::rename(&tmp, &final_path) {
262 Ok(_) => return Ok(()),
263 Err(e) => {
264 last_err = Some(e);
265 if attempt < 2 {
266 std::thread::sleep(std::time::Duration::from_millis(50));
267 }
268 }
269 }
270 }
271
272 let _ = std::fs::remove_file(&tmp);
274 Err(format!(
275 "Failed to rename active account file: {}",
276 last_err.map(|e| e.to_string()).unwrap_or_default()
277 ))
278}
279
280fn clear_active_account_file_in(app_data: &std::path::Path) -> Result<(), String> {
281 let path = app_data.join(ACTIVE_ACCOUNT_FILE);
282 if path.exists() {
283 std::fs::remove_file(&path)
284 .map_err(|e| format!("Failed to remove active account file: {}", e))?;
285 }
286 Ok(())
287}
288
289fn list_account_npubs_in(app_data: &std::path::Path) -> Vec<String> {
290 let mut out = Vec::new();
291 if let Ok(entries) = std::fs::read_dir(app_data) {
292 for entry in entries.flatten() {
293 if entry.file_type().map_or(false, |ft| ft.is_dir()) {
294 let name = entry.file_name().to_string_lossy().to_string();
295 if is_valid_npub(&name) {
296 out.push(name);
297 }
298 }
299 }
300 }
301 out
302}
303
304#[cfg(test)]
305mod active_account_tests {
306 use super::*;
307 use std::fs;
308 use tempfile::TempDir;
309
310 const VALID_A: &str = "npub16ye7evyevwnl0fc9hujsxf9zym72e063awn0pvde0huvpyec5nyq4dg4wn";
312 const VALID_B: &str = "npub12w73tzcqgpr2pcy4el5x60d2emeud4cyeeayynzqgg2fefzgytaqm4ktz3";
313
314 fn touch_account_dir(base: &std::path::Path, npub: &str) {
315 fs::create_dir_all(base.join(npub)).unwrap();
316 }
317
318 #[test]
319 fn npub_validator_accepts_canonical_form() {
320 assert!(is_valid_npub(VALID_A));
321 assert!(is_valid_npub(VALID_B));
322 }
323
324 #[test]
325 fn npub_validator_rejects_wrong_length() {
326 assert!(!is_valid_npub("npub1abc"));
327 assert!(!is_valid_npub(&format!("{}x", VALID_A)));
328 assert!(!is_valid_npub(""));
329 }
330
331 #[test]
332 fn npub_validator_rejects_missing_prefix() {
333 let body = &VALID_A[5..];
334 assert!(!is_valid_npub(&format!("nsec1{}", body)));
335 assert!(!is_valid_npub(&format!("xxxx1{}", body)));
336 }
337
338 #[test]
339 fn npub_validator_rejects_non_bech32_chars() {
340 for bad in ['1', 'b', 'i', 'o', 'B', 'I', 'O', '!', '*', ' '] {
342 let mut s = String::from(VALID_A);
343 s.replace_range(10..11, &bad.to_string());
344 assert!(!is_valid_npub(&s), "should reject character {:?}", bad);
345 }
346 }
347
348 #[test]
349 fn write_then_read_round_trips() {
350 let tmp = TempDir::new().unwrap();
351 touch_account_dir(tmp.path(), VALID_A);
352
353 write_active_account_file_in(tmp.path(), VALID_A).unwrap();
354 assert_eq!(
355 read_active_account_file_in(tmp.path()).unwrap(),
356 Some(VALID_A.to_string())
357 );
358 }
359
360 #[test]
361 fn write_rejects_invalid_npub() {
362 let tmp = TempDir::new().unwrap();
363 let err = write_active_account_file_in(tmp.path(), "npub1nope").unwrap_err();
364 assert!(err.contains("Invalid"));
365 assert!(!tmp.path().join(ACTIVE_ACCOUNT_FILE).exists());
367 assert!(!tmp.path().join(format!("{}.tmp", ACTIVE_ACCOUNT_FILE)).exists());
368 }
369
370 #[test]
371 fn write_rejects_missing_account_dir() {
372 let tmp = TempDir::new().unwrap();
376 let err = write_active_account_file_in(tmp.path(), VALID_A).unwrap_err();
377 assert!(err.contains("missing or invalid"),
378 "expected account-dir-missing error, got: {}", err);
379 assert!(!tmp.path().join(ACTIVE_ACCOUNT_FILE).exists());
381 assert!(!tmp.path().join(format!("{}.tmp", ACTIVE_ACCOUNT_FILE)).exists());
382 }
383
384 #[test]
385 fn write_rejects_symlinked_account_dir() {
386 let tmp = TempDir::new().unwrap();
391 let target = TempDir::new().unwrap();
392 let link = tmp.path().join(VALID_A);
393 #[cfg(unix)]
394 {
395 std::os::unix::fs::symlink(target.path(), &link).unwrap();
396 let err = write_active_account_file_in(tmp.path(), VALID_A).unwrap_err();
397 assert!(err.contains("missing or invalid"),
398 "expected symlink rejection, got: {}", err);
399 }
400 #[cfg(not(unix))]
403 let _ = (target, link);
404 }
405
406 #[test]
407 fn read_returns_none_when_marker_missing() {
408 let tmp = TempDir::new().unwrap();
409 assert_eq!(read_active_account_file_in(tmp.path()).unwrap(), None);
410 }
411
412 #[test]
413 fn read_returns_none_when_marker_is_garbage() {
414 let tmp = TempDir::new().unwrap();
415 fs::write(tmp.path().join(ACTIVE_ACCOUNT_FILE), b"not-an-npub\n").unwrap();
416 assert_eq!(read_active_account_file_in(tmp.path()).unwrap(), None);
417 }
418
419 #[test]
420 fn read_returns_none_when_account_dir_missing() {
421 let tmp = TempDir::new().unwrap();
424 fs::write(tmp.path().join(ACTIVE_ACCOUNT_FILE), VALID_A).unwrap();
425 assert_eq!(read_active_account_file_in(tmp.path()).unwrap(), None);
426 }
427
428 #[test]
429 fn read_returns_none_when_marker_oversized() {
430 let tmp = TempDir::new().unwrap();
434 let payload = vec![b'x'; (MARKER_MAX_BYTES + 1024) as usize];
435 fs::write(tmp.path().join(ACTIVE_ACCOUNT_FILE), &payload).unwrap();
436 assert_eq!(read_active_account_file_in(tmp.path()).unwrap(), None);
437 }
438
439 #[test]
440 fn read_trims_whitespace() {
441 let tmp = TempDir::new().unwrap();
442 touch_account_dir(tmp.path(), VALID_A);
443 fs::write(
444 tmp.path().join(ACTIVE_ACCOUNT_FILE),
445 format!(" {}\n", VALID_A),
446 ).unwrap();
447 assert_eq!(
448 read_active_account_file_in(tmp.path()).unwrap(),
449 Some(VALID_A.to_string())
450 );
451 }
452
453 #[test]
454 fn read_handles_crlf_line_endings() {
455 let tmp = TempDir::new().unwrap();
456 touch_account_dir(tmp.path(), VALID_A);
457 fs::write(
458 tmp.path().join(ACTIVE_ACCOUNT_FILE),
459 format!("{}\r\n", VALID_A),
460 ).unwrap();
461 assert_eq!(
462 read_active_account_file_in(tmp.path()).unwrap(),
463 Some(VALID_A.to_string())
464 );
465 }
466
467 #[test]
468 fn npub_validator_rejects_uppercase_prefix() {
469 let upper = format!("NPUB1{}", &VALID_A[5..]);
470 assert!(!is_valid_npub(&upper));
471 }
472
473 #[test]
474 fn write_then_read_round_trips_with_newline() {
475 let tmp = TempDir::new().unwrap();
478 touch_account_dir(tmp.path(), VALID_A);
479 write_active_account_file_in(tmp.path(), VALID_A).unwrap();
480
481 let raw = fs::read_to_string(tmp.path().join(ACTIVE_ACCOUNT_FILE)).unwrap();
482 assert!(raw.ends_with('\n'));
483
484 assert_eq!(
485 read_active_account_file_in(tmp.path()).unwrap(),
486 Some(VALID_A.to_string())
487 );
488 }
489
490 #[test]
491 fn write_overwrites_previous_marker_atomically() {
492 let tmp = TempDir::new().unwrap();
493 touch_account_dir(tmp.path(), VALID_A);
494 touch_account_dir(tmp.path(), VALID_B);
495
496 write_active_account_file_in(tmp.path(), VALID_A).unwrap();
497 write_active_account_file_in(tmp.path(), VALID_B).unwrap();
498
499 assert_eq!(
500 read_active_account_file_in(tmp.path()).unwrap(),
501 Some(VALID_B.to_string())
502 );
503 assert!(!tmp.path().join(format!("{}.tmp", ACTIVE_ACCOUNT_FILE)).exists());
505 }
506
507 #[test]
508 fn clear_removes_marker_and_is_idempotent() {
509 let tmp = TempDir::new().unwrap();
510 touch_account_dir(tmp.path(), VALID_A);
511 write_active_account_file_in(tmp.path(), VALID_A).unwrap();
512 assert!(tmp.path().join(ACTIVE_ACCOUNT_FILE).exists());
513
514 clear_active_account_file_in(tmp.path()).unwrap();
515 assert!(!tmp.path().join(ACTIVE_ACCOUNT_FILE).exists());
516
517 clear_active_account_file_in(tmp.path()).unwrap();
519 }
520
521 #[test]
522 fn list_npubs_finds_valid_dirs_only() {
523 let tmp = TempDir::new().unwrap();
524 touch_account_dir(tmp.path(), VALID_A);
525 touch_account_dir(tmp.path(), VALID_B);
526 fs::create_dir_all(tmp.path().join("npub1tooshort")).unwrap();
528 fs::create_dir_all(tmp.path().join("not-an-npub-dir")).unwrap();
529 fs::create_dir_all(tmp.path().join("tor")).unwrap();
530 fs::write(tmp.path().join(ACTIVE_ACCOUNT_FILE), VALID_A).unwrap();
531
532 let mut found = list_account_npubs_in(tmp.path());
533 found.sort();
534 let mut expected = vec![VALID_A.to_string(), VALID_B.to_string()];
535 expected.sort();
536 assert_eq!(found, expected);
537 }
538
539 #[test]
540 fn list_npubs_skips_dirs_containing_invalid_chars() {
541 let tmp = TempDir::new().unwrap();
542 let mut bogus = String::from(VALID_A);
544 bogus.replace_range(10..11, "b");
545 fs::create_dir_all(tmp.path().join(&bogus)).unwrap();
546
547 let found = list_account_npubs_in(tmp.path());
548 assert!(found.is_empty(), "found unexpected entries: {:?}", found);
549 }
550
551 #[test]
552 fn write_creates_app_data_dir_if_missing() {
553 let tmp = TempDir::new().unwrap();
554 let nested = tmp.path().join("does/not/exist/yet");
555 std::fs::create_dir_all(&nested).unwrap();
559 touch_account_dir(&nested, VALID_A);
560 write_active_account_file_in(&nested, VALID_A).unwrap();
561 assert!(nested.join(ACTIVE_ACCOUNT_FILE).exists());
562 }
563}
564
565static DB_READ_POOL: LazyLock<Arc<Mutex<Vec<rusqlite::Connection>>>> =
573 LazyLock::new(|| Arc::new(Mutex::new(Vec::new())));
574
575static DB_WRITE_CONN: LazyLock<Arc<Mutex<Option<rusqlite::Connection>>>> =
576 LazyLock::new(|| Arc::new(Mutex::new(None)));
577
578static POOL_GENERATION: AtomicU64 = AtomicU64::new(0);
591
592#[inline]
593fn current_pool_generation() -> u64 {
594 POOL_GENERATION.load(Ordering::Acquire)
595}
596
597#[inline]
598fn bump_pool_generation() -> u64 {
599 POOL_GENERATION.fetch_add(1, Ordering::AcqRel).wrapping_add(1)
601}
602
603pub struct ConnectionGuard {
605 conn: Option<rusqlite::Connection>,
606 generation: u64,
607}
608
609impl ConnectionGuard {
610 fn new(conn: rusqlite::Connection, generation: u64) -> Self {
611 Self { conn: Some(conn), generation }
612 }
613}
614
615impl Deref for ConnectionGuard {
616 type Target = rusqlite::Connection;
617 fn deref(&self) -> &Self::Target { self.conn.as_ref().expect("Connection already taken") }
618}
619
620impl DerefMut for ConnectionGuard {
621 fn deref_mut(&mut self) -> &mut Self::Target { self.conn.as_mut().expect("Connection already taken") }
622}
623
624impl Drop for ConnectionGuard {
625 fn drop(&mut self) {
626 if let Some(conn) = self.conn.take() {
627 if self.generation == current_pool_generation() {
631 if let Ok(mut pool) = DB_READ_POOL.lock() {
632 pool.push(conn);
633 }
634 }
635 }
636 }
637}
638
639pub struct WriteConnectionGuard {
641 conn: Option<rusqlite::Connection>,
642 generation: u64,
643}
644
645impl WriteConnectionGuard {
646 fn new(conn: rusqlite::Connection, generation: u64) -> Self {
647 Self { conn: Some(conn), generation }
648 }
649}
650
651impl Deref for WriteConnectionGuard {
652 type Target = rusqlite::Connection;
653 fn deref(&self) -> &Self::Target { self.conn.as_ref().expect("Write connection already taken") }
654}
655
656impl DerefMut for WriteConnectionGuard {
657 fn deref_mut(&mut self) -> &mut Self::Target { self.conn.as_mut().expect("Write connection already taken") }
658}
659
660impl Drop for WriteConnectionGuard {
661 fn drop(&mut self) {
662 if let Some(conn) = self.conn.take() {
663 if self.generation == current_pool_generation() {
668 if let Ok(mut slot) = DB_WRITE_CONN.lock() {
669 if slot.is_none() {
670 *slot = Some(conn);
671 }
672 }
673 }
674 }
675 }
676}
677
678pub fn account_dir(npub: &str) -> Result<PathBuf, String> {
692 if !is_valid_npub(npub) {
693 return Err(format!("Invalid npub format: {}", npub));
694 }
695 Ok(get_app_data_dir()?.join(npub))
696}
697
698fn get_current_db_path() -> Result<PathBuf, String> {
699 let npub = get_current_account()?;
700 Ok(account_dir(&npub)?.join("vector.db"))
701}
702
703fn create_connection(path: &PathBuf) -> Result<rusqlite::Connection, String> {
704 let conn = rusqlite::Connection::open(path)
705 .map_err(|e| format!("Failed to open database: {}", e))?;
706
707 conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL; PRAGMA foreign_keys=ON; PRAGMA busy_timeout=5000; PRAGMA cache_size=-16000; PRAGMA temp_store=MEMORY;")
711 .map_err(|e| format!("Failed to set pragmas: {}", e))?;
712
713 Ok(conn)
714}
715
716pub fn get_db_connection_guard_static() -> Result<ConnectionGuard, String> {
718 let generation = current_pool_generation();
719 if let Ok(mut pool) = DB_READ_POOL.lock() {
721 if let Some(conn) = pool.pop() {
722 return Ok(ConnectionGuard::new(conn, generation));
723 }
724 }
725 let path = get_current_db_path()?;
727 let conn = create_connection(&path)?;
728 Ok(ConnectionGuard::new(conn, generation))
729}
730
731#[cfg(test)]
737pub(crate) static DB_TEST_GUARD: std::sync::Mutex<()> = std::sync::Mutex::new(());
738
739pub fn get_write_connection_guard_static() -> Result<WriteConnectionGuard, String> {
741 let generation = current_pool_generation();
742 let mut write_slot = DB_WRITE_CONN.lock().unwrap();
743 if let Some(conn) = write_slot.take() {
744 return Ok(WriteConnectionGuard::new(conn, generation));
745 }
746 drop(write_slot);
747
748 let path = get_current_db_path()?;
749 let conn = create_connection(&path)?;
750 Ok(WriteConnectionGuard::new(conn, generation))
751}
752
753pub fn init_database(npub: &str) -> Result<(), String> {
759 let profile_dir = account_dir(npub)?;
760
761 if !profile_dir.exists() {
762 std::fs::create_dir_all(&profile_dir)
763 .map_err(|e| format!("Failed to create profile directory: {}", e))?;
764 }
765
766 let db_path = profile_dir.join("vector.db");
767 let mut conn = create_connection(&db_path)?;
768 conn.execute_batch(schema::SQL_SCHEMA)
769 .map_err(|e| format!("Failed to create schema: {}", e))?;
770
771 schema::run_migrations(&mut conn)?;
773
774 let _ = conn.execute_batch("PRAGMA optimize=0x10002;");
779
780 let mls_dir = profile_dir.join("mls");
786 if mls_dir.exists() {
787 match std::fs::remove_dir_all(&mls_dir) {
788 Ok(()) => crate::log_info!("[db] purged orphaned MLS store for account"),
789 Err(e) => crate::log_warn!("[db] could not purge orphaned MLS store: {}", e),
790 }
791 }
792
793 bump_pool_generation();
797
798 if let Ok(mut pool) = DB_READ_POOL.lock() {
800 pool.clear();
801 for _ in 0..4 {
802 if let Ok(c) = create_connection(&db_path) {
803 pool.push(c);
804 }
805 }
806 }
807
808 let write_conn = create_connection(&db_path)?;
810 *DB_WRITE_CONN.lock().unwrap() = Some(write_conn);
811
812 #[cfg(feature = "tor")]
818 {
819 let enabled = create_connection(&db_path)
820 .ok()
821 .and_then(|c| {
822 c.query_row(
823 "SELECT value FROM settings WHERE key = 'tor_enabled'",
824 [],
825 |row| row.get::<_, String>(0),
826 )
827 .ok()
828 })
829 .map(|v| v == "1" || v == "true")
830 .unwrap_or(false);
831 crate::tor::set_tor_enabled_pref(enabled);
832 }
833
834 Ok(())
835}
836
837pub fn close_database() {
842 bump_pool_generation();
843 if let Ok(mut pool) = DB_READ_POOL.lock() {
844 pool.clear();
845 }
846 *DB_WRITE_CONN.lock().unwrap() = None;
847}
848
849pub fn optimize_database() {
854 if let Ok(guard) = DB_WRITE_CONN.lock() {
855 if let Some(conn) = guard.as_ref() {
856 let _ = conn.execute_batch("PRAGMA optimize;");
857 }
858 }
859}
860
861pub fn get_accounts() -> Result<Vec<String>, String> {
863 let app_data = get_app_data_dir()?;
864 let mut accounts = Vec::new();
865
866 if let Ok(entries) = std::fs::read_dir(app_data) {
867 for entry in entries.flatten() {
868 if entry.file_type().map_or(false, |ft| ft.is_dir()) {
869 let name = entry.file_name().to_string_lossy().to_string();
870 if name.starts_with("npub1") {
871 if entry.path().join("vector.db").exists() {
873 accounts.push(name);
874 }
875 }
876 }
877 }
878 }
879
880 Ok(accounts)
881}
882
883pub fn get_profile_directory(npub: &str) -> Result<PathBuf, String> {
885 if !npub.starts_with("npub1") {
886 return Err(format!("Invalid npub format: {}", npub));
887 }
888 let dir = account_dir(npub)?;
889 if !dir.exists() {
890 std::fs::create_dir_all(&dir)
891 .map_err(|e| format!("Failed to create profile directory: {}", e))?;
892 }
893 Ok(dir)
894}
895
896pub fn get_database_path(npub: &str) -> Result<PathBuf, String> {
898 Ok(get_profile_directory(npub)?.join("vector.db"))
899}
900
901pub fn clear_id_caches() {
910 id_cache::clear_id_caches();
911 community::clear_banlist_cache();
912 community::clear_channel_community_cache();
913}
914
915#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
920#[repr(u8)]
921pub enum SystemEventType {
922 MemberLeft = 0,
923 MemberJoined = 1,
924 MemberRemoved = 2,
925 WallpaperChanged = 3,
926}
927
928impl SystemEventType {
929 pub fn display_message(&self, display_name: &str) -> String {
930 match self {
931 SystemEventType::MemberLeft => format!("{} has left", display_name),
932 SystemEventType::MemberJoined => format!("{} has joined", display_name),
933 SystemEventType::MemberRemoved => format!("{} was removed", display_name),
934 SystemEventType::WallpaperChanged => format!("{} changed the wallpaper", display_name),
935 }
936 }
937
938 pub fn as_u8(&self) -> u8 { *self as u8 }
939}
940
941#[cfg(test)]
942mod pool_generation_tests {
943 use super::*;
944 use tempfile::TempDir;
945
946 fn fake_conn() -> rusqlite::Connection {
950 rusqlite::Connection::open_in_memory().unwrap()
951 }
952
953 #[test]
954 fn close_database_bumps_generation() {
955 let before = current_pool_generation();
956 close_database();
957 let after = current_pool_generation();
958 assert!(after > before, "close_database must advance POOL_GENERATION");
959 }
960
961 #[test]
962 fn init_database_bumps_generation() {
963 let before = current_pool_generation();
968 let bumped = bump_pool_generation();
969 assert_eq!(bumped, before.wrapping_add(1));
970 assert_eq!(current_pool_generation(), bumped);
971 }
972
973 #[test]
974 fn stale_read_guard_does_not_return_to_pool_after_generation_bump() {
975 let _tmp = TempDir::new().unwrap(); let pool_size_before = DB_READ_POOL.lock().unwrap().len();
982
983 let stale_generation = current_pool_generation();
984 let guard = ConnectionGuard::new(fake_conn(), stale_generation);
985
986 bump_pool_generation();
988
989 drop(guard);
990
991 let pool_size_after = DB_READ_POOL.lock().unwrap().len();
992 assert_eq!(
993 pool_size_after, pool_size_before,
994 "stale read guard must not re-enter the pool"
995 );
996 }
997
998 #[test]
999 fn fresh_read_guard_returns_to_pool() {
1000 let pool_size_before = DB_READ_POOL.lock().unwrap().len();
1001
1002 let generation = current_pool_generation();
1003 let guard = ConnectionGuard::new(fake_conn(), generation);
1004
1005 drop(guard);
1007
1008 let pool_size_after = DB_READ_POOL.lock().unwrap().len();
1009 assert_eq!(
1010 pool_size_after,
1011 pool_size_before + 1,
1012 "fresh read guard should be returned to the pool"
1013 );
1014
1015 DB_READ_POOL.lock().unwrap().pop();
1018 }
1019
1020 #[test]
1021 fn stale_write_guard_does_not_overwrite_fresh_slot() {
1022 let stale_generation = current_pool_generation();
1025 let stale_guard = WriteConnectionGuard::new(fake_conn(), stale_generation);
1026
1027 bump_pool_generation();
1028
1029 let fresh_conn = fake_conn();
1031 *DB_WRITE_CONN.lock().unwrap() = Some(fresh_conn);
1032
1033 drop(stale_guard);
1034
1035 assert!(
1038 DB_WRITE_CONN.lock().unwrap().is_some(),
1039 "write slot must keep the freshly installed connection"
1040 );
1041
1042 *DB_WRITE_CONN.lock().unwrap() = None;
1044 }
1045
1046 #[test]
1047 fn stale_write_guard_does_not_fill_empty_slot() {
1048 let stale_generation = current_pool_generation();
1053 let stale_guard = WriteConnectionGuard::new(fake_conn(), stale_generation);
1054
1055 bump_pool_generation();
1056 *DB_WRITE_CONN.lock().unwrap() = None;
1057
1058 drop(stale_guard);
1059
1060 assert!(
1061 DB_WRITE_CONN.lock().unwrap().is_none(),
1062 "stale write guard must not fill an empty slot"
1063 );
1064 }
1065}