diff --git a/src-tauri/src/commands/config.rs b/src-tauri/src/commands/config.rs index b5ee00e..80a6030 100644 --- a/src-tauri/src/commands/config.rs +++ b/src-tauri/src/commands/config.rs @@ -53,3 +53,39 @@ pub fn import_theme(app: AppHandle) -> Result { Err("CANCELED".into()) } } + +#[tauri::command] +pub fn export_settings(app: AppHandle) -> Result<(), String> { + let config = config::load_config_raw(app.clone()); + let json = serde_json::to_string_pretty(&config).map_err(|e| e.to_string())?; + let file = rfd::FileDialog::new() + .add_filter("Emerald Settings", &["json"]) + .set_title("Export Launcher Settings") + .set_file_name("emerald_settings.json") + .save_file(); + + if let Some(path) = file { + fs::write(&path, &json).map_err(|e| e.to_string())?; + Ok(()) + } else { + Err("CANCELED".into()) + } +} + +#[tauri::command] +pub fn import_settings(app: AppHandle) -> Result { + let file = rfd::FileDialog::new() + .add_filter("Emerald Settings", &["json"]) + .set_title("Import Launcher Settings") + .pick_file(); + + if let Some(path) = file { + let content = fs::read_to_string(&path).map_err(|e| e.to_string())?; + let config: AppConfig = + serde_json::from_str(&content).map_err(|_| "Invalid settings JSON format".to_string())?; + config::save_config_raw(&app, &config); + Ok("Settings imported successfully! Restart the launcher to apply.".into()) + } else { + Err("CANCELED".into()) + } +} diff --git a/src-tauri/src/commands/game.rs b/src-tauri/src/commands/game.rs index 0076acb..db6df6b 100644 --- a/src-tauri/src/commands/game.rs +++ b/src-tauri/src/commands/game.rs @@ -5,11 +5,12 @@ use tauri::{AppHandle, Manager, State}; use tauri_plugin_opener::OpenerExt; use crate::commands::runners; use crate::config; +use crate::types::AppConfig; #[cfg(target_os = "macos")] use crate::platform::macos; #[cfg(unix)] use crate::platform::linux; -use crate::playtime::{self, PlaytimeResponse}; +use crate::playtime::{self, PlaytimeResponse, PlaytimeDayEntry}; use crate::state::GameState; use crate::types::McServer; use crate::util; @@ -53,9 +54,9 @@ pub async fn launch_game( #[cfg(target_os = "linux")] { - if let Some(runner_id) = config_val.linux_runner { + if let Some(ref runner_id) = config_val.linux_runner { let runners_list = runners::get_available_runners(app.clone()); - if let Some(runner) = runners_list.into_iter().find(|r| r.id == runner_id) { + if let Some(runner) = runners_list.into_iter().find(|r| r.id == *runner_id) { let is_proton = runner.r#type == "proton"; let program = if is_proton { PathBuf::from(&runner.path).join("proton").to_string_lossy().to_string() @@ -81,11 +82,16 @@ pub async fn launch_game( (&program, &[]) }; - let mut cmd = tokio::process::Command::new(prog); + let mut all_args: Vec = Vec::new(); for a in runner_args { - cmd.arg(a); + all_args.push(a.to_string()); } - for a in &args { + all_args.extend(args.clone()); + all_args.push(game_exe.to_string_lossy().to_string()); + all_args.extend(extra_args.clone()); + let (final_prog, final_args) = apply_launch_prefix(prog, all_args, &config_val); + let mut cmd = tokio::process::Command::new(&final_prog); + for a in &final_args { cmd.arg(a); } @@ -110,10 +116,7 @@ pub async fn launch_game( cmd.env_remove("QT_PLUGIN_PATH"); } - cmd.arg(&game_exe); - for a in &extra_args { - cmd.arg(a); - } + apply_launch_env_vars(&mut cmd, &config_val); cmd.current_dir(&working_dir); let playtime_start = std::time::Instant::now(); let child = cmd.spawn().map_err(|e| e.to_string())?; @@ -179,25 +182,30 @@ pub async fn launch_game( .map(|pp| pp.to_path_buf()) .ok_or_else(|| "Unable to locate wine bin directory inside runtime.".to_string())?; - let mut cmd = if let Some(wrapper) = gptk_no_hud { + let (mac_prog, mut mac_args): (String, Vec) = if let Some(ref wrapper) = gptk_no_hud { let win_path = util::unix_path_to_wine_z_path(&game_exe); - let mut c = tokio::process::Command::new(wrapper); - c.arg(&prefix_dir); - c.arg(win_path); - c + (wrapper.to_string_lossy().to_string(), vec![ + prefix_dir.to_string_lossy().to_string(), + win_path, + ]) } else { - let mut c = tokio::process::Command::new(&wine_binary); - c.env("WINEPREFIX", &prefix_dir); - c.arg(&game_exe); - c + (wine_binary.to_string_lossy().to_string(), vec![ + game_exe.to_string_lossy().to_string(), + ]) }; - for a in &extra_args { + + mac_args.extend(extra_args.clone()); + + let (final_prog, final_args) = apply_launch_prefix(&mac_prog, mac_args, &config_val); + let mut cmd = tokio::process::Command::new(&final_prog); + for a in &final_args { cmd.arg(a); } #[cfg(unix)] cmd.process_group(0); + apply_launch_env_vars(&mut cmd, &config_val); cmd.current_dir(&working_dir); cmd.env("WINEPREFIX", &prefix_dir); cmd.env("WINEDEBUG", "-all"); @@ -270,12 +278,16 @@ pub async fn launch_game( #[cfg(all(not(target_os = "macos"), not(target_os = "linux")))] { - let mut cmd = tokio::process::Command::new(&game_exe); - for a in &extra_args { + let exe_str = game_exe.to_string_lossy().to_string(); + let all_args: Vec = extra_args.clone(); + let (final_prog, final_args) = apply_launch_prefix(&exe_str, all_args, &config_val); + let mut cmd = tokio::process::Command::new(&final_prog); + for a in &final_args { cmd.arg(a); } #[cfg(unix)] cmd.process_group(0); + apply_launch_env_vars(&mut cmd, &config_val); cmd.current_dir(&working_dir); let playtime_start = std::time::Instant::now(); let child = cmd.spawn().map_err(|e| e.to_string())?; @@ -386,6 +398,93 @@ pub fn get_playtime(app: AppHandle, instance_id: String) -> PlaytimeResponse { playtime::get_playtime(&app, &instance_id) } +#[tauri::command] +pub fn get_playtime_daily(app: AppHandle, instance_id: String, days: u64) -> Vec { + playtime::get_playtime_daily(&app, &instance_id, days) +} + +#[tauri::command] +pub fn backup_instance(app: AppHandle, instance_id: String) -> Result<(), String> { + let instance_dir = util::get_instance_working_dir(&app, &instance_id); + if !instance_dir.exists() { + return Err("Instance not found".into()); + } + + let backup_name = format!("{}_backup.tar.gz", instance_id); + let file = rfd::FileDialog::new() + .set_title("Save Instance Backup") + .set_file_name(&backup_name) + .add_filter("Tar Gzip Archive", &["tar.gz"]) + .save_file(); + + if let Some(path) = file { + let parent = instance_dir.parent().ok_or("Invalid instance path")?; + let dir_name = instance_dir.file_name().ok_or("Invalid dir name")?; + + let status = std::process::Command::new("tar") + .args(["-czf", path.to_str().unwrap(), "-C", parent.to_str().unwrap(), dir_name.to_str().unwrap()]) + .status() + .map_err(|e| format!("Failed to create backup: {}", e))?; + + if status.success() { + Ok(()) + } else { + Err("Backup command failed".into()) + } + } else { + Err("CANCELED".into()) + } +} + +#[tauri::command] +pub fn restore_instance(app: AppHandle) -> Result { + let file = rfd::FileDialog::new() + .set_title("Restore Instance Backup") + .add_filter("Tar Gzip Archive", &["tar.gz"]) + .add_filter("Zip Archive", &["zip"]) + .pick_file(); + + if let Some(path) = file { + let instances_dir = util::get_app_dir(&app).join("instances"); + std::fs::create_dir_all(&instances_dir).map_err(|e| e.to_string())?; + + let ext = path.extension().and_then(|s| s.to_str()).unwrap_or(""); + let status = if ext == "zip" { + std::process::Command::new("unzip") + .arg("-o") + .arg(path.to_str().unwrap()) + .arg("-d") + .arg(instances_dir.to_str().unwrap()) + .status() + .map_err(|e| format!("Failed to extract backup: {}", e))? + } else { + std::process::Command::new("tar") + .args(["-xzf", path.to_str().unwrap(), "-C", instances_dir.to_str().unwrap()]) + .status() + .map_err(|e| format!("Failed to extract backup: {}", e))? + }; + + if status.success() { + let extracted: Vec<_> = std::fs::read_dir(&instances_dir) + .map_err(|e| e.to_string())? + .filter_map(|e| e.ok()) + .filter(|e| e.path().is_dir() && e.path().join("Minecraft.Client.exe").exists()) + .collect(); + + if let Some(new_instance) = extracted.into_iter().next() { + let id = new_instance.file_name().to_string_lossy().to_string(); + Ok(id) + } else { + Ok("restored".into()) + } + } else { + Err("Restore command failed".into()) + } + } else { + Err("CANCELED".into()) + } +} + pub fn ensure_server_list(instance_dir: &PathBuf, servers: Vec) { let servers_db = instance_dir.join("servers.db"); let mut all_servers = Vec::new(); @@ -503,6 +602,30 @@ fn perform_dlc_sync(app: &AppHandle, instance_dir: &PathBuf) -> Result<(), Strin } } +fn apply_launch_prefix(program: &str, args: Vec, config: &AppConfig) -> (String, Vec) { + if let Some(ref prefix) = config.launch_prefix { + let trimmed = prefix.trim(); + if !trimmed.is_empty() { + let parts: Vec<&str> = trimmed.split_whitespace().collect(); + if !parts.is_empty() { + let mut all_args: Vec = parts[1..].iter().map(|s| s.to_string()).collect(); + all_args.push(program.to_string()); + all_args.extend(args); + return (parts[0].to_string(), all_args); + } + } + } + (program.to_string(), args) +} + +fn apply_launch_env_vars(cmd: &mut tokio::process::Command, config: &AppConfig) { + if let Some(ref env_vars) = config.launch_env_vars { + for (k, v) in env_vars { + cmd.env(k, v); + } + } +} + async fn perform_instance_sync(app: &AppHandle, instance_id: &str) -> Result<(), String> { let target_dir = util::get_instance_working_dir(app, instance_id); if !target_dir.exists() { diff --git a/src-tauri/src/config.rs b/src-tauri/src/config.rs index b876c8b..fb16c7c 100644 --- a/src-tauri/src/config.rs +++ b/src-tauri/src/config.rs @@ -30,6 +30,9 @@ pub fn load_config_raw(app: AppHandle) -> AppConfig { legacy_mode: Some(false), mangohud_enabled: None, saved_servers: None, + extra_launch_args: None, + launch_prefix: None, + launch_env_vars: None, } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index f0cfceb..af0247e 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -57,6 +57,8 @@ pub fn run() { runners::get_available_runners, config_cmds::get_external_palettes, config_cmds::import_theme, + config_cmds::export_settings, + config_cmds::import_settings, file_dialogs::pick_folder, download::download_runner, game::delete_instance, @@ -82,6 +84,9 @@ pub fn run() { proxy_cmd::http_proxy_request, game::get_instance_path, game::get_playtime, + game::get_playtime_daily, + game::backup_instance, + game::restore_instance, commands::console2lce::import_world, stun::stun_discover, direct::start_direct_proxy, diff --git a/src-tauri/src/playtime.rs b/src-tauri/src/playtime.rs index 33465bf..a9ae036 100644 --- a/src-tauri/src/playtime.rs +++ b/src-tauri/src/playtime.rs @@ -72,3 +72,46 @@ pub fn get_playtime(app: &AppHandle, instance_id: &str) -> PlaytimeResponse { PlaytimeResponse { total_seconds, week_seconds, day_seconds } } + +#[derive(Serialize, Clone, Debug)] +#[serde(rename_all = "camelCase")] +pub struct PlaytimeDayEntry { + pub label: String, + pub seconds: u64, +} + +pub fn get_playtime_daily(app: &AppHandle, instance_id: &str, days: u64) -> Vec { + let data = load(app); + let sessions = data.sessions.get(instance_id); + let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs(); + let day_secs = 24 * 60 * 60; + let mut entries: Vec<(u64, u64)> = (0..days).map(|d| { + let day_start = now - (d * day_secs) - (now % day_secs); + (day_start, 0) + }).collect(); + + if let Some(sessions) = sessions { + for session in sessions { + for entry in entries.iter_mut() { + let day_end = entry.0 + day_secs; + if session.start < day_end && session.end > entry.0 { + let overlap_start = std::cmp::max(session.start, entry.0); + let overlap_end = std::cmp::min(session.end, day_end); + if overlap_end > overlap_start { + entry.1 += overlap_end - overlap_start; + } + } + } + } + } + + entries.reverse(); + let day_names = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; + entries.into_iter().map(|(ts, secs)| { + let weekday = ((ts / day_secs) + 4) % 7; + PlaytimeDayEntry { + label: day_names[weekday as usize].to_string(), + seconds: secs, + } + }).collect() +} diff --git a/src-tauri/src/types.rs b/src-tauri/src/types.rs index 17a1ecc..44db3b7 100644 --- a/src-tauri/src/types.rs +++ b/src-tauri/src/types.rs @@ -45,6 +45,9 @@ pub struct AppConfig { pub legacy_mode: Option, pub mangohud_enabled: Option, pub saved_servers: Option>, + pub extra_launch_args: Option>, + pub launch_prefix: Option, + pub launch_env_vars: Option>, } #[derive(Serialize, Deserialize, Clone, Debug)] diff --git a/src/components/modals/PlaytimeModal.tsx b/src/components/modals/PlaytimeModal.tsx index ecb999a..5dd3e1f 100644 --- a/src/components/modals/PlaytimeModal.tsx +++ b/src/components/modals/PlaytimeModal.tsx @@ -1,7 +1,10 @@ -import { useState, useEffect } from "react"; +import { useState, useEffect, useRef } from "react"; import { motion } from "framer-motion"; -import { TauriService, PlaytimeResponse } from "../../services/TauriService"; - +import { + TauriService, + PlaytimeResponse, + PlaytimeDayEntry, +} from "../../services/TauriService"; function formatTime(seconds: number): string { const h = Math.floor(seconds / 3600); const m = Math.floor((seconds % 3600) / 60); @@ -11,6 +14,54 @@ function formatTime(seconds: number): string { return `${s}s`; } +function formatShort(seconds: number): string { + const h = Math.floor(seconds / 3600); + const m = Math.floor((seconds % 3600) / 60); + if (h > 0) return `${h}h`; + if (m > 0) return `${m}m`; + return `${seconds}s`; +} + +function PlaytimeChart({ data }: { data: PlaytimeDayEntry[] }) { + const canvasRef = useRef(null); + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas || data.length === 0) return; + const ctx = canvas.getContext("2d"); + if (!ctx) return; + const dpr = window.devicePixelRatio || 1; + const w = 320; + const h = 120; + canvas.width = w * dpr; + canvas.height = h * dpr; + canvas.style.width = `${w}px`; + canvas.style.height = `${h}px`; + ctx.scale(dpr, dpr); + ctx.clearRect(0, 0, w, h); + const maxSecs = Math.max(...data.map((d) => d.seconds), 1); + const pad = { top: 8, bottom: 20, left: 6, right: 6 }; + const chartW = w - pad.left - pad.right; + const chartH = h - pad.top - pad.bottom; + const barW = Math.min(28, (chartW - (data.length - 1) * 4) / data.length); + const gap = (chartW - barW * data.length) / (data.length - 1); + data.forEach((entry, i) => { + const x = pad.left + i * (barW + gap); + const barH = (entry.seconds / maxSecs) * chartH; + const y = pad.top + chartH - barH; + ctx.fillStyle = "#FFFF55"; + ctx.fillRect(x, y, barW, barH); + ctx.fillStyle = "#FFFFFF"; + ctx.font = "9px Mojangles, monospace"; + ctx.textAlign = "center"; + ctx.fillText(formatShort(entry.seconds), x + barW / 2, y - 3); + ctx.fillStyle = "#AAAAAA"; + ctx.fillText(entry.label, x + barW / 2, h - 4); + }); + }, [data]); + + return ; +} + export default function PlaytimeModal({ isOpen, onClose, @@ -25,19 +76,27 @@ export default function PlaytimeModal({ playBackSound: (s?: string) => void; }) { const [playtime, setPlaytime] = useState(null); + const [dailyData, setDailyData] = useState([]); const [loading, setLoading] = useState(false); - + const [chartDays, setChartDays] = useState(7); useEffect(() => { if (!isOpen) { setPlaytime(null); + setDailyData([]); return; } setLoading(true); - TauriService.getPlaytime(instanceId) - .then(setPlaytime) + Promise.all([ + TauriService.getPlaytime(instanceId), + TauriService.getPlaytimeDaily(instanceId, chartDays), + ]) + .then(([pt, daily]) => { + setPlaytime(pt); + setDailyData(daily); + }) .catch(console.error) .finally(() => setLoading(false)); - }, [isOpen, instanceId]); + }, [isOpen, instanceId, chartDays]); useEffect(() => { if (!isOpen) return; @@ -52,7 +111,6 @@ export default function PlaytimeModal({ }, [isOpen, onClose, playBackSound]); if (!isOpen) return null; - return ( -

