diff --git a/backend/config/__init__.py b/backend/config/__init__.py index 3db6a3ece0..9483693638 100644 --- a/backend/config/__init__.py +++ b/backend/config/__init__.py @@ -292,6 +292,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 46518ca320..8a8153c853 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, @@ -117,6 +118,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 634ff30b21..cc2bce9b9e 100644 --- a/backend/endpoints/responses/heartbeat.py +++ b/backend/endpoints/responses/heartbeat.py @@ -30,6 +30,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 05bd993b40..e2a7a9852f 100644 --- a/backend/tests/endpoints/test_heartbeat.py +++ b/backend/tests/endpoints/test_heartbeat.py @@ -42,6 +42,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 07dc03e46c..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 \ @@ -129,6 +129,18 @@ 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 + +# 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 -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 alpine:${ALPINE_VERSION}@sha256:${ALPINE_SHA256} AS nginx-build @@ -255,6 +267,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..e7f5320e66 100644 --- a/docker/nginx/templates/default.conf.template +++ b/docker/nginx/templates/default.conf.template @@ -15,16 +15,17 @@ 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. Not $uri: try_files rewrites it to +# /index.html before add_header runs, so the query string is matched explicitly. map $request_uri $coep_header { default ""; - ~^/rom/.*/ejs$ "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/.*/(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/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/locales/bg_BG/play.json b/frontend/src/locales/bg_BG/play.json index 5dcf56f7cd..df6377421c 100644 --- a/frontend/src/locales/bg_BG/play.json +++ b/frontend/src/locales/bg_BG/play.json @@ -14,6 +14,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 4fb6642c8b..494e563d97 100644 --- a/frontend/src/locales/cs_CZ/play.json +++ b/frontend/src/locales/cs_CZ/play.json @@ -14,6 +14,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 37a8d1a107..5a7c9f61f5 100644 --- a/frontend/src/locales/de_DE/play.json +++ b/frontend/src/locales/de_DE/play.json @@ -14,6 +14,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 24a71e16f9..bb0bf304e7 100644 --- a/frontend/src/locales/en_GB/play.json +++ b/frontend/src/locales/en_GB/play.json @@ -14,6 +14,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 5d9cdaff55..c078c9e08c 100644 --- a/frontend/src/locales/en_US/play.json +++ b/frontend/src/locales/en_US/play.json @@ -14,6 +14,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 2dfdd4abc2..2aa0ba1d9f 100644 --- a/frontend/src/locales/es_ES/play.json +++ b/frontend/src/locales/es_ES/play.json @@ -14,6 +14,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 5cdd51cfe8..daee098783 100644 --- a/frontend/src/locales/fr_FR/play.json +++ b/frontend/src/locales/fr_FR/play.json @@ -14,6 +14,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 31d1a91b4f..52d5192686 100644 --- a/frontend/src/locales/hu_HU/play.json +++ b/frontend/src/locales/hu_HU/play.json @@ -14,6 +14,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 26a12d4c52..a569c493eb 100644 --- a/frontend/src/locales/it_IT/play.json +++ b/frontend/src/locales/it_IT/play.json @@ -14,6 +14,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 e2d278d383..5a54db9758 100644 --- a/frontend/src/locales/ja_JP/play.json +++ b/frontend/src/locales/ja_JP/play.json @@ -14,6 +14,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 093e6e6572..73c2c042f1 100644 --- a/frontend/src/locales/ko_KR/play.json +++ b/frontend/src/locales/ko_KR/play.json @@ -14,6 +14,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 31d6697650..3934527292 100644 --- a/frontend/src/locales/pl_PL/play.json +++ b/frontend/src/locales/pl_PL/play.json @@ -14,6 +14,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 4242e40190..1d05f3c54f 100644 --- a/frontend/src/locales/pt_BR/play.json +++ b/frontend/src/locales/pt_BR/play.json @@ -14,6 +14,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 41031328d0..1c909fd061 100644 --- a/frontend/src/locales/ro_RO/play.json +++ b/frontend/src/locales/ro_RO/play.json @@ -14,6 +14,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 0188215efe..7e11917878 100644 --- a/frontend/src/locales/ru_RU/play.json +++ b/frontend/src/locales/ru_RU/play.json @@ -14,6 +14,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 8fc86c14a1..d53c831f4c 100644 --- a/frontend/src/locales/tr_TR/play.json +++ b/frontend/src/locales/tr_TR/play.json @@ -14,6 +14,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 64df377f9c..00cb7cde8b 100644 --- a/frontend/src/locales/zh_CN/play.json +++ b/frontend/src/locales/zh_CN/play.json @@ -14,6 +14,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 5867f207e8..4c584224f3 100644 --- a/frontend/src/locales/zh_TW/play.json +++ b/frontend/src/locales/zh_TW/play.json @@ -14,6 +14,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/plugins/router.ts b/frontend/src/plugins/router.ts index 8ea4d03413..ca3e4aa542 100644 --- a/frontend/src/plugins/router.ts +++ b/frontend/src/plugins/router.ts @@ -33,6 +33,7 @@ export const ROUTES = { SMART_COLLECTION: "smart-collection", ROM: "rom", EMULATORJS: "emulatorjs", + JSDOS: "jsdos", RUFFLE: "ruffle", STREAM: "stream", SCAN: "scan", @@ -253,6 +254,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 ae4b53edfb..ef45eb02a2 100644 --- a/frontend/src/stores/heartbeat.ts +++ b/frontend/src/stores/heartbeat.ts @@ -39,6 +39,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/types/js-dos.d.ts b/frontend/src/types/js-dos.d.ts new file mode 100644 index 0000000000..0261b9c50b --- /dev/null +++ b/frontend/src/types/js-dos.d.ts @@ -0,0 +1,30 @@ +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; + }; +} + +export interface JsDosProps { + 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/utils/index.test.ts b/frontend/src/utils/index.test.ts index 46aa5ad2f9..2475efe469 100644 --- a/frontend/src/utils/index.test.ts +++ b/frontend/src/utils/index.test.ts @@ -1,6 +1,12 @@ 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, + isJsDosBundle, + isJsDosEmulationSupported, +} from "./index"; function makeRom(overrides: Partial): SimpleRom { return { @@ -87,3 +93,77 @@ 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); + }); +}); + +describe("isJsDosBundle", () => { + const withExt = (fs_extension: string) => makeRom({ fs_extension }); + + it("accepts a .jsdos bundle regardless of case", () => { + expect(isJsDosBundle(withExt("jsdos"))).toBe(true); + expect(isJsDosBundle(withExt("JSDOS"))).toBe(true); + }); + + // js-dos panics with "Broken bundle" on anything that is not its own format. + it("rejects plain archives, bare executables and folders", () => { + expect(isJsDosBundle(withExt("zip"))).toBe(false); + expect(isJsDosBundle(withExt("exe"))).toBe(false); + expect(isJsDosBundle(withExt(""))).toBe(false); + }); + + it("rejects a missing rom", () => { + expect(isJsDosBundle(null)).toBe(false); + expect(isJsDosBundle(undefined)).toBe(false); + }); +}); diff --git a/frontend/src/utils/index.ts b/frontend/src/utils/index.ts index 77ff79de86..de4cdd17bb 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,10 +698,42 @@ 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()); } +/** + * 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 = resolvePlatformSlug(platformSlug, config); + return ["win3x", "win9x"].includes(slug.toLowerCase()); +} + +/** + * Check if a ROM file is a js-dos bundle. + * + * js-dos panics on anything that is not an archive carrying + * `.jsdos/dosbox.conf`. + * + * @param rom The ROM to check. + * @returns True if the file is a js-dos bundle, false otherwise. + */ +export function isJsDosBundle(rom: SimpleRom | null | undefined) { + return rom?.fs_extension.toLowerCase() === "jsdos"; +} + export type PlayingStatus = RomUserStatus | "backlogged" | "now_playing" | "hidden"; 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..65ff389dce --- /dev/null +++ b/frontend/src/v2/composables/useCanPlay/index.test.ts @@ -0,0 +1,121 @@ +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), + // js-dos also demands its own bundle format; on by default so the engine + // stubs stay the only variable. + jsDosBundle: vi.fn(() => true), +})); + +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, + isJsDosBundle: support.jsDosBundle, +})); + +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.jsDosBundle.mockReturnValue(true); + support.ejs.mockClear(); + support.jsDos.mockClear(); + support.ruffle.mockClear(); + support.jsDosBundle.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); + }); + + // A bare game folder or plain archive makes js-dos panic with + // "Broken bundle", so offering Play would hand the user a dead player. + it("refuses js-dos for a rom that is not a bundle", () => { + support.jsDos.mockReturnValue(true); + support.jsDosBundle.mockReturnValue(false); + const { canPlay, canPlayJsDos } = useCanPlay(() => makeRom()); + + expect(canPlayJsDos.value).toBe(false); + expect(canPlay.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); + }); +}); diff --git a/frontend/src/v2/composables/useCanPlay/index.ts b/frontend/src/v2/composables/useCanPlay/index.ts index dbc0318e2b..0f3342ea84 100644 --- a/frontend/src/v2/composables/useCanPlay/index.ts +++ b/frontend/src/v2/composables/useCanPlay/index.ts @@ -1,51 +1,56 @@ -// 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. // -// "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), and // there is a file to boot: a physical game or one missing from the -// filesystem has nothing to hand the emulator. The individual flags are -// exposed so the play action can pick the right route (EJS vs Ruffle). +// filesystem has nothing to hand the emulator. js-dos additionally needs +// the file to be one of its own bundles. The 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, + isJsDosBundle, + isJsDosEmulationSupported, + isRuffleEmulationSupported, +} from "@/utils"; export function useCanPlay(getRom: () => SimpleRom | null | undefined): { canPlay: ComputedRef; canPlayEJS: ComputedRef; + canPlayJsDos: ComputedRef; canPlayRuffle: ComputedRef; } { const heartbeatStore = storeHeartbeat(); 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 canPlayEJS = supportedBy(isEJSEmulationSupported); + const canPlayRuffle = supportedBy(isRuffleEmulationSupported); - const canPlay = computed(() => canPlayEJS.value || canPlayRuffle.value); + // js-dos boots only its own `.jsdos` bundle, so the platform alone would + // offer Play on files the player panics on. + const onJsDosPlatform = supportedBy(isJsDosEmulationSupported); + const canPlayJsDos = computed( + () => onJsDosPlatform.value && isJsDosBundle(getRom()), + ); - return { canPlay, canPlayEJS, canPlayRuffle }; + const canPlay = computed( + () => canPlayEJS.value || canPlayJsDos.value || canPlayRuffle.value, + ); + + 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 8a29382e95..600115e639 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() }), @@ -122,6 +123,7 @@ beforeEach(() => { confirmFn.mockClear(); confirmProtectedLaunch.value = true; canPlayEJS.value = true; + canPlayJsDos.value = false; canPlayRuffle.value = false; streamContainer.value = null; grantedActions.value = null; @@ -187,6 +189,16 @@ describe("useGameActions.play — launch confirmation", () => { 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(); + }); + it("offers neither streaming nor download without a file behind the rom", () => { streamContainer.value = {}; const fileless = { ...makeRom(), has_file_on_disk: false } as SimpleRom; diff --git a/frontend/src/v2/composables/useGameActions/index.ts b/frontend/src/v2/composables/useGameActions/index.ts index 0ff147ad03..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, 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,9 +96,7 @@ export function useGameActions( streamingStore.containerForPlatform(rom.platform_slug), ); }); - const canPlay = computed( - () => canPlayStream.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. @@ -256,11 +259,16 @@ 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. - 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/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/EmulatorJS.vue b/frontend/src/v2/views/Player/EmulatorJS.vue index ec990a0233..6b2e670837 100644 --- a/frontend/src/v2/views/Player/EmulatorJS.vue +++ b/frontend/src/v2/views/Player/EmulatorJS.vue @@ -66,6 +66,7 @@ import { } from "@/v2/utils/playerDisc"; import { installIOSFullscreenShim } from "@/views/Player/EmulatorJS/utils"; import { rememberCore, resolveRememberedCore } from "./coreStorage"; +import { isJsResource, loadScript } from "./scriptLoader"; // Reuse v1's heavy emulator integration — do NOT rewrite this. Lazy so the // bundle doesn't pull in the EJS shims until we actually mount the player. @@ -290,38 +291,6 @@ async function onPlay() { const LOCAL_PATH = "/assets/emulatorjs/data"; const CDN_PATH = `https://cdn.emulatorjs.org/${EMULATORJS_VERSION}/data`; - function loadScript(src: string): Promise { - return new Promise((resolve, reject) => { - const s = document.createElement("script"); - s.src = src; - s.async = true; - s.onload = () => resolve(); - s.onerror = () => reject(new Error("Failed loading " + src)); - document.body.appendChild(s); - }); - } - - // The Vite dev server (and many SPA hosts) returns 200 + index.html - // when a static asset is missing. A + + + + diff --git a/frontend/src/v2/views/Player/scriptLoader.ts b/frontend/src/v2/views/Player/scriptLoader.ts new file mode 100644 index 0000000000..ebfa46f6ee --- /dev/null +++ b/frontend/src/v2/views/Player/scriptLoader.ts @@ -0,0 +1,38 @@ +// Shared runtime injection for the player views. EmulatorJS and js-dos serve +// their runtime locally in the full image and fall back to a CDN without it. + +/** Inject a