5180 fichiers 38.5 Mo 0 téléchargements 64 vues mis à jour il y a 3 heures ui
import {
    GetAssetManager,
    GetAvatarRenderManager,
    GetCommunication,
    GetConfiguration,
    GetDesiredResolution,
    GetLocalizationManager,
    GetRoomEngine,
    GetRoomSessionManager,
    GetSessionDataManager,
    GetSoundManager,
    GetStage,
    GetTexturePool,
    GetTicker,
    HabboWebTools,
    LegacyExternalInterface,
    LoadGameUrlEvent,
    OctaneEventType,
    OctaneLogger,
    OctaneVersion,
    PrepareRenderer
} from '@octane/renderer';
import { FC, useCallback, useEffect, useEffectEvent, useRef, useState } from 'react';
import { clearAccessToken, ClearRememberLogin, ClearRememberLoginIfStill, createBootSteps, exchangeSsoTicketForAccessToken, GetRememberLogin, GetUIVersion, isRememberedLogin, isRememberRejection, persistAccessTokenFromPayload, rememberRequestBody, renewAccessTokenIfDue, StoreRememberLoginFromPayload, withRememberLock } from './api';
import { Base } from './common';
import { LoadingView } from './components/loading/LoadingView';
import { LoginView } from './components/login/LoginView';
import { MainView } from './components/MainView';
import { ReconnectView } from './components/reconnect/ReconnectView';
import { ClearStoredChatHistory, getConnectionFailureAction, shouldClearLoginAfterDisconnect, useConnectionState, useDevicePixelRatio, useMessageEvent, useOctaneEvent } from './hooks';
import { SharedHookRegistry } from './state/useSharedHook';

OctaneVersion.UI_VERSION = GetUIVersion();

const getViewportDimensions = () => {
    const viewport = window.visualViewport;
    const width = Math.max(1, Math.floor(viewport?.width ?? window.innerWidth));
    const height = Math.max(1, Math.floor(viewport?.height ?? window.innerHeight));

    return { width, height };
};

const syncViewportCssVars = () => {
    const { width, height } = getViewportDimensions();

    document.documentElement.style.setProperty('--octane-app-width', `${width}px`);
    document.documentElement.style.setProperty('--octane-app-height', `${height}px`);
};

const preloadImage = (url: string): void => {
    if (!url) return;

    try {
        const image = new Image();
        image.decoding = 'async';
        image.src = url;
    } catch {}
};

const asStringArray = (value: unknown): string[] => {
    if (Array.isArray(value)) return value.filter((item) => typeof item === 'string');
    if (typeof value === 'string' && value.length) return [value];

    return [];
};

const hasRememberLogin = (): boolean => !!GetRememberLogin();

