mirror of
https://github.com/rommapp/romm.git
synced 2026-10-11 22:40:04 +00:00
feat(permissions): gate emulator streaming and in-browser play behind group permissions (#5185)
* feat(permissions): gate emulator streaming behind a group permission Add a streaming permission entity. Read shows streaming and allows joining a session; write allows starting one. Users without read get a disabled streaming config, so every streaming surface hides. Migration 0152 grants read and write to every existing group, so nobody loses access on upgrade. Groups created afterwards start without it. Owner-scoped session controls (save, exit, release) stay ungated so a revoked user can still leave a running game without losing saves. * fix(streaming): pr-ready code-review fixes for the streaming grant Own-only read no longer joins other users' sessions, the config refetches on permissions:changed, the store skips the joinable request when streaming is off, and tests clean up the groups they create. * refactor(streaming): pr-ready simplify pass on the streaming grant Collapse the streaming permission helpers onto assert_can/can_access, return an empty joinable list instead of 403, skip the permission resolve when streaming is off, and add a stream.start action key so the UI hides Stream (but keeps Join) for users without the write grant. * docs(streaming): pr-ready review-polish pass on the streaming grant Trim the 0152 migration docstring to a single summary line, matching 0150 and 0151. * docs(streaming): pr-ready humanizer pass on the streaming grant Plainer wording for the stream.start action key comment and the Permission Groups paragraph, which now says an own-only read shows streaming without the join. * feat(permissions): gate in-browser emulation behind a group permission Adds an emulation entity next to streaming. Read plays in the browser (EmulatorJS, Ruffle, js-dos, PICO-8, EasyRPG) and joins netplay rooms, write hosts them. Migration 0152 now backfills both entities onto every existing group. * fix(streaming): address Greptile review on PR 5185 - Pin the claimed container in the stream player, so a permission refresh that turns streaming off mid-game keeps save, load and disc swap until the session ends. - Empty the Join list when a refreshed config turns streaming off, and discard any joinable request still in flight. - Update the installPermissionsHydration doc to name every layout that calls it. * fix(migrations): renumber play permissions migration to 0153 after master's 0152 Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> * chore(permissions): drop redundant PermEntity comments Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> * refactor(streaming): drop the live config refresh on permission changes Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> --------- Co-authored-by: Georges-Antoine Assi <[email protected]> Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5.5
Georges-Antoine Assi
parent
0a54359ab4
commit
1f6b4d11ca
@@ -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)))
|
||||
@@ -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] = {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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] = []
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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?
|
||||
|
||||
|
||||
@@ -40,6 +40,8 @@ class PermEntity(enum.StrEnum):
|
||||
USERS = "users"
|
||||
TASKS = "tasks"
|
||||
LOGS = "logs"
|
||||
STREAMING = "streaming"
|
||||
EMULATION = "emulation"
|
||||
|
||||
|
||||
class PermAction(enum.StrEnum):
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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() == {}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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`)
|
||||
|
||||
+1
-1
@@ -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';
|
||||
|
||||
+1
-1
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<HTMLDivElement>(
|
||||
"description-overlay-ref",
|
||||
);
|
||||
const detailsOverlayRef = useTemplateRef<HTMLDivElement>("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(() => {
|
||||
|
||||
@@ -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<Events>>("emitter");
|
||||
emitter?.on("refreshDrawer", async () => {
|
||||
platformsStore.fetchPlatforms();
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -334,11 +334,13 @@
|
||||
"assets": "存档与状态",
|
||||
"collections": "收藏",
|
||||
"devices": "设备",
|
||||
"emulation": "在浏览器中游玩",
|
||||
"firmware": "固件",
|
||||
"logs": "日志",
|
||||
"platforms": "平台",
|
||||
"playlists": "播放列表",
|
||||
"roms": "游戏",
|
||||
"streaming": "串流",
|
||||
"tasks": "任务",
|
||||
"users": "用户"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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<typeof platformCapabilities> {
|
||||
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<void> {
|
||||
// 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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
isPico8Rom,
|
||||
isRuffleEmulationSupported,
|
||||
} from "@/utils";
|
||||
import { useCan } from "@/v2/composables/useCan";
|
||||
|
||||
export function useCanPlay(getRom: () => SimpleRom | null | undefined): {
|
||||
canPlay: ComputedRef<boolean>;
|
||||
@@ -37,6 +38,7 @@ export function useCanPlay(getRom: () => SimpleRom | null | undefined): {
|
||||
canPlayPico8: ComputedRef<boolean>;
|
||||
canPlayEasyRpg: ComputedRef<boolean>;
|
||||
canPlayRuffle: ComputedRef<boolean>;
|
||||
canReachStream: ComputedRef<boolean>;
|
||||
canPlayStream: ComputedRef<boolean>;
|
||||
canPlayNative: ComputedRef<boolean>;
|
||||
} {
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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<ActionKey>(["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", () => {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -15,6 +15,7 @@ const pico8Slugs = new Set<string>();
|
||||
const easyRpgSlugs = new Set<string>();
|
||||
const streamContainers = new Map<string, { label: string; emulator: string }>();
|
||||
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<typeof import("pinia")>();
|
||||
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", () => {
|
||||
|
||||
@@ -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<PlatformEmulator>(() =>
|
||||
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(() => {
|
||||
|
||||
@@ -27,6 +27,8 @@ const mocks = vi.hoisted(() => ({
|
||||
setVolume: vi.fn(),
|
||||
container: null as Record<string, unknown> | null,
|
||||
capabilities: {} as Record<string, unknown>,
|
||||
// Reactive once the store mock loads, so a test can turn streaming off.
|
||||
streamingOff: { value: false },
|
||||
presenceTick: null as (() => Promise<void>) | null,
|
||||
playSession: { start: vi.fn(), flush: vi.fn() },
|
||||
socketHandlers: {} as Record<string, (payload: unknown) => 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<void>) => {
|
||||
@@ -980,6 +988,7 @@ describe("Stream state controls", () => {
|
||||
|
||||
afterEach(() => {
|
||||
mocks.capabilities = {};
|
||||
mocks.streamingOff.value = false;
|
||||
});
|
||||
|
||||
async function barIcons(liveStates: boolean): Promise<unknown[]> {
|
||||
@@ -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.
|
||||
|
||||
@@ -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<StreamingContainer | null>(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<string | null>(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<void> {
|
||||
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.
|
||||
|
||||
Reference in New Issue
Block a user