From b233ae678912c05b5d66c1da8e8ab18b0d4c95f1 Mon Sep 17 00:00:00 2001 From: ExtremestoneGG <272833133+ExtremestoneGG@users.noreply.github.com> Date: Fri, 12 Jun 2026 12:53:20 -0300 Subject: [PATCH] Add safe instance management features --- FEATURES.md | 55 +++++ README.md | 5 +- package-lock.json | 4 +- package.json | 2 +- src-tauri/Cargo.lock | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/src/lib.rs | 417 ++++++++++++++++++++++++++++++++++++-- src-tauri/tauri.conf.json | 2 +- src/App.tsx | 86 +++++++- src/styles.css | 6 +- 10 files changed, 552 insertions(+), 29 deletions(-) create mode 100644 FEATURES.md diff --git a/FEATURES.md b/FEATURES.md new file mode 100644 index 0000000..c592983 --- /dev/null +++ b/FEATURES.md @@ -0,0 +1,55 @@ +# VEX Feature Roadmap + +This roadmap compares the most useful instance-management ideas from Modrinth App and Prism Launcher with VEX. It intentionally excludes features that would add complexity without improving the normal path to playing Minecraft. + +## Available In VEX + +- Microsoft and offline profiles with saved offline skins. +- Isolated Vanilla, Fabric, Quilt, Forge, and NeoForge instances. +- Official Forge and NeoForge installer integration. +- Automatic compatible Java runtime downloads. +- Modrinth discovery and installation without an API key. +- Optional CurseForge discovery when the user provides an official API key. +- Mod, modpack, resource pack, shader, and plugin discovery. +- Compatible content installation directly into an instance. +- Instance cloning, protected deletion, icons, folders, worlds, screenshots, logs, and content lists. +- Manual instance backups. +- Modrinth modpack update checks and updates with an automatic full backup. +- Existing Minecraft installation scanner with version, loader, mod, world, resource-pack, and shader summaries. +- Local server creation and console. +- Windows installer, self-contained portable build, and Linux AppImage. + +## Useful Next Features + +### High Priority + +- Import `.mrpack`, CurseForge ZIP, and generic ZIP files from disk or URL. +- Export a VEX instance as `.mrpack` while clearly separating user files from pack files. +- Detect installed mods by file hash and show exact project/version metadata. +- Check and update individual mods in batches with a review screen. +- Repair an instance by verifying libraries, assets, loader profiles, and managed modpack files. +- Restore or compare backups from inside the launcher. +- Import an existing Minecraft installation into an isolated VEX instance without changing the source. + +### Medium Priority + +- Temporarily disable mods without deleting them. +- Select which parts are copied when cloning an instance. +- Change an instance Minecraft version or loader with compatibility warnings. +- Add instance notes and per-instance Java, memory, resolution, and launch settings. +- Manage worlds and screenshots with rename, copy, export, and delete actions. +- Show modpack changelogs before updating and allow selecting an older release. +- Export a server-ready pack from a client instance. + +### Later + +- FTB and Technic pack providers. +- Pre-launch and post-exit custom commands with clear security warnings. +- Discord Rich Presence. +- Optional cloud synchronization for settings and backups. + +## Deliberately Not Required + +- A CurseForge API key is not required for Forge, NeoForge, Minecraft, Java, or Modrinth. +- VEX will not scrape private or undocumented CurseForge endpoints to bypass their official API rules. +- Automatic destructive modpack updates are avoided. VEX creates a backup and asks for confirmation first. diff --git a/README.md b/README.md index 2cc7bec..e135191 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ VEX is a free and open-source Minecraft launcher focused on making the path from The project started as a design experiment built with AI-assisted programming. Its goal is to remain lightweight, direct, and accessible for players using either an official Microsoft account or an offline profile. -## VEX 0.7 +## VEX 0.8 - Combined Modrinth and CurseForge discovery with source, version, loader, and content-type filters. - Dedicated pages for mods, modpacks, shaders, resource packs, and plugins. @@ -15,6 +15,7 @@ The project started as a design experiment built with AI-assisted programming. I - Automatic compatible Java runtime downloads from Eclipse Adoptium, isolated inside VEX data. - Saved offline profiles, a skin library, and official Microsoft login on Windows. - Instance library with cloning, protected deletion, worlds, screenshots, logs, and installed content. +- Instance backups, existing-installation scanning, and safe Modrinth modpack update checks. - Local Vanilla, Paper, or Fabric servers with a console and a playit.gg guide. - Dark, AMOLED, Light, and High Contrast themes. - Per-user installer, self-contained portable executable, and Linux AppImage. @@ -29,7 +30,7 @@ This flow does not depend on Prism Launcher metadata or ForgeWrapper. ## CurseForge -The official CurseForge API requires a free API key. For security, VEX does not publish or embed a private key in the open-source code. +The official CurseForge API requires a free API key. CurseForge is optional: Forge, NeoForge, Minecraft, Java, Modrinth, and every local launcher feature continue to work without it. For security, VEX does not publish or embed a private key in the open-source code. 1. Create a key at [console.curseforge.com](https://console.curseforge.com/). 2. Open **Settings > Network and sources**. diff --git a/package-lock.json b/package-lock.json index b3b8072..a3fd103 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "vex-launcher", - "version": "0.7.0", + "version": "0.8.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "vex-launcher", - "version": "0.7.0", + "version": "0.8.0", "dependencies": { "@tauri-apps/api": "^2.8.0", "lucide-react": "^0.468.0", diff --git a/package.json b/package.json index ba4e7c4..946e5b7 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "vex-launcher", "private": true, - "version": "0.7.0", + "version": "0.8.0", "type": "module", "scripts": { "dev": "vite --host 127.0.0.1", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index d6b73c1..5f2be69 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -4148,7 +4148,7 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" [[package]] name = "vex-launcher" -version = "0.7.0" +version = "0.8.0" dependencies = [ "base64 0.22.1", "flate2", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index dd5da90..836d16b 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "vex-launcher" -version = "0.7.0" +version = "0.8.0" description = "VEX Launcher - a modern, accessible Minecraft launcher" authors = ["VEX Launcher contributors"] edition = "2021" diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 4b49220..4139d08 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -565,6 +565,34 @@ struct InstalledInstance { last_played_unix: u64, } +#[derive(Debug, Serialize)] +struct ModpackUpdateStatus { + supported: bool, + update_available: bool, + project_id: String, + project_name: String, + author: String, + game_version: String, + current_version: String, + latest_version: String, + latest_version_id: String, + message: String, +} + +#[derive(Debug, Serialize)] +struct MinecraftInstallationScan { + path: String, + label: String, + versions: Vec, + loaders: Vec, + mods: usize, + worlds: usize, + resource_packs: usize, + shaders: usize, + size_mb: f64, + managed_by_vex: bool, +} + #[derive(Debug, Serialize)] struct LoaderGameVersion { game_version: String, @@ -1095,7 +1123,7 @@ async fn get_microsoft_skin_data_url() -> Result, String> { return Ok(None); }; let bytes = reqwest::Client::builder() - .user_agent("VEXLauncher/0.5") + .user_agent(concat!("VEXLauncher/", env!("CARGO_PKG_VERSION"))) .build() .map_err(|error| error.to_string())? .get(&url) @@ -1220,7 +1248,7 @@ fn begin_microsoft_login(app: tauri::AppHandle) -> Result<(), String> { #[tauri::command] async fn complete_microsoft_login(code: String) -> Result { let client = reqwest::Client::builder() - .user_agent("VEXLauncher/0.5") + .user_agent(concat!("VEXLauncher/", env!("CARGO_PKG_VERSION"))) .build() .map_err(|error| error.to_string())?; let (microsoft_access_token, refresh_token) = @@ -1555,7 +1583,7 @@ async fn create_instance( fs::create_dir_all(&instance_dir).map_err(|error| error.to_string())?; let version_id = if clean_loader == "fabric" { let client = reqwest::Client::builder() - .user_agent("VEXLauncher/0.5") + .user_agent(concat!("VEXLauncher/", env!("CARGO_PKG_VERSION"))) .build() .map_err(|error| error.to_string())?; let loaders: Value = client @@ -1580,7 +1608,7 @@ async fn create_instance( install_fabric_profile(&client, &game_dir, clean_version, loader_version).await? } else if clean_loader == "quilt" { let client = reqwest::Client::builder() - .user_agent("VEXLauncher/0.5") + .user_agent(concat!("VEXLauncher/", env!("CARGO_PKG_VERSION"))) .build() .map_err(|error| error.to_string())?; install_quilt_profile(&client, &game_dir, clean_version).await? @@ -1728,6 +1756,198 @@ fn copy_directory(source: &Path, destination: &Path) -> Result<(), String> { Ok(()) } +fn count_directory_entries(path: &Path, directories_only: bool) -> usize { + fs::read_dir(path) + .map(|entries| { + entries + .flatten() + .filter(|entry| !directories_only || entry.path().is_dir()) + .count() + }) + .unwrap_or_default() +} + +fn minecraft_installation_scan(path: &Path, game_dir: &Path) -> Option { + if !path.is_dir() { + return None; + } + let versions_dir = path.join("versions"); + let mods = count_directory_entries(&path.join("mods"), false); + let worlds = count_directory_entries(&path.join("saves"), true); + let resource_packs = count_directory_entries(&path.join("resourcepacks"), false); + let shaders = count_directory_entries(&path.join("shaderpacks"), false); + let mut versions: Vec = fs::read_dir(&versions_dir) + .map(|entries| { + entries + .flatten() + .filter(|entry| entry.path().is_dir()) + .filter_map(|entry| entry.file_name().to_str().map(str::to_owned)) + .collect() + }) + .unwrap_or_default(); + let instance_metadata = fs::read_to_string(path.join("instance.json")) + .ok() + .and_then(|raw| serde_json::from_str::(&raw).ok()); + if let Some(version) = instance_metadata + .as_ref() + .and_then(|metadata| metadata.get("McVersion")) + .and_then(Value::as_str) + { + versions.push(version.to_owned()); + } + versions.sort_by(|left, right| right.cmp(left)); + versions.dedup(); + versions.truncate(8); + if versions.is_empty() + && mods == 0 + && worlds == 0 + && resource_packs == 0 + && shaders == 0 + && !path.join("instance.json").is_file() + { + return None; + } + let mut loaders = Vec::new(); + for version in &versions { + let lower = version.to_ascii_lowercase(); + for loader in ["fabric", "forge", "neoforge", "quilt", "vanilla"] { + if lower.contains(loader) && !loaders.iter().any(|item| item == loader) { + loaders.push(loader.to_owned()); + } + } + } + if let Some(loader) = instance_metadata + .as_ref() + .and_then(|metadata| metadata.get("Loader")) + .and_then(Value::as_str) + { + if !loaders.iter().any(|item| item.eq_ignore_ascii_case(loader)) { + loaders.push(loader.to_owned()); + } + } + if loaders.is_empty() && !versions.is_empty() { + loaders.push(String::from("vanilla")); + } + let canonical_game = fs::canonicalize(game_dir).unwrap_or_else(|_| game_dir.to_path_buf()); + let canonical_path = fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()); + let managed_by_vex = path.join("instance.json").is_file() + || canonical_path.starts_with(canonical_game.join("instances")) + || canonical_path.starts_with(canonical_game.join("modpacks")); + Some(MinecraftInstallationScan { + path: path.to_string_lossy().into_owned(), + label: path + .file_name() + .and_then(|name| name.to_str()) + .filter(|name| !name.is_empty()) + .unwrap_or("Minecraft") + .to_owned(), + versions, + loaders, + mods, + worlds, + resource_packs, + shaders, + size_mb: (directory_size(path) as f64 / 1_048_576.0 * 10.0).round() / 10.0, + managed_by_vex, + }) +} + +fn add_candidate(candidates: &mut HashSet, path: PathBuf) { + if path.is_dir() { + candidates.insert(fs::canonicalize(&path).unwrap_or(path)); + } +} + +#[tauri::command] +fn scan_minecraft_installations() -> Vec { + let settings = read_settings(); + let game_dir = PathBuf::from(&settings.game_directory); + let mut candidates = HashSet::new(); + add_candidate(&mut candidates, game_dir.clone()); + add_candidate(&mut candidates, default_game_directory()); + if Path::new(r"D:\").is_dir() { + add_candidate(&mut candidates, PathBuf::from(r"D:\.minecraft")); + } + if let Ok(appdata) = env::var("APPDATA") { + let appdata = PathBuf::from(appdata); + add_candidate(&mut candidates, appdata.join(".minecraft")); + for root in [ + appdata + .join("CurseForge") + .join("Minecraft") + .join("Instances"), + appdata.join("ModrinthApp").join("profiles"), + ] { + if let Ok(entries) = fs::read_dir(root) { + for entry in entries.flatten().filter(|entry| entry.path().is_dir()) { + add_candidate(&mut candidates, entry.path()); + } + } + } + } + if let Ok(home) = env::var("HOME") { + let home = PathBuf::from(home); + add_candidate(&mut candidates, home.join(".minecraft")); + for root in [ + home.join(".local") + .join("share") + .join("ModrinthApp") + .join("profiles"), + home.join(".local") + .join("share") + .join("PrismLauncher") + .join("instances"), + ] { + if let Ok(entries) = fs::read_dir(root) { + for entry in entries.flatten().filter(|entry| entry.path().is_dir()) { + add_candidate(&mut candidates, entry.path()); + } + } + } + } + for root in [game_dir.join("instances"), game_dir.join("modpacks")] { + if let Ok(entries) = fs::read_dir(root) { + for entry in entries.flatten().filter(|entry| entry.path().is_dir()) { + add_candidate(&mut candidates, entry.path()); + } + } + } + let mut scans: Vec = candidates + .into_iter() + .filter_map(|path| minecraft_installation_scan(&path, &game_dir)) + .collect(); + scans.sort_by(|left, right| { + right + .managed_by_vex + .cmp(&left.managed_by_vex) + .then_with(|| right.size_mb.total_cmp(&left.size_mb)) + }); + scans +} + +fn backup_instance_directory(source: &Path) -> Result { + let base_name = source + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("instance"); + let timestamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_secs()) + .unwrap_or_default(); + let destination = storage_root() + .join("backups") + .join("instances") + .join(format!("{}-{timestamp}", safe_directory_name(base_name))); + copy_directory(source, &destination)?; + Ok(destination) +} + +#[tauri::command] +fn backup_instance(profile_dir: String) -> Result { + let source = validated_instance_dir(&profile_dir)?; + backup_instance_directory(&source).map(|path| path.to_string_lossy().into_owned()) +} + fn validated_instance_dir(profile_dir: &str) -> Result { let settings = read_settings(); let game_dir = fs::canonicalize(&settings.game_directory) @@ -1979,7 +2199,7 @@ async fn ensure_java_runtime( false, ); let client = reqwest::Client::builder() - .user_agent("VEXLauncher/0.5") + .user_agent(concat!("VEXLauncher/", env!("CARGO_PKG_VERSION"))) .build() .map_err(|error| error.to_string())?; #[cfg(windows)] @@ -3011,7 +3231,7 @@ async fn get_modrinth_install_targets( game_version: String, ) -> Result, String> { let client = reqwest::Client::builder() - .user_agent("VEXLauncher/0.5") + .user_agent(concat!("VEXLauncher/", env!("CARGO_PKG_VERSION"))) .build() .map_err(|error| error.to_string())?; let versions: Value = client @@ -3206,7 +3426,7 @@ async fn install_modrinth_target( .and_then(|name| name.to_str()) .ok_or_else(|| String::from("Nome de arquivo inválido."))?; let client = reqwest::Client::builder() - .user_agent("VEXLauncher/0.5") + .user_agent(concat!("VEXLauncher/", env!("CARGO_PKG_VERSION"))) .build() .map_err(|error| error.to_string())?; let bytes = download_bytes_with_progress( @@ -3654,6 +3874,137 @@ async fn list_loader_game_versions(loader: String) -> Result Result { + let instance_dir = validated_instance_dir(&profile_dir)?; + let metadata: Value = serde_json::from_str( + &fs::read_to_string(instance_dir.join("instance.json")) + .map_err(|error| error.to_string())?, + ) + .map_err(|error| error.to_string())?; + let source = metadata + .get("Source") + .and_then(Value::as_str) + .unwrap_or_default(); + let project_name = metadata + .get("Name") + .and_then(Value::as_str) + .unwrap_or("Modpack") + .to_owned(); + let author = metadata + .get("Author") + .and_then(Value::as_str) + .unwrap_or_default() + .to_owned(); + let game_version = metadata + .get("McVersion") + .and_then(Value::as_str) + .unwrap_or_default() + .to_owned(); + let project_id = metadata + .get("ProjectId") + .or_else(|| metadata.get("Id")) + .and_then(Value::as_str) + .unwrap_or_default() + .trim_start_matches("modrinth-") + .to_owned(); + let current_version = metadata + .get("ProjectVersionNumber") + .and_then(Value::as_str) + .unwrap_or("Desconhecida") + .to_owned(); + if !source.eq_ignore_ascii_case("modrinth") || project_id.is_empty() { + return Ok(ModpackUpdateStatus { + supported: false, + update_available: false, + project_id, + project_name, + author, + game_version, + current_version, + latest_version: String::new(), + latest_version_id: String::new(), + message: String::from( + "Atualizacao automatica esta disponivel para modpacks instalados pelo Modrinth.", + ), + }); + } + let client = reqwest::Client::builder() + .user_agent(concat!("VEXLauncher/", env!("CARGO_PKG_VERSION"))) + .build() + .map_err(|error| error.to_string())?; + let versions: Value = client + .get(format!( + "https://api.modrinth.com/v2/project/{project_id}/version" + )) + .send() + .await + .map_err(|error| error.to_string())? + .error_for_status() + .map_err(|error| error.to_string())? + .json() + .await + .map_err(|error| error.to_string())?; + let latest = versions + .as_array() + .and_then(|items| { + items.iter().find(|version| { + let game_matches = version + .get("game_versions") + .and_then(Value::as_array) + .is_some_and(|versions| { + versions + .iter() + .any(|version| version.as_str() == Some(&game_version)) + }); + let has_pack = + version + .get("files") + .and_then(Value::as_array) + .is_some_and(|files| { + files.iter().any(|file| { + file.get("filename") + .and_then(Value::as_str) + .is_some_and(|name| name.ends_with(".mrpack")) + }) + }); + game_matches && has_pack + }) + }) + .ok_or_else(|| format!("Nenhuma versao do modpack suporta Minecraft {game_version}."))?; + let latest_version_id = latest + .get("id") + .and_then(Value::as_str) + .unwrap_or_default() + .to_owned(); + let latest_version = latest + .get("version_number") + .and_then(Value::as_str) + .unwrap_or("Mais recente") + .to_owned(); + let current_version_id = metadata + .get("ProjectVersionId") + .and_then(Value::as_str) + .unwrap_or_default(); + let update_available = current_version_id.is_empty() || current_version_id != latest_version_id; + Ok(ModpackUpdateStatus { + supported: true, + update_available, + project_id, + project_name, + author, + game_version, + current_version, + latest_version: latest_version.clone(), + latest_version_id, + message: if update_available { + format!("A versao {latest_version} esta disponivel.") + } else { + String::from("O modpack ja esta atualizado.") + }, + }) +} + #[tauri::command] async fn install_modrinth_modpack( app: tauri::AppHandle, @@ -3661,13 +4012,14 @@ async fn install_modrinth_modpack( project_name: String, author: String, game_version: String, + target_profile_dir: Option, ) -> Result { let operation = "install-modpack"; emit_progress(&app, operation, "Consultando versões do modpack", 3, false); let settings = read_settings(); let game_dir = PathBuf::from(&settings.game_directory); let client = reqwest::Client::builder() - .user_agent("VEXLauncher/0.5") + .user_agent(concat!("VEXLauncher/", env!("CARGO_PKG_VERSION"))) .build() .map_err(|error| error.to_string())?; let versions: Value = client @@ -3712,6 +4064,16 @@ async fn install_modrinth_modpack( .ok_or_else(|| { format!("Nenhum arquivo .mrpack disponível para Minecraft {game_version}.") })?; + let selected_version_id = selected + .get("id") + .and_then(Value::as_str) + .unwrap_or_default() + .to_owned(); + let selected_version_number = selected + .get("version_number") + .and_then(Value::as_str) + .unwrap_or_default() + .to_owned(); let pack_file = selected .get("files") .and_then(Value::as_array) @@ -3754,7 +4116,26 @@ async fn install_modrinth_modpack( if instance_name.is_empty() { return Err(String::from("Nome de modpack inválido.")); } - let instance_dir = game_dir.join("modpacks").join(&instance_name); + let instance_dir = if let Some(profile_dir) = target_profile_dir.as_deref() { + validated_instance_dir(profile_dir)? + } else { + game_dir.join("modpacks").join(&instance_name) + }; + let previous_metadata = fs::read_to_string(instance_dir.join("instance.json")) + .ok() + .and_then(|raw| serde_json::from_str::(&raw).ok()); + let backup_path = if target_profile_dir.is_some() { + emit_progress( + &app, + operation, + "Criando backup antes da atualizacao", + 29, + false, + ); + Some(backup_instance_directory(&instance_dir)?) + } else { + None + }; fs::create_dir_all(&instance_dir).map_err(|error| error.to_string())?; let mut archive = zip::ZipArchive::new(Cursor::new(pack_bytes)).map_err(|error| error.to_string())?; @@ -3790,6 +4171,7 @@ async fn install_modrinth_modpack( "vanilla" }; + let mut managed_files: Vec = Vec::new(); for index in 0..archive.len() { let mut entry = archive.by_index(index).map_err(|error| error.to_string())?; if entry.is_dir() { @@ -3802,6 +4184,7 @@ async fn install_modrinth_modpack( .strip_prefix("overrides") .or_else(|_| enclosed.strip_prefix("client-overrides")); let Ok(relative) = relative else { continue }; + managed_files.push(relative.to_string_lossy().replace('\\', "/")); let destination = instance_dir.join(relative); if let Some(parent) = destination.parent() { fs::create_dir_all(parent).map_err(|error| error.to_string())?; @@ -3832,6 +4215,7 @@ async fn install_modrinth_modpack( }) { continue; } + managed_files.push(relative.replace('\\', "/")); let destination = instance_dir.join(relative_path); let downloads = file .get("downloads") @@ -3909,12 +4293,18 @@ async fn install_modrinth_modpack( "Name": project_name, "Author": author, "Source": "Modrinth", + "ProjectId": project_id, + "ProjectVersionId": selected_version_id, + "ProjectVersionNumber": selected_version_number, "IconPath": icon_path.clone().unwrap_or_default(), "McVersion": minecraft, "Loader": loader, "LoaderVersion": fabric_loader.or(quilt_loader).or(forge_loader).or(neoforge_loader).unwrap_or_default(), "VersionId": version_id, - "InstallDate": "installed-by-mine-launcher" + "InstallDate": "installed-by-vex-launcher", + "LastPlayedUnix": previous_metadata.as_ref().and_then(|value| value.get("LastPlayedUnix")).and_then(Value::as_u64).unwrap_or_default(), + "ManagedFiles": managed_files, + "LastBackupPath": backup_path.as_ref().map(|path| path.to_string_lossy().into_owned()).unwrap_or_default() }); fs::write( instance_dir.join("instance.json"), @@ -4204,7 +4594,7 @@ async fn launch_instance( fs::write(&log_path, format!("[Launcher] Preparando {version_id}\n")) .map_err(|error| error.to_string())?; let client = reqwest::Client::builder() - .user_agent("VEXLauncher/0.5") + .user_agent(concat!("VEXLauncher/", env!("CARGO_PKG_VERSION"))) .build() .map_err(|error| error.to_string())?; let microsoft_account = if settings.use_offline_profile { @@ -4733,7 +5123,7 @@ async fn start_server(app: tauri::AppHandle) -> Result { .map_err(|error| error.to_string())?; let client = reqwest::Client::builder() - .user_agent("VEXLauncher/0.5") + .user_agent(concat!("VEXLauncher/", env!("CARGO_PKG_VERSION"))) .build() .map_err(|error| error.to_string())?; let game_dir = PathBuf::from(read_settings().game_directory); @@ -4887,9 +5277,11 @@ pub fn run() { remove_curseforge_api_key, read_image_data_url, list_installed_instances, + scan_minecraft_installations, create_instance, list_loader_game_versions, clone_instance, + backup_instance, delete_instance, set_instance_icon, list_instance_content, @@ -4904,6 +5296,7 @@ pub fn run() { install_curseforge_modpack, get_modrinth_install_targets, install_modrinth_target, + check_modpack_update, install_modrinth_modpack, save_offline_profile, save_offline_skin, diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index f4a416d..796f0dd 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "VEX Launcher", - "version": "0.7.0", + "version": "0.8.0", "identifier": "app.vexlauncher.desktop", "build": { "beforeDevCommand": "npm run dev", diff --git a/src/App.tsx b/src/App.tsx index 2cc378e..2f7b22f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,4 +1,5 @@ import { + Archive, Bell, Box, Check, @@ -28,6 +29,7 @@ import { Play, Plus, RotateCcw, + RefreshCw, Search, Server, Settings, @@ -72,6 +74,8 @@ type ServerProfile = { name: string; version: string; software: string; memory_g type ServerStatus = { running: boolean; pid?: number; profile: ServerProfile; log_path: string }; type OperationProgress = { operation: string; label: string; percent: number; done: boolean }; type LoaderGameVersion = { game_version: string; loader_version: string; recommended: boolean }; +type ModpackUpdateStatus = { supported: boolean; update_available: boolean; project_id: string; project_name: string; author: string; game_version: string; current_version: string; latest_version: string; latest_version_id: string; message: string }; +type MinecraftInstallationScan = { path: string; label: string; versions: string[]; loaders: string[]; mods: number; worlds: number; resource_packs: number; shaders: number; size_mb: number; managed_by_vex: boolean }; const pageMeta: Record = { home: { title: "Início", eyebrow: "Visão geral" }, @@ -449,6 +453,7 @@ function LibraryPage({ play, appInstances, refreshInstances, notify }: { play: ( const [addMode, setAddMode] = useState(false); const [addResults, setAddResults] = useState([]); const [selectedProject, setSelectedProject] = useState(null); + const [instanceActionBusy, setInstanceActionBusy] = useState(false); const selected = appInstances.find((instance) => instance.id === selectedId) ?? appInstances[0]; const filteredInstances = appInstances.filter((instance) => instance.name.toLowerCase().includes(query.toLowerCase()) && (!onlyModded || instance.loader.toLowerCase() !== "vanilla")); const visibleContent = content.filter((item) => item.name.toLowerCase().includes(contentQuery.toLowerCase()) && (contentFilter === "Todos" || item.kind === contentFilter)); @@ -501,6 +506,45 @@ function LibraryPage({ play, appInstances, refreshInstances, notify }: { play: ( notify(`${selected.name} duplicada com segurança`); } catch (error) { notify(String(error)); } }; + const backup = async () => { + if (!selected?.profileDir || instanceActionBusy) return; + setInstanceActionBusy(true); + try { + const destination = await invoke("backup_instance", { profileDir: selected.profileDir }); + notify(`Backup criado em ${destination}`); + } catch (error) { + notify(`Nao foi possivel criar o backup: ${String(error)}`); + } finally { + setInstanceActionBusy(false); + } + }; + const checkUpdate = async () => { + if (!selected?.profileDir || instanceActionBusy) return; + setInstanceActionBusy(true); + try { + const status = await invoke("check_modpack_update", { profileDir: selected.profileDir }); + if (!status.supported || !status.update_available) { + notify(status.message); + return; + } + const accepted = window.confirm(`${status.message}\n\nO VEX criara um backup completo antes de atualizar. Continuar?`); + if (!accepted) return; + await invoke("install_modrinth_modpack", { + projectId: status.project_id, + projectName: status.project_name, + author: status.author, + gameVersion: status.game_version, + targetProfileDir: selected.profileDir + }); + await refreshInstances(); + reloadContent(); + notify(`${selected.name} atualizado com backup de seguranca`); + } catch (error) { + notify(`Nao foi possivel atualizar: ${String(error)}`); + } finally { + setInstanceActionBusy(false); + } + }; const searchCompatible = async () => { if (!selected) return; try { @@ -536,6 +580,8 @@ function LibraryPage({ play, appInstances, refreshInstances, notify }: { play: ( {selected ? <>
{selected.loader} · {selected.version}

