mirror of
https://github.com/LCE-Hub/LCE-Emerald-Launcher.git
synced 2026-07-15 23:10:48 +00:00
feat: another settings category, launch prefixes, backup&restore and more
This commit is contained in:
@@ -53,3 +53,39 @@ pub fn import_theme(app: AppHandle) -> Result<String, String> {
|
||||
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<String, String> {
|
||||
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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<String> = 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<String>) = 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<String> = 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<PlaytimeDayEntry> {
|
||||
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<String, String> {
|
||||
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<McServer>) {
|
||||
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<String>, config: &AppConfig) -> (String, Vec<String>) {
|
||||
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<String> = 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() {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<PlaytimeDayEntry> {
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -45,6 +45,9 @@ pub struct AppConfig {
|
||||
pub legacy_mode: Option<bool>,
|
||||
pub mangohud_enabled: Option<bool>,
|
||||
pub saved_servers: Option<Vec<McServer>>,
|
||||
pub extra_launch_args: Option<Vec<String>>,
|
||||
pub launch_prefix: Option<String>,
|
||||
pub launch_env_vars: Option<std::collections::HashMap<String, String>>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
|
||||
@@ -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<HTMLCanvasElement>(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 <canvas ref={canvasRef} className="w-full max-w-[320px] mx-auto" />;
|
||||
}
|
||||
|
||||
export default function PlaytimeModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
@@ -25,19 +76,27 @@ export default function PlaytimeModal({
|
||||
playBackSound: (s?: string) => void;
|
||||
}) {
|
||||
const [playtime, setPlaytime] = useState<PlaytimeResponse | null>(null);
|
||||
const [dailyData, setDailyData] = useState<PlaytimeDayEntry[]>([]);
|
||||
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 (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
@@ -72,41 +130,75 @@ export default function PlaytimeModal({
|
||||
Playtime
|
||||
</h2>
|
||||
|
||||
<p className="text-white text-sm mc-text-shadow mb-5 text-center">
|
||||
<p className="text-white text-sm mc-text-shadow mb-4 text-center">
|
||||
{instanceName}
|
||||
</p>
|
||||
|
||||
{loading ? (
|
||||
<div className="text-gray-400 text-sm mc-text-shadow mb-4">Loading...</div>
|
||||
) : playtime ? (
|
||||
<div className="w-full flex flex-col gap-3 mb-4">
|
||||
<div className="flex justify-between items-center bg-black/30 px-4 py-3 border border-[#373737]">
|
||||
<span className="text-[#AAAAAA] text-sm mc-text-shadow uppercase tracking-wider">
|
||||
Total
|
||||
</span>
|
||||
<span className="text-white text-lg mc-text-shadow font-bold">
|
||||
{formatTime(playtime.totalSeconds)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center bg-black/30 px-4 py-3 border border-[#373737]">
|
||||
<span className="text-[#AAAAAA] text-sm mc-text-shadow uppercase tracking-wider">
|
||||
This Week
|
||||
</span>
|
||||
<span className="text-white text-lg mc-text-shadow font-bold">
|
||||
{formatTime(playtime.weekSeconds)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center bg-black/30 px-4 py-3 border border-[#373737]">
|
||||
<span className="text-[#AAAAAA] text-sm mc-text-shadow uppercase tracking-wider">
|
||||
Today
|
||||
</span>
|
||||
<span className="text-white text-lg mc-text-shadow font-bold">
|
||||
{formatTime(playtime.daySeconds)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-gray-400 text-sm mc-text-shadow mb-4">
|
||||
Loading...
|
||||
</div>
|
||||
) : playtime ? (
|
||||
<>
|
||||
<div className="w-full flex flex-col gap-2 mb-4">
|
||||
<div className="flex justify-between items-center bg-black/30 px-4 py-2 border border-[#373737]">
|
||||
<span className="text-[#AAAAAA] text-sm mc-text-shadow uppercase tracking-wider">
|
||||
Total
|
||||
</span>
|
||||
<span className="text-white text-lg mc-text-shadow font-bold">
|
||||
{formatTime(playtime.totalSeconds)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center bg-black/30 px-4 py-2 border border-[#373737]">
|
||||
<span className="text-[#AAAAAA] text-sm mc-text-shadow uppercase tracking-wider">
|
||||
This Week
|
||||
</span>
|
||||
<span className="text-white text-lg mc-text-shadow font-bold">
|
||||
{formatTime(playtime.weekSeconds)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center bg-black/30 px-4 py-2 border border-[#373737]">
|
||||
<span className="text-[#AAAAAA] text-sm mc-text-shadow uppercase tracking-wider">
|
||||
Today
|
||||
</span>
|
||||
<span className="text-white text-lg mc-text-shadow font-bold">
|
||||
{formatTime(playtime.daySeconds)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{dailyData.length > 0 && (
|
||||
<div className="w-full mb-3">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-[#888] text-[10px] mc-text-shadow uppercase tracking-widest">
|
||||
Daily Breakdown
|
||||
</span>
|
||||
<div className="flex gap-1">
|
||||
{[3, 7, 14].map((d) => (
|
||||
<button
|
||||
key={d}
|
||||
onClick={() => setChartDays(d)}
|
||||
className={`text-[9px] px-1.5 py-0.5 border transition-colors ${
|
||||
chartDays === d
|
||||
? "border-[#FFFF55] text-[#FFFF55] bg-black/40"
|
||||
: "border-[#555] text-[#888] bg-black/20"
|
||||
} mc-text-shadow uppercase tracking-wider`}
|
||||
>
|
||||
{d}d
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-black/20 border border-[#373737] p-2">
|
||||
<PlaytimeChart data={dailyData} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="text-red-400 text-sm mc-text-shadow mb-4">Failed to load playtime data.</div>
|
||||
<div className="text-red-400 text-sm mc-text-shadow mb-4">
|
||||
Failed to load playtime data.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
|
||||
@@ -32,6 +32,12 @@ const SettingsView = memo(function SettingsView() {
|
||||
setLegacyMode,
|
||||
mangohudEnabled,
|
||||
setMangohudEnabled,
|
||||
extraLaunchArgs,
|
||||
setExtraLaunchArgs,
|
||||
launchPrefix,
|
||||
setLaunchPrefix,
|
||||
launchEnvVars,
|
||||
setLaunchEnvVars,
|
||||
} = useConfig();
|
||||
const {
|
||||
currentTrack,
|
||||
@@ -50,10 +56,16 @@ const SettingsView = memo(function SettingsView() {
|
||||
const { isLinux, isMac } = usePlatform();
|
||||
const [focusIndex, setFocusIndex] = useState<number | null>(null);
|
||||
const [currentSubMenu, setCurrentSubMenu] = useState<
|
||||
"main" | "audio" | "video" | "controls" | "launcher"
|
||||
"main" | "audio" | "video" | "controls" | "launcher" | "game"
|
||||
>("main");
|
||||
const [runners, setRunners] = useState<Runner[]>([]);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [argsInput, setArgsInput] = useState("");
|
||||
const [prefixInput, setPrefixInput] = useState("");
|
||||
const [envVarsInput, setEnvVarsInput] = useState("");
|
||||
const [showModal, setShowModal] = useState<
|
||||
"args" | "prefix" | "envVars" | null
|
||||
>(null);
|
||||
|
||||
const layouts = ["KBM", "PLAYSTATION", "XBOX"];
|
||||
|
||||
@@ -282,6 +294,16 @@ const SettingsView = memo(function SettingsView() {
|
||||
setFocusIndex(0);
|
||||
},
|
||||
});
|
||||
items.push({
|
||||
id: "game_menu",
|
||||
label: "Game",
|
||||
type: "button",
|
||||
onClick: () => {
|
||||
playPressSound();
|
||||
setCurrentSubMenu("game");
|
||||
setFocusIndex(0);
|
||||
},
|
||||
});
|
||||
} else if (currentSubMenu === "audio") {
|
||||
items.push({
|
||||
id: "music",
|
||||
@@ -331,6 +353,48 @@ const SettingsView = memo(function SettingsView() {
|
||||
type: "button",
|
||||
onClick: handleLayoutToggle,
|
||||
});
|
||||
} else if (currentSubMenu === "game") {
|
||||
const envVarsCount = launchEnvVars
|
||||
? Object.keys(launchEnvVars).length
|
||||
: 0;
|
||||
items.push({
|
||||
id: "extra_launch_args",
|
||||
label:
|
||||
extraLaunchArgs && extraLaunchArgs.length > 0
|
||||
? `Extra Args: ${extraLaunchArgs.join(" ")}`
|
||||
: "Extra Launch Args: None",
|
||||
type: "button",
|
||||
onClick: () => {
|
||||
playPressSound();
|
||||
setArgsInput(extraLaunchArgs?.join(" ") ?? "");
|
||||
setShowModal("args");
|
||||
},
|
||||
});
|
||||
items.push({
|
||||
id: "launch_prefix",
|
||||
label: launchPrefix ? `Prefix: ${launchPrefix}` : "Launch Prefix: None",
|
||||
type: "button",
|
||||
onClick: () => {
|
||||
playPressSound();
|
||||
setPrefixInput(launchPrefix ?? "");
|
||||
setShowModal("prefix");
|
||||
},
|
||||
});
|
||||
items.push({
|
||||
id: "launch_env_vars",
|
||||
label: `Launch Env Vars: ${envVarsCount > 0 ? `${envVarsCount} set` : "None"}`,
|
||||
type: "button",
|
||||
onClick: () => {
|
||||
playPressSound();
|
||||
const current = launchEnvVars
|
||||
? Object.entries(launchEnvVars)
|
||||
.map(([k, v]) => `${k}=${v}`)
|
||||
.join("\n")
|
||||
: "";
|
||||
setEnvVarsInput(current);
|
||||
setShowModal("envVars");
|
||||
},
|
||||
});
|
||||
} else if (currentSubMenu === "launcher") {
|
||||
items.push({
|
||||
id: "rpc",
|
||||
@@ -375,6 +439,32 @@ const SettingsView = memo(function SettingsView() {
|
||||
});
|
||||
}
|
||||
|
||||
items.push({
|
||||
id: "export_settings",
|
||||
label: "Export Settings",
|
||||
type: "button",
|
||||
onClick: async () => {
|
||||
playPressSound();
|
||||
try {
|
||||
await TauriService.exportSettings();
|
||||
} catch (e) {
|
||||
if (e !== "CANCELED") console.error(e);
|
||||
}
|
||||
},
|
||||
});
|
||||
items.push({
|
||||
id: "import_settings",
|
||||
label: "Import Settings",
|
||||
type: "button",
|
||||
onClick: async () => {
|
||||
playPressSound();
|
||||
try {
|
||||
await TauriService.importSettings();
|
||||
} catch (e) {
|
||||
if (e !== "CANCELED") console.error(e);
|
||||
}
|
||||
},
|
||||
});
|
||||
items.push({
|
||||
id: "reset_setup",
|
||||
label: "Reset Setup",
|
||||
@@ -444,11 +534,19 @@ const SettingsView = memo(function SettingsView() {
|
||||
playBackSound,
|
||||
setActiveView,
|
||||
runners,
|
||||
extraLaunchArgs,
|
||||
launchPrefix,
|
||||
launchEnvVars,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape" || e.key === "Backspace") {
|
||||
if (showModal) {
|
||||
playBackSound();
|
||||
setShowModal(null);
|
||||
return;
|
||||
}
|
||||
playBackSound();
|
||||
if (currentSubMenu !== "main") {
|
||||
setCurrentSubMenu("main");
|
||||
@@ -486,7 +584,14 @@ const SettingsView = memo(function SettingsView() {
|
||||
};
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [focusIndex, settingsItems, playBackSound, setActiveView, currentSubMenu]);
|
||||
}, [
|
||||
focusIndex,
|
||||
settingsItems,
|
||||
playBackSound,
|
||||
setActiveView,
|
||||
currentSubMenu,
|
||||
showModal,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (focusIndex !== null) {
|
||||
@@ -545,7 +650,9 @@ const SettingsView = memo(function SettingsView() {
|
||||
? "Video"
|
||||
: currentSubMenu === "controls"
|
||||
? "Controls"
|
||||
: "Launcher"}
|
||||
: currentSubMenu === "game"
|
||||
? "Game"
|
||||
: "Launcher"}
|
||||
</h2>
|
||||
|
||||
{currentSubMenu === "main" ? (
|
||||
@@ -584,8 +691,10 @@ const SettingsView = memo(function SettingsView() {
|
||||
);
|
||||
}
|
||||
|
||||
const isRed = ("color" in item && (item as { color: string }).color === "red");
|
||||
const isSmall = "small" in item && (item as { small: boolean }).small;
|
||||
const isRed =
|
||||
"color" in item && (item as { color: string }).color === "red";
|
||||
const isSmall =
|
||||
"small" in item && (item as { small: boolean }).small;
|
||||
|
||||
return (
|
||||
<button
|
||||
@@ -723,6 +832,261 @@ const SettingsView = memo(function SettingsView() {
|
||||
</button>
|
||||
);
|
||||
})()}
|
||||
|
||||
{showModal === "args" && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="fixed inset-0 w-screen h-screen z-[100] flex items-center justify-center bg-black/80 backdrop-blur-md"
|
||||
>
|
||||
<div
|
||||
className="relative w-[400px] p-6 flex flex-col items-center shadow-2xl"
|
||||
style={{
|
||||
backgroundImage: "url('/images/frame_background.png')",
|
||||
backgroundSize: "100% 100%",
|
||||
imageRendering: "pixelated",
|
||||
}}
|
||||
>
|
||||
<h2 className="text-[#FFFF55] text-2xl mc-text-shadow mb-4 border-b-2 border-[#373737] pb-2 w-full text-center uppercase">
|
||||
Extra Launch Args
|
||||
</h2>
|
||||
<p className="text-[#AAAAAA] text-xs mb-4 text-center mc-text-shadow">
|
||||
Space-separated arguments passed to the game executable
|
||||
</p>
|
||||
<input
|
||||
autoFocus
|
||||
value={argsInput}
|
||||
onChange={(e) => setArgsInput(e.target.value)}
|
||||
placeholder="e.g. -quitondisconnect -ip 127.0.0.1"
|
||||
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" }}
|
||||
/>
|
||||
<div className="flex gap-4 mt-6 w-full justify-center">
|
||||
<button
|
||||
onClick={() => {
|
||||
playBackSound();
|
||||
setShowModal(null);
|
||||
}}
|
||||
className="w-32 h-10 flex items-center justify-center text-xl mc-text-shadow text-white transition-colors outline-none border-none hover:text-[#FFFF55]"
|
||||
style={{
|
||||
backgroundImage: "url('/images/Button_Background.png')",
|
||||
backgroundSize: "100% 100%",
|
||||
imageRendering: "pixelated",
|
||||
}}
|
||||
onMouseEnter={(e) =>
|
||||
(e.currentTarget.style.backgroundImage =
|
||||
"url('/images/button_highlighted.png')")
|
||||
}
|
||||
onMouseLeave={(e) =>
|
||||
(e.currentTarget.style.backgroundImage =
|
||||
"url('/images/Button_Background.png')")
|
||||
}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
playPressSound();
|
||||
const trimmed = argsInput.trim();
|
||||
setExtraLaunchArgs(trimmed ? trimmed.split(/\s+/) : []);
|
||||
setShowModal(null);
|
||||
}}
|
||||
className="w-32 h-10 flex items-center justify-center text-xl mc-text-shadow text-white transition-colors outline-none border-none hover:text-[#FFFF55]"
|
||||
style={{
|
||||
backgroundImage: "url('/images/Button_Background.png')",
|
||||
backgroundSize: "100% 100%",
|
||||
imageRendering: "pixelated",
|
||||
}}
|
||||
onMouseEnter={(e) =>
|
||||
(e.currentTarget.style.backgroundImage =
|
||||
"url('/images/button_highlighted.png')")
|
||||
}
|
||||
onMouseLeave={(e) =>
|
||||
(e.currentTarget.style.backgroundImage =
|
||||
"url('/images/Button_Background.png')")
|
||||
}
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{showModal === "prefix" && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="fixed inset-0 w-screen h-screen z-[100] flex items-center justify-center bg-black/80 backdrop-blur-md"
|
||||
>
|
||||
<div
|
||||
className="relative w-[400px] p-6 flex flex-col items-center shadow-2xl"
|
||||
style={{
|
||||
backgroundImage: "url('/images/frame_background.png')",
|
||||
backgroundSize: "100% 100%",
|
||||
imageRendering: "pixelated",
|
||||
}}
|
||||
>
|
||||
<h2 className="text-[#FFFF55] text-2xl mc-text-shadow mb-4 border-b-2 border-[#373737] pb-2 w-full text-center uppercase">
|
||||
Launch Prefix
|
||||
</h2>
|
||||
<p className="text-[#AAAAAA] text-xs mb-4 text-center mc-text-shadow">
|
||||
Command that wraps the entire launch (e.g. gamemoderun)
|
||||
</p>
|
||||
<input
|
||||
autoFocus
|
||||
value={prefixInput}
|
||||
onChange={(e) => 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" }}
|
||||
/>
|
||||
<div className="flex gap-4 mt-6 w-full justify-center">
|
||||
<button
|
||||
onClick={() => {
|
||||
playBackSound();
|
||||
setShowModal(null);
|
||||
}}
|
||||
className="w-32 h-10 flex items-center justify-center text-xl mc-text-shadow text-white transition-colors outline-none border-none hover:text-[#FFFF55]"
|
||||
style={{
|
||||
backgroundImage: "url('/images/Button_Background.png')",
|
||||
backgroundSize: "100% 100%",
|
||||
imageRendering: "pixelated",
|
||||
}}
|
||||
onMouseEnter={(e) =>
|
||||
(e.currentTarget.style.backgroundImage =
|
||||
"url('/images/button_highlighted.png')")
|
||||
}
|
||||
onMouseLeave={(e) =>
|
||||
(e.currentTarget.style.backgroundImage =
|
||||
"url('/images/Button_Background.png')")
|
||||
}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
playPressSound();
|
||||
setLaunchPrefix(prefixInput.trim() || undefined);
|
||||
setShowModal(null);
|
||||
}}
|
||||
className="w-32 h-10 flex items-center justify-center text-xl mc-text-shadow text-white transition-colors outline-none border-none hover:text-[#FFFF55]"
|
||||
style={{
|
||||
backgroundImage: "url('/images/Button_Background.png')",
|
||||
backgroundSize: "100% 100%",
|
||||
imageRendering: "pixelated",
|
||||
}}
|
||||
onMouseEnter={(e) =>
|
||||
(e.currentTarget.style.backgroundImage =
|
||||
"url('/images/button_highlighted.png')")
|
||||
}
|
||||
onMouseLeave={(e) =>
|
||||
(e.currentTarget.style.backgroundImage =
|
||||
"url('/images/Button_Background.png')")
|
||||
}
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{showModal === "envVars" && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="fixed inset-0 w-screen h-screen z-[100] flex items-center justify-center bg-black/80 backdrop-blur-md"
|
||||
>
|
||||
<div
|
||||
className="relative w-[420px] p-6 flex flex-col items-center shadow-2xl"
|
||||
style={{
|
||||
backgroundImage: "url('/images/frame_background.png')",
|
||||
backgroundSize: "100% 100%",
|
||||
imageRendering: "pixelated",
|
||||
}}
|
||||
>
|
||||
<h2 className="text-[#FFFF55] text-2xl mc-text-shadow mb-4 border-b-2 border-[#373737] pb-2 w-full text-center uppercase">
|
||||
Launch Env Vars
|
||||
</h2>
|
||||
<p className="text-[#AAAAAA] text-xs mb-4 text-center mc-text-shadow">
|
||||
One KEY=VALUE per line
|
||||
</p>
|
||||
<textarea
|
||||
autoFocus
|
||||
value={envVarsInput}
|
||||
onChange={(e) => setEnvVarsInput(e.target.value)}
|
||||
placeholder="WINEDLLOVERRIDES=foo=n MANGOHUD=1"
|
||||
rows={6}
|
||||
className="w-full px-3 py-2 bg-black/40 border-2 border-[#373737] text-white text-sm outline-none font-['Mojangles'] resize-none"
|
||||
style={{ imageRendering: "pixelated" }}
|
||||
/>
|
||||
<div className="flex gap-4 mt-6 w-full justify-center">
|
||||
<button
|
||||
onClick={() => {
|
||||
playBackSound();
|
||||
setShowModal(null);
|
||||
}}
|
||||
className="w-32 h-10 flex items-center justify-center text-xl mc-text-shadow text-white transition-colors outline-none border-none hover:text-[#FFFF55]"
|
||||
style={{
|
||||
backgroundImage: "url('/images/Button_Background.png')",
|
||||
backgroundSize: "100% 100%",
|
||||
imageRendering: "pixelated",
|
||||
}}
|
||||
onMouseEnter={(e) =>
|
||||
(e.currentTarget.style.backgroundImage =
|
||||
"url('/images/button_highlighted.png')")
|
||||
}
|
||||
onMouseLeave={(e) =>
|
||||
(e.currentTarget.style.backgroundImage =
|
||||
"url('/images/Button_Background.png')")
|
||||
}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
playPressSound();
|
||||
const trimmed = envVarsInput.trim();
|
||||
if (trimmed) {
|
||||
const vars: Record<string, string> = {};
|
||||
for (const line of trimmed.split("\n")) {
|
||||
const eqIdx = line.indexOf("=");
|
||||
if (eqIdx > 0) {
|
||||
vars[line.slice(0, eqIdx).trim()] = line
|
||||
.slice(eqIdx + 1)
|
||||
.trim();
|
||||
}
|
||||
}
|
||||
setLaunchEnvVars(
|
||||
Object.keys(vars).length > 0 ? vars : undefined,
|
||||
);
|
||||
} else {
|
||||
setLaunchEnvVars(undefined);
|
||||
}
|
||||
setShowModal(null);
|
||||
}}
|
||||
className="w-32 h-10 flex items-center justify-center text-xl mc-text-shadow text-white transition-colors outline-none border-none hover:text-[#FFFF55]"
|
||||
style={{
|
||||
backgroundImage: "url('/images/Button_Background.png')",
|
||||
backgroundSize: "100% 100%",
|
||||
imageRendering: "pixelated",
|
||||
}}
|
||||
onMouseEnter={(e) =>
|
||||
(e.currentTarget.style.backgroundImage =
|
||||
"url('/images/button_highlighted.png')")
|
||||
}
|
||||
onMouseLeave={(e) =>
|
||||
(e.currentTarget.style.backgroundImage =
|
||||
"url('/images/Button_Background.png')")
|
||||
}
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</motion.div>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -620,6 +620,59 @@ const VersionsView = memo(function VersionsView() {
|
||||
</svg>
|
||||
Import World
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
playPressSound();
|
||||
TauriService.backupInstance(edition.instanceId).catch((err) => {
|
||||
if (err !== "CANCELED") console.error(err);
|
||||
});
|
||||
setOpenMenuId(null);
|
||||
}}
|
||||
className="w-full text-left px-3 py-2 text-xs text-[#dddddd] hover:text-white hover:bg-white/10 flex items-center gap-2 transition-colors mc-text-shadow"
|
||||
>
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
className="w-3.5 h-3.5"
|
||||
>
|
||||
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z" />
|
||||
<polyline points="17 21 17 13 7 13 7 21" />
|
||||
<polyline points="7 3 7 8 15 8" />
|
||||
</svg>
|
||||
Backup
|
||||
</button>
|
||||
<button
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation();
|
||||
playPressSound();
|
||||
setOpenMenuId(null);
|
||||
try {
|
||||
await TauriService.restoreInstance();
|
||||
} catch (err) {
|
||||
if (err !== "CANCELED") console.error(err);
|
||||
}
|
||||
}}
|
||||
className="w-full text-left px-3 py-2 text-xs text-[#dddddd] hover:text-white hover:bg-white/10 flex items-center gap-2 transition-colors mc-text-shadow"
|
||||
>
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
className="w-3.5 h-3.5"
|
||||
>
|
||||
<path d="M21 12a9 9 0 1 1-9-9 9 9 0 0 1 9 9z" />
|
||||
<polyline points="12 7 12 12 15 15" />
|
||||
</svg>
|
||||
Restore
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
|
||||
@@ -633,6 +633,61 @@ const WorkshopView = memo(function WorkshopView() {
|
||||
ref={gridRef}
|
||||
className="flex-1 overflow-y-auto p-6 scroll-smooth"
|
||||
>
|
||||
{isInstalledTab && !search.trim() && filteredItems.length > 0 && (
|
||||
<div className="flex items-center justify-center gap-3 mb-4 pb-3 border-b border-[#333]">
|
||||
<button
|
||||
onClick={async () => {
|
||||
const updates = filteredItems.filter((p) => hasUpdate(p));
|
||||
if (updates.length === 0) return;
|
||||
playPressSound();
|
||||
for (const pkg of updates) {
|
||||
const entries = installedPkgs.filter((p) => p.packageId === pkg.id);
|
||||
for (const entry of entries) {
|
||||
try {
|
||||
await TauriService.workshopInstall(entry.instanceId, pkg.id, pkg.zips!, pkg.version);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
refreshInstalled();
|
||||
}}
|
||||
className="h-8 px-4 flex items-center justify-center text-sm mc-text-shadow border border-[#555] text-[#FFFF55] hover:bg-white/10 transition-colors"
|
||||
style={{
|
||||
backgroundImage: "url('/images/Button_Background.png')",
|
||||
backgroundSize: "100% 100%",
|
||||
imageRendering: "pixelated",
|
||||
}}
|
||||
>
|
||||
Update All ({filteredItems.filter((p) => hasUpdate(p)).length})
|
||||
</button>
|
||||
<button
|
||||
onClick={async () => {
|
||||
if (filteredItems.length === 0) return;
|
||||
playPressSound();
|
||||
for (const pkg of filteredItems) {
|
||||
const entries = installedPkgs.filter((p) => p.packageId === pkg.id);
|
||||
for (const entry of entries) {
|
||||
try {
|
||||
await TauriService.workshopInstall(entry.instanceId, pkg.id, pkg.zips!, pkg.version);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
refreshInstalled();
|
||||
}}
|
||||
className="h-8 px-4 flex items-center justify-center text-sm mc-text-shadow border border-[#555] text-white hover:bg-white/10 transition-colors"
|
||||
style={{
|
||||
backgroundImage: "url('/images/Button_Background.png')",
|
||||
backgroundSize: "100% 100%",
|
||||
imageRendering: "pixelated",
|
||||
}}
|
||||
>
|
||||
Reinstall All ({filteredItems.length})
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{isSearchTab && !search.trim() ? (
|
||||
<div className="flex flex-col items-center justify-center h-[200px] opacity-40">
|
||||
<span className="text-xl mc-text-shadow tracking-widest uppercase">
|
||||
|
||||
@@ -51,6 +51,7 @@ export function LauncherProvider({ children }: { children: React.ReactNode }) {
|
||||
setProfile: configRaw.setProfile,
|
||||
customEditions: configRaw.customEditions,
|
||||
setCustomEditions: configRaw.setCustomEditions,
|
||||
extraLaunchArgs: configRaw.extraLaunchArgs,
|
||||
});
|
||||
const skinSync = useSkinSync({ username: configRaw.username, profile: configRaw.profile, editions: gameRaw.editions });
|
||||
const audioRaw = useAudioController({
|
||||
@@ -65,7 +66,8 @@ export function LauncherProvider({ children }: { children: React.ReactNode }) {
|
||||
configRaw.username, configRaw.theme, configRaw.layout, configRaw.vfxEnabled,
|
||||
configRaw.rpcEnabled, configRaw.musicVol, configRaw.sfxVol, configRaw.isDayTime,
|
||||
configRaw.profile, configRaw.linuxRunner, configRaw.perfBoost, configRaw.customEditions,
|
||||
configRaw.legacyMode, configRaw.animationsEnabled, configRaw.mangohudEnabled
|
||||
configRaw.legacyMode, configRaw.animationsEnabled, configRaw.mangohudEnabled,
|
||||
configRaw.extraLaunchArgs, configRaw.launchPrefix, configRaw.launchEnvVars
|
||||
]);
|
||||
|
||||
const game = useMemo(() => gameRaw, [
|
||||
@@ -130,6 +132,9 @@ export function LauncherProvider({ children }: { children: React.ReactNode }) {
|
||||
sfxVol: config.sfxVol,
|
||||
legacyMode: config.legacyMode,
|
||||
mangohudEnabled: config.mangohudEnabled,
|
||||
extraLaunchArgs: config.extraLaunchArgs,
|
||||
launchPrefix: config.launchPrefix,
|
||||
launchEnvVars: config.launchEnvVars,
|
||||
}).catch(console.error);
|
||||
}
|
||||
}, [
|
||||
@@ -137,7 +142,8 @@ export function LauncherProvider({ children }: { children: React.ReactNode }) {
|
||||
config.perfBoost, config.customEditions, config.profile,
|
||||
config.vfxEnabled, config.animationsEnabled,
|
||||
config.rpcEnabled, config.musicVol, config.sfxVol, config.legacyMode,
|
||||
config.mangohudEnabled, config.isLoaded
|
||||
config.mangohudEnabled, config.extraLaunchArgs, config.launchPrefix,
|
||||
config.launchEnvVars, config.isLoaded
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -19,6 +19,9 @@ export function useAppConfig() {
|
||||
const [perfBoost, setPerfBoost] = useState(false);
|
||||
const [customEditions, setCustomEditions] = useState<CustomEdition[]>([]);
|
||||
const [mangohudEnabled, setMangohudEnabled] = useState(false);
|
||||
const [extraLaunchArgs, setExtraLaunchArgs] = useState<string[] | undefined>();
|
||||
const [launchPrefix, setLaunchPrefix] = useState<string | undefined>();
|
||||
const [launchEnvVars, setLaunchEnvVars] = useState<Record<string, string> | undefined>();
|
||||
useEffect(() => {
|
||||
TauriService.loadConfig().then((config) => {
|
||||
if (config.username) setUsername(config.username);
|
||||
@@ -35,6 +38,9 @@ export function useAppConfig() {
|
||||
if (config.sfxVol !== undefined && config.sfxVol !== null) setSfxVol(config.sfxVol);
|
||||
if (config.legacyMode !== undefined) setLegacyMode(config.legacyMode);
|
||||
if (config.mangohudEnabled !== undefined) setMangohudEnabled(config.mangohudEnabled);
|
||||
if (config.extraLaunchArgs) setExtraLaunchArgs(config.extraLaunchArgs);
|
||||
if (config.launchPrefix) setLaunchPrefix(config.launchPrefix);
|
||||
if (config.launchEnvVars) setLaunchEnvVars(config.launchEnvVars);
|
||||
setIsLoaded(true);
|
||||
});
|
||||
}, []);
|
||||
@@ -55,9 +61,12 @@ export function useAppConfig() {
|
||||
sfxVol,
|
||||
legacyMode,
|
||||
mangohudEnabled,
|
||||
extraLaunchArgs,
|
||||
launchPrefix,
|
||||
launchEnvVars,
|
||||
}).catch(console.error);
|
||||
}
|
||||
}, [username, theme, linuxRunner, perfBoost, profile, customEditions, animationsEnabled, vfxEnabled, rpcEnabled, musicVol, sfxVol, legacyMode, mangohudEnabled, isLoaded]);
|
||||
}, [username, theme, linuxRunner, perfBoost, profile, customEditions, animationsEnabled, vfxEnabled, rpcEnabled, musicVol, sfxVol, legacyMode, mangohudEnabled, extraLaunchArgs, launchPrefix, launchEnvVars, isLoaded]);
|
||||
|
||||
return {
|
||||
username,
|
||||
@@ -93,5 +102,11 @@ export function useAppConfig() {
|
||||
setHasCompletedSetup,
|
||||
mangohudEnabled,
|
||||
setMangohudEnabled,
|
||||
extraLaunchArgs,
|
||||
setExtraLaunchArgs,
|
||||
launchPrefix,
|
||||
setLaunchPrefix,
|
||||
launchEnvVars,
|
||||
setLaunchEnvVars,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -77,6 +77,7 @@ interface GameManagerProps {
|
||||
setProfile: (id: string) => void;
|
||||
customEditions: CustomEdition[];
|
||||
setCustomEditions: (editions: CustomEdition[]) => void;
|
||||
extraLaunchArgs?: string[];
|
||||
}
|
||||
|
||||
function compareVersions(v1: string, v2: string) {
|
||||
@@ -101,6 +102,7 @@ export function useGameManager({
|
||||
setProfile,
|
||||
customEditions,
|
||||
setCustomEditions,
|
||||
extraLaunchArgs,
|
||||
}: GameManagerProps) {
|
||||
const [installs, setInstalls] = useState<string[]>([]);
|
||||
const [isGameRunning, setIsGameRunning] = useState(false);
|
||||
@@ -410,7 +412,7 @@ export function useGameManager({
|
||||
setIsGameRunning(true);
|
||||
try {
|
||||
getCurrentWindow().minimize();
|
||||
await TauriService.launchGame(profile, PARTNERSHIP_SERVERS);
|
||||
await TauriService.launchGame(profile, PARTNERSHIP_SERVERS, extraLaunchArgs);
|
||||
} catch (e: unknown) {
|
||||
console.error(e);
|
||||
setError(
|
||||
|
||||
@@ -41,6 +41,9 @@ export interface AppConfig {
|
||||
legacyMode?: boolean;
|
||||
mangohudEnabled?: boolean;
|
||||
savedServers?: McServer[];
|
||||
extraLaunchArgs?: string[];
|
||||
launchPrefix?: string;
|
||||
launchEnvVars?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface ThemePalette {
|
||||
@@ -74,6 +77,11 @@ export interface PlaytimeResponse {
|
||||
daySeconds: number;
|
||||
}
|
||||
|
||||
export interface PlaytimeDayEntry {
|
||||
label: string;
|
||||
seconds: number;
|
||||
}
|
||||
|
||||
export class TauriService {
|
||||
static async saveConfig(config: AppConfig): Promise<void> {
|
||||
return invoke("save_config", { config });
|
||||
@@ -91,6 +99,14 @@ export class TauriService {
|
||||
return invoke("import_theme");
|
||||
}
|
||||
|
||||
static async exportSettings(): Promise<void> {
|
||||
return invoke("export_settings");
|
||||
}
|
||||
|
||||
static async importSettings(): Promise<string> {
|
||||
return invoke("import_settings");
|
||||
}
|
||||
|
||||
static async getAvailableRunners(): Promise<Runner[]> {
|
||||
return invoke("get_available_runners");
|
||||
}
|
||||
@@ -293,6 +309,10 @@ export class TauriService {
|
||||
return invoke("get_playtime", { instanceId });
|
||||
}
|
||||
|
||||
static async getPlaytimeDaily(instanceId: string, days: number): Promise<PlaytimeDayEntry[]> {
|
||||
return invoke("get_playtime_daily", { instanceId, days });
|
||||
}
|
||||
|
||||
static async getInstancePath(instanceId: string): Promise<string> {
|
||||
return invoke("get_instance_path", { instanceId });
|
||||
}
|
||||
@@ -301,6 +321,14 @@ export class TauriService {
|
||||
return invoke("read_screenshot_as_data_url", { path });
|
||||
}
|
||||
|
||||
static async backupInstance(instanceId: string): Promise<void> {
|
||||
return invoke("backup_instance", { instanceId });
|
||||
}
|
||||
|
||||
static async restoreInstance(): Promise<string> {
|
||||
return invoke("restore_instance");
|
||||
}
|
||||
|
||||
static async importWorld(
|
||||
inputPath: string,
|
||||
outputPath: string,
|
||||
|
||||
Reference in New Issue
Block a user