diff --git a/.gitignore b/.gitignore index 90a9bfe..208d8f3 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,9 @@ Thumbs.db /public/configuration/client-mode.json /public/configuration/adsense.json /public/configuration/hotlooks.json + +# Game assets are served by an external server (emulator/CMS), not by Vite. +# Never recreate these as symlinks inside public/ — chokidar follows them and +# the dev server takes minutes to start with 100k+ files under public/. +/public/nitro-assets +/public/swf diff --git a/index.html b/index.html index 1c4fa2e..2d0b3f6 100644 --- a/index.html +++ b/index.html @@ -1 +1,12 @@ -
+ + + + + + Nitro + + +
+ + + diff --git a/package.json b/package.json index 119bdb5..aaf169a 100644 --- a/package.json +++ b/package.json @@ -58,6 +58,7 @@ "postcss": "^8.5.12", "postcss-nested": "^7.0.2", "sass": "^1.99.0", + "sirv": "^3.0.2", "tailwindcss": "^4.2.4", "typescript": "^6.0.3", "typescript-eslint": "^8.59.1", diff --git a/src/App.tsx b/src/App.tsx index e54defc..0a77cbf 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -76,18 +76,63 @@ export const App: FC<{}> = props => const rendererPromiseRef = useRef>(null); const gameInitPromiseRef = useRef | null>(null); const bootstrapDoneRef = useRef(false); + const lastPrepareTriggerRef = useRef(null); const tickersStartedRef = useRef(false); const heartbeatIntervalRef = useRef(null); const rememberRotateIntervalRef = useRef(null); + const isReadyRef = useRef(false); + const reconnectInProgressRef = useRef(false); + + const clearStoredCredentials = useCallback(() => + { + ClearRememberLogin(); + try { delete (window as any).NitroConfig?.['sso.ticket']; } catch {} + try { GetConfiguration().setValue('sso.ticket', ''); } catch {} + // Drop `?sso=` from the URL too — otherwise the next reload re-applies + // the same already-consumed ticket via bootstrap.ts and we loop right + // back into "Session expired" without ever showing the login form. + try + { + const url = new URL(window.location.href); + + if(url.searchParams.has('sso')) + { + url.searchParams.delete('sso'); + window.history.replaceState({}, '', url.toString()); + } + } + catch {} + }, []); + + const fallbackToLogin = useCallback(() => + { + // Using console.warn (not NitroLogger.log) on purpose: NitroLogger + // is gated on LOG_DEBUG, which only flips to true once startWarmup's + // GetConfiguration().init() completes. Auth-failure paths fire before + // that, so NitroLogger swallows their messages silently. + console.warn('[App] fallbackToLogin — surfacing login form, credentials cleared'); + // Wipe whatever credential the server just rejected so the form is + // pristine and the next attempt isn't sabotaged by the same stale data. + clearStoredCredentials(); + setHomeUrl(''); + setErrorMessage(''); + setIsReady(false); + setShowLogin(true); + setIsEnteringHotel(false); + }, [ clearStoredCredentials ]); + const showSessionExpired = useCallback(() => { + console.warn('[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 applySsoTicket = useCallback((ssoTicket: string) => { @@ -109,10 +154,20 @@ export const App: FC<{}> = props => { const remembered = GetRememberLogin(); - if(!remembered) return ''; - if(!remembered.token?.length && remembered.ssoTicket?.length) return remembered.ssoTicket; + console.warn('[App] tryRememberLogin start', { + hasRemembered: !!remembered, + hasToken: !!remembered?.token?.length, + hasStoredSso: !!remembered?.ssoTicket?.length + }); - let allowSsoFallback = true; + if(!remembered?.token?.length) + { + // No remember token means we'd be reusing a one-shot ssoTicket that + // the server already consumed. Force the login screen instead. + if(remembered) ClearRememberLogin(); + console.warn('[App] tryRememberLogin → no token, returning empty'); + return ''; + } try { @@ -139,24 +194,28 @@ export const App: FC<{}> = props => const ssoTicket = typeof payload.ssoTicket === 'string' ? payload.ssoTicket : (typeof payload.sso === 'string' ? payload.sso : ''); + console.warn('[App] tryRememberLogin → remember endpoint replied', { + status: response.status, + ok: response.ok, + gotSsoTicket: !!ssoTicket + }); + if(response.ok && ssoTicket) { StoreRememberLoginFromPayload(payload, typeof payload.username === 'string' ? payload.username : remembered.username, ssoTicket); return ssoTicket; } - - if(response.status === 400 || response.status === 401 || response.status === 403) - { - allowSsoFallback = false; - ClearRememberLogin(); - } } catch(error) { - NitroLogger.error('[LoginScreen] Remember login failed', error); + console.warn('[App] tryRememberLogin → fetch threw', error); } - if(allowSsoFallback && remembered.ssoTicket?.length) return remembered.ssoTicket; + // Any failure (rejected token, bad payload, network error) — drop the + // stored credentials. Never fall back to the cached ssoTicket: it's + // one-shot and reusing it leads straight to "Session expired". + ClearRememberLogin(); + console.warn('[App] tryRememberLogin → cleared remember, returning empty'); return ''; }, []); @@ -204,8 +263,51 @@ export const App: FC<{}> = props => } }, []); - // Listen for socket closed events (code 1000 "Bye" - server rejected SSO) - useNitroEvent(NitroEventType.SOCKET_CLOSED, showSessionExpired); + // Mirror isReady into a ref so the socket handlers below can read the + // freshest value without needing to re-subscribe on every state change. + useEffect(() => { isReadyRef.current = isReady; }, [ isReady ]); + + // Track whether a reconnect cycle is active. The renderer dispatches + // SOCKET_RECONNECTING when it starts retrying after an abnormal close + // (code != 1000/1001). On exhausted retries it fires SOCKET_RECONNECT_FAILED + // *and* a final SOCKET_CLOSED — we keep the flag set through that pair + // so ReconnectView's own overlay owns the UX and we don't double-render. + useNitroEvent(NitroEventType.SOCKET_RECONNECTING, () => { reconnectInProgressRef.current = true; }); + useNitroEvent(NitroEventType.SOCKET_REAUTHENTICATED, () => { reconnectInProgressRef.current = false; }); + + useNitroEvent(NitroEventType.SOCKET_CLOSED, () => + { + // Three distinct close scenarios converge here: + // + // 1. !isReady — initial handshake just failed (server replied + // with "Bye" / code 1000 to a bad SSO ticket). The user never + // had a session. Surface the login form instead of the + // misleading "Session expired" diagnostic. + // + // 2. isReady && reconnect in progress — ReconnectView already + // owns the UX (its overlay shows attempts and the "Session + // expired" message on RECONNECT_FAILED). Stay out of its way. + // + // 3. isReady && no reconnect — instant server kick mid-game + // (code 1000 from the server side). No reconnect path will + // run. Show the legacy session-expired diagnostic so the + // user knows to reload. + console.warn('[App] SOCKET_CLOSED fired', { + isReady: isReadyRef.current, + reconnectInProgress: reconnectInProgressRef.current + }); + + if(!isReadyRef.current) + { + console.warn('[App] Socket closed before authentication completed — falling back to login'); + fallbackToLogin(); + return; + } + + if(reconnectInProgressRef.current) return; + + showSessionExpired(); + }); useMessageEvent(LoadGameUrlEvent, event => { @@ -317,11 +419,19 @@ export const App: FC<{}> = props => }, []); const onSessionExpired = useEffectEvent(() => showSessionExpired()); + const onInitFailure = useEffectEvent(() => fallbackToLogin()); useEffect(() => { const prepare = async (width: number, height: number) => { + console.warn('[App] prepare() start', { + hasNitroConfig: !!window.NitroConfig, + ssoTicketInConfig: !!window.NitroConfig?.['sso.ticket'], + hasRememberLocal: !!GetRememberLogin(), + urlSso: new URLSearchParams(window.location.search).get('sso') + }); + try { if(!window.NitroConfig) throw new Error('NitroConfig is not defined!'); @@ -346,6 +456,13 @@ export const App: FC<{}> = props => const rawLoginEnabled = GetConfiguration().getValue('login.screen.enabled', false); const loginScreenEnabled = rawLoginEnabled === true || rawLoginEnabled === 'true' || rawLoginEnabled === 1; + console.warn('[App] no SSO path — login gate', { + configInitError: configInitError ? String((configInitError as Error)?.message ?? configInitError) : null, + rawLoginEnabled, + rawLoginEnabledType: typeof rawLoginEnabled, + loginScreenEnabled + }); + if(configInitError) { NitroLogger.error('[LoginScreen] Failed to load renderer-config.json — cannot resolve login.screen.enabled', configInitError); @@ -432,12 +549,27 @@ export const App: FC<{}> = props => } catch(err) { - NitroLogger.error(err); - setIsEnteringHotel(false); - onSessionExpired(); + NitroLogger.error('[App] Initialization failed — falling back to login', err); + // Anything thrown out of the post-auth chain (renderer init, + // asset download, GetCommunication().init()) is an init/connect + // failure, not session expiration. The credential we used is + // suspect — drop it and present the login form so the user + // can retry instead of getting stuck on a stale "Session expired". + onInitFailure(); } }; + // React Strict Mode in dev runs every effect twice (mount → cleanup → mount). + // `prepare()` is full of one-shot side effects (renderer init, websocket + // connect, NitroConfig mutation) — calling it twice with the same trigger + // value causes the second pass to race with the first and clobber state + // (e.g. the second pass falls through to onSessionExpired while the first + // had just set showLogin=true). Guard by trigger value: skip duplicate + // runs at the same trigger, but still re-run when handleAuthenticated + // bumps prepareTrigger after a successful login. + if(lastPrepareTriggerRef.current === prepareTrigger) return; + lastPrepareTriggerRef.current = prepareTrigger; + const { width, height } = getViewportDimensions(); prepare(width, height); @@ -455,7 +587,13 @@ export const App: FC<{}> = props => 0 } message={ errorMessage } homeUrl={ homeUrl } /> } { !isReady && showLogin && } { isReady && } - + { /* Reconnect overlay must NOT render before we've actually entered + the hotel — otherwise the renderer's auto-retry on an initial + handshake failure (e.g. emulator unreachable) would cover the + login form with "Reconnecting..." → "Session expired" and the + user wouldn't be able to interact with the form we just put up + via fallbackToLogin. */ } + { isReady && } ); diff --git a/src/bootstrap.ts b/src/bootstrap.ts index 3aeda2f..fe1eb9a 100644 --- a/src/bootstrap.ts +++ b/src/bootstrap.ts @@ -1,3 +1,4 @@ +import { GetConfiguration } from '@nitrots/configuration'; import { configFileUrl, getClientMode, installSecureFetch } from './secure-assets'; const ensureMobileViewport = () => @@ -15,7 +16,6 @@ const ensureMobileViewport = () => }; ensureMobileViewport(); -installSecureFetch(); const setBootDebug = (message: string) => { @@ -30,8 +30,6 @@ const setBootDebug = (message: string) => {} }; -setBootDebug('boot: secure fetch installed'); - const deployBaseUrl = (): string => { try @@ -89,6 +87,9 @@ const loadClientMode = async () => await loadClientMode(); +installSecureFetch(); +setBootDebug('boot: secure fetch installed'); + const search = new URLSearchParams(window.location.search); const clientMode = getClientMode(); @@ -107,6 +108,21 @@ const clientMode = getClientMode(); setBootDebug('boot: NitroConfig assigned'); +// Load renderer-config.json + ui-config.json BEFORE rendering React. Otherwise +// the first paint triggers a flood of "Missing configuration key" warnings for +// every key components read synchronously (asset.url, login.endpoint, …) until +// prepare()'s deferred init() finally lands. Doing it here makes the config +// already populated by the time index.tsx mounts . +try +{ + await GetConfiguration().init(); + setBootDebug('boot: configuration init done'); +} +catch(error) +{ + setBootDebug(`boot: configuration init failed ${ error?.message || error }`); +} + import('./index') .then(() => setBootDebug('boot: app bundle imported')) .catch(error => diff --git a/src/secure-assets.ts b/src/secure-assets.ts index 7f7c170..e2fc71e 100644 --- a/src/secure-assets.ts +++ b/src/secure-assets.ts @@ -123,15 +123,19 @@ const REKEY_ENDPOINTS = new Set([ '/api/auth/logout' ]); +let clientModeCache: NitroClientMode | null = null; + export const getClientMode = (): NitroClientMode => { + if(clientModeCache) return clientModeCache; + try { const configured = (window as any).__nitroClientMode; if(configured && typeof configured === 'object') { - return { + clientModeCache = { distObfuscationEnabled: configured.distObfuscationEnabled !== false, secureAssetsEnabled: configured.secureAssetsEnabled !== false, secureApiEnabled: configured.secureApiEnabled !== false, @@ -139,12 +143,14 @@ export const getClientMode = (): NitroClientMode => plainConfigBaseUrl: typeof configured.plainConfigBaseUrl === 'string' ? configured.plainConfigBaseUrl : '', plainGamedataBaseUrl: typeof configured.plainGamedataBaseUrl === 'string' ? configured.plainGamedataBaseUrl : '' }; + + return clientModeCache; } } catch {} - return { ...CLIENT_MODE_DEFAULTS }; + return CLIENT_MODE_DEFAULTS; }; const bytesToBase64 = (bytes: ArrayBuffer): string => @@ -489,6 +495,14 @@ export const installSecureFetch = (): void => { if(installed) return; + const mode = getClientMode(); + + if(!mode.secureAssetsEnabled && !mode.secureApiEnabled) + { + installed = true; + return; + } + installed = true; const nativeFetch = window.fetch.bind(window); diff --git a/vite.config.mjs b/vite.config.mjs index 29fe0fa..56ed2c6 100644 --- a/vite.config.mjs +++ b/vite.config.mjs @@ -2,11 +2,56 @@ import react from '@vitejs/plugin-react'; import { existsSync } from 'fs'; import { resolve } from 'path'; import { defineConfig } from 'vite'; +import sirv from 'sirv'; const legacyRendererRoot = resolve(__dirname, '..', 'renderer'); const currentRendererRoot = resolve(__dirname, '..', 'Nitro_Render_V3'); const rendererRoot = existsSync(currentRendererRoot) ? currentRendererRoot : legacyRendererRoot; +// Game assets live outside the repo, in a sibling directory next to Nitro-V3. +// They are NOT placed under public/ on purpose: with ~177k files a symlink +// under public/ makes chokidar try to install a watcher on each one and the +// dev server takes minutes to start on Windows. Serving them with a +// dedicated sirv middleware (below) bypasses chokidar entirely. +const nitroFilesRoot = resolve(__dirname, '..', 'Nitro-Files'); +const nitroAssetsRoot = resolve(nitroFilesRoot, 'nitro-assets'); +const swfRoot = resolve(nitroFilesRoot, 'swf'); + +const nitroAssetsServer = () => ({ + name: 'nitro-assets-serve', + configureServer(server) + { + if(existsSync(nitroAssetsRoot)) + { + server.middlewares.use('/nitro-assets', sirv(nitroAssetsRoot, { dev: true, etag: true, maxAge: 0 })); + } + else + { + server.config.logger.warn(`[nitro-assets-serve] ${ nitroAssetsRoot } not found — /nitro-assets/* requests will 404.`); + } + + if(existsSync(swfRoot)) + { + server.middlewares.use('/swf', sirv(swfRoot, { dev: true, etag: true, maxAge: 0 })); + } + else + { + server.config.logger.warn(`[nitro-assets-serve] ${ swfRoot } not found — /swf/* requests will 404.`); + } + }, + configurePreviewServer(server) + { + if(existsSync(nitroAssetsRoot)) + { + server.middlewares.use('/nitro-assets', sirv(nitroAssetsRoot, { dev: false, etag: true })); + } + if(existsSync(swfRoot)) + { + server.middlewares.use('/swf', sirv(swfRoot, { dev: false, etag: true })); + } + } +}); + if(!existsSync(rendererRoot)) { // Fail fast with a useful message instead of waiting for Rolldown to @@ -36,7 +81,8 @@ export default defineConfig({ [ 'babel-plugin-react-compiler', ReactCompilerConfig ] ] } - }) + }), + nitroAssetsServer() ], server: { fs: { @@ -47,7 +93,7 @@ export default defineConfig({ }, proxy: { '/api': { - target: process.env.AUTH_PROXY_TARGET || 'http://192.168.0.181:2096', + target: process.env.AUTH_PROXY_TARGET || 'http://localhost:2096', changeOrigin: true, } } diff --git a/yarn.lock b/yarn.lock index cd86ce1..80a5588 100644 --- a/yarn.lock +++ b/yarn.lock @@ -701,6 +701,11 @@ "@parcel/watcher-win32-ia32" "2.5.6" "@parcel/watcher-win32-x64" "2.5.6" +"@polka/url@^1.0.0-next.24": + version "1.0.0-next.29" + resolved "https://registry.yarnpkg.com/@polka/url/-/url-1.0.0-next.29.tgz#5a40109a1ab5f84d6fd8fc928b19f367cbe7e7b1" + integrity sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww== + "@radix-ui/number@1.1.1": version "1.1.1" resolved "https://registry.yarnpkg.com/@radix-ui/number/-/number-1.1.1.tgz#7b2c9225fbf1b126539551f5985769d0048d9090" @@ -3096,6 +3101,11 @@ motion-utils@^12.36.0: resolved "https://registry.yarnpkg.com/motion-utils/-/motion-utils-12.36.0.tgz#cff2df2a28c3fe53a3de7e0103ba7f73ff7d77a7" integrity sha512-eHWisygbiwVvf6PZ1vhaHCLamvkSbPIeAYxWUuL3a2PD/TROgE7FvfHWTIH4vMl798QLfMw15nRqIaRDXTlYRg== +mrmime@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/mrmime/-/mrmime-2.0.1.tgz#bc3e87f7987853a54c9850eeb1f1078cd44adddc" + integrity sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ== + ms@^2.1.3: version "2.1.3" resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" @@ -3665,6 +3675,15 @@ siginfo@^2.0.0: resolved "https://registry.yarnpkg.com/siginfo/-/siginfo-2.0.0.tgz#32e76c70b79724e3bb567cb9d543eb858ccfaf30" integrity sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g== +sirv@^3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/sirv/-/sirv-3.0.2.tgz#f775fccf10e22a40832684848d636346f41cd970" + integrity sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g== + dependencies: + "@polka/url" "^1.0.0-next.24" + mrmime "^2.0.0" + totalist "^3.0.0" + "source-map-js@>=0.6.2 <2.0.0", source-map-js@^1.2.1: version "1.2.1" resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.2.1.tgz#1ce5650fddd87abc099eda37dcff024c2667ae46" @@ -3826,6 +3845,11 @@ tldts@^7.0.5: dependencies: tldts-core "^7.0.30" +totalist@^3.0.0: + version "3.0.1" + resolved "https://registry.yarnpkg.com/totalist/-/totalist-3.0.1.tgz#ba3a3d600c915b1a97872348f79c127475f6acf8" + integrity sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ== + tough-cookie@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-6.0.1.tgz#a495f833836609ed983c19bc65639cfbceb54c76"