From 154340d332ec1eb2c20ea29df8be3e0a28646a8d Mon Sep 17 00:00:00 2001 From: Bruno Henriques <4727729+bphenriques@users.noreply.github.com> Date: Sat, 1 Aug 2026 22:53:34 +0100 Subject: [PATCH 01/11] feat: playable Win3.x and Win9.x using js-dos --- backend/config/__init__.py | 1 + backend/endpoints/heartbeat.py | 2 + backend/endpoints/responses/heartbeat.py | 1 + backend/tests/endpoints/test_heartbeat.py | 1 + docker/Dockerfile | 9 + docker/nginx/templates/default.conf.template | 6 +- env.template | 1 + .../src/__generated__/models/EmulationDict.ts | 1 + frontend/src/plugins/router.ts | 9 + frontend/src/stores/heartbeat.ts | 1 + frontend/src/utils/index.test.ts | 54 ++- frontend/src/utils/index.ts | 19 + .../src/v2/composables/useCanPlay/index.ts | 28 +- .../composables/useGameActions/index.test.ts | 14 +- .../v2/composables/useGameActions/index.ts | 15 +- frontend/src/v2/router/routes.ts | 1 + frontend/src/v2/views/Player/JsDos.vue | 362 ++++++++++++++++++ 17 files changed, 512 insertions(+), 13 deletions(-) create mode 100644 frontend/src/v2/views/Player/JsDos.vue diff --git a/backend/config/__init__.py b/backend/config/__init__.py index 500ae95714..a8e28e730b 100644 --- a/backend/config/__init__.py +++ b/backend/config/__init__.py @@ -283,6 +283,7 @@ def _get_env(var: str, fallback: str | None = None) -> str | None: # EMULATION DISABLE_EMULATOR_JS: Final[bool] = safe_str_to_bool(_get_env("DISABLE_EMULATOR_JS")) DISABLE_RUFFLE_RS: Final[bool] = safe_str_to_bool(_get_env("DISABLE_RUFFLE_RS")) +DISABLE_JSDOS: Final[bool] = safe_str_to_bool(_get_env("DISABLE_JSDOS")) # FRONTEND KIOSK_MODE: Final[bool] = safe_str_to_bool(_get_env("KIOSK_MODE")) diff --git a/backend/endpoints/heartbeat.py b/backend/endpoints/heartbeat.py index e67e803093..2fb7937640 100644 --- a/backend/endpoints/heartbeat.py +++ b/backend/endpoints/heartbeat.py @@ -5,6 +5,7 @@ from config import ( DISABLE_EMULATOR_JS, + DISABLE_JSDOS, DISABLE_LOGS_VIEWER, DISABLE_RUFFLE_RS, DISABLE_SETUP_WIZARD, @@ -116,6 +117,7 @@ async def heartbeat() -> HeartbeatResponse: "EMULATION": { "DISABLE_EMULATOR_JS": DISABLE_EMULATOR_JS, "DISABLE_RUFFLE_RS": DISABLE_RUFFLE_RS, + "DISABLE_JSDOS": DISABLE_JSDOS, }, "FRONTEND": { "DISABLE_USERPASS_LOGIN": DISABLE_USERPASS_LOGIN, diff --git a/backend/endpoints/responses/heartbeat.py b/backend/endpoints/responses/heartbeat.py index 27e9aea652..516c806b66 100644 --- a/backend/endpoints/responses/heartbeat.py +++ b/backend/endpoints/responses/heartbeat.py @@ -29,6 +29,7 @@ class FilesystemDict(TypedDict): class EmulationDict(TypedDict): DISABLE_EMULATOR_JS: bool DISABLE_RUFFLE_RS: bool + DISABLE_JSDOS: bool class FrontendDict(TypedDict): diff --git a/backend/tests/endpoints/test_heartbeat.py b/backend/tests/endpoints/test_heartbeat.py index 00a20e9ed5..4709708d61 100644 --- a/backend/tests/endpoints/test_heartbeat.py +++ b/backend/tests/endpoints/test_heartbeat.py @@ -41,6 +41,7 @@ def test_heartbeat(client): emulation = heartbeat["EMULATION"] assert isinstance(emulation["DISABLE_EMULATOR_JS"], bool) assert isinstance(emulation["DISABLE_RUFFLE_RS"], bool) + assert isinstance(emulation["DISABLE_JSDOS"], bool) assert "FRONTEND" in heartbeat frontend = heartbeat["FRONTEND"] diff --git a/docker/Dockerfile b/docker/Dockerfile index 0c4901c865..398fcb2832 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -129,6 +129,14 @@ RUN wget "https://github.com/ruffle-rs/ruffle/releases/download/${RUFFLE_VERSION unzip -o "${RUFFLE_FILE}" -d /ruffle && \ rm -f "${RUFFLE_FILE}" +ARG JSDOS_VERSION=8.4.1 +ARG JSDOS_SHA256=26118692bbb180aec78ec1697eb1ea6b28ff410101870cfa3e68309914c7eaa6 + +RUN wget "https://github.com/caiiiycuk/js-dos/releases/download/v${JSDOS_VERSION}/release.zip" -O jsdos.zip && \ + echo "${JSDOS_SHA256} jsdos.zip" | sha256sum -c - && \ + unzip -o jsdos.zip -d /jsdos && \ + rm -f jsdos.zip + # BUILD NGINX MODULE WITH MOD_ZIP FROM alpine:${ALPINE_VERSION}@sha256:${ALPINE_SHA256} AS nginx-build @@ -254,6 +262,7 @@ FROM slim-image AS full-image ARG WEBSERVER_FOLDER=/var/www/html COPY --from=emulator-stage /emulatorjs ${WEBSERVER_FOLDER}/assets/emulatorjs COPY --from=emulator-stage /ruffle ${WEBSERVER_FOLDER}/assets/ruffle +COPY --from=emulator-stage /jsdos/dist ${WEBSERVER_FOLDER}/assets/jsdos FROM slim-image AS dev-slim diff --git a/docker/nginx/templates/default.conf.template b/docker/nginx/templates/default.conf.template index 634542a1a4..a4224f3fea 100644 --- a/docker/nginx/templates/default.conf.template +++ b/docker/nginx/templates/default.conf.template @@ -15,16 +15,18 @@ map $http_x_forwarded_proto $forwardscheme { } # COEP and COOP headers for cross-origin isolation, which are set only for the -# EmulatorJS player paths, to enable SharedArrayBuffer support, which is needed -# for multi-threaded cores. +# EmulatorJS and js-dos player paths, to enable SharedArrayBuffer support, which +# is needed for multi-threaded cores. map $request_uri $coep_header { default ""; ~^/rom/.*/ejs$ "require-corp"; + ~^/rom/.*/jsdos$ "require-corp"; ~^/console/rom/[0-9]+/play "require-corp"; } map $request_uri $coop_header { default ""; ~^/rom/.*/ejs$ "same-origin"; + ~^/rom/.*/jsdos$ "same-origin"; ~^/console/rom/[0-9]+/play "same-origin"; } diff --git a/env.template b/env.template index 730f83dc81..49c242081b 100644 --- a/env.template +++ b/env.template @@ -109,6 +109,7 @@ SYNC_SSH_KNOWN_HOSTS_PATH= # Path to SSH known_hosts (defaults to $ROMM_BASE_PA # Emulation DISABLE_EMULATOR_JS=false # Disable in-browser play via EmulatorJS DISABLE_RUFFLE_RS=false # Disable in-browser Flash playback via RuffleRS +DISABLE_JSDOS=false # Disable in-browser Win3.x and Win9.x playback via js-dos # Integrations YOUTUBE_BASE_URL=https://www.youtube.com # Base URL for alternate YouTube frontends (Piped, Invidious, etc.) diff --git a/frontend/src/__generated__/models/EmulationDict.ts b/frontend/src/__generated__/models/EmulationDict.ts index c41ac325bc..6171be35e1 100644 --- a/frontend/src/__generated__/models/EmulationDict.ts +++ b/frontend/src/__generated__/models/EmulationDict.ts @@ -5,5 +5,6 @@ export type EmulationDict = { DISABLE_EMULATOR_JS: boolean; DISABLE_RUFFLE_RS: boolean; + DISABLE_JSDOS: boolean; }; diff --git a/frontend/src/plugins/router.ts b/frontend/src/plugins/router.ts index 1fe6facee8..aaa126db9d 100644 --- a/frontend/src/plugins/router.ts +++ b/frontend/src/plugins/router.ts @@ -31,6 +31,7 @@ export const ROUTES = { SMART_COLLECTION: "smart-collection", ROM: "rom", EMULATORJS: "emulatorjs", + JSDOS: "jsdos", RUFFLE: "ruffle", STREAM: "stream", SCAN: "scan", @@ -254,6 +255,14 @@ const routes = [ v2: v2For(ROUTES.EMULATORJS), }, }, + { + path: "rom/:rom/jsdos", + name: ROUTES.JSDOS, + components: { + default: () => import("@/views/Home.vue"), + v2: v2For(ROUTES.JSDOS), + }, + }, { path: "rom/:rom/ruffle", name: ROUTES.RUFFLE, diff --git a/frontend/src/stores/heartbeat.ts b/frontend/src/stores/heartbeat.ts index 62966bf6b7..886b19dd0c 100644 --- a/frontend/src/stores/heartbeat.ts +++ b/frontend/src/stores/heartbeat.ts @@ -38,6 +38,7 @@ const defaultHeartbeat: Heartbeat = { EMULATION: { DISABLE_EMULATOR_JS: false, DISABLE_RUFFLE_RS: false, + DISABLE_JSDOS: false, }, FRONTEND: { DISABLE_USERPASS_LOGIN: false, diff --git a/frontend/src/utils/index.test.ts b/frontend/src/utils/index.test.ts index 840c68f154..fd397380b2 100644 --- a/frontend/src/utils/index.test.ts +++ b/frontend/src/utils/index.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it } from "vitest"; +import type { Config } from "@/stores/config"; +import type { Heartbeat } from "@/stores/heartbeat"; import type { SimpleRom } from "@/stores/roms"; -import { getDownloadPath } from "./index"; +import { getDownloadPath, isJsDosEmulationSupported } from "./index"; function makeRom(overrides: Partial): SimpleRom { return { @@ -57,3 +59,53 @@ describe("getDownloadPath", () => { ); }); }); + +function makeHeartbeat( + emulation: Partial = {}, +): Heartbeat { + return { + EMULATION: { + DISABLE_EMULATOR_JS: false, + DISABLE_RUFFLE_RS: false, + DISABLE_JSDOS: false, + ...emulation, + }, + } as Heartbeat; +} + +function makeConfig(versions: Record = {}): Config { + return { PLATFORMS_VERSIONS: versions } as Config; +} + +describe("isJsDosEmulationSupported", () => { + it("supports win3x and win9x", () => { + expect(isJsDosEmulationSupported("win3x", makeHeartbeat())).toBe(true); + expect(isJsDosEmulationSupported("win9x", makeHeartbeat())).toBe(true); + }); + + it("is case-insensitive on the slug", () => { + expect(isJsDosEmulationSupported("WIN3X", makeHeartbeat())).toBe(true); + }); + + it("does not claim dos or other platforms", () => { + expect(isJsDosEmulationSupported("dos", makeHeartbeat())).toBe(false); + expect(isJsDosEmulationSupported("flash", makeHeartbeat())).toBe(false); + expect(isJsDosEmulationSupported("snes", makeHeartbeat())).toBe(false); + }); + + it("respects the DISABLE_JSDOS admin toggle", () => { + expect( + isJsDosEmulationSupported("win3x", makeHeartbeat({ DISABLE_JSDOS: true })), + ).toBe(false); + }); + + it("honours a PLATFORMS_VERSIONS remap onto win3x", () => { + expect( + isJsDosEmulationSupported( + "dos", + makeHeartbeat(), + makeConfig({ dos: "win3x" }), + ), + ).toBe(true); + }); +}); diff --git a/frontend/src/utils/index.ts b/frontend/src/utils/index.ts index 4144daa7af..466e1d23bc 100644 --- a/frontend/src/utils/index.ts +++ b/frontend/src/utils/index.ts @@ -684,6 +684,25 @@ export function isRuffleEmulationSupported( return ["flash", "browser"].includes(slug.toLowerCase()); } +/** + * Check if js-dos emulation is supported for a given platform. + * + * @param platformSlug The platform slug. + * @param heartbeat The heartbeat object. + * @param config Optional configuration object. + * @returns True if supported, false otherwise. + */ +export function isJsDosEmulationSupported( + platformSlug: string, + heartbeat: Heartbeat, + config?: Config, +) { + if (heartbeat.EMULATION.DISABLE_JSDOS) return false; + + const slug = config?.PLATFORMS_VERSIONS[platformSlug] || platformSlug; + return ["win3x", "win9x"].includes(slug.toLowerCase()); +} + export type PlayingStatus = RomUserStatus | "backlogged" | "now_playing" | "hidden"; diff --git a/frontend/src/v2/composables/useCanPlay/index.ts b/frontend/src/v2/composables/useCanPlay/index.ts index 6de02aa8cb..443a053a6a 100644 --- a/frontend/src/v2/composables/useCanPlay/index.ts +++ b/frontend/src/v2/composables/useCanPlay/index.ts @@ -4,20 +4,24 @@ // inside PlayBtn.vue; v2 lifts it to a composable so the card overlay // and the menu item agree with the details-header CTA. // -// "Playable" means either EJS or Ruffle can run the platform on this +// "Playable" means EJS, js-dos, or Ruffle can run the platform on this // server (admin toggles + platform support + WebGL availability). The -// individual flags are exposed so the play action can pick the right -// route (EJS vs Ruffle). +// individual flags are exposed so the play action can pick the right route. import { storeToRefs } from "pinia"; import { computed, type ComputedRef } from "vue"; import storeConfig from "@/stores/config"; import storeHeartbeat from "@/stores/heartbeat"; import type { SimpleRom } from "@/stores/roms"; -import { isEJSEmulationSupported, isRuffleEmulationSupported } from "@/utils"; +import { + isEJSEmulationSupported, + isJsDosEmulationSupported, + isRuffleEmulationSupported, +} from "@/utils"; export function useCanPlay(getRom: () => SimpleRom | null | undefined): { canPlay: ComputedRef; canPlayEJS: ComputedRef; + canPlayJsDos: ComputedRef; canPlayRuffle: ComputedRef; } { const heartbeatStore = storeHeartbeat(); @@ -44,7 +48,19 @@ export function useCanPlay(getRom: () => SimpleRom | null | undefined): { ); }); - const canPlay = computed(() => canPlayEJS.value || canPlayRuffle.value); + const canPlayJsDos = computed(() => { + const rom = getRom(); + if (!rom) return false; + return isJsDosEmulationSupported( + rom.platform_slug, + heartbeat.value, + configStore.config, + ); + }); + + const canPlay = computed( + () => canPlayEJS.value || canPlayJsDos.value || canPlayRuffle.value, + ); - return { canPlay, canPlayEJS, canPlayRuffle }; + return { canPlay, canPlayEJS, canPlayJsDos, canPlayRuffle }; } diff --git a/frontend/src/v2/composables/useGameActions/index.test.ts b/frontend/src/v2/composables/useGameActions/index.test.ts index 11d7a5b69b..3a2068cf86 100644 --- a/frontend/src/v2/composables/useGameActions/index.test.ts +++ b/frontend/src/v2/composables/useGameActions/index.test.ts @@ -17,6 +17,7 @@ const locationAssign = vi.fn(); const confirmFn = vi.fn(); const confirmProtectedLaunch = { value: true }; const canPlayEJS = { value: true }; +const canPlayJsDos = { value: false }; const canPlayRuffle = { value: false }; const streamContainer = { value: null as object | null }; let originalLocation: Location; @@ -65,7 +66,7 @@ vi.mock("@/v2/composables/useCan", () => ({ }), })); vi.mock("@/v2/composables/useCanPlay", () => ({ - useCanPlay: () => ({ canPlayEJS, canPlayRuffle }), + useCanPlay: () => ({ canPlayEJS, canPlayJsDos, canPlayRuffle }), })); vi.mock("@/v2/composables/useClipboard", () => ({ useClipboard: () => ({ copy: vi.fn() }), @@ -121,6 +122,7 @@ beforeEach(() => { confirmFn.mockClear(); confirmProtectedLaunch.value = true; canPlayEJS.value = true; + canPlayJsDos.value = false; canPlayRuffle.value = false; streamContainer.value = null; grantedActions.value = null; @@ -185,6 +187,16 @@ describe("useGameActions.play — launch confirmation", () => { expect(push).toHaveBeenCalledWith("/rom/1/ruffle"); expect(locationAssign).not.toHaveBeenCalled(); }); + + it("full-loads js-dos ahead of EmulatorJS for its platforms", async () => { + canPlayJsDos.value = true; + const actions = useGameActions(() => makeRom()); + + await actions.play(); + + expect(locationAssign).toHaveBeenCalledWith("/rom/1/jsdos"); + expect(push).not.toHaveBeenCalled(); + }); }); describe("useGameActions — write/destructive gates", () => { diff --git a/frontend/src/v2/composables/useGameActions/index.ts b/frontend/src/v2/composables/useGameActions/index.ts index 2ebf441a52..f14ddd910e 100644 --- a/frontend/src/v2/composables/useGameActions/index.ts +++ b/frontend/src/v2/composables/useGameActions/index.ts @@ -77,7 +77,7 @@ export function useGameActions( // delete that 403s. const canDelete = computed(() => hasDeleteGrant.value && canEdit.value); const { isFavorite, toggleFavorite } = useFavoriteToggle(emitter); - const { canPlayEJS, canPlayRuffle } = useCanPlay(getRom); + const { canPlayEJS, canPlayJsDos, canPlayRuffle } = useCanPlay(getRom); const streamingStore = useStreamingStore(); // Streaming is the preferred way to play where a container is @@ -88,7 +88,11 @@ export function useGameActions( Boolean(streamingStore.containerForPlatform(getRom()?.platform_slug)), ); const canPlay = computed( - () => canPlayStream.value || canPlayEJS.value || canPlayRuffle.value, + () => + canPlayStream.value || + canPlayJsDos.value || + canPlayEJS.value || + canPlayRuffle.value, ); const isFavorited = computed(() => { @@ -248,9 +252,14 @@ export function useGameActions( if (!ok) return; } - // EmulatorJS cores can require SharedArrayBuffer. Nginx only attaches the + // EmulatorJS and js-dos need SharedArrayBuffer. Nginx only attaches the // necessary COOP/COEP headers to the player document, so an SPA navigation // cannot enable cross-origin isolation. Load the document directly instead. + // js-dos owns win3x/win9x, so it is checked ahead of EJS. + if (!canPlayStream.value && canPlayJsDos.value) { + window.location.assign(`/rom/${rom.id}/jsdos`); + return; + } if (!canPlayStream.value && canPlayEJS.value) { window.location.assign(`/rom/${rom.id}/ejs`); return; diff --git a/frontend/src/v2/router/routes.ts b/frontend/src/v2/router/routes.ts index cc0bfb5078..f1ee05cc29 100644 --- a/frontend/src/v2/router/routes.ts +++ b/frontend/src/v2/router/routes.ts @@ -36,6 +36,7 @@ export const v2RouteComponents: Partial> = { rom: () => import("@/v2/views/GameDetails.vue"), // Wave 5 — Players emulatorjs: () => import("@/v2/views/Player/EmulatorJS.vue"), + jsdos: () => import("@/v2/views/Player/JsDos.vue"), ruffle: () => import("@/v2/views/Player/Ruffle.vue"), stream: () => import("@/v2/views/Player/Stream.vue"), // Wave 6 — Library Tools (Scan / Upload) + Pair diff --git a/frontend/src/v2/views/Player/JsDos.vue b/frontend/src/v2/views/Player/JsDos.vue new file mode 100644 index 0000000000..0e9dc998db --- /dev/null +++ b/frontend/src/v2/views/Player/JsDos.vue @@ -0,0 +1,362 @@ + + + + + From dc119011694d38b40a559f35d7f03f90629bc6db Mon Sep 17 00:00:00 2001 From: Bruno Henriques <4727729+bphenriques@users.noreply.github.com> Date: Tue, 4 Aug 2026 16:20:32 +0100 Subject: [PATCH 02/11] Fix lack of automatic saving Amp-Thread-ID: https://ampcode.com/threads/T-019fcd57-445f-754d-9a5b-a45cf2878d99 Co-authored-by: Amp --- frontend/src/locales/bg_BG/play.json | 2 + frontend/src/locales/cs_CZ/play.json | 2 + frontend/src/locales/de_DE/play.json | 2 + frontend/src/locales/en_GB/play.json | 2 + frontend/src/locales/en_US/play.json | 2 + frontend/src/locales/es_ES/play.json | 2 + frontend/src/locales/fr_FR/play.json | 2 + frontend/src/locales/hu_HU/play.json | 2 + frontend/src/locales/it_IT/play.json | 2 + frontend/src/locales/ja_JP/play.json | 2 + frontend/src/locales/ko_KR/play.json | 2 + frontend/src/locales/pl_PL/play.json | 2 + frontend/src/locales/pt_BR/play.json | 2 + frontend/src/locales/ro_RO/play.json | 2 + frontend/src/locales/ru_RU/play.json | 2 + frontend/src/locales/tr_TR/play.json | 2 + frontend/src/locales/zh_CN/play.json | 2 + frontend/src/locales/zh_TW/play.json | 2 + frontend/src/types/js-dos.d.ts | 34 +++ frontend/src/v2/views/Player/JsDos.test.ts | 337 +++++++++++++++++++++ frontend/src/v2/views/Player/JsDos.vue | 126 +++++--- 21 files changed, 494 insertions(+), 39 deletions(-) create mode 100644 frontend/src/types/js-dos.d.ts create mode 100644 frontend/src/v2/views/Player/JsDos.test.ts diff --git a/frontend/src/locales/bg_BG/play.json b/frontend/src/locales/bg_BG/play.json index 5930885258..56ca512edf 100644 --- a/frontend/src/locales/bg_BG/play.json +++ b/frontend/src/locales/bg_BG/play.json @@ -13,6 +13,8 @@ "deselect-save": "Отмени избрания запис", "deselect-state": "Отмени избрания бърз запис", "full-screen": "Цял екран", + "jsdos-browser-save-warning": "Запазените данни на js-dos се съхраняват само в този браузър и не се синхронизират с RomM.", + "jsdos-quit-without-saving": "Изход без запазване на скорошния напредък?", "no-save-selected": "Няма избран запис", "no-saves-available": "Няма налични записи", "no-screenshot-available": "Няма налична екранна снимка", diff --git a/frontend/src/locales/cs_CZ/play.json b/frontend/src/locales/cs_CZ/play.json index 6b5f99f112..e5e7d6ac27 100644 --- a/frontend/src/locales/cs_CZ/play.json +++ b/frontend/src/locales/cs_CZ/play.json @@ -13,6 +13,8 @@ "deselect-save": "Zrušit výběr uložené pozice", "deselect-state": "Zrušit výběr stavu", "full-screen": "Celá obrazovka", + "jsdos-browser-save-warning": "Pozice js-dos se ukládají pouze v tomto prohlížeči a nesynchronizují se s RomM.", + "jsdos-quit-without-saving": "Ukončit bez uložení posledního postupu?", "no-save-selected": "Není vybrána žádná uložená pozice", "no-saves-available": "Nejsou k dispozici žádné uložené pozice", "no-screenshot-available": "Žádný screenshot není k dispozici", diff --git a/frontend/src/locales/de_DE/play.json b/frontend/src/locales/de_DE/play.json index c3424681d6..826a5fdf64 100644 --- a/frontend/src/locales/de_DE/play.json +++ b/frontend/src/locales/de_DE/play.json @@ -13,6 +13,8 @@ "deselect-save": "Speicherstand abwählen", "deselect-state": "Speicherstand abwählen", "full-screen": "Vollbild", + "jsdos-browser-save-warning": "js-dos-Spielstände werden nur in diesem Browser gespeichert und nicht mit RomM synchronisiert.", + "jsdos-quit-without-saving": "Beenden, ohne den letzten Fortschritt zu speichern?", "no-save-selected": "Kein Speicherstand ausgewählt", "no-saves-available": "Keine Speicherstände verfügbar", "no-screenshot-available": "Kein Screenshot verfügbar", diff --git a/frontend/src/locales/en_GB/play.json b/frontend/src/locales/en_GB/play.json index c4d88ca752..eff07da48c 100644 --- a/frontend/src/locales/en_GB/play.json +++ b/frontend/src/locales/en_GB/play.json @@ -13,6 +13,8 @@ "deselect-save": "Deselect save", "deselect-state": "Deselect state", "full-screen": "Full screen", + "jsdos-browser-save-warning": "js-dos saves are stored only in this browser and are not synced with RomM.", + "jsdos-quit-without-saving": "Quit without saving recent progress?", "no-save-selected": "No save selected", "no-saves-available": "No saves available", "no-screenshot-available": "No screenshot available", diff --git a/frontend/src/locales/en_US/play.json b/frontend/src/locales/en_US/play.json index f10b27d60e..a9870caa07 100644 --- a/frontend/src/locales/en_US/play.json +++ b/frontend/src/locales/en_US/play.json @@ -13,6 +13,8 @@ "deselect-save": "Deselect save", "deselect-state": "Deselect state", "full-screen": "Full screen", + "jsdos-browser-save-warning": "js-dos saves are stored only in this browser and are not synced with RomM.", + "jsdos-quit-without-saving": "Quit without saving recent progress?", "no-save-selected": "No save selected", "no-saves-available": "No saves available", "no-screenshot-available": "No screenshot available", diff --git a/frontend/src/locales/es_ES/play.json b/frontend/src/locales/es_ES/play.json index 2aa55a2896..8af77fdbde 100644 --- a/frontend/src/locales/es_ES/play.json +++ b/frontend/src/locales/es_ES/play.json @@ -13,6 +13,8 @@ "deselect-save": "Deseleccionar guardado", "deselect-state": "Deseleccionar estado", "full-screen": "Pantalla completa", + "jsdos-browser-save-warning": "Las partidas de js-dos se almacenan solo en este navegador y no se sincronizan con RomM.", + "jsdos-quit-without-saving": "¿Salir sin guardar el progreso reciente?", "no-save-selected": "Ningún guardado seleccionado", "no-saves-available": "No hay guardados disponibles", "no-screenshot-available": "Captura no disponible", diff --git a/frontend/src/locales/fr_FR/play.json b/frontend/src/locales/fr_FR/play.json index 56ad187cee..441a3c553f 100644 --- a/frontend/src/locales/fr_FR/play.json +++ b/frontend/src/locales/fr_FR/play.json @@ -13,6 +13,8 @@ "deselect-save": "Désélectionner la sauvegarde", "deselect-state": "Désélectionner l'état", "full-screen": "Plein écran", + "jsdos-browser-save-warning": "Les sauvegardes js-dos sont stockées uniquement dans ce navigateur et ne sont pas synchronisées avec RomM.", + "jsdos-quit-without-saving": "Quitter sans enregistrer la progression récente ?", "no-save-selected": "Aucune sauvegarde sélectionnée", "no-saves-available": "Aucune sauvegarde disponible", "no-screenshot-available": "Aucune capture d'écran disponible", diff --git a/frontend/src/locales/hu_HU/play.json b/frontend/src/locales/hu_HU/play.json index 222d3d45cd..a4787c6ad9 100644 --- a/frontend/src/locales/hu_HU/play.json +++ b/frontend/src/locales/hu_HU/play.json @@ -13,6 +13,8 @@ "deselect-save": "Mentés kiválasztásának törlése", "deselect-state": "Állás kiválasztásának törlése", "full-screen": "Teljes képernyő", + "jsdos-browser-save-warning": "A js-dos mentések csak ebben a böngészőben tárolódnak, és nem szinkronizálódnak a RomM-mal.", + "jsdos-quit-without-saving": "Kilépés a legutóbbi előrehaladás mentése nélkül?", "no-save-selected": "Nincs kiválasztott mentés", "no-saves-available": "Nincs elérhető mentés", "no-screenshot-available": "Nincs elérhető képernyőkép", diff --git a/frontend/src/locales/it_IT/play.json b/frontend/src/locales/it_IT/play.json index 45db62c5ff..9d774830f4 100644 --- a/frontend/src/locales/it_IT/play.json +++ b/frontend/src/locales/it_IT/play.json @@ -13,6 +13,8 @@ "deselect-save": "Deseleziona Salvataggio", "deselect-state": "Deseleziona Stato", "full-screen": "Schermo Intero", + "jsdos-browser-save-warning": "I salvataggi di js-dos vengono archiviati solo in questo browser e non vengono sincronizzati con RomM.", + "jsdos-quit-without-saving": "Uscire senza salvare i progressi recenti?", "no-save-selected": "Nessun salvataggio selezionato", "no-saves-available": "Nessun salvataggio disponibile", "no-screenshot-available": "Nessuno screenshot disponibile", diff --git a/frontend/src/locales/ja_JP/play.json b/frontend/src/locales/ja_JP/play.json index 8c1f29e9cd..3034ff6b4f 100644 --- a/frontend/src/locales/ja_JP/play.json +++ b/frontend/src/locales/ja_JP/play.json @@ -13,6 +13,8 @@ "deselect-save": "セーブデータを解除", "deselect-state": "ステートを解除", "full-screen": "全画面", + "jsdos-browser-save-warning": "js-dos のセーブデータはこのブラウザーにのみ保存され、RomM とは同期されません。", + "jsdos-quit-without-saving": "最近の進行状況を保存せずに終了しますか?", "no-save-selected": "セーブデータが選択されていません", "no-saves-available": "利用可能なセーブデータがありません", "no-screenshot-available": "スクリーンショットはありません", diff --git a/frontend/src/locales/ko_KR/play.json b/frontend/src/locales/ko_KR/play.json index 740dc25907..45b8079ad5 100644 --- a/frontend/src/locales/ko_KR/play.json +++ b/frontend/src/locales/ko_KR/play.json @@ -13,6 +13,8 @@ "deselect-save": "세이브 선택 해제", "deselect-state": "상태 선택 해제", "full-screen": "전체 화면", + "jsdos-browser-save-warning": "js-dos 저장 데이터는 이 브라우저에만 저장되며 RomM과 동기화되지 않습니다.", + "jsdos-quit-without-saving": "최근 진행 상황을 저장하지 않고 종료하시겠습니까?", "no-save-selected": "선택된 세이브 없음", "no-saves-available": "사용 가능한 세이브 없음", "no-screenshot-available": "사용 가능한 스크린샷이 없습니다", diff --git a/frontend/src/locales/pl_PL/play.json b/frontend/src/locales/pl_PL/play.json index 2ce82a0305..c5053c45d5 100644 --- a/frontend/src/locales/pl_PL/play.json +++ b/frontend/src/locales/pl_PL/play.json @@ -13,6 +13,8 @@ "deselect-save": "Odznacz zapis", "deselect-state": "Odznacz stan", "full-screen": "Pełny ekran", + "jsdos-browser-save-warning": "Zapisy js-dos są przechowywane tylko w tej przeglądarce i nie są synchronizowane z RomM.", + "jsdos-quit-without-saving": "Wyjść bez zapisywania ostatnich postępów?", "no-save-selected": "Nie wybrano zapisu", "no-saves-available": "Brak dostępnych zapisów", "no-screenshot-available": "Brak dostępnych zrzutów ekranu", diff --git a/frontend/src/locales/pt_BR/play.json b/frontend/src/locales/pt_BR/play.json index 7d4bc4dfd7..f70c0e5283 100644 --- a/frontend/src/locales/pt_BR/play.json +++ b/frontend/src/locales/pt_BR/play.json @@ -13,6 +13,8 @@ "deselect-save": "Desmarcar save", "deselect-state": "Desmarcar estado", "full-screen": "Tela cheia", + "jsdos-browser-save-warning": "Os saves do js-dos são armazenados apenas neste navegador e não são sincronizados com o RomM.", + "jsdos-quit-without-saving": "Sair sem salvar o progresso recente?", "no-save-selected": "Nenhum save selecionado", "no-saves-available": "Nenhum save disponível", "no-screenshot-available": "Nenhuma captura de tela disponível", diff --git a/frontend/src/locales/ro_RO/play.json b/frontend/src/locales/ro_RO/play.json index f0aa884f06..c1302ba55b 100644 --- a/frontend/src/locales/ro_RO/play.json +++ b/frontend/src/locales/ro_RO/play.json @@ -13,6 +13,8 @@ "deselect-save": "Deselectează salvare", "deselect-state": "Deselectează stare", "full-screen": "Ecran complet", + "jsdos-browser-save-warning": "Salvările js-dos sunt stocate numai în acest browser și nu sunt sincronizate cu RomM.", + "jsdos-quit-without-saving": "Ieși fără a salva progresul recent?", "no-save-selected": "Nicio salvare selectată", "no-saves-available": "Nicio salvare disponibilă", "no-screenshot-available": "Nicio captură disponibilă", diff --git a/frontend/src/locales/ru_RU/play.json b/frontend/src/locales/ru_RU/play.json index 1acbc84920..0935da5853 100644 --- a/frontend/src/locales/ru_RU/play.json +++ b/frontend/src/locales/ru_RU/play.json @@ -13,6 +13,8 @@ "deselect-save": "Снять выбор сохранения", "deselect-state": "Снять выбор состояния", "full-screen": "Полный экран", + "jsdos-browser-save-warning": "Сохранения js-dos хранятся только в этом браузере и не синхронизируются с RomM.", + "jsdos-quit-without-saving": "Выйти, не сохраняя недавний прогресс?", "no-save-selected": "Сохранение не выбрано", "no-saves-available": "Нет доступных сохранений", "no-screenshot-available": "Скриншот недоступен", diff --git a/frontend/src/locales/tr_TR/play.json b/frontend/src/locales/tr_TR/play.json index d77ece5aff..9775e9e1fe 100644 --- a/frontend/src/locales/tr_TR/play.json +++ b/frontend/src/locales/tr_TR/play.json @@ -13,6 +13,8 @@ "deselect-save": "Kaydın seçimini kaldır", "deselect-state": "Durum kaydının seçimini kaldır", "full-screen": "Tam ekran", + "jsdos-browser-save-warning": "js-dos kayıtları yalnızca bu tarayıcıda saklanır ve RomM ile eşitlenmez.", + "jsdos-quit-without-saving": "Son ilerlemeyi kaydetmeden çıkılsın mı?", "no-save-selected": "Kayıt seçilmedi", "no-saves-available": "Mevcut kayıt yok", "no-screenshot-available": "Ekran görüntüsü yok", diff --git a/frontend/src/locales/zh_CN/play.json b/frontend/src/locales/zh_CN/play.json index cd09a6a542..ea806a0456 100644 --- a/frontend/src/locales/zh_CN/play.json +++ b/frontend/src/locales/zh_CN/play.json @@ -13,6 +13,8 @@ "deselect-save": "取消选择存档", "deselect-state": "取消选择状态", "full-screen": "全屏", + "jsdos-browser-save-warning": "js-dos 存档仅保存在此浏览器中,不会与 RomM 同步。", + "jsdos-quit-without-saving": "不保存最近的进度并退出吗?", "no-save-selected": "未选择存档", "no-saves-available": "无可用存档", "no-screenshot-available": "无可用截图", diff --git a/frontend/src/locales/zh_TW/play.json b/frontend/src/locales/zh_TW/play.json index 1ec1a8ae3f..4b94f7b947 100644 --- a/frontend/src/locales/zh_TW/play.json +++ b/frontend/src/locales/zh_TW/play.json @@ -13,6 +13,8 @@ "deselect-save": "取消選擇存檔", "deselect-state": "取消選擇即時存檔", "full-screen": "全螢幕", + "jsdos-browser-save-warning": "js-dos 存檔僅保存在此瀏覽器中,不會與 RomM 同步。", + "jsdos-quit-without-saving": "不儲存最近的進度並退出嗎?", "no-save-selected": "未選擇存檔", "no-saves-available": "無可用存檔", "no-screenshot-available": "沒有可用的截圖", diff --git a/frontend/src/types/js-dos.d.ts b/frontend/src/types/js-dos.d.ts new file mode 100644 index 0000000000..521d963f51 --- /dev/null +++ b/frontend/src/types/js-dos.d.ts @@ -0,0 +1,34 @@ +export interface JsDosOptions { + url: string; + backend: "dosbox" | "dosboxX"; + backendLocked: boolean; + pathPrefix: string; + autoStart: boolean; + autoSave: boolean; + fullScreen: boolean; + fsChanges: { + local: boolean; + urlToKey?: (url: string) => Promise; + pull?: (key: string) => Promise; + push?: (key: string, data: Uint8Array) => Promise; + delete?: (key: string) => Promise; + }; +} + +export interface JsDosProps { + getLocalChanges(key: string): Promise; + setNoCloud(noCloud: boolean): void; + save(): Promise; + stop(): Promise; +} + +export type JsDosFactory = ( + element: HTMLDivElement, + options: Partial, +) => JsDosProps; + +declare global { + interface Window { + Dos?: JsDosFactory; + } +} diff --git a/frontend/src/v2/views/Player/JsDos.test.ts b/frontend/src/v2/views/Player/JsDos.test.ts new file mode 100644 index 0000000000..2b454a53c6 --- /dev/null +++ b/frontend/src/v2/views/Player/JsDos.test.ts @@ -0,0 +1,337 @@ +import { flushPromises, mount, type VueWrapper } from "@vue/test-utils"; +import { + afterAll, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from "vitest"; +import { nextTick } from "vue"; +import type { JsDosOptions, JsDosProps } from "@/types/js-dos"; +import JsDos from "./JsDos.vue"; + +const mocks = vi.hoisted(() => ({ + flushPlaySession: vi.fn(), + getRom: vi.fn(), + locationReplace: vi.fn(), + playSessionStart: vi.fn(), + push: vi.fn(), + confirm: vi.fn(), + routeLeaveGuard: null as ((to: { fullPath: string }) => unknown) | null, + setPlaying: vi.fn(), + snackbarError: vi.fn(), + userId: 7 as number, +})); + +vi.mock("vue-i18n", () => ({ + useI18n: () => ({ t: (key: string) => key }), +})); + +vi.mock("vue-router", () => ({ + onBeforeRouteLeave: (guard: (to: { fullPath: string }) => unknown) => { + mocks.routeLeaveGuard = guard; + }, + useRoute: () => ({ params: { rom: "1" } }), + useRouter: () => ({ push: mocks.push }), +})); + +vi.mock("@/plugins/router", () => ({ + ROUTES: { ROM: "rom", PLATFORM: "platform" }, +})); + +vi.mock("@/services/api/rom", () => ({ + default: { getRom: mocks.getRom }, +})); + +vi.mock("@/stores/auth", () => ({ + default: () => ({ user: { id: mocks.userId } }), +})); + +vi.mock("@/stores/playing", () => ({ + default: () => ({ setPlaying: mocks.setPlaying }), +})); + +vi.mock("@/stores/roms", () => ({ + default: () => ({ currentRom: null }), +})); + +vi.mock("@/utils", () => ({ + getDownloadPath: () => "/api/roms/1/content/game.jsdos", +})); + +vi.mock("@/v2/components/shared/GameCover.vue", () => ({ + default: { template: "
" }, +})); + +vi.mock("@/v2/composables/useBackgroundArt", () => ({ + useBackgroundArt: () => vi.fn(), +})); + +vi.mock("@/v2/composables/useFullscreenPref", async () => { + const { ref } = await import("vue"); + return { useFullscreenPref: () => ({ fullscreenOnPlay: ref(false) }) }; +}); + +vi.mock("@/v2/composables/useConfirm", () => ({ + useConfirm: () => mocks.confirm, +})); + +vi.mock("@/v2/composables/usePageTitle", () => ({ + usePageTitle: vi.fn(), +})); + +vi.mock("@/v2/composables/usePlaySession", () => ({ + usePlaySession: () => ({ + start: mocks.playSessionStart, + flush: mocks.flushPlaySession, + }), +})); + +vi.mock("@/v2/composables/useSnackbar", () => ({ + useSnackbar: () => ({ error: mocks.snackbarError }), +})); + +vi.mock("@/v2/stores/galleryRoms", () => ({ + default: () => ({ getRomById: () => null }), +})); + +const rom = { + id: 1, + name: "Windows Game", + fs_name_no_ext: "Windows Game", + platform_id: 2, + platform_slug: "win9x", + rom_user: { status: null }, +}; + +let originalLocation: Location; + +beforeAll(() => { + originalLocation = window.location; + Object.defineProperty(window, "location", { + configurable: true, + value: { ...originalLocation, replace: mocks.locationReplace }, + }); + vi.spyOn(document.body, "appendChild").mockImplementation((node) => node); + vi.spyOn(document.head, "appendChild").mockImplementation((node) => node); + vi.spyOn(console, "error").mockImplementation(() => undefined); +}); + +afterAll(() => { + vi.restoreAllMocks(); + Object.defineProperty(window, "location", { + configurable: true, + value: originalLocation, + }); +}); + +beforeEach(() => { + vi.clearAllMocks(); + mocks.routeLeaveGuard = null; + mocks.userId = 7; + mocks.confirm.mockResolvedValue(false); + mocks.getRom.mockResolvedValue({ data: rom }); +}); + +async function mountPlayer(handle: JsDosProps): Promise { + window.Dos = vi.fn( + (_element: HTMLDivElement, _options: Partial) => handle, + ); + const wrapper = mount(JsDos, { + global: { + stubs: { + RBtn: { + emits: ["click"], + template: "", + }, + RCard: { template: "
" }, + RSpinner: true, + RSwitch: true, + }, + }, + }); + await flushPromises(); + await wrapper.get(".r-v2-jsdos__play").trigger("click"); + await nextTick(); + return wrapper; +} + +function makeHandle(saveResult: boolean) { + return { + getLocalChanges: vi.fn().mockResolvedValue(null), + save: vi.fn().mockResolvedValue(saveResult), + setNoCloud: vi.fn(), + stop: vi.fn(() => new Promise(() => undefined)), + }; +} + +describe("JsDos player exit", () => { + it("hard-navigates after saving without awaiting stop", async () => { + const handle = makeHandle(true); + const wrapper = await mountPlayer(handle); + + await wrapper.get(".r-v2-jsdos__quit").trigger("click"); + await flushPromises(); + + expect(handle.save).toHaveBeenCalledOnce(); + expect(handle.stop).toHaveBeenCalledOnce(); + expect(mocks.locationReplace).toHaveBeenCalledWith("/rom/1"); + expect(mocks.flushPlaySession).toHaveBeenCalledOnce(); + expect(mocks.setPlaying).toHaveBeenLastCalledWith(false); + wrapper.unmount(); + expect(handle.stop).toHaveBeenCalledOnce(); + }); + + it("keeps the player open when the final save is not confirmed", async () => { + const handle = makeHandle(false); + const wrapper = await mountPlayer(handle); + + await wrapper.get(".r-v2-jsdos__quit").trigger("click"); + await flushPromises(); + + expect(mocks.snackbarError).toHaveBeenCalledWith( + "play.stream-save-unconfirmed", + ); + expect(handle.stop).not.toHaveBeenCalled(); + expect(mocks.locationReplace).not.toHaveBeenCalled(); + expect(mocks.flushPlaySession).not.toHaveBeenCalled(); + expect(mocks.setPlaying).not.toHaveBeenCalledWith(false); + expect( + wrapper.get(".r-v2-jsdos__quit").attributes("disabled"), + ).toBeUndefined(); + wrapper.unmount(); + }); + + it("can discard recent changes and exit after a failed save", async () => { + mocks.confirm.mockResolvedValue(true); + const handle = makeHandle(false); + const wrapper = await mountPlayer(handle); + + await wrapper.get(".r-v2-jsdos__quit").trigger("click"); + await flushPromises(); + + expect(handle.stop).toHaveBeenCalledOnce(); + expect(mocks.flushPlaySession).toHaveBeenCalledOnce(); + expect(mocks.setPlaying).toHaveBeenLastCalledWith(false); + expect(mocks.locationReplace).toHaveBeenCalledWith("/rom/1"); + wrapper.unmount(); + }); + + it("keeps the player open when the final save fails", async () => { + const handle = makeHandle(true); + handle.save.mockRejectedValue(new Error("save failed")); + const wrapper = await mountPlayer(handle); + + await wrapper.get(".r-v2-jsdos__quit").trigger("click"); + await flushPromises(); + + expect(mocks.snackbarError).toHaveBeenCalledWith( + "play.stream-save-unconfirmed", + ); + expect(handle.stop).not.toHaveBeenCalled(); + expect(mocks.locationReplace).not.toHaveBeenCalled(); + wrapper.unmount(); + }); + + it("ignores a second quit while the final save is pending", async () => { + let finishSave: ((saved: boolean) => void) | undefined; + const handle = makeHandle(true); + handle.save.mockReturnValue( + new Promise((resolve) => { + finishSave = resolve; + }), + ); + const wrapper = await mountPlayer(handle); + + await wrapper.get(".r-v2-jsdos__quit").trigger("click"); + await wrapper.get(".r-v2-jsdos__quit").trigger("click"); + expect(handle.save).toHaveBeenCalledOnce(); + + finishSave?.(true); + await flushPromises(); + expect(mocks.locationReplace).toHaveBeenCalledOnce(); + wrapper.unmount(); + }); + + it("ignores route departure while another final save is pending", async () => { + let finishSave: ((saved: boolean) => void) | undefined; + const handle = makeHandle(true); + handle.save.mockReturnValue( + new Promise((resolve) => { + finishSave = resolve; + }), + ); + const wrapper = await mountPlayer(handle); + + await wrapper.get(".r-v2-jsdos__quit").trigger("click"); + expect(mocks.routeLeaveGuard?.({ fullPath: "/platform/2" })).toBe(false); + expect(handle.save).toHaveBeenCalledOnce(); + + finishSave?.(true); + await flushPromises(); + expect(mocks.locationReplace).toHaveBeenCalledOnce(); + expect(mocks.locationReplace).toHaveBeenCalledWith("/rom/1"); + wrapper.unmount(); + }); + + it("converts route departure into a saved hard navigation", async () => { + const handle = makeHandle(true); + const wrapper = await mountPlayer(handle); + + expect(mocks.routeLeaveGuard?.({ fullPath: "/platform/2" })).toBe(false); + await flushPromises(); + + expect(handle.save).toHaveBeenCalledOnce(); + expect(mocks.locationReplace).toHaveBeenCalledWith("/platform/2"); + wrapper.unmount(); + }); + + it("only performs best-effort stop during unmount", async () => { + const handle = makeHandle(true); + const wrapper = await mountPlayer(handle); + + wrapper.unmount(); + + expect(handle.save).not.toHaveBeenCalled(); + expect(handle.stop).toHaveBeenCalledOnce(); + expect(mocks.setPlaying).toHaveBeenLastCalledWith(false); + }); + + it("warns before reloading while the game is running", async () => { + const handle = makeHandle(true); + const wrapper = await mountPlayer(handle); + const event = new Event("beforeunload", { + cancelable: true, + }) as BeforeUnloadEvent; + + window.dispatchEvent(event); + + expect(event.defaultPrevented).toBe(true); + wrapper.unmount(); + }); + + it("uses a stable browser-local save key scoped to the RomM user", async () => { + const firstHandle = makeHandle(true); + const firstWrapper = await mountPlayer(firstHandle); + const firstOptions = vi.mocked(window.Dos!).mock.calls[0]![1]; + const firstKey = await firstOptions.fsChanges?.urlToKey?.( + "/api/roms/1/content/renamed.jsdos", + ); + firstWrapper.unmount(); + + mocks.userId = 8; + const secondHandle = makeHandle(true); + const secondWrapper = await mountPlayer(secondHandle); + const secondOptions = vi.mocked(window.Dos!).mock.calls[0]![1]; + const secondKey = await secondOptions.fsChanges?.urlToKey?.( + "/api/roms/1/content/renamed-again.jsdos", + ); + + expect(firstKey).toBe("romm-user-7-rom-1.changes"); + expect(secondKey).toBe("romm-user-8-rom-1.changes"); + expect(firstKey).not.toBe(secondKey); + secondWrapper.unmount(); + }); +}); diff --git a/frontend/src/v2/views/Player/JsDos.vue b/frontend/src/v2/views/Player/JsDos.vue index 0e9dc998db..dd88211eeb 100644 --- a/frontend/src/v2/views/Player/JsDos.vue +++ b/frontend/src/v2/views/Player/JsDos.vue @@ -2,17 +2,21 @@ import { RBtn, RCard, RSpinner, RSwitch } from "@v2/lib"; import { computed, nextTick, onBeforeUnmount, onMounted, ref } from "vue"; import { useI18n } from "vue-i18n"; -import { useRoute, useRouter } from "vue-router"; +import { onBeforeRouteLeave, useRoute, useRouter } from "vue-router"; import { ROUTES } from "@/plugins/router"; import romApi from "@/services/api/rom"; +import storeAuth from "@/stores/auth"; import storePlaying from "@/stores/playing"; import storeRoms, { type DetailedRom, type SimpleRom } from "@/stores/roms"; +import type { JsDosProps } from "@/types/js-dos"; import { getDownloadPath } from "@/utils"; import GameCover from "@/v2/components/shared/GameCover.vue"; import { useBackgroundArt } from "@/v2/composables/useBackgroundArt"; +import { useConfirm } from "@/v2/composables/useConfirm"; import { useFullscreenPref } from "@/v2/composables/useFullscreenPref"; import { usePageTitle } from "@/v2/composables/usePageTitle"; import { usePlaySession } from "@/v2/composables/usePlaySession"; +import { useSnackbar } from "@/v2/composables/useSnackbar"; import storeGalleryRoms from "@/v2/stores/galleryRoms"; // Cross-origin isolation requires same-origin runtime assets. @@ -21,33 +25,19 @@ const JSDOS_ASSET_BASE = "/assets/jsdos"; const { t } = useI18n(); const route = useRoute(); const router = useRouter(); +const authStore = storeAuth(); const playingStore = storePlaying(); const { fullscreenOnPlay } = useFullscreenPref(); const playSession = usePlaySession(); +const snackbar = useSnackbar(); +const confirm = useConfirm(); const rom = ref(null); const gameRunning = ref(false); +const quitting = ref(false); +const stage = ref(null); -type DosProps = { - stop: () => Promise; - setNoCloud: (disabled: boolean) => void; - save: () => Promise; -}; -type DosOptions = { - url: string; - backend: "dosbox" | "dosboxX"; - pathPrefix: string; - autoStart: boolean; - autoSave: boolean; - fullScreen: boolean; -}; -declare global { - interface Window { - Dos?: (el: HTMLElement, options: DosOptions) => DosProps; - } -} - -let dos: DosProps | null = null; +let dos: JsDosProps | null = null; // Seed the cover before the full ROM request resolves for the view transition. const morphRomId = computed(() => { @@ -108,23 +98,30 @@ async function onPlay() { // Preserve narrowing across nextTick(). const dosFactory = window.Dos; const currentRom = rom.value; - if (!currentRom || !dosFactory) return; + const userId = authStore.user?.id; + if (!currentRom || !dosFactory || userId == null) return; gameRunning.value = true; // Let the emulator own keyboard input while running. playingStore.setPlaying(true); await nextTick(); - const el = document.getElementById("r-v2-jsdos-stage"); - if (!el) return; + if (!stage.value) return; // DOSBox-X provides Windows support. - dos = dosFactory(el, { + dos = dosFactory(stage.value, { url: getDownloadPath({ rom: currentRom }), backend: "dosboxX", + backendLocked: true, pathPrefix: `${JSDOS_ASSET_BASE}/emulators/`, autoStart: true, autoSave: true, fullScreen: fullscreenOnPlay.value, + fsChanges: { + local: true, + // js-dos defaults to the bundle URL, which would share saves between + // RomM accounts using the same browser profile. + urlToKey: async () => `romm-user-${userId}-rom-${currentRom.id}.changes`, + }, }); // Hide the dos.zone cloud integration. dos.setNoCloud(true); @@ -132,26 +129,51 @@ async function onPlay() { playSession.start(currentRom); } -async function stopDos() { +function stopDos() { const handle = dos; dos = null; if (!handle) return; - try { - // Persist final filesystem changes before disposal. - await handle.save(); - } catch (e) { - console.error(e); - } finally { + void handle.stop().catch((error) => { + console.error("[js-dos] Stop failed", error); + }); +} + +async function leavePlayer(destination: string) { + if (quitting.value) return; + quitting.value = true; + + const handle = dos; + if (handle) { + let saved = false; try { - await handle.stop(); - } catch (e) { - console.error(e); + saved = await handle.save(); + } catch (error) { + console.error("[js-dos] Final save failed", error); + } + if (!saved) { + snackbar.error(t("play.stream-save-unconfirmed")); + const discard = await confirm({ + title: t("play.jsdos-quit-without-saving"), + confirmText: t("common.discard"), + cancelText: t("common.cancel"), + tone: "danger", + }); + if (!discard) { + quitting.value = false; + return; + } } } + + playSession.flush(); + playingStore.setPlaying(false); + stopDos(); + window.location.replace(destination); } function onlyQuit() { - window.history.back(); + const romId = rom.value?.id ?? route.params.rom; + void leavePlayer(`/rom/${romId}`); } function backToRom() { router.push({ name: ROUTES.ROM, params: { rom: rom.value?.id } }); @@ -163,7 +185,15 @@ function backToPlatform() { }); } +function onBeforeUnload(event: BeforeUnloadEvent) { + if (!dos || quitting.value) return; + event.preventDefault(); + event.returnValue = ""; +} + onMounted(async () => { + window.addEventListener("beforeunload", onBeforeUnload); + const romResponse = await romApi.getRom({ romId: parseInt(route.params.rom as string), }); @@ -178,10 +208,16 @@ onMounted(async () => { } }); -onBeforeUnmount(async () => { +onBeforeRouteLeave((to) => { + void leavePlayer(to.fullPath); + return false; +}); + +onBeforeUnmount(() => { + window.removeEventListener("beforeunload", onBeforeUnload); playSession.flush(); playingStore.setPlaying(false); - await stopDos(); + stopDos(); }); @@ -211,6 +247,10 @@ onBeforeUnmount(async () => {
+

