This commit is contained in:
parent
3d757e00f2
commit
6a65e43ca9
10 changed files with 353 additions and 104 deletions
|
|
@ -8,6 +8,7 @@ use mtp::type_map::TypeMap;
|
|||
use std::future::Future;
|
||||
use std::path::Path;
|
||||
use std::pin::Pin;
|
||||
use std::sync::Arc;
|
||||
|
||||
fn dev_cert_paths() -> (String, String) {
|
||||
let cert = std::env::var("MTP_DEV_CERT").unwrap_or_else(|_| {
|
||||
|
|
@ -41,13 +42,6 @@ async fn handle_pipe_loopback(
|
|||
let mut reader = req.accept().await?;
|
||||
println!(" [loopback] Pipe {pipe_id} accepted, reading data ...");
|
||||
|
||||
let mut buf = Vec::new();
|
||||
tokio::io::AsyncReadExt::read_to_end(&mut reader, &mut buf).await?;
|
||||
println!(
|
||||
" [loopback] Pipe {pipe_id} read {} bytes, creating return pipe ...",
|
||||
buf.len()
|
||||
);
|
||||
|
||||
let handle = conn.create_pipe("loopback").await?;
|
||||
println!(
|
||||
" [loopback] Return pipe created (id={}), waiting for client ...",
|
||||
|
|
@ -56,16 +50,19 @@ async fn handle_pipe_loopback(
|
|||
|
||||
match handle.wait().await? {
|
||||
Some(mut writer) => {
|
||||
println!(
|
||||
" [loopback] Client accepted return pipe, writing {} bytes ...",
|
||||
buf.len()
|
||||
);
|
||||
tokio::io::AsyncWriteExt::write_all(&mut writer, &buf).await?;
|
||||
println!(" [loopback] Client accepted return pipe, echoing incoming bytes ...");
|
||||
let mut total = 0usize;
|
||||
let mut buf = [0u8; 16 * 1024];
|
||||
loop {
|
||||
let n = tokio::io::AsyncReadExt::read(&mut reader, &mut buf).await?;
|
||||
if n == 0 {
|
||||
break;
|
||||
}
|
||||
total += n;
|
||||
tokio::io::AsyncWriteExt::write_all(&mut writer, &buf[..n]).await?;
|
||||
}
|
||||
writer.finish().await?;
|
||||
println!(
|
||||
" [loopback] Pipe {pipe_id} loopback complete ({} bytes)",
|
||||
buf.len()
|
||||
);
|
||||
println!(" [loopback] Pipe {pipe_id} loopback complete ({} bytes)", total);
|
||||
}
|
||||
None => {
|
||||
eprintln!(" [loopback] Return pipe denied by client for pipe {pipe_id}");
|
||||
|
|
@ -86,8 +83,10 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||
let (_host_id, host_keyring) = keys::load_or_generate_host_keys("host.mk")?;
|
||||
keys::export_host_public_keys(&host_keyring)?;
|
||||
|
||||
let decrypt_keyring = mtp::crypto::Keyring::from_bytes(&host_keyring.to_bytes())
|
||||
.expect("re-load host keyring for decryption");
|
||||
let decrypt_keyring = Arc::new(
|
||||
mtp::crypto::Keyring::from_bytes(&host_keyring.to_bytes())
|
||||
.expect("re-load host keyring for decryption"),
|
||||
);
|
||||
|
||||
let (clients, next_id) = clients::load_client_db("clients.json")?;
|
||||
|
||||
|
|
@ -152,61 +151,64 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||
println!("Server listening on {}", host.local_addr());
|
||||
|
||||
while let Some(conn) = host.accept().await? {
|
||||
let desc = conn.description.as_deref().unwrap_or("(no description)");
|
||||
println!(
|
||||
"\n--- New connection (version {}, description: {desc}) ---",
|
||||
conn.version
|
||||
);
|
||||
println!("Client ID: {}", conn.client_id);
|
||||
let decrypt_keyring = Arc::clone(&decrypt_keyring);
|
||||
tokio::spawn(async move {
|
||||
let desc = conn.description.as_deref().unwrap_or("(no description)");
|
||||
println!(
|
||||
"\n--- New connection (version {}, description: {desc}) ---",
|
||||
conn.version
|
||||
);
|
||||
println!("Client ID: {}", conn.client_id);
|
||||
|
||||
let tm: &TypeMap = conn.codec.registry().get(&conn.version).unwrap();
|
||||
let tm: &TypeMap = conn.codec.registry().get(&conn.version).unwrap();
|
||||
|
||||
println!("Waiting for messages / pipe requests ...");
|
||||
loop {
|
||||
tokio::select! {
|
||||
biased;
|
||||
println!("Waiting for messages / pipe requests ...");
|
||||
loop {
|
||||
tokio::select! {
|
||||
biased;
|
||||
|
||||
pipe_req = conn.receive_pipe() => {
|
||||
match pipe_req {
|
||||
Ok(req) => {
|
||||
println!(" Pipe request: id={} desc={:?}", req.id(), req.description());
|
||||
if let Err(e) = handle_pipe_loopback(&conn, req).await {
|
||||
eprintln!(" Pipe loopback error: {e}");
|
||||
pipe_req = conn.receive_pipe() => {
|
||||
match pipe_req {
|
||||
Ok(req) => {
|
||||
println!(" Pipe request: id={} desc={:?}", req.id(), req.description());
|
||||
if let Err(e) = handle_pipe_loopback(&conn, req).await {
|
||||
eprintln!(" Pipe loopback error: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
println!("Pipe channel closed: {e}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
msg = conn.receive() => {
|
||||
match msg {
|
||||
Ok(msg) => {
|
||||
println!("Received: {msg}");
|
||||
let response = handlers::process_and_respond(
|
||||
&msg,
|
||||
tm,
|
||||
conn.client_public_key.as_ref(),
|
||||
&decrypt_keyring,
|
||||
);
|
||||
println!("Sending: {response}");
|
||||
if let Err(e) = conn.sender.send(&response).await {
|
||||
eprintln!("Send error: {e}");
|
||||
Err(e) => {
|
||||
println!("Pipe channel closed: {e}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
println!("Connection ended: {e}");
|
||||
break;
|
||||
}
|
||||
msg = conn.receive() => {
|
||||
match msg {
|
||||
Ok(msg) => {
|
||||
println!("Received: {msg}");
|
||||
let response = handlers::process_and_respond(
|
||||
&msg,
|
||||
tm,
|
||||
conn.client_public_key.as_ref(),
|
||||
&decrypt_keyring,
|
||||
);
|
||||
println!("Sending: {response}");
|
||||
if let Err(e) = conn.sender.send(&response).await {
|
||||
eprintln!("Send error: {e}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
println!("Connection ended: {e}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
conn.sender.close();
|
||||
println!("Connection closed\n");
|
||||
conn.sender.close();
|
||||
println!("Connection closed\n");
|
||||
});
|
||||
}
|
||||
|
||||
Ok(())
|
||||
|
|
|
|||
|
|
@ -51,7 +51,7 @@
|
|||
<body>
|
||||
<h1>MTP WebTransport Client</h1>
|
||||
<label for="server-url">Server URL</label>
|
||||
<input id="server-url" value="https://localhost:8080" />
|
||||
<input id="server-url" value="https://127.0.0.1:8080" />
|
||||
|
||||
<label for="host-public-key">Host public key bundle hex</label>
|
||||
<textarea
|
||||
|
|
@ -82,6 +82,11 @@
|
|||
</div>
|
||||
<div id="pipe-status"></div>
|
||||
|
||||
<hr />
|
||||
<h2>Metrics</h2>
|
||||
<div id="metrics"></div>
|
||||
|
||||
<hr />
|
||||
<div id="key-status">Initializing...</div>
|
||||
<div id="status"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import type {
|
|||
MTPCredentialStorage,
|
||||
MTPLogEvent,
|
||||
MTPPipeReader,
|
||||
MTPPipeWriter,
|
||||
ParsedFrame,
|
||||
} from "mtp";
|
||||
|
||||
|
|
@ -23,6 +24,7 @@ const CLEAR_KEYS = document.getElementById("clear-keys") as HTMLButtonElement;
|
|||
const STREAM_MIC = document.getElementById("stream-mic") as HTMLButtonElement;
|
||||
const STOP_MIC = document.getElementById("stop-mic") as HTMLButtonElement;
|
||||
const PIPE_STATUS = document.getElementById("pipe-status")!;
|
||||
const METRICS = document.getElementById("metrics")!;
|
||||
|
||||
const CREDENTIALS_KEY = "mtp-web-client-credentials";
|
||||
const HOST_PUBLIC_KEY_KEY = "mtp-web-client-host-public-key";
|
||||
|
|
@ -41,8 +43,18 @@ let activeClient: ReturnType<typeof createClient> extends Promise<infer T>
|
|||
: never;
|
||||
let micStream: MediaStream | null = null;
|
||||
let mediaRecorder: MediaRecorder | null = null;
|
||||
let activePipeWriter: MTPPipeWriter | null = null;
|
||||
let pipeSendCount = 0;
|
||||
let pendingPipeReaders: MTPPipeReader[] = [];
|
||||
let audioContext: AudioContext | null = null;
|
||||
let micMonitorAudio: HTMLAudioElement | null = null;
|
||||
let currentPipePingMs: number | null = null;
|
||||
let lastPipeSendStartedAt = 0;
|
||||
let currentPipeId: number | null = null;
|
||||
let currentPipeDescription = "";
|
||||
let currentPipeState = "idle";
|
||||
let loopbackPlaybackCount = 0;
|
||||
let hasPipeRequestHandler = false;
|
||||
|
||||
const credentialStorage: MTPCredentialStorage = {
|
||||
getItem: (key) => localStorage.getItem(key),
|
||||
|
|
@ -61,7 +73,126 @@ function pipeLog(msg: string, cls = "pipe") {
|
|||
const line = document.createElement("div");
|
||||
line.textContent = msg;
|
||||
if (cls) line.className = cls;
|
||||
PIPE_STATUS.appendChild(line);
|
||||
PIPE_STATUS.prepend(line);
|
||||
}
|
||||
|
||||
function setMetric(name: string, value: string) {
|
||||
const row = document.querySelector(`[data-metric="${name}"]`);
|
||||
if (row) {
|
||||
row.querySelector<HTMLElement>(".metric-value")!.textContent = value;
|
||||
return;
|
||||
}
|
||||
|
||||
const wrapper = document.createElement("div");
|
||||
wrapper.dataset.metric = name;
|
||||
wrapper.innerHTML = `<span class="metric-name"></span>: <span class="metric-value"></span>`;
|
||||
wrapper.querySelector<HTMLElement>(".metric-name")!.textContent = name;
|
||||
wrapper.querySelector<HTMLElement>(".metric-value")!.textContent = value;
|
||||
METRICS.appendChild(wrapper);
|
||||
}
|
||||
|
||||
function updateMetrics() {
|
||||
setMetric(
|
||||
"Current Pipe",
|
||||
currentPipeId == null ? "none" : String(currentPipeId),
|
||||
);
|
||||
setMetric("Pipe State", currentPipeState);
|
||||
setMetric("Pipe Description", currentPipeDescription || "n/a");
|
||||
setMetric(
|
||||
"Current Pipe Ping",
|
||||
currentPipePingMs == null ? "n/a" : `${currentPipePingMs.toFixed(1)} ms`,
|
||||
);
|
||||
setMetric("Loopback Playback", String(loopbackPlaybackCount));
|
||||
setMetric("Sent Chunks", String(pipeSendCount));
|
||||
}
|
||||
|
||||
function setPipeState(
|
||||
state: string,
|
||||
details: Partial<{
|
||||
pipeId: number | null;
|
||||
description: string;
|
||||
pingMs: number | null;
|
||||
}>,
|
||||
) {
|
||||
if ("pipeId" in details) currentPipeId = details.pipeId ?? null;
|
||||
if ("description" in details)
|
||||
currentPipeDescription = details.description ?? "";
|
||||
if ("pingMs" in details)
|
||||
currentPipePingMs = details.pingMs ?? currentPipePingMs;
|
||||
currentPipeState = state;
|
||||
updateMetrics();
|
||||
}
|
||||
|
||||
function getPipeId(handle: unknown): number | null {
|
||||
if (handle && typeof handle === "object") {
|
||||
const candidate = handle as Record<string, unknown>;
|
||||
const value =
|
||||
candidate.pipeId ??
|
||||
candidate.pipe_id ??
|
||||
candidate["pipe-id"] ??
|
||||
candidate.id;
|
||||
if (typeof value === "number" && Number.isFinite(value)) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function ensureAudioContext() {
|
||||
if (!audioContext) {
|
||||
audioContext = new AudioContext();
|
||||
}
|
||||
return audioContext;
|
||||
}
|
||||
|
||||
async function playLoopbackAudio(chunks: BlobPart[], mimeType: string) {
|
||||
if (chunks.length === 0) return;
|
||||
const blob = new Blob(chunks, { type: mimeType });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const audio = new Audio(url);
|
||||
audio.autoplay = true;
|
||||
audio.onended = () => URL.revokeObjectURL(url);
|
||||
audio.onerror = () => URL.revokeObjectURL(url);
|
||||
try {
|
||||
await ensureAudioContext().resume();
|
||||
await audio.play();
|
||||
loopbackPlaybackCount += 1;
|
||||
updateMetrics();
|
||||
pipeLog(`Loopback playback started (${chunks.length} chunks).`);
|
||||
} catch (e) {
|
||||
URL.revokeObjectURL(url);
|
||||
pipeLog(`Loopback playback failed: ${e}`, "error");
|
||||
}
|
||||
}
|
||||
|
||||
async function startMicPlayback(stream: MediaStream) {
|
||||
if (micMonitorAudio) {
|
||||
micMonitorAudio.pause();
|
||||
micMonitorAudio.srcObject = null;
|
||||
micMonitorAudio = null;
|
||||
}
|
||||
|
||||
const audio = new Audio();
|
||||
audio.autoplay = true;
|
||||
audio.controls = false;
|
||||
audio.muted = false;
|
||||
audio.srcObject = stream;
|
||||
micMonitorAudio = audio;
|
||||
|
||||
try {
|
||||
await ensureAudioContext().resume();
|
||||
await audio.play();
|
||||
pipeLog("Microphone monitoring playback started.");
|
||||
} catch (e) {
|
||||
pipeLog(`Microphone monitoring playback failed: ${e}`, "error");
|
||||
}
|
||||
}
|
||||
|
||||
function stopMicPlayback() {
|
||||
if (!micMonitorAudio) return;
|
||||
micMonitorAudio.pause();
|
||||
micMonitorAudio.srcObject = null;
|
||||
micMonitorAudio = null;
|
||||
}
|
||||
|
||||
function renderStructured(value: unknown): string {
|
||||
|
|
@ -297,6 +428,7 @@ async function connect() {
|
|||
log("\nClient running. Waiting for incoming messages...");
|
||||
STREAM_MIC.disabled = false;
|
||||
log("\nPipe demo ready. Click 'Stream Microphone' to start.", "pipe");
|
||||
updateMetrics();
|
||||
} catch (error) {
|
||||
log(`[error] ${error}`, "error");
|
||||
}
|
||||
|
|
@ -317,35 +449,52 @@ async function startMicStreaming() {
|
|||
|
||||
STREAM_MIC.disabled = true;
|
||||
STOP_MIC.disabled = false;
|
||||
setPipeState("creating", { pipeId: null, description: "mic-audio" });
|
||||
pipeLog("Microphone acquired. Creating pipe ...");
|
||||
await startMicPlayback(micStream);
|
||||
|
||||
if (!hasPipeRequestHandler) {
|
||||
activeClient.setOnPipeRequest(async (request) => {
|
||||
pipeLog(
|
||||
`Incoming return pipe: id=${request.pipeId} desc=${request.description}`,
|
||||
);
|
||||
try {
|
||||
const reader = await activeClient!.acceptPipe(request.pipeId);
|
||||
pendingPipeReaders.push(reader);
|
||||
pipeLog(
|
||||
`Pipe accepted. Streaming return pipe (pipe id=${getPipeId(reader) ?? "unknown"}) ...`,
|
||||
);
|
||||
readLoopbackPipe(reader);
|
||||
} catch (e) {
|
||||
pipeLog(`Failed to accept return pipe: ${e}`, "error");
|
||||
}
|
||||
});
|
||||
hasPipeRequestHandler = true;
|
||||
}
|
||||
|
||||
const handle = await activeClient.createPipe("mic-audio");
|
||||
const pipeId = getPipeId(handle);
|
||||
setPipeState("waiting-for-accept", { pipeId, description: "mic-audio" });
|
||||
pipeLog(
|
||||
`Pipe created (id=${handle.pipeId}). Waiting for server to accept ...`,
|
||||
`Pipe created (id=${pipeId ?? "unknown"}). Waiting for server to accept ...`,
|
||||
);
|
||||
|
||||
// Handle incoming pipe requests from the server (loopback return pipes)
|
||||
activeClient.setOnPipeRequest(async (request) => {
|
||||
pipeLog(
|
||||
`Incoming return pipe: id=${request.pipeId} desc=${request.description}`,
|
||||
);
|
||||
try {
|
||||
const reader = await activeClient!.acceptPipe(request.pipeId);
|
||||
pendingPipeReaders.push(reader);
|
||||
readLoopbackPipe(reader);
|
||||
} catch (e) {
|
||||
pipeLog(`Failed to accept return pipe: ${e}`, "error");
|
||||
}
|
||||
});
|
||||
|
||||
const writer = await handle.wait();
|
||||
if (!writer) {
|
||||
setPipeState("denied", { pipeId, description: "mic-audio" });
|
||||
pipeLog("Pipe denied by server.", "error");
|
||||
stopMicStreaming();
|
||||
return;
|
||||
}
|
||||
activePipeWriter = writer;
|
||||
|
||||
pipeLog(`Pipe accepted. Streaming microphone (pipe id=${writer.pipeId}) ...`);
|
||||
setPipeState("streaming", {
|
||||
pipeId: getPipeId(writer) ?? pipeId,
|
||||
description: "mic-audio",
|
||||
});
|
||||
pipeLog(
|
||||
`Pipe accepted. Streaming microphone (pipe id=${getPipeId(writer) ?? pipeId ?? "unknown"}) ...`,
|
||||
);
|
||||
|
||||
// Stream microphone audio via MediaRecorder
|
||||
const mimeType = MediaRecorder.isTypeSupported("audio/webm;codecs=opus")
|
||||
|
|
@ -361,18 +510,19 @@ async function startMicStreaming() {
|
|||
const chunkNum = pipeSendCount;
|
||||
|
||||
try {
|
||||
lastPipeSendStartedAt = performance.now();
|
||||
const buffer = await event.data.arrayBuffer();
|
||||
const data = new Uint8Array(buffer);
|
||||
await writer.write(data);
|
||||
pipeLog(
|
||||
` [chunk ${chunkNum}] sent ${data.length} bytes (${(performance.now() - startTime).toFixed(1)}ms write)`,
|
||||
);
|
||||
currentPipePingMs = performance.now() - lastPipeSendStartedAt;
|
||||
updateMetrics();
|
||||
} catch (e) {
|
||||
pipeLog(` [chunk ${chunkNum}] send error: ${e}`, "error");
|
||||
}
|
||||
};
|
||||
|
||||
mediaRecorder.start(200); // emit data every 200ms
|
||||
updateMetrics();
|
||||
pipeLog("Streaming started (200ms chunks).");
|
||||
}
|
||||
|
||||
|
|
@ -380,6 +530,10 @@ async function readLoopbackPipe(reader: MTPPipeReader) {
|
|||
const startTime = performance.now();
|
||||
let totalBytes = 0;
|
||||
let chunkCount = 0;
|
||||
const chunks: BlobPart[] = [];
|
||||
const mimeType = MediaRecorder.isTypeSupported("audio/webm;codecs=opus")
|
||||
? "audio/webm;codecs=opus"
|
||||
: "audio/webm";
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
|
|
@ -387,6 +541,7 @@ async function readLoopbackPipe(reader: MTPPipeReader) {
|
|||
if (data == null) break; // EOF
|
||||
totalBytes += data.length;
|
||||
chunkCount++;
|
||||
chunks.push(data.slice().buffer);
|
||||
}
|
||||
} catch (e) {
|
||||
pipeLog(` Return pipe read error: ${e}`, "error");
|
||||
|
|
@ -398,6 +553,8 @@ async function readLoopbackPipe(reader: MTPPipeReader) {
|
|||
` Return pipe complete: ${chunkCount} chunks, ${totalBytes} bytes, ` +
|
||||
`delay=${elapsed.toFixed(1)}ms`,
|
||||
);
|
||||
setPipeState("loopback-ready", { pingMs: elapsed });
|
||||
await playLoopbackAudio(chunks, mimeType);
|
||||
|
||||
// Clean up the reader from the pending list
|
||||
const idx = pendingPipeReaders.indexOf(reader);
|
||||
|
|
@ -410,13 +567,28 @@ async function stopMicStreaming() {
|
|||
mediaRecorder = null;
|
||||
}
|
||||
|
||||
if (activePipeWriter) {
|
||||
try {
|
||||
await activePipeWriter.close();
|
||||
} catch (e) {
|
||||
pipeLog(`Pipe close error: ${e}`, "error");
|
||||
} finally {
|
||||
activePipeWriter = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (micStream) {
|
||||
micStream.getTracks().forEach((track) => track.stop());
|
||||
micStream = null;
|
||||
}
|
||||
stopMicPlayback();
|
||||
|
||||
// Close pending pipe readers
|
||||
pendingPipeReaders = [];
|
||||
setPipeState("stopped", {
|
||||
pipeId: currentPipeId,
|
||||
description: currentPipeDescription,
|
||||
});
|
||||
|
||||
STREAM_MIC.disabled = false;
|
||||
STOP_MIC.disabled = true;
|
||||
|
|
@ -480,3 +652,5 @@ initWasm()
|
|||
log(`Fatal error: ${e}`, "error");
|
||||
console.error(e);
|
||||
});
|
||||
|
||||
updateMetrics();
|
||||
|
|
|
|||
Loading…
Reference in a new issue