fix(webui): restore webrtc diagnostics page build
- commit
- f20ef211e0de86178d00a5c2f79e6757dc20cc16
- author
- brice · · 2026-02-08 12:50 UTC
- parent
- c75e65d
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
3 files · +175 -1
M webui/package.json +3 -1
| @@ -4,14 +4,16 @@ | |||
| 4 | 4 | "private": true, | |
| 5 | 5 | "type": "module", | |
| 6 | 6 | "scripts": { | |
| 7 | - | "build": "VITE_PAGE=homepage vite build && VITE_PAGE=error vite build && VITE_PAGE=config vite build", | |
| 7 | + | "build": "VITE_PAGE=homepage vite build && VITE_PAGE=error vite build && VITE_PAGE=config vite build && VITE_PAGE=webrtc vite build", | |
| 8 | 8 | "build:homepage": "VITE_PAGE=homepage vite build", | |
| 9 | 9 | "build:error": "VITE_PAGE=error vite build", | |
| 10 | 10 | "build:config": "VITE_PAGE=config vite build", | |
| 11 | + | "build:webrtc": "VITE_PAGE=webrtc vite build", | |
| 11 | 12 | "dev": "VITE_PAGE=homepage vite build --watch", | |
| 12 | 13 | "dev:homepage": "VITE_PAGE=homepage vite build --watch", | |
| 13 | 14 | "dev:error": "VITE_PAGE=error vite build --watch", | |
| 14 | 15 | "dev:config": "VITE_PAGE=config vite build --watch", | |
| 16 | + | "dev:webrtc": "VITE_PAGE=webrtc vite build --watch", | |
| 15 | 17 | "check": "svelte-check", | |
| 16 | 18 | "lint": "eslint src --fix", | |
| 17 | 19 | "fmt": "prettier --write ." | |
| @@ -4,14 +4,16 @@ | |||||
| 4 | "private": true, | 4 | "private": true, | ||
| 5 | "type": "module", | 5 | "type": "module", | ||
| 6 | "scripts": { | 6 | "scripts": { | ||
| 7 | - | "build": "VITE_PAGE=homepage vite build && VITE_PAGE=error vite build && VITE_PAGE=config vite build", | 7 | + | "build": "VITE_PAGE=homepage vite build && VITE_PAGE=error vite build && VITE_PAGE=config vite build && VITE_PAGE=webrtc vite build", |
| 8 | "build:homepage": "VITE_PAGE=homepage vite build", | 8 | "build:homepage": "VITE_PAGE=homepage vite build", | ||
| 9 | "build:error": "VITE_PAGE=error vite build", | 9 | "build:error": "VITE_PAGE=error vite build", | ||
| 10 | "build:config": "VITE_PAGE=config vite build", | 10 | "build:config": "VITE_PAGE=config vite build", | ||
| 11 | + | "build:webrtc": "VITE_PAGE=webrtc vite build", | |||
| 11 | "dev": "VITE_PAGE=homepage vite build --watch", | 12 | "dev": "VITE_PAGE=homepage vite build --watch", | ||
| 12 | "dev:homepage": "VITE_PAGE=homepage vite build --watch", | 13 | "dev:homepage": "VITE_PAGE=homepage vite build --watch", | ||
| 13 | "dev:error": "VITE_PAGE=error vite build --watch", | 14 | "dev:error": "VITE_PAGE=error vite build --watch", | ||
| 14 | "dev:config": "VITE_PAGE=config vite build --watch", | 15 | "dev:config": "VITE_PAGE=config vite build --watch", | ||
| 16 | + | "dev:webrtc": "VITE_PAGE=webrtc vite build --watch", | |||
| 15 | "check": "svelte-check", | 17 | "check": "svelte-check", | ||
| 16 | "lint": "eslint src --fix", | 18 | "lint": "eslint src --fix", | ||
| 17 | "fmt": "prettier --write ." | 19 | "fmt": "prettier --write ." | ||
A webui/src/pages/webrtc.ts +160 -0
| @@ -0,0 +1,160 @@ | |||
| 1 | + | import "../styles/app.css"; | |
| 2 | + | ||
| 3 | + | const activeStreams: MediaStream[] = []; | |
| 4 | + | ||
| 5 | + | function now(): string { | |
| 6 | + | return new Date().toLocaleTimeString(); | |
| 7 | + | } | |
| 8 | + | ||
| 9 | + | function appendLog(output: HTMLPreElement, message: string): void { | |
| 10 | + | output.textContent = `${output.textContent}[${now()}] ${message}\n`; | |
| 11 | + | output.scrollTop = output.scrollHeight; | |
| 12 | + | } | |
| 13 | + | ||
| 14 | + | function setStatus(status: HTMLElement, value: string): void { | |
| 15 | + | status.textContent = value; | |
| 16 | + | } | |
| 17 | + | ||
| 18 | + | function stopAllStreams(video: HTMLVideoElement, output: HTMLPreElement, status: HTMLElement): void { | |
| 19 | + | for (const stream of activeStreams.splice(0)) { | |
| 20 | + | for (const track of stream.getTracks()) { | |
| 21 | + | track.stop(); | |
| 22 | + | } | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | video.pause(); | |
| 26 | + | video.srcObject = null; | |
| 27 | + | setStatus(status, "idle"); | |
| 28 | + | appendLog(output, "Stopped all active tracks"); | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | async function probeDevices(output: HTMLPreElement): Promise<void> { | |
| 32 | + | if (!("mediaDevices" in navigator) || typeof navigator.mediaDevices.enumerateDevices !== "function") { | |
| 33 | + | appendLog(output, "enumerateDevices is unavailable"); | |
| 34 | + | return; | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | const devices = await navigator.mediaDevices.enumerateDevices(); | |
| 38 | + | const summary = devices | |
| 39 | + | .map((device) => `${device.kind} ${device.label || "(label hidden until permission)"}`) | |
| 40 | + | .join(" | "); | |
| 41 | + | ||
| 42 | + | appendLog(output, `Devices: ${summary || "none"}`); | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | async function requestUserMedia(video: HTMLVideoElement, output: HTMLPreElement, status: HTMLElement): Promise<void> { | |
| 46 | + | if (!("mediaDevices" in navigator) || typeof navigator.mediaDevices.getUserMedia !== "function") { | |
| 47 | + | appendLog(output, "getUserMedia is unavailable"); | |
| 48 | + | return; | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | try { | |
| 52 | + | setStatus(status, "requesting camera + microphone"); | |
| 53 | + | const stream = await navigator.mediaDevices.getUserMedia({ audio: true, video: true }); | |
| 54 | + | activeStreams.push(stream); | |
| 55 | + | video.srcObject = stream; | |
| 56 | + | await video.play(); | |
| 57 | + | setStatus(status, "camera + microphone granted"); | |
| 58 | + | appendLog(output, "camera + microphone permission granted"); | |
| 59 | + | await probeDevices(output); | |
| 60 | + | } catch (err) { | |
| 61 | + | setStatus(status, "camera + microphone blocked"); | |
| 62 | + | appendLog(output, `camera + microphone request failed: ${String(err)}`); | |
| 63 | + | } | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | async function requestDisplayMedia(video: HTMLVideoElement, output: HTMLPreElement, status: HTMLElement): Promise<void> { | |
| 67 | + | if (!("mediaDevices" in navigator) || typeof navigator.mediaDevices.getDisplayMedia !== "function") { | |
| 68 | + | appendLog(output, "getDisplayMedia is unavailable"); | |
| 69 | + | return; | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | try { | |
| 73 | + | setStatus(status, "requesting screen capture"); | |
| 74 | + | const stream = await navigator.mediaDevices.getDisplayMedia({ video: true, audio: true }); | |
| 75 | + | activeStreams.push(stream); | |
| 76 | + | video.srcObject = stream; | |
| 77 | + | await video.play(); | |
| 78 | + | setStatus(status, "screen capture granted"); | |
| 79 | + | appendLog(output, "screen capture permission granted"); | |
| 80 | + | } catch (err) { | |
| 81 | + | setStatus(status, "screen capture blocked"); | |
| 82 | + | appendLog(output, `screen capture request failed: ${String(err)}`); | |
| 83 | + | } | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | function mount(): void { | |
| 87 | + | document.title = "WebRTC Diagnostics"; | |
| 88 | + | document.body.innerHTML = ` | |
| 89 | + | <main class="page-shell" style="padding: 24px; max-width: 920px; margin: 0 auto; display: grid; gap: 16px;"> | |
| 90 | + | <header style="display: grid; gap: 8px;"> | |
| 91 | + | <h1 style="margin: 0; font-size: 1.8rem;">WebRTC Diagnostics</h1> | |
| 92 | + | <p style="margin: 0; opacity: 0.85;">Use this page to validate camera, microphone, and screen permission behavior.</p> | |
| 93 | + | </header> | |
| 94 | + | ||
| 95 | + | <section style="display: flex; flex-wrap: wrap; gap: 10px;"> | |
| 96 | + | <button id="btn-av" type="button">Request camera + microphone</button> | |
| 97 | + | <button id="btn-screen" type="button">Request screen capture</button> | |
| 98 | + | <button id="btn-stop" type="button">Stop all streams</button> | |
| 99 | + | </section> | |
| 100 | + | ||
| 101 | + | <section style="display: grid; gap: 8px;"> | |
| 102 | + | <strong>Status: <span id="status">idle</span></strong> | |
| 103 | + | <video id="preview" autoplay playsinline muted style="width: 100%; max-height: 320px; border-radius: 10px; background: #0c1522;"></video> | |
| 104 | + | </section> | |
| 105 | + | ||
| 106 | + | <section style="display: grid; gap: 8px;"> | |
| 107 | + | <strong>Event Log</strong> | |
| 108 | + | <pre id="output" style="margin: 0; padding: 12px; border-radius: 10px; background: #0d1727; color: #dbe8f4; min-height: 180px; max-height: 280px; overflow: auto;">[${now()}] Ready\n</pre> | |
| 109 | + | </section> | |
| 110 | + | </main> | |
| 111 | + | `; | |
| 112 | + | ||
| 113 | + | const status = document.getElementById("status"); | |
| 114 | + | const preview = document.getElementById("preview"); | |
| 115 | + | const output = document.getElementById("output"); | |
| 116 | + | const btnAV = document.getElementById("btn-av"); | |
| 117 | + | const btnScreen = document.getElementById("btn-screen"); | |
| 118 | + | const btnStop = document.getElementById("btn-stop"); | |
| 119 | + | ||
| 120 | + | if (!(status instanceof HTMLElement)) { | |
| 121 | + | return; | |
| 122 | + | } | |
| 123 | + | if (!(preview instanceof HTMLVideoElement)) { | |
| 124 | + | return; | |
| 125 | + | } | |
| 126 | + | if (!(output instanceof HTMLPreElement)) { | |
| 127 | + | return; | |
| 128 | + | } | |
| 129 | + | if (!(btnAV instanceof HTMLButtonElement)) { | |
| 130 | + | return; | |
| 131 | + | } | |
| 132 | + | if (!(btnScreen instanceof HTMLButtonElement)) { | |
| 133 | + | return; | |
| 134 | + | } | |
| 135 | + | if (!(btnStop instanceof HTMLButtonElement)) { | |
| 136 | + | return; | |
| 137 | + | } | |
| 138 | + | ||
| 139 | + | btnAV.addEventListener("click", () => { | |
| 140 | + | requestUserMedia(preview, output, status).catch((err: unknown) => { | |
| 141 | + | appendLog(output, `unexpected user media error: ${String(err)}`); | |
| 142 | + | }); | |
| 143 | + | }); | |
| 144 | + | ||
| 145 | + | btnScreen.addEventListener("click", () => { | |
| 146 | + | requestDisplayMedia(preview, output, status).catch((err: unknown) => { | |
| 147 | + | appendLog(output, `unexpected display media error: ${String(err)}`); | |
| 148 | + | }); | |
| 149 | + | }); | |
| 150 | + | ||
| 151 | + | btnStop.addEventListener("click", () => { | |
| 152 | + | stopAllStreams(preview, output, status); | |
| 153 | + | }); | |
| 154 | + | ||
| 155 | + | window.addEventListener("beforeunload", () => { | |
| 156 | + | stopAllStreams(preview, output, status); | |
| 157 | + | }); | |
| 158 | + | } | |
| 159 | + | ||
| 160 | + | mount(); | |
| @@ -0,0 +1,160 @@ | |||||
| 1 | + | import "../styles/app.css"; | |||
| 2 | + | ||||
| 3 | + | const activeStreams: MediaStream[] = []; | |||
| 4 | + | ||||
| 5 | + | function now(): string { | |||
| 6 | + | return new Date().toLocaleTimeString(); | |||
| 7 | + | } | |||
| 8 | + | ||||
| 9 | + | function appendLog(output: HTMLPreElement, message: string): void { | |||
| 10 | + | output.textContent = `${output.textContent}[${now()}] ${message}\n`; | |||
| 11 | + | output.scrollTop = output.scrollHeight; | |||
| 12 | + | } | |||
| 13 | + | ||||
| 14 | + | function setStatus(status: HTMLElement, value: string): void { | |||
| 15 | + | status.textContent = value; | |||
| 16 | + | } | |||
| 17 | + | ||||
| 18 | + | function stopAllStreams(video: HTMLVideoElement, output: HTMLPreElement, status: HTMLElement): void { | |||
| 19 | + | for (const stream of activeStreams.splice(0)) { | |||
| 20 | + | for (const track of stream.getTracks()) { | |||
| 21 | + | track.stop(); | |||
| 22 | + | } | |||
| 23 | + | } | |||
| 24 | + | ||||
| 25 | + | video.pause(); | |||
| 26 | + | video.srcObject = null; | |||
| 27 | + | setStatus(status, "idle"); | |||
| 28 | + | appendLog(output, "Stopped all active tracks"); | |||
| 29 | + | } | |||
| 30 | + | ||||
| 31 | + | async function probeDevices(output: HTMLPreElement): Promise<void> { | |||
| 32 | + | if (!("mediaDevices" in navigator) || typeof navigator.mediaDevices.enumerateDevices !== "function") { | |||
| 33 | + | appendLog(output, "enumerateDevices is unavailable"); | |||
| 34 | + | return; | |||
| 35 | + | } | |||
| 36 | + | ||||
| 37 | + | const devices = await navigator.mediaDevices.enumerateDevices(); | |||
| 38 | + | const summary = devices | |||
| 39 | + | .map((device) => `${device.kind} ${device.label || "(label hidden until permission)"}`) | |||
| 40 | + | .join(" | "); | |||
| 41 | + | ||||
| 42 | + | appendLog(output, `Devices: ${summary || "none"}`); | |||
| 43 | + | } | |||
| 44 | + | ||||
| 45 | + | async function requestUserMedia(video: HTMLVideoElement, output: HTMLPreElement, status: HTMLElement): Promise<void> { | |||
| 46 | + | if (!("mediaDevices" in navigator) || typeof navigator.mediaDevices.getUserMedia !== "function") { | |||
| 47 | + | appendLog(output, "getUserMedia is unavailable"); | |||
| 48 | + | return; | |||
| 49 | + | } | |||
| 50 | + | ||||
| 51 | + | try { | |||
| 52 | + | setStatus(status, "requesting camera + microphone"); | |||
| 53 | + | const stream = await navigator.mediaDevices.getUserMedia({ audio: true, video: true }); | |||
| 54 | + | activeStreams.push(stream); | |||
| 55 | + | video.srcObject = stream; | |||
| 56 | + | await video.play(); | |||
| 57 | + | setStatus(status, "camera + microphone granted"); | |||
| 58 | + | appendLog(output, "camera + microphone permission granted"); | |||
| 59 | + | await probeDevices(output); | |||
| 60 | + | } catch (err) { | |||
| 61 | + | setStatus(status, "camera + microphone blocked"); | |||
| 62 | + | appendLog(output, `camera + microphone request failed: ${String(err)}`); | |||
| 63 | + | } | |||
| 64 | + | } | |||
| 65 | + | ||||
| 66 | + | async function requestDisplayMedia(video: HTMLVideoElement, output: HTMLPreElement, status: HTMLElement): Promise<void> { | |||
| 67 | + | if (!("mediaDevices" in navigator) || typeof navigator.mediaDevices.getDisplayMedia !== "function") { | |||
| 68 | + | appendLog(output, "getDisplayMedia is unavailable"); | |||
| 69 | + | return; | |||
| 70 | + | } | |||
| 71 | + | ||||
| 72 | + | try { | |||
| 73 | + | setStatus(status, "requesting screen capture"); | |||
| 74 | + | const stream = await navigator.mediaDevices.getDisplayMedia({ video: true, audio: true }); | |||
| 75 | + | activeStreams.push(stream); | |||
| 76 | + | video.srcObject = stream; | |||
| 77 | + | await video.play(); | |||
| 78 | + | setStatus(status, "screen capture granted"); | |||
| 79 | + | appendLog(output, "screen capture permission granted"); | |||
| 80 | + | } catch (err) { | |||
| 81 | + | setStatus(status, "screen capture blocked"); | |||
| 82 | + | appendLog(output, `screen capture request failed: ${String(err)}`); | |||
| 83 | + | } | |||
| 84 | + | } | |||
| 85 | + | ||||
| 86 | + | function mount(): void { | |||
| 87 | + | document.title = "WebRTC Diagnostics"; | |||
| 88 | + | document.body.innerHTML = ` | |||
| 89 | + | <main class="page-shell" style="padding: 24px; max-width: 920px; margin: 0 auto; display: grid; gap: 16px;"> | |||
| 90 | + | <header style="display: grid; gap: 8px;"> | |||
| 91 | + | <h1 style="margin: 0; font-size: 1.8rem;">WebRTC Diagnostics</h1> | |||
| 92 | + | <p style="margin: 0; opacity: 0.85;">Use this page to validate camera, microphone, and screen permission behavior.</p> | |||
| 93 | + | </header> | |||
| 94 | + | ||||
| 95 | + | <section style="display: flex; flex-wrap: wrap; gap: 10px;"> | |||
| 96 | + | <button id="btn-av" type="button">Request camera + microphone</button> | |||
| 97 | + | <button id="btn-screen" type="button">Request screen capture</button> | |||
| 98 | + | <button id="btn-stop" type="button">Stop all streams</button> | |||
| 99 | + | </section> | |||
| 100 | + | ||||
| 101 | + | <section style="display: grid; gap: 8px;"> | |||
| 102 | + | <strong>Status: <span id="status">idle</span></strong> | |||
| 103 | + | <video id="preview" autoplay playsinline muted style="width: 100%; max-height: 320px; border-radius: 10px; background: #0c1522;"></video> | |||
| 104 | + | </section> | |||
| 105 | + | ||||
| 106 | + | <section style="display: grid; gap: 8px;"> | |||
| 107 | + | <strong>Event Log</strong> | |||
| 108 | + | <pre id="output" style="margin: 0; padding: 12px; border-radius: 10px; background: #0d1727; color: #dbe8f4; min-height: 180px; max-height: 280px; overflow: auto;">[${now()}] Ready\n</pre> | |||
| 109 | + | </section> | |||
| 110 | + | </main> | |||
| 111 | + | `; | |||
| 112 | + | ||||
| 113 | + | const status = document.getElementById("status"); | |||
| 114 | + | const preview = document.getElementById("preview"); | |||
| 115 | + | const output = document.getElementById("output"); | |||
| 116 | + | const btnAV = document.getElementById("btn-av"); | |||
| 117 | + | const btnScreen = document.getElementById("btn-screen"); | |||
| 118 | + | const btnStop = document.getElementById("btn-stop"); | |||
| 119 | + | ||||
| 120 | + | if (!(status instanceof HTMLElement)) { | |||
| 121 | + | return; | |||
| 122 | + | } | |||
| 123 | + | if (!(preview instanceof HTMLVideoElement)) { | |||
| 124 | + | return; | |||
| 125 | + | } | |||
| 126 | + | if (!(output instanceof HTMLPreElement)) { | |||
| 127 | + | return; | |||
| 128 | + | } | |||
| 129 | + | if (!(btnAV instanceof HTMLButtonElement)) { | |||
| 130 | + | return; | |||
| 131 | + | } | |||
| 132 | + | if (!(btnScreen instanceof HTMLButtonElement)) { | |||
| 133 | + | return; | |||
| 134 | + | } | |||
| 135 | + | if (!(btnStop instanceof HTMLButtonElement)) { | |||
| 136 | + | return; | |||
| 137 | + | } | |||
| 138 | + | ||||
| 139 | + | btnAV.addEventListener("click", () => { | |||
| 140 | + | requestUserMedia(preview, output, status).catch((err: unknown) => { | |||
| 141 | + | appendLog(output, `unexpected user media error: ${String(err)}`); | |||
| 142 | + | }); | |||
| 143 | + | }); | |||
| 144 | + | ||||
| 145 | + | btnScreen.addEventListener("click", () => { | |||
| 146 | + | requestDisplayMedia(preview, output, status).catch((err: unknown) => { | |||
| 147 | + | appendLog(output, `unexpected display media error: ${String(err)}`); | |||
| 148 | + | }); | |||
| 149 | + | }); | |||
| 150 | + | ||||
| 151 | + | btnStop.addEventListener("click", () => { | |||
| 152 | + | stopAllStreams(preview, output, status); | |||
| 153 | + | }); | |||
| 154 | + | ||||
| 155 | + | window.addEventListener("beforeunload", () => { | |||
| 156 | + | stopAllStreams(preview, output, status); | |||
| 157 | + | }); | |||
| 158 | + | } | |||
| 159 | + | ||||
| 160 | + | mount(); | |||
M webui/vite.config.ts +12 -0
| @@ -42,6 +42,18 @@ const pages = { | |||
| 42 | 42 | filename: "config.html", | |
| 43 | 43 | }, | |
| 44 | 44 | }, | |
| 45 | + | webrtc: { | |
| 46 | + | entry: "src/pages/webrtc.ts", | |
| 47 | + | output: "webrtc.min.js", | |
| 48 | + | global: "DumberWebRTC", | |
| 49 | + | html: { | |
| 50 | + | name: "webrtc", | |
| 51 | + | title: "WebRTC Diagnostics", | |
| 52 | + | script: "webrtc.min.js", | |
| 53 | + | css: "style.css", | |
| 54 | + | filename: "webrtc.html", | |
| 55 | + | }, | |
| 56 | + | }, | |
| 45 | 57 | } as const; | |
| 46 | 58 | ||
| 47 | 59 | // Get page from VITE_PAGE env var, default to building all | |
| @@ -42,6 +42,18 @@ const pages = { | |||||
| 42 | filename: "config.html", | 42 | filename: "config.html", | ||
| 43 | }, | 43 | }, | ||
| 44 | }, | 44 | }, | ||
| 45 | + | webrtc: { | |||
| 46 | + | entry: "src/pages/webrtc.ts", | |||
| 47 | + | output: "webrtc.min.js", | |||
| 48 | + | global: "DumberWebRTC", | |||
| 49 | + | html: { | |||
| 50 | + | name: "webrtc", | |||
| 51 | + | title: "WebRTC Diagnostics", | |||
| 52 | + | script: "webrtc.min.js", | |||
| 53 | + | css: "style.css", | |||
| 54 | + | filename: "webrtc.html", | |||
| 55 | + | }, | |||
| 56 | + | }, | |||
| 45 | } as const; | 57 | } as const; | ||
| 46 | 58 | ||||
| 47 | // Get page from VITE_PAGE env var, default to building all | 59 | // Get page from VITE_PAGE env var, default to building all | ||