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 a094235..cafe576 100644 --- a/packages/app/src/services/gamesApi.js +++ b/packages/app/src/services/gamesApi.js @@ -1,8 +1,11 @@ // 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'; @@ -49,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); @@ -59,15 +85,61 @@ const blobUrl = async (path) => { err.status = res.status; throw err; } + const expected = Number(res.headers.get('content-length')) || null; + 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)}`); +// 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)}`; +}; + +// 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]; + // 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}; + } +}; + +// 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) 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}; +}; + // Save state (binary) keyed per game. Returns null when none exists (404). export const getStateBytes = async (gameId) => { try { 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 43a244d..8d3f644 100644 --- a/packages/app/src/utils/emulatorjs.js +++ b/packages/app/src/utils/emulatorjs.js @@ -1,16 +1,26 @@ -// 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'; +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; +// 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; +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 @@ -63,12 +73,47 @@ export const unsupportedMessage = () => { // so reject after this timeout and let the caller show an error instead of hanging. const READY_TIMEOUT = 40000; +// 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 = () => { + 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. 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(); + // 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 || ''), + 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); + }; + + // 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), + blobRom: String(gameUrl || '').startsWith('blob:') + }); window.EJS_player = selector; window.EJS_core = core; window.EJS_gameUrl = gameUrl; @@ -81,11 +126,63 @@ 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(); }; + let reused = false; + const timer = setTimeout(() => { + stopWatching(); + // 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(); + 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(); + }; + + // 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 || '', + dataPath: CDN, + startOnLoad: true, + threads: false, + defaultOptions: window.EJS_defaultOptions + }); + 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) { + // 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)}); + } + } 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(); + reject(new Error('emulator-loader-unreachable')); + }; document.body.appendChild(loaderScript); }); @@ -168,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 353e605..24d2f6b 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 {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); @@ -92,7 +94,17 @@ 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.')); + // 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 (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 e0c89da..a249abc 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')}

- + {/* 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] || []; return (