mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-11-14 10:42:45 -05:00
This commit is contained in:
@@ -187,6 +187,7 @@ function buildSession(socket) {
|
||||
socketId: socket?.id || null,
|
||||
role: getRole(socket),
|
||||
operatingMode: getOperatingMode(socket),
|
||||
canLeaveRover: roverManager.canLeaveCurrentRover(socket).ok,
|
||||
mode: getMode(),
|
||||
isLocalNetwork: isLocalNetwork(getSocketIp(socket)),
|
||||
bandwidthSavings: buildBandwidthSavingsSessionState(socket, controllableUserCount),
|
||||
@@ -263,10 +264,14 @@ function buildSession(socket) {
|
||||
};
|
||||
}
|
||||
|
||||
// Cache only the published decision so sensor frames send updates on transitions.
|
||||
const leavePermissionBySocket = new WeakMap();
|
||||
|
||||
function syncSocket(socket) {
|
||||
if (!socket) return;
|
||||
const payload = buildSession(socket);
|
||||
logger.info('Syncing session', socket.id, payload.role, payload.assignment);
|
||||
leavePermissionBySocket.set(socket, payload.canLeaveRover);
|
||||
socket.emit('session:sync', payload);
|
||||
}
|
||||
|
||||
@@ -287,6 +292,15 @@ io.on('connection', (socket) => {
|
||||
syncSocket(socket);
|
||||
});
|
||||
|
||||
managerEvents.on('sensor', ({ roverId }) => {
|
||||
io.sockets.sockets.forEach((socket) => {
|
||||
const decision = roverManager.canLeaveCurrentRover(socket);
|
||||
if (decision.currentId !== roverId) return;
|
||||
if (leavePermissionBySocket.get(socket) === decision.ok) return;
|
||||
syncSocket(socket);
|
||||
});
|
||||
});
|
||||
|
||||
operatingModeEvents.on('change', () => syncAll());
|
||||
|
||||
roleEvents.on('change', ({ socket }) => {
|
||||
@@ -365,13 +379,13 @@ privateRoverAccessRequestEvents.on('change', (event = {}) => {
|
||||
syncAll();
|
||||
});
|
||||
|
||||
managerEvents.on('driver', ({ socketId }) => {
|
||||
if (!socketId) return;
|
||||
const socket = io.sockets.sockets.get(socketId);
|
||||
if (socket) {
|
||||
logger.info('Driver assignment change; syncing session', socketId);
|
||||
syncSocket(socket);
|
||||
}
|
||||
// Update the changed driver and anyone whose departure permission depends on them.
|
||||
managerEvents.on('driver', ({ socketId, roverId }) => {
|
||||
io.sockets.sockets.forEach((socket) => {
|
||||
if (socket.id === socketId || roverManager.isDriver(roverId, socket)) {
|
||||
syncSocket(socket);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
turnEvents.on('activeDriver', () => {
|
||||
|
||||
@@ -59,20 +59,6 @@ function BarcodeScanToast({ payload }) {
|
||||
);
|
||||
}
|
||||
|
||||
function UndockedExitWarningToast() {
|
||||
return (
|
||||
<div
|
||||
className="w-[min(32rem,80vw)] rounded-md bg-amber-950 px-4 py-3 text-left text-amber-50"
|
||||
role="alert"
|
||||
>
|
||||
<p className="text-xl font-bold leading-tight text-amber-100">Please dock your rover</p>
|
||||
<p className="mt-1 text-base font-medium leading-snug text-amber-50/90">
|
||||
Your rover is still undocked. Please dock it before leaving the page.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AlertContent({ alert }) {
|
||||
if (alert.kind === 'buttonbox-active' && alert.payload) {
|
||||
const payload = alert.payload;
|
||||
@@ -112,9 +98,6 @@ export default function AlertContent({ alert }) {
|
||||
if (alert.kind === 'barcode-scan' && alert.payload) {
|
||||
return <BarcodeScanToast payload={alert.payload} />;
|
||||
}
|
||||
if (alert.kind === 'undocked-exit-warning') {
|
||||
return <UndockedExitWarningToast />;
|
||||
}
|
||||
const color = typeof alert.color === 'string' ? alert.color.trim() : '';
|
||||
const backgroundColor = /^#?[0-9a-f]{6}$/i.test(color) ? `#${color.replace('#', '')}` : '#2196f3';
|
||||
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { createPortal, flushSync } from 'react-dom';
|
||||
import { useBlocker } from 'react-router-dom';
|
||||
import { useSessionSelector } from '../../context/SessionContext.jsx';
|
||||
|
||||
export default function PageExitGuard() {
|
||||
const shouldBlock = useSessionSelector((state) => state.session?.canLeaveRover === false);
|
||||
const [warning, setWarning] = useState({ blocked: shouldBlock, visible: false });
|
||||
// Clear an old exit attempt when server permission changes, so undocking
|
||||
// again does not resurrect a warning from the previous trip.
|
||||
if (warning.blocked !== shouldBlock) {
|
||||
setWarning({ blocked: shouldBlock, visible: false });
|
||||
}
|
||||
const blocker = useBlocker(({ currentLocation, nextLocation }) => shouldBlock && (
|
||||
currentLocation.pathname !== nextLocation.pathname || currentLocation.search !== nextLocation.search
|
||||
));
|
||||
|
||||
useEffect(() => {
|
||||
if (!shouldBlock) return undefined;
|
||||
function handleBeforeUnload(event) {
|
||||
// Commit before the native dialog pauses JavaScript. The browser still
|
||||
// decides whether it repaints the document before displaying its dialog.
|
||||
flushSync(() => setWarning({ blocked: true, visible: true }));
|
||||
event.preventDefault();
|
||||
event.returnValue = true;
|
||||
}
|
||||
window.addEventListener('beforeunload', handleBeforeUnload);
|
||||
return () => window.removeEventListener('beforeunload', handleBeforeUnload);
|
||||
}, [shouldBlock]);
|
||||
|
||||
useEffect(() => {
|
||||
// Docking cancels the attempted navigation; the next attempt can proceed.
|
||||
if (!shouldBlock && blocker.state === 'blocked') blocker.reset();
|
||||
}, [shouldBlock, blocker]);
|
||||
|
||||
const visible = shouldBlock && (warning.visible || blocker.state === 'blocked');
|
||||
if (!visible) return null;
|
||||
|
||||
function dismiss() {
|
||||
setWarning({ blocked: shouldBlock, visible: false });
|
||||
if (blocker.state === 'blocked') blocker.reset();
|
||||
}
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="page-exit-warning"
|
||||
className="fixed inset-0 z-[2147483647] flex flex-col items-center justify-center gap-10 bg-neutral-950 p-6 text-center text-white"
|
||||
onKeyDown={(event) => {
|
||||
event.stopPropagation();
|
||||
if (event.key === 'Escape') dismiss();
|
||||
}}
|
||||
>
|
||||
<p id="page-exit-warning" className="text-[clamp(3rem,10vw,10rem)] font-bold leading-tight">
|
||||
Please dock your rover
|
||||
</p>
|
||||
<button type="button" autoFocus className="button-dark px-8 py-4 text-3xl" onClick={dismiss}>
|
||||
Okay
|
||||
</button>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
// Undocked Page Exit Guard
|
||||
// Purpose: Asks the browser to confirm document-level navigation while the current driver's rover is undocked.
|
||||
// Scope: Owns the native beforeunload lifecycle and queues the matching AlertFeed explanation when leaving is attempted.
|
||||
import { useEffect } from 'react';
|
||||
import { useControlSelector } from '../../controls/index.js';
|
||||
import { useSessionActions } from '../../context/SessionContext.jsx';
|
||||
import { useTelemetrySelector } from '../../context/TelemetryContext.jsx';
|
||||
|
||||
const UNDOCKED_EXIT_ALERT_LIFETIME_MS = 15 * 1000;
|
||||
|
||||
function selectHomeBaseState(frame) {
|
||||
const homeBase = frame?.sensors?.chargingSources?.homeBase;
|
||||
|
||||
/*
|
||||
Missing telemetry must remain distinct from a real "not on the home base"
|
||||
reading. Treating the initial empty frame as undocked would enable the exit
|
||||
warning before the browser has received enough information to justify it.
|
||||
*/
|
||||
return typeof homeBase === 'boolean' ? homeBase : null;
|
||||
}
|
||||
|
||||
export default function UndockedPageExitGuard() {
|
||||
const roverId = useControlSelector((control) => control.state.roverId);
|
||||
const homeBaseState = useTelemetrySelector(roverId, selectHomeBaseState);
|
||||
const { pushAlert } = useSessionActions();
|
||||
const shouldConfirmExit = Boolean(roverId) && homeBaseState === false;
|
||||
|
||||
useEffect(() => {
|
||||
if (!shouldConfirmExit) return undefined;
|
||||
|
||||
function handleBeforeUnload(event) {
|
||||
/*
|
||||
Browsers intentionally own the wording and presentation of this dialog.
|
||||
preventDefault is the modern signal, while assigning returnValue keeps
|
||||
the confirmation working in browsers that still require the legacy part
|
||||
of the beforeunload contract.
|
||||
*/
|
||||
event.preventDefault();
|
||||
event.returnValue = true;
|
||||
|
||||
/*
|
||||
If the driver cancels the browser-owned confirmation, React remains
|
||||
mounted and AlertFeed presents the actionable reason for the warning.
|
||||
A stable id refreshes this one warning lane on another exit attempt
|
||||
instead of stacking duplicate cards over the driving interface.
|
||||
*/
|
||||
pushAlert({
|
||||
id: 'undocked-page-exit-warning',
|
||||
kind: 'undocked-exit-warning',
|
||||
lifetimeMs: UNDOCKED_EXIT_ALERT_LIFETIME_MS,
|
||||
});
|
||||
}
|
||||
|
||||
window.addEventListener('beforeunload', handleBeforeUnload);
|
||||
return () => {
|
||||
/*
|
||||
Removing the listener as soon as the rover docks or the assignment ends
|
||||
prevents ordinary spectators and completed drivers from seeing a stale
|
||||
leave-page confirmation.
|
||||
*/
|
||||
window.removeEventListener('beforeunload', handleBeforeUnload);
|
||||
};
|
||||
}, [pushAlert, shouldConfirmExit]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -28,7 +28,6 @@ import useLayoutMode from '../hooks/useLayoutMode.js';
|
||||
import { DriverHelpProvider } from '../layouts/driver/DriverHelpContext.jsx';
|
||||
import { LayoutProvider } from '../layouts/LayoutContext.jsx';
|
||||
import DriverLayoutRoot from '../layouts/driver/DriverLayoutRoot.jsx';
|
||||
import UndockedPageExitGuard from '../components/UndockedPageExitGuard/index.jsx';
|
||||
|
||||
/* Driver-page compositions live under layouts/driver. App retains only global providers, overlays, and route-level state. */
|
||||
function DriverPageRoot({ oldDesktop = false }) {
|
||||
@@ -92,7 +91,6 @@ function DriverPageContent({ layout, oldDesktop }) {
|
||||
return (
|
||||
<ControlSystemProvider>
|
||||
<RoomAudioPlayer />
|
||||
<UndockedPageExitGuard />
|
||||
<KeyboardInputManager />
|
||||
<GamepadInputManager />
|
||||
<main className={`relative flex w-full flex-col ${themeGapClass} text-base`}>
|
||||
|
||||
+39
-29
@@ -2,7 +2,7 @@
|
||||
// Purpose: Boots the React application and mounts global providers/router roots. Scope: Defines top-level route wiring and root render lifecycle for the browser app.
|
||||
import { lazy, StrictMode, Suspense } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'
|
||||
import { createBrowserRouter, Route, RouterProvider, Routes } from 'react-router-dom'
|
||||
import './index.css'
|
||||
// Theme artwork is a separate style concern from global component utilities. Loading its dedicated
|
||||
// entrypoint here keeps every route consistent without returning theme definitions to index.css.
|
||||
@@ -22,6 +22,7 @@ import AnalyticsReporter from './analytics/AnalyticsReporter.jsx'
|
||||
import PtzAppRoot from './ptz/PtzAppRoot.jsx'
|
||||
import MicApp from './mic/MicApp.jsx'
|
||||
import OperatingModeRouteSync from './components/OperatingModeRouteSync/index.jsx'
|
||||
import PageExitGuard from './components/PageExitGuard/index.jsx'
|
||||
import InitialSessionOverlay from './components/InitialSessionOverlay/index.jsx'
|
||||
import AppErrorBoundary from './components/AppErrorBoundary.jsx'
|
||||
|
||||
@@ -41,6 +42,42 @@ function lazyRoute(element, label) {
|
||||
)
|
||||
}
|
||||
|
||||
// A data router provides one blocker for links, programmatic navigation, and history.
|
||||
const router = createBrowserRouter([{
|
||||
path: '*',
|
||||
element: (
|
||||
<>
|
||||
<AnalyticsReporter />
|
||||
<OperatingModeRouteSync />
|
||||
<PageExitGuard />
|
||||
<Routes>
|
||||
<Route path="/" element={<DriverPageRoot />} />
|
||||
{/* The retired desktop composition remains available for direct comparison
|
||||
without duplicating the driver shell or changing canonical mobile layouts. */}
|
||||
<Route path="/old" element={<DriverPageRoot oldDesktop />} />
|
||||
<Route path="/spectate" element={<SpectatorApp />} />
|
||||
<Route path="/mini" element={<MiniSummaryApp />} />
|
||||
<Route path="/display" element={<ServerDisplayApp />} />
|
||||
<Route path="/mic" element={<MicApp />} />
|
||||
<Route path="/scanner" element={<ScannerApp />} />
|
||||
<Route path="/setup" element={lazyRoute(<SetupApp />, 'setup')} />
|
||||
<Route path="/admin" element={lazyRoute(<AdminApp />, 'administration')} />
|
||||
{/*
|
||||
PTZ is a separate route so the driver layout and its replay
|
||||
panel are not mounted behind the camera controller. This
|
||||
also makes orientation changes a PTZ layout concern instead
|
||||
of a local overlay-open state owned by the driver page.
|
||||
*/}
|
||||
<Route path="/ptz" element={<PtzAppRoot />} />
|
||||
<Route
|
||||
path="/reports"
|
||||
element={lazyRoute(<FleetReportsApp />, 'fleet reports')}
|
||||
/>
|
||||
</Routes>
|
||||
</>
|
||||
),
|
||||
}])
|
||||
|
||||
createRoot(document.getElementById('root')).render(
|
||||
<AppErrorBoundary>
|
||||
<StrictMode>
|
||||
@@ -55,34 +92,7 @@ createRoot(document.getElementById('root')).render(
|
||||
<SettingsProvider>
|
||||
<ChatProvider>
|
||||
<DeterrenceChaos />
|
||||
<BrowserRouter>
|
||||
<AnalyticsReporter />
|
||||
<OperatingModeRouteSync />
|
||||
<Routes>
|
||||
<Route path="/" element={<DriverPageRoot />} />
|
||||
{/* The retired desktop composition remains available for direct comparison
|
||||
without duplicating the driver shell or changing canonical mobile layouts. */}
|
||||
<Route path="/old" element={<DriverPageRoot oldDesktop />} />
|
||||
<Route path="/spectate" element={<SpectatorApp />} />
|
||||
<Route path="/mini" element={<MiniSummaryApp />} />
|
||||
<Route path="/display" element={<ServerDisplayApp />} />
|
||||
<Route path="/mic" element={<MicApp />} />
|
||||
<Route path="/scanner" element={<ScannerApp />} />
|
||||
<Route path="/setup" element={lazyRoute(<SetupApp />, 'setup')} />
|
||||
<Route path="/admin" element={lazyRoute(<AdminApp />, 'administration')} />
|
||||
{/*
|
||||
PTZ is a separate route so the driver layout and its replay
|
||||
panel are not mounted behind the camera controller. This
|
||||
also makes orientation changes a PTZ layout concern instead
|
||||
of a local overlay-open state owned by the driver page.
|
||||
*/}
|
||||
<Route path="/ptz" element={<PtzAppRoot />} />
|
||||
<Route
|
||||
path="/reports"
|
||||
element={lazyRoute(<FleetReportsApp />, 'fleet reports')}
|
||||
/>
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
<RouterProvider router={router} />
|
||||
</ChatProvider>
|
||||
</SettingsProvider>
|
||||
</TelemetryProvider>
|
||||
|
||||
Reference in New Issue
Block a user