diff --git a/crates/Cargo.lock b/crates/Cargo.lock index 0622ba03b..9c1b550c2 100644 --- a/crates/Cargo.lock +++ b/crates/Cargo.lock @@ -2907,7 +2907,9 @@ checksum = "b5a041e753da8b807c9255f28de81879c78c876392ff2469cde94799b2896b9d" name = "qbz-app" version = "0.1.0" dependencies = [ + "dirs", "log", + "rusqlite", "serde", "tokio", ] diff --git a/crates/qbz-app/Cargo.toml b/crates/qbz-app/Cargo.toml index bc5ff30e6..0938753f0 100644 --- a/crates/qbz-app/Cargo.toml +++ b/crates/qbz-app/Cargo.toml @@ -9,3 +9,5 @@ repository.workspace = true serde = { workspace = true } tokio = { workspace = true } log = { workspace = true } +rusqlite = { version = "0.31", features = ["bundled"] } +dirs = "6" diff --git a/crates/qbz-app/src/lib.rs b/crates/qbz-app/src/lib.rs index ea62c4145..53a848fed 100644 --- a/crates/qbz-app/src/lib.rs +++ b/crates/qbz-app/src/lib.rs @@ -1 +1,2 @@ pub mod runtime; +pub mod settings; diff --git a/crates/qbz-app/src/settings/legal.rs b/crates/qbz-app/src/settings/legal.rs new file mode 100644 index 000000000..7b217de8f --- /dev/null +++ b/crates/qbz-app/src/settings/legal.rs @@ -0,0 +1,147 @@ +//! Legal settings storage. +//! +//! Persists user acceptance of legal terms. + +use log::info; +use rusqlite::{params, Connection}; +use serde::{Deserialize, Serialize}; +use std::path::Path; +use std::sync::{Arc, Mutex}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LegalSettings { + /// User has accepted Qobuz Terms of Service. + pub qobuz_tos_accepted: bool, +} + +impl Default for LegalSettings { + fn default() -> Self { + Self { + qobuz_tos_accepted: false, + } + } +} + +pub struct LegalSettingsStore { + conn: Connection, +} + +impl LegalSettingsStore { + fn open_at(dir: &Path, db_name: &str) -> Result { + std::fs::create_dir_all(dir) + .map_err(|e| format!("Failed to create data directory: {}", e))?; + + let db_path = dir.join(db_name); + let conn = Connection::open(&db_path) + .map_err(|e| format!("Failed to open legal settings database: {}", e))?; + + conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;") + .map_err(|e| format!("Failed to enable WAL for legal settings database: {}", e))?; + + conn.execute_batch( + "CREATE TABLE IF NOT EXISTS legal_settings ( + id INTEGER PRIMARY KEY CHECK (id = 1), + qobuz_tos_accepted INTEGER NOT NULL DEFAULT 0 + );", + ) + .map_err(|e| format!("Failed to create legal settings table: {}", e))?; + + conn.execute( + "INSERT OR IGNORE INTO legal_settings (id, qobuz_tos_accepted) VALUES (1, 0)", + [], + ) + .map_err(|e| format!("Failed to insert default legal settings: {}", e))?; + + info!("[LegalSettings] Database initialized"); + + Ok(Self { conn }) + } + + pub fn new() -> Result { + let data_dir = dirs::data_dir() + .ok_or("Could not determine data directory")? + .join("qbz"); + Self::open_at(&data_dir, "legal_settings.db") + } + + pub fn new_at(base_dir: &Path) -> Result { + Self::open_at(base_dir, "legal_settings.db") + } + + pub fn get_settings(&self) -> Result { + self.conn + .query_row( + "SELECT qobuz_tos_accepted FROM legal_settings WHERE id = 1", + [], + |row| { + let qobuz_tos_accepted: i32 = row.get(0)?; + Ok(LegalSettings { + qobuz_tos_accepted: qobuz_tos_accepted != 0, + }) + }, + ) + .map_err(|e| format!("Failed to get legal settings: {}", e)) + } + + pub fn set_qobuz_tos_accepted(&self, accepted: bool) -> Result<(), String> { + self.conn + .execute( + "UPDATE legal_settings SET qobuz_tos_accepted = ? WHERE id = 1", + params![accepted as i32], + ) + .map_err(|e| format!("Failed to update ToS acceptance: {}", e))?; + + info!("[LegalSettings] Qobuz ToS accepted: {}", accepted); + Ok(()) + } +} + +pub type LegalSettingsState = Arc>>; + +pub fn create_legal_settings_state() -> Result { + let store = LegalSettingsStore::new()?; + Ok(Arc::new(Mutex::new(Some(store)))) +} + +pub fn create_empty_legal_settings_state() -> LegalSettingsState { + Arc::new(Mutex::new(None)) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn unique_test_dir(name: &str) -> std::path::PathBuf { + let nonce = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(); + std::env::temp_dir().join(format!("qbz-app-{name}-{}-{nonce}", std::process::id())) + } + + #[test] + fn legal_settings_default_to_unaccepted() { + let dir = unique_test_dir("legal-default"); + let store = LegalSettingsStore::new_at(&dir).unwrap(); + + let settings = store.get_settings().unwrap(); + + assert!(!settings.qobuz_tos_accepted); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn legal_settings_persist_tos_acceptance() { + let dir = unique_test_dir("legal-persist"); + { + let store = LegalSettingsStore::new_at(&dir).unwrap(); + store.set_qobuz_tos_accepted(true).unwrap(); + } + + let reopened = LegalSettingsStore::new_at(&dir).unwrap(); + let settings = reopened.get_settings().unwrap(); + + assert!(settings.qobuz_tos_accepted); + let _ = std::fs::remove_dir_all(dir); + } +} diff --git a/crates/qbz-app/src/settings/mod.rs b/crates/qbz-app/src/settings/mod.rs new file mode 100644 index 000000000..9cf0c18a3 --- /dev/null +++ b/crates/qbz-app/src/settings/mod.rs @@ -0,0 +1,2 @@ +pub mod legal; +pub mod subscription; diff --git a/crates/qbz-app/src/settings/subscription.rs b/crates/qbz-app/src/settings/subscription.rs new file mode 100644 index 000000000..2861437da --- /dev/null +++ b/crates/qbz-app/src/settings/subscription.rs @@ -0,0 +1,256 @@ +//! Subscription validity tracking for offline download compliance. +//! +//! Tracks when a user was first observed without a valid subscription. If the +//! invalid state persists for more than the grace period, offline downloads are +//! purged by the Tauri-side session lifecycle. + +use rusqlite::{params, Connection}; +use serde::{Deserialize, Serialize}; +use std::path::Path; +use std::sync::{Arc, Mutex}; + +/// How long we keep honoring offline access after the server first reports an +/// invalid subscription. +/// +/// Qobuz's own mobile app gives a 30-day offline grace window, so QBZ matches +/// that posture for compliance. Shorter windows would punish users on flaky +/// networks; longer windows would be more lenient than the official client. +/// +/// The primary protection for offline files is the CMAF-at-rest cache format. +/// This grace period is an additional compliance guard, not the main defense. +const GRACE_PERIOD_SECS: i64 = 30 * 24 * 60 * 60; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SubscriptionState { + pub invalid_since: Option, + pub last_invalid_at: Option, + pub last_valid_at: Option, + pub last_checked_at: Option, + pub downloads_purged_at: Option, +} + +impl Default for SubscriptionState { + fn default() -> Self { + Self { + invalid_since: None, + last_invalid_at: None, + last_valid_at: None, + last_checked_at: None, + downloads_purged_at: None, + } + } +} + +pub struct SubscriptionStateStore { + conn: Connection, +} + +impl SubscriptionStateStore { + fn open_at(dir: &Path, db_name: &str) -> Result { + std::fs::create_dir_all(dir) + .map_err(|e| format!("Failed to create data directory: {}", e))?; + + let db_path = dir.join(db_name); + let conn = Connection::open(&db_path) + .map_err(|e| format!("Failed to open subscription state database: {}", e))?; + + conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;") + .map_err(|e| { + format!( + "Failed to enable WAL for subscription state database: {}", + e + ) + })?; + + conn.execute_batch( + "CREATE TABLE IF NOT EXISTS subscription_state ( + id INTEGER PRIMARY KEY CHECK (id = 1), + invalid_since INTEGER, + last_invalid_at INTEGER, + last_valid_at INTEGER, + last_checked_at INTEGER, + downloads_purged_at INTEGER + ); + INSERT OR IGNORE INTO subscription_state (id) VALUES (1);", + ) + .map_err(|e| format!("Failed to create subscription state table: {}", e))?; + + Ok(Self { conn }) + } + + pub fn new() -> Result { + let data_dir = dirs::data_dir() + .ok_or("Could not determine data directory")? + .join("qbz"); + Self::open_at(&data_dir, "subscription_state.db") + } + + pub fn new_at(base_dir: &Path) -> Result { + Self::open_at(base_dir, "subscription_state.db") + } + + pub fn get_state(&self) -> Result { + self.conn + .query_row( + "SELECT invalid_since, last_invalid_at, last_valid_at, last_checked_at, downloads_purged_at + FROM subscription_state WHERE id = 1", + [], + |row| { + Ok(SubscriptionState { + invalid_since: row.get(0)?, + last_invalid_at: row.get(1)?, + last_valid_at: row.get(2)?, + last_checked_at: row.get(3)?, + downloads_purged_at: row.get(4)?, + }) + }, + ) + .map_err(|e| format!("Failed to read subscription state: {}", e)) + } + + pub fn mark_valid(&self, now: i64) -> Result<(), String> { + self.conn + .execute( + "UPDATE subscription_state + SET invalid_since = NULL, + last_valid_at = ?1, + last_checked_at = ?1 + WHERE id = 1", + params![now], + ) + .map_err(|e| format!("Failed to update subscription state: {}", e))?; + Ok(()) + } + + pub fn mark_invalid(&self, now: i64) -> Result<(), String> { + self.conn + .execute( + "UPDATE subscription_state + SET invalid_since = COALESCE(invalid_since, ?1), + last_invalid_at = ?1, + last_checked_at = ?1 + WHERE id = 1", + params![now], + ) + .map_err(|e| format!("Failed to update subscription state: {}", e))?; + Ok(()) + } + + pub fn mark_offline_cache_purged(&self, now: i64) -> Result<(), String> { + self.conn + .execute( + "UPDATE subscription_state SET downloads_purged_at = ?1 WHERE id = 1", + params![now], + ) + .map_err(|e| format!("Failed to update purge timestamp: {}", e))?; + Ok(()) + } + + pub fn should_purge_offline_cache(&self, now: i64) -> Result { + let state = self.get_state()?; + let Some(invalid_since) = state.invalid_since else { + return Ok(false); + }; + if now - invalid_since < GRACE_PERIOD_SECS { + return Ok(false); + } + if let Some(purged_at) = state.downloads_purged_at { + if purged_at >= invalid_since { + return Ok(false); + } + } + Ok(true) + } +} + +pub type SubscriptionStateState = Arc>>; + +pub fn create_subscription_state() -> Result { + let store = SubscriptionStateStore::new()?; + Ok(Arc::new(Mutex::new(Some(store)))) +} + +pub fn create_empty_subscription_state() -> SubscriptionStateState { + Arc::new(Mutex::new(None)) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn unique_test_dir(name: &str) -> std::path::PathBuf { + let nonce = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(); + std::env::temp_dir().join(format!("qbz-app-{name}-{}-{nonce}", std::process::id())) + } + + #[test] + fn subscription_state_defaults_to_valid_access() { + let dir = unique_test_dir("subscription-default"); + let store = SubscriptionStateStore::new_at(&dir).unwrap(); + + let state = store.get_state().unwrap(); + + assert!(state.invalid_since.is_none()); + assert!(!store.should_purge_offline_cache(0).unwrap()); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn subscription_invalid_since_preserves_first_invalid_observation() { + let dir = unique_test_dir("subscription-invalid"); + let store = SubscriptionStateStore::new_at(&dir).unwrap(); + + store.mark_invalid(100).unwrap(); + store.mark_invalid(200).unwrap(); + let state = store.get_state().unwrap(); + + assert_eq!(state.invalid_since, Some(100)); + assert_eq!(state.last_invalid_at, Some(200)); + assert_eq!(state.last_checked_at, Some(200)); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn subscription_purge_waits_for_grace_period_and_only_runs_once() { + let dir = unique_test_dir("subscription-purge"); + let store = SubscriptionStateStore::new_at(&dir).unwrap(); + + store.mark_invalid(100).unwrap(); + + assert!(!store + .should_purge_offline_cache(100 + GRACE_PERIOD_SECS - 1) + .unwrap()); + assert!(store + .should_purge_offline_cache(100 + GRACE_PERIOD_SECS) + .unwrap()); + + store + .mark_offline_cache_purged(100 + GRACE_PERIOD_SECS) + .unwrap(); + + assert!(!store + .should_purge_offline_cache(100 + GRACE_PERIOD_SECS + 1) + .unwrap()); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn subscription_mark_valid_clears_invalid_since() { + let dir = unique_test_dir("subscription-valid"); + let store = SubscriptionStateStore::new_at(&dir).unwrap(); + + store.mark_invalid(100).unwrap(); + store.mark_valid(200).unwrap(); + let state = store.get_state().unwrap(); + + assert_eq!(state.invalid_since, None); + assert_eq!(state.last_valid_at, Some(200)); + assert_eq!(state.last_checked_at, Some(200)); + assert!(!store.should_purge_offline_cache(1000).unwrap()); + let _ = std::fs::remove_dir_all(dir); + } +}