use crate::config::ConfigError; use hardware_id::get_id; use reqwest::Client; use serde::{Deserialize, Serialize}; use serde_json::Value; use std::collections::HashSet; use std::io::Read; use std::path::{Path, PathBuf}; #[cfg(not(target_os = "macos"))] use std::process::Stdio; use sysinfo::System; use tauri::AppHandle; use tauri::Emitter; use tauri::Manager; use tauri::State; use tauri::WebviewUrl; use tauri::WebviewWindowBuilder; use tauri::WindowEvent; use tauri_plugin_fs::FsExt; use tokio::fs; use tokio::io::AsyncWriteExt; #[cfg(not(target_os = "macos"))] use tokio::process::Command; #[cfg(not(target_os = "macos"))] use tokio::time::{Duration, sleep}; use crate::logging; use crate::osudb::parse_osudb; use crate::presence; use crate::state::AppState; #[cfg(all(not(windows), not(target_os = "macos")))] use crate::utils::get_osuwinello_path; use crate::utils::{ encrypt_password, get_osu_config, get_osu_user_config, is_net8_installed, is_osu_process, is_osuwinello_available, is_wmctrl_available, set_osu_config_vals, set_osu_user_config_vals, }; #[cfg(not(target_os = "macos"))] use crate::utils::{get_window_title_by_pid, is_osu_game_process, is_valid_osu_folder}; #[tauri::command] pub fn opened_urls(app: tauri::AppHandle) -> Vec { let args = app.state::().opened_files.lock().unwrap().clone(); app.state::().opened_files.lock().unwrap().clear(); args } #[tauri::command] pub fn get_launcher_version(app: tauri::AppHandle) -> String { app.package_info().version.to_string() } /// Forward a webview console message to the launcher log file. #[tauri::command] pub fn log_frontend(level: String, message: String) { let message: String = message.chars().take(16_000).collect(); match level.as_str() { "error" => log::error!(target: "launcher", "[webview] {message}"), "warn" => log::warn!(target: "launcher", "[webview] {message}"), "debug" => log::debug!(target: "launcher", "[webview] {message}"), "trace" => log::trace!(target: "launcher", "[webview] {message}"), _ => log::info!(target: "launcher", "[webview] {message}"), } } #[tauri::command] pub fn set_logging_enabled(enabled: bool) { logging::set_logging_enabled(enabled); } #[tauri::command] pub fn open_logs_folder() -> Result<(), String> { let dir = logging::logs_directory(); std::fs::create_dir_all(&dir) .map_err(|error| format!("Failed to create the logs folder: {error}"))?; open::that(&dir).map_err(|error| format!("Failed to open the logs folder: {error}")) } /// Return the byte size of a regular file. Used by the theme editor to /// validate and size picked assets without granting the webview broad fs reads. #[tauri::command] pub fn file_size(path: String) -> Option { let metadata = std::fs::metadata(path).ok()?; metadata.is_file().then_some(metadata.len()) } #[tauri::command] pub fn get_hwid() -> String { get_id() .unwrap_or("recorderinsandybridge".to_string()) .into() } /// Show the native macOS picker, which can select `osu!.app` bundles as files. /// The regular Tauri dialog opens app bundles as folders on macOS. #[tauri::command] pub async fn pick_osu_installation(app: AppHandle) -> Result, String> { #[cfg(target_os = "macos")] { let (tx, rx) = std::sync::mpsc::channel(); app.run_on_main_thread(move || { let _ = tx.send(crate::macos::pick_osu_installation()); }) .map_err(|error| format!("Could not open the osu! picker: {error}"))?; let picked = tauri::async_runtime::spawn_blocking(move || rx.recv()) .await .map_err(|error| format!("Could not open the osu! picker: {error}"))?; picked .map_err(|_| "The osu! picker closed unexpectedly".to_string())? .map(|path| path.map(|path| path.to_string_lossy().into_owned())) } #[cfg(not(target_os = "macos"))] { let _ = app; Ok(None) } } #[tauri::command(rename_all = "snake_case")] pub fn valid_osu_folder(folder: String) -> bool { normalized_osu_folder(&folder).is_some() } /// Resolve a selected path (osu! folder, `.app` wrapper, Wine prefix or drive_c) /// to the actual osu! installation folder. Returns `None` if it is not valid. #[tauri::command(rename_all = "snake_case")] pub fn resolve_osu_folder(folder: String) -> Option { normalized_osu_folder(&folder).map(|path| path.to_string_lossy().into_owned()) } #[cfg(target_os = "macos")] fn normalized_osu_folder(folder: &str) -> Option { crate::macos::resolve_osu_folder(Path::new(folder)) } #[cfg(not(target_os = "macos"))] fn normalized_osu_folder(folder: &str) -> Option { let path = PathBuf::from(folder); is_valid_osu_folder(&path).then_some(path) } #[derive(Serialize, Clone)] #[serde(rename_all = "camelCase")] pub struct InstallationDiagnostics { pub platform: String, pub os_version: Option, pub os_arch: String, pub launcher_version: String, pub osu_folder: String, pub resolved_folder: Option, pub osu_writable: Option, pub patcher_path: Option, pub patcher_installed: bool, pub patcher_file_count: usize, pub patcher_total_size: u64, pub host_net8: bool, pub osuwinello: bool, pub wmctrl: bool, pub wine_prefix: Option, pub wine_binary: Option, pub wine_app_bundle: Option, pub wine_version: Option, pub wine_prefix_arch: Option, pub wine_net8: Option, pub wine_error: Option, pub ffmpeg_status: Option, pub logs_directory: String, } fn directory_is_writable(path: &Path) -> bool { let probe = path.join(format!(".ezpplauncher-write-test-{}", std::process::id())); let _ = std::fs::remove_file(&probe); let writable = std::fs::OpenOptions::new() .write(true) .create_new(true) .open(&probe) .is_ok(); let _ = std::fs::remove_file(&probe); writable } fn osu_install_writable(osu_folder: &Path) -> bool { if !directory_is_writable(osu_folder) { return false; } let support_folder = osu_folder.join("EZPPLauncher"); !support_folder.is_dir() || directory_is_writable(&support_folder) } fn patcher_file_stats(osu_folder: &Path) -> (usize, u64) { let Ok(entries) = std::fs::read_dir(osu_folder.join("EZPPLauncher/patcher")) else { return (0, 0); }; entries .flatten() .filter_map(|entry| { let metadata = entry.metadata().ok()?; metadata.is_file().then_some(metadata.len()) }) .fold((0, 0), |(count, total), size| (count + 1, total + size)) } /// Collect the installation details shown in Settings → Diagnostics. #[tauri::command] pub async fn installation_diagnostics(app: AppHandle, folder: String) -> InstallationDiagnostics { let resolved = normalized_osu_folder(&folder); let resolved_folder = resolved .as_ref() .map(|path| path.to_string_lossy().into_owned()); let patcher_path = resolved .as_ref() .map(|path| path.join("EZPPLauncher/patcher/osu!.patcher.exe")); let (patcher_file_count, patcher_total_size) = resolved .as_deref() .map(patcher_file_stats) .unwrap_or_default(); let mut diagnostics = InstallationDiagnostics { platform: std::env::consts::OS.to_string(), os_version: System::long_os_version(), os_arch: std::env::consts::ARCH.to_string(), launcher_version: app.package_info().version.to_string(), osu_folder: folder, resolved_folder, osu_writable: resolved.as_deref().map(osu_install_writable), patcher_installed: patcher_path .as_ref() .map(|path| path.is_file()) .unwrap_or(false), patcher_file_count, patcher_total_size, patcher_path: patcher_path.map(|path| path.to_string_lossy().into_owned()), host_net8: is_net8_installed().await, osuwinello: is_osuwinello_available(), wmctrl: is_wmctrl_available(), wine_prefix: None, wine_binary: None, wine_app_bundle: None, wine_version: None, wine_prefix_arch: None, wine_net8: None, wine_error: None, ffmpeg_status: None, logs_directory: logging::logs_directory().to_string_lossy().into_owned(), }; #[cfg(target_os = "macos")] if let Some(path) = resolved { match crate::macos::WineDiagnostics::for_osu_folder(&path) { Ok(wine) => { diagnostics.wine_prefix = Some(wine.prefix.to_string_lossy().into_owned()); diagnostics.wine_binary = Some(wine.wine_bin.to_string_lossy().into_owned()); diagnostics.wine_app_bundle = wine .app_bundle .map(|bundle| bundle.to_string_lossy().into_owned()); diagnostics.wine_version = wine.wine_version; diagnostics.wine_prefix_arch = wine.prefix_arch; diagnostics.wine_net8 = Some(wine.net8_installed); } Err(error) => diagnostics.wine_error = Some(error), } diagnostics.ffmpeg_status = theme_ffmpeg_status(); } diagnostics } #[tauri::command] pub fn find_osu_installation() -> Option { find_osu_installation_impl() } #[cfg(windows)] fn find_osu_installation_impl() -> Option { use winreg::RegKey; use winreg::enums::*; let hklm_registry_paths = ["SOFTWARE\\Classes\\osu\\DefaultIcon"]; let hkcu_registry_paths = [ "Software\\Classes\\osustable.File.osk\\DefaultIcon", "Software\\Classes\\osustable.File.osr\\DefaultIcon", "Software\\Classes\\osustable.File.osz\\DefaultIcon", ]; let hklm = RegKey::predef(HKEY_LOCAL_MACHINE); for reg_path in &hklm_registry_paths { if let Ok(subkey) = hklm.open_subkey_with_flags(reg_path, KEY_READ | KEY_WOW64_32KEY) { let value_names = [""]; for value_name in &value_names { if let Ok(value) = subkey.get_value::(value_name) { let trimmed = value.trim_matches('"'); let stripped = trimmed.strip_suffix(",0").unwrap_or(trimmed); let path = PathBuf::from(stripped.trim()); if let Some(parent) = path.parent() { if is_valid_osu_folder(parent) { return Some(parent.to_string_lossy().into()); } } } } } } let hkcu = RegKey::predef(HKEY_CURRENT_USER); for reg_path in &hkcu_registry_paths { if let Ok(subkey) = hkcu.open_subkey_with_flags(reg_path, KEY_READ | KEY_WOW64_32KEY) { let value_names = [""]; for value_name in &value_names { if let Ok(value) = subkey.get_value::(value_name) { let trimmed = value.trim_matches('"'); let stripped = trimmed.strip_suffix(",1").unwrap_or(trimmed); let path = PathBuf::from(stripped.trim()); if let Some(parent) = path.parent() { if is_valid_osu_folder(parent) { return Some(parent.to_string_lossy().into()); } } } } } } None } #[cfg(target_os = "macos")] fn find_osu_installation_impl() -> Option { crate::macos::find_osu_installation() } #[cfg(all(not(windows), not(target_os = "macos")))] fn find_osu_installation_impl() -> Option { None } #[tauri::command] pub async fn get_beatmapsets_count(folder: String) -> Option { let path = PathBuf::from(folder); let osu_db_path = path.join("osu!.db"); if !osu_db_path.exists() { return Some(0); } let osu_db_bytes = fs::read(osu_db_path).await.ok()?; let osu_db = parse_osudb(osu_db_bytes).ok()?; let beatmap_sets = osu_db .beatmaps .iter() .map(|b| b.beatmapset_id) .collect::>() .len(); Some(beatmap_sets as u64) } #[tauri::command] pub async fn get_skins_count(folder: String) -> Option { let path = PathBuf::from(folder); let skins_folder = path.join("Skins"); if !skins_folder.exists() { return Some(0); } let mut count = 0; if let Ok(mut entries) = fs::read_dir(skins_folder).await { while let Ok(Some(entry)) = entries.next_entry().await { if let Ok(ft) = entry.file_type().await { if ft.is_dir() { let dir_path = entry.path(); let has_ini = tokio::fs::try_exists(dir_path.join("skin.ini")) .await .unwrap_or(false) || tokio::fs::try_exists(dir_path.join("Skin.ini")) .await .unwrap_or(false); if has_ini { count += 1; } } } } } Some(count) } #[tauri::command] pub fn get_osu_skin(folder: String) -> String { let path = PathBuf::from(folder); let osu_user_config = get_osu_user_config(path); osu_user_config .and_then(|config| config.get("Skin").cloned()) .unwrap_or_else(|| "Default".to_string()) } #[tauri::command] pub fn get_osu_version(folder: String) -> String { let path = PathBuf::from(folder); let osu_user_config = get_osu_user_config(path); osu_user_config .and_then(|config| config.get("LastVersion").cloned()) .unwrap_or_else(|| "failed".to_string()) } #[tauri::command] pub fn get_osu_release_stream(folder: String) -> String { let path = PathBuf::from(folder); let osu_config = get_osu_config(path); osu_config .and_then(|config| config.get("_ReleaseStream").cloned()) .unwrap_or_else(|| "Stable40".to_string()) } #[derive(serde::Deserialize)] pub struct ConfigEntry { pub key: String, pub value: String, } #[tauri::command] pub fn set_osu_user_config_values( osu_folder_path: String, entries: Vec, ) -> Result { let converted: Vec<(&str, Option<&str>)> = entries .iter() .map(|entry| (entry.key.as_str(), Some(entry.value.as_str()))) .collect(); Ok(set_osu_user_config_vals(&osu_folder_path, &converted).is_ok()) } #[tauri::command] pub fn set_osu_config_values( osu_folder_path: String, entries: Vec, ) -> Result { let converted: Vec<(&str, Option<&str>)> = entries .iter() .map(|entry| (entry.key.as_str(), Some(entry.value.as_str()))) .collect(); Ok(set_osu_config_vals(&osu_folder_path, &converted).is_ok()) } #[tauri::command] pub async fn run_osu_updater(folder: String) -> Result<(), String> { log::info!(target: "osu", "Starting the osu! updater for {folder}"); #[cfg(target_os = "macos")] crate::macos::run_osu_updater(&folder).await?; #[cfg(not(target_os = "macos"))] { let mut updater_process = { #[cfg(windows)] { const DETACHED_PROCESS: u32 = 0x00000008; const CREATE_NEW_PROCESS_GROUP: u32 = 0x00000200; let osu_exe_path = PathBuf::from(&folder).join("osu!.exe"); Command::new(&osu_exe_path) .arg("-repair") .stdin(Stdio::null()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .creation_flags(DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP) .spawn() .map_err(|e| { log::error!(target: "osu", "Failed to spawn the osu! updater: {e}"); format!("Failed to spawn updater: {}", e) })? } #[cfg(not(windows))] { let osu_wine_path = get_osuwinello_path().expect("osu-wine was not found"); Command::new(&osu_wine_path) .arg("-repair") .stdin(Stdio::null()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .spawn() .map_err(|e| { log::error!(target: "osu", "Failed to spawn the osu! updater: {e}"); format!("Failed to spawn updater: {}", e) })? } }; if let Some(pid) = updater_process.id() { log::info!(target: "osu", "osu! updater started (pid {pid})"); } crate::logging::capture_osu_output(&mut updater_process); sleep(Duration::from_millis(500)).await; let mut sys = System::new_all(); loop { sys.refresh_processes(sysinfo::ProcessesToUpdate::All, true); let mut found = false; for (_pid, process) in sys.processes() { if is_osu_game_process(process) { let pid = process.pid(); let title = get_window_title_by_pid(pid); if !title.is_empty() && !title.contains("updater") { let _ = process.kill_and_wait(); found = true; break; } } } if found { break; } sleep(Duration::from_millis(500)).await; } match updater_process.wait().await { Ok(status) => log::info!(target: "osu", "osu! updater exited with {status}"), Err(error) => log::warn!(target: "osu", "Could not wait for the osu! updater: {error}"), } } let force_update_files = [".require_update", "help.txt", "_pending"]; for update_file_name in &force_update_files { let path = PathBuf::from(&folder).join(update_file_name); if path.exists() { match std::fs::symlink_metadata(&path) { Ok(meta) => { let res = if meta.is_dir() { std::fs::remove_dir_all(&path) } else { std::fs::remove_file(&path) }; if let Err(e) = res { log::warn!(target: "osu", "Failed to remove {:?}: {}", path, e); } } Err(e) => { log::warn!(target: "osu", "Could not stat {:?}: {}", path, e); } } } } Ok(()) } #[tauri::command] pub async fn run_osu(folder: String, patch: bool) -> Result<(), String> { log::info!(target: "osu", "Launching osu! from {folder} (patch={patch})"); #[cfg(target_os = "macos")] { return crate::macos::run_osu(&folder, patch).await; } #[cfg(not(target_os = "macos"))] { #[cfg(windows)] const DETACHED_PROCESS: u32 = 0x00000008; #[cfg(windows)] const CREATE_NEW_PROCESS_GROUP: u32 = 0x00000200; let mut game_process = { #[cfg(windows)] { let osu_exe_path = PathBuf::from(&folder).join("osu!.exe"); Command::new(&osu_exe_path) .arg("-devserver") .arg("ez-pp.farm") .stdin(Stdio::null()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .creation_flags(DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP) .spawn() .map_err(|e| { log::error!(target: "osu", "Failed to spawn osu!: {e}"); format!("Failed to spawn osu: {}", e) })? } #[cfg(not(windows))] { let osu_wine_path = get_osuwinello_path().expect("osu-wine was not found"); Command::new(&osu_wine_path) .arg("--devserver") .arg("ez-pp.farm") .stdin(Stdio::null()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .spawn() .map_err(|e| { log::error!(target: "osu", "Failed to spawn osu!: {e}"); format!("Failed to spawn osu: {}", e) })? } }; if let Some(pid) = game_process.id() { log::info!(target: "osu", "osu! started (pid {pid})"); } crate::logging::capture_osu_output(&mut game_process); if patch { let patcher_exe_path = PathBuf::from(&folder) .join("EZPPLauncher") .join("patcher") .join("osu!.patcher.exe"); if patcher_exe_path.exists() { let mut sys = System::new_all(); loop { sys.refresh_processes(sysinfo::ProcessesToUpdate::All, true); let mut found = false; for (_pid, process) in sys.processes() { if process.name() == "osu!.exe" { let pid = process.pid(); let title = get_window_title_by_pid(pid); if !title.is_empty() && !title.contains("updater") { found = true; break; } } } if found { break; } sleep(Duration::from_millis(500)).await; } log::info!(target: "patcher", "Starting the patcher from {}", patcher_exe_path.display()); #[cfg(windows)] let mut patcher_process = Command::new(&patcher_exe_path) .stdin(Stdio::null()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .creation_flags(DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP) .spawn() .map_err(|e| { log::error!(target: "patcher", "Failed to start the patcher: {e}"); format!("Failed to run patcher: {e}") })?; #[cfg(not(windows))] let mut patcher_process = { use crate::utils::get_osuwinello_path; let osu_wine_path = get_osuwinello_path().expect("osu-wine was not found"); Command::new(&osu_wine_path) .arg("--wine") .arg(&patcher_exe_path) .stdin(Stdio::null()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .spawn() .map_err(|e| { log::error!(target: "patcher", "Failed to start the patcher: {e}"); format!("Failed to run patcher: {}", e) })? }; if let Some(pid) = patcher_process.id() { log::info!(target: "patcher", "Patcher started (pid {pid})"); } crate::logging::capture_patcher_output(&mut patcher_process); } else { log::warn!( target: "patcher", "Patcher was requested but {} does not exist", patcher_exe_path.display() ); } } match game_process.wait().await { Ok(status) => log::info!(target: "osu", "osu! exited with {status}"), Err(error) => { log::warn!(target: "osu", "Could not wait for osu!: {error}"); return Err(error.to_string()); } } Ok(()) } } #[derive(Deserialize, Serialize, Debug, Clone)] #[serde(rename_all = "camelCase")] pub struct UpdateFile { pub name: String, pub folder: String, pub url: String, pub size: usize, pub md5: String, } #[tauri::command] pub async fn get_ezpp_launcher_update_files( folder: String, update_url: String, update_stream: String, ) -> Result<(Vec, Vec), String> { let osu_path = PathBuf::from(folder); let client = Client::new(); let update_files = client .patch(update_url) .header("User-Agent", "EZPPLauncher") .query(&[("stream", update_stream)]) .send() .await .map_err(|e| e.to_string())? .json::>() .await .map_err(|e| e.to_string())?; let mut files_to_download = Vec::new(); for file in &update_files { let file_path = osu_path.join(&file.folder).join(&file.name); if file_path.exists() { let data = fs::read(&file_path).await.map_err(|e| e.to_string())?; let hash = format!("{:x}", md5::compute(&data)); let matches = hash.eq_ignore_ascii_case(&file.md5) || (file.name == "osu!.patcher.exe" && crate::utils::is_compatible_patcher(&data)); if !matches { files_to_download.push(file.clone()); } } else { files_to_download.push(file.clone()); } } Ok((files_to_download, update_files)) } #[derive(Deserialize, Serialize, Debug, Clone)] #[serde(rename_all = "camelCase")] pub struct UpdateStatus { pub file_name: String, pub downloaded: u64, pub size: usize, pub progress: f64, } #[tauri::command] pub async fn download_ezpp_launcher_update_files( app: AppHandle, folder: String, update_files: Vec, all_files: Vec, ) -> Result<(), String> { let osu_path = PathBuf::from(folder); let client = Client::new(); let valid_paths: HashSet = all_files .iter() .map(|f| osu_path.join(&f.folder).join(&f.name)) .collect(); for folder in all_files .iter() .map(|f| osu_path.join(&f.folder)) .collect::>() { if folder.exists() && folder != osu_path { let mut dir = fs::read_dir(&folder).await.map_err(|e| e.to_string())?; while let Some(entry) = dir.next_entry().await.map_err(|e| e.to_string())? { let path = entry.path(); if !valid_paths.contains(&path) { fs::remove_file(&path).await.ok(); } } } } for file in update_files { let file_path = osu_path.join(&file.folder).join(&file.name); let parent = file_path.parent().unwrap(); if !parent.exists() { fs::create_dir_all(parent) .await .map_err(|e| e.to_string())?; } let mut response = client .get(&file.url) .send() .await .map_err(|e| e.to_string())?; let mut file_out = fs::File::create(&file_path) .await .map_err(|e| e.to_string())?; let mut downloaded = 0u64; while let Some(chunk) = response.chunk().await.map_err(|e| e.to_string())? { downloaded += chunk.len() as u64; file_out .write_all(&chunk) .await .map_err(|e| e.to_string())?; app.emit( "download-progress", UpdateStatus { file_name: file.name.clone(), downloaded, size: file.size, progress: ((downloaded as f64 / file.size as f64 * 100.0) * 100.0).trunc() / 100.0, }, ) .unwrap_or_default(); } } let patcher_exe = osu_path.join("EZPPLauncher").join("patcher").join("osu!.patcher.exe"); if patcher_exe.is_file() { let _ = crate::utils::ensure_patcher_compatibility(&patcher_exe); } Ok(()) } #[derive(Serialize)] #[serde(tag = "type", content = "details")] pub enum ReplaceUIError { FileNotFound(String), IoError(String), } #[tauri::command] pub fn replace_ui_files(folder: String, revert: bool) -> Result<(), ReplaceUIError> { let osu_path = PathBuf::from(folder); let ezpp_ui = osu_path.join("EZPPLauncher").join("ezpp!ui.dll"); let osu_ui = osu_path.join("osu!ui.dll"); let ezpp_seasonal = osu_path.join("EZPPLauncher").join("ezpp!seasonal.dll"); let osu_seasonal = osu_path.join("osu!seasonal.dll"); let ezpp_gameplay = osu_path.join("EZPPLauncher").join("ezpp!gameplay.dll"); let osu_gameplay = osu_path.join("osu!gameplay.dll"); let osu_ui_bak = osu_path.join("osu!ui.dll.bak"); let osu_seasonal_bak = osu_path.join("osu!seasonal.dll.bak"); let osu_gameplay_bak = osu_path.join("osu!gameplay.dll.bak"); let temp_file = osu_path.join(".ezpplauncher_files"); let copy_file = |source: &PathBuf, dest: &PathBuf, name: &str| -> Result<(), ReplaceUIError> { if !source.exists() { return Err(ReplaceUIError::FileNotFound(source.display().to_string())); } if dest.exists() { std::fs::remove_file(dest).map_err(|e| { ReplaceUIError::IoError(format!("Failed to remove {}: {}", name, e)) })?; } std::fs::copy(source, dest) .map_err(|e| ReplaceUIError::IoError(format!("Failed to copy {}: {}", name, e)))?; Ok(()) }; let restore_backup = |bak: &PathBuf, dest: &PathBuf, name: &str| -> Result<(), ReplaceUIError> { if bak.exists() { if dest.exists() { std::fs::remove_file(dest).map_err(|e| { ReplaceUIError::IoError(format!("Failed to remove {}: {}", name, e)) })?; } std::fs::rename(bak, dest).map_err(|e| { ReplaceUIError::IoError(format!("Failed to rename {} from backup: {}", name, e)) })?; } Ok(()) }; if !revert { if osu_ui.exists() && !osu_ui_bak.exists() { std::fs::rename(&osu_ui, &osu_ui_bak).map_err(|e| { ReplaceUIError::IoError(format!("Failed to backup osu!ui.dll: {}", e)) })?; } if osu_seasonal.exists() && !osu_seasonal_bak.exists() { std::fs::rename(&osu_seasonal, &osu_seasonal_bak).map_err(|e| { ReplaceUIError::IoError(format!("Failed to backup osu!seasonal.dll: {}", e)) })?; } if osu_gameplay.exists() && !osu_gameplay_bak.exists() { std::fs::rename(&osu_gameplay, &osu_gameplay_bak).map_err(|e| { ReplaceUIError::IoError(format!("Failed to backup osu!gameplay.dll: {}", e)) })?; } copy_file(&ezpp_ui, &osu_ui, "osu!ui.dll")?; copy_file(&ezpp_seasonal, &osu_seasonal, "osu!seasonal.dll")?; copy_file(&ezpp_gameplay, &osu_gameplay, "osu!gameplay.dll")?; std::fs::write(&temp_file, b"1") .map_err(|e| ReplaceUIError::IoError(format!("Failed to write marker file: {}", e)))?; } else { restore_backup(&osu_ui_bak, &osu_ui, "osu!ui.dll")?; restore_backup(&osu_seasonal_bak, &osu_seasonal, "osu!seasonal.dll")?; restore_backup(&osu_gameplay_bak, &osu_gameplay, "osu!gameplay.dll")?; if osu_ui.exists() && osu_seasonal.exists() && osu_gameplay.exists() { std::fs::remove_file(&temp_file).map_err(|e| { ReplaceUIError::IoError(format!("Failed to remove marker file: {}", e)) })?; } } Ok(()) } #[derive(Serialize)] #[serde(tag = "type", content = "details")] pub enum CorruptionCheckError { IoError(String), FileNotFound(String), } #[tauri::command] pub async fn check_for_corruption(folder: String) -> Result { let osu_path = PathBuf::from(folder); let osu_ui = osu_path.join("osu!ui.dll"); let osu_gameplay = osu_path.join("osu!gameplay.dll"); let osu_seasonal = osu_path.join("osu!seasonal.dll"); let osu_ui_bak = osu_path.join("osu!ui.dll.bak"); let osu_gameplay_bak = osu_path.join("osu!gameplay.dll.bak"); let osu_seasonal_bak = osu_path.join("osu!seasonal.dll.bak"); let temp_file = osu_path.join(".ezpplauncher_files"); if temp_file.exists() { if osu_ui_bak.exists() { if osu_ui.exists() { fs::remove_file(&osu_ui).await.map_err(|e| { CorruptionCheckError::IoError(format!("Failed to remove osu!ui.dll: {}", e)) })?; } fs::rename(&osu_ui_bak, &osu_ui).await.map_err(|e| { CorruptionCheckError::IoError(format!( "Failed to restore osu!ui.dll from backup: {}", e )) })?; } if osu_gameplay_bak.exists() { if osu_gameplay.exists() { fs::remove_file(&osu_gameplay).await.map_err(|e| { CorruptionCheckError::IoError(format!( "Failed to remove osu!gameplay.dll: {}", e )) })?; } fs::rename(&osu_gameplay_bak, &osu_gameplay) .await .map_err(|e| { CorruptionCheckError::IoError(format!( "Failed to restore osu!gameplay.dll from backup: {}", e )) })?; } if osu_seasonal_bak.exists() { if osu_seasonal.exists() { fs::remove_file(&osu_seasonal).await.map_err(|e| { CorruptionCheckError::IoError(format!( "Failed to remove osu!seasonal.dll: {}", e )) })?; } fs::rename(&osu_seasonal_bak, &osu_seasonal) .await .map_err(|e| { CorruptionCheckError::IoError(format!( "Failed to restore osu!seasonal.dll from backup: {}", e )) })?; } return Ok(false); } if !osu_ui.exists() || !osu_gameplay.exists() || !osu_seasonal.exists() { return Ok(true); } Ok(false) } #[tauri::command] pub fn is_osu_running() -> bool { let mut sys = System::new_all(); sys.refresh_processes(sysinfo::ProcessesToUpdate::All, true); for process in sys.processes().values() { if is_osu_process(process) { return true; } } false } #[tauri::command] pub fn open_url_in_browser(url: String) -> Result<(), String> { open::that(&url).map_err(|e| format!("Failed to open URL: {}", e)) } #[tauri::command] pub fn exit(app: AppHandle) { app.exit(0x0100); } #[tauri::command] pub fn get_platform() -> String { std::env::consts::OS.to_string() } #[tauri::command] pub async fn presence_connect() -> bool { presence::connect().await } #[tauri::command] pub async fn presence_disconnect() { presence::disconnect().await } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] pub struct PresenceStatus { state: Option, details: Option, large_image_key: Option, } #[tauri::command] pub async fn presence_update_status(status: PresenceStatus) { presence::update_status( status.state.as_deref(), status.details.as_deref(), status.large_image_key.as_deref(), ); } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] pub struct PresenceUser { username: Option, id: Option, } #[tauri::command] pub async fn presence_update_user(user: PresenceUser) { presence::update_user(user.username.as_deref(), user.id.as_deref()); } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] pub struct PresenceButton { text: Option, url: Option, } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] pub struct PresenceActivityPayload { pub state: Option, pub details: Option, pub large_image_key: Option, pub large_image_text: Option, pub small_image_key: Option>, pub small_image_text: Option>, pub button1: Option>, pub button2: Option>, pub show_timestamp: Option, } #[tauri::command] pub async fn presence_update_activity(activity: PresenceActivityPayload) { presence::update_activity( activity.state.as_deref(), activity.details.as_deref(), activity.large_image_key.as_deref(), activity.large_image_text.as_deref(), activity.small_image_key, activity.small_image_text, activity.button1, activity.button2, activity.show_timestamp, ); } #[tauri::command] pub async fn presence_update_button(button: PresenceButton) { match (button.text.as_deref(), button.url.as_deref()) { (Some(text), Some(url)) => presence::set_button(text, url), _ => presence::clear_button(), } } #[tauri::command] pub async fn presence_is_connected() -> bool { presence::has_presence().await } #[tauri::command] pub fn has_wmctrl() -> bool { is_wmctrl_available() } #[tauri::command] pub fn has_osuwinello() -> bool { is_osuwinello_available() } #[tauri::command] pub async fn has_net8() -> bool { is_net8_installed().await } #[tauri::command] pub fn encrypt_string(string: String, entropy: String) -> String { encrypt_password(&string, &entropy).unwrap_or(string) } #[tauri::command] pub async fn download_ezpp_launcher_update(app: AppHandle, url: String) -> Result<(), String> { let client = Client::new(); let mut response = client.get(&url).send().await.map_err(|e| e.to_string())?; if !response.status().is_success() { return Err(format!("Failed to download update: {}", response.status())); } let temp_dir = app.path().temp_dir().expect("Failed to get temp directory"); let file_path = temp_dir.join("ezpplauncher_update.exe"); let mut file_out = fs::File::create(&file_path) .await .map_err(|e| e.to_string())?; let mut downloaded = 0u64; let size = response .content_length() .ok_or("Failed to get content length")? as usize; while let Some(chunk) = response.chunk().await.map_err(|e| e.to_string())? { downloaded += chunk.len() as u64; file_out .write_all(&chunk) .await .map_err(|e| e.to_string())?; app.emit( "download-progress", UpdateStatus { file_name: "Update".to_string(), downloaded, size, progress: ((downloaded as f64 / size as f64 * 100.0) * 100.0).trunc() / 100.0, }, ) .unwrap_or_default(); } Ok(()) } #[tauri::command] pub async fn install_ezpp_launcher_update(app: AppHandle) -> Result<(), String> { #[cfg(not(windows))] let _ = app; #[cfg(windows)] { let temp_dir = app.path().temp_dir().expect("Failed to get temp directory"); let file_path = temp_dir.join("ezpplauncher_update.exe"); if !file_path.exists() { return Err("Update file does not exist".to_string()); } // run this app detached and exit const DETACHED_PROCESS: u32 = 0x00000008; const CREATE_NEW_PROCESS_GROUP: u32 = 0x00000200; Command::new(&file_path) .arg("/S") .creation_flags(DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP) .spawn() .map_err(|e| format!("Failed to spawn updater: {}", e))?; sleep(Duration::from_millis(250)).await; app.exit(0x0100); } Ok(()) } #[tauri::command] pub async fn run_open_tablet_driver(path: String) -> Result<(), String> { let otd_path = PathBuf::from(&path); if !otd_path.exists() { return Err(format!("OpenTabletDriver not found at: {}", path)); } #[cfg(windows)] { const CREATE_NO_WINDOW: u32 = 0x08000000; Command::new(&otd_path) .creation_flags(CREATE_NO_WINDOW) .stdin(Stdio::null()) .stdout(Stdio::null()) .stderr(Stdio::null()) .spawn() .map_err(|e| format!("Failed to start OpenTabletDriver: {}", e))?; } Ok(()) } #[tauri::command] pub async fn stop_open_tablet_driver() -> Result<(), String> { #[cfg(windows)] { let mut sys = System::new_all(); sys.refresh_processes(sysinfo::ProcessesToUpdate::All, true); let process = sys .processes() .values() .find(|p| p.name().eq_ignore_ascii_case("OpenTabletDriver.Daemon.exe")) .ok_or("OpenTabletDriver is not running")?; process.kill(); } Ok(()) } #[tauri::command] pub fn is_open_tablet_driver_running() -> bool { #[cfg(windows)] { let mut sys = System::new_all(); sys.refresh_processes(sysinfo::ProcessesToUpdate::All, true); sys.processes() .values() .any(|p| p.name().eq_ignore_ascii_case("OpenTabletDriver.Daemon.exe")) } #[cfg(not(windows))] { true } } #[tauri::command] pub async fn is_open_tablet_driver_path_valid(app: AppHandle, path: String) -> bool { #[cfg(not(windows))] let _ = (&app, &path); #[cfg(windows)] { app.fs_scope().allow_file(&path).ok(); let otd_path = PathBuf::from(&path); otd_path.exists() } #[cfg(not(windows))] { true } } #[derive(Serialize, Clone)] pub struct ExtractProgress { pub theme_name: String, pub total: usize, pub extracted: usize, pub progress: f64, pub current_file: String, } #[derive(Serialize, Clone)] pub struct DownloadProgress { pub theme_name: String, pub received: u64, pub total: u64, pub progress: f64, } #[tauri::command] pub async fn download_and_extract_theme( app: AppHandle, download_url: String, expected_sha: String, theme_folder: String, theme_name: String, ) -> Result<(), String> { let client = reqwest::Client::new(); let response = client .get(&download_url) .header( "User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36", ) .send() .await .map_err(|e| format!("Failed to download theme: {}", e))?; let total = response.content_length().unwrap_or(0); let mut received: u64 = 0; let mut chunks: Vec = Vec::new(); let mut stream = response.bytes_stream(); while let Some(chunk) = futures_util::StreamExt::next(&mut stream).await { let chunk = chunk.map_err(|e| format!("Failed to read chunk: {}", e))?; received += chunk.len() as u64; chunks.extend_from_slice(&chunk); let _ = app.emit( "download_progress", DownloadProgress { theme_name: theme_name.clone(), received, total, progress: if total > 0 { received as f64 / total as f64 } else { 0.0 }, }, ); } let sha = tokio::task::spawn_blocking({ let chunks = chunks.clone(); move || { use sha1::{Digest, Sha1}; let header = format!("blob {}\0", chunks.len()); let mut hasher = Sha1::new(); hasher.update(header.as_bytes()); hasher.update(&chunks); hex::encode(hasher.finalize()) } }) .await .map_err(|e| format!("Thread error: {}", e))?; if sha != expected_sha { return Err("Failed to verify downloaded theme file hash.".to_string()); } // Parse zip on blocking thread let entries: Vec<(String, Vec, bool)> = tokio::task::spawn_blocking(move || { let cursor = std::io::Cursor::new(chunks); let mut archive = zip::ZipArchive::new(cursor) .map_err(|e| format!("Failed to read zip archive: {}", e))?; let mut entries = Vec::new(); for i in 0..archive.len() { let mut zip_entry = archive .by_index(i) .map_err(|e| format!("Failed to read zip entry: {}", e))?; let name = match zip_entry.enclosed_name() { Some(p) => p.to_string_lossy().to_string(), None => continue, }; let is_dir = zip_entry.is_dir(); let mut buf = Vec::new(); if !is_dir { std::io::copy(&mut zip_entry, &mut buf) .map_err(|e| format!("Failed to read zip entry data: {}", e))?; } entries.push((name, buf, is_dir)); } Ok::<_, String>(entries) }) .await .map_err(|e| format!("Thread error: {}", e))??; let total_files = entries.len(); let mut extracted = 0; for (name, buf, is_dir) in entries { let entry_path = std::path::Path::new(&theme_folder).join(&name); if is_dir { tokio::fs::create_dir_all(&entry_path) .await .map_err(|e| format!("Failed to create directory: {}", e))?; } else { if let Some(parent) = entry_path.parent() { tokio::fs::create_dir_all(parent) .await .map_err(|e| format!("Failed to create parent directory: {}", e))?; } tokio::fs::write(&entry_path, &buf) .await .map_err(|e| format!("Failed to write file: {}", e))?; } extracted += 1; let _ = app.emit( "extract_progress", ExtractProgress { theme_name: theme_name.clone(), total: total_files, extracted, progress: extracted as f64 / total_files as f64, current_file: name, }, ); } Ok(()) } #[tauri::command] pub async fn extract_theme( app: AppHandle, file_path: String, theme_folder: String, theme_name: String, ) -> Result<(), String> { app.fs_scope().allow_file(&file_path).ok(); let entries: Vec<(String, Vec, bool)> = tokio::task::spawn_blocking(move || { let file = std::fs::File::open(&file_path) .map_err(|e| format!("Failed to open theme file: {}", e))?; let mut archive = zip::ZipArchive::new(file).map_err(|e| format!("Failed to read zip archive: {}", e))?; let mut entries = Vec::new(); for i in 0..archive.len() { let mut zip_entry = archive .by_index(i) .map_err(|e| format!("Failed to read zip entry: {}", e))?; let name = match zip_entry.enclosed_name() { Some(p) => p.to_string_lossy().to_string(), None => continue, }; let is_dir = zip_entry.is_dir(); let mut buf = Vec::new(); if !is_dir { zip_entry .read_to_end(&mut buf) .map_err(|e| format!("Failed to read zip entry data: {}", e))?; } entries.push((name, buf, is_dir)); } Ok::<_, String>(entries) }) .await .map_err(|e| format!("Thread error: {}", e))??; let total = entries.len(); let mut extracted = 0; for (name, buf, is_dir) in entries { let entry_path = std::path::Path::new(&theme_folder).join(&name); if is_dir { tokio::fs::create_dir_all(&entry_path) .await .map_err(|e| format!("Failed to create directory: {}", e))?; } else { if let Some(parent) = entry_path.parent() { tokio::fs::create_dir_all(parent) .await .map_err(|e| format!("Failed to create parent directory: {}", e))?; } tokio::fs::write(&entry_path, &buf) .await .map_err(|e| format!("Failed to write file: {}", e))?; } extracted += 1; let _ = app.emit( "extract_progress", ExtractProgress { theme_name: theme_name.clone(), total, extracted, progress: extracted as f64 / total as f64, current_file: name, }, ); } Ok(()) } #[derive(Serialize, Deserialize, Clone, Default)] pub struct ThemeInfo { pub name: String, #[serde(default)] pub version: String, #[serde(rename = "apiVersion", default)] pub api_version: String, #[serde(default)] pub author: String, #[serde(default)] pub description: String, #[serde(default)] pub nsfw: bool, #[serde(default)] pub entry: String, #[serde(default)] pub style: String, #[serde(default)] pub preview: String, } #[tauri::command] pub async fn read_theme_info(app: AppHandle, file_path: String) -> Result { app.fs_scope().allow_file(&file_path).ok(); tokio::task::spawn_blocking(move || { let file = std::fs::File::open(&file_path) .map_err(|e| format!("Failed to open theme file: {}", e))?; let mut archive = zip::ZipArchive::new(file).map_err(|e| format!("Failed to read zip archive: {}", e))?; let mut theme_config = archive .by_name("theme.json") .map_err(|_| "Theme config file not found".to_string())?; let mut contents = String::new(); std::io::Read::read_to_string(&mut theme_config, &mut contents) .map_err(|e| format!("Failed to read theme config: {}", e))?; let theme_info: ThemeInfo = serde_json::from_str(&contents) .map_err(|e| format!("Failed to parse theme config: {}", e))?; Ok(theme_info) }) .await .map_err(|e| format!("Thread error: {}", e))? } pub(crate) fn theme_root() -> Result { let home = std::env::var("HOME") .or_else(|_| std::env::var("USERPROFILE")) .map_err(|_| "Could not locate the home directory".to_string())?; let root = PathBuf::from(home).join(".ezpplauncher").join("themes"); std::fs::create_dir_all(&root) .map_err(|e| format!("Failed to create the themes directory: {e}"))?; std::fs::canonicalize(&root).map_err(|e| format!("Failed to resolve the themes directory: {e}")) } #[cfg(any(target_os = "linux", target_os = "macos"))] fn loopback_theme_asset_base() -> String { if crate::theme_server::base_url().is_none() { if let Ok(root) = theme_root() { match crate::theme_server::media_root() { Ok(media) => { if let Err(error) = crate::theme_server::start(root, media) { log::warn!(target: "launcher", "Could not start the theme asset server: {error}"); } } Err(error) => { log::warn!(target: "launcher", "Could not prepare the media cache: {error}"); } } } } crate::theme_server::base_url().unwrap_or_default() } #[tauri::command] pub fn theme_asset_base() -> String { #[cfg(any(target_os = "linux", target_os = "macos"))] { return loopback_theme_asset_base(); } #[cfg(not(any(target_os = "linux", target_os = "macos")))] { String::new() } } #[tauri::command] pub fn theme_preview_url(path: String) -> Result { #[cfg(any(target_os = "linux", target_os = "macos"))] { let _ = loopback_theme_asset_base(); return crate::theme_server::preview_url(Path::new(&path)); } #[cfg(not(any(target_os = "linux", target_os = "macos")))] { let _ = path; Ok(String::new()) } } /// Return a hardware-decodable copy of a VP9/AV1 theme video. /// /// WebKit on macOS decodes VP9/AV1 in software. A 1080p WebM can use enough /// CPU to starve the audio decoder, while H.264 is decoded by the media /// engine. The first playback of an installed theme video or a video picked /// in the editor is transcoded to a cached 720p H.264/AAC file; later loads /// return the cached URL directly. Videos already encoded as H.264/HEVC, and /// very small clips, are left untouched. #[derive(Serialize, Clone)] #[serde(rename_all = "camelCase")] pub struct ThemeMediaProgress { pub stage: String, pub progress: f64, } #[cfg(target_os = "macos")] const FFMPEG_VERSION: &str = "9.0.2"; /// Small clips are usually short stickers; transcoding them would cost more /// time and disk than it saves. Anything larger may be a 1080p VP9 stream that /// WebKit cannot decode smoothly in software. #[cfg(target_os = "macos")] const THEME_VIDEO_OPTIMIZE_MIN_BYTES: u64 = 2 * 1024 * 1024; #[cfg(target_os = "macos")] struct FfmpegBuild { url: &'static str, sha256: &'static str, } #[cfg(target_os = "macos")] fn ffmpeg_build() -> Result { match std::env::consts::ARCH { "aarch64" => Ok(FfmpegBuild { url: "https://ffmpeg.martin-riedl.de/download/macos/arm64/1789931890_9.0.2/ffmpeg.zip", sha256: "c8ed4c4e6978a03c485edbfe4e0a5dc2380f8a30bba5150531b31b094492d924", }), "x86_64" => Ok(FfmpegBuild { url: "https://ffmpeg.martin-riedl.de/download/macos/amd64/1789931006_9.0.2/ffmpeg.zip", sha256: "7c6b4125b191cbf773832dc51f424cf2b6bb7da43007d1e066f95909e47cacd4", }), arch => Err(format!( "No FFmpeg build is available for the macOS architecture {arch}" )), } } #[tauri::command] pub async fn optimize_theme_media(app: AppHandle, source_url: String) -> Result { #[cfg(not(target_os = "macos"))] { let _ = app; return Ok(source_url); } #[cfg(target_os = "macos")] { let Some(source) = crate::theme_server::resolve_theme_url(&source_url) else { return Ok(source_url); }; let metadata = match tokio::fs::metadata(&source).await { Ok(metadata) => metadata, Err(error) => { log::warn!(target: "launcher", "Could not inspect theme media: {error}"); return Ok(source_url); } }; if metadata.len() < THEME_VIDEO_OPTIMIZE_MIN_BYTES { log::debug!( target: "launcher", "Skipping theme video optimization for {} ({} bytes)", source.display(), metadata.len() ); return Ok(source_url); } let (cache_name, destination) = match theme_media_cache_path(&source, &metadata) { Ok(cache) => cache, Err(error) => { log::warn!(target: "launcher", "Could not prepare the media cache: {error}"); return Ok(source_url); } }; if destination .metadata() .map(|meta| meta.is_file() && meta.len() > 1024) .unwrap_or(false) { return Ok(crate::theme_server::media_url(&cache_name) .unwrap_or_else(|| source_url.to_string())); } let ffmpeg = match ensure_theme_ffmpeg(&app).await { Ok(path) => path, Err(error) => { log::warn!(target: "launcher", "Theme video optimization is unavailable: {error}"); return Ok(source_url); } }; let _ = app.emit( "theme-media-progress", ThemeMediaProgress { stage: "optimize".to_string(), progress: 0.0, }, ); let original = source_url.clone(); tokio::task::spawn_blocking(move || { optimize_theme_media_blocking(&ffmpeg, &source, &original, &cache_name, &destination) }) .await .map_err(|error| format!("Theme media optimizer thread failed: {error}"))? } } #[cfg(target_os = "macos")] fn theme_media_cache_path( source: &Path, metadata: &std::fs::Metadata, ) -> Result<(String, PathBuf), String> { use sha2::{Digest, Sha256}; let cache_root = crate::theme_server::media_root()?; let modified = metadata .modified() .ok() .and_then(|time| time.duration_since(std::time::UNIX_EPOCH).ok()) .map(|duration| duration.as_nanos()) .unwrap_or(0); let mut hasher = Sha256::new(); hasher.update(source.to_string_lossy().as_bytes()); hasher.update(metadata.len().to_le_bytes()); hasher.update(modified.to_le_bytes()); hasher.update(b"h264-720-v1"); let cache_name = format!("{}.mp4", hex::encode(&hasher.finalize()[..16])); let destination = cache_root.join(&cache_name); Ok((cache_name, destination)) } #[cfg(target_os = "macos")] fn theme_tools_dir() -> Result { let home = std::env::var("HOME") .or_else(|_| std::env::var("USERPROFILE")) .map_err(|_| "Could not locate the home directory".to_string())?; let directory = PathBuf::from(home).join(".ezpplauncher").join("bin"); std::fs::create_dir_all(&directory) .map_err(|error| format!("Failed to create the theme media tools folder: {error}"))?; std::fs::canonicalize(&directory) .map_err(|error| format!("Failed to resolve the theme media tools folder: {error}")) } #[cfg(target_os = "macos")] fn verify_ffmpeg(path: &Path) -> bool { std::process::Command::new(path) .arg("-version") .output() .map(|output| { output.status.success() && String::from_utf8_lossy(&output.stdout).starts_with("ffmpeg version") }) .unwrap_or(false) } #[cfg(target_os = "macos")] fn theme_ffmpeg_status() -> Option { let home = std::env::var_os("HOME")?; let managed = PathBuf::from(home).join(".ezpplauncher/bin/ffmpeg"); if managed.is_file() && verify_ffmpeg(&managed) { return Some(format!( "Managed FFmpeg {FFMPEG_VERSION} at {}", managed.display() )); } find_media_binary("ffmpeg") .filter(|path| verify_ffmpeg(path)) .map(|path| format!("System FFmpeg at {}", path.display())) } #[cfg(target_os = "macos")] static FFMPEG_DOWNLOAD_LOCK: std::sync::OnceLock> = std::sync::OnceLock::new(); #[cfg(target_os = "macos")] async fn ensure_theme_ffmpeg(app: &AppHandle) -> Result { let managed = theme_tools_dir()?.join("ffmpeg"); if managed.is_file() && verify_ffmpeg(&managed) { return Ok(managed); } if managed.exists() { let _ = std::fs::remove_file(&managed); } match download_ffmpeg(app).await { Ok(path) => Ok(path), Err(download_error) => { log::warn!(target: "launcher", "Could not download ffmpeg: {download_error}"); find_media_binary("ffmpeg") .filter(|path| verify_ffmpeg(path)) .ok_or_else(|| { format!("ffmpeg is not installed and the download failed: {download_error}") }) } } } #[cfg(target_os = "macos")] async fn download_ffmpeg(app: &AppHandle) -> Result { use futures_util::StreamExt; use sha2::{Digest, Sha256}; use tokio::io::AsyncWriteExt; let build = ffmpeg_build()?; let directory = theme_tools_dir()?; let target = directory.join("ffmpeg"); let lock = FFMPEG_DOWNLOAD_LOCK.get_or_init(|| tokio::sync::Mutex::new(())); let _guard = lock.lock().await; if target.is_file() && verify_ffmpeg(&target) { return Ok(target); } let zip_path = directory.join("ffmpeg.zip.part"); let binary_path = directory.join("ffmpeg.part"); let _ = std::fs::remove_file(&zip_path); let _ = std::fs::remove_file(&binary_path); log::info!( target: "launcher", "Downloading FFmpeg {FFMPEG_VERSION} for theme video optimization" ); let _ = app.emit( "theme-media-progress", ThemeMediaProgress { stage: "download".to_string(), progress: 0.0, }, ); let client = Client::builder() .user_agent(concat!("EZPPLauncher/", env!("CARGO_PKG_VERSION"))) .build() .map_err(|error| format!("Could not create the FFmpeg download client: {error}"))?; let response = client .get(build.url) .send() .await .map_err(|error| format!("Could not download FFmpeg: {error}"))? .error_for_status() .map_err(|error| format!("FFmpeg download failed: {error}"))?; let total = response.content_length().unwrap_or(0); let mut file = tokio::fs::File::create(&zip_path) .await .map_err(|error| format!("Could not create the FFmpeg archive: {error}"))?; let mut hasher = Sha256::new(); let mut received = 0_u64; let mut last_percent = u8::MAX; let mut stream = response.bytes_stream(); while let Some(chunk) = stream.next().await { let chunk = chunk.map_err(|error| format!("Could not read the FFmpeg download: {error}"))?; received += chunk.len() as u64; hasher.update(&chunk); file.write_all(&chunk) .await .map_err(|error| format!("Could not write the FFmpeg archive: {error}"))?; if total > 0 { let percent = ((received.min(total) * 100) / total) as u8; if percent != last_percent { last_percent = percent; let _ = app.emit( "theme-media-progress", ThemeMediaProgress { stage: "download".to_string(), progress: percent as f64 / 100.0, }, ); } } } file.flush() .await .map_err(|error| format!("Could not finish the FFmpeg archive: {error}"))?; drop(file); let digest = hex::encode(hasher.finalize()); if digest != build.sha256 { let _ = std::fs::remove_file(&zip_path); return Err("The downloaded FFmpeg archive failed its SHA-256 check".to_string()); } let _ = app.emit( "theme-media-progress", ThemeMediaProgress { stage: "extract".to_string(), progress: 0.0, }, ); let zip_for_task = zip_path.clone(); let binary_for_task = binary_path.clone(); let target_for_task = target.clone(); tokio::task::spawn_blocking(move || -> Result<(), String> { use std::os::unix::fs::PermissionsExt; let file = std::fs::File::open(&zip_for_task) .map_err(|error| format!("Could not open the FFmpeg archive: {error}"))?; let mut archive = zip::ZipArchive::new(file) .map_err(|error| format!("Could not read the FFmpeg archive: {error}"))?; let mut entry = archive .by_name("ffmpeg") .map_err(|error| format!("The FFmpeg archive does not contain the binary: {error}"))?; let mut output = std::fs::File::create(&binary_for_task) .map_err(|error| format!("Could not create the FFmpeg binary: {error}"))?; std::io::copy(&mut entry, &mut output) .map_err(|error| format!("Could not extract the FFmpeg binary: {error}"))?; drop(output); std::fs::set_permissions(&binary_for_task, std::fs::Permissions::from_mode(0o755)) .map_err(|error| format!("Could not make FFmpeg executable: {error}"))?; std::fs::rename(&binary_for_task, &target_for_task) .map_err(|error| format!("Could not install FFmpeg: {error}"))?; let _ = std::fs::remove_file(&zip_for_task); Ok(()) }) .await .map_err(|error| format!("FFmpeg extraction thread failed: {error}"))??; if !verify_ffmpeg(&target) { let _ = std::fs::remove_file(&target); return Err("The downloaded FFmpeg binary could not be executed".to_string()); } log::info!( target: "launcher", "FFmpeg {FFMPEG_VERSION} installed at {}", target.display() ); Ok(target) } #[cfg(target_os = "macos")] fn find_media_binary(name: &str) -> Option { let variable = format!("EZPP_{}", name.to_ascii_uppercase()); if let Some(value) = std::env::var_os(variable) { let path = PathBuf::from(value); if path.is_file() { return Some(path); } } let mut candidates = Vec::new(); if let Some(path) = std::env::var_os("PATH") { candidates.extend(std::env::split_paths(&path).map(|directory| directory.join(name))); } for directory in [ "/opt/homebrew/bin", "/usr/local/bin", "/opt/local/bin", "/usr/bin", ] { candidates.push(PathBuf::from(directory).join(name)); } candidates.into_iter().find(|path| path.is_file()) } #[cfg(target_os = "macos")] fn optimize_theme_media_blocking( ffmpeg: &Path, source: &Path, source_url: &str, cache_name: &str, destination: &Path, ) -> Result { use std::process::Command; let probe = Command::new(ffmpeg) .args(["-hide_banner", "-i"]) .arg(source) .output() .map_err(|error| format!("Could not probe theme media: {error}"))?; let probe_text = String::from_utf8_lossy(&probe.stderr); let codec = probe_text.lines().find_map(|line| { let index = line.find("Video: ")?; line[index + 7..] .split(|character: char| character == ',' || character == ' ') .next() .map(str::to_ascii_lowercase) }); let Some(codec) = codec else { return Ok(source_url.to_string()); }; if codec == "h264" || codec == "hevc" { return Ok(source_url.to_string()); } let temporary = destination.with_extension("mp4.tmp"); let _ = std::fs::remove_file(&temporary); log::info!( target: "launcher", "Optimizing theme video for hardware playback ({} -> 720p H.264)", source.display() ); let started = std::time::Instant::now(); let mut command = Command::new(ffmpeg); command.args(["-hide_banner", "-loglevel", "error", "-nostdin", "-y", "-i"]); command.arg(source); command.args([ "-map", "0:v:0", "-map", "0:a:0?", "-vf", "scale=-2:720", "-pix_fmt", "yuv420p", "-c:v", "h264_videotoolbox", "-b:v", "3500k", "-maxrate", "4500k", "-bufsize", "7000k", "-c:a", "aac", "-b:a", "192k", "-movflags", "+faststart", "-map_metadata", "-1", "-f", "mp4", ]); command.arg(&temporary); let first = command.output(); let success = match first { Ok(output) if output.status.success() => true, Ok(output) => { log::warn!( target: "launcher", "Hardware theme video encode failed: {}", String::from_utf8_lossy(&output.stderr) .chars() .take(500) .collect::() ); false } Err(error) => { log::warn!(target: "launcher", "Could not run ffmpeg: {error}"); false } }; let success = if success { true } else { let _ = std::fs::remove_file(&temporary); let mut fallback = Command::new(ffmpeg); fallback.args(["-hide_banner", "-loglevel", "error", "-nostdin", "-y", "-i"]); fallback.arg(source); fallback.args([ "-map", "0:v:0", "-map", "0:a:0?", "-vf", "scale=-2:720", "-pix_fmt", "yuv420p", "-c:v", "libx264", "-preset", "veryfast", "-crf", "22", "-c:a", "aac", "-b:a", "192k", "-movflags", "+faststart", "-map_metadata", "-1", "-f", "mp4", ]); fallback.arg(&temporary); match fallback.output() { Ok(output) if output.status.success() => true, Ok(output) => { log::warn!( target: "launcher", "Software theme video encode failed: {}", String::from_utf8_lossy(&output.stderr) .chars() .take(500) .collect::() ); false } Err(error) => { log::warn!(target: "launcher", "Could not run the fallback ffmpeg encode: {error}"); false } } }; if !success { let _ = std::fs::remove_file(&temporary); return Ok(source_url.to_string()); } std::fs::rename(&temporary, destination) .map_err(|error| format!("Could not store the optimized theme video: {error}"))?; log::info!( target: "launcher", "Optimized theme video in {:.1}s: {}", started.elapsed().as_secs_f64(), destination.display() ); Ok(crate::theme_server::media_url(cache_name).unwrap_or_else(|| source_url.to_string())) } #[derive(Serialize, Clone)] #[serde(rename_all = "camelCase")] pub struct ThemeEditorContext { pub folder_name: Option, pub folder_path: Option, pub theme_json: Option, } /// Open (or focus) the advanced theme editor in its own native window. #[tauri::command] pub async fn open_theme_editor(app: AppHandle, theme_folder: Option) -> Result<(), String> { let folder = match theme_folder.filter(|value| !value.trim().is_empty()) { Some(folder) => { let checked = checked_theme_folder(&folder)?; if !checked.join("theme.json").is_file() { return Err("That theme does not contain a theme.json".to_string()); } Some(checked.to_string_lossy().into_owned()) } None => None, }; *app.state::().editor_theme_folder.lock().unwrap() = folder; if let Some(window) = app.get_webview_window("theme-editor") { let _ = window.unminimize(); let _ = window.show(); let _ = window.set_focus(); if let Some(main) = app.get_webview_window("main") { let _ = main.hide(); } app.emit_to("theme-editor", "theme-editor-context", ()) .map_err(|error| error.to_string())?; let _ = app.emit_to("main", "theme-editor-state", true); return Ok(()); } let window = WebviewWindowBuilder::new(&app, "theme-editor", WebviewUrl::App("index.html".into())) .title("EZPPLauncher Theme Editor") .inner_size(1440.0, 900.0) .min_inner_size(1100.0, 720.0) .resizable(true) // Tauri's native file-drop handler intercepts HTML5 drag events on // macOS/WebKit. The editor only needs HTML5 drag and drop for its layer // tree, while the main launcher keeps native theme-file dropping. .disable_drag_drop_handler() .center() .build() .map_err(|error| format!("Could not open the theme editor: {error}"))?; // Pause the launcher's own theme media while the editor window exists. The // editor is a separate WebView, so the launcher needs an explicit signal // rather than relying on focus/blur events. let main_window = app.clone(); window.on_window_event(move |event| { if matches!(event, WindowEvent::Destroyed) { if let Some(main) = main_window.get_webview_window("main") { let _ = main.show(); let _ = main.unminimize(); let _ = main.set_focus(); } let _ = main_window.emit_to("main", "theme-editor-state", false); } }); if let Some(main) = app.get_webview_window("main") { let _ = main.hide(); } let _ = app.emit_to("main", "theme-editor-state", true); Ok(()) } /// Focus the theme editor window and keep the main launcher window hidden. #[tauri::command] pub async fn focus_theme_editor(app: AppHandle) -> Result<(), String> { if let Some(window) = app.get_webview_window("theme-editor") { let _ = window.unminimize(); let _ = window.show(); let _ = window.set_focus(); if let Some(main) = app.get_webview_window("main") { let _ = main.hide(); } } Ok(()) } /// Close the theme editor window and restore the main launcher window. #[tauri::command] pub async fn close_theme_editor(app: AppHandle) -> Result<(), String> { if let Some(window) = app.get_webview_window("theme-editor") { let _ = window.close(); } if let Some(main) = app.get_webview_window("main") { let _ = main.show(); let _ = main.unminimize(); let _ = main.set_focus(); } let _ = app.emit_to("main", "theme-editor-state", false); Ok(()) } /// Open (or focus) the discord presence embed editor in its own native window. #[tauri::command] pub async fn open_presence_editor(app: AppHandle) -> Result<(), String> { if let Some(window) = app.get_webview_window("presence-editor") { let _ = window.unminimize(); let _ = window.show(); let _ = window.set_focus(); if let Some(main) = app.get_webview_window("main") { let _ = main.hide(); } let _ = app.emit_to("main", "presence-editor-state", true); return Ok(()); } let window = WebviewWindowBuilder::new(&app, "presence-editor", WebviewUrl::App("index.html".into())) .title("EZPPLauncher — Discord Rich Presence Editor") .inner_size(1200.0, 800.0) .min_inner_size(980.0, 650.0) .resizable(true) .center() .build() .map_err(|error| format!("Could not open the presence editor: {error}"))?; let main_window = app.clone(); window.on_window_event(move |event| { if matches!(event, WindowEvent::Destroyed) { if let Some(main) = main_window.get_webview_window("main") { let _ = main.show(); let _ = main.unminimize(); let _ = main.set_focus(); } let _ = main_window.emit_to("main", "presence-editor-state", false); } }); if let Some(main) = app.get_webview_window("main") { let _ = main.hide(); } let _ = app.emit_to("main", "presence-editor-state", true); Ok(()) } /// Focus the presence editor window and keep the main launcher window hidden. #[tauri::command] pub async fn focus_presence_editor(app: AppHandle) -> Result<(), String> { if let Some(window) = app.get_webview_window("presence-editor") { let _ = window.unminimize(); let _ = window.show(); let _ = window.set_focus(); if let Some(main) = app.get_webview_window("main") { let _ = main.hide(); } } Ok(()) } /// Close the presence editor window and restore the main launcher window. #[tauri::command] pub async fn close_presence_editor(app: AppHandle) -> Result<(), String> { if let Some(window) = app.get_webview_window("presence-editor") { let _ = window.close(); } if let Some(main) = app.get_webview_window("main") { let _ = main.show(); let _ = main.unminimize(); let _ = main.set_focus(); } let _ = app.emit_to("main", "presence-editor-state", false); Ok(()) } /// Theme data requested by the advanced editor window when it starts or switches themes. #[tauri::command] pub fn theme_editor_context(app: AppHandle) -> Result { let folder = app .state::() .editor_theme_folder .lock() .unwrap() .clone(); let Some(folder) = folder else { return Ok(ThemeEditorContext { folder_name: None, folder_path: None, theme_json: None, }); }; let path = PathBuf::from(&folder); let folder_name = path .file_name() .and_then(|name| name.to_str()) .map(ToOwned::to_owned); let theme_json = std::fs::read_to_string(path.join("theme.json")).ok(); Ok(ThemeEditorContext { folder_name, folder_path: Some(folder), theme_json, }) } #[derive(Serialize, Clone)] #[serde(rename_all = "camelCase")] pub struct ThemeBackupInfo { pub path: String, pub label: String, pub created_at: u64, pub size: u64, } fn theme_backups_root() -> Result { let home = std::env::var("HOME") .or_else(|_| std::env::var("USERPROFILE")) .map_err(|_| "Could not locate the home directory".to_string())?; let root = PathBuf::from(home) .join(".ezpplauncher") .join("theme-backups"); std::fs::create_dir_all(&root) .map_err(|error| format!("Failed to create the theme backup directory: {error}"))?; std::fs::canonicalize(&root) .map_err(|error| format!("Failed to resolve the theme backup directory: {error}")) } fn directory_size(path: &Path) -> u64 { let Ok(entries) = std::fs::read_dir(path) else { return 0; }; entries .filter_map(|entry| entry.ok()) .map(|entry| { let path = entry.path(); match entry.file_type() { Ok(kind) if kind.is_dir() => directory_size(&path), Ok(kind) if kind.is_file() => entry.metadata().map(|meta| meta.len()).unwrap_or(0), _ => 0, } }) .sum() } fn copy_theme_tree(source: &Path, destination: &Path) -> Result<(), String> { std::fs::create_dir_all(destination) .map_err(|error| format!("Failed to create {}: {error}", destination.display()))?; for entry in std::fs::read_dir(source) .map_err(|error| format!("Failed to read {}: {error}", source.display()))? { let entry = entry.map_err(|error| format!("Failed to read theme entry: {error}"))?; let kind = entry .file_type() .map_err(|error| format!("Failed to inspect theme entry: {error}"))?; if kind.is_symlink() { continue; } let target = destination.join(entry.file_name()); if kind.is_dir() { copy_theme_tree(&entry.path(), &target)?; } else if kind.is_file() { std::fs::copy(entry.path(), &target) .map_err(|error| format!("Failed to copy theme file: {error}"))?; } } Ok(()) } fn theme_backup_dir(theme_folder: &Path) -> Result { let name = theme_folder .file_name() .and_then(|name| name.to_str()) .ok_or_else(|| "The theme folder has no name".to_string())?; Ok(theme_backups_root()?.join(name)) } fn prune_theme_backups(directory: &Path) -> Result<(), String> { let mut entries = std::fs::read_dir(directory) .map_err(|error| format!("Failed to read theme backups: {error}"))? .filter_map(|entry| entry.ok()) .filter(|entry| entry.file_type().map(|kind| kind.is_dir()).unwrap_or(false)) .collect::>(); entries.sort_by_key(|entry| entry.file_name()); while entries.len() > 5 { let oldest = entries.remove(0); let _ = std::fs::remove_dir_all(oldest.path()); } Ok(()) } /// Copy a theme aside before overwriting it during an update. #[tauri::command] pub async fn backup_theme_folder(theme_folder: String) -> Result { let folder = checked_theme_folder(&theme_folder)?; if !folder.is_dir() { return Err("The theme folder does not exist".to_string()); } tokio::task::spawn_blocking(move || { let backup_root = theme_backup_dir(&folder)?; std::fs::create_dir_all(&backup_root) .map_err(|error| format!("Failed to create the theme backup directory: {error}"))?; let stamp = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|duration| duration.as_millis()) .unwrap_or(0); let destination = backup_root.join(format!("backup_{stamp}")); copy_theme_tree(&folder, &destination)?; prune_theme_backups(&backup_root)?; Ok(destination.to_string_lossy().into_owned()) }) .await .map_err(|error| format!("Thread error: {error}"))? } #[tauri::command] pub async fn list_theme_backups(theme_folder: String) -> Result, String> { let folder = checked_theme_folder(&theme_folder)?; tokio::task::spawn_blocking(move || { let backup_root = theme_backup_dir(&folder)?; if !backup_root.is_dir() { return Ok(Vec::new()); } let mut backups = std::fs::read_dir(&backup_root) .map_err(|error| format!("Failed to read theme backups: {error}"))? .filter_map(|entry| entry.ok()) .filter(|entry| entry.file_type().map(|kind| kind.is_dir()).unwrap_or(false)) .filter_map(|entry| { let path = entry.path(); let created_at = entry .metadata() .ok() .and_then(|meta| meta.modified().ok()) .and_then(|time| time.duration_since(std::time::UNIX_EPOCH).ok()) .map(|duration| duration.as_millis() as u64) .unwrap_or(0); Some(ThemeBackupInfo { label: entry.file_name().to_string_lossy().into_owned(), path: path.to_string_lossy().into_owned(), created_at, size: directory_size(&path), }) }) .collect::>(); backups.sort_by(|a, b| b.created_at.cmp(&a.created_at)); Ok(backups) }) .await .map_err(|error| format!("Thread error: {error}"))? } #[tauri::command] pub async fn restore_theme_backup( theme_folder: String, backup_folder: String, ) -> Result<(), String> { let folder = checked_theme_folder(&theme_folder)?; tokio::task::spawn_blocking(move || { let allowed_root = theme_backup_dir(&folder)?; let backup = std::fs::canonicalize(&backup_folder) .map_err(|error| format!("Failed to resolve the theme backup: {error}"))?; if !backup.starts_with(&allowed_root) || !backup.is_dir() { return Err("The backup is outside this theme's backup folder".to_string()); } if folder.exists() { std::fs::remove_dir_all(&folder) .map_err(|error| format!("Failed to replace the theme folder: {error}"))?; } copy_theme_tree(&backup, &folder) }) .await .map_err(|error| format!("Thread error: {error}"))? } /// Only allow writing inside `~/.ezpplauncher/themes`. fn checked_theme_folder(theme_folder: &str) -> Result { let root = theme_root()?; let requested = PathBuf::from(theme_folder); if !requested.is_absolute() { return Err("The theme folder must be an absolute path".to_string()); } let folder = if requested.exists() { std::fs::canonicalize(&requested) .map_err(|e| format!("Failed to resolve the theme folder: {e}"))? } else { let parent = requested .parent() .ok_or_else(|| "The theme folder has no parent directory".to_string())?; std::fs::create_dir_all(parent) .map_err(|e| format!("Failed to create the theme directory: {e}"))?; let parent = std::fs::canonicalize(parent) .map_err(|e| format!("Failed to resolve the theme directory: {e}"))?; let name = requested .file_name() .ok_or_else(|| "The theme folder has no name".to_string())?; parent.join(name) }; if folder == root || !folder.starts_with(&root) { return Err("The theme folder is outside the themes directory".to_string()); } Ok(folder) } #[derive(Deserialize)] pub struct ThemeAssetInput { pub name: String, pub source: String, } /// Write a theme created or edited by the in-app theme editor. #[tauri::command] pub async fn save_theme_project( theme_folder: String, config: String, assets: Vec, ) -> Result<(), String> { let folder = checked_theme_folder(&theme_folder)?; let parsed: Value = serde_json::from_str(&config) .map_err(|e| format!("The theme config is not valid JSON: {e}"))?; if parsed.get("apiVersion").and_then(Value::as_str) != Some("2.0") { return Err("The theme editor can only save API 2.0 themes".to_string()); } if parsed .get("name") .and_then(Value::as_str) .map(str::trim) .filter(|name| !name.is_empty()) .is_none() { return Err("The theme needs a name".to_string()); } let assets_folder = folder.join("assets"); tokio::fs::create_dir_all(&assets_folder) .await .map_err(|e| format!("Failed to create the assets folder: {e}"))?; for asset in assets { let name = asset.name.trim(); if name.is_empty() || name == "." || name == ".." || name.contains('/') || name.contains('\\') { return Err(format!("Invalid theme asset name: {name}")); } let source = PathBuf::from(&asset.source); if !source.is_file() { return Err(format!("Theme asset not found: {}", source.display())); } let destination = assets_folder.join(name); if source.canonicalize().ok() == destination.canonicalize().ok() { continue; } tokio::fs::copy(&source, &destination) .await .map_err(|e| format!("Failed to copy theme asset {name}: {e}"))?; } tokio::fs::write(folder.join("theme.json"), config) .await .map_err(|e| format!("Failed to write theme.json: {e}"))?; Ok(()) } /// Resolve a `theme.json` asset reference to a path relative to `assets/`. /// Remote URLs, data URLs and paths that could escape the assets folder are /// ignored. fn theme_asset_reference(src: &str) -> Option { let value = src.trim(); if value.is_empty() || value.contains("://") || value.starts_with('/') || value.starts_with("data:") || value.starts_with("blob:") || value.starts_with("asset:") { return None; } let normalized = value.replace('\\', "/"); let normalized = normalized.trim_start_matches("./"); if normalized.is_empty() || normalized .split('/') .any(|part| part.is_empty() || part == "." || part == "..") { return None; } Some(normalized.to_string()) } fn collect_theme_asset_refs(value: &Value, assets: &mut HashSet) { match value { Value::Object(object) => { for (key, value) in object { if key == "src" || key == "preview" { if let Some(src) = value.as_str() { if let Some(path) = theme_asset_reference(src) { assets.insert(path); } } } collect_theme_asset_refs(value, assets); } } Value::Array(values) => { for value in values { collect_theme_asset_refs(value, assets); } } _ => {} } } fn collect_svelte_sources(value: &Value, sources: &mut Vec) { let Some(elements) = value.get("elements").and_then(Value::as_array) else { return; }; for element in elements { if element.get("type").and_then(Value::as_str) != Some("svelte") { continue; } if let Some(source) = element.get("source").and_then(Value::as_str) { sources.push(source.to_string()); } } } /// Custom Svelte layers load assets through `asset("path")`, which is not a /// `src` field in theme.json. Keep any asset whose relative path appears in a /// component source so exports do not drop files used by custom layers. fn collect_svelte_asset_refs(assets_root: &Path, sources: &[String], assets: &mut HashSet) { if sources.is_empty() { return; } let Ok(entries) = std::fs::read_dir(assets_root) else { return; }; for entry in entries.flatten() { let path = entry.path(); if path.is_dir() { collect_svelte_asset_refs(&path, sources, assets); continue; } if !path.is_file() { continue; } let Ok(relative) = path.strip_prefix(assets_root) else { continue; }; let key = relative.to_string_lossy().replace('\\', "/"); if sources.iter().any(|source| source.contains(&key)) { assets.insert(key); } } } /// For API 2.0 packages, return the assets referenced by `theme.json` so an /// export can omit orphaned files left behind by older edits. API 1.0 themes /// can load assets dynamically from their bundle, so they are exported whole. fn referenced_theme_assets(theme_json: &Path) -> Option> { let text = std::fs::read_to_string(theme_json).ok()?; let parsed: Value = serde_json::from_str(&text).ok()?; let api_version = parsed.get("apiVersion")?.as_str()?; if api_version.split('.').next() != Some("2") { return None; } let mut assets = HashSet::new(); // The launcher falls back to this filename for theme thumbnails even when // `preview` is omitted from theme.json. assets.insert("preview.png".to_string()); collect_theme_asset_refs(&parsed, &mut assets); if let Some(folder) = theme_json.parent() { let assets_root = folder.join("assets"); let mut sources = Vec::new(); collect_svelte_sources(&parsed, &mut sources); collect_svelte_asset_refs(&assets_root, &sources, &mut assets); } Some(assets) } fn add_theme_folder_to_zip( zip: &mut zip::ZipWriter, root: &Path, directory: &Path, asset_filter: Option<&HashSet>, options: zip::write::SimpleFileOptions, ) -> Result<(), String> { let entries = std::fs::read_dir(directory) .map_err(|e| format!("Failed to read {}: {e}", directory.display()))?; let assets_root = root.join("assets"); for entry in entries { let entry = entry.map_err(|e| format!("Failed to read theme entry: {e}"))?; let file_type = entry .file_type() .map_err(|e| format!("Failed to inspect theme entry: {e}"))?; if file_type.is_symlink() { continue; } let entry_path = entry.path(); if file_type.is_dir() { add_theme_folder_to_zip(zip, root, &entry_path, asset_filter, options)?; continue; } if !file_type.is_file() { continue; } if let Some(filter) = asset_filter { if let Ok(asset_path) = entry_path.strip_prefix(&assets_root) { let key = asset_path.to_string_lossy().replace('\\', "/"); if !filter.contains(&key) { continue; } } } let relative = entry_path .strip_prefix(root) .map_err(|e| format!("Failed to build archive path: {e}"))? .to_string_lossy() .replace('\\', "/"); zip.start_file(relative, options) .map_err(|e| format!("Failed to add a file to the theme archive: {e}"))?; let mut file = std::fs::File::open(&entry_path) .map_err(|e| format!("Failed to open {}: {e}", entry_path.display()))?; std::io::copy(&mut file, zip) .map_err(|e| format!("Failed to write the theme archive: {e}"))?; } Ok(()) } /// Package a theme folder as a shareable `.ezpplauncher-theme` file. #[tauri::command] pub async fn export_theme(theme_folder: String, destination_path: String) -> Result<(), String> { let folder = checked_theme_folder(&theme_folder)?; if !folder.is_dir() { return Err("The theme folder does not exist".to_string()); } let destination = PathBuf::from(destination_path); if !destination.is_absolute() { return Err("The export path must be absolute".to_string()); } if destination.starts_with(&folder) { return Err("Choose a destination outside the theme folder".to_string()); } tokio::task::spawn_blocking(move || { let asset_filter = referenced_theme_assets(&folder.join("theme.json")); let file = std::fs::File::create(&destination) .map_err(|e| format!("Failed to create the theme archive: {e}"))?; let mut zip = zip::ZipWriter::new(file); let options = zip::write::SimpleFileOptions::default() .compression_method(zip::CompressionMethod::Deflated); add_theme_folder_to_zip(&mut zip, &folder, &folder, asset_filter.as_ref(), options)?; zip.finish() .map_err(|e| format!("Failed to finish the theme archive: {e}"))?; Ok::<_, String>(()) }) .await .map_err(|e| format!("Thread error: {e}"))??; Ok(()) } #[tauri::command] pub async fn open_theme_folder(theme_folder: String) -> Result<(), String> { let root = theme_root()?; let requested = std::fs::canonicalize(&theme_folder).unwrap_or_else(|_| root.clone()); if requested != root && !requested.starts_with(&root) { return Err("The theme folder is outside the themes directory".to_string()); } open::that(&requested).map_err(|e| format!("Failed to open the themes folder: {e}"))?; Ok(()) } #[tauri::command] pub fn config_get(key: String, state: State) -> Result, ConfigError> { state.config.lock().unwrap().get(&key) } #[tauri::command] pub fn config_set( key: String, value: Value, encrypt: bool, state: State, ) -> Result<(), ConfigError> { state.config.lock().unwrap().set(&key, value, encrypt) } #[tauri::command] pub fn config_exists(key: String, state: State) -> Result { state.config.lock().unwrap().exists(&key) } #[tauri::command] pub fn config_delete(key: String, state: State) -> Result<(), ConfigError> { state.config.lock().unwrap().delete(&key) } #[tauri::command] pub fn config_clear(state: State) -> Result<(), ConfigError> { state.config.lock().unwrap().clear() } #[tauri::command] pub fn config_all(state: State) -> Result, ConfigError> { state.config.lock().unwrap().all() } #[tauri::command] pub async fn reveal_window(window: tauri::WebviewWindow) -> Result<(), String> { let (tx, rx) = tokio::sync::oneshot::channel(); let win = window.clone(); window .run_on_main_thread(move || { let _ = win.show(); let _ = win.unminimize(); let _ = win.set_focus(); #[cfg(target_os = "macos")] { let res = crate::macos::activate_and_focus_window(&win); let _ = tx.send(res); return; } #[cfg(not(target_os = "macos"))] let _ = tx.send(Ok(())); }) .map_err(|e| e.to_string())?; rx.await .map_err(|_| "Main thread channel closed".to_string())? }