This commit is contained in:
parent
3d757e00f2
commit
6a65e43ca9
10 changed files with 353 additions and 104 deletions
15
Cargo.lock
generated
15
Cargo.lock
generated
|
|
@ -1105,6 +1105,7 @@ dependencies = [
|
||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
"wasm-bindgen-futures",
|
"wasm-bindgen-futures",
|
||||||
"wasm-bindgen-test",
|
"wasm-bindgen-test",
|
||||||
|
"web-sys",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -1837,9 +1838,9 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "syn"
|
name = "syn"
|
||||||
version = "2.0.118"
|
version = "2.0.119"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422"
|
checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
|
|
@ -2185,6 +2186,16 @@ version = "0.2.126"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c31d56021e873866c968588ed85ccdf56db5c426e44afdb4618c39895104b920"
|
checksum = "c31d56021e873866c968588ed85ccdf56db5c426e44afdb4618c39895104b920"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "web-sys"
|
||||||
|
version = "0.3.103"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141"
|
||||||
|
dependencies = [
|
||||||
|
"js-sys",
|
||||||
|
"wasm-bindgen",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "web-time"
|
name = "web-time"
|
||||||
version = "1.1.0"
|
version = "1.1.0"
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ use mtp::type_map::TypeMap;
|
||||||
use std::future::Future;
|
use std::future::Future;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use std::pin::Pin;
|
use std::pin::Pin;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
fn dev_cert_paths() -> (String, String) {
|
fn dev_cert_paths() -> (String, String) {
|
||||||
let cert = std::env::var("MTP_DEV_CERT").unwrap_or_else(|_| {
|
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?;
|
let mut reader = req.accept().await?;
|
||||||
println!(" [loopback] Pipe {pipe_id} accepted, reading data ...");
|
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?;
|
let handle = conn.create_pipe("loopback").await?;
|
||||||
println!(
|
println!(
|
||||||
" [loopback] Return pipe created (id={}), waiting for client ...",
|
" [loopback] Return pipe created (id={}), waiting for client ...",
|
||||||
|
|
@ -56,16 +50,19 @@ async fn handle_pipe_loopback(
|
||||||
|
|
||||||
match handle.wait().await? {
|
match handle.wait().await? {
|
||||||
Some(mut writer) => {
|
Some(mut writer) => {
|
||||||
println!(
|
println!(" [loopback] Client accepted return pipe, echoing incoming bytes ...");
|
||||||
" [loopback] Client accepted return pipe, writing {} bytes ...",
|
let mut total = 0usize;
|
||||||
buf.len()
|
let mut buf = [0u8; 16 * 1024];
|
||||||
);
|
loop {
|
||||||
tokio::io::AsyncWriteExt::write_all(&mut writer, &buf).await?;
|
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?;
|
writer.finish().await?;
|
||||||
println!(
|
println!(" [loopback] Pipe {pipe_id} loopback complete ({} bytes)", total);
|
||||||
" [loopback] Pipe {pipe_id} loopback complete ({} bytes)",
|
|
||||||
buf.len()
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
None => {
|
None => {
|
||||||
eprintln!(" [loopback] Return pipe denied by client for pipe {pipe_id}");
|
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")?;
|
let (_host_id, host_keyring) = keys::load_or_generate_host_keys("host.mk")?;
|
||||||
keys::export_host_public_keys(&host_keyring)?;
|
keys::export_host_public_keys(&host_keyring)?;
|
||||||
|
|
||||||
let decrypt_keyring = mtp::crypto::Keyring::from_bytes(&host_keyring.to_bytes())
|
let decrypt_keyring = Arc::new(
|
||||||
.expect("re-load host keyring for decryption");
|
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")?;
|
let (clients, next_id) = clients::load_client_db("clients.json")?;
|
||||||
|
|
||||||
|
|
@ -152,6 +151,8 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
println!("Server listening on {}", host.local_addr());
|
println!("Server listening on {}", host.local_addr());
|
||||||
|
|
||||||
while let Some(conn) = host.accept().await? {
|
while let Some(conn) = host.accept().await? {
|
||||||
|
let decrypt_keyring = Arc::clone(&decrypt_keyring);
|
||||||
|
tokio::spawn(async move {
|
||||||
let desc = conn.description.as_deref().unwrap_or("(no description)");
|
let desc = conn.description.as_deref().unwrap_or("(no description)");
|
||||||
println!(
|
println!(
|
||||||
"\n--- New connection (version {}, description: {desc}) ---",
|
"\n--- New connection (version {}, description: {desc}) ---",
|
||||||
|
|
@ -207,6 +208,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
|
||||||
conn.sender.close();
|
conn.sender.close();
|
||||||
println!("Connection closed\n");
|
println!("Connection closed\n");
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|
|
||||||
|
|
@ -51,7 +51,7 @@
|
||||||
<body>
|
<body>
|
||||||
<h1>MTP WebTransport Client</h1>
|
<h1>MTP WebTransport Client</h1>
|
||||||
<label for="server-url">Server URL</label>
|
<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>
|
<label for="host-public-key">Host public key bundle hex</label>
|
||||||
<textarea
|
<textarea
|
||||||
|
|
@ -82,6 +82,11 @@
|
||||||
</div>
|
</div>
|
||||||
<div id="pipe-status"></div>
|
<div id="pipe-status"></div>
|
||||||
|
|
||||||
|
<hr />
|
||||||
|
<h2>Metrics</h2>
|
||||||
|
<div id="metrics"></div>
|
||||||
|
|
||||||
|
<hr />
|
||||||
<div id="key-status">Initializing...</div>
|
<div id="key-status">Initializing...</div>
|
||||||
<div id="status"></div>
|
<div id="status"></div>
|
||||||
<script type="module" src="/src/main.ts"></script>
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import type {
|
||||||
MTPCredentialStorage,
|
MTPCredentialStorage,
|
||||||
MTPLogEvent,
|
MTPLogEvent,
|
||||||
MTPPipeReader,
|
MTPPipeReader,
|
||||||
|
MTPPipeWriter,
|
||||||
ParsedFrame,
|
ParsedFrame,
|
||||||
} from "mtp";
|
} 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 STREAM_MIC = document.getElementById("stream-mic") as HTMLButtonElement;
|
||||||
const STOP_MIC = document.getElementById("stop-mic") as HTMLButtonElement;
|
const STOP_MIC = document.getElementById("stop-mic") as HTMLButtonElement;
|
||||||
const PIPE_STATUS = document.getElementById("pipe-status")!;
|
const PIPE_STATUS = document.getElementById("pipe-status")!;
|
||||||
|
const METRICS = document.getElementById("metrics")!;
|
||||||
|
|
||||||
const CREDENTIALS_KEY = "mtp-web-client-credentials";
|
const CREDENTIALS_KEY = "mtp-web-client-credentials";
|
||||||
const HOST_PUBLIC_KEY_KEY = "mtp-web-client-host-public-key";
|
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;
|
: never;
|
||||||
let micStream: MediaStream | null = null;
|
let micStream: MediaStream | null = null;
|
||||||
let mediaRecorder: MediaRecorder | null = null;
|
let mediaRecorder: MediaRecorder | null = null;
|
||||||
|
let activePipeWriter: MTPPipeWriter | null = null;
|
||||||
let pipeSendCount = 0;
|
let pipeSendCount = 0;
|
||||||
let pendingPipeReaders: MTPPipeReader[] = [];
|
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 = {
|
const credentialStorage: MTPCredentialStorage = {
|
||||||
getItem: (key) => localStorage.getItem(key),
|
getItem: (key) => localStorage.getItem(key),
|
||||||
|
|
@ -61,7 +73,126 @@ function pipeLog(msg: string, cls = "pipe") {
|
||||||
const line = document.createElement("div");
|
const line = document.createElement("div");
|
||||||
line.textContent = msg;
|
line.textContent = msg;
|
||||||
if (cls) line.className = cls;
|
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 {
|
function renderStructured(value: unknown): string {
|
||||||
|
|
@ -297,6 +428,7 @@ async function connect() {
|
||||||
log("\nClient running. Waiting for incoming messages...");
|
log("\nClient running. Waiting for incoming messages...");
|
||||||
STREAM_MIC.disabled = false;
|
STREAM_MIC.disabled = false;
|
||||||
log("\nPipe demo ready. Click 'Stream Microphone' to start.", "pipe");
|
log("\nPipe demo ready. Click 'Stream Microphone' to start.", "pipe");
|
||||||
|
updateMetrics();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log(`[error] ${error}`, "error");
|
log(`[error] ${error}`, "error");
|
||||||
}
|
}
|
||||||
|
|
@ -317,14 +449,11 @@ async function startMicStreaming() {
|
||||||
|
|
||||||
STREAM_MIC.disabled = true;
|
STREAM_MIC.disabled = true;
|
||||||
STOP_MIC.disabled = false;
|
STOP_MIC.disabled = false;
|
||||||
|
setPipeState("creating", { pipeId: null, description: "mic-audio" });
|
||||||
pipeLog("Microphone acquired. Creating pipe ...");
|
pipeLog("Microphone acquired. Creating pipe ...");
|
||||||
|
await startMicPlayback(micStream);
|
||||||
|
|
||||||
const handle = await activeClient.createPipe("mic-audio");
|
if (!hasPipeRequestHandler) {
|
||||||
pipeLog(
|
|
||||||
`Pipe created (id=${handle.pipeId}). Waiting for server to accept ...`,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Handle incoming pipe requests from the server (loopback return pipes)
|
|
||||||
activeClient.setOnPipeRequest(async (request) => {
|
activeClient.setOnPipeRequest(async (request) => {
|
||||||
pipeLog(
|
pipeLog(
|
||||||
`Incoming return pipe: id=${request.pipeId} desc=${request.description}`,
|
`Incoming return pipe: id=${request.pipeId} desc=${request.description}`,
|
||||||
|
|
@ -332,20 +461,40 @@ async function startMicStreaming() {
|
||||||
try {
|
try {
|
||||||
const reader = await activeClient!.acceptPipe(request.pipeId);
|
const reader = await activeClient!.acceptPipe(request.pipeId);
|
||||||
pendingPipeReaders.push(reader);
|
pendingPipeReaders.push(reader);
|
||||||
|
pipeLog(
|
||||||
|
`Pipe accepted. Streaming return pipe (pipe id=${getPipeId(reader) ?? "unknown"}) ...`,
|
||||||
|
);
|
||||||
readLoopbackPipe(reader);
|
readLoopbackPipe(reader);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
pipeLog(`Failed to accept return pipe: ${e}`, "error");
|
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=${pipeId ?? "unknown"}). Waiting for server to accept ...`,
|
||||||
|
);
|
||||||
|
|
||||||
const writer = await handle.wait();
|
const writer = await handle.wait();
|
||||||
if (!writer) {
|
if (!writer) {
|
||||||
|
setPipeState("denied", { pipeId, description: "mic-audio" });
|
||||||
pipeLog("Pipe denied by server.", "error");
|
pipeLog("Pipe denied by server.", "error");
|
||||||
stopMicStreaming();
|
stopMicStreaming();
|
||||||
return;
|
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
|
// Stream microphone audio via MediaRecorder
|
||||||
const mimeType = MediaRecorder.isTypeSupported("audio/webm;codecs=opus")
|
const mimeType = MediaRecorder.isTypeSupported("audio/webm;codecs=opus")
|
||||||
|
|
@ -361,18 +510,19 @@ async function startMicStreaming() {
|
||||||
const chunkNum = pipeSendCount;
|
const chunkNum = pipeSendCount;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
lastPipeSendStartedAt = performance.now();
|
||||||
const buffer = await event.data.arrayBuffer();
|
const buffer = await event.data.arrayBuffer();
|
||||||
const data = new Uint8Array(buffer);
|
const data = new Uint8Array(buffer);
|
||||||
await writer.write(data);
|
await writer.write(data);
|
||||||
pipeLog(
|
currentPipePingMs = performance.now() - lastPipeSendStartedAt;
|
||||||
` [chunk ${chunkNum}] sent ${data.length} bytes (${(performance.now() - startTime).toFixed(1)}ms write)`,
|
updateMetrics();
|
||||||
);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
pipeLog(` [chunk ${chunkNum}] send error: ${e}`, "error");
|
pipeLog(` [chunk ${chunkNum}] send error: ${e}`, "error");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
mediaRecorder.start(200); // emit data every 200ms
|
mediaRecorder.start(200); // emit data every 200ms
|
||||||
|
updateMetrics();
|
||||||
pipeLog("Streaming started (200ms chunks).");
|
pipeLog("Streaming started (200ms chunks).");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -380,6 +530,10 @@ async function readLoopbackPipe(reader: MTPPipeReader) {
|
||||||
const startTime = performance.now();
|
const startTime = performance.now();
|
||||||
let totalBytes = 0;
|
let totalBytes = 0;
|
||||||
let chunkCount = 0;
|
let chunkCount = 0;
|
||||||
|
const chunks: BlobPart[] = [];
|
||||||
|
const mimeType = MediaRecorder.isTypeSupported("audio/webm;codecs=opus")
|
||||||
|
? "audio/webm;codecs=opus"
|
||||||
|
: "audio/webm";
|
||||||
|
|
||||||
try {
|
try {
|
||||||
while (true) {
|
while (true) {
|
||||||
|
|
@ -387,6 +541,7 @@ async function readLoopbackPipe(reader: MTPPipeReader) {
|
||||||
if (data == null) break; // EOF
|
if (data == null) break; // EOF
|
||||||
totalBytes += data.length;
|
totalBytes += data.length;
|
||||||
chunkCount++;
|
chunkCount++;
|
||||||
|
chunks.push(data.slice().buffer);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
pipeLog(` Return pipe read error: ${e}`, "error");
|
pipeLog(` Return pipe read error: ${e}`, "error");
|
||||||
|
|
@ -398,6 +553,8 @@ async function readLoopbackPipe(reader: MTPPipeReader) {
|
||||||
` Return pipe complete: ${chunkCount} chunks, ${totalBytes} bytes, ` +
|
` Return pipe complete: ${chunkCount} chunks, ${totalBytes} bytes, ` +
|
||||||
`delay=${elapsed.toFixed(1)}ms`,
|
`delay=${elapsed.toFixed(1)}ms`,
|
||||||
);
|
);
|
||||||
|
setPipeState("loopback-ready", { pingMs: elapsed });
|
||||||
|
await playLoopbackAudio(chunks, mimeType);
|
||||||
|
|
||||||
// Clean up the reader from the pending list
|
// Clean up the reader from the pending list
|
||||||
const idx = pendingPipeReaders.indexOf(reader);
|
const idx = pendingPipeReaders.indexOf(reader);
|
||||||
|
|
@ -410,13 +567,28 @@ async function stopMicStreaming() {
|
||||||
mediaRecorder = null;
|
mediaRecorder = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (activePipeWriter) {
|
||||||
|
try {
|
||||||
|
await activePipeWriter.close();
|
||||||
|
} catch (e) {
|
||||||
|
pipeLog(`Pipe close error: ${e}`, "error");
|
||||||
|
} finally {
|
||||||
|
activePipeWriter = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (micStream) {
|
if (micStream) {
|
||||||
micStream.getTracks().forEach((track) => track.stop());
|
micStream.getTracks().forEach((track) => track.stop());
|
||||||
micStream = null;
|
micStream = null;
|
||||||
}
|
}
|
||||||
|
stopMicPlayback();
|
||||||
|
|
||||||
// Close pending pipe readers
|
// Close pending pipe readers
|
||||||
pendingPipeReaders = [];
|
pendingPipeReaders = [];
|
||||||
|
setPipeState("stopped", {
|
||||||
|
pipeId: currentPipeId,
|
||||||
|
description: currentPipeDescription,
|
||||||
|
});
|
||||||
|
|
||||||
STREAM_MIC.disabled = false;
|
STREAM_MIC.disabled = false;
|
||||||
STOP_MIC.disabled = true;
|
STOP_MIC.disabled = true;
|
||||||
|
|
@ -480,3 +652,5 @@ initWasm()
|
||||||
log(`Fatal error: ${e}`, "error");
|
log(`Fatal error: ${e}`, "error");
|
||||||
console.error(e);
|
console.error(e);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
updateMetrics();
|
||||||
|
|
|
||||||
|
|
@ -169,9 +169,18 @@ async fn run_dispatcher(
|
||||||
loop {
|
loop {
|
||||||
match receiver.receive_event().await {
|
match receiver.receive_event().await {
|
||||||
Ok(mtp_transport::TransportEvent::Message(msg)) => {
|
Ok(mtp_transport::TransportEvent::Message(msg)) => {
|
||||||
|
println!(
|
||||||
|
"Dispatcher message: type={} id={}",
|
||||||
|
msg.get_type(),
|
||||||
|
msg.get_id()
|
||||||
|
);
|
||||||
if msg.get_type() == pipe_req_type {
|
if msg.get_type() == pipe_req_type {
|
||||||
let pipe_id = msg.get_id();
|
let pipe_id = msg.get_id();
|
||||||
let description = msg.get_str(DataType::Description).unwrap_or("").to_string();
|
let description = msg.get_str(DataType::Description).unwrap_or("").to_string();
|
||||||
|
println!(
|
||||||
|
"Dispatcher classified PipeRequest: id={} description={:?}",
|
||||||
|
pipe_id, description
|
||||||
|
);
|
||||||
let req = PipeRequest {
|
let req = PipeRequest {
|
||||||
pipe_id,
|
pipe_id,
|
||||||
description,
|
description,
|
||||||
|
|
@ -185,6 +194,10 @@ async fn run_dispatcher(
|
||||||
if msg.get_type() == pipe_resp_type {
|
if msg.get_type() == pipe_resp_type {
|
||||||
let pipe_id = msg.get_id();
|
let pipe_id = msg.get_id();
|
||||||
let accepted = msg.get_bool(DataType::Accepted).unwrap_or(false);
|
let accepted = msg.get_bool(DataType::Accepted).unwrap_or(false);
|
||||||
|
println!(
|
||||||
|
"Dispatcher classified PipeResponse: id={} accepted={}",
|
||||||
|
pipe_id, accepted
|
||||||
|
);
|
||||||
let mut pending = dispatcher.pending_creations.lock().await;
|
let mut pending = dispatcher.pending_creations.lock().await;
|
||||||
if let Some(tx) = pending.remove(&pipe_id) {
|
if let Some(tx) = pending.remove(&pipe_id) {
|
||||||
let _ = tx.send(Ok(accepted));
|
let _ = tx.send(Ok(accepted));
|
||||||
|
|
@ -198,10 +211,28 @@ async fn run_dispatcher(
|
||||||
}
|
}
|
||||||
Ok(mtp_transport::TransportEvent::Pipe(reader)) => {
|
Ok(mtp_transport::TransportEvent::Pipe(reader)) => {
|
||||||
let pipe_id = reader.pipe_id();
|
let pipe_id = reader.pipe_id();
|
||||||
|
println!(
|
||||||
|
"Dispatcher pipe stream: id={} description={:?}",
|
||||||
|
pipe_id,
|
||||||
|
reader.description()
|
||||||
|
);
|
||||||
let mut pending = dispatcher.pending_pipes.lock().await;
|
let mut pending = dispatcher.pending_pipes.lock().await;
|
||||||
if let Some(tx) = pending.remove(&pipe_id) {
|
if let Some(tx) = pending.remove(&pipe_id) {
|
||||||
let _ = tx.send(reader);
|
let _ = tx.send(reader);
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The WebTransport client opens the pipe request on a fresh stream.
|
||||||
|
// That stream arrives here as a pipe event, not a message event, so we
|
||||||
|
// reconstruct the host-side PipeRequest here and hand it to receive_pipe().
|
||||||
|
println!("Dispatcher treating pipe stream as PipeRequest: id={}", pipe_id);
|
||||||
|
let req = PipeRequest {
|
||||||
|
pipe_id,
|
||||||
|
description: reader.description().to_string(),
|
||||||
|
sender: sender.clone(),
|
||||||
|
dispatcher: dispatcher.clone(),
|
||||||
|
};
|
||||||
|
let _ = pipe_req_tx.send(req).await;
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
if app_tx.send(Err(e)).await.is_err() {
|
if app_tx.send(Err(e)).await.is_err() {
|
||||||
|
|
|
||||||
|
|
@ -101,8 +101,7 @@ pub async fn host(
|
||||||
};
|
};
|
||||||
|
|
||||||
let incoming_tx = incoming_tx.clone();
|
let incoming_tx = incoming_tx.clone();
|
||||||
let policy = policy.clone();
|
tokio::spawn(handle_connection(connection, incoming_tx, policy.clone()));
|
||||||
tokio::spawn(handle_connection(connection, incoming_tx, policy));
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -228,7 +228,7 @@ async fn test_receiver_backpressure_with_small_queue() {
|
||||||
let mut h = start_test_host(cert_pem.clone(), key_pem).await;
|
let mut h = start_test_host(cert_pem.clone(), key_pem).await;
|
||||||
let url = format!("https://127.0.0.1:{}", h.local_addr().port());
|
let url = format!("https://127.0.0.1:{}", h.local_addr().port());
|
||||||
let policy = Policy::default().with_receiver_queue_capacity(1);
|
let policy = Policy::default().with_receiver_queue_capacity(1);
|
||||||
let (client_tx, client_rx) = connect(&url, Some(cert_pem), policy.clone()).await.unwrap();
|
let (client_tx, client_rx) = connect(&url, Some(cert_pem), policy).await.unwrap();
|
||||||
let (_host_tx, host_rx) = h.next().await.unwrap();
|
let (_host_tx, host_rx) = h.next().await.unwrap();
|
||||||
|
|
||||||
let tm = TypeMap::latest();
|
let tm = TypeMap::latest();
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ crate-type = ["cdylib"]
|
||||||
wasm-bindgen = "0.2"
|
wasm-bindgen = "0.2"
|
||||||
wasm-bindgen-futures = "0.4"
|
wasm-bindgen-futures = "0.4"
|
||||||
js-sys = "0.3"
|
js-sys = "0.3"
|
||||||
|
web-sys = { version = "0.3", features = ["console"] }
|
||||||
futures-channel = "0.3"
|
futures-channel = "0.3"
|
||||||
console_error_panic_hook = "0.1"
|
console_error_panic_hook = "0.1"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -797,7 +797,18 @@ impl WasmClient {
|
||||||
let request_bytes = request
|
let request_bytes = request
|
||||||
.to_bytes()
|
.to_bytes()
|
||||||
.map_err(|e| js_error(&format!("encode failed: {}", e)))?;
|
.map_err(|e| js_error(&format!("encode failed: {}", e)))?;
|
||||||
|
web_sys::console::log_1(
|
||||||
|
&format!(
|
||||||
|
"[mtp wasm] create_pipe sending PipeRequest id={} description={description} bytes={}",
|
||||||
|
pipe_id,
|
||||||
|
request_bytes.len()
|
||||||
|
)
|
||||||
|
.into(),
|
||||||
|
);
|
||||||
transport.send_frame(&request_bytes).await?;
|
transport.send_frame(&request_bytes).await?;
|
||||||
|
web_sys::console::log_1(
|
||||||
|
&format!("[mtp wasm] create_pipe sent PipeRequest id={pipe_id}").into(),
|
||||||
|
);
|
||||||
|
|
||||||
Ok(WasmPipeHandle {
|
Ok(WasmPipeHandle {
|
||||||
pipe_id,
|
pipe_id,
|
||||||
|
|
@ -824,7 +835,13 @@ impl WasmClient {
|
||||||
let resp_bytes = resp
|
let resp_bytes = resp
|
||||||
.to_bytes()
|
.to_bytes()
|
||||||
.map_err(|e| js_error(&format!("encode failed: {}", e)))?;
|
.map_err(|e| js_error(&format!("encode failed: {}", e)))?;
|
||||||
|
web_sys::console::log_1(
|
||||||
|
&format!("[mtp wasm] accept_pipe sending PipeResponse id={pipe_id} accepted=true bytes={}", resp_bytes.len()).into(),
|
||||||
|
);
|
||||||
transport.send_frame(&resp_bytes).await?;
|
transport.send_frame(&resp_bytes).await?;
|
||||||
|
web_sys::console::log_1(
|
||||||
|
&format!("[mtp wasm] accept_pipe sent PipeResponse id={pipe_id}").into(),
|
||||||
|
);
|
||||||
|
|
||||||
let (tx, rx) = oneshot::channel();
|
let (tx, rx) = oneshot::channel();
|
||||||
self.pending_pipes.borrow_mut().insert(pipe_id, tx);
|
self.pending_pipes.borrow_mut().insert(pipe_id, tx);
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,12 @@ pub(crate) fn log_stream_error_code(error: &JsValue, context: &str) {
|
||||||
let stream_error_code = js_sys::Reflect::get(error, &JsValue::from_str("streamErrorCode"))
|
let stream_error_code = js_sys::Reflect::get(error, &JsValue::from_str("streamErrorCode"))
|
||||||
.ok()
|
.ok()
|
||||||
.and_then(|v| v.as_f64());
|
.and_then(|v| v.as_f64());
|
||||||
|
if matches!(stream_error_code, Some(0.0)) {
|
||||||
|
// WebTransport reports peer-driven stream shutdown as code 0 in this
|
||||||
|
// environment. For one-frame handshake streams, that is expected and
|
||||||
|
// should not be surfaced as a warning.
|
||||||
|
return;
|
||||||
|
}
|
||||||
let message = error
|
let message = error
|
||||||
.as_string()
|
.as_string()
|
||||||
.or_else(|| {
|
.or_else(|| {
|
||||||
|
|
@ -138,7 +144,11 @@ impl WasmTransport {
|
||||||
if let Some(hashes) = cert_hashes {
|
if let Some(hashes) = cert_hashes {
|
||||||
let wt_hashes = js_sys::Array::new();
|
let wt_hashes = js_sys::Array::new();
|
||||||
for h in hashes {
|
for h in hashes {
|
||||||
if let Some((algo, hex_val)) = h.split_once(':') {
|
let (algo, hex_val) = match h.split_once(':') {
|
||||||
|
Some((algo, hex_val)) => (algo, hex_val),
|
||||||
|
None => ("sha-256", h.as_str()),
|
||||||
|
};
|
||||||
|
|
||||||
if let Ok(bytes) = hex::decode(hex_val) {
|
if let Ok(bytes) = hex::decode(hex_val) {
|
||||||
let hash = js_sys::Object::new();
|
let hash = js_sys::Object::new();
|
||||||
js_sys::Reflect::set(
|
js_sys::Reflect::set(
|
||||||
|
|
@ -154,7 +164,6 @@ impl WasmTransport {
|
||||||
wt_hashes.push(&hash);
|
wt_hashes.push(&hash);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
if wt_hashes.length() > 0 {
|
if wt_hashes.length() > 0 {
|
||||||
let opts = js_sys::Object::new();
|
let opts = js_sys::Object::new();
|
||||||
js_sys::Reflect::set(
|
js_sys::Reflect::set(
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue