feat: LCEOnline instead of LCELive

This commit is contained in:
neoapps-dev 2026-07-10 15:11:51 +03:00
parent d8cbace39c
commit b2e3b2a322
10 changed files with 499 additions and 1022 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

View file

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

View file

@ -1,8 +1,17 @@
import { useState, useEffect } from "react";
import { motion } from "framer-motion";
import { TauriService } from "../../services/TauriService";
import { lceLiveService, GameInvite } from "../../services/LceLiveService";
import { lceOnlineService } from "../../services/LceOnlineService";
import type { Edition } from "../../types/edition";
interface GameInvite {
inviteId: string;
from: string | { 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<string>("");
const [isJoining, setIsJoining] = useState(false);
const [focusIndex, setFocusIndex] = useState(0);
const validInstances = editions.filter((e: Edition) =>
installs.includes(e.instanceId)
installs.includes(e.instanceId),
);
useEffect(() => {
@ -52,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<string, unknown>;
const fromIp = typeof invite.from !== 'string' ? (invite.from as unknown as Record<string, string>).hostIp : undefined;
const hostIp: string = (inviteData.hostIp as string) || fromIp || invite.hostIp;
const hostPort: number = (inviteData.hostPort as number) || invite.hostPort;
const sessionId = (inviteData.signalingSessionId as string) || invite.signalingSessionId || "";
const sessionId = invite.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 (
<motion.div
@ -146,25 +166,41 @@ export default function ChooseInstanceModal({
</p>
{validInstances.length > 0 ? (
<div className="w-full mb-4 flex flex-col gap-2 max-h-[300px] overflow-y-auto"
style={{ scrollbarWidth: "thin", scrollbarColor: "#373737 transparent" }}>
<div
className="w-full mb-4 flex flex-col gap-2 max-h-[300px] overflow-y-auto"
style={{
scrollbarWidth: "thin",
scrollbarColor: "#373737 transparent",
}}
>
{validInstances.map((inst: Edition) => {
const isSelected = selectedInstance === inst.instanceId;
return (
<div
key={inst.instanceId}
onClick={() => { playPressSound(); setSelectedInstance(inst.instanceId); }}
onClick={() => {
playPressSound();
setSelectedInstance(inst.instanceId);
}}
onMouseEnter={() => setFocusIndex(0)}
className={`w-full px-4 py-3 cursor-pointer flex items-center gap-3 transition-all outline-none border-none ${isSelected ? "bg-white/15 border-l-4 border-[#FFFF55]" : "bg-black/20 hover:bg-black/30 border-l-4 border-transparent"}`}
style={{ imageRendering: "pixelated" }}
>
<div className={`w-4 h-4 rounded-full border-2 flex items-center justify-center ${isSelected ? "border-[#FFFF55]" : "border-gray-500"}`}>
{isSelected && <div className="w-2 h-2 rounded-full bg-[#FFFF55]" />}
<div
className={`w-4 h-4 rounded-full border-2 flex items-center justify-center ${isSelected ? "border-[#FFFF55]" : "border-gray-500"}`}
>
{isSelected && (
<div className="w-2 h-2 rounded-full bg-[#FFFF55]" />
)}
</div>
<div className="flex flex-col">
<span className="text-white text-lg font-bold mc-text-shadow">{inst.name}</span>
<span className="text-white text-lg font-bold mc-text-shadow">
{inst.name}
</span>
{inst.selectedBranch && (
<span className="text-gray-400 text-xs">{inst.selectedBranch}</span>
<span className="text-gray-400 text-xs">
{inst.selectedBranch}
</span>
)}
</div>
</div>
@ -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({
</h2>
<div className="flex flex-col items-center gap-4 py-8">
<div className="w-12 h-12 border-4 border-[#FFFF55] border-t-transparent rounded-full animate-spin" />
<p className="text-white text-lg mc-text-shadow text-center">{status}</p>
<p className="text-white text-lg mc-text-shadow text-center">
{status}
</p>
</div>
{error && (
<div className="text-red-500 text-center mc-text-shadow uppercase text-xs tracking-widest mb-3">

View file

@ -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<number | null>(0);
const [acceptInvite, setAcceptInvite] = useState<GameInvite | null>(null);
const [friends, setFriends] = useState<LceLiveAccount[]>([]);
const [incomingReqs, setIncomingReqs] = useState<FriendRequest[]>([]);
const [outgoingReqs, setOutgoingReqs] = useState<FriendRequest[]>([]);
const [invites, setInvites] = useState<GameInvite[]>([]);
const [linkData, setLinkData] = useState<DeviceLinkStartResponse | null>(
null,
const [isSignedIn, setIsSignedIn] = useState(lceOnlineService.signedIn);
const [currentTab, setCurrentTab] = useState<"friends" | "requests">(
"friends",
);
const [linkError, setLinkError] = useState<string | null>(null);
const [focusIndex, setFocusIndex] = useState<number | null>(0);
const [friends, setFriends] = useState<string[]>([]);
const [incomingReqs, setIncomingReqs] = useState<string[]>([]);
const [outgoingReqs, setOutgoingReqs] = useState<string[]>([]);
const [isHosting, setIsHosting] = useState(false);
const [_hostStatus, setHostStatus] = useState("");
const [hostIp, setHostIp] = useState("");
const [hostPort, setHostPort] = useState(19132);
const [isDiscovering, setIsDiscovering] = useState(false);
const [invitedFriends, setInvitedFriends] = useState<Set<string>>(new Set());
const [showHostMethodPicker, setShowHostMethodPicker] = useState(false);
const [isAddingFriend, setIsAddingFriend] = useState(false);
const [addFriendUsername, setAddFriendUsername] = useState("");
const addFriendInputRef = useRef<HTMLInputElement>(null);
const [errorModal, setErrorModal] = useState<string | null>(null);
const [qrDataUrl, setQrDataUrl] = useState<string>("");
const [authMode, setAuthMode] = useState<"login" | "register">("login");
const [authUsername, setAuthUsername] = useState("");
const [authPassword, setAuthPassword] = useState("");
const [isAuthLoading, setIsAuthLoading] = useState(false);
const authUserRef = useRef<HTMLInputElement>(null);
const authPassRef = useRef<HTMLInputElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const scrollRef = useRef<HTMLDivElement>(null);
const fetchSocialData = async () => {
if (!lceLiveService.signedIn) return;
if (!lceOnlineService.signedIn) return;
try {
const [f, reqs, invs] = await Promise.all([
lceLiveService.getFriends(),
lceLiveService.getPendingRequests(),
lceLiveService.getGameInvites(),
]);
setFriends(f);
setIncomingReqs(reqs.incoming);
setOutgoingReqs(reqs.outgoing);
setInvites(invs.filter((i: GameInvite) => i.status === "pending"));
const lists = await lceOnlineService.getSocialLists();
setFriends(lists.friends);
setIncomingReqs(lists.requests);
setOutgoingReqs([]);
} catch (e: unknown) {
console.error(e);
}
@ -76,84 +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<typeof setInterval> | null = null;
const startLink = async () => {
try {
if (!linkData) {
const data = await lceLiveService.startDeviceLink();
if (mounted) setLinkData(data);
}
} catch (e: unknown) {
if (mounted) setLinkError(e instanceof Error ? e.message : String(e));
}
};
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<void>) => {
@ -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<MenuItem[]>(() => {
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 (
<div className="flex flex-col items-center justify-center flex-1 text-center py-12">
{!linkData ? (
<p className="text-lg text-[#2a2a2a] font-bold">
{linkError || "Starting device link..."}
</p>
) : (
<div className="flex items-start justify-center gap-10 w-full max-w-2xl">
<div className="flex flex-col items-center space-y-4 flex-1 min-w-0">
<p className="text-lg text-[#2a2a2a] font-bold">
Open this link in your browser:
</p>
<p className="text-[#111] text-base font-bold tracking-widest break-all bg-black/10 px-4 py-2 rounded shadow-inner">
{linkData.verificationUri}
</p>
<p className="text-lg text-[#2a2a2a] font-bold">
And enter the code:
</p>
<p className="text-[#111] text-4xl tracking-[0.2em] font-bold bg-black/10 px-6 py-3 rounded shadow-inner">
{linkData.userCode}
</p>
</div>
<div className="flex flex-col items-center gap-3 shrink-0">
{qrDataUrl && (
<img
src={qrDataUrl}
alt="QR Code"
className="w-48 h-48 image-rendering-pixelated"
/>
)}
<button
onClick={openAuthUrl}
className="h-10 px-6 flex items-center justify-center text-white mc-text-shadow text-base font-bold uppercase tracking-widest outline-none border-none hover:text-[#FFFF55] transition-colors"
style={{
backgroundImage: "url('/images/button_highlighted.png')",
backgroundSize: "100% 100%",
imageRendering: "pixelated",
}}
>
Open in Browser
</button>
</div>
</div>
)}
<h2 className="text-[#FFFF55] text-3xl mc-text-shadow mb-8 border-b-2 border-[#373737] pb-2 w-full text-center uppercase tracking-widest">
{authMode === "login" ? "Sign In" : "Register"}
</h2>
<input
ref={authUserRef}
type="text"
className="bg-black/20 border-4 border-[#555] text-white p-4 w-full max-w-sm text-2xl font-bold outline-none focus:border-[#FFFF55] transition-colors placeholder:text-[#888] mb-4 mc-text-shadow"
placeholder="Username"
value={authUsername}
onChange={(e) => setAuthUsername(e.target.value)}
disabled={isAuthLoading}
/>
<input
ref={authPassRef}
type="password"
className="bg-black/20 border-4 border-[#555] text-white p-4 w-full max-w-sm text-2xl font-bold outline-none focus:border-[#FFFF55] transition-colors placeholder:text-[#888] mb-6 mc-text-shadow"
placeholder="Password"
value={authPassword}
onChange={(e) => setAuthPassword(e.target.value)}
disabled={isAuthLoading}
/>
<div className="flex gap-4 w-full max-w-sm">
<button
className={`h-12 flex-1 flex items-center justify-center text-white mc-text-shadow text-xl font-bold uppercase tracking-widest hover:text-[#FFFF55] outline-none border-none ${isAuthLoading ? "opacity-50 pointer-events-none" : ""}`}
style={{
backgroundImage: "url('/images/button_highlighted.png')",
backgroundSize: "100% 100%",
imageRendering: "pixelated",
}}
onClick={handleAuth}
disabled={isAuthLoading}
>
{isAuthLoading
? "..."
: authMode === "login"
? "Login"
: "Register"}
</button>
<button
className={`h-12 flex-1 flex items-center justify-center text-white mc-text-shadow text-xl font-bold uppercase tracking-widest hover:text-[#FFFF55] outline-none border-none ${isAuthLoading ? "opacity-50 pointer-events-none" : ""}`}
style={{
backgroundImage: "url('/images/Button_Background.png')",
backgroundSize: "100% 100%",
imageRendering: "pixelated",
}}
onClick={() => {
playPressSound();
setAuthMode(authMode === "login" ? "register" : "login");
}}
disabled={isAuthLoading}
>
{authMode === "login" ? "Register" : "Login"}
</button>
</div>
</div>
);
}
@ -614,11 +421,7 @@ const LceLiveView = memo(function LceLiveView({
<div className="flex flex-col flex-1 bg-black/5 shadow-inner rounded overflow-hidden border-4 border-[#222]">
<div className="bg-black/10 px-4 py-3 text-[#2a2a2a] font-bold tracking-widest uppercase border-b-4 border-[#222] flex justify-between shadow-sm z-10">
<span>
{currentTab === "friends"
? "Joinable Friends"
: currentTab === "requests"
? "Pending Requests"
: "Game Invites"}
{currentTab === "friends" ? "Friends" : "Pending Requests"}
</span>
<span className="text-[#111]">{listItems.length}</span>
</div>
@ -646,28 +449,10 @@ const LceLiveView = memo(function LceLiveView({
<span className="text-[#2a2a2a] font-bold text-2xl truncate pr-4">
{item.label}
</span>
<span className="text-[#555] text-base font-bold truncate">
@
{menuItems.find((m) => m.id === item.id)?.type ===
"friend"
? friends.find(
(f) => `friend_${f.accountId}` === item.id,
)?.username
: item.type === "request_in"
? incomingReqs.find(
(f) => `req_in_${f.accountId}` === item.id,
)?.username
: item.type === "request_out"
? outgoingReqs.find(
(f) =>
`req_out_${f.accountId}` === item.id,
)?.username
: "Invite"}
</span>
</div>
</div>
<div className="flex space-x-3 pr-2 shrink-0">
{item.type === "friend" && !isHosting && (
{item.type === "friend" && (
<button
className={`px-6 h-12 flex items-center justify-center font-bold text-base outline-none uppercase tracking-widest mc-text-shadow ${isFocused ? "text-white shadow-md" : "text-gray-300"}`}
style={{
@ -681,41 +466,6 @@ const LceLiveView = memo(function LceLiveView({
REMOVE
</button>
)}
{item.type === "friend" && isHosting && (
<>
<button
className={`px-6 h-12 flex items-center justify-center font-bold text-base outline-none uppercase tracking-widest mc-text-shadow ${isFocused ? "text-white shadow-md" : "text-gray-300"}`}
style={{
backgroundImage:
"url('/images/button_highlighted.png')",
backgroundSize: "100% 100%",
imageRendering: "pixelated",
}}
onClick={item.onClick}
>
{invitedFriends.has(
item.id.replace("friend_", ""),
)
? "INVITED"
: "INVITE"}
</button>
<button
className={`px-6 h-12 flex items-center justify-center font-bold text-base outline-none uppercase tracking-widest mc-text-shadow ${isFocused ? "text-white shadow-md" : "text-gray-300"}`}
style={{
backgroundImage:
"url('/images/Button_Background.png')",
backgroundSize: "100% 100%",
imageRendering: "pixelated",
}}
onClick={(e) => {
e.stopPropagation();
item.onClickSecondary?.();
}}
>
REMOVE
</button>
</>
)}
{item.type === "request_out" && (
<button
className={`px-6 h-12 flex items-center justify-center font-bold text-base outline-none uppercase tracking-widest mc-text-shadow ${isFocused ? "text-white shadow-md" : "text-gray-300"}`}
@ -730,8 +480,7 @@ const LceLiveView = memo(function LceLiveView({
CANCEL
</button>
)}
{(item.type === "request_in" ||
item.type === "invite") && (
{item.type === "request_in" && (
<>
<button
className={`px-6 h-12 flex items-center justify-center font-bold text-base outline-none uppercase tracking-widest mc-text-shadow ${isFocused ? "text-white shadow-md" : "text-gray-300"}`}
@ -773,6 +522,7 @@ const LceLiveView = memo(function LceLiveView({
</div>
);
};
return (
<motion.div
ref={containerRef}
@ -784,7 +534,7 @@ const LceLiveView = memo(function LceLiveView({
className="flex flex-col items-center justify-center w-full h-full absolute inset-0 outline-none p-12"
>
<div className="w-full max-w-5xl h-full flex flex-col mt-[4vh] mb-[4vh] relative drop-shadow-2xl">
{currentTab !== "device_link" && (
{isSignedIn && (
<div
className="flex z-10 space-x-2 px-12 relative w-full items-end"
style={{ marginBottom: "-4px" }}
@ -815,13 +565,6 @@ const LceLiveView = memo(function LceLiveView({
{incomingReqs.length}
</span>
)}
{t === "invites" && invites.length > 0 && (
<span
className={`ml-3 text-white text-base px-3 py-1 rounded-full shadow-inner border-2 font-normal ${currentTab === t ? "bg-[#30872a] border-[#1b5e16]" : "bg-[#23681d] border-[#111]"}`}
>
{invites.length}
</span>
)}
</div>
</button>
))}
@ -840,14 +583,6 @@ const LceLiveView = memo(function LceLiveView({
>
{renderContent()}
</div>
<div className="flex justify-center pt-4 pb-2">
<img
src="/images/lcelive.png"
alt="LCELive"
className="h-5 opacity-70 cursor-pointer"
onClick={() => TauriService.openUrl("https://lcelive.co.uk")}
/>
</div>
</div>
<AnimatePresence>
@ -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({
</motion.div>
)}
</AnimatePresence>
<AnimatePresence>
{showHostMethodPicker && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-[105] flex items-center justify-center bg-black/80 backdrop-blur-sm outline-none border-none"
>
<div
className="relative w-[420px] p-8 flex flex-col items-center shadow-2xl gap-4"
style={{
backgroundImage: "url('/images/frame_background.png')",
backgroundSize: "100% 100%",
imageRendering: "pixelated",
}}
>
<h2 className="text-[#FFFF55] text-3xl mc-text-shadow mb-2 border-b-2 border-[#373737] pb-2 w-full text-center uppercase tracking-widest">
Host Game
</h2>
<button
data-index={0}
onMouseEnter={() => setFocusIndex(0)}
onClick={handleHostRelay}
className={`w-full h-14 flex items-center justify-center text-xl font-bold uppercase tracking-widest outline-none border-none ${focusIndex === 0 ? "text-[#FFFF55] mc-text-shadow" : "text-white mc-text-shadow hover:text-[#FFFF55]"}`}
style={{
backgroundImage:
focusIndex === 0
? "url('/images/button_highlighted.png')"
: "url('/images/Button_Background.png')",
backgroundSize: "100% 100%",
imageRendering: "pixelated",
}}
>
Relay
</button>
<button
data-index={1}
onMouseEnter={() => setFocusIndex(1)}
onClick={handleHostDirect}
className={`w-full h-14 flex items-center justify-center text-xl font-bold uppercase tracking-widest outline-none border-none ${focusIndex === 1 ? "text-[#FFFF55] mc-text-shadow" : "text-white mc-text-shadow hover:text-[#FFFF55]"}`}
style={{
backgroundImage:
focusIndex === 1
? "url('/images/button_highlighted.png')"
: "url('/images/Button_Background.png')",
backgroundSize: "100% 100%",
imageRendering: "pixelated",
}}
>
Direct (STUN)
</button>
<button
data-index={2}
onMouseEnter={() => setFocusIndex(2)}
onClick={() => {
setShowHostMethodPicker(false);
setFocusIndex(0);
playBackSound();
}}
className={`w-full h-14 flex items-center justify-center text-xl font-bold uppercase tracking-widest outline-none border-none ${focusIndex === 2 ? "text-[#FFFF55] mc-text-shadow" : "text-white mc-text-shadow hover:text-[#FFFF55]"}`}
style={{
backgroundImage:
focusIndex === 2
? "url('/images/button_highlighted.png')"
: "url('/images/Button_Background.png')",
backgroundSize: "100% 100%",
imageRendering: "pixelated",
}}
>
Cancel
</button>
</div>
</motion.div>
)}
</AnimatePresence>
<AnimatePresence>
{errorModal && (
<motion.div
@ -1032,21 +690,8 @@ const LceLiveView = memo(function LceLiveView({
</motion.div>
)}
</AnimatePresence>
<ChooseInstanceModal
isOpen={acceptInvite !== null}
onClose={() => {
setAcceptInvite(null);
fetchSocialData();
}}
playPressSound={playPressSound}
playBackSound={playBackSound}
editions={editions}
installs={installs}
invite={acceptInvite}
/>
</motion.div>
);
});
export default LceLiveView;
export default LceOnlineView;

View file

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

View file

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

View file

@ -0,0 +1,41 @@
import { useState, useEffect, useRef } from "react";
import { lceOnlineService } from "../services/LceOnlineService";
export function useLceOnlineNotifications() {
const [friendRequestMessage, setFriendRequestMessage] = useState<string | null>(null);
const seenRequests = useRef<Set<string>>(new Set());
useEffect(() => {
let pollInterval: ReturnType<typeof setInterval>;
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),
};
}

View file

@ -18,7 +18,7 @@ import OptionsEditorView from "../components/views/OptionsEditorView";
import ModelEditorView from "../components/views/ModelEditorView";
import ScreenshotsView from "../components/views/ScreenshotsView";
import SwfView from "../components/views/SwfView";
import LceLiveView from "../components/views/LceLiveView";
import LceOnlineView from "../components/views/LceOnlineView";
import CreditsView from "../components/views/CreditsView";
import SkinViewer from "../components/common/SkinViewer";
import PanoramaBackground from "../components/common/PanoramaBackground";
@ -35,7 +35,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" && (
<SwfView key="swf-editor-view" />
)}
{activeView === "lcelive" && (
<LceLiveView
key="lcelive-view"
{activeView === "lceonline" && (
<LceOnlineView
key="lceonline-view"
addFriendTarget={addFriendTarget}
onClearAddFriendTarget={() => setAddFriendTarget(null)}
/>
@ -662,22 +660,12 @@ export default function App() {
onClose={clearFriendRequestMessage}
onClick={() => {
clearFriendRequestMessage();
setActiveView("lcelive");
setActiveView("lceonline");
}}
title="Friend Request"
variant="update"
/>
<AchievementToast
message={gameInviteMessage}
onClose={clearGameInviteMessage}
onClick={() => {
clearGameInviteMessage();
setActiveView("lcelive");
}}
title="Game Invite"
variant="update"
/>
</div>
</MotionConfig>
);

View file

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

View file

@ -0,0 +1,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<void> {
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<void> {
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<T = any>(
method: string,
path: string,
body?: string | null,
baseUrl?: string,
): Promise<T> {
const headers: Record<string, string> = {
Accept: "text/plain, application/json",
"User-Agent": "MCLCE-LCEOnline/1.0",
};
if (body) {
headers["Content-Type"] = "text/plain";
}
if (this._session?.accessToken) {
headers["Authorization"] = `Bearer ${this._session.accessToken}`;
}
const url = `${baseUrl || this.baseUrl}${path}`;
let res;
try {
res = await TauriService.httpProxyRequest(
method,
url,
body ?? null,
headers,
);
} catch (e) {
throw new Error(`Network error when calling ${path}: ${e}`);
}
let data;
try {
data = res.body ? JSON.parse(res.body) : {};
} catch {
data = res.body ?? {};
}
if (res.status >= 400) {
const errorMsg =
data.message ||
data.detail ||
data.title ||
data.error ||
data ||
`HTTP ${res.status}`;
throw new Error(errorMsg);
}
return data;
}
async getSocialLists(): Promise<{
friends: string[];
requests: string[];
blocked: string[];
}> {
const raw: string = await this.request<string>(
"POST",
"/getSocialLists",
null,
);
if (typeof raw !== "string") {
return { friends: [], requests: [], blocked: [] };
}
const withoutPrefix = raw.startsWith("-") ? raw.slice(1) : raw;
const parts = withoutPrefix.split("|");
return {
friends: parts[0] ? parts[0].split(",").filter(Boolean) : [],
requests: parts[1] ? parts[1].split(",").filter(Boolean) : [],
blocked: parts[2] ? parts[2].split(",").filter(Boolean) : [],
};
}
async sendFriendRequest(target: string): Promise<void> {
await this.request("POST", "/sendrequest", target);
}
async acceptFriendRequest(from: string): Promise<void> {
await this.request("POST", "/acceptrequest", from);
}
async declineFriendRequest(from: string): Promise<void> {
await this.request("POST", "/declinerequest", from);
}
async removeFriend(from: string): Promise<void> {
await this.request("POST", "/removefriend", from);
}
}
export const lceOnlineService = new LceOnlineService();