+ {{ t("play.jsdos-browser-save-warning") }} +

+ {
-
+
{{ t("play.quit") }} @@ -329,6 +371,12 @@ onBeforeUnmount(async () => { margin-top: 8px; } +.r-v2-jsdos__save-note { + margin: 0; + color: var(--r-color-fg-muted); + font-size: var(--r-font-size-sm); +} + .r-v2-jsdos__stage-wrap { position: fixed; inset: var(--r-nav-h) 0 0 0; From 7c86fc2eb2a9db36590d0a01e9a835879bf669ee Mon Sep 17 00:00:00 2001 From: Bruno Henriques <4727729+bphenriques@users.noreply.github.com> Date: Sun, 16 Aug 2026 19:27:02 +0100 Subject: [PATCH 03/11] fix: guard js-dos player navigation state Co-authored-by: Amp Amp-Thread-ID: https://ampcode.com/threads/T-019fcd57-445f-754d-9a5b-a45cf2878d99 --- frontend/src/v2/views/Player/JsDos.test.ts | 53 +++++++++++++++++++--- frontend/src/v2/views/Player/JsDos.vue | 19 ++++++-- 2 files changed, 62 insertions(+), 10 deletions(-) diff --git a/frontend/src/v2/views/Player/JsDos.test.ts b/frontend/src/v2/views/Player/JsDos.test.ts index 2b454a53c6..a7a3290654 100644 --- a/frontend/src/v2/views/Player/JsDos.test.ts +++ b/frontend/src/v2/views/Player/JsDos.test.ts @@ -19,6 +19,7 @@ const mocks = vi.hoisted(() => ({ playSessionStart: vi.fn(), push: vi.fn(), confirm: vi.fn(), + galleryRom: null as Record | null, routeLeaveGuard: null as ((to: { fullPath: string }) => unknown) | null, setPlaying: vi.fn(), snackbarError: vi.fn(), @@ -94,7 +95,7 @@ vi.mock("@/v2/composables/useSnackbar", () => ({ })); vi.mock("@/v2/stores/galleryRoms", () => ({ - default: () => ({ getRomById: () => null }), + default: () => ({ getRomById: () => mocks.galleryRom }), })); const rom = { @@ -129,17 +130,16 @@ afterAll(() => { beforeEach(() => { vi.clearAllMocks(); + mocks.galleryRom = null; mocks.routeLeaveGuard = null; mocks.userId = 7; mocks.confirm.mockResolvedValue(false); mocks.getRom.mockResolvedValue({ data: rom }); + window.Dos = undefined; }); -async function mountPlayer(handle: JsDosProps): Promise { - window.Dos = vi.fn( - (_element: HTMLDivElement, _options: Partial) => handle, - ); - const wrapper = mount(JsDos, { +function mountView(): VueWrapper { + return mount(JsDos, { global: { stubs: { RBtn: { @@ -152,6 +152,13 @@ async function mountPlayer(handle: JsDosProps): Promise { }, }, }); +} + +async function mountPlayer(handle: JsDosProps): Promise { + window.Dos = vi.fn( + (_element: HTMLDivElement, _options: Partial) => handle, + ); + const wrapper = mountView(); await flushPromises(); await wrapper.get(".r-v2-jsdos__play").trigger("click"); await nextTick(); @@ -168,6 +175,40 @@ function makeHandle(saveResult: boolean) { } describe("JsDos player exit", () => { + it("reports when the runtime has not loaded", async () => { + const wrapper = mountView(); + await flushPromises(); + + await wrapper.get(".r-v2-jsdos__play").trigger("click"); + + expect(mocks.snackbarError).toHaveBeenCalledWith( + "play.stream-error-generic", + ); + expect(mocks.setPlaying).not.toHaveBeenCalledWith(true); + wrapper.unmount(); + }); + + it("uses the gallery seed for back navigation while the ROM loads", async () => { + mocks.galleryRom = rom; + mocks.getRom.mockReturnValue(new Promise(() => undefined)); + const wrapper = mountView(); + await nextTick(); + + const buttons = wrapper.findAll("button"); + await buttons[1]!.trigger("click"); + await buttons[2]!.trigger("click"); + + expect(mocks.push).toHaveBeenNthCalledWith(1, { + name: "rom", + params: { rom: 1 }, + }); + expect(mocks.push).toHaveBeenNthCalledWith(2, { + name: "platform", + params: { platform: 2 }, + }); + wrapper.unmount(); + }); + it("hard-navigates after saving without awaiting stop", async () => { const handle = makeHandle(true); const wrapper = await mountPlayer(handle); diff --git a/frontend/src/v2/views/Player/JsDos.vue b/frontend/src/v2/views/Player/JsDos.vue index dd88211eeb..d8b1db82b9 100644 --- a/frontend/src/v2/views/Player/JsDos.vue +++ b/frontend/src/v2/views/Player/JsDos.vue @@ -99,13 +99,21 @@ async function onPlay() { const dosFactory = window.Dos; const currentRom = rom.value; const userId = authStore.user?.id; - if (!currentRom || !dosFactory || userId == null) return; + if (!currentRom || userId == null) return; + if (!dosFactory) { + snackbar.error(t("play.stream-error-generic")); + return; + } gameRunning.value = true; // Let the emulator own keyboard input while running. playingStore.setPlaying(true); await nextTick(); - if (!stage.value) return; + if (!stage.value) { + gameRunning.value = false; + playingStore.setPlaying(false); + return; + } // DOSBox-X provides Windows support. dos = dosFactory(stage.value, { @@ -176,12 +184,15 @@ function onlyQuit() { void leavePlayer(`/rom/${romId}`); } function backToRom() { - router.push({ name: ROUTES.ROM, params: { rom: rom.value?.id } }); + const romId = heroRom.value?.id ?? route.params.rom; + router.push({ name: ROUTES.ROM, params: { rom: romId } }); } function backToPlatform() { + const platformId = heroRom.value?.platform_id; + if (platformId == null) return; router.push({ name: ROUTES.PLATFORM, - params: { platform: rom.value?.platform_id }, + params: { platform: platformId }, }); } From 29bad62e58b024971ff3cfd8fab50cb917b011d6 Mon Sep 17 00:00:00 2001 From: Georges-Antoine Assi Date: Sat, 29 Aug 2026 09:57:35 -0400 Subject: [PATCH 04/11] chore: trim unused js-dos assets from the image The js-dos release bundle ships a demo index.html that would be served unauthenticated under /assets/jsdos, plus source maps, Emscripten symbol tables and type declarations that no runtime code loads. Co-Authored-By: Claude Opus 5 (1M context) --- docker/Dockerfile | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index d2d06447b7..02c5e5460f 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -132,10 +132,14 @@ RUN wget "https://github.com/ruffle-rs/ruffle/releases/download/${RUFFLE_VERSION ARG JSDOS_VERSION=8.4.1 ARG JSDOS_SHA256=26118692bbb180aec78ec1697eb1ea6b28ff410101870cfa3e68309914c7eaa6 +# The bundled index.html is a js-dos demo page that would be served unauthenticated; +# source maps, Emscripten symbol files and type declarations are unused at runtime. RUN wget "https://github.com/caiiiycuk/js-dos/releases/download/v${JSDOS_VERSION}/release.zip" -O jsdos.zip && \ echo "${JSDOS_SHA256} jsdos.zip" | sha256sum -c - && \ unzip -o jsdos.zip -d /jsdos && \ - rm -f jsdos.zip + rm -f jsdos.zip && \ + rm -rf /jsdos/dist/index.html /jsdos/dist/emulators/types && \ + find /jsdos/dist \( -name '*.map' -o -name '*.symbols' \) -exec rm -f {} + # BUILD NGINX MODULE WITH MOD_ZIP From c38282f989d8b00dd2915657124ef9fdf9ddef0a Mon Sep 17 00:00:00 2001 From: Georges-Antoine Assi Date: Sat, 29 Aug 2026 09:59:27 -0400 Subject: [PATCH 05/11] test: cover the useCanPlay file gate useCanPlay had no tests of its own, and useGameActions mocks it wholesale, so nothing exercised the has_file_on_disk gate that keeps a physical or missing-file game from offering a Play action. Add the missing suite, including a case per engine so the js-dos route cannot regain the gate. Also apply trunk fmt to a js-dos case in utils/index.test.ts. Co-Authored-By: Claude Opus 5 (1M context) --- frontend/src/utils/index.test.ts | 5 +- .../v2/composables/useCanPlay/index.test.ts | 104 ++++++++++++++++++ 2 files changed, 108 insertions(+), 1 deletion(-) create mode 100644 frontend/src/v2/composables/useCanPlay/index.test.ts diff --git a/frontend/src/utils/index.test.ts b/frontend/src/utils/index.test.ts index d34b92159b..fa9015a50c 100644 --- a/frontend/src/utils/index.test.ts +++ b/frontend/src/utils/index.test.ts @@ -125,7 +125,10 @@ describe("isJsDosEmulationSupported", () => { it("respects the DISABLE_JSDOS admin toggle", () => { expect( - isJsDosEmulationSupported("win3x", makeHeartbeat({ DISABLE_JSDOS: true })), + isJsDosEmulationSupported( + "win3x", + makeHeartbeat({ DISABLE_JSDOS: true }), + ), ).toBe(false); }); diff --git a/frontend/src/v2/composables/useCanPlay/index.test.ts b/frontend/src/v2/composables/useCanPlay/index.test.ts new file mode 100644 index 0000000000..072a6d7c54 --- /dev/null +++ b/frontend/src/v2/composables/useCanPlay/index.test.ts @@ -0,0 +1,104 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { ref } from "vue"; +import type { SimpleRom } from "@/stores/roms"; +import { useCanPlay } from "./index"; + +// Each engine's support check is stubbed so a test can enable one route at a +// time and assert the gating around it, not the platform tables themselves. +const support = vi.hoisted(() => ({ + ejs: vi.fn(() => false), + jsDos: vi.fn(() => false), + ruffle: vi.fn(() => false), +})); + +const heartbeat = ref({}); + +vi.mock("pinia", () => ({ + storeToRefs: () => ({ value: heartbeat }), +})); +vi.mock("@/stores/config", () => ({ default: () => ({ config: {} }) })); +vi.mock("@/stores/heartbeat", () => ({ default: () => ({}) })); +vi.mock("@/utils", () => ({ + isEJSEmulationSupported: support.ejs, + isJsDosEmulationSupported: support.jsDos, + isRuffleEmulationSupported: support.ruffle, +})); + +function makeRom(overrides: Partial = {}): SimpleRom { + return { + id: 1, + name: "Chrono Trigger", + platform_slug: "snes", + has_file_on_disk: true, + ...overrides, + } as unknown as SimpleRom; +} + +beforeEach(() => { + support.ejs.mockReturnValue(false); + support.jsDos.mockReturnValue(false); + support.ruffle.mockReturnValue(false); + support.ejs.mockClear(); + support.jsDos.mockClear(); + support.ruffle.mockClear(); +}); + +describe("useCanPlay", () => { + it.each([ + ["EJS", "ejs", "canPlayEJS"], + ["js-dos", "jsDos", "canPlayJsDos"], + ["Ruffle", "ruffle", "canPlayRuffle"], + ] as const)("reports %s support on its own flag", (_label, stub, flag) => { + support[stub].mockReturnValue(true); + const result = useCanPlay(() => makeRom()); + + expect(result[flag].value).toBe(true); + expect(result.canPlay.value).toBe(true); + }); + + // A physical game, or one whose file vanished from the library, has nothing + // to hand the emulator: every route boots from the download endpoint. + it.each(["ejs", "jsDos", "ruffle"] as const)( + "refuses %s for a rom with no file on disk", + (stub) => { + support[stub].mockReturnValue(true); + const { canPlay } = useCanPlay(() => + makeRom({ has_file_on_disk: false }), + ); + + expect(canPlay.value).toBe(false); + expect(support[stub]).not.toHaveBeenCalled(); + }, + ); + + it("refuses every route when there is no rom", () => { + support.ejs.mockReturnValue(true); + support.jsDos.mockReturnValue(true); + support.ruffle.mockReturnValue(true); + const { canPlay, canPlayEJS, canPlayJsDos, canPlayRuffle } = useCanPlay( + () => null, + ); + + expect(canPlay.value).toBe(false); + expect(canPlayEJS.value).toBe(false); + expect(canPlayJsDos.value).toBe(false); + expect(canPlayRuffle.value).toBe(false); + }); + + it("stays unplayable when no engine supports the platform", () => { + const { canPlay } = useCanPlay(() => makeRom()); + + expect(canPlay.value).toBe(false); + }); + + it("re-evaluates when the rom behind the getter changes", () => { + support.jsDos.mockReturnValue(true); + const rom = ref(makeRom({ has_file_on_disk: false })); + const { canPlay } = useCanPlay(() => rom.value); + + expect(canPlay.value).toBe(false); + + rom.value = makeRom(); + expect(canPlay.value).toBe(true); + }); +}); From 4142fbc018b230f8aeb04ea0647095519173ae22 Mon Sep 17 00:00:00 2001 From: Georges-Antoine Assi Date: Sat, 29 Aug 2026 10:20:47 -0400 Subject: [PATCH 06/11] refactor: dedupe js-dos emulation checks and player plumbing usePlatformPlayable is the platform-level twin of useCanPlay, but only the latter learned about js-dos, so win3x/win9x games offered Play while their platform tile showed no playable badge and sorted into the unplayable bucket. Teach resolveEmulator about js-dos and derive `playable`/`isPlayable` from it instead of hand-maintained OR chains. Collapse the duplication the new backend introduced: a shared resolvePlatformSlug in utils, a supportedBy factory for useCanPlay's three identical computeds, one COOP/COEP nginx pattern per map, and a single hard-navigation branch in useGameActions that reuses the canPlay useCanPlay already returns. In JsDos.vue, share the teardown tail between leavePlayer and onBeforeUnmount, collapse four derivations of the route rom id into one, start the js-dos runtime alongside the rom request rather than behind it, and hold the rom in a shallowRef since the view only reads scalars off it. Co-Authored-By: Claude Opus 5 (1M context) --- docker/Dockerfile | 2 +- docker/nginx/templates/default.conf.template | 6 +- docs/BACKEND_ARCHITECTURE.md | 1 + docs/FRONTEND_ARCHITECTURE.md | 2 +- frontend/src/types/js-dos.d.ts | 4 - frontend/src/utils/index.ts | 17 ++- .../src/v2/composables/useCanPlay/index.ts | 42 ++----- .../v2/composables/useGameActions/index.ts | 29 +++-- .../composables/usePlatformPlayable/index.ts | 65 ++++------- frontend/src/v2/views/Player/JsDos.test.ts | 24 ++-- frontend/src/v2/views/Player/JsDos.vue | 107 +++++++++--------- 11 files changed, 131 insertions(+), 168 deletions(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 02c5e5460f..37702b4a6f 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -104,7 +104,7 @@ RUN git clone --recursive --branch "${RALIBRETRO_VERSION}" --depth 1 https://git make HAVE_CHD=1 -f ./Makefile.RAHasher -# FETCH EMULATORJS AND RUFFLE +# FETCH EMULATORJS, RUFFLE AND JS-DOS FROM alpine:${ALPINE_VERSION}@sha256:${ALPINE_SHA256} AS emulator-stage RUN apk add --no-cache \ diff --git a/docker/nginx/templates/default.conf.template b/docker/nginx/templates/default.conf.template index a4224f3fea..2944a1e6b5 100644 --- a/docker/nginx/templates/default.conf.template +++ b/docker/nginx/templates/default.conf.template @@ -19,14 +19,12 @@ map $http_x_forwarded_proto $forwardscheme { # is needed for multi-threaded cores. map $request_uri $coep_header { default ""; - ~^/rom/.*/ejs$ "require-corp"; - ~^/rom/.*/jsdos$ "require-corp"; + ~^/rom/.*/(ejs|jsdos)$ "require-corp"; ~^/console/rom/[0-9]+/play "require-corp"; } map $request_uri $coop_header { default ""; - ~^/rom/.*/ejs$ "same-origin"; - ~^/rom/.*/jsdos$ "same-origin"; + ~^/rom/.*/(ejs|jsdos)$ "same-origin"; ~^/console/rom/[0-9]+/play "same-origin"; } diff --git a/docs/BACKEND_ARCHITECTURE.md b/docs/BACKEND_ARCHITECTURE.md index 854b907ef1..c92636dad8 100644 --- a/docs/BACKEND_ARCHITECTURE.md +++ b/docs/BACKEND_ARCHITECTURE.md @@ -1601,6 +1601,7 @@ Falls back to `FakeRedis` in test mode. | `HLTB_API_ENABLED` | `false` | HowLongToBeat | | `DISABLE_EMULATOR_JS` | `false` | Hide EmulatorJS player | | `DISABLE_RUFFLE_RS` | `false` | Hide Ruffle Flash player | +| `DISABLE_JSDOS` | `false` | Hide js-dos player | #### Task Scheduling diff --git a/docs/FRONTEND_ARCHITECTURE.md b/docs/FRONTEND_ARCHITECTURE.md index 26ce4a254b..e49823c837 100644 --- a/docs/FRONTEND_ARCHITECTURE.md +++ b/docs/FRONTEND_ARCHITECTURE.md @@ -508,7 +508,7 @@ Server capability flags used throughout the UI: ```typescript METADATA_SOURCES: { IGDB, SS, MOBY, RA, STEAMGRIDDB, LAUNCHBOX, ... } -EMULATION: { DISABLE_EMULATOR_JS, DISABLE_RUFFLE_RS } +EMULATION: { DISABLE_EMULATOR_JS, DISABLE_RUFFLE_RS, DISABLE_JSDOS } FRONTEND: { DISABLE_USERPASS_LOGIN, DISABLE_LOGS_VIEWER, YOUTUBE_BASE_URL } OIDC: { ENABLED, AUTOLOGIN, PROVIDER, RP_INITIATED_LOGOUT } TASKS: { scheduled task configurations } diff --git a/frontend/src/types/js-dos.d.ts b/frontend/src/types/js-dos.d.ts index 521d963f51..0261b9c50b 100644 --- a/frontend/src/types/js-dos.d.ts +++ b/frontend/src/types/js-dos.d.ts @@ -9,14 +9,10 @@ export interface JsDosOptions { fsChanges: { local: boolean; urlToKey?: (url: string) => Promise; - pull?: (key: string) => Promise; - push?: (key: string, data: Uint8Array) => Promise; - delete?: (key: string) => Promise; }; } export interface JsDosProps { - getLocalChanges(key: string): Promise; setNoCloud(noCloud: boolean): void; save(): Promise; stop(): Promise; diff --git a/frontend/src/utils/index.ts b/frontend/src/utils/index.ts index c2344a909e..0bb3152797 100644 --- a/frontend/src/utils/index.ts +++ b/frontend/src/utils/index.ts @@ -615,6 +615,17 @@ const canvas = document.createElement("canvas"); const gl = canvas.getContext("webgl") || canvas.getContext("experimental-webgl"); +/** + * Resolve a platform slug through the configured version remap. + * + * @param platformSlug The platform slug. + * @param config Optional configuration object. + * @returns The remapped slug, or the original when no remap applies. + */ +export function resolvePlatformSlug(platformSlug: string, config?: Config) { + return config?.PLATFORMS_VERSIONS[platformSlug] || platformSlug; +} + /** * Check if EJS emulation is supported for a given platform. * @@ -630,7 +641,7 @@ export function isEJSEmulationSupported( ) { if (heartbeat.EMULATION.DISABLE_EMULATOR_JS) return false; - const slug = config?.PLATFORMS_VERSIONS[platformSlug] || platformSlug; + const slug = resolvePlatformSlug(platformSlug, config); return ( getSupportedEJSCores(slug, config?.EJS_NETPLAY_ENABLED).length > 0 && gl instanceof WebGLRenderingContext @@ -687,7 +698,7 @@ export function isRuffleEmulationSupported( ) { if (heartbeat.EMULATION.DISABLE_RUFFLE_RS) return false; - const slug = config?.PLATFORMS_VERSIONS[platformSlug] || platformSlug; + const slug = resolvePlatformSlug(platformSlug, config); return ["flash", "browser"].includes(slug.toLowerCase()); } @@ -706,7 +717,7 @@ export function isJsDosEmulationSupported( ) { if (heartbeat.EMULATION.DISABLE_JSDOS) return false; - const slug = config?.PLATFORMS_VERSIONS[platformSlug] || platformSlug; + const slug = resolvePlatformSlug(platformSlug, config); return ["win3x", "win9x"].includes(slug.toLowerCase()); } diff --git a/frontend/src/v2/composables/useCanPlay/index.ts b/frontend/src/v2/composables/useCanPlay/index.ts index d370f8ec35..6c5ebfebaf 100644 --- a/frontend/src/v2/composables/useCanPlay/index.ts +++ b/frontend/src/v2/composables/useCanPlay/index.ts @@ -1,6 +1,5 @@ -// useCanPlay — reactive "is this ROM playable in-browser?" check, shared -// by every surface that renders the Play action (GameCard overlay, -// GameActions ribbon, GameActionsList more-menu). v1 had the same gate +// useCanPlay — reactive "can this ROM be played in the browser?" check. +// v1 duplicated this logic across GameCard, GameDetails and the play menu // inside PlayBtn.vue; v2 lifts it to a composable so the card overlay // and the menu item agree with the details-header CTA. // @@ -30,35 +29,16 @@ export function useCanPlay(getRom: () => SimpleRom | null | undefined): { const configStore = storeConfig(); const { value: heartbeat } = storeToRefs(heartbeatStore); - const canPlayEJS = computed(() => { - const rom = getRom(); - if (!rom?.has_file_on_disk) return false; - return isEJSEmulationSupported( - rom.platform_slug, - heartbeat.value, - configStore.config, - ); - }); + const supportedBy = (check: typeof isEJSEmulationSupported) => + computed(() => { + const rom = getRom(); + if (!rom?.has_file_on_disk) return false; + return check(rom.platform_slug, heartbeat.value, configStore.config); + }); - const canPlayRuffle = computed(() => { - const rom = getRom(); - if (!rom?.has_file_on_disk) return false; - return isRuffleEmulationSupported( - rom.platform_slug, - heartbeat.value, - configStore.config, - ); - }); - - const canPlayJsDos = computed(() => { - const rom = getRom(); - if (!rom?.has_file_on_disk) return false; - return isJsDosEmulationSupported( - rom.platform_slug, - heartbeat.value, - configStore.config, - ); - }); + const canPlayEJS = supportedBy(isEJSEmulationSupported); + const canPlayJsDos = supportedBy(isJsDosEmulationSupported); + const canPlayRuffle = supportedBy(isRuffleEmulationSupported); const canPlay = computed( () => canPlayEJS.value || canPlayJsDos.value || canPlayRuffle.value, diff --git a/frontend/src/v2/composables/useGameActions/index.ts b/frontend/src/v2/composables/useGameActions/index.ts index 244daac561..5c8b755b5d 100644 --- a/frontend/src/v2/composables/useGameActions/index.ts +++ b/frontend/src/v2/composables/useGameActions/index.ts @@ -77,7 +77,12 @@ export function useGameActions( // delete that 403s. const canDelete = computed(() => hasDeleteGrant.value && canEdit.value); const { isFavorite, toggleFavorite } = useFavoriteToggle(emitter); - const { canPlayEJS, canPlayJsDos, canPlayRuffle } = useCanPlay(getRom); + const { + canPlay: canPlayLocal, + canPlayEJS, + canPlayJsDos, + canPlayRuffle, + } = useCanPlay(getRom); const streamingStore = useStreamingStore(); // Streaming is the preferred way to play where a container is @@ -91,13 +96,7 @@ export function useGameActions( streamingStore.containerForPlatform(rom.platform_slug), ); }); - const canPlay = computed( - () => - canPlayStream.value || - canPlayJsDos.value || - canPlayEJS.value || - canPlayRuffle.value, - ); + const canPlay = computed(() => canPlayStream.value || canPlayLocal.value); // Download, the copied link and the QR code all resolve to the download // endpoint, which has nothing to serve without a file behind the rom. @@ -263,13 +262,13 @@ export function useGameActions( // EmulatorJS and js-dos need SharedArrayBuffer. Nginx only attaches the // necessary COOP/COEP headers to the player document, so an SPA navigation // cannot enable cross-origin isolation. Load the document directly instead. - // js-dos owns win3x/win9x, so it is checked ahead of EJS. - if (!canPlayStream.value && canPlayJsDos.value) { - window.location.assign(`/rom/${rom.id}/jsdos`); - return; - } - if (!canPlayStream.value && canPlayEJS.value) { - window.location.assign(`/rom/${rom.id}/ejs`); + const isolated = canPlayJsDos.value + ? "jsdos" + : canPlayEJS.value + ? "ejs" + : null; + if (!canPlayStream.value && isolated) { + window.location.assign(`/rom/${rom.id}/${isolated}`); return; } diff --git a/frontend/src/v2/composables/usePlatformPlayable/index.ts b/frontend/src/v2/composables/usePlatformPlayable/index.ts index f77603f0da..194ac4ea8b 100644 --- a/frontend/src/v2/composables/usePlatformPlayable/index.ts +++ b/frontend/src/v2/composables/usePlatformPlayable/index.ts @@ -1,7 +1,7 @@ // usePlatformPlayable — reactive "can any ROM on this platform run // in-browser?" check. Companion to useCanPlay (which takes a rom); // platform-level surfaces (PlatformTile, PlatformListRow) only know the -// slug, so they read this instead. Reuses the same EJS + Ruffle utils +// slug, so they read this instead. Reuses the same engine-support utils // so the marker on the tile and the Play button on the ROM agree. // // `usePlatformPlayableChecker` is the batch sibling — returns a plain @@ -9,10 +9,11 @@ // slugs at once (sort comparators, group-by buckets in PlatformsIndex). // // `emulator` resolves to the in-browser engine that actually drives the -// platform: "ruffle" for Flash, "dosbox" when the EJS catalogue picks -// the dosbox_pure core (DOS is wrapped by EJS but distinctive enough to -// surface by name in the UI), "emulatorjs" for everything else playable, -// and `null` when nothing on the server can run it. +// platform: "ruffle" for Flash, "jsdos" for Windows 3.x/9x, "dosbox" +// when the EJS catalogue picks the dosbox_pure core (DOS is wrapped by +// EJS but distinctive enough to surface by name in the UI), +// "emulatorjs" for everything else playable, and `null` when nothing on +// the server can run it. import { storeToRefs } from "pinia"; import { computed, type ComputedRef } from "vue"; import storeConfig, { type Config } from "@/stores/config"; @@ -20,10 +21,13 @@ import storeHeartbeat, { type Heartbeat } from "@/stores/heartbeat"; import { getSupportedEJSCores, isEJSEmulationSupported, + isJsDosEmulationSupported, isRuffleEmulationSupported, + resolvePlatformSlug, } from "@/utils"; -export type PlatformEmulator = "emulatorjs" | "ruffle" | "dosbox" | null; +export type PlatformEmulator = + "emulatorjs" | "ruffle" | "jsdos" | "dosbox" | null; /** Pure helper — picks the engine that would actually run a platform. * Shared between the reactive and the batch composables so both surface @@ -35,46 +39,28 @@ function resolveEmulator( ): PlatformEmulator { if (!slug) return null; if (isRuffleEmulationSupported(slug, heartbeat, config)) return "ruffle"; + if (isJsDosEmulationSupported(slug, heartbeat, config)) return "jsdos"; if (!isEJSEmulationSupported(slug, heartbeat, config)) return null; - const resolved = config?.PLATFORMS_VERSIONS[slug] || slug; - const cores = getSupportedEJSCores(resolved); + const cores = getSupportedEJSCores(resolvePlatformSlug(slug, config)); if (cores.includes("dosbox_pure")) return "dosbox"; return "emulatorjs"; } export function usePlatformPlayable(getSlug: () => string | null | undefined): { playable: ComputedRef; - playableEJS: ComputedRef; - playableRuffle: ComputedRef; emulator: ComputedRef; } { const heartbeatStore = storeHeartbeat(); const configStore = storeConfig(); const { value: heartbeat } = storeToRefs(heartbeatStore); - const playableEJS = computed(() => { - const slug = getSlug(); - if (!slug) return false; - return isEJSEmulationSupported(slug, heartbeat.value, configStore.config); - }); - - const playableRuffle = computed(() => { - const slug = getSlug(); - if (!slug) return false; - return isRuffleEmulationSupported( - slug, - heartbeat.value, - configStore.config, - ); - }); - - const playable = computed(() => playableEJS.value || playableRuffle.value); - const emulator = computed(() => resolveEmulator(getSlug(), heartbeat.value, configStore.config), ); - return { playable, playableEJS, playableRuffle, emulator }; + const playable = computed(() => emulator.value !== null); + + return { playable, emulator }; } export function usePlatformPlayableChecker(): { @@ -90,18 +76,6 @@ export function usePlatformPlayableChecker(): { // Expose computed functions so callers that consume them inside another // computed (sort comparator, bucket discriminator) re-run when the // heartbeat or admin-toggle state changes. - const isPlayable = computed(() => { - const hb = heartbeat.value; - const cfg = configStore.config; - return (slug: string | null | undefined) => { - if (!slug) return false; - return ( - isEJSEmulationSupported(slug, hb, cfg) || - isRuffleEmulationSupported(slug, hb, cfg) - ); - }; - }); - const getEmulator = computed(() => { const hb = heartbeat.value; const cfg = configStore.config; @@ -109,6 +83,11 @@ export function usePlatformPlayableChecker(): { resolveEmulator(slug, hb, cfg); }); + const isPlayable = computed(() => { + const resolve = getEmulator.value; + return (slug: string | null | undefined) => resolve(slug) !== null; + }); + return { isPlayable, getEmulator }; } @@ -118,11 +97,13 @@ export function playableTooltip(emulator: PlatformEmulator): string { switch (emulator) { case "ruffle": return "Playable in browser through Ruffle"; + case "jsdos": + return "Playable in browser through js-dos"; case "dosbox": return "Playable in browser through DOSBox"; case "emulatorjs": return "Playable in browser through EmulatorJS"; case null: - return "Not supported by EmulatorJS"; + return "Not playable in browser"; } } diff --git a/frontend/src/v2/views/Player/JsDos.test.ts b/frontend/src/v2/views/Player/JsDos.test.ts index a7a3290654..82b2d02fb5 100644 --- a/frontend/src/v2/views/Player/JsDos.test.ts +++ b/frontend/src/v2/views/Player/JsDos.test.ts @@ -23,7 +23,7 @@ const mocks = vi.hoisted(() => ({ routeLeaveGuard: null as ((to: { fullPath: string }) => unknown) | null, setPlaying: vi.fn(), snackbarError: vi.fn(), - userId: 7 as number, + userId: 7, })); vi.mock("vue-i18n", () => ({ @@ -165,9 +165,8 @@ async function mountPlayer(handle: JsDosProps): Promise { return wrapper; } -function makeHandle(saveResult: boolean) { +function makeHandle(saveResult = true) { return { - getLocalChanges: vi.fn().mockResolvedValue(null), save: vi.fn().mockResolvedValue(saveResult), setNoCloud: vi.fn(), stop: vi.fn(() => new Promise(() => undefined)), @@ -210,7 +209,7 @@ describe("JsDos player exit", () => { }); it("hard-navigates after saving without awaiting stop", async () => { - const handle = makeHandle(true); + const handle = makeHandle(); const wrapper = await mountPlayer(handle); await wrapper.get(".r-v2-jsdos__quit").trigger("click"); @@ -261,7 +260,7 @@ describe("JsDos player exit", () => { }); it("keeps the player open when the final save fails", async () => { - const handle = makeHandle(true); + const handle = makeHandle(); handle.save.mockRejectedValue(new Error("save failed")); const wrapper = await mountPlayer(handle); @@ -278,7 +277,7 @@ describe("JsDos player exit", () => { it("ignores a second quit while the final save is pending", async () => { let finishSave: ((saved: boolean) => void) | undefined; - const handle = makeHandle(true); + const handle = makeHandle(); handle.save.mockReturnValue( new Promise((resolve) => { finishSave = resolve; @@ -298,7 +297,7 @@ describe("JsDos player exit", () => { it("ignores route departure while another final save is pending", async () => { let finishSave: ((saved: boolean) => void) | undefined; - const handle = makeHandle(true); + const handle = makeHandle(); handle.save.mockReturnValue( new Promise((resolve) => { finishSave = resolve; @@ -318,7 +317,7 @@ describe("JsDos player exit", () => { }); it("converts route departure into a saved hard navigation", async () => { - const handle = makeHandle(true); + const handle = makeHandle(); const wrapper = await mountPlayer(handle); expect(mocks.routeLeaveGuard?.({ fullPath: "/platform/2" })).toBe(false); @@ -330,7 +329,7 @@ describe("JsDos player exit", () => { }); it("only performs best-effort stop during unmount", async () => { - const handle = makeHandle(true); + const handle = makeHandle(); const wrapper = await mountPlayer(handle); wrapper.unmount(); @@ -341,7 +340,7 @@ describe("JsDos player exit", () => { }); it("warns before reloading while the game is running", async () => { - const handle = makeHandle(true); + const handle = makeHandle(); const wrapper = await mountPlayer(handle); const event = new Event("beforeunload", { cancelable: true, @@ -354,7 +353,7 @@ describe("JsDos player exit", () => { }); it("uses a stable browser-local save key scoped to the RomM user", async () => { - const firstHandle = makeHandle(true); + const firstHandle = makeHandle(); const firstWrapper = await mountPlayer(firstHandle); const firstOptions = vi.mocked(window.Dos!).mock.calls[0]![1]; const firstKey = await firstOptions.fsChanges?.urlToKey?.( @@ -363,7 +362,7 @@ describe("JsDos player exit", () => { firstWrapper.unmount(); mocks.userId = 8; - const secondHandle = makeHandle(true); + const secondHandle = makeHandle(); const secondWrapper = await mountPlayer(secondHandle); const secondOptions = vi.mocked(window.Dos!).mock.calls[0]![1]; const secondKey = await secondOptions.fsChanges?.urlToKey?.( @@ -372,7 +371,6 @@ describe("JsDos player exit", () => { expect(firstKey).toBe("romm-user-7-rom-1.changes"); expect(secondKey).toBe("romm-user-8-rom-1.changes"); - expect(firstKey).not.toBe(secondKey); secondWrapper.unmount(); }); }); diff --git a/frontend/src/v2/views/Player/JsDos.vue b/frontend/src/v2/views/Player/JsDos.vue index d8b1db82b9..0670b9166c 100644 --- a/frontend/src/v2/views/Player/JsDos.vue +++ b/frontend/src/v2/views/Player/JsDos.vue @@ -1,6 +1,13 @@ @@ -241,7 +240,7 @@ onBeforeUnmount(() => { :rom="heroRom" :title="title" :identified="heroRom?.is_identified ?? true" - :morph-id="morphRomId" + :morph-id="romId" style-context="player" morph-static hover-motion From 55938907060c3816f6afa046c7df5c2b591f9029 Mon Sep 17 00:00:00 2001 From: Georges-Antoine Assi Date: Sat, 29 Aug 2026 11:21:48 -0400 Subject: [PATCH 07/11] fix(jsdos): fall back to a CDN when the runtime is not served locally The js-dos runtime is only installed by docker/Dockerfile into the full image. Slim images ship without it, and the dev container has no emulator assets at all, so /assets/jsdos/js-dos.js resolved to the SPA fallback: 200 with index.html, which fails the stylesheet as text/html and rejects the script. EmulatorJS and Ruffle already fall back to a CDN in exactly this situation; js-dos was the only player without one, leaving it dead on slim images. Fall back to jsDelivr pinned to the same 8.4.1 the image bundles. It is the only source that is both version-pinned and sends `cross-origin-resource-policy: cross-origin`, so it still loads under the COOP and COEP headers nginx attaches to the player document; the official CDN sends no CORP header and has no versioned path. The emulator payloads follow whichever base served the runtime. Lift EmulatorJS's script injection and its content-type pre-flight into a shared module rather than adding a fourth copy. The pre-flight is what makes the fallback fire at all: a