Files
mtgc/src-tauri/src/state.rs
Garrett Mills 81b1df8c9f Named libraries: switch between several, each syncing on its own
The app can keep any number of named libraries and switch between them
from the top bar or Info → Libraries. Each can sync to the account's other
devices or stay on this one (a scratch library to try things in).

The sync server now keeps several libraries per user under
/api/libraries; a migration makes each existing library the user's default
"Main", which /api/library keeps serving for older app builds. Every sync
lines the device's libraries up with the account's: new ones elsewhere are
downloaded, new ones here created on the server, ones deleted elsewhere
flagged rather than thrown away. The existing library keeps its files and
merge base, so upgrading asks nothing.
2026-10-16 21:44:55 -05:00

658 lines
24 KiB
Rust

//! App state: the open [`Catalog`] and fuzzy [`NameIndex`], plus the paths,
//! the device's libraries, and the sync account.
//!
//! Each library lives in an internal SQLite working copy under the app data
//! dir, which works fully offline. One of them is open at a time — the active
//! library — and every command works on that one. When the device is signed in to a sync
//! server, that copy is reconciled with the server's whenever it can be reached
//! (see `sync.rs` and [`mtgc_core::sync::remote`]).
use std::path::{Path, PathBuf};
use std::sync::atomic::AtomicBool;
use std::sync::Arc;
use mtgc_core::sync::account::AccountUser;
use mtgc_core::{Catalog, NameIndex};
use serde::{Deserialize, Serialize};
/// Small app-shell preferences persisted across restarts, distinct from the
/// user's library data.
///
/// Fields from older builds (`document_path`, `document_name`, from when the
/// library was a file in a synced folder) are simply ignored when read.
#[derive(Default, Serialize, Deserialize, Clone)]
struct PersistedConfig {
/// A pre-document `library.db` inherited from an older build, kept until
/// it has been converted so the app can offer to do that once.
#[serde(default)]
legacy_library_path: Option<PathBuf>,
/// The sync server this device is (or was last) signed in to.
#[serde(default)]
sync: Option<SyncAccount>,
/// The libraries on this device, the default one first. Never empty once
/// loaded: a config from before named libraries gets the default.
#[serde(default)]
libraries: Vec<LocalLibrary>,
/// The id of the library that's open.
#[serde(default)]
active_library: String,
/// Server libraries removed from this device, which syncing therefore
/// doesn't download again.
#[serde(default)]
dismissed_remote: Vec<String>,
}
/// The id of the library every device starts with. Its files stay where the
/// one library always lived, so upgrading moves nothing.
pub const DEFAULT_LIBRARY: &str = "default";
/// One of the libraries on this device.
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
pub struct LocalLibrary {
/// Local only: the folder it lives in. Never shown.
pub id: String,
pub name: String,
/// The server's id for it, once linked to a library there.
#[serde(default)]
pub remote_id: Option<String>,
/// Whether it should be on the server. Off for a library kept on this
/// device only — a scratch library to try things in, say.
#[serde(default = "yes")]
pub sync: bool,
/// It was linked, and another device deleted it from the server. The copy
/// here stays until the user removes it or puts it back on the server.
#[serde(default)]
pub server_deleted: bool,
}
fn yes() -> bool {
true
}
impl LocalLibrary {
pub fn is_default(&self) -> bool {
self.id == DEFAULT_LIBRARY
}
}
fn default_library() -> LocalLibrary {
LocalLibrary {
id: DEFAULT_LIBRARY.into(),
name: "Main".into(),
remote_id: None,
sync: true,
server_deleted: false,
}
}
/// A fresh id for a library's folder.
fn new_library_id() -> String {
mtgc_core::sync::account::random_token()
.chars()
.filter(char::is_ascii_alphanumeric)
.take(16)
.collect()
}
/// This device's sync server account.
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
pub struct SyncAccount {
pub server_url: String,
/// This device's token. `None` once signed out, or once the server stopped
/// accepting it; the rest is kept so signing back in can be offered with
/// the address already filled in.
#[serde(default)]
pub token: Option<String>,
pub user: AccountUser,
#[serde(default)]
pub device_id: i64,
/// Unix seconds of the last sync that completed.
#[serde(default)]
pub last_synced_at: Option<i64>,
}
fn config_path(data_dir: &Path) -> PathBuf {
data_dir.join("config.json")
}
fn load_config(data_dir: &Path) -> PersistedConfig {
let mut cfg = read_config(data_dir);
// Before named libraries there was one, which is the default now.
if !cfg.libraries.iter().any(LocalLibrary::is_default) {
cfg.libraries.insert(0, default_library());
}
if !cfg.libraries.iter().any(|l| l.id == cfg.active_library) {
cfg.active_library = DEFAULT_LIBRARY.into();
}
cfg
}
fn read_config(data_dir: &Path) -> PersistedConfig {
let Ok(text) = std::fs::read_to_string(config_path(data_dir)) else {
return PersistedConfig::default();
};
// Every field defaults, so an older or partial file parses fine and simply
// leaves the unknown settings unset.
let mut cfg: PersistedConfig = serde_json::from_str(&text).unwrap_or_default();
// Builds before the document format stored `library_path` pointing at a
// SQLite file the user managed themselves. Pick that up so the UI can offer
// to convert it once — but never open it as the working copy.
if cfg.legacy_library_path.is_none() {
#[derive(Deserialize)]
struct OldConfig {
library_path: Option<PathBuf>,
}
cfg.legacy_library_path = serde_json::from_str::<OldConfig>(&text)
.ok()
.and_then(|c| c.library_path)
.filter(|p| p.extension().is_some_and(|e| e != "json"));
}
cfg
}
fn save_config(data_dir: &Path, cfg: &PersistedConfig) {
let Ok(text) = serde_json::to_string_pretty(cfg) else {
return;
};
// Written beside and renamed into place, so a crash mid-write can't leave
// a truncated config that silently signs the device out.
let path = config_path(data_dir);
let tmp = path.with_extension("json.tmp");
if let Err(e) = write_private(&tmp, &text).and_then(|_| std::fs::rename(&tmp, &path)) {
crate::mlog!("save_config: {e}");
let _ = std::fs::remove_file(&tmp);
}
}
/// Write a file only this user can read: the config holds the device's sync
/// token. (App data is already private on Android; this covers desktop.)
fn write_private(path: &Path, text: &str) -> std::io::Result<()> {
use std::io::Write;
let mut opts = std::fs::OpenOptions::new();
opts.write(true).create(true).truncate(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
opts.mode(0o600);
}
let mut f = opts.open(path)?;
f.write_all(text.as_bytes())?;
f.sync_all()
}
/// The folder a library's files live in.
fn library_dir(data_dir: &Path, id: &str) -> PathBuf {
if id == DEFAULT_LIBRARY {
data_dir.to_path_buf()
} else {
data_dir.join("libraries").join(id)
}
}
/// Managed application state, guarded by a `Mutex` in Tauri's state store.
pub struct AppState {
data_dir: PathBuf,
/// Held whole so that writing one preference can't drop the others — the
/// setters below persist this rather than reconstructing a fresh config.
config: PersistedConfig,
/// Internal SQLite working copy of the active library: the library itself,
/// on this device. Never chosen by, shown to, or moved by the user.
pub working_db_path: PathBuf,
pub reference_path: PathBuf,
pub catalog: Option<Catalog>,
pub index: Option<NameIndex>,
/// Set while `update_card_data` is running; `cancel_update_card_data`
/// flips it to abort the in-progress ingest.
pub ingest_cancel: Option<mtgc_core::ingest::CancelToken>,
/// Set while a sync is in flight. Sync fires automatically on launch, on
/// every foreground/background transition, and on reconnecting, so
/// overlapping calls are easy to provoke; a second one would only repeat
/// the first one's work.
pub sync_running: bool,
/// Why the last sync failed, until one succeeds. Not persisted: after a
/// restart the next sync says soon enough.
pub sync_error: Option<String>,
/// The first sync with this account found a library both here and on the
/// server, and only the user can say whether to combine them or take the
/// server's. Syncing waits until they do.
pub sync_needs_choice: bool,
/// Set while a sign-in waits on the browser; flipping it abandons the wait.
pub sign_in_cancel: Option<Arc<AtomicBool>>,
}
impl AppState {
pub fn new(data_dir: PathBuf) -> Self {
let config = load_config(&data_dir);
let mut s = Self {
reference_path: data_dir.join("reference.db"),
working_db_path: library_dir(&data_dir, &config.active_library).join("working.db"),
config,
data_dir,
catalog: None,
index: None,
ingest_cancel: None,
sync_running: false,
sync_error: None,
sync_needs_choice: false,
sign_in_cancel: None,
};
// A library whose folder vanished would otherwise be recreated empty
// by opening it, which is fine — but its folder must exist first.
let _ = std::fs::create_dir_all(s.library_dir(&s.config.active_library.clone()));
crate::mlog!(
"AppState::new: sync={:?} library={} working={} reference={} (exists={})",
s.config.sync.as_ref().map(|a| (&a.server_url, &a.user.username, a.token.is_some())),
s.config.active_library,
s.working_db_path.display(),
s.reference_path.display(),
s.reference_path.exists()
);
// Best-effort open; failures just leave the catalog unloaded (the UI
// falls back to "no card data yet" and offers Download/Import again).
// That fallback previously made a real open failure indistinguishable
// from a first run — log it so it shows up in `adb logcat -s
// RustStdoutStderr` instead of silently reappearing every launch.
if let Err(e) = s.reopen() {
crate::mlog!("AppState::new: reopen failed: {e}");
}
s
}
/// The sync account, signed in or not.
pub fn sync_account(&self) -> Option<&SyncAccount> {
self.config.sync.as_ref()
}
pub fn set_sync_account(&mut self, account: Option<SyncAccount>) {
self.config.sync = account;
save_config(&self.data_dir, &self.config);
}
/// Change the sync account in place and persist it. A no-op without one.
pub fn update_sync_account(&mut self, f: impl FnOnce(&mut SyncAccount)) {
if let Some(a) = self.config.sync.as_mut() {
f(a);
save_config(&self.data_dir, &self.config);
}
}
/// Forget every link to a server library. For signing in to a different
/// account or server, where the old ids mean nothing.
pub fn forget_remote_links(&mut self) {
for lib in &mut self.config.libraries {
lib.remote_id = None;
lib.server_deleted = false;
}
self.config.dismissed_remote.clear();
save_config(&self.data_dir, &self.config);
}
// --- libraries ------------------------------------------------------------
pub fn libraries(&self) -> &[LocalLibrary] {
&self.config.libraries
}
pub fn library(&self, id: &str) -> Option<&LocalLibrary> {
self.config.libraries.iter().find(|l| l.id == id)
}
pub fn active_library(&self) -> &LocalLibrary {
self.library(&self.config.active_library).expect("load_config keeps the active library listed")
}
/// The folder a library's files live in.
pub fn library_dir(&self, id: &str) -> PathBuf {
library_dir(&self.data_dir, id)
}
pub fn working_path(&self, id: &str) -> PathBuf {
self.library_dir(id).join("working.db")
}
/// Where a library's sync merge base is kept. The default library keeps
/// the name it had before there were others.
pub fn base_path(&self, id: &str) -> PathBuf {
if id == DEFAULT_LIBRARY {
self.data_dir.join("sync-server-base.json")
} else {
self.library_dir(id).join("sync-base.json")
}
}
/// Server libraries removed from this device.
pub fn dismissed_remote(&self) -> &[String] {
&self.config.dismissed_remote
}
/// Start a new, empty library. It isn't opened.
pub fn create_library(&mut self, name: &str, sync: bool, remote_id: Option<String>) -> Result<LocalLibrary, String> {
let name = self.check_name(name, None)?;
let lib = LocalLibrary { id: new_library_id(), name, remote_id, sync, server_deleted: false };
let dir = self.library_dir(&lib.id);
std::fs::create_dir_all(&dir).map_err(|e| format!("Couldn't create the library: {e}"))?;
// Created now, so the library exists on disk even before anything
// is added to it.
mtgc_core::db::open_library(&self.working_path(&lib.id)).map_err(|e| e.to_string())?;
if let Some(r) = &lib.remote_id {
self.config.dismissed_remote.retain(|d| d != r);
}
self.config.libraries.push(lib.clone());
save_config(&self.data_dir, &self.config);
Ok(lib)
}
/// A trimmed name, if no other library here already has it.
pub fn check_name(&self, name: &str, except: Option<&str>) -> Result<String, String> {
let name = name.trim();
if name.is_empty() {
return Err("A library needs a name.".into());
}
let taken = self
.config
.libraries
.iter()
.any(|l| Some(l.id.as_str()) != except && l.name.eq_ignore_ascii_case(name));
if taken {
return Err(format!("There's already a library called {name}."));
}
Ok(name.to_string())
}
/// Change a library in place and persist it. A no-op for an unknown id.
pub fn update_library(&mut self, id: &str, f: impl FnOnce(&mut LocalLibrary)) {
if let Some(lib) = self.config.libraries.iter_mut().find(|l| l.id == id) {
f(lib);
save_config(&self.data_dir, &self.config);
}
}
/// Stop downloading a server library to this device.
pub fn dismiss_remote(&mut self, remote_id: &str) {
if !self.config.dismissed_remote.iter().any(|d| d == remote_id) {
self.config.dismissed_remote.push(remote_id.to_string());
save_config(&self.data_dir, &self.config);
}
}
pub fn undismiss_remote(&mut self, remote_id: &str) {
self.config.dismissed_remote.retain(|d| d != remote_id);
save_config(&self.data_dir, &self.config);
}
/// Open another library.
pub fn switch_library(&mut self, id: &str) -> Result<(), String> {
if self.library(id).is_none() {
return Err("There's no such library on this device.".into());
}
if self.config.active_library == id {
return Ok(());
}
// A sync works on paths it took when it started.
if self.sync_running {
return Err("Wait for the sync to finish, then try again.".into());
}
let _ = std::fs::create_dir_all(self.library_dir(id));
self.config.active_library = id.to_string();
save_config(&self.data_dir, &self.config);
self.working_db_path = self.working_path(id);
self.reopen_catalog()
}
/// Delete a library and its files from this device. The active one is
/// left by switching to the default first; the default itself stays.
pub fn delete_library(&mut self, id: &str) -> Result<LocalLibrary, String> {
let lib = self.library(id).cloned().ok_or("There's no such library on this device.")?;
if lib.is_default() {
return Err("The main library can't be deleted.".into());
}
if self.sync_running {
return Err("Wait for the sync to finish, then try again.".into());
}
if self.config.active_library == id {
self.switch_library(DEFAULT_LIBRARY)?;
}
self.config.libraries.retain(|l| l.id != id);
save_config(&self.data_dir, &self.config);
let dir = self.library_dir(id);
if let Err(e) = std::fs::remove_dir_all(&dir) {
crate::mlog!("delete_library: couldn't remove {}: {e}", dir.display());
}
Ok(lib)
}
/// A pre-document `library.db` still waiting to be converted.
pub fn legacy_library_path(&self) -> Option<&Path> {
self.config.legacy_library_path.as_deref()
}
/// Forget the legacy library, once it's been converted or dismissed.
pub fn clear_legacy_library(&mut self) {
self.config.legacy_library_path = None;
save_config(&self.data_dir, &self.config);
}
/// Drop the open catalog (and its name index), releasing the handles on both
/// db files. Call before replacing `reference.db` on disk so nothing is
/// still reading the file being swapped out.
pub fn close(&mut self) {
self.catalog = None;
self.index = None;
}
/// (Re)open the catalog + rebuild the name index. Only opens when a reference
/// db exists — attaching a non-existent file would create an empty (tableless)
/// db and make every query fail.
pub fn reopen(&mut self) -> Result<(), String> {
if self.reference_path.exists() {
let catalog = Catalog::open(&self.working_db_path, &self.reference_path)
.map_err(|e| e.to_string())?;
let index = catalog.build_name_index().map_err(|e| e.to_string())?;
self.index = Some(index);
self.catalog = Some(catalog);
} else {
self.catalog = None;
self.index = None;
}
Ok(())
}
/// Reopen just the catalog, on the active library's working copy. The name
/// index only reads card data, so it survives a switch of library.
fn reopen_catalog(&mut self) -> Result<(), String> {
if self.index.is_none() {
return self.reopen();
}
self.catalog = None;
if self.reference_path.exists() {
self.catalog = Some(
Catalog::open(&self.working_db_path, &self.reference_path).map_err(|e| e.to_string())?,
);
}
Ok(())
}
pub fn catalog(&self) -> Result<&Catalog, String> {
self.catalog
.as_ref()
.ok_or_else(|| "Card data not loaded yet — click 'Download card data'.".to_string())
}
pub fn index(&self) -> Result<&NameIndex, String> {
self.index
.as_ref()
.ok_or_else(|| "Card data not loaded yet — click 'Download card data'.".to_string())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn scratch(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("mtgc-cfg-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
}
fn account() -> SyncAccount {
SyncAccount {
server_url: "https://mtgc.example".into(),
token: Some("mtgc_secret".into()),
user: AccountUser {
id: "u1".into(),
username: "alice".into(),
display_name: "Alice".into(),
email: "alice@example.com".into(),
method: "local".into(),
},
device_id: 7,
last_synced_at: Some(1_700_000_000),
}
}
#[test]
fn every_setting_round_trips() {
let dir = scratch("round-trip");
let scratch_lib = LocalLibrary {
id: "abc".into(),
name: "Scratch".into(),
remote_id: Some("r1".into()),
sync: false,
server_deleted: true,
};
let cfg = PersistedConfig {
legacy_library_path: Some(PathBuf::from("/tmp/old.db")),
sync: Some(account()),
libraries: vec![default_library(), scratch_lib],
active_library: "abc".into(),
dismissed_remote: vec!["r2".into()],
};
save_config(&dir, &cfg);
let back = load_config(&dir);
assert_eq!(back.legacy_library_path, cfg.legacy_library_path);
assert_eq!(back.sync, cfg.sync);
assert_eq!(back.libraries, cfg.libraries);
assert_eq!(back.active_library, "abc");
assert_eq!(back.dismissed_remote, cfg.dismissed_remote);
assert!(!dir.join("config.json.tmp").exists());
std::fs::remove_dir_all(&dir).unwrap();
}
#[cfg(unix)]
#[test]
fn the_config_holding_the_token_is_private() {
use std::os::unix::fs::PermissionsExt;
let dir = scratch("private");
save_config(&dir, &PersistedConfig { sync: Some(account()), ..Default::default() });
let mode = std::fs::metadata(config_path(&dir)).unwrap().permissions().mode();
assert_eq!(mode & 0o777, 0o600);
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn a_folder_sync_config_still_loads() {
// What the previous, folder-based build wrote. Its settings no longer
// mean anything, but the file must not fail to load over them.
let dir = scratch("folder-era");
std::fs::write(
config_path(&dir),
r#"{"document_path":"/home/u/Sync/library.json","legacy_library_path":"/tmp/old.db","document_name":"decks.json"}"#,
)
.unwrap();
let cfg = load_config(&dir);
assert_eq!(cfg.legacy_library_path, Some(PathBuf::from("/tmp/old.db")));
assert!(cfg.sync.is_none());
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn a_pre_document_config_offers_its_library_for_conversion() {
let dir = scratch("legacy");
std::fs::write(
config_path(&dir),
r#"{"library_path":"/home/u/Seafile/Decks-v1.db"}"#,
)
.unwrap();
let cfg = load_config(&dir);
assert_eq!(
cfg.legacy_library_path,
Some(PathBuf::from("/home/u/Seafile/Decks-v1.db"))
);
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn a_library_path_that_is_already_json_is_not_treated_as_legacy() {
let dir = scratch("json-path");
std::fs::write(config_path(&dir), r#"{"library_path":"/home/u/lib.json"}"#).unwrap();
assert!(load_config(&dir).legacy_library_path.is_none());
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn an_unreadable_or_corrupt_config_falls_back_to_defaults() {
let dir = scratch("corrupt");
assert!(load_config(&dir).sync.is_none(), "missing file");
std::fs::write(config_path(&dir), "{not json").unwrap();
assert!(load_config(&dir).sync.is_none(), "truncated file");
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn a_config_from_before_named_libraries_has_just_the_default() {
let dir = scratch("pre-libraries");
save_config(&dir, &PersistedConfig { sync: Some(account()), ..Default::default() });
let cfg = load_config(&dir);
assert_eq!(cfg.libraries, vec![default_library()]);
assert_eq!(cfg.active_library, DEFAULT_LIBRARY);
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn the_default_library_keeps_the_files_it_always_had() {
let dir = scratch("paths");
let s = AppState::new(dir.clone());
assert_eq!(s.working_db_path, dir.join("working.db"));
assert_eq!(s.base_path(DEFAULT_LIBRARY), dir.join("sync-server-base.json"));
assert_eq!(s.working_path("abc"), dir.join("libraries/abc/working.db"));
assert_eq!(s.base_path("abc"), dir.join("libraries/abc/sync-base.json"));
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn libraries_can_be_created_switched_to_and_deleted() {
let dir = scratch("libraries");
let mut s = AppState::new(dir.clone());
let lib = s.create_library(" Scratch ", false, None).unwrap();
assert_eq!(lib.name, "Scratch");
assert!(s.working_path(&lib.id).exists());
assert!(s.create_library("scratch", true, None).is_err(), "names are unique");
assert!(s.create_library(" ", true, None).is_err());
s.switch_library(&lib.id).unwrap();
assert_eq!(s.working_db_path, s.working_path(&lib.id));
// Remembered across restarts.
let mut s = AppState::new(dir.clone());
assert_eq!(s.active_library().id, lib.id);
s.sync_running = true;
assert!(s.switch_library(DEFAULT_LIBRARY).is_err(), "not mid-sync");
assert!(s.delete_library(&lib.id).is_err(), "not mid-sync");
s.sync_running = false;
assert!(s.delete_library(DEFAULT_LIBRARY).is_err());
s.delete_library(&lib.id).unwrap();
assert_eq!(s.active_library().id, DEFAULT_LIBRARY, "deleting the open one switches away");
assert!(!s.library_dir(&lib.id).exists());
assert_eq!(s.libraries().len(), 1);
std::fs::remove_dir_all(&dir).unwrap();
}
}