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 1/3] 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 2/3] 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 3/3] 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 }, }); }