diff --git a/backend/config/config_manager.py b/backend/config/config_manager.py index 829d3f920..219fe42cf 100644 --- a/backend/config/config_manager.py +++ b/backend/config/config_manager.py @@ -61,6 +61,12 @@ class EjsControls(TypedDict): EjsOption = dict[str, str] # option_name -> option_value +class NetplayICEServer(TypedDict): + urls: str + username: NotRequired[str] + credential: NotRequired[str] + + class Config: CONFIG_FILE_MOUNTED: bool CONFIG_FILE_WRITABLE: bool @@ -78,6 +84,8 @@ class Config: HIGH_PRIO_STRUCTURE_PATH: str EJS_DEBUG: bool EJS_CACHE_LIMIT: int | None + EJS_NETPLAY_ENABLED: bool + EJS_NETPLAY_ICE_SERVERS: list[NetplayICEServer] EJS_SETTINGS: dict[str, EjsOption] # core_name -> EjsOption EJS_CONTROLS: dict[str, EjsControls] # core_name -> EjsControls SCAN_METADATA_PRIORITY: list[str] @@ -220,6 +228,12 @@ class ConfigManager: EJS_CACHE_LIMIT=pydash.get( self._raw_config, "emulatorjs.cache_limit", None ), + EJS_NETPLAY_ENABLED=pydash.get( + self._raw_config, "emulatorjs.netplay.enabled", False + ), + EJS_NETPLAY_ICE_SERVERS=pydash.get( + self._raw_config, "emulatorjs.netplay.ice_servers", [] + ), EJS_SETTINGS=pydash.get(self._raw_config, "emulatorjs.settings", {}), EJS_CONTROLS=self._get_ejs_controls(), SCAN_METADATA_PRIORITY=pydash.get( @@ -391,6 +405,12 @@ class ConfigManager: log.critical("Invalid config.yml: emulatorjs.debug must be a boolean") sys.exit(3) + if not isinstance(self.config.EJS_NETPLAY_ENABLED, bool): + log.critical( + "Invalid config.yml: emulatorjs.netplay.enabled must be a boolean" + ) + sys.exit(3) + if self.config.EJS_CACHE_LIMIT is not None and not isinstance( self.config.EJS_CACHE_LIMIT, int ): @@ -399,6 +419,12 @@ class ConfigManager: ) sys.exit(3) + if not isinstance(self.config.EJS_NETPLAY_ICE_SERVERS, list): + log.critical( + "Invalid config.yml: emulatorjs.netplay.ice_servers must be a list" + ) + sys.exit(3) + if not isinstance(self.config.EJS_SETTINGS, dict): log.critical("Invalid config.yml: emulatorjs.settings must be a dictionary") sys.exit(3) @@ -507,6 +533,10 @@ class ConfigManager: "emulatorjs": { "debug": self.config.EJS_DEBUG, "cache_limit": self.config.EJS_CACHE_LIMIT, + "netplay": { + "enabled": self.config.EJS_NETPLAY_ENABLED, + "ice_servers": self.config.EJS_NETPLAY_ICE_SERVERS, + }, "settings": self.config.EJS_SETTINGS, "controls": self._format_ejs_controls_for_yaml(), }, diff --git a/backend/endpoints/configs.py b/backend/endpoints/configs.py index c223f637f..350e7c8bf 100644 --- a/backend/endpoints/configs.py +++ b/backend/endpoints/configs.py @@ -37,6 +37,8 @@ def get_config() -> ConfigResponse: SKIP_HASH_CALCULATION=cfg.SKIP_HASH_CALCULATION, EJS_DEBUG=cfg.EJS_DEBUG, EJS_CACHE_LIMIT=cfg.EJS_CACHE_LIMIT, + EJS_NETPLAY_ENABLED=cfg.EJS_NETPLAY_ENABLED, + EJS_NETPLAY_ICE_SERVERS=cfg.EJS_NETPLAY_ICE_SERVERS, EJS_CONTROLS=cfg.EJS_CONTROLS, EJS_SETTINGS=cfg.EJS_SETTINGS, SCAN_METADATA_PRIORITY=cfg.SCAN_METADATA_PRIORITY, diff --git a/backend/endpoints/netplay.py b/backend/endpoints/netplay.py new file mode 100644 index 000000000..ccf7937fc --- /dev/null +++ b/backend/endpoints/netplay.py @@ -0,0 +1,64 @@ +from typing import Dict, TypedDict + +from fastapi import Request + +from decorators.auth import protected_route +from handler.auth.constants import Scope +from handler.netplay_handler import NetplayRoom, netplay_handler +from utils.router import APIRouter + +router = APIRouter( + prefix="/netplay", + tags=["netplay"], +) + + +DEFAULT_MAX_PLAYERS = 4 + + +def _get_owner_player_name(room: NetplayRoom) -> str: + owner_sid = room.get("owner") + if not owner_sid: + return "Unknown" + + return next( + ( + p["player_name"] + for p in room["players"].values() + if p["socketId"] == owner_sid + ), + "Unknown", + ) + + +def _is_room_open(room: NetplayRoom, game_id: str) -> bool: + if len(room["players"]) >= room["max_players"]: + return False + return str(room["game_id"]) == str(game_id) + + +class RoomsResponse(TypedDict): + room_name: str + current: int + max: int + player_name: str + hasPassword: bool + + +@protected_route(router.get, "/list", [Scope.ASSETS_READ]) +async def get_rooms(request: Request, game_id: str) -> Dict[str, RoomsResponse]: + netplay_rooms = await netplay_handler.get_all() + + open_rooms: Dict[str, RoomsResponse] = { + session_id: RoomsResponse( + room_name=room["room_name"], + current=len(room["players"]), + max=room["max_players"], + player_name=_get_owner_player_name(room), + hasPassword=bool(room["password"]), + ) + for session_id, room in netplay_rooms.items() + if _is_room_open(room, game_id) + } + + return open_rooms diff --git a/backend/endpoints/responses/config.py b/backend/endpoints/responses/config.py index 30bf94290..59f4e6c40 100644 --- a/backend/endpoints/responses/config.py +++ b/backend/endpoints/responses/config.py @@ -1,6 +1,6 @@ from typing import TypedDict -from config.config_manager import EjsControls +from config.config_manager import EjsControls, NetplayICEServer class ConfigResponse(TypedDict): @@ -17,6 +17,8 @@ class ConfigResponse(TypedDict): SKIP_HASH_CALCULATION: bool EJS_DEBUG: bool EJS_CACHE_LIMIT: int | None + EJS_NETPLAY_ENABLED: bool + EJS_NETPLAY_ICE_SERVERS: list[NetplayICEServer] EJS_SETTINGS: dict[str, dict[str, str]] EJS_CONTROLS: dict[str, EjsControls] SCAN_METADATA_PRIORITY: list[str] diff --git a/backend/endpoints/sockets/netplay.py b/backend/endpoints/sockets/netplay.py new file mode 100644 index 000000000..2bed17e88 --- /dev/null +++ b/backend/endpoints/sockets/netplay.py @@ -0,0 +1,224 @@ +from typing import Any, NotRequired, TypedDict + +from endpoints.netplay import DEFAULT_MAX_PLAYERS +from handler.netplay_handler import NetplayPlayerInfo, NetplayRoom, netplay_handler +from handler.socket_handler import netplay_socket_handler + + +class RoomDataExtra(TypedDict): + sessionid: str | None + userid: str | None + playerId: str | None + room_name: NotRequired[str] + game_id: NotRequired[str] + domain: NotRequired[str] + player_name: NotRequired[str] + room_password: NotRequired[str] + + +class RoomData(TypedDict): + extra: RoomDataExtra + maxPlayers: NotRequired[int] + + +@netplay_socket_handler.socket_server.on("open-room") # type: ignore +async def open_room(sid: str, data: RoomData): + extra_data = data["extra"] + + session_id = extra_data.get("sessionid") + player_id = extra_data.get("userid") or extra_data.get("playerId") + + if not session_id or not player_id: + return "Invalid data: sessionId and playerId required" + + if await netplay_handler.get(session_id): + return "Room already exists" + + new_room = NetplayRoom( + owner=sid, + players={ + player_id: NetplayPlayerInfo( + socketId=sid, + player_name=extra_data.get("player_name") or f"Player {player_id}", + userid=extra_data.get("userid"), + playerId=extra_data.get("playerId"), + ) + }, + peers=[], + room_name=extra_data.get("room_name") or f"Room {session_id}", + game_id=extra_data.get("game_id") or "default", + domain=extra_data.get("domain", None), + password=extra_data.get("room_password", None), + max_players=data.get("maxPlayers") or DEFAULT_MAX_PLAYERS, + ) + await netplay_handler.set(session_id, new_room) + + await netplay_socket_handler.socket_server.enter_room(sid, session_id) + await netplay_socket_handler.socket_server.save_session( + sid, + { + "session_id": session_id, + "player_id": player_id, + }, + ) + await netplay_socket_handler.socket_server.emit( + "users-updated", new_room["players"], room=session_id + ) + + +@netplay_socket_handler.socket_server.on("join-room") # type: ignore +async def join_room(sid: str, data: RoomData): + extra_data = data["extra"] + + session_id = extra_data.get("sessionid") + player_id = extra_data.get("userid") or extra_data.get("playerId") + + if not session_id or not player_id: + return "Invalid data: sessionId and playerId required" + + current_room = await netplay_handler.get(session_id) + if not current_room: + return "Room not found" + + if current_room["password"] and current_room["password"] != extra_data.get( + "room_password" + ): + return "Incorrect password" + + if len(current_room["players"].keys()) >= current_room["max_players"]: + return "Room is full" + + current_room["players"][player_id] = NetplayPlayerInfo( + socketId=sid, + player_name=extra_data.get("player_name") or f"Player {player_id}", + userid=extra_data.get("userid"), + playerId=extra_data.get("playerId"), + ) + await netplay_handler.set(session_id, current_room) + + await netplay_socket_handler.socket_server.enter_room(sid, session_id) + await netplay_socket_handler.socket_server.save_session( + sid, + { + "session_id": session_id, + "player_id": player_id, + }, + ) + await netplay_socket_handler.socket_server.emit( + "users-updated", current_room["players"], room=session_id + ) + + return None, current_room["players"] + + +async def _handle_leave(sid: str, session_id: str, player_id: str): + current_room = await netplay_handler.get(session_id) + if not current_room: + return + + current_room["players"].pop(player_id, None) + + if not current_room["players"]: + await netplay_handler.delete([session_id]) + # Notify clients that the room is now empty + await netplay_socket_handler.socket_server.emit( + "users-updated", {}, room=session_id + ) + return + + if sid == current_room["owner"]: + # Owner left, assign a new one + remaining_players = list(current_room["players"].values()) + if remaining_players: + current_room["owner"] = remaining_players[0]["socketId"] + + await netplay_handler.set(session_id, current_room) + await netplay_socket_handler.socket_server.emit( + "users-updated", current_room["players"], room=session_id + ) + + +@netplay_socket_handler.socket_server.on("leave-room") # type: ignore +async def leave_room(sid: str): + stored_session = await netplay_socket_handler.socket_server.get_session(sid) + session_id = stored_session.get("session_id") + player_id = stored_session.get("player_id") + + if session_id and player_id: + await _handle_leave(sid, session_id, player_id) + await netplay_socket_handler.socket_server.leave_room(sid, session_id) + + +class WebRTCSignalData(TypedDict, total=False): + target: str + candidate: Any + offer: Any + answer: Any + requestRenegotiate: bool + + +@netplay_socket_handler.socket_server.on("webrtc-signal") # type: ignore +async def webrtc_signal(sid: str, data: WebRTCSignalData): + target = data.get("target") + request_renegotiate = data.get("requestRenegotiate", False) + + if request_renegotiate: + if not target: + return + await netplay_socket_handler.socket_server.emit( + "webrtc-signal", + {"sender": sid, "requestRenegotiate": True}, + to=target, + ) + else: + if not target: + return # drop message—no recipient + await netplay_socket_handler.socket_server.emit( + "webrtc-signal", + { + "sender": sid, + "candidate": data.get("candidate"), + "offer": data.get("offer"), + "answer": data.get("answer"), + }, + to=target, + ) + + +@netplay_socket_handler.socket_server.on("webrtc-signal-error") # type: ignore +async def webrtc_signal_error(_sid: str, _error: str, _data: Any): + pass + + +@netplay_socket_handler.socket_server.on("disconnect") # type: ignore +async def disconnect(sid: str): + stored_session = await netplay_socket_handler.socket_server.get_session(sid) + session_id = stored_session.get("session_id") + player_id = stored_session.get("player_id") + + if session_id and player_id: + await _handle_leave(sid, session_id, player_id) + + +async def _broadcast_to_room(sid: str, event: str, data: Any): + stored_session = await netplay_socket_handler.socket_server.get_session(sid) + session_id = stored_session.get("session_id") + if session_id: + await netplay_socket_handler.socket_server.emit( + event, data, room=session_id, skip_sid=sid + ) + + +@netplay_socket_handler.socket_server.on("data-message") # type: ignore +async def data_message(sid: str, data: Any): + await _broadcast_to_room(sid, "data-message", data) + + +@netplay_socket_handler.socket_server.on("snapshot") # type: ignore +async def snapshot(sid: str, data: Any): + await _broadcast_to_room(sid, "snapshot", data) + + +@netplay_socket_handler.socket_server.on("input") # type: ignore +async def input(sid: str, data: Any): + await _broadcast_to_room(sid, "input", data) diff --git a/backend/handler/netplay_handler.py b/backend/handler/netplay_handler.py new file mode 100644 index 000000000..2db36d5a5 --- /dev/null +++ b/backend/handler/netplay_handler.py @@ -0,0 +1,50 @@ +import json +from typing import Optional, TypedDict + +from handler.redis_handler import async_cache + + +class NetplayPlayerInfo(TypedDict): + socketId: str + player_name: str + userid: str | None + playerId: str | None + + +class NetplayRoom(TypedDict): + owner: str + players: dict[str, NetplayPlayerInfo] + peers: list[str] + room_name: str + game_id: str + domain: Optional[str] + password: Optional[str] + max_players: int + + +class NetplayHandler: + """A class to handle netplay rooms in Redis.""" + + def __init__(self): + self.hash_name = "netplay:rooms" + + async def get(self, room_id: str) -> NetplayRoom | None: + """Get a room from Redis.""" + room = await async_cache.hget(self.hash_name, room_id) + return json.loads(room) if room else None + + async def set(self, room_id: str, room_data: NetplayRoom): + """Set a room in Redis.""" + return await async_cache.hset(self.hash_name, room_id, json.dumps(room_data)) + + async def delete(self, room_ids: list[str]): + """Delete a room from Redis.""" + return await async_cache.hdel(self.hash_name, *room_ids) + + async def get_all(self) -> dict[str, NetplayRoom]: + """Get all rooms from Redis.""" + rooms = await async_cache.hgetall(self.hash_name) + return {room_id: json.loads(room_data) for room_id, room_data in rooms.items()} + + +netplay_handler = NetplayHandler() diff --git a/backend/handler/socket_handler.py b/backend/handler/socket_handler.py index 239ee2c99..9b5d5ebe8 100644 --- a/backend/handler/socket_handler.py +++ b/backend/handler/socket_handler.py @@ -5,7 +5,7 @@ from utils import json_module class SocketHandler: - def __init__(self) -> None: + def __init__(self, path: str) -> None: self.socket_server = socketio.AsyncServer( cors_allowed_origins="*", async_mode="asgi", @@ -19,9 +19,8 @@ class SocketHandler: cors_credentials=True, ) - self.socket_app = socketio.ASGIApp( - self.socket_server, socketio_path="/ws/socket.io" - ) + self.socket_app = socketio.ASGIApp(self.socket_server, socketio_path=path) -socket_handler = SocketHandler() +socket_handler = SocketHandler(path="/ws/socket.io") +netplay_socket_handler = SocketHandler(path="/netplay/socket.io") diff --git a/backend/main.py b/backend/main.py index 04e541d9d..1ea55e139 100644 --- a/backend/main.py +++ b/backend/main.py @@ -13,6 +13,7 @@ from fastapi_pagination import add_pagination from starlette.middleware.authentication import AuthenticationMiddleware from startup import main +import endpoints.sockets.netplay # noqa import endpoints.sockets.scan # noqa from config import ( DEV_HOST, @@ -31,6 +32,7 @@ from endpoints import ( firmware, gamelist, heartbeat, + netplay, platform, raw, rom, @@ -45,7 +47,7 @@ from endpoints import ( from handler.auth.hybrid_auth import HybridAuthBackend from handler.auth.middleware.csrf_middleware import CSRFMiddleware from handler.auth.middleware.redis_session_middleware import RedisSessionMiddleware -from handler.socket_handler import socket_handler +from handler.socket_handler import netplay_socket_handler, socket_handler from logger.formatter import LOGGING_CONFIG from utils import get_version from utils.context import ( @@ -93,7 +95,11 @@ if not IS_PYTEST_RUN and not DISABLE_CSRF_PROTECTION: CSRFMiddleware, cookie_name="romm_csrftoken", secret=ROMM_AUTH_SECRET_KEY, - exempt_urls=[re.compile(r"^/api/token.*"), re.compile(r"^/ws")], + exempt_urls=[ + re.compile(r"^/api/token.*"), + re.compile(r"^/ws"), + re.compile(r"^/netplay"), + ], ) # Handles both basic and oauth authentication @@ -130,8 +136,10 @@ app.include_router(screenshots.router, prefix="/api") app.include_router(firmware.router, prefix="/api") app.include_router(collections.router, prefix="/api") app.include_router(gamelist.router, prefix="/api") +app.include_router(netplay.router, prefix="/api") app.mount("/ws", socket_handler.socket_app) +app.mount("/netplay", netplay_socket_handler.socket_app) add_pagination(app) diff --git a/backend/startup.py b/backend/startup.py index ca1a7db51..6a66e3342 100644 --- a/backend/startup.py +++ b/backend/startup.py @@ -24,6 +24,7 @@ from handler.metadata.base_handler import ( from handler.redis_handler import async_cache from logger.logger import log from models.firmware import FIRMWARE_FIXTURES_DIR, KNOWN_BIOS_KEY +from tasks.scheduled.cleanup_netplay import cleanup_netplay_task from tasks.scheduled.convert_images_to_webp import convert_images_to_webp_task from tasks.scheduled.scan_library import scan_library_task from tasks.scheduled.sync_retroachievements_progress import ( @@ -46,6 +47,8 @@ async def main() -> None: log.info("Running startup tasks") # Initialize scheduled tasks + cleanup_netplay_task.init() + if ENABLE_SCHEDULED_RESCAN: log.info("Starting scheduled rescan") scan_library_task.init() diff --git a/backend/tasks/scheduled/cleanup_netplay.py b/backend/tasks/scheduled/cleanup_netplay.py new file mode 100644 index 000000000..d294606ca --- /dev/null +++ b/backend/tasks/scheduled/cleanup_netplay.py @@ -0,0 +1,32 @@ +from handler.netplay_handler import netplay_handler +from logger.logger import log +from tasks.tasks import PeriodicTask, TaskType + + +class CleanupNetplayTask(PeriodicTask): + def __init__(self): + super().__init__( + title="Scheduled netplay cleanup", + description="Cleans up empty netplay rooms", + task_type=TaskType.CLEANUP, + enabled=True, + manual_run=False, + cron_string="*/5 * * * *", # Every 5 minutes + func="tasks.scheduled.cleanup_netplay.cleanup_netplay_task.run", + ) + + async def run(self) -> None: + if not self.enabled: + self.unschedule() + return + + netplay_rooms = await netplay_handler.get_all() + rooms_to_delete = [ + sid for sid, r in netplay_rooms.items() if len(r.get("players", {})) == 0 + ] + if rooms_to_delete: + log.info(f"Cleaning up {len(rooms_to_delete)} empty netplay rooms") + await netplay_handler.delete(rooms_to_delete) + + +cleanup_netplay_task = CleanupNetplayTask() diff --git a/backend/tests/config/fixtures/config/config.yml b/backend/tests/config/fixtures/config/config.yml index d8e949556..b13131282 100644 --- a/backend/tests/config/fixtures/config/config.yml +++ b/backend/tests/config/fixtures/config/config.yml @@ -55,6 +55,13 @@ scan: emulatorjs: debug: true cache_limit: 1000 + netplay: + enabled: true + ice_servers: + - urls: "stun:stun.relay.metered.ca:80" + - urls: "turn:global.relay.metered.ca:80" + username: "user" + credential: "password" settings: parallel_n64: vsync: disabled diff --git a/backend/tests/config/test_config_loader.py b/backend/tests/config/test_config_loader.py index 5767c3b1e..8f92d9432 100644 --- a/backend/tests/config/test_config_loader.py +++ b/backend/tests/config/test_config_loader.py @@ -22,6 +22,15 @@ def test_config_loader(): assert loader.config.SKIP_HASH_CALCULATION assert loader.config.EJS_DEBUG assert loader.config.EJS_CACHE_LIMIT == 1000 + assert loader.config.EJS_NETPLAY_ENABLED + assert loader.config.EJS_NETPLAY_ICE_SERVERS == [ + {"urls": "stun:stun.relay.metered.ca:80"}, + { + "urls": "turn:global.relay.metered.ca:80", + "username": "user", + "credential": "password", + }, + ] assert loader.config.EJS_SETTINGS == { "parallel_n64": {"vsync": "disabled"}, "snes9x": {"snes9x_region": "ntsc"}, @@ -60,5 +69,7 @@ def test_empty_config_loader(): assert not loader.config.SKIP_HASH_CALCULATION assert not loader.config.EJS_DEBUG assert loader.config.EJS_CACHE_LIMIT is None + assert not loader.config.EJS_NETPLAY_ENABLED + assert loader.config.EJS_NETPLAY_ICE_SERVERS == [] assert loader.config.EJS_SETTINGS == {} assert loader.config.EJS_CONTROLS == {} diff --git a/docker/nginx/templates/default.conf.template b/docker/nginx/templates/default.conf.template index 8ab4e85dd..b7031a721 100644 --- a/docker/nginx/templates/default.conf.template +++ b/docker/nginx/templates/default.conf.template @@ -57,7 +57,7 @@ server { proxy_request_buffering off; proxy_buffering off; } - location /ws { + location ~ ^/(ws|netplay) { proxy_pass http://wsgi_server; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; diff --git a/examples/config.example.yml b/examples/config.example.yml index 61a0e4c2c..eb8b5fcab 100644 --- a/examples/config.example.yml +++ b/examples/config.example.yml @@ -115,6 +115,13 @@ filesystem: {} # { roms_folder: 'roms' } For example if your folder structure is # snes9x_region: ntsc # default: # These settings apply to all cores # fps: show +# netplay: +# enabled: true +# ice_servers: +# - urls: "stun:stun.relay.metered.ca:80" +# - urls: "turn:global.relay.metered.ca:80" +# username: "" +# credential: "" # controls: # https://emulatorjs.org/docs4devs/control-mapping/ # snes9x: # 0: # Player 1 diff --git a/frontend/src/__generated__/index.ts b/frontend/src/__generated__/index.ts index fd4fd806d..55c0fa3d1 100644 --- a/frontend/src/__generated__/index.ts +++ b/frontend/src/__generated__/index.ts @@ -51,6 +51,7 @@ export type { JobStatus } from './models/JobStatus'; export type { LaunchboxImage } from './models/LaunchboxImage'; export type { MetadataSourcesDict } from './models/MetadataSourcesDict'; export type { MobyMetadataPlatform } from './models/MobyMetadataPlatform'; +export type { NetplayICEServer } from './models/NetplayICEServer'; export type { OIDCDict } from './models/OIDCDict'; export type { PlatformSchema } from './models/PlatformSchema'; export type { RAGameRomAchievement } from './models/RAGameRomAchievement'; @@ -71,6 +72,7 @@ export type { RomRAMetadata } from './models/RomRAMetadata'; export type { RomSSMetadata } from './models/RomSSMetadata'; export type { RomUserSchema } from './models/RomUserSchema'; export type { RomUserStatus } from './models/RomUserStatus'; +export type { RoomsResponse } from './models/RoomsResponse'; export type { SaveSchema } from './models/SaveSchema'; export type { ScanStats } from './models/ScanStats'; export type { ScanTaskMeta } from './models/ScanTaskMeta'; diff --git a/frontend/src/__generated__/models/ConfigResponse.ts b/frontend/src/__generated__/models/ConfigResponse.ts index 0ee03b7c6..fcbc45ec8 100644 --- a/frontend/src/__generated__/models/ConfigResponse.ts +++ b/frontend/src/__generated__/models/ConfigResponse.ts @@ -3,6 +3,7 @@ /* tslint:disable */ /* eslint-disable */ import type { EjsControls } from './EjsControls'; +import type { NetplayICEServer } from './NetplayICEServer'; export type ConfigResponse = { CONFIG_FILE_MOUNTED: boolean; CONFIG_FILE_WRITABLE: boolean; @@ -17,6 +18,8 @@ export type ConfigResponse = { SKIP_HASH_CALCULATION: boolean; EJS_DEBUG: boolean; EJS_CACHE_LIMIT: (number | null); + EJS_NETPLAY_ENABLED: boolean; + EJS_NETPLAY_ICE_SERVERS: Array; EJS_SETTINGS: Record>; EJS_CONTROLS: Record; SCAN_METADATA_PRIORITY: Array; diff --git a/frontend/src/__generated__/models/DetailedRomSchema.ts b/frontend/src/__generated__/models/DetailedRomSchema.ts index 41f992f05..d6645cdfe 100644 --- a/frontend/src/__generated__/models/DetailedRomSchema.ts +++ b/frontend/src/__generated__/models/DetailedRomSchema.ts @@ -84,8 +84,8 @@ export type DetailedRomSchema = { missing_from_fs: boolean; siblings: Array; rom_user: RomUserSchema; - merged_ra_metadata: (RomRAMetadata | null); merged_screenshots: Array; + merged_ra_metadata: (RomRAMetadata | null); user_saves: Array; user_states: Array; user_screenshots: Array; diff --git a/frontend/src/__generated__/models/NetplayICEServer.ts b/frontend/src/__generated__/models/NetplayICEServer.ts new file mode 100644 index 000000000..3e02f538e --- /dev/null +++ b/frontend/src/__generated__/models/NetplayICEServer.ts @@ -0,0 +1,10 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export type NetplayICEServer = { + urls: string; + username?: string; + credential?: string; +}; + diff --git a/frontend/src/__generated__/models/RoomsResponse.ts b/frontend/src/__generated__/models/RoomsResponse.ts new file mode 100644 index 000000000..6c35ab671 --- /dev/null +++ b/frontend/src/__generated__/models/RoomsResponse.ts @@ -0,0 +1,12 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export type RoomsResponse = { + room_name: string; + current: number; + max: number; + player_name: string; + hasPassword: boolean; +}; + diff --git a/frontend/src/__generated__/models/SimpleRomSchema.ts b/frontend/src/__generated__/models/SimpleRomSchema.ts index f1ac66722..b55eb0970 100644 --- a/frontend/src/__generated__/models/SimpleRomSchema.ts +++ b/frontend/src/__generated__/models/SimpleRomSchema.ts @@ -79,7 +79,7 @@ export type SimpleRomSchema = { missing_from_fs: boolean; siblings: Array; rom_user: RomUserSchema; - merged_ra_metadata: (RomRAMetadata | null); merged_screenshots: Array; + merged_ra_metadata: (RomRAMetadata | null); }; diff --git a/frontend/src/console/views/Play.vue b/frontend/src/console/views/Play.vue index 261757a8c..7727b50af 100644 --- a/frontend/src/console/views/Play.vue +++ b/frontend/src/console/views/Play.vue @@ -607,9 +607,10 @@ async function boot() { // Allow route transition animation to settle await new Promise((r) => setTimeout(r, 50)); - const EMULATORJS_VERSION = "4.2.3"; - const LOCAL_PATH = "/assets/emulatorjs/data/"; - const CDN_PATH = `https://cdn.emulatorjs.org/${EMULATORJS_VERSION}/data/`; + const { EJS_NETPLAY_ENABLED } = configStore.config; + const EMULATORJS_VERSION = EJS_NETPLAY_ENABLED ? "nightly" : "4.2.3"; + 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) => { @@ -622,18 +623,20 @@ async function boot() { }); } - async function attemptLoad(path: string, label: "local" | "cdn") { + async function attemptLoad(label: "local" | "cdn") { + const path = label === "local" ? LOCAL_PATH : CDN_PATH; loaderStatus.value = label === "local" ? "loading-local" : "loading-cdn"; + window.EJS_pathtodata = path; - await loadScript(`${path}loader.js`); + await loadScript(`${path}/loader.js`); } try { try { - await attemptLoad(LOCAL_PATH, "local"); + await attemptLoad(EJS_NETPLAY_ENABLED ? "cdn" : "local"); } catch (e) { console.warn("[Play] Local loader failed, trying CDN", e); - await attemptLoad(CDN_PATH, "cdn"); + await attemptLoad(EJS_NETPLAY_ENABLED ? "local" : "cdn"); } // Wait for emulator bootstrap const startDeadline = Date.now() + 8000; // 8s diff --git a/frontend/src/stores/config.ts b/frontend/src/stores/config.ts index ba2135395..d61e6e8d9 100644 --- a/frontend/src/stores/config.ts +++ b/frontend/src/stores/config.ts @@ -24,7 +24,9 @@ const defaultConfig = { PLATFORMS_VERSIONS: {}, SKIP_HASH_CALCULATION: false, EJS_DEBUG: false, + EJS_NETPLAY_ENABLED: false, EJS_CACHE_LIMIT: null, + EJS_NETPLAY_ICE_SERVERS: [], EJS_SETTINGS: {}, EJS_CONTROLS: {}, SCAN_METADATA_PRIORITY: [], diff --git a/frontend/src/views/Player/EmulatorJS/Base.vue b/frontend/src/views/Player/EmulatorJS/Base.vue index 7d2dbcf87..11364fbfc 100644 --- a/frontend/src/views/Player/EmulatorJS/Base.vue +++ b/frontend/src/views/Player/EmulatorJS/Base.vue @@ -14,6 +14,7 @@ import { ROUTES } from "@/plugins/router"; import firmwareApi from "@/services/api/firmware"; import romApi from "@/services/api/rom"; import storeAuth from "@/stores/auth"; +import storeConfig from "@/stores/config"; import storePlaying from "@/stores/playing"; import { type DetailedRom } from "@/stores/roms"; import { formatTimestamp, getSupportedEJSCores } from "@/utils"; @@ -21,13 +22,12 @@ import { getEmptyCoverImage } from "@/utils/covers"; import CacheDialog from "@/views/Player/EmulatorJS/CacheDialog.vue"; import Player from "@/views/Player/EmulatorJS/Player.vue"; -const EMULATORJS_VERSION = "4.2.3"; - const { t, locale } = useI18n(); const { smAndDown } = useDisplay(); const route = useRoute(); const auth = storeAuth(); const playingStore = storePlaying(); +const configStore = storeConfig(); const { playing, fullScreen } = storeToRefs(playingStore); const rom = ref(null); const firmwareOptions = ref([]); @@ -42,7 +42,7 @@ const supportedCores = ref([]); const gameRunning = ref(false); const fullScreenOnPlay = useLocalStorage("emulation.fullScreenOnPlay", true); -function onPlay() { +async function onPlay() { if (rom.value && auth.scopes.includes("roms.user.write")) { romApi.updateUserRomProps({ romId: rom.value.id, @@ -56,33 +56,39 @@ function onPlay() { fullScreen.value = fullScreenOnPlay.value; playing.value = true; - const LOCAL_PATH = "/assets/emulatorjs/data/"; - const CDN_PATH = `https://cdn.emulatorjs.org/${EMULATORJS_VERSION}/data/`; + const { EJS_NETPLAY_ENABLED } = configStore.config; + const EMULATORJS_VERSION = EJS_NETPLAY_ENABLED ? "nightly" : "4.2.3"; + const LOCAL_PATH = "/assets/emulatorjs/data"; + const CDN_PATH = `https://cdn.emulatorjs.org/${EMULATORJS_VERSION}/data`; - // Try loading local loader.js via fetch to validate it's real JS - fetch(`${LOCAL_PATH}loader.js`) - .then((res) => { - const type = res.headers.get("content-type") || ""; - if (!res.ok || !type.includes("javascript")) { - throw new Error("Invalid local loader.js"); - } - window.EJS_pathtodata = LOCAL_PATH; - return res.text(); - }) - .then((jsCode) => { - playing.value = true; - fullScreen.value = fullScreenOnPlay.value; - const script = document.createElement("script"); - script.textContent = jsCode; - document.body.appendChild(script); - }) - .catch(() => { - console.warn("Local EmulatorJS failed, falling back to CDN"); - window.EJS_pathtodata = CDN_PATH; - const fallbackScript = document.createElement("script"); - fallbackScript.src = `${CDN_PATH}loader.js`; - document.body.appendChild(fallbackScript); + 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); }); + } + + async function attemptLoad(path: string) { + window.EJS_pathtodata = path; + await loadScript(`${path}/loader.js`); + } + + try { + try { + await attemptLoad(EJS_NETPLAY_ENABLED ? CDN_PATH : LOCAL_PATH); + } catch (e) { + console.warn("[Play] Local loader failed, trying CDN", e); + await attemptLoad(EJS_NETPLAY_ENABLED ? LOCAL_PATH : CDN_PATH); + } + playing.value = true; + fullScreen.value = fullScreenOnPlay.value; + } catch (err) { + console.error("[Play] Emulator load failure:", err); + } } function onFullScreenChange() { diff --git a/frontend/src/views/Player/EmulatorJS/Player.vue b/frontend/src/views/Player/EmulatorJS/Player.vue index d743918d1..b18461d1b 100644 --- a/frontend/src/views/Player/EmulatorJS/Player.vue +++ b/frontend/src/views/Player/EmulatorJS/Player.vue @@ -4,7 +4,12 @@ import { storeToRefs } from "pinia"; import { inject, onBeforeUnmount, onMounted, onUnmounted, ref } from "vue"; import { useRouter } from "vue-router"; import { useTheme } from "vuetify"; -import type { FirmwareSchema, SaveSchema, StateSchema } from "@/__generated__"; +import type { + FirmwareSchema, + SaveSchema, + StateSchema, + NetplayICEServer, +} from "@/__generated__"; import { ROUTES } from "@/plugins/router"; import { saveApi as api } from "@/services/api/save"; import storeConfig from "@/stores/config"; @@ -69,6 +74,7 @@ declare global { EJS_gameParentUrl: string; EJS_gamePatchUrl: string; EJS_netplayServer: string; + EJS_netplayICEServers: NetplayICEServer[]; EJS_alignStartButton: "top" | "center" | "bottom"; EJS_startOnLoaded: boolean; EJS_fullscreenOnLoaded: boolean; @@ -138,7 +144,16 @@ window.EJS_gameName = romRef.value.fs_name_no_tags window.EJS_language = selectedLanguage.value.value.replace("_", "-"); window.EJS_disableAutoLang = true; -const { EJS_DEBUG, EJS_CACHE_LIMIT } = configStore.config; +const { + EJS_DEBUG, + EJS_CACHE_LIMIT, + EJS_NETPLAY_ICE_SERVERS, + EJS_NETPLAY_ENABLED, +} = configStore.config; +window.EJS_netplayServer = EJS_NETPLAY_ENABLED ? window.location.host : ""; +window.EJS_netplayICEServers = EJS_NETPLAY_ENABLED + ? EJS_NETPLAY_ICE_SERVERS + : []; if (EJS_CACHE_LIMIT !== null) window.EJS_CacheLimit = EJS_CACHE_LIMIT; window.EJS_DEBUG_XX = EJS_DEBUG; @@ -374,6 +389,28 @@ window.EJS_onGameStart = async () => { romsStore.update(romRef.value); immediateExit(); }); + + // The netplay implementation is finnicky, these overrides make it work + const { defineNetplayFunctions } = window.EJS_emulator; + window.EJS_emulator.defineNetplayFunctions = () => { + defineNetplayFunctions.bind(window.EJS_emulator)(); + + window.EJS_emulator.netplay.url = { + path: "/netplay/socket.io", + }; + + window.EJS_emulator.netplayGetOpenRooms = async () => { + try { + const response = await fetch( + `/api/netplay/list?game_id=${window.EJS_gameID}`, + ); + return await response.json(); + } catch (error) { + console.error("Error fetching open rooms:", error); + return {}; + } + }; + }; }; function immediateExit() { diff --git a/frontend/vite.config.js b/frontend/vite.config.js index 03084d997..47aade6cb 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -123,7 +123,7 @@ export default defineConfig(({ mode }) => { changeOrigin: false, secure: false, }, - "/ws": { + "^/(?:ws|netplay)": { target: `http://127.0.0.1:${backendPort}`, changeOrigin: false, ws: true,