feat(devices): push downloads from the web UI to a device's download queue

This commit is contained in:
nendo
2026-09-28 02:08:03 +09:00
parent 23191407c1
commit f0c49b6a38
109 changed files with 4785 additions and 148 deletions
@@ -0,0 +1,29 @@
"""Record the capabilities a device sends when it registers or updates itself.
Revision ID: 0141_device_capabilities
Revises: 0140_device_save_sync_baseline
Create Date: 2026-09-27 00:00:00.000000
"""
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision = "0141_device_capabilities"
down_revision = "0140_device_save_sync_baseline"
branch_labels = None
depends_on = None
def upgrade() -> None:
with op.batch_alter_table("devices", schema=None) as batch_op:
batch_op.add_column(
sa.Column("capabilities", sa.JSON(), nullable=True),
if_not_exists=True,
)
def downgrade() -> None:
with op.batch_alter_table("devices", schema=None) as batch_op:
batch_op.drop_column("capabilities", if_exists=True)
+17
View File
@@ -353,6 +353,23 @@ SYNC_RETROARCH_PSP_SERIAL_MAP: Final[dict[str, str]] = json.loads(
_get_env("SYNC_RETROARCH_PSP_SERIAL_MAP", "{}")
)
# DEVICE INSTALL
DEVICE_INSTALL_ENABLED: Final[bool] = safe_str_to_bool(
_get_env("DEVICE_INSTALL_ENABLED", "true")
)
# Days an unfinished install request lives after its last change; 0 or less never expires.
DEVICE_INSTALL_REQUEST_TTL_DAYS: Final[int] = safe_int(
_get_env("DEVICE_INSTALL_REQUEST_TTL_DAYS"), 2
)
# Platforms whose roms cannot be pushed to a device for install.
DEVICE_INSTALL_EXCLUDED_PLATFORM_SLUGS: Final[frozenset[str]] = frozenset(
slug.strip().lower()
for slug in _get_env(
"DEVICE_INSTALL_EXCLUDED_PLATFORM_SLUGS", "win,win3x,win9x,windows-apps"
).split(",")
if slug.strip()
)
# EMULATION
DISABLE_EMULATOR_JS: Final[bool] = safe_str_to_bool(_get_env("DISABLE_EMULATOR_JS"))
DISABLE_RUFFLE_RS: Final[bool] = safe_str_to_bool(_get_env("DISABLE_RUFFLE_RS"))
+11 -5
View File
@@ -15,6 +15,7 @@ from handler.auth import auth_handler
from handler.auth.constants import Scope
from handler.database import db_client_token_handler
from handler.redis_handler import sync_cache
from handler.socket_handler import close_client_token_sockets
from models.audit_event import AuditAction, AuditTargetType
from models.client_token import ClientToken
from utils.client_tokens import (
@@ -94,7 +95,7 @@ def list_tokens(request: Request) -> list[ClientTokenSchema]:
@protected_route(router.delete, "/{token_id}", [Scope.ME_WRITE])
def delete_token(request: Request, token_id: int) -> None:
async def delete_token(request: Request, token_id: int) -> None:
token = db_client_token_handler.get_token(
token_id=token_id, user_id=request.user.id
)
@@ -107,10 +108,11 @@ def delete_token(request: Request, token_id: int) -> None:
detail="Token not found",
)
_record_token(request, AuditAction.CLIENT_TOKEN_REVOKE, token)
await close_client_token_sockets([token_id])
@protected_route(router.put, "/{token_id}/regenerate", [Scope.ME_WRITE])
def regenerate_token(request: Request, token_id: int) -> ClientTokenCreateSchema:
async def regenerate_token(request: Request, token_id: int) -> ClientTokenCreateSchema:
raw_token = auth_handler.generate_client_token()
new_hash = auth_handler.hash_client_token(raw_token)
@@ -125,6 +127,7 @@ def regenerate_token(request: Request, token_id: int) -> ClientTokenCreateSchema
detail="Token not found",
)
_record_token(request, AuditAction.CLIENT_TOKEN_REGENERATE, token)
await close_client_token_sockets([token_id])
return build_create_schema(token, raw_token)
@@ -161,11 +164,13 @@ def pair_status(code: str) -> None:
@router.post("/exchange")
def exchange_pair_code(
async def exchange_pair_code(
request: Request,
payload: ClientTokenExchangePayload,
) -> ClientTokenCreateSchema:
return exchange(request, payload.code)
token = exchange(request, payload.code)
await close_client_token_sockets([token.id])
return token
@protected_route(router.get, "/all", [Scope.USERS_READ])
@@ -175,7 +180,7 @@ def list_all_tokens(request: Request) -> list[ClientTokenAdminSchema]:
@protected_route(router.delete, "/{token_id}/admin", [Scope.USERS_WRITE])
def admin_delete_token(request: Request, token_id: int) -> None:
async def admin_delete_token(request: Request, token_id: int) -> None:
token = db_client_token_handler.get_token(token_id=token_id)
rows = db_client_token_handler.delete_token(token_id=token_id)
if rows == 0 or token is None:
@@ -184,3 +189,4 @@ def admin_delete_token(request: Request, token_id: int) -> None:
detail="Token not found",
)
_record_token(request, AuditAction.CLIENT_TOKEN_REVOKE, token)
await close_client_token_sockets([token_id])
+44 -4
View File
@@ -1,15 +1,28 @@
import uuid
from datetime import datetime, timezone
from typing import Any
from typing import Annotated, Any
from fastapi import HTTPException, Request, Response, status
from pydantic import BaseModel, model_validator
from pydantic import (
BaseModel,
Field,
StrictBool,
StringConstraints,
model_validator,
)
from decorators.auth import protected_route
from endpoints.responses.device import DeviceCreateResponse, DeviceSchema
from endpoints.sockets.devices import online_device_ids
from handler.auth.constants import Scope
from handler.database import db_device_handler, db_device_save_sync_handler
from handler.database import (
db_client_token_handler,
db_device_handler,
db_device_save_sync_handler,
)
from handler.device_install_handler import device_install_handler
from handler.filesystem import get_fs_sync_handler
from handler.socket_handler import close_client_token_sockets
from logger.logger import log
from models.device import Device, SyncMode
from utils.router import APIRouter
@@ -19,6 +32,12 @@ router = APIRouter(
tags=["devices"],
)
# Flags a device reports about itself, such as {"remote_install": true}.
DeviceCapabilities = Annotated[
dict[Annotated[str, StringConstraints(min_length=1, max_length=64)], StrictBool],
Field(max_length=32),
]
class DeviceCreatePayload(BaseModel):
name: str | None = None
@@ -30,6 +49,7 @@ class DeviceCreatePayload(BaseModel):
hostname: str | None = None
sync_mode: SyncMode | None = None
sync_config: dict[str, Any] | None = None
capabilities: DeviceCapabilities | None = None
allow_existing: bool = True
allow_duplicate: bool = False
reset_syncs: bool = False
@@ -52,6 +72,7 @@ class DeviceUpdatePayload(BaseModel):
sync_enabled: bool | None = None
sync_mode: SyncMode | None = None
sync_config: dict[str, Any] | None = None
capabilities: DeviceCapabilities | None = None
@protected_route(router.post, "", [Scope.DEVICES_WRITE])
@@ -88,6 +109,12 @@ def register_device(
db_device_handler.update_last_seen(
device_id=existing_device.id, user_id=request.user.id
)
if payload.capabilities is not None:
db_device_handler.update_device(
device_id=existing_device.id,
user_id=request.user.id,
data={"capabilities": payload.capabilities},
)
log.info(
f"Returned existing device {existing_device.id} for user {request.user.username}"
)
@@ -115,6 +142,7 @@ def register_device(
hostname=payload.hostname,
sync_mode=payload.sync_mode,
sync_config=payload.sync_config,
capabilities=payload.capabilities,
last_seen=now,
)
@@ -141,6 +169,15 @@ def get_devices(request: Request) -> list[DeviceSchema]:
return [DeviceSchema.model_validate(device) for device in devices]
@protected_route(router.get, "/online", [Scope.DEVICES_READ])
async def get_online_devices(request: Request) -> list[str]:
"""Ids of the caller's devices holding an open device socket."""
device_ids = [
device.id for device in db_device_handler.get_devices(user_id=request.user.id)
]
return await online_device_ids(device_ids)
@protected_route(router.get, "/{device_id}", [Scope.DEVICES_READ])
def get_device(request: Request, device_id: str) -> DeviceSchema:
device = db_device_handler.get_device(device_id=device_id, user_id=request.user.id)
@@ -182,7 +219,7 @@ def update_device(
[Scope.DEVICES_WRITE],
status_code=status.HTTP_204_NO_CONTENT,
)
def delete_device(request: Request, device_id: str) -> None:
async def delete_device(request: Request, device_id: str) -> None:
device = db_device_handler.get_device(device_id=device_id, user_id=request.user.id)
if not device:
raise HTTPException(
@@ -190,5 +227,8 @@ def delete_device(request: Request, device_id: str) -> None:
detail=f"Device with ID {device_id} not found",
)
token_ids = db_client_token_handler.get_token_ids_by_device(device_id)
db_device_handler.delete_device(device_id=device_id, user_id=request.user.id)
log.info(f"Deleted device {device_id} for user {request.user.username}")
await close_client_token_sockets(token_ids)
await device_install_handler.discard_for_device(device_id)
+317
View File
@@ -0,0 +1,317 @@
"""Install requests: the web UI queues and cancels them, the device claims and reports them."""
from collections.abc import Iterator
from contextlib import contextmanager
from typing import Annotated, Final
from fastapi import HTTPException
from fastapi import Path as PathVar
from fastapi import Request, Response, status
from pydantic import BaseModel, Field, field_validator
from config import DEVICE_INSTALL_ENABLED
from decorators.auth import protected_route
from endpoints.responses.device_install import (
InstallReason,
InstallRequestSchema,
InstallStatus,
)
from endpoints.sockets.devices import (
emit_install_cancelled,
emit_install_queued,
emit_install_updated,
)
from exceptions.endpoint_exceptions import (
DeviceInstallDisabledException,
RomNotFoundInDatabaseException,
)
from handler.auth.constants import Scope
from handler.auth.dependencies import assert_rom_visible
from handler.database import db_device_handler, db_rom_handler
from handler.device_install_handler import (
InstallTransitionError,
device_install_handler,
)
from handler.device_install_policy import (
InstallNotAllowedError,
accepts_remote_install,
is_installable_platform,
select_install_files,
)
from handler.notification_handler import notify
from logger.logger import log
from models.device import Device
from models.notification import NotificationKind, NotificationLevel
from utils.router import APIRouter
router = APIRouter(
prefix="/devices",
tags=["devices"],
)
DEVICE_REPORTS: Final = frozenset(
{InstallStatus.DONE, InstallStatus.ALREADY_INSTALLED, InstallStatus.FAILED}
)
class InstallRequestCreatePayload(BaseModel):
rom_id: int = Field(ge=1)
class InstallRequestUpdatePayload(BaseModel):
status: InstallStatus
reason: InstallReason | None = None
@field_validator("status")
@classmethod
def _reportable(cls, value: InstallStatus) -> InstallStatus:
if value not in DEVICE_REPORTS:
raise ValueError(
f"A device reports one of {', '.join(sorted(DEVICE_REPORTS))}"
)
return value
def _require_device(
request: Request, device_id: str, device_only: bool = False
) -> Device:
"""The caller's device, 404 when not theirs and 403 under ``device_only`` for another caller."""
bound_device_id = getattr(request.state, "device_id", None)
owned = db_device_handler.get_device(device_id=device_id, user_id=request.user.id)
if owned is None or bound_device_id not in (None, device_id):
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Device with ID {device_id} not found",
)
if device_only and bound_device_id != device_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only the device itself can do this",
)
return owned
async def _require_request(request: Request, device_id: str, request_id: str) -> None:
install = await device_install_handler.get(request_id)
if (
install is None
or install.device_id != device_id
or install.user_id != request.user.id
):
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Install request {request_id} not found",
)
@contextmanager
def _install_errors() -> Iterator[None]:
"""A gone request as a 404, a request in the wrong status as a 409."""
try:
yield
except KeyError as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Install request {exc.args[0]} not found",
) from exc
except InstallTransitionError as exc:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT, detail=str(exc)
) from exc
async def _notify_outcome(install: InstallRequestSchema, device: Device) -> None:
rom = db_rom_handler.get_rom_visibility_label(install.rom_id)
failed = install.status == InstallStatus.FAILED
await notify(
install.user_id,
(
NotificationKind.DEVICE_INSTALL_FAILED
if failed
else NotificationKind.DEVICE_INSTALL_COMPLETED
),
NotificationLevel.ERROR if failed else NotificationLevel.SUCCESS,
{
"rom_id": install.rom_id,
"rom_name": (rom.name or rom.fs_name) if rom else None,
"device_name": device.name or device.hostname,
"status": install.status,
"reason": install.reason,
},
)
@protected_route(
router.post,
"/{device_id}/installs",
[Scope.DEVICES_WRITE, Scope.ROMS_READ],
status_code=status.HTTP_201_CREATED,
responses={
status.HTTP_200_OK: {"model": InstallRequestSchema},
status.HTTP_400_BAD_REQUEST: {},
status.HTTP_404_NOT_FOUND: {},
status.HTTP_409_CONFLICT: {},
},
)
async def create_install_request(
request: Request,
response: Response,
device_id: Annotated[str, PathVar(description="Target device id.")],
payload: InstallRequestCreatePayload,
) -> InstallRequestSchema:
"""Queue a rom for install on a device, returning a live request for the same pair with 200."""
if not DEVICE_INSTALL_ENABLED:
raise DeviceInstallDisabledException()
device = _require_device(request, device_id)
if not accepts_remote_install(device.capabilities):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Device {device_id} does not accept remote installs",
)
rom = db_rom_handler.get_rom_install_target(payload.rom_id)
if not rom:
raise RomNotFoundInDatabaseException(payload.rom_id)
assert_rom_visible(request, rom)
if not is_installable_platform(rom.platform_slug):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Roms for {rom.platform_slug} cannot be installed on a device",
)
try:
file_ids = select_install_files(db_rom_handler.rom_files_for_rom_id(rom.id))
except InstallNotAllowedError as exc:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)
) from exc
with _install_errors():
install, created = await device_install_handler.create(
user_id=request.user.id,
device_id=device_id,
rom_id=rom.id,
file_ids=file_ids,
)
if not created:
response.status_code = status.HTTP_200_OK
return install
# A delete that ran between the device check and the write missed this request.
if (
db_device_handler.get_device(device_id=device_id, user_id=request.user.id)
is None
):
await device_install_handler.discard_for_device(device_id)
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Device with ID {device_id} not found",
)
log.info(f"Queued rom {rom.id} for install on device {device_id}")
await emit_install_queued(install)
await emit_install_updated(install)
return install
@protected_route(
router.get,
"/{device_id}/installs",
[Scope.DEVICES_READ],
responses={status.HTTP_404_NOT_FOUND: {}},
)
async def list_install_requests(
request: Request,
device_id: Annotated[str, PathVar(description="Target device id.")],
) -> list[InstallRequestSchema]:
"""The device's pending and taken requests, oldest first."""
if not DEVICE_INSTALL_ENABLED:
raise DeviceInstallDisabledException()
_require_device(request, device_id)
return await device_install_handler.list_for_device(device_id)
@protected_route(
router.post,
"/{device_id}/installs/claim",
[Scope.DEVICES_WRITE, Scope.ROMS_READ],
responses={
status.HTTP_403_FORBIDDEN: {},
status.HTTP_404_NOT_FOUND: {},
status.HTTP_409_CONFLICT: {},
},
)
async def claim_install_requests(
request: Request,
device_id: Annotated[str, PathVar(description="Target device id.")],
) -> list[InstallRequestSchema]:
"""Take every pending request and return all the device holds taken, oldest first."""
if not DEVICE_INSTALL_ENABLED:
raise DeviceInstallDisabledException()
_require_device(request, device_id, device_only=True)
with _install_errors():
claim = await device_install_handler.claim(device_id)
for install in claim.newly_taken:
await emit_install_updated(install)
return claim.taken
@protected_route(
router.put,
"/{device_id}/installs/{request_id}",
[Scope.DEVICES_WRITE],
responses={
status.HTTP_403_FORBIDDEN: {},
status.HTTP_404_NOT_FOUND: {},
status.HTTP_409_CONFLICT: {},
},
)
async def update_install_request(
request: Request,
device_id: Annotated[str, PathVar(description="Target device id.")],
request_id: Annotated[str, PathVar(description="Install request id.")],
payload: InstallRequestUpdatePayload,
) -> InstallRequestSchema:
"""The device reports the outcome of a request it has taken, which ends the request."""
if not DEVICE_INSTALL_ENABLED:
raise DeviceInstallDisabledException()
device = _require_device(request, device_id, device_only=True)
await _require_request(request, device_id, request_id)
with _install_errors():
ended = await device_install_handler.report(
request_id, payload.status, payload.reason
)
await emit_install_updated(ended)
await _notify_outcome(ended, device)
return ended
@protected_route(
router.delete,
"/{device_id}/installs/{request_id}",
[Scope.DEVICES_WRITE],
responses={status.HTTP_404_NOT_FOUND: {}, status.HTTP_409_CONFLICT: {}},
)
async def cancel_install_request(
request: Request,
device_id: Annotated[str, PathVar(description="Target device id.")],
request_id: Annotated[str, PathVar(description="Install request id.")],
) -> InstallRequestSchema:
"""Cancel a request still pending or taken; one already ended is a 404."""
if not DEVICE_INSTALL_ENABLED:
raise DeviceInstallDisabledException()
_require_device(request, device_id)
await _require_request(request, device_id, request_id)
with _install_errors():
ended = await device_install_handler.cancel(request_id)
await emit_install_cancelled(ended)
await emit_install_updated(ended)
return ended
+6
View File
@@ -8,6 +8,8 @@ from fastapi import HTTPException, Request, status
from adapters.services.sigil import SigilService
from config import (
DEVICE_INSTALL_ENABLED,
DEVICE_INSTALL_EXCLUDED_PLATFORM_SLUGS,
DISABLE_EMULATOR_JS,
DISABLE_JSDOS,
DISABLE_LOGS_VIEWER,
@@ -184,6 +186,10 @@ async def heartbeat() -> HeartbeatResponse:
"EMAIL_ENABLED": EMAIL_ENABLED,
"EMAILS_RESET_LINKS": reset_link_base_url() is not None,
},
"DEVICE_INSTALL": {
"ENABLED": DEVICE_INSTALL_ENABLED,
"EXCLUDED_PLATFORM_SLUGS": sorted(DEVICE_INSTALL_EXCLUDED_PLATFORM_SLUGS),
},
"TASKS": {
"ENABLE_SCHEDULED_RESCAN": ENABLE_SCHEDULED_RESCAN,
"SCHEDULED_RESCAN_CRON": SCHEDULED_RESCAN_CRON,
+5 -1
View File
@@ -20,12 +20,13 @@ from handler.audit_handler import AuditTarget, record
from handler.auth.constants import Scope
from handler.auth.dependencies import assert_admin, get_permissions
from handler.database import (
db_client_token_handler,
db_permission_handler,
db_platform_handler,
db_rom_handler,
db_user_handler,
)
from handler.socket_handler import socket_handler
from handler.socket_handler import close_client_token_sockets, socket_handler
from logger.logger import log
from models.audit_event import AuditAction, AuditTargetType
from models.permission import PermAction, PermEntity, PermissionGroup
@@ -50,6 +51,9 @@ async def emit_permissions_changed(user_id: int) -> None:
)
except Exception as e: # noqa: BLE001
log.warning(f"Failed to broadcast permissions:changed for user {user_id}: {e}")
await close_client_token_sockets(
db_client_token_handler.get_token_ids_by_user(user_id)
)
@protected_route(router.get, "/me", [Scope.ME_READ])
+1
View File
@@ -35,6 +35,7 @@ class DeviceSchema(BaseModel):
sync_mode: SyncMode
sync_enabled: bool
sync_config: dict[str, Any] | None
capabilities: dict[str, bool] | None
last_seen: UTCDatetime | None
created_at: UTCDatetime
updated_at: UTCDatetime
@@ -0,0 +1,32 @@
import enum
from typing import Annotated
from pydantic import Field
from .base import BaseModel, UTCDatetime
class InstallStatus(enum.StrEnum):
PENDING = "pending"
TAKEN = "taken"
DONE = "done"
ALREADY_INSTALLED = "already_installed"
FAILED = "failed"
CANCELLED = "cancelled"
InstallReason = Annotated[str, Field(max_length=500)]
class InstallRequestSchema(BaseModel):
"""An install request, as served and as stored in Redis while pending or taken."""
id: str
user_id: int
device_id: str
rom_id: int
file_ids: list[int]
status: InstallStatus
reason: InstallReason | None
created_at: UTCDatetime
updated_at: UTCDatetime
+6
View File
@@ -61,6 +61,11 @@ class NotificationsDict(TypedDict):
EMAILS_RESET_LINKS: bool
class DeviceInstallDict(TypedDict):
ENABLED: bool
EXCLUDED_PLATFORM_SLUGS: list[str]
class TasksDict(TypedDict):
ENABLE_SCHEDULED_RESCAN: bool
SCHEDULED_RESCAN_CRON: str
@@ -80,6 +85,7 @@ class HeartbeatResponse(TypedDict):
FRONTEND: FrontendDict
OIDC: OIDCDict
NOTIFICATIONS: NotificationsDict
DEVICE_INSTALL: DeviceInstallDict
TASKS: TasksDict
+2
View File
@@ -157,6 +157,7 @@ from utils.zip_cache import (
)
from .files import router as files_router
from .installs import router as installs_router
from .manual import router as manual_router
from .notes import router as notes_router
from .patch import router as patch_router
@@ -171,6 +172,7 @@ router = APIRouter(
)
router.include_router(upload_router)
router.include_router(files_router)
router.include_router(installs_router)
router.include_router(manual_router)
router.include_router(walkthrough_router)
router.include_router(soundtrack_router)
+41
View File
@@ -0,0 +1,41 @@
from typing import Annotated
from fastapi import Path as PathVar
from fastapi import Request, status
from config import DEVICE_INSTALL_ENABLED
from decorators.auth import protected_route
from endpoints.responses.device_install import InstallRequestSchema
from exceptions.endpoint_exceptions import (
DeviceInstallDisabledException,
RomNotFoundInDatabaseException,
)
from handler.auth.constants import Scope
from handler.auth.dependencies import assert_rom_visible
from handler.database import db_rom_handler
from handler.device_install_handler import device_install_handler
from utils.router import APIRouter
router = APIRouter()
@protected_route(
router.get,
"/{id}/installs",
[Scope.DEVICES_READ, Scope.ROMS_READ],
responses={status.HTTP_404_NOT_FOUND: {}},
)
async def get_rom_installs(
request: Request,
id: Annotated[int, PathVar(description="Rom internal id.", ge=1)],
) -> list[InstallRequestSchema]:
"""The caller's pending and taken requests for a rom on any device, oldest first."""
if not DEVICE_INSTALL_ENABLED:
raise DeviceInstallDisabledException()
rom = db_rom_handler.get_rom_visibility(id)
if not rom:
raise RomNotFoundInDatabaseException(id)
assert_rom_visible(request, rom)
return await device_install_handler.list_for_rom(request.user.id, id)
+188
View File
@@ -0,0 +1,188 @@
"""The ``/devices`` socket namespace, open only to device-bound client tokens."""
from collections.abc import Sequence
from datetime import datetime, timezone
from typing import Any, Final, NamedTuple
from socketio import exceptions as socketio_exceptions
from config import DEVICE_INSTALL_ENABLED, SESSION_MAX_AGE_SECONDS
from endpoints.responses.device_install import InstallRequestSchema
from handler.auth.constants import CLIENT_TOKEN_PREFIX, Scope
from handler.auth.hybrid_auth import resolve_client_token
from handler.database import db_client_token_handler, db_device_handler
from handler.redis_handler import async_cache
from handler.socket_handler import (
DEVICES_NAMESPACE,
client_token_sockets_key,
socket_handler,
)
from logger.logger import log
from utils.datetime import to_utc
CLIENT_TOKEN_ID_KEY: Final = "client_token_id"
DEVICE_ID_KEY: Final = "device_id"
# Clears entries a crashed worker left behind.
SOCKET_TRACKING_TTL_SECONDS: Final = SESSION_MAX_AGE_SECONDS
# A device is online while one of its sockets refreshed its presence within the TTL.
PRESENCE_TTL_SECONDS: Final = 90
PRESENCE_REFRESH_SECONDS: Final = 30
def device_room(device_id: str) -> str:
return f"device:{device_id}"
def _presence_key(device_id: str) -> str:
return f"device_presence:{device_id}"
def _raw_client_token(environ: dict[str, Any], auth: Any) -> str | None:
"""The token from the handshake's auth payload, else its bearer header."""
token = auth.get("token") if isinstance(auth, dict) else None
if isinstance(token, str):
return token
scheme, _, credentials = str(environ.get("HTTP_AUTHORIZATION", "")).partition(" ")
if scheme.lower() == "bearer" and credentials:
return credentials.strip()
return None
class BoundToken(NamedTuple):
token_id: int
device_id: str
expires_at: datetime | None
def _bound_token(raw_token: str) -> BoundToken | None:
"""A live, device-bound token holding devices.read."""
identity = resolve_client_token(raw_token)
if (
identity is None
or not identity.token.device_id
or Scope.DEVICES_READ not in identity.scopes
):
return None
expires_at = identity.token.expires_at
return BoundToken(
identity.token.id,
identity.token.device_id,
to_utc(expires_at) if expires_at else None,
)
async def _keep_tracked(sid: str, bound: BoundToken) -> None:
"""Refresh the socket's tracking while connected, closing it once its token expires."""
server = socket_handler.socket_server
token_id, device_id, expires_at = bound
while True:
await server.sleep(PRESENCE_REFRESH_SECONDS)
if not server.manager.is_connected(sid, DEVICES_NAMESPACE):
return
if expires_at and expires_at <= datetime.now(timezone.utc):
await server.disconnect(sid, namespace=DEVICES_NAMESPACE)
return
try:
await socket_handler.track(
_presence_key(device_id), sid, PRESENCE_TTL_SECONDS
)
await socket_handler.track(
client_token_sockets_key(token_id), sid, SOCKET_TRACKING_TTL_SECONDS
)
except Exception: # noqa: BLE001
log.warning(
f"Failed to refresh the tracking of device {device_id}", exc_info=True
)
@socket_handler.on("connect", namespace=DEVICES_NAMESPACE)
async def connect(sid: str, environ: dict[str, Any], auth: Any = None) -> None:
"""Admit a live client token bound to a device, whose owner and token hold devices.read."""
if not DEVICE_INSTALL_ENABLED:
raise socketio_exceptions.ConnectionRefusedError("disabled")
raw_token = _raw_client_token(environ, auth)
if not raw_token or not raw_token.startswith(CLIENT_TOKEN_PREFIX):
raise socketio_exceptions.ConnectionRefusedError("unauthorized")
bound = _bound_token(raw_token)
if bound is None:
raise socketio_exceptions.ConnectionRefusedError("unauthorized")
token_id, device_id, _ = bound
tracking_key = client_token_sockets_key(token_id)
await socket_handler.track(tracking_key, sid, SOCKET_TRACKING_TTL_SECONDS)
# A revocation that ran before the socket was tracked found nothing to close.
if _bound_token(raw_token) != bound:
await socket_handler.untrack(tracking_key, sid)
raise socketio_exceptions.ConnectionRefusedError("unauthorized")
db_client_token_handler.update_last_used(token_id)
db_device_handler.update_last_seen_debounced(device_id)
await socket_handler.track(_presence_key(device_id), sid, PRESENCE_TTL_SECONDS)
async with socket_handler.socket_server.session(
sid, namespace=DEVICES_NAMESPACE
) as session:
session[CLIENT_TOKEN_ID_KEY] = token_id
session[DEVICE_ID_KEY] = device_id
await socket_handler.socket_server.enter_room(
sid, device_room(device_id), namespace=DEVICES_NAMESPACE
)
socket_handler.socket_server.start_background_task(_keep_tracked, sid, bound)
@socket_handler.on("disconnect", namespace=DEVICES_NAMESPACE)
async def disconnect(sid: str) -> None:
"""Forget the socket for revocation and presence."""
session = await socket_handler.get_session(sid, namespace=DEVICES_NAMESPACE)
token_id = session.get(CLIENT_TOKEN_ID_KEY)
device_id = session.get(DEVICE_ID_KEY)
if token_id is not None:
await socket_handler.untrack(client_token_sockets_key(token_id), sid)
if device_id is not None:
await socket_handler.untrack(_presence_key(device_id), sid)
async def online_device_ids(device_ids: Sequence[str]) -> list[str]:
"""The devices among ``device_ids`` with a socket that refreshed its presence lately."""
if not device_ids:
return []
async with async_cache.pipeline(transaction=False) as pipe:
for device_id in device_ids:
pipe.exists(_presence_key(device_id))
counts = await pipe.execute()
return [
device_id for device_id, count in zip(device_ids, counts, strict=True) if count
]
async def _emit_to_device(event: str, request: InstallRequestSchema) -> None:
try:
await socket_handler.write_manager().emit(
event,
{"id": request.id, "rom_id": request.rom_id},
namespace=DEVICES_NAMESPACE,
room=device_room(request.device_id),
)
except Exception: # noqa: BLE001
log.warning(
f"Failed to push {event} to device {request.device_id}", exc_info=True
)
async def emit_install_queued(request: InstallRequestSchema) -> None:
"""Tell the device a request waits in its queue, logging a failure."""
await _emit_to_device("install:queued", request)
async def emit_install_cancelled(request: InstallRequestSchema) -> None:
"""Tell the device to drop a request it may already be downloading, logging a failure."""
await _emit_to_device("install:cancelled", request)
async def emit_install_updated(request: InstallRequestSchema) -> None:
"""Tell the owner's open tabs a request changed."""
await socket_handler.emit_to_user(
request.user_id, "install:updated", request.model_dump(mode="json")
)
+17 -1
View File
@@ -20,7 +20,12 @@ from handler.audit_handler import (
)
from handler.auth import auth_handler
from handler.auth.constants import Scope
from handler.database import db_user_handler
from handler.database import (
db_client_token_handler,
db_device_handler,
db_user_handler,
)
from handler.device_install_handler import device_install_handler
from handler.filesystem import fs_asset_handler
from handler.filesystem.assets_handler import (
build_asset_file_response,
@@ -29,6 +34,7 @@ from handler.filesystem.assets_handler import (
from handler.metadata import meta_ra_handler
from handler.metadata.ra_handler import RAUserProgression
from handler.notification_handler import notify
from handler.socket_handler import close_client_token_sockets
from logger.logger import log
from models.audit_event import AuditAction
from models.notification import NotificationKind, NotificationLevel
@@ -556,6 +562,11 @@ async def update_user(
actor_id=request.user.id,
)
if cleaned_data.get("enabled") is False:
await close_client_token_sockets(
db_client_token_handler.get_token_ids_by_user(id)
)
before = db_user
db_user = db_user_handler.get_user(id)
if not db_user:
@@ -602,7 +613,12 @@ async def delete_user(
status_code=400, detail="You cannot delete the last admin user"
)
token_ids = db_client_token_handler.get_token_ids_by_user(id)
device_ids = [device.id for device in db_device_handler.get_devices(user_id=id)]
db_user_handler.delete_user(id)
await close_client_token_sockets(token_ids)
for device_id in device_ids:
await device_install_handler.discard_for_device(device_id)
record(
AuditAction.USER_DELETE,
request,
+10
View File
@@ -88,6 +88,16 @@ class MusicPlaylistAlreadyExistsException(Exception):
return self.message
class DeviceInstallDisabledException(Exception):
def __init__(self) -> None:
self.message = "Installing on a device is disabled on this server"
super().__init__(self.message)
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=self.message)
def __repr__(self) -> str:
return self.message
class RomNotFoundInRetroAchievementsException(Exception):
def __init__(self, id: int) -> None:
self.message = f"Rom with id '{id}' does not exist on RetroAchievements"
+7 -2
View File
@@ -28,7 +28,12 @@ from config import (
)
from decorators.auth import oauth
from exceptions.auth_exceptions import OAuthCredentialsException, UserDisabledException
from handler.auth.constants import ALGORITHM, DEFAULT_OAUTH_TOKEN_EXPIRY, TokenPurpose
from handler.auth.constants import (
ALGORITHM,
CLIENT_TOKEN_PREFIX,
DEFAULT_OAUTH_TOKEN_EXPIRY,
TokenPurpose,
)
from handler.auth.middleware.redis_session_middleware import RedisSessionMiddleware
from handler.email_handler import EmailError, send_email
from handler.redis_handler import redis_client
@@ -106,7 +111,7 @@ class AuthHandler:
@staticmethod
def generate_client_token() -> str:
return "rmm_" + secrets.token_hex(32)
return CLIENT_TOKEN_PREFIX + secrets.token_hex(32)
@staticmethod
def hash_client_token(raw: str) -> str:
+1
View File
@@ -7,6 +7,7 @@ DEFAULT_OAUTH_TOKEN_EXPIRY: Final = timedelta(minutes=15)
# Name of the session cookie set by RedisSessionMiddleware (see main.py). Shared
# so the socket log-stream handshake reads the same cookie the middleware writes.
SESSION_COOKIE_NAME: Final = "romm_session"
CLIENT_TOKEN_PREFIX: Final = "rmm_"
class Scope(enum.StrEnum):
+2 -2
View File
@@ -30,7 +30,7 @@ from models.permission import PermAction, PermEntity
if TYPE_CHECKING:
from models.firmware import Firmware
from models.platform import Platform
from models.rom import Rom, RomVisibility, RomVisibilityLabel
from models.rom import Rom, RomInstallTarget, RomVisibility, RomVisibilityLabel
def get_permissions(request: Request) -> ResolvedPermissions:
@@ -109,7 +109,7 @@ def assert_admin(request: Request) -> ResolvedPermissions:
# download endpoints, which carry no permission context to resolve.
def assert_rom_visible(
request: Request,
rom: Rom | RomVisibility | RomVisibilityLabel,
rom: Rom | RomVisibility | RomVisibilityLabel | RomInstallTarget,
*,
not_found_detail: str | None = None,
) -> None:
+36 -18
View File
@@ -1,4 +1,5 @@
from datetime import datetime, timezone
from typing import NamedTuple
from fastapi import HTTPException
from fastapi.security.http import HTTPBasic
@@ -12,10 +13,40 @@ from handler.database import (
db_device_handler,
db_user_handler,
)
from models.client_token import ClientToken
from models.user import User
from utils.datetime import to_utc
from .constants import READ_SCOPES
from .constants import CLIENT_TOKEN_PREFIX, READ_SCOPES
class ClientTokenIdentity(NamedTuple):
token: ClientToken
user: User
scopes: list[str]
def resolve_client_token(raw_token: str) -> ClientTokenIdentity | None:
"""The live client token behind a raw one, its enabled owner, and the scopes both hold."""
client_token = db_client_token_handler.get_token_by_hash(
auth_handler.hash_client_token(raw_token)
)
if client_token is None:
return None
if client_token.expires_at and to_utc(client_token.expires_at) < datetime.now(
timezone.utc
):
return None
user = db_user_handler.get_user(client_token.user_id)
if user is None or not user.enabled:
return None
token_scopes = set(client_token.scopes.split())
return ClientTokenIdentity(
client_token, user, list(token_scopes & set(user.oauth_scopes))
)
class HybridAuthBackend(AuthenticationBackend):
@@ -57,25 +88,12 @@ class HybridAuthBackend(AuthenticationBackend):
# Check if bearer auth header is valid
if scheme.lower() == "bearer":
# Client API tokens use the rmm_ prefix
if token.startswith("rmm_"):
hashed = auth_handler.hash_client_token(token)
client_token = db_client_token_handler.get_token_by_hash(hashed)
if client_token is None:
if token.startswith(CLIENT_TOKEN_PREFIX):
identity = resolve_client_token(token)
if identity is None:
return None
if client_token.expires_at and to_utc(
client_token.expires_at
) < datetime.now(timezone.utc):
return None
user = db_user_handler.get_user(client_token.user_id)
if user is None or not user.enabled:
return None
token_scopes = set(client_token.scopes.split())
effective_scopes = list(token_scopes & set(user.oauth_scopes))
client_token, user, effective_scopes = identity
db_client_token_handler.update_last_used(client_token.id)
user.set_last_active()
conn.state.client_token_id = client_token.id
@@ -44,6 +44,26 @@ class DBClientTokensHandler(DBBaseHandler):
.order_by(ClientToken.created_at.desc())
).all()
@begin_session
def get_token_ids_by_user(
self,
user_id: int,
session: Session = None, # type: ignore[assignment]
) -> Sequence[int]:
return session.scalars(
select(ClientToken.id).where(ClientToken.user_id == user_id)
).all()
@begin_session
def get_token_ids_by_device(
self,
device_id: str,
session: Session = None, # type: ignore[assignment]
) -> Sequence[int]:
return session.scalars(
select(ClientToken.id).where(ClientToken.device_id == device_id)
).all()
@begin_session
def get_all_tokens(
self,
+22
View File
@@ -76,6 +76,7 @@ from models.rom import (
RomFileDocMeta,
RomFileUser,
RomIdentityKey,
RomInstallTarget,
RomMetadata,
RomNote,
RomUser,
@@ -772,6 +773,27 @@ class DBRomsHandler(DBBaseHandler):
id=row.id, platform_id=row.platform_id, name=row.name, fs_name=row.fs_name
)
@begin_session
def get_rom_install_target(
self,
id: int,
*,
session: Session = None, # type: ignore[assignment]
) -> RomInstallTarget | None:
"""The columns a device install request checks, in one query."""
row = session.execute(
select(Rom.id, Rom.platform_id, Platform.slug.label("platform_slug"))
.join(Platform, Rom.platform_id == Platform.id)
.where(Rom.id == id)
).one_or_none()
if row is None:
return None
return RomInstallTarget(
id=row.id, platform_id=row.platform_id, platform_slug=row.platform_slug
)
@begin_session
def get_rom_deletion_target(
self,
+316
View File
@@ -0,0 +1,316 @@
"""Install requests a user pushes to one of their devices, held in Redis while live."""
import uuid
from datetime import datetime, timezone
from typing import Final, NamedTuple
from redis.asyncio import Redis
from redis.asyncio.client import Pipeline
from redis.exceptions import WatchError
from config import DEVICE_INSTALL_REQUEST_TTL_DAYS
from endpoints.responses.device_install import InstallRequestSchema, InstallStatus
from handler.redis_handler import as_text, async_cache
from logger.logger import log
TRANSACTION_ATTEMPTS: Final = 3
REPORTABLE_FROM: Final = frozenset({InstallStatus.TAKEN})
CANCELLABLE_FROM: Final = frozenset({InstallStatus.PENDING, InstallStatus.TAKEN})
class InstallTransitionError(Exception):
"""The request is not in the status the operation needs, or kept changing."""
class InstallClaim(NamedTuple):
"""Every request the device holds taken, oldest first, and those this claim took."""
taken: list[InstallRequestSchema]
newly_taken: list[InstallRequestSchema]
def _oldest_first(
requests: list[InstallRequestSchema],
) -> list[InstallRequestSchema]:
return sorted(requests, key=lambda request: request.created_at)
def _claim(
already_taken: list[InstallRequestSchema], newly_taken: list[InstallRequestSchema]
) -> InstallClaim:
return InstallClaim(
taken=_oldest_first(already_taken + newly_taken),
newly_taken=_oldest_first(newly_taken),
)
def _request_key(request_id: str) -> str:
return f"install:req:{request_id}"
def _device_key(device_id: str) -> str:
return f"install:device:{device_id}"
def _rom_key(user_id: int, rom_id: int) -> str:
return f"install:rom:{user_id}:{rom_id}"
def _active_key(request: InstallRequestSchema) -> str:
return f"install:active:{request.user_id}:{request.device_id}:{request.rom_id}"
class DeviceInstallHandler:
def __init__(self, ttl_days: int = DEVICE_INSTALL_REQUEST_TTL_DAYS) -> None:
self.ttl_seconds: int | None = ttl_days * 24 * 60 * 60 if ttl_days > 0 else None
async def create(
self,
user_id: int,
device_id: str,
rom_id: int,
file_ids: list[int],
) -> tuple[InstallRequestSchema, bool]:
"""The live request for the rom on the device, and whether this call queued it.
Raises:
InstallTransitionError: Concurrent creates kept colliding.
"""
now = datetime.now(timezone.utc)
request = InstallRequestSchema(
id=str(uuid.uuid4()),
user_id=user_id,
device_id=device_id,
rom_id=rom_id,
file_ids=file_ids,
status=InstallStatus.PENDING,
reason=None,
created_at=now,
updated_at=now,
)
active_key = _active_key(request)
async with async_cache.pipeline(transaction=True) as pipe:
for _ in range(TRANSACTION_ATTEMPTS):
try:
await pipe.watch(active_key)
active_id = await pipe.get(active_key)
if active_id is not None:
[existing] = await self._read(pipe, [as_text(active_id)])
if existing is not None:
return existing, False
pipe.multi()
self._stage_write(pipe, request, owns_active_key=True)
await pipe.execute()
return request, True
except WatchError:
continue
raise InstallTransitionError(
"Another request for this rom was queued at the same time"
)
async def get(self, request_id: str) -> InstallRequestSchema | None:
[request] = await self._read(async_cache, [request_id])
return request
async def list_for_device(self, device_id: str) -> list[InstallRequestSchema]:
"""The device's live requests, oldest first."""
return await self._load(_device_key(device_id))
async def list_for_rom(
self, user_id: int, rom_id: int
) -> list[InstallRequestSchema]:
"""Every live request the user made for a rom, oldest first."""
return await self._load(_rom_key(user_id, rom_id))
async def claim(self, device_id: str) -> InstallClaim:
"""Take every pending request of the device, returning all it holds taken.
Raises:
InstallTransitionError: Other writers kept changing the requests.
"""
device_key = _device_key(device_id)
async with async_cache.pipeline(transaction=True) as pipe:
for _ in range(TRANSACTION_ATTEMPTS):
try:
await pipe.watch(device_key)
ids = [as_text(i) for i in await pipe.smembers(device_key)]
if ids:
await pipe.watch(*[_request_key(i) for i in ids])
stored = await self._read(pipe, ids)
gone = [
request_id
for request_id, request in zip(ids, stored, strict=True)
if request is None
]
live = [request for request in stored if request is not None]
already_taken = [
request
for request in live
if request.status == InstallStatus.TAKEN
]
pending = [
request
for request in live
if request.status == InstallStatus.PENDING
]
owned = (
await self._owns_active_keys(pipe, pending) if pending else []
)
now = datetime.now(timezone.utc)
taken = [
request.model_copy(
update={"status": InstallStatus.TAKEN, "updated_at": now}
)
for request in pending
]
if gone or taken:
pipe.multi()
if gone:
pipe.srem(device_key, *gone)
for request, owns_active_key in zip(taken, owned, strict=True):
self._stage_write(pipe, request, owns_active_key)
await pipe.execute()
return _claim(already_taken, taken)
except WatchError:
continue
raise InstallTransitionError(
"The device's requests kept changing while they were being claimed"
)
async def report(
self, request_id: str, outcome: InstallStatus, reason: str | None
) -> InstallRequestSchema:
"""End a taken request with the device's outcome, returning it as it ended.
Raises:
KeyError: The request is gone.
InstallTransitionError: The request is not taken.
"""
return await self._end(request_id, outcome, REPORTABLE_FROM, reason)
async def cancel(self, request_id: str) -> InstallRequestSchema:
"""End a pending or taken request as cancelled, returning it as it ended.
Raises:
KeyError: The request is gone.
InstallTransitionError: The request kept changing.
"""
return await self._end(
request_id, InstallStatus.CANCELLED, CANCELLABLE_FROM, None
)
async def discard_for_device(self, device_id: str) -> None:
"""Drop a deleted device's live requests and their index, logging a failure."""
try:
requests = await self.list_for_device(device_id)
async with async_cache.pipeline(transaction=True) as pipe:
for request in requests:
self._stage_delete(pipe, request, owns_active_key=True)
pipe.delete(_device_key(device_id))
await pipe.execute()
except Exception: # noqa: BLE001
log.warning(
f"Failed to drop the install requests of device {device_id}",
exc_info=True,
)
async def _end(
self,
request_id: str,
status: InstallStatus,
from_statuses: frozenset[InstallStatus],
reason: str | None,
) -> InstallRequestSchema:
key = _request_key(request_id)
async with async_cache.pipeline(transaction=True) as pipe:
for _ in range(TRANSACTION_ATTEMPTS):
try:
await pipe.watch(key)
[current] = await self._read(pipe, [request_id])
if current is None:
raise KeyError(request_id)
if current.status not in from_statuses:
raise InstallTransitionError(
f"Cannot move a {current.status} request to {status}"
)
[owns_active_key] = await self._owns_active_keys(pipe, [current])
pipe.multi()
self._stage_delete(pipe, current, owns_active_key)
await pipe.execute()
return current.model_copy(
update={
"status": status,
"reason": reason,
"updated_at": datetime.now(timezone.utc),
}
)
except WatchError:
continue
raise InstallTransitionError("The request kept changing while it was ended")
async def _read(
self, client: Redis[str], request_ids: list[str]
) -> list[InstallRequestSchema | None]:
"""The stored requests in ``request_ids`` order, None where one is gone."""
if not request_ids:
return []
stored = await client.mget([_request_key(i) for i in request_ids])
return [
InstallRequestSchema.model_validate_json(raw) if raw else None
for raw in stored
]
async def _load(self, set_key: str) -> list[InstallRequestSchema]:
ids = [as_text(i) for i in await async_cache.smembers(set_key)]
stored = await self._read(async_cache, ids)
gone = [
request_id
for request_id, request in zip(ids, stored, strict=True)
if request is None
]
if gone:
await async_cache.srem(set_key, *gone)
return _oldest_first([request for request in stored if request is not None])
async def _owns_active_keys(
self, pipe: Pipeline[str], requests: list[InstallRequestSchema]
) -> list[bool]:
"""Whether each rom's dedupe key still names its request, watching the keys."""
active_keys = [_active_key(request) for request in requests]
await pipe.watch(*active_keys)
active_ids = await pipe.mget(active_keys)
return [
active_id is not None and as_text(active_id) == request.id
for request, active_id in zip(requests, active_ids, strict=True)
]
def _stage_write(
self, pipe: Pipeline[str], request: InstallRequestSchema, owns_active_key: bool
) -> None:
"""Queue the request's write; each index lives as long as its newest request."""
device_key = _device_key(request.device_id)
rom_key = _rom_key(request.user_id, request.rom_id)
pipe.set(
_request_key(request.id), request.model_dump_json(), ex=self.ttl_seconds
)
pipe.sadd(device_key, request.id)
pipe.sadd(rom_key, request.id)
if owns_active_key:
pipe.set(_active_key(request), request.id, ex=self.ttl_seconds)
if self.ttl_seconds is not None:
pipe.expire(device_key, self.ttl_seconds)
pipe.expire(rom_key, self.ttl_seconds)
def _stage_delete(
self, pipe: Pipeline[str], request: InstallRequestSchema, owns_active_key: bool
) -> None:
pipe.delete(_request_key(request.id))
pipe.srem(_device_key(request.device_id), request.id)
pipe.srem(_rom_key(request.user_id, request.rom_id), request.id)
if owns_active_key:
pipe.delete(_active_key(request))
device_install_handler = DeviceInstallHandler()
+42
View File
@@ -0,0 +1,42 @@
"""Which roms, and which of their files, a user may push to a device."""
from collections.abc import Sequence
from typing import Final
from config import DEVICE_INSTALL_EXCLUDED_PLATFORM_SLUGS
from models.rom import RomFile, RomFileCategory
# None is an uncategorized root file. Documents reach a device through its own
# manual/walkthrough download.
INSTALLABLE_FILE_CATEGORIES: Final = frozenset(
{None, RomFileCategory.GAME, RomFileCategory.UPDATE, RomFileCategory.DLC}
)
REMOTE_INSTALL_CAPABILITY: Final = "remote_install"
class InstallNotAllowedError(Exception):
"""The rom has nothing that can be pushed to a device."""
def is_installable_platform(platform_slug: str) -> bool:
return platform_slug.lower() not in DEVICE_INSTALL_EXCLUDED_PLATFORM_SLUGS
def accepts_remote_install(capabilities: dict[str, bool] | None) -> bool:
return (capabilities or {}).get(REMOTE_INSTALL_CAPABILITY) is True
def select_install_files(files: Sequence[RomFile]) -> list[int]:
"""The ids of the rom's game, update and DLC files on disk, ascending.
Raises:
InstallNotAllowedError: None of the rom's files qualify.
"""
selected = sorted(
f.id
for f in files
if f.category in INSTALLABLE_FILE_CATEGORIES and not f.missing_from_fs
)
if not selected:
raise InstallNotAllowedError("This rom has no installable files")
return selected
@@ -3,6 +3,7 @@
from dataclasses import dataclass
from typing import Any
from endpoints.responses.device_install import InstallStatus
from endpoints.responses.notification import NotificationSchema
from models.notification import NotificationKind
from utils.urls import get_public_base_url
@@ -78,6 +79,24 @@ def _describe(n: NotificationSchema) -> tuple[str, str | None, str | None]:
_text(data.get("error")),
"/notifications?tab=channels",
)
case (
NotificationKind.DEVICE_INSTALL_COMPLETED
| NotificationKind.DEVICE_INSTALL_FAILED
):
game = _text(data.get("rom_name")) or "A game"
device = _text(data.get("device_name")) or "your device"
rom_id = _count(data.get("rom_id"))
if n.kind == NotificationKind.DEVICE_INSTALL_FAILED:
title = f"{game} could not be installed on {device}"
elif data.get("status") == InstallStatus.ALREADY_INSTALLED:
title = f"{game} is already installed on {device}"
else:
title = f"{game} was added to downloads on {device}"
return (
title,
_text(data.get("reason")),
f"/rom/{rom_id}" if rom_id else None,
)
case _:
return n.title or "New notification", n.body, n.link
+5
View File
@@ -101,6 +101,11 @@ def __get_async_binary_cache() -> AsyncRedis[bytes]:
async_binary_cache = __get_async_binary_cache()
def as_text(value: bytes | str) -> str:
"""A cached value as text, since the fake caches return bytes where Redis decodes."""
return value.decode() if isinstance(value, bytes) else value
def get_job_func_name(job: Job, fallback: str = "") -> str:
"""Safely get the function name from an RQ job, handling DeserializationError.
+54 -23
View File
@@ -5,7 +5,7 @@ from typing import TYPE_CHECKING, Any, Final
import socketio
from config import REDIS_URL, SESSION_MAX_AGE_SECONDS
from handler.redis_handler import async_cache
from handler.redis_handler import as_text, async_cache
from logger.logger import log
from utils import json_module
@@ -14,6 +14,11 @@ if TYPE_CHECKING:
# Where a socket's own session records the login session that opened it.
LOGIN_SESSION_ID_KEY: Final = "login_session_id"
DEVICES_NAMESPACE: Final = "/devices"
def client_token_sockets_key(token_id: int) -> str:
return f"device_token_sockets:{token_id}"
class SocketHandler:
@@ -37,11 +42,13 @@ class SocketHandler:
self._write_manager: socketio.AsyncRedisManager | None = None
self._write_manager_loop: asyncio.AbstractEventLoop | None = None
def on[F: Callable[..., Awaitable[object]]](self, event: str) -> Callable[[F], F]:
def on[F: Callable[..., Awaitable[object]]](
self, event: str, namespace: str | None = None
) -> Callable[[F], F]:
"""Register a socket event handler without changing its signature."""
def register(handler: F) -> F:
self.socket_server.on(event, handler)
self.socket_server.on(event, handler, namespace=namespace)
return handler
return register
@@ -72,10 +79,12 @@ class SocketHandler:
except Exception: # noqa: BLE001
log.warning(f"Failed to push {event} to user {user_id}", exc_info=True)
async def get_session(self, sid: str) -> dict[str, Any]:
async def get_session(
self, sid: str, namespace: str | None = None
) -> dict[str, Any]:
"""The socket's server-side session, empty once the socket is gone."""
try:
return await self.socket_server.get_session(sid) or {}
return await self.socket_server.get_session(sid, namespace=namespace) or {}
except KeyError:
return {}
@@ -99,11 +108,38 @@ class SocketHandler:
await self.bind_to_login_session(sid, session_id)
return user
async def track(self, key: str, sid: str, ttl_seconds: int) -> None:
"""Add a socket to the set under ``key`` and restart its expiry."""
await async_cache.sadd(key, sid)
await async_cache.expire(key, ttl_seconds)
async def untrack(self, key: str, sid: str) -> None:
"""Drop a socket from the set under ``key``, logging a failure."""
try:
await async_cache.srem(key, sid)
except Exception: # noqa: BLE001
log.warning(f"Failed to forget socket {sid} under {key}", exc_info=True)
async def close_tracked(self, key: str, namespace: str | None = None) -> None:
"""Disconnect every socket tracked under ``key`` on any worker, logging a failure."""
try:
sids = await async_cache.smembers(key)
await async_cache.delete(key)
except Exception: # noqa: BLE001
log.warning(f"Failed to close the sockets under {key}", exc_info=True)
return
for sid in sids:
try:
# A socket on another worker is disconnected through the broker.
await self.socket_server.disconnect(as_text(sid), namespace=namespace)
except Exception: # noqa: BLE001
log.warning(f"Failed to close socket {as_text(sid)}", exc_info=True)
async def bind_to_login_session(self, sid: str, session_id: str) -> None:
"""Record which login session opened a socket, so revoking it closes the socket."""
key = self._login_session_sockets_key(session_id)
await async_cache.sadd(key, sid)
await async_cache.expire(key, SESSION_MAX_AGE_SECONDS)
await self.track(
self._login_session_sockets_key(session_id), sid, SESSION_MAX_AGE_SECONDS
)
async with self.socket_server.session(sid) as session:
session[LOGIN_SESSION_ID_KEY] = session_id
@@ -112,7 +148,7 @@ class SocketHandler:
try:
session_id = (await self.get_session(sid)).get(LOGIN_SESSION_ID_KEY)
if session_id:
await async_cache.srem(self._login_session_sockets_key(session_id), sid)
await self.untrack(self._login_session_sockets_key(session_id), sid)
except Exception: # noqa: BLE001
log.warning(f"Failed to unbind socket {sid}", exc_info=True)
@@ -123,20 +159,7 @@ class SocketHandler:
session would otherwise go on receiving its user's and the admins' events.
"""
for session_id in session_ids:
key = self._login_session_sockets_key(session_id)
try:
sids = await async_cache.smembers(key)
await async_cache.delete(key)
for sid in sids:
# A socket on another worker is disconnected through the broker.
await self.socket_server.disconnect(
sid.decode() if isinstance(sid, bytes) else sid
)
except Exception: # noqa: BLE001
log.warning(
f"Failed to close the sockets of session {session_id}",
exc_info=True,
)
await self.close_tracked(self._login_session_sockets_key(session_id))
socket_handler = SocketHandler(path="/ws/socket.io")
@@ -152,3 +175,11 @@ async def close_login_session_sockets(session_ids: Iterable[str]) -> None:
socket_handler.close_login_sessions(ids),
netplay_socket_handler.close_login_sessions(ids),
)
async def close_client_token_sockets(token_ids: Iterable[int]) -> None:
"""Disconnect every device socket the given client tokens opened, on any worker."""
for token_id in token_ids:
await socket_handler.close_tracked(
client_token_sockets_key(token_id), DEVICES_NAMESPACE
)
+3
View File
@@ -13,6 +13,7 @@ from starlette.middleware.authentication import AuthenticationMiddleware
from startup import main
import endpoints.sockets.activity # noqa
import endpoints.sockets.devices # noqa
import endpoints.sockets.logs # noqa
import endpoints.sockets.netplay # noqa
import endpoints.sockets.scan # noqa
@@ -38,6 +39,7 @@ from endpoints.collections import router as collections_router
from endpoints.configs import router as configs_router
from endpoints.device import router as device_router
from endpoints.device_auth import router as device_auth_router
from endpoints.device_install import router as device_install_router
from endpoints.export import router as export_router
from endpoints.feeds import router as feeds_router
from endpoints.firmware import router as firmware_router
@@ -197,6 +199,7 @@ app.include_router(notifications_router, prefix="/api")
app.include_router(notification_channels_router, prefix="/api")
app.include_router(device_router, prefix="/api")
app.include_router(device_auth_router, prefix="/api")
app.include_router(device_install_router, prefix="/api")
app.include_router(play_sessions_router, prefix="/api")
app.include_router(platform_router, prefix="/api")
app.include_router(rom_router, prefix="/api")
+1
View File
@@ -73,6 +73,7 @@ class Device(BaseModel):
sync_mode: Mapped[SyncMode] = mapped_column(Enum(SyncMode), default=SyncMode.API)
sync_enabled: Mapped[bool] = mapped_column(Boolean, default=True)
sync_config: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
capabilities: Mapped[dict[str, bool] | None] = mapped_column(JSON, nullable=True)
last_seen: Mapped[datetime | None] = mapped_column(
TIMESTAMP(timezone=True), index=True
+5
View File
@@ -32,6 +32,8 @@ class NotificationKind(enum.StrEnum):
STREAMING_SESSION_ENDED = "streaming_session_ended"
ROLE_CHANGED = "role_changed"
CHANNEL_DISABLED = "channel_disabled"
DEVICE_INSTALL_COMPLETED = "device_install_completed"
DEVICE_INSTALL_FAILED = "device_install_failed"
class NotificationTopic(enum.StrEnum):
@@ -40,6 +42,7 @@ class NotificationTopic(enum.StrEnum):
SCANS = "scans"
TASKS = "tasks"
STREAMING = "streaming"
DEVICES = "devices"
ACCOUNT = "account"
CUSTOM = "custom"
@@ -52,6 +55,8 @@ _KIND_TOPICS: Final[dict[str, NotificationTopic]] = {
NotificationKind.STREAMING_SESSION_ENDED: NotificationTopic.STREAMING,
NotificationKind.ROLE_CHANGED: NotificationTopic.ACCOUNT,
NotificationKind.CHANNEL_DISABLED: NotificationTopic.ACCOUNT,
NotificationKind.DEVICE_INSTALL_COMPLETED: NotificationTopic.DEVICES,
NotificationKind.DEVICE_INSTALL_FAILED: NotificationTopic.DEVICES,
}
+8
View File
@@ -613,6 +613,14 @@ class RomVisibilityLabel(NamedTuple):
fs_name: str
class RomInstallTarget(NamedTuple):
"""`RomVisibility` plus the platform slug a device install request is checked against."""
id: int
platform_id: int
platform_slug: str
class RomDeletionTarget(NamedTuple):
"""The columns the bulk-delete route reads off one rom, and no relations."""
+32
View File
@@ -3,6 +3,7 @@ import functools
import os
import re
import socket
import uuid
from collections.abc import Iterator
from datetime import datetime, timedelta, timezone
from typing import Any
@@ -22,6 +23,7 @@ from handler.auth import auth_handler
from handler.auth.base_handler import oct_key
from handler.auth.constants import ALGORITHM
from handler.database import (
db_client_token_handler,
db_firmware_handler,
db_memory_card_handler,
db_permission_handler,
@@ -525,6 +527,36 @@ def viewer_user():
return db_user_handler.add_user(user)
@pytest.fixture
def add_device_token():
"""Factory for a client token, bound to ``device_id`` unless it is None.
Returns the stored token and the raw ``rmm_`` credential a client sends.
"""
def _add(
user: User,
device_id: str | None,
*,
scopes: str = "devices.read devices.write roms.read",
expires_at: datetime | None = None,
) -> tuple[ClientToken, str]:
raw_token = f"rmm_test_{uuid.uuid4().hex}"
token = db_client_token_handler.add_token(
ClientToken(
user_id=user.id,
name="Handheld",
hashed_token=auth_handler.hash_client_token(raw_token),
scopes=scopes,
expires_at=expires_at,
device_id=device_id,
)
)
return token, raw_token
return _add
@pytest.fixture
def expired_refresh_token(admin_user: User) -> str:
expire = int((datetime.now(timezone.utc) + timedelta(seconds=-1)).timestamp())
@@ -0,0 +1,82 @@
import asyncio
import pytest
from fastapi import status
from endpoints.responses.device_install import InstallRequestSchema
from endpoints.roms import installs
from handler.database.base_handler import sync_session
from handler.device_install_handler import device_install_handler
from models.permission import HiddenEntity, PermEntity
from models.rom import Rom
from models.user import User
@pytest.fixture
def headers(access_token: str) -> dict[str, str]:
return {"Authorization": f"Bearer {access_token}"}
@pytest.fixture
def editor_headers(editor_access_token: str) -> dict[str, str]:
return {"Authorization": f"Bearer {editor_access_token}"}
def _queue(user: User, rom: Rom, device_id: str) -> InstallRequestSchema:
request, _ = asyncio.run(
device_install_handler.create(
user_id=user.id, device_id=device_id, rom_id=rom.id, file_ids=[1]
)
)
return request
def _hide_rom(rom: Rom, user: User) -> None:
with sync_session.begin() as s:
s.add(HiddenEntity(entity=PermEntity.ROMS, entity_id=rom.id, user_id=user.id))
class TestRomInstalls:
def test_lists_the_callers_requests_for_the_rom(
self, client, headers, admin_user, rom
):
first = _queue(admin_user, rom, "dev-1")
second = _queue(admin_user, rom, "dev-2")
response = client.get(f"/api/roms/{rom.id}/installs", headers=headers)
assert response.status_code == status.HTTP_200_OK
assert [r["id"] for r in response.json()] == [first.id, second.id]
def test_another_users_requests_are_not_listed(
self, client, editor_headers, admin_user, rom
):
_queue(admin_user, rom, "dev-1")
response = client.get(f"/api/roms/{rom.id}/installs", headers=editor_headers)
assert response.status_code == status.HTTP_200_OK
assert response.json() == []
def test_an_ended_request_is_not_listed(self, client, headers, admin_user, rom):
request = _queue(admin_user, rom, "dev-1")
asyncio.run(device_install_handler.cancel(request.id))
response = client.get(f"/api/roms/{rom.id}/installs", headers=headers)
assert response.status_code == status.HTTP_200_OK
assert response.json() == []
def test_a_hidden_rom_is_not_found(self, client, editor_headers, editor_user, rom):
_hide_rom(rom, editor_user)
response = client.get(f"/api/roms/{rom.id}/installs", headers=editor_headers)
assert response.status_code == status.HTTP_404_NOT_FOUND
def test_is_not_found_while_disabled(self, mocker, client, headers, rom):
mocker.patch.object(installs, "DEVICE_INSTALL_ENABLED", False)
response = client.get(f"/api/roms/{rom.id}/installs", headers=headers)
assert response.status_code == status.HTTP_404_NOT_FOUND
@@ -0,0 +1,415 @@
from contextlib import asynccontextmanager
from datetime import datetime, timedelta, timezone
from typing import Any
from unittest.mock import AsyncMock, MagicMock
import pytest
from socketio import exceptions as socketio_exceptions
from endpoints.responses.device_install import InstallRequestSchema, InstallStatus
from endpoints.sockets import devices
from handler.auth.hybrid_auth import resolve_client_token
from handler.database import db_client_token_handler, db_device_handler
from handler.redis_handler import as_text, async_cache, sync_cache
from handler.socket_handler import DEVICES_NAMESPACE, socket_handler
from models.client_token import ClientToken
from models.device import Device
from models.user import User
NAMESPACE = DEVICES_NAMESPACE
@pytest.fixture(autouse=True)
def clear_cache():
sync_cache.flushall()
yield
sync_cache.flushall()
async def _members(key: str) -> set[str]:
return {as_text(member) for member in await async_cache.smembers(key)}
@pytest.fixture
def socket_session(mocker) -> dict[str, Any]:
stored: dict[str, Any] = {}
@asynccontextmanager
async def session(sid: str, namespace: str | None = None):
yield stored
mocker.patch.object(socket_handler.socket_server, "session", session)
return stored
@pytest.fixture
def background(mocker) -> MagicMock:
start = MagicMock()
mocker.patch.object(socket_handler.socket_server, "start_background_task", start)
return start
@pytest.fixture
def enter_room(mocker, socket_session, background):
return mocker.patch.object(socket_handler.socket_server, "enter_room", AsyncMock())
@pytest.fixture
def device_token(add_device_token):
"""A token for a new ``dev-socket`` device, or an unbound one."""
def _make(
user: User, *, device_bound: bool = True, **kwargs
) -> tuple[ClientToken, str]:
device_id = None
if device_bound:
device_id = db_device_handler.add_device(
Device(id="dev-socket", user_id=user.id, name="Handheld")
).id
token: tuple[ClientToken, str] = add_device_token(user, device_id, **kwargs)
return token
return _make
class TestConnect:
async def test_joins_the_devices_room_from_the_auth_payload(
self, enter_room, socket_session, admin_user, device_token
):
token, raw = device_token(admin_user)
await devices.connect("sid-1", {}, {"token": raw})
enter_room.assert_awaited_once_with(
"sid-1", "device:dev-socket", namespace=NAMESPACE
)
assert socket_session == {
devices.CLIENT_TOKEN_ID_KEY: token.id,
devices.DEVICE_ID_KEY: "dev-socket",
}
async def test_accepts_a_bearer_header(self, enter_room, admin_user, device_token):
_, raw = device_token(admin_user)
await devices.connect("sid-1", {"HTTP_AUTHORIZATION": f"Bearer {raw}"}, None)
enter_room.assert_awaited_once()
async def test_tracks_the_socket_for_revocation_and_presence(
self, enter_room, admin_user, device_token
):
token, raw = device_token(admin_user)
await devices.connect("sid-1", {}, {"token": raw})
token_key = f"device_token_sockets:{token.id}"
presence_key = "device_presence:dev-socket"
assert await _members(token_key) == {"sid-1"}
assert await _members(presence_key) == {"sid-1"}
assert (
0 < await async_cache.ttl(token_key) <= devices.SOCKET_TRACKING_TTL_SECONDS
)
assert 0 < await async_cache.ttl(presence_key) <= devices.PRESENCE_TTL_SECONDS
async def test_refuses_a_token_revoked_before_the_socket_was_tracked(
self, mocker, enter_room, admin_user, device_token
):
token, raw = device_token(admin_user)
mocker.patch.object(
devices,
"resolve_client_token",
side_effect=[resolve_client_token(raw), None],
)
with pytest.raises(socketio_exceptions.ConnectionRefusedError):
await devices.connect("sid-1", {}, {"token": raw})
assert not await async_cache.exists(f"device_token_sockets:{token.id}")
assert await devices.online_device_ids(["dev-socket"]) == []
enter_room.assert_not_awaited()
async def test_keeps_the_tracking_fresh_while_connected(
self, enter_room, background, admin_user, device_token
):
token, raw = device_token(admin_user)
await devices.connect("sid-1", {}, {"token": raw})
background.assert_called_once_with(
devices._keep_tracked,
"sid-1",
devices.BoundToken(token.id, "dev-socket", None),
)
async def test_bumps_the_tokens_last_use(
self, enter_room, admin_user, device_token
):
token, raw = device_token(admin_user)
await devices.connect("sid-1", {}, {"token": raw})
[stored] = db_client_token_handler.get_tokens_by_user(admin_user.id)
assert stored.id == token.id
assert stored.last_used_at is not None
async def test_never_writes_the_devices_capabilities(
self, enter_room, admin_user, device_token
):
_, raw = device_token(admin_user)
await devices.connect(
"sid-1", {}, {"token": raw, "capabilities": {"remote_install": True}}
)
stored = db_device_handler.get_device(
device_id="dev-socket", user_id=admin_user.id
)
assert stored is not None
assert stored.capabilities is None
@pytest.mark.parametrize(
"token_kwargs",
[
{"device_bound": False},
{"scopes": "roms.read"},
{"expires_at": datetime.now(timezone.utc) - timedelta(minutes=1)},
],
)
async def test_refuses_a_token_that_does_not_speak_for_a_device(
self, enter_room, admin_user, device_token, token_kwargs
):
_, raw = device_token(admin_user, **token_kwargs)
with pytest.raises(socketio_exceptions.ConnectionRefusedError):
await devices.connect("sid-1", {}, {"token": raw})
enter_room.assert_not_awaited()
@pytest.mark.parametrize("auth", [None, {"token": "rmm_unknown"}, {"token": "x"}])
async def test_refuses_a_missing_or_unknown_token(self, enter_room, auth):
with pytest.raises(socketio_exceptions.ConnectionRefusedError):
await devices.connect("sid-1", {}, auth)
enter_room.assert_not_awaited()
async def test_never_accepts_a_browser_session(self, enter_room):
environ = {"HTTP_COOKIE": "romm_session=abc"}
with pytest.raises(socketio_exceptions.ConnectionRefusedError):
await devices.connect("sid-1", environ, None)
async def test_refuses_every_device_while_install_is_disabled(
self, mocker, enter_room, admin_user, device_token
):
_, raw = device_token(admin_user)
mocker.patch.object(devices, "DEVICE_INSTALL_ENABLED", False)
with pytest.raises(socketio_exceptions.ConnectionRefusedError):
await devices.connect("sid-1", {}, {"token": raw})
enter_room.assert_not_awaited()
BOUND = devices.BoundToken(42, "dev-1", None)
class TestKeepTracked:
@pytest.fixture(autouse=True)
def sleep(self, mocker) -> AsyncMock:
sleep = AsyncMock()
mocker.patch.object(socket_handler.socket_server, "sleep", sleep)
return sleep
async def test_refreshes_while_the_socket_is_connected(self, mocker):
mocker.patch.object(
socket_handler.socket_server.manager,
"is_connected",
side_effect=[True, False],
)
await devices._keep_tracked("sid-1", BOUND)
assert await _members("device_presence:dev-1") == {"sid-1"}
assert 0 < await async_cache.ttl("device_presence:dev-1")
async def test_keeps_a_long_lived_socket_revocable(self, mocker):
mocker.patch.object(
socket_handler.socket_server.manager,
"is_connected",
side_effect=[True, False],
)
await async_cache.sadd("device_token_sockets:42", "sid-1")
await async_cache.expire("device_token_sockets:42", 5)
await devices._keep_tracked("sid-1", BOUND)
assert await _members("device_token_sockets:42") == {"sid-1"}
assert (
await async_cache.ttl("device_token_sockets:42")
> devices.SOCKET_TRACKING_TTL_SECONDS - 60
)
async def test_stops_once_the_socket_is_gone(self, mocker):
mocker.patch.object(
socket_handler.socket_server.manager, "is_connected", return_value=False
)
await devices._keep_tracked("sid-1", BOUND)
assert not await async_cache.exists("device_presence:dev-1")
assert not await async_cache.exists("device_token_sockets:42")
async def test_closes_the_socket_once_its_token_expires(self, mocker):
mocker.patch.object(
socket_handler.socket_server.manager,
"is_connected",
side_effect=[True, False],
)
disconnect = mocker.patch.object(
socket_handler.socket_server, "disconnect", AsyncMock()
)
expired = BOUND._replace(
expires_at=datetime.now(timezone.utc) - timedelta(seconds=1)
)
await devices._keep_tracked("sid-1", expired)
disconnect.assert_awaited_once_with("sid-1", namespace=NAMESPACE)
assert not await async_cache.exists("device_presence:dev-1")
async def test_keeps_a_socket_whose_token_has_not_expired(self, mocker):
mocker.patch.object(
socket_handler.socket_server.manager,
"is_connected",
side_effect=[True, False],
)
disconnect = mocker.patch.object(
socket_handler.socket_server, "disconnect", AsyncMock()
)
later = BOUND._replace(
expires_at=datetime.now(timezone.utc) + timedelta(days=1)
)
await devices._keep_tracked("sid-1", later)
disconnect.assert_not_awaited()
assert await _members("device_presence:dev-1") == {"sid-1"}
class TestEmitToDevice:
async def test_cancelled_goes_to_the_devices_room(self, mocker):
emit = AsyncMock()
mocker.patch.object(
socket_handler, "write_manager", return_value=MagicMock(emit=emit)
)
request = InstallRequestSchema(
id="req-1",
user_id=1,
device_id="dev-1",
rom_id=7,
file_ids=[10],
status=InstallStatus.CANCELLED,
reason=None,
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc),
)
await devices.emit_install_cancelled(request)
emit.assert_awaited_once_with(
"install:cancelled",
{"id": "req-1", "rom_id": 7},
namespace=NAMESPACE,
room="device:dev-1",
)
class TestDisconnect:
async def test_forgets_the_socket(self, mocker):
await async_cache.sadd("device_token_sockets:42", "sid-1", "sid-2")
await async_cache.sadd("device_presence:dev-1", "sid-1")
mocker.patch.object(
socket_handler.socket_server,
"get_session",
AsyncMock(
return_value={
devices.CLIENT_TOKEN_ID_KEY: 42,
devices.DEVICE_ID_KEY: "dev-1",
}
),
)
await devices.disconnect("sid-1")
assert await _members("device_token_sockets:42") == {"sid-2"}
assert await devices.online_device_ids(["dev-1"]) == []
def test_is_registered_on_the_devices_namespace_only(self):
handlers = socket_handler.socket_server.handlers
assert handlers[NAMESPACE]["disconnect"] is devices.disconnect
assert handlers[NAMESPACE]["connect"] is devices.connect
assert handlers["/"]["connect"] is not devices.connect
class TestPresence:
async def test_a_device_with_a_fresh_presence_is_online(self):
await async_cache.sadd("device_presence:dev-1", "sid-1")
assert await devices.online_device_ids(["dev-1", "dev-2"]) == ["dev-1"]
async def test_keeps_the_callers_order(self):
await async_cache.sadd("device_presence:dev-1", "sid-1")
await async_cache.sadd("device_presence:dev-2", "sid-2")
assert await devices.online_device_ids(["dev-2", "dev-1"]) == [
"dev-2",
"dev-1",
]
async def test_a_revocation_entry_alone_is_not_presence(self):
await async_cache.sadd("device_token_sockets:42", "sid-1")
assert await devices.online_device_ids(["dev-1"]) == []
async def test_no_devices_asks_nothing(self):
assert await devices.online_device_ids([]) == []
class TestEmit:
@pytest.fixture
def manager(self, mocker) -> MagicMock:
manager = MagicMock(emit=AsyncMock())
mocker.patch.object(socket_handler, "write_manager", return_value=manager)
return manager
def _request(self) -> InstallRequestSchema:
now = datetime.now(timezone.utc)
return InstallRequestSchema(
id="r1",
user_id=5,
device_id="dev-1",
rom_id=7,
file_ids=[1],
status=InstallStatus.PENDING,
reason=None,
created_at=now,
updated_at=now,
)
async def test_queued_reaches_only_the_devices_room_on_its_namespace(self, manager):
await devices.emit_install_queued(self._request())
manager.emit.assert_awaited_once_with(
"install:queued",
{"id": "r1", "rom_id": 7},
namespace=NAMESPACE,
room="device:dev-1",
)
async def test_updated_reaches_the_owners_tabs(self, manager):
await devices.emit_install_updated(self._request())
event, payload = manager.emit.await_args.args
assert event == "install:updated"
assert payload["id"] == "r1"
assert manager.emit.await_args.kwargs == {"room": "user:5"}
@@ -73,7 +73,7 @@ def server(mocker) -> Mock:
"""The netplay socket server, with the session store stubbed per socket."""
sessions: dict[str, dict[str, Any]] = {}
async def get_session(sid: str) -> dict[str, Any]:
async def get_session(sid: str, namespace: str | None = None) -> dict[str, Any]:
if sid not in sessions:
raise KeyError(sid)
return sessions[sid]
@@ -1,9 +1,11 @@
from datetime import timedelta
from unittest.mock import AsyncMock
import pytest
from fastapi import status
from config import OAUTH_ACCESS_TOKEN_EXPIRE_SECONDS
from endpoints import client_tokens as client_tokens_endpoints
from handler.auth import auth_handler, oauth_handler
from handler.database import db_client_token_handler, db_user_handler
from handler.redis_handler import sync_cache
@@ -690,3 +692,73 @@ class TestClientTokenAdmin:
headers={"Authorization": f"Bearer {editor_access_token}"},
)
assert response.status_code == status.HTTP_403_FORBIDDEN
class TestRevocationClosesDeviceSockets:
@pytest.fixture
def close(self, mocker) -> AsyncMock:
close = AsyncMock()
mocker.patch.object(
client_tokens_endpoints, "close_client_token_sockets", close
)
return close
@pytest.fixture
def headers(self, access_token) -> dict[str, str]:
return {"Authorization": f"Bearer {access_token}"}
@pytest.fixture
def token_id(self, client, headers) -> int:
response = client.post(
"/api/client-tokens",
json={"name": "Handheld", "scopes": ["devices.read"]},
headers=headers,
)
assert response.status_code == status.HTTP_201_CREATED
return int(response.json()["id"])
def test_deleting_a_token(self, client, headers, token_id, close):
response = client.delete(f"/api/client-tokens/{token_id}", headers=headers)
assert response.status_code == status.HTTP_200_OK
close.assert_awaited_once_with([token_id])
def test_regenerating_a_token(self, client, headers, token_id, close):
response = client.put(
f"/api/client-tokens/{token_id}/regenerate", headers=headers
)
assert response.status_code == status.HTTP_200_OK
close.assert_awaited_once_with([token_id])
def test_exchanging_a_pair_code(self, client, headers, token_id, close):
code = client.post(
f"/api/client-tokens/{token_id}/pair", headers=headers
).json()["code"]
response = client.post("/api/client-tokens/exchange", json={"code": code})
assert response.status_code == status.HTTP_200_OK
close.assert_awaited_once_with([token_id])
def test_an_admin_revoking_a_token(
self, client, headers, editor_access_token, editor_user, close
):
token_id = client.post(
"/api/client-tokens",
json={"name": "Editor handheld", "scopes": ["devices.read"]},
headers={"Authorization": f"Bearer {editor_access_token}"},
).json()["id"]
response = client.delete(
f"/api/client-tokens/{token_id}/admin", headers=headers
)
assert response.status_code == status.HTTP_200_OK
close.assert_awaited_once_with([token_id])
def test_a_missing_token_closes_nothing(self, client, headers, close):
response = client.delete("/api/client-tokens/99999", headers=headers)
assert response.status_code == status.HTTP_404_NOT_FOUND
close.assert_not_awaited()
+195
View File
@@ -1,6 +1,13 @@
import asyncio
from unittest.mock import AsyncMock
import pytest
from fastapi import status
from endpoints import device as device_endpoints
from handler.database import db_device_handler
from handler.device_install_handler import device_install_handler
from handler.redis_handler import sync_cache
from models.device import Device
from models.user import User
@@ -170,6 +177,194 @@ class TestDeviceEndpoints:
assert response.status_code == status.HTTP_200_OK
assert response.json()["client_device_identifier"] == "install-uuid-abc123"
def test_deleting_a_device_closes_its_token_sockets(
self, mocker, client, access_token: str, admin_user: User, add_device_token
):
device = db_device_handler.add_device(
Device(id="test-device-sockets", user_id=admin_user.id, name="Handheld")
)
token, _ = add_device_token(admin_user, device.id, scopes="devices.read")
close = mocker.patch.object(
device_endpoints, "close_client_token_sockets", AsyncMock()
)
response = client.delete(
f"/api/devices/{device.id}",
headers={"Authorization": f"Bearer {access_token}"},
)
assert response.status_code == status.HTTP_204_NO_CONTENT
close.assert_awaited_once_with([token.id])
def test_deleting_a_device_drops_its_install_requests(
self, client, access_token: str, admin_user: User
):
device = db_device_handler.add_device(
Device(id="test-device-installs", user_id=admin_user.id, name="Handheld")
)
install, _ = asyncio.run(
device_install_handler.create(
user_id=admin_user.id, device_id=device.id, rom_id=7, file_ids=[1]
)
)
response = client.delete(
f"/api/devices/{device.id}",
headers={"Authorization": f"Bearer {access_token}"},
)
assert response.status_code == status.HTTP_204_NO_CONTENT
assert asyncio.run(device_install_handler.get(install.id)) is None
assert asyncio.run(device_install_handler.list_for_rom(admin_user.id, 7)) == []
class TestDeviceCapabilities:
def test_registering_stores_the_reported_capabilities(
self, client, access_token: str, admin_user: User
):
response = client.post(
"/api/devices",
json={"name": "Handheld", "capabilities": {"remote_install": True}},
headers={"Authorization": f"Bearer {access_token}"},
)
assert response.status_code == status.HTTP_201_CREATED
stored = db_device_handler.get_device(
device_id=response.json()["device_id"], user_id=admin_user.id
)
assert stored is not None
assert stored.capabilities == {"remote_install": True}
def test_registering_again_replaces_the_capabilities(
self, client, access_token: str, admin_user: User
):
body = {"name": "Handheld", "hostname": "thor", "platform": "android"}
headers = {"Authorization": f"Bearer {access_token}"}
first = client.post(
"/api/devices",
json={**body, "capabilities": {"remote_install": False}},
headers=headers,
)
again = client.post(
"/api/devices",
json={**body, "capabilities": {"remote_install": True}},
headers=headers,
)
assert again.status_code == status.HTTP_200_OK
assert again.json()["device_id"] == first.json()["device_id"]
stored = db_device_handler.get_device(
device_id=first.json()["device_id"], user_id=admin_user.id
)
assert stored is not None
assert stored.capabilities == {"remote_install": True}
def test_updating_replaces_the_capabilities(
self, client, access_token: str, admin_user: User
):
device = db_device_handler.add_device(
Device(id="test-device-caps", user_id=admin_user.id, name="Handheld")
)
response = client.put(
f"/api/devices/{device.id}",
json={"capabilities": {"remote_install": True}},
headers={"Authorization": f"Bearer {access_token}"},
)
assert response.status_code == status.HTTP_200_OK
assert response.json()["capabilities"] == {"remote_install": True}
def test_an_update_without_capabilities_keeps_them(
self, client, access_token: str, admin_user: User
):
device = db_device_handler.add_device(
Device(id="test-device-keep-caps", user_id=admin_user.id, name="Handheld")
)
headers = {"Authorization": f"Bearer {access_token}"}
client.put(
f"/api/devices/{device.id}",
json={"capabilities": {"remote_install": True}},
headers=headers,
)
response = client.put(
f"/api/devices/{device.id}",
json={"name": "Renamed", "client_version": "2.18.0"},
headers=headers,
)
assert response.status_code == status.HTTP_200_OK
assert response.json()["capabilities"] == {"remote_install": True}
stored = db_device_handler.get_device(
device_id=device.id, user_id=admin_user.id
)
assert stored is not None
assert stored.capabilities == {"remote_install": True}
def test_registering_again_without_capabilities_keeps_them(
self, client, access_token: str, admin_user: User
):
body = {"name": "Handheld", "hostname": "thor", "platform": "android"}
headers = {"Authorization": f"Bearer {access_token}"}
first = client.post(
"/api/devices",
json={**body, "capabilities": {"remote_install": True}},
headers=headers,
)
again = client.post("/api/devices", json=body, headers=headers)
assert again.status_code == status.HTTP_200_OK
stored = db_device_handler.get_device(
device_id=first.json()["device_id"], user_id=admin_user.id
)
assert stored is not None
assert stored.capabilities == {"remote_install": True}
@pytest.mark.parametrize(
"capabilities",
[
{"remote_install": "yes"},
{"": True},
{"x" * 65: True},
{f"flag{i}": True for i in range(33)},
],
)
def test_rejects_capabilities_that_are_not_named_flags(
self, client, access_token: str, capabilities
):
response = client.post(
"/api/devices",
json={"name": "Handheld", "capabilities": capabilities},
headers={"Authorization": f"Bearer {access_token}"},
)
assert response.status_code == status.HTTP_422_UNPROCESSABLE_CONTENT
class TestDevicePresence:
def test_lists_the_callers_devices_holding_a_socket(
self, client, access_token: str, admin_user: User, editor_user: User
):
for device_id, user in (
("dev-online", admin_user),
("dev-offline", admin_user),
("dev-editor", editor_user),
):
db_device_handler.add_device(Device(id=device_id, user_id=user.id))
sync_cache.sadd("device_presence:dev-online", "sid-1")
sync_cache.sadd("device_presence:dev-editor", "sid-2")
response = client.get(
"/api/devices/online",
headers={"Authorization": f"Bearer {access_token}"},
)
assert response.status_code == status.HTTP_200_OK
assert response.json() == ["dev-online"]
class TestDeviceUserIsolation:
def test_list_devices_only_returns_own_devices(
@@ -0,0 +1,541 @@
import asyncio
from unittest.mock import AsyncMock
import pytest
from fastapi import status
from endpoints import device_install
from endpoints.responses.device_install import InstallRequestSchema, InstallStatus
from handler.database import db_device_handler, db_platform_handler, db_rom_handler
from handler.database.base_handler import sync_session
from handler.device_install_handler import device_install_handler
from models.device import Device
from models.notification import NotificationKind, NotificationLevel
from models.permission import HiddenEntity, PermEntity
from models.platform import Platform
from models.rom import Rom, RomFile, RomFileCategory
from models.user import User
REMOTE_INSTALL = {"remote_install": True}
@pytest.fixture(autouse=True)
def emits(mocker):
return {
"queued": mocker.patch.object(
device_install, "emit_install_queued", AsyncMock()
),
"cancelled": mocker.patch.object(
device_install, "emit_install_cancelled", AsyncMock()
),
"updated": mocker.patch.object(
device_install, "emit_install_updated", AsyncMock()
),
"notify": mocker.patch.object(device_install, "notify", AsyncMock()),
}
@pytest.fixture
def device(admin_user: User) -> Device:
return db_device_handler.add_device(
Device(
id="dev-1",
user_id=admin_user.id,
name="Handheld",
capabilities=REMOTE_INSTALL,
)
)
@pytest.fixture
def other_device(admin_user: User) -> Device:
return db_device_handler.add_device(
Device(
id="dev-2",
user_id=admin_user.id,
name="TV box",
capabilities=REMOTE_INSTALL,
)
)
@pytest.fixture
def device_headers(
admin_user: User, device: Device, add_device_token
) -> dict[str, str]:
_, raw_token = add_device_token(admin_user, device.id)
return {"Authorization": f"Bearer {raw_token}"}
@pytest.fixture
def headers(access_token: str) -> dict[str, str]:
return {"Authorization": f"Bearer {access_token}"}
@pytest.fixture
def editor_headers(editor_access_token: str) -> dict[str, str]:
return {"Authorization": f"Bearer {editor_access_token}"}
@pytest.fixture
def editor_device(editor_user: User) -> Device:
return db_device_handler.add_device(
Device(
id="dev-editor",
user_id=editor_user.id,
name="Editor handheld",
capabilities=REMOTE_INSTALL,
)
)
def _hide_rom(rom: Rom, user: User) -> None:
with sync_session.begin() as s:
s.add(HiddenEntity(entity=PermEntity.ROMS, entity_id=rom.id, user_id=user.id))
def _add_file(
rom: Rom,
file_name: str,
category: RomFileCategory | None,
missing: bool = False,
) -> RomFile:
return db_rom_handler.add_rom_file(
RomFile(
rom_id=rom.id,
file_name=file_name,
file_path=rom.fs_path,
file_size_bytes=1,
category=category,
missing_from_fs=missing,
)
)
@pytest.fixture
def rom_files(rom: Rom) -> dict[str, RomFile]:
return {
"game": _add_file(rom, "game.bin", RomFileCategory.GAME),
"update": _add_file(rom, "update.nsp", RomFileCategory.UPDATE),
"soundtrack": _add_file(rom, "ost.flac", RomFileCategory.SOUNDTRACK),
"manual": _add_file(rom, "manual.pdf", RomFileCategory.MANUAL),
"gone": _add_file(rom, "gone.bin", RomFileCategory.GAME, missing=True),
}
def _create(client, headers, rom_id: int, device_id: str = "dev-1"):
return client.post(
f"/api/devices/{device_id}/installs",
json={"rom_id": rom_id},
headers=headers,
)
def _claim(client, headers, device_id: str = "dev-1"):
return client.post(f"/api/devices/{device_id}/installs/claim", headers=headers)
def _report(client, device_headers, request_id: str, new_status: str, **body):
return client.put(
f"/api/devices/dev-1/installs/{request_id}",
json={"status": new_status, **body},
headers=device_headers,
)
def _cancel(client, headers, request_id: str, device_id: str = "dev-1"):
return client.delete(
f"/api/devices/{device_id}/installs/{request_id}", headers=headers
)
def _stored(request_id: str) -> InstallRequestSchema | None:
return asyncio.run(device_install_handler.get(request_id))
def _live_on_device(device_id: str = "dev-1") -> list[InstallRequestSchema]:
return asyncio.run(device_install_handler.list_for_device(device_id))
def _taken(client, headers, device_headers, rom_id: int) -> str:
request_id = str(_create(client, headers, rom_id).json()["id"])
assert _claim(client, device_headers).status_code == status.HTTP_200_OK
return request_id
class TestCreate:
def test_queues_the_installable_files_and_signals_the_device(
self, client, headers, device, rom, rom_files, emits
):
response = _create(client, headers, rom.id)
assert response.status_code == status.HTTP_201_CREATED
body = response.json()
assert body["status"] == "pending"
assert body["device_id"] == device.id
assert body["file_ids"] == sorted(
[rom_files["game"].id, rom_files["update"].id]
)
emits["queued"].assert_awaited_once()
assert emits["queued"].await_args.args[0].id == body["id"]
def test_a_second_tick_returns_the_request_already_queued(
self, client, headers, device, rom, rom_files, emits
):
first = _create(client, headers, rom.id).json()
response = _create(client, headers, rom.id)
assert response.status_code == status.HTTP_200_OK
assert response.json()["id"] == first["id"]
emits["queued"].assert_awaited_once()
def test_rejects_a_rom_with_nothing_installable(self, client, headers, device, rom):
_add_file(rom, "ost.flac", RomFileCategory.SOUNDTRACK)
_add_file(rom, "gone.bin", RomFileCategory.GAME, missing=True)
response = _create(client, headers, rom.id)
assert response.status_code == status.HTTP_400_BAD_REQUEST
def test_rejects_an_excluded_platform(self, client, headers, device):
platform = db_platform_handler.add_platform(
Platform(name="Windows", slug="win", fs_slug="win")
)
rom = db_rom_handler.add_rom(
Rom(
platform_id=platform.id,
name="setup",
slug="setup",
fs_name="setup.exe",
fs_name_no_tags="setup",
fs_name_no_ext="setup",
fs_extension="exe",
fs_path="win/roms",
)
)
_add_file(rom, "setup.exe", RomFileCategory.GAME)
response = _create(client, headers, rom.id)
assert response.status_code == status.HTTP_400_BAD_REQUEST
def test_a_hidden_rom_is_not_found(
self, client, editor_headers, editor_device, editor_user, rom, rom_files, emits
):
_hide_rom(rom, editor_user)
response = _create(client, editor_headers, rom.id, device_id=editor_device.id)
assert response.status_code == status.HTTP_404_NOT_FOUND
emits["queued"].assert_not_awaited()
def test_another_users_device_is_not_found(
self, client, editor_headers, device, rom, rom_files
):
response = _create(client, editor_headers, rom.id)
assert response.status_code == status.HTTP_404_NOT_FOUND
@pytest.mark.parametrize(
"capabilities", [None, {}, {"remote_install": False}, {"sync": True}]
)
def test_rejects_a_device_that_does_not_accept_remote_installs(
self, client, headers, admin_user, rom, rom_files, emits, capabilities
):
db_device_handler.add_device(
Device(
id="dev-sync",
user_id=admin_user.id,
name="Sync only",
capabilities=capabilities,
)
)
response = _create(client, headers, rom.id, device_id="dev-sync")
assert response.status_code == status.HTTP_400_BAD_REQUEST
emits["queued"].assert_not_awaited()
def test_a_device_deleted_during_the_create_keeps_no_request(
self, mocker, client, headers, admin_user, device, rom, rom_files, emits
):
real_create = device_install_handler.create
async def create_then_delete_device(
user_id: int, device_id: str, rom_id: int, file_ids: list[int]
) -> tuple[InstallRequestSchema, bool]:
created = await real_create(
user_id=user_id, device_id=device_id, rom_id=rom_id, file_ids=file_ids
)
db_device_handler.delete_device(device_id=device_id, user_id=user_id)
return created
mocker.patch.object(
device_install_handler, "create", side_effect=create_then_delete_device
)
response = _create(client, headers, rom.id)
assert response.status_code == status.HTTP_404_NOT_FOUND
assert _live_on_device() == []
emits["queued"].assert_not_awaited()
def test_is_not_found_while_disabled(
self, mocker, client, headers, device, rom, rom_files
):
mocker.patch.object(device_install, "DEVICE_INSTALL_ENABLED", False)
response = _create(client, headers, rom.id)
assert response.status_code == status.HTTP_404_NOT_FOUND
class TestList:
def test_the_device_reads_its_live_requests(
self, client, headers, device_headers, device, rom, second_rom, rom_files
):
taken = _taken(client, headers, device_headers, rom.id)
_add_file(second_rom, "other.bin", RomFileCategory.GAME)
pending = _create(client, headers, second_rom.id).json()["id"]
response = client.get("/api/devices/dev-1/installs", headers=device_headers)
assert response.status_code == status.HTTP_200_OK
assert [(r["id"], r["status"]) for r in response.json()] == [
(taken, "taken"),
(pending, "pending"),
]
def test_a_device_token_cannot_read_another_device(
self, client, device_headers, other_device
):
response = client.get("/api/devices/dev-2/installs", headers=device_headers)
assert response.status_code == status.HTTP_404_NOT_FOUND
class TestClaim:
def test_takes_and_returns_the_pending_requests(
self, client, headers, device_headers, device, rom, rom_files, emits
):
created = _create(client, headers, rom.id).json()
response = _claim(client, device_headers)
assert response.status_code == status.HTTP_200_OK
assert [(r["id"], r["status"]) for r in response.json()] == [
(created["id"], "taken")
]
stored = _stored(created["id"])
assert stored is not None and stored.status == InstallStatus.TAKEN
assert emits["updated"].await_args.args[0].status == "taken"
def test_a_second_claim_returns_the_taken_request_without_signalling_it(
self, client, headers, device_headers, device, rom, rom_files, emits
):
request_id = _create(client, headers, rom.id).json()["id"]
_claim(client, device_headers)
emits["updated"].reset_mock()
response = _claim(client, device_headers)
assert response.status_code == status.HTTP_200_OK
assert [(r["id"], r["status"]) for r in response.json()] == [
(request_id, "taken")
]
emits["updated"].assert_not_awaited()
def test_a_reported_request_is_not_claimed_again(
self, client, headers, device_headers, device, rom, rom_files
):
request_id = _taken(client, headers, device_headers, rom.id)
_report(client, device_headers, request_id, "done")
response = _claim(client, device_headers)
assert response.status_code == status.HTTP_200_OK
assert response.json() == []
def test_a_cancelled_request_is_never_claimed(
self, client, headers, device_headers, device, rom, rom_files
):
request_id = _create(client, headers, rom.id).json()["id"]
_cancel(client, headers, request_id)
response = _claim(client, device_headers)
assert response.status_code == status.HTTP_200_OK
assert response.json() == []
def test_another_devices_token_is_refused(
self, client, headers, device_headers, device, other_device, rom, rom_files
):
_create(client, headers, rom.id, device_id="dev-2")
response = _claim(client, device_headers, device_id="dev-2")
assert response.status_code == status.HTTP_404_NOT_FOUND
def test_the_web_ui_cannot_claim(self, client, headers, device, rom, rom_files):
_create(client, headers, rom.id)
response = _claim(client, headers)
assert response.status_code == status.HTTP_403_FORBIDDEN
def test_a_token_that_cannot_download_leaves_requests_pending(
self, client, headers, admin_user, device, rom, rom_files, add_device_token
):
request_id = _create(client, headers, rom.id).json()["id"]
_, raw = add_device_token(
admin_user, device.id, scopes="devices.read devices.write"
)
response = _claim(client, {"Authorization": f"Bearer {raw}"})
assert response.status_code == status.HTTP_403_FORBIDDEN
stored = _stored(request_id)
assert stored is not None and stored.status == InstallStatus.PENDING
class TestDeviceReport:
def test_a_failure_ends_the_request_and_notifies_the_owner(
self, client, headers, device_headers, admin_user, device, rom, rom_files, emits
):
request_id = _taken(client, headers, device_headers, rom.id)
response = _report(
client, device_headers, request_id, "failed", reason="no space"
)
assert response.status_code == status.HTTP_200_OK
assert response.json()["status"] == "failed"
assert response.json()["reason"] == "no space"
assert _stored(request_id) is None
assert _live_on_device() == []
assert emits["updated"].await_args.args[0].status == "failed"
emits["notify"].assert_awaited_once()
user_id, kind, level, data = emits["notify"].await_args.args
assert user_id == admin_user.id
assert kind == NotificationKind.DEVICE_INSTALL_FAILED
assert level == NotificationLevel.ERROR
assert data["reason"] == "no space"
assert data["rom_id"] == rom.id
assert data["device_name"] == "Handheld"
@pytest.mark.parametrize("outcome", ["done", "already_installed"])
def test_a_completion_notifies_the_owner(
self, client, headers, device_headers, device, rom, rom_files, emits, outcome
):
request_id = _taken(client, headers, device_headers, rom.id)
_report(client, device_headers, request_id, outcome)
_, kind, level, data = emits["notify"].await_args.args
assert kind == NotificationKind.DEVICE_INSTALL_COMPLETED
assert level == NotificationLevel.SUCCESS
assert data["status"] == outcome
def test_claiming_a_request_notifies_nobody(
self, client, headers, device_headers, device, rom, rom_files, emits
):
_taken(client, headers, device_headers, rom.id)
emits["notify"].assert_not_awaited()
def test_a_repeated_report_is_not_found_and_notifies_once(
self, client, headers, device_headers, device, rom, rom_files, emits
):
request_id = _taken(client, headers, device_headers, rom.id)
first = _report(client, device_headers, request_id, "done")
again = _report(client, device_headers, request_id, "done")
assert first.status_code == status.HTTP_200_OK
assert again.status_code == status.HTTP_404_NOT_FOUND
emits["notify"].assert_awaited_once()
def test_a_pending_request_is_a_conflict(
self, client, headers, device_headers, device, rom, rom_files, emits
):
request_id = _create(client, headers, rom.id).json()["id"]
response = _report(client, device_headers, request_id, "done")
assert response.status_code == status.HTTP_409_CONFLICT
stored = _stored(request_id)
assert stored is not None and stored.status == InstallStatus.PENDING
emits["notify"].assert_not_awaited()
def test_the_web_ui_cannot_report(
self, client, headers, device_headers, device, rom, rom_files
):
request_id = _taken(client, headers, device_headers, rom.id)
response = _report(client, headers, request_id, "done")
assert response.status_code == status.HTTP_403_FORBIDDEN
@pytest.mark.parametrize("reported", ["pending", "taken", "cancelled"])
def test_a_device_cannot_report_a_status_it_does_not_own(
self, client, headers, device_headers, device, rom, rom_files, reported
):
request_id = _taken(client, headers, device_headers, rom.id)
response = _report(client, device_headers, request_id, reported)
assert response.status_code == status.HTTP_422_UNPROCESSABLE_CONTENT
class TestCancel:
@pytest.mark.parametrize("claim", [False, True])
def test_cancels_a_live_request_and_deletes_it(
self, client, headers, device_headers, device, rom, rom_files, emits, claim
):
request_id = (
_taken(client, headers, device_headers, rom.id)
if claim
else _create(client, headers, rom.id).json()["id"]
)
response = _cancel(client, headers, request_id)
assert response.status_code == status.HTTP_200_OK
assert response.json()["status"] == "cancelled"
assert _stored(request_id) is None
assert emits["updated"].await_args.args[0].status == "cancelled"
emits["cancelled"].assert_awaited_once()
cancelled = emits["cancelled"].await_args
assert cancelled is not None
assert cancelled.args[0].id == request_id
emits["notify"].assert_not_awaited()
def test_the_device_report_after_a_cancel_is_not_found(
self, client, headers, device_headers, device, rom, rom_files
):
request_id = _taken(client, headers, device_headers, rom.id)
_cancel(client, headers, request_id)
response = _report(client, device_headers, request_id, "done")
assert response.status_code == status.HTTP_404_NOT_FOUND
def test_an_ended_request_cannot_be_cancelled(
self, client, headers, device_headers, device, rom, rom_files
):
request_id = _taken(client, headers, device_headers, rom.id)
_report(client, device_headers, request_id, "done")
response = _cancel(client, headers, request_id)
assert response.status_code == status.HTTP_404_NOT_FOUND
def test_a_request_on_another_device_path_is_not_found(
self, client, headers, device, other_device, rom, rom_files
):
request_id = _create(client, headers, rom.id).json()["id"]
response = _cancel(client, headers, request_id, device_id="dev-2")
assert response.status_code == status.HTTP_404_NOT_FOUND
+125 -1
View File
@@ -1,3 +1,4 @@
import asyncio
import base64
import json
import threading
@@ -14,14 +15,23 @@ from fastapi.testclient import TestClient
from joserfc.errors import InvalidClaimError
from config import OAUTH_ACCESS_TOKEN_EXPIRE_SECONDS
from endpoints import permissions as permissions_endpoints
from endpoints import user as user_endpoints
from handler.auth import auth_handler
from handler.auth import base_handler as auth_handler_module
from handler.auth import oauth_handler
from handler.auth.constants import SESSION_COOKIE_NAME
from handler.auth.middleware.redis_session_middleware import RedisSessionMiddleware
from handler.database import db_notification_handler
from handler.database import (
db_client_token_handler,
db_device_handler,
db_notification_handler,
)
from handler.database.users_handler import DBUsersHandler
from handler.device_install_handler import device_install_handler
from handler.redis_handler import async_cache, redis_client
from models.client_token import ClientToken
from models.device import Device
from models.notification import NotificationKind
from models.user import Role, User
@@ -461,6 +471,120 @@ def test_delete_user(client, access_token: str, editor_user: User):
assert response.status_code == HTTPStatus.OK
def test_delete_user_closes_their_device_sockets(
mocker, client, access_token: str, editor_user: User
):
token = db_client_token_handler.add_token(
ClientToken(
user_id=editor_user.id,
name="Handheld",
hashed_token=auth_handler.hash_client_token("rmm_deleted_user_device"),
scopes="devices.read",
)
)
close = mocker.patch.object(
user_endpoints, "close_client_token_sockets", mock.AsyncMock()
)
response = client.delete(
f"/api/users/{editor_user.id}",
headers={"Authorization": f"Bearer {access_token}"},
)
assert response.status_code == HTTPStatus.OK
close.assert_awaited_once_with([token.id])
def test_delete_user_drops_their_install_requests(
client, access_token: str, editor_user: User
):
db_device_handler.add_device(
Device(id="deleted-user-device", user_id=editor_user.id, name="Handheld")
)
install, _ = asyncio.run(
device_install_handler.create(
user_id=editor_user.id,
device_id="deleted-user-device",
rom_id=7,
file_ids=[1],
)
)
response = client.delete(
f"/api/users/{editor_user.id}",
headers={"Authorization": f"Bearer {access_token}"},
)
assert response.status_code == HTTPStatus.OK
assert asyncio.run(device_install_handler.get(install.id)) is None
def _editor_device_token(editor_user: User) -> ClientToken:
return db_client_token_handler.add_token(
ClientToken(
user_id=editor_user.id,
name="Handheld",
hashed_token=auth_handler.hash_client_token("rmm_updated_user_device"),
scopes="devices.read",
)
)
def test_disabling_a_user_closes_their_device_sockets(
mocker, client, access_token: str, editor_user: User
):
token = _editor_device_token(editor_user)
close = mocker.patch.object(
user_endpoints, "close_client_token_sockets", mock.AsyncMock()
)
response = client.put(
f"/api/users/{editor_user.id}",
data={"enabled": "false"},
headers={"Authorization": f"Bearer {access_token}"},
)
assert response.status_code == HTTPStatus.OK
close.assert_awaited_once_with([token.id])
def test_changing_a_users_role_closes_their_device_sockets(
mocker, client, access_token: str, editor_user: User
):
token = _editor_device_token(editor_user)
close = mocker.patch.object(
permissions_endpoints, "close_client_token_sockets", mock.AsyncMock()
)
response = client.put(
f"/api/users/{editor_user.id}",
data={"role": "admin"},
headers={"Authorization": f"Bearer {access_token}"},
)
assert response.status_code == HTTPStatus.OK
close.assert_awaited_once_with([token.id])
@pytest.mark.parametrize("form", [{"ra_username": "someone"}, {"enabled": "true"}])
def test_other_user_edits_leave_their_device_sockets_open(
mocker, client, access_token: str, editor_user: User, form
):
_editor_device_token(editor_user)
close = mocker.patch.object(
user_endpoints, "close_client_token_sockets", mock.AsyncMock()
)
response = client.put(
f"/api/users/{editor_user.id}",
data=form,
headers={"Authorization": f"Bearer {access_token}"},
)
assert response.status_code == HTTPStatus.OK
close.assert_not_awaited()
@pytest.mark.asyncio
async def test_admin_password_reset_invalidates_the_target_user_sessions(
client, access_token: str, editor_user: User
@@ -2,12 +2,15 @@
end-to-end effect on a member's /permissions/me."""
from datetime import timedelta
from unittest.mock import AsyncMock
from config import OAUTH_ACCESS_TOKEN_EXPIRE_SECONDS
from handler.auth import oauth_handler
from handler.database import db_user_handler
from endpoints import permissions as permissions_endpoints
from handler.auth import auth_handler, oauth_handler
from handler.database import db_client_token_handler, db_user_handler
from handler.database.base_handler import sync_session
from handler.database.permissions_handler import DBPermissionsHandler
from models.client_token import ClientToken
from models.permission import PermissionGroup
@@ -134,6 +137,31 @@ def test_user_override_adds_capability(client, access_token, viewer_user):
_cleanup()
def test_a_permission_change_closes_the_users_device_sockets(
mocker, client, access_token, viewer_user
):
token = db_client_token_handler.add_token(
ClientToken(
user_id=viewer_user.id,
name="Handheld",
hashed_token=auth_handler.hash_client_token("rmm_regrouped_user_device"),
scopes="devices.read",
)
)
close = mocker.patch.object(
permissions_endpoints, "close_client_token_sockets", AsyncMock()
)
response = client.put(
f"/api/permissions/users/{viewer_user.id}",
headers=_bearer(access_token),
json={"overrides": [{"entity": "roms", "action": "delete", "granted": True}]},
)
assert response.status_code == 200
close.assert_awaited_once_with([token.id])
def test_hide_entity_for_user(client, access_token, viewer_user):
resp = client.post(
"/api/permissions/hidden",
@@ -87,6 +87,37 @@ def base_url(mocker):
"discord.com answered 404",
"https://romm.example.com/notifications?tab=channels",
),
(
NotificationKind.DEVICE_INSTALL_COMPLETED,
{
"rom_name": "Zelda",
"rom_id": 12,
"device_name": "Thor",
"status": "done",
},
"Zelda was added to downloads on Thor",
None,
"https://romm.example.com/rom/12",
),
(
NotificationKind.DEVICE_INSTALL_COMPLETED,
{
"rom_name": "Zelda",
"rom_id": 12,
"device_name": "Thor",
"status": "already_installed",
},
"Zelda is already installed on Thor",
None,
"https://romm.example.com/rom/12",
),
(
NotificationKind.DEVICE_INSTALL_FAILED,
{"rom_name": "Zelda", "rom_id": 12, "reason": "No space left"},
"Zelda could not be installed on your device",
"No space left",
"https://romm.example.com/rom/12",
),
],
)
def test_words_romms_own_kinds_in_english(kind, data, title, body, url):
@@ -0,0 +1,324 @@
import pytest
from endpoints.responses.device_install import InstallRequestSchema, InstallStatus
from handler.device_install_handler import (
DeviceInstallHandler,
InstallTransitionError,
)
from handler.redis_handler import as_text, async_cache, sync_cache
TWO_DAYS = 2 * 24 * 60 * 60
OUTCOMES = [
InstallStatus.DONE,
InstallStatus.ALREADY_INSTALLED,
InstallStatus.FAILED,
]
@pytest.fixture(autouse=True)
def clear_cache():
sync_cache.flushall()
yield
sync_cache.flushall()
@pytest.fixture
def handler() -> DeviceInstallHandler:
return DeviceInstallHandler(ttl_days=2)
async def _create(
handler: DeviceInstallHandler, device_id: str = "dev-1", rom_id: int = 7
) -> InstallRequestSchema:
request, created = await handler.create(
user_id=1, device_id=device_id, rom_id=rom_id, file_ids=[10, 11]
)
assert created
return request
async def _taken(
handler: DeviceInstallHandler, device_id: str = "dev-1", rom_id: int = 7
) -> InstallRequestSchema:
request = await _create(handler, device_id, rom_id)
await handler.claim(device_id)
return request
async def _active_id(rom_id: int) -> str | None:
value = await async_cache.get(f"install:active:1:dev-1:{rom_id}")
return None if value is None else as_text(value)
async def _assert_gone(request: InstallRequestSchema) -> None:
assert not await async_cache.exists(f"install:req:{request.id}")
assert not await async_cache.exists(
f"install:active:{request.user_id}:{request.device_id}:{request.rom_id}"
)
assert not await async_cache.sismember(
f"install:device:{request.device_id}", request.id
)
assert not await async_cache.sismember(
f"install:rom:{request.user_id}:{request.rom_id}", request.id
)
class TestLifecycle:
async def test_a_new_request_is_listed_for_its_device_and_its_rom(self, handler):
request = await _create(handler)
assert request.status == InstallStatus.PENDING
assert [r.id for r in await handler.list_for_device("dev-1")] == [request.id]
assert await handler.list_for_device("dev-2") == []
assert [r.id for r in await handler.list_for_rom(1, 7)] == [request.id]
assert await handler.get(request.id) == request
async def test_a_live_request_expires_after_the_configured_days(self, handler):
request = await _create(handler)
for key in (
f"install:req:{request.id}",
"install:device:dev-1",
"install:rom:1:7",
"install:active:1:dev-1:7",
):
assert 0 < await async_cache.ttl(key) <= TWO_DAYS
async def test_an_unlimited_ttl_keeps_a_live_request(self):
handler = DeviceInstallHandler(ttl_days=0)
request = await _taken(handler)
assert await async_cache.ttl(f"install:req:{request.id}") == -1
@pytest.mark.parametrize("outcome", OUTCOMES)
async def test_a_report_ends_a_taken_request_and_deletes_it(self, handler, outcome):
request = await _taken(handler)
ended = await handler.report(request.id, outcome, "no space")
assert (ended.id, ended.status, ended.reason) == (
request.id,
outcome,
"no space",
)
await _assert_gone(request)
assert await handler.list_for_device("dev-1") == []
async def test_a_pending_request_cannot_be_reported(self, handler):
request = await _create(handler)
with pytest.raises(InstallTransitionError):
await handler.report(request.id, InstallStatus.DONE, None)
stored = await handler.get(request.id)
assert stored is not None and stored.status == InstallStatus.PENDING
@pytest.mark.parametrize("claim", [False, True])
async def test_a_live_request_can_be_cancelled(self, handler, claim):
request = await (_taken if claim else _create)(handler)
cancelled = await handler.cancel(request.id)
assert cancelled.status == InstallStatus.CANCELLED
await _assert_gone(request)
async def test_a_repeated_report_finds_nothing(self, handler):
request = await _taken(handler)
await handler.report(request.id, InstallStatus.DONE, None)
with pytest.raises(KeyError):
await handler.report(request.id, InstallStatus.DONE, None)
async def test_a_missing_request_is_a_key_error(self, handler):
with pytest.raises(KeyError):
await handler.cancel("gone")
async def test_ending_leaves_another_requests_dedupe_key_alone(self, handler):
request = await _create(handler)
await async_cache.set("install:active:1:dev-1:7", "other")
await handler.cancel(request.id)
assert await _active_id(7) == "other"
async def test_an_expired_request_drops_out_of_every_list(self, handler):
request = await _create(handler)
await async_cache.delete(f"install:req:{request.id}")
assert await handler.list_for_device("dev-1") == []
assert await handler.list_for_rom(1, 7) == []
assert not await async_cache.sismember("install:device:dev-1", request.id)
class TestEndRace:
async def test_a_cancel_between_the_read_and_the_commit_wins(self, mocker, handler):
request = await _taken(handler)
real_read = handler._read
cancelled_once = False
async def read_then_cancel(client, request_ids):
nonlocal cancelled_once
current = await real_read(client, request_ids)
if not cancelled_once:
cancelled_once = True
await async_cache.delete(f"install:req:{request.id}")
return current
mocker.patch.object(handler, "_read", side_effect=read_then_cancel)
with pytest.raises(KeyError):
await handler.report(request.id, InstallStatus.DONE, None)
class TestClaim:
async def test_takes_and_returns_every_pending_request(self, handler):
first = await _create(handler, rom_id=7)
second = await _create(handler, rom_id=8)
other_device = await _create(handler, device_id="dev-2", rom_id=7)
claim = await handler.claim("dev-1")
assert [r.id for r in claim.taken] == [first.id, second.id]
assert claim.newly_taken == claim.taken
assert {r.status for r in claim.taken} == {InstallStatus.TAKEN}
stored = await handler.get(first.id)
assert stored is not None and stored.status == InstallStatus.TAKEN
untouched = await handler.get(other_device.id)
assert untouched is not None and untouched.status == InstallStatus.PENDING
async def test_a_second_claim_returns_the_taken_requests_again(self, handler):
request = await _create(handler)
first = await handler.claim("dev-1")
again = await handler.claim("dev-1")
assert again.taken == first.taken
assert [r.id for r in again.taken] == [request.id]
assert again.newly_taken == []
async def test_returns_earlier_and_new_requests_oldest_first(self, handler):
earlier = await _taken(handler, rom_id=7)
later = await _create(handler, rom_id=8)
claim = await handler.claim("dev-1")
assert [r.id for r in claim.taken] == [earlier.id, later.id]
assert [r.id for r in claim.newly_taken] == [later.id]
async def test_a_reported_request_stops_coming_back(self, handler):
request = await _taken(handler)
await handler.report(request.id, InstallStatus.DONE, None)
assert await handler.claim("dev-1") == ([], [])
async def test_renews_only_the_dedupe_keys_its_requests_still_own(self, handler):
first = await _create(handler, rom_id=7)
second = await _create(handler, rom_id=8)
await async_cache.set("install:active:1:dev-1:7", "other")
claim = await handler.claim("dev-1")
assert [r.id for r in claim.newly_taken] == [first.id, second.id]
assert await _active_id(7) == "other"
assert await _active_id(8) == second.id
assert 0 < await async_cache.ttl("install:active:1:dev-1:8") <= TWO_DAYS
@pytest.mark.parametrize("with_pending", [False, True])
async def test_drops_expired_ids_from_the_device_index(self, handler, with_pending):
expired = await _create(handler, rom_id=7)
await async_cache.delete(f"install:req:{expired.id}")
if with_pending:
await _create(handler, rom_id=8)
await handler.claim("dev-1")
assert not await async_cache.sismember("install:device:dev-1", expired.id)
async def test_leaves_cancelled_requests_alone(self, handler):
request = await _create(handler)
await handler.cancel(request.id)
assert await handler.claim("dev-1") == ([], [])
async def test_a_cancel_between_the_read_and_the_commit_is_never_returned(
self, mocker, handler
):
request = await _create(handler)
real_read = handler._read
cancelled_once = False
async def read_then_cancel(client, request_ids):
nonlocal cancelled_once
current = await real_read(client, request_ids)
if not cancelled_once:
cancelled_once = True
await handler.cancel(request.id)
return current
mocker.patch.object(handler, "_read", side_effect=read_then_cancel)
claim = await handler.claim("dev-1")
assert claim == ([], [])
assert await handler.get(request.id) is None
class TestDiscardForDevice:
async def test_drops_every_live_request_of_the_device(self, handler):
pending = await _create(handler, rom_id=7)
taken = await _taken(handler, rom_id=8)
other_device = await _create(handler, device_id="dev-2", rom_id=7)
await handler.discard_for_device("dev-1")
await _assert_gone(pending)
await _assert_gone(taken)
assert not await async_cache.exists("install:device:dev-1")
assert [r.id for r in await handler.list_for_rom(1, 7)] == [other_device.id]
async def test_a_device_without_requests_is_a_no_op(self, handler):
await handler.discard_for_device("dev-1")
assert not await async_cache.exists("install:device:dev-1")
async def test_a_broker_failure_leaves_the_device_deletion_standing(
self, mocker, handler
):
mocker.patch.object(
handler, "list_for_device", side_effect=ConnectionError("redis")
)
await handler.discard_for_device("dev-1")
class TestDedupe:
@pytest.mark.parametrize("claim", [False, True])
async def test_a_second_create_returns_the_live_request(self, handler, claim):
first = await (_taken if claim else _create)(handler)
second, created = await handler.create(
user_id=1, device_id="dev-1", rom_id=7, file_ids=[10]
)
assert not created
assert second.id == first.id
assert len(await handler.list_for_device("dev-1")) == 1
async def test_each_device_gets_its_own_request(self, handler):
first = await _create(handler, device_id="dev-1")
second = await _create(handler, device_id="dev-2")
assert first.id != second.id
@pytest.mark.parametrize("outcome", [*OUTCOMES, InstallStatus.CANCELLED])
async def test_an_ended_request_frees_the_rom_for_a_new_one(self, handler, outcome):
first = await _taken(handler)
if outcome == InstallStatus.CANCELLED:
await handler.cancel(first.id)
else:
await handler.report(first.id, outcome, None)
second = await _create(handler)
assert second.id != first.id
assert await _active_id(7) == second.id
@@ -0,0 +1,65 @@
import pytest
from handler import device_install_policy as policy_module
from handler.device_install_policy import (
InstallNotAllowedError,
is_installable_platform,
select_install_files,
)
from models.rom import RomFile, RomFileCategory
def _file(file_id: int, category: RomFileCategory | None, missing: bool = False):
return RomFile(
id=file_id,
file_name=f"f{file_id}",
file_path="p",
category=category,
missing_from_fs=missing,
)
FILES = [
_file(4, RomFileCategory.DLC),
_file(1, None),
_file(2, RomFileCategory.GAME),
_file(3, RomFileCategory.UPDATE),
_file(5, RomFileCategory.PATCH),
_file(6, RomFileCategory.SOUNDTRACK),
_file(7, RomFileCategory.SCREENSHOT),
_file(8, RomFileCategory.CHEAT),
_file(9, RomFileCategory.MANUAL),
_file(10, RomFileCategory.WALKTHROUGH),
_file(11, RomFileCategory.GAME, missing=True),
]
class TestFileSelection:
def test_takes_the_game_update_and_dlc_files_on_disk_in_order(self):
assert select_install_files(FILES) == [1, 2, 3, 4]
def test_rejects_a_rom_with_nothing_installable(self):
with pytest.raises(InstallNotAllowedError):
select_install_files(
[
_file(6, RomFileCategory.SOUNDTRACK),
_file(11, RomFileCategory.GAME, missing=True),
]
)
class TestPlatformExclusion:
@pytest.mark.parametrize("slug", ["win", "win3x", "win9x", "windows-apps", "WIN"])
def test_windows_platforms_are_excluded_by_default(self, slug):
assert not is_installable_platform(slug)
def test_other_platforms_are_installable(self):
assert is_installable_platform("n64")
def test_the_configured_slugs_decide(self, monkeypatch):
monkeypatch.setattr(
policy_module, "DEVICE_INSTALL_EXCLUDED_PLATFORM_SLUGS", frozenset({"n64"})
)
assert not is_installable_platform("n64")
assert is_installable_platform("win")
+80 -1
View File
@@ -8,8 +8,10 @@ import socketio
from handler import socket_handler as socket_handler_module
from handler.database import db_user_handler
from handler.socket_handler import (
DEVICES_NAMESPACE,
LOGIN_SESSION_ID_KEY,
SocketHandler,
close_client_token_sockets,
close_login_session_sockets,
netplay_socket_handler,
socket_handler,
@@ -125,7 +127,7 @@ class TestLoginSessionSockets:
await handler.close_login_sessions(["s1"])
cache.delete.assert_awaited_once_with("session_sockets:socketio:s1")
disconnect.assert_awaited_once_with("sid-1")
disconnect.assert_awaited_once_with("sid-1", namespace=None)
async def test_a_broker_failure_moves_on_to_the_next_session(self, mocker, cache):
handler = SocketHandler(path="/test")
@@ -154,6 +156,83 @@ class TestLoginSessionSockets:
cache.sadd.assert_awaited_once_with("session_sockets:netplay:s1", "sid-1")
class TestTrackedSockets:
async def test_tracking_adds_the_socket_and_renews_the_ttl(self, cache):
handler = SocketHandler(path="/test")
await handler.track("some:key", "sid-1", 90)
cache.sadd.assert_awaited_once_with("some:key", "sid-1")
cache.expire.assert_awaited_once_with("some:key", 90)
async def test_untracking_drops_the_socket(self, cache):
handler = SocketHandler(path="/test")
await handler.untrack("some:key", "sid-1")
cache.srem.assert_awaited_once_with("some:key", "sid-1")
async def test_untracking_swallows_a_broker_failure(self, cache):
cache.srem.side_effect = ConnectionError("redis")
handler = SocketHandler(path="/test")
await handler.untrack("some:key", "sid-1")
async def test_closing_disconnects_on_the_given_namespace(self, mocker, cache):
handler = SocketHandler(path="/test")
disconnect = mocker.patch.object(
handler.socket_server, "disconnect", AsyncMock()
)
cache.smembers.return_value = {b"sid-1"}
await handler.close_tracked("some:key", "/devices")
cache.delete.assert_awaited_once_with("some:key")
disconnect.assert_awaited_once_with("sid-1", namespace="/devices")
async def test_a_failed_disconnect_still_closes_the_other_sockets(
self, mocker, cache
):
handler = SocketHandler(path="/test")
async def fail_for_the_first(sid: str, namespace: str | None = None) -> None:
if sid == "sid-1":
raise ConnectionError("broker")
disconnect = mocker.patch.object(
handler.socket_server,
"disconnect",
AsyncMock(side_effect=fail_for_the_first),
)
cache.smembers.return_value = {b"sid-1", b"sid-2"}
await handler.close_tracked("some:key", "/devices")
disconnect.assert_any_await("sid-2", namespace="/devices")
assert disconnect.await_count == 2
class TestClientTokenSockets:
async def test_closing_a_token_disconnects_its_device_sockets(self, mocker):
close = mocker.patch.object(socket_handler, "close_tracked", AsyncMock())
await close_client_token_sockets([42])
close.assert_awaited_once_with("device_token_sockets:42", DEVICES_NAMESPACE)
async def test_a_broker_failure_moves_on_to_the_next_token(self, mocker, cache):
cache.smembers.side_effect = [{b"sid-1"}, {b"sid-2"}]
disconnect = mocker.patch.object(
socket_handler.socket_server,
"disconnect",
AsyncMock(side_effect=[ConnectionError("redis"), None]),
)
await close_client_token_sockets([1, 2])
assert disconnect.await_count == 2
async def test_revoking_a_session_closes_its_sockets_on_every_server(mocker):
main = mocker.patch.object(socket_handler, "close_login_sessions", AsyncMock())
netplay = mocker.patch.object(
+17
View File
@@ -239,6 +239,7 @@ def _replay(connection: sa.Connection, filename: str) -> None:
("0138_exact_save_slots.py", "saves"),
("0139_rom_user_pinned_media.py", "rom_user"),
("0140_device_save_sync_baseline.py", "device_save_sync"),
("0141_device_capabilities.py", "devices"),
],
)
def test_a_revision_replayed_over_the_migrated_schema_is_a_no_op(
@@ -274,6 +275,22 @@ def test_the_play_session_sync_link_revision_reverses_and_replays():
assert _schema_of(connection, "play_sessions") == before
def test_the_device_capabilities_revision_reverses_and_replays():
migration = _load_migration("0141_device_capabilities.py")
with sync_engine.begin() as connection:
before = _schema_of(connection, "devices")
with Operations.context(MigrationContext.configure(connection)):
migration.downgrade()
assert not has_column(connection, "devices", "capabilities")
migration.downgrade()
migration.upgrade()
migration.upgrade()
assert _schema_of(connection, "devices") == before
def _slot_collations(connection: sa.Connection) -> dict[str, str | None]:
inspector = sa.inspect(connection)
collations = {}
+118 -28
View File
@@ -914,6 +914,12 @@ A reset link is emailed when SMTP is set up, the user has an address and `ROMM_B
| GET | `/{id}/files` | ROMS_READ | Get ROM file metadata |
| GET | `/{id}/files/content/{name}` | ROMS_READ | Download file (nginx X-Accel or direct) |
#### ROM Installs
| Method | Path | Scope | Description |
| ------ | ---------------- | ----------------------- | ---------------------------------------------- |
| GET | `/{id}/installs` | DEVICES_READ, ROMS_READ | The caller's live install requests for the ROM |
### 6.6 Music (`/api/music`)
Music-first read API over soundtrack `track_meta`, for external music-player clients. All routes are visibility-filtered (hidden platforms/ROMs excluded).
@@ -1020,13 +1026,21 @@ World`), so a name shared across platforms resolves to the lowest visible ROM id
### 6.11 Devices (`/api/devices`)
| Method | Path | Scope | Description |
| ------ | ------- | ------------- | ----------------------------------- |
| POST | `/` | DEVICES_WRITE | Register device (fingerprint dedup) |
| GET | `/` | DEVICES_READ | List devices |
| GET | `/{id}` | DEVICES_READ | Get device |
| PUT | `/{id}` | DEVICES_WRITE | Update device |
| DELETE | `/{id}` | DEVICES_WRITE | Delete device |
| Method | Path | Scope | Description |
| ------ | ------------------------- | ------------------------ | ------------------------------------------------------------- |
| POST | `/` | DEVICES_WRITE | Register device (fingerprint dedup) |
| GET | `/` | DEVICES_READ | List devices |
| GET | `/online` | DEVICES_READ | Ids of devices with a live device socket |
| GET | `/{id}` | DEVICES_READ | Get device |
| PUT | `/{id}` | DEVICES_WRITE | Update device |
| DELETE | `/{id}` | DEVICES_WRITE | Delete device |
| POST | `/{id}/installs` | DEVICES_WRITE, ROMS_READ | Queue a rom for install on the device |
| GET | `/{id}/installs` | DEVICES_READ | List pending and taken install requests |
| POST | `/{id}/installs/claim` | DEVICES_WRITE, ROMS_READ | Device-bound token takes every pending one, returns all taken |
| PUT | `/{id}/installs/{req_id}` | DEVICES_WRITE | Device-bound token reports a taken outcome |
| DELETE | `/{id}/installs/{req_id}` | DEVICES_WRITE | Cancel a pending or taken request |
Register and update take `capabilities`, boolean flags such as `{"remote_install": true}`, stored on `devices.capabilities`. A register or update that omits the field leaves the stored flags as they are.
### 6.12 Collections (`/api/collections`)
@@ -1396,7 +1410,7 @@ Manages two Socket.IO servers:
| `socket_handler` | `/ws` | Scan progress, general notifications |
| `netplay_socket_handler` | `/netplay` | Netplay room management |
Both use Redis as the message queue backend for horizontal scaling, each on its own channel: netplay clients are unauthenticated and name their own rooms, so they must never see the `user:{id}` or `admin` rooms. `socket_handler.emit_to_user(user_id, event, payload)` pushes to one user's open tabs from the web process or a worker.
Both use Redis as the message queue backend for horizontal scaling, each on its own channel: netplay clients are unauthenticated and name their own rooms, so they must never see the `user:{id}` or `admin` rooms. `socket_handler.emit_to_user(user_id, event, payload)` pushes to one user's open tabs from the web process or a worker. Registered devices connect to the `/devices` namespace of the `/ws` server (see Devices below); rooms and events are per namespace, so the web UI never receives device traffic.
**Scan Progress Events:**
@@ -1553,6 +1567,68 @@ relay.
Redis-backed for horizontal scaling across multiple server instances.
### Devices (namespace `/devices` on `/ws/socket.io`)
Only a registered device connects, with a client token bound to it
(`ClientToken.device_id`) that holds `devices.read`, sent as `token` in the
handshake auth payload or as an `Authorization: Bearer` header. A browser
session is never accepted, and every connection is refused while
`DEVICE_INSTALL_ENABLED` is off. Connecting bumps the token's `last_used_at`
and the device's `last_seen`; capabilities travel over the device REST
endpoints. The socket joins `device:{device_id}` in the namespace and is
closed when its token is deleted, regenerated or re-paired, its device or
owner is deleted, its owner is disabled, or its owner's permissions change,
and within 30 seconds of the token's `expires_at`. Each open socket refreshes `device_presence:{device_id}` and its token's
`device_token_sockets:{id}` every 30 seconds. The presence key lapses 90
seconds after the last refresh and loses the socket on disconnect;
`GET /api/devices/online` lists the caller's devices whose key is live. The
per-token socket sets exist only to close sockets on revocation and never
answer presence.
| Event | Direction | Description |
| ------------------- | ---------------------- | ---------------------------------------------------------- |
| `install:queued` | Server to device | `{id, rom_id}`: an install request is waiting |
| `install:cancelled` | Server to device | `{id, rom_id}`: the owner cancelled a pending or taken one |
| `install:updated` | Server to owner on `/` | An `InstallRequestSchema` changed status |
An install request exists only while it is `pending` or `taken`. It lives in
Redis as JSON under `install:req:{id}`, indexed by `install:device:{device_id}`
and `install:rom:{user_id}:{rom_id}`;
`install:active:{user_id}:{device_id}:{rom_id}` names the one live request per
rom and device, so a second create returns it. A live request expires
`DEVICE_INSTALL_REQUEST_TTL_DAYS` after its last change (never when that is 0),
which bounds an abandoned one. A request carries the rom's game, update and DLC
files on disk; a rom without any, a rom on a platform in
`DEVICE_INSTALL_EXCLUDED_PLATFORM_SLUGS`, or a device whose `capabilities` lack
`remote_install`, is a 400.
The owner creates requests (`POST /api/devices/{device_id}/installs`), reads a
rom's live ones (`GET /api/roms/{id}/installs`) and cancels one still pending or
taken (`DELETE /api/devices/{device_id}/installs/{id}`).
Every client drains its queue through `POST
/api/devices/{device_id}/installs/claim`, which moves all pending requests to
`taken` in one Redis transaction and returns every request the device holds
taken, so a device that lost a claim's response gets them back on its next
claim. Only newly taken requests emit `install:updated`. The socket is optional: a
client holding it claims on `install:queued`, and one that cannot hold a socket
polls claim on its own schedule. Only a client token bound to the device claims
or reports. A report (`PUT /api/devices/{device_id}/installs/{id}`) names
`done`, `already_installed` or `failed` for a taken request.
Each operation checks the status it needs and writes inside one Redis `WATCH`
transaction: claim takes only pending requests, a report needs a taken one, and
a cancel a pending or taken one. A request in another live status is a 409.
A report or a cancel ends the request: the server emits `install:updated` with
the final status, deletes the request, its index entries and its
`install:active` key in the same transaction, and for a report notifies the
owner (`device_install_completed` or `device_install_failed`, with the reason).
A report or cancel for a request that no longer exists is a 404, so a device
treats a 404 on a retried report as already handled, and a cancel racing a
report cannot both succeed. A cancel racing a claim either keeps the request
out of that claim or cancels it after the claim took it. Deleting a device, or its owner, drops
the device's live requests.
---
## 11. Background Tasks & Scheduling
@@ -1700,26 +1776,32 @@ Falls back to `FakeRedis` in test mode.
### Cache Key Patterns
| Pattern | TTL | Content |
| --------------------------------- | --------------- | ----------------------------------- |
| `session:{id}` | 14 days | Session JSON |
| `user_sessions:{username}` | 14 days | Set of session IDs |
| `session_sockets:{channel}:{id}` | 14 days | Socket IDs a session opened |
| `notification-channel:{id}:code` | 30 min | Hash of an email confirmation code |
| `notification-channel-cooldown:*` | 1 min | A user's and an address's last code |
| `reset-email:{user_id}` | 1 min | A user's last emailed reset link |
| `reset-jti:{jti}` | 10 min | Password reset token (one-time) |
| `invite-jti:{jti}` | 10 min | Invite token (one-time) |
| `refresh-jti:{jti}` | 7 days | Refresh token validation |
| `romm:mame_index` | Permanent | MAME game index |
| `romm:scummvm_index` | Permanent | ScummVM game index |
| `romm:ps1_serials` | Permanent | PS1 serial codes |
| `romm:ps2_serials` | Permanent | PS2 serial codes |
| `romm:psp_serials` | Permanent | PSP serial codes |
| `romm:switch_titledb` | Refreshed daily | Switch TitleDB |
| `romm:known_bios` | Permanent | Verified BIOS hashes |
| Upload sessions | 24 hours | Chunked upload state |
| Netplay rooms | Dynamic | Active room state |
| Pattern | TTL | Content |
| ----------------------------------- | --------------- | ----------------------------------- |
| `session:{id}` | 14 days | Session JSON |
| `user_sessions:{username}` | 14 days | Set of session IDs |
| `session_sockets:{channel}:{id}` | 14 days | Socket IDs a session opened |
| `device_token_sockets:{id}` | 14 days | Device socket IDs a token opened |
| `device_presence:{device_id}` | 90 s | A device's recently live socket IDs |
| `install:req:{id}` | 2 days | A live install request (JSON) |
| `install:active:{u}:{device}:{rom}` | 2 days | The pending or taken request's id |
| `install:device:{device_id}` | 2 days | A device's install requests |
| `install:rom:{user_id}:{rom_id}` | 2 days | A user's install requests for a rom |
| `notification-channel:{id}:code` | 30 min | Hash of an email confirmation code |
| `notification-channel-cooldown:*` | 1 min | A user's and an address's last code |
| `reset-email:{user_id}` | 1 min | A user's last emailed reset link |
| `reset-jti:{jti}` | 10 min | Password reset token (one-time) |
| `invite-jti:{jti}` | 10 min | Invite token (one-time) |
| `refresh-jti:{jti}` | 7 days | Refresh token validation |
| `romm:mame_index` | Permanent | MAME game index |
| `romm:scummvm_index` | Permanent | ScummVM game index |
| `romm:ps1_serials` | Permanent | PS1 serial codes |
| `romm:ps2_serials` | Permanent | PS2 serial codes |
| `romm:psp_serials` | Permanent | PSP serial codes |
| `romm:switch_titledb` | Refreshed daily | Switch TitleDB |
| `romm:known_bios` | Permanent | Verified BIOS hashes |
| Upload sessions | 24 hours | Chunked upload state |
| Netplay rooms | Dynamic | Active room state |
---
@@ -1847,6 +1929,14 @@ Falls back to `FakeRedis` in test mode.
| `SYNC_SSH_KNOWN_HOSTS_PATH` | | SSH known hosts path |
| `SYNC_RETROARCH_PSP_SERIAL_MAP` | `{}` | JSON map of PSP serial to ROM file name |
#### Device Install
| Variable | Default | Description |
| ---------------------------------------- | ------------------------------ | ------------------------------------------------------------- |
| `DEVICE_INSTALL_ENABLED` | `true` | Let users push a ROM to one of their devices |
| `DEVICE_INSTALL_REQUEST_TTL_DAYS` | `2` | Unfinished request lifetime after its last change; 0 keeps it |
| `DEVICE_INSTALL_EXCLUDED_PLATFORM_SLUGS` | `win,win3x,win9x,windows-apps` | Platforms that can never be pushed |
### YAML Configuration (`config.yml`)
```yaml
+5
View File
@@ -131,6 +131,11 @@ SYNC_SSH_KEYS_PATH= # Path to SSH keys for sync remotes (defaults to $ROMM_BASE
SYNC_SSH_KNOWN_HOSTS_PATH= # Path to SSH known_hosts (defaults to $ROMM_BASE_PATH/sync/known_hosts)
SYNC_RETROARCH_PSP_SERIAL_MAP= # JSON map of PSP serial to extensionless ROM file name, for RetroArch PSP saves whose title matches no ROM
# Device install
DEVICE_INSTALL_ENABLED=true # Let users push a ROM to one of their registered devices for install
DEVICE_INSTALL_REQUEST_TTL_DAYS=2 # Days an unfinished install request waits for its device after its last change; 0 waits forever
DEVICE_INSTALL_EXCLUDED_PLATFORM_SLUGS=win,win3x,win9x,windows-apps # Comma-separated platform slugs that can never be pushed to a device
# Emulation
DISABLE_EMULATOR_JS=false # Disable in-browser play via EmulatorJS
DISABLE_RUFFLE_RS=false # Disable in-browser Flash playback via RuffleRS
+5
View File
@@ -100,6 +100,7 @@ export type { DeviceAuthTokenResponse } from './models/DeviceAuthTokenResponse';
export type { DeviceCreatePayload } from './models/DeviceCreatePayload';
export type { DeviceCreateResponse } from './models/DeviceCreateResponse';
export type { DeviceHeartbeatPayload } from './models/DeviceHeartbeatPayload';
export type { DeviceInstallDict } from './models/DeviceInstallDict';
export type { DeviceSchema } from './models/DeviceSchema';
export type { DeviceSyncSchema } from './models/DeviceSyncSchema';
export type { DeviceUpdatePayload } from './models/DeviceUpdatePayload';
@@ -132,6 +133,10 @@ export type { IGDBMetadataMultiplayerMode } from './models/IGDBMetadataMultiplay
export type { IGDBMetadataPlatform } from './models/IGDBMetadataPlatform';
export type { IGDBRelatedGame } from './models/IGDBRelatedGame';
export type { ImportRefusalSchema } from './models/ImportRefusalSchema';
export type { InstallRequestCreatePayload } from './models/InstallRequestCreatePayload';
export type { InstallRequestSchema } from './models/InstallRequestSchema';
export type { InstallRequestUpdatePayload } from './models/InstallRequestUpdatePayload';
export type { InstallStatus } from './models/InstallStatus';
export type { InviteLinkSchema } from './models/InviteLinkSchema';
export type { JobStatus } from './models/JobStatus';
export type { JoinableSessionSchema } from './models/JoinableSessionSchema';
@@ -13,6 +13,7 @@ export type DeviceCreatePayload = {
hostname?: (string | null);
sync_mode?: (SyncMode | null);
sync_config?: (Record<string, any> | null);
capabilities?: (Record<string, boolean> | null);
allow_existing?: boolean;
allow_duplicate?: boolean;
reset_syncs?: boolean;
+9
View File
@@ -0,0 +1,9 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export type DeviceInstallDict = {
ENABLED: boolean;
EXCLUDED_PLATFORM_SLUGS: Array<string>;
};
+1
View File
@@ -17,6 +17,7 @@ export type DeviceSchema = {
sync_mode: SyncMode;
sync_enabled: boolean;
sync_config: (Record<string, any> | null);
capabilities: (Record<string, any> | null);
last_seen: (string | null);
created_at: string;
updated_at: string;
@@ -14,5 +14,6 @@ export type DeviceUpdatePayload = {
sync_enabled?: (boolean | null);
sync_mode?: (SyncMode | null);
sync_config?: (Record<string, any> | null);
capabilities?: (Record<string, boolean> | null);
};
+2
View File
@@ -2,6 +2,7 @@
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
import type { DeviceInstallDict } from './DeviceInstallDict';
import type { EmulationDict } from './EmulationDict';
import type { FilesystemDict } from './FilesystemDict';
import type { FrontendDict } from './FrontendDict';
@@ -18,6 +19,7 @@ export type HeartbeatResponse = {
FRONTEND: FrontendDict;
OIDC: OIDCDict;
NOTIFICATIONS: NotificationsDict;
DEVICE_INSTALL: DeviceInstallDict;
TASKS: TasksDict;
};
@@ -0,0 +1,8 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export type InstallRequestCreatePayload = {
rom_id: number;
};
@@ -0,0 +1,20 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
import type { InstallStatus } from './InstallStatus';
/**
* An install request, as served and as stored in Redis while pending or taken.
*/
export type InstallRequestSchema = {
id: string;
user_id: number;
device_id: string;
rom_id: number;
file_ids: Array<number>;
status: InstallStatus;
reason: (string | null);
created_at: string;
updated_at: string;
};
@@ -0,0 +1,10 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
import type { InstallStatus } from './InstallStatus';
export type InstallRequestUpdatePayload = {
status: InstallStatus;
reason?: (string | null);
};
+5
View File
@@ -0,0 +1,5 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export type InstallStatus = 'pending' | 'taken' | 'done' | 'already_installed' | 'failed' | 'cancelled';
+1 -1
View File
@@ -5,4 +5,4 @@
/**
* What happened, translated by the client; any other kind shows its own title and body.
*/
export type NotificationKind = 'custom' | 'scan_completed' | 'scan_failed' | 'task_completed' | 'task_failed' | 'streaming_session_ended' | 'role_changed' | 'channel_disabled';
export type NotificationKind = 'custom' | 'scan_completed' | 'scan_failed' | 'task_completed' | 'task_failed' | 'streaming_session_ended' | 'role_changed' | 'channel_disabled' | 'device_install_completed' | 'device_install_failed';
+1 -1
View File
@@ -5,4 +5,4 @@
/**
* The groups a notification channel picks what it forwards by.
*/
export type NotificationTopic = 'scans' | 'tasks' | 'streaming' | 'account' | 'custom';
export type NotificationTopic = 'scans' | 'tasks' | 'streaming' | 'devices' | 'account' | 'custom';
@@ -67,6 +67,11 @@
"channels-empty": "Все още няма канали",
"channels-empty-hint": "Препращайте известията си извън RomM: към уебхук, имейл адрес и други.",
"channels-load-failed": "Каналите ви не можаха да бъдат заредени",
"device-install-already-installed": "{game}: вече е инсталирано на {device}",
"device-install-completed": "{game}: добавено към изтеглянията на {device}",
"device-install-failed": "{game}: не можа да бъде инсталирано на {device}",
"device-install-unknown-game": "Игра",
"device-install-your-device": "вашето устройство",
"dismiss-all": "Затвори всички",
"dismiss-all-body": "Всички известия ще бъдат премахнати.",
"dismiss-all-title": "Затваряне на всички известия?",
@@ -105,6 +110,7 @@
"task-failed": "Задачата е неуспешна: {task}",
"topic-account": "Акаунт",
"topic-custom": "Персонализирани",
"topic-devices": "Устройства",
"topic-scans": "Сканирания",
"topic-streaming": "Стрийминг",
"topic-tasks": "Задачи",
+12
View File
@@ -180,6 +180,18 @@
"hidden": "Скрито",
"how-long-to-beat": "How Long to Beat",
"info": "Информация",
"install-on-device": "Инсталиране на устройство",
"install-on-device-cancel-failed": "Инсталацията не можа да бъде отменена: {error}",
"install-on-device-empty": "Все още нито едно устройство не може да получава инсталации. Влезте в RomM от приложение за устройство, което поддържа отдалечено инсталиране.",
"install-on-device-failed": "Инсталацията не можа да бъде изпратена до устройството: {error}",
"install-on-device-load-failed": "Устройствата ви не можаха да бъдат заредени",
"install-on-device-preparing": "Подготовка на устройството…",
"install-on-device-status-already-installed": "Вече е инсталирано",
"install-on-device-status-cancelled": "Отменено",
"install-on-device-status-done": "Добавено към изтеглянията",
"install-on-device-status-failed": "Неуспешно",
"install-on-device-status-pending": "Изчакване на устройството",
"install-on-device-status-taken": "Изтегля се",
"join-session": "Присъединяване към сесия",
"join-session-of": "Присъединяване към сесията на {user}",
"labels-applied-n": "Добавени етикети към {n} елемент | Добавени етикети към {n} елемента",
@@ -67,6 +67,11 @@
"channels-empty": "Zatím žádné kanály",
"channels-empty-hint": "Přeposílejte oznámení mimo RomM: na webhook, e-mailovou adresu a další.",
"channels-load-failed": "Vaše kanály se nepodařilo načíst",
"device-install-already-installed": "{game}: již nainstalováno na {device}",
"device-install-completed": "{game}: přidáno do stahování na {device}",
"device-install-failed": "{game}: instalace na {device} selhala",
"device-install-unknown-game": "Hra",
"device-install-your-device": "vašem zařízení",
"dismiss-all": "Zavřít vše",
"dismiss-all-body": "Všechna oznámení budou odstraněna.",
"dismiss-all-title": "Zavřít všechna oznámení?",
@@ -105,6 +110,7 @@
"task-failed": "Úloha selhala: {task}",
"topic-account": "Účet",
"topic-custom": "Vlastní",
"topic-devices": "Zařízení",
"topic-scans": "Skenování",
"topic-streaming": "Streamování",
"topic-tasks": "Úlohy",
+12
View File
@@ -180,6 +180,18 @@
"hidden": "Skryté",
"how-long-to-beat": "Délka do dokončení",
"info": "Info",
"install-on-device": "Nainstalovat do zařízení",
"install-on-device-cancel-failed": "Instalaci se nepodařilo zrušit: {error}",
"install-on-device-empty": "Zatím žádné zařízení nemůže přijímat instalace. Přihlaste se do RomM z aplikace v zařízení, která podporuje vzdálenou instalaci.",
"install-on-device-failed": "Instalaci se nepodařilo odeslat do zařízení: {error}",
"install-on-device-load-failed": "Vaše zařízení se nepodařilo načíst",
"install-on-device-preparing": "Připravuje se zařízení…",
"install-on-device-status-already-installed": "Již nainstalováno",
"install-on-device-status-cancelled": "Zrušeno",
"install-on-device-status-done": "Přidáno do stahování",
"install-on-device-status-failed": "Selhalo",
"install-on-device-status-pending": "Čeká se na zařízení",
"install-on-device-status-taken": "Stahuje se",
"join-session": "Připojit se k relaci",
"join-session-of": "Připojit se k relaci uživatele {user}",
"labels-applied-n": "Štítky přidány k {n} položce | Štítky přidány k {n} položkám",
@@ -67,6 +67,11 @@
"channels-empty": "Noch keine Kanäle",
"channels-empty-hint": "Leite deine Benachrichtigungen aus RomM heraus weiter: an einen Webhook, eine E-Mail-Adresse und mehr.",
"channels-load-failed": "Deine Kanäle konnten nicht geladen werden",
"device-install-already-installed": "{game} ist auf {device} bereits installiert",
"device-install-completed": "{game} wurde auf {device} zu den Downloads hinzugefügt",
"device-install-failed": "{game} konnte nicht auf {device} installiert werden",
"device-install-unknown-game": "Ein Spiel",
"device-install-your-device": "deinem Gerät",
"dismiss-all": "Alle verwerfen",
"dismiss-all-body": "Alle Benachrichtigungen werden entfernt.",
"dismiss-all-title": "Alle Benachrichtigungen verwerfen?",
@@ -105,6 +110,7 @@
"task-failed": "{task} fehlgeschlagen",
"topic-account": "Konto",
"topic-custom": "Benutzerdefiniert",
"topic-devices": "Geräte",
"topic-scans": "Scans",
"topic-streaming": "Streaming",
"topic-tasks": "Aufgaben",
+12
View File
@@ -180,6 +180,18 @@
"hidden": "Versteckt",
"how-long-to-beat": "Spieldauer",
"info": "Info",
"install-on-device": "Auf Gerät installieren",
"install-on-device-cancel-failed": "Die Installation konnte nicht abgebrochen werden: {error}",
"install-on-device-empty": "Noch kann kein Gerät Installationen empfangen. Melde dich in einer Geräte-App, die Ferninstallation unterstützt, bei RomM an.",
"install-on-device-failed": "Die Installation konnte nicht an das Gerät gesendet werden: {error}",
"install-on-device-load-failed": "Deine Geräte konnten nicht geladen werden",
"install-on-device-preparing": "Gerät wird vorbereitet…",
"install-on-device-status-already-installed": "Bereits installiert",
"install-on-device-status-cancelled": "Abgebrochen",
"install-on-device-status-done": "Zu den Downloads hinzugefügt",
"install-on-device-status-failed": "Fehlgeschlagen",
"install-on-device-status-pending": "Wartet auf das Gerät",
"install-on-device-status-taken": "Wird heruntergeladen",
"join-session": "Sitzung beitreten",
"join-session-of": "Sitzung von {user} beitreten",
"labels-applied-n": "Labels zu {n} Element hinzugefügt | Labels zu {n} Elementen hinzugefügt",
@@ -67,6 +67,11 @@
"channels-empty": "No channels yet",
"channels-empty-hint": "Forward your notifications outside RomM, to a webhook, an email address and more.",
"channels-load-failed": "Could not load your channels",
"device-install-already-installed": "{game} is already installed on {device}",
"device-install-completed": "{game} was added to downloads on {device}",
"device-install-failed": "{game} could not be installed on {device}",
"device-install-unknown-game": "A game",
"device-install-your-device": "your device",
"dismiss-all": "Dismiss all",
"dismiss-all-body": "Every notification will be removed.",
"dismiss-all-title": "Dismiss all notifications?",
@@ -105,6 +110,7 @@
"task-failed": "{task} failed",
"topic-account": "Account",
"topic-custom": "Custom",
"topic-devices": "Devices",
"topic-scans": "Scans",
"topic-streaming": "Streaming",
"topic-tasks": "Tasks",
+12
View File
@@ -180,6 +180,18 @@
"hidden": "Hidden",
"how-long-to-beat": "How Long to Beat",
"info": "Info",
"install-on-device": "Install on device",
"install-on-device-cancel-failed": "Could not cancel the install: {error}",
"install-on-device-empty": "No device can receive installs yet. Sign in to RomM from a device app that supports remote install.",
"install-on-device-failed": "Could not send the install to the device: {error}",
"install-on-device-load-failed": "Could not load your devices",
"install-on-device-preparing": "Preparing device…",
"install-on-device-status-already-installed": "Already installed",
"install-on-device-status-cancelled": "Cancelled",
"install-on-device-status-done": "Added to downloads",
"install-on-device-status-failed": "Failed",
"install-on-device-status-pending": "Waiting for the device",
"install-on-device-status-taken": "Downloading",
"join-session": "Join session",
"join-session-of": "Join {user}'s session",
"labels-applied-n": "Labels added to {n} item | Labels added to {n} items",
@@ -67,6 +67,11 @@
"channels-empty": "No channels yet",
"channels-empty-hint": "Forward your notifications outside RomM, to a webhook, an email address and more.",
"channels-load-failed": "Could not load your channels",
"device-install-already-installed": "{game} is already installed on {device}",
"device-install-completed": "{game} was added to downloads on {device}",
"device-install-failed": "{game} could not be installed on {device}",
"device-install-unknown-game": "A game",
"device-install-your-device": "your device",
"dismiss-all": "Dismiss all",
"dismiss-all-body": "Every notification will be removed.",
"dismiss-all-title": "Dismiss all notifications?",
@@ -105,6 +110,7 @@
"task-failed": "{task} failed",
"topic-account": "Account",
"topic-custom": "Custom",
"topic-devices": "Devices",
"topic-scans": "Scans",
"topic-streaming": "Streaming",
"topic-tasks": "Tasks",
+12
View File
@@ -180,6 +180,18 @@
"hidden": "Hidden",
"how-long-to-beat": "How Long to Beat",
"info": "Info",
"install-on-device": "Install on device",
"install-on-device-cancel-failed": "Could not cancel the install: {error}",
"install-on-device-empty": "No device can receive installs yet. Sign in to RomM from a device app that supports remote install.",
"install-on-device-failed": "Could not send the install to the device: {error}",
"install-on-device-load-failed": "Could not load your devices",
"install-on-device-preparing": "Preparing device…",
"install-on-device-status-already-installed": "Already installed",
"install-on-device-status-cancelled": "Canceled",
"install-on-device-status-done": "Added to downloads",
"install-on-device-status-failed": "Failed",
"install-on-device-status-pending": "Waiting for the device",
"install-on-device-status-taken": "Downloading",
"join-session": "Join session",
"join-session-of": "Join {user}'s session",
"labels-applied-n": "Labels added to {n} item | Labels added to {n} items",
@@ -67,6 +67,11 @@
"channels-empty": "Aún no hay canales",
"channels-empty-hint": "Reenvía tus notificaciones fuera de RomM: a un webhook, a un correo y más.",
"channels-load-failed": "No se pudieron cargar tus canales",
"device-install-already-installed": "{game} ya está instalado en {device}",
"device-install-completed": "{game} se añadió a las descargas en {device}",
"device-install-failed": "No se pudo instalar {game} en {device}",
"device-install-unknown-game": "Un juego",
"device-install-your-device": "tu dispositivo",
"dismiss-all": "Descartar todas",
"dismiss-all-body": "Se eliminarán todas las notificaciones.",
"dismiss-all-title": "¿Descartar todas las notificaciones?",
@@ -105,6 +110,7 @@
"task-failed": "{task} ha fallado",
"topic-account": "Cuenta",
"topic-custom": "Personalizadas",
"topic-devices": "Dispositivos",
"topic-scans": "Escaneos",
"topic-streaming": "Streaming",
"topic-tasks": "Tareas",
+12
View File
@@ -180,6 +180,18 @@
"hidden": "Oculto",
"how-long-to-beat": "Tiempo de Juego",
"info": "Info",
"install-on-device": "Instalar en un dispositivo",
"install-on-device-cancel-failed": "No se pudo cancelar la instalación: {error}",
"install-on-device-empty": "Ningún dispositivo puede recibir instalaciones todavía. Inicia sesión en RomM desde una app de dispositivo que admita la instalación remota.",
"install-on-device-failed": "No se pudo enviar la instalación al dispositivo: {error}",
"install-on-device-load-failed": "No se pudieron cargar tus dispositivos",
"install-on-device-preparing": "Preparando el dispositivo…",
"install-on-device-status-already-installed": "Ya instalado",
"install-on-device-status-cancelled": "Cancelado",
"install-on-device-status-done": "Añadido a las descargas",
"install-on-device-status-failed": "Error",
"install-on-device-status-pending": "Esperando al dispositivo",
"install-on-device-status-taken": "Descargando",
"join-session": "Unirse a la sesión",
"join-session-of": "Unirse a la sesión de {user}",
"labels-applied-n": "Etiquetas añadidas a {n} elemento | Etiquetas añadidas a {n} elementos",
@@ -67,6 +67,11 @@
"channels-empty": "Aucun canal pour l'instant",
"channels-empty-hint": "Transférez vos notifications hors de RomM : vers un webhook, une adresse e-mail et plus encore.",
"channels-load-failed": "Impossible de charger vos canaux",
"device-install-already-installed": "{game} est déjà installé sur {device}",
"device-install-completed": "{game} a été ajouté aux téléchargements sur {device}",
"device-install-failed": "{game} n'a pas pu être installé sur {device}",
"device-install-unknown-game": "Un jeu",
"device-install-your-device": "votre appareil",
"dismiss-all": "Tout ignorer",
"dismiss-all-body": "Toutes les notifications seront supprimées.",
"dismiss-all-title": "Ignorer toutes les notifications ?",
@@ -105,6 +110,7 @@
"task-failed": "Échec de la tâche : {task}",
"topic-account": "Compte",
"topic-custom": "Personnalisées",
"topic-devices": "Appareils",
"topic-scans": "Scans",
"topic-streaming": "Streaming",
"topic-tasks": "Tâches",
+12
View File
@@ -180,6 +180,18 @@
"hidden": "Caché",
"how-long-to-beat": "Durée de Jeu",
"info": "Info",
"install-on-device": "Installer sur un appareil",
"install-on-device-cancel-failed": "Impossible d'annuler l'installation : {error}",
"install-on-device-empty": "Aucun appareil ne peut encore recevoir d'installation. Connectez-vous à RomM depuis une application qui prend en charge l'installation à distance.",
"install-on-device-failed": "Impossible d'envoyer l'installation à l'appareil : {error}",
"install-on-device-load-failed": "Impossible de charger vos appareils",
"install-on-device-preparing": "Préparation de l'appareil…",
"install-on-device-status-already-installed": "Déjà installé",
"install-on-device-status-cancelled": "Annulé",
"install-on-device-status-done": "Ajouté aux téléchargements",
"install-on-device-status-failed": "Échec",
"install-on-device-status-pending": "En attente de l'appareil",
"install-on-device-status-taken": "Téléchargement en cours",
"join-session": "Rejoindre la session",
"join-session-of": "Rejoindre la session de {user}",
"labels-applied-n": "Étiquettes ajoutées à {n} élément | Étiquettes ajoutées à {n} éléments",
@@ -67,6 +67,11 @@
"channels-empty": "Még nincsenek csatornák",
"channels-empty-hint": "Továbbítsd az értesítéseidet a RomM-on kívülre: webhookra, e-mail-címre és máshová.",
"channels-load-failed": "Nem sikerült betölteni a csatornáidat",
"device-install-already-installed": "{game} már telepítve van ezen: {device}",
"device-install-completed": "{game} hozzáadva a letöltésekhez ezen: {device}",
"device-install-failed": "{game} telepítése nem sikerült ezen: {device}",
"device-install-unknown-game": "Egy játék",
"device-install-your-device": "az eszközöd",
"dismiss-all": "Összes elvetése",
"dismiss-all-body": "Az összes értesítés törlődik.",
"dismiss-all-title": "Elveted az összes értesítést?",
@@ -105,6 +110,7 @@
"task-failed": "A feladat sikertelen: {task}",
"topic-account": "Fiók",
"topic-custom": "Egyéni",
"topic-devices": "Eszközök",
"topic-scans": "Szkennelések",
"topic-streaming": "Streamelés",
"topic-tasks": "Feladatok",
+12
View File
@@ -180,6 +180,18 @@
"hidden": "Rejtett",
"how-long-to-beat": "How Long to Beat",
"info": "Info",
"install-on-device": "Telepítés eszközre",
"install-on-device-cancel-failed": "Nem sikerült megszakítani a telepítést: {error}",
"install-on-device-empty": "Még egyetlen eszköz sem fogadhat telepítést. Jelentkezz be a RomM-be egy távoli telepítést támogató eszközalkalmazásból.",
"install-on-device-failed": "Nem sikerült elküldeni a telepítést az eszközre: {error}",
"install-on-device-load-failed": "Nem sikerült betölteni az eszközeidet",
"install-on-device-preparing": "Eszköz előkészítése…",
"install-on-device-status-already-installed": "Már telepítve",
"install-on-device-status-cancelled": "Megszakítva",
"install-on-device-status-done": "Hozzáadva a letöltésekhez",
"install-on-device-status-failed": "Sikertelen",
"install-on-device-status-pending": "Várakozás az eszközre",
"install-on-device-status-taken": "Letöltés folyamatban",
"join-session": "Csatlakozás a munkamenethez",
"join-session-of": "Csatlakozás {user} munkamenetéhez",
"labels-applied-n": "Címkék hozzáadva {n} elemhez | Címkék hozzáadva {n} elemhez",
@@ -67,6 +67,11 @@
"channels-empty": "Ancora nessun canale",
"channels-empty-hint": "Inoltra le tue notifiche fuori da RomM: a un webhook, a un indirizzo email e altro.",
"channels-load-failed": "Impossibile caricare i tuoi canali",
"device-install-already-installed": "{game} è già installato su {device}",
"device-install-completed": "{game} è stato aggiunto ai download su {device}",
"device-install-failed": "Impossibile installare {game} su {device}",
"device-install-unknown-game": "Un gioco",
"device-install-your-device": "il tuo dispositivo",
"dismiss-all": "Ignora tutte",
"dismiss-all-body": "Tutte le notifiche verranno rimosse.",
"dismiss-all-title": "Ignorare tutte le notifiche?",
@@ -105,6 +110,7 @@
"task-failed": "Attività non riuscita: {task}",
"topic-account": "Account",
"topic-custom": "Personalizzate",
"topic-devices": "Dispositivi",
"topic-scans": "Scansioni",
"topic-streaming": "Streaming",
"topic-tasks": "Attività",
+12
View File
@@ -180,6 +180,18 @@
"hidden": "Nascosto",
"how-long-to-beat": "Durata di Gioco",
"info": "Info",
"install-on-device": "Installa sul dispositivo",
"install-on-device-cancel-failed": "Impossibile annullare l'installazione: {error}",
"install-on-device-empty": "Nessun dispositivo può ancora ricevere installazioni. Accedi a RomM da un'app per dispositivi che supporta l'installazione remota.",
"install-on-device-failed": "Impossibile inviare l'installazione al dispositivo: {error}",
"install-on-device-load-failed": "Impossibile caricare i tuoi dispositivi",
"install-on-device-preparing": "Preparazione del dispositivo…",
"install-on-device-status-already-installed": "Già installato",
"install-on-device-status-cancelled": "Annullato",
"install-on-device-status-done": "Aggiunto ai download",
"install-on-device-status-failed": "Non riuscito",
"install-on-device-status-pending": "In attesa del dispositivo",
"install-on-device-status-taken": "Download in corso",
"join-session": "Partecipa alla sessione",
"join-session-of": "Partecipa alla sessione di {user}",
"labels-applied-n": "Etichette aggiunte a {n} elemento | Etichette aggiunte a {n} elementi",
@@ -67,6 +67,11 @@
"channels-empty": "チャンネルはまだありません",
"channels-empty-hint": "通知を RomM の外へ転送できます。Webhook、メールアドレスなどに送れます。",
"channels-load-failed": "チャンネルを読み込めませんでした",
"device-install-already-installed": "{game} は {device} にインストール済みです",
"device-install-completed": "{game} を {device} のダウンロードに追加しました",
"device-install-failed": "{game} を {device} にインストールできませんでした",
"device-install-unknown-game": "ゲーム",
"device-install-your-device": "お使いのデバイス",
"dismiss-all": "すべて閉じる",
"dismiss-all-body": "すべての通知が削除されます。",
"dismiss-all-title": "すべての通知を閉じますか?",
@@ -105,6 +110,7 @@
"task-failed": "タスクが失敗しました: {task}",
"topic-account": "アカウント",
"topic-custom": "カスタム",
"topic-devices": "デバイス",
"topic-scans": "スキャン",
"topic-streaming": "ストリーミング",
"topic-tasks": "タスク",
+12
View File
@@ -180,6 +180,18 @@
"hidden": "非表示",
"how-long-to-beat": "プレイ時間",
"info": "詳細",
"install-on-device": "デバイスにインストール",
"install-on-device-cancel-failed": "インストールを取り消せませんでした:{error}",
"install-on-device-empty": "インストールを受け取れるデバイスはまだありません。リモートインストールに対応したデバイスのアプリから RomM にサインインしてください。",
"install-on-device-failed": "デバイスにインストールを送信できませんでした:{error}",
"install-on-device-load-failed": "デバイスを読み込めませんでした",
"install-on-device-preparing": "デバイスを準備しています…",
"install-on-device-status-already-installed": "インストール済み",
"install-on-device-status-cancelled": "取り消し済み",
"install-on-device-status-done": "ダウンロードに追加しました",
"install-on-device-status-failed": "失敗",
"install-on-device-status-pending": "デバイスを待っています",
"install-on-device-status-taken": "ダウンロード中",
"join-session": "セッションに参加",
"join-session-of": "{user} のセッションに参加",
"labels-applied-n": "{n} 件にラベルを追加しました",
@@ -67,6 +67,11 @@
"channels-empty": "아직 채널이 없습니다",
"channels-empty-hint": "알림을 RomM 밖으로 전달하세요. 웹훅, 이메일 주소 등으로 보낼 수 있습니다.",
"channels-load-failed": "채널을 불러올 수 없습니다",
"device-install-already-installed": "{game}은(는) 이미 {device}에 설치되어 있습니다",
"device-install-completed": "{game}을(를) {device}의 다운로드에 추가했습니다",
"device-install-failed": "{game}을(를) {device}에 설치하지 못했습니다",
"device-install-unknown-game": "게임",
"device-install-your-device": "내 기기",
"dismiss-all": "모두 닫기",
"dismiss-all-body": "모든 알림이 삭제됩니다.",
"dismiss-all-title": "모든 알림을 닫으시겠습니까?",
@@ -105,6 +110,7 @@
"task-failed": "작업 실패: {task}",
"topic-account": "계정",
"topic-custom": "사용자 지정",
"topic-devices": "기기",
"topic-scans": "스캔",
"topic-streaming": "스트리밍",
"topic-tasks": "작업",
+12
View File
@@ -180,6 +180,18 @@
"hidden": "숨김",
"how-long-to-beat": "플레이 시간",
"info": "정보",
"install-on-device": "기기에 설치",
"install-on-device-cancel-failed": "설치를 취소하지 못했습니다: {error}",
"install-on-device-empty": "아직 설치를 받을 수 있는 기기가 없습니다. 원격 설치를 지원하는 기기 앱에서 RomM에 로그인하세요.",
"install-on-device-failed": "기기로 설치를 보내지 못했습니다: {error}",
"install-on-device-load-failed": "기기 목록을 불러오지 못했습니다",
"install-on-device-preparing": "기기 준비 중…",
"install-on-device-status-already-installed": "이미 설치됨",
"install-on-device-status-cancelled": "취소됨",
"install-on-device-status-done": "다운로드에 추가됨",
"install-on-device-status-failed": "실패",
"install-on-device-status-pending": "기기를 기다리는 중",
"install-on-device-status-taken": "다운로드 중",
"join-session": "세션 참가",
"join-session-of": "{user}의 세션에 참가",
"labels-applied-n": "{n}개 항목에 라벨을 추가했습니다",
@@ -67,6 +67,11 @@
"channels-empty": "Brak kanałów",
"channels-empty-hint": "Przekazuj powiadomienia poza RomM: do webhooka, na adres e-mail i nie tylko.",
"channels-load-failed": "Nie udało się wczytać Twoich kanałów",
"device-install-already-installed": "{game}: już zainstalowano na {device}",
"device-install-completed": "{game}: dodano do pobranych na {device}",
"device-install-failed": "{game}: nie udało się zainstalować na {device}",
"device-install-unknown-game": "Gra",
"device-install-your-device": "twoim urządzeniu",
"dismiss-all": "Odrzuć wszystkie",
"dismiss-all-body": "Wszystkie powiadomienia zostaną usunięte.",
"dismiss-all-title": "Odrzucić wszystkie powiadomienia?",
@@ -105,6 +110,7 @@
"task-failed": "Zadanie nie powiodło się: {task}",
"topic-account": "Konto",
"topic-custom": "Własne",
"topic-devices": "Urządzenia",
"topic-scans": "Skanowania",
"topic-streaming": "Streaming",
"topic-tasks": "Zadania",
+12
View File
@@ -180,6 +180,18 @@
"hidden": "Ukryte",
"how-long-to-beat": "Czas Gry",
"info": "Informacje",
"install-on-device": "Zainstaluj na urządzeniu",
"install-on-device-cancel-failed": "Nie udało się anulować instalacji: {error}",
"install-on-device-empty": "Żadne urządzenie nie może jeszcze odbierać instalacji. Zaloguj się do RomM z aplikacji na urządzeniu, która obsługuje zdalną instalację.",
"install-on-device-failed": "Nie udało się wysłać instalacji na urządzenie: {error}",
"install-on-device-load-failed": "Nie udało się wczytać twoich urządzeń",
"install-on-device-preparing": "Przygotowywanie urządzenia…",
"install-on-device-status-already-installed": "Już zainstalowano",
"install-on-device-status-cancelled": "Anulowano",
"install-on-device-status-done": "Dodano do pobranych",
"install-on-device-status-failed": "Niepowodzenie",
"install-on-device-status-pending": "Oczekiwanie na urządzenie",
"install-on-device-status-taken": "Pobieranie",
"join-session": "Dołącz do sesji",
"join-session-of": "Dołącz do sesji użytkownika {user}",
"labels-applied-n": "Dodano etykiety do {n} elementu | Dodano etykiety do {n} elementów",
@@ -67,6 +67,11 @@
"channels-empty": "Nenhum canal ainda",
"channels-empty-hint": "Encaminhe suas notificações para fora do RomM: para um webhook, um endereço de e-mail e mais.",
"channels-load-failed": "Não foi possível carregar seus canais",
"device-install-already-installed": "{game} já está instalado em {device}",
"device-install-completed": "{game} foi adicionado aos downloads em {device}",
"device-install-failed": "Não foi possível instalar {game} em {device}",
"device-install-unknown-game": "Um jogo",
"device-install-your-device": "seu dispositivo",
"dismiss-all": "Dispensar todas",
"dismiss-all-body": "Todas as notificações serão removidas.",
"dismiss-all-title": "Dispensar todas as notificações?",
@@ -105,6 +110,7 @@
"task-failed": "Falha na tarefa: {task}",
"topic-account": "Conta",
"topic-custom": "Personalizadas",
"topic-devices": "Dispositivos",
"topic-scans": "Escaneamentos",
"topic-streaming": "Streaming",
"topic-tasks": "Tarefas",
+12
View File
@@ -180,6 +180,18 @@
"hidden": "Oculto",
"how-long-to-beat": "Tempo de Jogo",
"info": "Informações",
"install-on-device": "Instalar no dispositivo",
"install-on-device-cancel-failed": "Não foi possível cancelar a instalação: {error}",
"install-on-device-empty": "Nenhum dispositivo pode receber instalações ainda. Entre no RomM a partir de um app de dispositivo que suporte instalação remota.",
"install-on-device-failed": "Não foi possível enviar a instalação ao dispositivo: {error}",
"install-on-device-load-failed": "Não foi possível carregar seus dispositivos",
"install-on-device-preparing": "Preparando o dispositivo…",
"install-on-device-status-already-installed": "Já instalado",
"install-on-device-status-cancelled": "Cancelado",
"install-on-device-status-done": "Adicionado aos downloads",
"install-on-device-status-failed": "Falhou",
"install-on-device-status-pending": "Aguardando o dispositivo",
"install-on-device-status-taken": "Baixando",
"join-session": "Entrar na sessão",
"join-session-of": "Entrar na sessão de {user}",
"labels-applied-n": "Etiquetas adicionadas a {n} item | Etiquetas adicionadas a {n} itens",
@@ -67,6 +67,11 @@
"channels-empty": "Încă nu există canale",
"channels-empty-hint": "Redirecționează notificările în afara RomM: către un webhook, o adresă de e-mail și altele.",
"channels-load-failed": "Canalele tale nu au putut fi încărcate",
"device-install-already-installed": "{game} este deja instalat pe {device}",
"device-install-completed": "{game} a fost adăugat la descărcări pe {device}",
"device-install-failed": "{game} nu a putut fi instalat pe {device}",
"device-install-unknown-game": "Un joc",
"device-install-your-device": "dispozitivul tău",
"dismiss-all": "Închide toate",
"dismiss-all-body": "Toate notificările vor fi eliminate.",
"dismiss-all-title": "Închizi toate notificările?",
@@ -105,6 +110,7 @@
"task-failed": "Sarcina a eșuat: {task}",
"topic-account": "Cont",
"topic-custom": "Personalizate",
"topic-devices": "Dispozitive",
"topic-scans": "Scanări",
"topic-streaming": "Streaming",
"topic-tasks": "Sarcini",
+12
View File
@@ -180,6 +180,18 @@
"hidden": "Ascuns",
"how-long-to-beat": "Timp de Joc",
"info": "Informații",
"install-on-device": "Instalează pe dispozitiv",
"install-on-device-cancel-failed": "Instalarea nu a putut fi anulată: {error}",
"install-on-device-empty": "Niciun dispozitiv nu poate primi încă instalări. Conectează-te la RomM dintr-o aplicație de dispozitiv care acceptă instalarea la distanță.",
"install-on-device-failed": "Instalarea nu a putut fi trimisă către dispozitiv: {error}",
"install-on-device-load-failed": "Dispozitivele tale nu au putut fi încărcate",
"install-on-device-preparing": "Se pregătește dispozitivul…",
"install-on-device-status-already-installed": "Deja instalat",
"install-on-device-status-cancelled": "Anulat",
"install-on-device-status-done": "Adăugat la descărcări",
"install-on-device-status-failed": "Eșuat",
"install-on-device-status-pending": "Se așteaptă dispozitivul",
"install-on-device-status-taken": "Se descarcă",
"join-session": "Alătură-te sesiunii",
"join-session-of": "Alătură-te sesiunii lui {user}",
"labels-applied-n": "Etichete adăugate la {n} element | Etichete adăugate la {n} elemente",
@@ -67,6 +67,11 @@
"channels-empty": "Каналов пока нет",
"channels-empty-hint": "Пересылайте уведомления за пределы RomM: в вебхук, на адрес эл. почты и не только.",
"channels-load-failed": "Не удалось загрузить ваши каналы",
"device-install-already-installed": "{game}: уже установлено на {device}",
"device-install-completed": "{game}: добавлено в загрузки на {device}",
"device-install-failed": "{game}: не удалось установить на {device}",
"device-install-unknown-game": "Игра",
"device-install-your-device": "вашем устройстве",
"dismiss-all": "Закрыть все",
"dismiss-all-body": "Все уведомления будут удалены.",
"dismiss-all-title": "Закрыть все уведомления?",
@@ -105,6 +110,7 @@
"task-failed": "Задача не удалась: {task}",
"topic-account": "Аккаунт",
"topic-custom": "Пользовательские",
"topic-devices": "Устройства",
"topic-scans": "Сканирования",
"topic-streaming": "Стриминг",
"topic-tasks": "Задачи",
+12
View File
@@ -180,6 +180,18 @@
"hidden": "Скрыто",
"how-long-to-beat": "Время Прохождения",
"info": "Информация",
"install-on-device": "Установить на устройство",
"install-on-device-cancel-failed": "Не удалось отменить установку: {error}",
"install-on-device-empty": "Пока ни одно устройство не может принимать установки. Войдите в RomM из приложения на устройстве, которое поддерживает удалённую установку.",
"install-on-device-failed": "Не удалось отправить установку на устройство: {error}",
"install-on-device-load-failed": "Не удалось загрузить ваши устройства",
"install-on-device-preparing": "Подготовка устройства…",
"install-on-device-status-already-installed": "Уже установлено",
"install-on-device-status-cancelled": "Отменено",
"install-on-device-status-done": "Добавлено в загрузки",
"install-on-device-status-failed": "Ошибка",
"install-on-device-status-pending": "Ожидание устройства",
"install-on-device-status-taken": "Загружается",
"join-session": "Присоединиться к сессии",
"join-session-of": "Присоединиться к сессии {user}",
"labels-applied-n": "Метки добавлены к {n} элементу | Метки добавлены к {n} элементам",
@@ -67,6 +67,11 @@
"channels-empty": "Henüz kanal yok",
"channels-empty-hint": "Bildirimlerinizi RomM dışına iletin: bir webhook'a, bir e-posta adresine ve daha fazlasına.",
"channels-load-failed": "Kanallarınız yüklenemedi",
"device-install-already-installed": "{game} zaten {device} üzerinde yüklü",
"device-install-completed": "{game}, {device} üzerinde indirmelere eklendi",
"device-install-failed": "{game}, {device} üzerine yüklenemedi",
"device-install-unknown-game": "Bir oyun",
"device-install-your-device": "cihazınız",
"dismiss-all": "Tümünü kapat",
"dismiss-all-body": "Tüm bildirimler kaldırılacak.",
"dismiss-all-title": "Tüm bildirimler kapatılsın mı?",
@@ -105,6 +110,7 @@
"task-failed": "Görev başarısız: {task}",
"topic-account": "Hesap",
"topic-custom": "Özel",
"topic-devices": "Cihazlar",
"topic-scans": "Taramalar",
"topic-streaming": "Yayın",
"topic-tasks": "Görevler",
+12
View File
@@ -180,6 +180,18 @@
"hidden": "Gizli",
"how-long-to-beat": "Tamamlama Süresi",
"info": "Bilgi",
"install-on-device": "Cihaza yükle",
"install-on-device-cancel-failed": "Yükleme iptal edilemedi: {error}",
"install-on-device-empty": "Henüz hiçbir cihaz yükleme alamıyor. Uzaktan yüklemeyi destekleyen bir cihaz uygulamasından RomM'a giriş yapın.",
"install-on-device-failed": "Yükleme cihaza gönderilemedi: {error}",
"install-on-device-load-failed": "Cihazlarınız yüklenemedi",
"install-on-device-preparing": "Cihaz hazırlanıyor…",
"install-on-device-status-already-installed": "Zaten yüklü",
"install-on-device-status-cancelled": "İptal edildi",
"install-on-device-status-done": "İndirmelere eklendi",
"install-on-device-status-failed": "Başarısız",
"install-on-device-status-pending": "Cihaz bekleniyor",
"install-on-device-status-taken": "İndiriliyor",
"join-session": "Oturuma katıl",
"join-session-of": "{user} adlı kullanıcının oturumuna katıl",
"labels-applied-n": "{n} ögeye etiket eklendi",
@@ -67,6 +67,11 @@
"channels-empty": "暂无渠道",
"channels-empty-hint": "将你的通知转发到 RomM 之外:Webhook、电子邮件地址等。",
"channels-load-failed": "无法加载你的渠道",
"device-install-already-installed": "{game} 已安装在 {device} 上",
"device-install-completed": "{game} 已加入 {device} 的下载",
"device-install-failed": "无法将 {game} 安装到 {device}",
"device-install-unknown-game": "一款游戏",
"device-install-your-device": "你的设备",
"dismiss-all": "全部忽略",
"dismiss-all-body": "所有通知都将被移除。",
"dismiss-all-title": "忽略所有通知?",
@@ -105,6 +110,7 @@
"task-failed": "任务失败:{task}",
"topic-account": "账户",
"topic-custom": "自定义",
"topic-devices": "设备",
"topic-scans": "扫描",
"topic-streaming": "串流",
"topic-tasks": "任务",
+12
View File
@@ -180,6 +180,18 @@
"hidden": "隐藏",
"how-long-to-beat": "游戏时长",
"info": "信息",
"install-on-device": "安装到设备",
"install-on-device-cancel-failed": "无法取消安装:{error}",
"install-on-device-empty": "目前还没有设备可以接收安装。请在支持远程安装的设备应用中登录 RomM。",
"install-on-device-failed": "无法将安装发送到设备:{error}",
"install-on-device-load-failed": "无法加载你的设备",
"install-on-device-preparing": "正在准备设备…",
"install-on-device-status-already-installed": "已安装",
"install-on-device-status-cancelled": "已取消",
"install-on-device-status-done": "已加入下载",
"install-on-device-status-failed": "失败",
"install-on-device-status-pending": "正在等待设备",
"install-on-device-status-taken": "正在下载",
"join-session": "加入会话",
"join-session-of": "加入 {user} 的会话",
"labels-applied-n": "已为 {n} 个项目添加标签",
@@ -67,6 +67,11 @@
"channels-empty": "尚無管道",
"channels-empty-hint": "將你的通知轉發到 RomM 之外:Webhook、電子郵件地址等。",
"channels-load-failed": "無法載入你的管道",
"device-install-already-installed": "{game} 已安裝在 {device} 上",
"device-install-completed": "{game} 已加入 {device} 的下載",
"device-install-failed": "無法將 {game} 安裝到 {device}",
"device-install-unknown-game": "一款遊戲",
"device-install-your-device": "你的裝置",
"dismiss-all": "全部關閉",
"dismiss-all-body": "所有通知都將被移除。",
"dismiss-all-title": "關閉所有通知?",
@@ -105,6 +110,7 @@
"task-failed": "任務失敗:{task}",
"topic-account": "帳戶",
"topic-custom": "自訂",
"topic-devices": "裝置",
"topic-scans": "掃描",
"topic-streaming": "串流",
"topic-tasks": "任務",
+12
View File
@@ -180,6 +180,18 @@
"hidden": "隱藏",
"how-long-to-beat": "遊戲時長",
"info": "資訊",
"install-on-device": "安裝到裝置",
"install-on-device-cancel-failed": "無法取消安裝:{error}",
"install-on-device-empty": "目前還沒有裝置可以接收安裝。請在支援遠端安裝的裝置應用程式中登入 RomM。",
"install-on-device-failed": "無法將安裝傳送到裝置:{error}",
"install-on-device-load-failed": "無法載入你的裝置",
"install-on-device-preparing": "正在準備裝置…",
"install-on-device-status-already-installed": "已安裝",
"install-on-device-status-cancelled": "已取消",
"install-on-device-status-done": "已加入下載",
"install-on-device-status-failed": "失敗",
"install-on-device-status-pending": "正在等待裝置",
"install-on-device-status-taken": "正在下載",
"join-session": "加入工作階段",
"join-session-of": "加入 {user} 的工作階段",
"labels-applied-n": "已為 {n} 個項目新增標籤",
@@ -0,0 +1,31 @@
import type {
InstallRequestCreatePayload,
InstallRequestSchema,
} from "@/__generated__";
import api from "@/services/api";
async function fetchRomInstalls(romId: number) {
return api.get<InstallRequestSchema[]>(`/roms/${romId}/installs`);
}
async function createInstall(
deviceId: string,
payload: InstallRequestCreatePayload,
) {
return api.post<InstallRequestSchema>(
`/devices/${encodeURIComponent(deviceId)}/installs`,
payload,
);
}
async function cancelInstall(deviceId: string, requestId: string) {
return api.delete<InstallRequestSchema>(
`/devices/${encodeURIComponent(deviceId)}/installs/${encodeURIComponent(requestId)}`,
);
}
export default {
fetchRomInstalls,
createInstall,
cancelInstall,
};
+15
View File
@@ -0,0 +1,15 @@
import type { DeviceSchema } from "@/__generated__";
import api from "@/services/api";
async function fetchDevices() {
return api.get<DeviceSchema[]>("/devices");
}
async function fetchOnlineDeviceIds() {
return api.get<string[]>("/devices/online");
}
export default {
fetchDevices,
fetchOnlineDeviceIds,
};
+4
View File
@@ -63,6 +63,10 @@ const defaultHeartbeat: Heartbeat = {
EMAIL_ENABLED: false,
EMAILS_RESET_LINKS: false,
},
DEVICE_INSTALL: {
ENABLED: false,
EXCLUDED_PLATFORM_SLUGS: [],
},
TASKS: {
ENABLE_SCHEDULED_RESCAN: false,
SCHEDULED_RESCAN_CRON: "",
+1
View File
@@ -103,6 +103,7 @@ export type Events = {
// and the NDS/download helpers all accept SimpleRom, so gallery surfaces
// (which only ever hold SimpleRom) can trigger it too.
showQRCodeDialog: SimpleRom;
showInstallOnDeviceDialog: SimpleRom;
selectSaveDialog: DetailedRom;
selectStateDialog: DetailedRom;
saveSelected: SaveSchema;
@@ -1,7 +1,4 @@
<script setup lang="ts">
// GlobalDialogs — the stack of emitter-driven dialogs + notification
// mount-points that need to live at the layout level so they overlay
// every route.
import AboutDialog from "@/v2/components/Dialogs/AboutDialog.vue";
import AddPhysicalGameDialog from "@/v2/components/Dialogs/AddPhysicalGameDialog.vue";
import ChangelogDialog from "@/v2/components/Dialogs/ChangelogDialog.vue";
@@ -10,6 +7,7 @@ import CreateSmartCollectionDialog from "@/v2/components/Dialogs/CreateSmartColl
import DeleteManualDialog from "@/v2/components/Dialogs/DeleteManualDialog.vue";
import DeleteRomDialog from "@/v2/components/Dialogs/DeleteRomDialog.vue";
import EditRomDialog from "@/v2/components/Dialogs/EditRomDialog.vue";
import InstallOnDeviceDialog from "@/v2/components/Dialogs/InstallOnDeviceDialog.vue";
import ManageCollectionsDialog from "@/v2/components/Dialogs/ManageCollectionsDialog.vue";
import ManualUploadTargetDialog from "@/v2/components/Dialogs/ManualUploadTargetDialog.vue";
import MatchRomDialog from "@/v2/components/Dialogs/MatchRomDialog.vue";
@@ -39,6 +37,7 @@ defineOptions({ inheritAttrs: false });
<RefreshMetadataDialog />
<SearchCoverDialog />
<ShowQRCodeDialog />
<InstallOnDeviceDialog />
<CopyDownloadLinkDialog />
<ManageCollectionsDialog />
<CreateSmartCollectionDialog />
@@ -0,0 +1,343 @@
import { flushPromises, mount, type VueWrapper } from "@vue/test-utils";
import mitt, { type Emitter } from "mitt";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ref } from "vue";
import type { DeviceSchema, InstallRequestSchema } from "@/__generated__";
import type { SimpleRom } from "@/stores/roms";
import type { Events } from "@/types/emitter";
import InstallOnDeviceDialog from "./InstallOnDeviceDialog.vue";
const {
fetchDevices,
fetchOnlineDeviceIds,
fetchRomInstalls,
createInstall,
cancelInstall,
socketHandlers,
} = vi.hoisted(() => ({
fetchDevices: vi.fn(),
fetchOnlineDeviceIds: vi.fn(),
fetchRomInstalls: vi.fn(),
createInstall: vi.fn(),
cancelInstall: vi.fn(),
socketHandlers: new Map<string, (payload: unknown) => void>(),
}));
vi.mock("vue-i18n", () => ({
useI18n: () => ({
t: (key: string) => key,
}),
}));
vi.mock("@/services/api/device", () => ({
default: { fetchDevices, fetchOnlineDeviceIds },
}));
vi.mock("@/services/api/device-install", () => ({
default: { fetchRomInstalls, createInstall, cancelInstall },
}));
vi.mock("@/v2/composables/useSocketEvent", () => ({
useSocketEvent: (event: string, handler: (payload: unknown) => void) => {
socketHandlers.set(event, handler);
},
}));
vi.mock("@/v2/composables/useBreakpoint", () => ({
useBreakpoint: () => ({ mdAndUp: ref(true) }),
}));
vi.mock("@/v2/composables/useSnackbar", () => ({
useSnackbar: () => ({ error: vi.fn() }),
}));
const ROM = { id: 7, name: "Zelda", fs_name: "zelda.zip" } as SimpleRom;
function device(id: string, install = true): DeviceSchema {
return {
id,
name: id,
capabilities: install ? { remote_install: true } : null,
last_seen: null,
} as DeviceSchema;
}
function installRequest(
overrides: Partial<InstallRequestSchema> = {},
): InstallRequestSchema {
return {
id: "req-1",
user_id: 1,
device_id: "handheld",
rom_id: ROM.id,
file_ids: [10],
status: "pending",
reason: null,
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:00:00Z",
...overrides,
};
}
function mountDialog(emitter: Emitter<Events>): VueWrapper {
return mount(InstallOnDeviceDialog, {
global: {
provide: { emitter },
stubs: {
RDialog: {
props: { modelValue: { type: Boolean, default: false } },
emits: ["update:modelValue", "close"],
template: `<div v-if="modelValue">
<slot name="content" />
<button data-test-close @click="$emit('update:modelValue', false); $emit('close')" />
</div>`,
},
RCheckbox: {
props: {
modelValue: { type: Boolean, default: false },
disabled: { type: Boolean, default: false },
subtitle: { type: String, default: undefined },
},
emits: ["update:modelValue"],
template: `<label data-test-device>
<input type="checkbox" :checked="modelValue" :disabled="disabled"
@change="$emit('update:modelValue', $event.target.checked)" />
<slot />
<span data-test-subtitle>{{ subtitle }}</span>
</label>`,
},
RBtn: true,
RIcon: true,
RSpinner: true,
REmptyState: true,
},
},
});
}
async function open(devices: DeviceSchema[] = [device("handheld")]) {
fetchDevices.mockResolvedValue({ data: devices });
const emitter = mitt<Events>();
const wrapper = mountDialog(emitter);
emitter.emit("showInstallOnDeviceDialog", ROM);
await flushPromises();
return wrapper;
}
async function toggle(wrapper: VueWrapper, checked: boolean) {
await wrapper.find("[data-test-device] input").setValue(checked);
}
describe("InstallOnDeviceDialog", () => {
beforeEach(() => {
vi.useFakeTimers();
vi.clearAllMocks();
socketHandlers.clear();
fetchOnlineDeviceIds.mockResolvedValue({ data: [] });
fetchRomInstalls.mockResolvedValue({ data: [] });
createInstall.mockImplementation((deviceId: string) =>
Promise.resolve({ data: installRequest({ device_id: deviceId }) }),
);
cancelInstall.mockResolvedValue({
data: installRequest({ status: "cancelled" }),
});
});
afterEach(() => {
vi.useRealTimers();
});
it("keeps a reopened dialog's send busy when an earlier one finishes", async () => {
fetchDevices.mockResolvedValue({ data: [device("handheld")] });
const releases: Array<() => void> = [];
createInstall.mockImplementation(
(deviceId: string) =>
new Promise((resolve) => {
releases.push(() =>
resolve({ data: installRequest({ device_id: deviceId }) }),
);
}),
);
const emitter = mitt<Events>();
const wrapper = mountDialog(emitter);
emitter.emit("showInstallOnDeviceDialog", ROM);
await flushPromises();
await toggle(wrapper, true);
await wrapper.find("[data-test-close]").trigger("click");
emitter.emit("showInstallOnDeviceDialog", { ...ROM, id: 8 });
await flushPromises();
await toggle(wrapper, true);
vi.advanceTimersByTime(3000);
await flushPromises();
expect(createInstall).toHaveBeenCalledTimes(2);
releases[0]?.();
await flushPromises();
expect(
wrapper.find("[data-test-device] input").attributes("disabled"),
).toBeDefined();
});
it("lists only the devices that accept installs", async () => {
const wrapper = await open([device("handheld"), device("tv", false)]);
expect(wrapper.findAll("[data-test-device]")).toHaveLength(1);
expect(wrapper.text()).toContain("handheld");
});
it("sends after the preparing delay", async () => {
const wrapper = await open();
await toggle(wrapper, true);
expect(wrapper.find("[data-test-subtitle]").text()).toBe(
"rom.install-on-device-preparing",
);
vi.advanceTimersByTime(2999);
expect(createInstall).not.toHaveBeenCalled();
vi.advanceTimersByTime(1);
await flushPromises();
expect(createInstall).toHaveBeenCalledOnce();
expect(createInstall).toHaveBeenCalledWith("handheld", { rom_id: ROM.id });
expect(wrapper.find("[data-test-subtitle]").text()).toBe(
"rom.install-on-device-status-pending",
);
});
it("sends nothing when unchecked while preparing", async () => {
const wrapper = await open();
await toggle(wrapper, true);
vi.advanceTimersByTime(2000);
await toggle(wrapper, false);
vi.advanceTimersByTime(10_000);
await flushPromises();
expect(createInstall).not.toHaveBeenCalled();
});
it("sends a preparing install at once when closed", async () => {
const wrapper = await open();
await toggle(wrapper, true);
await wrapper.find("[data-test-close]").trigger("click");
await flushPromises();
expect(createInstall).toHaveBeenCalledOnce();
vi.advanceTimersByTime(10_000);
expect(createInstall).toHaveBeenCalledOnce();
});
it("cancels a live request when unchecked", async () => {
fetchRomInstalls.mockResolvedValue({ data: [installRequest()] });
const wrapper = await open();
await toggle(wrapper, false);
await flushPromises();
expect(cancelInstall).toHaveBeenCalledWith("handheld", "req-1");
expect(wrapper.find("[data-test-subtitle]").text()).toBe(
"rom.install-on-device-status-cancelled",
);
});
it("follows socket updates for the open rom only", async () => {
const wrapper = await open();
const update = socketHandlers.get("install:updated");
if (!update) throw new Error("the dialog never subscribed");
update(installRequest({ rom_id: 99, status: "taken" }));
await flushPromises();
expect(wrapper.find("[data-test-subtitle]").text()).toBe("");
update(installRequest({ status: "taken" }));
await flushPromises();
expect(wrapper.find("[data-test-subtitle]").text()).toBe(
"rom.install-on-device-status-taken",
);
});
it("keeps the newer request when an older one arrives late", async () => {
const wrapper = await open();
const update = socketHandlers.get("install:updated");
if (!update) throw new Error("the dialog never subscribed");
update(
installRequest({
id: "req-2",
status: "taken",
created_at: "2026-01-02T00:00:00Z",
}),
);
update(installRequest({ status: "cancelled" }));
await flushPromises();
expect(wrapper.find("[data-test-subtitle]").text()).toBe(
"rom.install-on-device-status-taken",
);
});
it("keeps the newer status when a stale one of the same request arrives late", async () => {
const wrapper = await open();
const update = socketHandlers.get("install:updated");
if (!update) throw new Error("the dialog never subscribed");
update(
installRequest({ status: "taken", updated_at: "2026-01-01T00:00:05Z" }),
);
update(installRequest({ status: "pending" }));
await flushPromises();
expect(wrapper.find("[data-test-subtitle]").text()).toBe(
"rom.install-on-device-status-taken",
);
});
it("keeps a socket update that lands while the dialog loads", async () => {
let resolveInstalls: (value: {
data: InstallRequestSchema[];
}) => void = () => {};
fetchRomInstalls.mockReturnValue(
new Promise((resolve) => {
resolveInstalls = resolve;
}),
);
fetchDevices.mockResolvedValue({ data: [device("handheld")] });
const emitter = mitt<Events>();
const wrapper = mountDialog(emitter);
emitter.emit("showInstallOnDeviceDialog", ROM);
const update = socketHandlers.get("install:updated");
if (!update) throw new Error("the dialog never subscribed");
update(
installRequest({ status: "done", updated_at: "2026-01-01T00:00:09Z" }),
);
resolveInstalls({
data: [
installRequest({ status: "taken", updated_at: "2026-01-01T00:00:05Z" }),
],
});
await flushPromises();
expect(wrapper.find("[data-test-subtitle]").text()).toBe(
"rom.install-on-device-status-done",
);
});
it("locks a delivered install", async () => {
fetchRomInstalls.mockResolvedValue({ data: [] });
const wrapper = await open();
const update = socketHandlers.get("install:updated");
if (!update) throw new Error("the dialog never subscribed");
update(installRequest({ status: "done" }));
await flushPromises();
const input = wrapper.find("[data-test-device] input");
expect(input.attributes("disabled")).toBeDefined();
expect((input.element as HTMLInputElement).checked).toBe(true);
});
});

Some files were not shown because too many files have changed in this diff Show More