mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-15 17:12:59 -04:00
duplicate tab protection
This commit is contained in:
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -78,8 +78,8 @@
|
||||
<script defer src="https://analytics.otter.land/script.js" data-website-id="82dd56a5-db44-4279-bd1e-a4d9fee39af7" data-domains="rover.otter.land"></script>
|
||||
<script defer src="https://analytics.otter.land/recorder.js" data-website-id="82dd56a5-db44-4279-bd1e-a4d9fee39af7" data-domains="rover.otter.land" data-sample-rate="0.15" data-mask-level="moderate" data-max-duration="300000"></script>
|
||||
<title>Roomba Rover</title>
|
||||
<script type="module" crossorigin src="/assets/index-DYZ0CDIw.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index--DqrzMoo.css">
|
||||
<script type="module" crossorigin src="/assets/index-Cp0GiTXH.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-CflZqP0e.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -30,6 +30,7 @@ const { registerVerificationHooks } = require('./hooks');
|
||||
const verificationEvents = new EventEmitter();
|
||||
const IDENTITY_TIMEOUT_MS = 2 * 60 * 1000;
|
||||
const IDENTITY_SWEEP_INTERVAL_MS = 15 * 1000;
|
||||
const DUPLICATE_IDENTITY_DISCONNECT_DELAY_MS = 250;
|
||||
|
||||
function emitChange(reason, payload = {}) {
|
||||
verificationEvents.emit('change', { reason, ...payload });
|
||||
@@ -119,8 +120,17 @@ function identifySocket(socket, payload = {}) {
|
||||
data.overseerEnabled = true;
|
||||
}
|
||||
|
||||
/*
|
||||
Spectator-style pages send the same identity heartbeat as the driver page,
|
||||
but they should not participate in multitabbing prevention. The page surface
|
||||
flag makes that distinction explicit before role changes finish, which avoids
|
||||
a race where a spectator route briefly looks like a normal user connection.
|
||||
*/
|
||||
data.identitySurface = payload.identitySurface === 'driver' ? 'driver' : 'passive';
|
||||
|
||||
const verification = reevaluateSocketVerification(socket);
|
||||
const deterrence = reevaluateSocketDeterrence(socket);
|
||||
enforceSingleUnverifiedSocketPerIdentity(socket);
|
||||
emitChange('identify', { socketId: socket.id });
|
||||
return {
|
||||
cookieUserId: data.cookieUserId,
|
||||
@@ -132,6 +142,92 @@ function identifySocket(socket, payload = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
function emitDuplicateIdentityAndDisconnect(socket, payload = {}) {
|
||||
if (!socket?.id || socket.disconnected) return;
|
||||
|
||||
/*
|
||||
The browser needs a small amount of time to render the blocking overlay
|
||||
before the transport closes. Socket.IO does not guarantee that an immediate
|
||||
disconnect after emit will be visible to the client, so the short timer is
|
||||
intentionally used as an event-delivery grace period rather than a retry or
|
||||
background worker.
|
||||
*/
|
||||
socket.emit('session:duplicateIdentity', {
|
||||
reason: 'duplicate_identity',
|
||||
message: 'This driver session is already active in another tab.',
|
||||
...payload,
|
||||
});
|
||||
setTimeout(() => {
|
||||
if (!socket.disconnected) {
|
||||
socket.disconnect(true);
|
||||
}
|
||||
}, DUPLICATE_IDENTITY_DISCONNECT_DELAY_MS);
|
||||
}
|
||||
|
||||
function enforceSingleUnverifiedSocketPerIdentity(currentSocket) {
|
||||
const currentKey = normalizeCookieUserId(currentSocket?.data?.cookieUserId);
|
||||
if (
|
||||
!currentSocket?.id ||
|
||||
!currentKey ||
|
||||
currentSocket.data?.isVerified ||
|
||||
currentSocket.data?.identitySurface !== 'driver'
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
/*
|
||||
Verification status is evaluated before this function runs. That ordering is
|
||||
important because verified users are immune to duplicate-tab enforcement:
|
||||
a verified socket is never disconnected here, and a verified current socket
|
||||
never causes older tabs to be removed.
|
||||
*/
|
||||
const duplicates = Array.from(io.sockets.sockets.values()).filter((candidate) => {
|
||||
if (!candidate?.id || candidate.id === currentSocket.id || candidate.disconnected) return false;
|
||||
if (candidate?.data?.identitySurface !== 'driver') return false;
|
||||
const candidateKey = normalizeCookieUserId(candidate?.data?.cookieUserId);
|
||||
return Boolean(candidateKey && candidateKey === currentKey);
|
||||
});
|
||||
|
||||
if (duplicates.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const verifiedDuplicate = duplicates.find((candidate) => candidate?.data?.isVerified);
|
||||
if (verifiedDuplicate) {
|
||||
/*
|
||||
A verified tab is allowed to keep running, but the non-verified tab that
|
||||
collided with it should still be blocked. This keeps the immunity attached
|
||||
to verified users instead of turning a verified identity key into a bypass
|
||||
for unverified browser sessions.
|
||||
*/
|
||||
logger.info('Disconnecting non-verified socket because its identity is already active on a verified socket', {
|
||||
socketId: currentSocket.id,
|
||||
retainedSocketId: verifiedDuplicate.id,
|
||||
cookieUserId: currentKey,
|
||||
});
|
||||
emitDuplicateIdentityAndDisconnect(currentSocket, {
|
||||
retainedSocketId: verifiedDuplicate.id,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
/*
|
||||
When all duplicates are non-verified, the newest socket wins. Opening a new
|
||||
tab should move the user to that tab instead of leaving an older background
|
||||
tab with rover control, chat identity, or game participation.
|
||||
*/
|
||||
duplicates.forEach((duplicate) => {
|
||||
logger.info('Disconnecting older non-verified duplicate identity socket', {
|
||||
socketId: duplicate.id,
|
||||
retainedSocketId: currentSocket.id,
|
||||
cookieUserId: currentKey,
|
||||
});
|
||||
emitDuplicateIdentityAndDisconnect(duplicate, {
|
||||
retainedSocketId: currentSocket.id,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function getVerificationStateForSocket(socket) {
|
||||
return verificationFlow.getVerificationStateForSocket(socket, requestFlow.getPendingRequestForIdentity);
|
||||
}
|
||||
|
||||
+3
-1
@@ -45,6 +45,7 @@ import BarcodeGamesPanel from './components/BarcodeGamesPanel/index.jsx';
|
||||
import OdometerPanel from './components/OdometerPanel/index.jsx';
|
||||
import RewardRunOverlay from './components/RewardRunOverlay/index.jsx';
|
||||
import SocketConnectionPill from './components/SocketConnectionPill/index.jsx';
|
||||
import DuplicateIdentityOverlay from './components/DuplicateIdentityOverlay/index.jsx';
|
||||
import { pageBackgroundClass, themeGapClass, themeStackClass } from './themeFlags.js';
|
||||
import { trackAnalyticsEvent } from './analytics/index.js';
|
||||
|
||||
@@ -304,7 +305,7 @@ function App() {
|
||||
|
||||
function AppWithProviders({ layout, isDesktop, fullscreen }) {
|
||||
useDefaultNickname();
|
||||
useUserIdentitySync();
|
||||
useUserIdentitySync({ identitySurface: 'driver' });
|
||||
useTelemetryVisualPolicy({ mobile: !isDesktop });
|
||||
const {
|
||||
visible: fullscreenVisible,
|
||||
@@ -425,6 +426,7 @@ function AppWithProviders({ layout, isDesktop, fullscreen }) {
|
||||
{renderedLayout}
|
||||
</main>
|
||||
<AlertFeed />
|
||||
<DuplicateIdentityOverlay />
|
||||
<RewardRunOverlay />
|
||||
<TurnAlertListener />
|
||||
<ModeGateOverlay />
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
// Duplicate Identity Overlay
|
||||
// Purpose: Blocks an older non-verified driver tab after the server detects the same identity in another driver tab.
|
||||
// Scope: Presents a driver-page-only message while leaving enforcement owned by the server.
|
||||
import { useSessionSelector } from '../../context/SessionContext.jsx';
|
||||
|
||||
export default function DuplicateIdentityOverlay() {
|
||||
const duplicateIdentityBlock = useSessionSelector((state) => state.duplicateIdentityBlock);
|
||||
|
||||
if (!duplicateIdentityBlock) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const message =
|
||||
typeof duplicateIdentityBlock?.message === 'string' && duplicateIdentityBlock.message.trim()
|
||||
? duplicateIdentityBlock.message.trim()
|
||||
: 'This driver session is already active in another tab.';
|
||||
|
||||
return (
|
||||
<div className="pointer-events-auto fixed inset-0 z-[2000] flex items-center justify-center bg-black px-0.5 py-0.5 text-slate-100">
|
||||
<section className="surface w-full max-w-md space-y-0.5 text-center shadow-2xl">
|
||||
<div className="space-y-0.5">
|
||||
<h1 className="text-lg font-semibold text-white">Another driver tab is already open</h1>
|
||||
<p className="text-sm text-slate-300">{message}</p>
|
||||
</div>
|
||||
{/* <div className="surface-muted space-y-0.5">
|
||||
<p className="text-sm text-slate-100">
|
||||
Continue from the newest tab or close this one.
|
||||
</p>
|
||||
<p className="text-xs text-slate-400">
|
||||
Verified users are allowed to keep multiple tabs open.
|
||||
</p>
|
||||
</div> */}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -18,6 +18,7 @@ const INITIAL_STATE = {
|
||||
replayJobs: {},
|
||||
latestReplay: null,
|
||||
latestRequestedReplay: null,
|
||||
duplicateIdentityBlock: null,
|
||||
};
|
||||
|
||||
const SessionContext = createContext(null);
|
||||
@@ -283,6 +284,26 @@ export function SessionProvider({ children }) {
|
||||
const memory = payload && typeof payload === 'object' ? payload : null;
|
||||
setState((prev) => ({ ...prev, overseerMemory: memory }));
|
||||
}
|
||||
function handleDuplicateIdentity(payload = {}) {
|
||||
/*
|
||||
The server sends this event immediately before closing an older duplicate
|
||||
tab. Persisting the reason in app state lets the UI replace the whole
|
||||
page with a clear blocking screen instead of leaving users on a normal
|
||||
disconnected interface that looks recoverable.
|
||||
*/
|
||||
const message =
|
||||
typeof payload?.message === 'string' && payload.message.trim()
|
||||
? payload.message.trim()
|
||||
: 'This driver session is already active in another tab.';
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
duplicateIdentityBlock: {
|
||||
...payload,
|
||||
message,
|
||||
receivedAt: Date.now(),
|
||||
},
|
||||
}));
|
||||
}
|
||||
socket.on('session:sync', handleSession);
|
||||
socket.on('log:init', handleLogInit);
|
||||
socket.on('log:entry', handleLogEntry);
|
||||
@@ -295,6 +316,7 @@ export function SessionProvider({ children }) {
|
||||
socket.on('replay:status', handleReplayStatus);
|
||||
socket.on('replay:ready', handleReplayReady);
|
||||
socket.on('replay:failed', handleReplayFailed);
|
||||
socket.on('session:duplicateIdentity', handleDuplicateIdentity);
|
||||
return () => {
|
||||
socket.off('session:sync', handleSession);
|
||||
socket.off('log:init', handleLogInit);
|
||||
@@ -308,14 +330,15 @@ export function SessionProvider({ children }) {
|
||||
socket.off('replay:status', handleReplayStatus);
|
||||
socket.off('replay:ready', handleReplayReady);
|
||||
socket.off('replay:failed', handleReplayFailed);
|
||||
socket.off('session:duplicateIdentity', handleDuplicateIdentity);
|
||||
};
|
||||
}, [setState, socket]);
|
||||
|
||||
const actions = useMemo(
|
||||
() => ({
|
||||
login: (username, password) => emitWithAck('auth:login', { username, password }),
|
||||
identifySession: ({ cookieUserId, nickname, overseerEnabled } = {}) =>
|
||||
emitWithAck('session:identify', { cookieUserId, nickname, overseerEnabled }),
|
||||
identifySession: ({ cookieUserId, nickname, overseerEnabled, identitySurface } = {}) =>
|
||||
emitWithAck('session:identify', { cookieUserId, nickname, overseerEnabled, identitySurface }),
|
||||
setRole: (role) => emitWithAck('session:setRole', { role }),
|
||||
requestControl: (roverId, options = {}) =>
|
||||
emitWithAck('session:requestControl', { roverId, ...options }),
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useSessionActions, useSessionSelector } from '../context/SessionContext
|
||||
import { useSocket } from '../context/SocketContext.jsx';
|
||||
import { useSettingsNamespace } from '../settings/index.js';
|
||||
|
||||
export default function useUserIdentitySync() {
|
||||
export default function useUserIdentitySync({ identitySurface = 'passive' } = {}) {
|
||||
const socket = useSocket();
|
||||
const connected = useSessionSelector((state) => state.connected);
|
||||
const { identifySession } = useSessionActions();
|
||||
@@ -27,6 +27,7 @@ export default function useUserIdentitySync() {
|
||||
const cookieUserId = (identity?.cookieUserId || '').trim();
|
||||
const nickname = (profile?.nickname || '').trim();
|
||||
const overseerEnabled = Boolean(overseerPreference?.enabled);
|
||||
const normalizedIdentitySurface = identitySurface === 'driver' ? 'driver' : 'passive';
|
||||
|
||||
const clearRetry = useCallback(() => {
|
||||
if (retryTimerRef.current) {
|
||||
@@ -39,7 +40,18 @@ export default function useUserIdentitySync() {
|
||||
if (!ready || !connected || !socket?.id || inFlightRef.current) return;
|
||||
inFlightRef.current = true;
|
||||
try {
|
||||
const resp = await identifySession({ cookieUserId, nickname, overseerEnabled });
|
||||
/*
|
||||
Every route shares the same persisted identity key, but only the main
|
||||
driver page should trigger duplicate-tab prevention. Sending the surface
|
||||
with the heartbeat lets the server make that decision before spectator
|
||||
pages finish their role switch.
|
||||
*/
|
||||
const resp = await identifySession({
|
||||
cookieUserId,
|
||||
nickname,
|
||||
overseerEnabled,
|
||||
identitySurface: normalizedIdentitySurface,
|
||||
});
|
||||
const nextKey = (resp?.cookieUserId || '').trim();
|
||||
if (nextKey && nextKey !== cookieUserId) {
|
||||
saveIdentity((current) => ({ ...(current || {}), cookieUserId: nextKey }));
|
||||
@@ -59,6 +71,7 @@ export default function useUserIdentitySync() {
|
||||
connected,
|
||||
cookieUserId,
|
||||
identifySession,
|
||||
normalizedIdentitySurface,
|
||||
nickname,
|
||||
overseerEnabled,
|
||||
ready,
|
||||
@@ -75,7 +88,7 @@ export default function useUserIdentitySync() {
|
||||
useEffect(() => {
|
||||
if (!ready || !connected || !socket?.id) return;
|
||||
sendIdentify();
|
||||
}, [ready, connected, socket?.id, cookieUserId, nickname, overseerEnabled, sendIdentify]);
|
||||
}, [ready, connected, socket?.id, cookieUserId, nickname, overseerEnabled, normalizedIdentitySurface, sendIdentify]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleOnline = () => {
|
||||
|
||||
Reference in New Issue
Block a user