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 APP_VERSION: OnceLock<String> = OnceLock::new();
58
59pub fn set_app_version(version: impl Into<String>) {
60 let _ = APP_VERSION.set(version.into());
61}
62
63static DOWNLOAD_DIR_OVERRIDE: OnceLock<PathBuf> = OnceLock::new();
68
69pub fn set_download_dir(path: PathBuf) {
73 let _ = DOWNLOAD_DIR_OVERRIDE.set(path);
74}
75
76pub fn get_download_dir() -> PathBuf {
83 if let Some(installed) = DOWNLOAD_DIR_OVERRIDE.get() {
84 return installed.clone();
85 }
86 #[cfg(any(target_os = "macos", target_os = "linux"))]
87 {
88 if let Ok(home) = std::env::var("HOME") {
89 return PathBuf::from(home).join("Downloads/vector");
90 }
91 }
92 #[cfg(target_os = "windows")]
93 {
94 if let Ok(profile) = std::env::var("USERPROFILE") {
95 return PathBuf::from(profile).join("Downloads").join("vector");
96 }
97 }
98 if let Ok(data_dir) = get_app_data_dir() {
100 return data_dir.join("vector_downloads");
101 }
102 PathBuf::from("/tmp/vector_downloads")
103}
104
105static CURRENT_ACCOUNT: LazyLock<Arc<RwLock<Option<String>>>> = LazyLock::new(|| Arc::new(RwLock::new(None)));
110const ACTIVE_ACCOUNT_FILE: &str = "active_account";
115
116fn is_valid_npub(s: &str) -> bool {
118 if s.len() != 63 || !s.starts_with("npub1") {
119 return false;
120 }
121 s.bytes().skip(5).all(|c| matches!(c,
122 b'q' | b'p' | b'z' | b'r' | b'y' | b'9' | b'x' | b'8' |
123 b'g' | b'f' | b'2' | b't' | b'v' | b'd' | b'w' | b'0' |
124 b's' | b'3' | b'j' | b'n' | b'5' | b'4' | b'k' | b'h' |
125 b'c' | b'e' | b'6' | b'm' | b'u' | b'a' | b'7' | b'l'
126 ))
127}
128
129pub fn get_current_account() -> Result<String, String> {
130 CURRENT_ACCOUNT.read().unwrap()
131 .as_ref().cloned()
132 .ok_or_else(|| "No active account".to_string())
133}
134
135pub fn set_current_account(npub: String) -> Result<(), String> {
142 *CURRENT_ACCOUNT.write().unwrap() = Some(npub.clone());
143 let _ = write_active_account_file(&npub);
144 Ok(())
145}
146
147pub fn clear_current_account_in_memory() {
151 *CURRENT_ACCOUNT.write().unwrap() = None;
152}
153
154pub fn read_active_account_file() -> Result<Option<String>, String> {
158 let app_data = match get_app_data_dir() {
159 Ok(p) => p,
160 Err(_) => return Ok(None),
161 };
162 read_active_account_file_in(app_data)
163}
164
165pub fn write_active_account_file(npub: &str) -> Result<(), String> {
167 let app_data = get_app_data_dir()?.clone();
168 write_active_account_file_in(&app_data, npub)
169}
170
171pub fn clear_active_account_file() -> Result<(), String> {
173 let app_data = get_app_data_dir()?;
174 clear_active_account_file_in(app_data)
175}
176
177pub fn list_account_npubs() -> Result<Vec<String>, String> {
181 let app_data = get_app_data_dir()?;
182 Ok(list_account_npubs_in(app_data))
183}
184
185const MARKER_MAX_BYTES: u64 = 256;
192
193fn read_active_account_file_in(app_data: &std::path::Path) -> Result<Option<String>, String> {
194 use std::io::Read;
195
196 let path = app_data.join(ACTIVE_ACCOUNT_FILE);
197 if !path.exists() {
198 return Ok(None);
199 }
200 if let Ok(meta) = std::fs::metadata(&path) {
204 if meta.len() > MARKER_MAX_BYTES {
205 return Ok(None);
206 }
207 } else {
208 return Ok(None);
209 }
210 let mut buf = String::new();
211 let file = match std::fs::File::open(&path) {
212 Ok(f) => f,
213 Err(_) => return Ok(None),
214 };
215 if file.take(MARKER_MAX_BYTES).read_to_string(&mut buf).is_err() {
216 return Ok(None);
217 }
218 let npub = buf.trim().to_string();
219 if !is_valid_npub(&npub) {
220 return Ok(None);
221 }
222 match std::fs::symlink_metadata(app_data.join(&npub)) {
229 Ok(meta) if meta.file_type().is_dir() && !meta.file_type().is_symlink() => {}
230 _ => return Ok(None),
231 }
232 Ok(Some(npub))
233}
234
235fn write_active_account_file_in(app_data: &std::path::Path, npub: &str) -> Result<(), String> {
236 if !is_valid_npub(npub) {
237 return Err(format!("Invalid npub format: {}", npub));
238 }
239 if !app_data.exists() {
240 std::fs::create_dir_all(app_data)
241 .map_err(|e| format!("Failed to create app data dir: {}", e))?;
242 }
243 match std::fs::symlink_metadata(app_data.join(npub)) {
249 Ok(meta) if meta.file_type().is_dir() && !meta.file_type().is_symlink() => {}
250 _ => return Err(format!("Account directory missing or invalid: {}", npub)),
251 }
252 let tmp = app_data.join(format!("{}.tmp", ACTIVE_ACCOUNT_FILE));
253 let final_path = app_data.join(ACTIVE_ACCOUNT_FILE);
254
255 let mut payload = String::with_capacity(npub.len() + 1);
258 payload.push_str(npub);
259 payload.push('\n');
260
261 if let Err(e) = std::fs::write(&tmp, payload.as_bytes()) {
262 let _ = std::fs::remove_file(&tmp);
263 return Err(format!("Failed to write active account temp file: {}", e));
264 }
265
266 let mut last_err = None;
269 for attempt in 0..3 {
270 match std::fs::rename(&tmp, &final_path) {
271 Ok(_) => return Ok(()),
272 Err(e) => {
273 last_err = Some(e);
274 if attempt < 2 {
275 std::thread::sleep(std::time::Duration::from_millis(50));
276 }
277 }
278 }
279 }
280
281 let _ = std::fs::remove_file(&tmp);
283 Err(format!(
284 "Failed to rename active account file: {}",
285 last_err.map(|e| e.to_string()).unwrap_or_default()
286 ))
287}
288
289fn clear_active_account_file_in(app_data: &std::path::Path) -> Result<(), String> {
290 let path = app_data.join(ACTIVE_ACCOUNT_FILE);
291 if path.exists() {
292 std::fs::remove_file(&path)
293 .map_err(|e| format!("Failed to remove active account file: {}", e))?;
294 }
295 Ok(())
296}
297
298fn list_account_npubs_in(app_data: &std::path::Path) -> Vec<String> {
299 let mut out = Vec::new();
300 if let Ok(entries) = std::fs::read_dir(app_data) {
301 for entry in entries.flatten() {
302 if entry.file_type().map_or(false, |ft| ft.is_dir()) {
303 let name = entry.file_name().to_string_lossy().to_string();
304 if is_valid_npub(&name) {
305 out.push(name);
306 }
307 }
308 }
309 }
310 out
311}
312
313#[cfg(test)]
314mod active_account_tests {
315 use super::*;
316 use std::fs;
317 use tempfile::TempDir;
318
319 const VALID_A: &str = "npub16ye7evyevwnl0fc9hujsxf9zym72e063awn0pvde0huvpyec5nyq4dg4wn";
321 const VALID_B: &str = "npub12w73tzcqgpr2pcy4el5x60d2emeud4cyeeayynzqgg2fefzgytaqm4ktz3";
322
323 fn touch_account_dir(base: &std::path::Path, npub: &str) {
324 fs::create_dir_all(base.join(npub)).unwrap();
325 }
326
327 #[test]
328 fn npub_validator_accepts_canonical_form() {
329 assert!(is_valid_npub(VALID_A));
330 assert!(is_valid_npub(VALID_B));
331 }
332
333 #[test]
334 fn npub_validator_rejects_wrong_length() {
335 assert!(!is_valid_npub("npub1abc"));
336 assert!(!is_valid_npub(&format!("{}x", VALID_A)));
337 assert!(!is_valid_npub(""));
338 }
339
340 #[test]
341 fn npub_validator_rejects_missing_prefix() {
342 let body = &VALID_A[5..];
343 assert!(!is_valid_npub(&format!("nsec1{}", body)));
344 assert!(!is_valid_npub(&format!("xxxx1{}", body)));
345 }
346
347 #[test]
348 fn npub_validator_rejects_non_bech32_chars() {
349 for bad in ['1', 'b', 'i', 'o', 'B', 'I', 'O', '!', '*', ' '] {
351 let mut s = String::from(VALID_A);
352 s.replace_range(10..11, &bad.to_string());
353 assert!(!is_valid_npub(&s), "should reject character {:?}", bad);
354 }
355 }
356
357 #[test]
358 fn write_then_read_round_trips() {
359 let tmp = TempDir::new().unwrap();
360 touch_account_dir(tmp.path(), VALID_A);
361
362 write_active_account_file_in(tmp.path(), VALID_A).unwrap();
363 assert_eq!(
364 read_active_account_file_in(tmp.path()).unwrap(),
365 Some(VALID_A.to_string())
366 );
367 }
368
369 #[test]
370 fn write_rejects_invalid_npub() {
371 let tmp = TempDir::new().unwrap();
372 let err = write_active_account_file_in(tmp.path(), "npub1nope").unwrap_err();
373 assert!(err.contains("Invalid"));
374 assert!(!tmp.path().join(ACTIVE_ACCOUNT_FILE).exists());
376 assert!(!tmp.path().join(format!("{}.tmp", ACTIVE_ACCOUNT_FILE)).exists());
377 }
378
379 #[test]
380 fn write_rejects_missing_account_dir() {
381 let tmp = TempDir::new().unwrap();
385 let err = write_active_account_file_in(tmp.path(), VALID_A).unwrap_err();
386 assert!(err.contains("missing or invalid"),
387 "expected account-dir-missing error, got: {}", err);
388 assert!(!tmp.path().join(ACTIVE_ACCOUNT_FILE).exists());
390 assert!(!tmp.path().join(format!("{}.tmp", ACTIVE_ACCOUNT_FILE)).exists());
391 }
392
393 #[test]
394 fn write_rejects_symlinked_account_dir() {
395 let tmp = TempDir::new().unwrap();
400 let target = TempDir::new().unwrap();
401 let link = tmp.path().join(VALID_A);
402 #[cfg(unix)]
403 {
404 std::os::unix::fs::symlink(target.path(), &link).unwrap();
405 let err = write_active_account_file_in(tmp.path(), VALID_A).unwrap_err();
406 assert!(err.contains("missing or invalid"),
407 "expected symlink rejection, got: {}", err);
408 }
409 #[cfg(not(unix))]
412 let _ = (target, link);
413 }
414
415 #[test]
416 fn read_returns_none_when_marker_missing() {
417 let tmp = TempDir::new().unwrap();
418 assert_eq!(read_active_account_file_in(tmp.path()).unwrap(), None);
419 }
420
421 #[test]
422 fn read_returns_none_when_marker_is_garbage() {
423 let tmp = TempDir::new().unwrap();
424 fs::write(tmp.path().join(ACTIVE_ACCOUNT_FILE), b"not-an-npub\n").unwrap();
425 assert_eq!(read_active_account_file_in(tmp.path()).unwrap(), None);
426 }
427
428 #[test]
429 fn read_returns_none_when_account_dir_missing() {
430 let tmp = TempDir::new().unwrap();
433 fs::write(tmp.path().join(ACTIVE_ACCOUNT_FILE), VALID_A).unwrap();
434 assert_eq!(read_active_account_file_in(tmp.path()).unwrap(), None);
435 }
436
437 #[test]
438 fn read_returns_none_when_marker_oversized() {
439 let tmp = TempDir::new().unwrap();
443 let payload = vec![b'x'; (MARKER_MAX_BYTES + 1024) as usize];
444 fs::write(tmp.path().join(ACTIVE_ACCOUNT_FILE), &payload).unwrap();
445 assert_eq!(read_active_account_file_in(tmp.path()).unwrap(), None);
446 }
447
448 #[test]
449 fn read_trims_whitespace() {
450 let tmp = TempDir::new().unwrap();
451 touch_account_dir(tmp.path(), VALID_A);
452 fs::write(
453 tmp.path().join(ACTIVE_ACCOUNT_FILE),
454 format!(" {}\n", VALID_A),
455 ).unwrap();
456 assert_eq!(
457 read_active_account_file_in(tmp.path()).unwrap(),
458 Some(VALID_A.to_string())
459 );
460 }
461
462 #[test]
463 fn read_handles_crlf_line_endings() {
464 let tmp = TempDir::new().unwrap();
465 touch_account_dir(tmp.path(), VALID_A);
466 fs::write(
467 tmp.path().join(ACTIVE_ACCOUNT_FILE),
468 format!("{}\r\n", VALID_A),
469 ).unwrap();
470 assert_eq!(
471 read_active_account_file_in(tmp.path()).unwrap(),
472 Some(VALID_A.to_string())
473 );
474 }
475
476 #[test]
477 fn npub_validator_rejects_uppercase_prefix() {
478 let upper = format!("NPUB1{}", &VALID_A[5..]);
479 assert!(!is_valid_npub(&upper));
480 }
481
482 #[test]
483 fn write_then_read_round_trips_with_newline() {
484 let tmp = TempDir::new().unwrap();
487 touch_account_dir(tmp.path(), VALID_A);
488 write_active_account_file_in(tmp.path(), VALID_A).unwrap();
489
490 let raw = fs::read_to_string(tmp.path().join(ACTIVE_ACCOUNT_FILE)).unwrap();
491 assert!(raw.ends_with('\n'));
492
493 assert_eq!(
494 read_active_account_file_in(tmp.path()).unwrap(),
495 Some(VALID_A.to_string())
496 );
497 }
498
499 #[test]
500 fn write_overwrites_previous_marker_atomically() {
501 let tmp = TempDir::new().unwrap();
502 touch_account_dir(tmp.path(), VALID_A);
503 touch_account_dir(tmp.path(), VALID_B);
504
505 write_active_account_file_in(tmp.path(), VALID_A).unwrap();
506 write_active_account_file_in(tmp.path(), VALID_B).unwrap();
507
508 assert_eq!(
509 read_active_account_file_in(tmp.path()).unwrap(),
510 Some(VALID_B.to_string())
511 );
512 assert!(!tmp.path().join(format!("{}.tmp", ACTIVE_ACCOUNT_FILE)).exists());
514 }
515
516 #[test]
517 fn clear_removes_marker_and_is_idempotent() {
518 let tmp = TempDir::new().unwrap();
519 touch_account_dir(tmp.path(), VALID_A);
520 write_active_account_file_in(tmp.path(), VALID_A).unwrap();
521 assert!(tmp.path().join(ACTIVE_ACCOUNT_FILE).exists());
522
523 clear_active_account_file_in(tmp.path()).unwrap();
524 assert!(!tmp.path().join(ACTIVE_ACCOUNT_FILE).exists());
525
526 clear_active_account_file_in(tmp.path()).unwrap();
528 }
529
530 #[test]
531 fn list_npubs_finds_valid_dirs_only() {
532 let tmp = TempDir::new().unwrap();
533 touch_account_dir(tmp.path(), VALID_A);
534 touch_account_dir(tmp.path(), VALID_B);
535 fs::create_dir_all(tmp.path().join("npub1tooshort")).unwrap();
537 fs::create_dir_all(tmp.path().join("not-an-npub-dir")).unwrap();
538 fs::create_dir_all(tmp.path().join("tor")).unwrap();
539 fs::write(tmp.path().join(ACTIVE_ACCOUNT_FILE), VALID_A).unwrap();
540
541 let mut found = list_account_npubs_in(tmp.path());
542 found.sort();
543 let mut expected = vec![VALID_A.to_string(), VALID_B.to_string()];
544 expected.sort();
545 assert_eq!(found, expected);
546 }
547
548 #[test]
549 fn list_npubs_skips_dirs_containing_invalid_chars() {
550 let tmp = TempDir::new().unwrap();
551 let mut bogus = String::from(VALID_A);
553 bogus.replace_range(10..11, "b");
554 fs::create_dir_all(tmp.path().join(&bogus)).unwrap();
555
556 let found = list_account_npubs_in(tmp.path());
557 assert!(found.is_empty(), "found unexpected entries: {:?}", found);
558 }
559
560 #[test]
561 fn write_creates_app_data_dir_if_missing() {
562 let tmp = TempDir::new().unwrap();
563 let nested = tmp.path().join("does/not/exist/yet");
564 std::fs::create_dir_all(&nested).unwrap();
568 touch_account_dir(&nested, VALID_A);
569 write_active_account_file_in(&nested, VALID_A).unwrap();
570 assert!(nested.join(ACTIVE_ACCOUNT_FILE).exists());
571 }
572}
573
574static DB_READ_POOL: LazyLock<Arc<Mutex<Vec<rusqlite::Connection>>>> =
582 LazyLock::new(|| Arc::new(Mutex::new(Vec::new())));
583
584static DB_WRITE_CONN: LazyLock<Arc<Mutex<Option<rusqlite::Connection>>>> =
585 LazyLock::new(|| Arc::new(Mutex::new(None)));
586
587static POOL_GENERATION: AtomicU64 = AtomicU64::new(0);
600
601#[inline]
602fn current_pool_generation() -> u64 {
603 POOL_GENERATION.load(Ordering::Acquire)
604}
605
606#[inline]
607fn bump_pool_generation() -> u64 {
608 POOL_GENERATION.fetch_add(1, Ordering::AcqRel).wrapping_add(1)
610}
611
612pub struct ConnectionGuard {
614 conn: Option<rusqlite::Connection>,
615 generation: u64,
616}
617
618impl ConnectionGuard {
619 fn new(conn: rusqlite::Connection, generation: u64) -> Self {
620 Self { conn: Some(conn), generation }
621 }
622}
623
624impl Deref for ConnectionGuard {
625 type Target = rusqlite::Connection;
626 fn deref(&self) -> &Self::Target { self.conn.as_ref().expect("Connection already taken") }
627}
628
629impl DerefMut for ConnectionGuard {
630 fn deref_mut(&mut self) -> &mut Self::Target { self.conn.as_mut().expect("Connection already taken") }
631}
632
633impl Drop for ConnectionGuard {
634 fn drop(&mut self) {
635 if let Some(conn) = self.conn.take() {
636 if self.generation == current_pool_generation() {
640 if let Ok(mut pool) = DB_READ_POOL.lock() {
641 pool.push(conn);
642 }
643 }
644 }
645 }
646}
647
648pub struct WriteConnectionGuard {
650 conn: Option<rusqlite::Connection>,
651 generation: u64,
652}
653
654impl WriteConnectionGuard {
655 fn new(conn: rusqlite::Connection, generation: u64) -> Self {
656 Self { conn: Some(conn), generation }
657 }
658}
659
660impl Deref for WriteConnectionGuard {
661 type Target = rusqlite::Connection;
662 fn deref(&self) -> &Self::Target { self.conn.as_ref().expect("Write connection already taken") }
663}
664
665impl DerefMut for WriteConnectionGuard {
666 fn deref_mut(&mut self) -> &mut Self::Target { self.conn.as_mut().expect("Write connection already taken") }
667}
668
669impl Drop for WriteConnectionGuard {
670 fn drop(&mut self) {
671 if let Some(conn) = self.conn.take() {
672 if self.generation == current_pool_generation() {
677 if let Ok(mut slot) = DB_WRITE_CONN.lock() {
678 if slot.is_none() {
679 *slot = Some(conn);
680 }
681 }
682 }
683 }
684 }
685}
686
687pub fn account_dir(npub: &str) -> Result<PathBuf, String> {
701 if !is_valid_npub(npub) {
702 return Err(format!("Invalid npub format: {}", npub));
703 }
704 Ok(get_app_data_dir()?.join(npub))
705}
706
707fn get_current_db_path() -> Result<PathBuf, String> {
708 let npub = get_current_account()?;
709 Ok(account_dir(&npub)?.join("vector.db"))
710}
711
712fn create_connection(path: &PathBuf) -> Result<rusqlite::Connection, String> {
713 let conn = rusqlite::Connection::open(path)
714 .map_err(|e| format!("Failed to open database: {}", e))?;
715
716 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;")
720 .map_err(|e| format!("Failed to set pragmas: {}", e))?;
721
722 Ok(conn)
723}
724
725pub fn get_db_connection_guard_static() -> Result<ConnectionGuard, String> {
727 let generation = current_pool_generation();
728 if let Ok(mut pool) = DB_READ_POOL.lock() {
730 if let Some(conn) = pool.pop() {
731 return Ok(ConnectionGuard::new(conn, generation));
732 }
733 }
734 let path = get_current_db_path()?;
736 let conn = create_connection(&path)?;
737 Ok(ConnectionGuard::new(conn, generation))
738}
739
740#[cfg(test)]
746pub(crate) static DB_TEST_GUARD: std::sync::Mutex<()> = std::sync::Mutex::new(());
747
748pub fn get_write_connection_guard_static() -> Result<WriteConnectionGuard, String> {
750 let generation = current_pool_generation();
751 let mut write_slot = DB_WRITE_CONN.lock().unwrap();
752 if let Some(conn) = write_slot.take() {
753 return Ok(WriteConnectionGuard::new(conn, generation));
754 }
755 drop(write_slot);
756
757 let path = get_current_db_path()?;
758 let conn = create_connection(&path)?;
759 Ok(WriteConnectionGuard::new(conn, generation))
760}
761
762const LAST_APP_VERSION_KEY: &str = "last_app_version";
768
769#[derive(Debug, Clone, serde::Serialize)]
775pub struct DowngradeBlock {
776 pub db_schema: u32,
778 pub supported_schema: u32,
780 pub last_app_version: Option<String>,
782}
783
784impl std::fmt::Display for DowngradeBlock {
785 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
786 write!(f, "This account was last opened by a newer version of Vector")?;
787 if let Some(version) = &self.last_app_version {
788 write!(f, " ({version})")?;
789 }
790 write!(
791 f,
792 ". Its database is at schema {} and this build only understands {}. \
793 Opening it would corrupt your messages, so Vector has stopped. \
794 Reinstall the newer version to continue.",
795 self.db_schema, self.supported_schema
796 )
797 }
798}
799
800fn downgrade_block(conn: &rusqlite::Connection) -> Option<DowngradeBlock> {
802 let db_schema = schema::applied_migration_high_water(conn);
803 if db_schema <= schema::HIGHEST_MIGRATION_ID {
804 return None;
805 }
806 Some(DowngradeBlock {
807 db_schema,
808 supported_schema: schema::HIGHEST_MIGRATION_ID,
809 last_app_version: conn
810 .query_row(
811 "SELECT value FROM settings WHERE key = ?1",
812 rusqlite::params![LAST_APP_VERSION_KEY],
813 |row| row.get::<_, String>(0),
814 )
815 .ok(),
816 })
817}
818
819pub fn inspect_downgrade(npub: &str) -> Result<Option<DowngradeBlock>, String> {
825 let db_path = account_dir(npub)?.join("vector.db");
826 if !db_path.exists() {
828 return Ok(None);
829 }
830 let conn = create_connection(&db_path)?;
831 Ok(downgrade_block(&conn))
832}
833
834pub fn init_database(npub: &str) -> Result<(), String> {
840 let profile_dir = account_dir(npub)?;
841
842 if !profile_dir.exists() {
843 std::fs::create_dir_all(&profile_dir)
844 .map_err(|e| format!("Failed to create profile directory: {}", e))?;
845 }
846
847 let db_path = profile_dir.join("vector.db");
848 let mut conn = create_connection(&db_path)?;
849
850 if let Some(block) = downgrade_block(&conn) {
854 return Err(block.to_string());
855 }
856
857 conn.execute_batch(schema::SQL_SCHEMA)
858 .map_err(|e| format!("Failed to create schema: {}", e))?;
859
860 schema::run_migrations(&mut conn)?;
862
863 if let Some(version) = APP_VERSION.get() {
867 let _ = conn.execute(
868 "INSERT OR REPLACE INTO settings (key, value) VALUES (?1, ?2)",
869 rusqlite::params![LAST_APP_VERSION_KEY, version],
870 );
871 }
872
873 let _ = conn.execute_batch("PRAGMA optimize=0x10002;");
878
879 let mls_dir = profile_dir.join("mls");
885 if mls_dir.exists() {
886 match std::fs::remove_dir_all(&mls_dir) {
887 Ok(()) => crate::log_info!("[db] purged orphaned MLS store for account"),
888 Err(e) => crate::log_warn!("[db] could not purge orphaned MLS store: {}", e),
889 }
890 }
891
892 bump_pool_generation();
896
897 if let Ok(mut pool) = DB_READ_POOL.lock() {
899 pool.clear();
900 for _ in 0..4 {
901 if let Ok(c) = create_connection(&db_path) {
902 pool.push(c);
903 }
904 }
905 }
906
907 let write_conn = create_connection(&db_path)?;
909 *DB_WRITE_CONN.lock().unwrap() = Some(write_conn);
910
911 #[cfg(feature = "tor")]
917 {
918 let enabled = create_connection(&db_path)
919 .ok()
920 .and_then(|c| {
921 c.query_row(
922 "SELECT value FROM settings WHERE key = 'tor_enabled'",
923 [],
924 |row| row.get::<_, String>(0),
925 )
926 .ok()
927 })
928 .map(|v| v == "1" || v == "true")
929 .unwrap_or(false);
930 crate::tor::set_tor_enabled_pref(enabled);
931 }
932
933 Ok(())
934}
935
936pub fn close_database() {
941 bump_pool_generation();
942 if let Ok(mut pool) = DB_READ_POOL.lock() {
943 pool.clear();
944 }
945 *DB_WRITE_CONN.lock().unwrap() = None;
946}
947
948pub fn optimize_database() {
953 if let Ok(guard) = DB_WRITE_CONN.lock() {
954 if let Some(conn) = guard.as_ref() {
955 let _ = conn.execute_batch("PRAGMA optimize;");
956 }
957 }
958}
959
960pub fn get_accounts() -> Result<Vec<String>, String> {
962 let app_data = get_app_data_dir()?;
963 let mut accounts = Vec::new();
964
965 if let Ok(entries) = std::fs::read_dir(app_data) {
966 for entry in entries.flatten() {
967 if entry.file_type().map_or(false, |ft| ft.is_dir()) {
968 let name = entry.file_name().to_string_lossy().to_string();
969 if name.starts_with("npub1") {
970 if entry.path().join("vector.db").exists() {
972 accounts.push(name);
973 }
974 }
975 }
976 }
977 }
978
979 Ok(accounts)
980}
981
982pub fn get_profile_directory(npub: &str) -> Result<PathBuf, String> {
984 if !npub.starts_with("npub1") {
985 return Err(format!("Invalid npub format: {}", npub));
986 }
987 let dir = account_dir(npub)?;
988 if !dir.exists() {
989 std::fs::create_dir_all(&dir)
990 .map_err(|e| format!("Failed to create profile directory: {}", e))?;
991 }
992 Ok(dir)
993}
994
995pub fn get_database_path(npub: &str) -> Result<PathBuf, String> {
997 Ok(get_profile_directory(npub)?.join("vector.db"))
998}
999
1000pub fn clear_id_caches() {
1009 id_cache::clear_id_caches();
1010 community::clear_banlist_cache();
1011 community::clear_channel_community_cache();
1012}
1013
1014#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1019#[repr(u8)]
1020pub enum SystemEventType {
1021 MemberLeft = 0,
1022 MemberJoined = 1,
1023 MemberRemoved = 2,
1024 WallpaperChanged = 3,
1025}
1026
1027impl SystemEventType {
1028 pub fn display_message(&self, display_name: &str) -> String {
1029 match self {
1030 SystemEventType::MemberLeft => format!("{} has left", display_name),
1031 SystemEventType::MemberJoined => format!("{} has joined", display_name),
1032 SystemEventType::MemberRemoved => format!("{} was removed", display_name),
1033 SystemEventType::WallpaperChanged => format!("{} changed the wallpaper", display_name),
1034 }
1035 }
1036
1037 pub fn as_u8(&self) -> u8 { *self as u8 }
1038}
1039
1040#[cfg(test)]
1041mod pool_generation_tests {
1042 use super::*;
1043 use tempfile::TempDir;
1044
1045 fn fake_conn() -> rusqlite::Connection {
1049 rusqlite::Connection::open_in_memory().unwrap()
1050 }
1051
1052 #[test]
1053 fn close_database_bumps_generation() {
1054 let before = current_pool_generation();
1055 close_database();
1056 let after = current_pool_generation();
1057 assert!(after > before, "close_database must advance POOL_GENERATION");
1058 }
1059
1060 #[test]
1061 fn init_database_bumps_generation() {
1062 let before = current_pool_generation();
1067 let bumped = bump_pool_generation();
1068 assert_eq!(bumped, before.wrapping_add(1));
1069 assert_eq!(current_pool_generation(), bumped);
1070 }
1071
1072 #[test]
1073 fn stale_read_guard_does_not_return_to_pool_after_generation_bump() {
1074 let _tmp = TempDir::new().unwrap(); let pool_size_before = DB_READ_POOL.lock().unwrap().len();
1081
1082 let stale_generation = current_pool_generation();
1083 let guard = ConnectionGuard::new(fake_conn(), stale_generation);
1084
1085 bump_pool_generation();
1087
1088 drop(guard);
1089
1090 let pool_size_after = DB_READ_POOL.lock().unwrap().len();
1091 assert_eq!(
1092 pool_size_after, pool_size_before,
1093 "stale read guard must not re-enter the pool"
1094 );
1095 }
1096
1097 #[test]
1098 fn fresh_read_guard_returns_to_pool() {
1099 let pool_size_before = DB_READ_POOL.lock().unwrap().len();
1100
1101 let generation = current_pool_generation();
1102 let guard = ConnectionGuard::new(fake_conn(), generation);
1103
1104 drop(guard);
1106
1107 let pool_size_after = DB_READ_POOL.lock().unwrap().len();
1108 assert_eq!(
1109 pool_size_after,
1110 pool_size_before + 1,
1111 "fresh read guard should be returned to the pool"
1112 );
1113
1114 DB_READ_POOL.lock().unwrap().pop();
1117 }
1118
1119 #[test]
1120 fn stale_write_guard_does_not_overwrite_fresh_slot() {
1121 let stale_generation = current_pool_generation();
1124 let stale_guard = WriteConnectionGuard::new(fake_conn(), stale_generation);
1125
1126 bump_pool_generation();
1127
1128 let fresh_conn = fake_conn();
1130 *DB_WRITE_CONN.lock().unwrap() = Some(fresh_conn);
1131
1132 drop(stale_guard);
1133
1134 assert!(
1137 DB_WRITE_CONN.lock().unwrap().is_some(),
1138 "write slot must keep the freshly installed connection"
1139 );
1140
1141 *DB_WRITE_CONN.lock().unwrap() = None;
1143 }
1144
1145 #[test]
1146 fn stale_write_guard_does_not_fill_empty_slot() {
1147 let stale_generation = current_pool_generation();
1152 let stale_guard = WriteConnectionGuard::new(fake_conn(), stale_generation);
1153
1154 bump_pool_generation();
1155 *DB_WRITE_CONN.lock().unwrap() = None;
1156
1157 drop(stale_guard);
1158
1159 assert!(
1160 DB_WRITE_CONN.lock().unwrap().is_none(),
1161 "stale write guard must not fill an empty slot"
1162 );
1163 }
1164}
1165
1166#[cfg(test)]
1167mod downgrade_tests {
1168 use super::*;
1169
1170 fn test_account() -> (tempfile::TempDir, std::sync::MutexGuard<'static, ()>, String) {
1171 let guard = DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
1172 close_database();
1173 clear_id_caches();
1174 let tmp = tempfile::tempdir().unwrap();
1175 static N: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(61_000);
1180 let n = N.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1181 const B: &[u8] = b"qpzry9x8gf2tvdw0s3jn54khce6mua7l";
1182 let mut acct = String::from("npub1");
1183 let mut v = n as usize;
1184 for _ in 0..58 {
1185 acct.push(B[v % 32] as char);
1186 v = v / 32 + 7;
1187 }
1188 set_app_data_dir(tmp.path().to_path_buf());
1189 set_current_account(acct.clone()).unwrap();
1190 (tmp, guard, acct)
1191 }
1192
1193 #[test]
1195 fn an_equal_schema_opens_normally() {
1196 let (_tmp, _guard, acct) = test_account();
1197 init_database(&acct).unwrap();
1198 assert!(inspect_downgrade(&acct).unwrap().is_none());
1199 init_database(&acct).unwrap();
1201 assert!(inspect_downgrade(&acct).unwrap().is_none());
1202 }
1203
1204 #[test]
1206 fn a_missing_database_is_not_a_downgrade() {
1207 let (_tmp, _guard, acct) = test_account();
1208 assert!(inspect_downgrade(&acct).unwrap().is_none());
1209 assert!(!account_dir(&acct).unwrap().join("vector.db").exists());
1210 }
1211
1212 #[test]
1213 fn a_newer_schema_blocks_the_open_and_names_the_build() {
1214 let (_tmp, _guard, acct) = test_account();
1215 init_database(&acct).unwrap();
1216
1217 let db_path = account_dir(&acct).unwrap().join("vector.db");
1219 {
1220 let conn = create_connection(&db_path).unwrap();
1221 conn.execute(
1222 "INSERT OR REPLACE INTO schema_migrations (id, applied_at) VALUES (?1, 0)",
1223 rusqlite::params![schema::HIGHEST_MIGRATION_ID + 1],
1224 )
1225 .unwrap();
1226 conn.execute(
1227 "INSERT OR REPLACE INTO settings (key, value) VALUES (?1, ?2)",
1228 rusqlite::params![LAST_APP_VERSION_KEY, "9.9.9"],
1229 )
1230 .unwrap();
1231 }
1232 close_database();
1233
1234 let block = inspect_downgrade(&acct)
1235 .unwrap()
1236 .expect("a higher migration id must read as a downgrade");
1237 assert_eq!(block.db_schema, schema::HIGHEST_MIGRATION_ID + 1);
1238 assert_eq!(block.supported_schema, schema::HIGHEST_MIGRATION_ID);
1239 assert_eq!(block.last_app_version.as_deref(), Some("9.9.9"));
1240
1241 let err = init_database(&acct).unwrap_err();
1242 assert!(err.contains("9.9.9"), "must name the newer build: {err}");
1243 }
1244
1245 #[test]
1248 fn a_blocked_open_writes_nothing() {
1249 let (_tmp, _guard, acct) = test_account();
1250 init_database(&acct).unwrap();
1251 let db_path = account_dir(&acct).unwrap().join("vector.db");
1252 {
1253 let conn = create_connection(&db_path).unwrap();
1254 conn.execute(
1255 "INSERT OR REPLACE INTO schema_migrations (id, applied_at) VALUES (?1, 0)",
1256 rusqlite::params![schema::HIGHEST_MIGRATION_ID + 5],
1257 )
1258 .unwrap();
1259 conn.execute("DROP TABLE IF EXISTS settings", []).unwrap();
1260 }
1261 close_database();
1262
1263 assert!(init_database(&acct).is_err());
1264
1265 let conn = create_connection(&db_path).unwrap();
1267 let exists: bool = conn
1268 .query_row(
1269 "SELECT 1 FROM sqlite_master WHERE type='table' AND name='settings'",
1270 [],
1271 |_| Ok(true),
1272 )
1273 .unwrap_or(false);
1274 assert!(!exists, "a blocked open must not write to the database");
1275 }
1276}