Merge refactor/qbz-app-ui-preferences-foundation: qbz-app UI preference stores

cx
This commit is contained in:
vicrodh committed 2026-05-15 00:41:12 -06:00
commit ebe03193c2
6 files changed
+883 -670

No files matched your search

+2
View File
@@ -1,4 +1,6 @@
pub mod favorites; pub mod favorites;
pub mod legal; pub mod legal;
pub mod library; pub mod library;
pub mod playback;
pub mod subscription; pub mod subscription;
pub mod tray;
+451
View File
@@ -0,0 +1,451 @@
//! Playback and small playback-adjacent UI preferences.
//!
//! `show_context_icon` is persisted here to preserve the existing settings
//! contract, but it is a portable UI preference, not playback domain logic.
use log::info;
use rusqlite::{params, Connection};
use serde::{Deserialize, Serialize};
use std::path::Path;
use std::sync::{Arc, Mutex};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum AutoplayMode {
/// Continue playing within the source (album, playlist, etc.)
#[serde(rename = "continue")]
ContinueWithinSource,
/// Play only the selected track, then stop.
#[serde(rename = "track_only")]
PlayTrackOnly,
/// Create infinite radio when queue ends (based on recent tracks).
#[serde(rename = "infinite")]
InfiniteRadio,
}
impl Default for AutoplayMode {
fn default() -> Self {
Self::ContinueWithinSource
}
}
impl AutoplayMode {
fn to_db_value(self) -> &'static str {
match self {
AutoplayMode::ContinueWithinSource => "continue",
AutoplayMode::PlayTrackOnly => "track_only",
AutoplayMode::InfiniteRadio => "infinite",
}
}
fn from_db_value(value: &str) -> Self {
match value {
"track_only" => AutoplayMode::PlayTrackOnly,
"infinite" => AutoplayMode::InfiniteRadio,
_ => AutoplayMode::ContinueWithinSource,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PlaybackPreferences {
pub autoplay_mode: AutoplayMode,
/// Portable UI preference for showing the context-stack icon.
/// This is not domain playback behavior.
pub show_context_icon: bool,
pub persist_session: bool,
/// Sub-preference of `persist_session`. When true, restoring a
/// session also seeks to `current_position_secs` of the saved
/// track. When false (default), the saved track is shown paused at
/// 0:00 and the user starts the next listen fresh.
pub resume_playback_position: bool,
}
impl Default for PlaybackPreferences {
fn default() -> Self {
Self {
autoplay_mode: AutoplayMode::ContinueWithinSource,
show_context_icon: false,
persist_session: false,
resume_playback_position: false,
}
}
}
pub struct PlaybackPreferencesStore {
conn: Connection,
}
impl PlaybackPreferencesStore {
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 playback preferences database: {}", e))?;
conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;")
.map_err(|e| {
format!(
"Failed to enable WAL for playback preferences database: {}",
e
)
})?;
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS playback_preferences (
id INTEGER PRIMARY KEY CHECK (id = 1),
autoplay_mode TEXT NOT NULL DEFAULT 'continue'
);",
)
.map_err(|e| format!("Failed to create playback preferences table: {}", e))?;
let show_context_icon_exists =
column_exists(&conn, "playback_preferences", "show_context_icon");
info!(
"[PlaybackPrefs] Column show_context_icon exists: {}",
show_context_icon_exists
);
if !show_context_icon_exists {
info!("[PlaybackPrefs] Migrating: adding show_context_icon column");
conn.execute(
"ALTER TABLE playback_preferences ADD COLUMN show_context_icon INTEGER NOT NULL DEFAULT 0",
[],
)
.map_err(|e| format!("Failed to add show_context_icon column: {}", e))?;
info!("[PlaybackPrefs] Migration successful");
}
if !column_exists(&conn, "playback_preferences", "persist_session") {
info!("[PlaybackPrefs] Migrating: adding persist_session column");
conn.execute(
"ALTER TABLE playback_preferences ADD COLUMN persist_session INTEGER NOT NULL DEFAULT 0",
[],
)
.map_err(|e| format!("Failed to add persist_session column: {}", e))?;
info!("[PlaybackPrefs] persist_session migration successful");
}
if !column_exists(&conn, "playback_preferences", "resume_playback_position") {
info!("[PlaybackPrefs] Migrating: adding resume_playback_position column");
conn.execute(
"ALTER TABLE playback_preferences ADD COLUMN resume_playback_position INTEGER NOT NULL DEFAULT 0",
[],
)
.map_err(|e| format!("Failed to add resume_playback_position column: {}", e))?;
info!("[PlaybackPrefs] resume_playback_position migration successful");
}
conn.execute(
"INSERT OR IGNORE INTO playback_preferences (id, autoplay_mode, show_context_icon, persist_session, resume_playback_position)
VALUES (1, 'continue', 0, 0, 0)",
[],
)
.map_err(|e| format!("Failed to insert default preferences: {}", 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, "playback_preferences.db")
}
pub fn new_at(base_dir: &Path) -> Result<Self, String> {
Self::open_at(base_dir, "playback_preferences.db")
}
pub fn get_preferences(&self) -> Result<PlaybackPreferences, String> {
self.conn
.query_row(
"SELECT autoplay_mode, show_context_icon, persist_session, resume_playback_position FROM playback_preferences WHERE id = 1",
[],
|row| {
let autoplay_str: String = row.get(0)?;
let show_icon: i32 = row.get(1)?;
let persist: i32 = row.get(2)?;
let resume_pos: i32 = row.get(3)?;
Ok(PlaybackPreferences {
autoplay_mode: AutoplayMode::from_db_value(&autoplay_str),
show_context_icon: show_icon != 0,
persist_session: persist != 0,
resume_playback_position: resume_pos != 0,
})
},
)
.map_err(|e| format!("Failed to get playback preferences: {}", e))
}
pub fn set_autoplay_mode(&self, mode: AutoplayMode) -> Result<(), String> {
self.conn
.execute(
"UPDATE playback_preferences SET autoplay_mode = ?1 WHERE id = 1",
params![mode.to_db_value()],
)
.map_err(|e| format!("Failed to set autoplay mode: {}", e))?;
Ok(())
}
pub fn set_show_context_icon(&self, show: bool) -> Result<(), String> {
self.conn
.execute(
"UPDATE playback_preferences SET show_context_icon = ?1 WHERE id = 1",
params![if show { 1 } else { 0 }],
)
.map_err(|e| format!("Failed to set show context icon: {}", e))?;
Ok(())
}
pub fn set_persist_session(&self, persist: bool) -> Result<(), String> {
self.conn
.execute(
"UPDATE playback_preferences SET persist_session = ?1 WHERE id = 1",
params![if persist { 1 } else { 0 }],
)
.map_err(|e| format!("Failed to set persist session: {}", e))?;
Ok(())
}
pub fn set_resume_playback_position(&self, resume: bool) -> Result<(), String> {
self.conn
.execute(
"UPDATE playback_preferences SET resume_playback_position = ?1 WHERE id = 1",
params![if resume { 1 } else { 0 }],
)
.map_err(|e| format!("Failed to set resume playback position: {}", e))?;
Ok(())
}
/// Reset all playback preferences to their default values.
pub fn reset_all(&self) -> Result<PlaybackPreferences, String> {
let defaults = PlaybackPreferences::default();
self.conn
.execute(
"UPDATE playback_preferences SET autoplay_mode = ?1, show_context_icon = ?2, persist_session = ?3, resume_playback_position = ?4 WHERE id = 1",
params![
defaults.autoplay_mode.to_db_value(),
if defaults.show_context_icon { 1 } else { 0 },
if defaults.persist_session { 1 } else { 0 },
if defaults.resume_playback_position { 1 } else { 0 },
],
)
.map_err(|e| format!("Failed to reset playback preferences: {}", e))?;
Ok(defaults)
}
}
pub struct PlaybackPreferencesState {
pub store: Arc<Mutex<Option<PlaybackPreferencesStore>>>,
}
impl PlaybackPreferencesState {
pub fn new() -> Result<Self, String> {
let store = PlaybackPreferencesStore::new()?;
Ok(Self {
store: Arc::new(Mutex::new(Some(store))),
})
}
pub fn new_empty() -> Self {
Self {
store: Arc::new(Mutex::new(None)),
}
}
pub fn init_at(&self, base_dir: &Path) -> Result<(), String> {
let new_store = PlaybackPreferencesStore::new_at(base_dir)?;
let mut guard = self
.store
.lock()
.map_err(|_| "Failed to lock playback preferences store".to_string())?;
*guard = Some(new_store);
Ok(())
}
pub fn teardown(&self) -> Result<(), String> {
let mut guard = self
.store
.lock()
.map_err(|_| "Failed to lock playback preferences store".to_string())?;
*guard = None;
Ok(())
}
pub fn get_preferences(&self) -> Result<PlaybackPreferences, String> {
let guard = self
.store
.lock()
.map_err(|_| "Failed to lock playback preferences store".to_string())?;
let store = guard.as_ref().ok_or("No active session - please log in")?;
store.get_preferences()
}
pub fn set_autoplay_mode(&self, mode: AutoplayMode) -> Result<(), String> {
let guard = self
.store
.lock()
.map_err(|_| "Failed to lock playback preferences store".to_string())?;
let store = guard.as_ref().ok_or("No active session - please log in")?;
store.set_autoplay_mode(mode)
}
pub fn set_show_context_icon(&self, show: bool) -> Result<(), String> {
let guard = self
.store
.lock()
.map_err(|_| "Failed to lock playback preferences store".to_string())?;
let store = guard.as_ref().ok_or("No active session - please log in")?;
store.set_show_context_icon(show)
}
pub fn set_persist_session(&self, persist: bool) -> Result<(), String> {
let guard = self
.store
.lock()
.map_err(|_| "Failed to lock playback preferences store".to_string())?;
let store = guard.as_ref().ok_or("No active session - please log in")?;
store.set_persist_session(persist)
}
pub fn set_resume_playback_position(&self, resume: bool) -> Result<(), String> {
let guard = self
.store
.lock()
.map_err(|_| "Failed to lock playback preferences store".to_string())?;
let store = guard.as_ref().ok_or("No active session - please log in")?;
store.set_resume_playback_position(resume)
}
}
fn column_exists(conn: &Connection, table: &str, column: &str) -> bool {
conn.query_row(
&format!("SELECT COUNT(*) FROM pragma_table_info('{table}') WHERE name = ?1"),
[column],
|row| {
let count: i32 = row.get(0)?;
Ok(count > 0)
},
)
.unwrap_or(false)
}
#[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()))
}
fn fresh_store(name: &str) -> (std::path::PathBuf, PlaybackPreferencesStore) {
let dir = unique_test_dir(name);
let store = PlaybackPreferencesStore::new_at(&dir).expect("open store in temp dir");
(dir, store)
}
#[test]
fn playback_preferences_default_values_are_stable() {
let prefs = PlaybackPreferences::default();
assert_eq!(prefs.autoplay_mode, AutoplayMode::ContinueWithinSource);
assert!(!prefs.show_context_icon);
assert!(!prefs.persist_session);
assert!(!prefs.resume_playback_position);
}
#[test]
fn playback_preferences_store_returns_defaults() {
let (dir, store) = fresh_store("playback-default");
let prefs = store.get_preferences().expect("get prefs");
assert_eq!(prefs.autoplay_mode, AutoplayMode::ContinueWithinSource);
assert!(!prefs.show_context_icon);
assert!(!prefs.persist_session);
assert!(!prefs.resume_playback_position);
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn playback_preferences_persist_all_fields() {
let dir = unique_test_dir("playback-persist");
{
let store = PlaybackPreferencesStore::new_at(&dir).expect("open store");
store
.set_autoplay_mode(AutoplayMode::InfiniteRadio)
.expect("set autoplay");
store.set_show_context_icon(true).expect("set context icon");
store
.set_persist_session(true)
.expect("set persist session");
store
.set_resume_playback_position(true)
.expect("set resume position");
}
let reopened = PlaybackPreferencesStore::new_at(&dir).expect("reopen store");
let prefs = reopened.get_preferences().expect("get prefs");
assert_eq!(prefs.autoplay_mode, AutoplayMode::InfiniteRadio);
assert!(prefs.show_context_icon);
assert!(prefs.persist_session);
assert!(prefs.resume_playback_position);
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn playback_preferences_migrates_legacy_schema() {
let dir = unique_test_dir("playback-migrate");
std::fs::create_dir_all(&dir).expect("create temp dir");
let db_path = dir.join("playback_preferences.db");
{
let conn = Connection::open(&db_path).expect("open legacy db");
conn.execute_batch(
"CREATE TABLE playback_preferences (
id INTEGER PRIMARY KEY CHECK (id = 1),
autoplay_mode TEXT NOT NULL DEFAULT 'continue'
);
INSERT INTO playback_preferences (id, autoplay_mode) VALUES (1, 'track_only');",
)
.expect("create legacy schema");
}
let store = PlaybackPreferencesStore::new_at(&dir).expect("migrate store");
let prefs = store.get_preferences().expect("get prefs");
assert_eq!(prefs.autoplay_mode, AutoplayMode::PlayTrackOnly);
assert!(!prefs.show_context_icon);
assert!(!prefs.persist_session);
assert!(!prefs.resume_playback_position);
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn playback_preferences_reset_all_preserves_existing_behavior() {
let (dir, store) = fresh_store("playback-reset");
store
.set_autoplay_mode(AutoplayMode::InfiniteRadio)
.expect("set autoplay");
store.set_show_context_icon(true).expect("set context icon");
store.set_persist_session(true).expect("set persist");
store
.set_resume_playback_position(true)
.expect("set resume position");
let defaults = store.reset_all().expect("reset prefs");
let prefs = store.get_preferences().expect("get prefs");
assert_eq!(defaults.autoplay_mode, AutoplayMode::ContinueWithinSource);
assert_eq!(prefs.autoplay_mode, AutoplayMode::ContinueWithinSource);
assert!(!prefs.show_context_icon);
assert!(!prefs.persist_session);
assert!(!prefs.resume_playback_position);
let _ = std::fs::remove_dir_all(dir);
}
}
+1 -1
View File
@@ -2,7 +2,7 @@
//! //!
//! Tracks when a user was first observed without a valid subscription. If the //! 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 //! invalid state persists for more than the grace period, offline downloads are
//! purged by the Tauri-side session lifecycle. //! purged by the host-side session lifecycle.
use rusqlite::{params, Connection}; use rusqlite::{params, Connection};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
+373
View File
@@ -0,0 +1,373 @@
//! Portable tray preference storage.
//!
//! This module owns persisted tray preferences only. Runtime tray creation,
//! icon updates, window hiding/showing, and emitted events remain in the
//! host application layer.
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 TraySettings {
/// Show system tray icon (requires restart to take effect).
pub enable_tray: bool,
/// Hide window to tray when clicking minimize.
pub minimize_to_tray: bool,
/// Hide window to tray instead of quitting when clicking close.
pub close_to_tray: bool,
/// Tray icon variant override:
/// - "auto" follows system color scheme,
/// - "mono-light" uses a light glyph for dark panels,
/// - "mono-dark" uses a dark glyph for light panels,
/// - "color" uses the full-color vinyl logo.
#[serde(default = "default_tray_icon_theme")]
pub tray_icon_theme: String,
}
fn default_tray_icon_theme() -> String {
"auto".to_string()
}
/// Coerce free-form values to the supported set. Anything outside the
/// supported list falls back to "auto".
///
/// Legacy 1.2.9-pre values "light"/"dark" had inverted semantics
/// relative to the labels users saw. They are remapped here to the value
/// that matches the user's original intent.
pub fn normalize_tray_icon_theme(input: &str) -> String {
match input {
"mono-light" | "mono-dark" | "color" | "auto" => input.to_string(),
"light" => "mono-light".to_string(),
"dark" => "mono-dark".to_string(),
_ => "auto".to_string(),
}
}
impl Default for TraySettings {
fn default() -> Self {
Self {
enable_tray: true,
minimize_to_tray: false,
close_to_tray: false,
tray_icon_theme: default_tray_icon_theme(),
}
}
}
pub struct TraySettingsStore {
conn: Connection,
}
impl TraySettingsStore {
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 tray settings database: {}", e))?;
conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;")
.map_err(|e| format!("Failed to enable WAL for tray settings database: {}", e))?;
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS tray_settings (
id INTEGER PRIMARY KEY CHECK (id = 1),
enable_tray INTEGER NOT NULL DEFAULT 1,
minimize_to_tray INTEGER NOT NULL DEFAULT 0,
close_to_tray INTEGER NOT NULL DEFAULT 0
);",
)
.map_err(|e| format!("Failed to create tray settings table: {}", e))?;
let has_theme_column: i64 = conn
.query_row(
"SELECT COUNT(*) FROM pragma_table_info('tray_settings') WHERE name = 'tray_icon_theme'",
[],
|row| row.get(0),
)
.map_err(|e| format!("Failed to check tray_icon_theme column: {}", e))?;
if has_theme_column == 0 {
conn.execute_batch(
"ALTER TABLE tray_settings ADD COLUMN tray_icon_theme TEXT NOT NULL DEFAULT 'auto';",
)
.map_err(|e| format!("Failed to add tray_icon_theme column: {}", e))?;
}
conn.execute(
"INSERT OR IGNORE INTO tray_settings (id, enable_tray, minimize_to_tray, close_to_tray, tray_icon_theme)
VALUES (1, 1, 0, 0, 'auto')",
[],
)
.map_err(|e| format!("Failed to insert default tray settings: {}", e))?;
info!("[TraySettings] 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, "tray_settings.db")
}
pub fn new_at(base_dir: &Path) -> Result<Self, String> {
Self::open_at(base_dir, "tray_settings.db")
}
pub fn get_settings(&self) -> Result<TraySettings, String> {
self.conn
.query_row(
"SELECT enable_tray, minimize_to_tray, close_to_tray, tray_icon_theme FROM tray_settings WHERE id = 1",
[],
|row| {
let enable_tray: i32 = row.get(0)?;
let minimize_to_tray: i32 = row.get(1)?;
let close_to_tray: i32 = row.get(2)?;
let tray_icon_theme: String = row.get(3)?;
Ok(TraySettings {
enable_tray: enable_tray != 0,
minimize_to_tray: minimize_to_tray != 0,
close_to_tray: close_to_tray != 0,
tray_icon_theme: normalize_tray_icon_theme(&tray_icon_theme),
})
},
)
.map_err(|e| format!("Failed to get tray settings: {}", e))
}
pub fn set_enable_tray(&self, value: bool) -> Result<(), String> {
self.conn
.execute(
"UPDATE tray_settings SET enable_tray = ?1 WHERE id = 1",
params![if value { 1 } else { 0 }],
)
.map_err(|e| format!("Failed to set enable_tray: {}", e))?;
Ok(())
}
pub fn set_minimize_to_tray(&self, value: bool) -> Result<(), String> {
self.conn
.execute(
"UPDATE tray_settings SET minimize_to_tray = ?1 WHERE id = 1",
params![if value { 1 } else { 0 }],
)
.map_err(|e| format!("Failed to set minimize_to_tray: {}", e))?;
Ok(())
}
pub fn set_close_to_tray(&self, value: bool) -> Result<(), String> {
self.conn
.execute(
"UPDATE tray_settings SET close_to_tray = ?1 WHERE id = 1",
params![if value { 1 } else { 0 }],
)
.map_err(|e| format!("Failed to set close_to_tray: {}", e))?;
Ok(())
}
pub fn set_tray_icon_theme(&self, value: &str) -> Result<(), String> {
let normalized = normalize_tray_icon_theme(value);
self.conn
.execute(
"UPDATE tray_settings SET tray_icon_theme = ?1 WHERE id = 1",
params![normalized],
)
.map_err(|e| format!("Failed to set tray_icon_theme: {}", e))?;
Ok(())
}
}
pub struct TraySettingsState {
pub store: Arc<Mutex<Option<TraySettingsStore>>>,
}
impl TraySettingsState {
pub fn new() -> Result<Self, String> {
let store = TraySettingsStore::new()?;
Ok(Self {
store: Arc::new(Mutex::new(Some(store))),
})
}
pub fn new_empty() -> Self {
Self {
store: Arc::new(Mutex::new(None)),
}
}
pub fn init_at(&self, base_dir: &Path) -> Result<(), String> {
let new_store = TraySettingsStore::new_at(base_dir)?;
let mut guard = self
.store
.lock()
.map_err(|_| "Failed to lock tray settings store".to_string())?;
*guard = Some(new_store);
Ok(())
}
pub fn teardown(&self) -> Result<(), String> {
let mut guard = self
.store
.lock()
.map_err(|_| "Failed to lock tray settings store".to_string())?;
*guard = None;
Ok(())
}
pub fn get_settings(&self) -> Result<TraySettings, String> {
let guard = self
.store
.lock()
.map_err(|_| "Failed to lock tray settings store".to_string())?;
let store = guard.as_ref().ok_or("No active session - please log in")?;
store.get_settings()
}
pub fn set_enable_tray(&self, value: bool) -> Result<(), String> {
let guard = self
.store
.lock()
.map_err(|_| "Failed to lock tray settings store".to_string())?;
let store = guard.as_ref().ok_or("No active session - please log in")?;
store.set_enable_tray(value)
}
pub fn set_minimize_to_tray(&self, value: bool) -> Result<(), String> {
let guard = self
.store
.lock()
.map_err(|_| "Failed to lock tray settings store".to_string())?;
let store = guard.as_ref().ok_or("No active session - please log in")?;
store.set_minimize_to_tray(value)
}
pub fn set_close_to_tray(&self, value: bool) -> Result<(), String> {
let guard = self
.store
.lock()
.map_err(|_| "Failed to lock tray settings store".to_string())?;
let store = guard.as_ref().ok_or("No active session - please log in")?;
store.set_close_to_tray(value)
}
pub fn set_tray_icon_theme(&self, value: &str) -> Result<(), String> {
let guard = self
.store
.lock()
.map_err(|_| "Failed to lock tray settings store".to_string())?;
let store = guard.as_ref().ok_or("No active session - please log in")?;
store.set_tray_icon_theme(value)
}
}
#[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()))
}
fn fresh_store(name: &str) -> (std::path::PathBuf, TraySettingsStore) {
let dir = unique_test_dir(name);
let store = TraySettingsStore::new_at(&dir).expect("open store in temp dir");
(dir, store)
}
#[test]
fn tray_settings_default_values_are_stable() {
let settings = TraySettings::default();
assert!(settings.enable_tray);
assert!(!settings.minimize_to_tray);
assert!(!settings.close_to_tray);
assert_eq!(settings.tray_icon_theme, "auto");
}
#[test]
fn tray_settings_store_returns_defaults() {
let (dir, store) = fresh_store("tray-default");
let settings = store.get_settings().expect("get settings");
assert!(settings.enable_tray);
assert!(!settings.minimize_to_tray);
assert!(!settings.close_to_tray);
assert_eq!(settings.tray_icon_theme, "auto");
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn tray_settings_persist_all_fields() {
let dir = unique_test_dir("tray-persist");
{
let store = TraySettingsStore::new_at(&dir).expect("open store");
store.set_enable_tray(false).expect("set enable");
store.set_minimize_to_tray(true).expect("set minimize");
store.set_close_to_tray(true).expect("set close");
store
.set_tray_icon_theme("mono-light")
.expect("set icon theme");
}
let reopened = TraySettingsStore::new_at(&dir).expect("reopen store");
let settings = reopened.get_settings().expect("get settings");
assert!(!settings.enable_tray);
assert!(settings.minimize_to_tray);
assert!(settings.close_to_tray);
assert_eq!(settings.tray_icon_theme, "mono-light");
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn tray_icon_theme_normalizes_supported_legacy_and_unknown_values() {
assert_eq!(normalize_tray_icon_theme("auto"), "auto");
assert_eq!(normalize_tray_icon_theme("mono-light"), "mono-light");
assert_eq!(normalize_tray_icon_theme("mono-dark"), "mono-dark");
assert_eq!(normalize_tray_icon_theme("color"), "color");
assert_eq!(normalize_tray_icon_theme("light"), "mono-light");
assert_eq!(normalize_tray_icon_theme("dark"), "mono-dark");
assert_eq!(normalize_tray_icon_theme("invalid"), "auto");
}
#[test]
fn tray_settings_migrates_legacy_schema() {
let dir = unique_test_dir("tray-migrate");
std::fs::create_dir_all(&dir).expect("create temp dir");
let db_path = dir.join("tray_settings.db");
{
let conn = Connection::open(&db_path).expect("open legacy db");
conn.execute_batch(
"CREATE TABLE tray_settings (
id INTEGER PRIMARY KEY CHECK (id = 1),
enable_tray INTEGER NOT NULL DEFAULT 1,
minimize_to_tray INTEGER NOT NULL DEFAULT 0,
close_to_tray INTEGER NOT NULL DEFAULT 0
);
INSERT INTO tray_settings (id, enable_tray, minimize_to_tray, close_to_tray)
VALUES (1, 0, 1, 1);",
)
.expect("create legacy schema");
}
let store = TraySettingsStore::new_at(&dir).expect("migrate store");
let settings = store.get_settings().expect("get settings");
assert!(!settings.enable_tray);
assert!(settings.minimize_to_tray);
assert!(settings.close_to_tray);
assert_eq!(settings.tray_icon_theme, "auto");
let _ = std::fs::remove_dir_all(dir);
}
}
+9 -361
View File
@@ -1,364 +1,12 @@
//! Playback preferences //! Tauri commands for playback and playback-adjacent UI preferences.
//!
//! Stores user preferences for playback behavior (autoplay mode, etc.)
use log::info; pub use qbz_app::settings::playback::{
use rusqlite::{params, Connection}; AutoplayMode, PlaybackPreferences, PlaybackPreferencesState, PlaybackPreferencesStore,
use serde::{Deserialize, Serialize}; };
use std::path::Path;
use std::sync::{Arc, Mutex};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum AutoplayMode {
/// Continue playing within the source (album, playlist, etc.)
#[serde(rename = "continue")]
ContinueWithinSource,
/// Play only the selected track, then stop
#[serde(rename = "track_only")]
PlayTrackOnly,
/// Create infinite radio when queue ends (based on recent tracks)
#[serde(rename = "infinite")]
InfiniteRadio,
}
impl Default for AutoplayMode {
fn default() -> Self {
Self::ContinueWithinSource
}
}
impl AutoplayMode {
fn to_db_value(&self) -> &'static str {
match self {
AutoplayMode::ContinueWithinSource => "continue",
AutoplayMode::PlayTrackOnly => "track_only",
AutoplayMode::InfiniteRadio => "infinite",
}
}
fn from_db_value(s: &str) -> Self {
match s {
"track_only" => AutoplayMode::PlayTrackOnly,
"infinite" => AutoplayMode::InfiniteRadio,
_ => AutoplayMode::ContinueWithinSource,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PlaybackPreferences {
pub autoplay_mode: AutoplayMode,
pub show_context_icon: bool,
pub persist_session: bool,
/// Sub-preference of `persist_session`. When true, restoring a
/// session also seeks to `current_position_secs` of the saved
/// track. When false (default), the saved track is shown paused at
/// 0:00 and the user starts the next listen fresh — the common
/// preference for "open app the next morning to a clean slate"
/// (issue #317).
pub resume_playback_position: bool,
}
impl Default for PlaybackPreferences {
fn default() -> Self {
Self {
autoplay_mode: AutoplayMode::ContinueWithinSource,
show_context_icon: false,
persist_session: false,
resume_playback_position: false,
}
}
}
pub struct PlaybackPreferencesStore {
conn: Connection,
}
impl PlaybackPreferencesStore {
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 playback preferences database: {}", e))?;
conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;")
.map_err(|e| {
format!(
"Failed to enable WAL for playback preferences database: {}",
e
)
})?;
// Step 1: Create table with old schema (for new installs) or do nothing if exists
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS playback_preferences (
id INTEGER PRIMARY KEY CHECK (id = 1),
autoplay_mode TEXT NOT NULL DEFAULT 'continue'
);",
)
.map_err(|e| format!("Failed to create playback preferences table: {}", e))?;
// Step 2: Check if show_context_icon column exists
let column_exists: bool = conn
.query_row(
"SELECT COUNT(*) FROM pragma_table_info('playback_preferences') WHERE name='show_context_icon'",
[],
|row| {
let count: i32 = row.get(0)?;
Ok(count > 0)
}
)
.unwrap_or(false);
info!(
"[PlaybackPrefs] Column show_context_icon exists: {}",
column_exists
);
// Step 3: Add column if it doesn't exist (migration for existing users)
if !column_exists {
info!("[PlaybackPrefs] Migrating: adding show_context_icon column");
conn.execute(
"ALTER TABLE playback_preferences ADD COLUMN show_context_icon INTEGER NOT NULL DEFAULT 0",
[]
).map_err(|e| format!("Failed to add show_context_icon column: {}", e))?;
info!("[PlaybackPrefs] Migration successful");
}
// Step 3b: Check if persist_session column exists
let persist_session_exists: bool = conn
.query_row(
"SELECT COUNT(*) FROM pragma_table_info('playback_preferences') WHERE name='persist_session'",
[],
|row| {
let count: i32 = row.get(0)?;
Ok(count > 0)
}
)
.unwrap_or(false);
if !persist_session_exists {
info!("[PlaybackPrefs] Migrating: adding persist_session column");
conn.execute(
"ALTER TABLE playback_preferences ADD COLUMN persist_session INTEGER NOT NULL DEFAULT 0",
[]
).map_err(|e| format!("Failed to add persist_session column: {}", e))?;
info!("[PlaybackPrefs] persist_session migration successful");
}
// Step 3c: Check if resume_playback_position column exists
let resume_position_exists: bool = conn
.query_row(
"SELECT COUNT(*) FROM pragma_table_info('playback_preferences') WHERE name='resume_playback_position'",
[],
|row| {
let count: i32 = row.get(0)?;
Ok(count > 0)
}
)
.unwrap_or(false);
if !resume_position_exists {
info!("[PlaybackPrefs] Migrating: adding resume_playback_position column");
conn.execute(
"ALTER TABLE playback_preferences ADD COLUMN resume_playback_position INTEGER NOT NULL DEFAULT 0",
[]
).map_err(|e| format!("Failed to add resume_playback_position column: {}", e))?;
info!("[PlaybackPrefs] resume_playback_position migration successful");
}
// Step 4: Insert default row if it doesn't exist
conn.execute(
"INSERT OR IGNORE INTO playback_preferences (id, autoplay_mode, show_context_icon, persist_session, resume_playback_position)
VALUES (1, 'continue', 0, 0, 0)",
[],
)
.map_err(|e| format!("Failed to insert default preferences: {}", 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, "playback_preferences.db")
}
pub fn new_at(base_dir: &Path) -> Result<Self, String> {
Self::open_at(base_dir, "playback_preferences.db")
}
pub fn get_preferences(&self) -> Result<PlaybackPreferences, String> {
self.conn
.query_row(
"SELECT autoplay_mode, show_context_icon, persist_session, resume_playback_position FROM playback_preferences WHERE id = 1",
[],
|row| {
let autoplay_str: String = row.get(0)?;
let show_icon: i32 = row.get(1)?;
let persist: i32 = row.get(2)?;
let resume_pos: i32 = row.get(3)?;
Ok(PlaybackPreferences {
autoplay_mode: AutoplayMode::from_db_value(&autoplay_str),
show_context_icon: show_icon != 0,
persist_session: persist != 0,
resume_playback_position: resume_pos != 0,
})
},
)
.map_err(|e| format!("Failed to get playback preferences: {}", e))
}
pub fn set_autoplay_mode(&self, mode: AutoplayMode) -> Result<(), String> {
self.conn
.execute(
"UPDATE playback_preferences SET autoplay_mode = ?1 WHERE id = 1",
params![mode.to_db_value()],
)
.map_err(|e| format!("Failed to set autoplay mode: {}", e))?;
Ok(())
}
pub fn set_show_context_icon(&self, show: bool) -> Result<(), String> {
self.conn
.execute(
"UPDATE playback_preferences SET show_context_icon = ?1 WHERE id = 1",
params![if show { 1 } else { 0 }],
)
.map_err(|e| format!("Failed to set show context icon: {}", e))?;
Ok(())
}
pub fn set_persist_session(&self, persist: bool) -> Result<(), String> {
self.conn
.execute(
"UPDATE playback_preferences SET persist_session = ?1 WHERE id = 1",
params![if persist { 1 } else { 0 }],
)
.map_err(|e| format!("Failed to set persist session: {}", e))?;
Ok(())
}
pub fn set_resume_playback_position(&self, resume: bool) -> Result<(), String> {
self.conn
.execute(
"UPDATE playback_preferences SET resume_playback_position = ?1 WHERE id = 1",
params![if resume { 1 } else { 0 }],
)
.map_err(|e| format!("Failed to set resume playback position: {}", e))?;
Ok(())
}
/// Reset all playback preferences to their default values
pub fn reset_all(&self) -> Result<PlaybackPreferences, String> {
let defaults = PlaybackPreferences::default();
self.conn
.execute(
"UPDATE playback_preferences SET autoplay_mode = ?1, show_context_icon = ?2, persist_session = ?3, resume_playback_position = ?4 WHERE id = 1",
params![
defaults.autoplay_mode.to_db_value(),
if defaults.show_context_icon { 1 } else { 0 },
if defaults.persist_session { 1 } else { 0 },
if defaults.resume_playback_position { 1 } else { 0 },
],
)
.map_err(|e| format!("Failed to reset playback preferences: {}", e))?;
Ok(defaults)
}
}
/// Global state wrapper for thread-safe access
pub struct PlaybackPreferencesState {
pub store: Arc<Mutex<Option<PlaybackPreferencesStore>>>,
}
impl PlaybackPreferencesState {
pub fn new() -> Result<Self, String> {
let store = PlaybackPreferencesStore::new()?;
Ok(Self {
store: Arc::new(Mutex::new(Some(store))),
})
}
pub fn new_empty() -> Self {
Self {
store: Arc::new(Mutex::new(None)),
}
}
pub fn init_at(&self, base_dir: &Path) -> Result<(), String> {
let new_store = PlaybackPreferencesStore::new_at(base_dir)?;
let mut guard = self
.store
.lock()
.map_err(|_| "Failed to lock playback preferences store".to_string())?;
*guard = Some(new_store);
Ok(())
}
pub fn teardown(&self) -> Result<(), String> {
let mut guard = self
.store
.lock()
.map_err(|_| "Failed to lock playback preferences store".to_string())?;
*guard = None;
Ok(())
}
pub fn get_preferences(&self) -> Result<PlaybackPreferences, String> {
let guard = self
.store
.lock()
.map_err(|_| "Failed to lock playback preferences store".to_string())?;
let store = guard.as_ref().ok_or("No active session - please log in")?;
store.get_preferences()
}
pub fn set_autoplay_mode(&self, mode: AutoplayMode) -> Result<(), String> {
let guard = self
.store
.lock()
.map_err(|_| "Failed to lock playback preferences store".to_string())?;
let store = guard.as_ref().ok_or("No active session - please log in")?;
store.set_autoplay_mode(mode)
}
pub fn set_show_context_icon(&self, show: bool) -> Result<(), String> {
let guard = self
.store
.lock()
.map_err(|_| "Failed to lock playback preferences store".to_string())?;
let store = guard.as_ref().ok_or("No active session - please log in")?;
store.set_show_context_icon(show)
}
pub fn set_persist_session(&self, persist: bool) -> Result<(), String> {
let guard = self
.store
.lock()
.map_err(|_| "Failed to lock playback preferences store".to_string())?;
let store = guard.as_ref().ok_or("No active session - please log in")?;
store.set_persist_session(persist)
}
pub fn set_resume_playback_position(&self, resume: bool) -> Result<(), String> {
let guard = self
.store
.lock()
.map_err(|_| "Failed to lock playback preferences store".to_string())?;
let store = guard.as_ref().ok_or("No active session - please log in")?;
store.set_resume_playback_position(resume)
}
}
// Tauri commands
#[tauri::command] #[tauri::command]
pub fn get_playback_preferences( pub fn get_playback_preferences(
state: tauri::State<PlaybackPreferencesState>, state: tauri::State<'_, PlaybackPreferencesState>,
) -> Result<PlaybackPreferences, String> { ) -> Result<PlaybackPreferences, String> {
state.get_preferences() state.get_preferences()
} }
@@ -366,7 +14,7 @@ pub fn get_playback_preferences(
#[tauri::command] #[tauri::command]
pub fn set_autoplay_mode( pub fn set_autoplay_mode(
mode: String, mode: String,
state: tauri::State<PlaybackPreferencesState>, state: tauri::State<'_, PlaybackPreferencesState>,
) -> Result<(), String> { ) -> Result<(), String> {
let autoplay_mode = match mode.as_str() { let autoplay_mode = match mode.as_str() {
"continue" => AutoplayMode::ContinueWithinSource, "continue" => AutoplayMode::ContinueWithinSource,
@@ -380,7 +28,7 @@ pub fn set_autoplay_mode(
#[tauri::command] #[tauri::command]
pub fn set_show_context_icon( pub fn set_show_context_icon(
show: bool, show: bool,
state: tauri::State<PlaybackPreferencesState>, state: tauri::State<'_, PlaybackPreferencesState>,
) -> Result<(), String> { ) -> Result<(), String> {
state.set_show_context_icon(show) state.set_show_context_icon(show)
} }
@@ -388,7 +36,7 @@ pub fn set_show_context_icon(
#[tauri::command] #[tauri::command]
pub fn set_persist_session( pub fn set_persist_session(
persist: bool, persist: bool,
state: tauri::State<PlaybackPreferencesState>, state: tauri::State<'_, PlaybackPreferencesState>,
) -> Result<(), String> { ) -> Result<(), String> {
state.set_persist_session(persist) state.set_persist_session(persist)
} }
@@ -396,7 +44,7 @@ pub fn set_persist_session(
#[tauri::command] #[tauri::command]
pub fn set_resume_playback_position( pub fn set_resume_playback_position(
resume: bool, resume: bool,
state: tauri::State<PlaybackPreferencesState>, state: tauri::State<'_, PlaybackPreferencesState>,
) -> Result<(), String> { ) -> Result<(), String> {
state.set_resume_playback_position(resume) state.set_resume_playback_position(resume)
} }
+47 -308
View File
@@ -1,308 +1,47 @@
//! Tray icon settings //! Tauri commands for tray settings.
//! //!
//! Stores user preferences for system tray behavior: //! Persisted tray preferences live in `qbz-app`. Runtime tray creation,
//! - enable_tray: Show tray icon (requires restart) //! icon updates, window behavior, and emitted events remain Tauri-owned.
//! - minimize_to_tray: Hide to tray when minimizing
//! - close_to_tray: Hide to tray when closing window use log::info;
use log::info; pub use qbz_app::settings::tray::{
use rusqlite::{params, Connection}; normalize_tray_icon_theme, TraySettings, TraySettingsState, TraySettingsStore,
use serde::{Deserialize, Serialize}; };
use std::path::Path;
use std::sync::{Arc, Mutex}; #[tauri::command]
pub fn get_tray_settings(
#[derive(Debug, Clone, Serialize, Deserialize)] state: tauri::State<'_, TraySettingsState>,
pub struct TraySettings { ) -> Result<TraySettings, String> {
/// Show system tray icon (requires restart to take effect) state.get_settings()
pub enable_tray: bool, }
/// Hide window to tray when clicking minimize
pub minimize_to_tray: bool, #[tauri::command]
/// Hide window to tray instead of quitting when clicking close pub fn set_enable_tray(
pub close_to_tray: bool, value: bool,
/// Tray icon variant override: state: tauri::State<'_, TraySettingsState>,
/// - "auto" (default; follows system color scheme), ) -> Result<(), String> {
/// - "mono-light" (light-coloured glyph for dark panels — useful info!(
/// on GNOME where the top bar is permanently dark), "[TraySettings] Setting enable_tray to {} (restart required)",
/// - "mono-dark" (dark-coloured glyph for light panels), value
/// - "color" (full colour vinyl logo). );
#[serde(default = "default_tray_icon_theme")] state.set_enable_tray(value)
pub tray_icon_theme: String, }
}
#[tauri::command]
fn default_tray_icon_theme() -> String { pub fn set_minimize_to_tray(
"auto".to_string() value: bool,
} state: tauri::State<'_, TraySettingsState>,
) -> Result<(), String> {
/// Coerce free-form values to the supported set. Anything outside the info!("[TraySettings] Setting minimize_to_tray to {}", value);
/// supported list falls back to "auto". state.set_minimize_to_tray(value)
/// }
/// Legacy 1.2.9-pre values "light"/"dark" had inverted semantics
/// relative to the labels users saw (picking "Light" got the dark #[tauri::command]
/// glyph and vice-versa). They're remapped here to the value that pub fn set_close_to_tray(
/// matches the user's original intent. value: bool,
pub fn normalize_tray_icon_theme(input: &str) -> String { state: tauri::State<'_, TraySettingsState>,
match input { ) -> Result<(), String> {
"mono-light" | "mono-dark" | "color" | "auto" => input.to_string(), info!("[TraySettings] Setting close_to_tray to {}", value);
"light" => "mono-light".to_string(), state.set_close_to_tray(value)
"dark" => "mono-dark".to_string(), }
_ => "auto".to_string(),
}
}
impl Default for TraySettings {
fn default() -> Self {
Self {
enable_tray: true,
minimize_to_tray: false,
close_to_tray: false,
tray_icon_theme: default_tray_icon_theme(),
}
}
}
pub struct TraySettingsStore {
conn: Connection,
}
impl TraySettingsStore {
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 tray settings database: {}", e))?;
conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;")
.map_err(|e| format!("Failed to enable WAL for tray settings database: {}", e))?;
// Create table
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS tray_settings (
id INTEGER PRIMARY KEY CHECK (id = 1),
enable_tray INTEGER NOT NULL DEFAULT 1,
minimize_to_tray INTEGER NOT NULL DEFAULT 0,
close_to_tray INTEGER NOT NULL DEFAULT 0
);",
)
.map_err(|e| format!("Failed to create tray settings table: {}", e))?;
// Migration: add tray_icon_theme column if it doesn't exist (older DBs).
let has_theme_column: i64 = conn
.query_row(
"SELECT COUNT(*) FROM pragma_table_info('tray_settings') WHERE name = 'tray_icon_theme'",
[],
|row| row.get(0),
)
.map_err(|e| format!("Failed to check tray_icon_theme column: {}", e))?;
if has_theme_column == 0 {
conn.execute_batch(
"ALTER TABLE tray_settings ADD COLUMN tray_icon_theme TEXT NOT NULL DEFAULT 'auto';",
)
.map_err(|e| format!("Failed to add tray_icon_theme column: {}", e))?;
}
// Insert default row if it doesn't exist
conn.execute(
"INSERT OR IGNORE INTO tray_settings (id, enable_tray, minimize_to_tray, close_to_tray, tray_icon_theme)
VALUES (1, 1, 0, 0, 'auto')",
[],
)
.map_err(|e| format!("Failed to insert default tray settings: {}", e))?;
info!("[TraySettings] 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, "tray_settings.db")
}
pub fn new_at(base_dir: &Path) -> Result<Self, String> {
Self::open_at(base_dir, "tray_settings.db")
}
pub fn get_settings(&self) -> Result<TraySettings, String> {
self.conn
.query_row(
"SELECT enable_tray, minimize_to_tray, close_to_tray, tray_icon_theme FROM tray_settings WHERE id = 1",
[],
|row| {
let enable_tray: i32 = row.get(0)?;
let minimize_to_tray: i32 = row.get(1)?;
let close_to_tray: i32 = row.get(2)?;
let tray_icon_theme: String = row.get(3)?;
Ok(TraySettings {
enable_tray: enable_tray != 0,
minimize_to_tray: minimize_to_tray != 0,
close_to_tray: close_to_tray != 0,
tray_icon_theme: normalize_tray_icon_theme(&tray_icon_theme),
})
},
)
.map_err(|e| format!("Failed to get tray settings: {}", e))
}
pub fn set_enable_tray(&self, value: bool) -> Result<(), String> {
self.conn
.execute(
"UPDATE tray_settings SET enable_tray = ?1 WHERE id = 1",
params![if value { 1 } else { 0 }],
)
.map_err(|e| format!("Failed to set enable_tray: {}", e))?;
Ok(())
}
pub fn set_minimize_to_tray(&self, value: bool) -> Result<(), String> {
self.conn
.execute(
"UPDATE tray_settings SET minimize_to_tray = ?1 WHERE id = 1",
params![if value { 1 } else { 0 }],
)
.map_err(|e| format!("Failed to set minimize_to_tray: {}", e))?;
Ok(())
}
pub fn set_close_to_tray(&self, value: bool) -> Result<(), String> {
self.conn
.execute(
"UPDATE tray_settings SET close_to_tray = ?1 WHERE id = 1",
params![if value { 1 } else { 0 }],
)
.map_err(|e| format!("Failed to set close_to_tray: {}", e))?;
Ok(())
}
pub fn set_tray_icon_theme(&self, value: &str) -> Result<(), String> {
let normalized = normalize_tray_icon_theme(value);
self.conn
.execute(
"UPDATE tray_settings SET tray_icon_theme = ?1 WHERE id = 1",
params![normalized],
)
.map_err(|e| format!("Failed to set tray_icon_theme: {}", e))?;
Ok(())
}
}
/// Global state wrapper for thread-safe access
pub struct TraySettingsState {
pub store: Arc<Mutex<Option<TraySettingsStore>>>,
}
impl TraySettingsState {
pub fn new() -> Result<Self, String> {
let store = TraySettingsStore::new()?;
Ok(Self {
store: Arc::new(Mutex::new(Some(store))),
})
}
pub fn new_empty() -> Self {
Self {
store: Arc::new(Mutex::new(None)),
}
}
pub fn init_at(&self, base_dir: &Path) -> Result<(), String> {
let new_store = TraySettingsStore::new_at(base_dir)?;
let mut guard = self
.store
.lock()
.map_err(|_| "Failed to lock tray settings store".to_string())?;
*guard = Some(new_store);
Ok(())
}
pub fn teardown(&self) -> Result<(), String> {
let mut guard = self
.store
.lock()
.map_err(|_| "Failed to lock tray settings store".to_string())?;
*guard = None;
Ok(())
}
pub fn get_settings(&self) -> Result<TraySettings, String> {
let guard = self
.store
.lock()
.map_err(|_| "Failed to lock tray settings store".to_string())?;
let store = guard.as_ref().ok_or("No active session - please log in")?;
store.get_settings()
}
pub fn set_enable_tray(&self, value: bool) -> Result<(), String> {
let guard = self
.store
.lock()
.map_err(|_| "Failed to lock tray settings store".to_string())?;
let store = guard.as_ref().ok_or("No active session - please log in")?;
store.set_enable_tray(value)
}
pub fn set_minimize_to_tray(&self, value: bool) -> Result<(), String> {
let guard = self
.store
.lock()
.map_err(|_| "Failed to lock tray settings store".to_string())?;
let store = guard.as_ref().ok_or("No active session - please log in")?;
store.set_minimize_to_tray(value)
}
pub fn set_close_to_tray(&self, value: bool) -> Result<(), String> {
let guard = self
.store
.lock()
.map_err(|_| "Failed to lock tray settings store".to_string())?;
let store = guard.as_ref().ok_or("No active session - please log in")?;
store.set_close_to_tray(value)
}
pub fn set_tray_icon_theme(&self, value: &str) -> Result<(), String> {
let guard = self
.store
.lock()
.map_err(|_| "Failed to lock tray settings store".to_string())?;
let store = guard.as_ref().ok_or("No active session - please log in")?;
store.set_tray_icon_theme(value)
}
}
// Tauri commands
#[tauri::command]
pub fn get_tray_settings(state: tauri::State<TraySettingsState>) -> Result<TraySettings, String> {
state.get_settings()
}
#[tauri::command]
pub fn set_enable_tray(value: bool, state: tauri::State<TraySettingsState>) -> Result<(), String> {
info!(
"[TraySettings] Setting enable_tray to {} (restart required)",
value
);
state.set_enable_tray(value)
}
#[tauri::command]
pub fn set_minimize_to_tray(
value: bool,
state: tauri::State<TraySettingsState>,
) -> Result<(), String> {
info!("[TraySettings] Setting minimize_to_tray to {}", value);
state.set_minimize_to_tray(value)
}
#[tauri::command]
pub fn set_close_to_tray(
value: bool,
state: tauri::State<TraySettingsState>,
) -> Result<(), String> {
info!("[TraySettings] Setting close_to_tray to {}", value);
state.set_close_to_tray(value)
}