Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/app/resources/strings.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
86 changes: 79 additions & 7 deletions packages/app/src/services/gamesApi.js
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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);
Expand All @@ -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 {
Expand Down
99 changes: 99 additions & 0 deletions packages/app/src/services/gamesApi.test.js
Original file line number Diff line number Diff line change
@@ -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');
});
});
114 changes: 106 additions & 8 deletions packages/app/src/utils/emulatorjs.js
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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;
Expand All @@ -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);
});

Expand Down Expand Up @@ -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 */ } });
};
Loading
Loading