1use std::path::PathBuf;
11use std::sync::{Arc, Mutex, OnceLock, LazyLock, RwLock};
12use std::ops::{Deref, DerefMut};
13
14use serde::{Deserialize, Serialize};
15
16pub mod settings;
17pub mod schema;
18pub mod profiles;
19pub mod id_cache;
20pub mod events;
21pub mod attachments;
22pub mod chats;
23pub mod wrappers;
24pub mod nip17_keys;
25pub mod community;
26pub mod bots;
27
28pub use settings::{
29 get_sql_setting, set_sql_setting, advance_u64_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 get_nip55_user_pubkey, set_nip55_user_pubkey,
35 get_nip55_signer_package, set_nip55_signer_package,
36 commit_nip55_account_setup,
37};
38
39static APP_DATA_DIR: OnceLock<PathBuf> = OnceLock::new();
44
45pub fn set_app_data_dir(path: PathBuf) {
46 let _ = APP_DATA_DIR.set(path);
47}
48
49#[cfg(test)]
59pub(crate) fn shared_test_data_dir() -> &'static std::path::Path {
60 static DIR: OnceLock<tempfile::TempDir> = OnceLock::new();
61 DIR.get_or_init(|| tempfile::tempdir().expect("test data dir")).path()
64}
65
66pub fn get_app_data_dir() -> Result<&'static PathBuf, String> {
67 APP_DATA_DIR.get().ok_or_else(|| "App data directory not initialized".to_string())
68}
69
70static APP_VERSION: OnceLock<String> = OnceLock::new();
74
75pub fn set_app_version(version: impl Into<String>) {
76 let _ = APP_VERSION.set(version.into());
77}
78
79static DOWNLOAD_DIR_OVERRIDE: OnceLock<PathBuf> = OnceLock::new();
84
85pub fn set_download_dir(path: PathBuf) {
89 let _ = DOWNLOAD_DIR_OVERRIDE.set(path);
90}
91
92pub fn get_download_dir() -> PathBuf {
99 if let Some(installed) = DOWNLOAD_DIR_OVERRIDE.get() {
100 return installed.clone();
101 }
102 #[cfg(any(target_os = "macos", target_os = "linux"))]
103 {
104 if let Ok(home) = std::env::var("HOME") {
105 return PathBuf::from(home).join("Downloads/vector");
106 }
107 }
108 #[cfg(target_os = "windows")]
109 {
110 if let Ok(profile) = std::env::var("USERPROFILE") {
111 return PathBuf::from(profile).join("Downloads").join("vector");
112 }
113 }
114 if let Ok(data_dir) = get_app_data_dir() {
116 return data_dir.join("vector_downloads");
117 }
118 PathBuf::from("/tmp/vector_downloads")
119}
120
121static CURRENT_ACCOUNT: LazyLock<Arc<RwLock<Option<String>>>> = LazyLock::new(|| Arc::new(RwLock::new(None)));
126const ACTIVE_ACCOUNT_FILE: &str = "active_account";
131
132fn is_valid_npub(s: &str) -> bool {
134 if s.len() != 63 || !s.starts_with("npub1") {
135 return false;
136 }
137 s.bytes().skip(5).all(|c| matches!(c,
138 b'q' | b'p' | b'z' | b'r' | b'y' | b'9' | b'x' | b'8' |
139 b'g' | b'f' | b'2' | b't' | b'v' | b'd' | b'w' | b'0' |
140 b's' | b'3' | b'j' | b'n' | b'5' | b'4' | b'k' | b'h' |
141 b'c' | b'e' | b'6' | b'm' | b'u' | b'a' | b'7' | b'l'
142 ))
143}
144
145pub fn get_current_account() -> Result<String, String> {
146 CURRENT_ACCOUNT.read().unwrap()
147 .as_ref().cloned()
148 .ok_or_else(|| "No active account".to_string())
149}
150
151pub fn set_current_account(npub: String) -> Result<(), String> {
158 *CURRENT_ACCOUNT.write().unwrap() = Some(npub.clone());
159 let _ = write_active_account_file(&npub);
160 Ok(())
161}
162
163pub fn clear_current_account_in_memory() {
167 *CURRENT_ACCOUNT.write().unwrap() = None;
168}
169
170pub fn read_active_account_file() -> Result<Option<String>, String> {
174 let app_data = match get_app_data_dir() {
175 Ok(p) => p,
176 Err(_) => return Ok(None),
177 };
178 read_active_account_file_in(app_data)
179}
180
181pub fn write_active_account_file(npub: &str) -> Result<(), String> {
183 let app_data = get_app_data_dir()?.clone();
184 write_active_account_file_in(&app_data, npub)
185}
186
187pub fn clear_active_account_file() -> Result<(), String> {
189 let app_data = get_app_data_dir()?;
190 clear_active_account_file_in(app_data)
191}
192
193pub fn list_account_npubs() -> Result<Vec<String>, String> {
197 let app_data = get_app_data_dir()?;
198 Ok(list_account_npubs_in(app_data))
199}
200
201const MARKER_MAX_BYTES: u64 = 256;
208
209fn read_active_account_file_in(app_data: &std::path::Path) -> Result<Option<String>, String> {
210 use std::io::Read;
211
212 let path = app_data.join(ACTIVE_ACCOUNT_FILE);
213 if !path.exists() {
214 return Ok(None);
215 }
216 if let Ok(meta) = std::fs::metadata(&path) {
220 if meta.len() > MARKER_MAX_BYTES {
221 return Ok(None);
222 }
223 } else {
224 return Ok(None);
225 }
226 let mut buf = String::new();
227 let file = match std::fs::File::open(&path) {
228 Ok(f) => f,
229 Err(_) => return Ok(None),
230 };
231 if file.take(MARKER_MAX_BYTES).read_to_string(&mut buf).is_err() {
232 return Ok(None);
233 }
234 let npub = buf.trim().to_string();
235 if !is_valid_npub(&npub) {
236 return Ok(None);
237 }
238 match std::fs::symlink_metadata(app_data.join(&npub)) {
245 Ok(meta) if meta.file_type().is_dir() && !meta.file_type().is_symlink() => {}
246 _ => return Ok(None),
247 }
248 Ok(Some(npub))
249}
250
251fn write_active_account_file_in(app_data: &std::path::Path, npub: &str) -> Result<(), String> {
252 if !is_valid_npub(npub) {
253 return Err(format!("Invalid npub format: {}", npub));
254 }
255 if !app_data.exists() {
256 std::fs::create_dir_all(app_data)
257 .map_err(|e| format!("Failed to create app data dir: {}", e))?;
258 }
259 match std::fs::symlink_metadata(app_data.join(npub)) {
265 Ok(meta) if meta.file_type().is_dir() && !meta.file_type().is_symlink() => {}
266 _ => return Err(format!("Account directory missing or invalid: {}", npub)),
267 }
268 let tmp = app_data.join(format!("{}.tmp", ACTIVE_ACCOUNT_FILE));
269 let final_path = app_data.join(ACTIVE_ACCOUNT_FILE);
270
271 let mut payload = String::with_capacity(npub.len() + 1);
274 payload.push_str(npub);
275 payload.push('\n');
276
277 if let Err(e) = std::fs::write(&tmp, payload.as_bytes()) {
278 let _ = std::fs::remove_file(&tmp);
279 return Err(format!("Failed to write active account temp file: {}", e));
280 }
281
282 let mut last_err = None;
285 for attempt in 0..3 {
286 match std::fs::rename(&tmp, &final_path) {
287 Ok(_) => return Ok(()),
288 Err(e) => {
289 last_err = Some(e);
290 if attempt < 2 {
291 std::thread::sleep(std::time::Duration::from_millis(50));
292 }
293 }
294 }
295 }
296
297 let _ = std::fs::remove_file(&tmp);
299 Err(format!(
300 "Failed to rename active account file: {}",
301 last_err.map(|e| e.to_string()).unwrap_or_default()
302 ))
303}
304
305fn clear_active_account_file_in(app_data: &std::path::Path) -> Result<(), String> {
306 let path = app_data.join(ACTIVE_ACCOUNT_FILE);
307 if path.exists() {
308 std::fs::remove_file(&path)
309 .map_err(|e| format!("Failed to remove active account file: {}", e))?;
310 }
311 Ok(())
312}
313
314fn list_account_npubs_in(app_data: &std::path::Path) -> Vec<String> {
315 let mut out = Vec::new();
316 if let Ok(entries) = std::fs::read_dir(app_data) {
317 for entry in entries.flatten() {
318 if entry.file_type().map_or(false, |ft| ft.is_dir()) {
319 let name = entry.file_name().to_string_lossy().to_string();
320 if is_valid_npub(&name) {
321 out.push(name);
322 }
323 }
324 }
325 }
326 out
327}
328
329#[cfg(test)]
330mod active_account_tests {
331 use super::*;
332 use std::fs;
333 use tempfile::TempDir;
334
335 const VALID_A: &str = "npub16ye7evyevwnl0fc9hujsxf9zym72e063awn0pvde0huvpyec5nyq4dg4wn";
337 const VALID_B: &str = "npub12w73tzcqgpr2pcy4el5x60d2emeud4cyeeayynzqgg2fefzgytaqm4ktz3";
338
339 fn touch_account_dir(base: &std::path::Path, npub: &str) {
340 fs::create_dir_all(base.join(npub)).unwrap();
341 }
342
343 #[test]
344 fn npub_validator_accepts_canonical_form() {
345 assert!(is_valid_npub(VALID_A));
346 assert!(is_valid_npub(VALID_B));
347 }
348
349 #[test]
350 fn npub_validator_rejects_wrong_length() {
351 assert!(!is_valid_npub("npub1abc"));
352 assert!(!is_valid_npub(&format!("{}x", VALID_A)));
353 assert!(!is_valid_npub(""));
354 }
355
356 #[test]
357 fn npub_validator_rejects_missing_prefix() {
358 let body = &VALID_A[5..];
359 assert!(!is_valid_npub(&format!("nsec1{}", body)));
360 assert!(!is_valid_npub(&format!("xxxx1{}", body)));
361 }
362
363 #[test]
364 fn npub_validator_rejects_non_bech32_chars() {
365 for bad in ['1', 'b', 'i', 'o', 'B', 'I', 'O', '!', '*', ' '] {
367 let mut s = String::from(VALID_A);
368 s.replace_range(10..11, &bad.to_string());
369 assert!(!is_valid_npub(&s), "should reject character {:?}", bad);
370 }
371 }
372
373 #[test]
374 fn write_then_read_round_trips() {
375 let tmp = TempDir::new().unwrap();
376 touch_account_dir(tmp.path(), VALID_A);
377
378 write_active_account_file_in(tmp.path(), VALID_A).unwrap();
379 assert_eq!(
380 read_active_account_file_in(tmp.path()).unwrap(),
381 Some(VALID_A.to_string())
382 );
383 }
384
385 #[test]
386 fn write_rejects_invalid_npub() {
387 let tmp = TempDir::new().unwrap();
388 let err = write_active_account_file_in(tmp.path(), "npub1nope").unwrap_err();
389 assert!(err.contains("Invalid"));
390 assert!(!tmp.path().join(ACTIVE_ACCOUNT_FILE).exists());
392 assert!(!tmp.path().join(format!("{}.tmp", ACTIVE_ACCOUNT_FILE)).exists());
393 }
394
395 #[test]
396 fn write_rejects_missing_account_dir() {
397 let tmp = TempDir::new().unwrap();
401 let err = write_active_account_file_in(tmp.path(), VALID_A).unwrap_err();
402 assert!(err.contains("missing or invalid"),
403 "expected account-dir-missing error, got: {}", err);
404 assert!(!tmp.path().join(ACTIVE_ACCOUNT_FILE).exists());
406 assert!(!tmp.path().join(format!("{}.tmp", ACTIVE_ACCOUNT_FILE)).exists());
407 }
408
409 #[test]
410 fn write_rejects_symlinked_account_dir() {
411 let tmp = TempDir::new().unwrap();
416 let target = TempDir::new().unwrap();
417 let link = tmp.path().join(VALID_A);
418 #[cfg(unix)]
419 {
420 std::os::unix::fs::symlink(target.path(), &link).unwrap();
421 let err = write_active_account_file_in(tmp.path(), VALID_A).unwrap_err();
422 assert!(err.contains("missing or invalid"),
423 "expected symlink rejection, got: {}", err);
424 }
425 #[cfg(not(unix))]
428 let _ = (target, link);
429 }
430
431 #[test]
432 fn read_returns_none_when_marker_missing() {
433 let tmp = TempDir::new().unwrap();
434 assert_eq!(read_active_account_file_in(tmp.path()).unwrap(), None);
435 }
436
437 #[test]
438 fn read_returns_none_when_marker_is_garbage() {
439 let tmp = TempDir::new().unwrap();
440 fs::write(tmp.path().join(ACTIVE_ACCOUNT_FILE), b"not-an-npub\n").unwrap();
441 assert_eq!(read_active_account_file_in(tmp.path()).unwrap(), None);
442 }
443
444 #[test]
445 fn read_returns_none_when_account_dir_missing() {
446 let tmp = TempDir::new().unwrap();
449 fs::write(tmp.path().join(ACTIVE_ACCOUNT_FILE), VALID_A).unwrap();
450 assert_eq!(read_active_account_file_in(tmp.path()).unwrap(), None);
451 }
452
453 #[test]
454 fn read_returns_none_when_marker_oversized() {
455 let tmp = TempDir::new().unwrap();
459 let payload = vec![b'x'; (MARKER_MAX_BYTES + 1024) as usize];
460 fs::write(tmp.path().join(ACTIVE_ACCOUNT_FILE), &payload).unwrap();
461 assert_eq!(read_active_account_file_in(tmp.path()).unwrap(), None);
462 }
463
464 #[test]
465 fn read_trims_whitespace() {
466 let tmp = TempDir::new().unwrap();
467 touch_account_dir(tmp.path(), VALID_A);
468 fs::write(
469 tmp.path().join(ACTIVE_ACCOUNT_FILE),
470 format!(" {}\n", VALID_A),
471 ).unwrap();
472 assert_eq!(
473 read_active_account_file_in(tmp.path()).unwrap(),
474 Some(VALID_A.to_string())
475 );
476 }
477
478 #[test]
479 fn read_handles_crlf_line_endings() {
480 let tmp = TempDir::new().unwrap();
481 touch_account_dir(tmp.path(), VALID_A);
482 fs::write(
483 tmp.path().join(ACTIVE_ACCOUNT_FILE),
484 format!("{}\r\n", VALID_A),
485 ).unwrap();
486 assert_eq!(
487 read_active_account_file_in(tmp.path()).unwrap(),
488 Some(VALID_A.to_string())
489 );
490 }
491
492 #[test]
493 fn npub_validator_rejects_uppercase_prefix() {
494 let upper = format!("NPUB1{}", &VALID_A[5..]);
495 assert!(!is_valid_npub(&upper));
496 }
497
498 #[test]
499 fn write_then_read_round_trips_with_newline() {
500 let tmp = TempDir::new().unwrap();
503 touch_account_dir(tmp.path(), VALID_A);
504 write_active_account_file_in(tmp.path(), VALID_A).unwrap();
505
506 let raw = fs::read_to_string(tmp.path().join(ACTIVE_ACCOUNT_FILE)).unwrap();
507 assert!(raw.ends_with('\n'));
508
509 assert_eq!(
510 read_active_account_file_in(tmp.path()).unwrap(),
511 Some(VALID_A.to_string())
512 );
513 }
514
515 #[test]
516 fn write_overwrites_previous_marker_atomically() {
517 let tmp = TempDir::new().unwrap();
518 touch_account_dir(tmp.path(), VALID_A);
519 touch_account_dir(tmp.path(), VALID_B);
520
521 write_active_account_file_in(tmp.path(), VALID_A).unwrap();
522 write_active_account_file_in(tmp.path(), VALID_B).unwrap();
523
524 assert_eq!(
525 read_active_account_file_in(tmp.path()).unwrap(),
526 Some(VALID_B.to_string())
527 );
528 assert!(!tmp.path().join(format!("{}.tmp", ACTIVE_ACCOUNT_FILE)).exists());
530 }
531
532 #[test]
533 fn clear_removes_marker_and_is_idempotent() {
534 let tmp = TempDir::new().unwrap();
535 touch_account_dir(tmp.path(), VALID_A);
536 write_active_account_file_in(tmp.path(), VALID_A).unwrap();
537 assert!(tmp.path().join(ACTIVE_ACCOUNT_FILE).exists());
538
539 clear_active_account_file_in(tmp.path()).unwrap();
540 assert!(!tmp.path().join(ACTIVE_ACCOUNT_FILE).exists());
541
542 clear_active_account_file_in(tmp.path()).unwrap();
544 }
545
546 #[test]
547 fn list_npubs_finds_valid_dirs_only() {
548 let tmp = TempDir::new().unwrap();
549 touch_account_dir(tmp.path(), VALID_A);
550 touch_account_dir(tmp.path(), VALID_B);
551 fs::create_dir_all(tmp.path().join("npub1tooshort")).unwrap();
553 fs::create_dir_all(tmp.path().join("not-an-npub-dir")).unwrap();
554 fs::create_dir_all(tmp.path().join("tor")).unwrap();
555 fs::write(tmp.path().join(ACTIVE_ACCOUNT_FILE), VALID_A).unwrap();
556
557 let mut found = list_account_npubs_in(tmp.path());
558 found.sort();
559 let mut expected = vec![VALID_A.to_string(), VALID_B.to_string()];
560 expected.sort();
561 assert_eq!(found, expected);
562 }
563
564 #[test]
565 fn list_npubs_skips_dirs_containing_invalid_chars() {
566 let tmp = TempDir::new().unwrap();
567 let mut bogus = String::from(VALID_A);
569 bogus.replace_range(10..11, "b");
570 fs::create_dir_all(tmp.path().join(&bogus)).unwrap();
571
572 let found = list_account_npubs_in(tmp.path());
573 assert!(found.is_empty(), "found unexpected entries: {:?}", found);
574 }
575
576 #[test]
577 fn write_creates_app_data_dir_if_missing() {
578 let tmp = TempDir::new().unwrap();
579 let nested = tmp.path().join("does/not/exist/yet");
580 std::fs::create_dir_all(&nested).unwrap();
584 touch_account_dir(&nested, VALID_A);
585 write_active_account_file_in(&nested, VALID_A).unwrap();
586 assert!(nested.join(ACTIVE_ACCOUNT_FILE).exists());
587 }
588}
589
590pub struct Session {
608 id: u64,
615 db_path: Option<PathBuf>,
619 read_pool: Mutex<Vec<rusqlite::Connection>>,
620 write_conn: Mutex<Option<rusqlite::Connection>>,
621 chat_state: Arc<tokio::sync::Mutex<crate::state::ChatState>>,
624 scoped: RwLock<std::collections::HashMap<std::any::TypeId, Arc<dyn std::any::Any + Send + Sync>>>,
626 stopped: SessionStop,
628}
629
630#[derive(Default)]
633struct SessionStop {
634 flag: std::sync::atomic::AtomicBool,
635 wake: tokio::sync::Notify,
636}
637
638impl Session {
639 fn empty() -> Arc<Self> {
640 Arc::new(Session {
641 id: next_session_id(),
642 db_path: None,
643 read_pool: Mutex::new(Vec::new()),
644 write_conn: Mutex::new(None),
645 chat_state: Arc::new(tokio::sync::Mutex::new(crate::state::ChatState::new())),
646 scoped: RwLock::new(std::collections::HashMap::new()),
647 stopped: SessionStop::default(),
648 })
649 }
650
651 fn bound(db_path: PathBuf) -> Arc<Self> {
654 Arc::new(Session {
655 id: next_session_id(),
656 db_path: Some(db_path),
657 read_pool: Mutex::new(Vec::new()),
658 write_conn: Mutex::new(None),
659 chat_state: Arc::new(tokio::sync::Mutex::new(crate::state::ChatState::new())),
660 scoped: RwLock::new(std::collections::HashMap::new()),
661 stopped: SessionStop::default(),
662 })
663 }
664
665 fn rebound(&self, db_path: PathBuf) -> Arc<Self> {
676 Arc::new(Session {
677 id: self.id,
678 db_path: Some(db_path),
679 read_pool: Mutex::new(Vec::new()),
680 write_conn: Mutex::new(None),
681 chat_state: self.chat_state.clone(),
682 scoped: RwLock::new(self.scoped.read().unwrap_or_else(|e| e.into_inner()).clone()),
683 stopped: SessionStop::default(),
684 })
685 }
686
687 pub fn scoped<K: 'static, T: Default + Send + Sync + 'static>(self: &Arc<Self>) -> Arc<T> {
708 let key = std::any::TypeId::of::<(K, T)>();
709 if let Some(existing) = self.scoped.read().unwrap_or_else(|e| e.into_inner()).get(&key) {
710 return existing.clone().downcast::<T>().expect("keyed by its own TypeId");
711 }
712 let mut map = self.scoped.write().unwrap_or_else(|e| e.into_inner());
713 map.entry(key)
715 .or_insert_with(|| Arc::new(T::default()) as Arc<dyn std::any::Any + Send + Sync>)
716 .clone()
717 .downcast::<T>()
718 .expect("keyed by its own TypeId")
719 }
720
721 pub fn id(&self) -> u64 {
725 self.id
726 }
727
728 pub fn stopped(&self) -> bool {
738 self.stopped.flag.load(std::sync::atomic::Ordering::Acquire)
739 }
740
741 pub async fn on_stop(&self) {
744 loop {
745 let waiting = self.stopped.wake.notified();
746 if self.stopped() {
747 return;
748 }
749 waiting.await;
750 if self.stopped() {
751 return;
752 }
753 }
754 }
755
756 fn stop(&self) {
757 self.stopped.flag.store(true, std::sync::atomic::Ordering::Release);
758 self.stopped.wake.notify_waiters();
759 }
760
761 pub fn is_live(&self) -> bool {
767 self.id == CURRENT_SESSION.read().unwrap_or_else(|e| e.into_inner()).id
768 }
769
770 pub fn chat_state(&self) -> Arc<tokio::sync::Mutex<crate::state::ChatState>> {
776 self.chat_state.clone()
777 }
778
779 fn path(&self) -> Result<PathBuf, String> {
783 match &self.db_path {
784 Some(p) => Ok(p.clone()),
785 None => get_current_db_path(),
786 }
787 }
788
789 pub fn acquire_read(self: &Arc<Self>) -> Result<ConnectionGuard, String> {
792 if let Ok(mut pool) = self.read_pool.lock() {
793 if let Some(conn) = pool.pop() {
794 return Ok(ConnectionGuard::new(conn, self.clone()));
795 }
796 }
797 let conn = create_connection(&self.path()?)?;
798 Ok(ConnectionGuard::new(conn, self.clone()))
799 }
800
801 pub fn acquire_write(self: &Arc<Self>) -> Result<WriteConnectionGuard, String> {
804 {
805 let mut slot = self.write_conn.lock().unwrap_or_else(|e| e.into_inner());
806 if let Some(conn) = slot.take() {
807 return Ok(WriteConnectionGuard::new(conn, self.clone()));
808 }
809 }
810 let conn = create_connection(&self.path()?)?;
811 Ok(WriteConnectionGuard::new(conn, self.clone()))
812 }
813}
814
815static CURRENT_SESSION: LazyLock<RwLock<Arc<Session>>> = LazyLock::new(|| RwLock::new(Session::empty()));
818
819tokio::task_local! {
820 static TASK_SESSION: Arc<Session>;
826}
827
828pub fn current_session() -> Arc<Session> {
837 TASK_SESSION
838 .try_with(Arc::clone)
839 .unwrap_or_else(|_| CURRENT_SESSION.read().unwrap_or_else(|e| e.into_inner()).clone())
840}
841
842pub fn scoped<F: std::future::Future>(fut: F) -> impl std::future::Future<Output = F::Output> {
864 TASK_SESSION.scope(current_session(), Box::pin(fut))
865}
866
867pub fn scoped_result<T, E, F>(fut: F) -> impl std::future::Future<Output = Result<T, E>>
874where
875 F: std::future::Future<Output = Result<T, E>>,
876 E: From<String>,
877{
878 let session = current_session();
879 let id = session.id;
880 let bound = TASK_SESSION.scope(session, Box::pin(fut));
883 async move {
884 let out = bound.await;
885 if id != CURRENT_SESSION.read().unwrap_or_else(|e| e.into_inner()).id {
886 return Err(E::from("account changed during the operation".to_string()));
887 }
888 out
889 }
890}
891
892pub fn with_session<F: std::future::Future>(
898 session: Arc<Session>,
899 fut: F,
900) -> impl std::future::Future<Output = F::Output> {
901 TASK_SESSION.scope(session, Box::pin(fut))
902}
903
904pub fn spawn_bound<F>(fut: F) -> tokio::task::JoinHandle<F::Output>
911where
912 F: std::future::Future + Send + 'static,
913 F::Output: Send + 'static,
914{
915 let session = current_session();
916 tokio::spawn(TASK_SESSION.scope(session, fut))
918}
919
920impl std::fmt::Debug for Session {
921 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
922 f.debug_struct("Session").field("id", &self.id).field("db", &self.db_path).finish()
923 }
924}
925
926pub fn session_stopped() -> bool {
931 current_session().stopped()
932}
933
934pub fn current_session_id() -> u64 {
936 current_session().id
937}
938
939pub fn session_is_live() -> bool {
947 current_session().id == CURRENT_SESSION.read().unwrap_or_else(|e| e.into_inner()).id
948}
949
950fn next_session_id() -> u64 {
951 static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
952 NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
953}
954
955fn replace_session() {
959 install(Session::empty());
960}
961
962fn install(next: Arc<Session>) {
964 let mut current = CURRENT_SESSION.write().unwrap_or_else(|e| e.into_inner());
965 if current.id != next.id {
966 current.stop();
967 }
968 *current = next;
969}
970
971pub struct ConnectionGuard {
978 conn: Option<rusqlite::Connection>,
979 session: Arc<Session>,
980}
981
982impl ConnectionGuard {
983 fn new(conn: rusqlite::Connection, session: Arc<Session>) -> Self {
984 Self { conn: Some(conn), session }
985 }
986}
987
988impl Deref for ConnectionGuard {
989 type Target = rusqlite::Connection;
990 fn deref(&self) -> &Self::Target { self.conn.as_ref().expect("Connection already taken") }
991}
992
993impl DerefMut for ConnectionGuard {
994 fn deref_mut(&mut self) -> &mut Self::Target { self.conn.as_mut().expect("Connection already taken") }
995}
996
997impl Drop for ConnectionGuard {
998 fn drop(&mut self) {
999 if let Some(conn) = self.conn.take() {
1000 if let Ok(mut pool) = self.session.read_pool.lock() {
1001 pool.push(conn);
1002 }
1003 }
1004 }
1005}
1006
1007pub struct WriteConnectionGuard {
1009 conn: Option<rusqlite::Connection>,
1010 session: Arc<Session>,
1011}
1012
1013impl WriteConnectionGuard {
1014 fn new(conn: rusqlite::Connection, session: Arc<Session>) -> Self {
1015 Self { conn: Some(conn), session }
1016 }
1017}
1018
1019impl Deref for WriteConnectionGuard {
1020 type Target = rusqlite::Connection;
1021 fn deref(&self) -> &Self::Target { self.conn.as_ref().expect("Write connection already taken") }
1022}
1023
1024impl DerefMut for WriteConnectionGuard {
1025 fn deref_mut(&mut self) -> &mut Self::Target { self.conn.as_mut().expect("Write connection already taken") }
1026}
1027
1028impl Drop for WriteConnectionGuard {
1029 fn drop(&mut self) {
1030 if let Some(conn) = self.conn.take() {
1031 if let Ok(mut slot) = self.session.write_conn.lock() {
1034 if slot.is_none() {
1035 *slot = Some(conn);
1036 }
1037 }
1038 }
1039 }
1040}
1041
1042pub fn account_dir(npub: &str) -> Result<PathBuf, String> {
1056 if !is_valid_npub(npub) {
1057 return Err(format!("Invalid npub format: {}", npub));
1058 }
1059 Ok(get_app_data_dir()?.join(npub))
1060}
1061
1062fn get_current_db_path() -> Result<PathBuf, String> {
1063 let npub = get_current_account()?;
1064 Ok(account_dir(&npub)?.join("vector.db"))
1065}
1066
1067fn create_connection(path: &PathBuf) -> Result<rusqlite::Connection, String> {
1076 const OPEN_RETRIES: u32 = 4;
1077 let mut last_err = String::new();
1078 for attempt in 0..OPEN_RETRIES {
1079 match open_connection(path) {
1080 Ok(conn) => return Ok(conn),
1081 Err(e) if e.contains("locked") || e.contains("busy") => {
1082 last_err = e;
1083 std::thread::sleep(std::time::Duration::from_millis(50 * u64::from(attempt + 1)));
1084 }
1085 Err(e) => return Err(e),
1086 }
1087 }
1088 Err(last_err)
1089}
1090
1091fn open_connection(path: &PathBuf) -> Result<rusqlite::Connection, String> {
1092 let conn = rusqlite::Connection::open(path)
1093 .map_err(|e| format!("Failed to open database: {}", e))?;
1094
1095 conn.execute_batch("PRAGMA busy_timeout=5000;")
1100 .map_err(|e| format!("Failed to set busy_timeout: {}", e))?;
1101
1102 conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL; PRAGMA foreign_keys=ON; PRAGMA cache_size=-16000; PRAGMA temp_store=MEMORY;")
1106 .map_err(|e| format!("Failed to set pragmas: {}", e))?;
1107
1108 Ok(conn)
1109}
1110
1111pub fn get_db_connection_guard_static() -> Result<ConnectionGuard, String> {
1113 current_session().acquire_read()
1114}
1115
1116#[cfg(test)]
1122pub(crate) static DB_TEST_GUARD: std::sync::Mutex<()> = std::sync::Mutex::new(());
1123
1124pub fn get_write_connection_guard_static() -> Result<WriteConnectionGuard, String> {
1126 current_session().acquire_write()
1127}
1128
1129const LAST_APP_VERSION_KEY: &str = "last_app_version";
1135
1136#[derive(Debug, Clone, serde::Serialize)]
1142pub struct DowngradeBlock {
1143 pub db_schema: u32,
1145 pub supported_schema: u32,
1147 pub last_app_version: Option<String>,
1149}
1150
1151impl std::fmt::Display for DowngradeBlock {
1152 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1153 write!(f, "This account was last opened by a newer version of Vector")?;
1154 if let Some(version) = &self.last_app_version {
1155 write!(f, " ({version})")?;
1156 }
1157 write!(
1158 f,
1159 ". Its database is at schema {} and this build only understands {}. \
1160 Opening it would corrupt your messages, so Vector has stopped. \
1161 Reinstall the newer version to continue.",
1162 self.db_schema, self.supported_schema
1163 )
1164 }
1165}
1166
1167fn downgrade_block(conn: &rusqlite::Connection) -> Option<DowngradeBlock> {
1169 let db_schema = schema::applied_migration_high_water(conn);
1170 if db_schema <= schema::HIGHEST_MIGRATION_ID {
1171 return None;
1172 }
1173 Some(DowngradeBlock {
1174 db_schema,
1175 supported_schema: schema::HIGHEST_MIGRATION_ID,
1176 last_app_version: conn
1177 .query_row(
1178 "SELECT value FROM settings WHERE key = ?1",
1179 rusqlite::params![LAST_APP_VERSION_KEY],
1180 |row| row.get::<_, String>(0),
1181 )
1182 .ok(),
1183 })
1184}
1185
1186pub fn inspect_downgrade(npub: &str) -> Result<Option<DowngradeBlock>, String> {
1192 let db_path = account_dir(npub)?.join("vector.db");
1193 if !db_path.exists() {
1195 return Ok(None);
1196 }
1197 let conn = create_connection(&db_path)?;
1198 Ok(downgrade_block(&conn))
1199}
1200
1201pub fn init_database(npub: &str) -> Result<(), String> {
1207 let profile_dir = account_dir(npub)?;
1208
1209 if !profile_dir.exists() {
1210 std::fs::create_dir_all(&profile_dir)
1211 .map_err(|e| format!("Failed to create profile directory: {}", e))?;
1212 }
1213
1214 let db_path = profile_dir.join("vector.db");
1215 let mut conn = create_connection(&db_path)?;
1216
1217 if let Some(block) = downgrade_block(&conn) {
1221 return Err(block.to_string());
1222 }
1223
1224 conn.execute_batch(schema::SQL_SCHEMA)
1225 .map_err(|e| format!("Failed to create schema: {}", e))?;
1226
1227 schema::run_migrations(&mut conn)?;
1229
1230 if let Some(version) = APP_VERSION.get() {
1234 let _ = conn.execute(
1235 "INSERT OR REPLACE INTO settings (key, value) VALUES (?1, ?2)",
1236 rusqlite::params![LAST_APP_VERSION_KEY, version],
1237 );
1238 }
1239
1240 let _ = conn.execute_batch("PRAGMA optimize=0x10002;");
1245
1246 {
1250 let mut stmt = conn.prepare("SELECT event_id FROM deleted_messages")
1251 .map_err(|e| format!("tombstone seed prepare: {}", e))?;
1252 let ids: Vec<String> = stmt.query_map([], |row| row.get::<_, String>(0))
1253 .map_err(|e| format!("tombstone seed query: {}", e))?
1254 .filter_map(|r| r.ok())
1255 .collect();
1256 drop(stmt);
1257 crate::state::seed_message_tombstones(ids);
1258 }
1259
1260 let mls_dir = profile_dir.join("mls");
1266 if mls_dir.exists() {
1267 match std::fs::remove_dir_all(&mls_dir) {
1268 Ok(()) => crate::log_info!("[db] purged orphaned MLS store for account"),
1269 Err(e) => crate::log_warn!("[db] could not purge orphaned MLS store: {}", e),
1270 }
1271 }
1272
1273 let session = {
1281 let next = {
1282 let current = CURRENT_SESSION.read().unwrap_or_else(|e| e.into_inner());
1283 match current.db_path.as_deref() {
1284 Some(p) if p != db_path => Session::bound(db_path.clone()),
1285 _ => current.rebound(db_path.clone()),
1286 }
1287 };
1288 install(next.clone());
1289 next
1290 };
1291
1292 if let Ok(mut pool) = session.read_pool.lock() {
1294 for _ in 0..4 {
1295 if let Ok(c) = create_connection(&db_path) {
1296 pool.push(c);
1297 }
1298 }
1299 }
1300
1301 let write_conn = create_connection(&db_path)?;
1303 *session.write_conn.lock().unwrap_or_else(|e| e.into_inner()) = Some(write_conn);
1304
1305 #[cfg(feature = "tor")]
1311 {
1312 let enabled = create_connection(&db_path)
1313 .ok()
1314 .and_then(|c| {
1315 c.query_row(
1316 "SELECT value FROM settings WHERE key = 'tor_enabled'",
1317 [],
1318 |row| row.get::<_, String>(0),
1319 )
1320 .ok()
1321 })
1322 .map(|v| v == "1" || v == "true")
1323 .unwrap_or(false);
1324 crate::tor::set_tor_enabled_pref(enabled);
1325 }
1326
1327 Ok(())
1328}
1329
1330pub fn close_database() {
1336 replace_session();
1337}
1338
1339pub fn optimize_database() {
1344 let session = current_session();
1345 let guard = session.write_conn.lock().unwrap_or_else(|e| e.into_inner());
1346 if let Some(conn) = guard.as_ref() {
1347 let _ = conn.execute_batch("PRAGMA optimize;");
1348 }
1349}
1350
1351pub fn get_accounts() -> Result<Vec<String>, String> {
1353 let app_data = get_app_data_dir()?;
1354 let mut accounts = Vec::new();
1355
1356 if let Ok(entries) = std::fs::read_dir(app_data) {
1357 for entry in entries.flatten() {
1358 if entry.file_type().map_or(false, |ft| ft.is_dir()) {
1359 let name = entry.file_name().to_string_lossy().to_string();
1360 if name.starts_with("npub1") {
1361 if entry.path().join("vector.db").exists() {
1363 accounts.push(name);
1364 }
1365 }
1366 }
1367 }
1368 }
1369
1370 Ok(accounts)
1371}
1372
1373pub fn get_profile_directory(npub: &str) -> Result<PathBuf, String> {
1375 if !npub.starts_with("npub1") {
1376 return Err(format!("Invalid npub format: {}", npub));
1377 }
1378 let dir = account_dir(npub)?;
1379 if !dir.exists() {
1380 std::fs::create_dir_all(&dir)
1381 .map_err(|e| format!("Failed to create profile directory: {}", e))?;
1382 }
1383 Ok(dir)
1384}
1385
1386pub fn get_database_path(npub: &str) -> Result<PathBuf, String> {
1388 Ok(get_profile_directory(npub)?.join("vector.db"))
1389}
1390
1391pub fn clear_id_caches() {
1400 id_cache::clear_id_caches();
1401 community::clear_banlist_cache();
1402 community::clear_channel_community_cache();
1403}
1404
1405#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1410#[repr(u8)]
1411pub enum SystemEventType {
1412 MemberLeft = 0,
1413 MemberJoined = 1,
1414 MemberRemoved = 2,
1415 WallpaperChanged = 3,
1416}
1417
1418impl SystemEventType {
1419 pub fn display_message(&self, display_name: &str) -> String {
1420 match self {
1421 SystemEventType::MemberLeft => format!("{} has left", display_name),
1422 SystemEventType::MemberJoined => format!("{} has joined", display_name),
1423 SystemEventType::MemberRemoved => format!("{} was removed", display_name),
1424 SystemEventType::WallpaperChanged => format!("{} changed the wallpaper", display_name),
1425 }
1426 }
1427
1428 pub fn as_u8(&self) -> u8 { *self as u8 }
1429}
1430
1431#[cfg(test)]
1432mod pool_generation_tests {
1433 use super::*;
1434
1435 fn fake_conn() -> rusqlite::Connection {
1439 rusqlite::Connection::open_in_memory().unwrap()
1440 }
1441
1442 #[test]
1443 fn close_database_installs_a_fresh_session() {
1444 let _guard = DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
1448 let before = current_session();
1449 close_database();
1450 let after = current_session();
1451 assert!(
1452 !Arc::ptr_eq(&before, &after),
1453 "close_database must install a NEW session — the old one is what in-flight guards return to"
1454 );
1455 }
1456
1457 #[test]
1458 fn a_guard_returns_its_connection_to_its_own_session() {
1459 let session = Session::empty();
1460 drop(ConnectionGuard::new(fake_conn(), session.clone()));
1461 assert_eq!(session.read_pool.lock().unwrap().len(), 1, "the connection goes home");
1462 }
1463
1464 #[test]
1465 fn a_guard_outstanding_across_a_swap_returns_to_the_session_it_came_from() {
1466 let old = Session::empty();
1472 let new_session = Session::empty();
1473
1474 let guard = ConnectionGuard::new(fake_conn(), old.clone());
1475 drop(guard);
1477
1478 assert_eq!(old.read_pool.lock().unwrap().len(), 1, "returned to the session it was taken from");
1479 assert_eq!(new_session.read_pool.lock().unwrap().len(), 0, "never reachable from the new account");
1480 }
1481
1482 #[test]
1483 fn a_pool_miss_after_a_swap_opens_the_sessions_own_database() {
1484 let dir = tempfile::tempdir().unwrap();
1489 let path_a = dir.path().join("a.db");
1490 let path_b = dir.path().join("b.db");
1491
1492 let session_a = Session::bound(path_a.clone());
1493 let _unrelated = Session::bound(path_b.clone());
1494
1495 let guard = session_a.acquire_read().expect("acquire against the held session");
1497 let opened = guard.path().expect("a file-backed connection").to_string();
1499 assert!(
1500 opened.ends_with("a.db") && !opened.ends_with("b.db"),
1501 "a miss opens the session's OWN database, never the incoming account's (opened {opened})"
1502 );
1503 assert!(!Arc::ptr_eq(&session_a, ¤t_session()), "the live session really did move on");
1504 }
1505
1506 async fn bound_to<F: std::future::Future>(session: Arc<Session>, fut: F) -> F::Output {
1514 TASK_SESSION.scope(session, fut).await
1515 }
1516
1517 #[test]
1522 fn per_account_tasks_are_spawned_bound_to_their_account() {
1523 crate::spawn_audit::assert_all_spawns_bound(std::path::Path::new(env!("CARGO_MANIFEST_DIR")), &[]);
1524 }
1525
1526 #[tokio::test]
1527 async fn a_bound_task_keeps_its_account_across_a_swap() {
1528 let dir = tempfile::tempdir().unwrap();
1533 let a = Session::bound(dir.path().join("a.db"));
1534
1535 let seen = bound_to(a.clone(), async {
1536 tokio::task::yield_now().await;
1537 current_session()
1538 })
1539 .await;
1540
1541 assert!(Arc::ptr_eq(&seen, &a), "the task still sees the account it started under");
1542 assert!(!Arc::ptr_eq(&seen, ¤t_session()), "the live account is not reachable from it");
1543 }
1544
1545 #[tokio::test]
1546 async fn a_bound_tasks_chat_writes_cannot_reach_the_new_account() {
1547 use crate::chat::{Chat, ChatType};
1552 let dir = tempfile::tempdir().unwrap();
1553 let a = Session::bound(dir.path().join("a.db"));
1554 let live_before = current_session().chat_state().lock().await.chats.len();
1555
1556 bound_to(a.clone(), async {
1557 tokio::task::yield_now().await;
1558 crate::state::STATE.lock().await.chats.push(Chat::new("a-chat".into(), ChatType::DirectMessage, Vec::new()));
1559 })
1560 .await;
1561
1562 assert_eq!(
1563 a.chat_state().lock().await.chats.len(),
1564 1,
1565 "it landed in the state of the account the task began under"
1566 );
1567 assert_eq!(
1568 current_session().chat_state().lock().await.chats.len(),
1569 live_before,
1570 "and nothing reached the account on screen"
1571 );
1572 }
1573
1574 #[tokio::test]
1575 async fn a_bound_task_cannot_publish_through_the_new_accounts_client() {
1576 use nostr_sdk::prelude::*;
1581 let dir = tempfile::tempdir().unwrap();
1582 let a = Session::bound(dir.path().join("a.db"));
1583 let a_identity = Keys::generate().public_key();
1584
1585 let seen = bound_to(a.clone(), async move {
1586 crate::state::set_my_public_key(a_identity);
1587 tokio::task::yield_now().await;
1588 crate::state::my_public_key()
1589 })
1590 .await;
1591
1592 assert_eq!(seen, Some(a_identity), "the task signs as the account it began under");
1593 assert_ne!(crate::state::my_public_key(), Some(a_identity), "and the live account is someone else");
1594 }
1595
1596 #[test]
1597 fn binding_an_unbound_session_to_a_database_keeps_what_it_holds() {
1598 let dir = tempfile::tempdir().unwrap();
1602 let staging = Session::empty();
1603 let held = staging.scoped::<Session, Mutex<u8>>();
1604 *held.lock().unwrap() = 7;
1605
1606 let promoted = staging.rebound(dir.path().join("a.db"));
1607 assert_eq!(*promoted.scoped::<Session, Mutex<u8>>().lock().unwrap(), 7, "the login survives being bound");
1608 assert!(promoted.db_path.is_some(), "and it now has a database");
1609 assert_eq!(promoted.id, staging.id, "and it is still the same account, so its tasks keep painting");
1610 }
1611
1612 #[tokio::test]
1613 async fn a_bound_task_paints_nothing_into_the_account_on_screen() {
1614 let dir = tempfile::tempdir().unwrap();
1618 let previous = Session::bound(dir.path().join("previous.db"));
1619 assert!(session_is_live(), "work for the account on screen paints");
1620
1621 let painted = bound_to(previous, async {
1622 tokio::task::yield_now().await;
1623 session_is_live()
1624 })
1625 .await;
1626
1627 assert!(!painted, "a task bound to another account paints nothing");
1628 assert!(session_is_live(), "and the account now on screen still does");
1629 }
1630
1631 #[tokio::test]
1632 async fn switching_accounts_tells_the_previous_one_to_stop() {
1633 let _serialized = DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
1637 let previous = current_session();
1638 assert!(!previous.stopped(), "running work is not told to stop");
1639
1640 let waiter = { let p = previous.clone(); tokio::spawn(async move { p.on_stop().await }) };
1642 tokio::task::yield_now().await;
1643 assert!(!waiter.is_finished(), "nothing to report while the account is current");
1644
1645 close_database();
1646
1647 assert!(previous.stopped(), "the outgoing account is told to stop");
1648 assert!(!current_session().stopped(), "the incoming one is not");
1649 tokio::time::timeout(std::time::Duration::from_secs(5), waiter)
1650 .await
1651 .expect("on_stop resolves on the switch")
1652 .expect("without panicking");
1653 }
1654
1655 #[test]
1656 fn re_initialising_the_same_account_does_not_tell_it_to_stop() {
1657 let dir = tempfile::tempdir().unwrap();
1661 let staging = Session::empty();
1662 let promoted = staging.rebound(dir.path().join("a.db"));
1663 assert!(!staging.stopped(), "binding a session is not switching away from it");
1664 assert_eq!(promoted.id, staging.id);
1665 }
1666
1667 #[test]
1677 fn binding_a_future_does_not_embed_it() {
1678 let fat = async {
1680 let block = [0u8; 8192];
1681 tokio::task::yield_now().await;
1682 block[0]
1683 };
1684 let fat_size = std::mem::size_of_val(&fat);
1685 assert!(fat_size >= 8192, "the body really is large ({fat_size})");
1686
1687 let bound = scoped(fat);
1688 let bound_size = std::mem::size_of_val(&bound);
1689 assert!(
1690 bound_size < 1024,
1691 "binding must cost a pointer, not a copy of the body \
1692 (body {fat_size} bytes, bound {bound_size})"
1693 );
1694 }
1695
1696 #[test]
1697 fn a_dropped_session_closes_its_pool() {
1698 let session = Session::empty();
1700 drop(ConnectionGuard::new(fake_conn(), session.clone()));
1701 assert_eq!(Arc::strong_count(&session), 1, "the guard released its reference");
1702 drop(session); }
1704
1705 #[test]
1706 fn a_stale_write_guard_cannot_clobber_the_new_accounts_connection() {
1707 let old = Session::empty();
1711 let new_session = Session::empty();
1712
1713 let stale_guard = WriteConnectionGuard::new(fake_conn(), old.clone());
1714 *new_session.write_conn.lock().unwrap() = Some(fake_conn());
1716
1717 drop(stale_guard);
1718
1719 assert!(
1720 new_session.write_conn.lock().unwrap().is_some(),
1721 "the new account's write connection is untouched"
1722 );
1723 assert!(
1724 old.write_conn.lock().unwrap().is_some(),
1725 "the stale guard returned to its own session's slot"
1726 );
1727 }
1728
1729 #[test]
1730 fn a_new_accounts_empty_write_slot_stays_empty() {
1731 let old = Session::empty();
1735 let new_session = Session::empty();
1736
1737 let stale_guard = WriteConnectionGuard::new(fake_conn(), old.clone());
1738 drop(stale_guard);
1739
1740 assert!(
1741 new_session.write_conn.lock().unwrap().is_none(),
1742 "the new account's slot is untouched by the previous account's guard"
1743 );
1744 }
1745}
1746
1747#[cfg(test)]
1748mod downgrade_tests {
1749 use super::*;
1750
1751 fn test_account() -> (tempfile::TempDir, std::sync::MutexGuard<'static, ()>, String) {
1752 let guard = DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
1753 close_database();
1754 clear_id_caches();
1755 let tmp = tempfile::tempdir().unwrap();
1756 static N: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(61_000);
1761 let n = N.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1762 const B: &[u8] = b"qpzry9x8gf2tvdw0s3jn54khce6mua7l";
1763 let mut acct = String::from("npub1");
1764 let mut v = n as usize;
1765 for _ in 0..58 {
1766 acct.push(B[v % 32] as char);
1767 v = v / 32 + 7;
1768 }
1769 set_app_data_dir(crate::db::shared_test_data_dir().to_path_buf());
1770 set_current_account(acct.clone()).unwrap();
1771 (tmp, guard, acct)
1772 }
1773
1774 #[tokio::test]
1775 async fn re_initialising_the_same_account_keeps_its_loaded_state() {
1776 use crate::chat::{Chat, ChatType};
1781 let (_dir, _lock, acct) = test_account();
1782 init_database(&acct).unwrap();
1783 crate::state::STATE.lock().await.chats.push(Chat::new("kept".into(), ChatType::DirectMessage, Vec::new()));
1784
1785 init_database(&acct).unwrap();
1786 assert_eq!(crate::state::STATE.lock().await.chats.len(), 1, "same account, same in-memory state");
1787 }
1788
1789 #[test]
1791 fn an_equal_schema_opens_normally() {
1792 let (_tmp, _guard, acct) = test_account();
1793 init_database(&acct).unwrap();
1794 assert!(inspect_downgrade(&acct).unwrap().is_none());
1795 init_database(&acct).unwrap();
1797 assert!(inspect_downgrade(&acct).unwrap().is_none());
1798 }
1799
1800 #[test]
1802 fn a_missing_database_is_not_a_downgrade() {
1803 let (_tmp, _guard, acct) = test_account();
1804 assert!(inspect_downgrade(&acct).unwrap().is_none());
1805 assert!(!account_dir(&acct).unwrap().join("vector.db").exists());
1806 }
1807
1808 #[test]
1809 fn a_newer_schema_blocks_the_open_and_names_the_build() {
1810 let (_tmp, _guard, acct) = test_account();
1811 init_database(&acct).unwrap();
1812
1813 let db_path = account_dir(&acct).unwrap().join("vector.db");
1815 {
1816 let conn = create_connection(&db_path).unwrap();
1817 conn.execute(
1818 "INSERT OR REPLACE INTO schema_migrations (id, applied_at) VALUES (?1, 0)",
1819 rusqlite::params![schema::HIGHEST_MIGRATION_ID + 1],
1820 )
1821 .unwrap();
1822 conn.execute(
1823 "INSERT OR REPLACE INTO settings (key, value) VALUES (?1, ?2)",
1824 rusqlite::params![LAST_APP_VERSION_KEY, "9.9.9"],
1825 )
1826 .unwrap();
1827 }
1828 close_database();
1829
1830 let block = inspect_downgrade(&acct)
1831 .unwrap()
1832 .expect("a higher migration id must read as a downgrade");
1833 assert_eq!(block.db_schema, schema::HIGHEST_MIGRATION_ID + 1);
1834 assert_eq!(block.supported_schema, schema::HIGHEST_MIGRATION_ID);
1835 assert_eq!(block.last_app_version.as_deref(), Some("9.9.9"));
1836
1837 let err = init_database(&acct).unwrap_err();
1838 assert!(err.contains("9.9.9"), "must name the newer build: {err}");
1839 }
1840
1841 #[test]
1844 fn a_blocked_open_writes_nothing() {
1845 let (_tmp, _guard, acct) = test_account();
1846 init_database(&acct).unwrap();
1847 let db_path = account_dir(&acct).unwrap().join("vector.db");
1848 {
1849 let conn = create_connection(&db_path).unwrap();
1850 conn.execute(
1851 "INSERT OR REPLACE INTO schema_migrations (id, applied_at) VALUES (?1, 0)",
1852 rusqlite::params![schema::HIGHEST_MIGRATION_ID + 5],
1853 )
1854 .unwrap();
1855 conn.execute("DROP TABLE IF EXISTS settings", []).unwrap();
1856 }
1857 close_database();
1858
1859 assert!(init_database(&acct).is_err());
1860
1861 let conn = create_connection(&db_path).unwrap();
1863 let exists: bool = conn
1864 .query_row(
1865 "SELECT 1 FROM sqlite_master WHERE type='table' AND name='settings'",
1866 [],
1867 |_| Ok(true),
1868 )
1869 .unwrap_or(false);
1870 assert!(!exists, "a blocked open must not write to the database");
1871 }
1872}