From d2e4aaaa0049b774f277d8bbc66856926dbf01bc Mon Sep 17 00:00:00 2001 From: MasterOfKay <144215097+MasterOfKay@users.noreply.github.com> Date: Wed, 5 Aug 2026 02:19:51 +0200 Subject: [PATCH 1/4] Scrollbar selecting fix and game not launching (on huge row libary fix) --- packages/app/src/views/Games/Games.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/app/src/views/Games/Games.js b/packages/app/src/views/Games/Games.js index e0c89da..d541e28 100644 --- a/packages/app/src/views/Games/Games.js +++ b/packages/app/src/views/Games/Games.js @@ -80,7 +80,9 @@ const Games = ({library, onSelectGame, onHome, backHandlerRef}) => { return (

{library?.Name || $L('Games')}

- + // Scrollbar to CSS makes it not selectable (smoother goinging down the list) + // This also made the Game Card (If the Libary was long enough), not visiable or just for a frame, and then it jumped to the scrollbar. + {rows.map((system) => { const games = gamesBySystem[system.id] || []; return ( From d55e374742132a1bfafc8dc36d9df138e3901175 Mon Sep 17 00:00:00 2001 From: MasterOfKay <144215097+MasterOfKay@users.noreply.github.com> Date: Wed, 5 Aug 2026 03:42:39 +0200 Subject: [PATCH 2/4] Fixed code comment, and added game loggings to better see what is going on, plus fixed a bug where game types won't open --- packages/app/src/services/gamesApi.js | 56 ++++++++- packages/app/src/utils/emulatorjs.js | 110 +++++++++++++++++- .../app/src/views/GamePlayer/GamePlayer.js | 18 ++- packages/app/src/views/Games/Games.js | 4 +- 4 files changed, 175 insertions(+), 13 deletions(-) diff --git a/packages/app/src/services/gamesApi.js b/packages/app/src/services/gamesApi.js index a094235..e3836be 100644 --- a/packages/app/src/services/gamesApi.js +++ b/packages/app/src/services/gamesApi.js @@ -6,8 +6,12 @@ import {getServerUrl, getAuthHeader, getApiKey, getTokenParam} from './jellyfinApi'; import {platformFetch} from './secureFetch'; +import serverLogger from './serverLogger'; import {fetchWithTimeout} from '../utils/fetchTimeout'; +const logGames = (message, context) => + serverLogger.debug(serverLogger.LOG_CATEGORIES.APP, `[Games] ${message}`, context); + // A stable per-user id for the global settings blob (settings are not per game). export const SETTINGS_ID = 'moonfin-global'; @@ -59,8 +63,15 @@ const blobUrl = async (path) => { err.status = res.status; throw err; } - const blob = await res.blob(); - return URL.createObjectURL(blob); + // Buffering full N64 games crashes the TV. For NES games this works, but anything bigger then roghly 6mb will fail or crash. + const expected = Number(res.headers.get('content-length')) || null; + try { + const blob = await res.blob(); + return URL.createObjectURL(blob); + } catch (e) { + logGames('buffering the file failed', {path, expectedBytes: expected, message: e.message || String(e)}); + throw e; + } }; export const getRomBlobUrl = (libraryId, gameId) => @@ -68,6 +79,47 @@ export const getRomBlobUrl = (libraryId, gameId) => export const getBiosBlobUrl = (libraryId, biosId) => blobUrl(`${enc(libraryId)}/Bios/${enc(biosId)}`); +// Returns the direct URL for a ROM, if available. +const romDirectUrl = (libraryId, gameId) => { + const token = getApiKey(); + if (!token) return null; + return `${base()}/Moonfin/Games/${enc(libraryId)}/Rom/${enc(gameId)}?${getTokenParam()}=${enc(token)}`; +}; + +// Probes a ROM URL to determine its size and availability. +const probeRom = async (url) => { + try { + const res = await fetchWithTimeout(url, {headers: {Range: 'bytes=0-0'}}, 15000); + if (res.status !== 206 && !res.ok) return {ok: false, totalBytes: null}; + const range = res.headers.get('content-range'); + const total = range && range.split('/')[1]; + return {ok: true, totalBytes: Number(total) || Number(res.headers.get('content-length')) || null}; + } catch (e) { + return {ok: false, totalBytes: null}; + } +}; + +// Maximum allowed size for a ROM file (48 MB). This is just a guess number from some testing. Needs better testing. anything more crashes TV +const MAX_ROM_BYTES = 48 * 1024 * 1024; + +// Returns {url, isBlob} for the ROM. +export const getRomUrl = async (libraryId, gameId) => { + const direct = romDirectUrl(libraryId, gameId); + const probe = direct ? await probeRom(direct) : {ok: false, totalBytes: null}; + if (probe.totalBytes && probe.totalBytes > MAX_ROM_BYTES) { + logGames('rom refused as too large', {gameId, totalBytes: probe.totalBytes, limit: MAX_ROM_BYTES}); + const err = new Error('rom-too-large'); + err.romTooLarge = true; + throw err; + } + if (probe.ok) { + logGames('serving rom by url', {gameId, totalBytes: probe.totalBytes}); + return {url: direct, isBlob: false}; + } + logGames('serving rom as blob', {gameId, reason: direct ? 'query auth refused' : 'no token'}); + return {url: await getRomBlobUrl(libraryId, gameId), isBlob: true}; +}; + // Save state (binary) keyed per game. Returns null when none exists (404). export const getStateBytes = async (gameId) => { try { diff --git a/packages/app/src/utils/emulatorjs.js b/packages/app/src/utils/emulatorjs.js index 43a244d..f6c9f96 100644 --- a/packages/app/src/utils/emulatorjs.js +++ b/packages/app/src/utils/emulatorjs.js @@ -6,11 +6,19 @@ import $L from '@enact/i18n/$L'; +import serverLogger from '../services/serverLogger'; import {isWebOS, isTizen} from '../platform'; +const logGames = (message, context) => + serverLogger.debug(serverLogger.LOG_CATEGORIES.APP, `[Games] ${message}`, context); +const logGamesError = (message, context) => + serverLogger.error(serverLogger.LOG_CATEGORIES.APP, `[Games] ${message}`, context, false); + const CDN = 'https://cdn.emulatorjs.org/stable/data/'; let loaderScript = null; +// Skipp loading on second run of the game, to make it faster. +let loadedConfig = null; // EmulatorJS cores are WebAssembly, which needs Chromium 57+. Older WebViews (webOS 4 and // below at Chrome 53, Tizen 4 and below at Chrome 56) lack it entirely, so games can't run @@ -63,12 +71,55 @@ export const unsupportedMessage = () => { // so reject after this timeout and let the caller show an error instead of hanging. const READY_TIMEOUT = 40000; +// Shows the current status in logs +const emulatorStatusText = () => { + try { + const emu = window.EJS_emulator; + return (emu && emu.textElem && emu.textElem.innerText) || null; + } catch (e) { + return null; + } +}; + // Starts EmulatorJS in the element matching `selector` and resolves once the core is ready. export const startEmulator = ({selector, core, gameUrl, biosUrl, gameName, settingsJson}) => new Promise((resolve, reject) => { if (settingsJson) { try { window.localStorage.setItem('ejs-settings', settingsJson); } catch (e) { /* ignore */ } } + + const startedAt = Date.now(); + // If errors during boot happend log them. + const onWindowError = (ev) => logGamesError('window error during emulator boot', { + core, + message: ev.message || String(ev.error || ''), + source: ev.filename ? `${ev.filename}:${ev.lineno}` : null + }); + const onRejection = (ev) => logGamesError('unhandled rejection during emulator boot', { + core, + reason: String((ev.reason && (ev.reason.message || ev.reason)) || '') + }); + window.addEventListener('error', onWindowError); + window.addEventListener('unhandledrejection', onRejection); + const stopWatching = () => { + window.removeEventListener('error', onWindowError); + window.removeEventListener('unhandledrejection', onRejection); + }; + + let shader = null; + try { shader = settingsJson ? JSON.parse(settingsJson).shader : null; } catch (e) { /* not JSON */ } + logGames('starting emulator', { + core, + gameName, + bios: Boolean(biosUrl), + cdn: CDN, + // SharedArrayBuffer is only available in cross-origin isolated contexts, which the app isn't. + threads: false, + sharedArrayBuffer: typeof SharedArrayBuffer !== 'undefined', + crossOriginIsolated: typeof window.crossOriginIsolated === 'boolean' ? window.crossOriginIsolated : null, + // Blobs are supported on all the platforms and run bigger files for N64 and so on. + shader + }); window.EJS_player = selector; window.EJS_core = core; window.EJS_gameUrl = gameUrl; @@ -81,12 +132,55 @@ export const startEmulator = ({selector, core, gameUrl, biosUrl, gameName, setti // No touch screen on TV; keep the on-screen pad off. window.EJS_defaultOptions = Object.assign({}, window.EJS_defaultOptions, {'virtual-gamepad': 'disabled'}); - const timer = setTimeout(() => reject(new Error('emulator-load-timeout')), READY_TIMEOUT); - window.EJS_ready = () => { clearTimeout(timer); resolve(); }; + const timer = setTimeout(() => { + stopWatching(); + logGamesError('emulator never became ready', { + core, + waitedMs: READY_TIMEOUT, + // EmulatorJS logging its own status + emulatorStatus: emulatorStatusText() + }); + reject(new Error('emulator-load-timeout')); + }, READY_TIMEOUT); + window.EJS_ready = () => { + clearTimeout(timer); + stopWatching(); + const reused = Boolean(loadedConfig); + try { loadedConfig = (window.EJS_emulator && window.EJS_emulator.config) || loadedConfig; } catch (e) { /* ignore */ } + logGames('emulator ready', {core, ms: Date.now() - startedAt, reused}); + resolve(); + }; + + // guard for running same game twice (happedn me). + if (loadedConfig && typeof EmulatorJS !== 'undefined') { + try { + const config = Object.assign({}, loadedConfig, { + gameUrl, + system: core, + biosUrl: biosUrl || '', + gameName: gameName || '' + }); + window.EJS_emulator = new EmulatorJS(selector, config); + logGames('reusing loaded emulator scripts', {core}); + return; + } catch (e) { + logGamesError('reusing loaded emulator scripts failed, falling back to loader', { + core, + message: e.message || String(e) + }); + } + } - loaderScript = document.createElement('script'); - loaderScript.src = CDN + 'loader.js'; - document.body.appendChild(loaderScript); + const script = document.createElement('script'); + script.src = CDN + 'loader.js'; + script.onerror = () => { + clearTimeout(timer); + stopWatching(); + logGamesError('loader.js failed to download', {core, url: script.src}); + reject(new Error('emulator-loader-unreachable')); + }; + loaderScript = script; + document.body.appendChild(script); }); const gm = () => window.EJS_emulator && window.EJS_emulator.gameManager; @@ -158,6 +252,12 @@ export const getOptions = () => { // Tears the emulator down: stops the loop, clears the container, drops EJS globals + loader. export const destroyEmulator = () => { + // If game doesn't boot repot status + logGames('tearing down emulator', { + core: window.EJS_core || null, + booted: Boolean(gm()), + emulatorStatus: emulatorStatusText() + }); try { setPaused(true); } catch (e) { /* ignore */ } try { const el = document.querySelector(window.EJS_player || '#game'); diff --git a/packages/app/src/views/GamePlayer/GamePlayer.js b/packages/app/src/views/GamePlayer/GamePlayer.js index 353e605..e49193f 100644 --- a/packages/app/src/views/GamePlayer/GamePlayer.js +++ b/packages/app/src/views/GamePlayer/GamePlayer.js @@ -7,6 +7,7 @@ import SpotlightContainerDecorator from '@enact/spotlight/SpotlightContainerDeco import AdminMessageDialog from '../../components/AdminMessageDialog'; import LoadingSpinner from '../../components/LoadingSpinner'; import * as gamesApi from '../../services/gamesApi'; +import serverLogger from '../../services/serverLogger'; import {initVideo, keepScreenOn, setupVisibilityHandler} from '../../services/video'; import * as ejs from '../../utils/emulatorjs'; @@ -66,13 +67,14 @@ const GamePlayer = ({library, game, startFresh, onBack, backHandlerRef}) => { const libraryId = library?.Id; (async () => { try { - const [romUrl, settingsJson, existing] = await Promise.all([ - gamesApi.getRomBlobUrl(libraryId, game.id), + const [rom, settingsJson, existing] = await Promise.all([ + gamesApi.getRomUrl(libraryId, game.id), gamesApi.getSettingsBlob(), startFresh ? Promise.resolve(null) : gamesApi.getStateBytes(game.id) ]); if (cancelled) return; - blobs.current.push(romUrl); + const romUrl = rom.url; + if (rom.isBlob) blobs.current.push(romUrl); let biosUrl; if (game.bios && game.bios.length) { biosUrl = await gamesApi.getBiosBlobUrl(libraryId, game.bios[0].id); @@ -92,7 +94,15 @@ const GamePlayer = ({library, game, startFresh, onBack, backHandlerRef}) => { if (existing) { try { ejs.loadState(existing); } catch (e) { /* ignore */ } } setReady(true); } catch (e) { - if (!cancelled) setError(e.status === 404 ? $L('Game file not found.') : $L('Could not start this game on this device.')); + serverLogger.error(serverLogger.LOG_CATEGORIES.APP, '[Games] could not start game', { + core: game.core, + system: game.system, + status: e.status || null, + message: e.message || String(e) + }, false); + if (cancelled) return; + if (e.romTooLarge) setError($L('This game is too large to run on this TV.')); + else setError(e.status === 404 ? $L('Game file not found.') : $L('Could not start this game on this device.')); } })(); return () => { diff --git a/packages/app/src/views/Games/Games.js b/packages/app/src/views/Games/Games.js index d541e28..7edd4e2 100644 --- a/packages/app/src/views/Games/Games.js +++ b/packages/app/src/views/Games/Games.js @@ -80,8 +80,8 @@ const Games = ({library, onSelectGame, onHome, backHandlerRef}) => { return (

{library?.Name || $L('Games')}

- // Scrollbar to CSS makes it not selectable (smoother goinging down the list) - // This also made the Game Card (If the Libary was long enough), not visiable or just for a frame, and then it jumped to the scrollbar. + {/* Scrollbar to CSS makes it not selectable (smoother goinging down the list). + This also made the Game Card (If the Libary was long enough), not visiable or just for a frame, and then it jumped to the scrollbar. */} {rows.map((system) => { const games = gamesBySystem[system.id] || []; From c99bf17f8113c41283a1872d8ce117969c63c933 Mon Sep 17 00:00:00 2001 From: MasterOfKay <144215097+MasterOfKay@users.noreply.github.com> Date: Wed, 5 Aug 2026 03:47:46 +0200 Subject: [PATCH 3/4] This shpuld fix the picky JS constructor on Build --- packages/app/src/utils/emulatorjs.js | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/packages/app/src/utils/emulatorjs.js b/packages/app/src/utils/emulatorjs.js index f6c9f96..4960c96 100644 --- a/packages/app/src/utils/emulatorjs.js +++ b/packages/app/src/utils/emulatorjs.js @@ -19,6 +19,8 @@ const CDN = 'https://cdn.emulatorjs.org/stable/data/'; let loaderScript = null; // Skipp loading on second run of the game, to make it faster. let loadedConfig = null; +// emulator constructor, which is only available after the core is loaded. +let EmulatorCtor = null; // EmulatorJS cores are WebAssembly, which needs Chromium 57+. Older WebViews (webOS 4 and // below at Chrome 53, Tizen 4 and below at Chrome 56) lack it entirely, so games can't run @@ -146,13 +148,16 @@ export const startEmulator = ({selector, core, gameUrl, biosUrl, gameName, setti clearTimeout(timer); stopWatching(); const reused = Boolean(loadedConfig); - try { loadedConfig = (window.EJS_emulator && window.EJS_emulator.config) || loadedConfig; } catch (e) { /* ignore */ } + try { + loadedConfig = (window.EJS_emulator && window.EJS_emulator.config) || loadedConfig; + EmulatorCtor = (window.EJS_emulator && window.EJS_emulator.constructor) || EmulatorCtor; + } catch (e) { /* ignore */ } logGames('emulator ready', {core, ms: Date.now() - startedAt, reused}); resolve(); }; // guard for running same game twice (happedn me). - if (loadedConfig && typeof EmulatorJS !== 'undefined') { + if (loadedConfig && EmulatorCtor) { try { const config = Object.assign({}, loadedConfig, { gameUrl, @@ -160,7 +165,7 @@ export const startEmulator = ({selector, core, gameUrl, biosUrl, gameName, setti biosUrl: biosUrl || '', gameName: gameName || '' }); - window.EJS_emulator = new EmulatorJS(selector, config); + window.EJS_emulator = new EmulatorCtor(selector, config); logGames('reusing loaded emulator scripts', {core}); return; } catch (e) { From 37fcbc3f10f432e408a55c3764d7b7e29869f576 Mon Sep 17 00:00:00 2001 From: RadicalMuffinMan <103554043+RadicalMuffinMan@users.noreply.github.com> Date: Wed, 5 Aug 2026 14:00:02 -0400 Subject: [PATCH 4/4] Fixed game launching and scrollbar focus in game libraries --- packages/app/resources/strings.json | 1 + packages/app/src/services/gamesApi.js | 92 +++++++++------ packages/app/src/services/gamesApi.test.js | 99 ++++++++++++++++ packages/app/src/utils/emulatorjs.js | 107 ++++++++---------- .../app/src/views/GamePlayer/GamePlayer.js | 8 +- packages/app/src/views/Games/Games.js | 4 +- 6 files changed, 213 insertions(+), 98 deletions(-) create mode 100644 packages/app/src/services/gamesApi.test.js diff --git a/packages/app/resources/strings.json b/packages/app/resources/strings.json index ac990c0..ec1f4ab 100644 --- a/packages/app/resources/strings.json +++ b/packages/app/resources/strings.json @@ -665,6 +665,7 @@ "This TV runs Tizen {version}. Games require Tizen 5 or newer.": "This TV runs Tizen {version}. Games require Tizen 5 or newer.", "This TV runs webOS {version}. Games require webOS 5 or newer.": "This TV runs webOS {version}. Games require webOS 5 or newer.", "This core has no adjustable options.": "This core has no adjustable options.", + "This game is too large to run on this TV.": "This game is too large to run on this TV.", "Thumb": "Thumb", "Timeout": "Timeout", "Tizen Version": "Tizen Version", diff --git a/packages/app/src/services/gamesApi.js b/packages/app/src/services/gamesApi.js index e3836be..cafe576 100644 --- a/packages/app/src/services/gamesApi.js +++ b/packages/app/src/services/gamesApi.js @@ -1,17 +1,16 @@ // Client for the Moonbase plugin retro-games (EmulatorJS) endpoints under /Moonfin/Games. // JSON calls route through platformFetch (the webOS Let's-Encrypt TLS proxy) so metadata and -// the settings blob work on old webOS. ROM/BIOS and the binary save state use native fetch and -// a Blob URL (the proxy is text-only), so they inherit the same old-webOS+LE limitation as -// video playback; cores are loaded from the trusted-cert CDN and work everywhere. +// the settings blob work on old webOS. A ROM is served as a direct server URL carrying the +// token in the query, which lets EmulatorJS stream the file instead of the app buffering it, +// and falls back to a Blob URL where that request does not get through. BIOS files and the +// binary save state always use native fetch and a Blob URL (the proxy is text-only), so they +// inherit the same old-webOS+LE limitation as video playback. Cores load from the +// trusted-cert CDN and work everywhere. import {getServerUrl, getAuthHeader, getApiKey, getTokenParam} from './jellyfinApi'; import {platformFetch} from './secureFetch'; -import serverLogger from './serverLogger'; import {fetchWithTimeout} from '../utils/fetchTimeout'; -const logGames = (message, context) => - serverLogger.debug(serverLogger.LOG_CATEGORIES.APP, `[Games] ${message}`, context); - // A stable per-user id for the global settings blob (settings are not per game). export const SETTINGS_ID = 'moonfin-global'; @@ -53,8 +52,31 @@ export const gameThumbUrl = (libraryId, gameId, kind = 'boxart') => { return `${base()}/Moonfin/Games/${enc(libraryId)}/Thumb/${enc(gameId)}?type=${enc(kind)}${auth}`; }; -// ROM / BIOS as a same-origin Blob URL (avoids CORS; EmulatorJS fetches the blob directly). -const blobUrl = async (path) => { +// The largest ROM a TV will take. Buffering is what kills it, since the response, the Blob and +// the copy EmulatorJS keeps all sit in the WebView heap at once and past this the app is killed +// part-way through loading. Measured on a device rather than taken from a published limit, so +// it is deliberately conservative. +const MAX_ROM_BYTES = 48 * 1024 * 1024; + +const romTooLarge = (bytes) => { + const err = new Error('rom-too-large'); + err.romTooLarge = true; + err.totalBytes = bytes; + return err; +}; + +// Drops a response once its headers have been read, so nothing is left streaming a body that +// will never be used. Older WebViews have no res.body, hence the guard. +const discardBody = (res) => { + try { + if (res.body && res.body.cancel) res.body.cancel(); + } catch (e) { /* already closed */ } +}; + +// ROM / BIOS as a same-origin Blob URL, which avoids CORS since EmulatorJS fetches the blob +// directly. maxBytes refuses an oversized file before it is buffered, because the failure +// happens inside res.blob(), where the TV can take the whole app down with it. +const blobUrl = async (path, maxBytes) => { const res = await fetchWithTimeout(`${base()}/Moonfin/Games/${path}`, { headers: authHeaders() }, 60000); @@ -63,60 +85,58 @@ const blobUrl = async (path) => { err.status = res.status; throw err; } - // Buffering full N64 games crashes the TV. For NES games this works, but anything bigger then roghly 6mb will fail or crash. const expected = Number(res.headers.get('content-length')) || null; - try { - const blob = await res.blob(); - return URL.createObjectURL(blob); - } catch (e) { - logGames('buffering the file failed', {path, expectedBytes: expected, message: e.message || String(e)}); - throw e; + if (maxBytes && expected && expected > maxBytes) { + discardBody(res); + throw romTooLarge(expected); } + const blob = await res.blob(); + return URL.createObjectURL(blob); }; -export const getRomBlobUrl = (libraryId, gameId) => - blobUrl(`${enc(libraryId)}/Rom/${enc(gameId)}`); +const getRomBlobUrl = (libraryId, gameId) => + blobUrl(`${enc(libraryId)}/Rom/${enc(gameId)}`, MAX_ROM_BYTES); +// A BIOS is a few hundred kilobytes at most, so there is nothing to refuse on size. export const getBiosBlobUrl = (libraryId, biosId) => blobUrl(`${enc(libraryId)}/Bios/${enc(biosId)}`); -// Returns the direct URL for a ROM, if available. +// The ROM endpoint takes the token in the query, which is how EmulatorJS's own XHR +// authenticates. Null when there is no token to put there. const romDirectUrl = (libraryId, gameId) => { const token = getApiKey(); if (!token) return null; return `${base()}/Moonfin/Games/${enc(libraryId)}/Rom/${enc(gameId)}?${getTokenParam()}=${enc(token)}`; }; -// Probes a ROM URL to determine its size and availability. +// Asks for the first byte only. The endpoint streams with range processing on, so a 206 comes +// back carrying the full size in Content-Range and this costs one byte instead of the ROM. const probeRom = async (url) => { try { const res = await fetchWithTimeout(url, {headers: {Range: 'bytes=0-0'}}, 15000); + discardBody(res); if (res.status !== 206 && !res.ok) return {ok: false, totalBytes: null}; const range = res.headers.get('content-range'); const total = range && range.split('/')[1]; - return {ok: true, totalBytes: Number(total) || Number(res.headers.get('content-length')) || null}; + // Content-Length on a 206 describes the one byte asked for, so it only stands in for the + // size when the server ignored the range and answered with the whole file. + const whole = res.status === 206 ? null : Number(res.headers.get('content-length')); + return {ok: true, totalBytes: Number(total) || whole || null}; } catch (e) { return {ok: false, totalBytes: null}; } }; -// Maximum allowed size for a ROM file (48 MB). This is just a guess number from some testing. Needs better testing. anything more crashes TV -const MAX_ROM_BYTES = 48 * 1024 * 1024; - -// Returns {url, isBlob} for the ROM. +// Returns {url, isBlob} for a game's ROM. The direct URL is preferred, because EmulatorJS then +// streams the file itself and no second copy passes through the app. The Blob URL covers the +// platforms where a plain fetch to the server does not get through, such as old webOS behind +// Let's Encrypt, and isBlob tells the caller whether it has a URL to revoke afterwards. export const getRomUrl = async (libraryId, gameId) => { const direct = romDirectUrl(libraryId, gameId); const probe = direct ? await probeRom(direct) : {ok: false, totalBytes: null}; - if (probe.totalBytes && probe.totalBytes > MAX_ROM_BYTES) { - logGames('rom refused as too large', {gameId, totalBytes: probe.totalBytes, limit: MAX_ROM_BYTES}); - const err = new Error('rom-too-large'); - err.romTooLarge = true; - throw err; - } - if (probe.ok) { - logGames('serving rom by url', {gameId, totalBytes: probe.totalBytes}); - return {url: direct, isBlob: false}; - } - logGames('serving rom as blob', {gameId, reason: direct ? 'query auth refused' : 'no token'}); + if (probe.totalBytes && probe.totalBytes > MAX_ROM_BYTES) throw romTooLarge(probe.totalBytes); + if (probe.ok) return {url: direct, isBlob: false}; + // A failed probe leaves the size unknown, so getRomBlobUrl applies the same ceiling from + // Content-Length. Falling back must not mean skipping the check. return {url: await getRomBlobUrl(libraryId, gameId), isBlob: true}; }; diff --git a/packages/app/src/services/gamesApi.test.js b/packages/app/src/services/gamesApi.test.js new file mode 100644 index 0000000..a1dcd9c --- /dev/null +++ b/packages/app/src/services/gamesApi.test.js @@ -0,0 +1,99 @@ +import {getRomUrl} from './gamesApi'; +import {fetchWithTimeout} from '../utils/fetchTimeout'; + +let mockToken = 'key'; + +jest.mock('../utils/fetchTimeout', () => ({fetchWithTimeout: jest.fn()})); +jest.mock('./secureFetch', () => ({platformFetch: jest.fn()})); +jest.mock('./jellyfinApi', () => ({ + getServerUrl: () => 'https://server', + getAuthHeader: () => 'MediaBrowser Token="t"', + getApiKey: () => mockToken, + getTokenParam: () => 'ApiKey' +})); + +const MB = 1024 * 1024; + +const response = ({status = 200, headers = {}} = {}) => { + const lower = {}; + Object.keys(headers).forEach((k) => { lower[k.toLowerCase()] = headers[k]; }); + return { + status, + ok: status >= 200 && status < 300, + headers: {get: (name) => (name.toLowerCase() in lower ? lower[name.toLowerCase()] : null)}, + body: {cancel: jest.fn()}, + blob: () => Promise.resolve({size: 1}) + }; +}; + +// A 206 to the one-byte probe, reporting `total` as the size of the whole ROM. +const probeOf = (total) => response({ + status: 206, + headers: {'content-range': `bytes 0-0/${total}`, 'content-length': '1'} +}); + +describe('getRomUrl', () => { + beforeEach(() => { + fetchWithTimeout.mockReset(); + mockToken = 'key'; + global.URL.createObjectURL = jest.fn(() => 'blob:rom'); + }); + + test('serves the direct url when the probed size is under the ceiling', async () => { + fetchWithTimeout.mockResolvedValueOnce(probeOf(4 * MB)); + + const rom = await getRomUrl('lib', 'game'); + + expect(rom.isBlob).toBe(false); + expect(rom.url).toBe('https://server/Moonfin/Games/lib/Rom/game?ApiKey=key'); + // Only the probe went out, so nothing was buffered. + expect(fetchWithTimeout).toHaveBeenCalledTimes(1); + }); + + test('refuses a rom past the ceiling instead of buffering it', async () => { + fetchWithTimeout.mockResolvedValueOnce(probeOf(96 * MB)); + + await expect(getRomUrl('lib', 'game')).rejects.toMatchObject({romTooLarge: true}); + expect(fetchWithTimeout).toHaveBeenCalledTimes(1); + }); + + test('reads the size off Content-Range, not the one byte the probe asked for', async () => { + fetchWithTimeout.mockResolvedValueOnce(response({status: 206, headers: {'content-length': '1'}})); + + const rom = await getRomUrl('lib', 'game'); + + // No Content-Range means the size stayed unknown, so the 1 must not pass as the total. + expect(rom).toEqual({url: 'https://server/Moonfin/Games/lib/Rom/game?ApiKey=key', isBlob: false}); + }); + + test('falls back to a blob url when the probe cannot reach the server', async () => { + fetchWithTimeout + .mockRejectedValueOnce(new Error('network')) + .mockResolvedValueOnce(response({headers: {'content-length': String(4 * MB)}})); + + const rom = await getRomUrl('lib', 'game'); + + expect(rom).toEqual({url: 'blob:rom', isBlob: true}); + }); + + test('applies the ceiling to the blob fallback, where the size is only known from the headers', async () => { + fetchWithTimeout + .mockRejectedValueOnce(new Error('network')) + .mockResolvedValueOnce(response({headers: {'content-length': String(96 * MB)}})); + + await expect(getRomUrl('lib', 'game')).rejects.toMatchObject({romTooLarge: true}); + expect(global.URL.createObjectURL).not.toHaveBeenCalled(); + }); + + test('goes straight to the blob when there is no token to put in the query', async () => { + mockToken = null; + fetchWithTimeout.mockResolvedValueOnce(response({headers: {'content-length': String(2 * MB)}})); + + const rom = await getRomUrl('lib', 'game'); + + expect(rom.isBlob).toBe(true); + // The probe is skipped entirely, so the blob fetch is the first request. + expect(fetchWithTimeout).toHaveBeenCalledTimes(1); + expect(fetchWithTimeout.mock.calls[0][0]).toBe('https://server/Moonfin/Games/lib/Rom/game'); + }); +}); diff --git a/packages/app/src/utils/emulatorjs.js b/packages/app/src/utils/emulatorjs.js index 4960c96..8d3f644 100644 --- a/packages/app/src/utils/emulatorjs.js +++ b/packages/app/src/utils/emulatorjs.js @@ -1,8 +1,8 @@ -// Same-origin EmulatorJS control glue. Ports the Moonbase plugin player.html script so the -// enact app drives EmulatorJS directly (no iframe / postMessage). Loader + WASM cores come -// from the trusted-cert CDN (works on old webOS); the ROM/BIOS are Blob URLs the app already -// fetched. Threads are off (no cross-origin isolation on app:// / file://), so single-threaded -// cores only. +// EmulatorJS control glue. Ports the Moonbase plugin player.html script so the enact app +// drives EmulatorJS directly (no iframe / postMessage). Loader + WASM cores come from the +// trusted-cert CDN and work on old webOS. The ROM is either a direct server URL EmulatorJS +// streams itself or a Blob URL the app already fetched, and the BIOS is always a Blob URL. +// Threads are off (no cross-origin isolation on app:// / file://), so single-threaded cores only. import $L from '@enact/i18n/$L'; @@ -17,9 +17,9 @@ const logGamesError = (message, context) => const CDN = 'https://cdn.emulatorjs.org/stable/data/'; let loaderScript = null; -// Skipp loading on second run of the game, to make it faster. +// The config loader.js built and the class it constructed. Both outlive a teardown, so a later +// launch can build an emulator straight away instead of downloading the bundle again. let loadedConfig = null; -// emulator constructor, which is only available after the core is loaded. let EmulatorCtor = null; // EmulatorJS cores are WebAssembly, which needs Chromium 57+. Older WebViews (webOS 4 and @@ -73,14 +73,11 @@ export const unsupportedMessage = () => { // so reject after this timeout and let the caller show an error instead of hanging. const READY_TIMEOUT = 40000; -// Shows the current status in logs +// EmulatorJS writes what it is doing into its own status element, which is the only clue to +// where a boot stalled once it stops making progress. const emulatorStatusText = () => { - try { - const emu = window.EJS_emulator; - return (emu && emu.textElem && emu.textElem.innerText) || null; - } catch (e) { - return null; - } + const emu = window.EJS_emulator; + return (emu && emu.textElem && emu.textElem.innerText) || null; }; // Starts EmulatorJS in the element matching `selector` and resolves once the core is ready. @@ -91,7 +88,8 @@ export const startEmulator = ({selector, core, gameUrl, biosUrl, gameName, setti } const startedAt = Date.now(); - // If errors during boot happend log them. + // EmulatorJS reports a failed boot by never firing ready, so the reason only shows up as + // an uncaught error. Watch the window for as long as the boot runs to keep it. const onWindowError = (ev) => logGamesError('window error during emulator boot', { core, message: ev.message || String(ev.error || ''), @@ -108,19 +106,13 @@ export const startEmulator = ({selector, core, gameUrl, biosUrl, gameName, setti window.removeEventListener('unhandledrejection', onRejection); }; - let shader = null; - try { shader = settingsJson ? JSON.parse(settingsJson).shader : null; } catch (e) { /* not JSON */ } + // A TV that runs out of memory mid-boot takes the app with it and never reaches the + // timeout below, so this line is the last thing a report will show. logGames('starting emulator', { core, gameName, bios: Boolean(biosUrl), - cdn: CDN, - // SharedArrayBuffer is only available in cross-origin isolated contexts, which the app isn't. - threads: false, - sharedArrayBuffer: typeof SharedArrayBuffer !== 'undefined', - crossOriginIsolated: typeof window.crossOriginIsolated === 'boolean' ? window.crossOriginIsolated : null, - // Blobs are supported on all the platforms and run bigger files for N64 and so on. - shader + blobRom: String(gameUrl || '').startsWith('blob:') }); window.EJS_player = selector; window.EJS_core = core; @@ -134,58 +126,64 @@ export const startEmulator = ({selector, core, gameUrl, biosUrl, gameName, setti // No touch screen on TV; keep the on-screen pad off. window.EJS_defaultOptions = Object.assign({}, window.EJS_defaultOptions, {'virtual-gamepad': 'disabled'}); + let reused = false; const timer = setTimeout(() => { stopWatching(); - logGamesError('emulator never became ready', { - core, - waitedMs: READY_TIMEOUT, - // EmulatorJS logging its own status - emulatorStatus: emulatorStatusText() - }); + // Whatever was cached built an emulator that never started, so drop it and let the + // next launch go back through the loader rather than repeat the same dead boot. + loadedConfig = null; + EmulatorCtor = null; + logGamesError('emulator never became ready', {reused, emulatorStatus: emulatorStatusText()}); reject(new Error('emulator-load-timeout')); }, READY_TIMEOUT); window.EJS_ready = () => { clearTimeout(timer); stopWatching(); - const reused = Boolean(loadedConfig); - try { - loadedConfig = (window.EJS_emulator && window.EJS_emulator.config) || loadedConfig; - EmulatorCtor = (window.EJS_emulator && window.EJS_emulator.constructor) || EmulatorCtor; - } catch (e) { /* ignore */ } + loadedConfig = (window.EJS_emulator && window.EJS_emulator.config) || loadedConfig; + EmulatorCtor = (window.EJS_emulator && window.EJS_emulator.constructor) || EmulatorCtor; logGames('emulator ready', {core, ms: Date.now() - startedAt, reused}); resolve(); }; - // guard for running same game twice (happedn me). - if (loadedConfig && EmulatorCtor) { + // Once the bundle is parsed a later launch can build the emulator from the cached class + // instead of downloading the loader again. loader.js hooks the ready callback up itself + // with emulator.on('ready', ...) rather than passing it in the config, so this path has + // to do the same or the promise above could only ever settle by timing out. + if (loadedConfig && EmulatorCtor && typeof EmulatorCtor.prototype?.on === 'function') { try { const config = Object.assign({}, loadedConfig, { gameUrl, system: core, biosUrl: biosUrl || '', - gameName: gameName || '' + gameName: gameName || '', + dataPath: CDN, + startOnLoad: true, + threads: false, + defaultOptions: window.EJS_defaultOptions }); - window.EJS_emulator = new EmulatorCtor(selector, config); - logGames('reusing loaded emulator scripts', {core}); + const emulator = new EmulatorCtor(selector, config); + window.EJS_emulator = emulator; + window.EJS_adBlocked = (url, del) => emulator.adBlocked(url, del); + emulator.on('ready', window.EJS_ready); + reused = true; return; } catch (e) { - logGamesError('reusing loaded emulator scripts failed, falling back to loader', { - core, - message: e.message || String(e) - }); + // Once the cached pair has thrown it is not worth trusting again this session. + loadedConfig = null; + EmulatorCtor = null; + logGamesError('could not build the emulator from the cached class', {message: e.message || String(e)}); } } - const script = document.createElement('script'); - script.src = CDN + 'loader.js'; - script.onerror = () => { + loaderScript = document.createElement('script'); + loaderScript.src = CDN + 'loader.js'; + // Without this the promise sits on the full timeout when the CDN is simply unreachable. + loaderScript.onerror = () => { clearTimeout(timer); stopWatching(); - logGamesError('loader.js failed to download', {core, url: script.src}); reject(new Error('emulator-loader-unreachable')); }; - loaderScript = script; - document.body.appendChild(script); + document.body.appendChild(loaderScript); }); const gm = () => window.EJS_emulator && window.EJS_emulator.gameManager; @@ -257,12 +255,6 @@ export const getOptions = () => { // Tears the emulator down: stops the loop, clears the container, drops EJS globals + loader. export const destroyEmulator = () => { - // If game doesn't boot repot status - logGames('tearing down emulator', { - core: window.EJS_core || null, - booted: Boolean(gm()), - emulatorStatus: emulatorStatusText() - }); try { setPaused(true); } catch (e) { /* ignore */ } try { const el = document.querySelector(window.EJS_player || '#game'); @@ -273,6 +265,7 @@ export const destroyEmulator = () => { } loaderScript = null; ['EJS_emulator', 'EJS_player', 'EJS_core', 'EJS_gameUrl', 'EJS_biosUrl', 'EJS_gameName', - 'EJS_pathtodata', 'EJS_startOnLoaded', 'EJS_threads', 'EJS_ready', 'EJS_defaultOptions'] + 'EJS_pathtodata', 'EJS_startOnLoaded', 'EJS_threads', 'EJS_ready', 'EJS_defaultOptions', + 'EJS_adBlocked'] .forEach((k) => { try { delete window[k]; } catch (e) { /* ignore */ } }); }; diff --git a/packages/app/src/views/GamePlayer/GamePlayer.js b/packages/app/src/views/GamePlayer/GamePlayer.js index e49193f..24d2f6b 100644 --- a/packages/app/src/views/GamePlayer/GamePlayer.js +++ b/packages/app/src/views/GamePlayer/GamePlayer.js @@ -73,8 +73,8 @@ const GamePlayer = ({library, game, startFresh, onBack, backHandlerRef}) => { startFresh ? Promise.resolve(null) : gamesApi.getStateBytes(game.id) ]); if (cancelled) return; - const romUrl = rom.url; - if (rom.isBlob) blobs.current.push(romUrl); + const {url: romUrl, isBlob} = rom; + if (isBlob) blobs.current.push(romUrl); let biosUrl; if (game.bios && game.bios.length) { biosUrl = await gamesApi.getBiosBlobUrl(libraryId, game.bios[0].id); @@ -94,13 +94,15 @@ const GamePlayer = ({library, game, startFresh, onBack, backHandlerRef}) => { if (existing) { try { ejs.loadState(existing); } catch (e) { /* ignore */ } } setReady(true); } catch (e) { + // Backing out mid-load lands here too, and that is not worth reporting. + if (cancelled) return; serverLogger.error(serverLogger.LOG_CATEGORIES.APP, '[Games] could not start game', { core: game.core, system: game.system, status: e.status || null, + totalBytes: e.totalBytes || null, message: e.message || String(e) }, false); - if (cancelled) return; if (e.romTooLarge) setError($L('This game is too large to run on this TV.')); else setError(e.status === 404 ? $L('Game file not found.') : $L('Could not start this game on this device.')); } diff --git a/packages/app/src/views/Games/Games.js b/packages/app/src/views/Games/Games.js index 7edd4e2..a249abc 100644 --- a/packages/app/src/views/Games/Games.js +++ b/packages/app/src/views/Games/Games.js @@ -80,8 +80,8 @@ const Games = ({library, onSelectGame, onHome, backHandlerRef}) => { return (

{library?.Name || $L('Games')}

- {/* Scrollbar to CSS makes it not selectable (smoother goinging down the list). - This also made the Game Card (If the Libary was long enough), not visiable or just for a frame, and then it jumped to the scrollbar. */} + {/* A focusable scrollbar sits in the way once the library is long enough to scroll, + taking the focus on the way down instead of letting it reach the next row. */} {rows.map((system) => { const games = gamesBySystem[system.id] || [];