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();