feat: 1.5.0 drop 2 (#122)

Co-authored-by: str1k3r <115313679+S1l3ntStr1ke87@users.noreply.github.com>
This commit is contained in:
/home/neo
2026-07-11 21:04:53 +03:00
committed by GitHub
parent f46453f1cd
commit 034fbf5737
24 changed files with 900 additions and 1385 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 MiB

After

Width:  |  Height:  |  Size: 364 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 MiB

After

Width:  |  Height:  |  Size: 364 KiB

View File

@@ -23,7 +23,6 @@ use commands::runners;
use commands::skin;
use commands::steam;
use commands::workshop;
use networking::direct;
use networking::relay;
use networking::stun;
use state::{DownloadState, GameState, ProxyGuard};
@@ -108,7 +107,6 @@ pub fn run() {
game::restore_instance,
commands::console2lce::import_world,
stun::stun_discover,
direct::start_direct_proxy,
relay::start_relay_proxy,
relay::start_host_relay,
relay::stop_proxy,

View File

@@ -1,93 +0,0 @@
use tauri::State;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio_util::sync::CancellationToken;
use crate::state::ProxyGuard;
async fn run_direct_proxy(
proxy_state: &ProxyGuard,
target_ip: &str,
target_port: u16,
cancel: CancellationToken,
) -> Result<u16, String> {
let remote = tokio::net::TcpStream::connect(format!("{}:{}", target_ip, target_port))
.await
.map_err(|e| format!("Direct TCP connect failed: {}", e))?;
let listener = tokio::net::TcpListener::bind("0.0.0.0:0")
.await
.map_err(|e| format!("Bind failed: {}", e))?;
let local_port = listener.local_addr().map_err(|e| e.to_string())?.port();
{
let mut port = proxy_state.local_port.lock().await;
*port = Some(local_port);
}
let (local_stream, _) = tokio::select! {
result = listener.accept() => result.map_err(|e| format!("Accept failed: {}", e))?,
_ = cancel.cancelled() => return Err("Proxy cancelled".into()),
};
let (mut a_read, mut a_write) = remote.into_split();
let (mut b_read, mut b_write) = local_stream.into_split();
let cancel_a = cancel.clone();
let task_a = tokio::spawn(async move {
let mut buf = [0u8; 65536];
loop {
tokio::select! {
result = a_read.read(&mut buf) => {
match result {
Ok(0) | Err(_) => break,
Ok(n) => { if b_write.write_all(&buf[..n]).await.is_err() { break; } }
}
}
_ = cancel_a.cancelled() => break,
}
}
});
let cancel_b = cancel.clone();
let task_b = tokio::spawn(async move {
let mut buf = [0u8; 65536];
loop {
tokio::select! {
result = b_read.read(&mut buf) => {
match result {
Ok(0) | Err(_) => break,
Ok(n) => { if a_write.write_all(&buf[..n]).await.is_err() { break; } }
}
}
_ = cancel_b.cancelled() => break,
}
}
});
tokio::select! {
_ = task_a => {},
_ = task_b => {},
_ = cancel.cancelled() => {},
}
Ok(local_port)
}
#[tauri::command]
pub async fn start_direct_proxy(
proxy_state: State<'_, ProxyGuard>,
target_ip: String,
target_port: u16,
) -> Result<u16, String> {
let cancel = CancellationToken::new();
let session_id = "__direct__".to_string();
{
let mut tokens = proxy_state.cancel_tokens.lock().await;
tokens.insert(session_id.clone(), cancel.clone());
}
let local_port = run_direct_proxy(&proxy_state, &target_ip, target_port, cancel).await?;
{
let mut tokens = proxy_state.cancel_tokens.lock().await;
tokens.remove(&session_id);
}
Ok(local_port)
}

View File

@@ -1,3 +1,2 @@
pub mod stun;
pub mod relay;
pub mod direct;

View File

@@ -1,268 +1,176 @@
use futures_util::{SinkExt, StreamExt};
use tauri::State;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::{TcpListener, TcpStream};
use tokio_util::sync::CancellationToken;
use crate::state::ProxyGuard;
use crate::util;
async fn run_relay_proxy(
proxy_state: &ProxyGuard,
ws_url: &str,
auth_token: &str,
cancel: CancellationToken,
) -> Result<u16, String> {
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
eprintln!("[Emerald] Joiner relay: connecting WS...");
let mut request = ws_url
.into_client_request()
.map_err(|e| format!("Failed to build WS request: {}", e))?;
request.headers_mut().insert(
http::header::AUTHORIZATION,
format!("Bearer {}", auth_token)
.parse()
.map_err(|_| "Invalid auth header value".to_string())?,
);
request.headers_mut().insert(
http::header::USER_AGENT,
"MCLCE-LceLive/1.0"
.parse()
.map_err(|_| "Invalid UA header value".to_string())?,
);
let (ws_stream, _) = tokio_tungstenite::connect_async(request)
.await
.map_err(|e| format!("Relay WS connect failed: {}", e))?;
eprintln!("[Emerald] Joiner relay: WS connected");
let listener = tokio::net::TcpListener::bind("0.0.0.0:61000")
.await
.map_err(|e| format!("Bind failed: {}", e))?;
let local_port = listener.local_addr().map_err(|e| e.to_string())?.port();
eprintln!("[Emerald] Joiner relay: bound on 0.0.0.0:{}", local_port);
{
let mut port = proxy_state.local_port.lock().await;
*port = Some(local_port);
const PROXY_ADDR: &str = "proxy.mclegacyedition.xyz:2052"; //neo: yeah bro im hardcoding it
async fn read_line(stream: &mut TcpStream) -> Result<String, String> {
let mut buf = Vec::new();
let mut byte = [0u8; 1];
loop {
stream.read_exact(&mut byte).await.map_err(|e| e.to_string())?;
if byte[0] == b'\n' { break; }
buf.push(byte[0]);
}
String::from_utf8(buf).map_err(|e| e.to_string())
}
tokio::spawn(async move {
eprintln!("[Emerald] Joiner relay: waiting for TCP accept on port {}...", local_port);
let (tcp_stream, _) = tokio::select! {
result = listener.accept() => {
eprintln!("[Emerald] Joiner relay: TCP accepted");
result.map_err(|e| format!("Accept failed: {}", e)).unwrap()
},
_ = cancel.cancelled() => {
eprintln!("[Emerald] Joiner relay: cancelled before accept");
return;
},
};
eprintln!("[Emerald] Joiner relay: starting forwarders");
let (tcp_read, tcp_write) = tcp_stream.into_split();
let (ws_write, ws_read) = ws_stream.split();
let cancel_ws = cancel.clone();
let forward_tcp = tokio::spawn(async move {
let mut ws_write = ws_write;
let mut tcp_read = tcp_read;
let mut buf = [0u8; 65536];
loop {
tokio::select! {
result = tcp_read.read(&mut buf) => {
match result {
Ok(0) => { eprintln!("[Emerald] Joiner relay: TCP→WS EOF"); break; },
Err(e) => { eprintln!("[Emerald] Joiner relay: TCP→WS read error: {e}"); break; },
Ok(n) => {
eprintln!("[Emerald] Joiner relay: TCP→WS forwarding {} bytes", n);
if ws_write.send(tokio_tungstenite::tungstenite::Message::Binary(buf[..n].to_vec())).await.is_err() {
eprintln!("[Emerald] Joiner relay: TCP→WS send error"); break;
}
}
}
}
_ = cancel_ws.cancelled() => { eprintln!("[Emerald] Joiner relay: TCP→WS cancelled"); break; },
}
}
});
let cancel_tcp = cancel.clone();
let forward_ws = tokio::spawn(async move {
let ws_read = ws_read;
let mut tcp_write = tcp_write;
tokio::pin!(ws_read);
loop {
tokio::select! {
result = ws_read.next() => {
match result {
Some(Ok(tokio_tungstenite::tungstenite::Message::Binary(data))) => {
eprintln!("[Emerald] Joiner relay: WS→TCP forwarding {} bytes", data.len());
if tcp_write.write_all(&data).await.is_err() { break; }
}
Some(Ok(tokio_tungstenite::tungstenite::Message::Close(_))) => { break; }
None => { break; }
Some(Err(e)) => { eprintln!("[Emerald] Joiner relay: WS→TCP error: {e}"); break; }
_ => {}
}
}
_ = cancel_tcp.cancelled() => { break; },
}
}
});
tokio::select! {
_ = forward_tcp => eprintln!("[Emerald] Joiner relay: forward_tcp done"),
_ = forward_ws => eprintln!("[Emerald] Joiner relay: forward_ws done"),
_ = cancel.cancelled() => eprintln!("[Emerald] Joiner relay: cancelled"),
}
eprintln!("[Emerald] Joiner relay: relay task ended");
});
Ok(local_port)
async fn write_line(stream: &mut TcpStream, line: &str) -> Result<(), String> {
let data = format!("{}\n", line);
stream.write_all(data.as_bytes()).await.map_err(|e| e.to_string())
}
async fn run_host_relay(
_proxy_state: &ProxyGuard,
ws_url: &str,
proxy_addr: &str,
auth_token: &str,
game_port: u16,
cancel: CancellationToken,
) -> Result<(), String> {
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
eprintln!("[Emerald] Host relay: connecting WS...");
let mut request = ws_url
.into_client_request()
.map_err(|e| format!("Failed to build WS request: {}", e))?;
request.headers_mut().insert(
http::header::AUTHORIZATION,
format!("Bearer {}", auth_token)
.parse()
.map_err(|_| "Invalid auth header value".to_string())?,
);
request.headers_mut().insert(
http::header::USER_AGENT,
"MCLCE-LceLive/1.0"
.parse()
.map_err(|_| "Invalid UA header value".to_string())?,
);
let (ws_stream, _) = tokio_tungstenite::connect_async(request)
let mut host_conn = TcpStream::connect(proxy_addr)
.await
.map_err(|e| format!("Host relay WS connect failed: {}", e))?;
eprintln!("[Emerald] Host relay: WS connected");
eprintln!("[Emerald] Host relay: connecting to game 127.0.0.1:{}...", game_port);
.map_err(|e| format!("Proxy connect failed: {}", e))?;
write_line(&mut host_conn, &format!("HOST {} 0", auth_token)).await?;
let game_stream = loop {
match tokio::net::TcpStream::connect(format!("127.0.0.1:{}", game_port)).await {
Ok(stream) => { eprintln!("[Emerald] Host relay: connected to game"); break stream; },
Err(e) => {
eprintln!("[Emerald] Host relay: game connect failed (retrying): {e}");
match TcpStream::connect(format!("127.0.0.1:{}", game_port)).await {
Ok(s) => break s,
Err(_) => {
tokio::select! {
_ = cancel.cancelled() => return Err("Host relay cancelled".into()),
_ = tokio::time::sleep(std::time::Duration::from_secs(2)) => {}
_ = cancel.cancelled() => return Err("Cancelled".into()),
_ = tokio::time::sleep(std::time::Duration::from_secs(1)) => {}
}
}
}
};
eprintln!("[Emerald] Host relay: starting forwarders");
let (game_read, game_write) = game_stream.into_split();
let (ws_write, ws_read) = ws_stream.split();
let cancel_ws = cancel.clone();
let forward_game = tokio::spawn(async move {
let mut ws_write = ws_write;
let mut game_read = game_read;
let client_line = read_line(&mut host_conn).await?;
let client_parts: Vec<&str> = client_line.split_whitespace().collect();
if client_parts.len() < 2 || client_parts[0] != "CLIENT" {
return Err(format!("Expected CLIENT, got: {}", client_line));
}
let joiner_id = client_parts[1];
let mut accept_conn = TcpStream::connect(proxy_addr)
.await
.map_err(|e| format!("Proxy connect failed: {}", e))?;
write_line(&mut accept_conn, &format!("ACCEPT {} 0 {}", auth_token, joiner_id)).await?;
let (mut g_read, mut g_write) = game_stream.into_split();
let (mut a_read, mut a_write) = accept_conn.into_split();
let c1 = cancel.clone();
let c2 = cancel.clone();
let t1 = tokio::spawn(async move {
let mut buf = [0u8; 65536];
loop {
tokio::select! {
result = game_read.read(&mut buf) => {
match result {
Ok(0) => { eprintln!("[Emerald] Host relay: game→WS EOF"); break; },
Err(e) => { eprintln!("[Emerald] Host relay: game→WS read error: {e}"); break; },
Ok(n) => {
eprintln!("[Emerald] Host relay: game→WS forwarding {} bytes", n);
if ws_write.send(tokio_tungstenite::tungstenite::Message::Binary(buf[..n].to_vec())).await.is_err() { break; }
}
r = g_read.read(&mut buf) => {
match r {
Ok(0) | Err(_) => break,
Ok(n) => { if a_write.write_all(&buf[..n]).await.is_err() { break; } }
}
}
_ = cancel_ws.cancelled() => { break; },
_ = c1.cancelled() => break,
}
}
});
let cancel_ws2 = cancel.clone();
let forward_ws = tokio::spawn(async move {
let ws_read = ws_read;
let mut game_write = game_write;
tokio::pin!(ws_read);
let t2 = tokio::spawn(async move {
let mut buf = [0u8; 65536];
loop {
tokio::select! {
result = ws_read.next() => {
match result {
Some(Ok(tokio_tungstenite::tungstenite::Message::Binary(data))) => {
eprintln!("[Emerald] Host relay: WS→game forwarding {} bytes", data.len());
if game_write.write_all(&data).await.is_err() { break; }
}
Some(Ok(tokio_tungstenite::tungstenite::Message::Close(_))) => { break; }
None => { break; }
Some(Err(e)) => { eprintln!("[Emerald] Host relay: WS→game error: {e}"); break; }
_ => {}
r = a_read.read(&mut buf) => {
match r {
Ok(0) | Err(_) => break,
Ok(n) => { if g_write.write_all(&buf[..n]).await.is_err() { break; } }
}
}
_ = cancel_ws2.cancelled() => { break; },
_ = c2.cancelled() => break,
}
}
});
tokio::select! {
_ = forward_game => eprintln!("[Emerald] Host relay: forward_game done"),
_ = forward_ws => eprintln!("[Emerald] Host relay: forward_ws done"),
_ = cancel.cancelled() => eprintln!("[Emerald] Host relay: cancelled"),
}
let _ = tokio::join!(t1, t2);
Ok(())
}
#[tauri::command]
pub async fn start_relay_proxy(
proxy_state: State<'_, ProxyGuard>,
api_base_url: String,
access_token: String,
session_id: String,
async fn run_relay_proxy(
proxy_state: &ProxyGuard,
proxy_addr: &str,
auth_token: &str,
target_session: &str,
cancel: CancellationToken,
) -> Result<u16, String> {
let ws_base = util::ws_base_url(&api_base_url);
let ws_url = format!("{}/api/relay/ws?sessionId={}&role=joiner", ws_base, session_id);
let cancel = CancellationToken::new();
let mut stream = TcpStream::connect(proxy_addr)
.await
.map_err(|e| format!("Proxy connect failed: {}", e))?;
write_line(&mut stream, &format!("JOIN {} 0 {}", auth_token, target_session)).await?;
let listener = TcpListener::bind("0.0.0.0:61000")
.await
.map_err(|e| format!("Bind failed: {}", e))?;
let local_port = listener.local_addr().map_err(|e| e.to_string())?.port();
{
let mut tokens = proxy_state.cancel_tokens.lock().await;
tokens.insert(session_id.clone(), cancel.clone());
let mut port = proxy_state.local_port.lock().await;
*port = Some(local_port);
}
let local_port = run_relay_proxy(&proxy_state, &ws_url, &access_token, cancel).await?;
let (local_stream, _) = tokio::select! {
r = listener.accept() => r.map_err(|e| format!("Accept failed: {}", e))?,
_ = cancel.cancelled() => return Err("Cancelled".into()),
};
{
let mut tokens = proxy_state.cancel_tokens.lock().await;
tokens.remove(&session_id);
}
let (mut l_read, mut l_write) = local_stream.into_split();
let (mut s_read, mut s_write) = stream.into_split();
let c1 = cancel.clone();
let c2 = cancel.clone();
let t1 = tokio::spawn(async move {
let mut buf = [0u8; 65536];
loop {
tokio::select! {
r = l_read.read(&mut buf) => {
match r {
Ok(0) | Err(_) => break,
Ok(n) => { if s_write.write_all(&buf[..n]).await.is_err() { break; } }
}
}
_ = c1.cancelled() => break,
}
}
});
let t2 = tokio::spawn(async move {
let mut buf = [0u8; 65536];
loop {
tokio::select! {
r = s_read.read(&mut buf) => {
match r {
Ok(0) | Err(_) => break,
Ok(n) => { if l_write.write_all(&buf[..n]).await.is_err() { break; } }
}
}
_ = c2.cancelled() => break,
}
}
});
let _ = tokio::join!(t1, t2);
Ok(local_port)
}
#[tauri::command]
pub async fn start_host_relay(
proxy_state: State<'_, ProxyGuard>,
api_base_url: String,
access_token: String,
session_id: String,
auth_token: String,
game_port: u16,
) -> Result<(), String> {
let ws_base = util::ws_base_url(&api_base_url);
let ws_url = format!("{}/api/relay/ws?sessionId={}&role=host", ws_base, session_id);
let addr = PROXY_ADDR;
let cancel = CancellationToken::new();
let session_id = "__host__".to_string();
{
let mut tokens = proxy_state.cancel_tokens.lock().await;
tokens.insert(session_id.clone(), cancel.clone());
}
let result = run_host_relay(&proxy_state, &ws_url, &access_token, game_port, cancel).await;
let result = run_host_relay(&proxy_state, &addr, &auth_token, game_port, cancel).await;
{
let mut tokens = proxy_state.cancel_tokens.lock().await;
tokens.remove(&session_id);
@@ -271,6 +179,29 @@ pub async fn start_host_relay(
result
}
#[tauri::command]
pub async fn start_relay_proxy(
proxy_state: State<'_, ProxyGuard>,
auth_token: String,
target_session: String,
) -> Result<u16, String> {
let addr = PROXY_ADDR;
let cancel = CancellationToken::new();
let session_id = target_session.clone();
{
let mut tokens = proxy_state.cancel_tokens.lock().await;
tokens.insert(session_id.clone(), cancel.clone());
}
let local_port = run_relay_proxy(&proxy_state, &addr, &auth_token, &target_session, cancel).await?;
{
let mut tokens = proxy_state.cancel_tokens.lock().await;
tokens.remove(&session_id);
}
Ok(local_port)
}
#[tauri::command]
pub async fn stop_proxy(proxy_state: State<'_, ProxyGuard>, session_id: String) -> Result<(), String> {
let mut tokens = proxy_state.cancel_tokens.lock().await;
@@ -299,10 +230,10 @@ pub async fn join_game(
game_state: State<'_, crate::state::GameState>,
_proxy_state: State<'_, ProxyGuard>,
_api_base_url: String,
_access_token: String,
_auth_token: String,
host_ip: String,
host_port: u16,
_session_id: String,
_target_session: String,
instance_id: String,
) -> Result<(), String> {
let server = crate::types::McServer {

View File

@@ -44,14 +44,6 @@ pub fn copy_dir_all(src: impl AsRef<std::path::Path>, dst: impl AsRef<std::path:
Ok(())
}
pub fn ws_base_url(api_base_url: &str) -> String {
if api_base_url.starts_with("https") {
api_base_url.replace("https", "wss")
} else {
api_base_url.replace("http", "ws")
}
}
#[cfg(unix)]
pub fn unix_path_to_wine_z_path(unix_path: &PathBuf) -> String {
let p = unix_path.to_string_lossy();

View File

@@ -507,7 +507,7 @@ const SkinViewer = memo(function SkinViewer({
setActiveView("screenshots");
} else if (focusIndex === 3) {
playPressSound();
setActiveView("lcelive");
setActiveView("lceonline");
}
}
};
@@ -749,7 +749,7 @@ const SkinViewer = memo(function SkinViewer({
onMouseEnter={() => isFocusedSection && setFocusIndex(3)}
onClick={() => {
playPressSound();
setActiveView("lcelive");
setActiveView("lceonline");
}}
className={`mc-sq-btn w-12 h-12 flex items-center justify-center outline-none border-none ${isFocusedSection && focusIndex === 3 ? "" : ""}`}
style={
@@ -760,11 +760,11 @@ const SkinViewer = memo(function SkinViewer({
}
: {}
}
title="LCELive"
title="LCE Online"
>
<img
src="/images/friends.png"
alt="LCELive"
alt="LCE Online"
className="w-8 h-8 object-contain"
style={{ imageRendering: "pixelated" }}
/>

View File

@@ -1,8 +1,17 @@
import { useState, useEffect } from "react";
import { motion } from "framer-motion";
import { TauriService } from "../../services/TauriService";
import { lceLiveService, GameInvite } from "../../services/LceLiveService";
import { lceOnlineService } from "../../services/LceOnlineService";
import type { Edition } from "../../types/edition";
interface GameInvite {
inviteId: string;
from: string;
hostIp: string;
hostPort: number;
hostName: string;
sessionId?: string;
status: string;
}
export default function ChooseInstanceModal({
isOpen,
@@ -26,9 +35,8 @@ export default function ChooseInstanceModal({
const [error, setError] = useState<string>("");
const [isJoining, setIsJoining] = useState(false);
const [focusIndex, setFocusIndex] = useState(0);
const validInstances = editions.filter((e: Edition) =>
installs.includes(e.instanceId)
installs.includes(e.instanceId),
);
useEffect(() => {
@@ -52,30 +60,37 @@ export default function ChooseInstanceModal({
playPressSound();
setIsJoining(true);
setError("");
setStatus("Accepting invite...");
setStatus("Launching game...");
try {
const inviteData = await lceLiveService.acceptGameInvite(invite.inviteId) as Record<string, unknown>;
const fromIp = typeof invite.from !== 'string' ? (invite.from as unknown as Record<string, string>).hostIp : undefined;
const hostIp: string = (inviteData.hostIp as string) || fromIp || invite.hostIp;
const hostPort: number = (inviteData.hostPort as number) || invite.hostPort;
const sessionId = (inviteData.signalingSessionId as string) || invite.signalingSessionId || "";
const sessionId = invite.sessionId || "";
if (sessionId) {
setStatus("Connecting via relay...");
const baseUrl = lceLiveService.apiBaseUrl;
const accessToken = lceLiveService.accessToken ?? "";
await TauriService.startRelayProxy(baseUrl, accessToken, sessionId);
const accessToken = lceOnlineService.accessToken ?? "";
const relayPromise = TauriService.startRelayProxy(
accessToken,
sessionId,
);
setStatus("Launching game...");
await TauriService.launchGame(
selectedInstance,
[{ name: invite.hostName || "LCELive Game", ip: "127.0.0.1", port: 61000 }],
["-ip", "127.0.0.1", "-port", "61000", "-quitondisconnect"]
[
{
name: invite.hostName || "LCE Online Game",
ip: "127.0.0.1",
port: 61000,
},
],
["-ip", "127.0.0.1", "-port", "61000", "-quitondisconnect"],
);
await relayPromise;
} else {
setStatus("Launching game...");
await TauriService.stopAllProxies();
await TauriService.launchGame(selectedInstance, [
{ name: invite.hostName || "LCELive Game", ip: hostIp, port: hostPort }
{
name: invite.hostName || "LCE Online Game",
ip: invite.hostIp,
port: invite.hostPort,
},
]);
}
onClose();
@@ -103,13 +118,18 @@ export default function ChooseInstanceModal({
setFocusIndex((prev) => (prev - 1 + max) % max);
} else if (e.key === "Enter") {
if (focusIndex === 0 && validInstances.length > 0) {
const currentIdx = validInstances.findIndex((i: Edition) => i.instanceId === selectedInstance);
const currentIdx = validInstances.findIndex(
(i: Edition) => i.instanceId === selectedInstance,
);
const next = (currentIdx + 1) % validInstances.length;
setSelectedInstance(validInstances[next].instanceId);
playPressSound();
} else if (focusIndex === 1 && !isJoining) {
handleJoin();
} else if (focusIndex === (validInstances.length > 0 ? 2 : 1) && !isJoining) {
} else if (
focusIndex === (validInstances.length > 0 ? 2 : 1) &&
!isJoining
) {
onClose();
}
}
@@ -119,8 +139,11 @@ export default function ChooseInstanceModal({
}, [isOpen, focusIndex, selectedInstance, validInstances, isJoining]);
if (!isOpen) return null;
const hostName = invite ? (typeof invite.from === 'string' ? invite.from : invite.from.displayName) : "";
const hostName = invite
? typeof invite.from === "string"
? invite.from
: invite.hostName
: "";
return (
<motion.div
@@ -147,25 +170,41 @@ export default function ChooseInstanceModal({
</p>
{validInstances.length > 0 ? (
<div className="w-full mb-4 flex flex-col gap-2 max-h-[300px] overflow-y-auto"
style={{ scrollbarWidth: "thin", scrollbarColor: "#373737 transparent" }}>
<div
className="w-full mb-4 flex flex-col gap-2 max-h-[300px] overflow-y-auto"
style={{
scrollbarWidth: "thin",
scrollbarColor: "#373737 transparent",
}}
>
{validInstances.map((inst: Edition) => {
const isSelected = selectedInstance === inst.instanceId;
return (
<div
key={inst.instanceId}
onClick={() => { playPressSound(); setSelectedInstance(inst.instanceId); }}
onClick={() => {
playPressSound();
setSelectedInstance(inst.instanceId);
}}
onMouseEnter={() => setFocusIndex(0)}
className={`w-full px-4 py-3 cursor-pointer flex items-center gap-3 transition-all outline-none border-none ${isSelected ? "bg-white/15 border-l-4 border-[#FFFF55]" : "bg-black/20 hover:bg-black/30 border-l-4 border-transparent"}`}
style={{ imageRendering: "pixelated" }}
>
<div className={`w-4 h-4 rounded-full border-2 flex items-center justify-center ${isSelected ? "border-[#FFFF55]" : "border-gray-500"}`}>
{isSelected && <div className="w-2 h-2 rounded-full bg-[#FFFF55]" />}
<div
className={`w-4 h-4 rounded-full border-2 flex items-center justify-center ${isSelected ? "border-[#FFFF55]" : "border-gray-500"}`}
>
{isSelected && (
<div className="w-2 h-2 rounded-full bg-[#FFFF55]" />
)}
</div>
<div className="flex flex-col">
<span className="text-white text-lg font-bold mc-text-shadow">{inst.name}</span>
<span className="text-white text-lg font-bold mc-text-shadow">
{inst.name}
</span>
{inst.selectedBranch && (
<span className="text-gray-400 text-xs">{inst.selectedBranch}</span>
<span className="text-gray-400 text-xs">
{inst.selectedBranch}
</span>
)}
</div>
</div>
@@ -190,10 +229,15 @@ export default function ChooseInstanceModal({
const cancelIdx = validInstances.length > 0 ? 2 : 1;
setFocusIndex(cancelIdx);
}}
onClick={() => { playBackSound(); onClose(); }}
onClick={() => {
playBackSound();
onClose();
}}
className={`w-32 h-10 flex items-center justify-center text-xl mc-text-shadow transition-colors outline-none border-none ${(() => {
const cancelIdx = validInstances.length > 0 ? 2 : 1;
return focusIndex === cancelIdx ? "text-[#FFFF55]" : "text-white";
return focusIndex === cancelIdx
? "text-[#FFFF55]"
: "text-white";
})()}`}
style={{
backgroundImage: (() => {
@@ -214,9 +258,10 @@ export default function ChooseInstanceModal({
onClick={handleJoin}
className={`w-32 h-10 flex items-center justify-center text-xl mc-text-shadow transition-colors outline-none border-none ${focusIndex === 1 ? "text-[#FFFF55]" : "text-white"}`}
style={{
backgroundImage: focusIndex === 1
? "url('/images/button_highlighted.png')"
: "url('/images/Button_Background.png')",
backgroundImage:
focusIndex === 1
? "url('/images/button_highlighted.png')"
: "url('/images/Button_Background.png')",
backgroundSize: "100% 100%",
imageRendering: "pixelated",
}}
@@ -233,7 +278,9 @@ export default function ChooseInstanceModal({
</h2>
<div className="flex flex-col items-center gap-4 py-8">
<div className="w-12 h-12 border-4 border-[#FFFF55] border-t-transparent rounded-full animate-spin" />
<p className="text-white text-lg mc-text-shadow text-center">{status}</p>
<p className="text-white text-lg mc-text-shadow text-center">
{status}
</p>
</div>
{error && (
<div className="text-red-500 text-center mc-text-shadow uppercase text-xs tracking-widest mb-3">

View File

@@ -209,6 +209,7 @@ const CreditsView = memo(function CreditsView() {
{ name: "dr.av", url: "#" },
{ name: "alreadywarned", url: "#" },
{ name: "andrewjcf", url: "#" },
{ name: "dille", url: "#" },
],
},
],
@@ -427,4 +428,4 @@ const CreditsView = memo(function CreditsView() {
);
});
export default CreditsView;
export default CreditsView;

View File

@@ -1,4 +1,4 @@
import { useState, useEffect, useRef, useMemo, useCallback, memo } from "react";
import { useState, useEffect, useRef, useMemo, memo } from "react";
import { motion, AnimatePresence } from "framer-motion";
import {
useUI,
@@ -6,69 +6,55 @@ import {
useAudio,
useGame,
} from "../../context/LauncherContext";
import {
lceLiveService,
LceLiveAccount,
FriendRequest,
GameInvite,
DeviceLinkStartResponse,
} from "../../services/LceLiveService";
import { TauriService } from "../../services/TauriService";
import ChooseInstanceModal from "../modals/ChooseInstanceModal";
import QRCode from "qrcode";
interface LceLiveViewProps {
import { lceOnlineService } from "../../services/LceOnlineService";
import { TauriService } from "../../services/TauriService";
interface LceOnlineViewProps {
addFriendTarget?: string | null;
onClearAddFriendTarget?: () => void;
invites?: Array<{
inviteid: string;
from: { uuid: string; username: string };
sessionid: string;
}>;
}
const LceLiveView = memo(function LceLiveView({
const LceOnlineView = memo(function LceOnlineView({
addFriendTarget,
onClearAddFriendTarget,
}: LceLiveViewProps) {
invites: invitesProp,
}: LceOnlineViewProps) {
const { setActiveView } = useUI();
const { animationsEnabled } = useConfig();
const { playPressSound, playBackSound } = useAudio();
const { editions, installs } = useGame();
const [isSignedIn, setIsSignedIn] = useState(lceLiveService.signedIn);
const game = useGame();
const [isSignedIn, setIsSignedIn] = useState(lceOnlineService.signedIn);
const [currentTab, setCurrentTab] = useState<
"friends" | "requests" | "invites" | "device_link"
"friends" | "requests" | "invites"
>("friends");
const [focusIndex, setFocusIndex] = useState<number | null>(0);
const [acceptInvite, setAcceptInvite] = useState<GameInvite | null>(null);
const [friends, setFriends] = useState<LceLiveAccount[]>([]);
const [incomingReqs, setIncomingReqs] = useState<FriendRequest[]>([]);
const [outgoingReqs, setOutgoingReqs] = useState<FriendRequest[]>([]);
const [invites, setInvites] = useState<GameInvite[]>([]);
const [linkData, setLinkData] = useState<DeviceLinkStartResponse | null>(
null,
);
const [linkError, setLinkError] = useState<string | null>(null);
const [friends, setFriends] = useState<string[]>([]);
const [incomingReqs, setIncomingReqs] = useState<string[]>([]);
const [outgoingReqs, setOutgoingReqs] = useState<string[]>([]);
const invites = invitesProp ?? [];
const [isHosting, setIsHosting] = useState(false);
const [_hostStatus, setHostStatus] = useState("");
const [hostIp, setHostIp] = useState("");
const [hostPort, setHostPort] = useState(19132);
const [isDiscovering, setIsDiscovering] = useState(false);
const [invitedFriends, setInvitedFriends] = useState<Set<string>>(new Set());
const [showHostMethodPicker, setShowHostMethodPicker] = useState(false);
const [isAddingFriend, setIsAddingFriend] = useState(false);
const [addFriendUsername, setAddFriendUsername] = useState("");
const addFriendInputRef = useRef<HTMLInputElement>(null);
const [errorModal, setErrorModal] = useState<string | null>(null);
const [qrDataUrl, setQrDataUrl] = useState<string>("");
const [joinTarget, setJoinTarget] = useState<{
inviteid: string;
sessionId: string;
hostName: string;
} | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
const scrollRef = useRef<HTMLDivElement>(null);
const fetchSocialData = async () => {
if (!lceLiveService.signedIn) return;
if (!lceOnlineService.signedIn) return;
try {
const [f, reqs, invs] = await Promise.all([
lceLiveService.getFriends(),
lceLiveService.getPendingRequests(),
lceLiveService.getGameInvites(),
]);
setFriends(f);
setIncomingReqs(reqs.incoming);
setOutgoingReqs(reqs.outgoing);
setInvites(invs.filter((i: GameInvite) => i.status === "pending"));
const lists = await lceOnlineService.getSocialLists();
setFriends(lists.friends);
setIncomingReqs(lists.requests);
setOutgoingReqs([]);
} catch (e: unknown) {
console.error(e);
}
@@ -76,84 +62,57 @@ const LceLiveView = memo(function LceLiveView({
useEffect(() => {
if (isSignedIn) {
if (currentTab === "device_link") setCurrentTab("friends");
fetchSocialData();
const pollInvites = setInterval(async () => {
try {
const invs = await lceLiveService.getGameInvites();
setInvites(invs.filter((i: GameInvite) => i.status === "pending"));
} catch (e) {
console.warn("Failed to poll invites", e);
}
}, 5000);
return () => clearInterval(pollInvites);
} else {
setCurrentTab("device_link");
}
}, [isSignedIn, currentTab]);
}, [isSignedIn]);
useEffect(() => {
if (currentTab !== "device_link") return;
let mounted = true;
let pollInterval: ReturnType<typeof setInterval> | null = null;
const startLink = async () => {
try {
if (!linkData) {
const data = await lceLiveService.startDeviceLink();
if (mounted) setLinkData(data);
}
} catch (e: unknown) {
if (mounted) setLinkError(e instanceof Error ? e.message : String(e));
}
};
return lceOnlineService.onSessionChange(() => {
setIsSignedIn(lceOnlineService.signedIn);
});
}, []);
startLink();
if (linkData?.deviceCode) {
pollInterval = setInterval(
async () => {
try {
const res = await lceLiveService.pollDeviceLink(
linkData.deviceCode,
);
if (res.isLinked && mounted) {
setIsSignedIn(true);
setLinkData(null);
if (pollInterval !== null) clearInterval(pollInterval);
}
} catch (e: unknown) {
console.warn("Poll failed", e);
}
},
Math.max(linkData.intervalSeconds * 1000, 2000),
useEffect(() => {
if (!isSignedIn) {
TauriService.openUrl(
"https://mclegacyedition.xyz/internal/auth?appId=emerald_launcher",
);
}
return () => {
mounted = false;
if (pollInterval !== null) clearInterval(pollInterval);
};
}, [currentTab, linkData]);
}, []);
useEffect(() => {
if (!linkData?.verificationUri || !linkData?.userCode) return;
const authUrl = `${linkData.verificationUri}?code=${linkData.userCode}`;
QRCode.toDataURL(authUrl, { width: 200, margin: 1 }, (err, url) => {
if (!err) setQrDataUrl(url);
});
}, [linkData]);
const openAuthUrl = useCallback(() => {
if (!linkData?.verificationUri || !linkData?.userCode) return;
const authUrl = `${linkData.verificationUri}?code=${linkData.userCode}`;
TauriService.openUrl(authUrl);
}, [linkData]);
if (!addFriendTarget) return;
setCurrentTab("friends");
handleAction(() => lceOnlineService.sendFriendRequest(addFriendTarget));
onClearAddFriendTarget?.();
}, [addFriendTarget, onClearAddFriendTarget]);
const handleLogout = () => {
playPressSound();
lceLiveService.logoutLocal();
lceOnlineService.logoutLocal();
setIsSignedIn(false);
setLinkData(null);
};
const handleStartHosting = async () => {
playPressSound();
try {
const token = lceOnlineService.accessToken ?? "";
if (!token) return;
TauriService.startHostRelay(token, 25565).catch(() => {});
setIsHosting(true);
} catch (e: unknown) {
setErrorModal(e instanceof Error ? e.message : "Failed to start hosting");
}
};
const handleStopHosting = async () => {
playPressSound();
try {
await TauriService.stopAllProxies();
} catch (e: unknown) {
console.warn("Stop hosting failed", e);
}
setIsHosting(false);
};
const handleAction = async (action: () => Promise<void>) => {
@@ -166,113 +125,6 @@ const LceLiveView = memo(function LceLiveView({
}
};
const handleStartHosting = () => {
playPressSound();
setShowHostMethodPicker(true);
setFocusIndex(0);
};
const handleHostDirect = async () => {
setShowHostMethodPicker(false);
setFocusIndex(0);
setIsDiscovering(true);
setHostStatus("Discovering external IP...");
try {
const endpoint = await TauriService.stunDiscover();
setHostIp(endpoint.ip);
setHostPort(25565);
setIsHosting(true);
setHostStatus(`Hosting at ${endpoint.ip}:25565`);
setInvitedFriends(new Set());
} catch (e: unknown) {
const msg =
e instanceof Error
? e.message
: typeof e === "string"
? e
: "Unknown error";
setErrorModal("STUN discovery failed: " + msg);
setHostStatus("");
} finally {
setIsDiscovering(false);
}
};
const handleHostRelay = async () => {
setShowHostMethodPicker(false);
setFocusIndex(0);
setIsDiscovering(true);
setHostStatus("Discovering external IP for invite...");
try {
const endpoint = await TauriService.stunDiscover();
setHostIp(endpoint.ip);
setHostPort(25565);
} catch {
setHostIp("127.0.0.1");
setHostPort(25565);
}
setIsHosting(true);
setHostStatus("Relay ready - invite friends to activate");
setInvitedFriends(new Set());
setIsDiscovering(false);
};
const handleStopHosting = async () => {
playPressSound();
try {
await TauriService.stopAllProxies();
await lceLiveService.deactivateGameInvites();
} catch (e: unknown) {
console.warn("Stop hosting failed", e);
}
setIsHosting(false);
setHostStatus("");
setHostIp("");
setInvitedFriends(new Set());
};
const handleInviteFriend = async (friend: LceLiveAccount) => {
playPressSound();
const name = lceLiveService.displayUsername;
const sessionId = crypto.randomUUID();
try {
await lceLiveService.sendGameInvite(
friend.accountId,
hostIp,
hostPort,
name,
sessionId,
);
setInvitedFriends((prev) => new Set(prev).add(friend.accountId));
setHostStatus("Connecting relay...");
TauriService.startHostRelay(
lceLiveService.apiBaseUrl,
lceLiveService.accessToken ?? "",
sessionId,
25565,
)
.then(() => setHostStatus("Relay active"))
.catch((relayErr: unknown) => {
const relayMsg =
relayErr instanceof Error
? relayErr.message
: typeof relayErr === "string"
? relayErr
: "Unknown error";
console.warn("Relay failed:", relayMsg);
setHostStatus("Relay disconnected");
});
} catch (e: unknown) {
const msg =
e instanceof Error
? e.message
: typeof e === "string"
? e
: "Unknown error";
setErrorModal("Failed to send invite: " + msg);
}
};
type MenuItem = {
id: string;
type: "button" | "friend" | "request_in" | "request_out" | "invite";
@@ -283,35 +135,21 @@ const LceLiveView = memo(function LceLiveView({
const menuItems = useMemo<MenuItem[]>(() => {
const items: MenuItem[] = [];
if (currentTab === "device_link") {
if (linkData) {
if (currentTab === "friends") {
if (!isHosting) {
items.push({
id: "link_retry",
id: "host_game",
type: "button",
label: "Restart Link",
onClick: () => {
setLinkData(null);
playPressSound();
},
label: "Host Game",
onClick: handleStartHosting,
});
} else {
items.push({
id: "stop_hosting",
type: "button",
label: "Stop Hosting",
onClick: handleStopHosting,
});
}
} else if (currentTab === "friends") {
if (!isDiscovering && !showHostMethodPicker) {
if (!isHosting) {
items.push({
id: "host_game",
type: "button",
label: "Host Game",
onClick: handleStartHosting,
});
} else {
items.push({
id: "stop_hosting",
type: "button",
label: "Stop Hosting",
onClick: handleStopHosting,
});
}
}
items.push({
id: "add_friend",
@@ -331,56 +169,57 @@ const LceLiveView = memo(function LceLiveView({
});
friends.forEach((f) => {
items.push({
id: `friend_${f.accountId}`,
id: `friend_${f}`,
type: "friend",
label: f.displayName,
onClick: isHosting
? () => handleInviteFriend(f)
: () =>
handleAction(() => lceLiveService.removeFriend(f.accountId)),
label: f,
onClick: () => handleAction(() => lceOnlineService.removeFriend(f)),
onClickSecondary: isHosting
? () => handleAction(() => lceLiveService.removeFriend(f.accountId))
? () => handleAction(() => lceOnlineService.sendInvite(f))
: undefined,
});
});
} else if (currentTab === "requests") {
incomingReqs.forEach((r) => {
items.push({
id: `req_in_${r.username}`,
id: `req_in_${r}`,
type: "request_in",
label: r.displayName,
label: r,
onClick: () =>
handleAction(() => lceLiveService.sendFriendRequest(r.username)),
handleAction(() => lceOnlineService.acceptFriendRequest(r)),
onClickSecondary: () =>
handleAction(() =>
lceLiveService.declineFriendRequest(r.accountId),
),
handleAction(() => lceOnlineService.declineFriendRequest(r)),
});
});
outgoingReqs.forEach((r) => {
items.push({
id: `req_out_${r.username}`,
id: `req_out_${r}`,
type: "request_out",
label: r.displayName,
label: r,
onClick: () =>
handleAction(() =>
lceLiveService.declineFriendRequest(r.accountId),
),
handleAction(() => lceOnlineService.declineFriendRequest(r)),
});
});
} else if (currentTab === "invites") {
invites.forEach((inv) => {
items.push({
id: `inv_${inv.inviteId}`,
id: `invite_${inv.inviteid}`,
type: "invite",
label:
typeof inv.from === "string" ? "Unknown" : inv.from.displayName,
onClick: () => {
playPressSound();
setAcceptInvite(inv);
},
label: inv.from.username,
onClick: () =>
handleAction(async () => {
const sessionId = await lceOnlineService.acceptInvite(
inv.from.username,
);
setJoinTarget({
inviteid: inv.inviteid,
sessionId,
hostName: inv.from.username,
});
}),
onClickSecondary: () =>
handleAction(() => lceLiveService.declineGameInvite(inv.inviteId)),
handleAction(() =>
lceOnlineService.declineInvite(inv.from.username),
),
});
});
}
@@ -392,22 +231,10 @@ const LceLiveView = memo(function LceLiveView({
incomingReqs,
outgoingReqs,
invites,
linkData,
playPressSound,
isHosting,
isDiscovering,
showHostMethodPicker,
]);
useEffect(() => {
if (!addFriendTarget) return;
setCurrentTab("friends");
handleAction(() =>
lceLiveService.sendFriendRequest(addFriendTarget),
);
onClearAddFriendTarget?.();
}, [addFriendTarget, onClearAddFriendTarget]);
const tabs: ("friends" | "requests" | "invites")[] = [
"friends",
"requests",
@@ -429,7 +256,7 @@ const LceLiveView = memo(function LceLiveView({
} else if (e.key === "Enter") {
if (addFriendUsername.trim() !== "") {
handleAction(() =>
lceLiveService.sendFriendRequest(addFriendUsername.trim()),
lceOnlineService.sendFriendRequest(addFriendUsername.trim()),
);
setIsAddingFriend(false);
}
@@ -437,23 +264,11 @@ const LceLiveView = memo(function LceLiveView({
return;
}
if (showHostMethodPicker) {
if (!isSignedIn) {
if (e.key === "Escape" || e.key === "Backspace") {
setShowHostMethodPicker(false);
setFocusIndex(0);
playBackSound();
} else if (e.key === "ArrowDown") {
setFocusIndex((prev) => (prev === null || prev >= 2 ? 0 : prev + 1));
} else if (e.key === "ArrowUp") {
setFocusIndex((prev) => (prev === null || prev <= 0 ? 2 : prev - 1));
} else if (e.key === "Enter") {
if (focusIndex === 0) handleHostRelay();
else if (focusIndex === 1) handleHostDirect();
else if (focusIndex === 2) {
setShowHostMethodPicker(false);
setFocusIndex(0);
playBackSound();
}
setActiveView("main");
return;
}
return;
}
@@ -464,22 +279,20 @@ const LceLiveView = memo(function LceLiveView({
return;
}
if (currentTab !== "device_link") {
const curIdx = tabs.indexOf(currentTab);
if (e.key === "q" || e.key === "Q" || e.key === "ArrowLeft") {
const next = curIdx > 0 ? tabs[curIdx - 1] : tabs[tabs.length - 1];
setCurrentTab(next);
setFocusIndex(0);
playPressSound();
return;
}
if (e.key === "e" || e.key === "E" || e.key === "ArrowRight") {
const next = curIdx < tabs.length - 1 ? tabs[curIdx + 1] : tabs[0];
setCurrentTab(next);
setFocusIndex(0);
playPressSound();
return;
}
const curIdx = tabs.indexOf(currentTab);
if (e.key === "q" || e.key === "Q" || e.key === "ArrowLeft") {
const next = curIdx > 0 ? tabs[curIdx - 1] : tabs[tabs.length - 1];
setCurrentTab(next);
setFocusIndex(0);
playPressSound();
return;
}
if (e.key === "e" || e.key === "E" || e.key === "ArrowRight") {
const next = curIdx < tabs.length - 1 ? tabs[curIdx + 1] : tabs[0];
setCurrentTab(next);
setFocusIndex(0);
playPressSound();
return;
}
const itemCount = menuItems.length;
@@ -508,9 +321,7 @@ const LceLiveView = memo(function LceLiveView({
isAddingFriend,
addFriendUsername,
errorModal,
showHostMethodPicker,
handleHostDirect,
handleHostRelay,
isSignedIn,
]);
useEffect(() => {
@@ -532,52 +343,21 @@ const LceLiveView = memo(function LceLiveView({
}
}
}, [focusIndex, isAddingFriend]);
const renderContent = () => {
if (currentTab === "device_link") {
if (!isSignedIn) {
return (
<div className="flex flex-col items-center justify-center flex-1 text-center py-12">
{!linkData ? (
<p className="text-lg text-[#2a2a2a] font-bold">
{linkError || "Starting device link..."}
</p>
) : (
<div className="flex items-start justify-center gap-10 w-full max-w-2xl">
<div className="flex flex-col items-center space-y-4 flex-1 min-w-0">
<p className="text-lg text-[#2a2a2a] font-bold">
Open this link in your browser:
</p>
<p className="text-[#111] text-base font-bold tracking-widest break-all bg-black/10 px-4 py-2 rounded shadow-inner">
{linkData.verificationUri}
</p>
<p className="text-lg text-[#2a2a2a] font-bold">
And enter the code:
</p>
<p className="text-[#111] text-4xl tracking-[0.2em] font-bold bg-black/10 px-6 py-3 rounded shadow-inner">
{linkData.userCode}
</p>
</div>
<div className="flex flex-col items-center gap-3 shrink-0">
{qrDataUrl && (
<img
src={qrDataUrl}
alt="QR Code"
className="w-48 h-48 image-rendering-pixelated"
/>
)}
<button
onClick={openAuthUrl}
className="h-10 px-6 flex items-center justify-center text-white mc-text-shadow text-base font-bold uppercase tracking-widest outline-none border-none hover:text-[#FFFF55] transition-colors"
style={{
backgroundImage: "url('/images/button_highlighted.png')",
backgroundSize: "100% 100%",
imageRendering: "pixelated",
}}
>
Open in Browser
</button>
</div>
</div>
)}
<h2 className="text-[#FFFF55] text-3xl mc-text-shadow mb-8 pb-2 w-full text-center uppercase tracking-widest">
<img
src="/images/lceonline.png"
alt="LCE Online"
className="h-16 mx-auto"
/>
</h2>
<p className="text-white text-lg mc-text-shadow mb-8 max-w-sm">
Awaiting authentication...
</p>
</div>
);
}
@@ -617,10 +397,10 @@ const LceLiveView = memo(function LceLiveView({
<div className="bg-black/10 px-4 py-3 text-[#2a2a2a] font-bold tracking-widest uppercase border-b-4 border-[#222] flex justify-between shadow-sm z-10">
<span>
{currentTab === "friends"
? "Joinable Friends"
: currentTab === "requests"
? "Pending Requests"
: "Game Invites"}
? "Friends"
: currentTab === "invites"
? "Invites"
: "Pending Requests"}
</span>
<span className="text-[#111]">{listItems.length}</span>
</div>
@@ -648,59 +428,28 @@ const LceLiveView = memo(function LceLiveView({
<span className="text-[#2a2a2a] font-bold text-2xl truncate pr-4">
{item.label}
</span>
<span className="text-[#555] text-base font-bold truncate">
@
{menuItems.find((m) => m.id === item.id)?.type ===
"friend"
? friends.find(
(f) => `friend_${f.accountId}` === item.id,
)?.username
: item.type === "request_in"
? incomingReqs.find(
(f) => `req_in_${f.accountId}` === item.id,
)?.username
: item.type === "request_out"
? outgoingReqs.find(
(f) =>
`req_out_${f.accountId}` === item.id,
)?.username
: "Invite"}
</span>
</div>
</div>
<div className="flex space-x-3 pr-2 shrink-0">
{item.type === "friend" && !isHosting && (
<button
className={`px-6 h-12 flex items-center justify-center font-bold text-base outline-none uppercase tracking-widest mc-text-shadow ${isFocused ? "text-white shadow-md" : "text-gray-300"}`}
style={{
backgroundImage:
"url('/images/Button_Background.png')",
backgroundSize: "100% 100%",
imageRendering: "pixelated",
}}
onClick={item.onClick}
>
REMOVE
</button>
)}
{item.type === "friend" && isHosting && (
{item.type === "friend" && (
<>
<button
className={`px-6 h-12 flex items-center justify-center font-bold text-base outline-none uppercase tracking-widest mc-text-shadow ${isFocused ? "text-white shadow-md" : "text-gray-300"}`}
style={{
backgroundImage:
"url('/images/button_highlighted.png')",
backgroundSize: "100% 100%",
imageRendering: "pixelated",
}}
onClick={item.onClick}
>
{invitedFriends.has(
item.id.replace("friend_", ""),
)
? "INVITED"
: "INVITE"}
</button>
{item.onClickSecondary && (
<button
className={`px-6 h-12 flex items-center justify-center font-bold text-base outline-none uppercase tracking-widest mc-text-shadow ${isFocused ? "text-white shadow-md" : "text-gray-300"}`}
style={{
backgroundImage:
"url('/images/button_highlighted.png')",
backgroundSize: "100% 100%",
imageRendering: "pixelated",
}}
onClick={(e) => {
e.stopPropagation();
item.onClickSecondary?.();
}}
>
INVITE
</button>
)}
<button
className={`px-6 h-12 flex items-center justify-center font-bold text-base outline-none uppercase tracking-widest mc-text-shadow ${isFocused ? "text-white shadow-md" : "text-gray-300"}`}
style={{
@@ -709,10 +458,7 @@ const LceLiveView = memo(function LceLiveView({
backgroundSize: "100% 100%",
imageRendering: "pixelated",
}}
onClick={(e) => {
e.stopPropagation();
item.onClickSecondary?.();
}}
onClick={item.onClick}
>
REMOVE
</button>
@@ -732,8 +478,38 @@ const LceLiveView = memo(function LceLiveView({
CANCEL
</button>
)}
{(item.type === "request_in" ||
item.type === "invite") && (
{item.type === "invite" && (
<>
<button
className={`px-6 h-12 flex items-center justify-center font-bold text-base outline-none uppercase tracking-widest mc-text-shadow ${isFocused ? "text-white shadow-md" : "text-gray-300"}`}
style={{
backgroundImage:
"url('/images/button_highlighted.png')",
backgroundSize: "100% 100%",
imageRendering: "pixelated",
}}
onClick={item.onClick}
>
ACCEPT
</button>
<button
className={`px-6 h-12 flex items-center justify-center font-bold text-base outline-none uppercase tracking-widest mc-text-shadow ${isFocused ? "text-white shadow-md" : "text-gray-300"}`}
style={{
backgroundImage:
"url('/images/Button_Background.png')",
backgroundSize: "100% 100%",
imageRendering: "pixelated",
}}
onClick={(e) => {
e.stopPropagation();
item.onClickSecondary?.();
}}
>
DECLINE
</button>
</>
)}
{item.type === "request_in" && (
<>
<button
className={`px-6 h-12 flex items-center justify-center font-bold text-base outline-none uppercase tracking-widest mc-text-shadow ${isFocused ? "text-white shadow-md" : "text-gray-300"}`}
@@ -775,6 +551,7 @@ const LceLiveView = memo(function LceLiveView({
</div>
);
};
return (
<motion.div
ref={containerRef}
@@ -786,7 +563,7 @@ const LceLiveView = memo(function LceLiveView({
className="flex flex-col items-center justify-center w-full h-full absolute inset-0 outline-none p-12"
>
<div className="w-full max-w-5xl h-full flex flex-col mt-[4vh] mb-[4vh] relative drop-shadow-2xl">
{currentTab !== "device_link" && (
{isSignedIn && (
<div
className="flex z-10 space-x-2 px-12 relative w-full items-end"
style={{ marginBottom: "-4px" }}
@@ -819,7 +596,7 @@ const LceLiveView = memo(function LceLiveView({
)}
{t === "invites" && invites.length > 0 && (
<span
className={`ml-3 text-white text-base px-3 py-1 rounded-full shadow-inner border-2 font-normal ${currentTab === t ? "bg-[#30872a] border-[#1b5e16]" : "bg-[#23681d] border-[#111]"}`}
className={`ml-3 text-white text-base px-3 py-1 rounded-full shadow-inner border-2 font-normal ${currentTab === t ? "bg-[#d72f2f] border-[#8a1a1a]" : "bg-[#a81f1f] border-[#111]"}`}
>
{invites.length}
</span>
@@ -842,14 +619,6 @@ const LceLiveView = memo(function LceLiveView({
>
{renderContent()}
</div>
<div className="flex justify-center pt-4 pb-2">
<img
src="/images/lcelive.png"
alt="LCELive"
className="h-5 opacity-70 cursor-pointer"
onClick={() => TauriService.openUrl("https://lcelive.co.uk")}
/>
</div>
</div>
<AnimatePresence>
@@ -891,7 +660,7 @@ const LceLiveView = memo(function LceLiveView({
playPressSound();
if (addFriendUsername.trim() !== "") {
handleAction(() =>
lceLiveService.sendFriendRequest(
lceOnlineService.sendFriendRequest(
addFriendUsername.trim(),
),
);
@@ -920,83 +689,6 @@ const LceLiveView = memo(function LceLiveView({
</motion.div>
)}
</AnimatePresence>
<AnimatePresence>
{showHostMethodPicker && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-[105] flex items-center justify-center bg-black/80 backdrop-blur-sm outline-none border-none"
>
<div
className="relative w-[420px] p-8 flex flex-col items-center shadow-2xl gap-4"
style={{
backgroundImage: "url('/images/frame_background.png')",
backgroundSize: "100% 100%",
imageRendering: "pixelated",
}}
>
<h2 className="text-[#FFFF55] text-3xl mc-text-shadow mb-2 border-b-2 border-[#373737] pb-2 w-full text-center uppercase tracking-widest">
Host Game
</h2>
<button
data-index={0}
onMouseEnter={() => setFocusIndex(0)}
onClick={handleHostRelay}
className={`w-full h-14 flex items-center justify-center text-xl font-bold uppercase tracking-widest outline-none border-none ${focusIndex === 0 ? "text-[#FFFF55] mc-text-shadow" : "text-white mc-text-shadow hover:text-[#FFFF55]"}`}
style={{
backgroundImage:
focusIndex === 0
? "url('/images/button_highlighted.png')"
: "url('/images/Button_Background.png')",
backgroundSize: "100% 100%",
imageRendering: "pixelated",
}}
>
Relay
</button>
<button
data-index={1}
onMouseEnter={() => setFocusIndex(1)}
onClick={handleHostDirect}
className={`w-full h-14 flex items-center justify-center text-xl font-bold uppercase tracking-widest outline-none border-none ${focusIndex === 1 ? "text-[#FFFF55] mc-text-shadow" : "text-white mc-text-shadow hover:text-[#FFFF55]"}`}
style={{
backgroundImage:
focusIndex === 1
? "url('/images/button_highlighted.png')"
: "url('/images/Button_Background.png')",
backgroundSize: "100% 100%",
imageRendering: "pixelated",
}}
>
Direct (STUN)
</button>
<button
data-index={2}
onMouseEnter={() => setFocusIndex(2)}
onClick={() => {
setShowHostMethodPicker(false);
setFocusIndex(0);
playBackSound();
}}
className={`w-full h-14 flex items-center justify-center text-xl font-bold uppercase tracking-widest outline-none border-none ${focusIndex === 2 ? "text-[#FFFF55] mc-text-shadow" : "text-white mc-text-shadow hover:text-[#FFFF55]"}`}
style={{
backgroundImage:
focusIndex === 2
? "url('/images/button_highlighted.png')"
: "url('/images/Button_Background.png')",
backgroundSize: "100% 100%",
imageRendering: "pixelated",
}}
>
Cancel
</button>
</div>
</motion.div>
)}
</AnimatePresence>
<AnimatePresence>
{errorModal && (
<motion.div
@@ -1034,21 +726,27 @@ const LceLiveView = memo(function LceLiveView({
</motion.div>
)}
</AnimatePresence>
<ChooseInstanceModal
isOpen={acceptInvite !== null}
onClose={() => {
setAcceptInvite(null);
fetchSocialData();
}}
playPressSound={playPressSound}
playBackSound={playBackSound}
editions={editions}
installs={installs}
invite={acceptInvite}
/>
{joinTarget && (
<ChooseInstanceModal
isOpen={true}
onClose={() => setJoinTarget(null)}
playPressSound={playPressSound}
playBackSound={playBackSound}
editions={game.editions}
installs={game.installs}
invite={{
inviteId: joinTarget.inviteid,
from: joinTarget.hostName,
hostIp: "",
hostPort: 0,
hostName: joinTarget.hostName,
sessionId: joinTarget.sessionId,
status: "pending",
}}
/>
)}
</motion.div>
);
});
export default LceLiveView;
export default LceOnlineView;

View File

@@ -83,7 +83,7 @@ interface ServerListing {
id: string;
owner: string;
name: string;
short_descripton: string
short_descripton: string;
description: string;
discord?: string;
version: string;
@@ -510,17 +510,10 @@ const WorkshopView = memo(function WorkshopView({
useEffect(() => {
if (!workshopTarget || loading) return;
if (
workshopTarget.type === "lceonline" && serverPlugins.length === 0
)
if (workshopTarget.type === "lceonline" && serverPlugins.length === 0)
return;
if (
workshopTarget.type === "plugin" && pluginPackages.length === 0
)
return;
if (
workshopTarget.type === "version" && versionPackages.length === 0
)
if (workshopTarget.type === "plugin" && pluginPackages.length === 0) return;
if (workshopTarget.type === "version" && versionPackages.length === 0)
return;
if (!workshopTarget.type && allPackages.length === 0) return;
@@ -1292,9 +1285,10 @@ function PackageModal({
const needsUpdate =
hasInstalled && installedEntries.some((e) => e.version !== pkg.version);
const unresolvedDeps = useMemo(
() => (pkg.dependencies || []).filter(
(depId) => !installedPkgs.some((p) => p.packageId === depId),
),
() =>
(pkg.dependencies || []).filter(
(depId) => !installedPkgs.some((p) => p.packageId === depId),
),
[pkg.dependencies, installedPkgs],
);
const focusOptions: Array<"install" | "uninstall" | "close"> = isGameServerTab
@@ -1351,9 +1345,7 @@ function PackageModal({
["*.dll", "*"],
);
if (!path) return;
const response = await fetch(
`${pkg.download_url}`,
);
const response = await fetch(`${pkg.download_url}`);
const blob = await response.blob();
const buffer = await blob.arrayBuffer();
await TauriService.writeBinaryFile(path, new Uint8Array(buffer));
@@ -1498,7 +1490,13 @@ function PackageModal({
Details
</span>
<div className="text-sm text-white mc-text-shadow leading-relaxed workshop-markdown">
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeRaw, [rehypeSanitize, defaultSchema]]}>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[
rehypeRaw,
[rehypeSanitize, defaultSchema],
]}
>
{pkg.extended_description}
</ReactMarkdown>
</div>
@@ -1685,7 +1683,9 @@ function PackageModal({
<div className="flex flex-wrap gap-1.5 mt-1">
{pkg.dependencies.map((depId) => {
const dep = allPackages.find((p) => p.id === depId);
const isDepInstalled = installedPkgs.some((p) => p.packageId === depId);
const isDepInstalled = installedPkgs.some(
(p) => p.packageId === depId,
);
return (
<span
key={depId}
@@ -1711,9 +1711,12 @@ function PackageModal({
<div className="flex flex-wrap gap-1.5 mt-1">
{pkg.required_versions.map((verId) => {
const builtin = BASE_EDITIONS.find((e) => e.id === verId);
const versionPkg = versionPackages.find((v) => v.id === verId);
const versionPkg = versionPackages.find(
(v) => v.id === verId,
);
const customEd = customEditions.find((e) => e.id === verId);
const displayName = builtin?.name || customEd?.name || versionPkg?.name;
const displayName =
builtin?.name || customEd?.name || versionPkg?.name;
if (!displayName) return null;
return (
<span
@@ -1937,8 +1940,16 @@ function InstallModal({
isPluginTab?: boolean;
}) {
const game = useContext(GameContext);
const availableEditions =
const allInstalled =
game?.editions.filter((e) => game.installs.includes(e.id)) || [];
const availableEditions =
pkg.required_versions && pkg.required_versions.length > 0
? allInstalled.filter((e) =>
pkg.required_versions!.some(
(rv) => e.id === rv || e.id.startsWith(rv + "_"),
),
)
: allInstalled;
const [focusedIdx, setFocusedIdx] = useState(0);
const [status, setStatus] = useState<
"idle" | "installing" | "success" | "error"

View File

@@ -230,4 +230,5 @@ export const SPLASHES = [
"You're using asterisks wrong*",
"Edgy humor is soo edgy",
"I LOVE YURI!!!!!!! oh and yaoi ig",
"RIP PrismaChunk0's Dog 2010-2026",
];

View File

@@ -62,7 +62,7 @@ export function useDiscordRPC({
devtools: "Developing for LCE",
skins: "Changing Skins",
workshop: "Browsing Workshop",
lcelive: "Browsing Friends",
lceonline: "Browsing Friends",
"pck-editor": "Editing a PCK file",
"options-editor": "Editing Options Files",
"arc-editor": "Editing an ARC file",

View File

@@ -71,12 +71,23 @@ export const BASE_EDITIONS = [
id: "moon_edition",
name: "Minecraft: Moon Edition",
desc: "Galacticraft LCE port (Modded build!)",
url: "https://INTENTIONALLY_INVALID_LINK_BECAUSE_FUCK_YOU_THATS_WHY", //neo: placeholder for now
url: "https://github.com/blazin-blaze/moon-edition/releases/download/v1.0.1/moonEditionWindows64.zip",
titleImage: "/images/minecraft_title_moon.png",
supportsSlimSkins: false,
logo: "/images/moonEdition.png",
panorama: "moonedition",
},
{
id: "lceonline",
name: "LCE Online Client",
desc: "Restoring the classic LCE online experience with friends, world hosting, leaderboards & more.",
url: "https://github.com/lceonline/MCLEClient/releases/download/v1.0.0b/LCENWindows64.zip",
titleImage: "/images/lceonline.png",
supportsSlimSkins: false,
logo: "/images/lce_online.png",
panorama: "vanilla_tu19",
lceOnline: true,
},
];
const PARTNERSHIP_SERVERS = [
@@ -481,10 +492,18 @@ export function useGameManager({
setIsGameRunning(true);
try {
getCurrentWindow().minimize();
const currentEdition = editions.find((e) => e.instanceId === profile);
await TauriService.launchGame(
profile,
PARTNERSHIP_SERVERS,
extraLaunchArgs,
currentEdition?.lceOnline
? extraLaunchArgs!.concat([
"-token",
localStorage.getItem("lceonline_session")
? JSON.parse(localStorage.getItem("lceonline_session")!).accessToken
: "",
])
: extraLaunchArgs,
);
} catch (e: unknown) {
console.error(e);

View File

@@ -1,66 +0,0 @@
import { useState, useEffect, useRef } from "react";
import { lceLiveService, type FriendRequest, type GameInvite } from "../services/LceLiveService";
export function useLceLiveNotifications() {
const [friendRequestMessage, setFriendRequestMessage] = useState<string | null>(null);
const [gameInviteMessage, setGameInviteMessage] = useState<string | null>(null);
const seenRequests = useRef<Set<string>>(new Set());
const seenInvites = useRef<Set<string>>(new Set());
useEffect(() => {
let pollInterval: ReturnType<typeof setInterval>;
const init = async () => {
if (lceLiveService.signedIn) {
try {
await lceLiveService.refreshSession();
} catch (e) { }
}
if (lceLiveService.signedIn) {
try {
const [reqs, invs] = await Promise.all([
lceLiveService.getPendingRequests(),
lceLiveService.getGameInvites()
]);
reqs.incoming.forEach((r: FriendRequest) => seenRequests.current.add(r.accountId));
invs.filter((i: GameInvite) => i.status === "pending").forEach((i: GameInvite) => seenInvites.current.add(i.inviteId));
} catch (e) { }
}
pollInterval = setInterval(async () => {
if (!lceLiveService.signedIn) return;
try {
const [reqs, invs] = await Promise.all([
lceLiveService.getPendingRequests(),
lceLiveService.getGameInvites()
]);
reqs.incoming.forEach((r: FriendRequest) => {
if (!seenRequests.current.has(r.accountId)) {
seenRequests.current.add(r.accountId);
setFriendRequestMessage(`New request from ${r.displayName}`);
}
});
invs.filter((i: GameInvite) => i.status === "pending").forEach((i: GameInvite) => {
if (!seenInvites.current.has(i.inviteId)) {
seenInvites.current.add(i.inviteId);
const fromName = typeof i.from === 'string' ? "Unknown" : i.from.displayName;
setGameInviteMessage(`${fromName} invited you to play!`);
}
});
} catch (e) { }
}, 10000);
};
init();
return () => {
if (pollInterval) clearInterval(pollInterval);
};
}, []);
return {
friendRequestMessage,
gameInviteMessage,
clearFriendRequestMessage: () => setFriendRequestMessage(null),
clearGameInviteMessage: () => setGameInviteMessage(null),
};
}

View File

@@ -0,0 +1,81 @@
import { useState, useEffect, useRef } from "react";
import { lceOnlineService } from "../services/LceOnlineService";
export function useLceOnlineNotifications() {
const [friendRequestMessage, setFriendRequestMessage] = useState<
string | null
>(null);
const [InviteMessage, setInviteMessage] = useState<string | null>(null);
const [invites, setInvites] = useState<
Array<{
inviteid: string;
from: { uuid: string; username: string };
sessionid: string;
}>
>([]);
const seenRequests = useRef<Set<string>>(new Set());
const seenInvites = useRef<Set<string>>(new Set());
useEffect(() => {
let pollInterval: ReturnType<typeof setInterval>;
const poll = async () => {
if (!lceOnlineService.signedIn) return;
try {
const lists = await lceOnlineService.getSocialLists();
lists.requests.forEach((r: string) => {
if (!seenRequests.current.has(r)) {
seenRequests.current.add(r);
setFriendRequestMessage(`New Friend request from ${r}`);
}
});
} catch (e) {}
try {
const invitesData = await lceOnlineService.getInvites();
setInvites(invitesData);
invitesData.forEach((i) => {
if (!seenInvites.current.has(i.inviteid)) {
seenInvites.current.add(i.inviteid);
setInviteMessage(`New invite from ${i.from.username}`);
}
});
} catch {}
};
const init = async () => {
if (lceOnlineService.signedIn) {
try {
const lists = await lceOnlineService.getSocialLists();
lists.requests.forEach((r: string) => {
if (!seenRequests.current.has(r)) {
seenRequests.current.add(r);
setFriendRequestMessage(`New Friend request from ${r}`);
}
});
} catch (e) {}
try {
const invitesData = await lceOnlineService.getInvites();
setInvites(invitesData);
invitesData.forEach((i) => {
if (!seenInvites.current.has(i.inviteid)) {
seenInvites.current.add(i.inviteid);
setInviteMessage(`New invite from ${i.from.username}`);
}
});
} catch {}
}
pollInterval = setInterval(poll, 3000);
};
init();
return () => {
if (pollInterval) clearInterval(pollInterval);
};
}, []);
return {
friendRequestMessage,
InviteMessage,
clearFriendRequestMessage: () => setFriendRequestMessage(null),
clearInviteMessage: () => setInviteMessage(null),
invites,
};
}

View File

@@ -18,7 +18,7 @@ import OptionsEditorView from "../components/views/OptionsEditorView";
import ModelEditorView from "../components/views/ModelEditorView";
import ScreenshotsView from "../components/views/ScreenshotsView";
import SwfView from "../components/views/SwfView";
import LceLiveView from "../components/views/LceLiveView";
import LceOnlineView from "../components/views/LceOnlineView";
import CreditsView from "../components/views/CreditsView";
import SkinViewer from "../components/common/SkinViewer";
import PanoramaBackground from "../components/common/PanoramaBackground";
@@ -35,7 +35,8 @@ import {
useSkin,
} from "../context/LauncherContext";
import { TauriService } from "../services/TauriService";
import { useLceLiveNotifications } from "../hooks/useLceLiveNotifications";
import { lceOnlineService } from "../services/LceOnlineService";
import { useLceOnlineNotifications } from "../hooks/useLceOnlineNotifications";
import { usePluginViews } from "../plugins/PluginContext";
import { usePlatform } from "../hooks/usePlatform";
import { PluginManager } from "../plugins/PluginManager";
@@ -67,12 +68,13 @@ export default function App() {
const game = useGame();
const skin = useSkin();
const { skinUrl, setSkinUrl, capeUrl } = skin;
const notifications = useLceLiveNotifications();
const notifications = useLceOnlineNotifications();
const {
friendRequestMessage,
gameInviteMessage,
InviteMessage,
clearFriendRequestMessage,
clearGameInviteMessage,
clearInviteMessage,
invites
} = notifications;
const [showSetup, setShowSetup] = useState(false);
const [isSetupChecked, setIsSetupChecked] = useState(false);
@@ -157,16 +159,22 @@ export default function App() {
return;
}
if (
action === "lcelive" &&
parts.length >= 2 &&
parts[1] === "addfriend"
) {
const username = parsed.searchParams.get("username");
if (username) {
setActiveView("lcelive");
setAddFriendTarget(username);
return;
if (action === "lceonline" && parts.length >= 2) {
if (parts[1] === "auth") {
const token = parsed.searchParams.get("token");
if (token) {
lceOnlineService.loginWithTokenAndFetchAccount(token);
setActiveView("lceonline");
return;
}
}
if (parts[1] === "addfriend") {
const username = parsed.searchParams.get("username");
if (username) {
setActiveView("lceonline");
setAddFriendTarget(username);
return;
}
}
}
@@ -614,11 +622,12 @@ export default function App() {
{activeView === "swf-editor" && (
<SwfView key="swf-editor-view" />
)}
{activeView === "lcelive" && (
<LceLiveView
key="lcelive-view"
{activeView === "lceonline" && (
<LceOnlineView
key="lceonline-view"
addFriendTarget={addFriendTarget}
onClearAddFriendTarget={() => setAddFriendTarget(null)}
invites={invites}
/>
)}
{activeView === "skins" && <SkinsView key="skins-view" />}
@@ -662,18 +671,18 @@ export default function App() {
onClose={clearFriendRequestMessage}
onClick={() => {
clearFriendRequestMessage();
setActiveView("lcelive");
setActiveView("lceonline");
}}
title="Friend Request"
variant="update"
/>
<AchievementToast
message={gameInviteMessage}
onClose={clearGameInviteMessage}
message={InviteMessage}
onClose={clearInviteMessage}
onClick={() => {
clearGameInviteMessage();
setActiveView("lcelive");
clearInviteMessage();
setActiveView("lceonline");
}}
title="Game Invite"
variant="update"

View File

@@ -1,384 +0,0 @@
const LOCAL_STORAGE_KEY = "lcelive_session";
const DEFAULT_BASE_URL = "https://api.lcelive.co.uk";
const MAX_REFRESH_RETRIES = 1;
import { TauriService } from "./TauriService";
export interface LceLiveAccount {
accountId: string;
username: string;
displayName: string;
}
export interface SessionData {
accessToken: string;
refreshToken: string;
account: LceLiveAccount;
}
export interface DeviceLinkStartResponse {
deviceCode: string;
userCode: string;
verificationUri: string;
verificationUriComplete?: string;
intervalSeconds: number;
expiresInSeconds?: number;
}
export interface DeviceLinkPollResponse {
status: string;
isLinked?: boolean;
accessToken?: string;
refreshToken?: string;
account?: LceLiveAccount;
}
export interface GameInvite {
inviteId: string;
from: LceLiveAccount | string;
hostIp: string;
hostPort: number;
hostName: string;
signalingSessionId?: string;
status: string;
}
export interface FriendRequest {
accountId: string;
username: string;
displayName: string;
}
export interface PendingRequests {
incoming: FriendRequest[];
outgoing: FriendRequest[];
}
export class LceLiveService {
private _session: SessionData | null = null;
private baseUrl: string = DEFAULT_BASE_URL;
private _refreshPromise: Promise<void> | null = null;
constructor() {
this.loadSession();
}
setBaseUrl(url: string) {
this.baseUrl = url.replace(/\/$/, "");
}
get signedIn(): boolean {
return this._session !== null;
}
get account(): LceLiveAccount | null {
return this._session?.account || null;
}
get displayUsername(): string {
if (!this._session) return "Not signed in";
return (
this._session.account.displayName ||
this._session.account.username ||
"Unknown"
);
}
get apiBaseUrl(): string {
return this.baseUrl;
}
get accessToken(): string | null {
return this._session?.accessToken || null;
}
public generateDeviceId(): string {
let id = localStorage.getItem("lcelive_device_id");
if (!id) {
id = crypto.randomUUID();
localStorage.setItem("lcelive_device_id", id);
}
return id;
}
public getDeviceName(): string {
return "LCE Emerald Launcher";
}
private loadSession() {
try {
const data = localStorage.getItem(LOCAL_STORAGE_KEY);
if (data) {
this._session = JSON.parse(data);
}
} catch (e) {
console.warn("Failed to load LceLive session", e);
}
}
private saveSession() {
if (this._session) {
localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(this._session));
} else {
localStorage.removeItem(LOCAL_STORAGE_KEY);
}
}
private async request<T = any>(
method: string,
path: string,
body?: unknown,
authed: boolean = true,
retryCount: number = 0,
): Promise<T> {
if (authed && this._session?.refreshToken && retryCount === 0) {
try {
await this.refreshSession(); //neo: i do this on every request only because it doesnt always return 401
} catch (err) {
this.logoutLocal();
throw new Error("Session expired. Please log in again.");
}
}
const headers: Record<string, string> = {
Accept: "application/json",
"User-Agent": "MCLCE-LceLive/1.0",
};
if (body) {
headers["Content-Type"] = "application/json";
}
if (authed && this._session?.accessToken) {
headers["Authorization"] = `Bearer ${this._session.accessToken}`;
}
const bodyStr = body ? JSON.stringify(body) : null;
let res;
try {
res = await TauriService.httpProxyRequest(
method,
`${this.baseUrl}${path}`,
bodyStr,
headers,
);
} catch (e) {
throw new Error(`Network error when calling ${path}: ${e}`);
}
if (
res.status === 401 &&
authed &&
this._session?.refreshToken &&
retryCount < MAX_REFRESH_RETRIES
) {
try {
await this.refreshSession();
return this.request(method, path, body, authed, retryCount + 1);
} catch (err) {
this.logoutLocal();
throw new Error("Session expired. Please log in again.");
}
}
let data;
try {
data = res.body ? JSON.parse(res.body) : {};
} catch {
data = { message: res.body };
}
if (res.status >= 400) {
const errorMsg =
data.message ||
data.detail ||
data.title ||
data.error ||
`HTTP ${res.status}`;
throw new Error(errorMsg);
}
return data;
}
async startDeviceLink(): Promise<DeviceLinkStartResponse> {
return this.request<DeviceLinkStartResponse>(
"POST",
"/api/auth/device/start",
{
deviceId: this.generateDeviceId(),
deviceName: this.getDeviceName(),
},
false,
);
}
async pollDeviceLink(deviceCode: string): Promise<DeviceLinkPollResponse> {
const data = await this.request<DeviceLinkPollResponse>(
"GET",
`/api/auth/device/poll/${deviceCode}`,
null,
false,
);
if (data.isLinked && data.accessToken) {
this._session = {
accessToken: data.accessToken,
refreshToken: data.refreshToken ?? "",
account: data.account ?? {
accountId: "",
username: "",
displayName: "",
},
};
this.saveSession();
}
return data;
}
async refreshSession(): Promise<void> {
if (!this._session?.refreshToken) throw new Error("No refresh token");
if (this._refreshPromise) {
return this._refreshPromise;
}
this._refreshPromise = (async () => {
try {
const data = await this.request(
"POST",
"/api/auth/refresh",
{
refreshToken: this._session!.refreshToken,
},
false,
);
this._session = {
accessToken: data.accessToken,
refreshToken: data.refreshToken,
account: data.account,
};
this.saveSession();
} finally {
this._refreshPromise = null;
}
})();
return this._refreshPromise;
}
async logout(): Promise<void> {
if (this._session?.refreshToken) {
try {
await this.request(
"POST",
"/api/auth/logout",
{
refreshToken: this._session.refreshToken,
},
true,
);
} catch (e) {
console.warn("Server logout failed", e);
}
}
this.logoutLocal();
}
logoutLocal(): void {
this._session = null;
this.saveSession();
}
async getFriends(): Promise<LceLiveAccount[]> {
const data = await this.request("GET", "/api/social/friends");
return data.friends || [];
}
async removeFriend(accountId: string): Promise<void> {
await this.request("DELETE", `/api/social/friends/${accountId}`);
}
async sendFriendRequest(username: string): Promise<void> {
await this.request("POST", "/api/social/request", { username });
}
async getPendingRequests(): Promise<PendingRequests> {
const data = await this.request("GET", "/api/social/requests");
return {
incoming: (data.incoming || []).map((r: Record<string, string>) => ({
accountId: r.requesterUserId || r.accountId || r.userId,
username: r.requesterUsername || r.username,
displayName: r.requesterDisplayName || r.displayName,
})),
outgoing: (data.outgoing || []).map((r: Record<string, string>) => ({
accountId: r.targetUserId || r.accountId || r.userId,
username: r.targetUsername || r.username,
displayName: r.targetDisplayName || r.displayName,
})),
};
}
async acceptFriendRequest(accountId: string): Promise<void> {
await this.request("POST", `/api/social/requests/${accountId}/accept`, {});
}
async declineFriendRequest(accountId: string): Promise<void> {
await this.request("POST", `/api/social/requests/${accountId}/decline`, {});
}
async getGameInvites(): Promise<GameInvite[]> {
const data = await this.request("GET", "/api/sessions/invites");
const incoming = data.incoming || [];
return incoming.map((inv: Record<string, unknown>) => ({
inviteId: inv.inviteId,
from: {
accountId: inv.senderAccountId,
username: inv.senderUsername,
displayName: inv.senderDisplayName,
},
hostIp: inv.hostIp,
hostPort: inv.hostPort,
hostName: inv.hostName,
status: inv.status,
signalingSessionId: inv.signalingSessionId,
}));
}
async sendGameInvite(
recipientAccountId: string,
hostIp: string,
hostPort: number,
hostName: string,
signalingSessionId?: string,
): Promise<void> {
await this.request("POST", "/api/sessions/invites", {
recipientAccountId,
hostIp,
hostPort,
hostName,
signalingSessionId,
});
}
async acceptGameInvite(inviteId: string): Promise<Record<string, unknown>> {
return this.request("POST", `/api/sessions/invites/${inviteId}/accept`, {});
}
async declineGameInvite(inviteId: string): Promise<void> {
await this.request("POST", `/api/sessions/invites/${inviteId}/decline`, {});
}
async deactivateGameInvites(): Promise<void> {
await this.request("POST", "/api/sessions/invites/deactivate", {});
}
async requestJoinTicket(): Promise<string> {
const data = await this.request("POST", "/api/sessions/ticket", {});
return data.ticket;
}
async validateJoinTicket(ticket: string): Promise<LceLiveAccount> {
return this.request("POST", "/api/sessions/validate", { ticket }, false);
}
}
export const lceLiveService = new LceLiveService();

View File

@@ -0,0 +1,282 @@
const SESSION_KEY = "lceonline_session";
const SOCIAL_BASE_URL = "https://social.mclegacyedition.xyz";
const AUTH_BASE_URL = "https://auth.mclegacyedition.xyz"; //neo: yeah bro im hardcoding all three
import { TauriService } from "./TauriService";
export interface LceOnlineAccount {
username: string;
displayName: string;
}
export interface SessionData {
accessToken: string;
account: LceOnlineAccount;
}
export interface FriendRequest {
username: string;
displayName: string;
}
export class LceOnlineService {
private _session: SessionData | null = null;
private baseUrl: string = SOCIAL_BASE_URL;
private _listeners: Array<() => void> = [];
constructor() {
this.loadSession();
}
onSessionChange(listener: () => void): () => void {
this._listeners.push(listener);
return () => {
this._listeners = this._listeners.filter((l) => l !== listener);
};
}
private _notify() {
for (const l of this._listeners) l();
}
get signedIn(): boolean {
return this._session !== null;
}
get account(): LceOnlineAccount | null {
return this._session?.account || null;
}
get displayUsername(): string {
if (!this._session) return "Not signed in";
return (
this._session.account.displayName ||
this._session.account.username ||
"Unknown"
);
}
get accessToken(): string | null {
return this._session?.accessToken || null;
}
logoutLocal(): void {
this._session = null;
this.saveSession();
this._notify();
}
async login(username: string, password: string): Promise<void> {
const res = await this.request<string>(
"POST",
"/login",
`${username}:${password}`,
AUTH_BASE_URL,
);
const text = typeof res === "string" ? res : "";
if (!text.startsWith("-")) {
throw new Error(text || "Login failed");
}
const token = text.split(":")[1];
await this.loginWithTokenAndFetchAccount(token);
}
async register(username: string, password: string): Promise<void> {
const res = await this.request<string>(
"POST",
"/register",
`${username}:${password}`,
AUTH_BASE_URL,
);
const text = typeof res === "string" ? res : "";
if (!text.startsWith("-")) {
throw new Error(text || "Registration failed");
}
const token = text.split(":")[1];
await this.loginWithTokenAndFetchAccount(token);
}
loginWithToken(token: string, username?: string) {
const name = username || "Player";
this._session = {
accessToken: token,
account: { username: name, displayName: name },
};
this.saveSession();
this._notify();
}
async loginWithTokenAndFetchAccount(token: string): Promise<void> {
this._session = {
accessToken: token,
account: { username: "Player", displayName: "Player" },
};
this.saveSession();
this._notify();
try {
const raw: string = await this.request<string>("POST", "/accountinfo");
if (typeof raw === "string" && raw.startsWith("-")) {
const username = raw.slice(1);
this._session!.account = { username, displayName: username };
this.saveSession();
this._notify();
}
} catch (e) {
console.warn("Failed to fetch account info", e);
}
}
private loadSession() {
try {
const data = localStorage.getItem(SESSION_KEY);
if (data) {
this._session = JSON.parse(data);
}
} catch (e) {
console.warn("Failed to load LCE Online session", e);
}
}
private saveSession() {
if (this._session) {
localStorage.setItem(SESSION_KEY, JSON.stringify(this._session));
} else {
localStorage.removeItem(SESSION_KEY);
}
}
private async request<T = any>(
method: string,
path: string,
body?: string | null,
baseUrl?: string,
): Promise<T> {
const headers: Record<string, string> = {
Accept: "text/plain, application/json",
"User-Agent": "MCLCE-LCEOnline/1.0",
};
if (body) {
headers["Content-Type"] = "text/plain";
}
if (this._session?.accessToken) {
headers["Authorization"] = `Bearer ${this._session.accessToken}`;
}
const url = `${baseUrl || this.baseUrl}${path}`;
let res;
try {
res = await TauriService.httpProxyRequest(
method,
url,
body ?? null,
headers,
);
} catch (e) {
throw new Error(`Network error when calling ${path}: ${e}`);
}
let data;
try {
data = res.body ? JSON.parse(res.body) : {};
} catch {
data = res.body ?? {};
}
if (res.status >= 400) {
const errorMsg =
data.message ||
data.detail ||
data.title ||
data.error ||
data ||
`HTTP ${res.status}`;
throw new Error(errorMsg);
}
return data;
}
async getSocialLists(): Promise<{
friends: string[];
requests: string[];
blocked: string[];
}> {
const raw: string = await this.request<string>(
"POST",
"/getSocialLists",
null,
);
if (typeof raw !== "string") {
return { friends: [], requests: [], blocked: [] };
}
const withoutPrefix = raw.startsWith("-") ? raw.slice(1) : raw;
const parts = withoutPrefix.split("|");
return {
friends: parts[0] ? parts[0].split(",").filter(Boolean) : [],
requests: parts[1] ? parts[1].split(",").filter(Boolean) : [],
blocked: parts[2] ? parts[2].split(",").filter(Boolean) : [],
};
}
async sendFriendRequest(target: string): Promise<void> {
const res = await this.request<string>("POST", "/sendrequest", target);
if (typeof res === "string" && res !== "Successfully Sent Friend Request") {
throw new Error(res);
}
}
async acceptFriendRequest(from: string): Promise<void> {
const res = await this.request<string>("POST", "/acceptrequest", from);
if (typeof res === "string" && res !== "1") {
throw new Error(res);
}
}
async declineFriendRequest(from: string): Promise<void> {
const res = await this.request<string>("POST", "/declinerequest", from);
if (typeof res === "string" && res !== "1") {
throw new Error(res);
}
}
async removeFriend(from: string): Promise<void> {
const res = await this.request<string>("POST", "/removefriend", from);
if (typeof res === "string") {
throw new Error(res);
}
}
async sendInvite(target: string): Promise<void> {
const res = await this.request<string>("POST", "/invite", target);
if (typeof res === "string" && res !== "Invite Sent") {
throw new Error(res);
}
}
async acceptInvite(from: string): Promise<string> {
const res = await this.request<string>("POST", "/acceptinvite", from);
if (typeof res !== "string") throw new Error("Failed to accept invite");
return res;
}
async declineInvite(from: string): Promise<void> {
try {
await this.request("POST", "/declineinvite", from);
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : "";
if (msg !== "Declined Invite") throw e;
}
}
async getInvites(): Promise<
Array<{
inviteid: string;
from: { uuid: string; username: string };
sessionid: string;
}>
> {
const res = await this.request<string>("GET", "/getinvites", null);
return Array.isArray(res) ? res : [];
}
}
export const lceOnlineService = new LceOnlineService();

View File

@@ -300,31 +300,19 @@ export class TauriService {
return invoke("stun_discover");
}
static async startDirectProxy(
targetIp: string,
targetPort: number,
): Promise<number> {
return invoke("start_direct_proxy", { targetIp, targetPort });
}
static async startRelayProxy(
apiBaseUrl: string,
accessToken: string,
sessionId: string,
authToken: string,
targetSession: string,
): Promise<number> {
return invoke("start_relay_proxy", { apiBaseUrl, accessToken, sessionId });
return invoke("start_relay_proxy", { authToken, targetSession });
}
static async startHostRelay(
apiBaseUrl: string,
accessToken: string,
sessionId: string,
authToken: string,
gamePort: number,
): Promise<void> {
return invoke("start_host_relay", {
apiBaseUrl,
accessToken,
sessionId,
authToken,
gamePort,
});
}
@@ -339,18 +327,18 @@ export class TauriService {
static async joinGame(
apiBaseUrl: string,
accessToken: string,
authToken: string,
hostIp: string,
hostPort: number,
sessionId: string,
targetSession: string,
instanceId: string,
): Promise<void> {
return invoke("join_game", {
apiBaseUrl,
accessToken,
authToken,
hostIp,
hostPort,
sessionId,
targetSession,
instanceId,
});
}

View File

@@ -13,6 +13,7 @@ export interface Edition {
comingSoon?: boolean;
category?: string[];
officialDLC?: string;
lceOnline?: boolean;
}
export interface CustomEditionInput {