diff --git a/public/images/lce_online.png b/public/images/lce_online.png new file mode 100644 index 0000000..6bc4424 Binary files /dev/null and b/public/images/lce_online.png differ diff --git a/public/images/lcelive.png b/public/images/lcelive.png deleted file mode 100644 index 47345a6..0000000 Binary files a/public/images/lcelive.png and /dev/null differ diff --git a/public/panorama/hellishends_Panorama_Background_Day.png b/public/panorama/hellishends_Panorama_Background_Day.png index e856bf1..7220025 100644 Binary files a/public/panorama/hellishends_Panorama_Background_Day.png and b/public/panorama/hellishends_Panorama_Background_Day.png differ diff --git a/public/panorama/hellishends_Panorama_Background_Night.png b/public/panorama/hellishends_Panorama_Background_Night.png index e856bf1..7220025 100644 Binary files a/public/panorama/hellishends_Panorama_Background_Night.png and b/public/panorama/hellishends_Panorama_Background_Night.png differ diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 09aca54..636f89a 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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, diff --git a/src-tauri/src/networking/direct.rs b/src-tauri/src/networking/direct.rs deleted file mode 100644 index 29eaf1b..0000000 --- a/src-tauri/src/networking/direct.rs +++ /dev/null @@ -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 { - 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 { - 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) -} diff --git a/src-tauri/src/networking/mod.rs b/src-tauri/src/networking/mod.rs index 2e65f2d..6261c84 100644 --- a/src-tauri/src/networking/mod.rs +++ b/src-tauri/src/networking/mod.rs @@ -1,3 +1,2 @@ pub mod stun; pub mod relay; -pub mod direct; diff --git a/src-tauri/src/networking/relay.rs b/src-tauri/src/networking/relay.rs index ef1ed60..adbc7e6 100644 --- a/src-tauri/src/networking/relay.rs +++ b/src-tauri/src/networking/relay.rs @@ -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 { - 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 { + 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 { - 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 { + 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 { diff --git a/src-tauri/src/util.rs b/src-tauri/src/util.rs index 576a70e..774bc9a 100644 --- a/src-tauri/src/util.rs +++ b/src-tauri/src/util.rs @@ -44,14 +44,6 @@ pub fn copy_dir_all(src: impl AsRef, dst: impl AsRef 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(); diff --git a/src/components/common/SkinViewer.tsx b/src/components/common/SkinViewer.tsx index 33d0f85..a2491c6 100644 --- a/src/components/common/SkinViewer.tsx +++ b/src/components/common/SkinViewer.tsx @@ -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" > LCELive diff --git a/src/components/modals/ChooseInstanceModal.tsx b/src/components/modals/ChooseInstanceModal.tsx index 637069d..39a3e0d 100644 --- a/src/components/modals/ChooseInstanceModal.tsx +++ b/src/components/modals/ChooseInstanceModal.tsx @@ -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(""); 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; - const fromIp = typeof invite.from !== 'string' ? (invite.from as unknown as Record).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 ( {validInstances.length > 0 ? ( -
+
{validInstances.map((inst: Edition) => { const isSelected = selectedInstance === inst.instanceId; return (
{ 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" }} > -
- {isSelected &&
} +
+ {isSelected && ( +
+ )}
- {inst.name} + + {inst.name} + {inst.selectedBranch && ( - {inst.selectedBranch} + + {inst.selectedBranch} + )}
@@ -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({
-

{status}

+

+ {status} +

{error && (
diff --git a/src/components/views/CreditsView.tsx b/src/components/views/CreditsView.tsx index a0730ac..8599c22 100644 --- a/src/components/views/CreditsView.tsx +++ b/src/components/views/CreditsView.tsx @@ -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; \ No newline at end of file diff --git a/src/components/views/LceLiveView.tsx b/src/components/views/LceOnlineView.tsx similarity index 53% rename from src/components/views/LceLiveView.tsx rename to src/components/views/LceOnlineView.tsx index 36bff36..01cc762 100644 --- a/src/components/views/LceLiveView.tsx +++ b/src/components/views/LceOnlineView.tsx @@ -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(0); - const [acceptInvite, setAcceptInvite] = useState(null); - const [friends, setFriends] = useState([]); - const [incomingReqs, setIncomingReqs] = useState([]); - const [outgoingReqs, setOutgoingReqs] = useState([]); - const [invites, setInvites] = useState([]); - const [linkData, setLinkData] = useState( - null, - ); - const [linkError, setLinkError] = useState(null); + const [friends, setFriends] = useState([]); + const [incomingReqs, setIncomingReqs] = useState([]); + const [outgoingReqs, setOutgoingReqs] = useState([]); + 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>(new Set()); - const [showHostMethodPicker, setShowHostMethodPicker] = useState(false); const [isAddingFriend, setIsAddingFriend] = useState(false); const [addFriendUsername, setAddFriendUsername] = useState(""); const addFriendInputRef = useRef(null); const [errorModal, setErrorModal] = useState(null); - const [qrDataUrl, setQrDataUrl] = useState(""); + const [joinTarget, setJoinTarget] = useState<{ + inviteid: string; + sessionId: string; + hostName: string; + } | null>(null); const containerRef = useRef(null); const scrollRef = useRef(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 | 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) => { @@ -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(() => { 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 (
- {!linkData ? ( -

- {linkError || "Starting device link..."} -

- ) : ( -
-
-

- Open this link in your browser: -

-

- {linkData.verificationUri} -

-

- And enter the code: -

-

- {linkData.userCode} -

-
-
- {qrDataUrl && ( - QR Code - )} - -
-
- )} +

+ LCE Online +

+

+ Awaiting authentication... +

); } @@ -617,10 +397,10 @@ const LceLiveView = memo(function LceLiveView({
{currentTab === "friends" - ? "Joinable Friends" - : currentTab === "requests" - ? "Pending Requests" - : "Game Invites"} + ? "Friends" + : currentTab === "invites" + ? "Invites" + : "Pending Requests"} {listItems.length}
@@ -648,59 +428,28 @@ const LceLiveView = memo(function LceLiveView({ {item.label} - - @ - {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"} -
- {item.type === "friend" && !isHosting && ( - - )} - {item.type === "friend" && isHosting && ( + {item.type === "friend" && ( <> - + {item.onClickSecondary && ( + + )} @@ -732,8 +478,38 @@ const LceLiveView = memo(function LceLiveView({ CANCEL )} - {(item.type === "request_in" || - item.type === "invite") && ( + {item.type === "invite" && ( + <> + + + + )} + {item.type === "request_in" && ( <> - - -
- - )} - - {errorModal && ( )} - - { - setAcceptInvite(null); - fetchSocialData(); - }} - playPressSound={playPressSound} - playBackSound={playBackSound} - editions={editions} - installs={installs} - invite={acceptInvite} - /> + {joinTarget && ( + 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", + }} + /> + )} ); }); -export default LceLiveView; +export default LceOnlineView; diff --git a/src/components/views/WorkshopView.tsx b/src/components/views/WorkshopView.tsx index d2c5e33..831f27c 100644 --- a/src/components/views/WorkshopView.tsx +++ b/src/components/views/WorkshopView.tsx @@ -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
- + {pkg.extended_description}
@@ -1685,7 +1683,9 @@ function PackageModal({
{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 ( {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 ( 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" diff --git a/src/data/splashes.ts b/src/data/splashes.ts index fdcb0dc..65db7a7 100644 --- a/src/data/splashes.ts +++ b/src/data/splashes.ts @@ -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", ]; \ No newline at end of file diff --git a/src/hooks/useDiscordRPC.ts b/src/hooks/useDiscordRPC.ts index e437d39..343234a 100644 --- a/src/hooks/useDiscordRPC.ts +++ b/src/hooks/useDiscordRPC.ts @@ -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", diff --git a/src/hooks/useGameManager.ts b/src/hooks/useGameManager.ts index 9b3d80e..944b00f 100644 --- a/src/hooks/useGameManager.ts +++ b/src/hooks/useGameManager.ts @@ -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); diff --git a/src/hooks/useLceLiveNotifications.ts b/src/hooks/useLceLiveNotifications.ts deleted file mode 100644 index 6f8140a..0000000 --- a/src/hooks/useLceLiveNotifications.ts +++ /dev/null @@ -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(null); - const [gameInviteMessage, setGameInviteMessage] = useState(null); - const seenRequests = useRef>(new Set()); - const seenInvites = useRef>(new Set()); - useEffect(() => { - let pollInterval: ReturnType; - 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), - }; -} diff --git a/src/hooks/useLceOnlineNotifications.ts b/src/hooks/useLceOnlineNotifications.ts new file mode 100644 index 0000000..ad45415 --- /dev/null +++ b/src/hooks/useLceOnlineNotifications.ts @@ -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(null); + const [invites, setInvites] = useState< + Array<{ + inviteid: string; + from: { uuid: string; username: string }; + sessionid: string; + }> + >([]); + const seenRequests = useRef>(new Set()); + const seenInvites = useRef>(new Set()); + useEffect(() => { + let pollInterval: ReturnType; + + 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, + }; +} diff --git a/src/pages/App.tsx b/src/pages/App.tsx index bb47a53..a8a32af 100644 --- a/src/pages/App.tsx +++ b/src/pages/App.tsx @@ -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" && ( )} - {activeView === "lcelive" && ( - setAddFriendTarget(null)} + invites={invites} /> )} {activeView === "skins" && } @@ -662,18 +671,18 @@ export default function App() { onClose={clearFriendRequestMessage} onClick={() => { clearFriendRequestMessage(); - setActiveView("lcelive"); + setActiveView("lceonline"); }} title="Friend Request" variant="update" /> { - clearGameInviteMessage(); - setActiveView("lcelive"); + clearInviteMessage(); + setActiveView("lceonline"); }} title="Game Invite" variant="update" diff --git a/src/services/LceLiveService.ts b/src/services/LceLiveService.ts deleted file mode 100644 index 39eeae8..0000000 --- a/src/services/LceLiveService.ts +++ /dev/null @@ -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 | 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( - method: string, - path: string, - body?: unknown, - authed: boolean = true, - retryCount: number = 0, - ): Promise { - 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 = { - 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 { - return this.request( - "POST", - "/api/auth/device/start", - { - deviceId: this.generateDeviceId(), - deviceName: this.getDeviceName(), - }, - false, - ); - } - - async pollDeviceLink(deviceCode: string): Promise { - const data = await this.request( - "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 { - 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 { - 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 { - const data = await this.request("GET", "/api/social/friends"); - return data.friends || []; - } - - async removeFriend(accountId: string): Promise { - await this.request("DELETE", `/api/social/friends/${accountId}`); - } - - async sendFriendRequest(username: string): Promise { - await this.request("POST", "/api/social/request", { username }); - } - - async getPendingRequests(): Promise { - const data = await this.request("GET", "/api/social/requests"); - return { - incoming: (data.incoming || []).map((r: Record) => ({ - accountId: r.requesterUserId || r.accountId || r.userId, - username: r.requesterUsername || r.username, - displayName: r.requesterDisplayName || r.displayName, - })), - outgoing: (data.outgoing || []).map((r: Record) => ({ - accountId: r.targetUserId || r.accountId || r.userId, - username: r.targetUsername || r.username, - displayName: r.targetDisplayName || r.displayName, - })), - }; - } - - async acceptFriendRequest(accountId: string): Promise { - await this.request("POST", `/api/social/requests/${accountId}/accept`, {}); - } - - async declineFriendRequest(accountId: string): Promise { - await this.request("POST", `/api/social/requests/${accountId}/decline`, {}); - } - - async getGameInvites(): Promise { - const data = await this.request("GET", "/api/sessions/invites"); - const incoming = data.incoming || []; - return incoming.map((inv: Record) => ({ - 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 { - await this.request("POST", "/api/sessions/invites", { - recipientAccountId, - hostIp, - hostPort, - hostName, - signalingSessionId, - }); - } - - async acceptGameInvite(inviteId: string): Promise> { - return this.request("POST", `/api/sessions/invites/${inviteId}/accept`, {}); - } - - async declineGameInvite(inviteId: string): Promise { - await this.request("POST", `/api/sessions/invites/${inviteId}/decline`, {}); - } - - async deactivateGameInvites(): Promise { - await this.request("POST", "/api/sessions/invites/deactivate", {}); - } - - async requestJoinTicket(): Promise { - const data = await this.request("POST", "/api/sessions/ticket", {}); - return data.ticket; - } - - async validateJoinTicket(ticket: string): Promise { - return this.request("POST", "/api/sessions/validate", { ticket }, false); - } -} - -export const lceLiveService = new LceLiveService(); diff --git a/src/services/LceOnlineService.ts b/src/services/LceOnlineService.ts new file mode 100644 index 0000000..85d0748 --- /dev/null +++ b/src/services/LceOnlineService.ts @@ -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 { + const res = await this.request( + "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 { + const res = await this.request( + "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 { + this._session = { + accessToken: token, + account: { username: "Player", displayName: "Player" }, + }; + this.saveSession(); + this._notify(); + try { + const raw: string = await this.request("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( + method: string, + path: string, + body?: string | null, + baseUrl?: string, + ): Promise { + const headers: Record = { + 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( + "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 { + const res = await this.request("POST", "/sendrequest", target); + if (typeof res === "string" && res !== "Successfully Sent Friend Request") { + throw new Error(res); + } + } + + async acceptFriendRequest(from: string): Promise { + const res = await this.request("POST", "/acceptrequest", from); + if (typeof res === "string" && res !== "1") { + throw new Error(res); + } + } + + async declineFriendRequest(from: string): Promise { + const res = await this.request("POST", "/declinerequest", from); + if (typeof res === "string" && res !== "1") { + throw new Error(res); + } + } + + async removeFriend(from: string): Promise { + const res = await this.request("POST", "/removefriend", from); + if (typeof res === "string") { + throw new Error(res); + } + } + + async sendInvite(target: string): Promise { + const res = await this.request("POST", "/invite", target); + if (typeof res === "string" && res !== "Invite Sent") { + throw new Error(res); + } + } + + async acceptInvite(from: string): Promise { + const res = await this.request("POST", "/acceptinvite", from); + if (typeof res !== "string") throw new Error("Failed to accept invite"); + return res; + } + + async declineInvite(from: string): Promise { + 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("GET", "/getinvites", null); + return Array.isArray(res) ? res : []; + } +} + +export const lceOnlineService = new LceOnlineService(); diff --git a/src/services/TauriService.ts b/src/services/TauriService.ts index c96c759..cbfbc37 100644 --- a/src/services/TauriService.ts +++ b/src/services/TauriService.ts @@ -300,31 +300,19 @@ export class TauriService { return invoke("stun_discover"); } - static async startDirectProxy( - targetIp: string, - targetPort: number, - ): Promise { - return invoke("start_direct_proxy", { targetIp, targetPort }); - } - static async startRelayProxy( - apiBaseUrl: string, - accessToken: string, - sessionId: string, + authToken: string, + targetSession: string, ): Promise { - 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 { 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 { return invoke("join_game", { apiBaseUrl, - accessToken, + authToken, hostIp, hostPort, - sessionId, + targetSession, instanceId, }); } diff --git a/src/types/edition.ts b/src/types/edition.ts index 7c8a538..5ade209 100644 --- a/src/types/edition.ts +++ b/src/types/edition.ts @@ -13,6 +13,7 @@ export interface Edition { comingSoon?: boolean; category?: string[]; officialDLC?: string; + lceOnline?: boolean; } export interface CustomEditionInput {