From b2e3b2a32256ec2502432dcbecb14d29f07c1ab5 Mon Sep 17 00:00:00 2001 From: neoapps-dev Date: Fri, 10 Jul 2026 15:11:51 +0300 Subject: [PATCH] feat: LCEOnline instead of LCELive --- public/images/lcelive.png | Bin 40669 -> 0 bytes src/components/common/SkinViewer.tsx | 8 +- src/components/modals/ChooseInstanceModal.tsx | 108 ++- .../{LceLiveView.tsx => LceOnlineView.tsx} | 673 +++++------------- src/hooks/useDiscordRPC.ts | 2 +- src/hooks/useLceLiveNotifications.ts | 66 -- src/hooks/useLceOnlineNotifications.ts | 41 ++ src/pages/App.tsx | 30 +- src/services/LceLiveService.ts | 384 ---------- src/services/LceOnlineService.ts | 209 ++++++ 10 files changed, 499 insertions(+), 1022 deletions(-) delete mode 100644 public/images/lcelive.png rename src/components/views/{LceLiveView.tsx => LceOnlineView.tsx} (50%) delete mode 100644 src/hooks/useLceLiveNotifications.ts create mode 100644 src/hooks/useLceOnlineNotifications.ts delete mode 100644 src/services/LceLiveService.ts create mode 100644 src/services/LceOnlineService.ts diff --git a/public/images/lcelive.png b/public/images/lcelive.png deleted file mode 100644 index 47345a671da109b157fadbabcd8682076e69b7b6..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 40669 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 bSs_7rd0AQ6u0g_=y;n-|Ah{YD(~$oHuA{n9 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 1592e4d..e10925b 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 | { displayName: string }; + hostIp: string; + hostPort: number; + hostName: string; + signalingSessionId?: 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,29 +60,33 @@ 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.signalingSessionId || ""; if (sessionId) { setStatus("Connecting via relay..."); - const accessToken = lceLiveService.accessToken ?? ""; + const accessToken = lceOnlineService.accessToken ?? ""; await 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"], ); } 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(); @@ -102,13 +114,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(); } } @@ -118,8 +135,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.from.displayName + : ""; 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} + )}
@@ -189,10 +225,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: (() => { @@ -213,9 +254,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", }} @@ -232,7 +274,9 @@ export default function ChooseInstanceModal({
-

{status}

+

+ {status} +

{error && (
diff --git a/src/components/views/LceLiveView.tsx b/src/components/views/LceOnlineView.tsx similarity index 50% rename from src/components/views/LceLiveView.tsx rename to src/components/views/LceOnlineView.tsx index 5372e0b..b7e7bb5 100644 --- a/src/components/views/LceLiveView.tsx +++ b/src/components/views/LceOnlineView.tsx @@ -1,74 +1,49 @@ -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, - useConfig, - useAudio, - useGame, -} from "../../context/LauncherContext"; -import { - lceLiveService, - LceLiveAccount, - FriendRequest, - GameInvite, - DeviceLinkStartResponse, -} from "../../services/LceLiveService"; +import { useUI, useConfig, useAudio } from "../../context/LauncherContext"; +import { lceOnlineService } from "../../services/LceOnlineService"; import { TauriService } from "../../services/TauriService"; -import ChooseInstanceModal from "../modals/ChooseInstanceModal"; -import QRCode from "qrcode"; - -interface LceLiveViewProps { +interface LceOnlineViewProps { addFriendTarget?: string | null; onClearAddFriendTarget?: () => void; } -const LceLiveView = memo(function LceLiveView({ +const LceOnlineView = memo(function LceOnlineView({ addFriendTarget, onClearAddFriendTarget, -}: LceLiveViewProps) { +}: LceOnlineViewProps) { const { setActiveView } = useUI(); const { animationsEnabled } = useConfig(); const { playPressSound, playBackSound } = useAudio(); - const { editions, installs } = useGame(); - const [isSignedIn, setIsSignedIn] = useState(lceLiveService.signedIn); - const [currentTab, setCurrentTab] = useState< - "friends" | "requests" | "invites" | "device_link" - >("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 [isSignedIn, setIsSignedIn] = useState(lceOnlineService.signedIn); + const [currentTab, setCurrentTab] = useState<"friends" | "requests">( + "friends", ); - const [linkError, setLinkError] = useState(null); + const [focusIndex, setFocusIndex] = useState(0); + const [friends, setFriends] = useState([]); + const [incomingReqs, setIncomingReqs] = useState([]); + const [outgoingReqs, setOutgoingReqs] = useState([]); 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 [authMode, setAuthMode] = useState<"login" | "register">("login"); + const [authUsername, setAuthUsername] = useState(""); + const [authPassword, setAuthPassword] = useState(""); + const [isAuthLoading, setIsAuthLoading] = useState(false); + const authUserRef = useRef(null); + const authPassRef = useRef(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 +51,21 @@ 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)); - } - }; - - 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), - ); - } - - 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 handleAction = async (action: () => Promise) => { @@ -166,54 +78,15 @@ const LceLiveView = memo(function LceLiveView({ } }; - const handleStartHosting = () => { + const handleStartHosting = async () => { playPressSound(); - setShowHostMethodPicker(true); - setFocusIndex(0); - }; - - const handleHostDirect = async () => { - setShowHostMethodPicker(false); - setFocusIndex(0); setIsDiscovering(true); - setHostStatus("Discovering external IP..."); + setHostStatus("Starting relay..."); 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); - } + await TauriService.stunDiscover(); + } catch {} setIsHosting(true); - setHostStatus("Relay ready - invite friends to activate"); - setInvitedFriends(new Set()); + setHostStatus("Relay ready"); setIsDiscovering(false); }; @@ -221,59 +94,33 @@ const LceLiveView = memo(function LceLiveView({ 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 hostUsername = lceLiveService.account?.username ?? name; + const handleAuth = async () => { + if (!authUsername.trim() || !authPassword.trim()) return; + setIsAuthLoading(true); try { - await lceLiveService.sendGameInvite( - friend.accountId, - hostIp, - hostPort, - name, - hostUsername, - ); - setInvitedFriends((prev) => new Set(prev).add(friend.accountId)); - setHostStatus("Connecting relay..."); - TauriService.startHostRelay( - lceLiveService.accessToken ?? "", - 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"); - }); + if (authMode === "login") { + await lceOnlineService.login(authUsername.trim(), authPassword); + } else { + await lceOnlineService.register(authUsername.trim(), authPassword); + } + setIsSignedIn(true); } catch (e: unknown) { - const msg = - e instanceof Error - ? e.message - : typeof e === "string" - ? e - : "Unknown error"; - setErrorModal("Failed to send invite: " + msg); + setErrorModal(e instanceof Error ? e.message : "Authentication failed"); + } finally { + setIsAuthLoading(false); } }; type MenuItem = { id: string; - type: "button" | "friend" | "request_in" | "request_out" | "invite"; + type: "button" | "friend" | "request_in" | "request_out"; label: string; onClick: () => void; onClickSecondary?: () => void; @@ -281,20 +128,8 @@ const LceLiveView = memo(function LceLiveView({ const menuItems = useMemo(() => { const items: MenuItem[] = []; - if (currentTab === "device_link") { - if (linkData) { - items.push({ - id: "link_retry", - type: "button", - label: "Restart Link", - onClick: () => { - setLinkData(null); - playPressSound(); - }, - }); - } - } else if (currentTab === "friends") { - if (!isDiscovering && !showHostMethodPicker) { + if (currentTab === "friends") { + if (!isDiscovering) { if (!isHosting) { items.push({ id: "host_game", @@ -329,56 +164,31 @@ 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)), - onClickSecondary: isHosting - ? () => handleAction(() => lceLiveService.removeFriend(f.accountId)) - : undefined, + label: f, + onClick: () => handleAction(() => lceOnlineService.removeFriend(f)), }); }); } 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), - ), - }); - }); - } else if (currentTab === "invites") { - invites.forEach((inv) => { - items.push({ - id: `inv_${inv.inviteId}`, - type: "invite", - label: - typeof inv.from === "string" ? "Unknown" : inv.from.displayName, - onClick: () => { - playPressSound(); - setAcceptInvite(inv); - }, - onClickSecondary: () => - handleAction(() => lceLiveService.declineGameInvite(inv.inviteId)), + handleAction(() => lceOnlineService.declineFriendRequest(r)), }); }); } @@ -389,28 +199,12 @@ const LceLiveView = memo(function LceLiveView({ friends, 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", - "invites", - ]; + const tabs: ("friends" | "requests")[] = ["friends", "requests"]; useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (errorModal) { @@ -427,7 +221,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); } @@ -435,23 +229,24 @@ 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; + } + if (e.key === "Tab") { + e.preventDefault(); + if (document.activeElement === authUserRef.current) { + authPassRef.current?.focus(); + } else { + authUserRef.current?.focus(); } + return; + } + if (e.key === "Enter") { + handleAuth(); + return; } return; } @@ -462,22 +257,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; @@ -506,9 +299,10 @@ const LceLiveView = memo(function LceLiveView({ isAddingFriend, addFriendUsername, errorModal, - showHostMethodPicker, - handleHostDirect, - handleHostRelay, + handleAuth, + isSignedIn, + authUsername, + authPassword, ]); useEffect(() => { @@ -530,52 +324,65 @@ 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 - )} - -
-
- )} +

+ {authMode === "login" ? "Sign In" : "Register"} +

+ setAuthUsername(e.target.value)} + disabled={isAuthLoading} + /> + setAuthPassword(e.target.value)} + disabled={isAuthLoading} + /> +
+ + +
); } @@ -614,11 +421,7 @@ const LceLiveView = memo(function LceLiveView({
- {currentTab === "friends" - ? "Joinable Friends" - : currentTab === "requests" - ? "Pending Requests" - : "Game Invites"} + {currentTab === "friends" ? "Friends" : "Pending Requests"} {listItems.length}
@@ -646,28 +449,10 @@ 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" && ( - - - )} {item.type === "request_out" && ( ))} @@ -840,14 +583,6 @@ const LceLiveView = memo(function LceLiveView({ > {renderContent()}
-
- LCELive TauriService.openUrl("https://lcelive.co.uk")} - /> -
@@ -889,7 +624,7 @@ const LceLiveView = memo(function LceLiveView({ playPressSound(); if (addFriendUsername.trim() !== "") { handleAction(() => - lceLiveService.sendFriendRequest( + lceOnlineService.sendFriendRequest( addFriendUsername.trim(), ), ); @@ -918,83 +653,6 @@ const LceLiveView = memo(function LceLiveView({ )} - - - {showHostMethodPicker && ( - -
-

- Host Game -

- - - -
-
- )} -
- {errorModal && ( )} - - { - setAcceptInvite(null); - fetchSocialData(); - }} - playPressSound={playPressSound} - playBackSound={playBackSound} - editions={editions} - installs={installs} - invite={acceptInvite} - /> ); }); -export default LceLiveView; +export default LceOnlineView; 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/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..b54fc60 --- /dev/null +++ b/src/hooks/useLceOnlineNotifications.ts @@ -0,0 +1,41 @@ +import { useState, useEffect, useRef } from "react"; +import { lceOnlineService } from "../services/LceOnlineService"; +export function useLceOnlineNotifications() { + const [friendRequestMessage, setFriendRequestMessage] = useState(null); + const seenRequests = useRef>(new Set()); + useEffect(() => { + let pollInterval: ReturnType; + + const init = async () => { + if (lceOnlineService.signedIn) { + try { + const lists = await lceOnlineService.getSocialLists(); + lists.requests.forEach((r: string) => seenRequests.current.add(r)); + } catch (e) { } + } + + pollInterval = setInterval(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 request from ${r}`); + } + }); + } catch (e) { } + }, 10000); + }; + + init(); + return () => { + if (pollInterval) clearInterval(pollInterval); + }; + }, []); + + return { + friendRequestMessage, + clearFriendRequestMessage: () => setFriendRequestMessage(null), + }; +} diff --git a/src/pages/App.tsx b/src/pages/App.tsx index bb47a53..b501e3f 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,7 @@ import { useSkin, } from "../context/LauncherContext"; import { TauriService } from "../services/TauriService"; -import { useLceLiveNotifications } from "../hooks/useLceLiveNotifications"; +import { useLceOnlineNotifications } from "../hooks/useLceOnlineNotifications"; import { usePluginViews } from "../plugins/PluginContext"; import { usePlatform } from "../hooks/usePlatform"; import { PluginManager } from "../plugins/PluginManager"; @@ -67,12 +67,10 @@ export default function App() { const game = useGame(); const skin = useSkin(); const { skinUrl, setSkinUrl, capeUrl } = skin; - const notifications = useLceLiveNotifications(); + const notifications = useLceOnlineNotifications(); const { friendRequestMessage, - gameInviteMessage, clearFriendRequestMessage, - clearGameInviteMessage, } = notifications; const [showSetup, setShowSetup] = useState(false); const [isSetupChecked, setIsSetupChecked] = useState(false); @@ -158,13 +156,13 @@ export default function App() { } if ( - action === "lcelive" && + action === "lceonline" && parts.length >= 2 && parts[1] === "addfriend" ) { const username = parsed.searchParams.get("username"); if (username) { - setActiveView("lcelive"); + setActiveView("lceonline"); setAddFriendTarget(username); return; } @@ -614,9 +612,9 @@ export default function App() { {activeView === "swf-editor" && ( )} - {activeView === "lcelive" && ( - setAddFriendTarget(null)} /> @@ -662,22 +660,12 @@ export default function App() { onClose={clearFriendRequestMessage} onClick={() => { clearFriendRequestMessage(); - setActiveView("lcelive"); + setActiveView("lceonline"); }} title="Friend Request" variant="update" /> - { - clearGameInviteMessage(); - setActiveView("lcelive"); - }} - 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..8de40ac --- /dev/null +++ b/src/services/LceOnlineService.ts @@ -0,0 +1,209 @@ +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; + + constructor() { + this.loadSession(); + } + + 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(); + } + + async login(username: string, password: string): Promise { + const res = await this.request<{ body: string }>( + "POST", + "/login", + `${username}:${password}`, + AUTH_BASE_URL, + ); + console.log("auth login response", res); + const pretoken = + res && typeof res === "object" && "body" in res + ? (res as any).body + : typeof res === "string" + ? res + : ""; + const token = pretoken.split(":")[1]; + this.loginWithToken(token, username); + } + + async register(username: string, password: string): Promise { + const res = await this.request<{ body: string }>( + "POST", + "/register", + `${username}:${password}`, + AUTH_BASE_URL, + ); + console.log("auth register response", res); + const pretoken = + res && typeof res === "object" && "body" in res + ? (res as any).body + : typeof res === "string" + ? res + : ""; + const token = pretoken.split(":")[1]; + this.loginWithToken(token, username); + } + + loginWithToken(token: string, username: string) { + this._session = { + accessToken: token, + account: { username, displayName: username }, + }; + this.saveSession(); + } + + 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 { + await this.request("POST", "/sendrequest", target); + } + + async acceptFriendRequest(from: string): Promise { + await this.request("POST", "/acceptrequest", from); + } + + async declineFriendRequest(from: string): Promise { + await this.request("POST", "/declinerequest", from); + } + + async removeFriend(from: string): Promise { + await this.request("POST", "/removefriend", from); + } +} + +export const lceOnlineService = new LceOnlineService();