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] || [];