This commit is contained in:
legop3
2026-03-17 18:33:27 -04:00
parent c44632ed4d
commit 0e4954fadf
12 changed files with 696 additions and 498 deletions
+4 -2
View File
@@ -23,10 +23,12 @@ audioForward:
enabled: true enabled: true
# Optional override; defaults to "ffmpeg" # Optional override; defaults to "ffmpeg"
ffmpegBin: "ffmpeg" ffmpegBin: "ffmpeg"
# Optional test file path for admin-only first-pass playback
testAudioPath: "server/assets/test-audio.mp3"
# Optional stream suffix for fallback URL generation # Optional stream suffix for fallback URL generation
streamSuffix: "-fwd" streamSuffix: "-fwd"
# Max upload payload accepted via VIP forward upload
maxUploadBytes: 8388608
# Maximum playback duration per upload (seconds)
maxUploadSeconds: 45
audioLevels: audioLevels:
# Gains are multipliers (0.0 - 4.0) applied globally to all rovers. # Gains are multipliers (0.0 - 4.0) applied globally to all rovers.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -11,7 +11,7 @@
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" /> <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="Multi Roomba Rover" /> <meta name="apple-mobile-web-app-title" content="Multi Roomba Rover" />
<title>Multi Roomba Rover</title> <title>Multi Roomba Rover</title>
<script type="module" crossorigin src="/assets/index-C4govqUc.js"></script> <script type="module" crossorigin src="/assets/index-D3RnimBF.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-WqYsCIRI.css"> <link rel="stylesheet" crossorigin href="/assets/index-WqYsCIRI.css">
</head> </head>
<body> <body>
+92 -34
View File
@@ -7,7 +7,7 @@ const logger = require('../globals/logger').child('audioForwardService');
const { loadConfig } = require('../helpers/configLoader'); const { loadConfig } = require('../helpers/configLoader');
const roverManager = require('./roverManager'); const roverManager = require('./roverManager');
const { isAdmin } = require('./roleService'); const { isAdmin } = require('./roleService');
const { audioLevelsEvents } = require('./audioLevelsService'); const { isVerified } = require('./verificationService');
const audioForwardEvents = new EventEmitter(); const audioForwardEvents = new EventEmitter();
const config = loadConfig(); const config = loadConfig();
@@ -16,15 +16,13 @@ const serviceEnabled = audioForwardConfig.enabled !== false;
const ffmpegBin = audioForwardConfig.ffmpegBin || 'ffmpeg'; const ffmpegBin = audioForwardConfig.ffmpegBin || 'ffmpeg';
const streamSuffix = typeof audioForwardConfig.streamSuffix === 'string' ? audioForwardConfig.streamSuffix : '-fwd'; const streamSuffix = typeof audioForwardConfig.streamSuffix === 'string' ? audioForwardConfig.streamSuffix : '-fwd';
const runtimeDir = path.resolve(audioForwardConfig.runtimeDir || '/tmp/mrr-audio-forward'); const runtimeDir = path.resolve(audioForwardConfig.runtimeDir || '/tmp/mrr-audio-forward');
const repoRoot = path.resolve(__dirname, '..', '..', '..'); const uploadsDir = path.join(runtimeDir, 'uploads');
const defaultAudioPath = path.join(__dirname, '..', '..', 'assets', 'test-audio.mp3'); const maxUploadBytes = Number.isFinite(audioForwardConfig.maxUploadBytes)
const configuredAudioPath = audioForwardConfig.testAudioPath; ? Math.max(256 * 1024, Math.floor(audioForwardConfig.maxUploadBytes))
const testAudioPath = : 8 * 1024 * 1024;
configuredAudioPath && path.isAbsolute(configuredAudioPath) const maxUploadSeconds = Number.isFinite(audioForwardConfig.maxUploadSeconds)
? configuredAudioPath ? Math.max(1, Math.floor(audioForwardConfig.maxUploadSeconds))
: configuredAudioPath : 45;
? path.resolve(repoRoot, configuredAudioPath)
: defaultAudioPath;
const states = new Map(); // roverId -> { state, source, error, startedAt, updatedAt } const states = new Map(); // roverId -> { state, source, error, startedAt, updatedAt }
const workers = new Map(); // roverId -> worker const workers = new Map(); // roverId -> worker
@@ -62,12 +60,43 @@ function ensureServiceEnabled() {
function ensureRuntimeDir() { function ensureRuntimeDir() {
fs.mkdirSync(runtimeDir, { recursive: true }); fs.mkdirSync(runtimeDir, { recursive: true });
fs.mkdirSync(uploadsDir, { recursive: true });
} }
function sanitizeRoverId(roverId) { function sanitizeRoverId(roverId) {
return String(roverId || '').replace(/[^a-zA-Z0-9_-]+/g, '_'); return String(roverId || '').replace(/[^a-zA-Z0-9_-]+/g, '_');
} }
function sanitizeFileStem(name) {
return String(name || 'upload')
.toLowerCase()
.replace(/[^a-z0-9._-]+/g, '_')
.replace(/^_+|_+$/g, '')
.slice(0, 64);
}
function extFromUpload(name, mime) {
const lowerName = String(name || '').toLowerCase();
const lowerMime = String(mime || '').toLowerCase();
if (lowerName.endsWith('.mp3') || lowerMime === 'audio/mpeg' || lowerMime === 'audio/mp3') return '.mp3';
if (lowerName.endsWith('.wav') || lowerMime === 'audio/wav' || lowerMime === 'audio/x-wav') return '.wav';
if (lowerName.endsWith('.ogg') || lowerMime === 'audio/ogg') return '.ogg';
throw new Error('Unsupported upload format (allowed: mp3, wav, ogg)');
}
function ensureVipVerified(socket) {
if (!isVerified(socket)) {
throw new Error('VIP verification required');
}
}
function ensureAudioForwardPermission(socket, roverId) {
ensureVipVerified(socket);
if (!isAdmin(socket) && !roverManager.canDrive(roverId, socket)) {
throw new Error('Not your turn or no control');
}
}
function ensureFifo(fifoPath) { function ensureFifo(fifoPath) {
try { try {
const stat = fs.statSync(fifoPath); const stat = fs.statSync(fifoPath);
@@ -208,7 +237,7 @@ function buildSilenceWriterArgs() {
]; ];
} }
function buildClipWriterArgs(filePath) { function buildUploadWriterArgs(filePath) {
return [ return [
'-hide_banner', '-hide_banner',
'-loglevel', '-loglevel',
@@ -219,6 +248,8 @@ function buildClipWriterArgs(filePath) {
'-vn', '-vn',
'-af', '-af',
'aresample=16000', 'aresample=16000',
'-t',
String(maxUploadSeconds),
'-f', '-f',
's16le', 's16le',
'-ac', '-ac',
@@ -251,6 +282,16 @@ function attachWriterPipe(worker, proc) {
}); });
} }
function cleanupUploadFile(worker) {
if (!worker?.activeUploadPath) return;
try {
fs.unlinkSync(worker.activeUploadPath);
} catch {
// noop
}
worker.activeUploadPath = null;
}
function stopContentWriter(worker) { function stopContentWriter(worker) {
if (!worker?.contentProc) return; if (!worker?.contentProc) return;
stopProc(worker.contentProc); stopProc(worker.contentProc);
@@ -263,6 +304,7 @@ function startSilenceWriter(roverId) {
if (!worker || worker.stopping) return; if (!worker || worker.stopping) return;
stopContentWriter(worker); stopContentWriter(worker);
cleanupUploadFile(worker);
const proc = spawnProcess(roverId, 'silence-writer', buildSilenceWriterArgs(), { captureStdout: true }); const proc = spawnProcess(roverId, 'silence-writer', buildSilenceWriterArgs(), { captureStdout: true });
worker.contentProc = proc; worker.contentProc = proc;
worker.contentKind = 'silence'; worker.contentKind = 'silence';
@@ -287,18 +329,20 @@ function startSilenceWriter(roverId) {
setState(roverId, { state: 'idle', source: 'silence', error: null, startedAt: null }); setState(roverId, { state: 'idle', source: 'silence', error: null, startedAt: null });
} }
function startClipWriter(roverId, filePath) { function startUploadWriter(roverId, filePath) {
const worker = workers.get(roverId); const worker = workers.get(roverId);
if (!worker || worker.stopping) return; if (!worker || worker.stopping) return;
stopContentWriter(worker); stopContentWriter(worker);
const proc = spawnProcess(roverId, 'clip-writer', buildClipWriterArgs(filePath), { captureStdout: true }); cleanupUploadFile(worker);
worker.activeUploadPath = filePath;
const proc = spawnProcess(roverId, 'upload-writer', buildUploadWriterArgs(filePath), { captureStdout: true });
worker.contentProc = proc; worker.contentProc = proc;
worker.contentKind = 'clip'; worker.contentKind = 'upload';
const seq = ++worker.writerSeq; const seq = ++worker.writerSeq;
attachWriterPipe(worker, proc); attachWriterPipe(worker, proc);
setState(roverId, { state: 'playing', source: 'clip', error: null, startedAt: Date.now() }); setState(roverId, { state: 'playing', source: 'upload', error: null, startedAt: Date.now() });
proc.on('exit', (code, signal) => { proc.on('exit', (code, signal) => {
const current = workers.get(roverId); const current = workers.get(roverId);
@@ -308,7 +352,7 @@ function startClipWriter(roverId, filePath) {
current.contentKind = null; current.contentKind = null;
if (code != null && code !== 0 && signal !== 'SIGTERM') { if (code != null && code !== 0 && signal !== 'SIGTERM') {
setState(roverId, { state: 'error', source: 'clip', error: `clip writer exited code=${code} signal=${signal || 'none'}` }); setState(roverId, { state: 'error', source: 'upload', error: `upload writer exited code=${code} signal=${signal || 'none'}` });
} }
startSilenceWriter(roverId); startSilenceWriter(roverId);
}); });
@@ -344,6 +388,7 @@ function ensureWorker(roverId) {
publisherProc: publisher, publisherProc: publisher,
contentProc: null, contentProc: null,
contentKind: null, contentKind: null,
activeUploadPath: null,
writerSeq: 0, writerSeq: 0,
stopping: false, stopping: false,
}; };
@@ -366,12 +411,31 @@ function ensureWorker(roverId) {
return worker; return worker;
} }
function playTestAudio(roverId) { function writeUploadFile(roverId, payload = {}) {
if (!fs.existsSync(testAudioPath)) { const { name, mime, dataBase64 } = payload || {};
throw new Error(`Test audio missing: ${testAudioPath}`); const ext = extFromUpload(name, mime);
const encoded = typeof dataBase64 === 'string' ? dataBase64.trim() : '';
if (!encoded) {
throw new Error('Upload payload missing');
} }
const bytes = Buffer.from(encoded, 'base64');
if (!bytes.length) {
throw new Error('Upload decode failed');
}
if (bytes.length > maxUploadBytes) {
throw new Error(`Upload too large (max ${maxUploadBytes} bytes)`);
}
ensureRuntimeDir();
const stem = sanitizeFileStem(name || `upload-${Date.now()}`);
const filePath = path.join(uploadsDir, `${sanitizeRoverId(roverId)}-${Date.now()}-${stem}${ext}`);
fs.writeFileSync(filePath, bytes);
return filePath;
}
function playUploadedAudio(roverId, payload = {}) {
const uploadPath = writeUploadFile(roverId, payload);
ensureWorker(roverId); ensureWorker(roverId);
startClipWriter(roverId, testAudioPath); startUploadWriter(roverId, uploadPath);
} }
function stopPlayback(roverId) { function stopPlayback(roverId) {
@@ -385,6 +449,7 @@ function stopWorker(roverId) {
worker.stopping = true; worker.stopping = true;
stopContentWriter(worker); stopContentWriter(worker);
cleanupUploadFile(worker);
stopProc(worker.publisherProc); stopProc(worker.publisherProc);
try { try {
@@ -417,30 +482,23 @@ roverManager.managerEvents.on('rover', ({ roverId, action } = {}) => {
} }
}); });
audioLevelsEvents.on('change', () => {
// Forward gain now applies at rover ALSA mixer (ForwardMaster), so no writer restart is needed.
});
io.on('connection', (socket) => { io.on('connection', (socket) => {
socket.on('audio:testPlay', ({ roverId } = {}, cb = () => {}) => { socket.on('audio:uploadPlay', (payload = {}, cb = () => {}) => {
try { try {
if (!isAdmin(socket)) { const roverId = String(payload?.roverId || '').trim();
throw new Error('Not authorized'); ensureAudioForwardPermission(socket, roverId);
}
const normalized = String(roverId || '').trim(); const normalized = String(roverId || '').trim();
playTestAudio(normalized); playUploadedAudio(normalized, payload);
cb({ success: true, roverId: normalized }); cb({ success: true, roverId: normalized });
} catch (err) { } catch (err) {
cb({ error: err.message }); cb({ error: err.message });
} }
}); });
socket.on('audio:testStop', ({ roverId } = {}, cb = () => {}) => { socket.on('audio:uploadStop', ({ roverId } = {}, cb = () => {}) => {
try { try {
if (!isAdmin(socket)) {
throw new Error('Not authorized');
}
const normalized = String(roverId || '').trim(); const normalized = String(roverId || '').trim();
ensureAudioForwardPermission(socket, normalized);
stopPlayback(normalized); stopPlayback(normalized);
cb({ success: true, roverId: normalized }); cb({ success: true, roverId: normalized });
} catch (err) { } catch (err) {
@@ -452,6 +510,6 @@ io.on('connection', (socket) => {
module.exports = { module.exports = {
getAudioForwardState, getAudioForwardState,
audioForwardEvents, audioForwardEvents,
playTestAudio, playUploadedAudio,
stopPlayback, stopPlayback,
}; };
-43
View File
@@ -20,8 +20,6 @@ export default function AdminPanel() {
setAdminReason, setAdminReason,
rebootRover, rebootRover,
rebootServer, rebootServer,
playTestAudio,
stopTestAudio,
setAudioLevels, setAudioLevels,
llmControl, llmControl,
adminLogs, adminLogs,
@@ -30,7 +28,6 @@ export default function AdminPanel() {
const roster = useMemo(() => session?.roster ?? [], [session?.roster]); const roster = useMemo(() => session?.roster ?? [], [session?.roster]);
const [lockStates, setLockStates] = useState({}); const [lockStates, setLockStates] = useState({});
const [rebootStates, setRebootStates] = useState({}); const [rebootStates, setRebootStates] = useState({});
const [audioStates, setAudioStates] = useState({});
const [serverRebooting, setServerRebooting] = useState(false); const [serverRebooting, setServerRebooting] = useState(false);
const [clearingLlmHistory, setClearingLlmHistory] = useState(false); const [clearingLlmHistory, setClearingLlmHistory] = useState(false);
const health = session?.health || null; const health = session?.health || null;
@@ -94,30 +91,6 @@ export default function AdminPanel() {
} }
}; };
const handlePlayTestAudio = async (roverId) => {
if (!roverId) return;
setAudioStates((prev) => ({ ...prev, [roverId]: true }));
try {
await playTestAudio(roverId);
} catch (err) {
alert(err.message);
} finally {
setAudioStates((prev) => ({ ...prev, [roverId]: false }));
}
};
const handleStopTestAudio = async (roverId) => {
if (!roverId) return;
setAudioStates((prev) => ({ ...prev, [roverId]: true }));
try {
await stopTestAudio(roverId);
} catch (err) {
alert(err.message);
} finally {
setAudioStates((prev) => ({ ...prev, [roverId]: false }));
}
};
const handleServerReboot = async () => { const handleServerReboot = async () => {
const ok = window.confirm('Reboot the server host now? This will disconnect all users.'); const ok = window.confirm('Reboot the server host now? This will disconnect all users.');
if (!ok) return; if (!ok) return;
@@ -364,22 +337,6 @@ export default function AdminPanel() {
> >
{rebootStates[rover.id] ? 'Rebooting...' : 'Reboot'} {rebootStates[rover.id] ? 'Rebooting...' : 'Reboot'}
</button> </button>
<button
type="button"
onClick={() => handlePlayTestAudio(rover.id)}
disabled={Boolean(audioStates[rover.id])}
className="button-dark disabled:cursor-not-allowed disabled:opacity-60"
>
Play Test Audio
</button>
<button
type="button"
onClick={() => handleStopTestAudio(rover.id)}
disabled={Boolean(audioStates[rover.id])}
className="button-dark disabled:cursor-not-allowed disabled:opacity-60"
>
Stop Test Audio
</button>
<span className="surface-muted"> <span className="surface-muted">
audio: {session?.audioForward?.[rover.id]?.state || 'idle'} audio: {session?.audioForward?.[rover.id]?.state || 'idle'}
</span> </span>
+29 -289
View File
@@ -1,19 +1,13 @@
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { useSession } from '../context/SessionContext.jsx'; import { useSession } from '../context/SessionContext.jsx';
import { useSettingsNamespace } from '../settings/index.js'; import { useSettingsNamespace } from '../settings/index.js';
import NicknameForm from './NicknameForm.jsx'; import { COOKIE_KEY_REGEX, flowWrapClass } from './vip/constants.js';
import VipAudioForwardingCard from './vip/VipAudioForwardingCard.jsx';
function maskKey(value) { import VipVerificationCard from './vip/VipVerificationCard.jsx';
const key = String(value || '').trim(); import VipIdentityCard from './vip/VipIdentityCard.jsx';
if (!key) return '';
if (key.length <= 10) return `${key.slice(0, 2)}***${key.slice(-2)}`;
return `${key.slice(0, 6)}...${key.slice(-6)}`;
}
const cookieKeyRegex = /^cu_[a-f0-9]{32}$/;
export default function VipPanel() { export default function VipPanel() {
const { session, identifySession, requestVerification } = useSession(); const { session, identifySession, requestVerification, playUploadedAudio, stopUploadedAudio } = useSession();
const { value: identity, save: saveIdentity } = useSettingsNamespace('identity', { cookieUserId: '' }); const { value: identity, save: saveIdentity } = useSettingsNamespace('identity', { cookieUserId: '' });
const { value: profile } = useSettingsNamespace('profile', { nickname: '' }); const { value: profile } = useSettingsNamespace('profile', { nickname: '' });
@@ -21,24 +15,15 @@ export default function VipPanel() {
const nickname = (profile?.nickname || '').trim(); const nickname = (profile?.nickname || '').trim();
const isVerified = Boolean(session?.isVerified); const isVerified = Boolean(session?.isVerified);
const pendingRequestId = session?.verification?.pendingRequestId || null; const pendingRequestId = session?.verification?.pendingRequestId || null;
const roster = useMemo(() => session?.roster ?? [], [session?.roster]);
const [requestFlowStep, setRequestFlowStep] = useState(0);
const [requestKeyInput, setRequestKeyInput] = useState('');
const [confirmNickname, setConfirmNickname] = useState(false);
const [restoreFlowStep, setRestoreFlowStep] = useState(0);
const [restoreKeyInput, setRestoreKeyInput] = useState('');
const [working, setWorking] = useState(false);
const [message, setMessage] = useState(''); const [message, setMessage] = useState('');
const fieldClass = 'field-input w-full max-w-sm text-left focus:ring-emerald-500';
const flowWrapClass = 'mx-auto w-full max-w-xl flex justify-center';
const innerFlowClass = 'mx-auto flex w-full max-w-md flex-col items-center space-y-0.5 text-center';
const applyIdentityKey = async (nextRaw) => { const applyIdentityKey = async (nextRaw) => {
const next = String(nextRaw || '').trim().toLowerCase(); const next = String(nextRaw || '').trim().toLowerCase();
if (!next) { if (!next) {
throw new Error('Identity key required.'); throw new Error('Identity key required.');
} }
if (!cookieKeyRegex.test(next)) { if (!COOKIE_KEY_REGEX.test(next)) {
throw new Error('Identity key must match format: cu_ + 32 lowercase hex chars.'); throw new Error('Identity key must match format: cu_ + 32 lowercase hex chars.');
} }
saveIdentity((current) => ({ ...(current || {}), cookieUserId: next })); saveIdentity((current) => ({ ...(current || {}), cookieUserId: next }));
@@ -46,277 +31,32 @@ export default function VipPanel() {
return next; return next;
}; };
const beginRequestFlow = () => {
setRequestFlowStep(1);
setRequestKeyInput(currentStoredKey);
setConfirmNickname(false);
setMessage('');
};
const cancelRequestFlow = () => {
setRequestFlowStep(0);
setConfirmNickname(false);
};
const beginRestoreFlow = () => {
setRestoreFlowStep(1);
setRestoreKeyInput('');
setMessage('');
};
const cancelRestoreFlow = () => {
setRestoreFlowStep(0);
setRestoreKeyInput('');
};
const handleRequestSubmit = async (event) => {
event.preventDefault();
if (!confirmNickname) {
setMessage('Please confirm your nickname agreement before sending.');
return;
}
if (requestFlowStep < 3) {
setMessage('Complete all request steps before sending.');
return;
}
setWorking(true);
setMessage('');
try {
const applied = await applyIdentityKey(requestKeyInput);
await requestVerification();
setRequestKeyInput(applied);
setRequestFlowStep(0);
setConfirmNickname(false);
setMessage('Verification request sent to lockdown admins.');
} catch (err) {
setMessage(err.message || 'Failed to submit request.');
} finally {
setWorking(false);
}
};
const handleRestoreSubmit = async (event) => {
event.preventDefault();
if (restoreFlowStep < 2) return;
setWorking(true);
setMessage('');
try {
await applyIdentityKey(restoreKeyInput);
setRestoreFlowStep(0);
setRestoreKeyInput('');
setMessage('Identity key restored.');
} catch (err) {
setMessage(err.message || 'Failed to restore key.');
} finally {
setWorking(false);
}
};
return ( return (
<section className="panel-section space-y-0.5 text-base"> <section className="panel-section space-y-0.5 text-base">
{isVerified ? ( {isVerified ? (
<section className="surface text-sm text-slate-200"> <VipAudioForwardingCard
VIP controls will show here... when there are some... roster={roster}
</section> audioForwardByRover={session?.audioForward || {}}
) : null} playUploadedAudio={playUploadedAudio}
stopUploadedAudio={stopUploadedAudio}
onMessage={setMessage}
/>
) : (
<VipVerificationCard
pendingRequestId={pendingRequestId}
currentStoredKey={currentStoredKey}
nickname={nickname}
requestVerification={requestVerification}
applyIdentityKey={applyIdentityKey}
onMessage={setMessage}
/>
)}
{!isVerified ? ( <VipIdentityCard
pendingRequestId ? ( currentStoredKey={currentStoredKey}
<section className={`surface text-sm text-slate-300 ${flowWrapClass}`}> applyIdentityKey={applyIdentityKey}
<div className={innerFlowClass}>Verification request pending: {pendingRequestId}</div> onMessage={setMessage}
</section> />
) : requestFlowStep === 0 ? (
<section className={`surface ${flowWrapClass}`}>
<div className={innerFlowClass}>
<p className="text-sm text-slate-300">Verification</p>
<button type="button" className="button-dark text-sm" onClick={beginRequestFlow} disabled={working}>
Request Verification
</button>
</div>
</section>
) : (
<form className={`surface ${flowWrapClass}`} onSubmit={handleRequestSubmit}>
<div className={innerFlowClass}>
<p className="text-sm text-slate-300">Request verification</p>
<p className="text-xs text-slate-500">
Step {requestFlowStep} of 3
</p>
{requestFlowStep === 1 ? (
<div className="space-y-0.5">
<p className="text-xs text-slate-400">
Confirm your nickname, which is used as part of the verification process. You can edit it here before requesting.
</p>
<div className="mx-auto w-full max-w-sm">
<NicknameForm compact />
</div>
<div className="surface-muted mx-auto w-full max-w-sm text-xs text-slate-300 text-center">
Current nickname: <span className="font-semibold">{nickname || '(not set)'}</span>
</div>
<label className="flex items-center justify-center gap-0.5 text-xs text-slate-300 text-center">
<input
type="checkbox"
className="accent-emerald-500"
checked={confirmNickname}
onChange={(event) => setConfirmNickname(event.target.checked)}
/>
<span>I understand this nickname is tied to my verification.</span>
</label>
<div className="flex justify-center gap-0.5">
<button
type="button"
className="button-dark text-sm"
disabled={!nickname || !confirmNickname}
onClick={() => setRequestFlowStep(2)}
>
Continue
</button>
<button type="button" className="button-dark text-sm" onClick={cancelRequestFlow}>
Cancel
</button>
</div>
</div>
) : null}
{requestFlowStep === 2 ? (
<div className="space-y-0.5">
<p className="text-xs text-slate-400">
Save your identity key in a safe place. You can use it to restore your identity in another browser.
</p>
<input
className={fieldClass}
type="password"
name="identity_key_request"
autoComplete="current-password"
maxLength={35}
value={requestKeyInput}
onChange={(event) => setRequestKeyInput(event.target.value.toLowerCase())}
placeholder="Identity key for this request"
/>
<div className="flex justify-center gap-0.5">
<button
type="button"
className="button-dark text-sm"
disabled={!requestKeyInput}
onClick={() => navigator.clipboard?.writeText(requestKeyInput)}
>
Copy Key
</button>
<button
type="button"
className="button-dark text-sm"
disabled={!requestKeyInput}
onClick={() => setRequestFlowStep(3)}
>
Continue
</button>
<button type="button" className="button-dark text-sm" onClick={() => setRequestFlowStep(1)}>
Back
</button>
</div>
</div>
) : null}
{requestFlowStep === 3 ? (
<div className="space-y-0.5">
<p className="text-xs text-slate-400">
Final step: confirm and send your verification request.
</p>
<input
className={fieldClass}
type="password"
name="identity_key_request_final"
autoComplete="current-password"
maxLength={35}
value={requestKeyInput}
onChange={(event) => setRequestKeyInput(event.target.value.toLowerCase())}
placeholder="Identity key for this request"
/>
<div className="flex justify-center gap-0.5">
<button type="submit" className="button-dark text-sm" disabled={working || !requestKeyInput}>
Confirm Request
</button>
<button type="button" className="button-dark text-sm" onClick={() => setRequestFlowStep(2)}>
Back
</button>
</div>
</div>
) : null}
</div>
</form>
)
) : null}
<section className={`surface ${flowWrapClass}`}>
<div className={innerFlowClass}>
<p className="text-sm text-slate-300">Identity key</p>
<p className="text-xs text-slate-500">Current: {maskKey(currentStoredKey) || 'not set yet'}</p>
<input
className={fieldClass}
type="password"
name="identity_key_current"
autoComplete="current-password"
maxLength={35}
value={currentStoredKey}
readOnly
placeholder="Identity key"
/>
{restoreFlowStep === 0 ? (
<div className="flex justify-center gap-0.5">
<button
type="button"
className="button-dark text-sm"
disabled={!currentStoredKey}
onClick={() => navigator.clipboard?.writeText(currentStoredKey)}
>
Copy Key
</button>
<button type="button" className="button-dark text-sm" onClick={beginRestoreFlow} disabled={working}>
Restore Key
</button>
</div>
) : null}
{restoreFlowStep === 1 ? (
<div className="space-y-0.5">
<p className="text-xs text-slate-400">
Restoring your key should only be done when needed. Use this to move your identity to another browser.
</p>
<div className="flex justify-center gap-0.5">
<button type="button" className="button-dark text-sm" onClick={() => setRestoreFlowStep(2)}>
Continue
</button>
<button type="button" className="button-dark text-sm" onClick={cancelRestoreFlow}>
Cancel
</button>
</div>
</div>
) : null}
{restoreFlowStep === 2 ? (
<form className="space-y-0.5" onSubmit={handleRestoreSubmit}>
<input
className={fieldClass}
type="password"
name="identity_key_restore"
autoComplete="current-password"
maxLength={35}
value={restoreKeyInput}
onChange={(event) => setRestoreKeyInput(event.target.value.toLowerCase())}
placeholder="Paste key to restore"
/>
<div className="flex justify-center gap-0.5">
<button type="submit" className="button-dark text-sm" disabled={working || !restoreKeyInput}>
Confirm Restore
</button>
<button type="button" className="button-dark text-sm" onClick={() => setRestoreFlowStep(1)}>
Back
</button>
</div>
</form>
) : null}
</div>
</section>
{message ? ( {message ? (
<div className={flowWrapClass}> <div className={flowWrapClass}>
@@ -0,0 +1,120 @@
import { useMemo, useState } from 'react';
import { MAX_UPLOAD_BYTES, bytesToBase64, fieldClass } from './constants.js';
export default function VipAudioForwardingCard({
roster = [],
audioForwardByRover = {},
playUploadedAudio,
stopUploadedAudio,
onMessage,
}) {
const [selectedRoverId, setSelectedRoverId] = useState('');
const [selectedUpload, setSelectedUpload] = useState(null);
const [working, setWorking] = useState(false);
const selectedForwardState = useMemo(
() => (selectedRoverId ? audioForwardByRover?.[selectedRoverId] || null : null),
[audioForwardByRover, selectedRoverId],
);
const handleUploadPlay = async () => {
const roverId = String(selectedRoverId || '').trim();
if (!roverId) {
onMessage?.('Select a rover first.');
return;
}
if (!selectedUpload) {
onMessage?.('Select an audio file first.');
return;
}
if (selectedUpload.size > MAX_UPLOAD_BYTES) {
onMessage?.(`File too large (max ${MAX_UPLOAD_BYTES} bytes).`);
return;
}
setWorking(true);
onMessage?.('');
try {
const buffer = await selectedUpload.arrayBuffer();
const base64 = bytesToBase64(new Uint8Array(buffer));
await playUploadedAudio?.({
roverId,
name: selectedUpload.name,
mime: selectedUpload.type || '',
dataBase64: base64,
});
onMessage?.(`Playing upload on ${roverId}.`);
} catch (err) {
onMessage?.(err.message || 'Failed to play upload.');
} finally {
setWorking(false);
}
};
const handleUploadStop = async () => {
const roverId = String(selectedRoverId || '').trim();
if (!roverId) {
onMessage?.('Select a rover first.');
return;
}
setWorking(true);
onMessage?.('');
try {
await stopUploadedAudio?.(roverId);
onMessage?.(`Stopped upload on ${roverId}.`);
} catch (err) {
onMessage?.(err.message || 'Failed to stop upload.');
} finally {
setWorking(false);
}
};
return (
<section className="surface space-y-0.5 text-sm text-slate-200">
<p className="text-slate-300">VIP Audio Forwarding</p>
<label className="grid gap-0.5 text-xs text-slate-300">
<span>Target rover</span>
<select
className={fieldClass}
value={selectedRoverId}
onChange={(event) => setSelectedRoverId(event.target.value)}
disabled={working}
>
<option value="">Select rover</option>
{roster.map((rover) => (
<option key={rover.id} value={rover.id}>
{rover.name || rover.id}
</option>
))}
</select>
</label>
<label className="grid gap-0.5 text-xs text-slate-300">
<span>Audio file (mp3 / wav / ogg)</span>
<input
className={fieldClass}
type="file"
accept=".mp3,.wav,.ogg,audio/mpeg,audio/wav,audio/ogg"
disabled={working}
onChange={(event) => setSelectedUpload(event.target.files?.[0] || null)}
/>
</label>
{selectedUpload ? (
<div className="surface-muted text-xs text-slate-300 text-center">
{selectedUpload.name} ({selectedUpload.size} bytes)
</div>
) : null}
<div className="flex justify-center gap-0.5">
<button type="button" className="button-dark text-sm" disabled={working} onClick={handleUploadPlay}>
{working ? 'Working...' : 'Play Upload'}
</button>
<button type="button" className="button-dark text-sm" disabled={working} onClick={handleUploadStop}>
Stop
</button>
</div>
{selectedForwardState ? (
<div className="surface-muted text-xs text-slate-300 text-center">
state: {selectedForwardState.state || 'idle'}
{selectedForwardState.error ? ` | error: ${selectedForwardState.error}` : ''}
</div>
) : null}
</section>
);
}
@@ -0,0 +1,109 @@
import { useState } from 'react';
import { fieldClass, flowWrapClass, innerFlowClass, maskKey } from './constants.js';
export default function VipIdentityCard({ currentStoredKey, applyIdentityKey, onMessage }) {
const [restoreFlowStep, setRestoreFlowStep] = useState(0);
const [restoreKeyInput, setRestoreKeyInput] = useState('');
const [working, setWorking] = useState(false);
const beginRestoreFlow = () => {
setRestoreFlowStep(1);
setRestoreKeyInput('');
onMessage?.('');
};
const cancelRestoreFlow = () => {
setRestoreFlowStep(0);
setRestoreKeyInput('');
};
const handleRestoreSubmit = async (event) => {
event.preventDefault();
if (restoreFlowStep < 2) return;
setWorking(true);
onMessage?.('');
try {
await applyIdentityKey(restoreKeyInput);
setRestoreFlowStep(0);
setRestoreKeyInput('');
onMessage?.('Identity key restored.');
} catch (err) {
onMessage?.(err.message || 'Failed to restore key.');
} finally {
setWorking(false);
}
};
return (
<section className={`surface ${flowWrapClass}`}>
<div className={innerFlowClass}>
<p className="text-sm text-slate-300">Identity key</p>
<p className="text-xs text-slate-500">Current: {maskKey(currentStoredKey) || 'not set yet'}</p>
<input
className={fieldClass}
type="password"
name="identity_key_current"
autoComplete="current-password"
maxLength={35}
value={currentStoredKey}
readOnly
placeholder="Identity key"
/>
{restoreFlowStep === 0 ? (
<div className="flex justify-center gap-0.5">
<button
type="button"
className="button-dark text-sm"
disabled={!currentStoredKey}
onClick={() => navigator.clipboard?.writeText(currentStoredKey)}
>
Copy Key
</button>
<button type="button" className="button-dark text-sm" onClick={beginRestoreFlow} disabled={working}>
Restore Key
</button>
</div>
) : null}
{restoreFlowStep === 1 ? (
<div className="space-y-0.5">
<p className="text-xs text-slate-400">
Restoring your key should only be done when needed. Use this to move your identity to another browser.
</p>
<div className="flex justify-center gap-0.5">
<button type="button" className="button-dark text-sm" onClick={() => setRestoreFlowStep(2)}>
Continue
</button>
<button type="button" className="button-dark text-sm" onClick={cancelRestoreFlow}>
Cancel
</button>
</div>
</div>
) : null}
{restoreFlowStep === 2 ? (
<form className="space-y-0.5" onSubmit={handleRestoreSubmit}>
<input
className={fieldClass}
type="password"
name="identity_key_restore"
autoComplete="current-password"
maxLength={35}
value={restoreKeyInput}
onChange={(event) => setRestoreKeyInput(event.target.value.toLowerCase())}
placeholder="Paste key to restore"
/>
<div className="flex justify-center gap-0.5">
<button type="submit" className="button-dark text-sm" disabled={working || !restoreKeyInput}>
Confirm Restore
</button>
<button type="button" className="button-dark text-sm" onClick={() => setRestoreFlowStep(1)}>
Back
</button>
</div>
</form>
) : null}
</div>
</section>
);
}
@@ -0,0 +1,188 @@
import { useState } from 'react';
import NicknameForm from '../NicknameForm.jsx';
import { flowWrapClass, innerFlowClass, fieldClass } from './constants.js';
export default function VipVerificationCard({
pendingRequestId,
currentStoredKey,
nickname,
requestVerification,
applyIdentityKey,
onMessage,
}) {
const [requestFlowStep, setRequestFlowStep] = useState(0);
const [requestKeyInput, setRequestKeyInput] = useState('');
const [confirmNickname, setConfirmNickname] = useState(false);
const [working, setWorking] = useState(false);
const beginRequestFlow = () => {
setRequestFlowStep(1);
setRequestKeyInput(currentStoredKey);
setConfirmNickname(false);
onMessage?.('');
};
const cancelRequestFlow = () => {
setRequestFlowStep(0);
setConfirmNickname(false);
};
const handleRequestSubmit = async (event) => {
event.preventDefault();
if (!confirmNickname) {
onMessage?.('Please confirm your nickname agreement before sending.');
return;
}
if (requestFlowStep < 3) {
onMessage?.('Complete all request steps before sending.');
return;
}
setWorking(true);
onMessage?.('');
try {
const applied = await applyIdentityKey(requestKeyInput);
await requestVerification?.();
setRequestKeyInput(applied);
setRequestFlowStep(0);
setConfirmNickname(false);
onMessage?.('Verification request sent to lockdown admins.');
} catch (err) {
onMessage?.(err.message || 'Failed to submit request.');
} finally {
setWorking(false);
}
};
if (pendingRequestId) {
return (
<section className={`surface text-sm text-slate-300 ${flowWrapClass}`}>
<div className={innerFlowClass}>Verification request pending: {pendingRequestId}</div>
</section>
);
}
if (requestFlowStep === 0) {
return (
<section className={`surface ${flowWrapClass}`}>
<div className={innerFlowClass}>
<p className="text-sm text-slate-300">Verification</p>
<button type="button" className="button-dark text-sm" onClick={beginRequestFlow} disabled={working}>
Request Verification
</button>
</div>
</section>
);
}
return (
<form className={`surface ${flowWrapClass}`} onSubmit={handleRequestSubmit}>
<div className={innerFlowClass}>
<p className="text-sm text-slate-300">Request verification</p>
<p className="text-xs text-slate-500">
Step {requestFlowStep} of 3
</p>
{requestFlowStep === 1 ? (
<div className="space-y-0.5">
<p className="text-xs text-slate-400">
Confirm your nickname, which is used as part of the verification process. You can edit it here before requesting.
</p>
<div className="mx-auto w-full max-w-sm">
<NicknameForm compact />
</div>
<div className="surface-muted mx-auto w-full max-w-sm text-xs text-slate-300 text-center">
Current nickname: <span className="font-semibold">{nickname || '(not set)'}</span>
</div>
<label className="flex items-center justify-center gap-0.5 text-xs text-slate-300 text-center">
<input
type="checkbox"
className="accent-emerald-500"
checked={confirmNickname}
onChange={(event) => setConfirmNickname(event.target.checked)}
/>
<span>I understand this nickname is tied to my verification.</span>
</label>
<div className="flex justify-center gap-0.5">
<button
type="button"
className="button-dark text-sm"
disabled={!nickname || !confirmNickname}
onClick={() => setRequestFlowStep(2)}
>
Continue
</button>
<button type="button" className="button-dark text-sm" onClick={cancelRequestFlow}>
Cancel
</button>
</div>
</div>
) : null}
{requestFlowStep === 2 ? (
<div className="space-y-0.5">
<p className="text-xs text-slate-400">
Save your identity key in a safe place. You can use it to restore your identity in another browser.
</p>
<input
className={fieldClass}
type="password"
name="identity_key_request"
autoComplete="current-password"
maxLength={35}
value={requestKeyInput}
onChange={(event) => setRequestKeyInput(event.target.value.toLowerCase())}
placeholder="Identity key for this request"
/>
<div className="flex justify-center gap-0.5">
<button
type="button"
className="button-dark text-sm"
disabled={!requestKeyInput}
onClick={() => navigator.clipboard?.writeText(requestKeyInput)}
>
Copy Key
</button>
<button
type="button"
className="button-dark text-sm"
disabled={!requestKeyInput}
onClick={() => setRequestFlowStep(3)}
>
Continue
</button>
<button type="button" className="button-dark text-sm" onClick={() => setRequestFlowStep(1)}>
Back
</button>
</div>
</div>
) : null}
{requestFlowStep === 3 ? (
<div className="space-y-0.5">
<p className="text-xs text-slate-400">
Final step: confirm and send your verification request.
</p>
<input
className={fieldClass}
type="password"
name="identity_key_request_final"
autoComplete="current-password"
maxLength={35}
value={requestKeyInput}
onChange={(event) => setRequestKeyInput(event.target.value.toLowerCase())}
placeholder="Identity key for this request"
/>
<div className="flex justify-center gap-0.5">
<button type="submit" className="button-dark text-sm" disabled={working || !requestKeyInput}>
Confirm Request
</button>
<button type="button" className="button-dark text-sm" onClick={() => setRequestFlowStep(2)}>
Back
</button>
</div>
</div>
) : null}
</div>
</form>
);
}
+23
View File
@@ -0,0 +1,23 @@
export const COOKIE_KEY_REGEX = /^cu_[a-f0-9]{32}$/;
export const MAX_UPLOAD_BYTES = 8 * 1024 * 1024;
export const fieldClass = 'field-input w-full max-w-sm text-left focus:ring-emerald-500';
export const flowWrapClass = 'mx-auto w-full max-w-xl flex justify-center';
export const innerFlowClass = 'mx-auto flex w-full max-w-md flex-col items-center space-y-0.5 text-center';
export function maskKey(value) {
const key = String(value || '').trim();
if (!key) return '';
if (key.length <= 10) return `${key.slice(0, 2)}***${key.slice(-2)}`;
return `${key.slice(0, 6)}...${key.slice(-6)}`;
}
export function bytesToBase64(bytes) {
let binary = '';
const chunkSize = 0x8000;
for (let i = 0; i < bytes.length; i += chunkSize) {
const chunk = bytes.subarray(i, i + chunkSize);
binary += String.fromCharCode(...chunk);
}
return btoa(binary);
}
+5 -4
View File
@@ -26,8 +26,8 @@ const SessionContext = createContext({
setAdminReason: async () => {}, setAdminReason: async () => {},
rebootRover: async () => {}, rebootRover: async () => {},
rebootServer: async () => {}, rebootServer: async () => {},
playTestAudio: async () => {}, playUploadedAudio: async () => {},
stopTestAudio: async () => {}, stopUploadedAudio: async () => {},
setAudioLevels: async () => {}, setAudioLevels: async () => {},
llmControl: async () => {}, llmControl: async () => {},
}); });
@@ -144,8 +144,9 @@ export function SessionProvider({ children }) {
rebootRover: (roverId) => rebootRover: (roverId) =>
emitWithAck('command', { roverId, type: 'reboot', data: { reboot: {} } }), emitWithAck('command', { roverId, type: 'reboot', data: { reboot: {} } }),
rebootServer: () => emitWithAck('server:reboot'), rebootServer: () => emitWithAck('server:reboot'),
playTestAudio: (roverId) => emitWithAck('audio:testPlay', { roverId }), playUploadedAudio: ({ roverId, name, mime, dataBase64 }) =>
stopTestAudio: (roverId) => emitWithAck('audio:testStop', { roverId }), emitWithAck('audio:uploadPlay', { roverId, name, mime, dataBase64 }),
stopUploadedAudio: (roverId) => emitWithAck('audio:uploadStop', { roverId }),
setAudioLevels: (levels = {}) => emitWithAck('audioLevels:set', levels), setAudioLevels: (levels = {}) => emitWithAck('audioLevels:set', levels),
llmControl: (action, controls = {}) => llmControl: (action, controls = {}) =>
emitWithAck('llm:control', { controls: { action, ...controls } }), emitWithAck('llm:control', { controls: { action, ...controls } }),