Settings System

Complete guide to the settings management system in DesQTA

Settings System Overview

DesQTA uses a comprehensive settings system that stores user preferences locally and optionally syncs them to BetterSEQTA Cloud. Settings are managed by the Rust backend and exposed via Tauri commands.

Settings Structure

Settings Data Model

// src-tauri/src/utils/settings.rs

#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct Settings {
    // Dashboard
    pub shortcuts: Vec<Shortcut>,
    pub feeds: Vec<Feed>,
    
    // Appearance
    pub accent_color: String,
    pub theme: String,
    pub current_theme: Option<String>,
    pub enhanced_animations: bool,
    pub disable_school_picture: bool,
    
    // Features
    pub weather_enabled: bool,
    pub weather_city: String,
    pub weather_country: String,
    pub force_use_location: bool,
    pub reminders_enabled: bool,
    
    // AI Integrations
    pub gemini_api_key: Option<String>,
    pub ai_integrations_enabled: Option<bool>,
    pub grade_analyser_enabled: Option<bool>,
    pub lesson_summary_analyser_enabled: Option<bool>,
    
    // UI Behavior
    pub auto_collapse_sidebar: bool,
    pub auto_expand_sidebar_hover: bool,
    pub global_search_enabled: bool,
    
    // Developer
    pub dev_sensitive_info_hider: bool,
    pub dev_force_offline_mode: bool,
    
    // Cloud
    pub accepted_cloud_eula: bool,
    
    // Localization
    pub language: String,
    
    // Navigation
    #[serde(default)]
    pub menu_order: Option<Vec<String>>,
    
    // Onboarding
    #[serde(default)]
    pub has_been_through_onboarding: bool,
}

#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct Shortcut {
    pub name: String,
    pub icon: String,
    pub url: String,
}

#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct Feed {
    pub url: String,
}

Settings Storage

File Location

Settings are stored per profile:

$DATA_DIR/DesQTA/profiles/{profile_id}/settings.json

Loading Settings

impl Settings {
    pub fn load() -> Self {
        let path = settings_file();
        
        if let Ok(mut file) = fs::File::open(&path) {
            let mut contents = String::new();
            if file.read_to_string(&mut contents).is_ok() {
                if let Ok(settings) = serde_json::from_str::<Settings>(&contents) {
                    return settings;
                }
            }
        }
        
        Settings::default()
    }
    
    pub fn save(&self) -> io::Result<()> {
        let path = settings_file();
        
        // Create parent directory if needed
        if let Some(parent) = path.parent() {
            fs::create_dir_all(parent)?;
        }
        
        let json = serde_json::to_string_pretty(self)
            .map_err(|e| io::Error::new(io::ErrorKind::Other, e))?;
        
        fs::write(&path, json)
    }
}

Tauri Commands

Get All Settings

#[tauri::command]
pub fn get_settings() -> Result<Settings, String> {
    Ok(Settings::load())
}

Get Settings Subset

#[tauri::command]
pub fn get_settings_subset(
    keys: Vec<String>
) -> Result<HashMap<String, serde_json::Value>, String> {
    let settings = Settings::load();
    let mut subset = HashMap::new();
    
    for key in keys {
        match key.as_str() {
            "shortcuts" => subset.insert("shortcuts".to_string(), json!(settings.shortcuts)),
            "feeds" => subset.insert("feeds".to_string(), json!(settings.feeds)),
            "weather_enabled" => subset.insert("weather_enabled".to_string(), json!(settings.weather_enabled)),
            "accent_color" => subset.insert("accent_color".to_string(), json!(settings.accent_color)),
            "theme" => subset.insert("theme".to_string(), json!(settings.theme)),
            // ... more keys
            _ => {}
        }
    }
    
    Ok(subset)
}

Save Settings

#[tauri::command]
pub fn save_settings(settings: Settings) -> Result<(), String> {
    settings.save()
        .map_err(|e| format!("Failed to save settings: {}", e))
}

Update Settings (Partial)

