mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 01:21:20 -04:00
inter-instance!
This commit is contained in:
+8
-2
@@ -35,6 +35,7 @@ import SettingsPanel from './components/SettingsPanel/index.jsx';
|
||||
import Tabs, { Tab, TabList, TabPanel, TabPanels } from './components/Tabs/index.jsx';
|
||||
import useDefaultNickname from './hooks/useDefaultNickname.js';
|
||||
import useUserIdentitySync from './hooks/useUserIdentitySync.js';
|
||||
import useIncomingInterInstanceTransfer from './hooks/useIncomingInterInstanceTransfer.js';
|
||||
import GlobalObjectiveBanner from './components/GlobalObjectiveBanner/index.jsx';
|
||||
import RoverQueuesPanel from './components/RoverQueuesPanel/index.jsx';
|
||||
import VipPanel from './components/VipPanel/index.jsx';
|
||||
@@ -250,7 +251,9 @@ function MobilePortraitLayout({ onOpenHelpOverlay, swapMobileControlColumns = fa
|
||||
</section>
|
||||
<div className={`grid ${themeGapClass} grid-cols-[minmax(0,1fr)_minmax(0,1.4fr)]`}>
|
||||
<ReplaySourcesPanel panelId="replay-sources-mobile-portrait" />
|
||||
<RoverQueuesPanel />
|
||||
<div className="space-y-0.5">
|
||||
<RoverQueuesPanel />
|
||||
</div>
|
||||
</div>
|
||||
{/* <ControlSummary /> */}
|
||||
<MobileFeatureTabs
|
||||
@@ -278,7 +281,9 @@ function MobileLandscapeLayout({ onOpenHelpOverlay, swapMobileControlColumns = f
|
||||
<DriverVideo layoutFormat="mobile-landscape" />
|
||||
<div className={`grid ${themeGapClass} grid-cols-[minmax(0,1fr)_minmax(0,1.4fr)]`}>
|
||||
<ReplaySourcesPanel panelId="replay-sources-mobile-landscape" />
|
||||
<RoverQueuesPanel />
|
||||
<div className="space-y-0.5">
|
||||
<RoverQueuesPanel />
|
||||
</div>
|
||||
</div>
|
||||
{/* <TelemetryPanel /> */}
|
||||
</div>
|
||||
@@ -309,6 +314,7 @@ function App() {
|
||||
|
||||
function AppWithProviders({ layout, isDesktop, fullscreen }) {
|
||||
useDefaultNickname();
|
||||
useIncomingInterInstanceTransfer();
|
||||
useUserIdentitySync({ identitySurface: 'driver' });
|
||||
useTelemetryVisualPolicy({ mobile: !isDesktop });
|
||||
const {
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
// Inter Instance Panel
|
||||
// Purpose: Renders remote rover servers discovered through the inter-instance directory.
|
||||
// Scope: Owns external server metadata presentation while reusing RoverQueuesPanel for rover/queue rows.
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useSessionSelector } from '../../context/SessionContext.jsx';
|
||||
import CardFrame from '../CardFrame/index.jsx';
|
||||
import RoverQueuesPanel from '../RoverQueuesPanel/index.jsx';
|
||||
import { openExternalRoverWithPrompt } from '../../lib/interInstanceTransfer.js';
|
||||
import { isFeatureEnabled } from '../../lib/features.js';
|
||||
|
||||
function classNames(...values) {
|
||||
return values.filter(Boolean).join(' ');
|
||||
}
|
||||
|
||||
function useRemoteInstances() {
|
||||
return useSessionSelector((state) => state.session?.interInstances?.instances ?? []);
|
||||
}
|
||||
|
||||
function useInterInstanceEnabled() {
|
||||
return useSessionSelector((state) => isFeatureEnabled(state, 'interInstance'));
|
||||
}
|
||||
|
||||
function featureEntries(features = {}) {
|
||||
return Object.entries(features || {})
|
||||
.filter(([, enabled]) => Boolean(enabled))
|
||||
.map(([name]) => name);
|
||||
}
|
||||
|
||||
function InstanceStatus({ remote }) {
|
||||
const mode = remote?.instance?.mode || 'unknown';
|
||||
const online = Boolean(remote?.online);
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-0.5 text-[0.7rem]">
|
||||
<span className={classNames('rounded px-1', online ? 'bg-emerald-700/60 text-emerald-100' : 'bg-red-800/60 text-red-100')}>
|
||||
{online ? 'Online' : 'Offline'}
|
||||
</span>
|
||||
<span className="rounded bg-slate-800 px-1 text-slate-200">{mode}</span>
|
||||
{remote?.latencyMs != null ? (
|
||||
<span className="rounded bg-slate-800 px-1 text-slate-300">{remote.latencyMs}ms</span>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function OfflineInstanceCard({ remote }) {
|
||||
const name = remote?.instance?.name || remote?.url || 'External server';
|
||||
return (
|
||||
<CardFrame title={name} meta="Offline" bodyClassName="space-y-0.5 p-0.5 text-sm">
|
||||
<p className="text-slate-400">{remote?.url || 'No URL available.'}</p>
|
||||
{remote?.lastError ? <p className="text-red-300">{remote.lastError}</p> : null}
|
||||
</CardFrame>
|
||||
);
|
||||
}
|
||||
|
||||
function InstanceMetadata({ remote }) {
|
||||
const instance = remote?.instance || {};
|
||||
const features = featureEntries(instance.features);
|
||||
const color = instance.color || '#64748b';
|
||||
return (
|
||||
<CardFrame
|
||||
title={instance.name || remote.url || 'External server'}
|
||||
meta={<InstanceStatus remote={remote} />}
|
||||
bodyClassName="space-y-0.5 p-0.5 text-sm"
|
||||
>
|
||||
<div className="flex items-start gap-0.5">
|
||||
<span
|
||||
className="mt-0.5 h-4 w-4 shrink-0 rounded border border-white/20"
|
||||
style={{ backgroundColor: color }}
|
||||
title={color}
|
||||
/>
|
||||
<div className="min-w-0 flex-1 space-y-0.5">
|
||||
{instance.description ? <p className="text-slate-200">{instance.description}</p> : null}
|
||||
<div className="flex flex-wrap items-center gap-0.5">
|
||||
{instance.publicUrl ? <span className="truncate text-slate-400">{instance.publicUrl}</span> : null}
|
||||
<button type="button" className="button-dark" onClick={() => openExternalRoverWithPrompt(remote, '')}>
|
||||
Open server
|
||||
</button>
|
||||
</div>
|
||||
{features.length ? (
|
||||
<div className="flex flex-wrap gap-0.5">
|
||||
{features.map((feature) => (
|
||||
<span key={feature} className="rounded bg-slate-800 px-1 text-[0.7rem] text-slate-200">
|
||||
{feature}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-[0.75rem] text-slate-500">No advertised feature flags.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardFrame>
|
||||
);
|
||||
}
|
||||
|
||||
function RemoteMediaStrip({ remote }) {
|
||||
const roverSnapshots = (remote.roster || [])
|
||||
.map((rover) => ({
|
||||
id: rover.id,
|
||||
name: rover.name || rover.id,
|
||||
url: rover.snapshots?.latestUrl,
|
||||
updatedAt: rover.snapshots?.updatedAt,
|
||||
}))
|
||||
.filter((entry) => entry.url);
|
||||
const roomCameras = Array.isArray(remote.roomCameras) ? remote.roomCameras.filter((camera) => camera.snapshotUrl) : [];
|
||||
const items = [
|
||||
...roverSnapshots.map((entry) => ({ ...entry, kind: 'Rover' })),
|
||||
...roomCameras.map((entry) => ({ ...entry, name: entry.name || entry.id, url: entry.snapshotUrl, kind: 'Room' })),
|
||||
];
|
||||
if (!items.length) return null;
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-0.5 md:grid-cols-3">
|
||||
{items.map((item) => (
|
||||
<div key={`${item.kind}-${item.id}`} className="surface-muted overflow-hidden text-xs">
|
||||
<img src={item.url} alt={item.name} className="aspect-video w-full bg-black object-cover" loading="lazy" />
|
||||
<div className="flex items-center justify-between gap-0.5 p-0.5">
|
||||
<span className="truncate text-slate-200">{item.name}</span>
|
||||
<span className="text-[0.65rem] text-slate-500">{item.kind}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ExternalInstancesCompact() {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [popupOpen, setPopupOpen] = useState(false);
|
||||
const enabled = useInterInstanceEnabled();
|
||||
const instances = useRemoteInstances();
|
||||
const visible = useMemo(() => instances.filter((remote) => remote?.online || remote?.url), [instances]);
|
||||
if (!enabled) return null;
|
||||
if (!visible.length) return null;
|
||||
return (
|
||||
<div className="space-y-0.5">
|
||||
<div className="grid grid-cols-2 gap-0.5">
|
||||
<button type="button" className="button-dark w-full" onClick={() => setExpanded((value) => !value)}>
|
||||
{expanded ? 'Hide external' : `Show external (${visible.length})`}
|
||||
</button>
|
||||
<button type="button" className="button-dark w-full" onClick={() => setPopupOpen(true)}>
|
||||
Browse servers
|
||||
</button>
|
||||
</div>
|
||||
{expanded ? (
|
||||
<div className="space-y-0.5">
|
||||
{visible.map((remote) =>
|
||||
remote.online ? (
|
||||
<RoverQueuesPanel
|
||||
key={remote.url}
|
||||
title={remote.instance?.name || remote.url}
|
||||
roster={remote.roster}
|
||||
turnQueues={remote.turnQueues}
|
||||
users={remote.users}
|
||||
externalInstance={remote}
|
||||
/>
|
||||
) : (
|
||||
<OfflineInstanceCard key={remote.url} remote={remote} />
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
{popupOpen ? <InterInstancePopup onClose={() => setPopupOpen(false)} /> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function InterInstancePopup({ onClose }) {
|
||||
const enabled = useInterInstanceEnabled();
|
||||
if (!enabled) return null;
|
||||
return (
|
||||
<div className="fixed inset-0 z-[70] flex items-center justify-center bg-black/80 p-0.5">
|
||||
<CardFrame
|
||||
title="External instances"
|
||||
actions={
|
||||
<button type="button" className="button-dark" onClick={onClose}>
|
||||
Close
|
||||
</button>
|
||||
}
|
||||
className="w-full max-w-6xl"
|
||||
bodyClassName="max-h-[82vh] overflow-y-auto p-0.5"
|
||||
clipOverflow={false}
|
||||
>
|
||||
<InterInstancePanel />
|
||||
</CardFrame>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function InterInstancePanel({ compact = false, centered = false }) {
|
||||
const enabled = useInterInstanceEnabled();
|
||||
const instances = useRemoteInstances();
|
||||
if (!enabled) return null;
|
||||
if (compact) return <ExternalInstancesCompact />;
|
||||
if (!instances.length) {
|
||||
return (
|
||||
<CardFrame title="External instances" bodyClassName="p-0.5 text-sm">
|
||||
<p className="text-slate-500">No external instances discovered.</p>
|
||||
</CardFrame>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className={classNames(
|
||||
'flex flex-wrap justify-center gap-0.5',
|
||||
centered && 'mx-auto w-full max-w-3xl',
|
||||
)}>
|
||||
{instances.map((remote) => (
|
||||
<div key={remote.url} className="w-full max-w-md flex-1 basis-80 space-y-0.5">
|
||||
<InstanceMetadata remote={remote} />
|
||||
{remote.online ? (
|
||||
<>
|
||||
<RemoteMediaStrip remote={remote} />
|
||||
<RoverQueuesPanel
|
||||
title={remote.instance?.name || remote.url}
|
||||
roster={remote.roster}
|
||||
turnQueues={remote.turnQueues}
|
||||
users={remote.users}
|
||||
externalInstance={remote}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<OfflineInstanceCard remote={remote} />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -7,6 +7,8 @@ import { useSessionSelector } from '../../context/SessionContext.jsx';
|
||||
import { useSharedClock } from '../../hooks/useSharedClock.js';
|
||||
import SocialButton from '../SocialButton/index.jsx';
|
||||
import ChatPanel from '../ChatPanel/index.jsx';
|
||||
import InterInstancePanel from '../InterInstancePanel/index.jsx';
|
||||
import { isFeatureEnabled } from '../../lib/features.js';
|
||||
|
||||
const PRIVILEGED_ROLES = new Set(['admin', 'lockdown']);
|
||||
const LOCKDOWN_ROLES = new Set(['lockdown']);
|
||||
@@ -32,6 +34,7 @@ export default function ModeGateOverlay() {
|
||||
const role = useSessionSelector((state) => state.session?.role || null);
|
||||
const reason = useSessionSelector((state) => state.session?.adminReason?.text || '');
|
||||
const timezone = useSessionSelector((state) => state.session?.timezone || 'UTC');
|
||||
const interInstanceEnabled = useSessionSelector((state) => isFeatureEnabled(state, 'interInstance'));
|
||||
const restricted = RESTRICTED_MODES.has(mode);
|
||||
const privileged = mode === 'lockdown' ? LOCKDOWN_ROLES.has(role) : PRIVILEGED_ROLES.has(role);
|
||||
/*
|
||||
@@ -62,33 +65,40 @@ export default function ModeGateOverlay() {
|
||||
const details = getModeDetails(mode);
|
||||
|
||||
return (
|
||||
<div className="pointer-events-auto fixed inset-0 z-50 flex items-center justify-center bg-black px-0.5 py-0.5">
|
||||
<div className="surface w-full max-w-md space-y-0.5 text-slate-100 shadow-2xl">
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-lg font-semibold">{details.title}</p>
|
||||
<p className="text-sm text-slate-300">{details.description}</p>
|
||||
<div className="pointer-events-auto fixed inset-0 z-50 overflow-y-auto bg-black px-0.5 py-0.5">
|
||||
<div className="mx-auto flex min-h-full w-full max-w-6xl flex-col items-center justify-center gap-0.5">
|
||||
<div className="surface w-full max-w-md space-y-0.5 text-slate-100 shadow-2xl">
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-lg font-semibold">{details.title}</p>
|
||||
<p className="text-sm text-slate-300">{details.description}</p>
|
||||
</div>
|
||||
<div className="surface-muted space-y-0.5">
|
||||
<p className="text-[0.7rem] tracking-wide text-slate-400">Reason for locking:</p>
|
||||
<p className="text-lg font-semibold text-slate-100">
|
||||
{reason ? reason : 'No reason set.'}
|
||||
</p>
|
||||
<p className="text-center text-sm text-slate-300">Server time: {serverTime}</p>
|
||||
</div>
|
||||
<div className="surface-muted">
|
||||
<AuthPanel />
|
||||
</div>
|
||||
<SocialButton id="discord" label="Join our Discord server for updates!" />
|
||||
You can still use the chat while the server is locked:
|
||||
{/* set max height of this box */}
|
||||
<div className='max-h-80 overflow-y-auto'>
|
||||
<ChatPanel nicknameLayout="stacked" />
|
||||
</div>
|
||||
|
||||
{/* <p className="text-xs text-slate-500">
|
||||
Your controls are paused until access is granted. You will automatically regain the interface once the mode
|
||||
changes or after a successful login.
|
||||
</p> */}
|
||||
</div>
|
||||
<div className="surface-muted space-y-0.5">
|
||||
<p className="text-[0.7rem] tracking-wide text-slate-400">Reason for locking:</p>
|
||||
<p className="text-lg font-semibold text-slate-100">
|
||||
{reason ? reason : 'No reason set.'}
|
||||
</p>
|
||||
<p className="text-center text-sm text-slate-300">Server time: {serverTime}</p>
|
||||
</div>
|
||||
<div className="surface-muted">
|
||||
<AuthPanel />
|
||||
</div>
|
||||
<SocialButton id="discord" label="Join our Discord server for updates!" />
|
||||
You can still use the chat while the server is locked:
|
||||
{/* set max height of this box */}
|
||||
<div className='max-h-80 overflow-y-auto'>
|
||||
<ChatPanel nicknameLayout="stacked" />
|
||||
</div>
|
||||
|
||||
{/* <p className="text-xs text-slate-500">
|
||||
Your controls are paused until access is granted. You will automatically regain the interface once the mode
|
||||
changes or after a successful login.
|
||||
</p> */}
|
||||
{interInstanceEnabled ? (
|
||||
<div className="w-full">
|
||||
<InterInstancePanel centered />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -195,7 +195,7 @@ function QueueReplayLinksRow() {
|
||||
*/
|
||||
return (
|
||||
<div className={`flex ${themeGapClass}`}>
|
||||
<div className="min-w-0 basis-0 grow-[1]">
|
||||
<div className={`min-w-0 basis-0 grow-[1] space-y-0.5`}>
|
||||
<RoverQueuesPanel />
|
||||
</div>
|
||||
<div className="min-w-0 basis-0 grow-[0.9]">
|
||||
|
||||
@@ -7,6 +7,9 @@ import { useSharedClock } from '../../hooks/useSharedClock.js';
|
||||
import CardFrame from '../CardFrame/index.jsx';
|
||||
import RoverLabel from '../RoverLabel/index.jsx';
|
||||
import { trackAnalyticsEvent } from '../../analytics/index.js';
|
||||
import { openExternalRoverWithPrompt } from '../../lib/interInstanceTransfer.js';
|
||||
import { ExternalInstancesCompact } from '../InterInstancePanel/index.jsx';
|
||||
import { isFeatureEnabled } from '../../lib/features.js';
|
||||
|
||||
function classNames(...values) {
|
||||
return values.filter(Boolean).join(' ');
|
||||
@@ -46,11 +49,18 @@ function formatLabel(user, selfId) {
|
||||
return base;
|
||||
}
|
||||
|
||||
export default function RoverQueuesPanel({ title = 'Rovers' }) {
|
||||
export default function RoverQueuesPanel({
|
||||
title = 'Rovers',
|
||||
roster: rosterOverride = null,
|
||||
turnQueues: turnQueuesOverride = null,
|
||||
users: usersOverride = null,
|
||||
externalInstance = null,
|
||||
}) {
|
||||
const role = useSessionSelector((state) => state.session?.role || null);
|
||||
const roster = useSessionSelector((state) => state.session?.roster ?? []);
|
||||
const turnQueues = useSessionSelector((state) => state.session?.turnQueues ?? {});
|
||||
const users = useSessionSelector((state) => state.session?.users ?? []);
|
||||
const localRoster = useSessionSelector((state) => state.session?.roster ?? []);
|
||||
const localTurnQueues = useSessionSelector((state) => state.session?.turnQueues ?? {});
|
||||
const localUsers = useSessionSelector((state) => state.session?.users ?? []);
|
||||
const interInstanceEnabled = useSessionSelector((state) => isFeatureEnabled(state, 'interInstance'));
|
||||
const selfId = useSessionSelector((state) => state.session?.socketId || null);
|
||||
const assignedRoverId = useSessionSelector((state) => String(state.session?.assignment?.roverId || '').trim());
|
||||
const assignedRoverName = useSessionSelector((state) => {
|
||||
@@ -62,8 +72,12 @@ export default function RoverQueuesPanel({ title = 'Rovers' }) {
|
||||
const { requestControl, rebootOwnRover } = useSessionActions();
|
||||
const [pending, setPending] = useState({});
|
||||
const [rebootPending, setRebootPending] = useState(false);
|
||||
const externalMode = Boolean(externalInstance);
|
||||
const roster = Array.isArray(rosterOverride) ? rosterOverride : localRoster;
|
||||
const turnQueues = turnQueuesOverride && typeof turnQueuesOverride === 'object' ? turnQueuesOverride : localTurnQueues;
|
||||
const users = Array.isArray(usersOverride) ? usersOverride : localUsers;
|
||||
|
||||
const canRequest = useMemo(() => role && role !== 'spectator', [role]);
|
||||
const canRequest = useMemo(() => externalMode || (role && role !== 'spectator'), [externalMode, role]);
|
||||
const adminCapable = useMemo(
|
||||
() => role === 'admin' || role === 'lockdown',
|
||||
[role],
|
||||
@@ -89,6 +103,15 @@ export default function RoverQueuesPanel({ title = 'Rovers' }) {
|
||||
|
||||
async function handleRequest(targetRoverId) {
|
||||
if (!targetRoverId) return;
|
||||
if (externalMode) {
|
||||
/*
|
||||
External queue cards deliberately reuse the local row layout, but their
|
||||
action cannot go through this Socket.IO server. The row opens the remote
|
||||
instance, optionally carrying settings after the source-page prompt.
|
||||
*/
|
||||
openExternalRoverWithPrompt(externalInstance, targetRoverId);
|
||||
return;
|
||||
}
|
||||
setPending((prev) => ({ ...prev, [targetRoverId]: true }));
|
||||
trackAnalyticsEvent('rover_queue_join', {
|
||||
roverId: targetRoverId,
|
||||
@@ -139,7 +162,7 @@ export default function RoverQueuesPanel({ title = 'Rovers' }) {
|
||||
}
|
||||
|
||||
const headerActions =
|
||||
role !== 'spectator' && assignedRoverId ? (
|
||||
!externalMode && role !== 'spectator' && assignedRoverId ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRebootOwnRover}
|
||||
@@ -153,11 +176,12 @@ export default function RoverQueuesPanel({ title = 'Rovers' }) {
|
||||
|
||||
return (
|
||||
<CardFrame title={title} actions={headerActions} bodyClassName="space-y-0.5 text-sm">
|
||||
{rosterItems.length === 0 ? (
|
||||
<p className="text-sm text-slate-500">No rovers registered.</p>
|
||||
) : (
|
||||
<ul className="space-y-0.5 text-sm">
|
||||
{rosterItems.map((rover) => {
|
||||
<div className="space-y-0.5">
|
||||
{rosterItems.length === 0 ? (
|
||||
<p className="text-sm text-slate-500">No rovers registered.</p>
|
||||
) : (
|
||||
<ul className="space-y-0.5 text-sm">
|
||||
{rosterItems.map((rover) => {
|
||||
const roverId = String(rover.id);
|
||||
const info = turnQueues?.[roverId] || null;
|
||||
const queue = info?.queue || [];
|
||||
@@ -178,10 +202,12 @@ export default function RoverQueuesPanel({ title = 'Rovers' }) {
|
||||
const isPrivateOpen = Boolean(rover?.private?.enabled && rover?.private?.open);
|
||||
const isGrantedClosedPrivate = Boolean(rover?.private?.enabled && !rover?.private?.open);
|
||||
const locked = Boolean(rover.locked);
|
||||
const lockedBlocked = locked && !adminCapable && !isGrantedClosedPrivate;
|
||||
const lockedBlocked = !externalMode && locked && !adminCapable && !isGrantedClosedPrivate;
|
||||
const lockLabel = rover.lockReason ? `locked: ${rover.lockReason}` : 'locked';
|
||||
const buttonLabel = pending[roverId]
|
||||
? '...'
|
||||
: externalMode
|
||||
? 'Open'
|
||||
: locked && !isGrantedClosedPrivate
|
||||
? lockLabel
|
||||
: 'request';
|
||||
@@ -271,9 +297,11 @@ export default function RoverQueuesPanel({ title = 'Rovers' }) {
|
||||
) : null}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
{!externalMode && interInstanceEnabled ? <ExternalInstancesCompact /> : null}
|
||||
</div>
|
||||
</CardFrame>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
// Hook: useIncomingInterInstanceTransfer
|
||||
// Purpose: Applies settings transferred through an inter-instance URL before the normal identity heartbeat runs.
|
||||
// Scope: Owns only inbound URL parameters; requesting the target rover is handled after socket/session state is ready.
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useSettings } from '../settings/index.js';
|
||||
import { base64UrlDecodeJson } from '../lib/interInstanceTransfer.js';
|
||||
import { useSessionActions, useSessionSelector } from '../context/SessionContext.jsx';
|
||||
|
||||
export default function useIncomingInterInstanceTransfer() {
|
||||
const settings = useSettings();
|
||||
const { requestControl } = useSessionActions();
|
||||
const connected = useSessionSelector((state) => state.connected);
|
||||
const appliedRef = useRef(false);
|
||||
const requestedRef = useRef(false);
|
||||
const roverIdRef = useRef('');
|
||||
|
||||
useEffect(() => {
|
||||
if (appliedRef.current || typeof window === 'undefined') return;
|
||||
const url = new URL(window.location.href);
|
||||
const transfer = url.searchParams.get('settingsTransfer');
|
||||
roverIdRef.current = String(url.searchParams.get('rover') || '').trim();
|
||||
if (!transfer) {
|
||||
appliedRef.current = true;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
/*
|
||||
The source page already asked before adding settingsTransfer. A present
|
||||
transfer param is therefore an explicit instruction to replace the local
|
||||
settings cookie without asking again on the destination server.
|
||||
*/
|
||||
const nextSettings = base64UrlDecodeJson(transfer);
|
||||
settings.saveAll(nextSettings && typeof nextSettings === 'object' ? nextSettings : {});
|
||||
url.searchParams.delete('settingsTransfer');
|
||||
window.history.replaceState({}, '', `${url.pathname}${url.search}${url.hash}`);
|
||||
} catch (error) {
|
||||
// A bad transfer payload should not block the page or the rover request.
|
||||
console.warn('Failed to apply transferred inter-instance settings', error);
|
||||
} finally {
|
||||
appliedRef.current = true;
|
||||
}
|
||||
}, [settings]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!appliedRef.current || requestedRef.current || !connected) return;
|
||||
const roverId = roverIdRef.current;
|
||||
if (!roverId) return;
|
||||
requestedRef.current = true;
|
||||
/*
|
||||
The identity heartbeat reacts to the settings overwrite through the shared
|
||||
settings context. Waiting for a connected socket here keeps this hook from
|
||||
racing the initial Socket.IO connection while still using the existing
|
||||
request-control path.
|
||||
*/
|
||||
requestControl(roverId).catch((error) => {
|
||||
requestedRef.current = false;
|
||||
console.warn('Failed to request transferred inter-instance rover', error);
|
||||
});
|
||||
}, [connected, requestControl]);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
// Inter-Instance Transfer Helpers
|
||||
// Purpose: Builds cross-server links and moves the local settings cookie only when the user opts in before leaving.
|
||||
// Scope: Keeps URL encoding and settings-transfer behavior out of the rover queue rendering code.
|
||||
import { loadSettings } from '../settings/persistence.js';
|
||||
|
||||
function base64UrlEncodeJson(value) {
|
||||
const json = JSON.stringify(value ?? {});
|
||||
const bytes = new TextEncoder().encode(json);
|
||||
let binary = '';
|
||||
bytes.forEach((byte) => {
|
||||
binary += String.fromCharCode(byte);
|
||||
});
|
||||
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
|
||||
}
|
||||
|
||||
export function base64UrlDecodeJson(value) {
|
||||
const raw = String(value || '').replace(/-/g, '+').replace(/_/g, '/');
|
||||
const padded = raw.padEnd(Math.ceil(raw.length / 4) * 4, '=');
|
||||
const binary = atob(padded);
|
||||
const bytes = Uint8Array.from(binary, (char) => char.charCodeAt(0));
|
||||
return JSON.parse(new TextDecoder().decode(bytes));
|
||||
}
|
||||
|
||||
export function buildExternalRoverUrl(instance, roverId, { includeSettings = false } = {}) {
|
||||
const publicUrl = String(instance?.instance?.publicUrl || instance?.publicUrl || instance?.url || '').trim();
|
||||
if (!publicUrl) return '';
|
||||
const url = new URL(publicUrl);
|
||||
if (roverId) url.searchParams.set('rover', String(roverId));
|
||||
/*
|
||||
The destination always applies settingsTransfer if present, so this helper
|
||||
only adds it after the current page has already asked for consent.
|
||||
*/
|
||||
if (includeSettings) {
|
||||
url.searchParams.set('settingsTransfer', base64UrlEncodeJson(loadSettings()));
|
||||
}
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
export function openExternalRoverWithPrompt(instance, roverId) {
|
||||
const withoutTransfer = buildExternalRoverUrl(instance, roverId);
|
||||
if (!withoutTransfer) return;
|
||||
const instanceName = String(instance?.instance?.name || instance?.url || 'that server');
|
||||
const includeSettings = window.confirm(
|
||||
`Transfer your identity and settings to ${instanceName}? Press Cancel to open without transferring them.`,
|
||||
);
|
||||
const targetUrl = buildExternalRoverUrl(instance, roverId, { includeSettings });
|
||||
window.location.href = targetUrl || withoutTransfer;
|
||||
}
|
||||
Reference in New Issue
Block a user