mirror of
https://github.com/rommapp/romm.git
synced 2026-06-28 14:56:01 +00:00
Secure activity identity, cut Redis churn, remove v1 activity
Backend: - Resolve the acting user from the authenticated socket session on connect instead of trusting the client-supplied user_id, so a client can no longer spoof a "now playing" session for another user. Only rom_id/device_id come from the payload. - Emit activity:update/clear through the already-initialised socket server instead of opening (and leaking) a fresh AsyncRedisManager per REST heartbeat. - Collapse get_all_active's per-key GET into a single MGET. - Drop the pure pass-through _build_activity_entry helper. Frontend: - Remove all activity emits from the v1 EmulatorJS Player; the v2 shell is the single driver of the activity lifecycle. - Remove activity from the v1 UI entirely (Activity view, ActivityBtn, ActivePlayers on game details, navigation, and the now-v2-only route). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,15 +1,14 @@
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import socketio # type: ignore
|
||||
from fastapi import HTTPException, Request, status
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from config import REDIS_URL
|
||||
from decorators.auth import protected_route
|
||||
from endpoints.responses.activity import ActivityClearSchema, ActivityEntrySchema
|
||||
from handler.activity_handler import ActivityEntry, activity_handler
|
||||
from handler.auth.constants import Scope
|
||||
from handler.database import db_device_handler, db_rom_handler
|
||||
from handler.socket_handler import socket_handler
|
||||
from logger.logger import log
|
||||
from utils.router import APIRouter
|
||||
|
||||
@@ -24,40 +23,6 @@ class DeviceHeartbeatPayload(BaseModel):
|
||||
device_id: str = Field(min_length=1, max_length=255)
|
||||
|
||||
|
||||
def _get_socket_manager() -> socketio.AsyncRedisManager:
|
||||
"""Create a write-only Redis manager for emitting from REST endpoints."""
|
||||
return socketio.AsyncRedisManager(REDIS_URL, write_only=True)
|
||||
|
||||
|
||||
def _build_activity_entry(
|
||||
*,
|
||||
user_id: int,
|
||||
username: str,
|
||||
avatar_path: str,
|
||||
rom_id: int,
|
||||
rom_name: str,
|
||||
rom_cover_path: str,
|
||||
platform_slug: str,
|
||||
platform_name: str,
|
||||
device_id: str,
|
||||
device_type: str,
|
||||
started_at: str,
|
||||
) -> ActivityEntry:
|
||||
return ActivityEntry(
|
||||
user_id=user_id,
|
||||
username=username,
|
||||
avatar_path=avatar_path,
|
||||
rom_id=rom_id,
|
||||
rom_name=rom_name,
|
||||
rom_cover_path=rom_cover_path,
|
||||
platform_slug=platform_slug,
|
||||
platform_name=platform_name,
|
||||
device_id=device_id,
|
||||
device_type=device_type,
|
||||
started_at=started_at,
|
||||
)
|
||||
|
||||
|
||||
@protected_route(router.get, "", [Scope.ROMS_USER_READ])
|
||||
async def get_all_activity(request: Request) -> list[ActivityEntrySchema]:
|
||||
"""Return every currently active play session across all users."""
|
||||
@@ -105,7 +70,7 @@ async def device_heartbeat(
|
||||
)
|
||||
|
||||
platform = rom.platform
|
||||
entry = _build_activity_entry(
|
||||
entry = ActivityEntry(
|
||||
user_id=request.user.id,
|
||||
username=request.user.username,
|
||||
avatar_path=request.user.avatar_path or "",
|
||||
@@ -124,10 +89,11 @@ async def device_heartbeat(
|
||||
# Update the device last_seen as a side-effect (mirrors play session ingest).
|
||||
db_device_handler.update_last_seen(device_id=device.id, user_id=request.user.id)
|
||||
|
||||
# Broadcast to all connected sockets.
|
||||
# Broadcast to all connected sockets. The REST app shares this process with
|
||||
# the Socket.IO server, so emit through the already-initialised, Redis-backed
|
||||
# server (it fans out across workers) rather than opening a manager per call.
|
||||
try:
|
||||
sm = _get_socket_manager()
|
||||
await sm.emit("activity:update", dict(entry))
|
||||
await socket_handler.socket_server.emit("activity:update", dict(entry))
|
||||
except Exception as e: # noqa: BLE001
|
||||
log.warning(
|
||||
f"Failed to broadcast activity:update for user {request.user.id}: {e}"
|
||||
@@ -149,8 +115,7 @@ async def clear_device_activity(request: Request, device_id: str) -> None:
|
||||
return None
|
||||
|
||||
try:
|
||||
sm = _get_socket_manager()
|
||||
await sm.emit(
|
||||
await socket_handler.socket_server.emit(
|
||||
"activity:clear",
|
||||
ActivityClearSchema(
|
||||
user_id=request.user.id,
|
||||
|
||||
@@ -6,13 +6,18 @@ Handles:
|
||||
- activity:stop - client reports stopping (emits activity:clear)
|
||||
- disconnect - safety net: clears any activity registered for the socket
|
||||
|
||||
The acting user is never taken from the client payload — it is resolved from
|
||||
the authenticated socket session (stored on connect, see ``endpoints.sockets``)
|
||||
so a client cannot broadcast a "now playing" session on behalf of another user.
|
||||
Only ``rom_id`` / ``device_id`` come from the client.
|
||||
|
||||
All events broadcast to every connected client on the main `/ws` namespace.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import TypedDict
|
||||
from typing import Any, TypedDict
|
||||
|
||||
from endpoints.responses.activity import ActivityClearSchema
|
||||
from handler.activity_handler import ActivityEntry, activity_handler
|
||||
@@ -20,19 +25,43 @@ from handler.database import db_device_handler, db_rom_handler, db_user_handler
|
||||
from handler.socket_handler import socket_handler
|
||||
from logger.logger import log
|
||||
|
||||
# Socket-session key holding the authenticated user id, written by the connect
|
||||
# handler. Identity for every activity event is derived from this, not payload.
|
||||
AUTH_USER_SESSION_KEY = "activity_auth_user_id"
|
||||
|
||||
|
||||
class ActivityEventPayload(TypedDict, total=False):
|
||||
rom_id: int
|
||||
user_id: int
|
||||
device_id: str
|
||||
|
||||
|
||||
async def _session(sid: str) -> dict[str, Any]:
|
||||
try:
|
||||
return await socket_handler.socket_server.get_session(sid) or {}
|
||||
except KeyError:
|
||||
return {}
|
||||
|
||||
|
||||
async def store_authenticated_user(sid: str, user_id: int) -> None:
|
||||
"""Record the authenticated user for a socket so activity events trust it.
|
||||
|
||||
Called from the connect handler once the session has been resolved. Without
|
||||
this, activity events have no identity to act on and are ignored.
|
||||
"""
|
||||
existing = await _session(sid)
|
||||
existing[AUTH_USER_SESSION_KEY] = user_id
|
||||
await socket_handler.socket_server.save_session(sid, existing)
|
||||
|
||||
|
||||
async def _authenticated_user_id(sid: str) -> int | None:
|
||||
"""Return the user id resolved at connect time, or ``None`` if unauthenticated."""
|
||||
user_id = (await _session(sid)).get(AUTH_USER_SESSION_KEY)
|
||||
return int(user_id) if user_id is not None else None
|
||||
|
||||
|
||||
async def _store_session(sid: str, user_id: int, device_id: str) -> None:
|
||||
"""Remember the user/device associated with a socket for disconnect cleanup."""
|
||||
try:
|
||||
existing = await socket_handler.socket_server.get_session(sid) or {}
|
||||
except KeyError:
|
||||
existing = {}
|
||||
existing = await _session(sid)
|
||||
existing["activity_user_id"] = user_id
|
||||
existing["activity_device_id"] = device_id
|
||||
await socket_handler.socket_server.save_session(sid, existing)
|
||||
@@ -78,15 +107,14 @@ async def _build_entry(
|
||||
)
|
||||
|
||||
|
||||
def _extract_payload(data: object) -> tuple[int | None, str | None, int | None]:
|
||||
"""Return ``(user_id, device_id, rom_id)`` parsed from an event payload."""
|
||||
def _extract_payload(data: object) -> tuple[str | None, int | None]:
|
||||
"""Return ``(device_id, rom_id)`` parsed from an event payload.
|
||||
|
||||
``user_id`` is deliberately not read from the client — identity comes from
|
||||
the authenticated socket session, never the payload.
|
||||
"""
|
||||
if not isinstance(data, dict):
|
||||
return None, None, None
|
||||
try:
|
||||
data_user_id = data.get("user_id")
|
||||
user_id = int(data_user_id) if data_user_id else None
|
||||
except (TypeError, ValueError):
|
||||
user_id = None
|
||||
return None, None
|
||||
device_id = data.get("device_id")
|
||||
if not isinstance(device_id, str) or not device_id:
|
||||
device_id = None
|
||||
@@ -95,14 +123,15 @@ def _extract_payload(data: object) -> tuple[int | None, str | None, int | None]:
|
||||
rom_id = int(data_rom_id) if data_rom_id else None
|
||||
except (TypeError, ValueError):
|
||||
rom_id = None
|
||||
return user_id, device_id, rom_id
|
||||
return device_id, rom_id
|
||||
|
||||
|
||||
@socket_handler.socket_server.on("activity:start") # type: ignore
|
||||
async def activity_start(sid: str, data: ActivityEventPayload) -> None:
|
||||
user_id, device_id, rom_id = _extract_payload(data)
|
||||
user_id = await _authenticated_user_id(sid)
|
||||
device_id, rom_id = _extract_payload(data)
|
||||
if user_id is None or device_id is None or rom_id is None:
|
||||
log.debug(f"activity:start ignored (invalid payload): {data}")
|
||||
log.debug(f"activity:start ignored (unauthenticated or invalid): {data}")
|
||||
return
|
||||
|
||||
entry = await _build_entry(
|
||||
@@ -121,7 +150,8 @@ async def activity_start(sid: str, data: ActivityEventPayload) -> None:
|
||||
|
||||
@socket_handler.socket_server.on("activity:heartbeat") # type: ignore
|
||||
async def activity_heartbeat(sid: str, data: ActivityEventPayload) -> None:
|
||||
user_id, device_id, rom_id = _extract_payload(data)
|
||||
user_id = await _authenticated_user_id(sid)
|
||||
device_id, rom_id = _extract_payload(data)
|
||||
if user_id is None or device_id is None or rom_id is None:
|
||||
return
|
||||
|
||||
@@ -141,20 +171,15 @@ async def activity_heartbeat(sid: str, data: ActivityEventPayload) -> None:
|
||||
|
||||
@socket_handler.socket_server.on("activity:stop") # type: ignore
|
||||
async def activity_stop(sid: str, data: ActivityEventPayload | None = None) -> None:
|
||||
user_id: int | None = None
|
||||
user_id = await _authenticated_user_id(sid)
|
||||
|
||||
device_id: str | None = None
|
||||
|
||||
if data:
|
||||
user_id, device_id, _ = _extract_payload(data)
|
||||
device_id, _ = _extract_payload(data)
|
||||
|
||||
# Fall back to the stored session if the payload is missing fields.
|
||||
if user_id is None or device_id is None:
|
||||
try:
|
||||
session = await socket_handler.socket_server.get_session(sid) or {}
|
||||
except KeyError:
|
||||
session = {}
|
||||
user_id = user_id if user_id is not None else session.get("activity_user_id")
|
||||
device_id = device_id if device_id else session.get("activity_device_id")
|
||||
# Fall back to the device stored at start time if the payload omits it.
|
||||
if not device_id:
|
||||
device_id = (await _session(sid)).get("activity_device_id")
|
||||
|
||||
if user_id is None or not device_id:
|
||||
return
|
||||
@@ -174,11 +199,7 @@ async def activity_stop(sid: str, data: ActivityEventPayload | None = None) -> N
|
||||
@socket_handler.socket_server.on("disconnect") # type: ignore
|
||||
async def activity_on_disconnect(sid: str) -> None:
|
||||
"""Safety net: clear any activity tied to a disconnecting socket."""
|
||||
try:
|
||||
session = await socket_handler.socket_server.get_session(sid) or {}
|
||||
except KeyError:
|
||||
return
|
||||
|
||||
session = await _session(sid)
|
||||
user_id = session.get("activity_user_id")
|
||||
device_id = session.get("activity_device_id")
|
||||
if user_id is None or not device_id:
|
||||
|
||||
@@ -17,6 +17,7 @@ from typing import Any, Final
|
||||
import socketio # type: ignore
|
||||
|
||||
from config import DISABLE_LOGS_VIEWER, REDIS_URL
|
||||
from endpoints.sockets.activity import store_authenticated_user
|
||||
from handler.database import db_user_handler
|
||||
from handler.redis_handler import async_cache
|
||||
from handler.socket_handler import socket_handler
|
||||
@@ -33,14 +34,14 @@ FORWARDER_LOCK_TTL: Final = 30 # seconds
|
||||
|
||||
@socket_handler.socket_server.on("connect") # type: ignore
|
||||
async def connect(sid: str, environ: dict[str, Any], auth: Any = None) -> None:
|
||||
"""Join admin users to the log-streaming room on socket connect.
|
||||
"""Resolve the authenticated user on socket connect.
|
||||
|
||||
Always returns ``None`` (accepts the connection) — only the room membership
|
||||
is gated, so the existing scan/sync sockets keep working for everyone.
|
||||
Stores the user id in the socket session so activity events can trust the
|
||||
server-resolved identity instead of a client-supplied ``user_id``, and joins
|
||||
admin users to the log-streaming room. Always returns ``None`` (accepts the
|
||||
connection) — only identity storage and room membership are gated, so the
|
||||
existing scan/sync sockets keep working for everyone.
|
||||
"""
|
||||
if DISABLE_LOGS_VIEWER:
|
||||
return
|
||||
|
||||
try:
|
||||
session = await get_session_from_environ(environ)
|
||||
if session.get("iss") != "romm:auth":
|
||||
@@ -51,10 +52,15 @@ async def connect(sid: str, environ: dict[str, Any], auth: Any = None) -> None:
|
||||
return
|
||||
|
||||
user = db_user_handler.get_user_by_username(username)
|
||||
if user and user.enabled and user.role == Role.ADMIN:
|
||||
if not user or not user.enabled:
|
||||
return
|
||||
|
||||
await store_authenticated_user(sid, user.id)
|
||||
|
||||
if not DISABLE_LOGS_VIEWER and user.role == Role.ADMIN:
|
||||
await socket_handler.socket_server.enter_room(sid, ADMIN_ROOM)
|
||||
except Exception: # noqa: BLE001 - never let auth resolution refuse a socket
|
||||
log.exception("Failed to resolve admin for log stream connect")
|
||||
log.exception("Failed to resolve user on socket connect")
|
||||
|
||||
|
||||
async def get_recent_logs(limit: int) -> list[dict[str, Any]]:
|
||||
|
||||
@@ -93,10 +93,14 @@ class ActivityHandler:
|
||||
|
||||
async def get_all_active(self) -> list[ActivityEntry]:
|
||||
"""Get all currently active play sessions across all users."""
|
||||
entries: list[ActivityEntry] = []
|
||||
pattern = f"{self.KEY_PREFIX}*"
|
||||
async for key in async_cache.scan_iter(match=pattern):
|
||||
raw = await async_cache.get(key)
|
||||
keys = [key async for key in async_cache.scan_iter(match=pattern)]
|
||||
if not keys:
|
||||
return []
|
||||
|
||||
# Single round-trip for every value instead of a GET per key.
|
||||
entries: list[ActivityEntry] = []
|
||||
for raw in await async_cache.mget(keys):
|
||||
if not raw:
|
||||
continue
|
||||
try:
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import storeActivity from "@/stores/activity";
|
||||
|
||||
const props = defineProps<{ romId: number }>();
|
||||
const { t } = useI18n();
|
||||
const activityStore = storeActivity();
|
||||
|
||||
onMounted(() => {
|
||||
activityStore.initSocket();
|
||||
if (!activityStore.initialized) {
|
||||
activityStore.fetchAll();
|
||||
}
|
||||
});
|
||||
|
||||
const activePlayers = computed(() => activityStore.getByRomId(props.romId));
|
||||
|
||||
function formatStartedAt(iso: string): string {
|
||||
try {
|
||||
const date = new Date(iso);
|
||||
return date.toLocaleTimeString([], {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-card
|
||||
v-if="activePlayers.length > 0"
|
||||
class="mb-3"
|
||||
variant="tonal"
|
||||
color="success"
|
||||
density="compact"
|
||||
>
|
||||
<v-card-text class="d-flex align-center flex-wrap py-2">
|
||||
<v-icon class="mr-2" color="success" size="small">
|
||||
mdi-access-point
|
||||
</v-icon>
|
||||
<span class="text-body-2 font-weight-medium mr-3">
|
||||
{{ t("activity.now-playing") }}
|
||||
</span>
|
||||
<v-avatar
|
||||
v-for="player in activePlayers"
|
||||
:key="`${player.user_id}-${player.device_id}`"
|
||||
size="32"
|
||||
class="mr-1"
|
||||
>
|
||||
<v-img
|
||||
v-if="player.avatar_path"
|
||||
:src="`/assets/${player.avatar_path}`"
|
||||
:alt="player.username"
|
||||
/>
|
||||
<v-icon v-else>mdi-account-circle</v-icon>
|
||||
<v-tooltip activator="parent" location="bottom">
|
||||
<div>
|
||||
<strong>{{ player.username }}</strong>
|
||||
<div class="text-caption">
|
||||
{{ t("activity.playing-on", { device: player.device_type }) }}
|
||||
</div>
|
||||
<div class="text-caption">
|
||||
{{
|
||||
t("activity.playing-since", {
|
||||
time: formatStartedAt(player.started_at),
|
||||
})
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
</v-tooltip>
|
||||
</v-avatar>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</template>
|
||||
@@ -1,68 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import storeActivity from "@/stores/activity";
|
||||
import storeNavigation from "@/stores/navigation";
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
block?: boolean;
|
||||
height?: string;
|
||||
rounded?: boolean;
|
||||
withTag?: boolean;
|
||||
}>(),
|
||||
{
|
||||
block: false,
|
||||
height: "",
|
||||
rounded: false,
|
||||
withTag: false,
|
||||
},
|
||||
);
|
||||
|
||||
const { t } = useI18n();
|
||||
const navigationStore = storeNavigation();
|
||||
const activityStore = storeActivity();
|
||||
|
||||
onMounted(() => {
|
||||
activityStore.initSocket();
|
||||
if (!activityStore.initialized) {
|
||||
activityStore.fetchAll();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-btn
|
||||
icon
|
||||
:block="block"
|
||||
variant="flat"
|
||||
color="background"
|
||||
:height="height"
|
||||
:class="{ rounded: rounded }"
|
||||
class="py-4 bg-background d-flex align-center justify-center"
|
||||
@click="navigationStore.goActivity"
|
||||
>
|
||||
<div class="d-flex flex-column align-center">
|
||||
<v-badge
|
||||
:model-value="activityStore.activeCount > 0"
|
||||
:content="activityStore.activeCount"
|
||||
color="success"
|
||||
offset-x="-2"
|
||||
offset-y="-2"
|
||||
>
|
||||
<v-icon :color="$route.path.startsWith('/activity') ? 'primary' : ''">
|
||||
mdi-access-point
|
||||
</v-icon>
|
||||
</v-badge>
|
||||
<v-expand-transition>
|
||||
<span
|
||||
v-if="withTag"
|
||||
class="text-caption text-center"
|
||||
:class="{ 'text-primary': $route.path.startsWith('/activity') }"
|
||||
>
|
||||
{{ t("common.activity") }}
|
||||
</span>
|
||||
</v-expand-transition>
|
||||
</div>
|
||||
</v-btn>
|
||||
</template>
|
||||
@@ -4,7 +4,6 @@ import { storeToRefs } from "pinia";
|
||||
import { useDisplay } from "vuetify";
|
||||
import RandomBtn from "@/components/Gallery/AppBar/common/RandomBtn.vue";
|
||||
import UploadRomDialog from "@/components/common/Game/Dialog/UploadRom.vue";
|
||||
import ActivityBtn from "@/components/common/Navigation/ActivityBtn.vue";
|
||||
import CollectionsBtn from "@/components/common/Navigation/CollectionsBtn.vue";
|
||||
import CollectionsDrawer from "@/components/common/Navigation/CollectionsDrawer.vue";
|
||||
import ConsoleModeBtn from "@/components/common/Navigation/ConsoleModeBtn.vue";
|
||||
@@ -49,7 +48,6 @@ function collapse() {
|
||||
</template>
|
||||
|
||||
<template #append>
|
||||
<ActivityBtn class="mr-2" />
|
||||
<PatcherBtn class="mr-2" />
|
||||
<RandomBtn class="mr-2" />
|
||||
<UploadBtn class="mr-2" />
|
||||
@@ -114,7 +112,6 @@ function collapse() {
|
||||
<ConsoleModeBtn :with-tag="!mainBarCollapsed" rounded class="mt-2" block />
|
||||
|
||||
<template #append>
|
||||
<ActivityBtn :with-tag="!mainBarCollapsed" rounded class="mt-2" block />
|
||||
<PatcherBtn :with-tag="!mainBarCollapsed" rounded class="mt-2" block />
|
||||
<RandomBtn :with-tag="!mainBarCollapsed" rounded class="mt-2" block />
|
||||
<UploadBtn
|
||||
|
||||
@@ -325,7 +325,9 @@ const routes = [
|
||||
title: i18n.global.t("activity.active-sessions"),
|
||||
},
|
||||
components: {
|
||||
default: () => import("@/views/Activity.vue"),
|
||||
// v2-only view; v1 has no activity concept so it redirects
|
||||
// home if a v1 user deep-links here.
|
||||
default: () => import("@/views/Home.vue"),
|
||||
v2: v2For(ROUTES.ACTIVITY),
|
||||
},
|
||||
},
|
||||
|
||||
@@ -49,10 +49,6 @@ export default defineStore("navigation", {
|
||||
this.reset();
|
||||
this.$router.push({ name: ROUTES.PATCHER });
|
||||
},
|
||||
goActivity() {
|
||||
this.reset();
|
||||
this.$router.push({ name: ROUTES.ACTIVITY });
|
||||
},
|
||||
goSearch() {
|
||||
this.reset();
|
||||
this.$router.push({ name: ROUTES.SEARCH });
|
||||
|
||||
@@ -229,14 +229,6 @@ async function onLogout() {
|
||||
<div class="r-v2-user-menu__group-label">
|
||||
{{ t("settings.group-system") }}
|
||||
</div>
|
||||
<!-- Live "now playing" board — readable by any authenticated user, so
|
||||
it leads the System group (mirrors SettingsSidebar). -->
|
||||
<RMenuItem
|
||||
:to="{ name: ROUTES.ACTIVITY }"
|
||||
icon="mdi-access-point"
|
||||
:label="t('activity.active-sessions')"
|
||||
@click="open = false"
|
||||
/>
|
||||
<RMenuItem
|
||||
v-if="canSeeAdmin"
|
||||
:to="{ name: ROUTES.ADMINISTRATION }"
|
||||
@@ -244,6 +236,12 @@ async function onLogout() {
|
||||
:label="t('common.administration')"
|
||||
@click="open = false"
|
||||
/>
|
||||
<RMenuItem
|
||||
:to="{ name: ROUTES.ACTIVITY }"
|
||||
icon="mdi-access-point"
|
||||
:label="t('activity.active-sessions')"
|
||||
@click="open = false"
|
||||
/>
|
||||
<RMenuItem
|
||||
v-if="isAdmin"
|
||||
:to="{ name: ROUTES.SERVER_STATS }"
|
||||
|
||||
@@ -90,14 +90,6 @@ const gameRunning = ref(false);
|
||||
const removeIOSFullscreenShim = ref<(() => void) | null>(null);
|
||||
|
||||
// ── Live activity ("now playing") ──────────────────────────────────
|
||||
// The v2 shell owns the activity lifecycle directly rather than relying
|
||||
// on the reused v1 <Player>'s `window.EJS_onGameStart` callback — that
|
||||
// callback doesn't fire reliably in the v2 mount sequence (the async
|
||||
// <Player> mounts after the shell has already booted the loader), so
|
||||
// the board never saw the session. Driving it off the shell's own
|
||||
// deterministic `gameRunning` transition guarantees the emit. The
|
||||
// backend's set_active / clear_active are idempotent, so a stray emit
|
||||
// from the v1 layer (were it ever to fire) is harmless.
|
||||
const ACTIVITY_HEARTBEAT_MS = 30_000;
|
||||
let activityHeartbeatTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
@@ -110,7 +102,6 @@ function emitActivityStart() {
|
||||
if (!socket.connected) socket.connect();
|
||||
socket.emit("activity:start", {
|
||||
rom_id: rom.value.id,
|
||||
user_id: auth.user.id,
|
||||
device_id: activityDeviceId(),
|
||||
});
|
||||
}
|
||||
@@ -119,7 +110,6 @@ function emitActivityHeartbeat() {
|
||||
if (!auth.user || !rom.value) return;
|
||||
socket.emit("activity:heartbeat", {
|
||||
rom_id: rom.value.id,
|
||||
user_id: auth.user.id,
|
||||
device_id: activityDeviceId(),
|
||||
});
|
||||
}
|
||||
@@ -127,7 +117,6 @@ function emitActivityHeartbeat() {
|
||||
function emitActivityStop() {
|
||||
if (!auth.user) return;
|
||||
socket.emit("activity:stop", {
|
||||
user_id: auth.user.id,
|
||||
device_id: activityDeviceId(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,192 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeMount, onBeforeUnmount, ref } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { ROUTES } from "@/plugins/router";
|
||||
import type { ActivityEntry } from "@/services/api/activity";
|
||||
import storeActivity from "@/stores/activity";
|
||||
import { FRONTEND_RESOURCES_PATH } from "@/utils";
|
||||
|
||||
const { t } = useI18n();
|
||||
const activityStore = storeActivity();
|
||||
const now = ref(Date.now());
|
||||
let tickTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
onBeforeMount(async () => {
|
||||
activityStore.initSocket();
|
||||
await activityStore.fetchAll();
|
||||
// Update "elapsed time" labels every 30 seconds.
|
||||
tickTimer = setInterval(() => {
|
||||
now.value = Date.now();
|
||||
}, 30_000);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (tickTimer) {
|
||||
clearInterval(tickTimer);
|
||||
tickTimer = null;
|
||||
}
|
||||
});
|
||||
|
||||
const activities = computed(() =>
|
||||
[...activityStore.activities].sort(
|
||||
(a, b) =>
|
||||
new Date(a.started_at).getTime() - new Date(b.started_at).getTime(),
|
||||
),
|
||||
);
|
||||
|
||||
function coverSrc(entry: ActivityEntry): string {
|
||||
if (!entry.rom_cover_path) return "";
|
||||
return `${FRONTEND_RESOURCES_PATH}/${entry.rom_cover_path}`;
|
||||
}
|
||||
|
||||
function avatarSrc(entry: ActivityEntry): string {
|
||||
if (!entry.avatar_path) return "";
|
||||
return `/assets/${entry.avatar_path}`;
|
||||
}
|
||||
|
||||
function elapsedLabel(startedAt: string): string {
|
||||
const started = new Date(startedAt).getTime();
|
||||
if (Number.isNaN(started)) return "";
|
||||
const diffMs = Math.max(0, now.value - started);
|
||||
const minutes = Math.floor(diffMs / 60_000);
|
||||
if (minutes < 1) return t("activity.just-now");
|
||||
if (minutes < 60) return t("activity.minutes-ago", { n: minutes });
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const remMin = minutes % 60;
|
||||
if (hours < 24) {
|
||||
return remMin === 0
|
||||
? t("activity.hours-ago", { n: hours })
|
||||
: t("activity.hours-minutes-ago", { h: hours, m: remMin });
|
||||
}
|
||||
const days = Math.floor(hours / 24);
|
||||
return t("activity.days-ago", { n: days });
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-container class="py-4">
|
||||
<div class="d-flex align-center mb-4">
|
||||
<v-icon class="mr-2" color="success">mdi-access-point</v-icon>
|
||||
<h2 class="text-h5 font-weight-medium">
|
||||
{{ t("activity.active-sessions") }}
|
||||
</h2>
|
||||
<v-chip
|
||||
v-if="activities.length > 0"
|
||||
class="ml-3"
|
||||
color="success"
|
||||
size="small"
|
||||
variant="tonal"
|
||||
>
|
||||
{{ activities.length }}
|
||||
</v-chip>
|
||||
</div>
|
||||
|
||||
<v-alert
|
||||
v-if="activities.length === 0"
|
||||
type="info"
|
||||
variant="tonal"
|
||||
icon="mdi-information-outline"
|
||||
>
|
||||
{{ t("activity.no-activity") }}
|
||||
</v-alert>
|
||||
|
||||
<v-row v-else>
|
||||
<v-col
|
||||
v-for="entry in activities"
|
||||
:key="`${entry.user_id}-${entry.device_id}`"
|
||||
cols="12"
|
||||
sm="6"
|
||||
md="4"
|
||||
lg="3"
|
||||
>
|
||||
<router-link
|
||||
:to="{ name: ROUTES.ROM, params: { rom: entry.rom_id } }"
|
||||
class="activity-link"
|
||||
>
|
||||
<v-card variant="tonal" class="h-100">
|
||||
<div class="cover-wrapper">
|
||||
<v-img
|
||||
v-if="coverSrc(entry)"
|
||||
:src="coverSrc(entry)"
|
||||
:alt="entry.rom_name"
|
||||
aspect-ratio="2/3"
|
||||
cover
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
class="cover-placeholder d-flex align-center justify-center"
|
||||
>
|
||||
<v-icon size="48" color="grey-lighten-1">
|
||||
mdi-nintendo-game-boy
|
||||
</v-icon>
|
||||
</div>
|
||||
<div class="cover-overlay">
|
||||
<v-chip
|
||||
color="success"
|
||||
variant="flat"
|
||||
size="x-small"
|
||||
class="live-chip"
|
||||
>
|
||||
<v-icon start size="x-small">mdi-access-point</v-icon>
|
||||
{{ t("activity.live") }}
|
||||
</v-chip>
|
||||
</div>
|
||||
</div>
|
||||
<v-card-text class="pa-3">
|
||||
<div class="text-body-2 font-weight-medium text-truncate">
|
||||
{{ entry.rom_name }}
|
||||
</div>
|
||||
<div class="text-caption text-medium-emphasis text-truncate">
|
||||
{{ entry.platform_name }}
|
||||
</div>
|
||||
<v-divider class="my-2" />
|
||||
<div class="d-flex align-center">
|
||||
<v-avatar size="24" class="mr-2">
|
||||
<v-img
|
||||
v-if="avatarSrc(entry)"
|
||||
:src="avatarSrc(entry)"
|
||||
:alt="entry.username"
|
||||
/>
|
||||
<v-icon v-else size="small">mdi-account-circle</v-icon>
|
||||
</v-avatar>
|
||||
<div class="text-body-2 text-truncate">
|
||||
{{ entry.username }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-caption text-medium-emphasis mt-1">
|
||||
{{ elapsedLabel(entry.started_at) }}
|
||||
<span v-if="entry.device_type">
|
||||
· {{ entry.device_type }}
|
||||
</span>
|
||||
</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</router-link>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-container>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.activity-link {
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
display: block;
|
||||
height: 100%;
|
||||
}
|
||||
.cover-wrapper {
|
||||
position: relative;
|
||||
}
|
||||
.cover-placeholder {
|
||||
aspect-ratio: 2 / 3;
|
||||
background-color: rgba(var(--v-theme-surface-variant, 0.08));
|
||||
}
|
||||
.cover-overlay {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
}
|
||||
.live-chip {
|
||||
box-shadow: 0 0 12px rgba(76, 175, 80, 0.6);
|
||||
}
|
||||
</style>
|
||||
@@ -6,7 +6,6 @@ import { useI18n } from "vue-i18n";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { useDisplay } from "vuetify";
|
||||
import ActionBar from "@/components/Details/ActionBar.vue";
|
||||
import ActivePlayers from "@/components/Details/ActivePlayers.vue";
|
||||
import AdditionalContent from "@/components/Details/AdditionalContent.vue";
|
||||
import BackgroundHeader from "@/components/Details/BackgroundHeader.vue";
|
||||
import GameData from "@/components/Details/GameData.vue";
|
||||
@@ -234,7 +233,6 @@ watch(
|
||||
<v-window-item value="details">
|
||||
<v-row no-gutters>
|
||||
<v-col>
|
||||
<ActivePlayers :rom-id="currentRom.id" />
|
||||
<FileInfo :rom="currentRom" />
|
||||
<GameInfo :rom="currentRom" />
|
||||
</v-col>
|
||||
|
||||
@@ -13,7 +13,6 @@ import type {
|
||||
import { ROUTES } from "@/plugins/router";
|
||||
import playSessionApi from "@/services/api/play-session";
|
||||
import { saveApi as api } from "@/services/api/save";
|
||||
import socket from "@/services/socket";
|
||||
import storeAuth from "@/stores/auth";
|
||||
import storeConfig from "@/stores/config";
|
||||
import storeLanguage from "@/stores/language";
|
||||
@@ -58,54 +57,6 @@ const romRef = ref<DetailedRom>(props.rom);
|
||||
const saveRef = ref<SaveSchema | null>(props.save);
|
||||
const sessionStartTime = ref<Date | null>(null);
|
||||
const deviceIDRef = ref(authStore.user?.current_device_id ?? undefined);
|
||||
const activityHeartbeatTimer = ref<ReturnType<typeof setInterval> | null>(null);
|
||||
const ACTIVITY_HEARTBEAT_MS = 30_000;
|
||||
|
||||
function activityDeviceId(): string {
|
||||
return deviceIDRef.value ?? "web";
|
||||
}
|
||||
|
||||
function emitActivityStart() {
|
||||
if (!authStore.user) return;
|
||||
if (!socket.connected) socket.connect();
|
||||
socket.emit("activity:start", {
|
||||
rom_id: romRef.value.id,
|
||||
user_id: authStore.user.id,
|
||||
device_id: activityDeviceId(),
|
||||
});
|
||||
}
|
||||
|
||||
function emitActivityHeartbeat() {
|
||||
if (!authStore.user) return;
|
||||
socket.emit("activity:heartbeat", {
|
||||
rom_id: romRef.value.id,
|
||||
user_id: authStore.user.id,
|
||||
device_id: activityDeviceId(),
|
||||
});
|
||||
}
|
||||
|
||||
function emitActivityStop() {
|
||||
if (!authStore.user) return;
|
||||
socket.emit("activity:stop", {
|
||||
user_id: authStore.user.id,
|
||||
device_id: activityDeviceId(),
|
||||
});
|
||||
}
|
||||
|
||||
function startActivityHeartbeat() {
|
||||
if (activityHeartbeatTimer.value) return;
|
||||
activityHeartbeatTimer.value = setInterval(
|
||||
emitActivityHeartbeat,
|
||||
ACTIVITY_HEARTBEAT_MS,
|
||||
);
|
||||
}
|
||||
|
||||
function stopActivityHeartbeat() {
|
||||
if (activityHeartbeatTimer.value) {
|
||||
clearInterval(activityHeartbeatTimer.value);
|
||||
activityHeartbeatTimer.value = null;
|
||||
}
|
||||
}
|
||||
const theme = useTheme();
|
||||
const emitter = inject<Emitter<Events>>("emitter");
|
||||
const { playing, fullScreen } = storeToRefs(playingStore);
|
||||
@@ -275,8 +226,6 @@ onBeforeUnmount(async () => {
|
||||
window.EJS_emulator?.callEvent("exit");
|
||||
fullScreen.value = false;
|
||||
playing.value = false;
|
||||
stopActivityHeartbeat();
|
||||
emitActivityStop();
|
||||
});
|
||||
|
||||
function displayMessage(
|
||||
@@ -411,8 +360,6 @@ window.EJS_onSaveState = async function ({
|
||||
|
||||
window.EJS_onGameStart = async () => {
|
||||
sessionStartTime.value = new Date();
|
||||
emitActivityStart();
|
||||
startActivityHeartbeat();
|
||||
setTimeout(async () => {
|
||||
if (props.save) await loadSave(props.save);
|
||||
if (props.state) await loadState(props.state);
|
||||
@@ -500,9 +447,6 @@ window.EJS_onGameStart = async () => {
|
||||
};
|
||||
|
||||
function immediateExit() {
|
||||
stopActivityHeartbeat();
|
||||
emitActivityStop();
|
||||
|
||||
if (!sessionStartTime.value) {
|
||||
return router
|
||||
.push({ name: ROUTES.ROM, params: { rom: romRef.value.id } })
|
||||
|
||||
Reference in New Issue
Block a user