{selected.name}

{selected.lastPlayed}

+ {selected.kind === "modpack" && void checkUpdate()}>} + void backup()}> void clone()}> void invoke("open_path", { path: selected.profileDir }).catch((error) => notify(String(error)))}> setDeleteTarget(selected)}> @@ -581,6 +627,7 @@ function DiscoverPage({ appInstances }: { appInstances: Instance[] }) { const [page, setPage] = useState(1); const [pageSize, setPageSize] = useState(20); const [totalHits, setTotalHits] = useState(0); + const [curseForgeConfigured, setCurseForgeConfigured] = useState(false); const shown = useMemo(() => remoteProjects ?? projects.filter((project) => kind === "Tudo" || project.kind === kind.slice(0, -1) || project.kind === kind), [kind, remoteProjects]); const totalPages = Math.max(1, Math.ceil(totalHits / pageSize)); const visiblePages = useMemo(() => { @@ -616,7 +663,7 @@ function DiscoverPage({ appInstances }: { appInstances: Instance[] }) { }) .catch((error) => ({ projects: [], total: 0, error: String(error) }))); } - if (selectedSource !== "Modrinth") { + if (selectedSource !== "Modrinth" && curseForgeConfigured) { tasks.push(invoke("search_curseforge", { query, projectType: kindMap[selectedKind] ?? "", gameVersion: selectedVersion, loader: selectedLoader, index: offset, pageSize: Math.min(selectedPageSize, 50) @@ -630,7 +677,10 @@ function DiscoverPage({ appInstances }: { appInstances: Instance[] }) { const results = await Promise.all(tasks); setRemoteProjects(results.flatMap((result) => result.projects)); setTotalHits(results.reduce((total, result) => total + result.total, 0)); - setSearchError(results.map((result) => result.error).filter(Boolean).join(" · ")); + const providerErrors = results.map((result) => result.error).filter(Boolean).join(" · "); + setSearchError(selectedSource === "CurseForge" && !curseForgeConfigured + ? "CurseForge e opcional. Conecte uma chave gratuita nas configuracoes ou use o Modrinth sem chave." + : providerErrors); } catch (error) { setSearchError(error instanceof Error ? error.message : "Não foi possível pesquisar agora."); setRemoteProjects(null); @@ -641,7 +691,10 @@ function DiscoverPage({ appInstances }: { appInstances: Instance[] }) { }; useEffect(() => { fetch("https://launchermeta.mojang.com/mc/game/version_manifest_v2.json").then((response) => response.json()).then((data: { versions?: Array<{ id: string; type: string }> }) => setVersionOptions((data.versions ?? []).filter((item) => item.type === "release").map((item) => item.id))).catch(() => undefined); - void searchProjects("Modpacks", "downloads", 1, 20, "Todas", "", ""); + invoke("get_curseforge_status").then((status) => { + setCurseForgeConfigured(status.configured); + void searchProjects("Modpacks", "downloads", 1, 20, status.configured ? "Todas" : "Modrinth", "", ""); + }).catch(() => void searchProjects("Modpacks", "downloads", 1, 20, "Modrinth", "", "")); }, []); const openProject = async (project: Project) => { setSelected(project); @@ -710,7 +763,7 @@ function ProjectDetail({ project, close, appInstances }: { project: Project; clo if (projectType === "modpack") { try { setInstallMessage(`Instalando ${project.name}. Isso pode levar alguns minutos...`); - await invoke(project.source === "CurseForge" ? "install_curseforge_modpack" : "install_modrinth_modpack", { projectId: project.id, projectName: project.name, author: project.author, gameVersion: version }); + await invoke(project.source === "CurseForge" ? "install_curseforge_modpack" : "install_modrinth_modpack", { projectId: project.id, projectName: project.name, author: project.author, gameVersion: version, targetProfileDir: null }); setInstallMessage(`${project.name} instalado. Atualizando a Biblioteca...`); window.setTimeout(() => window.location.reload(), 1800); } catch (error) { @@ -903,6 +956,8 @@ function SettingsPage({ username, skinDataUrl, microsoftSkinDataUrl, onSkinChang const [dense, setDense] = useState(false); const [curseForgeStatus, setCurseForgeStatus] = useState({ configured: false, source: "Não configurada" }); const [curseForgeKey, setCurseForgeKey] = useState(""); + const [installations, setInstallations] = useState([]); + const [scanningInstallations, setScanningInstallations] = useState(false); const skinInput = useRef(null); useEffect(() => setDraftName(username), [username]); useEffect(() => { invoke("get_curseforge_status").then(setCurseForgeStatus).catch(() => undefined); }, []); @@ -949,6 +1004,18 @@ function SettingsPage({ username, skinDataUrl, microsoftSkinDataUrl, onSkinChang notify(String(error)); } }; + const scanInstallations = async () => { + setScanningInstallations(true); + try { + const found = await invoke("scan_minecraft_installations"); + setInstallations(found); + notify(`${found.length} instalacao(oes) Minecraft encontrada(s)`); + } catch (error) { + notify(`Nao foi possivel analisar as instalacoes: ${String(error)}`); + } finally { + setScanningInstallations(false); + } + }; const saveCurseForgeKey = async () => { try { const status = await invoke("save_curseforge_api_key", { key: curseForgeKey }); @@ -989,8 +1056,15 @@ function SettingsPage({ username, skinDataUrl, microsoftSkinDataUrl, onSkinChang

Perfil offline

Use o nome e a skin locais quando não quiser entrar com a Microsoft.

{useOfflineProfile ? : }{useOfflineProfile ? "Em uso" : "Disponível"}
{username}{skinDataUrl ? "Skin offline personalizada salva" : "Skin clássica salva"}
} - {section === "minecraft" &&

Pastas e armazenamento

Downloads e instâncias permanecem no disco escolhido.

Pasta do Minecraft{gameDirectory}
Cache do launcher{storageRoot} · downloads temporários
Java detectado{javaRuntimes[0] ? `Java ${javaRuntimes[0].major} · ${javaRuntimes[0].path}` : "Nenhum Java encontrado"}
} - {section === "network" &&

Rede e fontes

Conteúdo é pesquisado e baixado diretamente de fontes conhecidas.

ModrinthPronto para mods, modpacks, shaders e texturas
CurseForge{curseForgeStatus.configured ? `Conectado · ${curseForgeStatus.source}` : "Exige uma chave gratuita de desenvolvedor. Ela fica protegida neste dispositivo."}
{!curseForgeStatus.configured && setCurseForgeKey(event.target.value)} placeholder="Chave da API CurseForge" autoComplete="off" />} {!curseForgeStatus.configured && }{curseForgeStatus.configured && }
playit.ggTúnel opcional para compartilhar servidores locais
} + {section === "minecraft" &&
+

Pastas e armazenamento

Downloads e instâncias permanecem no disco escolhido.

+
Pasta do Minecraft{gameDirectory}
+
Instalações existentesEncontra pastas do Minecraft e resume versões, mods, mundos, texturas e shaders.
+ {installations.map((installation) =>
{installation.label}{installation.managed_by_vex ? " · VEX" : ""}{installation.versions.slice(0, 3).join(", ") || "Versão não identificada"} · {installation.mods} mods · {installation.worlds} mundos · {installation.resource_packs} texturas · {installation.shaders} shaders · {installation.size_mb} MB{installation.path}
)} +
Cache do launcher{storageRoot} · downloads temporários
+
Java detectado{javaRuntimes[0] ? `Java ${javaRuntimes[0].major} · ${javaRuntimes[0].path}` : "Nenhum Java encontrado"}
+
} + {section === "network" &&

Rede e fontes

O Modrinth funciona sem configuração. CurseForge é uma fonte adicional opcional.

ModrinthFonte principal sem chave para mods, modpacks, shaders e texturas
CurseForge · opcional{curseForgeStatus.configured ? `Conectado · ${curseForgeStatus.source}` : "A API oficial exige uma chave gratuita. O restante do VEX continua funcionando sem ela."}
{!curseForgeStatus.configured && setCurseForgeKey(event.target.value)} placeholder="Chave opcional da API CurseForge" autoComplete="off" />} {!curseForgeStatus.configured && }{curseForgeStatus.configured && }
playit.ggTúnel opcional para compartilhar servidores locais
} {section === "appearance" &&

Aparência e acessibilidade

Escolha um tema confortável para sua tela e ajuste a densidade.

{([{ id: "dark", label: "Escuro", note: "Padrão equilibrado" }, { id: "amoled", label: "AMOLED", note: "Pretos absolutos" }, { id: "light", label: "Claro", note: "Ambientes iluminados" }, { id: "contrast", label: "Alto contraste", note: "Acessibilidade" }] as Array<{ id: Theme; label: string; note: string }>).map((item) => )}
Modo compactoReduz espaços em listas longas.
} {section === "advanced" && <>

Comportamento do jogo

Preferências aplicadas ao iniciar Minecraft.

Minimizar ao iniciarRecolhe o VEX quando o Minecraft abrir.
MangoHud no LinuxAtiva a camada MangoHud quando ela estiver instalada no sistema.

Java detectado

O launcher seleciona automaticamente uma versão compatível.

{javaRuntimes.map((runtime) =>
Java {runtime.major}{runtime.path}
)}{!javaRuntimes.length &&
Nenhum Java encontrado.
}
} {section === "help" &&

Ajuda

Tutoriais, documentação e diagnóstico.

Tutorial do VEXVeja novamente o guia completo do launcher.
Ajuda do MinecraftConta, instalação e solução de problemas
Pasta de logs{storageRoot}\logs
} diff --git a/src/styles.css b/src/styles.css index e3940c9..c593ac5 100644 --- a/src/styles.css +++ b/src/styles.css @@ -407,9 +407,9 @@ h2 { font-size: 16px; line-height: 1.25; letter-spacing: 0; } .account-row { display: flex; align-items: center; gap: 10px; padding-top: 14px; border-top: 1px solid var(--line-soft); } .account-icon { width: 36px; height: 36px; display: grid; place-items: center; color: var(--text-2); background: var(--surface-3); border-radius: 5px; } .setting-row { display: flex; align-items: center; justify-content: space-between; gap: 10px; min-height: 56px; padding: 7px 0; border-top: 1px solid var(--line-soft); } -.setting-row > span { display: flex; flex-direction: column; gap: 4px; } +.setting-row > span { display: flex; flex-direction: column; gap: 4px; min-width: 0; } .setting-row b { font-size: 10px; } -.setting-row small { color: var(--text-3); font-size: 8px; } +.setting-row small { color: var(--text-3); font-size: 8px; overflow-wrap: anywhere; } .logout-button { align-self: flex-start; display: flex; align-items: center; gap: 8px; padding: 9px 12px; border: 1px solid rgba(229,114,123,.18); border-radius: 5px; background: rgba(229,114,123,.06); color: #db858d; font-size: 9px; font-weight: 700; } .logout-button:hover { background: rgba(229,114,123,.11); } .live-console { min-height: calc(100vh - 155px); padding: 17px; } @@ -568,7 +568,7 @@ h2 { font-size: 16px; line-height: 1.25; letter-spacing: 0; } .titlebar-brand span { display: none; } .breadcrumbs { font-size: 10px; } .sidebar-mobile-wrap { position: fixed; left: 0; top: 48px; bottom: 0; z-index: 8; transform: translateX(-100%); transition: transform .2s ease; box-shadow: 16px 0 40px rgba(0,0,0,.45); } - .sidebar-mobile-wrap.open { transform: translateX(0); } + .sidebar-mobile-wrap.open { transform: translateX(0) !important; } .sidebar { width: 216px !important; } .sidebar .sidebar-section-label, .sidebar .nav-item span, .sidebar .instance-nav-text, .sidebar .profile-copy, .sidebar .profile-row svg { opacity: 1 !important; max-width: 150px !important; } .sidebar .nav-item, .sidebar .instance-nav, .sidebar .profile-row { justify-content: flex-start !important; padding: 0 10px !important; }