export const App: FC<{}> = (props) => {
    const connectionState = useConnectionState();
    const devicePixelRatio = useDevicePixelRatio();
    const [isReady, setIsReady] = useState(false);
    const [errorMessage, setErrorMessage] = useState('');
    const [homeUrl, setHomeUrl] = useState('');
    const [showLogin, setShowLogin] = useState(false);
    const [isEnteringHotel, setIsEnteringHotel] = useState(() => !!window.OctaneConfig?.['sso.ticket'] || hasRememberLogin());
    const [prepareTrigger, setPrepareTrigger] = useState(0);
    const [loadingProgress, setLoadingProgress] = useState(0);
    const [loadingTask, setLoadingTask] = useState('');
    const taskLabel = useCallback((key: string, fallback: string): string => {
        try {
            const locManager = GetLocalizationManager();
            if (locManager && typeof locManager.getValue === 'function') {
                const fromLoc = locManager.getValue(key, false);

                if (typeof fromLoc === 'string' && fromLoc.length && fromLoc !== key) return fromLoc;
            }
        } catch {}

        // A loading text the external texts don't have yet may come from the config; it is optional there.
        try {
            const fromConfig = GetConfiguration().definitions.has(key) ? GetConfiguration().getValue<string>(key, '') : '';
            if (typeof fromConfig === 'string' && fromConfig.length) return fromConfig;
        } catch {}

        return fallback;
    }, []);
    const bumpProgress = useCallback((value: number, task?: string) => {
        setLoadingProgress((prev) => (value > prev ? value : prev));
        if (task !== undefined) setLoadingTask(task);
    }, []);
    // Each boot step runs once and is shared; a step that failed is dropped, so the next login retries it.
    const [runBootStep] = useState(createBootSteps);
    // The latest prepare() run; an older run that a new login replaced ends without touching the UI.
    const prepareRunRef = useRef(0);
    // Settles when the base texts are in: furnidata writes its names over them, so session data waits for it.
    const localizationReadyRef = useRef<{ promise: Promise<void>; resolve: () => void }>(null);
    const bootstrapDoneRef = useRef(false);
    const lastPrepareTriggerRef = useRef<number | null>(null);
    const tickersStartedRef = useRef(false);
    const heartbeatIntervalRef = useRef<number>(null);
    const rememberRotateIntervalRef = useRef<number>(null);
    const previousConnectionPhaseRef = useRef(connectionState.phase);

    const clearStoredCredentials = useCallback(() => {
        ClearStoredChatHistory();
        ClearRememberLogin();
        clearAccessToken();
        try {
            delete (window as any).OctaneConfig?.['sso.ticket'];
        } catch {}
        try {
            GetConfiguration().setValue('sso.ticket', '');
        } catch {}
        try {
            const url = new URL(window.location.href);

            if (url.searchParams.has('sso')) {
                url.searchParams.delete('sso');
                window.history.replaceState({}, '', url.toString());
            }
        } catch {}
    }, []);

    const showSessionExpired = useCallback(() => {
        OctaneLogger.log('[App] showSessionExpired — diagnostic shown (mid-game close)');
        clearStoredCredentials();

        const baseUrl = window.location.origin + '/';
        setHomeUrl(baseUrl);
        setErrorMessage('Your session has expired.\nPlease log in again to enter the hotel.');
        setIsReady(false);
        setShowLogin(false);
        setIsEnteringHotel(false);
    }, [clearStoredCredentials]);

    const fallbackToLogin = useCallback(() => {
        const rawLoginEnabled = GetConfiguration().getValue<unknown>('login.screen.enabled', false);
        const loginScreenEnabled = rawLoginEnabled === true || rawLoginEnabled === 'true' || rawLoginEnabled === 1;

        if (!loginScreenEnabled) {
            OctaneLogger.log('[App] fallbackToLogin — login.screen.enabled=false, redirecting to home instead');
            showSessionExpired();
            return;
        }
        OctaneLogger.log('[App] fallbackToLogin — surfacing login form, credentials cleared');
        clearStoredCredentials();
        setHomeUrl('');
        setErrorMessage('');
        setIsReady(false);
        setShowLogin(true);
        setIsEnteringHotel(false);
    }, [clearStoredCredentials, showSessionExpired]);

    const applySsoTicket = useCallback((ssoTicket: string) => {
        if (!ssoTicket) return;
        ClearStoredChatHistory();
        window.OctaneConfig['sso.ticket'] = ssoTicket;
        GetConfiguration().setValue('sso.ticket', ssoTicket);
        void exchangeSsoTicketForAccessToken(ssoTicket);
    }, []);

    useEffect(() => {
        const ssoTicket = window.OctaneConfig?.['sso.ticket'];

        if (typeof ssoTicket === 'string' && ssoTicket.length) void exchangeSsoTicketForAccessToken(ssoTicket);
    }, []);

    const handleAuthenticated = useCallback(
        (ssoTicket: string) => {
            if (!ssoTicket) return;
            applySsoTicket(ssoTicket);
            setIsEnteringHotel(true);
            setErrorMessage('');
            setPrepareTrigger((prev) => prev + 1);
        },
        [applySsoTicket]
    );

    const tryRememberLoginUnlocked = useCallback(async (): Promise<string> => {
        const remembered = GetRememberLogin();

        OctaneLogger.log('[App] tryRememberLogin start', {
            hasRemembered: !!remembered,
            hasToken: !!remembered?.token?.length
        });

        if (!isRememberedLogin(remembered)) {
            if (remembered) ClearRememberLogin();
            OctaneLogger.log('[App] tryRememberLogin → no token, returning empty');
            return '';
        }

        try {
            const rawEndpoint = GetConfiguration().getValue<string>('login.remember.endpoint', '${api.url}/api/auth/remember');
            const endpoint = GetConfiguration().interpolate(rawEndpoint);
            const response = await fetch(endpoint, {
                method: 'POST',
                credentials: 'include',
                headers: {
                    'Content-Type': 'application/json',
                    Accept: 'application/json',
                    'X-Requested-With': 'OctaneRememberLogin'
                },
                body: JSON.stringify(rememberRequestBody(remembered))
            });

            let payload: Record<string, unknown> = {};
            try {
                payload = await response.json();
            } catch {}

            const ssoTicket = typeof payload.ssoTicket === 'string' ? payload.ssoTicket : typeof payload.sso === 'string' ? payload.sso : '';

            OctaneLogger.log('[App] tryRememberLogin → remember endpoint replied', {
                status: response.status,
                ok: response.ok,
                gotSsoTicket: !!ssoTicket
            });

            if (response.ok && ssoTicket) {
                persistAccessTokenFromPayload(payload, ssoTicket);
                StoreRememberLoginFromPayload(payload, typeof payload.username === 'string' ? payload.username : remembered.username, remembered.cookie === true);
                return ssoTicket;
            }

            // Only a refusal of the token forgets it: an emulator restarting or a dropped connection used to cost the
            // player the remembered login.
            if (isRememberRejection(response.status)) {
                ClearRememberLoginIfStill(remembered.token);
                OctaneLogger.log('[App] tryRememberLogin → token refused, cleared remember');
            }
        } catch (error) {
            console.warn('[App] tryRememberLogin → fetch threw', error);
        }

        return '';
    }, []);

    // Remember and refresh rotate one shared token: every tab takes the same lock and reads the token inside it.
    const tryRememberLogin = useCallback(() => withRememberLock(tryRememberLoginUnlocked), [tryRememberLoginUnlocked]);

    const rotateRememberLoginUnlocked = useCallback(async (): Promise<void> => {
        const remembered = GetRememberLogin();

        if (!isRememberedLogin(remembered)) return;

        try {
            const rawEndpoint = GetConfiguration().getValue<string>('login.refresh.endpoint', '${api.url}/api/auth/refresh');
            const endpoint = GetConfiguration().interpolate(rawEndpoint);
            const response = await fetch(endpoint, {
                method: 'POST',
                credentials: 'include',
                headers: {
                    'Content-Type': 'application/json',
                    Accept: 'application/json',
                    'X-Requested-With': 'OctaneRememberRotate'
                },
                body: JSON.stringify(rememberRequestBody(remembered))
            });

            let payload: Record<string, unknown> = {};
            try {
                payload = await response.json();
            } catch {}

            if (response.ok) {
                persistAccessTokenFromPayload(payload);
                StoreRememberLoginFromPayload(payload, remembered.username, remembered.cookie === true);
                return;
            }

            if (isRememberRejection(response.status)) ClearRememberLoginIfStill(remembered.token);
        } catch (error) {
            OctaneLogger.error('[LoginScreen] Remember rotation failed', error);
        }
    }, []);

    const rotateRememberLogin = useCallback(() => withRememberLock(rotateRememberLoginUnlocked), [rotateRememberLoginUnlocked]);

    useEffect(() => {
        const previousPhase = previousConnectionPhaseRef.current;
        const currentPhase = connectionState.phase;
        previousConnectionPhaseRef.current = currentPhase;

        const action = getConnectionFailureAction(previousPhase, currentPhase, isReady, connectionState.disconnectReason);

        if (action === 'kicked') {
            // ReconnectView shows the reason over the hotel; only a ban forgets the stored login.
            if (shouldClearLoginAfterDisconnect(connectionState.disconnectReason)) clearStoredCredentials();
        } else if (action === 'login') {
            console.warn('[App] Connection failed before authentication completed — falling back to login');
            fallbackToLogin();
        } else if (action === 'expired') {
            showSessionExpired();
        }
    }, [connectionState.phase, connectionState.disconnectReason, clearStoredCredentials, fallbackToLogin, isReady, showSessionExpired]);

    useMessageEvent<LoadGameUrlEvent>(LoadGameUrlEvent, (event) => {
        const parser = event.getParser();

        if (!parser) return;

        LegacyExternalInterface.callGame('showGame', parser.url);
    });

    const startRenderer = useCallback((width: number, height: number): Promise<any> => runBootStep('renderer', () => {

        const rawUseBackBuffer = window.OctaneConfig?.['renderer.useBackBuffer'];
        const useBackBuffer = rawUseBackBuffer === undefined ? true : rawUseBackBuffer === true || rawUseBackBuffer === 'true';

        return PrepareRenderer({
            width: Math.floor(width),
            height: Math.floor(height),
            resolution: GetDesiredResolution(),
            autoDensity: true,
            backgroundAlpha: 0,
            preference: 'webgl',
            eventMode: 'none',
            failIfMajorPerformanceCaveat: false,
            roundPixels: true,
            useBackBuffer
        });
    }), [runBootStep]);

    const getLocalizationReady = useCallback(() => {
        if (!localizationReadyRef.current) {
            let resolve: () => void = null;
            const promise = new Promise<void>((done) => (resolve = done));

            localizationReadyRef.current = { promise, resolve };
        }

        return localizationReadyRef.current;
    }, []);

    const startWarmup = useCallback(
        (width: number, height: number) => {
            return runBootStep('warmup', async () => {
                await GetConfiguration().init();
                bumpProgress(25, taskLabel('loader.waiting', 'Loading content...'));

                // 0 = the display's refresh rate. A cap (the old 24 default) makes
                // Pixi skip whole animation frames: on a 60 Hz display it ticks at an
                // alternating 33/50 ms, so walking judders and the 24 Hz visual
                // clock (system.fps.animation) is sampled unevenly. Animation cadence
                // is time based, so a higher tick rate does not speed anything up.
                GetTicker().maxFPS = GetConfiguration().getValue<number>('system.fps.max', 0);
                OctaneLogger.LOG_DEBUG = GetConfiguration().getValue<boolean>('system.log.debug', true);
                OctaneLogger.LOG_WARN = GetConfiguration().getValue<boolean>('system.log.warn', false);
                OctaneLogger.LOG_ERROR = GetConfiguration().getValue<boolean>('system.log.error', false);
                OctaneLogger.LOG_EVENTS = GetConfiguration().getValue<boolean>('system.log.events', false);
                OctaneLogger.LOG_PACKETS = GetConfiguration().getValue<boolean>('system.log.packets', false);

                startRenderer(width, height).catch((error) => OctaneLogger.error('[LoginScreen] Renderer warmup failed', error));

                const interpolate = (value: string) => GetConfiguration().interpolate(value);
                const assetUrls = asStringArray(GetConfiguration().getValue<unknown>('preload.assets.urls')).map(interpolate);
                const loginImages = (GetConfiguration().getValue<Record<string, unknown>>('loginview', {})?.images as Record<string, string>) ?? {};
                const loginImageUrls = [
                    loginImages.background,
                    loginImages.sun,
                    loginImages.drape,
                    loginImages.left,
                    loginImages['right.repeat'],
                    loginImages.right
                ]
                    .filter(Boolean)
                    .map(interpolate);

                loginImageUrls.forEach(preloadImage);

                const warmupTasks: { promise: Promise<any>; label: string }[] = [
                    { promise: runBootStep('assets', () => GetAssetManager().downloadAssets(assetUrls)), label: taskLabel('loading.task.assets', 'Loading game assets...') },
                    {
                        promise: runBootStep('localization', () => GetLocalizationManager().init().then(() => getLocalizationReady().resolve())),
                        label: taskLabel('loading.task.localization', 'Loading translations...')
                    },
                    { promise: runBootStep('avatars', () => GetAvatarRenderManager().init()), label: taskLabel('loading.task.avatar', 'Loading wardrobe...') },
                    { promise: runBootStep('sounds', () => GetSoundManager().init()), label: taskLabel('loading.task.sounds', 'Loading sounds...') }
                ];
                let warmupDone = 0;
                const warmupStart = 25;
                const warmupSpan = 45;
                await Promise.all(
                    warmupTasks.map((t) =>
                        t.promise.then((value) => {
                            warmupDone++;
                            bumpProgress(warmupStart + Math.round((warmupSpan * warmupDone) / warmupTasks.length), t.label);
                            return value;
                        })
                    )
                );
            });
        },
        [runBootStep, startRenderer, bumpProgress, taskLabel, getLocalizationReady]
    );

    useEffect(() => {
        syncViewportCssVars();

        const handleViewportResize = () => syncViewportCssVars();
        const viewport = window.visualViewport;

        window.addEventListener('resize', handleViewportResize);
        viewport?.addEventListener('resize', handleViewportResize);
        viewport?.addEventListener('scroll', handleViewportResize);

        return () => {
            window.removeEventListener('resize', handleViewportResize);
            viewport?.removeEventListener('resize', handleViewportResize);
            viewport?.removeEventListener('scroll', handleViewportResize);
        };
    }, []);

    const onSessionExpired = useEffectEvent(() => showSessionExpired());
    const onInitFailure = useEffectEvent(() => fallbackToLogin());

    useEffect(() => {
        const prepare = async (width: number, height: number) => {
            const run = ++prepareRunRef.current;

            OctaneLogger.log('[App] prepare() start', {
                hasOctaneConfig: !!window.OctaneConfig,
                ssoTicketInConfig: !!window.OctaneConfig?.['sso.ticket'],
                hasRememberLocal: !!GetRememberLogin(),
                hasUrlSso: !!new URLSearchParams(window.location.search).get('sso')
            });

            const bootLabel = taskLabel('loader', 'Booting...');
            setLoadingProgress(0);
            setLoadingTask(bootLabel);
            bumpProgress(5, bootLabel);

            try {
                if (!window.OctaneConfig) throw new Error('OctaneConfig is not defined!');

                let ssoTicket = window.OctaneConfig['sso.ticket'];
                if (ssoTicket) GetConfiguration().setValue('sso.ticket', ssoTicket);

                bumpProgress(10, taskLabel('loading.task.session', 'Verifying session...'));

                if (!ssoTicket || ssoTicket === '') {
                    let configInitError: unknown = null;
                    try {
                        await GetConfiguration().init();
                    } catch (e) {
                        configInitError = e;
                    }

                    const rawLoginEnabled = GetConfiguration().getValue<unknown>('login.screen.enabled', false);
                    const loginScreenEnabled = rawLoginEnabled === true || rawLoginEnabled === 'true' || rawLoginEnabled === 1;

                    OctaneLogger.log('[App] no SSO path — login gate', {
                        configInitError: configInitError ? String((configInitError as Error)?.message ?? configInitError) : null,
                        rawLoginEnabled,
                        rawLoginEnabledType: typeof rawLoginEnabled,
                        loginScreenEnabled
                    });

                    if (configInitError) {
                        OctaneLogger.error('[LoginScreen] Failed to load renderer-config.json — cannot resolve login.screen.enabled', configInitError);
                    }

                    if (loginScreenEnabled) {
                        const rememberedSsoTicket = await tryRememberLogin();

                        if (rememberedSsoTicket) {
                            ssoTicket = rememberedSsoTicket;
                            applySsoTicket(rememberedSsoTicket);
                            setShowLogin(false);
                        } else {
                            setIsReady(false);
                            setShowLogin(true);
                            startWarmup(width, height).catch((error) => OctaneLogger.error('[LoginScreen] Warmup failed', error));
                            return;
                        }
                    } else {
                        if (configInitError) {
                            setHomeUrl(window.location.origin + '/');
                            setErrorMessage(`Unable to load renderer-config.json.\n${String((configInitError as Error)?.message ?? configInitError)}`);
                            setIsReady(false);
                            setShowLogin(false);
                            setIsEnteringHotel(false);
                            return;
                        }

                        onSessionExpired();
                        return;
                    }
                }

                // Log in while the hotel loads. Messages after the login stay held by the connection
                // until MainView has mounted and calls ready(), so no handler misses its packets.
                // A login that failed is replaced by this one; finished boot steps are kept.
                const connected = GetCommunication().init();

                connected.catch(() => undefined);

                await startRenderer(width, height);
                bumpProgress(20, taskLabel('loading.task.renderer', 'Initializing renderer...'));

                const sessionData = runBootStep('session-data', async () => {
                    await getLocalizationReady().promise;
                    await GetSessionDataManager().init();
                });

                sessionData.catch(() => undefined);

                await startWarmup(width, height);
                bumpProgress(70, taskLabel('loading.task.startsession', 'Starting session...'));
                await sessionData;
                bumpProgress(78, taskLabel('loading.task.userdata', 'Loading user data...'));
                await runBootStep('room-sessions', () => GetRoomSessionManager().init());
                bumpProgress(85, taskLabel('loading.task.rooms', 'Loading rooms...'));
                await runBootStep('room-engine', () => GetRoomEngine().init());
                bumpProgress(92, taskLabel('loading.task.engine', 'Loading graphics engine...'));
                await connected;
                bumpProgress(98, taskLabel('generic.reconnecting', 'Connecting to server...'));

                if (run !== prepareRunRef.current) return;

                const renderer = await startRenderer(width, height);

                if (!bootstrapDoneRef.current) {
                    bootstrapDoneRef.current = true;
                    if (LegacyExternalInterface.available) LegacyExternalInterface.call('legacyTrack', 'authentication', 'authok', []);
                    HabboWebTools.sendHeartBeat();
                }

                if (heartbeatIntervalRef.current !== null) window.clearInterval(heartbeatIntervalRef.current);
                heartbeatIntervalRef.current = window.setInterval(() => HabboWebTools.sendHeartBeat(), 10000);

                if (rememberRotateIntervalRef.current !== null) window.clearInterval(rememberRotateIntervalRef.current);

                const rotateMinutes = Math.max(1, Number(GetConfiguration().getValue<unknown>('login.remember.rotate.interval.minutes', 15)) || 15);
                if (isRememberedLogin(GetRememberLogin()))
                    rememberRotateIntervalRef.current = window.setInterval(() => rotateRememberLogin(), rotateMinutes * 60 * 1000);
                // Without a remembered login the access token is renewed from the session before it expires.
                else rememberRotateIntervalRef.current = window.setInterval(() => void renewAccessTokenIfDue(), 60 * 1000);

                if (!tickersStartedRef.current) {
                    tickersStartedRef.current = true;
                    GetTicker().add((ticker) => GetRoomEngine().update(ticker));
                    GetTicker().add((ticker) => renderer.render(GetStage()));
                    GetTicker().add((ticker) => GetTexturePool().run());
                }

                bumpProgress(100, taskLabel('onboarding.button.ready', 'Ready!'));
                setIsReady(true);
                setShowLogin(false);
                setIsEnteringHotel(false);
            } catch (err) {
                // A newer login replaced this run (and its connection attempt): that one owns the screen now.
                if (run !== prepareRunRef.current) return;

                OctaneLogger.error('[App] Initialization failed — falling back to login', err);
                onInitFailure();
            }
        };

        if (lastPrepareTriggerRef.current === prepareTrigger) return;
        lastPrepareTriggerRef.current = prepareTrigger;

        const { width, height } = getViewportDimensions();

        prepare(width, height);

        return () => {
            if (heartbeatIntervalRef.current !== null) window.clearInterval(heartbeatIntervalRef.current);
            if (rememberRotateIntervalRef.current !== null) window.clearInterval(rememberRotateIntervalRef.current);
        };
    }, [prepareTrigger, runBootStep, startWarmup, startRenderer, getLocalizationReady, tryRememberLogin, applySsoTicket, rotateRememberLogin, bumpProgress, taskLabel]);

    return (
        <Base fit overflow="hidden" className={`octane-app-root ${!(devicePixelRatio % 1) ? 'image-rendering-pixelated' : ''}`}>
            {!isReady && !showLogin && (
                <LoadingView isError={errorMessage.length > 0} message={errorMessage} homeUrl={homeUrl} progress={loadingProgress} currentTask={loadingTask} />
            )}
            {!isReady && showLogin && <LoginView onAuthenticated={handleAuthenticated} isEntering={isEnteringHotel} />}
            {isReady && (
                <SharedHookRegistry fallback={<LoadingView message="Loading…" />}>
                    <MainView />
                    <ReconnectView />
                </SharedHookRegistry>
            )}
            <Base id="draggable-windows-container" />
        </Base>
    );
};
15Sujets
1Dépôts
20Membres
6En ligne
Profile
Inscrit
Dernière visite
Messages
0
Votes positifs
0
Votes négatifs
0

Nous utilisons des cookies pour assurer le bon fonctionnement du site et améliorer votre expérience. en savoir plus

Autoriser