refactor(qbz-app): add settings stores
cx
This commit is contained in:
6 files changed
+410
No files matched your search
Generated
+2
@@ -2907,7 +2907,9 @@ checksum = "b5a041e753da8b807c9255f28de81879c78c876392ff2469cde94799b2896b9d"
|
|||||||
name = "qbz-app"
|
name = "qbz-app"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"dirs",
|
||||||
"log",
|
"log",
|
||||||
|
"rusqlite",
|
||||||
"serde",
|
"serde",
|
||||||
"tokio",
|
"tokio",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -9,3 +9,5 @@ repository.workspace = true
|
|||||||
serde = { workspace = true }
|
serde = { workspace = true }
|
||||||
tokio = { workspace = true }
|
tokio = { workspace = true }
|
||||||
log = { workspace = true }
|
log = { workspace = true }
|
||||||
|
rusqlite = { version = "0.31", features = ["bundled"] }
|
||||||
|
dirs = "6"
|
||||||
@@ -1 +1,2 @@
|
|||||||
pub mod runtime;
|
pub mod runtime;
|
||||||
|
pub mod settings;
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
pub mod legal;
|
||||||
|
pub mod subscription;
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user