//! Theseus settings file use serde::{Deserialize, Serialize}; use sqlx::{Pool, Sqlite}; use std::collections::HashMap; // Types /// Global Theseus settings #[derive(Serialize, Deserialize, Debug, Clone)] pub struct Settings { pub max_concurrent_downloads: usize, pub max_concurrent_writes: usize, pub theme: Theme, pub locale: String, pub default_page: DefaultPage, pub collapsed_navigation: bool, pub hide_nametag_skins_page: bool, pub advanced_rendering: bool, pub native_decorations: bool, pub toggle_sidebar: bool, pub telemetry: bool, pub discord_rpc: bool, pub personalized_ads: bool, pub onboarded: bool, pub extra_launch_args: Vec, pub custom_env_vars: Vec<(String, String)>, pub memory: MemorySettings, pub force_fullscreen: bool, pub game_resolution: WindowSize, pub hide_on_process_start: bool, pub hooks: Hooks, pub custom_dir: Option, pub prev_custom_dir: Option, pub migrated: bool, pub developer_mode: bool, pub feature_flags: HashMap, pub skipped_update: Option, pub pending_update_toast_for_version: Option, pub auto_download_updates: Option, pub version: usize, } #[derive(Serialize, Deserialize, Debug, Clone, Copy, Eq, Hash, PartialEq)] #[serde(rename_all = "snake_case")] pub enum FeatureFlag { PagePath, ProjectBackground, WorldsTab, WorldsInHome, } impl Settings { const CURRENT_VERSION: usize = 2; pub async fn get( exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite>, ) -> crate::Result { let res = sqlx::query!( " SELECT max_concurrent_writes, max_concurrent_downloads, theme, locale, default_page, collapsed_navigation, hide_nametag_skins_page, advanced_rendering, native_decorations, discord_rpc, developer_mode, telemetry, personalized_ads, onboarded, json(extra_launch_args) extra_launch_args, json(custom_env_vars) custom_env_vars, mc_memory_max, mc_force_fullscreen, mc_game_resolution_x, mc_game_resolution_y, hide_on_process_start, hook_pre_launch, hook_wrapper, hook_post_exit, custom_dir, prev_custom_dir, migrated, json(feature_flags) feature_flags, toggle_sidebar, skipped_update, pending_update_toast_for_version, auto_download_updates, version FROM settings " ) .fetch_one(exec) .await?; Ok(Self { max_concurrent_downloads: res.max_concurrent_downloads as usize, max_concurrent_writes: res.max_concurrent_writes as usize, theme: Theme::from_string(&res.theme), locale: res.locale, default_page: DefaultPage::from_string(&res.default_page), collapsed_navigation: res.collapsed_navigation == 1, hide_nametag_skins_page: res.hide_nametag_skins_page == 1, advanced_rendering: res.advanced_rendering == 1, native_decorations: res.native_decorations == 1, toggle_sidebar: res.toggle_sidebar == 1, telemetry: res.telemetry == 1, discord_rpc: res.discord_rpc == 1, developer_mode: res.developer_mode == 1, personalized_ads: res.personalized_ads == 1, onboarded: res.onboarded == 1, extra_launch_args: res .extra_launch_args .as_ref() .and_then(|x| serde_json::from_str(x).ok()) .unwrap_or_default(), custom_env_vars: res .custom_env_vars .as_ref() .and_then(|x| serde_json::from_str(x).ok()) .unwrap_or_default(), memory: MemorySettings { maximum: res.mc_memory_max as u32, }, force_fullscreen: res.mc_force_fullscreen == 1, game_resolution: WindowSize( res.mc_game_resolution_x as u16, res.mc_game_resolution_y as u16, ), hide_on_process_start: res.hide_on_process_start == 1, hooks: Hooks { pre_launch: res.hook_pre_launch, wrapper: res.hook_wrapper, post_exit: res.hook_post_exit, }, custom_dir: res.custom_dir, prev_custom_dir: res.prev_custom_dir, migrated: res.migrated == 1, feature_flags: res .feature_flags .as_ref() .and_then(|x| serde_json::from_str(x).ok()) .unwrap_or_default(), skipped_update: res.skipped_update, pending_update_toast_for_version: res .pending_update_toast_for_version, auto_download_updates: res.auto_download_updates.map(|x| x == 1), version: res.version as usize, }) } pub async fn update( &self, exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite>, ) -> crate::Result<()> { let max_concurrent_writes = self.max_concurrent_writes as i32; let max_concurrent_downloads = self.max_concurrent_downloads as i32; let theme = self.theme.as_str(); let default_page = self.default_page.as_str(); let extra_launch_args = serde_json::to_string(&self.extra_launch_args)?; let custom_env_vars = serde_json::to_string(&self.custom_env_vars)?; let feature_flags = serde_json::to_string(&self.feature_flags)?; let version = self.version as i64; sqlx::query!( " UPDATE settings SET max_concurrent_writes = $1, max_concurrent_downloads = $2, theme = $3, locale = $4, default_page = $5, collapsed_navigation = $6, advanced_rendering = $7, native_decorations = $8, discord_rpc = $9, developer_mode = $10, telemetry = $11, personalized_ads = $12, onboarded = $13, extra_launch_args = jsonb($14), custom_env_vars = jsonb($15), mc_memory_max = $16, mc_force_fullscreen = $17, mc_game_resolution_x = $18, mc_game_resolution_y = $19, hide_on_process_start = $20, hook_pre_launch = $21, hook_wrapper = $22, hook_post_exit = $23, custom_dir = $24, prev_custom_dir = $25, migrated = $26, toggle_sidebar = $27, feature_flags = $28, hide_nametag_skins_page = $29, skipped_update = $30, pending_update_toast_for_version = $31, auto_download_updates = $32, version = $33 ", max_concurrent_writes, max_concurrent_downloads, theme, self.locale, default_page, self.collapsed_navigation, self.advanced_rendering, self.native_decorations, self.discord_rpc, self.developer_mode, self.telemetry, self.personalized_ads, self.onboarded, extra_launch_args, custom_env_vars, self.memory.maximum, self.force_fullscreen, self.game_resolution.0, self.game_resolution.1, self.hide_on_process_start, self.hooks.pre_launch, self.hooks.wrapper, self.hooks.post_exit, self.custom_dir, self.prev_custom_dir, self.migrated, self.toggle_sidebar, feature_flags, self.hide_nametag_skins_page, self.skipped_update, self.pending_update_toast_for_version, self.auto_download_updates, version, ) .execute(exec) .await?; Ok(()) } pub async fn migrate(exec: &Pool) -> crate::Result<()> { let mut settings = Self::get(exec).await?; if settings.version < Settings::CURRENT_VERSION { tracing::info!( "Migrating settings version {} to {:?}", settings.version, Settings::CURRENT_VERSION ); } while settings.version < Settings::CURRENT_VERSION { if let Err(err) = settings.perform_migration() { tracing::error!( "Failed to migrate settings from version {}: {}", settings.version, err ); return Err(err); } } settings.update(exec).await?; Ok(()) } pub fn perform_migration(&mut self) -> crate::Result<()> { match self.version { 1 => { let quoter = shlex::Quoter::new().allow_nul(true); // Previously split by spaces if let Some(pre_launch) = self.hooks.pre_launch.as_ref() { self.hooks.pre_launch = Some(quoter.join(pre_launch.split(' ')).unwrap()) } // Previously treated as complete path to command if let Some(wrapper) = self.hooks.wrapper.as_ref() { self.hooks.wrapper = Some(quoter.quote(wrapper).unwrap().to_string()) } // Previously split by spaces if let Some(post_exit) = self.hooks.post_exit.as_ref() { self.hooks.post_exit = Some(quoter.join(post_exit.split(' ')).unwrap()) } self.version = 2; } version => { return Err(crate::ErrorKind::OtherError(format!( "Invalid settings version: {version}" )) .into()); } } Ok(()) } } /// Theseus theme #[derive(Debug, Clone, Copy, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum Theme { Dark, Light, Oled, System, } impl Theme { pub fn as_str(&self) -> &'static str { match self { Theme::Dark => "dark", Theme::Light => "light", Theme::Oled => "oled", Theme::System => "system", } } pub fn from_string(string: &str) -> Theme { match string { "dark" => Theme::Dark, "light" => Theme::Light, "oled" => Theme::Oled, "system" => Theme::System, _ => Theme::Dark, } } } /// Minecraft memory settings #[derive(Serialize, Deserialize, Debug, Clone, Copy)] pub struct MemorySettings { pub maximum: u32, } /// Game window size #[derive(Serialize, Deserialize, Debug, Clone, Copy)] pub struct WindowSize(pub u16, pub u16); /// Game initialization hooks #[derive(Serialize, Deserialize, Debug, Clone)] #[serde_with::serde_as] pub struct Hooks { #[serde_as(as = "serde_with::NoneAsEmptyString")] pub pre_launch: Option, #[serde_as(as = "serde_with::NoneAsEmptyString")] pub wrapper: Option, #[serde_as(as = "serde_with::NoneAsEmptyString")] pub post_exit: Option, } /// Opening window to start with #[derive(Serialize, Deserialize, Debug, Clone, Copy)] pub enum DefaultPage { Home, Library, } impl DefaultPage { pub fn as_str(&self) -> &'static str { match self { DefaultPage::Home => "home", DefaultPage::Library => "library", } } pub fn from_string(string: &str) -> Self { match string { "home" => Self::Home, "library" => Self::Library, _ => Self::Home, } } }