From a18af540d7b8481ff47943668af9b145c4e6e24e Mon Sep 17 00:00:00 2001 From: Benedikt Werner <1benediktwerner@gmail.com> Date: Thu, 17 Dec 2020 13:42:03 +0100 Subject: [PATCH] Version 0.1 --- Cargo.lock | 18 ++ Cargo.toml | 1 + README.md | 57 ++++++ TODO.md | 6 +- src/app.rs | 223 ++++++++++++++++++--- src/data.rs | 82 ++++++++ src/main.rs | 82 ++++++-- src/server.rs | 17 +- src/utils.rs | 9 +- svelte/package.json | 2 +- svelte/src/App.svelte | 53 +++-- svelte/src/data.d.ts | 7 + svelte/src/dialogs/AboutDialog.svelte | 15 ++ svelte/src/dialogs/AddGameDialog.svelte | 23 ++- svelte/src/dialogs/ManageGameDialog.svelte | 6 +- svelte/src/dialogs/SettingsDialog.svelte | 52 +++-- svelte/src/global.scss | 5 +- 17 files changed, 551 insertions(+), 107 deletions(-) create mode 100644 README.md diff --git a/Cargo.lock b/Cargo.lock index 44b166a..24686f0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2065,6 +2065,23 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "webbrowser" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecad156490d6b620308ed411cfee90d280b3cbd13e189ea0d3fada8acc89158a" +dependencies = [ + "web-sys", + "widestring", + "winapi 0.3.9", +] + +[[package]] +name = "widestring" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c168940144dd21fd8046987c16a46a33d5fc84eec29ef9dcddc2ac9e31526b7c" + [[package]] name = "winapi" version = "0.2.8" @@ -2150,6 +2167,7 @@ dependencies = [ "serde", "serde_json", "simplelog", + "webbrowser", "zip", ] diff --git a/Cargo.toml b/Cargo.toml index 383a6c4..472cda5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,6 +23,7 @@ semver = { version = "0.11", features = ["serde"] } blake2s_simd = "0.5" anyhow = "1.0" zip = "0.5" +webbrowser = "0.5" # [target.'cfg(windows)'.dependencies] # winreg = "0.8" diff --git a/README.md b/README.md new file mode 100644 index 0000000..5de014e --- /dev/null +++ b/README.md @@ -0,0 +1,57 @@ +# ytinu Mod Manager + +A Mod Manager for Unity Mods using the [BepInEx](https://github.com/BepInEx/BepInEx) Modding framework. + +## How to use + +1. Download the the correct ytinu version for your operating system: + - Windows: https://github.com/ytinu-mods/ytinu/releases/download/v0.1.0/ytinu.exe + - Linux: https://github.com/ytinu-mods/ytinu/releases/download/v0.1.0/ytinu + - MacOS: There currently aren't any pre-built executables for MacOS. Sorry! You can either compile ytinu yourself or install mods manually. +2. Run the downloaded executable. + +**Note:** For the basic setup ytinu requires you to have a working Chromium based browser like Google Chrome or the new Microsoft Edge installed. +On newer versions of Windows 10 the correct Microsoft Edge should be installed by default. For other systems see the section below. + +### Usage without a Chromium based browser + +If you don't have or want to install a Chromium browser you can also use ytinu with any other regular browser with ytinu acting as the web server. + +To do this you need to create or modify the configuration file +at `%appdata%\ytinu\config.json` (i.e. `C:\Users\\AppData\Roaming\ytinu\config.json`) on Windows +or at `$HOME/Library/Application Support/ytinu/config.json` on MacOS +or at `$HOME/.config/ytinu/config.json` on other Unix systems and add the following: + +```json +{ + "open_ui": "browser" +} +``` + +This will attempt to automatically open ytinu in your default browser on startup. + +Alternatively you can just launch the ytinu web server part, fixate the port on which it is opened and +use your own methods to view the UI. To do this change your configuration to this: + +```json +{ + "open_ui": "none", + "port": 1337 // Port on which ytinu should serve it's UI +} +``` + +If you launch ytinu with such a configuration you will also find an additional shutdown button in the menu bar to stop the ytinu server. +Remember that simply closing the browser window will do nothing to the program running in the background and ytinu currently only +refreshes mod metadata on startup. + +## Compile ytinu + +If you want to compile ytinu yourself, you need a decently up-to-date version of [Rust](https://rust-lang.org/) and [Node.js](https://nodejs.org/). + +After that, it's fairly simple: + +1. Build the frontend: + 1. Go into the `sevelte` directory + 2. Run `npm install` to install all dependencies + 3. Run `npm run build` +2. Build the executable with `cargo build --release` diff --git a/TODO.md b/TODO.md index 69052cc..46062d1 100644 --- a/TODO.md +++ b/TODO.md @@ -7,10 +7,8 @@ - Refresh info buttons (meta from inet and local mod loader state) - Differentiate between game-specific and general mods - Disable mods - -- Update ytinu -- open in browser - +- Support links + - https://github.com/electron/electron/issues/1344 - Auto-detect game location from registry - https://stackoverflow.com/questions/34090258/find-steam-games-folder - https://docs.rs/winreg/0.8.0/winreg/ diff --git a/src/app.rs b/src/app.rs index 897523e..d7ffa7d 100644 --- a/src/app.rs +++ b/src/app.rs @@ -2,15 +2,18 @@ use std::{ collections::HashMap, fs::File, path::PathBuf, + process::Command, sync::{Arc, Mutex}, }; -use alcro::dialog::{self, MessageBoxIcon, YesNo}; -use rouille::{RequestBody, Response}; +use alcro::dialog::{self, MessageBoxIcon, YesNo::*}; +use anyhow::Context; +use app_dirs::AppDataType; +use rouille::{Request, Response}; use semver::Version; use serde::de::DeserializeOwned; -use crate::data::*; +use crate::{data::*, ErrorExt, APP_VERSION}; pub static METADATA_URL: &str = "https://raw.githubusercontent.com/ytinu-mods/meta/master/meta.json"; @@ -36,11 +39,14 @@ pub struct App { data_path: PathBuf, metadata: Option, state: State, + config: Config, } impl App { - pub fn start() -> Arc> { - let data_path = crate::data_root_unwrap().join("data.json"); + pub fn start(ui_mode: Option) -> Arc> { + let data_path = crate::utils::app_dir(AppDataType::UserData) + .unwrap_or_die("Startup error: Failed to get data directory") + .join("data.json"); log::info!("Using data file at: '{}'", data_path.to_string_lossy()); let state: State = File::open(&data_path) @@ -60,28 +66,29 @@ impl App { }); let metadata = fetch_metadata(); + let mut config = Config::load(); + if let Some(ui_mode) = ui_mode { + config.open_ui = ui_mode; + } + let mut app = App { data_path, metadata, state, + config, }; - app.try_ensure_game_selected(); + if app.config.check_for_updates { + app.check_for_updates(); + } app.show_messages(); + app.try_ensure_game_selected(); app.store_state(); Arc::new(Mutex::new(app)) } - pub fn server_port(&self) -> u16 { - if cfg!(debug_assertions) { - 5001 - } else { - 0 - } - } - - pub fn handle(&mut self, path: &str, body: Option) -> Result { + pub fn handle(&mut self, path: &str, request: &Request) -> Result { match path { "find_game_directory" => Ok(Response::json(&self.metadata.as_ref().map(|meta| { meta.games @@ -90,16 +97,13 @@ impl App { }))), "browse_directory" => Ok(Response::json(&alcro::dialog::select_folder_dialog( "Browse directory", - self.state - .current_game() - .map(|g| g.install_path.as_str()) - .unwrap_or(""), + &request.get_param("path").unwrap_or_default(), ))), "update_install_path" => self - .update_install_path(parse_request_body(body)?) + .update_install_path(parse_request_body(request)?) .map(|()| Response::json(&true)), "add_game" => self - .add_game(parse_request_body(body)?) + .add_game(parse_request_body(request)?) .map(|()| Response::json(&true)), "state" => Ok(Response::json(&StateOut::new(&self.state))), "metadata" => Ok(Response::json( @@ -119,6 +123,21 @@ impl App { self.store_state(); Ok(Response::empty_204()) } + "update" => { + self.check_for_updates(); + Ok(Response::empty_204()) + } + "shutdown" => { + crate::server::stop(); + Ok(Response::empty_204()) + } + "get_config" => Ok(Response::json(&self.config)), + "set_config" => parse_request_body(request) + .map(|config| { + self.config = config; + self.config.store(); + }) + .map(|()| Response::json(&true)), _ => { if let Some(dir) = path.strip_prefix("open/") { self.state.open_dir(dir); @@ -159,6 +178,153 @@ impl App { } } + fn check_for_updates(&self) { + if let Some(meta) = self.metadata.as_ref() { + if meta.version > crate::APP_VERSION { + log::info!( + "Update available. Installed version: {}. Latest version: {}", + APP_VERSION, + meta.version + ); + let choice = dialog::message_box_yes_no( + "Update available", + &format!( + "A new version of ytinu is available:\n\n\ + Installed version: {}\n\ + Latest version: {}\n\ + \n\ + Do you want to update?", + APP_VERSION, meta.version + ), + MessageBoxIcon::Question, + No, + ); + + if choice == Yes { + match meta.downloads.get(std::env::consts::OS) { + Some(url) => { + if let Err(error) = self.install_update(url) { + crate::show_error(&format!( + "Failed to install update: {:#}", + error + )); + } + } + None => crate::show_error(&format!( + "No update url for OS: '{}'", + std::env::consts::OS + )), + } + } + return; + } + } + + self.remove_old_version(); + } + + fn install_update(&self, url: &str) -> anyhow::Result<()> { + let exe_path = + std::env::current_exe().context("Failed to get location of ytinu installation")?; + let exe_dir = exe_path + .parent() + .context("Failed to get directory of ytinu installation")?; + + let tmp_path_new = exe_dir.join("ytinu_new"); + crate::utils::download(url, &tmp_path_new)?; + + let tmp_path_old = exe_dir.join("ytinu_old"); + std::fs::rename(&exe_path, &tmp_path_old).context("Failed to remove current version")?; + + if let Err(error) = std::fs::rename(&tmp_path_new, &exe_path) { + log::error!( + "Failed to move new version into place: {}. Trying to restore current version.", + error + ); + dialog::message_box_ok( + "Error during update", + &format!( + "Failed to move new version into place: {}.\n\nTrying to restore current version.", + error + ), + MessageBoxIcon::Error + ); + if let Err(error) = std::fs::rename(&tmp_path_old, &exe_path) { + log::error!( + "Failed to restore current version: {}. The backuped version is located at: {}", + error, + &tmp_path_old.to_string_lossy() + ); + dialog::message_box_ok( + "Error during restore", + &format!( + "Failed to resture current version: {}\n\n\ + The backuped version is located at: {}\n\n\ + You can try to manually replace it with the downloaded latest version at {}.", + error, + tmp_path_old.to_string_lossy(), + tmp_path_new.to_string_lossy() + ), + MessageBoxIcon::Error, + ); + std::process::exit(-1); + } + if let Err(error) = std::fs::remove_file(&tmp_path_new) { + log::error!( + "Failed to remove downloaded new version at '{}': {}", + tmp_path_new.to_string_lossy(), + error + ); + } + } else { + #[cfg(not(windows))] + { + use std::os::unix::fs::PermissionsExt; + if let Err(error) = std::fs::metadata(&exe_path).and_then(|meta| { + let mut perms = meta.permissions(); + perms.set_mode(0o755); + std::fs::set_permissions(&exe_path, perms) + }) { + log::warn!("Sucessfully downloaded and replaced binary but failed to make it executable: {}", error); + dialog::message_box_ok("Update partially sucessful", + "Sucessfully downloaded and replaced ytinu but failed to make the new version executable. Please adjust the permissions manually and restart ytinu.", MessageBoxIcon::Info); + std::process::exit(-1); + } + } + log::info!("Successfully updated and replaced executable. Restarting..."); + if let Err(error) = Command::new(exe_path).args(std::env::args()).spawn() { + log::warn!("Failed to start new process: {}", error); + dialog::message_box_ok("Update sucessful but failed to restart", "The update was sucessfully installed but ytinu was not able to restart itself automatically. Please start it again manually.", MessageBoxIcon::Info); + } + std::process::exit(0); + } + + Ok(()) + } + + pub fn remove_old_version(&self) { + let run = || -> anyhow::Result<()> { + let path = std::env::current_exe() + .context("Failed to get location of ytinu installation")? + .parent() + .context("Failed to get directory of ytinu installation")? + .join("ytinu_old"); + + if path.is_file() { + log::info!( + "Found old leftover ytinu executable at '{}'. Removing...", + path.to_string_lossy() + ); + std::fs::remove_file(path).context("Failed to remove file")?; + } + Ok(()) + }; + log::info!("Checking for old leftover ytinu executables"); + if let Err(error) = run() { + log::warn!("Failed to check or remove old ytinu exexcutable: {}", error); + } + } + fn get_mod(&self, id: &str) -> Option<&Mod> { if let Some(m) = self.metadata.as_ref()?.mods.get(id) { return Some(m); @@ -341,9 +507,9 @@ impl App { "Failed to backup data.json", "Failed to backup data.json. Do you want to try and overwrite it anyway?", MessageBoxIcon::Error, - YesNo::No, + No, ); - if choice == YesNo::No { + if choice == No { log::info!("User chose to NOT overwrite data.json"); return Err(()); } @@ -355,16 +521,23 @@ impl App { } Ok(None) } + + pub fn config(&self) -> &Config { + &self.config + } } impl Drop for App { fn drop(&mut self) { self.store_state(); + self.config.store(); } } -fn parse_request_body(body: Option) -> Result { - let body = body.ok_or_else(|| "Missing Request body".to_string())?; +fn parse_request_body(request: &Request) -> Result { + let body = request + .data() + .ok_or_else(|| "Missing Request body".to_string())?; serde_json::from_reader(body).map_err(|e| format!("Failed to parse request body: {}", e)) } diff --git a/src/data.rs b/src/data.rs index 1726844..13047bd 100644 --- a/src/data.rs +++ b/src/data.rs @@ -1,10 +1,12 @@ use std::{ collections::{HashMap, HashSet}, + fs::File, path::{Path, PathBuf}, }; use alcro::dialog::{self, MessageBoxIcon, YesNo::*}; use anyhow::{bail, ensure, Context}; +use app_dirs::AppDataType; use semver::Version; use serde::{Deserialize, Serialize}; @@ -354,6 +356,7 @@ pub struct InstalledMod { #[derive(Deserialize, Debug, Clone)] pub struct Metadata { pub version: semver::Version, + pub downloads: HashMap, pub messages: Vec, pub games: HashMap, pub game_mods: HashMap>, @@ -364,6 +367,7 @@ impl From for Metadata { fn from(meta: MetadataIn) -> Self { Self { version: meta.version, + downloads: meta.downloads, messages: meta.messages, games: meta.games.into_iter().map(|g| (g.id.clone(), g)).collect(), mods: meta.mods.into_iter().map(|m| (m.id.clone(), m)).collect(), @@ -396,6 +400,7 @@ impl MetadataOut { #[derive(Deserialize, Debug, Clone)] pub struct MetadataIn { version: semver::Version, + downloads: HashMap, messages: Vec, games: Vec, mods: Vec, @@ -506,3 +511,80 @@ impl From for HashMap { .collect() } } + +#[derive(Serialize, Deserialize, Debug, Clone)] +#[serde(default)] +pub struct Config { + pub dark_mode: DarkMode, + pub show_dev_mods: bool, + pub port: u16, + pub check_for_updates: bool, + pub open_ui: OpenUIConfig, +} + +impl Config { + pub fn load() -> Self { + match Self::load_impl() { + Ok(config) => config, + Err(error) => { + crate::show_error(&format!("Failed to load config file: {:#}", error)); + Self::default() + } + } + } + + fn path() -> PathBuf { + crate::utils::app_dir(AppDataType::UserConfig) + .unwrap_or_die("Startup error: Failed to get config directory") + .join("config.json") + } + + fn load_impl() -> anyhow::Result { + let path = Config::path(); + if path.is_file() { + let file = File::open(path).context("Failed to open config file")?; + serde_json::from_reader(file).context("Failed to deserialize config file") + } else { + Ok(Self::default()) + } + } + + pub fn store(&self) { + if let Err(error) = self.store_impl() { + crate::show_error(&format!("Failed to save config file: {:#}", error)); + } + } + + fn store_impl(&self) -> anyhow::Result<()> { + let file = File::create(Config::path()).context("Failed to create config file")?; + serde_json::to_writer(file, self).context("Failed to serialize config file") + } +} + +impl Default for Config { + fn default() -> Self { + Self { + dark_mode: DarkMode::System, + show_dev_mods: false, + port: 0, + check_for_updates: true, + open_ui: OpenUIConfig::Chromium, + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy)] +#[serde(rename_all = "lowercase")] +pub enum DarkMode { + System, + Dark, + Light, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy)] +#[serde(rename_all = "lowercase")] +pub enum OpenUIConfig { + Chromium, + Browser, + None, +} diff --git a/src/main.rs b/src/main.rs index bf0e28d..403cf9a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,8 +1,15 @@ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] -use std::{fs::File, path::PathBuf}; +use std::{ + fs::File, + path::PathBuf, + sync::{Arc, Mutex}, +}; use alcro::{dialog, Content, UIBuilder}; +use app_dirs::AppDataType; +use data::OpenUIConfig; +use server::ServerHandle; use simplelog::{ CombinedLogger, Config, LevelFilter, SharedLogger, TermLogger, TerminalMode, WriteLogger, }; @@ -13,7 +20,7 @@ mod server; mod utils; pub use app::App; -pub use utils::{data_root, data_root_unwrap, show_error, ErrorExt}; +pub use utils::{show_error, ErrorExt}; static APP_VERSION: semver::Version = semver::Version { major: 0, @@ -43,7 +50,7 @@ fn setup_logging() { TerminalMode::Mixed, )]; let mut error = None; - let log_file = match data_root() { + let log_file = match utils::app_dir(AppDataType::UserData) { Ok(file) => file.join("ytinu.log"), Err(err) => { error = Some(err); @@ -65,28 +72,69 @@ fn setup_logging() { } } +fn launch_ui(app: Arc>, server_handle: ServerHandle, port: u16) { + let ui_mode = app + .lock() + .unwrap_or_die("App::lock() failed") + .config() + .open_ui; + + match ui_mode { + OpenUIConfig::Chromium => { + let ui = UIBuilder::new() + .content(Content::Url(&format!("http://127.0.0.1:{}/", port))) + .size(1200, 720) + .run() + .unwrap_or_die("Startup Error on UIBuilder::run"); + + ui.wait_finish(); + log::info!("UI closed. Waiting for server to stop..."); + server::stop(); + } + OpenUIConfig::Browser => { + if let Err(error) = webbrowser::open(&format!("http://127.0.0.1:{}/", port)) { + crate::show_error(&format!("Failed to launch browser: {}", error)); + } + } + OpenUIConfig::None => (), + } + + server_handle.join() +} + fn main() { setup_panic_hook(); setup_logging(); - let app = App::start(); - let (server_handle, port) = server::start(app); + let ui_mode = parse_args(); + + let app = App::start(ui_mode); + let (server_handle, port) = server::start(Arc::clone(&app)); log::info!("Started server on localhost:{}", port); - let no_ui = std::env::args().any(|a| a.as_str() == "--no-ui"); + launch_ui(Arc::clone(&app), server_handle, port); + + let app = app.lock(); + if let Ok(app) = app { + app.remove_old_version(); + } +} - if no_ui { - server_handle.join(); +fn parse_args() -> Option { + let mut args_iter = std::env::args(); + if args_iter.any(|a| a.as_str() == "--ui") { + let mode = match args_iter.next().as_deref() { + Some("chromium") => OpenUIConfig::Chromium, + Some("browser") => OpenUIConfig::Browser, + Some("none") => OpenUIConfig::None, + _ => { + crate::show_error("Invalid arguments. Usage: ytinu [--ui chromium|browser|none]"); + std::process::exit(-1); + } + }; + Some(mode) } else { - let ui = UIBuilder::new() - .content(Content::Url(&format!("http://127.0.0.1:{}/", port))) - .size(1200, 720) - .run() - .unwrap_or_die("Startup Error on UIBuilder::run"); - - ui.wait_finish(); - log::info!("UI closed. Waiting for server to stop..."); - server_handle.stop(); + None } } diff --git a/src/server.rs b/src/server.rs index 3301d6a..5a3d2bb 100644 --- a/src/server.rs +++ b/src/server.rs @@ -18,7 +18,11 @@ static STOP_SERVER: AtomicBool = AtomicBool::new(false); struct Asset; fn run(app: Arc>, port_tx: std::sync::mpsc::Sender) { - let port = app.lock().unwrap_or_die("App::lock() failed").server_port(); + let port = if cfg!(debug_assertions) { + 5001 + } else { + app.lock().unwrap_or_die("App::lock() failed").config().port + }; let server = Server::new(("127.0.0.1", port), move |request| { let path = request.url(); @@ -28,7 +32,7 @@ fn run(app: Arc>, port_tx: std::sync::mpsc::Sender) { "index.html" } else if let Some(path) = path.strip_prefix("/api/") { let mut app = app.lock().unwrap_or_die("App::lock() failed"); - match app.handle(path, request.data()) { + match app.handle(path, request) { Ok(response) => return add_cors(response), Err(error) => return add_cors(Response::json(&APIErrorResponse::new(error))), } @@ -81,12 +85,11 @@ pub fn start(app: Arc>) -> (ServerHandle, u16) { (ServerHandle(handle), port) } -impl ServerHandle { - pub fn stop(self: ServerHandle) { - STOP_SERVER.store(true, Ordering::SeqCst); - self.join(); - } +pub fn stop() { + STOP_SERVER.store(true, Ordering::SeqCst); +} +impl ServerHandle { pub fn join(self: ServerHandle) { if let Err(error) = self.0.join() { log::error!("Error while joining server thread: {:?}", error); diff --git a/src/utils.rs b/src/utils.rs index bb722e9..07aef86 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -7,6 +7,7 @@ use std::{ use alcro::dialog; use anyhow::{bail, Context}; +use app_dirs::AppDataType; pub trait ErrorExt { type R; @@ -44,18 +45,14 @@ pub fn show_error(msg: &str) { dialog::message_box_ok("Error", msg, dialog::MessageBoxIcon::Error); } -pub fn data_root() -> Result { - app_dirs::data_root(app_dirs::AppDataType::UserData).and_then(|path| { +pub fn app_dir(dir_type: AppDataType) -> Result { + app_dirs::data_root(dir_type).and_then(|path| { let path = path.join("ytinu"); std::fs::create_dir_all(&path)?; Ok(path) }) } -pub fn data_root_unwrap() -> PathBuf { - data_root().unwrap_or_die("Startup error: Failed to get data path") -} - pub fn checksum(path: &Path) -> Result { let mut bytes = Vec::new(); File::open(path)?.read_to_end(&mut bytes)?; diff --git a/svelte/package.json b/svelte/package.json index b212233..3faa9e2 100644 --- a/svelte/package.json +++ b/svelte/package.json @@ -1,6 +1,6 @@ { "name": "ytinu", - "version": "1.0.0", + "version": "0.1.0", "scripts": { "build": "rollup -c", "dev": "HOST= rollup -c -w", diff --git a/svelte/src/App.svelte b/svelte/src/App.svelte index 4ad2bd3..6ce6f33 100644 --- a/svelte/src/App.svelte +++ b/svelte/src/App.svelte @@ -13,10 +13,7 @@ let selectedGameId = null; let selectedGame: SetupGame = null; let os = null; - let settings: { - dark_mode?: "system" | "dark" | "light"; - show_dev_mods?: boolean; - } = {}; + let settings: Config = null; let installed_mods: InstalledMod[] = []; let recommended_mods: Mod[] = []; let available_mods: Mod[] = []; @@ -96,22 +93,29 @@ } meta = r as Metadata; updateModList(); - // TODO: Use meta.update }); } function loadSettings() { - settings = JSON.parse(localStorage.getItem("settings")) || {}; - - if ( - settings?.dark_mode === "dark" || - (settings?.dark_mode !== "light" && - window.matchMedia?.("(prefers-color-scheme: dark)").matches) - ) { - document.documentElement.setAttribute("data-theme", "dark"); - } else { - document.documentElement.setAttribute("data-theme", "light"); - } + fetch(API_BASE + "get_config") + .then((r) => r.json()) + .then((r) => { + if (r === null || r.error !== undefined) return; + settings = r; + if ( + settings?.dark_mode === "dark" || + (settings?.dark_mode !== "light" && + window.matchMedia?.("(prefers-color-scheme: dark)") + .matches) + ) { + document.documentElement.setAttribute("data-theme", "dark"); + } else { + document.documentElement.setAttribute( + "data-theme", + "light" + ); + } + }); } function handleClickInstall() { @@ -137,6 +141,10 @@ function uninstallMod(id: string) { fetch(API_BASE + "remove_mod/" + id).then(() => fetchState()); } + + function shutdown() { + fetch(API_BASE + "shutdown").then(() => window.close()); + }