+

{instanceName}

{loading ? ( -
Loading...
- ) : playtime ? ( -
-
- - Total - - - {formatTime(playtime.totalSeconds)} - -
-
- - This Week - - - {formatTime(playtime.weekSeconds)} - -
-
- - Today - - - {formatTime(playtime.daySeconds)} - -
+
+ Loading...
+ ) : playtime ? ( + <> +
+
+ + Total + + + {formatTime(playtime.totalSeconds)} + +
+
+ + This Week + + + {formatTime(playtime.weekSeconds)} + +
+
+ + Today + + + {formatTime(playtime.daySeconds)} + +
+
+ + {dailyData.length > 0 && ( +
+
+ + Daily Breakdown + +
+ {[3, 7, 14].map((d) => ( + + ))} +
+
+
+ +
+
+ )} + ) : ( -
Failed to load playtime data.
+
+ Failed to load playtime data. +
)} + +
+ +
+ )} + + {showModal === "prefix" && ( + +
+

+ Launch Prefix +

+

+ Command that wraps the entire launch (e.g. gamemoderun) +

+ setPrefixInput(e.target.value)} + placeholder="e.g. gamemoderun" + className="w-full h-10 px-3 bg-black/40 border-2 border-[#373737] text-white text-base outline-none font-['Mojangles'] text-center" + style={{ imageRendering: "pixelated" }} + /> +
+ + +
+
+
+ )} + + {showModal === "envVars" && ( + +
+

+ Launch Env Vars +

+

+ One KEY=VALUE per line +

+