feat(webrtc): end-to-end encrypted voice & video calls with adaptive codecs
CodeQL Analysis / Analyze CodeQL (push) Canceled after 0s
Deploy Application / deploy (push) Canceled after 0s
Mirror to Codeberg / mirror (push) Canceled after 0s
Mirror to PrivacyGuides / mirror (push) Canceled after 0s

Add 1:1 voice and video calling over the existing SAS-verified peer
connection. Audio and video tracks ride the same RTCPeerConnection as the
chat, bundled onto one DTLS-SRTP transport, so media inherits the session's
end-to-end encryption. SDP offer/answer is renegotiated in-band over the
verified data channel — no signalling server, so the media's DTLS
fingerprints are authenticated end-to-end. Calls are gated on a connected,
SAS-verified session.

Codecs & adaptation:
- Opus tuned for lossy links (in-band FEC, DTX, RED redundancy); audio is
  bandwidth-prioritised and never throttled.
- VP9/AV1 single-encoding SVC with H.264/VP8 fallback; video degrades by
  spatial/temporal layer.
- Runtime NetworkAdaptationController trims video bitrate on loss/RTT and
  recovers as the link clears — no renegotiation. Live connection-quality
  indicator (Excellent/Good/Fair/Weak) in the call UI.

In-call controls: mute, camera on/off (voice→video upgrade in-band),
camera flip, minimize-to-widget, hang up, and accept/decline for incoming
calls. Production logging disabled (DEBUG_MODE=false); temporary call
diagnostic logger removed. Codec rationale in docs/webrtc-config.md.
This commit is contained in:
lockbitchat
2026-07-23 12:56:19 -04:00
parent 0de8ab2d54
commit b3fcf54670
32 changed files with 3855 additions and 86 deletions
+2 -2
View File
@@ -145,8 +145,8 @@
Header set X-Frame-Options "DENY" Header set X-Frame-Options "DENY"
# Force HTTPS (2 years + preload) to close the first-visit SSL-strip window. # Force HTTPS (2 years + preload) to close the first-visit SSL-strip window.
Header set Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" Header set Strict-Transport-Security "max-age=63072000; includeSubDomains; preload"
# Restrict powerful features; camera kept for in-page QR scanning. # Restrict powerful features; camera + microphone kept for QR scanning and calls.
Header set Permissions-Policy "camera=(self), microphone=(), geolocation=(), payment=(), usb=(), magnetometer=(), gyroscope=(), accelerometer=()" Header set Permissions-Policy "camera=(self), microphone=(self), geolocation=(), payment=(), usb=(), magnetometer=(), gyroscope=(), accelerometer=()"
</IfModule> </IfModule>
# Content Security Policy (frame-ancestors and report-uri only work in HTTP headers, not meta tags) # Content Security Policy (frame-ancestors and report-uri only work in HTTP headers, not meta tags)
+17
View File
@@ -1,5 +1,22 @@
# Changelog # Changelog
## v5.5.0 — Encrypted voice & video calls
SecureBit now supports **end-to-end encrypted voice and video calls** — the "5.5 Secure Voice & Calls" roadmap milestone.
### Added
- **Voice & video calls.** Start a call from the chat header (phone / camera buttons). Audio and video tracks ride the **same RTCPeerConnection as the chat**, bundled onto one **DTLS-SRTP** transport — so media is end-to-end encrypted with the very connection that in-person **SAS verification** authenticated. Call setup (SDP offer/answer) is renegotiated **in-band over the verified data channel**, never through a signalling server, so the media's DTLS fingerprints are authenticated end-to-end too. There is no server that can see or MITM a call.
- **Verification-gated.** Calls are only permitted once a session is **connected and SAS-verified**; the manager rejects call signalling otherwise. The header call buttons stay disabled until then.
- **In-call controls.** Mute / unmute, camera on/off (turning the camera on during a voice call upgrades it to video in-band), front/back camera flip, minimize-to-widget (chat stays usable) and hang up. Incoming calls show an accept / decline prompt.
- **Adaptive audio codec.** Opus tuned for real-world links — in-band FEC, DTX and RED redundancy (RED preferred first where the browser advertises it) keep speech intelligible under 1520% packet loss. Audio is bandwidth-prioritised and is **never** throttled by the network controller.
- **Adaptive video codec.** VP9 / AV1 single-encoding **SVC** (with H.264 / VP8 fallback), so video degrades gracefully by spatial/temporal layer on a weak link. A runtime `NetworkAdaptationController` reads `getStats()` every second and trims video bitrate on loss/RTT, recovering as the link clears — no renegotiation, no track restart.
- **Live connection-quality indicator.** Excellent → Good → Fair → Weak, surfaced in the voice overlay, video top bar and minimized widget. Codec tunables and their rationale are documented in [`docs/webrtc-config.md`](docs/webrtc-config.md).
### Security
- Media inherits the session's DTLS-SRTP encryption; SDP is exchanged only over the ECDH + SAS-authenticated data channel. No new ICE or signalling server is introduced — calls reuse the existing verified transport.
## v5.4.5 — Encrypted voice messages ## v5.4.5 — Encrypted voice messages
SecureBit now supports **end-to-end encrypted voice messages**, sent over the same secure transfer channel as files. SecureBit now supports **end-to-end encrypted voice messages**, sent over the same secure transfer channel as files.
+7 -1
View File
@@ -9,7 +9,7 @@
No accounts. No servers storing your messages. No installation required. No accounts. No servers storing your messages. No installation required.
[![License: MIT](https://img.shields.io/badge/License-MIT-f0892a.svg)](LICENSE) [![License: MIT](https://img.shields.io/badge/License-MIT-f0892a.svg)](LICENSE)
[![Version](https://img.shields.io/badge/version-5.4.5-3ecf8e.svg)](CHANGELOG.md) [![Version](https://img.shields.io/badge/version-5.5.0-3ecf8e.svg)](CHANGELOG.md)
[![PWA](https://img.shields.io/badge/PWA-installable-3ecf8e.svg)](#install-as-an-app) [![PWA](https://img.shields.io/badge/PWA-installable-3ecf8e.svg)](#install-as-an-app)
[![Encryption](https://img.shields.io/badge/crypto-ECDH%20P--384%20%C2%B7%20AES--256--GCM-blue.svg)](#security-model) [![Encryption](https://img.shields.io/badge/crypto-ECDH%20P--384%20%C2%B7%20AES--256--GCM-blue.svg)](#security-model)
@@ -42,6 +42,12 @@ It is designed for people who need a small, auditable, zero-infrastructure way t
- Optional **relay-only mode** routes traffic through your own TURN server so your IP is never exposed to the peer. - Optional **relay-only mode** routes traffic through your own TURN server so your IP is never exposed to the peer.
- Local key metadata is stored encrypted in IndexedDB; disconnecting cleans up session state. - Local key metadata is stored encrypted in IndexedDB; disconnecting cleans up session state.
** Encrypted calls**
- **1:1 voice and video calls** over the same verified peer-to-peer connection — media rides the SAS-verified DTLS-SRTP transport, so calls inherit the session's end-to-end encryption and never traverse a SecureBit server.
- **Adaptive audio**: Opus with in-band FEC, DTX and RED redundancy for intelligible speech under 1520% packet loss; audio is prioritised and never throttled by the network controller.
- **Adaptive video**: VP9/AV1 single-encoding SVC (H.264/VP8 fallback) that degrades by spatial/temporal layer, with a runtime controller that trims video bitrate on loss/RTT and recovers as the link clears.
- **Live connection-quality indicator** (Excellent → Good → Fair → Weak) shown in the call UI, plus in-call mute and video-upgrade controls.
** Messaging** ** Messaging**
- **Encrypted voice messages** — record in the browser and send over the same end-to-end encrypted transfer channel as files. Audio is captured as PCM/WAV, integrity-protected by a signed hash, and played back inline on the recipient's device without ever touching disk. - **Encrypted voice messages** — record in the browser and send over the same end-to-end encrypted transfer channel as files. Audio is captured as PCM/WAV, integrity-protected by a signed hash, and played back inline on the recipient's device without ever touching disk.
- Code blocks with syntax highlighting and an auto-clearing copy button. - Code blocks with syntax highlighting and an auto-clearing copy button.
+1 -1
View File
File diff suppressed because one or more lines are too long
+3 -3
View File
@@ -88,9 +88,9 @@ http {
# Force HTTPS for two years and preload, closing the first-visit SSL-strip # Force HTTPS for two years and preload, closing the first-visit SSL-strip
# window that upgrade-insecure-requests alone does not cover. # window that upgrade-insecure-requests alone does not cover.
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always; add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
# Lock down powerful features. Camera is allowed for in-page QR scanning; # Lock down powerful features. Camera + microphone are allowed for QR
# microphone/geolocation and other sensors are denied outright. # scanning and encrypted voice/video calls; other sensors are denied.
add_header Permissions-Policy "camera=(self), microphone=(), geolocation=(), payment=(), usb=(), magnetometer=(), gyroscope=(), accelerometer=()" always; add_header Permissions-Policy "camera=(self), microphone=(self), geolocation=(), payment=(), usb=(), magnetometer=(), gyroscope=(), accelerometer=()" always;
add_header Cache-Control $sb_cache always; add_header Cache-Control $sb_cache always;
# Edge-cache directive for Cloudflare/CDNs (empty value → header is omitted). # Edge-cache directive for Cloudflare/CDNs (empty value → header is omitted).
add_header CDN-Cache-Control $sb_cdn_cache always; add_header CDN-Cache-Control $sb_cdn_cache always;
+1384 -2
View File
File diff suppressed because it is too large Load Diff
+4 -4
View File
File diff suppressed because one or more lines are too long
Vendored
+83 -15
View File
@@ -2168,9 +2168,8 @@ var SecureBitChatHeader = ({ status, onDisconnect, webrtcManager, title, isOffli
]); ]);
const headerResponsiveCss = React.createElement("style", { key: "hdr-css", dangerouslySetInnerHTML: { const headerResponsiveCss = React.createElement("style", { key: "hdr-css", dangerouslySetInnerHTML: {
__html: ( __html: (
// Mobile: leave room for the drawer hamburger and shed non-essential header // Encrypted call buttons — green hover per the design.
// chrome so avatar + name + status + Disconnect fit a narrow screen. ".sb-call-btn:not(.sb-call-off):hover{border-color:rgba(62,207,142,0.45) !important;color:#3ecf8e !important;background:rgba(62,207,142,0.07) !important;}@media (max-width:768px){.sb-chat-header{padding-left:58px !important;gap:8px !important;}.sb-chat-header .sb-secpill{display:none !important;}.sb-chat-header .sb-conn-text{display:none !important;}.sb-chat-header .sb-conn{padding:9px !important;}.sb-chat-header .sb-hdr-sub{display:none !important;}.sb-chat-header .sb-disconnect{width:40px !important;height:40px !important;padding:0 !important;gap:0 !important;justify-content:center !important;border-radius:9px !important;color:#e5727a !important;border-color:rgba(229,114,122,0.28) !important;}}@media (max-width:600px){.sb-chips{flex-wrap:nowrap !important;justify-content:flex-start !important;overflow-x:auto;-webkit-overflow-scrolling:touch;scrollbar-width:none;}.sb-chips::-webkit-scrollbar{display:none;}.sb-chips>*{flex:0 0 auto !important;}.sb-chips .sb-chips-right{flex-wrap:nowrap !important;}.sb-chips .sb-chip{white-space:nowrap;}}@media (max-width:480px){.sb-chat-header{padding-right:12px !important;}}"
"@media (max-width:768px){.sb-chat-header{padding-left:60px !important;gap:10px !important;}.sb-chat-header .sb-sec-score,.sb-chat-header .sb-sec-label,.sb-chat-header .sb-sec-div{display:none !important;}.sb-chat-header .sb-secpill{padding:8px !important;gap:6px !important;}.sb-chat-header .sb-conn-text{display:none !important;}.sb-chat-header .sb-conn{padding:9px !important;}.sb-chat-header .sb-hdr-sub{display:none !important;}}@media (max-width:480px){.sb-chat-header{padding-right:12px !important;gap:8px !important;}}"
) )
} }); } });
const header = React.createElement("header", { const header = React.createElement("header", {
@@ -2195,13 +2194,47 @@ var SecureBitChatHeader = ({ status, onDisconnect, webrtcManager, title, isOffli
]) : React.createElement("div", { key: "txt", style: { lineHeight: 1.2, minWidth: 0 } }, [ ]) : React.createElement("div", { key: "txt", style: { lineHeight: 1.2, minWidth: 0 } }, [
React.createElement("div", { key: "r1", style: { display: "flex", alignItems: "center", gap: "7px" } }, [ React.createElement("div", { key: "r1", style: { display: "flex", alignItems: "center", gap: "7px" } }, [
React.createElement("span", { key: "n", style: { fontSize: "15px", fontWeight: 800, letterSpacing: "-0.3px", color: "#f4f4f6", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" } }, title || "Secure chat"), React.createElement("span", { key: "n", style: { fontSize: "15px", fontWeight: 800, letterSpacing: "-0.3px", color: "#f4f4f6", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" } }, title || "Secure chat"),
React.createElement("button", { key: "edit", onClick: startRename, title: "Rename chat (local only)", style: { flex: "none", width: "24px", height: "24px", borderRadius: "7px", display: "grid", placeItems: "center", border: "none", background: "transparent", color: "#56565e", cursor: "pointer" } }, React.createElement("i", { className: "fas fa-pen", style: { fontSize: "11px" } })) React.createElement("button", { key: "edit", className: "sb-rename-btn", onClick: startRename, title: "Rename chat (local only)", style: { flex: "none", width: "24px", height: "24px", borderRadius: "7px", display: "grid", placeItems: "center", border: "none", background: "transparent", color: "#56565e", cursor: "pointer" } }, React.createElement("i", { className: "fas fa-pen", style: { fontSize: "11px" } }))
]), ]),
React.createElement("div", { key: "r2", className: "sb-hdr-sub", style: { fontSize: "11px", color: "#6b6b73", fontWeight: 500, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" } }, isOffline ? "No network \xB7 reconnecting" : peerPresenceWord || (onlineConnected ? "P2P \xB7 end-to-end encrypted" : status === "peer_disconnected" ? "Peer disconnected" : status === "disconnected" ? "Disconnected" : "Connecting\u2026")) React.createElement("div", { key: "r2", className: "sb-hdr-sub", style: { fontSize: "11px", color: "#6b6b73", fontWeight: 500, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" } }, isOffline ? "No network \xB7 reconnecting" : peerPresenceWord || (onlineConnected ? "P2P \xB7 end-to-end encrypted" : status === "peer_disconnected" ? "Peer disconnected" : status === "disconnected" ? "Disconnected" : "Connecting\u2026"))
]) ])
]), ]),
secBtn, secBtn,
React.createElement("div", { key: "right", className: "sb-hdr-right", style: { display: "flex", alignItems: "center", gap: "9px" } }, [ React.createElement("div", { key: "right", className: "sb-hdr-right", style: { display: "flex", alignItems: "center", gap: "9px" } }, [
// Encrypted call buttons — enabled only once the session is
// connected AND SAS-verified (the manager enforces the same
// gate; this just reflects it). Media rides the verified
// DTLS-SRTP transport, so calls inherit the E2E encryption.
...(() => {
const callReady = connected && webrtcManager && webrtcManager.isVerified === true;
const startCall = (video) => {
if (!callReady || !webrtcManager) return;
try {
const p = webrtcManager.startCall(video);
if (p && p.catch) p.catch(() => {
});
} catch (_) {
}
};
const callBtnStyle = {
width: "40px",
height: "40px",
borderRadius: "9px",
display: "grid",
placeItems: "center",
border: "1px solid rgba(255,255,255,0.08)",
background: "rgba(255,255,255,0.02)",
color: callReady ? "#9a9aa2" : "#3f3f47",
cursor: callReady ? "pointer" : "not-allowed",
transition: "all .15s"
};
const PHONE_SVG = '<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.8 19.8 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6A19.8 19.8 0 0 1 2.12 4.18 2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72c.13.96.36 1.9.7 2.81a2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45c.91.34 1.85.57 2.81.7A2 2 0 0 1 22 16.92z"/></svg>';
const VIDEO_SVG = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M23 7l-7 5 7 5V7z"/><rect x="1" y="5" width="15" height="14" rx="2.5"/></svg>';
return [
React.createElement("button", { key: "call-audio", className: callReady ? "sb-call-btn" : "sb-call-btn sb-call-off", disabled: !callReady, onClick: () => startCall(false), title: callReady ? "Start encrypted voice call" : "Verify the session to enable calls", style: callBtnStyle, dangerouslySetInnerHTML: { __html: PHONE_SVG } }),
React.createElement("button", { key: "call-video", className: callReady ? "sb-call-btn" : "sb-call-btn sb-call-off", disabled: !callReady, onClick: () => startCall(true), title: callReady ? "Start encrypted video call" : "Verify the session to enable calls", style: callBtnStyle, dangerouslySetInnerHTML: { __html: VIDEO_SVG } })
];
})(),
React.createElement("div", { key: "conn", className: "sb-conn", style: { display: "flex", alignItems: "center", gap: "8px", padding: "8px 13px", borderRadius: "9px", border: "1px solid rgba(255,255,255,0.07)", background: "rgba(255,255,255,0.02)" } }, [ React.createElement("div", { key: "conn", className: "sb-conn", style: { display: "flex", alignItems: "center", gap: "8px", padding: "8px 13px", borderRadius: "9px", border: "1px solid rgba(255,255,255,0.07)", background: "rgba(255,255,255,0.02)" } }, [
React.createElement("span", { key: "dot", style: { flex: "none", width: "7px", height: "7px", borderRadius: "50%", background: connDot, boxShadow: connGlow } }), React.createElement("span", { key: "dot", style: { flex: "none", width: "7px", height: "7px", borderRadius: "50%", background: connDot, boxShadow: connGlow } }),
React.createElement("span", { key: "t", className: "sb-conn-text", style: { fontSize: "13px", fontWeight: 600, color: "#cfcfd4" } }, connLabel) React.createElement("span", { key: "t", className: "sb-conn-text", style: { fontSize: "13px", fontWeight: 600, color: "#cfcfd4" } }, connLabel)
@@ -2525,7 +2558,7 @@ var EnhancedChatInterface = ({
showDropzone: fileSendMode showDropzone: fileSendMode
}) })
); );
const chipsRow = React.createElement("div", { key: "chips", style: { display: "flex", alignItems: "center", justifyContent: "space-between", flexWrap: "wrap", gap: "8px", marginBottom: "10px" } }, [ const chipsRow = React.createElement("div", { key: "chips", className: "sb-chips", style: { display: "flex", alignItems: "center", justifyContent: "space-between", flexWrap: "wrap", gap: "8px", marginBottom: "10px" } }, [
React.createElement("button", { key: "files", onClick: () => { React.createElement("button", { key: "files", onClick: () => {
if (showFileTransfer && fileSendMode) { if (showFileTransfer && fileSendMode) {
setShowFileTransfer(false); setShowFileTransfer(false);
@@ -2538,7 +2571,7 @@ var EnhancedChatInterface = ({
React.createElement("i", { key: "i", className: "fas fa-paperclip", style: { fontSize: "13px" } }), React.createElement("i", { key: "i", className: "fas fa-paperclip", style: { fontSize: "13px" } }),
showFileTransfer && fileSendMode ? "Hide files" : "Send files" showFileTransfer && fileSendMode ? "Hide files" : "Send files"
]), ]),
React.createElement("div", { key: "right", style: { display: "flex", alignItems: "center", gap: "6px", flexWrap: "wrap" } }, [ React.createElement("div", { key: "right", className: "sb-chips-right", style: { display: "flex", alignItems: "center", gap: "6px", flexWrap: "wrap" } }, [
React.createElement("button", { key: "code", onClick: () => setCodeMode((v) => !v), className: "sb-chip", style: chipStyle(codeMode) }, [ React.createElement("button", { key: "code", onClick: () => setCodeMode((v) => !v), className: "sb-chip", style: chipStyle(codeMode) }, [
React.createElement("i", { key: "i", className: "fas fa-code", style: { fontSize: "13px" } }), React.createElement("i", { key: "i", className: "fas fa-code", style: { fontSize: "13px" } }),
"Code" "Code"
@@ -2623,7 +2656,7 @@ var EnhancedChatInterface = ({
); );
const composer = React.createElement( const composer = React.createElement(
"footer", "footer",
{ key: "composer", style: { flex: "none", padding: "12px 20px 18px", background: "#0f0f11", borderTop: "1px solid rgba(255,255,255,0.05)" } }, { key: "composer", style: { flex: "none", padding: "12px 20px calc(18px + env(safe-area-inset-bottom, 0px))", background: "#0f0f11", borderTop: "1px solid rgba(255,255,255,0.05)" } },
React.createElement( React.createElement(
"div", "div",
{ style: { maxWidth: "1000px", margin: "0 auto" } }, { style: { maxWidth: "1000px", margin: "0 auto" } },
@@ -2645,10 +2678,15 @@ var EnhancedChatInterface = ({
peerPresence, peerPresence,
onRenameTitle onRenameTitle
}); });
const callOverlay = window.CallUIComponent && React.createElement(window.CallUIComponent, {
key: "call-overlay",
webrtcManager,
peerTitle: title
});
return React.createElement("div", { return React.createElement("div", {
className: "chat-container", className: "chat-container",
style: { display: "flex", flexDirection: "column", height: "100vh", background: "#0f0f11", color: "#e8e8eb" } style: { position: "relative", display: "flex", flexDirection: "column", height: "100vh", background: "#0f0f11", color: "#e8e8eb" }
}, [chatHeader, messagesArea, scrollBtn, composer]); }, [chatHeader, messagesArea, scrollBtn, composer, callOverlay]);
}; };
var buildSessionMessage = (message, type, opts = {}) => ({ var buildSessionMessage = (message, type, opts = {}) => ({
message, message,
@@ -2746,7 +2784,7 @@ var SessionsSidebar = ({ chats, collapsed, drawerOpen, onToggleCollapse, onSelec
{ style: { width: size + "px", height: size + "px", flex: "none", display: "grid", placeItems: "center" } }, { style: { width: size + "px", height: size + "px", flex: "none", display: "grid", placeItems: "center" } },
h("img", { src: "/logo/securebit-mark.svg", alt: "SecureBit", style: { width: "100%", height: "100%", objectFit: "contain", display: "block" } }) h("img", { src: "/logo/securebit-mark.svg", alt: "SecureBit", style: { width: "100%", height: "100%", objectFit: "contain", display: "block" } })
); );
const collapseBtn = (svg, title) => h("button", { onClick: onToggleCollapse, title, style: { width: "30px", height: "30px", borderRadius: "8px", display: "grid", placeItems: "center", border: "1px solid rgba(255,255,255,0.07)", background: "transparent", color: "#8a8a92", cursor: "pointer" }, dangerouslySetInnerHTML: { __html: svg } }); const collapseBtn = (svg, title) => h("button", { className: "sb-collapse-btn", onClick: onToggleCollapse, title, style: { width: "30px", height: "30px", borderRadius: "8px", display: "grid", placeItems: "center", border: "1px solid rgba(255,255,255,0.07)", background: "transparent", color: "#8a8a92", cursor: "pointer" }, dangerouslySetInnerHTML: { __html: svg } });
const myMeta = MY_STATUS_OPTIONS.find((o) => o.key === myStatus) || MY_STATUS_OPTIONS[0]; const myMeta = MY_STATUS_OPTIONS.find((o) => o.key === myStatus) || MY_STATUS_OPTIONS[0];
const PRES_SVG = { const PRES_SVG = {
user: '<svg width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M18 20v-1.5a4 4 0 0 0-4-4h-4a4 4 0 0 0-4 4V20"/><circle cx="12" cy="8" r="3.6"/></svg>', user: '<svg width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M18 20v-1.5a4 4 0 0 0-4-4h-4a4 4 0 0 0-4 4V20"/><circle cx="12" cy="8" r="3.6"/></svg>',
@@ -2847,7 +2885,7 @@ var SessionsSidebar = ({ chats, collapsed, drawerOpen, onToggleCollapse, onSelec
const inner = collapsed ? collapsedInner : expandedInner; const inner = collapsed ? collapsedInner : expandedInner;
return h(React.Fragment, null, [ return h(React.Fragment, null, [
// Responsive behaviour (inline styles can't express media queries). // Responsive behaviour (inline styles can't express media queries).
h("style", { key: "css", dangerouslySetInnerHTML: { __html: "@media (max-width:1023px){.sb-rail{display:none !important;}.sb-burger{display:grid !important;}}@media (min-width:1024px){.sb-drawer-overlay{display:none !important;}}" } }), h("style", { key: "css", dangerouslySetInnerHTML: { __html: "@media (max-width:1023px){.sb-rail{display:none !important;}.sb-burger{display:grid !important;}}@media (min-width:1024px){.sb-drawer-overlay{display:none !important;}}.sb-mobile-drawer .sb-collapse-btn{display:none !important;}html,body{background:#0f0f11 !important;overscroll-behavior:none;}.sb-app-shell{height:var(--sb-vh,100dvh) !important;}.sb-app-col{height:var(--sb-vh,100dvh) !important;}.chat-container{height:var(--sb-vh,100dvh) !important;}@media (max-width:768px){textarea,input,select{font-size:16px !important;}}@media (max-width:768px){.sb-rename-btn{display:none !important;}}" } }),
// Desktop rail // Desktop rail
h("aside", { key: "rail", className: "sb-rail", style: railStyle }, inner), h("aside", { key: "rail", className: "sb-rail", style: railStyle }, inner),
// Mobile drawer overlay // Mobile drawer overlay
@@ -2856,7 +2894,13 @@ var SessionsSidebar = ({ chats, collapsed, drawerOpen, onToggleCollapse, onSelec
className: "sb-drawer-overlay", className: "sb-drawer-overlay",
onClick: onCloseDrawer, onClick: onCloseDrawer,
style: { position: "fixed", inset: 0, zIndex: 60, background: "rgba(6,6,8,0.6)", backdropFilter: "blur(4px)", WebkitBackdropFilter: "blur(4px)", display: drawerOpen ? "block" : "none" } style: { position: "fixed", inset: 0, zIndex: 60, background: "rgba(6,6,8,0.6)", backdropFilter: "blur(4px)", WebkitBackdropFilter: "blur(4px)", display: drawerOpen ? "block" : "none" }
}, h("aside", { onClick: (e) => e.stopPropagation(), style: { position: "absolute", left: 0, top: 0, bottom: 0, width: "292px", display: "flex", flexDirection: "column", background: "#0c0c0e", borderRight: "1px solid rgba(255,255,255,0.06)", boxShadow: "0 0 60px rgba(0,0,0,0.6)" } }, expandedInner)) }, h("aside", { className: "sb-mobile-drawer", onClick: (e) => e.stopPropagation(), style: { position: "absolute", left: 0, top: 0, bottom: 0, width: "min(292px, 86vw)", display: "flex", flexDirection: "column", background: "#0c0c0e", borderRight: "1px solid rgba(255,255,255,0.06)", boxShadow: "0 0 60px rgba(0,0,0,0.6)" } }, [
// Explicit close button — the drawer's own header only has a
// "collapse" chevron (a desktop-rail action), so on mobile there was
// no obvious way to dismiss it. This X closes the drawer reliably.
h("button", { key: "x", onClick: onCloseDrawer, title: "Close menu", "aria-label": "Close menu", style: { position: "absolute", top: "15px", right: "13px", zIndex: 2, width: "34px", height: "34px", borderRadius: "9px", display: "grid", placeItems: "center", border: "1px solid rgba(255,255,255,0.1)", background: "rgba(255,255,255,0.05)", color: "#cfcfd4", cursor: "pointer" } }, h("i", { className: "fas fa-xmark", style: { fontSize: "16px" } })),
expandedInner
]))
]); ]);
}; };
var EnhancedSecureP2PChat = () => { var EnhancedSecureP2PChat = () => {
@@ -2995,6 +3039,28 @@ var EnhancedSecureP2PChat = () => {
window.removeEventListener("online", goOnline); window.removeEventListener("online", goOnline);
}; };
}, []); }, []);
React.useEffect(() => {
const vv = typeof window !== "undefined" ? window.visualViewport : null;
const apply = () => {
const h = vv ? vv.height : window.innerHeight || 0;
if (h) document.documentElement.style.setProperty("--sb-vh", h + "px");
};
apply();
if (vv) {
vv.addEventListener("resize", apply);
vv.addEventListener("scroll", apply);
}
window.addEventListener("resize", apply);
window.addEventListener("orientationchange", apply);
return () => {
if (vv) {
vv.removeEventListener("resize", apply);
vv.removeEventListener("scroll", apply);
}
window.removeEventListener("resize", apply);
window.removeEventListener("orientationchange", apply);
};
}, []);
const [relayOnlyMode, setRelayOnlyMode] = React.useState(() => { const [relayOnlyMode, setRelayOnlyMode] = React.useState(() => {
try { try {
return localStorage.getItem("securebit_relay_only_mode") === "true"; return localStorage.getItem("securebit_relay_only_mode") === "true";
@@ -3523,7 +3589,7 @@ var EnhancedSecureP2PChat = () => {
} catch (error) { } catch (error) {
} }
} }
handleMessage(" SecureBit.chat Enhanced Security Edition v5.4.10 - ECDH + DTLS + SAS initialized. Ready to establish a secure connection with ECDH key exchange, DTLS fingerprint verification, and SAS authentication to prevent MITM attacks.", "system"); handleMessage(" SecureBit.chat Enhanced Security Edition v5.5.0 - ECDH + DTLS + SAS initialized. Ready to establish a secure connection with ECDH key exchange, DTLS fingerprint verification, and SAS authentication to prevent MITM attacks.", "system");
manager.setFileTransferCallbacks( manager.setFileTransferCallbacks(
// Progress callback — drives the voice-note upload/download ring. // Progress callback — drives the voice-note upload/download ring.
(progress) => { (progress) => {
@@ -5139,11 +5205,13 @@ var EnhancedSecureP2PChat = () => {
return s && s.sas && s.sas.isVerified; return s && s.sas && s.sas.isVerified;
}); });
return React.createElement("div", { return React.createElement("div", {
className: "minimal-bg", className: showSidebar ? "minimal-bg sb-app-shell" : "minimal-bg",
// With the rail visible the app is a fixed-height shell (rail + column // With the rail visible the app is a fixed-height shell (rail + column
// fill the viewport, design-style). Otherwise it's the scrollable landing. // fill the viewport, design-style). Otherwise it's the scrollable landing.
// flexDirection:'row' is explicit — the .minimal-bg class forces // flexDirection:'row' is explicit — the .minimal-bg class forces
// flex-direction:column, which would otherwise stack the rail ABOVE the chat. // flex-direction:column, which would otherwise stack the rail ABOVE the chat.
// height:100vh is the fallback; .sb-app-shell upgrades it to 100dvh on
// mobile so the shell fits under the browser toolbar (header stays put).
style: showSidebar ? { display: "flex", flexDirection: "row", height: "100vh", width: "100%", overflow: "hidden" } : { minHeight: "100vh" } style: showSidebar ? { display: "flex", flexDirection: "row", height: "100vh", width: "100%", overflow: "hidden" } : { minHeight: "100vh" }
}, [ }, [
showSidebar && React.createElement(SessionsSidebar, { showSidebar && React.createElement(SessionsSidebar, {
@@ -5169,7 +5237,7 @@ var EnhancedSecureP2PChat = () => {
}), }),
React.createElement("div", { React.createElement("div", {
key: "app-column", key: "app-column",
className: showSidebar ? "minimal-bg" : "minimal-bg min-h-screen", className: showSidebar ? "minimal-bg sb-app-col" : "minimal-bg min-h-screen",
style: showSidebar ? { flex: 1, minWidth: 0, height: "100vh", overflow: "hidden", display: "flex", flexDirection: "column" } : {} style: showSidebar ? { flex: 1, minWidth: 0, height: "100vh", overflow: "hidden", display: "flex", flexDirection: "column" } : {}
}, [ }, [
// Advanced network settings now render inside the connection // Advanced network settings now render inside the connection
+2 -2
View File
File diff suppressed because one or more lines are too long
+143
View File
@@ -0,0 +1,143 @@
# WebRTC Audit — SecureBit.chat call stack
**Step 1 deliverable. No code changed.** This maps the existing WebRTC/call code so we can plan the adaptive voice/video stack (Opus FEC/DTX/RED, VP9-SVC + fallbacks, TWCC/NACK/PLI, getStats adaptation) *without* creating a parallel branch.
---
## 0. Reality vs. the task spec (read this first)
The task is written against a modular TypeScript layout (`src/webrtc/codecs/*.ts`, `call.ts`, `config.ts`, …). **That layout does not exist and does not match the repo.** Concretely:
| Task assumption | Actual repo |
|---|---|
| TypeScript (`.ts`) | Plain JS / JSX. No `tsconfig`, no `.ts` files, esbuild bundles JS as-is. |
| `src/webrtc/` modular stack | One monolith: `src/network/EnhancedSecureWebRTCManager.js` (~14.8k lines). |
| Fresh `RTCPeerConnection` per call, `addTransceiver` at init | **One long-lived PC** shared with the encrypted **data channel**; calls are **renegotiated onto it**. Media is added with `addTrack`, **never** `addTransceiver`. |
| Standard signalling server / offer at init | **Two separate SDP paths** (see §5). Call SDP is exchanged **in-band over the E2E data channel**. |
| Playwright available | Not installed. Tests are plain `node tests/*.test.mjs`. No `RTCPeerConnection` in Node. |
| `debug('webrtc:adapt')` | No `debug` dependency. Logging is `_secureLog(...)` + the new `window.sbCallLog(...)`. |
**Consequence:** we integrate into the existing manager + a small set of **new plain-JS helper modules** it imports. We do **not** add TypeScript or a `src/webrtc/` TS tree. Proposed JS layout in §7.
---
## 1. Module map
| File | Kind | Exports / globals | Role |
|---|---|---|---|
| `src/network/EnhancedSecureWebRTCManager.js` | ES module | `export { EnhancedSecureWebRTCManager, SecureMasterKeyManager, SecureIndexedDBWrapper, SecurePersistentKeyStorage }` (line 14812). Also `window.EnhancedSecureWebRTCManager`. | **All** transport + crypto + call logic. The class to extend. |
| `src/components/ui/CallUI.jsx` | ES module (side-effect) | `window.CallUIComponent`, `export { CallUIComponent }` | Presentational call overlay (voice/video/minimized/incoming). Pure UI; no media/crypto. |
| `src/scripts/app-boot.js` | bundled entry | imports the manager + UI components; sets `window.*` | Real loader (→ `dist/app-boot.js`). CallUI is imported here. |
| `src/app.jsx` | bundled entry | React app (`h`/createElement) | Header call buttons + mounts `CallUI`. getUserMedia for **voice messages** (unrelated to calls) at 816817. |
| `config/ice-servers*.js` | script | `window.SECUREBIT_ICE_SERVERS` | Operator STUN/TURN override. |
| `src/network/iceServers.js`, `iceSettingsStore.js` | ES modules | ICE validation/persistence | User-supplied ICE servers. |
> `src/scripts/bootstrap-modules.js` exists but is **dead** (not referenced anywhere). Ignore it.
---
## 2. RTCPeerConnection: creation & config
- **`createPeerConnection()`** — `EnhancedSecureWebRTCManager.js:7731`. Single `new RTCPeerConnection(config)` at **7737**. Wires `onconnectionstatechange`, `oniceconnectionstatechange`, `onicecandidateerror`, **`ontrack`** (7802, call media), `ondatachannel`.
- Called from **9983** (initiator/offer path) and **10646** (responder/answer path).
- **`_buildPeerConnectionConfig()`** — `7661`. Returns:
```js
{ iceServers, iceCandidatePoolSize: 10, bundlePolicy: 'balanced' } // + iceTransportPolicy:'relay' in privacy mode
```
No `rtcpMuxPolicy`, no codec/degradation config (nothing to do there; those live on senders/transceivers).
- One PC per session; multi-session keeps a `Map<id, manager>` (each with its own PC).
## 3. SDP formation
No codec munging exists today — only **DTLS fingerprint extraction** (`_extractDTLSFingerprintFromSDP`) and **ICE candidate diagnostics** (`_summarizeIceCandidatesInSDP` 1057, `_logIceCandidateDiagnostics` 1119, `_warnIfRemoteCandidatesNeedRelay` 1145).
| Op | Line(s) | Path |
|---|---|---|
| `createOffer` | 10004 | **Data-channel handshake** (initial connection) |
| `setLocalDescription(offer)` | 10009 | handshake |
| `setRemoteDescription(offer)` | 10682 | handshake |
| `createAnswer` | 10717 | handshake |
| `setLocalDescription(answer)` | 10727 | handshake |
| `setRemoteDescription(answer)` | 11462 | handshake |
| `createOffer` | 13047, 13262 | **Call** (startCall / renegotiate) |
| `setLocalDescription` | 13048, 13263 | call |
| `setRemoteDescription(offer)` | 13081 | call |
| `createAnswer` + `setLocalDescription` | 1308713088 | call |
| `setRemoteDescription(answer)` | 13285 | call |
| `setLocalDescription({type:'rollback'})` | 13176 | call teardown |
**Where codec/RTP munging must hook:** only the **call** offers/answers (13047/13087/13262). The handshake SDP (m=application/data channel only) must stay untouched.
## 4. Media: getUserMedia / addTrack / transceivers
- **getUserMedia (calls):** `_acquireLocalMedia` 13012, `upgradeToVideo` 13221, `switchCamera` 13245.
- Unrelated: voice-message recorder `app.jsx:817`; QR scanner `QRScanner.js:72`.
- **addTrack:** `_acquireLocalMedia` 13017, `upgradeToVideo` 13230. **← the only way media is attached today.**
- **replaceTrack:** upgrade 13228, switchCamera 13252, teardown 13151.
- **removeTrack / getReceivers / getTransceivers / .stop():** teardown 1314613158.
- **`addTransceiver`: NONE.** `sendEncodings`: NONE. `setParameters`/`getParameters`: NONE. `setCodecPreferences`/`getCapabilities`: NONE.
- **`ontrack`** handler: 7802 (accumulates into `this.remoteMediaStream`).
## 5. Signalling model (two SDP paths — important)
1. **Connection handshake** (data channel): offer/answer compressed and exchanged **out-of-band** (QR / copy-paste), authenticated by **SAS**. Establishes the DTLS transport + `securechat` data channel (`createDataChannel` 9986).
2. **Call setup** (media): after the channel is verified, call SDP rides **in-band over that data channel** via `MESSAGE_TYPES.CALL_OFFER/CALL_ANSWER/CALL_ICE/CALL_DECLINE/CALL_END`. Routed in the live `dataChannel.onmessage` (~7996) → `_handleCallSignal` (13274). Media BUNDLEs onto the existing transport, so **no new ICE**.
→ Adaptive stack changes affect **path 2 only**.
## 6. Call subsystem inventory (all in the manager, added recently)
State/getters: `callState` object; `getCallState` 12957, `getRemoteMediaStream` 12961, `getLocalMediaStream` 12965, `_updateCallState` 12969 (fires `onCallStateChanged` + `securebit-call-state` DOM event).
Lifecycle: `_callCanStart` 12983 (gated on connected+verified), `_acquireLocalMedia` 12999, `startCall` 13025, `_onIncomingCallOffer` 13065, `_answerCallOffer` 13080, `acceptCall`/`declineCall`, `endCall` 13120, `_teardownCallMedia` 13132.
Controls: `setMicEnabled` 13186, `toggleMic` 13193, `setCameraEnabled` 13197, `toggleCamera` 13215, `upgradeToVideo` 13218, `switchCamera` 13241, `_renegotiateCall` 13258.
Signalling: `_handleCallSignal` 13274, `_sendCallSignal`, `MESSAGE_TYPES.CALL_*`.
Debug: `window.sbCallLog` / `window.__sbCallLog` (module top of the manager).
UI: `CallUI.jsx` + header buttons (`app.jsx`) + overlay mount in `EnhancedChatInterface`.
## 7. Gaps vs. the target spec
| Requirement | Status | Note |
|---|---|---|
| Opus fmtp (FEC/DTX/RED, maxavgbitrate, cbr=0) | ❌ | No SDP munging. |
| RED via setCodecPreferences | ❌ | No codec prefs. |
| Audio sender priority/networkPriority/maxBitrate | ❌ | No setParameters. |
| VP9 SVC (`L3T3_KEY`) + simulcast fallback | ❌ | No `sendEncodings`; uses `addTrack`. |
| AV1 / H.264 / VP8 fallback order | ❌ | No `getCapabilities` sort. |
| TWCC/NACK/PLI/FIR/REMB on m-lines | ⚠️ | Whatever the browser emits by default; not enforced/verified. |
| `NetworkAdaptationController` (getStats loop) | ❌ | None. |
| Config with sourced constants | ❌ | Only `_config.webrtc` (ICE/privacy). |
## 8. Proposed integration (for confirmation — not yet built)
New **plain-JS** modules under `src/network/webrtc/` (imported by the manager), mapping to the task's requested files:
| Task file | Proposed actual file |
|---|---|
| `src/webrtc/config.ts` | `src/network/webrtc/config.js` — all constants + source comments |
| `src/webrtc/codecs/audio.ts` | `src/network/webrtc/audio.js` — `configureAudioSender` |
| `src/webrtc/codecs/video.ts` | `src/network/webrtc/video.js` — `configureVideoSender` |
| `src/webrtc/codecs/sdp.ts` | `src/network/webrtc/sdp.js` — pure SDP munging utils (testable in Node) |
| `src/webrtc/adaptation/metrics.ts` | `src/network/webrtc/adaptation/metrics.js` — getStats parsing |
| `src/webrtc/adaptation/controller.ts` | `src/network/webrtc/adaptation/controller.js` — `NetworkAdaptationController` |
| `src/webrtc/call.ts` | **integrate into `EnhancedSecureWebRTCManager.js`** call methods |
Hook points in the manager:
- `_acquireLocalMedia` (12999): switch `addTrack` → `addTransceiver('audio'/'video', {direction, sendEncodings})`, then `setCodecPreferences` on the transceivers.
- `startCall`/`_answerCallOffer`/`_renegotiateCall`: after `createOffer/createAnswer`, run `sdp.js` munging before `setLocalDescription`; after `setRemoteDescription`, call `configureAudioSender`/`configureVideoSender` and start the controller.
- `_teardownCallMedia`: stop the controller.
**Key compromise to confirm:** the PC is long-lived and shared with the data channel, so we **cannot** follow Step-6's "addTransceiver at PC init" literally. Transceivers are added/negotiated in-band at call start on the existing PC (the audio/video transceivers can be created once on first call and reused via `replaceTrack` thereafter — this also fixes the transceiver-accumulation issue seen earlier).
## 9. Constraints / risks
- **Browser matrix:** `scalabilityMode`/`setCodecPreferences` support varies. Firefox: SVC limited → simulcast fallback mandatory. Safari: VP9 often absent → H.264 fallback; RED support varies. All feature-detected via `getCapabilities`/try-catch.
- **SDP munging is fragile** across browsers (m-line/payload ordering). Utilities must be defensive and idempotent (spec says "no duplicate lines").
- **Testing:** SDP utils + controller (mock `getStats`) → plain `node .mjs` unit tests (fits current infra). **Integration test needs a real browser** (two `RTCPeerConnection`s + throttling) — **Playwright is not installed**; that item needs either adding Playwright (a dependency — task says avoid unless necessary) or manual `chrome://webrtc-internals` verification.
- **In-band renegotiation** must keep the perfect-negotiation guards already in place to avoid glare.
## 10. Open questions before Step 2
1. Confirm JS (not TS) + `src/network/webrtc/` layout in §8.
2. Confirm the long-lived-PC compromise in §8 (reuse transceivers via `replaceTrack`, negotiate in-band).
3. Playwright: add it for the integration test, or rely on `chrome://webrtc-internals` manual verification for the "adaptation visible under throttling" criterion?
4. Commit-per-step + deploy to Fly after each step (as before), GitHub untouched — same as current workflow?
+160
View File
@@ -0,0 +1,160 @@
# WebRTC call configuration & rationale
All tunables live in `src/network/webrtc/config.js`. This doc explains the values
and where they come from. Built incrementally per step; **all steps shipped**
adaptive audio, SVC video, transport feedback, and runtime bitrate adaptation are
live in the 1:1 call flow.
## How the pieces attach (why not one `configureAudioSender`)
A single `RTCRtpSender` cannot carry codec ordering or fmtp, so the brief's
`configureAudioSender` is split across the three WebRTC surfaces at their correct
lifecycle points (see `EnhancedSecureWebRTCManager` call methods):
| Concern | API surface | When | Code |
|---|---|---|---|
| RED-first / Opus-second ordering | `transceiver.setCodecPreferences` | before `createOffer`/`createAnswer` | `applyAudioCodecPreferences` (audio.js) via `_applyAudioCodecPrefs` |
| Opus FEC/DTX/bitrate | SDP `a=fmtp` munging | after create, before `setLocalDescription` | `applyOpusSettings` (sdp.js) via `_mungeCallSdp` |
| priority / networkPriority / maxBitrate | `sender.setParameters` | after `setLocalDescription` | `configureAudioSender` (audio.js) via `_applyCallSenderParams` |
Both peers run the same munging, so the negotiated session carries the params.
## Step 2 — Audio (`AUDIO_CONFIG`)
### Opus fmtp (`opusFmtp`)
| Param | Value | Why |
|---|---|---|
| `minptime` | 10 | Smaller packetisation time → lower latency. Opus RFC 7587 §7. |
| `useinbandfec` | 1 | In-band FEC reconstructs a lost packet from the next one — the main lever for the 1520% loss target. RFC 6716 §2.1.7. |
| `usedtx` | 1 | Discontinuous transmission: stop sending in silence, freeing the shared transport for video/FEC. RFC 7587 §3.1.3. |
| `stereo` | 0 | Mono voice — halves bitrate, no quality loss for speech. |
| `maxaveragebitrate` | 32000 | Comfortable wideband speech; brief-specified. |
| `cbr` | 0 | Variable bitrate spends bits only when needed — better quality/bit than CBR for speech. |
### RED (`preferRed: true`)
RFC 2198 redundant audio: each packet also carries the previous frame's payload,
so isolated losses recover without retransmission. Enabled only when the browser
advertises `audio/red` in `RTCRtpSender.getCapabilities('audio')` (Chromium yes;
Safari/Firefox vary → silently skipped). Ordered **RED first, Opus second** via
`setCodecPreferences`.
### Sender (`sender`)
| Param | Value | Why |
|---|---|---|
| `maxBitrate` | 40000 bps | Brief value; head-room above 32 kbps Opus for RED redundancy. |
| `priority` | `high` | Bandwidth arbitration within the PC — audio wins over video. |
| `networkPriority` | `high` | DSCP hint so audio packets are prioritised on the wire. |
## Step 3 — Video (`VIDEO_CONFIG`)
### Codec preference (`codecPreferenceOrder`)
`VP9 → AV1 → H.264 → VP8`, applied with `setCodecPreferences` (via
`applyVideoCodecPreferences`). rtx/red/fec codecs are kept after the media codecs
so retransmission/FEC still work. VP9/AV1 give SVC; H.264/VP8 are plain.
### Single-encoding SVC (why not multi-rid simulcast)
This is **1:1 P2P** (one receiver), so a single encoding with **SVC** is the right
tool: one stream that degrades by spatial/temporal layer. It's applied through
`sender.setParameters` (`configureVideoSender`) and needs **no** `addTransceiver`/
rids, so it doesn't disturb the working `addTrack` media path.
| Codec | scalabilityMode | maxBitrate | degradationPreference |
|---|---|---|---|
| VP9 | `L3T3_KEY` (3 spatial × 3 temporal, key-aligned) | 1.5 Mbps | `balanced` |
| AV1 | `L1T3` | 1.2 Mbps | `maintain-framerate` |
| H.264 / VP8 | none (plain) | 1.5 Mbps | `balanced` |
`networkPriority: 'medium'` — below audio's `high`. If a browser rejects the SVC
mode (Firefox/Safari gaps), `configureVideoSender` retries with a plain encoding.
### Initialization (Step 6) + simulcast (Step 3) — what shipped, and why
**Attempted then reverted:** an explicit `addTransceiver({sendEncodings})` path
(single-encoding SVC / multi-rid simulcast) was implemented but **broke media on
real devices** during testing:
- on the **answerer**, reusing the transceiver created by `setRemoteDescription`
rejected the SVC `setParameters` (`"parameters are not valid"`), and
- on **role-reversed / repeat calls**, the reused transceiver **directions
desynced** — the call connected (timer ran, `ontrack` fired) but no audio/video
actually flowed.
**What ships instead:** media is attached with **`addTrack`** (reused across calls
via `replaceTrack`), letting the browser manage transceiver direction implicitly —
this is what keeps audio/video flowing across reversed and repeat calls. Video uses
**single-encoding SVC** (VP9 `L3T3_KEY` / AV1 `L1T3`) applied via
`configureVideoSender``setParameters`, plus `setCodecPreferences` (VP9→AV1→H264→VP8).
For 1:1 P2P a single SVC stream is the right tool anyway — it degrades by
spatial/temporal layer, which is what the "video degrades gracefully" requirement
needs. `configureVideoSender` falls back to a plain encoding if a scalabilityMode
is rejected.
**Multi-rid simulcast** (`buildVideoSendEncodings`, `VIDEO_CONFIG.*.simulcast`) is
kept as **tested, exported primitives** for a future SFU/group-call path, but is
**not wired into the 1:1 call flow** — it needs `addTransceiver({sendEncodings})`,
which requires the answerer-direction / setParameters issues above to be solved
first (ideally with a Playwright two-PC test rig, which isn't set up). The
adaptation controller is already simulcast-aware (`_applyToVideoSender` throttles
only the top layer) for when that lands.
## Step 4 — Transport (`TRANSPORT_CONFIG`)
Ensures the RTCP feedback / header extensions are present on the call m-lines
(most browsers already emit them, so this is an idempotent safety net):
| m-line | rtcp-fb | header ext |
|---|---|---|
| video | `transport-cc`, `nack`, `nack pli`, `ccm fir`, `goog-remb` | TWCC (`…transport-wide-cc…`) |
| audio | `transport-cc`, `nack` | TWCC |
Implemented as pure, idempotent SDP utils (`ensureRtcpFb`, `ensureExtmap`,
`applyTransport`) — added only when missing, never duplicated, applied only to
primary codecs (rtx/red/fec skipped). `transport-cc` (TWCC) is what feeds the
bandwidth estimator the Step-5 controller reads.
**Safety:** munged local SDP is set via `_setLocalMunged` with progressive
fallback — full munge → Opus-only → raw — so a browser rejecting an injected
line can never break the call.
## Step 5 — Adaptation + quality indicator (`ADAPTATION_CONFIG`)
`NetworkAdaptationController` reads `pc.getStats()` every 1 s and reacts:
| Condition | Action | Note |
|---|---|---|
| loss > 10% **or** RTT > 300 ms | video `maxBitrate` 20% (floor 100 kbps) | audio never touched |
| loss < 3% **and** RTT < 150 ms, 5 ticks | video `maxBitrate` +10% (up to ceiling) | gradual recovery |
| `qualityLimitationReason === 'cpu'` | `scaleResolutionDownBy` ×1.5 | bitrate unchanged |
All changes go through `sender.setParameters` — **no renegotiation, no track
restart**. **Audio is never throttled** by the controller, so speech stays intact
regardless of loss (stronger than the brief's "≥25%" guard). The pure decision
(`decideAdaptation`) and stats parsing (`summarizeStats`) are unit-tested with
mock getStats.
### Connection-quality indicator (user-facing)
The same `getStats` sample yields a coarse label (`qualityFromMetrics`) surfaced in
`callState.quality` and rendered in the call UI as signal bars + text:
| Label | Loss / RTT | Colour |
|---|---|---|
| Excellent | <3% and <150 ms | green |
| Good | <7% and <250 ms | green |
| Fair | <15% and <400 ms | yellow |
| Weak | otherwise | red |
Shown in the voice overlay, the video top bar, and (compact bars) the minimized
widget. Hidden until the first sample has data.
## Verification (all steps)
- Unit: `npm test` (SDP, video codecs, adaptation) — all green.
- Manual (`chrome://webrtc-internals`, per criteria): throttle the link (DevTools
Network / OS) → outbound video `targetBitrate` steps down within ~12 s and
recovers when the link clears; audio bitrate holds; the in-call indicator moves
Excellent → Fair → Weak.
- Audio: in a call, open `chrome://webrtc-internals` → audio outbound-rtp should
show Opus with the fmtp above; on Chromium the codec is `red`/`opus`. Under
packet loss, `useinbandfec` keeps audio intelligible.
> The temporary `[Call]` / `window.__sbCallLog` diagnostic logger used during
> bring-up has been removed for production; the manager's `_secureLog` handles
> runtime logging and stays silent in production builds (`DEBUG_MODE = false`).
+1 -1
View File
@@ -14,7 +14,7 @@ primary_region = "fra"
force_https = true # matches the app's upgrade-insecure-requests CSP force_https = true # matches the app's upgrade-insecure-requests CSP
auto_stop_machines = "stop" auto_stop_machines = "stop"
auto_start_machines = true auto_start_machines = true
min_machines_running = 0 # scale to zero when idle (free-tier friendly) min_machines_running = 1 # keep one machine warm so cold starts never 503 during testing
[http_service.concurrency] [http_service.concurrency]
type = "requests" type = "requests"
+23 -23
View File
@@ -2,7 +2,7 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0, interactive-widget=resizes-content">
<meta http-equiv="Content-Security-Policy" <meta http-equiv="Content-Security-Policy"
content="default-src 'self'; content="default-src 'self';
script-src 'self'; script-src 'self';
@@ -23,7 +23,7 @@
<!-- PWA Manifest --> <!-- PWA Manifest -->
<link rel="manifest" href="./manifest.json"> <link rel="manifest" href="./manifest.json">
<link rel="icon" type="image/x-icon" href="./logo/favicon.ico?v=1784757947117"> <link rel="icon" type="image/x-icon" href="./logo/favicon.ico?v=1784825757783">
<!-- PWA Meta Tags --> <!-- PWA Meta Tags -->
<meta name="mobile-web-app-capable" content="yes"> <meta name="mobile-web-app-capable" content="yes">
@@ -89,7 +89,7 @@
<link rel="apple-touch-startup-image" media="screen and (device-width: 744px) and (device-height: 1133px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)" href="./logo/splash/splash_screens/8.3__iPad_Mini_portrait.png"> <link rel="apple-touch-startup-image" media="screen and (device-width: 744px) and (device-height: 1133px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)" href="./logo/splash/splash_screens/8.3__iPad_Mini_portrait.png">
<!-- Apple Touch Icons --> <!-- Apple Touch Icons -->
<link rel="apple-touch-icon" href="./logo/icon-180x180.png?v=1784757947117"> <link rel="apple-touch-icon" href="./logo/icon-180x180.png?v=1784825757783">
<link rel="apple-touch-icon" sizes="57x57" href="./logo/icon-57x57.png"> <link rel="apple-touch-icon" sizes="57x57" href="./logo/icon-57x57.png">
<link rel="apple-touch-icon" sizes="60x60" href="./logo/icon-60x60.png"> <link rel="apple-touch-icon" sizes="60x60" href="./logo/icon-60x60.png">
<link rel="apple-touch-icon" sizes="72x72" href="./logo/icon-72x72.png"> <link rel="apple-touch-icon" sizes="72x72" href="./logo/icon-72x72.png">
@@ -98,7 +98,7 @@
<link rel="apple-touch-icon" sizes="120x120" href="./logo/icon-120x120.png"> <link rel="apple-touch-icon" sizes="120x120" href="./logo/icon-120x120.png">
<link rel="apple-touch-icon" sizes="144x144" href="./logo/icon-144x144.png"> <link rel="apple-touch-icon" sizes="144x144" href="./logo/icon-144x144.png">
<link rel="apple-touch-icon" sizes="152x152" href="./logo/icon-152x152.png"> <link rel="apple-touch-icon" sizes="152x152" href="./logo/icon-152x152.png">
<link rel="apple-touch-icon" sizes="180x180" href="./logo/icon-180x180.png?v=1784757947117"> <link rel="apple-touch-icon" sizes="180x180" href="./logo/icon-180x180.png?v=1784825757783">
<!-- Microsoft Tiles --> <!-- Microsoft Tiles -->
<meta name="msapplication-TileColor" content="#ff6b35"> <meta name="msapplication-TileColor" content="#ff6b35">
@@ -182,7 +182,7 @@
<!-- Render-blocking JS is deferred: classic deferred scripts and module scripts <!-- Render-blocking JS is deferred: classic deferred scripts and module scripts
both execute in document order after parsing, so React still runs before the both execute in document order after parsing, so React still runs before the
app modules below, but the parser / first paint is no longer blocked. --> app modules below, but the parser / first paint is no longer blocked. -->
<script defer src="config/ice-servers.js?v=1784757947117"></script> <script defer src="config/ice-servers.js?v=1784825757783"></script>
<script defer src="libs/react/react.production.min.js"></script> <script defer src="libs/react/react.production.min.js"></script>
<script defer src="libs/react-dom/react-dom.production.min.js"></script> <script defer src="libs/react-dom/react-dom.production.min.js"></script>
<!-- Prism syntax highlighting (vendored, offline). Tokenizes code as TEXT only — <!-- Prism syntax highlighting (vendored, offline). Tokenizes code as TEXT only —
@@ -190,8 +190,8 @@
Its CSS is loaded async via load-async-css.js (not paint-critical). --> Its CSS is loaded async via load-async-css.js (not paint-critical). -->
<script defer src="libs/prism/prism.js"></script> <script defer src="libs/prism/prism.js"></script>
<!-- Critical, paint-defining CSS stays render-blocking (avoids FOUC / layout shift). --> <!-- Critical, paint-defining CSS stays render-blocking (avoids FOUC / layout shift). -->
<link rel="stylesheet" href="assets/tailwind.css?v=1784757947117"> <link rel="stylesheet" href="assets/tailwind.css?v=1784825757783">
<link rel="icon" type="image/x-icon" href="/logo/favicon.ico?v=1784757947117"> <link rel="icon" type="image/x-icon" href="/logo/favicon.ico?v=1784825757783">
<!-- Preload only the fonts needed for first paint. fa-solid covers the bulk of UI <!-- Preload only the fonts needed for first paint. fa-solid covers the bulk of UI
icons; fa-regular/fa-brands are loaded on demand by their CSS (rarely on the icons; fa-regular/fa-brands are loaded on demand by their CSS (rarely on the
first screen). Inter latin 400/700 cover body text and headings/buttons. --> first screen). Inter latin 400/700 cover body text and headings/buttons. -->
@@ -199,31 +199,31 @@
<link rel="preload" href="/assets/fonts/inter/files/inter-latin-400.woff2" as="font" type="font/woff2" crossorigin> <link rel="preload" href="/assets/fonts/inter/files/inter-latin-400.woff2" as="font" type="font/woff2" crossorigin>
<link rel="preload" href="/assets/fonts/inter/files/inter-latin-700.woff2" as="font" type="font/woff2" crossorigin> <link rel="preload" href="/assets/fonts/inter/files/inter-latin-700.woff2" as="font" type="font/woff2" crossorigin>
<link rel="stylesheet" href="/assets/fonts/inter/inter.css"> <link rel="stylesheet" href="/assets/fonts/inter/inter.css">
<link rel="stylesheet" href="src/styles/main.css?v=1784757947117"> <link rel="stylesheet" href="src/styles/main.css?v=1784825757783">
<link rel="stylesheet" href="src/styles/animations.css?v=1784757947117"> <link rel="stylesheet" href="src/styles/animations.css?v=1784825757783">
<link rel="stylesheet" href="src/styles/components.css?v=1784757947117"> <link rel="stylesheet" href="src/styles/components.css?v=1784825757783">
<!-- Non-critical CSS (FontAwesome ~102KB, Prism) loaded async — no longer blocks paint. --> <!-- Non-critical CSS (FontAwesome ~102KB, Prism) loaded async — no longer blocks paint. -->
<script defer src="src/scripts/load-async-css.js?v=1784757947117"></script> <script defer src="src/scripts/load-async-css.js?v=1784825757783"></script>
<noscript> <noscript>
<link rel="stylesheet" href="/assets/fontawesome/css/all.min.css"> <link rel="stylesheet" href="/assets/fontawesome/css/all.min.css">
<link rel="stylesheet" href="libs/prism/prism.css"> <link rel="stylesheet" href="libs/prism/prism.css">
</noscript> </noscript>
<script defer src="src/scripts/fa-check.js?v=1784757947117"></script> <script defer src="src/scripts/fa-check.js?v=1784825757783"></script>
<!-- Update Manager - система принудительного обновления --> <!-- Update Manager - система принудительного обновления -->
<script defer src="src/utils/updateManager.js?v=1784757947117"></script> <script defer src="src/utils/updateManager.js?v=1784825757783"></script>
<script type="module" src="src/components/UpdateChecker.jsx?v=1784757947117"></script> <script type="module" src="src/components/UpdateChecker.jsx?v=1784825757783"></script>
<script type="module" src="dist/qr-local.js?v=1784757947117"></script> <script type="module" src="dist/qr-local.js?v=1784825757783"></script>
<script type="module" src="src/components/QRScanner.js?v=1784757947117"></script> <script type="module" src="src/components/QRScanner.js?v=1784825757783"></script>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
<script type="module" src="dist/app-boot.js?v=1784757947117"></script> <script type="module" src="dist/app-boot.js?v=1784825757783"></script>
<script type="module" src="dist/app.js?v=1784757947117"></script> <script type="module" src="dist/app.js?v=1784825757783"></script>
<script defer src="src/scripts/pwa-register.js?v=1784757947117"></script> <script defer src="src/scripts/pwa-register.js?v=1784825757783"></script>
<script src="./src/pwa/install-prompt.js?v=1784757947117" type="module"></script> <script src="./src/pwa/install-prompt.js?v=1784825757783" type="module"></script>
<script src="./src/pwa/pwa-manager.js?v=1784757947117" type="module"></script> <script src="./src/pwa/pwa-manager.js?v=1784825757783" type="module"></script>
<script defer src="./src/scripts/pwa-offline-test.js?v=1784757947117"></script> <script defer src="./src/scripts/pwa-offline-test.js?v=1784825757783"></script>
<link rel="stylesheet" href="./src/styles/pwa.css?v=1784757947117"> <link rel="stylesheet" href="./src/styles/pwa.css?v=1784825757783">
</body> </body>
</html> </html>
+7 -7
View File
@@ -1,10 +1,10 @@
{ {
"version": "1784757947117", "version": "1784825757783",
"buildVersion": "1784757947117", "buildVersion": "1784825757783",
"appVersion": "5.4.10", "appVersion": "5.5.0",
"buildTime": "2026-07-22T22:05:47.156Z", "buildTime": "2026-07-23T16:55:57.822Z",
"buildId": "1784757947117-c98a01b", "buildId": "1784825757783-e544593",
"gitHash": "c98a01b", "gitHash": "e544593",
"generated": true, "generated": true,
"generatedAt": "2026-07-22T22:05:47.157Z" "generatedAt": "2026-07-23T16:55:57.823Z"
} }
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "securebit-chat", "name": "securebit-chat",
"version": "5.4.10", "version": "5.5.0",
"description": "Secure P2P Communication Application with End-to-End Encryption", "description": "Secure P2P Communication Application with End-to-End Encryption",
"main": "index.html", "main": "index.html",
"scripts": { "scripts": {
@@ -11,7 +11,7 @@
"dev": "npm run build && python -m http.server 8000", "dev": "npm run build && python -m http.server 8000",
"watch": "npx tailwindcss -i src/styles/tw-input.css -o assets/tailwind.css --watch", "watch": "npx tailwindcss -i src/styles/tw-input.css -o assets/tailwind.css --watch",
"serve": "npx http-server -p 8000", "serve": "npx http-server -p 8000",
"test": "node tests/sas-verification.test.mjs && node tests/file-transfer-consent.test.mjs && node tests/incoming-message-sanitization.test.mjs && node tests/outgoing-message-integrity.test.mjs && node tests/secure-chat-features.test.mjs && node tests/notification-meta-forwarding.test.mjs && node tests/file-type-allowlist.test.mjs && node tests/webrtc-privacy-mode.test.mjs && node tests/indexeddb-metadata-encryption.test.mjs && node tests/disconnect-cleanup.test.mjs && node tests/timer-lifecycle.test.mjs && node tests/file-transfer-cleanup.test.mjs && node tests/file-transfer-ui-cleanup.test.mjs && node tests/file-transfer-callback-propagation.test.mjs && node tests/debug-window-hooks.test.mjs && node tests/inbound-message-rate-limit.test.mjs && node tests/file-transfer-chunk-rate-limit.test.mjs && node tests/ice-servers-validation.test.mjs && node tests/sessions-reducer.test.mjs" "test": "node tests/sas-verification.test.mjs && node tests/file-transfer-consent.test.mjs && node tests/incoming-message-sanitization.test.mjs && node tests/outgoing-message-integrity.test.mjs && node tests/secure-chat-features.test.mjs && node tests/notification-meta-forwarding.test.mjs && node tests/file-type-allowlist.test.mjs && node tests/webrtc-privacy-mode.test.mjs && node tests/indexeddb-metadata-encryption.test.mjs && node tests/disconnect-cleanup.test.mjs && node tests/timer-lifecycle.test.mjs && node tests/file-transfer-cleanup.test.mjs && node tests/file-transfer-ui-cleanup.test.mjs && node tests/file-transfer-callback-propagation.test.mjs && node tests/debug-window-hooks.test.mjs && node tests/inbound-message-rate-limit.test.mjs && node tests/file-transfer-chunk-rate-limit.test.mjs && node tests/ice-servers-validation.test.mjs && node tests/sessions-reducer.test.mjs && node tests/webrtc-sdp.test.mjs && node tests/webrtc-video.test.mjs && node tests/webrtc-adaptation.test.mjs"
}, },
"keywords": [ "keywords": [
"p2p", "p2p",
+105 -17
View File
@@ -1947,17 +1947,31 @@ import {
]); ]);
const headerResponsiveCss = React.createElement('style', { key: 'hdr-css', dangerouslySetInnerHTML: { __html: const headerResponsiveCss = React.createElement('style', { key: 'hdr-css', dangerouslySetInnerHTML: { __html:
// Encrypted call buttons green hover per the design.
'.sb-call-btn:not(.sb-call-off):hover{border-color:rgba(62,207,142,0.45) !important;color:#3ecf8e !important;background:rgba(62,207,142,0.07) !important;}' +
// Mobile: leave room for the drawer hamburger and shed non-essential header // Mobile: leave room for the drawer hamburger and shed non-essential header
// chrome so avatar + name + status + Disconnect fit a narrow screen. // chrome so avatar + name + status + call/Disconnect fit a narrow screen.
'@media (max-width:768px){' + '@media (max-width:768px){' +
'.sb-chat-header{padding-left:60px !important;gap:10px !important;}' + '.sb-chat-header{padding-left:58px !important;gap:8px !important;}' +
'.sb-chat-header .sb-sec-score,.sb-chat-header .sb-sec-label,.sb-chat-header .sb-sec-div{display:none !important;}' + // Remove the security/verification pill on mobile (per request) the
'.sb-chat-header .sb-secpill{padding:8px !important;gap:6px !important;}' + // full report is still available from the desktop header.
'.sb-chat-header .sb-secpill{display:none !important;}' +
'.sb-chat-header .sb-conn-text{display:none !important;}' + '.sb-chat-header .sb-conn-text{display:none !important;}' +
'.sb-chat-header .sb-conn{padding:9px !important;}' + '.sb-chat-header .sb-conn{padding:9px !important;}' +
'.sb-chat-header .sb-hdr-sub{display:none !important;}' + '.sb-chat-header .sb-hdr-sub{display:none !important;}' +
// Disconnect: compact icon-only square, red-tinted, matching the call buttons.
'.sb-chat-header .sb-disconnect{width:40px !important;height:40px !important;padding:0 !important;gap:0 !important;justify-content:center !important;border-radius:9px !important;color:#e5727a !important;border-color:rgba(229,114,122,0.28) !important;}' +
'}' + '}' +
'@media (max-width:480px){.sb-chat-header{padding-right:12px !important;gap:8px !important;}}' // Composer mode chips: one horizontally-scrollable row instead of wrapping
// to two ugly rows on narrow screens.
'@media (max-width:600px){' +
'.sb-chips{flex-wrap:nowrap !important;justify-content:flex-start !important;overflow-x:auto;-webkit-overflow-scrolling:touch;scrollbar-width:none;}' +
'.sb-chips::-webkit-scrollbar{display:none;}' +
'.sb-chips>*{flex:0 0 auto !important;}' +
'.sb-chips .sb-chips-right{flex-wrap:nowrap !important;}' +
'.sb-chips .sb-chip{white-space:nowrap;}' +
'}' +
'@media (max-width:480px){.sb-chat-header{padding-right:12px !important;}}'
} }); } });
const header = React.createElement('header', { const header = React.createElement('header', {
key: 'hdr', className: 'sb-chat-header', style: { flex: 'none', display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '24px', padding: '0 20px', height: '64px', borderBottom: '1px solid rgba(255,255,255,0.06)', background: 'rgba(18,18,20,0.72)', backdropFilter: 'blur(14px)', WebkitBackdropFilter: 'blur(14px)' } key: 'hdr', className: 'sb-chat-header', style: { flex: 'none', display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '24px', padding: '0 20px', height: '64px', borderBottom: '1px solid rgba(255,255,255,0.06)', background: 'rgba(18,18,20,0.72)', backdropFilter: 'blur(14px)', WebkitBackdropFilter: 'blur(14px)' }
@@ -1981,13 +1995,38 @@ import {
: React.createElement('div', { key: 'txt', style: { lineHeight: 1.2, minWidth: 0 } }, [ : React.createElement('div', { key: 'txt', style: { lineHeight: 1.2, minWidth: 0 } }, [
React.createElement('div', { key: 'r1', style: { display: 'flex', alignItems: 'center', gap: '7px' } }, [ React.createElement('div', { key: 'r1', style: { display: 'flex', alignItems: 'center', gap: '7px' } }, [
React.createElement('span', { key: 'n', style: { fontSize: '15px', fontWeight: 800, letterSpacing: '-0.3px', color: '#f4f4f6', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' } }, title || 'Secure chat'), React.createElement('span', { key: 'n', style: { fontSize: '15px', fontWeight: 800, letterSpacing: '-0.3px', color: '#f4f4f6', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' } }, title || 'Secure chat'),
React.createElement('button', { key: 'edit', onClick: startRename, title: 'Rename chat (local only)', style: { flex: 'none', width: '24px', height: '24px', borderRadius: '7px', display: 'grid', placeItems: 'center', border: 'none', background: 'transparent', color: '#56565e', cursor: 'pointer' } }, React.createElement('i', { className: 'fas fa-pen', style: { fontSize: '11px' } })) React.createElement('button', { key: 'edit', className: 'sb-rename-btn', onClick: startRename, title: 'Rename chat (local only)', style: { flex: 'none', width: '24px', height: '24px', borderRadius: '7px', display: 'grid', placeItems: 'center', border: 'none', background: 'transparent', color: '#56565e', cursor: 'pointer' } }, React.createElement('i', { className: 'fas fa-pen', style: { fontSize: '11px' } }))
]), ]),
React.createElement('div', { key: 'r2', className: 'sb-hdr-sub', style: { fontSize: '11px', color: '#6b6b73', fontWeight: 500, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' } }, isOffline ? 'No network · reconnecting' : (peerPresenceWord || (onlineConnected ? 'P2P · end-to-end encrypted' : (status === 'peer_disconnected' ? 'Peer disconnected' : (status === 'disconnected' ? 'Disconnected' : 'Connecting…'))))) React.createElement('div', { key: 'r2', className: 'sb-hdr-sub', style: { fontSize: '11px', color: '#6b6b73', fontWeight: 500, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' } }, isOffline ? 'No network · reconnecting' : (peerPresenceWord || (onlineConnected ? 'P2P · end-to-end encrypted' : (status === 'peer_disconnected' ? 'Peer disconnected' : (status === 'disconnected' ? 'Disconnected' : 'Connecting…')))))
]) ])
]), ]),
secBtn, secBtn,
React.createElement('div', { key: 'right', className: 'sb-hdr-right', style: { display: 'flex', alignItems: 'center', gap: '9px' } }, [ React.createElement('div', { key: 'right', className: 'sb-hdr-right', style: { display: 'flex', alignItems: 'center', gap: '9px' } }, [
// Encrypted call buttons enabled only once the session is
// connected AND SAS-verified (the manager enforces the same
// gate; this just reflects it). Media rides the verified
// DTLS-SRTP transport, so calls inherit the E2E encryption.
...((() => {
const callReady = connected && webrtcManager && webrtcManager.isVerified === true;
const startCall = (video) => {
if (!callReady || !webrtcManager) return;
try { const p = webrtcManager.startCall(video); if (p && p.catch) p.catch(() => {}); }
catch (_) {}
};
// Exact design spec: 40×40, 9px radius, SVG phone/video icons,
// green hover (via the .sb-call-btn CSS rule below).
const callBtnStyle = {
width: '40px', height: '40px', borderRadius: '9px', display: 'grid', placeItems: 'center',
border: '1px solid rgba(255,255,255,0.08)', background: 'rgba(255,255,255,0.02)',
color: callReady ? '#9a9aa2' : '#3f3f47', cursor: callReady ? 'pointer' : 'not-allowed', transition: 'all .15s'
};
const PHONE_SVG = '<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.8 19.8 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6A19.8 19.8 0 0 1 2.12 4.18 2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72c.13.96.36 1.9.7 2.81a2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45c.91.34 1.85.57 2.81.7A2 2 0 0 1 22 16.92z"/></svg>';
const VIDEO_SVG = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M23 7l-7 5 7 5V7z"/><rect x="1" y="5" width="15" height="14" rx="2.5"/></svg>';
return [
React.createElement('button', { key: 'call-audio', className: callReady ? 'sb-call-btn' : 'sb-call-btn sb-call-off', disabled: !callReady, onClick: () => startCall(false), title: callReady ? 'Start encrypted voice call' : 'Verify the session to enable calls', style: callBtnStyle, dangerouslySetInnerHTML: { __html: PHONE_SVG } }),
React.createElement('button', { key: 'call-video', className: callReady ? 'sb-call-btn' : 'sb-call-btn sb-call-off', disabled: !callReady, onClick: () => startCall(true), title: callReady ? 'Start encrypted video call' : 'Verify the session to enable calls', style: callBtnStyle, dangerouslySetInnerHTML: { __html: VIDEO_SVG } })
];
})()),
React.createElement('div', { key: 'conn', className: 'sb-conn', style: { display: 'flex', alignItems: 'center', gap: '8px', padding: '8px 13px', borderRadius: '9px', border: '1px solid rgba(255,255,255,0.07)', background: 'rgba(255,255,255,0.02)' } }, [ React.createElement('div', { key: 'conn', className: 'sb-conn', style: { display: 'flex', alignItems: 'center', gap: '8px', padding: '8px 13px', borderRadius: '9px', border: '1px solid rgba(255,255,255,0.07)', background: 'rgba(255,255,255,0.02)' } }, [
React.createElement('span', { key: 'dot', style: { flex: 'none', width: '7px', height: '7px', borderRadius: '50%', background: connDot, boxShadow: connGlow } }), React.createElement('span', { key: 'dot', style: { flex: 'none', width: '7px', height: '7px', borderRadius: '50%', background: connDot, boxShadow: connGlow } }),
React.createElement('span', { key: 't', className: 'sb-conn-text', style: { fontSize: '13px', fontWeight: 600, color: '#cfcfd4' } }, connLabel) React.createElement('span', { key: 't', className: 'sb-conn-text', style: { fontSize: '13px', fontWeight: 600, color: '#cfcfd4' } }, connLabel)
@@ -2295,7 +2334,7 @@ import {
); );
// ---- chips row ---- // ---- chips row ----
const chipsRow = React.createElement('div', { key: 'chips', style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: '8px', marginBottom: '10px' } }, [ const chipsRow = React.createElement('div', { key: 'chips', className: 'sb-chips', style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: '8px', marginBottom: '10px' } }, [
React.createElement('button', { key: 'files', onClick: () => { React.createElement('button', { key: 'files', onClick: () => {
if (showFileTransfer && fileSendMode) { setShowFileTransfer(false); setFileSendMode(false); } if (showFileTransfer && fileSendMode) { setShowFileTransfer(false); setFileSendMode(false); }
else { setShowFileTransfer(true); setFileSendMode(true); } else { setShowFileTransfer(true); setFileSendMode(true); }
@@ -2303,7 +2342,7 @@ import {
React.createElement('i', { key: 'i', className: 'fas fa-paperclip', style: { fontSize: '13px' } }), React.createElement('i', { key: 'i', className: 'fas fa-paperclip', style: { fontSize: '13px' } }),
(showFileTransfer && fileSendMode) ? 'Hide files' : 'Send files' (showFileTransfer && fileSendMode) ? 'Hide files' : 'Send files'
]), ]),
React.createElement('div', { key: 'right', style: { display: 'flex', alignItems: 'center', gap: '6px', flexWrap: 'wrap' } }, [ React.createElement('div', { key: 'right', className: 'sb-chips-right', style: { display: 'flex', alignItems: 'center', gap: '6px', flexWrap: 'wrap' } }, [
React.createElement('button', { key: 'code', onClick: () => setCodeMode(v => !v), className: 'sb-chip', style: chipStyle(codeMode) }, [ React.createElement('button', { key: 'code', onClick: () => setCodeMode(v => !v), className: 'sb-chip', style: chipStyle(codeMode) }, [
React.createElement('i', { key: 'i', className: 'fas fa-code', style: { fontSize: '13px' } }), 'Code' React.createElement('i', { key: 'i', className: 'fas fa-code', style: { fontSize: '13px' } }), 'Code'
]), ]),
@@ -2380,7 +2419,7 @@ import {
}) })
); );
const composer = React.createElement('footer', { key: 'composer', style: { flex: 'none', padding: '12px 20px 18px', background: '#0f0f11', borderTop: '1px solid rgba(255,255,255,0.05)' } }, const composer = React.createElement('footer', { key: 'composer', style: { flex: 'none', padding: '12px 20px calc(18px + env(safe-area-inset-bottom, 0px))', background: '#0f0f11', borderTop: '1px solid rgba(255,255,255,0.05)' } },
React.createElement('div', { style: { maxWidth: '1000px', margin: '0 auto' } }, React.createElement('div', { style: { maxWidth: '1000px', margin: '0 auto' } },
isRecording isRecording
? [recordingBar] ? [recordingBar]
@@ -2397,10 +2436,17 @@ import {
key: 'chat-header', status: status, onDisconnect: onDisconnect, webrtcManager: webrtcManager, title: title, isOffline: isOffline, peerPresence: peerPresence, onRenameTitle: onRenameTitle key: 'chat-header', status: status, onDisconnect: onDisconnect, webrtcManager: webrtcManager, title: title, isOffline: isOffline, peerPresence: peerPresence, onRenameTitle: onRenameTitle
}); });
// Encrypted call overlay renders nothing when idle; otherwise covers
// the chat (expanded) or docks bottom-right (minimized). It subscribes
// to the active session manager's call state internally.
const callOverlay = window.CallUIComponent && React.createElement(window.CallUIComponent, {
key: 'call-overlay', webrtcManager: webrtcManager, peerTitle: title
});
return React.createElement('div', { return React.createElement('div', {
className: 'chat-container', className: 'chat-container',
style: { display: 'flex', flexDirection: 'column', height: '100vh', background: '#0f0f11', color: '#e8e8eb' } style: { position: 'relative', display: 'flex', flexDirection: 'column', height: '100vh', background: '#0f0f11', color: '#e8e8eb' }
}, [chatHeader, messagesArea, scrollBtn, composer]); }, [chatHeader, messagesArea, scrollBtn, composer, callOverlay]);
}; };
@@ -2501,7 +2547,7 @@ import {
// transparent background no black tile. // transparent background no black tile.
const brandMark = (size) => h('div', { style: { width: size + 'px', height: size + 'px', flex: 'none', display: 'grid', placeItems: 'center' } }, const brandMark = (size) => h('div', { style: { width: size + 'px', height: size + 'px', flex: 'none', display: 'grid', placeItems: 'center' } },
h('img', { src: '/logo/securebit-mark.svg', alt: 'SecureBit', style: { width: '100%', height: '100%', objectFit: 'contain', display: 'block' } })); h('img', { src: '/logo/securebit-mark.svg', alt: 'SecureBit', style: { width: '100%', height: '100%', objectFit: 'contain', display: 'block' } }));
const collapseBtn = (svg, title) => h('button', { onClick: onToggleCollapse, title, style: { width: '30px', height: '30px', borderRadius: '8px', display: 'grid', placeItems: 'center', border: '1px solid rgba(255,255,255,0.07)', background: 'transparent', color: '#8a8a92', cursor: 'pointer' }, dangerouslySetInnerHTML: { __html: svg } }); const collapseBtn = (svg, title) => h('button', { className: 'sb-collapse-btn', onClick: onToggleCollapse, title, style: { width: '30px', height: '30px', borderRadius: '8px', display: 'grid', placeItems: 'center', border: '1px solid rgba(255,255,255,0.07)', background: 'transparent', color: '#8a8a92', cursor: 'pointer' }, dangerouslySetInnerHTML: { __html: svg } });
// ---- Expanded rail content ---- // ---- Expanded rail content ----
// ---- Presence ("You" status) panel ---- // ---- Presence ("You" status) panel ----
@@ -2605,7 +2651,20 @@ import {
return h(React.Fragment, null, [ return h(React.Fragment, null, [
// Responsive behaviour (inline styles can't express media queries). // Responsive behaviour (inline styles can't express media queries).
h('style', { key: 'css', dangerouslySetInnerHTML: { __html: '@media (max-width:1023px){.sb-rail{display:none !important;}.sb-burger{display:grid !important;}}@media (min-width:1024px){.sb-drawer-overlay{display:none !important;}}' } }), h('style', { key: 'css', dangerouslySetInnerHTML: { __html: '@media (max-width:1023px){.sb-rail{display:none !important;}.sb-burger{display:grid !important;}}@media (min-width:1024px){.sb-drawer-overlay{display:none !important;}}.sb-mobile-drawer .sb-collapse-btn{display:none !important;}' +
// Dark app background everywhere so mobile safe-areas / overscroll show
// black, not the grey landing background (the "grey band" bug).
'html,body{background:#0f0f11 !important;overscroll-behavior:none;}' +
// App-shell height tracks the *visual* viewport (--sb-vh, set from the
// VisualViewport API) so the layout shrinks when the on-screen keyboard
// opens no grey gap under the composer. Falls back to 100dvh, then 100vh.
'.sb-app-shell{height:var(--sb-vh,100dvh) !important;}.sb-app-col{height:var(--sb-vh,100dvh) !important;}.chat-container{height:var(--sb-vh,100dvh) !important;}' +
// iOS Safari zooms the page when a focused field has font-size < 16px.
// Force 16px on mobile inputs to stop the zoom-and-reflow on tap.
'@media (max-width:768px){textarea,input,select{font-size:16px !important;}}' +
// Declutter the mobile chat header: hide the inline rename pencil (rename
// is still available by double-tapping a chat in the drawer).
'@media (max-width:768px){.sb-rename-btn{display:none !important;}}' } }),
// Desktop rail // Desktop rail
h('aside', { key: 'rail', className: 'sb-rail', style: railStyle }, inner), h('aside', { key: 'rail', className: 'sb-rail', style: railStyle }, inner),
// Mobile drawer overlay // Mobile drawer overlay
@@ -2613,7 +2672,13 @@ import {
key: 'drawer', className: 'sb-drawer-overlay', key: 'drawer', className: 'sb-drawer-overlay',
onClick: onCloseDrawer, onClick: onCloseDrawer,
style: { position: 'fixed', inset: 0, zIndex: 60, background: 'rgba(6,6,8,0.6)', backdropFilter: 'blur(4px)', WebkitBackdropFilter: 'blur(4px)', display: drawerOpen ? 'block' : 'none' } style: { position: 'fixed', inset: 0, zIndex: 60, background: 'rgba(6,6,8,0.6)', backdropFilter: 'blur(4px)', WebkitBackdropFilter: 'blur(4px)', display: drawerOpen ? 'block' : 'none' }
}, h('aside', { onClick: (e) => e.stopPropagation(), style: { position: 'absolute', left: 0, top: 0, bottom: 0, width: '292px', display: 'flex', flexDirection: 'column', background: '#0c0c0e', borderRight: '1px solid rgba(255,255,255,0.06)', boxShadow: '0 0 60px rgba(0,0,0,0.6)' } }, expandedInner)) }, h('aside', { className: 'sb-mobile-drawer', onClick: (e) => e.stopPropagation(), style: { position: 'absolute', left: 0, top: 0, bottom: 0, width: 'min(292px, 86vw)', display: 'flex', flexDirection: 'column', background: '#0c0c0e', borderRight: '1px solid rgba(255,255,255,0.06)', boxShadow: '0 0 60px rgba(0,0,0,0.6)' } }, [
// Explicit close button the drawer's own header only has a
// "collapse" chevron (a desktop-rail action), so on mobile there was
// no obvious way to dismiss it. This X closes the drawer reliably.
h('button', { key: 'x', onClick: onCloseDrawer, title: 'Close menu', 'aria-label': 'Close menu', style: { position: 'absolute', top: '15px', right: '13px', zIndex: 2, width: '34px', height: '34px', borderRadius: '9px', display: 'grid', placeItems: 'center', border: '1px solid rgba(255,255,255,0.1)', background: 'rgba(255,255,255,0.05)', color: '#cfcfd4', cursor: 'pointer' } }, h('i', { className: 'fas fa-xmark', style: { fontSize: '16px' } })),
expandedInner
]))
]); ]);
}; };
@@ -2777,6 +2842,27 @@ import {
window.addEventListener('online', goOnline); window.addEventListener('online', goOnline);
return () => { window.removeEventListener('offline', goOffline); window.removeEventListener('online', goOnline); }; return () => { window.removeEventListener('offline', goOffline); window.removeEventListener('online', goOnline); };
}, []); }, []);
// Keyboard-aware viewport height. iOS Safari does not shrink the layout
// viewport when the on-screen keyboard opens, so a fixed 100dvh shell leaves
// a grey gap under the composer. Mirror the VisualViewport height into a CSS
// var (--sb-vh) that the app shell uses, so the layout tracks the keyboard.
React.useEffect(() => {
const vv = (typeof window !== 'undefined') ? window.visualViewport : null;
const apply = () => {
const h = vv ? vv.height : (window.innerHeight || 0);
if (h) document.documentElement.style.setProperty('--sb-vh', h + 'px');
};
apply();
if (vv) { vv.addEventListener('resize', apply); vv.addEventListener('scroll', apply); }
window.addEventListener('resize', apply);
window.addEventListener('orientationchange', apply);
return () => {
if (vv) { vv.removeEventListener('resize', apply); vv.removeEventListener('scroll', apply); }
window.removeEventListener('resize', apply);
window.removeEventListener('orientationchange', apply);
};
}, []);
const [relayOnlyMode, setRelayOnlyMode] = React.useState(() => { const [relayOnlyMode, setRelayOnlyMode] = React.useState(() => {
try { return localStorage.getItem('securebit_relay_only_mode') === 'true'; } catch { return false; } try { return localStorage.getItem('securebit_relay_only_mode') === 'true'; } catch { return false; }
}); });
@@ -3447,7 +3533,7 @@ import {
} }
} }
handleMessage(' SecureBit.chat Enhanced Security Edition v5.4.10 - ECDH + DTLS + SAS initialized. Ready to establish a secure connection with ECDH key exchange, DTLS fingerprint verification, and SAS authentication to prevent MITM attacks.', 'system'); handleMessage(' SecureBit.chat Enhanced Security Edition v5.5.0 - ECDH + DTLS + SAS initialized. Ready to establish a secure connection with ECDH key exchange, DTLS fingerprint verification, and SAS authentication to prevent MITM attacks.', 'system');
// Setup file transfer callbacks (id-bound to THIS session's manager). // Setup file transfer callbacks (id-bound to THIS session's manager).
manager.setFileTransferCallbacks( manager.setFileTransferCallbacks(
@@ -5331,11 +5417,13 @@ import {
}); });
return React.createElement('div', { return React.createElement('div', {
className: "minimal-bg", className: showSidebar ? "minimal-bg sb-app-shell" : "minimal-bg",
// With the rail visible the app is a fixed-height shell (rail + column // With the rail visible the app is a fixed-height shell (rail + column
// fill the viewport, design-style). Otherwise it's the scrollable landing. // fill the viewport, design-style). Otherwise it's the scrollable landing.
// flexDirection:'row' is explicit the .minimal-bg class forces // flexDirection:'row' is explicit the .minimal-bg class forces
// flex-direction:column, which would otherwise stack the rail ABOVE the chat. // flex-direction:column, which would otherwise stack the rail ABOVE the chat.
// height:100vh is the fallback; .sb-app-shell upgrades it to 100dvh on
// mobile so the shell fits under the browser toolbar (header stays put).
style: showSidebar ? { display: 'flex', flexDirection: 'row', height: '100vh', width: '100%', overflow: 'hidden' } : { minHeight: '100vh' } style: showSidebar ? { display: 'flex', flexDirection: 'row', height: '100vh', width: '100%', overflow: 'hidden' } : { minHeight: '100vh' }
}, [ }, [
showSidebar && React.createElement(SessionsSidebar, { showSidebar && React.createElement(SessionsSidebar, {
@@ -5361,7 +5449,7 @@ import {
}), }),
React.createElement('div', { React.createElement('div', {
key: 'app-column', key: 'app-column',
className: showSidebar ? 'minimal-bg' : 'minimal-bg min-h-screen', className: showSidebar ? 'minimal-bg sb-app-col' : 'minimal-bg min-h-screen',
style: showSidebar ? { flex: 1, minWidth: 0, height: '100vh', overflow: 'hidden', display: 'flex', flexDirection: 'column' } : {} style: showSidebar ? { flex: 1, minWidth: 0, height: '100vh', overflow: 'hidden', display: 'flex', flexDirection: 'column' } : {}
}, [ }, [
// Advanced network settings now render inside the connection // Advanced network settings now render inside the connection
+266
View File
@@ -0,0 +1,266 @@
// Encrypted voice / video call UI for SecureBit.chat.
//
// Faithful implementation of the "SecureBit Chat.dc.html" design: the voice-call
// (expanded), video-call (expanded) and minimized-widget states use the exact
// icons, sizes, colours and layout from that mockup. This component is a thin
// presentational shell over the call subsystem in EnhancedSecureWebRTCManager —
// all media and crypto live there. Media is DTLS-SRTP encrypted on the same
// SAS-verified transport as the chat (see the manager's call section).
//
// Loaded as a native ES module (no JSX compilation), so this file uses
// React.createElement directly, mirroring the other components/ui modules.
const CallUIComponent = ({ webrtcManager, peerTitle }) => {
const h = React.createElement;
const MONO = "'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, monospace";
// Exact SVG icons lifted from the design. Rendered via innerHTML (CSP-safe —
// markup, not script); `stroke="currentColor"` inherits the button's colour.
const ICON = {
lock: '<path d="M7 11V7a5 5 0 0 1 10 0v4"/><rect x="4.5" y="11" width="15" height="9" rx="2.2"/>',
minimize: '<path d="M9 4v4a1 1 0 0 1-1 1H4M15 4v4a1 1 0 0 0 1 1h4M9 20v-4a1 1 0 0 0-1-1H4M15 20v-4a1 1 0 0 1 1-1h4"/>',
expand: '<path d="M15 3h6v6M9 21H3v-6M21 3l-7 7M3 21l7-7"/>',
user: '<circle cx="12" cy="8" r="3.6"/><path d="M5 20c0-3.5 3-5.5 7-5.5s7 2 7 5.5"/>',
micOn: '<rect x="9" y="3" width="6" height="11" rx="3"/><path d="M5 11a7 7 0 0 0 14 0"/><path d="M12 18v3"/>',
micOff: '<path d="M9 9v-1a3 3 0 0 1 5.1-2.1M15 11v3a3 3 0 0 1-4.6 2.5"/><path d="M5 11a7 7 0 0 0 10.3 6.2M19 11a7 7 0 0 1-.4 2.3"/><path d="M12 18v3"/><path d="M3 3l18 18"/>',
camOn: '<path d="M23 7l-7 5 7 5V7z"/><rect x="1" y="5" width="15" height="14" rx="2.5"/>',
camOff: '<path d="M16 16H3a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1h2l2-2M11 6h2l7-3v14M2 2l20 20"/>',
flip: '<path d="M3 7h3l2-2h8l2 2h3v12H3z"/><path d="M9.5 13a2.5 2.5 0 0 1 5 0M14.5 13l-1.3-1.3M14.5 13l1.3-1.3"/>',
phone: '<path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.8 19.8 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6A19.8 19.8 0 0 1 2.12 4.18 2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72c.13.96.36 1.9.7 2.81a2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45c.91.34 1.85.57 2.81.7A2 2 0 0 1 22 16.92z"/>',
phoneHangup: '<path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.8 19.8 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6A19.8 19.8 0 0 1 2.12 4.18 2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72c.13.96.36 1.9.7 2.81a2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45c.91.34 1.85.57 2.81.7A2 2 0 0 1 22 16.92z" transform="rotate(135 12 12)"/>'
};
const svg = (inner, size, sw) => h('span', {
style: { display: 'grid', placeItems: 'center', width: size + 'px', height: size + 'px' },
dangerouslySetInnerHTML: { __html: `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="${sw}" stroke-linecap="round" stroke-linejoin="round">${inner}</svg>` }
});
const [call, setCall] = React.useState(() => (webrtcManager?.getCallState?.() || { phase: 'idle', active: false }));
const [minimized, setMinimized] = React.useState(false);
const [seconds, setSeconds] = React.useState(0);
const remoteVideoRef = React.useRef(null);
const remoteAudioRef = React.useRef(null);
const selfVideoRef = React.useRef(null);
// Subscribe to call-state changes from the active session's manager.
React.useEffect(() => {
if (!webrtcManager) return;
const onState = (state) => setCall(state);
const prev = webrtcManager.onCallStateChanged;
webrtcManager.onCallStateChanged = onState;
setCall(webrtcManager.getCallState ? webrtcManager.getCallState() : { phase: 'idle', active: false });
return () => { if (webrtcManager.onCallStateChanged === onState) webrtcManager.onCallStateChanged = prev || null; };
}, [webrtcManager]);
const phase = call.phase || 'idle';
const active = !!call.active;
const isVideo = !!call.withVideo || !!call.remoteHasVideo;
// Attach media streams to the <video>/<audio> elements AND explicitly play():
// just setting srcObject does not reliably start playback (esp. when the
// element mounts before the stream exists), so the peer's audio stayed silent.
React.useEffect(() => {
const remoteStream = webrtcManager?.getRemoteMediaStream?.();
const localStream = webrtcManager?.getLocalMediaStream?.();
const attach = (el, stream, muted) => {
if (!el || !stream) return;
if (el.srcObject !== stream) { el.muted = muted; el.srcObject = stream; }
const p = el.play && el.play();
if (p && p.catch) p.catch(() => {});
};
// Remote audio via the dedicated <audio>; remote <video> stays muted so the
// peer's audio isn't played twice.
attach(remoteAudioRef.current, remoteStream, false, 'remoteAudio');
attach(remoteVideoRef.current, remoteStream, true, 'remoteVideo');
attach(selfVideoRef.current, localStream, true, 'selfVideo');
});
// Call duration timer (starts when the call goes active).
React.useEffect(() => {
if (phase !== 'active') { setSeconds(0); return; }
const started = Date.now();
const iv = setInterval(() => setSeconds(Math.floor((Date.now() - started) / 1000)), 1000);
return () => clearInterval(iv);
}, [phase]);
React.useEffect(() => { if (phase === 'idle') setMinimized(false); }, [phase]);
if (!active || phase === 'idle' || phase === 'ended') return null;
const fmt = (s) => `${String(Math.floor(s / 60)).padStart(2, '0')}:${String(s % 60).padStart(2, '0')}`;
const ringing = phase === 'outgoing' || phase === 'connecting';
const callStatus = phase === 'outgoing' ? 'Ringing…'
: phase === 'connecting' ? 'Connecting…'
: phase === 'active' ? fmt(seconds) : 'Ringing…';
const name = peerTitle || 'Secure peer';
// ── shared styles (from the design) ──────────────────────────────────────
const ctrlBase = {
width: '56px', height: '56px', borderRadius: '50%', display: 'grid', placeItems: 'center',
border: '1px solid rgba(255,255,255,0.1)', background: 'rgba(255,255,255,0.05)',
color: '#cfcfd4', cursor: 'pointer', transition: 'all .15s'
};
const dangerCtrl = { ...ctrlBase, background: '#e5484d', color: '#fff', border: '1px solid transparent' };
const endBtn = {
width: '56px', height: '56px', borderRadius: '50%', display: 'grid', placeItems: 'center',
border: 'none', background: '#e5484d', color: '#fff', cursor: 'pointer',
boxShadow: '0 8px 24px rgba(229,72,77,0.35)', transition: 'transform .15s'
};
const minimizeBtn = (light) => ({
width: '36px', height: '36px', borderRadius: '9px', display: 'grid', placeItems: 'center',
border: '1px solid rgba(255,255,255,' + (light ? '0.15' : '0.1') + ')',
background: light ? 'rgba(0,0,0,0.35)' : 'rgba(255,255,255,0.04)',
color: light ? '#fff' : '#cfcfd4', cursor: 'pointer', transition: 'all .15s'
});
const encBadge = h('span', { key: 'enc', style: { display: 'inline-flex', alignItems: 'center', gap: '4px', fontSize: '11px', fontWeight: 600, color: '#3ecf8e' } },
[svg(ICON.lock, 11, 2), 'Encrypted']);
// Connection-quality indicator — driven by the adaptation controller's
// getStats (loss + RTT). Signal bars + label; hidden until there's data.
const QUALITY = {
excellent: { bars: 4, color: '#3ecf8e', label: 'Excellent' },
good: { bars: 3, color: '#3ecf8e', label: 'Good' },
fair: { bars: 2, color: '#e3c84e', label: 'Fair' },
poor: { bars: 1, color: '#e5727a', label: 'Weak' },
};
const qualityIndicator = (compact) => {
const q = QUALITY[call.quality];
if (!q) return null;
const bars = h('span', { key: 'bars', style: { display: 'inline-flex', alignItems: 'flex-end', gap: '2px', height: '14px' } },
[0, 1, 2, 3].map(i => h('span', {
key: i, style: { width: '3px', height: (5 + i * 3) + 'px', borderRadius: '1px', background: i < q.bars ? q.color : 'rgba(255,255,255,0.18)' }
})));
if (compact) return bars;
return h('span', { key: 'q', title: 'Connection quality', style: { display: 'inline-flex', alignItems: 'center', gap: '6px', fontSize: '11.5px', fontWeight: 600, color: q.color } }, [bars, q.label]);
};
// ── actions ──────────────────────────────────────────────────────────────
const doAccept = () => webrtcManager?.acceptCall?.();
const doDecline = () => webrtcManager?.declineCall?.();
const doEnd = () => { setMinimized(false); webrtcManager?.endCall?.(); };
const doMute = () => webrtcManager?.toggleMic?.();
const doCamera = () => webrtcManager?.toggleCamera?.();
const doFlip = () => webrtcManager?.switchCamera?.();
const doUpgrade = () => webrtcManager?.upgradeToVideo?.();
const hiddenAudio = h('audio', { key: 'ra', ref: remoteAudioRef, autoPlay: true, playsInline: true, style: { display: 'none' } });
const labeled = (key, btn, label) => h('div', { key, style: { display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '8px' } },
[btn, h('span', { key: 'l', style: { fontFamily: MONO, fontSize: '10.5px', color: '#8a8a92' } }, label)]);
// Avatar disc with optional ringing pulse (voice + incoming).
const avatarDisc = (size, ring) => h('div', { key: 'av', style: { position: 'relative', width: '120px', height: '120px', marginBottom: '28px', display: 'grid', placeItems: 'center' } }, [
ring && h('span', { key: 'p1', style: { position: 'absolute', inset: 0, borderRadius: '50%', border: '1.5px solid rgba(240,137,42,0.5)', animation: 'sbCallPulse 2s ease-out infinite' } }),
ring && h('span', { key: 'p2', style: { position: 'absolute', inset: 0, borderRadius: '50%', border: '1.5px solid rgba(240,137,42,0.4)', animation: 'sbCallPulse 2s ease-out infinite', animationDelay: '1s' } }),
h('div', { key: 'c', style: { width: '104px', height: '104px', borderRadius: '50%', display: 'grid', placeItems: 'center', background: 'radial-gradient(circle at 35% 30%, #2a2a30, #161618)', border: '1px solid rgba(255,255,255,0.1)', boxShadow: '0 12px 30px rgba(0,0,0,0.4)', color: '#8a8a92' } }, svg(ICON.user, size, 1.6))
]);
// ── INCOMING (ringing) prompt ────────────────────────────────────────────
if (phase === 'incoming') {
return h('div', { style: { position: 'absolute', inset: 0, zIndex: 40, display: 'flex', flexDirection: 'column', background: 'radial-gradient(680px 460px at 50% 36%, rgba(240,137,42,0.08), transparent 70%), #0d0d0f', animation: 'sbExpand .2s ease' } }, [
hiddenAudio,
h('div', { key: 'top', style: { flex: 'none', display: 'flex', alignItems: 'center', justifyContent: 'flex-start', padding: '16px 18px' } },
h('span', { style: { display: 'inline-flex', alignItems: 'center', gap: '7px', fontSize: '12px', fontWeight: 600, color: '#3ecf8e' } }, [svg(ICON.lock, 13, 2), 'Encrypted call'])),
h('div', { key: 'mid', style: { flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center' } }, [
avatarDisc(46, true),
h('div', { key: 'nm', style: { fontSize: '24px', fontWeight: 800, letterSpacing: '-0.5px', color: '#f4f4f6' } }, name),
h('div', { key: 'st', style: { fontFamily: MONO, fontSize: '14px', fontWeight: 500, color: '#9a9aa2', marginTop: '8px' } }, call.withVideo ? 'Incoming video call' : 'Incoming call')
]),
h('div', { key: 'ctrls', style: { flex: 'none', display: 'flex', alignItems: 'flex-start', justifyContent: 'center', gap: '48px', padding: '28px 24px 40px' } }, [
labeled('dec', h('button', { onClick: doDecline, title: 'Decline', style: { ...endBtn, width: '62px', height: '62px' } }, svg(ICON.phoneHangup, 24, 1.9)), 'Decline'),
labeled('acc', h('button', { onClick: doAccept, title: 'Accept', style: { width: '62px', height: '62px', borderRadius: '50%', display: 'grid', placeItems: 'center', border: 'none', background: '#3ecf8e', color: '#06231a', cursor: 'pointer', boxShadow: '0 8px 24px rgba(62,207,142,0.35)' } }, svg(ICON.phone, 24, 1.9)), 'Accept')
])
]);
}
// ── MINIMIZED widget ─────────────────────────────────────────────────────
if (minimized) {
return h('div', { style: { position: 'absolute', bottom: '18px', right: '18px', zIndex: 40, width: '236px', borderRadius: '14px', overflow: 'hidden', background: '#161618', border: '1px solid rgba(255,255,255,0.1)', boxShadow: '0 18px 44px rgba(0,0,0,0.55)', animation: 'sbExpand .18s ease' } }, [
hiddenAudio,
isVideo && h('div', { key: 'v', style: { position: 'relative', height: '132px', background: '#111' } }, [
h('video', { key: 'rv', ref: remoteVideoRef, autoPlay: true, muted: true, playsInline: true, style: { width: '100%', height: '100%', objectFit: 'cover', display: 'block' } }),
!call.remoteHasVideo && h('div', { key: 'off', style: { position: 'absolute', inset: 0, display: 'grid', placeItems: 'center', background: 'linear-gradient(120deg,#15151b,#1d1a24)', color: '#6b6b73' } }, svg(ICON.camOff, 22, 1.8)),
h('span', { key: 's', style: { position: 'absolute', top: '8px', left: '9px', fontFamily: MONO, fontSize: '11px', fontWeight: 600, color: '#fff', padding: '3px 7px', borderRadius: '6px', background: 'rgba(0,0,0,0.5)' } }, callStatus)
]),
h('div', { key: 'bar', style: { display: 'flex', alignItems: 'center', gap: '11px', padding: '11px 12px' } }, [
h('span', { key: 'ic', style: { position: 'relative', flex: 'none', width: '34px', height: '34px', borderRadius: '9px', display: 'grid', placeItems: 'center', background: 'rgba(62,207,142,0.1)', border: '1px solid rgba(62,207,142,0.25)', color: '#3ecf8e' } }, svg(ICON.user, 16, 1.9)),
h('div', { key: 'tx', style: { flex: 1, minWidth: 0 } }, [
h('div', { key: 'n', style: { fontSize: '13px', fontWeight: 700, color: '#f4f4f6', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' } }, name),
h('div', { key: 's', style: { display: 'flex', alignItems: 'center', gap: '7px', fontFamily: MONO, fontSize: '11px', color: '#9a9aa2' } }, [
(isVideo ? 'Video · ' : 'Voice · ') + callStatus,
phase === 'active' && qualityIndicator(true)
])
]),
h('button', { key: 'exp', onClick: () => setMinimized(false), title: 'Expand', style: { flex: 'none', width: '32px', height: '32px', borderRadius: '8px', display: 'grid', placeItems: 'center', border: 'none', background: 'rgba(255,255,255,0.05)', color: '#cfcfd4', cursor: 'pointer', transition: 'all .15s' } }, svg(ICON.expand, 15, 2)),
h('button', { key: 'end', onClick: doEnd, title: 'End call', style: { flex: 'none', width: '32px', height: '32px', borderRadius: '8px', display: 'grid', placeItems: 'center', border: 'none', background: '#e5484d', color: '#fff', cursor: 'pointer', transition: 'transform .15s' } }, svg(ICON.phoneHangup, 15, 2))
])
]);
}
// ── VIDEO call (expanded) ────────────────────────────────────────────────
if (isVideo) {
return h('div', { style: { position: 'absolute', inset: 0, zIndex: 40, overflow: 'hidden', background: '#0a0a0c', animation: 'sbExpand .2s ease' } }, [
hiddenAudio,
call.remoteHasVideo
? h('video', { key: 'rv', ref: remoteVideoRef, autoPlay: true, muted: true, playsInline: true, style: { position: 'absolute', inset: 0, width: '100%', height: '100%', objectFit: 'cover', background: '#0a0a0c' } })
: h('div', { key: 'ph', style: { position: 'absolute', inset: 0, background: 'linear-gradient(120deg, #15151b, #1d1a24, #161620)', backgroundSize: '200% 200%', animation: 'sbLiveBg 9s ease-in-out infinite', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: '18px' } }, [
h('div', { key: 'a', style: { width: '120px', height: '120px', borderRadius: '50%', display: 'grid', placeItems: 'center', background: 'radial-gradient(circle at 35% 30%, #2a2a30, #161618)', border: '1px solid rgba(255,255,255,0.1)', color: '#9a9aa2' } }, svg(ICON.user, 54, 1.5)),
h('div', { key: 't', style: { fontSize: '15px', fontWeight: 600, color: '#8a8a92' } }, "Peer's camera is off")
]),
// Top bar
h('div', { key: 'top', style: { position: 'absolute', top: 0, left: 0, right: 0, display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: '14px', padding: '18px 20px', background: 'linear-gradient(180deg, rgba(0,0,0,0.55), transparent)' } }, [
h('div', { key: 'l' }, [
h('div', { key: 'n', style: { fontSize: '18px', fontWeight: 800, letterSpacing: '-0.3px', color: '#fff' } }, name),
h('div', { key: 's', style: { display: 'inline-flex', alignItems: 'center', gap: '9px', marginTop: '4px' } }, [
h('span', { key: 'st', style: { fontFamily: MONO, fontSize: '12.5px', fontWeight: 500, color: '#e8e8eb' } }, callStatus),
encBadge,
phase === 'active' && qualityIndicator(false)
])
]),
h('button', { key: 'min', onClick: () => setMinimized(true), title: 'Minimize', style: { flex: 'none', ...minimizeBtn(true) } }, svg(ICON.minimize, 16, 2))
]),
// Self-cam PiP
h('div', { key: 'self', style: { position: 'absolute', bottom: '108px', right: '18px', width: '132px', height: '176px', borderRadius: '14px', overflow: 'hidden', border: '1px solid rgba(255,255,255,0.16)', boxShadow: '0 12px 30px rgba(0,0,0,0.5)', background: '#111' } }, [
h('video', { key: 'sv', ref: selfVideoRef, autoPlay: true, muted: true, playsInline: true, style: { width: '100%', height: '100%', objectFit: 'cover', transform: 'scaleX(-1)', display: 'block' } }),
!call.cameraEnabled && h('div', { key: 'off', style: { position: 'absolute', inset: 0, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: '8px', background: '#161618', color: '#6b6b73' } }, [
svg(ICON.camOff, 24, 1.8),
h('span', { key: 't', style: { fontSize: '10.5px', color: '#6b6b73', fontFamily: MONO } }, 'Camera off')
])
]),
// Control bar
h('div', { key: 'ctrls', style: { position: 'absolute', bottom: 0, left: 0, right: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '18px', padding: '22px 24px 28px', background: 'linear-gradient(0deg, rgba(0,0,0,0.6), transparent)' } }, [
h('button', { key: 'mute', onClick: doMute, title: 'Mute', style: call.micEnabled ? ctrlBase : dangerCtrl }, svg(call.micEnabled ? ICON.micOn : ICON.micOff, 21, 1.9)),
h('button', { key: 'cam', onClick: doCamera, title: 'Camera', style: call.cameraEnabled ? ctrlBase : dangerCtrl }, svg(call.cameraEnabled ? ICON.camOn : ICON.camOff, 21, 1.8)),
h('button', { key: 'flip', onClick: doFlip, title: 'Flip camera', style: ctrlBase }, svg(ICON.flip, 21, 1.8)),
h('button', { key: 'end', onClick: doEnd, title: 'End call', style: endBtn }, svg(ICON.phoneHangup, 22, 1.9))
])
]);
}
// ── VOICE call (expanded) ────────────────────────────────────────────────
return h('div', { style: { position: 'absolute', inset: 0, zIndex: 40, display: 'flex', flexDirection: 'column', background: 'radial-gradient(680px 460px at 50% 36%, rgba(240,137,42,0.08), transparent 70%), #0d0d0f', animation: 'sbExpand .2s ease' } }, [
hiddenAudio,
h('div', { key: 'top', style: { flex: 'none', display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '16px 18px' } }, [
h('span', { key: 'enc', style: { display: 'inline-flex', alignItems: 'center', gap: '7px', fontSize: '12px', fontWeight: 600, color: '#3ecf8e' } }, [svg(ICON.lock, 13, 2), 'Encrypted call']),
h('button', { key: 'min', onClick: () => setMinimized(true), title: 'Minimize', style: minimizeBtn(false) }, svg(ICON.minimize, 16, 2))
]),
h('div', { key: 'mid', style: { flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center' } }, [
avatarDisc(46, ringing),
h('div', { key: 'nm', style: { fontSize: '24px', fontWeight: 800, letterSpacing: '-0.5px', color: '#f4f4f6' } }, name),
h('div', { key: 'st', style: { fontFamily: MONO, fontSize: '14px', fontWeight: 500, color: '#9a9aa2', marginTop: '8px' } }, callStatus),
phase === 'active' && h('div', { key: 'q', style: { marginTop: '12px' } }, qualityIndicator(false))
]),
h('div', { key: 'ctrls', style: { flex: 'none', display: 'flex', alignItems: 'flex-start', justifyContent: 'center', gap: '26px', padding: '28px 24px 34px' } }, [
labeled('mute', h('button', { onClick: doMute, title: 'Mute', style: call.micEnabled ? ctrlBase : dangerCtrl }, svg(call.micEnabled ? ICON.micOn : ICON.micOff, 22, 1.9)), call.micEnabled ? 'Mute' : 'Muted'),
labeled('video', h('button', { onClick: doUpgrade, title: 'Add video', style: ctrlBase }, svg(ICON.camOn, 22, 1.8)), 'Video'),
labeled('end', h('button', { onClick: doEnd, title: 'End call', style: endBtn }, svg(ICON.phoneHangup, 22, 1.9)), 'End')
])
]);
};
if (typeof window !== 'undefined') {
window.CallUIComponent = CallUIComponent;
}
export { CallUIComponent };
+1 -1
View File
@@ -559,7 +559,7 @@ const EnhancedMinimalHeader = ({
React.createElement('div', { key: 'txt', style: { lineHeight: 1.2, minWidth: 0 } }, [ React.createElement('div', { key: 'txt', style: { lineHeight: 1.2, minWidth: 0 } }, [
React.createElement('div', { key: 'r1', style: { display: 'flex', alignItems: 'baseline', gap: '7px' } }, [ React.createElement('div', { key: 'r1', style: { display: 'flex', alignItems: 'baseline', gap: '7px' } }, [
React.createElement('span', { key: 'n', style: { fontSize: '16px', fontWeight: 800, letterSpacing: '-0.3px', color: '#e8e8eb' } }, 'SecureBit'), React.createElement('span', { key: 'n', style: { fontSize: '16px', fontWeight: 800, letterSpacing: '-0.3px', color: '#e8e8eb' } }, 'SecureBit'),
React.createElement('span', { key: 'v', style: { fontFamily: MONO, fontSize: '10px', fontWeight: 500, color: '#56565e' } }, 'v5.4.10') React.createElement('span', { key: 'v', style: { fontFamily: MONO, fontSize: '10px', fontWeight: 500, color: '#56565e' } }, 'v5.5.0')
]), ]),
React.createElement('div', { key: 'r2', className: 'hidden sm:block', style: { fontSize: '11px', color: '#6b6b73', fontWeight: 500 } }, 'End-to-end encrypted') React.createElement('div', { key: 'r2', className: 'hidden sm:block', style: { fontSize: '11px', color: '#6b6b73', fontWeight: 500 } }, 'End-to-end encrypted')
]) ])
+601 -4
View File
@@ -1,6 +1,13 @@
// Import EnhancedSecureFileTransfer // Import EnhancedSecureFileTransfer
import { EnhancedSecureFileTransfer } from '../transfer/EnhancedSecureFileTransfer.js'; import { EnhancedSecureFileTransfer } from '../transfer/EnhancedSecureFileTransfer.js';
// Adaptive voice/video call stack: codec tuning, SDP munging, network adaptation.
import { AUDIO_CONFIG, TRANSPORT_CONFIG } from './webrtc/config.js';
import { applyOpusSettings, applyTransport } from './webrtc/sdp.js';
import { configureAudioSender, applyAudioCodecPreferences } from './webrtc/audio.js';
import { configureVideoSender, applyVideoCodecPreferences } from './webrtc/video.js';
import { NetworkAdaptationController } from './webrtc/adaptation/controller.js';
// MUTEX SYSTEM FIXES - RESOLVING MESSAGE DELIVERY ISSUES // MUTEX SYSTEM FIXES - RESOLVING MESSAGE DELIVERY ISSUES
// ============================================ // ============================================
// Issue: After introducing the Mutex system, messages stopped being delivered between users // Issue: After introducing the Mutex system, messages stopped being delivered between users
@@ -94,7 +101,17 @@ class EnhancedSecureWebRTCManager {
CHUNK_CONFIRMATION: 'chunk_confirmation', CHUNK_CONFIRMATION: 'chunk_confirmation',
FILE_TRANSFER_COMPLETE: 'file_transfer_complete', FILE_TRANSFER_COMPLETE: 'file_transfer_complete',
FILE_TRANSFER_ERROR: 'file_transfer_error', FILE_TRANSFER_ERROR: 'file_transfer_error',
// Encrypted voice / video calls. SDP + ICE are exchanged over the
// already-authenticated (ECDH + SAS-verified) data channel, so the
// DTLS-SRTP fingerprints negotiated for the media are themselves
// authenticated end-to-end — a signalling server never sees them.
CALL_OFFER: 'call_offer',
CALL_ANSWER: 'call_answer',
CALL_ICE: 'call_ice',
CALL_DECLINE: 'call_decline',
CALL_END: 'call_end',
// Fake traffic // Fake traffic
FAKE: 'fake' FAKE: 'fake'
}; };
@@ -119,7 +136,7 @@ class EnhancedSecureWebRTCManager {
]); ]);
// Static debug flag instead of this._debugMode // Static debug flag instead of this._debugMode
static DEBUG_MODE = true; // Set to true during development, false in production static DEBUG_MODE = false; // Set to true during development, false in production
constructor(onMessage, onStatusChange, onKeyExchange, onVerificationRequired, onAnswerError = null, onVerificationStateChange = null, config = {}) { constructor(onMessage, onStatusChange, onKeyExchange, onVerificationRequired, onAnswerError = null, onVerificationStateChange = null, config = {}) {
@@ -364,7 +381,34 @@ this._secureLog('info', '🔒 Enhanced Mutex system fully initialized and valida
}; };
this.onFileReceived = null; this.onFileReceived = null;
this.onFileError = null; this.onFileError = null;
// ── Encrypted call subsystem ───────────────────────────────────────────
// Media (audio/video) rides the SAME RTCPeerConnection as the data channel
// (bundled over one DTLS-SRTP transport), so it inherits the connection's
// end-to-end encryption and needs no new ICE. Signalling is renegotiated
// in-band over the verified data channel. UI observes state via the
// onCallStateChanged callback and the 'securebit-call-state' DOM event.
this.onCallStateChanged = null; // (state) => void
this.callState = {
active: false, // a call session exists (ringing or connected)
phase: 'idle', // idle | outgoing | incoming | connecting | active | ended
withVideo: false, // whether video is part of this call
micEnabled: true,
cameraEnabled: false,
remoteHasVideo: false,
callId: null,
quality: null, // 'excellent'|'good'|'fair'|'poor'|null — link quality for the UI
error: null
};
this.localMediaStream = null;
this.remoteMediaStream = null;
this._pendingCallOffer = null; // { sdp, callId, withVideo } awaiting accept
this._callMakingOffer = false; // perfect-negotiation guard
this._callAudioSender = null;
this._callVideoSender = null;
this._callFacingMode = 'user';
this._adaptationController = null; // NetworkAdaptationController while a call is active
// PFS (Perfect Forward Secrecy) Implementation // PFS (Perfect Forward Secrecy) Implementation
this.keyRotationInterval = null; // отключаем таймерную ротацию this.keyRotationInterval = null; // отключаем таймерную ротацию
this.lastKeyRotation = Date.now(); this.lastKeyRotation = Date.now();
@@ -6850,6 +6894,20 @@ async processMessage(data) {
return; return;
} }
// Encrypted call signalling (offer / answer / ice / decline / end).
if (parsed.type && [
EnhancedSecureWebRTCManager.MESSAGE_TYPES.CALL_OFFER,
EnhancedSecureWebRTCManager.MESSAGE_TYPES.CALL_ANSWER,
EnhancedSecureWebRTCManager.MESSAGE_TYPES.CALL_ICE,
EnhancedSecureWebRTCManager.MESSAGE_TYPES.CALL_DECLINE,
EnhancedSecureWebRTCManager.MESSAGE_TYPES.CALL_END
].includes(parsed.type)) {
try { await this._handleCallSignal(parsed.type, parsed.data || {}); } catch (e) {
this._secureLog('error', '❌ Call signal handling failed', { errorType: e?.constructor?.name });
}
return;
}
// ============================================ // ============================================
// SYSTEM MESSAGES (WITHOUT MUTEX) // SYSTEM MESSAGES (WITHOUT MUTEX)
// ============================================ // ============================================
@@ -7669,6 +7727,11 @@ async processMessage(data) {
this.peerConnection = new RTCPeerConnection(config); this.peerConnection = new RTCPeerConnection(config);
// A fresh PC invalidates any call senders kept from a previous PC — reset
// them so the next call creates new transceivers on this connection.
this._callAudioSender = null;
this._callVideoSender = null;
this.peerConnection.onconnectionstatechange = () => { this.peerConnection.onconnectionstatechange = () => {
const state = this.peerConnection.connectionState; const state = this.peerConnection.connectionState;
console.info('[SecureBit ICE] connection state changed', { console.info('[SecureBit ICE] connection state changed', {
@@ -7729,8 +7792,20 @@ async processMessage(data) {
}); });
}; };
// Inbound call media (DTLS-SRTP encrypted, bundled on this same transport).
// ontrack does NOT re-fire for a REUSED transceiver on a later call, so we
// never rely on the event's track alone — we rebuild the remote stream from
// pc.getReceivers() (the source of truth for current live inbound tracks).
this.peerConnection.ontrack = (event) => {
try {
this._refreshRemoteStream();
} catch (e) {
this._secureLog('warn', '⚠️ ontrack handling failed', { errorType: e?.constructor?.name });
}
};
this.peerConnection.ondatachannel = (event) => { this.peerConnection.ondatachannel = (event) => {
// CRITICAL: Store the received data channel // CRITICAL: Store the received data channel
if (event.channel.label === 'securechat') { if (event.channel.label === 'securechat') {
this.dataChannel = event.channel; this.dataChannel = event.channel;
@@ -7911,6 +7986,22 @@ async processMessage(data) {
return; return;
} }
// Encrypted call signalling (offer / answer / ice / decline / end).
// This is the live inbound path for the data channel — the call
// types are NOT in the system-message list above, so they must be
// routed explicitly here or they would be silently dropped.
if (parsed.type && [
EnhancedSecureWebRTCManager.MESSAGE_TYPES.CALL_OFFER,
EnhancedSecureWebRTCManager.MESSAGE_TYPES.CALL_ANSWER,
EnhancedSecureWebRTCManager.MESSAGE_TYPES.CALL_ICE,
EnhancedSecureWebRTCManager.MESSAGE_TYPES.CALL_DECLINE,
EnhancedSecureWebRTCManager.MESSAGE_TYPES.CALL_END
].includes(parsed.type)) {
try { await this._handleCallSignal(parsed.type, parsed.data || {}); }
catch (_) {}
return;
}
// ============================================ // ============================================
// SYSTEM MESSAGES (WITHOUT MUTEX) // SYSTEM MESSAGES (WITHOUT MUTEX)
// ============================================ // ============================================
@@ -12226,6 +12317,13 @@ async processMessage(data) {
try { try {
this._sessionAlive = false; this._sessionAlive = false;
// End any active call and fully release the persistent mic/camera capture
// (kept alive between calls; the session is ending now).
try { this._stopAdaptation?.(); } catch (_) {}
try { this._stopLocalMediaPermanently?.(); } catch (_) {}
this._callAudioSender = null;
this._callVideoSender = null;
// Preserve the explicit-disconnect notification flow before channels are closed. // Preserve the explicit-disconnect notification flow before channels are closed.
this.intentionalDisconnect = true; this.intentionalDisconnect = true;
window.EnhancedSecureCryptoUtils.secureLog.log('info', 'Starting intentional disconnect'); window.EnhancedSecureCryptoUtils.secureLog.log('info', 'Starting intentional disconnect');
@@ -12841,6 +12939,505 @@ checkFileTransferReadiness() {
} }
return true; return true;
} }
// ════════════════════════════════════════════════════════════════════════
// ENCRYPTED VOICE / VIDEO CALLS
//
// Media is carried by the existing RTCPeerConnection: audio/video tracks
// are bundled onto the same DTLS-SRTP transport already used by the data
// channel, so they are end-to-end encrypted with the very connection that
// in-person SAS verification authenticated. Renegotiation SDP travels over
// the verified data channel (never a server), so the media's DTLS
// fingerprints are authenticated end-to-end too. Calls are therefore only
// permitted once the session is connected AND SAS-verified.
// ════════════════════════════════════════════════════════════════════════
getCallState() {
return { ...this.callState };
}
getRemoteMediaStream() {
return this.remoteMediaStream;
}
getLocalMediaStream() {
return this.localMediaStream;
}
// Rebuild the remote MediaStream from the PC's current receivers. This is the
// reliable source of inbound tracks — ontrack does not re-fire for a reused
// transceiver on a later call, so relying on it dropped remote audio/video.
_refreshRemoteStream() {
const pc = this.peerConnection;
if (!pc || typeof pc.getReceivers !== 'function') return;
const live = pc.getReceivers()
.map(r => r.track)
.filter(t => t && (t.kind === 'audio' || t.kind === 'video') && t.readyState === 'live');
const prev = this.remoteMediaStream ? this.remoteMediaStream.getTracks() : [];
const unchanged = prev.length === live.length && prev.every(t => live.includes(t));
if (!unchanged) {
// Build a NEW MediaStream so the UI's srcObject-changed check fires and
// re-attaches (an element does not reliably pick up a track added to a
// stream that is already its srcObject).
this.remoteMediaStream = new MediaStream(live);
}
this._updateCallState({ remoteHasVideo: live.some(t => t.kind === 'video') });
}
// Remote tracks can take a beat to go 'live' after setRemoteDescription, and
// ontrack may not fire for reused transceivers — so refresh now and shortly
// after to reliably pick up the inbound audio/video.
_scheduleRemoteRefresh() {
this._refreshRemoteStream();
setTimeout(() => { try { this._refreshRemoteStream(); } catch (_) {} }, 300);
setTimeout(() => { try { this._refreshRemoteStream(); } catch (_) {} }, 1200);
}
_updateCallState(patch) {
this.callState = { ...this.callState, ...patch };
const snapshot = this.getCallState();
// Adaptation controller follows the call lifecycle: run while active,
// stop when the call ends.
if (snapshot.phase === 'active') this._startAdaptation();
else if (snapshot.phase === 'idle') this._stopAdaptation();
try { this.onCallStateChanged?.(snapshot); } catch (_) {}
if (typeof document !== 'undefined') {
try {
document.dispatchEvent(new CustomEvent('securebit-call-state', {
detail: { managerId: this._managerId || null, state: snapshot }
}));
} catch (_) {}
}
}
_callCanStart() {
const connected = typeof this.isConnected === 'function' ? this.isConnected() : false;
const channelOpen = this.dataChannel && this.dataChannel.readyState === 'open';
// SAS verification is mandatory: it is what authenticates the DTLS
// transport the media rides on, closing the MITM window.
const ok = !!(connected && channelOpen && this.isVerified);
return ok;
}
async _sendCallSignal(type, data) {
const sent = await this.sendSystemMessage({ type, ...data });
return sent;
}
_audioConstraints() {
return { echoCancellation: true, noiseSuppression: true, autoGainControl: true };
}
_videoConstraints() {
return { facingMode: this._callFacingMode, width: { ideal: 1280 }, height: { ideal: 720 } };
}
async _acquireLocalMedia(withVideo) {
// Fresh capture each call (the mic/camera is fully released on hang-up, so
// the OS indicator goes off). Senders are reused across calls via
// replaceTrack; addTrack only on first use.
const stream = await navigator.mediaDevices.getUserMedia({
audio: this._audioConstraints(),
video: withVideo ? this._videoConstraints() : false
});
this.localMediaStream = stream;
const pc = this.peerConnection;
const audioTrack = stream.getAudioTracks()[0] || null;
const videoTrack = stream.getVideoTracks()[0] || null;
if (audioTrack) {
if (this._callAudioSender) await this._callAudioSender.replaceTrack(audioTrack);
else this._callAudioSender = pc.addTrack(audioTrack, stream);
}
if (videoTrack) {
if (this._callVideoSender) await this._callVideoSender.replaceTrack(videoTrack);
else this._callVideoSender = pc.addTrack(videoTrack, stream);
}
this._applyCallCodecPrefs(); // codec prefs BEFORE createOffer/createAnswer
return stream;
}
// Fully release the mic/camera — only on session disconnect, not between calls.
_stopLocalMediaPermanently() {
try {
if (this.localMediaStream) {
for (const t of this.localMediaStream.getTracks()) { try { t.stop(); } catch (_) {} }
}
} catch (_) {}
this.localMediaStream = null;
}
// Codec preferences on the audio (RED→Opus) and video (VP9→AV1→H264→VP8)
// transceivers created by addTrack. Called before offer/answer creation.
_applyCallCodecPrefs() {
try {
const trs = this.peerConnection?.getTransceivers?.() || [];
const audioTr = trs.find(t => t.sender && t.sender === this._callAudioSender);
if (audioTr) applyAudioCodecPreferences(audioTr);
const videoTr = trs.find(t => t.sender && t.sender === this._callVideoSender);
if (videoTr) applyVideoCodecPreferences(videoTr);
} catch (_) {}
}
// Munge locally-created call SDP: Opus FEC/DTX/bitrate fmtp + transport
// feedback (TWCC/NACK/PLI/FIR/REMB) & the TWCC header extension. Both peers
// run this, so the negotiated result carries it.
_mungeCallSdp(sdp) {
try {
let out = applyOpusSettings(sdp, AUDIO_CONFIG.opusFmtp);
out = applyTransport(out, TRANSPORT_CONFIG);
return out;
} catch (e) { return sdp; }
}
// setLocalDescription with progressive fallback so munging can never break a
// call: try full munge → Opus-only munge → raw. (Some browsers reject added
// rtcp-fb/extmap lines in a local description; the raw path always works.)
async _setLocalMunged(desc) {
const pc = this.peerConnection;
try {
await pc.setLocalDescription({ type: desc.type, sdp: this._mungeCallSdp(desc.sdp) });
return;
} catch (e) {
try {
await pc.setLocalDescription({ type: desc.type, sdp: applyOpusSettings(desc.sdp, AUDIO_CONFIG.opusFmtp) });
return;
} catch (e2) {
await pc.setLocalDescription(desc);
}
}
}
// Apply sender-level params after setLocalDescription, when senders have live
// parameters: audio priority/bitrate, video SVC/bitrate/degradation.
async _applyCallSenderParams() {
try {
if (this._callAudioSender) await configureAudioSender(this._callAudioSender, {});
if (this._callVideoSender) await configureVideoSender(this._callVideoSender, {});
} catch (_) {}
}
// Start the reactive bitrate controller for the active call. Idempotent. It
// also feeds the connection-quality indicator shown in the call UI.
_startAdaptation() {
if (this._adaptationController || !this.peerConnection) return;
try {
this._adaptationController = new NetworkAdaptationController(this.peerConnection, {
getVideoSender: () => this._callVideoSender,
ceilingBitrate: 1500000,
onQuality: (q) => {
if (q !== this.callState.quality) this._updateCallState({ quality: q });
},
});
this._adaptationController.start();
} catch (_) {}
}
_stopAdaptation() {
if (this._adaptationController) {
try { this._adaptationController.stop(); } catch (_) {}
this._adaptationController = null;
}
}
// Turn a getUserMedia failure into a clear, user-visible reason (shown in the
// chat as a system message) + a machine-readable callState.error code. These
// are device/permission problems, NOT connection problems — surfacing them
// stops the call from looking like it "silently drops".
_notifyCallMediaError(error, wantVideo) {
const name = error?.name || '';
const dev = wantVideo ? 'camera/microphone' : 'microphone';
let msg, code;
if (name === 'NotAllowedError') {
code = 'permission_denied';
msg = `⚠️ Call not started — ${dev} access is blocked. Allow it for this site in the browser, and enable your browser under System Settings → Privacy & Security → ${wantVideo ? 'Camera/Microphone' : 'Microphone'}, then try again.`;
} else if (name === 'NotFoundError' || name === 'OverconstrainedError') {
code = 'device_not_found';
msg = `⚠️ Call not started — no ${dev} found on this device.`;
} else if (name === 'NotReadableError' || name === 'AbortError') {
code = 'device_busy';
msg = `⚠️ Call not started — your ${dev} is in use by another app. Close it and try again.`;
} else {
code = 'media_failed';
msg = `⚠️ Call not started — could not access ${dev}${name ? ' (' + name + ')' : ''}.`;
}
try { this.deliverMessageToUI(msg, 'system'); } catch (_) {}
return code;
}
// Caller side: begin an outgoing call.
async startCall(withVideo = false) {
if (!this._callCanStart()) {
this._updateCallState({ error: 'not_verified' });
throw new Error('Calls require a connected, SAS-verified session.');
}
if (this.callState.active) {
this._secureLog('warn', '⚠️ startCall ignored — a call is already active');
return;
}
const callId = (crypto?.randomUUID?.() || String(Date.now()) + Math.random().toString(36).slice(2));
this._updateCallState({
active: true, phase: 'outgoing', withVideo, callId,
micEnabled: true, cameraEnabled: withVideo, remoteHasVideo: false, error: null
});
try {
await this._acquireLocalMedia(withVideo);
this._callMakingOffer = true;
const offer = await this.peerConnection.createOffer();
await this._setLocalMunged(offer);
this._callMakingOffer = false;
await this._applyCallSenderParams();
await this._sendCallSignal(EnhancedSecureWebRTCManager.MESSAGE_TYPES.CALL_OFFER, {
callId, withVideo, sdp: this.peerConnection.localDescription.sdp
});
} catch (error) {
this._callMakingOffer = false;
this._secureLog('error', '❌ startCall failed', { errorType: error?.constructor?.name });
const code = this._notifyCallMediaError(error, withVideo);
await this._teardownCallMedia();
this._updateCallState({ active: false, phase: 'idle', error: code });
throw error;
}
}
// Callee side: an inbound call offer arrived → surface it to the UI.
async _onIncomingCallOffer(data) {
// A renegotiation of an ALREADY-active call (e.g. audio → video upgrade,
// or the peer turning their camera on) is auto-accepted transparently.
if (this.callState.active && (this.callState.phase === 'active' || this.callState.phase === 'connecting')) {
await this._answerCallOffer(data, /* renegotiation */ true);
if (data.withVideo) this._updateCallState({ withVideo: true });
return;
}
this._pendingCallOffer = data;
this._updateCallState({
active: true, phase: 'incoming', withVideo: !!data.withVideo,
callId: data.callId, remoteHasVideo: !!data.withVideo, error: null
});
}
async _answerCallOffer(data, renegotiation = false) {
await this.peerConnection.setRemoteDescription({ type: 'offer', sdp: data.sdp });
// Attach OUR media on every fresh accept. Crucial: _acquireLocalMedia must
// run even when a persistent localMediaStream already exists — teardown
// detached the tracks from the senders (replaceTrack(null)), and this
// re-attaches them. Without it the answerer sends silence/black (the "no
// sound when I pick up / on the reverse call" bug). It reuses the live
// capture internally, so no extra getUserMedia. Skip only on renegotiation
// (upgrade to video), where the senders already carry live tracks.
if (!renegotiation) {
await this._acquireLocalMedia(!!data.withVideo);
}
const answer = await this.peerConnection.createAnswer();
await this._setLocalMunged(answer);
await this._applyCallSenderParams();
await this._sendCallSignal(EnhancedSecureWebRTCManager.MESSAGE_TYPES.CALL_ANSWER, {
callId: data.callId, sdp: this.peerConnection.localDescription.sdp
});
}
// Callee accepts the ringing call.
async acceptCall() {
const data = this._pendingCallOffer;
if (!data) return;
this._pendingCallOffer = null;
this._updateCallState({ phase: 'connecting', cameraEnabled: !!data.withVideo });
try {
await this._answerCallOffer(data, false);
this._updateCallState({ phase: 'active' });
this._scheduleRemoteRefresh();
} catch (error) {
this._secureLog('error', '❌ acceptCall failed', { errorType: error?.constructor?.name });
const code = this._notifyCallMediaError(error, !!data.withVideo);
// Tell the caller we couldn't pick up so their ringing UI stops.
try { await this._sendCallSignal(EnhancedSecureWebRTCManager.MESSAGE_TYPES.CALL_END, { callId: data.callId }); } catch (_) {}
await this._teardownCallMedia();
this._updateCallState({ active: false, phase: 'idle', error: code });
}
}
// Callee rejects the ringing call.
async declineCall() {
const callId = this.callState.callId;
this._pendingCallOffer = null;
await this._sendCallSignal(EnhancedSecureWebRTCManager.MESSAGE_TYPES.CALL_DECLINE, { callId });
this._updateCallState({ active: false, phase: 'idle', withVideo: false, remoteHasVideo: false });
}
// Either side hangs up.
async endCall(sendSignal = true) {
const callId = this.callState.callId;
if (sendSignal && callId) {
try { await this._sendCallSignal(EnhancedSecureWebRTCManager.MESSAGE_TYPES.CALL_END, { callId }); } catch (_) {}
}
await this._teardownCallMedia();
this._updateCallState({
active: false, phase: 'idle', withVideo: false, micEnabled: true,
cameraEnabled: false, remoteHasVideo: false, callId: null, quality: null
});
}
async _teardownCallMedia() {
this._stopAdaptation();
const pc = this.peerConnection;
try {
// 1. STOP our capture tracks → releases the mic/camera so the OS
// indicator goes off after the call.
if (this.localMediaStream) {
for (const track of this.localMediaStream.getTracks()) { try { track.stop(); } catch (_) {} }
}
// 2. Do NOT stop the REMOTE receiver tracks: the transceivers are reused
// on the next call and ontrack won't re-fire, so a stopped receiver
// track would leave the next call with NO remote audio/video. They go
// 'muted' when the peer stops sending (idle, negligible CPU) and are
// fully released in disconnect(). Just detach our senders.
if (pc) {
for (const sender of [this._callAudioSender, this._callVideoSender].filter(Boolean)) {
try { await sender.replaceTrack(null); } catch (_) {}
}
}
} catch (_) {}
this.localMediaStream = null;
this.remoteMediaStream = null;
// _callAudioSender/_callVideoSender are KEPT (reused next call; reset only
// when the PC is rebuilt — see createPeerConnection).
this._callMakingOffer = false;
// If we created an offer that was never answered (call declined / failed
// mid-negotiation), the PC is stuck in 'have-local-offer'. Roll it back to
// 'stable' so the data channel and future calls keep working.
try {
if (this.peerConnection &&
(this.peerConnection.signalingState === 'have-local-offer' ||
this.peerConnection.signalingState === 'have-local-pranswer')) {
await this.peerConnection.setLocalDescription({ type: 'rollback' });
}
} catch (_) {}
// No teardown renegotiation otherwise: stopping the tracks fires
// 'ended'/'mute' on the peer's receivers, and the next startCall
// renegotiates the PC from scratch anyway. Skipping it avoids offer/answer
// glare when both ends hang up at once; dormant transceivers are reused.
}
// Mute / unmute the microphone (no renegotiation — just toggles the track).
setMicEnabled(enabled) {
if (this.localMediaStream) {
this.localMediaStream.getAudioTracks().forEach(t => { t.enabled = enabled; });
}
this._updateCallState({ micEnabled: enabled });
}
toggleMic() { this.setMicEnabled(!this.callState.micEnabled); }
// Turn the camera on/off. Turning it on for an audio-only call adds a video
// track and renegotiates (an in-call "upgrade to video").
async setCameraEnabled(enabled) {
if (!enabled) {
if (this.localMediaStream) {
this.localMediaStream.getVideoTracks().forEach(t => { t.enabled = false; });
}
this._updateCallState({ cameraEnabled: false });
return;
}
// Enabling: reuse an existing (disabled) track, otherwise add one.
const existing = this.localMediaStream?.getVideoTracks?.() || [];
if (existing.length) {
existing.forEach(t => { t.enabled = true; });
this._updateCallState({ cameraEnabled: true, withVideo: true });
return;
}
await this.upgradeToVideo();
}
async toggleCamera() { await this.setCameraEnabled(!this.callState.cameraEnabled); }
// Add a camera to an in-progress audio call and renegotiate.
async upgradeToVideo() {
if (!this.localMediaStream) return;
try {
// Add ONLY a camera track to the in-call stream (don't re-capture audio).
const camStream = await navigator.mediaDevices.getUserMedia({ video: this._videoConstraints() });
const videoTrack = camStream.getVideoTracks()[0];
if (!videoTrack) return;
this.localMediaStream.addTrack(videoTrack);
if (this._callVideoSender) await this._callVideoSender.replaceTrack(videoTrack);
else this._callVideoSender = this.peerConnection.addTrack(videoTrack, this.localMediaStream);
this._applyCallCodecPrefs();
this._updateCallState({ cameraEnabled: true, withVideo: true });
await this._renegotiateCall();
} catch (error) {
this._secureLog('error', '❌ upgradeToVideo failed', { errorType: error?.constructor?.name });
this._updateCallState({ cameraEnabled: false, error: 'camera_failed' });
}
}
// Flip between front/back cameras without renegotiation (replaceTrack).
async switchCamera() {
if (!this._callVideoSender || !this.localMediaStream) return;
this._callFacingMode = this._callFacingMode === 'user' ? 'environment' : 'user';
try {
const camStream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: this._callFacingMode }
});
const newTrack = camStream.getVideoTracks()[0];
const old = this.localMediaStream.getVideoTracks()[0];
if (old) { this.localMediaStream.removeTrack(old); try { old.stop(); } catch (_) {} }
this.localMediaStream.addTrack(newTrack);
await this._callVideoSender.replaceTrack(newTrack);
} catch (error) {
this._secureLog('warn', '⚠️ switchCamera failed', { errorType: error?.constructor?.name });
}
}
async _renegotiateCall() {
if (this._callMakingOffer) return;
try {
this._callMakingOffer = true;
const offer = await this.peerConnection.createOffer();
await this._setLocalMunged(offer);
await this._applyCallSenderParams();
await this._sendCallSignal(EnhancedSecureWebRTCManager.MESSAGE_TYPES.CALL_OFFER, {
callId: this.callState.callId, withVideo: this.callState.withVideo,
sdp: this.peerConnection.localDescription.sdp
});
} finally {
this._callMakingOffer = false;
}
}
// Central inbound call-signal router (called from processMessage).
async _handleCallSignal(type, data) {
const T = EnhancedSecureWebRTCManager.MESSAGE_TYPES;
switch (type) {
case T.CALL_OFFER: {
await this._onIncomingCallOffer(data);
return;
}
case T.CALL_ANSWER: {
try {
if (this.peerConnection.signalingState === 'have-local-offer') {
await this.peerConnection.setRemoteDescription({ type: 'answer', sdp: data.sdp });
}
if (this.callState.phase === 'outgoing') this._updateCallState({ phase: 'active' });
this._scheduleRemoteRefresh(); // caller: pick up the answerer's tracks
} catch (e) {
this._secureLog('warn', '⚠️ Failed to apply call answer', { errorType: e?.constructor?.name });
}
return;
}
case T.CALL_ICE: {
try { if (data.candidate) await this.peerConnection.addIceCandidate(data.candidate); } catch (_) {}
return;
}
case T.CALL_DECLINE: {
await this._teardownCallMedia();
this._updateCallState({ active: false, phase: 'idle', withVideo: false, remoteHasVideo: false, error: 'declined' });
return;
}
case T.CALL_END: {
await this.endCall(/* sendSignal */ false);
return;
}
default: return;
}
}
} }
class SecureKeyStorage { class SecureKeyStorage {
+133
View File
@@ -0,0 +1,133 @@
// NetworkAdaptationController — reactive bitrate control for calls.
//
// Once a second it reads pc.getStats(), classifies the link, and nudges the VIDEO
// sender's maxBitrate / scaleResolutionDownBy via setParameters — never
// renegotiation, never touching the tracks, never touching AUDIO (audio stays at
// its configured bitrate so speech survives; see ADAPTATION_CONFIG.audioProtect).
//
// The decision logic (decideAdaptation) is pure and unit-tested separately from
// the live getStats/setParameters plumbing.
import { ADAPTATION_CONFIG, IS_WEBKIT } from '../config.js';
import { summarizeStats, qualityFromMetrics } from './metrics.js';
/**
* Pure adaptation decision.
* @param {object} m metrics from summarizeStats
* @param {object} state { targetBitrate, ceilingBitrate, scaleResolutionDownBy, goodTicks }
* @param {object} cfg ADAPTATION_CONFIG
* @returns {{targetBitrate:number, scaleResolutionDownBy:number, goodTicks:number,
* changed:boolean, reason:string}}
*/
export function decideAdaptation(m, state, cfg = ADAPTATION_CONFIG) {
let { targetBitrate, ceilingBitrate, scaleResolutionDownBy, goodTicks } = state;
let changed = false, reason = 'steady';
if (m.qualityLimitationReason === 'cpu') {
// CPU-bound: drop resolution, leave bitrate alone (brief §5).
const next = Math.min(4, +(scaleResolutionDownBy * cfg.cpuScaleStep).toFixed(3));
if (next !== scaleResolutionDownBy) { scaleResolutionDownBy = next; changed = true; }
goodTicks = 0; reason = 'cpu';
} else if (m.lossPct > cfg.loss.highPct || m.rttMs > cfg.rtt.highMs) {
// Congestion: step video bitrate down (never below the floor).
const next = Math.max(cfg.minVideoBitrate, Math.round(targetBitrate * (1 - cfg.stepDownPct)));
if (next !== targetBitrate) { targetBitrate = next; changed = true; }
goodTicks = 0; reason = 'backoff';
} else if (m.lossPct < cfg.loss.recoverPct && m.rttMs < cfg.rtt.recoverMs) {
// Sustained good link: ramp up after enough consecutive good ticks.
goodTicks += 1; reason = 'recovering';
if (goodTicks >= cfg.recoverStableTicks) {
const next = Math.min(ceilingBitrate, Math.round(targetBitrate * (1 + cfg.stepUpPct)));
if (next !== targetBitrate) { targetBitrate = next; changed = true; reason = 'rampup'; }
goodTicks = 0;
}
} else {
goodTicks = 0; // neutral zone: hold.
}
return { targetBitrate, scaleResolutionDownBy, goodTicks, changed, reason };
}
export class NetworkAdaptationController {
/**
* @param {RTCPeerConnection} pc
* @param {object} opts { getVideoSender:()=>RTCRtpSender|null, ceilingBitrate?, onQuality?, cfg? }
*/
constructor(pc, opts = {}) {
this.pc = pc;
this.getVideoSender = opts.getVideoSender || (() => null);
this.onQuality = opts.onQuality || (() => {});
this.cfg = opts.cfg || ADAPTATION_CONFIG;
this._timer = null;
this._prevCounters = {};
this._lastQuality = undefined;
this.state = {
targetBitrate: opts.ceilingBitrate || 1500000,
ceilingBitrate: opts.ceilingBitrate || 1500000,
scaleResolutionDownBy: 1,
goodTicks: 0,
};
}
start() {
if (this._timer) return;
this._timer = setInterval(() => { this._tick().catch(() => {}); }, this.cfg.intervalMs);
}
stop() {
if (this._timer) { clearInterval(this._timer); this._timer = null; }
}
async _tick() {
if (!this.pc || typeof this.pc.getStats !== 'function') return;
const report = await this.pc.getStats();
const stats = typeof report.values === 'function' ? Array.from(report.values()) : report;
const m = summarizeStats(stats, this._prevCounters);
this._prevCounters = m.counters;
// Quality → UI (only emit on change).
const q = qualityFromMetrics(m);
if (q && q !== this._lastQuality) {
this._lastQuality = q;
try { this.onQuality(q, m); } catch (_) {}
}
if (!m.hasData) return;
const decision = decideAdaptation(m, this.state, this.cfg);
this.state = {
targetBitrate: decision.targetBitrate,
ceilingBitrate: this.state.ceilingBitrate,
scaleResolutionDownBy: decision.scaleResolutionDownBy,
goodTicks: decision.goodTicks,
};
if (decision.changed) {
await this._applyToVideoSender();
}
}
async _applyToVideoSender() {
// WebKit: never write sender params (kills the capture); quality indicator
// still works (read-only getStats). Safari's own congestion control adapts.
if (IS_WEBKIT) return;
const sender = this.getVideoSender();
if (!sender || typeof sender.getParameters !== 'function') return;
try {
const params = sender.getParameters();
if (!params.encodings || params.encodings.length === 0) params.encodings = [{}];
if (params.encodings.length === 1) {
// Single encoding (SVC or plain): cap bitrate + scale resolution.
params.encodings[0].maxBitrate = this.state.targetBitrate;
params.encodings[0].scaleResolutionDownBy = this.state.scaleResolutionDownBy;
} else {
// Simulcast: throttle only the TOP (highest-bitrate) layer so the
// low/mid layers keep flowing; leave their per-rid scale ladder intact.
const top = params.encodings[params.encodings.length - 1];
top.maxBitrate = this.state.targetBitrate;
}
await sender.setParameters(params);
} catch (e) {
// WebKit/older browsers can reject setParameters; safe to skip this tick.
}
}
}
+62
View File
@@ -0,0 +1,62 @@
// getStats parsing + quality classification for adaptive calls.
//
// summarizeStats() takes a plain array of RTCStats objects (Array.from(report
// .values())) plus the previous counter snapshot, so it is fully unit-testable
// in Node without a live RTCPeerConnection.
/**
* Reduce a getStats() report to the few numbers the controller / UI need.
* @param {Array<object>} stats RTCStats objects (video preferred over audio).
* @param {{packetsSent?:number, packetsLost?:number}} [prev] previous counters.
* @returns {{lossPct:number, rttMs:number, jitterMs:number,
* availableOutgoingBitrate:(number|null), qualityLimitationReason:string,
* counters:{packetsSent:number, packetsLost:number}, hasData:boolean}}
*/
export function summarizeStats(stats, prev = {}) {
let outbound = null, remoteInbound = null, candidatePair = null;
for (const s of stats) {
if (!s || typeof s.type !== 'string') continue;
if (s.type === 'outbound-rtp' && !s.isRemote) {
if (!outbound || s.kind === 'video') outbound = s; // prefer video
} else if (s.type === 'remote-inbound-rtp') {
if (!remoteInbound || s.kind === 'video') remoteInbound = s;
} else if (s.type === 'candidate-pair') {
const active = s.nominated || s.selected || s.state === 'succeeded';
if (active && (!candidatePair || s.nominated)) candidatePair = s;
}
}
const packetsSent = Number(outbound?.packetsSent ?? 0);
const packetsLost = Number(remoteInbound?.packetsLost ?? 0);
const dSent = packetsSent - (prev.packetsSent ?? packetsSent);
const dLost = packetsLost - (prev.packetsLost ?? packetsLost);
const denom = dSent + dLost;
// Interval loss ratio (guard against counter resets / first sample).
const lossPct = denom > 0 ? Math.min(1, Math.max(0, dLost / denom)) : 0;
const rttSec = candidatePair?.currentRoundTripTime ?? remoteInbound?.roundTripTime ?? 0;
const rttMs = Number(rttSec) * 1000;
const jitterMs = Number(remoteInbound?.jitter ?? 0) * 1000;
const availableOutgoingBitrate = candidatePair?.availableOutgoingBitrate != null
? Number(candidatePair.availableOutgoingBitrate) : null;
const qualityLimitationReason = outbound?.qualityLimitationReason ?? 'none';
return {
lossPct, rttMs, jitterMs, availableOutgoingBitrate, qualityLimitationReason,
counters: { packetsSent, packetsLost },
hasData: !!(outbound && (remoteInbound || candidatePair)),
};
}
/**
* Coarse connection-quality label for the UI, from loss + RTT.
* @returns {'excellent'|'good'|'fair'|'poor'|null} null when there's no data yet.
*/
export function qualityFromMetrics(m) {
if (!m || !m.hasData) return null;
const l = m.lossPct, r = m.rttMs;
if (l < 0.03 && r < 150) return 'excellent';
if (l < 0.07 && r < 250) return 'good';
if (l < 0.15 && r < 400) return 'fair';
return 'poor';
}
+68
View File
@@ -0,0 +1,68 @@
// Audio sender / transceiver configuration for calls.
//
// Splits the brief's "configureAudioSender" into the three WebRTC surfaces it
// actually touches, because a single RTCRtpSender cannot do all of them:
// - setCodecPreferences → on the TRANSCEIVER, before createOffer (RED ordering)
// - fmtp munging → on the SDP string (see sdp.js applyOpusSettings)
// - setParameters → on the SENDER, any time after it exists
//
// All functions feature-detect and swallow unsupported-API errors so Safari /
// Firefox (which vary on RED and setCodecPreferences) degrade gracefully.
import { AUDIO_CONFIG, IS_WEBKIT } from './config.js';
/**
* Order codecs so RED (RFC 2198) is preferred first and Opus second, keeping any
* other codecs (e.g. telephone-event) after them. No-op when RED isn't offered
* or setCodecPreferences is unsupported.
* @param {RTCRtpTransceiver} transceiver
*/
export function applyAudioCodecPreferences(transceiver) {
try {
if (IS_WEBKIT) return false; // codec preferences skipped on WebKit (capture-safe)
if (!transceiver || typeof transceiver.setCodecPreferences !== 'function') return false;
if (!AUDIO_CONFIG.preferRed) return false;
const caps = (typeof RTCRtpSender !== 'undefined' && RTCRtpSender.getCapabilities)
? RTCRtpSender.getCapabilities('audio')
: null;
if (!caps || !Array.isArray(caps.codecs)) return false;
const isRed = c => /red$/i.test(c.mimeType);
const isOpus = c => /opus$/i.test(c.mimeType);
if (!caps.codecs.some(isRed)) return false; // RED not available
const red = caps.codecs.filter(isRed);
const opus = caps.codecs.filter(isOpus);
const rest = caps.codecs.filter(c => !isRed(c) && !isOpus(c));
transceiver.setCodecPreferences([...red, ...opus, ...rest]);
return true;
} catch (e) {
return false;
}
}
/**
* Apply sender-level parameters: high bandwidth priority, high network priority
* (audio ahead of video on the wire) and a maxBitrate cap. Uses read-modify-write
* on getParameters()/setParameters() so we never clobber existing encodings.
* @param {RTCRtpSender} sender
* @param {object} [options] overrides for AUDIO_CONFIG.sender
*/
export async function configureAudioSender(sender, options = {}) {
try {
if (IS_WEBKIT) return false; // sender setParameters skipped on WebKit (capture-safe)
if (!sender || typeof sender.getParameters !== 'function') return false;
const cfg = { ...AUDIO_CONFIG.sender, ...options };
const params = sender.getParameters();
if (!params.encodings || params.encodings.length === 0) params.encodings = [{}];
for (const enc of params.encodings) {
enc.maxBitrate = cfg.maxBitrate;
enc.priority = cfg.priority;
enc.networkPriority = cfg.networkPriority;
}
await sender.setParameters(params);
return true;
} catch (e) {
return false;
}
}
+122
View File
@@ -0,0 +1,122 @@
// Adaptive WebRTC call parameters — single source of truth.
//
// Every value here is a tuning constant for the voice/video call stack, with a
// note on where it comes from. Kept as plain data (no behaviour) so it can be
// imported by both the browser bundle and the Node unit tests.
//
// Targets (from the task brief): usable voice at up to 1520% loss, RTT up to
// 400 ms, fluctuating bandwidth; voice must not break as bitrate drops; video
// degrades gracefully by layer.
// WebKit (desktop Safari + ALL iOS browsers) kills a live capture MediaStreamTrack
// with "capture failure" when we manipulate the sender via setParameters /
// setCodecPreferences (RED, priorities, SVC bitrate). So the adaptive codec/bitrate
// tuning is applied on Chromium only; WebKit runs plain WebRTC (Opus + the
// browser's own congestion control), which is reliable. The read-only quality
// indicator still works everywhere. Matches Chrome/Chromium/Edge; excludes Safari
// and iOS browsers (all WebKit — iOS Chrome is "CriOS", not "Chrome").
export const IS_WEBKIT = (typeof navigator !== 'undefined')
&& /AppleWebKit/.test(navigator.userAgent)
&& !/Chrome|Chromium|Edg\//.test(navigator.userAgent);
// ── AUDIO ──────────────────────────────────────────────────────────────────
// Opus is mandatory-to-implement in WebRTC (RFC 7874). FEC + DTX + a low target
// bitrate keep speech intelligible under loss.
export const AUDIO_CONFIG = {
// Opus fmtp params applied via SDP munging.
// - minptime=10 smaller packets → lower latency (Opus RFC 7587 §7).
// - useinbandfec=1 in-band Forward Error Correction — reconstructs lost
// packets from the next one (RFC 6716 §2.1.7). Key for loss.
// - usedtx=1 Discontinuous Transmission — stop sending in silence,
// frees the pipe for video/FEC (RFC 7587 §3.1.3).
// - stereo=0 mono: voice doesn't need stereo, halves the bitrate.
// - maxaveragebitrate 32 kbps — brief says 32000; comfortable wideband speech.
// - cbr=0 variable bitrate: lets the encoder spend bits only when
// needed, better quality per bit than CBR for speech.
opusFmtp: {
minptime: 10,
useinbandfec: 1,
usedtx: 1,
stereo: 0,
maxaveragebitrate: 32000,
cbr: 0,
},
// RED (RFC 2198) wraps Opus payloads with a redundant copy of the previous
// frame — recovers isolated losses without waiting for retransmission. Only
// enabled when the browser advertises audio/red (Chromium yes; Safari/FF vary).
preferRed: true,
// RTCRtpSender.setParameters — encoding-level knobs. Audio is prioritised over
// video on the shared transport so speech survives congestion.
sender: {
maxBitrate: 40000, // bps — brief: 40000. Head-room over 32 kbps for RED.
priority: 'high', // RTCPriorityType — bandwidth arbitration within the PC.
networkPriority: 'high', // DSCP marking hint — audio ahead of video on the wire.
},
};
// ── VIDEO ──────────────────────────────────────────────────────────────────
// Preference order VP9 → AV1 → H.264 → VP8. VP9/AV1 give temporal/spatial
// scalability (SVC) so a single stream degrades by layer; H.264/VP8 fall back to
// plain simulcast. Values from the brief.
export const VIDEO_CONFIG = {
codecPreferenceOrder: ['VP9', 'AV1', 'H264', 'VP8'],
// Preferred single-encoding SVC mode for VP9 (3 spatial × 3 temporal, key-frame
// aligned). If the browser rejects it we fall back to the simulcast ladder below.
vp9: {
preferredScalabilityMode: 'L3T3_KEY',
simulcast: [
{ rid: 'low', scaleResolutionDownBy: 4, maxBitrate: 150000, scalabilityMode: 'L1T3' },
{ rid: 'mid', scaleResolutionDownBy: 2, maxBitrate: 500000, scalabilityMode: 'L1T3' },
{ rid: 'high', scaleResolutionDownBy: 1, maxBitrate: 1500000, scalabilityMode: 'L1T3' },
],
degradationPreference: 'balanced',
},
av1: {
scalabilityMode: 'L1T3',
maxBitrate: 1200000,
degradationPreference: 'maintain-framerate',
},
// H.264 / VP8: ordinary simulcast, no SVC.
simulcast: [
{ rid: 'low', scaleResolutionDownBy: 4, maxBitrate: 150000 },
{ rid: 'mid', scaleResolutionDownBy: 2, maxBitrate: 500000 },
{ rid: 'high', scaleResolutionDownBy: 1, maxBitrate: 1500000 },
],
networkPriority: 'medium', // below audio's 'high'.
};
// ── TRANSPORT (RTCP feedback / header extensions to guarantee in SDP) ────────
// transport-wide-cc drives the bandwidth estimator; nack/pli/fir/remb handle
// loss recovery and keyframe requests. Video gets the full set, audio a subset.
export const TRANSPORT_CONFIG = {
twccUri: 'http://www.ietf.org/id/draft-holmer-rmcat-transport-wide-cc-extensions-01',
video: {
rtcpFb: ['transport-cc', 'nack', 'nack pli', 'ccm fir', 'goog-remb'],
twcc: true,
},
audio: {
rtcpFb: ['transport-cc', 'nack'],
twcc: true,
},
};
// ── ADAPTATION (getStats loop) ──────────────────────────────────────────────
// Reactive bitrate control. Thresholds from the brief. Applied via setParameters
// only — never renegotiation, never track restarts.
export const ADAPTATION_CONFIG = {
intervalMs: 1000,
loss: {
highPct: 0.10, // >10% loss → back off video.
recoverPct: 0.03, // <3% loss (sustained) → ramp up.
audioProtectPct: 0.25, // don't touch audio until loss exceeds 25%.
},
rtt: {
highMs: 300, // >300 ms → back off.
recoverMs: 150, // <150 ms (sustained) → ramp up.
},
stepDownPct: 0.20, // shrink video maxBitrate by 20% per bad tick.
stepUpPct: 0.10, // grow by 10% per good window.
minVideoBitrate: 100000, // floor for the low layer (bps).
recoverStableTicks: 5, // consecutive good ticks before ramping up.
cpuScaleStep: 1.5, // qualityLimitationReason 'cpu' → bump scaleResolutionDownBy ×1.5.
};
+200
View File
@@ -0,0 +1,200 @@
// Pure SDP munging utilities.
//
// SDP munging is inherently fragile across browsers, so every function here is:
// - idempotent (running twice yields the same SDP — no duplicate lines),
// - line-ending preserving (SDP uses CRLF; we detect and keep it),
// - defensive (unknown/missing sections are left untouched, never thrown on).
//
// No DOM/WebRTC APIs are used, so this module is unit-testable in plain Node.
/** Detect the line terminator used by an SDP blob (CRLF per RFC 4566, but be safe). */
function detectEol(sdp) {
return sdp.indexOf('\r\n') !== -1 ? '\r\n' : '\n';
}
/**
* Split an SDP into its session part and per-media sections.
* @returns {{ eol:string, session:string[], media:Array<{lines:string[]}> }}
*/
export function splitSdp(sdp) {
const eol = detectEol(sdp);
const lines = sdp.split(/\r\n|\n/);
const session = [];
const media = [];
let current = null;
for (const line of lines) {
if (line.startsWith('m=')) {
current = { lines: [line] };
media.push(current);
} else if (current) {
current.lines.push(line);
} else {
session.push(line);
}
}
return { eol, session, media };
}
/** Reassemble a split SDP, preserving the original line ending. */
export function joinSdp(parsed) {
const all = [...parsed.session];
for (const m of parsed.media) all.push(...m.lines);
return all.join(parsed.eol);
}
/** The media kind ('audio' | 'video' | 'application') of a section. */
export function sectionKind(section) {
const m = section.lines[0].match(/^m=(\w+)/);
return m ? m[1] : null;
}
/**
* All payload types in a section whose rtpmap encoding name matches `codecName`
* (case-insensitive), e.g. 'opus', 'VP9', 'red'.
* @returns {string[]}
*/
export function findPayloadTypes(section, codecName) {
const re = new RegExp('^a=rtpmap:(\\d+)\\s+' + codecName + '\\/', 'i');
const pts = [];
for (const line of section.lines) {
const m = line.match(re);
if (m) pts.push(m[1]);
}
return pts;
}
/** Parse an `a=fmtp:<pt> k=v;k2=v2;flag` value string into an ordered map. */
function parseFmtpParams(value) {
const map = new Map();
for (const part of value.split(';')) {
const p = part.trim();
if (!p) continue;
const eq = p.indexOf('=');
if (eq === -1) map.set(p, undefined); // bare flag
else map.set(p.slice(0, eq).trim(), p.slice(eq + 1).trim());
}
return map;
}
/** Serialise an fmtp param map back to `k=v;k2=v2` form. */
function serializeFmtpParams(map) {
const parts = [];
for (const [k, v] of map) parts.push(v === undefined ? k : `${k}=${v}`);
return parts.join(';');
}
/**
* Insert or update the `a=fmtp:<pt>` line for a payload type, merging `params`
* over any existing values. Idempotent: existing keys are overwritten, not
* duplicated; a missing fmtp line is created right after the rtpmap line.
*/
export function upsertFmtp(section, pt, params) {
const fmtpIdx = section.lines.findIndex(l => l.startsWith(`a=fmtp:${pt} `) || l === `a=fmtp:${pt}`);
if (fmtpIdx !== -1) {
const existing = section.lines[fmtpIdx].slice(`a=fmtp:${pt} `.length);
const map = parseFmtpParams(existing);
for (const [k, v] of Object.entries(params)) map.set(k, String(v));
section.lines[fmtpIdx] = `a=fmtp:${pt} ${serializeFmtpParams(map)}`;
return;
}
// No fmtp yet — build one and place it after the matching rtpmap line.
const map = new Map();
for (const [k, v] of Object.entries(params)) map.set(k, String(v));
const newLine = `a=fmtp:${pt} ${serializeFmtpParams(map)}`;
const rtpmapIdx = section.lines.findIndex(l => l.startsWith(`a=rtpmap:${pt} `));
if (rtpmapIdx !== -1) section.lines.splice(rtpmapIdx + 1, 0, newLine);
else section.lines.push(newLine);
}
/**
* Apply Opus fmtp parameters (FEC / DTX / bitrate / etc.) to every Opus payload
* type in the audio m-line. Returns the munged SDP; input SDP with no Opus
* audio section is returned unchanged.
*/
export function applyOpusSettings(sdp, opusFmtp) {
if (!sdp || typeof sdp !== 'string') return sdp;
const parsed = splitSdp(sdp);
let changed = false;
for (const section of parsed.media) {
if (sectionKind(section) !== 'audio') continue;
for (const pt of findPayloadTypes(section, 'opus')) {
upsertFmtp(section, pt, opusFmtp);
changed = true;
}
}
return changed ? joinSdp(parsed) : sdp;
}
// ── Transport feedback ───────────────────────────────────────────────────────
// Auxiliary payloads that are not primary media codecs (no per-codec rtcp-fb).
const AUX_CODEC = /^(rtx|red|ulpfec|flexfec-03|telephone-event|CN)$/i;
/** Primary-codec payload types in a section (excludes rtx/red/fec/DTMF/CN). */
export function getCodecPayloadTypes(section) {
const pts = [];
for (const line of section.lines) {
const m = line.match(/^a=rtpmap:(\d+)\s+([^/]+)\//);
if (m && !AUX_CODEC.test(m[2])) pts.push(m[1]);
}
return pts;
}
/**
* Ensure each `a=rtcp-fb:<pt> <fb>` line exists for every primary codec in the
* section. Idempotent — never duplicates an existing line.
*/
export function ensureRtcpFb(section, feedbacks) {
for (const pt of getCodecPayloadTypes(section)) {
for (const fb of feedbacks) {
const line = `a=rtcp-fb:${pt} ${fb}`;
if (section.lines.includes(line)) continue;
// Insert after the last existing attribute line for this pt.
let insertAt = -1;
for (let i = 0; i < section.lines.length; i++) {
const l = section.lines[i];
if (l.startsWith(`a=rtpmap:${pt} `) || l.startsWith(`a=fmtp:${pt} `) || l.startsWith(`a=rtcp-fb:${pt} `)) insertAt = i;
}
if (insertAt === -1) insertAt = section.lines.length - 1;
section.lines.splice(insertAt + 1, 0, line);
}
}
}
/**
* Ensure an RTP header extension (`a=extmap:<id> <uri>`) is present, allocating
* the next free id. Idempotent — a section already carrying the uri is untouched.
*/
export function ensureExtmap(section, uri) {
if (section.lines.some(l => l.startsWith('a=extmap:') && l.includes(uri))) return;
let maxId = 0, insertAt = -1;
for (let i = 0; i < section.lines.length; i++) {
const m = section.lines[i].match(/^a=extmap:(\d+)/);
if (m) { maxId = Math.max(maxId, Number(m[1])); insertAt = i; }
}
if (insertAt === -1) {
// No extmap yet — place after mid/rtpmap area, else end.
insertAt = section.lines.findIndex(l => l.startsWith('a=mid:'));
if (insertAt === -1) insertAt = section.lines.length - 1;
}
section.lines.splice(insertAt + 1, 0, `a=extmap:${maxId + 1} ${uri}`);
}
/**
* Ensure transport-wide-cc / nack / pli / fir / remb feedback and the TWCC header
* extension are present on the audio/video m-lines per `cfg` (TRANSPORT_CONFIG).
* Idempotent and non-destructive; SDP with no matching sections is returned as-is.
*/
export function applyTransport(sdp, cfg) {
if (!sdp || typeof sdp !== 'string' || !cfg) return sdp;
const parsed = splitSdp(sdp);
let changed = false;
for (const section of parsed.media) {
const kind = sectionKind(section);
const c = kind === 'video' ? cfg.video : kind === 'audio' ? cfg.audio : null;
if (!c) continue;
if (Array.isArray(c.rtcpFb)) { ensureRtcpFb(section, c.rtcpFb); changed = true; }
if (c.twcc && cfg.twccUri) { ensureExtmap(section, cfg.twccUri); changed = true; }
}
return changed ? joinSdp(parsed) : sdp;
}
+162
View File
@@ -0,0 +1,162 @@
// Video sender / transceiver configuration for calls.
//
// Strategy for 1:1 P2P (single remote peer): use a SINGLE encoding with SVC
// (VP9 L3T3_KEY / AV1 L1T3) applied through sender.setParameters — this needs no
// addTransceiver/rids and therefore doesn't disturb the working addTrack flow.
// Layered SVC already gives graceful per-layer degradation for one receiver;
// multi-rid simulcast (mainly useful for SFUs) is a documented fallback, not
// wired here. All calls feature-detect and fall back gracefully:
// VP9 → AV1 → H.264 → VP8, and SVC → plain encoding if a mode is rejected.
import { VIDEO_CONFIG, IS_WEBKIT } from './config.js';
const CODEC_RANK = { VP9: 0, AV1: 1, H264: 2, VP8: 3 };
/** 'video/VP9' → 'VP9', 'video/H264' → 'H264', 'video/rtx' → 'RTX', … */
export function codecShortName(mimeType) {
const sub = String(mimeType || '').split('/')[1] || '';
return sub.toUpperCase();
}
/**
* Reorder a getCapabilities().codecs array so our preferred video codecs come
* first (VP9, AV1, H264, VP8), with everything else (rtx / red / *fec) kept in
* its original relative order afterwards — dropping rtx/fec would break
* retransmission, so they must stay in the list.
*/
export function sortVideoCodecs(codecs) {
const rank = c => {
const n = codecShortName(c.mimeType);
return Object.prototype.hasOwnProperty.call(CODEC_RANK, n) ? CODEC_RANK[n] : 99;
};
return codecs
.map((c, i) => ({ c, i }))
.sort((a, b) => (rank(a.c) - rank(b.c)) || (a.i - b.i)) // stable
.map(x => x.c);
}
/** The top available video codec by our preference order, or null. */
export function pickPreferredVideoCodec(caps) {
if (!caps || !Array.isArray(caps.codecs)) return null;
const present = new Set(caps.codecs.map(c => codecShortName(c.mimeType)));
for (const name of VIDEO_CONFIG.codecPreferenceOrder) if (present.has(name)) return name;
return null;
}
function videoCaps() {
return (typeof RTCRtpSender !== 'undefined' && RTCRtpSender.getCapabilities)
? RTCRtpSender.getCapabilities('video') : null;
}
/**
* Order codec preferences on the video transceiver (VP9 first …). Must be called
* before createOffer/createAnswer. Returns the chosen top codec name (or false).
* @param {RTCRtpTransceiver} transceiver
*/
export function applyVideoCodecPreferences(transceiver) {
try {
if (IS_WEBKIT) return false; // codec preferences skipped on WebKit (capture-safe)
if (!transceiver || typeof transceiver.setCodecPreferences !== 'function') return false;
const caps = videoCaps();
if (!caps || !Array.isArray(caps.codecs)) return false;
transceiver.setCodecPreferences(sortVideoCodecs(caps.codecs));
return pickPreferredVideoCodec(caps);
} catch (e) {
return false;
}
}
/**
* Whether the browser confirms the SVC scalabilityMode we want for a codec.
* Uses the `scalabilityModes` capability field (Chromium M113+, recent Firefox).
* When it's absent we return false so we fall back to simulcast — safer than
* assuming SVC on a browser that can't do it (e.g. older Firefox/Safari).
*/
export function codecSupportsSvc(caps, name) {
const codec = caps?.codecs?.find(c => codecShortName(c.mimeType) === name);
const modes = codec && codec.scalabilityModes;
if (!Array.isArray(modes)) return false;
const want = name === 'AV1' ? VIDEO_CONFIG.av1.scalabilityMode : VIDEO_CONFIG.vp9.preferredScalabilityMode;
return modes.includes(want);
}
/**
* Build the `sendEncodings` for addTransceiver('video', …) per the brief:
* - VP9/AV1 with confirmed SVC → a SINGLE encoding with scalabilityMode
* (one stream that degrades by layer — ideal for 1:1),
* - VP9 without confirmed SVC → the 3-rid L1T3 simulcast ladder,
* - H.264/VP8 (or AV1 w/o SVC) → plain 3-rid simulcast.
* @param {RTCRtpCapabilities} [caps] pass to override detection (tests)
*/
export function buildVideoSendEncodings(caps = videoCaps()) {
const preferred = pickPreferredVideoCodec(caps) || 'VP8';
if ((preferred === 'VP9' || preferred === 'AV1') && codecSupportsSvc(caps, preferred)) {
const plan = encodingPlanFor(preferred);
return [{ scalabilityMode: plan.scalabilityMode, maxBitrate: plan.maxBitrate }];
}
if (preferred === 'VP9') {
return VIDEO_CONFIG.vp9.simulcast.map(e => ({ ...e }));
}
return VIDEO_CONFIG.simulcast.map(e => ({ ...e }));
}
/** SVC mode + bitrate + degradation for a given codec (single-encoding P2P). */
export function encodingPlanFor(codecName) {
if (codecName === 'VP9') {
return { scalabilityMode: VIDEO_CONFIG.vp9.preferredScalabilityMode, maxBitrate: 1500000, degradationPreference: VIDEO_CONFIG.vp9.degradationPreference };
}
if (codecName === 'AV1') {
return { scalabilityMode: VIDEO_CONFIG.av1.scalabilityMode, maxBitrate: VIDEO_CONFIG.av1.maxBitrate, degradationPreference: VIDEO_CONFIG.av1.degradationPreference };
}
// H.264 / VP8 / unknown: plain single encoding, no SVC.
return { scalabilityMode: undefined, maxBitrate: 1500000, degradationPreference: 'balanced' };
}
/**
* Apply the single-encoding SVC plan + networkPriority + degradationPreference to
* a video sender via setParameters. Falls back to a plain encoding if the browser
* rejects the requested scalabilityMode (Firefox/Safari SVC gaps).
* @param {RTCRtpSender} sender
*/
export async function configureVideoSender(sender, options = {}) {
try {
if (IS_WEBKIT) return false; // sender setParameters skipped on WebKit (capture-safe)
if (!sender || typeof sender.getParameters !== 'function') return false;
const preferred = pickPreferredVideoCodec(videoCaps()) || 'VP8';
const plan = { ...encodingPlanFor(preferred), ...options };
const params = sender.getParameters();
if (!params.encodings || params.encodings.length === 0) params.encodings = [{}];
const simulcast = params.encodings.length > 1;
if (simulcast) {
// Simulcast: keep the per-rid bitrate ladder set at addTransceiver time;
// only stamp networkPriority so video stays below audio on the wire.
for (const enc of params.encodings) enc.networkPriority = VIDEO_CONFIG.networkPriority;
} else {
const enc = params.encodings[0];
enc.maxBitrate = plan.maxBitrate;
enc.networkPriority = VIDEO_CONFIG.networkPriority;
if (plan.scalabilityMode) enc.scalabilityMode = plan.scalabilityMode;
}
if (plan.degradationPreference) params.degradationPreference = plan.degradationPreference;
try {
await sender.setParameters(params);
return true;
} catch (e) {
// Most likely an unsupported scalabilityMode → retry without it.
if (!simulcast && plan.scalabilityMode) {
delete params.encodings[0].scalabilityMode;
try {
await sender.setParameters(params);
return true;
} catch (e2) {
return false;
}
}
return false;
}
} catch (e) {
return false;
}
}
+1
View File
@@ -12,6 +12,7 @@ import '../components/ui/Roadmap.jsx';
import '../components/ui/CommunityCTA.jsx'; import '../components/ui/CommunityCTA.jsx';
import '../components/ui/FileTransfer.jsx'; import '../components/ui/FileTransfer.jsx';
import '../components/ui/IceServerSettings.jsx'; import '../components/ui/IceServerSettings.jsx';
import '../components/ui/CallUI.jsx';
// Expose to global for legacy usage inside app code // Expose to global for legacy usage inside app code
window.EnhancedSecureCryptoUtils = EnhancedSecureCryptoUtils; window.EnhancedSecureCryptoUtils = EnhancedSecureCryptoUtils;
+1
View File
@@ -37,6 +37,7 @@
import(`../components/ui/Roadmap.jsx?v=${timestamp}`), import(`../components/ui/Roadmap.jsx?v=${timestamp}`),
import(`../components/ui/FileTransfer.jsx?v=${timestamp}`), import(`../components/ui/FileTransfer.jsx?v=${timestamp}`),
import(`../components/ui/IceServerSettings.jsx?v=${timestamp}`), import(`../components/ui/IceServerSettings.jsx?v=${timestamp}`),
import(`../components/ui/CallUI.jsx?v=${timestamp}`),
]); ]);
// Components are automatically registered on window by their respective modules // Components are automatically registered on window by their respective modules
+12
View File
@@ -39,3 +39,15 @@
0% { transform: scale(1); opacity: 0.5; } 0% { transform: scale(1); opacity: 0.5; }
100% { transform: scale(2.1); opacity: 0; } 100% { transform: scale(2.1); opacity: 0; }
} }
/* ── Encrypted call UI ─────────────────────────────────────────────── */
/* Expanding ring pulse behind the caller avatar while ringing. */
@keyframes sbCallPulse {
0% { transform: scale(1); opacity: 0.5; }
100% { transform: scale(1.85); opacity: 0; }
}
/* Slow animated gradient behind a camera-off video placeholder. */
@keyframes sbLiveBg {
0%, 100% { background-position: 0% 50%; }
50% { background-position: 100% 50%; }
}
+1 -1
View File
@@ -11,7 +11,7 @@ let DYNAMIC_CACHE = 'securebit-pwa-dynamic-v4.7.56';
// Build stamp — rewritten by scripts/post-build.js on every release so this file's // Build stamp — rewritten by scripts/post-build.js on every release so this file's
// bytes change each deploy. That is what makes the browser detect a new Service Worker, // bytes change each deploy. That is what makes the browser detect a new Service Worker,
// reinstall it, drop stale caches and (via controllerchange) prompt the page to update. // reinstall it, drop stale caches and (via controllerchange) prompt the page to update.
const SW_BUILD_VERSION = '1784757947117'; const SW_BUILD_VERSION = '1784825757783';
// Load version from meta.json on install // Load version from meta.json on install
async function getAppVersion() { async function getAppVersion() {
+81
View File
@@ -0,0 +1,81 @@
// Unit tests for the adaptive-bitrate metrics + decision logic.
// Run: node tests/webrtc-adaptation.test.mjs
import assert from 'node:assert';
import { summarizeStats, qualityFromMetrics } from '../src/network/webrtc/adaptation/metrics.js';
import { decideAdaptation } from '../src/network/webrtc/adaptation/controller.js';
import { ADAPTATION_CONFIG as CFG } from '../src/network/webrtc/config.js';
const stats = (packetsSent, packetsLost, rttSec, extra = {}) => ([
{ type: 'outbound-rtp', kind: 'video', packetsSent, qualityLimitationReason: extra.qlr || 'none' },
{ type: 'remote-inbound-rtp', kind: 'video', packetsLost, roundTripTime: rttSec, jitter: 0.01 },
{ type: 'candidate-pair', nominated: true, currentRoundTripTime: rttSec, availableOutgoingBitrate: 1000000 },
]);
function testMetrics() {
// First sample: no deltas → 0% loss.
const m1 = summarizeStats(stats(100, 0, 0.05));
assert.strictEqual(m1.counters.packetsSent, 100);
assert.strictEqual(m1.lossPct, 0, 'first sample has no loss');
assert.strictEqual(m1.rttMs, 50, 'rtt in ms');
assert.ok(m1.hasData);
// Second sample: 100 sent, 20 lost → 20/120 ≈ 16.7%.
const m2 = summarizeStats(stats(200, 20, 0.05), m1.counters);
assert.ok(Math.abs(m2.lossPct - 20 / 120) < 1e-9, `loss ~16.7% (got ${m2.lossPct})`);
// Quality thresholds.
assert.strictEqual(qualityFromMetrics({ hasData: true, lossPct: 0.01, rttMs: 100 }), 'excellent');
assert.strictEqual(qualityFromMetrics({ hasData: true, lossPct: 0.05, rttMs: 200 }), 'good');
assert.strictEqual(qualityFromMetrics({ hasData: true, lossPct: 0.12, rttMs: 350 }), 'fair');
assert.strictEqual(qualityFromMetrics({ hasData: true, lossPct: 0.30, rttMs: 500 }), 'poor');
assert.strictEqual(qualityFromMetrics({ hasData: false }), null, 'no data → null');
}
function testDecide() {
const base = { targetBitrate: 1500000, ceilingBitrate: 1500000, scaleResolutionDownBy: 1, goodTicks: 0 };
// High loss → step down 20%.
const d1 = decideAdaptation({ lossPct: 0.15, rttMs: 100, qualityLimitationReason: 'none' }, base, CFG);
assert.strictEqual(d1.targetBitrate, 1200000, 'loss backoff -20%');
assert.ok(d1.changed && d1.reason === 'backoff');
// High RTT → step down.
const d2 = decideAdaptation({ lossPct: 0, rttMs: 350, qualityLimitationReason: 'none' }, base, CFG);
assert.strictEqual(d2.targetBitrate, 1200000, 'rtt backoff -20%');
// Floor respected.
const low = { ...base, targetBitrate: CFG.minVideoBitrate };
const d3 = decideAdaptation({ lossPct: 0.5, rttMs: 500, qualityLimitationReason: 'none' }, low, CFG);
assert.strictEqual(d3.targetBitrate, CFG.minVideoBitrate, 'never below floor');
assert.strictEqual(d3.changed, false, 'no change at floor');
// CPU limitation → scale down resolution, bitrate untouched.
const d4 = decideAdaptation({ lossPct: 0, rttMs: 50, qualityLimitationReason: 'cpu' }, base, CFG);
assert.strictEqual(d4.targetBitrate, 1500000, 'cpu keeps bitrate');
assert.ok(d4.scaleResolutionDownBy > 1, 'cpu scales resolution down');
// Recovery: needs recoverStableTicks good ticks, then ramps +10%.
let st = { ...base, targetBitrate: 1000000 };
const good = { lossPct: 0.01, rttMs: 100, qualityLimitationReason: 'none' };
for (let i = 1; i < CFG.recoverStableTicks; i++) {
st = { ...st, ...decideAdaptation(good, st, CFG) };
assert.strictEqual(st.targetBitrate, 1000000, `no ramp before ${CFG.recoverStableTicks} ticks (tick ${i})`);
}
const dR = decideAdaptation(good, st, CFG);
assert.strictEqual(dR.targetBitrate, 1100000, 'ramp +10% after stable ticks');
assert.ok(dR.changed && dR.reason === 'rampup');
// Ceiling respected.
const atCeil = { ...base, targetBitrate: 1500000, goodTicks: CFG.recoverStableTicks - 1 };
const dC = decideAdaptation(good, atCeil, CFG);
assert.strictEqual(dC.targetBitrate, 1500000, 'never above ceiling');
// Neutral zone → hold, reset goodTicks.
const dN = decideAdaptation({ lossPct: 0.05, rttMs: 200, qualityLimitationReason: 'none' }, { ...base, goodTicks: 3 }, CFG);
assert.strictEqual(dN.changed, false);
assert.strictEqual(dN.goodTicks, 0, 'neutral resets goodTicks');
}
testMetrics();
testDecide();
console.log('webrtc-adaptation.test.mjs: all assertions passed');
+117
View File
@@ -0,0 +1,117 @@
// Unit tests for the pure SDP munging utilities (src/network/webrtc/sdp.js).
// Run: node tests/webrtc-sdp.test.mjs
import assert from 'node:assert';
import {
splitSdp, joinSdp, sectionKind, findPayloadTypes, upsertFmtp, applyOpusSettings,
getCodecPayloadTypes, ensureRtcpFb, ensureExtmap, applyTransport,
} from '../src/network/webrtc/sdp.js';
import { AUDIO_CONFIG, TRANSPORT_CONFIG } from '../src/network/webrtc/config.js';
const CRLF = '\r\n';
// Synthetic SDP: one audio m-line (opus 111 + telephone-event 126, opus already
// has a partial fmtp) and one video m-line (VP9).
const SDP = [
'v=0',
'o=- 1 2 IN IP4 127.0.0.1',
's=-',
't=0 0',
'a=group:BUNDLE 0 1',
'm=audio 9 UDP/TLS/RTP/SAVPF 111 126',
'c=IN IP4 0.0.0.0',
'a=rtpmap:111 opus/48000/2',
'a=fmtp:111 minptime=10;useinbandfec=1',
'a=rtpmap:126 telephone-event/8000',
'a=mid:0',
'm=video 9 UDP/TLS/RTP/SAVPF 96',
'c=IN IP4 0.0.0.0',
'a=rtpmap:96 VP9/90000',
'a=mid:1',
].join(CRLF) + CRLF;
function run() {
// splitSdp / kinds
const parsed = splitSdp(SDP);
assert.strictEqual(parsed.eol, CRLF, 'detects CRLF');
assert.strictEqual(parsed.media.length, 2, 'two m-sections');
assert.strictEqual(sectionKind(parsed.media[0]), 'audio');
assert.strictEqual(sectionKind(parsed.media[1]), 'video');
// findPayloadTypes
assert.deepStrictEqual(findPayloadTypes(parsed.media[0], 'opus'), ['111']);
assert.deepStrictEqual(findPayloadTypes(parsed.media[1], 'VP9'), ['96']);
// applyOpusSettings: all requested params present, existing ones merged (not dropped).
const munged = applyOpusSettings(SDP, AUDIO_CONFIG.opusFmtp);
const fmtp = munged.split(/\r\n/).find(l => l.startsWith('a=fmtp:111 '));
assert.ok(fmtp, 'opus fmtp line exists');
for (const [k, v] of Object.entries(AUDIO_CONFIG.opusFmtp)) {
assert.ok(fmtp.includes(`${k}=${v}`), `fmtp has ${k}=${v} (got: ${fmtp})`);
}
assert.ok(fmtp.includes('minptime=10'), 'pre-existing param retained');
// No duplicate fmtp keys.
const params = fmtp.slice('a=fmtp:111 '.length).split(';').map(s => s.split('=')[0]);
assert.strictEqual(new Set(params).size, params.length, 'no duplicate fmtp keys');
// Idempotent: munging twice equals munging once.
assert.strictEqual(applyOpusSettings(munged, AUDIO_CONFIG.opusFmtp), munged, 'idempotent');
// Line endings preserved.
assert.ok(munged.includes(CRLF), 'CRLF preserved');
// Video section untouched (still exactly one line for VP9, no fmtp injected).
assert.ok(!munged.includes('a=fmtp:96'), 'video section not modified');
// No-Opus SDP returned unchanged (identity).
const noOpus = 'v=0\r\nm=application 9 UDP/DTLS/SCTP webrtc-datachannel\r\na=mid:0\r\n';
assert.strictEqual(applyOpusSettings(noOpus, AUDIO_CONFIG.opusFmtp), noOpus, 'no-opus unchanged');
// upsertFmtp creates a line when none exists.
const p2 = splitSdp('m=audio 9 x 111\r\na=rtpmap:111 opus/48000/2\r\n');
upsertFmtp(p2.media[0], '111', { usedtx: 1 });
assert.ok(joinSdp(p2).includes('a=fmtp:111 usedtx=1'), 'fmtp created when missing');
// joinSdp round-trips an unmodified SDP exactly (trailing CRLF preserved).
assert.strictEqual(joinSdp(splitSdp(SDP)), SDP, 'round-trip preserves SDP exactly');
// ── Transport feedback (Step 4) ──────────────────────────────────────────
// Video section with a real codec (96) + rtx (97). Only 96 is a primary codec.
const vsdp = [
'v=0', 't=0 0',
'm=video 9 UDP/TLS/RTP/SAVPF 96 97',
'a=rtpmap:96 VP9/90000',
'a=rtpmap:97 rtx/90000',
'a=fmtp:97 apt=96',
'a=mid:0',
'',
].join(CRLF);
const vparsed = splitSdp(vsdp);
assert.deepStrictEqual(getCodecPayloadTypes(vparsed.media[0]), ['96'], 'rtx excluded from codec PTs');
const t = applyTransport(vsdp, TRANSPORT_CONFIG);
for (const fb of TRANSPORT_CONFIG.video.rtcpFb) {
assert.ok(t.includes(`a=rtcp-fb:96 ${fb}`), `video has rtcp-fb 96 ${fb}`);
}
assert.ok(!t.includes('a=rtcp-fb:97'), 'no rtcp-fb on rtx');
assert.ok(t.includes(`a=extmap:1 ${TRANSPORT_CONFIG.twccUri}`), 'TWCC extension added (id 1)');
// Idempotent: applying twice adds nothing new.
assert.strictEqual(applyTransport(t, TRANSPORT_CONFIG), t, 'applyTransport idempotent');
const fbCount = (t.match(/a=rtcp-fb:96 transport-cc/g) || []).length;
assert.strictEqual(fbCount, 1, 'no duplicate rtcp-fb line');
// ensureExtmap allocates the next free id, not a colliding one.
const ep = splitSdp('m=video 9 x 96\r\na=rtpmap:96 VP9/90000\r\na=extmap:3 urn:foo\r\na=mid:0\r\n');
ensureExtmap(ep.media[0], 'urn:bar');
assert.ok(joinSdp(ep).includes('a=extmap:4 urn:bar'), 'next free extmap id used');
// Audio gets the subset (transport-cc + nack), no pli/fir/remb.
const at = applyTransport(SDP, TRANSPORT_CONFIG);
assert.ok(at.includes('a=rtcp-fb:111 transport-cc'), 'audio transport-cc');
assert.ok(at.includes('a=rtcp-fb:111 nack'), 'audio nack');
assert.ok(!at.includes('a=rtcp-fb:111 nack pli'), 'audio has no pli');
console.log('webrtc-sdp.test.mjs: all assertions passed');
}
run();
+83
View File
@@ -0,0 +1,83 @@
// Unit tests for the pure video codec helpers (src/network/webrtc/video.js).
// Run: node tests/webrtc-video.test.mjs
import assert from 'node:assert';
import {
codecShortName, sortVideoCodecs, pickPreferredVideoCodec, encodingPlanFor,
codecSupportsSvc, buildVideoSendEncodings,
} from '../src/network/webrtc/video.js';
import { VIDEO_CONFIG } from '../src/network/webrtc/config.js';
// names: array of codec short names, or {name, modes} objects for scalabilityModes.
function caps(names) {
return { codecs: names.map(n => (typeof n === 'string'
? { mimeType: 'video/' + n }
: { mimeType: 'video/' + n.name, scalabilityModes: n.modes })) };
}
function run() {
// codecShortName
assert.strictEqual(codecShortName('video/VP9'), 'VP9');
assert.strictEqual(codecShortName('video/H264'), 'H264');
assert.strictEqual(codecShortName('video/rtx'), 'RTX');
// pickPreferredVideoCodec honours the VP9 > AV1 > H264 > VP8 order.
assert.strictEqual(pickPreferredVideoCodec(caps(['VP8', 'H264', 'VP9', 'AV1'])), 'VP9');
assert.strictEqual(pickPreferredVideoCodec(caps(['VP8', 'H264', 'AV1'])), 'AV1');
assert.strictEqual(pickPreferredVideoCodec(caps(['VP8', 'H264'])), 'H264');
assert.strictEqual(pickPreferredVideoCodec(caps(['VP8'])), 'VP8');
assert.strictEqual(pickPreferredVideoCodec(caps(['red', 'rtx'])), null);
assert.strictEqual(pickPreferredVideoCodec(null), null);
// sortVideoCodecs: preferred codecs first (in our order), rtx/red/fec kept
// afterwards in original relative order; no codec dropped.
const input = caps(['rtx', 'VP8', 'red', 'H264', 'VP9', 'AV1', 'ulpfec']);
const sorted = sortVideoCodecs(input.codecs).map(c => codecShortName(c.mimeType));
assert.deepStrictEqual(sorted, ['VP9', 'AV1', 'H264', 'VP8', 'RTX', 'RED', 'ULPFEC']);
assert.strictEqual(sorted.length, input.codecs.length, 'no codec dropped');
// Stability: non-preferred codecs keep their input order.
const s2 = sortVideoCodecs(caps(['red', 'rtx', 'VP9']).codecs).map(c => codecShortName(c.mimeType));
assert.deepStrictEqual(s2, ['VP9', 'RED', 'RTX']);
// encodingPlanFor: VP9 → L3T3_KEY balanced; AV1 → L1T3 maintain-framerate; else plain.
const vp9 = encodingPlanFor('VP9');
assert.strictEqual(vp9.scalabilityMode, VIDEO_CONFIG.vp9.preferredScalabilityMode);
assert.strictEqual(vp9.degradationPreference, 'balanced');
const av1 = encodingPlanFor('AV1');
assert.strictEqual(av1.scalabilityMode, 'L1T3');
assert.strictEqual(av1.maxBitrate, VIDEO_CONFIG.av1.maxBitrate);
assert.strictEqual(av1.degradationPreference, 'maintain-framerate');
const h264 = encodingPlanFor('H264');
assert.strictEqual(h264.scalabilityMode, undefined, 'H264 no SVC');
const vp8 = encodingPlanFor('VP8');
assert.strictEqual(vp8.scalabilityMode, undefined, 'VP8 no SVC');
// codecSupportsSvc: only true when scalabilityModes confirms our mode.
assert.strictEqual(codecSupportsSvc(caps([{ name: 'VP9', modes: ['L1T3', 'L3T3_KEY'] }]), 'VP9'), true);
assert.strictEqual(codecSupportsSvc(caps([{ name: 'VP9', modes: ['L1T3'] }]), 'VP9'), false, 'mode not present');
assert.strictEqual(codecSupportsSvc(caps(['VP9']), 'VP9'), false, 'no scalabilityModes field → false');
assert.strictEqual(codecSupportsSvc(caps([{ name: 'AV1', modes: ['L1T3'] }]), 'AV1'), true);
// buildVideoSendEncodings:
// VP9 + confirmed SVC → single encoding with L3T3_KEY.
const e1 = buildVideoSendEncodings(caps([{ name: 'VP9', modes: ['L3T3_KEY'] }, 'H264']));
assert.strictEqual(e1.length, 1, 'VP9 SVC → single encoding');
assert.strictEqual(e1[0].scalabilityMode, 'L3T3_KEY');
// VP9 without confirmed SVC → 3-rid L1T3 simulcast.
const e2 = buildVideoSendEncodings(caps(['VP9', 'H264']));
assert.strictEqual(e2.length, 3, 'VP9 no-SVC → simulcast ×3');
assert.strictEqual(e2[0].scalabilityMode, 'L1T3', 'VP9 fallback uses L1T3 rids');
assert.deepStrictEqual(e2.map(e => e.rid), ['low', 'mid', 'high']);
// H.264 only → plain 3-rid simulcast (no scalabilityMode).
const e3 = buildVideoSendEncodings(caps(['H264']));
assert.strictEqual(e3.length, 3, 'H264 → simulcast ×3');
assert.strictEqual(e3[0].scalabilityMode, undefined, 'plain simulcast has no SVC mode');
// AV1 + confirmed SVC → single L1T3.
const e4 = buildVideoSendEncodings(caps([{ name: 'AV1', modes: ['L1T3'] }]));
assert.strictEqual(e4.length, 1);
assert.strictEqual(e4[0].scalabilityMode, 'L1T3');
console.log('webrtc-video.test.mjs: all assertions passed');
}
run();