#[tauri::command]
pub fn update_settings(
    updates: HashMap<String, serde_json::Value>
) -> Result<(), String> {
    let mut settings = Settings::load();
    
    // Update fields from HashMap
    for (key, value) in updates {
        match key.as_str() {
            "accent_color" => {
                if let Some(color) = value.as_str() {
                    settings.accent_color = color.to_string();
                }
            }
            "theme" => {
                if let Some(theme) = value.as_str() {
                    settings.theme = theme.to_string();
                }
            }
            "shortcuts" => {
                if let Ok(shortcuts) = serde_json::from_value::<Vec<Shortcut>>(value) {
                    settings.shortcuts = shortcuts;
                }
            }
            // ... more fields
            _ => {}
        }
    }
    
    settings.save()
        .map_err(|e| format!("Failed to save settings: {}", e))
}

Frontend Integration

Loading Settings

import { invoke } from '@tauri-apps/api/core';

// Load all settings
const settings = await invoke<Settings>('get_settings');

// Load subset (more efficient)
const subset = await invoke<Record<string, any>>('get_settings_subset', {
  keys: ['accent_color', 'theme', 'shortcuts']
});

Saving Settings

// Save all settings
await invoke('save_settings', { settings });

// Update partial settings
await invoke('update_settings', {
  updates: {
    accent_color: '#ff0000',
    theme: 'dark'
  }
});

Settings Sync Service

Frontend Sync Service

// src/lib/services/settingsSync.ts

interface SettingsQueue {
  [key: string]: any;
}

let settingsQueue: SettingsQueue = {};
let syncTimeout: ReturnType<typeof setTimeout> | null = null;

export async function saveSettingsWithQueue(
  patch: Record<string, any>
): Promise<void> {
  // Add to queue
  Object.assign(settingsQueue, patch);
  
  // Debounce saves
  if (syncTimeout) {
    clearTimeout(syncTimeout);
  }
  
  syncTimeout = setTimeout(async () => {
    await flushSettingsQueue();
  }, 500); // Wait 500ms for more changes
}

export async function flushSettingsQueue(): Promise<void> {
  if (Object.keys(settingsQueue).length === 0) return;
  
  const updates = { ...settingsQueue };
  settingsQueue = {};
  
  try {
    // Save to backend
    await invoke('update_settings', { updates });
    
    // Sync to cloud if enabled
    const cloudUser = await cloudAuthService.init();
    if (cloudUser) {
      await cloudSettingsService.sync(updates);
    }
  } catch (error) {
    logger.error('settingsSync', 'flushSettingsQueue', `Failed: ${error}`, { error });
  }
}

Cloud Synchronization

Cloud Settings Service

// src/lib/services/cloudSettingsService.ts

export class CloudSettingsService {
  private baseUrl = 'https://accounts.betterseqta.org/api';
  
  async sync(settings: Record<string, any>): Promise<void> {
    const token = await this.getToken();
    if (!token) return;
    
    await fetch(`${this.baseUrl}/settings/sync`, {
      method: 'POST',
      headers: {
        'Authorization': `Bearer ${token}`,
        'Content-Type': 'application/json'
      },
      body: JSON.stringify({ settings })
    });
  }
  
  async load(): Promise<Record<string, any> | null> {
    const token = await this.getToken();
    if (!token) return null;
    
    const response = await fetch(`${this.baseUrl}/settings`, {
      headers: {
        'Authorization': `Bearer ${token}`
      }
    });
    
    return await response.json();
  }
}

export const cloudSettingsService = new CloudSettingsService();

Settings Defaults

Default Values

impl Default for Settings {
    fn default() -> Self {
        Self {
            shortcuts: Vec::new(),
            feeds: Vec::new(),
            weather_enabled: false,
            weather_city: String::new(),
            weather_country: String::new(),
            reminders_enabled: true,
            force_use_location: false,
            accent_color: "#3b82f6".to_string(), // Blue-500
            theme: "dark".to_string(),
            current_theme: Some("default".to_string()),
            disable_school_picture: false,
            enhanced_animations: true,
            gemini_api_key: None,
            ai_integrations_enabled: Some(false),
            grade_analyser_enabled: Some(true),
            lesson_summary_analyser_enabled: Some(true),
            auto_collapse_sidebar: false,
            auto_expand_sidebar_hover: false,
            global_search_enabled: false,
            dev_sensitive_info_hider: false,
            dev_force_offline_mode: false,
            accepted_cloud_eula: false,
            language: "en".to_string(),
            menu_order: None,
            has_been_through_onboarding: false,
        }
    }
}

Settings Usage Patterns

Pattern 1: Load on Mount

