//! Where the app keeps its state, and how it writes there safely. //! //! Two rules carried over from `proton-drive-linux`'s config handling, both //! learned the hard way: //! //! 1. **Write atomically.** Temp file → `sync_all` → rename → fsync the parent //! directory. A crash mid-write then leaves either the old file or the new //! one, never a truncated one. //! 2. **Never overwrite a config that would not parse.** An unreadable file is //! far more likely to be a bug of ours, or a half-finished hand edit, than //! something to be silently replaced — and replacing it destroys the only //! record of the user's shares. use std::fs; use std::io::Write; use std::path::{Path, PathBuf}; use crate::error::{Error, Result}; /// The app's directories, resolved once. #[derive(Debug, Clone)] pub struct AppDirs { /// Config: the share list. Small, hand-editable, worth backing up. pub config: PathBuf, /// State: the catalog database and metadata cache. Rebuildable. pub data: PathBuf, /// Cache: downloaded content blocks and poster images. Disposable — the app /// must work correctly after this is deleted while it is not running. pub cache: PathBuf, } impl AppDirs { /// Use directories supplied by a platform host. /// /// Android owns its application directories and exposes them through /// `Context`; resolving them through desktop conventions would put state in /// a path that does not exist inside the application sandbox. pub fn from_paths( config: impl Into, data: impl Into, cache: impl Into, ) -> Result { let dirs = Self { config: config.into(), data: data.into(), cache: cache.into(), }; dirs.create()?; Ok(dirs) } /// Resolve the platform directories and create them. pub fn ensure() -> Result { let dirs = directories::ProjectDirs::from("io", "narl", "proton-stream").ok_or_else(|| { Error::Config("no home directory to resolve app directories from".into()) })?; let dirs = Self { config: dirs.config_dir().to_path_buf(), data: dirs.data_dir().to_path_buf(), cache: dirs.cache_dir().to_path_buf(), }; dirs.create()?; Ok(dirs) } fn create(&self) -> Result<()> { for path in [&self.config, &self.data, &self.cache] { fs::create_dir_all(path)?; } Ok(()) } /// Settings only the desktop app has: hardware decoding, the cache budget. pub fn desktop_prefs_file(&self) -> PathBuf { self.config.join("desktop.json") } /// The desktop window's size, for the next launch to open at. pub fn window_file(&self) -> PathBuf { self.config.join("window.json") } /// The share list. pub fn shares_file(&self) -> PathBuf { self.config.join("shares.json") } /// This installation's identity for watch-history sync. See /// [`crate::sync`]. pub fn sync_file(&self) -> PathBuf { self.config.join("sync.json") } /// The catalog database. pub fn catalog_db(&self) -> PathBuf { self.data.join("catalog.db") } /// The content-block cache root. pub fn block_cache(&self) -> PathBuf { self.cache.join("blocks") } /// Complete files explicitly kept for disconnected playback. pub fn offline_content(&self) -> PathBuf { self.data.join("offline") } /// Opaque path for one complete offline revision. No media name or share /// secret reaches the filesystem. pub fn offline_file(&self, share_id: &str, link_id: &str, revision: &str) -> PathBuf { use sha2::{Digest, Sha256}; let mut hash = Sha256::new(); hash.update(share_id.as_bytes()); hash.update([0]); hash.update(link_id.as_bytes()); hash.update([0]); hash.update(revision.as_bytes()); let digest = hash.finalize(); let name: String = digest.iter().map(|byte| format!("{byte:02x}")).collect(); self.offline_content().join(format!("{name}.media")) } /// Where decrypted poster thumbnails are kept. /// /// Separate from the block cache because it is cheap, tiny and wanted on /// every render, where a block is expensive, large and wanted once. Clearing /// either must leave the app correct. pub fn thumbnail_cache(&self) -> PathBuf { self.cache.join("thumbs") } /// Where artwork downloaded from a metadata provider is kept. /// /// Separate from the Proton thumbnails next to it because the two are /// invalidated by different things — a recrawl for one, a provider switch /// for the other — and because deleting all of one must not touch the other. pub fn poster_cache(&self) -> PathBuf { self.cache.join("posters") } } /// Read and deserialize a JSON config, or `None` when it does not exist yet. /// /// A file that exists but will not parse is an error, never a `None` — see the /// module note on why that distinction is load-bearing. pub fn read_json(path: &Path) -> Result> { let text = match fs::read_to_string(path) { Ok(text) => text, Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None), Err(e) => return Err(e.into()), }; serde_json::from_str(&text) .map(Some) .map_err(|e| Error::Config(format!("{} is not valid config: {e}", path.display()))) } /// Serialize and write a JSON config atomically. pub fn write_json(path: &Path, value: &T) -> Result<()> { let text = serde_json::to_string_pretty(value) .map_err(|e| Error::Config(format!("serialize config: {e}")))?; let parent = path .parent() .ok_or_else(|| Error::Config(format!("{} has no parent directory", path.display())))?; fs::create_dir_all(parent)?; // Named for the target so two concurrent writers of *different* configs // cannot collide on one temp path. let temp = parent.join(format!( ".{}.tmp", path.file_name() .map(|n| n.to_string_lossy().into_owned()) .unwrap_or_else(|| "config".into()) )); { let mut file = fs::File::create(&temp)?; file.write_all(text.as_bytes())?; // Before the rename, so the rename cannot publish an empty file. file.sync_all()?; } fs::rename(&temp, path)?; // The rename itself needs durability, or a crash can lose the whole file // even though its contents were synced. if let Ok(dir) = fs::File::open(parent) { let _ = dir.sync_all(); } Ok(()) } #[cfg(test)] mod tests { use super::*; #[derive(Debug, PartialEq, serde::Serialize, serde::Deserialize)] struct Sample { name: String, count: u32, } fn temp_dir(tag: &str) -> PathBuf { let dir = std::env::temp_dir().join(format!( "pstr-config-{tag}-{}-{:?}", std::process::id(), std::thread::current().id() )); let _ = fs::remove_dir_all(&dir); fs::create_dir_all(&dir).expect("create temp dir"); dir } #[test] fn a_config_round_trips_through_an_atomic_write() { let dir = temp_dir("roundtrip"); let path = dir.join("sample.json"); let value = Sample { name: "anime".into(), count: 42, }; write_json(&path, &value).expect("write"); let read: Sample = read_json(&path).expect("read").expect("present"); assert_eq!(read, value); fs::remove_dir_all(&dir).ok(); } /// A missing config is the first-run case, not a failure. #[test] fn a_missing_config_reads_as_none() { let dir = temp_dir("missing"); let read: Option = read_json(&dir.join("absent.json")).expect("read"); assert!(read.is_none()); fs::remove_dir_all(&dir).ok(); } /// The distinction that protects the share list: unparseable is an error, /// so a caller doing read-modify-write cannot mistake it for "empty" and /// overwrite the user's shares with nothing. #[test] fn an_unparseable_config_is_an_error_and_not_an_empty_one() { let dir = temp_dir("corrupt"); let path = dir.join("broken.json"); fs::write(&path, b"{ this is not json").expect("write garbage"); let read: Result> = read_json(&path); assert!(read.is_err(), "must refuse rather than report absence"); // And the bad file is still there to be inspected or repaired. assert!(path.exists()); fs::remove_dir_all(&dir).ok(); } /// The temp file must not survive a successful write. #[test] fn an_atomic_write_leaves_no_temp_file_behind() { let dir = temp_dir("notemp"); let path = dir.join("sample.json"); write_json( &path, &Sample { name: "x".into(), count: 1, }, ) .expect("write"); let leftovers: Vec<_> = fs::read_dir(&dir) .expect("read dir") .filter_map(|entry| entry.ok()) .map(|entry| entry.file_name().to_string_lossy().into_owned()) .filter(|name| name.ends_with(".tmp")) .collect(); assert!(leftovers.is_empty(), "temp files left: {leftovers:?}"); fs::remove_dir_all(&dir).ok(); } }