diff --git a/backend/alembic/versions/0153_play_permissions.py b/backend/alembic/versions/0153_play_permissions.py new file mode 100644 index 000000000..c95dca4e0 --- /dev/null +++ b/backend/alembic/versions/0153_play_permissions.py @@ -0,0 +1,70 @@ +"""Grant streaming and emulation read and write to every existing permission group + +Revision ID: 0153_play_permissions +Revises: 0152_cross_user_smart_scopes +Create Date: 2026-10-07 00:00:00.000000 + +""" + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = "0153_play_permissions" +down_revision = "0152_cross_user_smart_scopes" +branch_labels = None +depends_on = None + +ENTITIES = ("streaming", "emulation") +ACTIONS = ("read", "write") + + +def _groups_t() -> sa.TableClause: + return sa.table("permission_groups", sa.column("id", sa.Integer)) + + +def _grants_t() -> sa.TableClause: + return sa.table( + "permission_group_grants", + sa.column("group_id", sa.Integer), + sa.column("entity", sa.String), + sa.column("action", sa.String), + sa.column("own_only", sa.Boolean), + ) + + +def _overrides_t() -> sa.TableClause: + return sa.table("user_permission_overrides", sa.column("entity", sa.String)) + + +def upgrade() -> None: + conn = op.get_bind() + grants_t = _grants_t() + + existing = { + (row.group_id, row.entity, row.action) + for row in conn.execute( + sa.select(grants_t.c.group_id, grants_t.c.entity, grants_t.c.action).where( + grants_t.c.entity.in_(ENTITIES) + ) + ) + } + group_ids: list[int] = list(conn.execute(sa.select(_groups_t().c.id)).scalars()) + rows = [ + {"group_id": group_id, "entity": entity, "action": action, "own_only": False} + for group_id in group_ids + for entity in ENTITIES + for action in ACTIONS + if (group_id, entity, action) not in existing + ] + if rows: + conn.execute(grants_t.insert(), rows) + + +def downgrade() -> None: + # The previous PermEntity cannot load these rows, so none may remain. + conn = op.get_bind() + grants_t = _grants_t() + overrides_t = _overrides_t() + conn.execute(sa.delete(grants_t).where(grants_t.c.entity.in_(ENTITIES))) + conn.execute(sa.delete(overrides_t).where(overrides_t.c.entity.in_(ENTITIES))) diff --git a/backend/endpoints/netplay.py b/backend/endpoints/netplay.py index 46b99c3bb..b98d8b802 100644 --- a/backend/endpoints/netplay.py +++ b/backend/endpoints/netplay.py @@ -5,9 +5,10 @@ from fastapi import Request from decorators.auth import protected_route from exceptions.endpoint_exceptions import RomNotFoundInDatabaseException from handler.auth.constants import Scope -from handler.auth.dependencies import assert_rom_visible +from handler.auth.dependencies import assert_rom_visible, can_access, get_permissions from handler.database import db_rom_handler from handler.netplay_handler import NetplayRoom, netplay_handler +from models.permission import PermAction, PermEntity from utils.router import APIRouter router = APIRouter( @@ -56,6 +57,10 @@ async def get_rooms(request: Request, game_id: int) -> Dict[str, RoomsResponse]: assert_rom_visible(request, rom) + # Empty rather than 403, like the streaming joinable list. + if not can_access(get_permissions(request), PermEntity.EMULATION, PermAction.READ): + return {} + netplay_rooms = await netplay_handler.get_all() open_rooms: Dict[str, RoomsResponse] = { diff --git a/backend/endpoints/responses/permission.py b/backend/endpoints/responses/permission.py index bbbfed5b4..65e147ecd 100644 --- a/backend/endpoints/responses/permission.py +++ b/backend/endpoints/responses/permission.py @@ -47,6 +47,10 @@ class ActionKey(enum.StrEnum): PLAYLIST_EDIT = "playlist.edit" # Library LIBRARY_SCAN = "library.scan" + # Streaming read has no key: the UI reads the streaming config's `enabled`. + STREAM_START = "stream.start" + # Netplay (rom.play is the emulation read grant) + NETPLAY_HOST = "netplay.host" # Users USER_VIEW = "user.view" USER_CREATE = "user.create" @@ -63,7 +67,6 @@ class ActionKey(enum.StrEnum): _ENTITY_ACTION_KEYS: dict[tuple[PermEntity, PermAction], tuple[ActionKey, ...]] = { (PermEntity.ROMS, PermAction.READ): ( ActionKey.ROM_VIEW, - ActionKey.ROM_PLAY, ActionKey.ROM_DOWNLOAD, ActionKey.ROM_FAVORITE, ), @@ -88,6 +91,9 @@ _ENTITY_ACTION_KEYS: dict[tuple[PermEntity, PermAction], tuple[ActionKey, ...]] ), (PermEntity.COLLECTIONS, PermAction.DELETE): (ActionKey.COLLECTION_DELETE,), (PermEntity.PLAYLISTS, PermAction.WRITE): (ActionKey.PLAYLIST_EDIT,), + (PermEntity.STREAMING, PermAction.WRITE): (ActionKey.STREAM_START,), + (PermEntity.EMULATION, PermAction.READ): (ActionKey.ROM_PLAY,), + (PermEntity.EMULATION, PermAction.WRITE): (ActionKey.NETPLAY_HOST,), (PermEntity.USERS, PermAction.READ): (ActionKey.USER_VIEW,), (PermEntity.USERS, PermAction.WRITE): ( ActionKey.USER_CREATE, diff --git a/backend/endpoints/roms/easyrpg.py b/backend/endpoints/roms/easyrpg.py index 24290028a..77cf16951 100644 --- a/backend/endpoints/roms/easyrpg.py +++ b/backend/endpoints/roms/easyrpg.py @@ -11,10 +11,11 @@ from config import DEV_MODE, DISABLE_EASYRPG from decorators.auth import protected_route from exceptions.endpoint_exceptions import RomNotFoundInDatabaseException from handler.auth.constants import Scope -from handler.auth.dependencies import assert_rom_visible +from handler.auth.dependencies import assert_rom_visible, get_permissions from handler.database import db_rom_handler from handler.easyrpg import INDEX_FILE, RTP_WEB_PATH, easyrpg_handler from handler.filesystem import fs_rom_handler +from models.permission import PermAction, PermEntity from utils.nginx import FileRedirectResponse from utils.router import APIRouter @@ -49,6 +50,13 @@ async def get_easyrpg_file( if DISABLE_EASYRPG: raise _not_found("EasyRPG is disabled") + # Only the in-browser player reads these, and a game it runs is the user's own. + perms = get_permissions(request) + if not perms.allows(PermEntity.EMULATION, PermAction.READ, owned=True): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, detail="Insufficient permissions" + ) + # The player fetches every asset through here, so the full rom is never loaded. rom = db_rom_handler.get_rom_visibility(id) if not rom: diff --git a/backend/endpoints/sockets/netplay.py b/backend/endpoints/sockets/netplay.py index a8ec2edd5..e441052de 100644 --- a/backend/endpoints/sockets/netplay.py +++ b/backend/endpoints/sockets/netplay.py @@ -15,6 +15,7 @@ from handler.database import db_rom_handler, db_user_handler from handler.netplay_handler import NetplayPlayerInfo, NetplayRoom, netplay_handler from handler.socket_handler import netplay_socket_handler from logger.logger import log +from models.permission import PermAction, PermEntity if TYPE_CHECKING: from models.user import User @@ -68,19 +69,30 @@ async def _authenticated_user(sid: str) -> User | None: return user -async def _playable_rom_id(sid: str, game_id: Any) -> int | None: - """The ROM id if the socket's user passes the gate the ROM endpoints apply, else None.""" +async def _playable_rom_id( + sid: str, game_id: Any, *actions: PermAction, owned: bool +) -> int | None: + """The ROM id if the socket's user may play it in the browser, else None. + + Args: + actions: the emulation grants needed besides the ROM gate. + owned: whether the room is the user's own, where an "own items only" grant counts. + """ user = await _authenticated_user(sid) if user is None or Scope.ROMS_READ not in user.oauth_scopes: return None + perms = resolve_permissions(user) + if not all(perms.allows(PermEntity.EMULATION, a, owned=owned) for a in actions): + return None + try: rom_id = int(game_id) except TypeError, ValueError: return None rom = db_rom_handler.get_rom_visibility(rom_id) - if rom is None or not resolve_permissions(user).can_see_rom(rom): + if rom is None or not perms.can_see_rom(rom): return None return rom.id @@ -146,7 +158,9 @@ async def open_room(sid: str, data: RoomData) -> str | None: if not session_id or not player_id: return "Invalid data: sessionId and playerId required" - rom_id = await _playable_rom_id(sid, extra_data.get("game_id")) + rom_id = await _playable_rom_id( + sid, extra_data.get("game_id"), PermAction.READ, PermAction.WRITE, owned=True + ) if rom_id is None: log.warning("Netplay room creation rejected: not authorized for this rom") return "Not authorized to open a room for this game" @@ -195,11 +209,17 @@ async def join_room( if not current_room: return "Room not found" - # A password room admits whoever has the password, any other needs the ROM. + # A password room admits whoever has the password, any other needs the ROM + # and a full emulation read, as the room is someone else's. if current_room["password"]: if not _password_matches(current_room["password"], _room_password(data)): return "Incorrect password" - elif await _playable_rom_id(sid, current_room["game_id"]) is None: + elif ( + await _playable_rom_id( + sid, current_room["game_id"], PermAction.READ, owned=False + ) + is None + ): log.warning("Netplay join rejected: not authorized for this rom") return "Not authorized to join this room" diff --git a/backend/endpoints/streaming.py b/backend/endpoints/streaming.py index 029da610b..bdab54542 100644 --- a/backend/endpoints/streaming.py +++ b/backend/endpoints/streaming.py @@ -7,6 +7,9 @@ is absent from READ_SCOPES -- which is all KIOSK_MODE hands an anonymous visitor. Without it, kiosk visitors (who all share one synthetic user, so session ownership cannot separate them) could claim sessions and overwrite each other's save states. + +On top of the scopes, the `streaming` permission grant decides who sees +streaming, joins a session (read) and starts one (read and write). """ import asyncio @@ -332,6 +335,11 @@ def _check_key(kind: str, c: ResolvedContainer) -> Hashable: @protected_route(router.get, "/config", [Scope.ROMS_READ]) async def get_config(request: Request) -> StreamingConfigSchema: """Return streaming configuration to the frontend""" + # The frontend hides every streaming surface on `enabled`. + if streaming_enabled() and not access.can_see_streaming(request): + return StreamingConfigSchema( + enabled=False, containers=[], emulator_labels=emulator_labels() + ) # One row per platform: a pool is a backend concern, the frontend picks a # platform and the claim decides which container serves it. # The record carries the platform's label and capabilities, so a @@ -829,6 +837,8 @@ async def claim_session( Returns 409 if every container in the platform's first pool is occupied. Returns 428 if the container's pre-existing memory card needs a decision. """ + access.assert_can_start_streaming(request) + rom = db_rom_handler.get_rom(req.rom_id) if rom is None: raise HTTPException(status_code=404, detail="ROM not found") @@ -1308,6 +1318,8 @@ async def join_session( through access.assert_session_owner, so a joiner cannot change the volume, write states, or release the container. """ + access.assert_can_join_streaming(request) + if container is not None: candidate, _, session = await access.resolve_named_container( platform, container @@ -1733,6 +1745,9 @@ async def list_joinable_sessions( sessions whose host opted into multiplayer at launch, and only the fields a Join button needs. Sessions the caller is already hosting are left out. """ + if not access.can_join_streaming(request): + return JoinableSessionsResponse(sessions=[]) + grouped = containers_by_key() sessions: list[JoinableSessionSchema] = [] diff --git a/backend/handler/auth/permissions_map.py b/backend/handler/auth/permissions_map.py index 41032faf5..4be4e9385 100644 --- a/backend/handler/auth/permissions_map.py +++ b/backend/handler/auth/permissions_map.py @@ -92,6 +92,10 @@ LEGACY_VIEWER_GRANTS: tuple[Grant, ...] = ( (PermEntity.DEVICES, PermAction.READ, True), (PermEntity.DEVICES, PermAction.WRITE, True), (PermEntity.DEVICES, PermAction.DELETE, True), + (PermEntity.STREAMING, PermAction.READ, False), + (PermEntity.STREAMING, PermAction.WRITE, False), + (PermEntity.EMULATION, PermAction.READ, False), + (PermEntity.EMULATION, PermAction.WRITE, False), ) # "Editor" == EDIT_SCOPES: viewer + library-wide write AND delete of diff --git a/backend/handler/streaming/access.py b/backend/handler/streaming/access.py index 2a593fa72..3ab4126d7 100644 --- a/backend/handler/streaming/access.py +++ b/backend/handler/streaming/access.py @@ -4,7 +4,12 @@ from typing import Any from fastapi import HTTPException, Request -from handler.auth.dependencies import get_permissions, get_rom_visibility_filter +from handler.auth.dependencies import ( + assert_can, + can_access, + get_permissions, + get_rom_visibility_filter, +) from handler.database import db_platform_handler, db_rom_handler from handler.streaming.config import ( ResolvedContainer, @@ -19,6 +24,7 @@ from handler.streaming.session_store import ( session_platform_matches, ) from logger.logger import log +from models.permission import PermAction, PermEntity from models.rom import Rom from models.user import Role @@ -43,6 +49,33 @@ def assert_session_owner(session: dict[str, Any], request: Request) -> None: raise HTTPException(status_code=403, detail="Session is claimed by another user") +def _allows_streaming(request: Request, *actions: PermAction) -> bool: + # A session is always the caller's own, so an "own items only" grant counts. + perms = get_permissions(request) + return all(perms.allows(PermEntity.STREAMING, a, owned=True) for a in actions) + + +def can_see_streaming(request: Request) -> bool: + """Whether the caller may see streaming and play their own sessions.""" + return _allows_streaming(request, PermAction.READ) + + +def can_join_streaming(request: Request) -> bool: + """Whether the caller may join someone else's session.""" + return can_access(get_permissions(request), PermEntity.STREAMING, PermAction.READ) + + +def assert_can_join_streaming(request: Request) -> None: + """Raise 403 unless the caller may join someone else's session.""" + assert_can(get_permissions(request), PermEntity.STREAMING, PermAction.READ) + + +def assert_can_start_streaming(request: Request) -> None: + """Raise 403 unless the caller may start a session, which takes a container.""" + if not _allows_streaming(request, PermAction.READ, PermAction.WRITE): + raise HTTPException(status_code=403, detail="Insufficient permissions") + + def rom_is_visible(request: Request, rom: Rom | None) -> bool: """Can the caller see this ROM? diff --git a/backend/models/permission.py b/backend/models/permission.py index 62d3bb5c2..a09bcddf3 100644 --- a/backend/models/permission.py +++ b/backend/models/permission.py @@ -40,6 +40,8 @@ class PermEntity(enum.StrEnum): USERS = "users" TASKS = "tasks" LOGS = "logs" + STREAMING = "streaming" + EMULATION = "emulation" class PermAction(enum.StrEnum): diff --git a/backend/tests/endpoints/roms/test_easyrpg.py b/backend/tests/endpoints/roms/test_easyrpg.py index 0b060ec42..4195d99dd 100644 --- a/backend/tests/endpoints/roms/test_easyrpg.py +++ b/backend/tests/endpoints/roms/test_easyrpg.py @@ -7,7 +7,12 @@ from endpoints.roms import easyrpg from handler.database import db_rom_handler from handler.database.base_handler import sync_session from handler.easyrpg import easyrpg_handler -from models.permission import HiddenEntity, PermEntity +from models.permission import ( + HiddenEntity, + PermAction, + PermEntity, + UserPermissionOverride, +) from models.platform import Platform from models.rom import Rom, RomFile from models.user import User @@ -108,6 +113,33 @@ def test_a_hidden_game_is_not_found( assert response.status_code == status.HTTP_404_NOT_FOUND +@pytest.mark.parametrize( + "own_only, expected", + [(False, status.HTTP_403_FORBIDDEN), (True, status.HTTP_200_OK)], + ids=["revoked", "own-only"], +) +def test_needs_the_emulation_read_grant( + client: TestClient, editor_headers, editor_user: User, game: Rom, own_only, expected +): + """The player is the only reader, and a game it runs counts as the user's own.""" + with sync_session.begin() as s: + s.add( + UserPermissionOverride( + user_id=editor_user.id, + entity=PermEntity.EMULATION, + action=PermAction.READ, + granted=own_only, + own_only=own_only, + ) + ) + + response = client.get( + f"/api/roms/{game.id}/easyrpg/index.json", headers=editor_headers + ) + + assert response.status_code == expected + + def test_is_not_found_while_disabled(mocker, client: TestClient, headers, game: Rom): mocker.patch.object(easyrpg, "DISABLE_EASYRPG", True) diff --git a/backend/tests/endpoints/sockets/test_netplay.py b/backend/tests/endpoints/sockets/test_netplay.py index 366313ecc..79515743c 100644 --- a/backend/tests/endpoints/sockets/test_netplay.py +++ b/backend/tests/endpoints/sockets/test_netplay.py @@ -23,6 +23,7 @@ from handler.auth.constants import Scope from handler.database import db_rom_handler, db_user_handler from handler.netplay_handler import NetplayPlayerInfo, NetplayRoom from handler.socket_handler import netplay_socket_handler +from models.permission import PermAction, PermEntity ROM_ID = 42 PLATFORM_ID = 7 @@ -30,6 +31,16 @@ VISIBLE_ROM = Mock(id=ROM_ID, platform_id=PLATFORM_ID) ROOM_1 = netplay_module._socket_room("room-1") +def _grant(rooms: Mock, *actions: PermAction, own_only: bool = False) -> None: + """Hold only these emulation grants, as library-wide or own-only ones.""" + + def allows(entity: PermEntity, action: PermAction, *, owned: bool | None = None): + held = entity == PermEntity.EMULATION and action in actions + return held and (not own_only or owned is True) + + rooms.permissions.return_value.allows = Mock(side_effect=allows) + + def _user(*scopes: Scope) -> Mock: user = Mock() user.id = 1 @@ -118,6 +129,7 @@ def rooms(mocker) -> Mock: mocker.patch.object(db_rom_handler, "get_rom_visibility", return_value=VISIBLE_ROM) permissions = mocker.patch.object(netplay_module, "resolve_permissions") permissions.return_value.can_see_rom = Mock(return_value=True) + permissions.return_value.allows = Mock(return_value=True) return Mock(store=store, handler=handler, permissions=permissions) @@ -258,6 +270,29 @@ class TestOpenRoomAuthorization: assert server.sessions["sid"][AUTH_USER_SESSION_KEY] == 1 assert server.sessions["sid"]["session_id"] == "room-1" + @pytest.mark.parametrize( + "held", + [(), (PermAction.READ,), (PermAction.WRITE,)], + ids=["none", "read", "write"], + ) + async def test_rejects_without_emulation_read_and_write( + self, server, rooms, authorized, held + ): + _grant(rooms, *held) + + result = await open_room("sid", _open()) + + assert result is not None and "Not authorized" in result + rooms.handler.set.assert_not_awaited() + + async def test_an_own_only_grant_hosts(self, server, rooms, authorized): + """The room is the host's own, so an "own items only" grant counts.""" + _grant(rooms, PermAction.READ, PermAction.WRITE, own_only=True) + + assert await open_room("sid", _open()) is None + + assert "room-1" in rooms.store + class TestJoinRoomAuthorization: async def test_rejects_guest_without_a_password(self, mocker, server, rooms): @@ -335,6 +370,43 @@ class TestJoinRoomAuthorization: assert joined is not None server.enter_room.assert_awaited_once_with("sid", ROOM_1) + async def test_rejects_user_without_emulation_read(self, server, rooms, authorized): + rooms.store["room-1"] = _room() + _grant(rooms, PermAction.WRITE) + + result = await join_room("sid", _join()) + + assert "Not authorized" in result + server.enter_room.assert_not_awaited() + + async def test_rejects_an_own_only_read(self, server, rooms, authorized): + """The room is someone else's, so an "own items only" grant does not reach it.""" + rooms.store["room-1"] = _room() + _grant(rooms, PermAction.READ, own_only=True) + + result = await join_room("sid", _join()) + + assert "Not authorized" in result + server.enter_room.assert_not_awaited() + + async def test_joins_with_emulation_read_alone(self, server, rooms, authorized): + rooms.store["room-1"] = _room() + _grant(rooms, PermAction.READ) + + assert await join_room("sid", _join()) is not None + + server.enter_room.assert_awaited_once_with("sid", ROOM_1) + + async def test_a_password_invite_needs_no_emulation_grant( + self, server, rooms, authorized + ): + rooms.store["room-1"] = _room(password="s3cret") + _grant(rooms) + + assert await join_room("sid", _join(password="s3cret")) is not None + + server.enter_room.assert_awaited_once_with("sid", ROOM_1) + class TestWebRtcSignalRelay: async def test_drops_a_signal_to_a_non_peer(self, mocker, server, rooms): diff --git a/backend/tests/endpoints/test_netplay.py b/backend/tests/endpoints/test_netplay.py index 32ec50a49..7e134bbde 100644 --- a/backend/tests/endpoints/test_netplay.py +++ b/backend/tests/endpoints/test_netplay.py @@ -11,7 +11,12 @@ from handler.auth.base_handler import oauth_handler from handler.auth.constants import Scope from handler.database.base_handler import sync_session from handler.netplay_handler import NetplayPlayerInfo, NetplayRoom, netplay_handler -from models.permission import HiddenEntity, PermEntity +from models.permission import ( + HiddenEntity, + PermAction, + PermEntity, + UserPermissionOverride, +) from models.user import User @@ -95,3 +100,30 @@ def test_listing_needs_the_rom_read_scope(client, viewer_user, rooms): assert resp.status_code == 403 rooms.assert_not_awaited() + + +def _override_emulation_read(user: User, *, own_only: bool = False) -> None: + with sync_session.begin() as s: + s.add( + UserPermissionOverride( + user_id=user.id, + entity=PermEntity.EMULATION, + action=PermAction.READ, + granted=own_only, + own_only=own_only, + ) + ) + + +@pytest.mark.parametrize("own_only", [False, True], ids=["revoked", "own-only"]) +def test_listing_is_empty_without_a_full_emulation_read( + client, viewer_user, rom, rooms, own_only +): + """Every listed room is someone else's, so only a library-wide grant shows them.""" + _override_emulation_read(viewer_user, own_only=own_only) + rooms.return_value = {"room-1": _room(rom.id)} + + resp = client.get(f"/api/netplay/list?game_id={rom.id}", headers=_auth(viewer_user)) + + assert resp.status_code == 200 + assert resp.json() == {} diff --git a/backend/tests/endpoints/test_permissions_me.py b/backend/tests/endpoints/test_permissions_me.py index 19b583c56..655d03661 100644 --- a/backend/tests/endpoints/test_permissions_me.py +++ b/backend/tests/endpoints/test_permissions_me.py @@ -1,7 +1,14 @@ """`/permissions/me` returns backend-enforced grants in the UI action vocabulary.""" +import pytest + from handler.database.base_handler import sync_session -from models.permission import HiddenEntity, PermEntity +from models.permission import ( + HiddenEntity, + PermAction, + PermEntity, + UserPermissionOverride, +) VIEWER_ACTIONS = { "rom.view", @@ -14,6 +21,8 @@ VIEWER_ACTIONS = { "collection.edit", "collection.delete", "playlist.edit", + "stream.start", + "netplay.host", } @@ -94,3 +103,47 @@ def test_grant_scopes_are_global(client, viewer_access_token): headers={"Authorization": f"Bearer {viewer_access_token}"}, ).json() assert all(g["scope"]["kind"] == "global" for g in body["grants"]) + + +def test_revoking_streaming_write_drops_stream_start( + client, viewer_user, viewer_access_token +): + with sync_session.begin() as s: + s.add( + UserPermissionOverride( + user_id=viewer_user.id, + entity=PermEntity.STREAMING, + action=PermAction.WRITE, + granted=False, + ) + ) + body = client.get( + "/api/permissions/me", + headers={"Authorization": f"Bearer {viewer_access_token}"}, + ).json() + assert "stream.start" not in _actions(body) + + +@pytest.mark.parametrize( + "action, dropped", + [(PermAction.READ, "rom.play"), (PermAction.WRITE, "netplay.host")], +) +def test_revoking_emulation_drops_its_key( + client, viewer_user, viewer_access_token, action, dropped +): + with sync_session.begin() as s: + s.add( + UserPermissionOverride( + user_id=viewer_user.id, + entity=PermEntity.EMULATION, + action=action, + granted=False, + ) + ) + body = client.get( + "/api/permissions/me", + headers={"Authorization": f"Bearer {viewer_access_token}"}, + ).json() + actions = _actions(body) + assert dropped not in actions + assert "rom.view" in actions diff --git a/backend/tests/endpoints/test_streaming.py b/backend/tests/endpoints/test_streaming.py index f5b426d1f..7f64185f1 100644 --- a/backend/tests/endpoints/test_streaming.py +++ b/backend/tests/endpoints/test_streaming.py @@ -94,10 +94,17 @@ from handler.streaming.session_store import ( ) from models.assets import MemoryCard, MemoryCardVersion, Save, Screenshot, State from models.notification import NotificationKind -from models.permission import HiddenEntity, PermEntity +from models.permission import ( + HiddenEntity, + PermAction, + PermEntity, + PermissionGroup, + PermissionGroupGrant, + UserPermissionOverride, +) from models.platform import Platform from models.rom import Rom, RomFile, SaveTargetLayout -from models.user import User +from models.user import Role, User from utils.memory_cards import content_hash_of_bytes from utils.zip_cache import ensure_zipfile_writable @@ -12855,3 +12862,180 @@ async def test_store_save_asset_fits_a_long_multibyte_rom_name( assert filename.startswith("ゲーム") assert filename.endswith("].saves.zip") add_save.assert_called_once() + + +# ── streaming permission ────────────────────────────────────────────────────── + + +def _override_streaming( + user: User, *actions: PermAction, granted: bool = False, own_only: bool = False +) -> None: + with sync_session.begin() as s: + for action in actions: + s.add( + UserPermissionOverride( + user_id=user.id, + entity=PermEntity.STREAMING, + action=action, + granted=granted, + own_only=own_only, + ) + ) + + +def test_get_config_is_disabled_without_streaming_read( + client, viewer_access_token, viewer_user: User, rom: Rom +): + """A user kept off streaming gets the same answer as a server without it.""" + _override_streaming(viewer_user, PermAction.READ) + with _streaming(_container_for(rom)): + response = client.get( + "/api/streaming/config", headers=_auth(viewer_access_token) + ) + assert response.status_code == 200 + assert response.json()["enabled"] is False + assert response.json()["containers"] == [] + + +@pytest.fixture +def guests_group() -> Iterator[int]: + """A group with no streaming grant; conftest keeps groups between tests.""" + with sync_session.begin() as s: + group = PermissionGroup( + name="Guests", + grants=[ + PermissionGroupGrant(entity=PermEntity.ROMS, action=PermAction.READ) + ], + ) + s.add(group) + s.flush() + group_id = group.id + yield group_id + with sync_session.begin() as s: + s.query(PermissionGroup).filter_by(id=group_id).delete() + + +def test_get_config_is_disabled_for_a_group_without_streaming( + client, rom: Rom, guests_group: int +): + """A group an admin creates starts with no streaming grant.""" + guest = db_user_handler.add_user( + User(username="guest", role=Role.USER, permission_group_id=guests_group) + ) + with _streaming(_container_for(rom)): + response = client.get( + "/api/streaming/config", headers=_auth(_access_token(guest)) + ) + assert response.json()["enabled"] is False + + +def test_get_config_keeps_streaming_for_an_admin_with_no_grants( + client, access_token, rom: Rom +): + with _streaming(_container_for(rom)): + response = client.get("/api/streaming/config", headers=_auth(access_token)) + assert response.json()["enabled"] is True + + +@pytest.mark.parametrize("action", [PermAction.READ, PermAction.WRITE]) +def test_claim_without_streaming_grant_is_forbidden( + client, viewer_access_token, viewer_user: User, rom: Rom, action: PermAction +): + """Starting a session takes a container, so it needs both grants.""" + _override_streaming(viewer_user, action) + with _streaming(_container_for(rom)): + with patch("handler.streaming.commands.launch") as call_broker: + r = _claim(client, viewer_access_token, rom.id) + assert r.status_code == 403 + call_broker.assert_not_called() + + +def test_an_own_only_streaming_grant_counts( + client, viewer_access_token, viewer_user: User, rom: Rom +): + """A session the player starts is their own, so "own items only" counts.""" + _override_streaming( + viewer_user, PermAction.READ, PermAction.WRITE, granted=True, own_only=True + ) + with _streaming(_container_for(rom)): + config = client.get("/api/streaming/config", headers=_auth(viewer_access_token)) + r = _claim_ok(client, viewer_access_token, rom.id) + assert config.json()["enabled"] is True + assert r.status_code == 202 + + +def test_claim_with_streaming_grant_still_launches( + client, viewer_access_token, rom: Rom +): + with _streaming(_container_for(rom)): + r = _claim_ok(client, viewer_access_token, rom.id) + assert r.status_code == 202 + + +def test_joining_without_streaming_read_is_forbidden( + client, access_token, viewer_access_token, viewer_user: User, rom: Rom +): + _override_streaming(viewer_user, PermAction.READ) + with _streaming(_ws_for(rom)): + _claim_multiplayer(client, access_token, rom.id) + with patch("handler.streaming.webstation.join") as join_broker: + response = _join(client, viewer_access_token, rom.platform_slug) + assert response.status_code == 403 + join_broker.assert_not_called() + + +def test_joining_needs_only_streaming_read( + client, access_token, viewer_access_token, viewer_user: User, rom: Rom +): + """A guest seat runs on the host's container, so it costs no launch.""" + _override_streaming(viewer_user, PermAction.WRITE) + with _streaming(_ws_for(rom)): + _claim_multiplayer(client, access_token, rom.id) + body = _joinable(client, viewer_access_token).json() + with _joined_room(): + joined = _join(client, viewer_access_token, rom.platform_slug) + assert len(body["sessions"]) == 1 + assert joined.status_code == 200 + + +def test_an_own_only_streaming_read_cannot_join_someone_elses_session( + client, access_token, viewer_access_token, viewer_user: User, rom: Rom +): + """Another player's session is not the caller's own.""" + _override_streaming(viewer_user, PermAction.READ, granted=True, own_only=True) + with _streaming(_ws_for(rom)): + _claim_multiplayer(client, access_token, rom.id) + listed = _joinable(client, viewer_access_token) + with patch("handler.streaming.webstation.join") as join_broker: + joined = _join(client, viewer_access_token, rom.platform_slug) + assert listed.status_code == 200 + assert listed.json()["sessions"] == [] + assert joined.status_code == 403 + join_broker.assert_not_called() + + +def test_joinable_is_empty_without_streaming_read( + client, access_token, viewer_access_token, viewer_user: User, rom: Rom +): + _override_streaming(viewer_user, PermAction.READ) + with _streaming(_ws_for(rom)): + _claim_multiplayer(client, access_token, rom.id) + response = _joinable(client, viewer_access_token) + assert response.status_code == 200 + assert response.json()["sessions"] == [] + + +def test_a_revoked_player_can_still_leave_their_session( + client, viewer_access_token, viewer_user: User, rom: Rom +): + """Revoking streaming mid-game must not strand the player's save.""" + with _streaming(_container_for(rom)): + assert _claim_ok(client, viewer_access_token, rom.id).status_code == 202 + _override_streaming(viewer_user, PermAction.READ, PermAction.WRITE) + with _stub_stop(): + r = client.delete( + f"/api/streaming/sessions/{rom.platform_slug}", + headers=_auth(viewer_access_token), + ) + assert r.status_code == 200 + assert r.json()["status"] == "released" diff --git a/backend/tests/test_migrations.py b/backend/tests/test_migrations.py index d070a05c3..bc636a590 100644 --- a/backend/tests/test_migrations.py +++ b/backend/tests/test_migrations.py @@ -1121,3 +1121,101 @@ def test_the_sibling_platform_names_revision_renames_only_the_stale_name(): ("c64", "Commodore 64"), ("c128-custom", "C128 (custom)"), } + + +_GROUPS = sa.table( + "permission_groups", + sa.column("id", sa.Integer), + sa.column("name", sa.String), + sa.column("description", sa.String), + sa.column("is_default", sa.Boolean), + sa.column("hide_unrated_roms", sa.Boolean), +) +_GRANTS = sa.table( + "permission_group_grants", + sa.column("group_id", sa.Integer), + sa.column("entity", sa.String), + sa.column("action", sa.String), + sa.column("own_only", sa.Boolean), +) +_OVERRIDES = sa.table( + "user_permission_overrides", + sa.column("user_id", sa.Integer), + sa.column("entity", sa.String), + sa.column("action", sa.String), + sa.column("granted", sa.Boolean), + sa.column("own_only", sa.Boolean), +) + + +_PLAY_ENTITIES = ("streaming", "emulation") + + +def _play_grants(connection: sa.Connection) -> set[tuple[int, str, str, bool]]: + return { + (row.group_id, row.entity, row.action, bool(row.own_only)) + for row in connection.execute( + sa.select( + _GRANTS.c.group_id, + _GRANTS.c.entity, + _GRANTS.c.action, + _GRANTS.c.own_only, + ).where(_GRANTS.c.entity.in_(_PLAY_ENTITIES)) + ) + } + + +def _play_overrides(connection: sa.Connection) -> int: + return connection.execute( + sa.select(sa.func.count()) + .select_from(_OVERRIDES) + .where(_OVERRIDES.c.entity.in_(_PLAY_ENTITIES)) + ).scalar_one() + + +def test_the_play_permissions_revision_backfills_reverses_and_replays( + admin_user: User, +): + """0153 grants streaming and emulation to every existing group and downgrades cleanly.""" + migration = _load_migration("0153_play_permissions.py") + + with sync_engine.begin() as connection: + connection.execute( + _GROUPS.insert().values( + name="Locked down", + description="", + is_default=False, + hide_unrated_roms=False, + ) + ) + connection.execute( + _OVERRIDES.insert(), + [ + { + "user_id": admin_user.id, + "entity": entity, + "action": "write", + "granted": False, + "own_only": False, + } + for entity in _PLAY_ENTITIES + ], + ) + with Operations.context(MigrationContext.configure(connection)): + migration.downgrade() + migration.downgrade() + assert _play_grants(connection) == set() + assert _play_overrides(connection) == 0 + + migration.upgrade() + migration.upgrade() + + group_ids: set[int] = set(connection.execute(sa.select(_GROUPS.c.id)).scalars()) + assert len(group_ids) >= 3 + assert _play_grants(connection) == { + (group_id, entity, action, False) + for group_id in group_ids + for entity in _PLAY_ENTITIES + for action in ("read", "write") + } + connection.execute(_GROUPS.delete().where(_GROUPS.c.name == "Locked down")) diff --git a/docs/BACKEND_ARCHITECTURE.md b/docs/BACKEND_ARCHITECTURE.md index d2c209b0c..3cede5408 100644 --- a/docs/BACKEND_ARCHITECTURE.md +++ b/docs/BACKEND_ARCHITECTURE.md @@ -1292,6 +1292,12 @@ users.read / users.write : User management (admin) tasks.run : Task execution ``` +### Permission Groups + +Endpoints also check the caller's permission group (`handler/auth/permissions.py`), which holds entity and action grants plus per-user overrides. Most entities line up with a scope above; `streaming` and `emulation` have none. A `read` grant shows streaming and lets the user join someone else's session, while an "own items only" `read` shows it without the join. Starting a session takes `read` and `write`. A user who loses the grant keeps control of a session they already hold. + +`emulation` covers the in-browser players (EmulatorJS, Ruffle, js-dos, PICO-8, EasyRPG). A `read` grant lets the user play in the browser and join someone else's netplay room, and an "own items only" `read` plays without the join. Hosting a room takes `read` and `write`. A password room admits anyone with the password, so the invite stands in for the grant. ROM files are also served for downloads, so the grant hides the players in the UI and is enforced on the netplay sockets, the room list and the EasyRPG file route. + ### CSRF Protection - Cookie: `romm_csrftoken` (signed with `itsdangerous`) diff --git a/frontend/src/__generated__/models/ActionKey.ts b/frontend/src/__generated__/models/ActionKey.ts index c4132bdfd..3cb1f1854 100644 --- a/frontend/src/__generated__/models/ActionKey.ts +++ b/frontend/src/__generated__/models/ActionKey.ts @@ -2,4 +2,4 @@ /* istanbul ignore file */ /* tslint:disable */ /* eslint-disable */ -export type ActionKey = 'rom.view' | 'rom.play' | 'rom.download' | 'rom.upload' | 'rom.edit' | 'rom.delete' | 'rom.match' | 'rom.refresh' | 'rom.favorite' | 'platform.view' | 'platform.create' | 'platform.edit' | 'platform.delete' | 'collection.view' | 'collection.create' | 'collection.edit' | 'collection.delete' | 'playlist.edit' | 'library.scan' | 'user.view' | 'user.create' | 'user.edit' | 'user.delete' | 'app.admin'; +export type ActionKey = 'rom.view' | 'rom.play' | 'rom.download' | 'rom.upload' | 'rom.edit' | 'rom.delete' | 'rom.match' | 'rom.refresh' | 'rom.favorite' | 'platform.view' | 'platform.create' | 'platform.edit' | 'platform.delete' | 'collection.view' | 'collection.create' | 'collection.edit' | 'collection.delete' | 'playlist.edit' | 'library.scan' | 'stream.start' | 'netplay.host' | 'user.view' | 'user.create' | 'user.edit' | 'user.delete' | 'app.admin'; diff --git a/frontend/src/__generated__/models/PermEntity.ts b/frontend/src/__generated__/models/PermEntity.ts index f2ad99e38..3e5f3ed46 100644 --- a/frontend/src/__generated__/models/PermEntity.ts +++ b/frontend/src/__generated__/models/PermEntity.ts @@ -9,4 +9,4 @@ * grant matrix can be projected back onto the legacy ``Scope`` set without * drift (see ``handler/auth/permissions_map.py``). */ -export type PermEntity = 'platforms' | 'roms' | 'collections' | 'playlists' | 'firmware' | 'assets' | 'devices' | 'users' | 'tasks' | 'logs'; +export type PermEntity = 'platforms' | 'roms' | 'collections' | 'playlists' | 'firmware' | 'assets' | 'devices' | 'users' | 'tasks' | 'logs' | 'streaming' | 'emulation'; diff --git a/frontend/src/components/common/Game/Card/ActionBar.vue b/frontend/src/components/common/Game/Card/ActionBar.vue index ef9e379ae..11a0057a3 100644 --- a/frontend/src/components/common/Game/Card/ActionBar.vue +++ b/frontend/src/components/common/Game/Card/ActionBar.vue @@ -10,6 +10,7 @@ import storeDownload from "@/stores/download"; import storeHeartbeat from "@/stores/heartbeat"; import type { SimpleRom } from "@/stores/roms"; import { isEJSEmulationSupported, isRuffleEmulationSupported } from "@/utils"; +import { useCan } from "@/v2/composables/useCan"; const props = defineProps<{ rom: SimpleRom; sizeActionBar: number }>(); const { t } = useI18n(); @@ -28,7 +29,9 @@ const isAprilFools = computed(() => { return today.getMonth() === 3 && today.getDate() === 1; }); +const canPlayInBrowser = useCan("rom.play"); const isEmulationSupported = computed(() => { + if (!canPlayInBrowser.value) return false; return ( isEJSEmulationSupported( props.rom.platform_slug, diff --git a/frontend/src/components/common/Game/PlayBtn.vue b/frontend/src/components/common/Game/PlayBtn.vue index 368f5ec18..6b7fa3571 100644 --- a/frontend/src/components/common/Game/PlayBtn.vue +++ b/frontend/src/components/common/Game/PlayBtn.vue @@ -13,6 +13,7 @@ import storeHeartbeat from "@/stores/heartbeat"; import storeRoms, { type SimpleRom } from "@/stores/roms"; import type { Events } from "@/types/emitter"; import { isEJSEmulationSupported, isRuffleEmulationSupported } from "@/utils"; +import { useCan } from "@/v2/composables/useCan"; const props = defineProps<{ rom: SimpleRom; iconEmbedded?: boolean }>(); const attrs = useAttrs(); @@ -29,7 +30,9 @@ const isAprilFools = computed(() => { return today.getMonth() === 3 && today.getDate() === 1; }); +const canPlayInBrowser = useCan("rom.play"); const isEmulationSupported = computed(() => { + if (!canPlayInBrowser.value) return false; return ( isEJSEmulationSupported( props.rom.platform_slug, diff --git a/frontend/src/console/Layout.vue b/frontend/src/console/Layout.vue index bb4754435..5d984816f 100644 --- a/frontend/src/console/Layout.vue +++ b/frontend/src/console/Layout.vue @@ -11,6 +11,7 @@ import { ROUTES } from "@/plugins/router"; import storeCollections from "@/stores/collections"; import storeNavigation from "@/stores/navigation"; import storePlatforms from "@/stores/platforms"; +import { installPermissionsHydration } from "@/v2/composables/useCan"; const router = useRouter(); const bus = new InputBus(); @@ -21,6 +22,9 @@ const navigationStore = storeNavigation(); const platformsStore = storePlatforms(); const collectionsStore = storeCollections(); +// Play reads the in-browser play grant from the permissions store. +installPermissionsHydration(); + const showVirtualCollections = useLocalStorage( "settings.showVirtualCollections", true, diff --git a/frontend/src/console/views/Game.vue b/frontend/src/console/views/Game.vue index 108158da5..1523159a8 100644 --- a/frontend/src/console/views/Game.vue +++ b/frontend/src/console/views/Game.vue @@ -30,6 +30,7 @@ import { getUnmatchedCoverImage, EXTENSION_REGEX, } from "@/utils/covers"; +import { useCan } from "@/v2/composables/useCan"; type FocusZone = "play" | "description" | "details" | "shots" | "lightbox" | "states"; @@ -61,8 +62,11 @@ const descriptionOverlayRef = useTemplateRef( "description-overlay-ref", ); const detailsOverlayRef = useTemplateRef("details-overlay-ref"); +const canPlayInBrowser = useCan("rom.play"); const isConsoleEmulationDisabled = computed( - () => heartbeatStore.value.EMULATION.DISABLE_EMULATOR_JS, + () => + heartbeatStore.value.EMULATION.DISABLE_EMULATOR_JS || + !canPlayInBrowser.value, ); const releaseDate = computed(() => { diff --git a/frontend/src/layouts/Main.vue b/frontend/src/layouts/Main.vue index 46b161fab..cfbb9f0f8 100644 --- a/frontend/src/layouts/Main.vue +++ b/frontend/src/layouts/Main.vue @@ -30,11 +30,15 @@ import storeCollections from "@/stores/collections"; import storeNavigation from "@/stores/navigation"; import storePlatforms from "@/stores/platforms"; import type { Events } from "@/types/emitter"; +import { installPermissionsHydration } from "@/v2/composables/useCan"; const navigationStore = storeNavigation(); const platformsStore = storePlatforms(); const collectionsStore = storeCollections(); +// The play buttons read the in-browser play grant from the permissions store. +installPermissionsHydration(); + const emitter = inject>("emitter"); emitter?.on("refreshDrawer", async () => { platformsStore.fetchPlatforms(); diff --git a/frontend/src/locales/bg_BG/settings.json b/frontend/src/locales/bg_BG/settings.json index 1ed70cd6d..fb6a527c0 100644 --- a/frontend/src/locales/bg_BG/settings.json +++ b/frontend/src/locales/bg_BG/settings.json @@ -334,11 +334,13 @@ "assets": "Saves & states", "collections": "Collections", "devices": "Devices", + "emulation": "Играй в браузъра", "firmware": "Firmware", "logs": "Logs", "platforms": "Platforms", "playlists": "Playlists", "roms": "Games", + "streaming": "Streaming", "tasks": "Tasks", "users": "Users" }, diff --git a/frontend/src/locales/cs_CZ/settings.json b/frontend/src/locales/cs_CZ/settings.json index 03e7debc0..2c3f6777b 100644 --- a/frontend/src/locales/cs_CZ/settings.json +++ b/frontend/src/locales/cs_CZ/settings.json @@ -334,11 +334,13 @@ "assets": "Saves & states", "collections": "Collections", "devices": "Devices", + "emulation": "Hrát v prohlížeči", "firmware": "Firmware", "logs": "Logs", "platforms": "Platforms", "playlists": "Playlists", "roms": "Games", + "streaming": "Streaming", "tasks": "Tasks", "users": "Users" }, diff --git a/frontend/src/locales/de_DE/settings.json b/frontend/src/locales/de_DE/settings.json index 97d17b276..c649e4ebc 100644 --- a/frontend/src/locales/de_DE/settings.json +++ b/frontend/src/locales/de_DE/settings.json @@ -334,11 +334,13 @@ "assets": "Saves & states", "collections": "Collections", "devices": "Devices", + "emulation": "Im Browser spielen", "firmware": "Firmware", "logs": "Logs", "platforms": "Platforms", "playlists": "Playlists", "roms": "Games", + "streaming": "Streaming", "tasks": "Tasks", "users": "Users" }, diff --git a/frontend/src/locales/en_GB/settings.json b/frontend/src/locales/en_GB/settings.json index 8b49ae774..b2a7068da 100644 --- a/frontend/src/locales/en_GB/settings.json +++ b/frontend/src/locales/en_GB/settings.json @@ -334,11 +334,13 @@ "assets": "Saves & states", "collections": "Collections", "devices": "Devices", + "emulation": "Play in browser", "firmware": "Firmware", "logs": "Logs", "platforms": "Platforms", "playlists": "Playlists", "roms": "Games", + "streaming": "Streaming", "tasks": "Tasks", "users": "Users" }, diff --git a/frontend/src/locales/en_US/settings.json b/frontend/src/locales/en_US/settings.json index cbe02145e..8af9cc4fc 100644 --- a/frontend/src/locales/en_US/settings.json +++ b/frontend/src/locales/en_US/settings.json @@ -334,11 +334,13 @@ "assets": "Saves & states", "collections": "Collections", "devices": "Devices", + "emulation": "Play in browser", "firmware": "Firmware", "logs": "Logs", "platforms": "Platforms", "playlists": "Playlists", "roms": "Games", + "streaming": "Streaming", "tasks": "Tasks", "users": "Users" }, diff --git a/frontend/src/locales/es_ES/settings.json b/frontend/src/locales/es_ES/settings.json index 136511619..fe87f1df2 100644 --- a/frontend/src/locales/es_ES/settings.json +++ b/frontend/src/locales/es_ES/settings.json @@ -334,11 +334,13 @@ "assets": "Saves & states", "collections": "Collections", "devices": "Devices", + "emulation": "Jugar en el navegador", "firmware": "Firmware", "logs": "Logs", "platforms": "Platforms", "playlists": "Playlists", "roms": "Games", + "streaming": "Streaming", "tasks": "Tasks", "users": "Users" }, diff --git a/frontend/src/locales/fr_FR/settings.json b/frontend/src/locales/fr_FR/settings.json index 21c4bbc20..c1f71e8d1 100644 --- a/frontend/src/locales/fr_FR/settings.json +++ b/frontend/src/locales/fr_FR/settings.json @@ -334,11 +334,13 @@ "assets": "Saves & states", "collections": "Collections", "devices": "Devices", + "emulation": "Jouer dans le navigateur", "firmware": "Firmware", "logs": "Logs", "platforms": "Platforms", "playlists": "Playlists", "roms": "Games", + "streaming": "Streaming", "tasks": "Tasks", "users": "Users" }, diff --git a/frontend/src/locales/hu_HU/settings.json b/frontend/src/locales/hu_HU/settings.json index e004bec2c..dc5287591 100644 --- a/frontend/src/locales/hu_HU/settings.json +++ b/frontend/src/locales/hu_HU/settings.json @@ -334,11 +334,13 @@ "assets": "Saves & states", "collections": "Collections", "devices": "Devices", + "emulation": "Játék a böngészőben", "firmware": "Firmware", "logs": "Logs", "platforms": "Platforms", "playlists": "Playlists", "roms": "Games", + "streaming": "Streaming", "tasks": "Tasks", "users": "Users" }, diff --git a/frontend/src/locales/it_IT/settings.json b/frontend/src/locales/it_IT/settings.json index 0945b86a5..c064a2ff7 100644 --- a/frontend/src/locales/it_IT/settings.json +++ b/frontend/src/locales/it_IT/settings.json @@ -334,11 +334,13 @@ "assets": "Salvataggi & stati", "collections": "Collezioni", "devices": "Dispositivi", + "emulation": "Gioca nel browser", "firmware": "Firmware", "logs": "Logs", "platforms": "Piattaforme", "playlists": "Playlist", "roms": "Giochi", + "streaming": "Streaming", "tasks": "Attività", "users": "Utenti" }, diff --git a/frontend/src/locales/ja_JP/settings.json b/frontend/src/locales/ja_JP/settings.json index 1881c34cc..c42bfbf3e 100644 --- a/frontend/src/locales/ja_JP/settings.json +++ b/frontend/src/locales/ja_JP/settings.json @@ -334,11 +334,13 @@ "assets": "Saves & states", "collections": "Collections", "devices": "Devices", + "emulation": "ブラウザでプレイ", "firmware": "Firmware", "logs": "Logs", "platforms": "Platforms", "playlists": "Playlists", "roms": "Games", + "streaming": "Streaming", "tasks": "Tasks", "users": "Users" }, diff --git a/frontend/src/locales/ko_KR/settings.json b/frontend/src/locales/ko_KR/settings.json index 91f2c5e35..7be8d6301 100644 --- a/frontend/src/locales/ko_KR/settings.json +++ b/frontend/src/locales/ko_KR/settings.json @@ -334,11 +334,13 @@ "assets": "Saves & states", "collections": "Collections", "devices": "Devices", + "emulation": "브라우저에서 플레이", "firmware": "Firmware", "logs": "Logs", "platforms": "Platforms", "playlists": "Playlists", "roms": "Games", + "streaming": "Streaming", "tasks": "Tasks", "users": "Users" }, diff --git a/frontend/src/locales/pl_PL/settings.json b/frontend/src/locales/pl_PL/settings.json index 2bccbe754..1ccf6dedd 100644 --- a/frontend/src/locales/pl_PL/settings.json +++ b/frontend/src/locales/pl_PL/settings.json @@ -334,11 +334,13 @@ "assets": "Saves & states", "collections": "Collections", "devices": "Devices", + "emulation": "Zagraj w przeglądarce", "firmware": "Firmware", "logs": "Logs", "platforms": "Platforms", "playlists": "Playlists", "roms": "Games", + "streaming": "Streaming", "tasks": "Tasks", "users": "Users" }, diff --git a/frontend/src/locales/pt_BR/settings.json b/frontend/src/locales/pt_BR/settings.json index 073199720..5c69fd1f1 100644 --- a/frontend/src/locales/pt_BR/settings.json +++ b/frontend/src/locales/pt_BR/settings.json @@ -334,11 +334,13 @@ "assets": "Saves e states", "collections": "Coleções", "devices": "Dispositivos", + "emulation": "Jogar no navegador", "firmware": "Firmware", "logs": "Logs", "platforms": "Plataformas", "playlists": "Listas de reprodução", "roms": "Jogos", + "streaming": "Streaming", "tasks": "Tarefas", "users": "Usuários" }, diff --git a/frontend/src/locales/ro_RO/settings.json b/frontend/src/locales/ro_RO/settings.json index 0c0dfe0f8..63bf2051d 100644 --- a/frontend/src/locales/ro_RO/settings.json +++ b/frontend/src/locales/ro_RO/settings.json @@ -334,11 +334,13 @@ "assets": "Saves & states", "collections": "Collections", "devices": "Devices", + "emulation": "Joacă în browser", "firmware": "Firmware", "logs": "Logs", "platforms": "Platforms", "playlists": "Playlists", "roms": "Games", + "streaming": "Streaming", "tasks": "Tasks", "users": "Users" }, diff --git a/frontend/src/locales/ru_RU/settings.json b/frontend/src/locales/ru_RU/settings.json index 12e3b4f60..b1ee14272 100644 --- a/frontend/src/locales/ru_RU/settings.json +++ b/frontend/src/locales/ru_RU/settings.json @@ -334,11 +334,13 @@ "assets": "Saves & states", "collections": "Collections", "devices": "Devices", + "emulation": "Играть в браузере", "firmware": "Firmware", "logs": "Logs", "platforms": "Platforms", "playlists": "Playlists", "roms": "Games", + "streaming": "Streaming", "tasks": "Tasks", "users": "Users" }, diff --git a/frontend/src/locales/tr_TR/settings.json b/frontend/src/locales/tr_TR/settings.json index de497b9df..e661ffa86 100644 --- a/frontend/src/locales/tr_TR/settings.json +++ b/frontend/src/locales/tr_TR/settings.json @@ -334,11 +334,13 @@ "assets": "Saves & states", "collections": "Collections", "devices": "Devices", + "emulation": "Tarayıcıda oyna", "firmware": "Firmware", "logs": "Logs", "platforms": "Platforms", "playlists": "Playlists", "roms": "Games", + "streaming": "Streaming", "tasks": "Tasks", "users": "Users" }, diff --git a/frontend/src/locales/zh_CN/settings.json b/frontend/src/locales/zh_CN/settings.json index 796f1ba6e..275cd257b 100644 --- a/frontend/src/locales/zh_CN/settings.json +++ b/frontend/src/locales/zh_CN/settings.json @@ -334,11 +334,13 @@ "assets": "存档与状态", "collections": "收藏", "devices": "设备", + "emulation": "在浏览器中游玩", "firmware": "固件", "logs": "日志", "platforms": "平台", "playlists": "播放列表", "roms": "游戏", + "streaming": "串流", "tasks": "任务", "users": "用户" }, diff --git a/frontend/src/locales/zh_TW/settings.json b/frontend/src/locales/zh_TW/settings.json index 18ef4cfa3..5bac68edf 100644 --- a/frontend/src/locales/zh_TW/settings.json +++ b/frontend/src/locales/zh_TW/settings.json @@ -334,11 +334,13 @@ "assets": "Saves & states", "collections": "Collections", "devices": "Devices", + "emulation": "在瀏覽器中遊玩", "firmware": "Firmware", "logs": "Logs", "platforms": "Platforms", "playlists": "Playlists", "roms": "Games", + "streaming": "Streaming", "tasks": "Tasks", "users": "Users" }, diff --git a/frontend/src/stores/permissions.ts b/frontend/src/stores/permissions.ts index 857c227a0..7b831946e 100644 --- a/frontend/src/stores/permissions.ts +++ b/frontend/src/stores/permissions.ts @@ -1,10 +1,10 @@ // permissions: Pinia store holding the current user's effective grants. // // Hydrated from the backend's `/permissions/me` (the source of truth) via -// `installPermissionsHydration()` in AppLayout, and refreshed live on the -// `permissions:changed` socket event. v2 features consume this store via -// `useCan(action, scope?)`. v1 does not import from here: its existing inline -// scope checks keep working. +// `installPermissionsHydration()` in each layout, and refreshed live on the +// `permissions:changed` socket event. Features consume this store via +// `useCan(action, scope?)`. v1 and console mode read only `rom.play` here; +// their other checks stay on scopes. import { defineStore } from "pinia"; import type { PermissionScopeSchema, diff --git a/frontend/src/stores/streaming.test.ts b/frontend/src/stores/streaming.test.ts index 8fc25ace0..ebacdff0e 100644 --- a/frontend/src/stores/streaming.test.ts +++ b/frontend/src/stores/streaming.test.ts @@ -143,6 +143,15 @@ describe("joinable sessions", () => { expect(store.joinableForRom(7)).toBeNull(); }); + it("asks for nothing once the config says streaming is off", async () => { + const store = useStreamingStore(); + store.configLoaded = true; + + await store.fetchJoinableSessions(true); + + expect(listJoinableSessions).not.toHaveBeenCalled(); + }); + it("keeps the last known list when a refresh fails", async () => { const store = useStreamingStore(); await store.fetchJoinableSessions(); diff --git a/frontend/src/stores/streaming.ts b/frontend/src/stores/streaming.ts index c10f5e2b0..68a5a7923 100644 --- a/frontend/src/stores/streaming.ts +++ b/frontend/src/stores/streaming.ts @@ -101,7 +101,14 @@ export const useStreamingStore = defineStore("streaming", () => { supportsDiscSwap: boolean; hasManualDiscSwap: boolean; } { - const caps = containerForPlatform(slug)?.capabilities; + return containerCapabilities(containerForPlatform(slug)); + } + + /** The same capability set for a container already in hand. */ + function containerCapabilities( + container: StreamingContainer | null | undefined, + ): ReturnType { + const caps = container?.capabilities; if (!caps) return { ...NO_CAPABILITIES }; return { maxSlots: caps.max_slots, @@ -214,6 +221,8 @@ export const useStreamingStore = defineStore("streaming", () => { * the user is about to act on. */ async function fetchJoinableSessions(force = false): Promise { + // Streaming is off for this user, so there is nothing to join. + if (configLoaded.value && !isEnabled.value) return; if (joinableRequest) return joinableRequest; if (!force && Date.now() - joinableFetchedAt < JOINABLE_MAX_AGE_MS) return; @@ -422,6 +431,7 @@ export const useStreamingStore = defineStore("streaming", () => { containerLabelForPlatform, emulatorLabel, platformCapabilities, + containerCapabilities, fetchConfig, reset, claimSession, diff --git a/frontend/src/v2/composables/useCan/index.ts b/frontend/src/v2/composables/useCan/index.ts index f1aaa8730..01508e21f 100644 --- a/frontend/src/v2/composables/useCan/index.ts +++ b/frontend/src/v2/composables/useCan/index.ts @@ -22,7 +22,8 @@ export type { ActionKey, Grant, PermissionScope }; /** Mount-time helper: keep permissionsStore in sync with the current user by * fetching /permissions/me on login and on the `permissions:changed` socket - * event. Call once high in the v2 tree (AppLayout): the watch is idempotent. */ + * event. Call once high in each layout (v2 AppLayout, v1 Main, console + * Layout): the watch is idempotent. */ export function installPermissionsHydration() { const auth = storeAuth(); const permissions = storePermissions(); diff --git a/frontend/src/v2/composables/useCanPlay/index.test.ts b/frontend/src/v2/composables/useCanPlay/index.test.ts index 5d7345243..45fc4daee 100644 --- a/frontend/src/v2/composables/useCanPlay/index.test.ts +++ b/frontend/src/v2/composables/useCanPlay/index.test.ts @@ -37,6 +37,12 @@ vi.mock("@/stores/streaming", () => ({ containerForPlatform: () => streamContainer.value, }), })); +const canStartStream = ref(true); +const canPlayInBrowser = ref(true); +vi.mock("@/v2/composables/useCan", () => ({ + useCan: (action: string) => + action === "rom.play" ? canPlayInBrowser : canStartStream, +})); vi.mock("@/stores/native", () => ({ useNativeStore: () => ({ isSupportedPlatform: () => nativeSupported.value, @@ -72,6 +78,8 @@ beforeEach(() => { support.ruffle.mockReturnValue(false); support.jsDosBundle.mockReturnValue(true); streamContainer.value = null; + canStartStream.value = true; + canPlayInBrowser.value = true; nativeSupported.value = false; }); @@ -162,6 +170,43 @@ describe("useCanPlay", () => { expect(canPlay.value).toBe(false); }); + it.each([ + ["EJS", "ejs", "canPlayEJS", []], + ["js-dos", "jsDos", "canPlayJsDos", []], + ["PICO-8", "pico8", "canPlayPico8", ["pico8Rom"]], + ["EasyRPG", "easyRpg", "canPlayEasyRpg", ["easyRpgGame"]], + ["Ruffle", "ruffle", "canPlayRuffle", []], + ] as const)( + "withholds %s without the in-browser play grant", + (_label, stub, flag, alsoRequires) => { + support[stub].mockReturnValue(true); + for (const extra of alsoRequires) support[extra].mockReturnValue(true); + canPlayInBrowser.value = false; + const result = useCanPlay(() => playableRom()); + + expect(result[flag].value).toBe(false); + expect(result.canPlay.value).toBe(false); + }, + ); + + it("still streams without the in-browser play grant", () => { + streamContainer.value = {}; + canPlayInBrowser.value = false; + const { canPlay, canPlayStream } = useCanPlay(() => playableRom()); + + expect(canPlayStream.value).toBe(true); + expect(canPlay.value).toBe(true); + }); + + it("reaches a stream it may not start", () => { + streamContainer.value = {}; + canStartStream.value = false; + const { canReachStream, canPlayStream } = useCanPlay(() => playableRom()); + + expect(canReachStream.value).toBe(true); + expect(canPlayStream.value).toBe(false); + }); + // The desktop shell runs a locally installed emulator, so it makes a rom // playable on its own even where nothing on the server can touch it. it("reports native support on its own flag", () => { diff --git a/frontend/src/v2/composables/useCanPlay/index.ts b/frontend/src/v2/composables/useCanPlay/index.ts index 1ab4ea6d3..a6e114e29 100644 --- a/frontend/src/v2/composables/useCanPlay/index.ts +++ b/frontend/src/v2/composables/useCanPlay/index.ts @@ -29,6 +29,7 @@ import { isPico8Rom, isRuffleEmulationSupported, } from "@/utils"; +import { useCan } from "@/v2/composables/useCan"; export function useCanPlay(getRom: () => SimpleRom | null | undefined): { canPlay: ComputedRef; @@ -37,6 +38,7 @@ export function useCanPlay(getRom: () => SimpleRom | null | undefined): { canPlayPico8: ComputedRef; canPlayEasyRpg: ComputedRef; canPlayRuffle: ComputedRef; + canReachStream: ComputedRef; canPlayStream: ComputedRef; canPlayNative: ComputedRef; } { @@ -46,10 +48,12 @@ export function useCanPlay(getRom: () => SimpleRom | null | undefined): { const nativeStore = useNativeStore(); const { value: heartbeat } = storeToRefs(heartbeatStore); + // The emulation read grant covers every in-browser engine. + const canPlayInBrowser = useCan("rom.play"); const supportedBy = (check: typeof isEJSEmulationSupported) => computed(() => { const rom = getRom(); - if (!rom?.has_file_on_disk) return false; + if (!canPlayInBrowser.value || !rom?.has_file_on_disk) return false; return check(rom.platform_slug, heartbeat.value, configStore.config); }); @@ -75,11 +79,16 @@ export function useCanPlay(getRom: () => SimpleRom | null | undefined): { // The broker is handed the ROM file, so a physical game or one missing // from the filesystem has nothing to stream any more than it has to boot. - const canPlayStream = computed(() => { + // Reaching the stream is enough to join one; starting one takes a grant. + const canReachStream = computed(() => { const rom = getRom(); if (!rom?.has_file_on_disk) return false; return streamingStore.containerForPlatform(rom.platform_slug) !== null; }); + const canStartStream = useCan("stream.start"); + const canPlayStream = computed( + () => canReachStream.value && canStartStream.value, + ); // The shell either downloads the file or reads it off disk, so the same // "something to boot" rule applies. @@ -107,6 +116,7 @@ export function useCanPlay(getRom: () => SimpleRom | null | undefined): { canPlayPico8, canPlayEasyRpg, canPlayRuffle, + canReachStream, canPlayStream, canPlayNative, }; diff --git a/frontend/src/v2/composables/useGameActions/index.test.ts b/frontend/src/v2/composables/useGameActions/index.test.ts index 87eefc163..ac7afc252 100644 --- a/frontend/src/v2/composables/useGameActions/index.test.ts +++ b/frontend/src/v2/composables/useGameActions/index.test.ts @@ -119,13 +119,22 @@ vi.mock("@/v2/composables/useCanPlay", () => ({ canPlayEasyRpg, canPlayRuffle, canPlayNative, - canPlayStream: { + canReachStream: { get value() { return ( Boolean(getRom()?.has_file_on_disk) && streamContainer.value !== null ); }, }, + canPlayStream: { + get value() { + return ( + Boolean(getRom()?.has_file_on_disk) && + streamContainer.value !== null && + (grantedActions.value?.has("stream.start") ?? true) + ); + }, + }, }), })); vi.mock("@/v2/composables/useClipboard", () => ({ @@ -493,6 +502,17 @@ describe("useGameActions.play — launch confirmation", () => { expect(actions.canPlayStream.value).toBe(false); expect(actions.canDownload.value).toBe(false); }); + + it("still offers Join without the grant to start a stream", () => { + grantedActions.value = new Set(["rom.view"]); + streamContainer.value = { host: "http://stream" }; + joinableSession.value = { host_username: "ada" }; + const actions = useGameActions(() => romWithStatus()); + + expect(actions.canPlayStream.value).toBe(false); + expect(actions.canJoinStream.value).toBe(true); + expect(actions.playPath("stream")).toBeNull(); + }); }); describe("useGameActions.needsLaunchConfirm", () => { diff --git a/frontend/src/v2/composables/useGameActions/index.ts b/frontend/src/v2/composables/useGameActions/index.ts index cf1ffddca..a41febab0 100644 --- a/frontend/src/v2/composables/useGameActions/index.ts +++ b/frontend/src/v2/composables/useGameActions/index.ts @@ -95,6 +95,7 @@ export function useGameActions( canPlayPico8, canPlayEasyRpg, canPlayRuffle, + canReachStream, canPlayStream, canPlayNative, } = useCanPlay(getRom); @@ -142,7 +143,7 @@ export function useGameActions( // and holds the answer for a freshness window, so a gallery of cards costs // what a single card costs. watch( - canPlayStream, + canReachStream, (can) => { if (can) void streamingStore.fetchJoinableSessions(); }, @@ -159,7 +160,7 @@ export function useGameActions( }); const canJoinStream = computed( - () => canPlayStream.value && joinableSession.value !== null, + () => canReachStream.value && joinableSession.value !== null, ); const joinHostLabel = computed( diff --git a/frontend/src/v2/composables/usePlatformPlayable/index.test.ts b/frontend/src/v2/composables/usePlatformPlayable/index.test.ts index 7d3253e33..9b236db0c 100644 --- a/frontend/src/v2/composables/usePlatformPlayable/index.test.ts +++ b/frontend/src/v2/composables/usePlatformPlayable/index.test.ts @@ -15,6 +15,7 @@ const pico8Slugs = new Set(); const easyRpgSlugs = new Set(); const streamContainers = new Map(); const streamingEnabled = { value: true }; +const canPlayInBrowser = { value: true }; vi.mock("@/utils", () => ({ isEJSEmulationSupported: (slug: string) => ejsSlugs.has(slug), @@ -69,6 +70,9 @@ vi.mock("pinia", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, storeToRefs: (store: unknown) => store }; }); +vi.mock("@/v2/composables/useCan", () => ({ + useCan: () => canPlayInBrowser, +})); vi.mock("@/locales", () => ({ default: { global: { @@ -88,6 +92,7 @@ beforeEach(() => { easyRpgSlugs.clear(); streamContainers.clear(); streamingEnabled.value = true; + canPlayInBrowser.value = true; }); describe("usePlatformPlayable", () => { @@ -145,6 +150,19 @@ describe("usePlatformPlayable", () => { expect(streamLabel.value).toBeNull(); expect(mode.value).toBeNull(); }); + + it("drops the browser engine without the in-browser play grant", () => { + canPlayInBrowser.value = false; + ejsSlugs.add("psx"); + streamContainers.set("psx", { + label: "DuckStation", + emulator: "duckstation", + }); + const { mode, emulator, playable } = usePlatformPlayable(() => "psx"); + expect(emulator.value).toBeNull(); + expect(playable.value).toBe(false); + expect(mode.value).toBe("stream"); + }); }); describe("usePlatformPlayableChecker", () => { @@ -186,6 +204,14 @@ describe("usePlatformPlayableChecker", () => { const { isStreamable } = usePlatformPlayableChecker(); expect(isStreamable.value("ps2")).toBe(false); }); + + it("reports nothing playable without the in-browser play grant", () => { + canPlayInBrowser.value = false; + ejsSlugs.add("snes"); + const { isPlayable, getEmulator } = usePlatformPlayableChecker(); + expect(isPlayable.value("snes")).toBe(false); + expect(getEmulator.value("snes")).toBeNull(); + }); }); describe("playTooltip", () => { diff --git a/frontend/src/v2/composables/usePlatformPlayable/index.ts b/frontend/src/v2/composables/usePlatformPlayable/index.ts index a2281ca68..9c7faa903 100644 --- a/frontend/src/v2/composables/usePlatformPlayable/index.ts +++ b/frontend/src/v2/composables/usePlatformPlayable/index.ts @@ -12,7 +12,8 @@ // 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. +// everything else playable, and `null` when nothing on the server can run it +// or the user lacks the in-browser play grant. // // `mode` folds the in-browser answer together with streaming: a platform // served by a configured streaming container is playable too, just not in @@ -34,6 +35,7 @@ import { isRuffleEmulationSupported, resolvePlatformSlug, } from "@/utils"; +import { useCan } from "@/v2/composables/useCan"; export type PlatformEmulator = "emulatorjs" | "ruffle" | "jsdos" | "pico8" | "easyrpg" | "dosbox" | null; @@ -77,8 +79,11 @@ export function usePlatformPlayable(getSlug: () => string | null | undefined): { const streamingStore = useStreamingStore(); const { value: heartbeat } = storeToRefs(heartbeatStore); + const canPlayInBrowser = useCan("rom.play"); const emulator = computed(() => - resolveEmulator(getSlug(), heartbeat.value, configStore.config), + canPlayInBrowser.value + ? resolveEmulator(getSlug(), heartbeat.value, configStore.config) + : null, ); const playable = computed(() => emulator.value !== null); @@ -119,11 +124,13 @@ 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 canPlayInBrowser = useCan("rom.play"); const getEmulator = computed(() => { const hb = heartbeat.value; const cfg = configStore.config; + const allowed = canPlayInBrowser.value; return (slug: string | null | undefined): PlatformEmulator => - resolveEmulator(slug, hb, cfg); + allowed ? resolveEmulator(slug, hb, cfg) : null; }); const isPlayable = computed(() => { diff --git a/frontend/src/v2/views/Player/Stream.test.ts b/frontend/src/v2/views/Player/Stream.test.ts index 3ec5eee40..428242f0b 100644 --- a/frontend/src/v2/views/Player/Stream.test.ts +++ b/frontend/src/v2/views/Player/Stream.test.ts @@ -27,6 +27,8 @@ const mocks = vi.hoisted(() => ({ setVolume: vi.fn(), container: null as Record | null, capabilities: {} as Record, + // Reactive once the store mock loads, so a test can turn streaming off. + streamingOff: { value: false }, presenceTick: null as (() => Promise) | null, playSession: { start: vi.fn(), flush: vi.fn() }, socketHandlers: {} as Record unknown>, @@ -78,24 +80,30 @@ vi.mock("@/v2/stores/galleryRoms", () => ({ default: () => ({ getRomById: () => null }), })); -vi.mock("@/stores/streaming", () => ({ - useStreamingStore: () => ({ - claimSession: mocks.claimSession, - containerForPlatform: () => mocks.container, - emulatorLabel: (emulator: string) => - emulator === "duckstation" ? "DuckStation" : emulator, - platformCapabilities: () => mocks.capabilities, - fetchConfig: mocks.fetchConfig, - fetchSessionStatus: mocks.fetchSessionStatus, - forgetJoinableSession: vi.fn(), - heartbeatSession: mocks.heartbeatSession, - joinSession: mocks.joinSession, - releaseSession: mocks.releaseSession, - releaseSessionKeepalive: mocks.releaseSessionKeepalive, - saveAndExit: mocks.saveAndExit, - saveAndExitKeepalive: mocks.saveAndExitKeepalive, - }), -})); +vi.mock("@/stores/streaming", async () => { + const { ref } = await import("vue"); + mocks.streamingOff = ref(false); + return { + useStreamingStore: () => ({ + claimSession: mocks.claimSession, + containerForPlatform: () => + mocks.streamingOff.value ? null : mocks.container, + containerCapabilities: (container: unknown) => + container ? mocks.capabilities : {}, + emulatorLabel: (emulator: string) => + emulator === "duckstation" ? "DuckStation" : emulator, + fetchConfig: mocks.fetchConfig, + fetchSessionStatus: mocks.fetchSessionStatus, + forgetJoinableSession: vi.fn(), + heartbeatSession: mocks.heartbeatSession, + joinSession: mocks.joinSession, + releaseSession: mocks.releaseSession, + releaseSessionKeepalive: mocks.releaseSessionKeepalive, + saveAndExit: mocks.saveAndExit, + saveAndExitKeepalive: mocks.saveAndExitKeepalive, + }), + }; +}); vi.mock("@/v2/composables/useActivityPresence", () => ({ useActivityPresence: (_rom: unknown, tick: () => Promise) => { @@ -980,6 +988,7 @@ describe("Stream state controls", () => { afterEach(() => { mocks.capabilities = {}; + mocks.streamingOff.value = false; }); async function barIcons(liveStates: boolean): Promise { @@ -996,6 +1005,18 @@ describe("Stream state controls", () => { expect(icons).toContain("mdi-restore"); }); + it("keeps the controls when streaming is revoked mid-game", async () => { + const wrapper = await launch({ picker: false }); + await vmOf(wrapper).onPlay(); + await launchReady(); + mocks.streamingOff.value = true; + await flushPromises(); + + const icons = wrapper.findAllComponents(RBtn).map((b) => b.props("icon")); + expect(icons).toContain("mdi-content-save-outline"); + expect(icons).toContain("mdi-restore"); + }); + it("offers neither where the emulator writes its state only on exit", async () => { // DuckStation and RPCS3 keep an autosave slot for their state library, // so that slot alone must not bring the buttons back. diff --git a/frontend/src/v2/views/Player/Stream.vue b/frontend/src/v2/views/Player/Stream.vue index d738b066c..4d36349f0 100644 --- a/frontend/src/v2/views/Player/Stream.vue +++ b/frontend/src/v2/views/Player/Stream.vue @@ -35,6 +35,7 @@ import { onBeforeUnmount, onMounted, ref, + shallowRef, watch, } from "vue"; import { useI18n } from "vue-i18n"; @@ -50,6 +51,7 @@ import streamingApi, { type LaunchReady, type MemoryCardImport, type MemoryCardImportDetail, + type StreamingContainer, } from "@/services/api/streaming"; import storeAuth from "@/stores/auth"; import storeRoms, { type DetailedRom, type SimpleRom } from "@/stores/roms"; @@ -239,14 +241,19 @@ watch(playerState, (state) => setBgArt(state === "playing" ? null : bgCoverUrl.value), ); -const container = computed(() => - rom.value - ? streamingStore.containerForPlatform(rom.value.platform_slug) - : null, +// Pinned at claim, so a permission refresh that turns streaming off mid-game +// leaves the session its controls until it ends. +const heldContainer = shallowRef(null); +const container = computed( + () => + heldContainer.value ?? + (rom.value + ? streamingStore.containerForPlatform(rom.value.platform_slug) + : null), ); const capabilities = computed(() => - streamingStore.platformCapabilities(rom.value?.platform_slug), + streamingStore.containerCapabilities(container.value), ); // Emulators that keep saves in the emulated filesystem rather than in @@ -729,6 +736,7 @@ const claimedAt = ref(null); function forgetClaim(): void { holdsClaim.value = false; + heldContainer.value = null; claimedContainer.value = null; claimedAt.value = null; } @@ -957,6 +965,7 @@ async function onPlay(cardImport?: MemoryCardImport): Promise { multiplayerOnPlay.value, ); claimedContainer.value = launching.container; + heldContainer.value = container.value; claimedAt.value = launching.claimed_at; holdsClaim.value = true; // Every exit path ran before there was a claim to hand back.