mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-15 17:12:59 -04:00
inter-instance!
This commit is contained in:
@@ -10,6 +10,18 @@ admins:
|
||||
|
||||
timezone: "America/New_York"
|
||||
|
||||
interInstance:
|
||||
enabled: false
|
||||
directoryUrls:
|
||||
- "https://raw.githubusercontent.com/legop3/multi-roomba-rover-instance-directory/refs/heads/main/directory.json"
|
||||
pollIntervalMs: 30000
|
||||
requestTimeoutMs: 5000
|
||||
profile:
|
||||
publicUrl: "https://rover.example.com"
|
||||
name: "Example Rover Server"
|
||||
description: "A short public description of this rover server."
|
||||
color: "#38bdf8"
|
||||
|
||||
llmCommentary:
|
||||
enabled: false
|
||||
model: "qwen2.5:7b-instruct"
|
||||
|
||||
@@ -30,6 +30,7 @@ require('./src/services/videoAuthService');
|
||||
require('./src/services/videoSocketService');
|
||||
require('./src/services/roomCameraService');
|
||||
require('./src/services/roverSnapshotService');
|
||||
require('./src/services/interInstanceService');
|
||||
require('./src/services/humanAlertButtonService');
|
||||
require('./src/services/embedHttpService');
|
||||
require('./src/services/logStreamService');
|
||||
|
||||
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
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-Buubck0y.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-DaeBPi7X.css">
|
||||
<script type="module" crossorigin src="/assets/index-qmfprxrq.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-CjRD7Org.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -48,6 +48,7 @@ function buildFeatureFlags(config = loadConfig()) {
|
||||
const barcodeScannerConfig = config.barcodeScanner || {};
|
||||
const barcodeGamesConfig = config.barcodeGames || {};
|
||||
const socialsConfig = config.socials || {};
|
||||
const interInstanceConfig = config.interInstance || {};
|
||||
const homeAssistant = Boolean(
|
||||
asBoolean(homeAssistantConfig.enabled) &&
|
||||
asTrimmedString(homeAssistantConfig.url) &&
|
||||
@@ -78,6 +79,7 @@ function buildFeatureFlags(config = loadConfig()) {
|
||||
asTrimmedString(homeAssistantConfig.neato?.device),
|
||||
),
|
||||
socials: Boolean(asBoolean(socialsConfig.enabled) && getConfiguredSocials(config).length > 0),
|
||||
interInstance: asBoolean(interInstanceConfig.enabled),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,408 @@
|
||||
// Inter Instance Service
|
||||
// Purpose: Publishes this server's public instance profile and polls public profiles from peer servers.
|
||||
// Scope: Owns only the inter-instance directory/API contract; local control, auth, and rover state stay in their existing services.
|
||||
const EventEmitter = require('events');
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
const { app } = require('../../globals/http');
|
||||
const io = require('../../globals/io');
|
||||
const logger = require('../../globals/logger').child('interInstanceService');
|
||||
const { loadConfig } = require('../../helpers/configLoader');
|
||||
const { getFeatureFlags, getConfiguredSocials } = require('../../helpers/features');
|
||||
const { getMode, MODES } = require('../modeManager');
|
||||
const roverManager = require('../roverManager');
|
||||
const { getTurnQueues } = require('../turnService');
|
||||
const { getRoomCameras, getRoomCameraState } = require('../roomCameraService');
|
||||
const { getRoverSnapshotState } = require('../roverSnapshotService');
|
||||
const { getRole } = require('../roleService');
|
||||
const { getNickname } = require('../nicknameService');
|
||||
|
||||
const DEFAULT_POLL_INTERVAL_MS = 30000;
|
||||
const DEFAULT_REQUEST_TIMEOUT_MS = 5000;
|
||||
const INFO_PATH = '/api/inter-instance/info';
|
||||
const INSTANCE_ID = uuidv4();
|
||||
const config = loadConfig();
|
||||
const interInstanceConfig = config.interInstance || {};
|
||||
const profileConfig = interInstanceConfig.profile || {};
|
||||
const interInstanceEvents = new EventEmitter();
|
||||
const remoteInstances = new Map();
|
||||
|
||||
let polling = false;
|
||||
function asTrimmedString(value) {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
function normalizeBaseUrl(value) {
|
||||
const raw = asTrimmedString(value);
|
||||
if (!raw) return '';
|
||||
try {
|
||||
const parsed = new URL(raw);
|
||||
parsed.hash = '';
|
||||
parsed.search = '';
|
||||
return parsed.toString().replace(/\/$/, '');
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function isEnabled() {
|
||||
return Boolean(interInstanceConfig.enabled);
|
||||
}
|
||||
|
||||
function requestTimeoutMs() {
|
||||
const value = Number(interInstanceConfig.requestTimeoutMs);
|
||||
return Number.isFinite(value) && value > 0 ? value : DEFAULT_REQUEST_TIMEOUT_MS;
|
||||
}
|
||||
|
||||
function pollIntervalMs() {
|
||||
const value = Number(interInstanceConfig.pollIntervalMs);
|
||||
return Number.isFinite(value) && value > 0 ? value : DEFAULT_POLL_INTERVAL_MS;
|
||||
}
|
||||
|
||||
function ownPublicUrl() {
|
||||
return normalizeBaseUrl(profileConfig.publicUrl);
|
||||
}
|
||||
|
||||
function ownInstanceId() {
|
||||
/*
|
||||
This id exists only for this Node process. That is enough to detect self
|
||||
aliases during a poll cycle because every public URL that reaches this same
|
||||
running server returns the same generated value.
|
||||
*/
|
||||
return INSTANCE_ID;
|
||||
}
|
||||
|
||||
function buildPublicUrl(pathname) {
|
||||
const base = ownPublicUrl();
|
||||
if (!base || !pathname) return null;
|
||||
return `${base}${pathname.startsWith('/') ? pathname : `/${pathname}`}`;
|
||||
}
|
||||
|
||||
function publicProfile() {
|
||||
const publicUrl = ownPublicUrl();
|
||||
return {
|
||||
id: ownInstanceId(),
|
||||
name: asTrimmedString(profileConfig.name) || publicUrl || 'Rover server',
|
||||
description: asTrimmedString(profileConfig.description),
|
||||
color: asTrimmedString(profileConfig.color),
|
||||
publicUrl,
|
||||
};
|
||||
}
|
||||
|
||||
function isLockdownMode() {
|
||||
return getMode() === MODES.LOCKDOWN;
|
||||
}
|
||||
|
||||
function buildUserEntry(socket) {
|
||||
const primaryRover = roverManager.getPrimaryRoverForSocket(socket.id);
|
||||
return {
|
||||
socketId: socket.id,
|
||||
userId: socket?.data?.userId || null,
|
||||
nickname: getNickname(socket) || null,
|
||||
role: getRole(socket),
|
||||
roverId: primaryRover || null,
|
||||
};
|
||||
}
|
||||
|
||||
function addRoverSnapshotLinks(rover) {
|
||||
const id = String(rover?.id || '').trim();
|
||||
if (!id) return rover;
|
||||
const state = getRoverSnapshotState(id);
|
||||
const latestUrl = buildPublicUrl(`/api/inter-instance/rover-snapshots/${encodeURIComponent(id)}/latest`);
|
||||
if (!latestUrl) return rover;
|
||||
return {
|
||||
...rover,
|
||||
snapshots: {
|
||||
latestUrl,
|
||||
updatedAt: state?.ts || null,
|
||||
error: state?.error || null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function buildRoomCameraInfo(camera) {
|
||||
const state = getRoomCameraState(camera.id);
|
||||
const snapshotUrl = buildPublicUrl(`/api/inter-instance/room-cameras/${encodeURIComponent(camera.id)}/snapshot`);
|
||||
/*
|
||||
Room camera config can point at private LAN URLs. The inter-instance payload
|
||||
advertises this server's public snapshot endpoint instead, so remote clients
|
||||
do not learn or depend on the local camera's internal address.
|
||||
*/
|
||||
return {
|
||||
id: camera.id,
|
||||
name: camera.name,
|
||||
description: camera.description || null,
|
||||
snapshotUrl,
|
||||
updatedAt: state?.ts || null,
|
||||
error: state?.error || null,
|
||||
};
|
||||
}
|
||||
|
||||
function buildLocalInfo() {
|
||||
const mode = getMode();
|
||||
const lockdown = isLockdownMode();
|
||||
const features = getFeatureFlags();
|
||||
const roster = roverManager.getRoster().map((rover) => (lockdown ? rover : addRoverSnapshotLinks(rover)));
|
||||
const roomCameras = lockdown || !features.roomCameras ? [] : getRoomCameras().map(buildRoomCameraInfo);
|
||||
return {
|
||||
instance: {
|
||||
...publicProfile(),
|
||||
mode,
|
||||
open: mode !== MODES.ADMIN && mode !== MODES.LOCKDOWN,
|
||||
features,
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
roster,
|
||||
turnQueues: getTurnQueues(),
|
||||
users: Array.from(io.sockets.sockets.values()).map(buildUserEntry),
|
||||
roomCameras,
|
||||
socials: features.socials ? getConfiguredSocials(config) : [],
|
||||
};
|
||||
}
|
||||
|
||||
function sendJpegState(res, state, missingMessage) {
|
||||
if (!state?.frame) {
|
||||
res.status(404).json({ error: missingMessage });
|
||||
return;
|
||||
}
|
||||
res.set('Cache-Control', 'no-store');
|
||||
res.set('X-Rover-Snapshot-Ts', String(state.ts || ''));
|
||||
res.type('jpeg').send(state.frame);
|
||||
}
|
||||
|
||||
app.get(INFO_PATH, (req, res) => {
|
||||
if (!isEnabled()) {
|
||||
res.status(404).json({ error: 'Inter-instance sharing disabled' });
|
||||
return;
|
||||
}
|
||||
res.set('Cache-Control', 'no-store');
|
||||
res.json(buildLocalInfo());
|
||||
});
|
||||
|
||||
app.get('/api/inter-instance/rover-snapshots/:roverId/latest', (req, res) => {
|
||||
if (!isEnabled() || isLockdownMode()) {
|
||||
res.status(404).json({ error: 'Snapshot unavailable' });
|
||||
return;
|
||||
}
|
||||
const roverId = String(req.params.roverId || '');
|
||||
sendJpegState(res, getRoverSnapshotState(roverId), 'Rover snapshot unavailable');
|
||||
});
|
||||
|
||||
app.get('/api/inter-instance/room-cameras/:cameraId/snapshot', (req, res) => {
|
||||
if (!isEnabled() || isLockdownMode()) {
|
||||
res.status(404).json({ error: 'Snapshot unavailable' });
|
||||
return;
|
||||
}
|
||||
const cameraId = String(req.params.cameraId || '');
|
||||
sendJpegState(res, getRoomCameraState(cameraId), 'Room camera snapshot unavailable');
|
||||
});
|
||||
|
||||
function normalizeDirectoryEntry(entry) {
|
||||
if (typeof entry === 'string') {
|
||||
const url = normalizeBaseUrl(entry);
|
||||
return url ? { url, name: '' } : null;
|
||||
}
|
||||
if (!entry || typeof entry !== 'object') return null;
|
||||
const url = normalizeBaseUrl(entry.url || entry.baseUrl || entry.publicUrl);
|
||||
if (!url) return null;
|
||||
return {
|
||||
url,
|
||||
name: asTrimmedString(entry.name),
|
||||
};
|
||||
}
|
||||
|
||||
function uniqueDirectoryEntries(entries) {
|
||||
const seen = new Set();
|
||||
return entries.filter((entry) => {
|
||||
if (!entry?.url || seen.has(entry.url)) return false;
|
||||
seen.add(entry.url);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchJson(url) {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), requestTimeoutMs());
|
||||
try {
|
||||
const res = await fetch(url, { signal: controller.signal, headers: { Accept: 'application/json' } });
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
return await res.json();
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchDirectoryEntries() {
|
||||
const urls = Array.isArray(interInstanceConfig.directoryUrls)
|
||||
? interInstanceConfig.directoryUrls.map((url) => asTrimmedString(url)).filter(Boolean)
|
||||
: [];
|
||||
const lists = await Promise.allSettled(urls.map((url) => fetchJson(url)));
|
||||
const entries = [];
|
||||
lists.forEach((result, idx) => {
|
||||
if (result.status !== 'fulfilled') {
|
||||
logger.warn('Directory fetch failed', { url: urls[idx], error: result.reason?.message || String(result.reason) });
|
||||
return;
|
||||
}
|
||||
if (!Array.isArray(result.value)) {
|
||||
logger.warn('Directory response was not an array', { url: urls[idx] });
|
||||
return;
|
||||
}
|
||||
result.value.forEach((entry) => {
|
||||
const normalized = normalizeDirectoryEntry(entry);
|
||||
if (normalized) entries.push(normalized);
|
||||
});
|
||||
});
|
||||
const self = ownPublicUrl();
|
||||
return uniqueDirectoryEntries(entries).filter((entry) => entry.url !== self);
|
||||
}
|
||||
|
||||
function normalizeRemotePayload(entry, payload) {
|
||||
const instance = payload?.instance && typeof payload.instance === 'object' ? payload.instance : {};
|
||||
/*
|
||||
Remote payloads are intentionally additive. Every read below has a passive
|
||||
fallback so older or partially configured servers still produce a useful
|
||||
listing instead of breaking the whole directory view.
|
||||
*/
|
||||
return {
|
||||
url: entry.url,
|
||||
online: true,
|
||||
lastSuccessAt: Date.now(),
|
||||
lastError: null,
|
||||
latencyMs: null,
|
||||
instance: {
|
||||
...instance,
|
||||
id: asTrimmedString(instance.id),
|
||||
name: asTrimmedString(instance.name) || entry.name || entry.url,
|
||||
publicUrl: normalizeBaseUrl(instance.publicUrl) || entry.url,
|
||||
description: asTrimmedString(instance.description),
|
||||
color: asTrimmedString(instance.color),
|
||||
features: instance.features && typeof instance.features === 'object' ? instance.features : {},
|
||||
},
|
||||
roster: Array.isArray(payload?.roster) ? payload.roster : [],
|
||||
turnQueues: payload?.turnQueues && typeof payload.turnQueues === 'object' ? payload.turnQueues : {},
|
||||
users: Array.isArray(payload?.users) ? payload.users : [],
|
||||
roomCameras: Array.isArray(payload?.roomCameras) ? payload.roomCameras : [],
|
||||
socials: Array.isArray(payload?.socials) ? payload.socials : [],
|
||||
};
|
||||
}
|
||||
|
||||
function remoteIdentityKey(remote) {
|
||||
const advertisedId = asTrimmedString(remote?.instance?.id);
|
||||
if (advertisedId) return `id:${advertisedId}`;
|
||||
const advertisedPublicUrl = normalizeBaseUrl(remote?.instance?.publicUrl);
|
||||
if (advertisedPublicUrl) return `url:${advertisedPublicUrl}`;
|
||||
return `url:${normalizeBaseUrl(remote?.url) || remote?.url || ''}`;
|
||||
}
|
||||
|
||||
function isSelfRemote(remote) {
|
||||
const ownId = ownInstanceId();
|
||||
const remoteId = asTrimmedString(remote?.instance?.id);
|
||||
if (ownId && remoteId && ownId === remoteId) return true;
|
||||
const self = ownPublicUrl();
|
||||
const remotePublicUrl = normalizeBaseUrl(remote?.instance?.publicUrl);
|
||||
const remoteUrl = normalizeBaseUrl(remote?.url);
|
||||
return Boolean(self && (remotePublicUrl === self || remoteUrl === self));
|
||||
}
|
||||
|
||||
function preferRemoteEntry(current, candidate) {
|
||||
/*
|
||||
When the directory has multiple URLs for one instance, keep the healthier
|
||||
entry. Online data beats offline placeholders, and lower latency is a useful
|
||||
tiebreaker when two aliases both work.
|
||||
*/
|
||||
if (!current) return candidate;
|
||||
if (candidate.online && !current.online) return candidate;
|
||||
if (!candidate.online && current.online) return current;
|
||||
if (candidate.online && current.online) {
|
||||
const currentLatency = Number.isFinite(current.latencyMs) ? current.latencyMs : Infinity;
|
||||
const candidateLatency = Number.isFinite(candidate.latencyMs) ? candidate.latencyMs : Infinity;
|
||||
return candidateLatency < currentLatency ? candidate : current;
|
||||
}
|
||||
const currentName = asTrimmedString(current?.instance?.name);
|
||||
const candidateName = asTrimmedString(candidate?.instance?.name);
|
||||
return !currentName && candidateName ? candidate : current;
|
||||
}
|
||||
|
||||
function replaceRemoteInstances(nextEntries) {
|
||||
const deduped = new Map();
|
||||
nextEntries.forEach((entry) => {
|
||||
if (!entry || isSelfRemote(entry)) return;
|
||||
const key = remoteIdentityKey(entry);
|
||||
deduped.set(key, preferRemoteEntry(deduped.get(key), entry));
|
||||
});
|
||||
remoteInstances.clear();
|
||||
Array.from(deduped.values()).forEach((entry) => {
|
||||
remoteInstances.set(remoteIdentityKey(entry), entry);
|
||||
});
|
||||
}
|
||||
|
||||
function markOffline(entry, error) {
|
||||
const previous = remoteInstances.get(`url:${entry.url}`) || {};
|
||||
return {
|
||||
...previous,
|
||||
url: entry.url,
|
||||
online: false,
|
||||
lastError: error?.message || String(error || 'Unknown error'),
|
||||
instance: {
|
||||
...(previous.instance || {}),
|
||||
name: previous.instance?.name || entry.name || entry.url,
|
||||
publicUrl: previous.instance?.publicUrl || entry.url,
|
||||
},
|
||||
roster: previous.roster || [],
|
||||
turnQueues: previous.turnQueues || {},
|
||||
users: previous.users || [],
|
||||
roomCameras: previous.roomCameras || [],
|
||||
socials: previous.socials || [],
|
||||
};
|
||||
}
|
||||
|
||||
async function pollRemoteInstance(entry) {
|
||||
const start = Date.now();
|
||||
try {
|
||||
const payload = await fetchJson(`${entry.url}${INFO_PATH}`);
|
||||
const normalized = normalizeRemotePayload(entry, payload);
|
||||
normalized.latencyMs = Date.now() - start;
|
||||
return normalized;
|
||||
} catch (err) {
|
||||
return markOffline(entry, err);
|
||||
}
|
||||
}
|
||||
|
||||
async function pollNow() {
|
||||
if (!isEnabled() || polling) return;
|
||||
polling = true;
|
||||
try {
|
||||
const entries = await fetchDirectoryEntries();
|
||||
const nextEntries = await Promise.all(entries.map((entry) => pollRemoteInstance(entry)));
|
||||
replaceRemoteInstances(nextEntries);
|
||||
interInstanceEvents.emit('change');
|
||||
} catch (err) {
|
||||
logger.warn('Inter-instance poll failed', { error: err.message });
|
||||
} finally {
|
||||
polling = false;
|
||||
}
|
||||
}
|
||||
|
||||
function startPolling() {
|
||||
if (!isEnabled()) return;
|
||||
pollNow();
|
||||
setInterval(pollNow, pollIntervalMs());
|
||||
}
|
||||
|
||||
function getState() {
|
||||
return {
|
||||
enabled: isEnabled(),
|
||||
profile: publicProfile(),
|
||||
instances: Array.from(remoteInstances.values()).sort((a, b) =>
|
||||
String(a.instance?.name || a.url).localeCompare(String(b.instance?.name || b.url)),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
startPolling();
|
||||
|
||||
module.exports = {
|
||||
getState,
|
||||
interInstanceEvents,
|
||||
buildLocalInfo,
|
||||
pollNow,
|
||||
};
|
||||
@@ -36,6 +36,7 @@ const { getFeatureFlags } = require('../../helpers/features');
|
||||
const { getAudioForwardState, audioForwardEvents } = require('../audioForwardService');
|
||||
const { getAudioLevels, audioLevelsEvents } = require('../audioLevelsService');
|
||||
const { getButtonBoxState } = require('../buttonBoxService');
|
||||
const { getState: getInterInstanceState, interInstanceEvents } = require('../interInstanceService');
|
||||
const {
|
||||
discordInvite,
|
||||
kofiLink,
|
||||
@@ -132,6 +133,12 @@ function buildSession(socket) {
|
||||
audioForward: getAudioForwardState(),
|
||||
audioLevels: getAudioLevels(),
|
||||
buttonBox: getButtonBoxState(),
|
||||
/*
|
||||
Inter-instance state is a read-only directory snapshot. It is included in
|
||||
session sync because the UI already treats session payloads as the source
|
||||
of truth for rovers, queues, and public feature availability.
|
||||
*/
|
||||
interInstances: getInterInstanceState(),
|
||||
overseerVote: {
|
||||
...overseerVote,
|
||||
preference: typeof socket?.data?.overseerEnabled === 'boolean' ? socket.data.overseerEnabled : true,
|
||||
@@ -347,6 +354,10 @@ audioLevelsEvents.on('change', () => {
|
||||
syncAll();
|
||||
});
|
||||
|
||||
interInstanceEvents.on('change', () => {
|
||||
syncAll();
|
||||
});
|
||||
|
||||
// sync all sockets 20 seconds
|
||||
// setInterval(() => {
|
||||
// logger.info('Periodic session sync for all clients');
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
1. improve spectator page, options on what to see and what not to see
|
||||
3. synthesize wheel speed sensors based on encoder readings server side, add them to json sensor data, show them in topdown
|
||||
4. pagewide system for "why was i removed from a rover", instead of the link to spectator page thing
|
||||
5. admin command for kicking people off of rovers. just a kick, nothing persistent
|
||||
6. kick people off rover after 3 consecurtive bump-off attempts
|
||||
7. make signaling more clear for idle skips and idle skip kicks
|
||||
8. add more background gap themes
|
||||
9. fix this:
|
||||
2. synthesize wheel speed sensors based on encoder readings server side, add them to json sensor data, show them in topdown
|
||||
3. add more background gap themes
|
||||
4. fix this:
|
||||
`Jun 18 15:14:18 roombaserver.local node[216731]: /home/daniel/MultiRoombaRover/server/src/services/roverManager/socketHandlers.js:92
|
||||
Jun 18 15:14:18 roombaserver.local node[216731]: cb({ error: err.message });
|
||||
Jun 18 15:14:18 roombaserver.local node[216731]: ^
|
||||
|
||||
+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