feat: GNU/Linux support and more (#10)

Co-authored-by: Santiago Fisela <santyfisela@gmail.com>
This commit is contained in:
/home/neo 2026-03-12 20:39:42 +03:00 committed by GitHub
parent 7a75ef113c
commit 5b21bc6484
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
61 changed files with 5108 additions and 117 deletions

60
.github/workflows/publish.yml vendored Normal file
View file

@ -0,0 +1,60 @@
name: 'publish'
on:
workflow_dispatch:
jobs:
publish-tauri:
permissions:
contents: write
strategy:
fail-fast: false
matrix:
include:
- platform: 'macos-latest'
args: '--target aarch64-apple-darwin'
- platform: 'macos-latest'
args: '--target x86_64-apple-darwin'
- platform: 'ubuntu-22.04'
args: ''
- platform: 'windows-latest'
args: ''
runs-on: ${{ matrix.platform }}
steps:
- uses: actions/checkout@v4
- name: install dependencies (ubuntu only)
if: matrix.platform == 'ubuntu-22.04'
run: |
sudo apt-get update
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf
- name: setup node
uses: actions/setup-node@v4
with:
node-version: lts/*
cache: 'pnpm'
- name: install Rust stable
uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.platform == 'macos-latest' && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }}
- name: Rust cache
uses: swatinem/rust-cache@v2
with:
workspaces: './src-tauri -> target'
- name: install frontend dependencies
run: pnpm install
- uses: tauri-apps/tauri-action@v0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
tagName: app-v__VERSION__
releaseName: 'Emerald v__VERSION__'
releaseBody: '*pls edit me*'
releaseDraft: true
prerelease: false
args: ${{ matrix.args }}

6
.gitignore vendored
View file

@ -24,11 +24,5 @@
.DS_Store
Thumbs.db
# Build output
/app-icon.png
/src-tauri/icons/*.png
/src-tauri/icons/*.ico
/src-tauri/icons/*.icns
# Log files
*.log

24
BUILD.md Normal file
View file

@ -0,0 +1,24 @@
# Building Emerald Launcher
## Requirements
- Node.js (and NPM)
- Rust
- WebKit2GTK-4.1 (GNU/Linux only)
- WebKit (macOS only, preinstalled)
- Microsoft Edge WebView2 Runtime (Windows only)
- PNPM (optional, but recommended)
- Windows, GNU/Linux or a macOS system.
## Building
```sh
pnpm install # or npm
pnpm tauri build # or npm
```
## Notes
On GNU/Linux, the build might fail due to missing logos (because still Alpha, contributions to fix this are welcome).
To fix that, build with this command instead:
```sh
pnpm tauri build --no-bundle
```

View file

@ -1,10 +1,10 @@
# Emerald Legacy Launcher
<div align="center">
<img height="150" src="https://raw.githubusercontent.com/Emerald-Legacy-Launcher/Emerald-Legacy-Launcher/refs/heads/main/public/images/logo.png">
<h1>Emerald Legacy Launcher</h1>
<p>FOSS cross-platform launcher for Minecraft Legacy Console Edition</p>
</div>
**A free and open-source Minecraft Legacy Console Edition launcher, built with Rust and Tauri.**
---
<p align="left">
<p align="center">
<a href="https://ko-fi.com/kayjann">
<img src="https://img.shields.io/badge/ko--fi-Donate%20to%20the%20Project-grey?style=for-the-badge&logo=kofi&logoColor=white&labelColor=FF5E5B" alt="Donate to the Project">
</a>
@ -21,10 +21,13 @@
---
<img width="1355" height="922" alt="image" src="https://github.com/user-attachments/assets/6798dd00-4c7c-4b57-9116-7648ce90ae93" />
### PROJECT OVERVIEW
Emerald Legacy Launcher started as a solo project to create a better way to play Legacy Console Edition. Over time, other developers joined the effort, bringing expertise from various preservation projects into one place.
**Our Philosophy:** For too long, the LCE scene has been fragmented across different, often resource-heavy launchers. Emerald was built to stop this fragmentation and centralize everything into a single, definitive hub. By avoiding bloated frameworks and utilizing a modern Rust/Tauri stack, we deliver a high-performance, cross-platform experience that uses only ~15MB of RAMleaving all your PC's resources dedicated to the game itself.
**Our Philosophy:** For too long, the LCE scene has been fragmented across different, often resource-heavy launchers. Emerald was built to stop this fragmentation and centralize everything into a single, definitive hub. By avoiding bloated frameworks and utilizing a modern Rust/Tauri stack, we deliver a high-performance, cross-platform experience that uses only ~15MB of RAM, leaving all your PC's resources dedicated to the game itself.
---
@ -68,8 +71,4 @@ Click the heading above to track progress, view active tasks, and see upcoming l
---
---
***This project is licensed under the GNU GPL v3 License - see the LICENSE file for details.***
***This project is licensed under the [GNU GPL v3 License](LICENSE)***

2102
pnpm-lock.yaml Normal file

File diff suppressed because it is too large Load diff

BIN
public/images/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

File diff suppressed because it is too large Load diff

BIN
src-tauri/icons/128x128.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

BIN
src-tauri/icons/32x32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

BIN
src-tauri/icons/64x64.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6 KiB

After

Width:  |  Height:  |  Size: 7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3 KiB

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.4 KiB

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 KiB

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.4 KiB

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.2 KiB

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.2 KiB

After

Width:  |  Height:  |  Size: 7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.1 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.5 KiB

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.1 KiB

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.9 KiB

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.5 KiB

After

Width:  |  Height:  |  Size: 9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

BIN
src-tauri/icons/icon.icns Normal file

Binary file not shown.

BIN
src-tauri/icons/icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

BIN
src-tauri/icons/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 987 B

After

Width:  |  Height:  |  Size: 1,014 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5 KiB

After

Width:  |  Height:  |  Size: 5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.7 KiB

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.7 KiB

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.6 KiB

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.6 KiB

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.7 KiB

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.2 KiB

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.1 KiB

After

Width:  |  Height:  |  Size: 7.5 KiB

View file

@ -7,7 +7,23 @@ use futures_util::StreamExt;
use std::sync::Arc;
use tokio::sync::Mutex;
use tokio_util::sync::CancellationToken;
use tauri_plugin_opener::OpenerExt;
use tauri_plugin_opener::OpenerExt;
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize, Clone, Debug)]
#[serde(rename_all = "camelCase")]
pub struct AppConfig {
pub username: String,
pub linux_runner: Option<String>,
}
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct Runner {
pub id: String,
pub name: String,
pub path: String,
pub r#type: String,
}
pub struct DownloadState { pub token: Arc<Mutex<Option<CancellationToken>>> }
@ -17,17 +33,78 @@ fn get_app_dir(app: &AppHandle) -> PathBuf {
})
}
#[tauri::command]
fn save_config(app: AppHandle, username: String) {
let path = get_app_dir(&app).join("emerald_legacy_config.txt");
let _ = fs::create_dir_all(path.parent().unwrap());
let _ = fs::write(path, &username);
fn get_config_path(app: &AppHandle) -> PathBuf {
get_app_dir(app).join("emerald_legacy_config.json")
}
#[tauri::command]
fn load_config(app: AppHandle) -> String {
let path = get_app_dir(&app).join("emerald_legacy_config.txt");
fs::read_to_string(path).unwrap_or_default()
fn save_config(app: AppHandle, config: AppConfig) {
let path = get_config_path(&app);
let _ = fs::create_dir_all(path.parent().unwrap());
if let Ok(json) = serde_json::to_string(&config) {
let _ = fs::write(path, json);
}
}
#[tauri::command]
fn load_config(app: AppHandle) -> AppConfig {
let path = get_config_path(&app);
if let Ok(content) = fs::read_to_string(path) {
if let Ok(config) = serde_json::from_str(&content) {
return config;
}
}
let old_path = get_app_dir(&app).join("emerald_legacy_config.txt");
let username = fs::read_to_string(old_path).unwrap_or_else(|_| "Player".into());
AppConfig { username, linux_runner: None }
}
#[tauri::command]
fn get_available_runners() -> Vec<Runner> {
let mut runners = Vec::new();
#[cfg(target_os = "linux")]
{
if let Ok(output) = Command::new("which").arg("wine").output() {
if output.status.success() {
let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
runners.push(Runner {
id: "wine".to_string(),
name: "System Wine".to_string(),
path,
r#type: "wine".to_string(),
});
}
}
let home = std::env::var("HOME").unwrap_or_default();
let steam_paths = [
format!("{}/.steam/steam/steamapps/common", home),
format!("{}/.local/share/Steam/steamapps/common", home),
];
for base_path in steam_paths {
if let Ok(entries) = fs::read_dir(base_path) {
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
let name = entry.file_name().to_string_lossy().to_string();
if name.starts_with("Proton") {
runners.push(Runner {
id: format!("proton_{}", name),
name: name,
path: path.to_string_lossy().to_string(),
r#type: "proton".to_string(),
});
}
}
}
}
}
}
runners
}
#[tauri::command]
@ -89,6 +166,13 @@ async fn download_and_install(app: AppHandle, state: State<'_, DownloadState>, u
drop(file);
{ *state.token.lock().await = None; }
#[cfg(target_os = "linux")]
let status = Command::new("unzip")
.args(["-q", zip_path.to_str().unwrap(), "-d", instance_dir.to_str().unwrap()])
.status()
.map_err(|e| e.to_string())?;
#[cfg(not(target_os = "linux"))]
let status = Command::new("tar")
.args(["-xf", zip_path.to_str().unwrap(), "-C", instance_dir.to_str().unwrap()])
.status()
@ -125,8 +209,8 @@ async fn download_and_install(app: AppHandle, state: State<'_, DownloadState>, u
async fn launch_game(app: AppHandle, instanceId: String) -> Result<(), String> {
let root = get_app_dir(&app);
let instance_dir = root.join("instances").join(&instanceId);
let config_path = root.join("emerald_legacy_config.txt");
let username = fs::read_to_string(config_path).unwrap_or_else(|_| "Player".into());
let config = load_config(app.clone());
let username = config.username;
let _ = fs::write(instance_dir.join("username.txt"), &username);
@ -135,7 +219,38 @@ async fn launch_game(app: AppHandle, instanceId: String) -> Result<(), String> {
return Err("Game executable not found in instance folder.".into());
}
let _ = Command::new(&game_exe).spawn().map_err(|e| e.to_string())?;
#[cfg(target_os = "linux")]
{
if let Some(runner_id) = config.linux_runner {
let runners = get_available_runners();
if let Some(runner) = runners.into_iter().find(|r| r.id == runner_id) {
let mut cmd = if runner.r#type == "proton" {
let proton_exe = PathBuf::from(&runner.path).join("proton");
let mut c = Command::new(proton_exe);
let compat_data = instance_dir.join("proton_prefix");
fs::create_dir_all(&compat_data).map_err(|e| e.to_string())?;
c.env("STEAM_COMPAT_CLIENT_INSTALL_PATH", "");
c.env("STEAM_COMPAT_DATA_PATH", compat_data.to_str().unwrap());
c.arg("run");
c
} else {
Command::new(runner.path)
};
cmd.arg(&game_exe)
.current_dir(&instance_dir)
.spawn()
.map_err(|e| e.to_string())?;
return Ok(());
}
}
return Err("No Linux runner selected in settings.".into());
}
#[cfg(not(target_os = "linux"))]
{
let _ = Command::new(&game_exe).spawn().map_err(|e| e.to_string())?;
}
Ok(())
}
@ -144,7 +259,7 @@ pub fn run() {
tauri::Builder::default()
.manage(DownloadState { token: Arc::new(Mutex::new(None)) })
.plugin(tauri_plugin_opener::init())
.invoke_handler(tauri::generate_handler![launch_game, check_game_installed, save_config, load_config, download_and_install, open_instance_folder, cancel_download])
.invoke_handler(tauri::generate_handler![launch_game, check_game_installed, save_config, load_config, download_and_install, open_instance_folder, cancel_download, get_available_runners])
.run(tauri::generate_context!())
.expect("error");
}

View file

@ -1,38 +1,210 @@
@font-face { font-family: 'Minecraft'; src: url('/fonts/Mojangles.ttf') format('truetype'); }
@tailwind base; @tailwind components; @tailwind utilities;
@font-face {
font-family: 'Minecraft';
src: url('/fonts/Mojangles.ttf') format('truetype');
}
* { border-radius: 0 !important; font-family: 'Minecraft', sans-serif !important; image-rendering: pixelated; font-weight: normal !important; }
body { background: #000; margin: 0; }
main { background: url('/images/dirt-background.png') repeat !important; background-size: 2000px !important; position: relative; }
main::before { content: ""; position: absolute; inset: 0; background: rgba(0, 0, 0, 0.7); z-index: 0; }
@tailwind base;
@tailwind components;
@tailwind utilities;
.no-scrollbar::-webkit-scrollbar { display: none; }
.no-scrollbar { -ms-overflow-style: none; scrollbar-width: none; }
* {
border-radius: 0 !important;
font-family: 'Minecraft', sans-serif !important;
image-rendering: pixelated;
font-weight: normal !important;
}
body {
margin: 0;
}
main {
background: url('/images/dirt-background.png') repeat !important;
background-size: 2000px !important;
position: relative;
}
main::before {
content: "";
position: absolute;
inset: 0;
/*background: rgba(0, 0, 0, 0.7);*/
z-index: 0;
}
.no-scrollbar::-webkit-scrollbar {
display: none;
}
.no-scrollbar {
-ms-overflow-style: none;
scrollbar-width: none;
}
.legacy-btn {
@apply transition-all duration-75 flex items-center justify-center;
background: #bebebe; border: 4px solid #000 !important;
background: #bebebe;
border: 4px solid #000 !important;
box-shadow: inset 4px 4px #ffffff, inset -4px -4px #555555 !important;
color: #3e3e3e !important; cursor: pointer;
color: #3e3e3e !important;
cursor: pointer;
}
.legacy-btn:hover:not(:disabled) { background: #d0d0d0; outline: 4px solid #fff; outline-offset: -8px; }
.legacy-btn:active:not(:disabled) { box-shadow: inset -4px -4px #ffffff, inset 4px 4px #555555 !important; }
.legacy-btn:disabled { opacity: 0.6; cursor: not-allowed; filter: grayscale(1); }
.legacy-btn:hover:not(:disabled) {
background: #d0d0d0;
outline: 4px solid #fff;
outline-offset: -8px;
}
.active-tab { background: #388e3c !important; box-shadow: inset 4px 4px #55ff55, inset -4px -4px #1a4d0a !important; color: #fff !important; }
.reinstall-btn, .confirm-red-btn, .cancel-download-btn { background: #ff5555 !important; box-shadow: inset 4px 4px #ffaaaa, inset -4px -4px #aa0000 !important; color: #fff !important; }
.legacy-btn:active:not(:disabled) {
box-shadow: inset -4px -4px #ffffff, inset 4px 4px #555555 !important;
}
.mc-range { -webkit-appearance: none; background: #000; height: 12px; border: 3px solid #555; width: 100%; }
.mc-range::-webkit-slider-thumb { -webkit-appearance: none; width: 16px; height: 24px; background: #bebebe; border: 3px solid #000; box-shadow: inset 2px 2px #fff, inset -2px -2px #555; cursor: pointer; }
.legacy-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
filter: grayscale(1);
}
.social-btn { background: transparent; border: 4px solid #000; box-shadow: inset 4px 4px #555, inset -4px -4px #111; width: 64px; height: 64px; display: flex; justify-content: center; align-items: center; cursor: pointer; transition: transform 0.1s; }
.social-btn:hover { transform: scale(1.1); }
.social-btn svg { fill: white; width: 32px; height: 32px; }
.legacy-select {
appearance: none;
-webkit-appearance: none;
-moz-appearance: none;
background: #bebebe url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='%233e3e3e' stroke-width='3' stroke-linecap='square' stroke-linejoin='miter'%3E%3Cpath d='M7 10l5 5 5-5'/%3E%3C/svg%3E") no-repeat right 12px center;
border: 4px solid #000 !important;
box-shadow: inset 4px 4px #ffffff, inset -4px -4px #555555 !important;
color: #3e3e3e !important;
cursor: pointer;
padding-right: 48px;
}
.btn-discord { background: #5865F2 !important; box-shadow: inset 4px 4px #8ea1e1, inset -4px -4px #313338 !important; }
.btn-github { background: #333 !important; box-shadow: inset 4px 4px #666, inset -4px -4px #000 !important; }
.btn-reddit { background: #FF4500 !important; box-shadow: inset 4px 4px #ff8b60, inset -4px -4px #942700 !important; }
.legacy-select:focus {
outline: 4px solid #fff;
outline-offset: -8px;
}
.splash-text { color: #ffff00; text-shadow: 4px 4px #3f3f00; white-space: nowrap; pointer-events: none; z-index: 20; transform: rotate(-20deg); animation: splash-pulse 0.5s infinite alternate; transform-origin: center; }
@keyframes splash-pulse { from { transform: rotate(-20deg) scale(1); } to { transform: rotate(-20deg) scale(1.05); } }
.sidebar-progress {
background: #2a2a2a;
border-top: 4px solid #000;
padding: 20px;
position: relative;
box-shadow: inset 0 4px #444;
}
.mc-progress-container {
background: #313131 !important;
border: 4px solid #000 !important;
height: 32px !important;
position: relative;
display: flex;
align-items: center;
padding: 0 4px;
}
.mc-progress-bar {
background: #55FF55 !important;
height: 16px !important;
box-shadow: inset -2px -2px #00AA00, inset 2px 2px #AAFFAA !important;
}
.mc-progress-text {
position: absolute;
width: 100%;
text-align: center;
color: #fff;
text-shadow: 2px 2px #000;
font-size: 14px;
z-index: 10;
pointer-events: none;
}
.active-tab {
background: #388e3c !important;
box-shadow: inset 4px 4px #55ff55, inset -4px -4px #1a4d0a !important;
color: #fff !important;
}
.reinstall-btn,
.confirm-red-btn,
.cancel-download-btn {
background: #ff5555 !important;
box-shadow: inset 4px 4px #ffaaaa, inset -4px -4px #aa0000 !important;
color: #fff !important;
}
.mc-range {
-webkit-appearance: none;
background: #000;
height: 12px;
border: 3px solid #555;
width: 100%;
}
.mc-range::-webkit-slider-thumb {
-webkit-appearance: none;
width: 16px;
height: 24px;
background: #bebebe;
border: 3px solid #000;
box-shadow: inset 2px 2px #fff, inset -2px -2px #555;
cursor: pointer;
}
.social-btn {
background: transparent;
border: 4px solid #000;
box-shadow: inset 4px 4px #555, inset -4px -4px #111;
width: 64px;
height: 64px;
display: flex;
justify-content: center;
align-items: center;
cursor: pointer;
transition: transform 0.1s;
}
.social-btn:hover {
transform: scale(1.1);
}
.social-btn svg {
fill: white;
width: 32px;
height: 32px;
}
.btn-discord {
background: #5865F2 !important;
box-shadow: inset 4px 4px #8ea1e1, inset -4px -4px #313338 !important;
}
.btn-github {
background: #333 !important;
box-shadow: inset 4px 4px #666, inset -4px -4px #000 !important;
}
.btn-reddit {
background: #FF4500 !important;
box-shadow: inset 4px 4px #ff8b60, inset -4px -4px #942700 !important;
}
.splash-text {
color: #ffff00;
text-shadow: 4px 4px #3f3f00;
white-space: nowrap;
pointer-events: none;
z-index: 20;
transform: rotate(-20deg);
animation: splash-pulse 0.5s infinite alternate;
transform-origin: center;
}
@keyframes splash-pulse {
from {
transform: rotate(-20deg) scale(1);
}
to {
transform: rotate(-20deg) scale(1.05);
}
}

View file

@ -5,14 +5,27 @@ import { useState, useEffect, useRef } from "react";
import "./App.css";
const Icons = {
Discord: () => <svg width="32" height="32" viewBox="0 0 32 32" fill="currentColor"><path d="M6 6h20v4h2v12h-2v4h-4v-4h-8v4H6v-4H4V10h2V6zm4 6v4h4v-4h-4zm8 0v4h4v-4h-4z"/></svg>,
Github: () => <svg width="32" height="32" viewBox="0 0 32 32" fill="currentColor"><path d="M12 4h8v4h4v4h4v8h-4v4h-4v4h-8v-4H8v-4H4v-8h4V8h4V4zm2 8v4h4v-4h-4z"/></svg>,
Reddit: () => <svg width="32" height="32" viewBox="0 0 32 32" fill="currentColor"><path d="M10 4h12v4h4v4h2v12h-2v4H10v-4H4V12h2V8h4V4zm2 10v4h8v-4h-8z"/></svg>,
Discord: () => <svg width="32" height="32" viewBox="0 0 32 32" fill="currentColor"><path d="M6 6h20v4h2v12h-2v4h-4v-4h-8v4H6v-4H4V10h2V6zm4 6v4h4v-4h-4zm8 0v4h4v-4h-4z" /></svg>,
Github: () => <svg width="32" height="32" viewBox="0 0 32 32" fill="currentColor"><path d="M12 4h8v4h4v4h4v8h-4v4h-4v4h-8v-4H8v-4H4v-8h4V8h4V4zm2 8v4h4v-4h-4z" /></svg>,
Reddit: () => <svg width="32" height="32" viewBox="0 0 32 32" fill="currentColor"><path d="M10 4h12v4h4v4h2v12h-2v4H10v-4H4V12h2V8h4V4zm2 10v4h8v-4h-8z" /></svg>,
Volume: ({ level }: { level: number }) => (
<svg width="32" height="32" viewBox="0 0 32 32" fill="currentColor"><path d="M4 12h8l8-8v24l-8-8H4v-8z"/>{level > 0 && <path d="M24 12h2v8h-2z"/>}{level > 0.5 && <path d="M28 8h2v16h-2z"/>}</svg>
)
<svg width="32" height="32" viewBox="0 0 32 32" fill="currentColor"><path d="M4 12h8l8-8v24l-8-8H4v-8z" />{level > 0 && <path d="M24 12h2v8h-2z" />}{level > 0.5 && <path d="M28 8h2v16h-2z" />}</svg>
),
Linux: () => <svg width="32" height="32" viewBox="0 0 32 32" fill="currentColor"><path d="M16 4c-3.3 0-6 2.7-6 6 0 1.2.4 2.3 1 3.2C8.7 15.1 7 18.3 7 22h2c0-3.9 3.1-7 7-7s7 3.1 7 7h2c0-3.7-1.7-6.9-4-8.8.6-.9 1-2 1-3.2 0-3.3-2.7-6-6-6zm0 2c2.2 0 4 1.8 4 4s-1.8 4-4 4-4-1.8-4-4 1.8-4 4-4z" /></svg>
};
interface Runner {
id: string;
name: string;
path: string;
type: string;
}
interface AppConfig {
username: string;
linuxRunner?: string;
}
let audioCtx: AudioContext | null = null;
let sfxGain: GainNode | null = null;
const buffers: Record<string, AudioBuffer> = {};
@ -23,16 +36,19 @@ export default function App() {
const [isFirstRun, setIsFirstRun] = useState(true);
const [isRunning, setIsRunning] = useState(false);
const [installingInstance, setInstallingInstance] = useState<string | null>(null);
const [downloadProgress, setDownloadProgress] = useState<number>(0);
const [downloadProgress, setDownloadProgress] = useState<number>(0);
const [installedStatus, setInstalledStatus] = useState<Record<string, boolean>>({ vanilla_tu19: false, vanilla_tu24: false });
const [selectedInstance, setSelectedInstance] = useState<string>("vanilla_tu19");
const [reinstallModal, setReinstallModal] = useState<{ id: string, url: string } | null>(null);
const [mcNotif, setMcNotif] = useState<{t: string, m: string} | null>(null);
const [mcNotif, setMcNotif] = useState<{ t: string, m: string } | null>(null);
const [availableRunners, setAvailableRunners] = useState<Runner[]>([]);
const [selectedRunner, setSelectedRunner] = useState<string>("");
const [isLinux, setIsLinux] = useState(false);
const [musicVol, setMusicVol] = useState(parseFloat(localStorage.getItem("musicVol") || "0.4"));
const [sfxVol, setSfxVol] = useState(parseFloat(localStorage.getItem("sfxVol") || "0.7"));
const [isMuted, setIsMuted] = useState(localStorage.getItem("isMuted") === "true");
const musicRef = useRef<HTMLAudioElement | null>(null);
const lastTrack = useRef<number>(0);
@ -80,7 +96,7 @@ export default function App() {
lastTrack.current = track;
musicRef.current.src = `/music/music${track}.ogg`;
musicRef.current.volume = isMuted ? 0 : musicVol;
musicRef.current.play().catch(() => {});
musicRef.current.play().catch(() => { });
};
const fadeAndLaunch = async () => {
@ -97,17 +113,17 @@ export default function App() {
}, 50);
}
setTimeout(async () => {
try {
await invoke("launch_game", { instanceId: selectedInstance });
} catch (e) {
alert(`Failed to launch game: ${e}`);
} finally {
setIsRunning(false);
if (musicRef.current) {
musicRef.current.volume = isMuted ? 0 : musicVol;
playRandomMusic();
}
try {
await invoke("launch_game", { instanceId: selectedInstance });
} catch (e) {
alert(`Failed to launch game: ${e}`);
} finally {
setIsRunning(false);
if (musicRef.current) {
musicRef.current.volume = isMuted ? 0 : musicVol;
playRandomMusic();
}
}
}, 1500);
};
@ -118,7 +134,7 @@ export default function App() {
setMcNotif({ t: "Success!", m: "Ready to play." }); playSfx('orb.ogg');
setTimeout(() => setMcNotif(null), 4000);
updateAllStatus();
} catch (e) {
} catch (e) {
console.error(e);
alert("Error during installation: " + e);
}
@ -132,14 +148,24 @@ export default function App() {
};
useEffect(() => {
invoke("load_config").then((n) => {
const savedName = n as string;
if (savedName && savedName.trim() !== "") {
setUsername(savedName);
invoke("load_config").then((c) => {
const config = c as AppConfig;
if (config.username && config.username.trim() !== "") {
setUsername(config.username);
setIsFirstRun(false);
setTimeout(playRandomMusic, 1000);
}
if (config.linuxRunner) setSelectedRunner(config.linuxRunner);
});
const platform = window.navigator.platform.toLowerCase();
if (platform.includes("linux")) {
setIsLinux(true);
invoke<Runner[]>("get_available_runners").then(runners => {
setAvailableRunners(runners);
});
}
updateAllStatus();
const unlisten = listen<number>("download-progress", (e) => setDownloadProgress(Math.round(e.payload)));
return () => { unlisten.then(f => f()); };
@ -152,7 +178,7 @@ export default function App() {
<div className="bg-[#2a2a2a] p-10 border-4 border-black w-full max-w-2xl text-center shadow-[inset_4px_4px_#555,inset_-4px_-4px_#111]">
<h2 className="text-4xl text-emerald-400 mb-4">Welcome to Emerald Legacy!</h2>
<input type="text" value={username} onChange={e => setUsername(e.target.value)} className="w-full bg-black border-4 border-emerald-900 p-4 text-3xl text-center mb-8 outline-none" placeholder="Username..." />
<button onClick={() => { ensureAudio(); playSfx('click.wav'); invoke("save_config", { username }); setIsFirstRun(false); setTimeout(playRandomMusic, 500); }} disabled={!username.trim()} className="legacy-btn py-4 px-12 text-3xl w-full">Start Setup</button>
<button onClick={() => { ensureAudio(); playSfx('click.wav'); invoke("save_config", { config: { username, linuxRunner: isLinux ? selectedRunner : undefined } }); setIsFirstRun(false); setTimeout(playRandomMusic, 500); }} disabled={!username.trim() || (isLinux && availableRunners.length === 0)} className="legacy-btn py-4 px-12 text-3xl w-full">Start Setup</button>
</div>
</div>
);
@ -163,8 +189,7 @@ export default function App() {
<audio ref={musicRef} onEnded={playRandomMusic} />
<aside className="w-64 bg-[#2a2a2a] border-r-4 border-black p-6 flex flex-col gap-2 z-20 shadow-[inset_-4px_0_#555]">
<div className="mb-10 px-2">
<h2 className="text-emerald-500 text-3xl italic tracking-tighter">EMERALD LEGACY</h2>
<p className="text-[10px] text-slate-500 uppercase tracking-widest">Launcher</p>
<img src="/images/logo.png" />
</div>
<nav className="flex flex-col gap-3">
<button onClick={() => { playSfx('click.wav'); setActiveTab("home"); updateAllStatus(); }} className={`p-4 legacy-btn justify-start ${activeTab === "home" ? "active-tab" : ""}`}>HOME</button>
@ -172,7 +197,20 @@ export default function App() {
<button onClick={() => { playSfx('click.wav'); setActiveTab("settings"); }} className={`p-4 legacy-btn justify-start ${activeTab === "settings" ? "active-tab" : ""}`}>SETTINGS</button>
</nav>
<div onClick={() => { playSfx('click.wav'); openUrl("https://github.com/KayJannOnGit"); }} className="mt-auto pt-6 flex flex-col items-center border-t-4 border-black/30 cursor-pointer group">
{installingInstance && (
<div className="sidebar-progress mt-auto">
<div className="flex justify-between mb-3 text-slate-300 font-bold text-[10px] uppercase tracking-widest px-1">
<span>Installing</span>
<button onClick={() => { playSfx('back.ogg'); invoke("cancel_download"); }} className="text-red-500 hover:underline">CANCEL</button>
</div>
<div className="mc-progress-container">
<div className="mc-progress-bar transition-all duration-300" style={{ width: `${downloadProgress}%` }}></div>
<div className="mc-progress-text">{downloadProgress}%</div>
</div>
</div>
)}
<div onClick={() => { playSfx('click.wav'); openUrl("https://github.com/KayJannOnGit"); }} className={`${installingInstance ? "pt-6" : "mt-auto pt-6"} flex flex-col items-center border-t-4 border-black/30 cursor-pointer group`}>
<span className="text-slate-500 text-[10px] uppercase">Developed by</span>
<span className="text-emerald-500 text-sm font-bold group-hover:underline">KayJann</span>
</div>
@ -189,11 +227,11 @@ export default function App() {
<div className="bg-black/80 p-8 border-4 border-black w-[550px] flex flex-col gap-6 mt-12">
{installedStatus.vanilla_tu19 || installedStatus.vanilla_tu24 ? (
<>
<select value={selectedInstance} onChange={e => { playSfx('click.wav'); setSelectedInstance(e.target.value); }} className="w-full bg-[#2a2a2a] border-4 border-black p-3 text-2xl text-white outline-none">
{installedStatus.vanilla_tu19 && <option value="vanilla_tu19">Vanilla Nightly (TU19)</option>}
{installedStatus.vanilla_tu24 && <option value="vanilla_tu24">Vanilla TU24</option>}
<select value={selectedInstance} onChange={e => { playSfx('click.wav'); setSelectedInstance(e.target.value); }} className="w-full legacy-select p-3 text-2xl outline-none">
{installedStatus.vanilla_tu19 && <option value="vanilla_tu19">Vanilla Nightly (TU19)</option>}
{installedStatus.vanilla_tu24 && <option value="vanilla_tu24">Vanilla TU24</option>}
</select>
<button onClick={fadeAndLaunch} disabled={isRunning || !!installingInstance} className="legacy-btn py-4 text-6xl w-full">{installingInstance ? "DOWNLOADING..." : isRunning ? "RUNNING..." : "PLAY"}</button>
<button onClick={fadeAndLaunch} disabled={isRunning || !!installingInstance} className="legacy-btn py-4 text-6xl w-full">{installingInstance ? "WAITING..." : isRunning ? "RUNNING..." : "PLAY"}</button>
</>
) : (
<div className="text-center">
@ -209,7 +247,7 @@ export default function App() {
<div className="w-full max-w-3xl bg-black/80 p-12 border-4 border-black h-full overflow-y-auto no-scrollbar animate-in fade-in">
<h2 className="text-5xl mb-8 border-b-4 border-white/20 pb-4">Instances</h2>
<div className="flex flex-col gap-6">
{/* TU19 */}
<div className="flex justify-between items-center bg-[#2a2a2a] border-4 border-black p-6">
<div><h3 className="text-2xl font-bold">Vanilla Nightly (TU19)</h3><p className="text-slate-400 text-sm">Leaked 4J Studios build.</p></div>
@ -247,17 +285,6 @@ export default function App() {
</div>
))}
</div>
{installingInstance && (
<div className="mt-8 p-6 border-4 border-emerald-500 bg-[#1a2a1a] relative">
<div className="flex justify-between mb-3 text-emerald-400 font-bold text-xl uppercase">
<span>Downloading {downloadProgress}%</span>
<button onClick={() => { playSfx('back.ogg'); invoke("cancel_download"); }} className="legacy-btn px-3 py-1 text-sm cancel-download-btn">Cancel</button>
</div>
<div className="w-full bg-black border-4 border-emerald-900 h-8">
<div className="bg-emerald-500 h-full transition-all duration-300" style={{ width: `${downloadProgress}%` }}></div>
</div>
</div>
)}
</div>
)}
@ -269,19 +296,40 @@ export default function App() {
<label className="text-xl text-slate-400 italic">In-game Username</label>
<div className="flex gap-4">
<input type="text" value={username} onChange={e => setUsername(e.target.value)} className="flex-1 bg-black border-4 border-slate-700 p-4 text-3xl outline-none focus:border-emerald-500" />
<button onClick={() => { playSfx('wood click.wav'); invoke("save_config", { username }); }} className="legacy-btn px-8 text-2xl relative">Save</button>
<button onClick={() => { playSfx('wood click.wav'); invoke("save_config", { config: { username, linuxRunner: selectedRunner || undefined } }); }} className="legacy-btn px-8 text-2xl relative">Save</button>
</div>
</div>
{isLinux && (
<div className="flex flex-col gap-4">
<label className="text-xl text-slate-400 italic flex items-center gap-2"><Icons.Linux /> Linux Runner</label>
<div className="flex flex-col gap-2">
<select
value={selectedRunner}
onChange={e => { playSfx('click.wav'); setSelectedRunner(e.target.value); }}
className="w-full legacy-select p-4 text-2xl outline-none focus:border-emerald-500"
>
<option value="" disabled>Select a runner...</option>
{availableRunners.map(r => (
<option key={r.id} value={r.id}>{r.name} ({r.type})</option>
))}
</select>
{availableRunners.length === 0 && (
<p className="text-red-500 text-sm">No Proton or Wine installations found. Please install Steam or Wine.</p>
)}
</div>
</div>
)}
<div className="flex flex-col gap-4 bg-[#2a2a2a] p-6 border-4 border-black shadow-[inset_4px_4px_#555]">
<label className="text-xl flex items-center gap-4"><Icons.Volume level={musicVol}/> Audio Controls</label>
<label className="text-xl flex items-center gap-4"><Icons.Volume level={musicVol} /> Audio Controls</label>
<div className="grid grid-cols-2 gap-8">
<div className="flex flex-col gap-2">
<span className="text-sm uppercase opacity-50">Music {Math.round(musicVol*100)}%</span>
<span className="text-sm uppercase opacity-50">Music {Math.round(musicVol * 100)}%</span>
<input type="range" min="0" max="1" step="0.01" value={musicVol} onChange={e => setMusicVol(parseFloat(e.target.value))} className="mc-range" />
</div>
<div className="flex flex-col gap-2">
<span className="text-sm uppercase opacity-50">SFX {Math.round(sfxVol*100)}%</span>
<span className="text-sm uppercase opacity-50">SFX {Math.round(sfxVol * 100)}%</span>
<input type="range" min="0" max="1" step="0.01" value={sfxVol} onChange={e => setSfxVol(parseFloat(e.target.value))} className="mc-range" />
</div>
</div>
@ -289,17 +337,17 @@ export default function App() {
</div>
<div className="about-section border-4 border-black bg-[#2a2a2a] p-6 shadow-[inset_4px_4px_#555]">
<h3 className="text-2xl text-[#ffff55] mb-2 uppercase tracking-wide">About the project</h3>
<p className="text-xl text-white leading-relaxed mb-6 opacity-90">
I'm <span className="text-emerald-400">KayJann</span>, and I absolutely love this project! It's my very first one,
and my goal is to create a central hub for the LCE community to bring us all together.
</p>
<h3 className="text-sm text-slate-500 mb-4 uppercase tracking-widest">Social Links</h3>
<div className="flex gap-6">
<button onClick={() => openUrl("https://discord.gg/nzbxB8Hxjh")} className="social-btn btn-discord" title="Discord"><Icons.Discord /></button>
<button onClick={() => openUrl("https://github.com/KayJannOnGit")} className="social-btn btn-github" title="GitHub"><Icons.Github /></button>
<button onClick={() => openUrl("https://reddit.com/user/KayJann")} className="social-btn btn-reddit" title="Reddit"><Icons.Reddit /></button>
</div> </div>
<h3 className="text-2xl text-[#ffff55] mb-2 uppercase tracking-wide">About the project</h3>
<p className="text-xl text-white leading-relaxed mb-6 opacity-90">
I'm <span className="text-emerald-400">KayJann</span>, and I absolutely love this project! It's my very first one,
and my goal is to create a central hub for the LCE community to bring us all together.
</p>
<h3 className="text-sm text-slate-500 mb-4 uppercase tracking-widest">Social Links</h3>
<div className="flex gap-6">
<button onClick={() => openUrl("https://discord.gg/nzbxB8Hxjh")} className="social-btn btn-discord" title="Discord"><Icons.Discord /></button>
<button onClick={() => openUrl("https://github.com/KayJannOnGit")} className="social-btn btn-github" title="GitHub"><Icons.Github /></button>
<button onClick={() => openUrl("https://reddit.com/user/KayJann")} className="social-btn btn-reddit" title="Reddit"><Icons.Reddit /></button>
</div> </div>
</div>
</div>
)}