diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 62c39a9..fbb8497 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -56,6 +56,8 @@ jobs: path: | src-tauri/target/release/bundle/nsis/*.exe src-tauri/target/release/VEX Launcher Portable.exe + src-tauri/target/release/VEX Launcher Portable.zip + src-tauri/target/release/SHA256SUMS.txt if-no-files-found: error linux: diff --git a/FEATURES.md b/FEATURES.md index c592983..842a487 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -4,7 +4,7 @@ This roadmap compares the most useful instance-management ideas from Modrinth Ap ## Available In VEX -- Microsoft and offline profiles with saved offline skins. +- Microsoft and offline profiles with a reusable offline skin library and interactive 3D previews. - Isolated Vanilla, Fabric, Quilt, Forge, and NeoForge instances. - Official Forge and NeoForge installer integration. - Automatic compatible Java runtime downloads. @@ -17,7 +17,7 @@ This roadmap compares the most useful instance-management ideas from Modrinth Ap - 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. +- Windows installer, recommended portable ZIP, optional self-contained portable build, and Linux AppImage. ## Useful Next Features diff --git a/PORTABLE.md b/PORTABLE.md new file mode 100644 index 0000000..3b10066 --- /dev/null +++ b/PORTABLE.md @@ -0,0 +1,14 @@ +# VEX Portable + +The recommended portable download is `VEX Launcher Portable.zip`. + +Extract the ZIP and keep these files together: + +- `VEX Launcher.exe` +- `WebView2Loader.dll` + +This version does not install the launcher and does not need administrator privileges. It avoids the self-extracting behavior used by the single-file portable executable, which can trigger stricter browser heuristics. + +The single-file portable remains available for convenience, but Windows SmartScreen can warn about any newly published unsigned executable. A trusted code-signing certificate is required to display a verified publisher. + +Always download VEX from the official GitHub release and compare its SHA-256 hash with `SHA256SUMS.txt`. diff --git a/README.md b/README.md index e135191..62db052 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.8 +## VEX 0.9 - Combined Modrinth and CurseForge discovery with source, version, loader, and content-type filters. - Dedicated pages for mods, modpacks, shaders, resource packs, and plugins. @@ -13,7 +13,7 @@ The project started as a design experiment built with AI-assisted programming. I - Compatible Minecraft versions shown directly in the instance editor. - Modrinth and CurseForge modpack installation with integrity checks when the source provides hashes. - Automatic compatible Java runtime downloads from Eclipse Adoptium, isolated inside VEX data. -- Saved offline profiles, a skin library, and official Microsoft login on Windows. +- Saved offline profiles, a reusable skin library, an interactive 3D skin viewer, 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. @@ -70,7 +70,7 @@ On Windows: .\build-portable.ps1 ``` -This creates the installer and the self-contained portable executable. +This creates the installer, a self-contained portable executable, a recommended portable ZIP, and SHA-256 checksums. The Linux AppImage is built and tested automatically by GitHub Actions. It can run on most modern distributions without installation: diff --git a/SECURITY.md b/SECURITY.md index 1d6d1e0..7e2a75f 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,27 +1,36 @@ -# Política de segurança +# Security policy -## Dados locais +## Local data -O VEX armazena perfis, skins, logs, instâncias, servidores, mundos, configurações e runtimes Java localmente. Esses dados não fazem parte do repositório e nunca devem ser enviados em commits, issues ou relatórios públicos. +VEX stores profiles, skins, logs, instances, servers, worlds, settings, and Java runtimes locally. These files are not part of the repository and must never be included in commits, issues, or public reports. -O launcher não envia mundos, skins, nomes de usuário, logs ou arquivos de instâncias para serviços próprios. A rede é usada somente para autenticação solicitada pelo jogador e para baixar metadados, conteúdo e runtimes de fontes conhecidas. +The launcher does not upload worlds, skins, usernames, logs, or instance files to a VEX service. Network access is used only for authentication requested by the player and for downloading metadata, content, and runtimes from known sources. -## Credenciais +## Credentials -- No Windows, o token de renovação da conta Microsoft e a chave do CurseForge são protegidos para o usuário atual com DPAPI. -- No Linux, a chave do CurseForge é armazenada em um arquivo local acessível somente pelo próprio usuário. -- Senhas Microsoft são digitadas apenas na página oficial da Microsoft e nunca passam pelo VEX. -- Nenhuma chave, token ou dado pessoal deve ser incluído no código-fonte. +- On Windows, Microsoft refresh tokens and the optional CurseForge API key are protected for the current user with DPAPI. +- On Linux, the CurseForge key is stored in a local user-only file. +- Microsoft passwords are entered only on the official Microsoft page and never pass through VEX. +- Keys, tokens, and personal data must never be included in source code. -## Downloads automáticos +## Automatic downloads -- Java: Eclipse Adoptium, verificado com SHA-256. -- Modrinth: arquivos oficiais, verificados com SHA-512 quando disponível. -- CurseForge: arquivos da CDN oficial, verificados com MD5 quando disponível. -- Forge e NeoForge: instaladores obtidos dos repositórios Maven oficiais. +- Java: Eclipse Adoptium, verified with SHA-256. +- Modrinth: official files, verified with SHA-512 when available. +- CurseForge: official CDN files, verified with MD5 when available. +- Forge and NeoForge: installers obtained from their official Maven repositories. -O VEX restringe instalações automáticas às pastas configuradas do Minecraft, das instâncias e dos servidores. +VEX restricts automatic installations to configured Minecraft, instance, and server directories. -## Relatar uma vulnerabilidade +## Windows SmartScreen -Não publique vulnerabilidades que incluam tokens, caminhos pessoais, logs privados ou dados de jogadores em uma issue pública. Entre em contato de forma privada com o responsável pelo projeto e inclua somente os passos mínimos para reproduzir o problema. +Current public builds are not digitally signed because the project does not yet own a trusted code-signing certificate. Windows SmartScreen and browsers can warn about newly published unsigned executables even when no malware is detected. + +- The recommended portable distribution is a ZIP containing the launcher and `WebView2Loader.dll`. +- The single-file portable is self-extracting and may trigger stricter heuristic warnings. +- Every release publishes `SHA256SUMS.txt` so downloads can be verified. +- A self-signed certificate is not used because it does not establish public trust and can make warnings more confusing. + +## Reporting a vulnerability + +Do not publish vulnerabilities containing tokens, personal paths, private logs, or player data in a public issue. Contact the project owner privately and include only the minimum steps required to reproduce the issue. diff --git a/build-portable.ps1 b/build-portable.ps1 index cb7782b..299ed01 100644 --- a/build-portable.ps1 +++ b/build-portable.ps1 @@ -23,6 +23,8 @@ if (Test-Path -LiteralPath $tools) { $releaseRoot = if ($env:CARGO_TARGET_DIR) { Join-Path $env:CARGO_TARGET_DIR "release" } else { Join-Path $PSScriptRoot "src-tauri\target\release" } $portableOutput = Join-Path $releaseRoot "VEX Launcher Portable.exe" +$portableDirectory = Join-Path $releaseRoot "VEX Launcher Portable" +$portableZip = Join-Path $releaseRoot "VEX Launcher Portable.zip" $launcherPayload = Join-Path $releaseRoot "vex-launcher.exe" $loaderPayload = Join-Path $releaseRoot "WebView2Loader.dll" $bootstrapSource = Join-Path $PSScriptRoot "portable-bootstrap\Program.cs" @@ -38,4 +40,38 @@ if (-not (Test-Path -LiteralPath $loaderPayload)) { if ($LASTEXITCODE -ne 0) { throw "Não foi possível gerar o portátil autocontido." } + +$portableResolvedRoot = [IO.Path]::GetFullPath($portableDirectory) +$releaseResolvedRoot = [IO.Path]::GetFullPath($releaseRoot) +if (-not $portableResolvedRoot.StartsWith($releaseResolvedRoot, [StringComparison]::OrdinalIgnoreCase)) { + throw "Pasta portátil fora do diretório de build." +} +if (Test-Path -LiteralPath $portableDirectory) { + Remove-Item -LiteralPath $portableDirectory -Recurse -Force +} +New-Item -ItemType Directory -Path $portableDirectory | Out-Null +Copy-Item -LiteralPath $launcherPayload -Destination (Join-Path $portableDirectory "VEX Launcher.exe") +Copy-Item -LiteralPath $loaderPayload -Destination (Join-Path $portableDirectory "WebView2Loader.dll") +Copy-Item -LiteralPath (Join-Path $PSScriptRoot "PORTABLE.md") -Destination (Join-Path $portableDirectory "README.md") +if (Test-Path -LiteralPath $portableZip) { + Remove-Item -LiteralPath $portableZip -Force +} +Compress-Archive -Path (Join-Path $portableDirectory "*") -DestinationPath $portableZip -CompressionLevel Optimal + +$installerOutput = Get-ChildItem -LiteralPath (Join-Path $releaseRoot "bundle\nsis") -Filter "*-setup.exe" | + Sort-Object LastWriteTime -Descending | + Select-Object -First 1 +$checksumOutput = Join-Path $releaseRoot "SHA256SUMS.txt" +$checksumFiles = @($portableOutput, $portableZip) +if ($installerOutput) { + $checksumFiles += $installerOutput.FullName +} +$checksumLines = $checksumFiles | ForEach-Object { + $hash = Get-FileHash -LiteralPath $_ -Algorithm SHA256 + "$($hash.Hash.ToLowerInvariant()) $(Split-Path $_ -Leaf)" +} +Set-Content -LiteralPath $checksumOutput -Value $checksumLines -Encoding UTF8 + Write-Host "Portable: $portableOutput" +Write-Host "Portable ZIP recomendado: $portableZip" +Write-Host "Checksums: $checksumOutput" diff --git a/package-lock.json b/package-lock.json index a3fd103..86a2291 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,8 @@ "@tauri-apps/api": "^2.8.0", "lucide-react": "^0.468.0", "react": "^19.1.0", - "react-dom": "^19.1.0" + "react-dom": "^19.1.0", + "skinview3d": "^3.4.2" }, "devDependencies": { "@tauri-apps/cli": "^2.8.0", @@ -1506,6 +1507,30 @@ "@types/react": "^19.2.0" } }, + "node_modules/@types/stats.js": { + "version": "0.17.4", + "resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz", + "integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==", + "license": "MIT" + }, + "node_modules/@types/three": { + "version": "0.156.0", + "resolved": "https://registry.npmjs.org/@types/three/-/three-0.156.0.tgz", + "integrity": "sha512-733bXDSRdlrxqOmQuOmfC1UBRuJ2pREPk8sWnx9MtIJEVDQMx8U0NQO5MVVaOrjzDPyLI+cFPim2X/ss9v0+LQ==", + "license": "MIT", + "dependencies": { + "@types/stats.js": "*", + "@types/webxr": "*", + "fflate": "~0.6.10", + "meshoptimizer": "~0.18.1" + } + }, + "node_modules/@types/webxr": { + "version": "0.5.24", + "resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz", + "integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==", + "license": "MIT" + }, "node_modules/@vitejs/plugin-react": { "version": "4.7.0", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", @@ -1704,6 +1729,12 @@ } } }, + "node_modules/fflate": { + "version": "0.6.10", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.6.10.tgz", + "integrity": "sha512-IQrh3lEPM93wVCEczc9SaAOvkmcoQn/G8Bo1e8ZPlY3X3bnAxWaBdvTdvM1hP62iZp0BXWDy4vTAy4fF0+Dlpg==", + "license": "MIT" + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -1781,6 +1812,12 @@ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc" } }, + "node_modules/meshoptimizer": { + "version": "0.18.1", + "resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-0.18.1.tgz", + "integrity": "sha512-ZhoIoL7TNV4s5B6+rx5mC//fw8/POGyNxS/DZyCJeiZ12ScLfVwRE/GfsxwiTkMYYD5DmK2/JXnEVXqL4rF+Sw==", + "license": "MIT" + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -1958,6 +1995,23 @@ "semver": "bin/semver.js" } }, + "node_modules/skinview-utils": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/skinview-utils/-/skinview-utils-0.7.1.tgz", + "integrity": "sha512-4eLrMqR526ehlZbsd8SuZ/CHpS9GiH0xUMoV+PYlJVi95ZFz5HJu7Spt5XYa72DRS7wgt5qquvHZf0XZJgmu9Q==", + "license": "MIT" + }, + "node_modules/skinview3d": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/skinview3d/-/skinview3d-3.4.2.tgz", + "integrity": "sha512-nwxTmT+OWCckDu5CsbjFnyOqthNjl9Fahjqzyy55qcl6jXHIjMrmvDj0b1VZFvT39UKYuO1gnzAQicQ1TH4hvg==", + "license": "MIT", + "dependencies": { + "@types/three": "^0.156.0", + "skinview-utils": "^0.7.1", + "three": "^0.156.0" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -1968,6 +2022,12 @@ "node": ">=0.10.0" } }, + "node_modules/three": { + "version": "0.156.1", + "resolved": "https://registry.npmjs.org/three/-/three-0.156.1.tgz", + "integrity": "sha512-kP7H0FK9d/k6t/XvQ9FO6i+QrePoDcNhwl0I02+wmUJRNSLCUIDMcfObnzQvxb37/0Uc9TDT0T1HgsRRrO6SYQ==", + "license": "MIT" + }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", diff --git a/package.json b/package.json index 946e5b7..74fe3ba 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "vex-launcher", "private": true, - "version": "0.8.0", + "version": "0.9.0", "type": "module", "scripts": { "dev": "vite --host 127.0.0.1", @@ -13,7 +13,8 @@ "@tauri-apps/api": "^2.8.0", "lucide-react": "^0.468.0", "react": "^19.1.0", - "react-dom": "^19.1.0" + "react-dom": "^19.1.0", + "skinview3d": "^3.4.2" }, "devDependencies": { "@tauri-apps/cli": "^2.8.0", diff --git a/portable-bootstrap/Program.cs b/portable-bootstrap/Program.cs index 4a59bcb..c076eea 100644 --- a/portable-bootstrap/Program.cs +++ b/portable-bootstrap/Program.cs @@ -6,6 +6,14 @@ using System.Text; using System.Windows.Forms; +[assembly: AssemblyTitle("VEX Launcher Portable")] +[assembly: AssemblyDescription("Self-contained portable bootstrap for the open-source VEX Minecraft Launcher")] +[assembly: AssemblyCompany("VEX Launcher")] +[assembly: AssemblyProduct("VEX Launcher")] +[assembly: AssemblyCopyright("Copyright (c) VEX Launcher contributors")] +[assembly: AssemblyVersion("0.9.0.0")] +[assembly: AssemblyFileVersion("0.9.0.0")] + internal static class Program { private const string LauncherResource = "VexLauncher.Payload.exe"; diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 5f2be69..07e2735 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -4148,7 +4148,7 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" [[package]] name = "vex-launcher" -version = "0.8.0" +version = "0.9.0" dependencies = [ "base64 0.22.1", "flate2", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 836d16b..63302e2 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "vex-launcher" -version = "0.8.0" +version = "0.9.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 4139d08..f98c421 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -120,6 +120,26 @@ struct LauncherSettings { minimize_on_launch: bool, } +#[derive(Debug, Clone, Serialize, Deserialize)] +struct StoredOfflineSkin { + id: String, + name: String, + path: String, + model: String, + created_unix: u64, +} + +#[derive(Debug, Clone, Serialize)] +struct OfflineSkinSummary { + id: String, + name: String, + path: String, + model: String, + created_unix: u64, + data_url: String, + active: bool, +} + impl Default for LauncherSettings { fn default() -> Self { Self { @@ -257,6 +277,82 @@ fn microsoft_skin_path() -> PathBuf { storage_root().join("profiles").join("microsoft-skin.png") } +fn offline_skin_library_dir() -> PathBuf { + storage_root().join("profiles").join("skins") +} + +fn offline_skin_library_path() -> PathBuf { + offline_skin_library_dir().join("library.json") +} + +fn write_offline_skin_library(skins: &[StoredOfflineSkin]) -> Result<(), String> { + let path = offline_skin_library_path(); + fs::create_dir_all( + path.parent() + .ok_or_else(|| String::from("Caminho da biblioteca de skins inválido."))?, + ) + .map_err(|error| error.to_string())?; + fs::write( + path, + serde_json::to_string_pretty(skins).map_err(|error| error.to_string())?, + ) + .map_err(|error| error.to_string()) +} + +fn read_offline_skin_library() -> Vec { + let mut skins: Vec = fs::read_to_string(offline_skin_library_path()) + .ok() + .and_then(|raw| serde_json::from_str(&raw).ok()) + .unwrap_or_default(); + skins.retain(|skin| Path::new(&skin.path).is_file()); + + if let Some(active_path) = read_settings() + .offline_skin_path + .filter(|path| Path::new(path).is_file()) + { + if !skins.iter().any(|skin| skin.path == active_path) { + let id = Sha256::digest(active_path.as_bytes())[..12] + .iter() + .map(|byte| format!("{byte:02x}")) + .collect(); + skins.push(StoredOfflineSkin { + id, + name: String::from("Skin anterior"), + path: active_path, + model: String::from("auto-detect"), + created_unix: SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_secs()) + .unwrap_or_default(), + }); + } + } + let _ = write_offline_skin_library(&skins); + skins +} + +fn skin_summary(skin: &StoredOfflineSkin, active_path: Option<&str>) -> OfflineSkinSummary { + OfflineSkinSummary { + id: skin.id.clone(), + name: skin.name.clone(), + path: skin.path.clone(), + model: skin.model.clone(), + created_unix: skin.created_unix, + data_url: image_data_url(Path::new(&skin.path), "image/png").unwrap_or_default(), + active: active_path == Some(skin.path.as_str()), + } +} + +fn list_skin_summaries() -> Vec { + let active_path = read_settings().offline_skin_path; + let mut skins = read_offline_skin_library(); + skins.sort_by(|left, right| right.created_unix.cmp(&left.created_unix)); + skins + .iter() + .map(|skin| skin_summary(skin, active_path.as_deref())) + .collect() +} + fn normalized_minecraft_texture_url(value: &str) -> Option { let mut url = reqwest::Url::parse(value).ok()?; if url.host_str() != Some("textures.minecraft.net") || !url.path().starts_with("/texture/") { @@ -4859,8 +4955,7 @@ fn save_offline_profile( Ok(settings) } -#[tauri::command] -fn save_offline_skin(bytes: Vec) -> Result { +fn validate_offline_skin_png(bytes: &[u8]) -> Result<(), String> { const PNG_SIGNATURE: &[u8; 8] = b"\x89PNG\r\n\x1a\n"; if bytes.len() > 10 * 1024 * 1024 { return Err(String::from("A skin precisa ter no máximo 10 MB.")); @@ -4884,27 +4979,128 @@ fn save_offline_skin(bytes: Vec) -> Result { "Dimensões inválidas: {width}x{height}. Use 64x64 ou 64x32." )); } + Ok(()) +} - let skin_path = storage_root().join("profiles").join("offline_skin.png"); - let parent = skin_path - .parent() - .ok_or_else(|| String::from("Caminho de skin inválido."))?; - fs::create_dir_all(parent).map_err(|error| error.to_string())?; +fn normalized_skin_model(model: &str) -> String { + match model.trim().to_ascii_lowercase().as_str() { + "default" | "wide" => String::from("default"), + "slim" => String::from("slim"), + _ => String::from("auto-detect"), + } +} + +#[tauri::command] +fn list_saved_skins() -> Vec { + list_skin_summaries() +} + +#[tauri::command] +fn add_saved_skin( + bytes: Vec, + name: String, + model: String, +) -> Result { + validate_offline_skin_png(&bytes)?; + let digest = Sha256::digest(&bytes); + let id: String = digest[..12] + .iter() + .map(|byte| format!("{byte:02x}")) + .collect(); + let skin_dir = offline_skin_library_dir(); + fs::create_dir_all(&skin_dir).map_err(|error| error.to_string())?; + let skin_path = skin_dir.join(format!("{id}.png")); fs::write(&skin_path, bytes).map_err(|error| error.to_string())?; + let mut skins = read_offline_skin_library(); + let clean_name = name.trim(); + let model = normalized_skin_model(&model); + let created_unix = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_secs()) + .unwrap_or_default(); + if let Some(existing) = skins.iter_mut().find(|skin| skin.id == id) { + if !clean_name.is_empty() { + existing.name = clean_name.chars().take(48).collect(); + } + existing.model = model.clone(); + } else { + skins.push(StoredOfflineSkin { + id: id.clone(), + name: if clean_name.is_empty() { + format!("Skin {}", skins.len() + 1) + } else { + clean_name.chars().take(48).collect() + }, + path: skin_path.to_string_lossy().into_owned(), + model: model.clone(), + created_unix, + }); + } + write_offline_skin_library(&skins)?; + let mut settings = read_settings(); settings.offline_skin_path = Some(skin_path.to_string_lossy().into_owned()); settings.use_offline_profile = true; + settings.onboarding_completed = true; write_settings(&settings)?; - Ok(skin_path.to_string_lossy().into_owned()) + let stored = skins + .iter() + .find(|skin| skin.id == id) + .ok_or_else(|| String::from("A skin foi salva, mas não entrou na biblioteca."))?; + Ok(skin_summary(stored, settings.offline_skin_path.as_deref())) } #[tauri::command] -fn remove_offline_skin() -> Result<(), String> { +fn activate_saved_skin(id: String) -> Result { + let skins = read_offline_skin_library(); + let skin = skins + .iter() + .find(|skin| skin.id == id && Path::new(&skin.path).is_file()) + .ok_or_else(|| String::from("Skin salva não encontrada."))?; let mut settings = read_settings(); - if let Some(path) = settings.offline_skin_path.take() { - let _ = fs::remove_file(path); + settings.offline_skin_path = Some(skin.path.clone()); + settings.use_offline_profile = true; + settings.onboarding_completed = true; + write_settings(&settings)?; + Ok(skin_summary(skin, settings.offline_skin_path.as_deref())) +} + +#[tauri::command] +fn delete_saved_skin(id: String) -> Result, String> { + let mut skins = read_offline_skin_library(); + let index = skins + .iter() + .position(|skin| skin.id == id) + .ok_or_else(|| String::from("Skin salva não encontrada."))?; + let removed = skins.remove(index); + let removed_path = PathBuf::from(&removed.path); + if removed_path.starts_with(offline_skin_library_dir()) { + let _ = fs::remove_file(&removed_path); } + let mut settings = read_settings(); + if settings.offline_skin_path.as_deref() == Some(removed.path.as_str()) { + settings.offline_skin_path = skins.first().map(|skin| skin.path.clone()); + } + write_settings(&settings)?; + write_offline_skin_library(&skins)?; + Ok(list_skin_summaries()) +} + +#[tauri::command] +fn save_offline_skin(bytes: Vec) -> Result { + add_saved_skin( + bytes, + String::from("Skin salva"), + String::from("auto-detect"), + ) + .map(|skin| skin.path) +} + +#[tauri::command] +fn remove_offline_skin() -> Result<(), String> { + let mut settings = read_settings(); + settings.offline_skin_path = None; write_settings(&settings) } @@ -5299,6 +5495,10 @@ pub fn run() { check_modpack_update, install_modrinth_modpack, save_offline_profile, + list_saved_skins, + add_saved_skin, + activate_saved_skin, + delete_saved_skin, save_offline_skin, remove_offline_skin, get_server_profile, diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 796f0dd..413e13a 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.8.0", + "version": "0.9.0", "identifier": "app.vexlauncher.desktop", "build": { "beforeDevCommand": "npm run dev", diff --git a/src/App.tsx b/src/App.tsx index 2f7b22f..6905d49 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -26,6 +26,7 @@ import { Minus, MoreHorizontal, PackagePlus, + Pause, Play, Plus, RotateCcw, @@ -34,6 +35,7 @@ import { Server, Settings, ShieldCheck, + Shirt, SlidersHorizontal, Square, Sun, @@ -51,12 +53,13 @@ import { useEffect } from "react"; import { invoke, isTauri } from "@tauri-apps/api/core"; import { listen } from "@tauri-apps/api/event"; import { getCurrentWindow } from "@tauri-apps/api/window"; +import type { SkinViewer as SkinViewerType } from "skinview3d"; import { projects, type Instance, type Project } from "./data"; import steveFace from "./assets/steve-face.svg"; import vexFace from "./assets/vexface.svg"; import vexLogo from "./assets/vex.svg"; -type Page = "home" | "library" | "discover" | "server" | "logs" | "settings"; +type Page = "home" | "library" | "discover" | "skins" | "server" | "logs" | "settings"; type DiscoverKind = "Tudo" | "Mods" | "Modpacks" | "Texturas" | "Shaders" | "Plugins"; type DiscoverSource = "Todas" | "Modrinth" | "CurseForge"; type BackendInstance = { id: string; name: string; loader: string; mc_version: string; version_id: string; profile_dir: string; icon_path?: string; kind: string; size_mb: number; modified_unix: number; last_played_unix: number }; @@ -76,11 +79,13 @@ type OperationProgress = { operation: string; label: string; percent: number; do 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 }; +type SavedSkin = { id: string; name: string; path: string; model: "default" | "slim" | "auto-detect"; created_unix: number; data_url: string; active: boolean }; const pageMeta: Record = { home: { title: "Início", eyebrow: "Visão geral" }, library: { title: "Biblioteca", eyebrow: "Suas instâncias" }, discover: { title: "Descobrir", eyebrow: "Conteúdo da comunidade" }, + skins: { title: "Skins", eyebrow: "Seu visual" }, server: { title: "Meu servidor", eyebrow: "Servidor local" }, logs: { title: "Console", eyebrow: "Saída do launcher e Java" }, settings: { title: "Configurações", eyebrow: "Preferências" } @@ -157,11 +162,199 @@ function SkinFace({ skinDataUrl, label, large = false }: { skinDataUrl?: string; return ; } +function SkinViewer3D({ skinDataUrl, model = "auto-detect", compact = false }: { skinDataUrl?: string; model?: SavedSkin["model"]; compact?: boolean }) { + const canvasRef = useRef(null); + const containerRef = useRef(null); + const viewerRef = useRef(null); + const skinSourceRef = useRef(skinDataUrl); + const modelRef = useRef(model); + const [rotating, setRotating] = useState(true); + skinSourceRef.current = skinDataUrl; + modelRef.current = model; + + useEffect(() => { + const canvas = canvasRef.current; + const container = containerRef.current; + if (!canvas || !container) return; + let disposed = false; + let observer: ResizeObserver | undefined; + void import("skinview3d").then(({ IdleAnimation, SkinViewer }) => { + if (disposed) return; + const viewer = new SkinViewer({ + canvas, + width: Math.max(220, container.clientWidth), + height: Math.max(compact ? 250 : 390, container.clientHeight), + enableControls: true, + zoom: compact ? 0.72 : 0.78, + pixelRatio: "match-device" + }); + viewer.autoRotate = true; + viewer.autoRotateSpeed = 0.42; + viewer.animation = new IdleAnimation(); + viewer.animation.speed = 0.75; + viewer.globalLight.intensity = 2.3; + viewer.cameraLight.intensity = 0.7; + viewer.controls.enablePan = false; + viewerRef.current = viewer; + if (skinSourceRef.current) void viewer.loadSkin(skinSourceRef.current, { model: modelRef.current }).catch(() => undefined); + observer = new ResizeObserver(() => { + viewer.setSize(Math.max(220, container.clientWidth), Math.max(compact ? 250 : 390, container.clientHeight)); + }); + observer.observe(container); + }); + return () => { + disposed = true; + observer?.disconnect(); + viewerRef.current?.dispose(); + viewerRef.current = null; + }; + }, [compact]); + + useEffect(() => { + const viewer = viewerRef.current; + if (!viewer) return; + if (skinDataUrl) void viewer.loadSkin(skinDataUrl, { model }).catch(() => undefined); + else viewer.loadSkin(null); + }, [skinDataUrl, model]); + + useEffect(() => { + if (viewerRef.current) viewerRef.current.autoRotate = rotating; + }, [rotating]); + + return
+ + {!skinDataUrl &&
Nenhuma skin selecionadaAdicione uma skin PNG para visualizar em 3D.
} +
+ setRotating(!rotating)}>{rotating ? : } + viewerRef.current?.resetCameraPose()}> +
+ Arraste para girar · use a roda para zoom +
; +} + +function AddSkinModal({ file, dataUrl, close, saved, notify }: { file: File; dataUrl: string; close: () => void; saved: (skin: SavedSkin) => void; notify: (message: string) => void }) { + const [name, setName] = useState(file.name.replace(/\.png$/i, "")); + const [model, setModel] = useState("auto-detect"); + const [busy, setBusy] = useState(false); + const submit = async () => { + setBusy(true); + try { + const bytes = Array.from(new Uint8Array(await file.arrayBuffer())); + const skin = await invoke("add_saved_skin", { bytes, name, model }); + saved(skin); + close(); + } catch (error) { + notify(String(error)); + } finally { + setBusy(false); + } + }; + return
+
+
Biblioteca de skins

Adicionar skin

+
+ +
+ +
Modelo dos braços
+ + + +
+
{file.name}PNG · {(file.size / 1024).toFixed(1)} KB
+
+
+
+
+
; +} + +function SkinsPage({ username, activeSkin, useOfflineProfile, onSkinChanged, onUseOffline, notify }: { username: string; activeSkin?: string; useOfflineProfile: boolean; onSkinChanged: (dataUrl?: string) => void; onUseOffline: () => void; notify: (message: string) => void }) { + const [skins, setSkins] = useState([]); + const [preview, setPreview] = useState(null); + const [pending, setPending] = useState<{ file: File; dataUrl: string } | null>(null); + const inputRef = useRef(null); + const refresh = async () => { + const found = await invoke("list_saved_skins").catch(() => []); + setSkins(found); + setPreview((current) => found.find((skin) => skin.id === current?.id) ?? found.find((skin) => skin.active) ?? null); + }; + useEffect(() => { void refresh(); }, []); + const chooseFile = async (file?: File) => { + if (!file) return; + const dataUrl = await new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(String(reader.result)); + reader.onerror = () => reject(reader.error); + reader.readAsDataURL(file); + }).catch(() => ""); + if (!dataUrl) { + notify("Não foi possível ler a skin."); + return; + } + setPending({ file, dataUrl }); + if (inputRef.current) inputRef.current.value = ""; + }; + const activate = async (skin: SavedSkin) => { + try { + const active = await invoke("activate_saved_skin", { id: skin.id }); + setSkins((current) => current.map((item) => ({ ...item, active: item.id === active.id }))); + setPreview(active); + onSkinChanged(active.data_url); + onUseOffline(); + notify(`${active.name} agora é sua skin offline`); + } catch (error) { + notify(String(error)); + } + }; + const remove = async (skin: SavedSkin) => { + if (!window.confirm(`Excluir "${skin.name}" da biblioteca de skins?`)) return; + try { + const remaining = await invoke("delete_saved_skin", { id: skin.id }); + setSkins(remaining); + const next = remaining.find((item) => item.active); + setPreview(next ?? null); + if (skin.active) onSkinChanged(next?.data_url); + notify(`${skin.name} removida da biblioteca`); + } catch (error) { + notify(String(error)); + } + }; + const selected = preview ?? skins.find((skin) => skin.active); + return
+
Perfil offline

Skins

Visualize em 3D, guarde seus visuais e troque com um clique.

+ void chooseFile(event.target.files?.[0])} /> +
+
+
{useOfflineProfile ? : }{useOfflineProfile ? "Perfil offline ativo" : "Visualização atual"}

{selected?.name ?? username}

{selected ? (selected.model === "slim" ? "Braços slim" : selected.model === "default" ? "Braços clássicos" : "Modelo automático") : "Skin atual do perfil"}
+ +
+
+
{skins.length} salva(s)

Biblioteca de skins

+
+ + {skins.map((skin) =>
+ +
void remove(skin)}>
+
)} +
+ {!skins.length &&
Adicione sua primeira skin para criar a biblioteca.
} +
+
+ {pending && setPending(null)} saved={(skin) => { setSkins((current) => [skin, ...current.map((item) => ({ ...item, active: false }))]); setPreview(skin); onSkinChanged(skin.data_url); onUseOffline(); notify(`${skin.name} adicionada e ativada`); }} notify={notify} />} +
; +} + function Sidebar({ page, setPage, compact, setCompact, username, skinDataUrl, accountLabel, appInstances }: { page: Page; setPage: (p: Page) => void; compact: boolean; setCompact: (v: boolean) => void; username: string; skinDataUrl?: string; accountLabel: string; appInstances: Instance[] }) { const items: { id: Page; label: string; icon: React.ReactNode }[] = [ { id: "home", label: "Início", icon: }, { id: "library", label: "Biblioteca", icon: }, { id: "discover", label: "Descobrir", icon: }, + { id: "skins", label: "Skins", icon: }, { id: "server", label: "Meu servidor", icon: }, { id: "logs", label: "Console", icon: } ]; @@ -1078,6 +1271,7 @@ const tutorialSteps: Array<{ page: Page; title: string; text: string }> = [ { page: "home", title: "Seu ponto de partida", text: "O Início mostra o perfil ativo, a última instância jogada e ações rápidas." }, { page: "library", title: "Sua biblioteca", text: "Crie, clone, configure e apague instâncias. Abra uma instância para cuidar dos mundos e conteúdos dela." }, { page: "discover", title: "Descobrir conteúdo", text: "Pesquise no Modrinth e CurseForge, filtre por versão e instale apenas em instâncias compatíveis." }, + { page: "skins", title: "Sua biblioteca de skins", text: "Visualize skins em 3D, guarde seus visuais offline e troque a skin usada em todas as instâncias." }, { page: "server", title: "Servidor local", text: "Crie e controle seu servidor. Para amigos entrarem pela internet, o caminho mais simples é configurar um túnel no playit.gg." }, { page: "logs", title: "Console e diagnóstico", text: "Acompanhe o Java em tempo real, copie o log e ajuste o tamanho do texto para investigar erros." }, { page: "settings", title: "Conta e preferências", text: "Troque perfil, skin, tema, pasta do jogo e veja novamente este tutorial." } @@ -1328,6 +1522,7 @@ function App() { {page === "home" && } {page === "library" && } {page === "discover" && } + {page === "skins" && void chooseOffline()} notify={notify} />} {page === "server" && } {page === "logs" && } {page === "settings" && void beginMicrosoftLogin()} onUseMicrosoft={() => void useMicrosoft()} onUseOffline={() => void chooseOffline()} onMicrosoftLogout={() => void logoutMicrosoft()} storageRoot={storageRoot} gameDirectory={gameDirectory} javaRuntimes={javaRuntimes} onGameDirectoryChanged={setGameDirectory} notify={notify} theme={theme} onThemeChanged={setTheme} onOpenTutorial={() => setTutorialOpen(true)} mangohudEnabled={mangohudEnabled} minimizeOnLaunch={minimizeOnLaunch} onRuntimePreferencesChanged={async (mangohud, minimize) => { setMangohudEnabled(mangohud); setMinimizeOnLaunch(minimize); await invoke("set_runtime_preferences", { mangohudEnabled: mangohud, minimizeOnLaunch: minimize }).catch((error) => notify(String(error))); }} />} diff --git a/src/styles.css b/src/styles.css index c593ac5..252bc3a 100644 --- a/src/styles.css +++ b/src/styles.css @@ -215,6 +215,53 @@ h2 { font-size: 16px; line-height: 1.25; letter-spacing: 0; } .skin-face-base, .skin-face-overlay { position: absolute; inset: 0; background-repeat: no-repeat; background-size: 800% 800%; image-rendering: pixelated; } .skin-face-base { background-position: 14.2857% 14.2857%; } .skin-face-overlay { background-position: 71.4286% 14.2857%; } +.skins-page { max-width: 1450px; margin: auto; } +.skins-layout { display: grid; grid-template-columns: minmax(300px, 360px) minmax(480px, 1fr); gap: 24px; min-height: calc(100vh - 145px); } +.skin-stage-panel { position: sticky; top: 0; align-self: start; min-height: 590px; overflow: hidden; background: transparent; border: 0; } +.skin-stage-title { position: relative; z-index: 3; display: flex; flex-direction: column; align-items: center; gap: 5px; padding: 8px 10px 0; text-align: center; } +.skin-stage-title h2 { margin-top: 4px; font-size: 18px; } +.skin-stage-title small { color: var(--text-3); font-size: 9px; } +.skin-viewer-3d { position: relative; width: 100%; min-height: 500px; overflow: hidden; } +.skin-viewer-3d::before { content: ""; position: absolute; left: 50%; bottom: 36px; width: 210px; height: 48px; transform: translateX(-50%); border: 1px solid var(--line); border-radius: 50%; background: rgba(0,0,0,.16); box-shadow: 0 18px 35px rgba(0,0,0,.25); } +.skin-viewer-3d canvas { position: absolute; z-index: 1; inset: 0; width: 100%; height: 100%; display: block; cursor: grab; } +.skin-viewer-3d canvas:active { cursor: grabbing; } +.skin-viewer-3d.compact { min-height: 290px; background: var(--input); border: 1px solid var(--line); border-radius: 7px; } +.skin-viewer-3d.compact::before { bottom: 27px; width: 140px; height: 32px; } +.skin-viewer-empty { position: absolute; z-index: 2; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 7px; color: var(--text-3); text-align: center; pointer-events: none; } +.skin-viewer-empty b { color: var(--text-2); font-size: 11px; } +.skin-viewer-empty span { max-width: 210px; font-size: 8px; line-height: 1.5; } +.skin-viewer-controls { position: absolute; z-index: 3; right: 8px; bottom: 8px; display: flex; gap: 3px; padding: 3px; border: 1px solid var(--line); border-radius: 6px; background: color-mix(in srgb, var(--surface) 88%, transparent); backdrop-filter: blur(10px); } +.skin-viewer-hint { position: absolute; z-index: 3; left: 50%; bottom: 14px; transform: translateX(-50%); color: var(--text-3); font-size: 8px; white-space: nowrap; pointer-events: none; } +.skin-viewer-3d.compact .skin-viewer-hint { display: none; } +.skins-library-panel { min-width: 0; padding: 18px; } +.saved-skins-grid { display: grid; grid-template-columns: repeat(3, minmax(145px, 1fr)); gap: 9px; } +.add-skin-card, .saved-skin-card { min-height: 190px; border: 1px solid var(--line); border-radius: 7px; background: var(--surface-2); } +.add-skin-card { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 7px; color: var(--text-3); } +.add-skin-card:hover { color: var(--text); background: var(--surface-3); border-color: var(--text-3); } +.add-skin-card b { color: var(--text-2); font-size: 10px; } +.add-skin-card span { font-size: 8px; } +.saved-skin-card { position: relative; display: flex; flex-direction: column; overflow: hidden; transition: border-color .18s ease, background-color .18s ease, transform .18s ease; } +.saved-skin-card:hover { transform: translateY(-2px); border-color: var(--text-3); } +.saved-skin-card.active { border-color: var(--violet); box-shadow: inset 0 0 0 1px var(--violet); } +.saved-skin-card.preview:not(.active) { border-color: #577080; } +.saved-skin-main { position: relative; flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 10px; padding: 16px 10px 9px; border: 0; background: transparent; color: var(--text); } +.saved-skin-main .skin-face.large { width: 78px; height: 78px; box-shadow: 0 14px 30px rgba(0,0,0,.28); } +.saved-skin-main > span:not(.skin-face):not(.skin-active-mark) { display: flex; flex-direction: column; align-items: center; gap: 3px; max-width: 100%; } +.saved-skin-main b { max-width: 100%; overflow: hidden; text-overflow: ellipsis; font-size: 10px; white-space: nowrap; } +.saved-skin-main small { color: var(--text-3); font-size: 8px; } +.skin-active-mark { position: absolute; left: 7px; top: 7px; display: flex; align-items: center; gap: 3px; padding: 4px 6px; color: var(--accent-text); background: var(--violet); border-radius: 4px; font-size: 7px; font-weight: 800; } +.saved-skin-actions { display: flex; align-items: center; justify-content: space-between; min-height: 36px; padding: 3px 5px 3px 9px; border-top: 1px solid var(--line-soft); } +.skin-empty { min-height: 170px; margin-top: 12px; } +.skin-add-modal { width: min(780px, 100%); max-height: calc(100vh - 48px); padding: 22px; overflow: auto; background: var(--surface); border: 1px solid #354956; border-radius: 10px; box-shadow: 0 28px 90px rgba(0,0,0,.62); } +.skin-add-layout { display: grid; grid-template-columns: minmax(250px, 310px) 1fr; gap: 22px; margin-top: 18px; } +.skin-add-form { min-width: 0; } +.skin-model-picker { display: grid; grid-template-columns: repeat(3, 1fr); gap: 5px; } +.skin-model-picker button { height: 35px; border: 1px solid var(--line); border-radius: 5px; background: var(--surface-2); color: var(--text-3); font-size: 9px; font-weight: 700; } +.skin-model-picker button.active { color: var(--text); border-color: var(--violet); background: var(--violet-soft); } +.skin-texture-info { display: flex; align-items: center; gap: 10px; margin-top: 18px; padding: 10px; border: 1px solid var(--line-soft); border-radius: 6px; background: var(--surface-2); } +.skin-texture-info > span:last-child { display: flex; flex-direction: column; gap: 4px; min-width: 0; } +.skin-texture-info b { overflow: hidden; text-overflow: ellipsis; font-size: 9px; white-space: nowrap; } +.skin-texture-info small { color: var(--text-3); font-size: 8px; } .profile-center { display: flex; flex-direction: column; align-items: center; gap: 4px; padding: 12px 0; } .profile-center b { font-size: 12px; } .profile-center span { color: var(--text-3); font-size: 9px; } @@ -460,7 +507,7 @@ h2 { font-size: 16px; line-height: 1.25; letter-spacing: 0; } .tutorial-card h1 { margin-top: 8px; font-size: 22px; } .tutorial-card p { margin-top: 9px; color: var(--text-2); font-size: 10px; line-height: 1.65; } .tutorial-count { position: absolute; right: 20px; top: 20px; color: var(--text-3); font-size: 8px; } -.tutorial-progress { display: grid; grid-template-columns: repeat(6, 1fr); gap: 4px; margin-top: 18px; } +.tutorial-progress { display: grid; grid-template-columns: repeat(auto-fit, minmax(20px, 1fr)); gap: 4px; margin-top: 18px; } .tutorial-progress span { height: 3px; border-radius: 3px; background: var(--line); } .tutorial-progress span.active { background: var(--violet); } .floating-help { position: fixed; z-index: 30; right: 18px; bottom: 18px; width: 42px; height: 42px; display: grid; place-items: center; color: var(--accent-text); background: var(--violet); border: 0; border-radius: 50%; box-shadow: 0 10px 30px rgba(0,0,0,.38); } @@ -558,6 +605,8 @@ h2 { font-size: 16px; line-height: 1.25; letter-spacing: 0; } .project-grid { grid-template-columns: 1fr; } .detail-columns, .server-columns { grid-template-columns: 1fr; } .server-side-stack { display: grid; grid-template-columns: 1fr 1fr; height: auto; overflow: visible; } + .skins-layout { grid-template-columns: minmax(260px, 320px) minmax(380px, 1fr); gap: 14px; } + .saved-skins-grid { grid-template-columns: repeat(2, minmax(140px, 1fr)); } } @media (max-width: 850px) { @@ -582,6 +631,9 @@ h2 { font-size: 16px; line-height: 1.25; letter-spacing: 0; } .settings-layout { grid-template-columns: 1fr; } .settings-nav { flex-direction: row; overflow-x: auto; } .settings-nav button { white-space: nowrap; } + .skins-layout, .skin-add-layout { grid-template-columns: 1fr; } + .skin-stage-panel { position: relative; min-height: 470px; } + .skin-viewer-3d { min-height: 405px; } .results-heading { align-items: flex-start; gap: 10px; } .results-controls { flex-direction: column; align-items: stretch; } }