refactor(qbz-app): add settings stores

cx
This commit is contained in:
vicrodh committed 2026-05-14 17:08:45 -06:00
1 parent dc74db6401
commit 800447d33c
6 files changed
+410

No files matched your search

+2
View File
@@ -2907,7 +2907,9 @@ checksum = "b5a041e753da8b807c9255f28de81879c78c876392ff2469cde94799b2896b9d"
name = "qbz-app"
version = "0.1.0"
dependencies = [
"dirs",
"log",
"rusqlite",
"serde",
"tokio",
]
+2
View File
@@ -9,3 +9,5 @@ repository.workspace = true
serde = { workspace = true }
tokio = { workspace = true }
log = { workspace = true }
rusqlite = { version = "0.31", features = ["bundled"] }
dirs = "6"
+1
View File
@@ -1 +1,2 @@
pub mod runtime;
pub mod settings;
+147
View File
@@ -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<Self, String> {
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<Self, String> {
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, String> {
Self::open_at(base_dir, "legal_settings.db")
}
pub fn get_settings(&self) -> Result<LegalSettings, String> {
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<Mutex<Option<LegalSettingsStore>>>;
pub fn create_legal_settings_state() -> Result<LegalSettingsState, String> {
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);
}
}
+2
View File
@@ -0,0 +1,2 @@
pub mod legal;
pub mod subscription;
+256
View File
@@ -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<i64>,
pub last_invalid_at: Option<i64>,
pub last_valid_at: Option<i64>,
pub last_checked_at: Option<i64>,
pub downloads_purged_at: Option<i64>,
}
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<Self, String> {
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<Self, String> {
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, String> {
Self::open_at(base_dir, "subscription_state.db")
}
pub fn get_state(&self) -> Result<SubscriptionState, String> {
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<bool, String> {
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<Mutex<Option<SubscriptionStateStore>>>;
pub fn create_subscription_state() -> Result<SubscriptionStateState, String> {
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);
}
}