<script lang="ts">
  import { onMount } from 'svelte';
  import { invoke } from '@tauri-apps/api/core';
  
  let accentColor = $state('#3b82f6');
  let theme = $state<'light' | 'dark' | 'system'>('system');
  
  onMount(async () => {
    const subset = await invoke('get_settings_subset', {
      keys: ['accent_color', 'theme']
    });
    
    accentColor = subset.accent_color || '#3b82f6';
    theme = subset.theme || 'system';
  });
</script>

Pattern 2: Reactive Settings

<script lang="ts">
  import { invoke } from '@tauri-apps/api/core';
  import { saveSettingsWithQueue } from '$lib/services/settingsSync';
  
  let shortcuts = $state<Shortcut[]>([]);
  
  async function loadShortcuts() {
    const subset = await invoke('get_settings_subset', {
      keys: ['shortcuts']
    });
    shortcuts = subset.shortcuts || [];
  }
  
  async function saveShortcuts() {
    await saveSettingsWithQueue({ shortcuts });
  }
  
  function addShortcut(shortcut: Shortcut) {
    shortcuts = [...shortcuts, shortcut];
    saveShortcuts();
  }
</script>

Pattern 3: Settings Store

// src/lib/stores/settings.ts
import { writable } from 'svelte/store';
import { invoke } from '@tauri-apps/api/core';

export const settings = writable<Partial<Settings>>({});

export async function loadSettings() {
  const loaded = await invoke<Settings>('get_settings');
  settings.set(loaded);
}

export async function updateSetting(key: string, value: any) {
  settings.update(s => ({ ...s, [key]: value }));
  await invoke('update_settings', { updates: { [key]: value } });
}

Settings Validation

Type Validation

impl Settings {
    pub fn validate(&self) -> Result<(), Vec<String>> {
        let mut errors = Vec::new();
        
        // Validate accent color format
        if !self.accent_color.starts_with('#') || self.accent_color.len() != 7 {
            errors.push("Invalid accent color format".to_string());
        }
        
        // Validate theme
        if !["light", "dark", "system"].contains(&self.theme.as_str()) {
            errors.push("Invalid theme value".to_string());
        }
        
        // Validate shortcuts
        for shortcut in &self.shortcuts {
            if shortcut.name.is_empty() {
                errors.push("Shortcut name cannot be empty".to_string());
            }
            if !shortcut.url.starts_with("http://") && !shortcut.url.starts_with("https://") {
                errors.push(format!("Invalid shortcut URL: {}", shortcut.url));
            }
        }
        
        if errors.is_empty() {
            Ok(())
        } else {
            Err(errors)
        }
    }
}

Settings Migration

Version-Based Migration

#[derive(Debug, Serialize, Deserialize)]
struct SettingsV1 {
    accent_color: String,
    theme: String,
}

#[derive(Debug, Serialize, Deserialize)]
struct SettingsV2 {
    accent_color: String,
    theme: String,
    current_theme: Option<String>, // New field
}

impl Settings {
    pub fn migrate() -> Self {
        let path = settings_file();
        let contents = fs::read_to_string(&path).ok()?;
        
        // Try to detect version
        if contents.contains("current_theme") {
            // Already V2
            serde_json::from_str(&contents).ok()
        } else {
            // V1, migrate
            let v1: SettingsV1 = serde_json::from_str(&contents).ok()?;
            Some(Settings {
                accent_color: v1.accent_color,
                theme: v1.theme,
                current_theme: Some("default".to_string()),
                ..Settings::default()
            })
        }
    }
}

Best Practices

1. Use Subset Loading

// ✅ Good - Load only what you need
const subset = await invoke('get_settings_subset', {
  keys: ['accent_color', 'theme']
});

// ❌ Avoid - Loading all settings
const allSettings = await invoke('get_settings');

2. Debounce Saves

// ✅ Good - Debounced saves
await saveSettingsWithQueue({ accent_color: color });

// ❌ Avoid - Immediate saves for every change
await invoke('update_settings', { updates: { accent_color: color } });

3. Validate Before Save

// ✅ Good - Validate before saving
function updateAccentColor(color: string) {
  if (!/^#[0-9A-F]{6}$/i.test(color)) {
    throw new Error('Invalid color format');
  }
  saveSettingsWithQueue({ accent_color: color });
}

Next Steps