mirror of
https://github.com/LCE-Hub/LCE-Emerald-Launcher.git
synced 2026-07-18 08:17:23 +00:00
385 lines
9.5 KiB
TypeScript
385 lines
9.5 KiB
TypeScript
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();
|