feat(webrtc): end-to-end encrypted voice & video calls with adaptive codecs
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:
@@ -145,8 +145,8 @@
|
||||
Header set X-Frame-Options "DENY"
|
||||
# Force HTTPS (2 years + preload) to close the first-visit SSL-strip window.
|
||||
Header set Strict-Transport-Security "max-age=63072000; includeSubDomains; preload"
|
||||
# Restrict powerful features; camera kept for in-page QR scanning.
|
||||
Header set Permissions-Policy "camera=(self), microphone=(), geolocation=(), payment=(), usb=(), magnetometer=(), gyroscope=(), accelerometer=()"
|
||||
# Restrict powerful features; camera + microphone kept for QR scanning and calls.
|
||||
Header set Permissions-Policy "camera=(self), microphone=(self), geolocation=(), payment=(), usb=(), magnetometer=(), gyroscope=(), accelerometer=()"
|
||||
</IfModule>
|
||||
|
||||
# Content Security Policy (frame-ancestors and report-uri only work in HTTP headers, not meta tags)
|
||||
|
||||
@@ -1,5 +1,22 @@
|
||||
# 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 15–20% 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
|
||||
|
||||
SecureBit now supports **end-to-end encrypted voice messages**, sent over the same secure transfer channel as files.
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
No accounts. No servers storing your messages. No installation required.
|
||||
|
||||
[](LICENSE)
|
||||
[](CHANGELOG.md)
|
||||
[](CHANGELOG.md)
|
||||
[](#install-as-an-app)
|
||||
[](#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.
|
||||
- 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 15–20% 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**
|
||||
- **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.
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
+3
-3
@@ -88,9 +88,9 @@ http {
|
||||
# Force HTTPS for two years and preload, closing the first-visit SSL-strip
|
||||
# window that upgrade-insecure-requests alone does not cover.
|
||||
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
|
||||
# Lock down powerful features. Camera is allowed for in-page QR scanning;
|
||||
# microphone/geolocation and other sensors are denied outright.
|
||||
add_header Permissions-Policy "camera=(self), microphone=(), geolocation=(), payment=(), usb=(), magnetometer=(), gyroscope=(), accelerometer=()" always;
|
||||
# Lock down powerful features. Camera + microphone are allowed for QR
|
||||
# scanning and encrypted voice/video calls; other sensors are denied.
|
||||
add_header Permissions-Policy "camera=(self), microphone=(self), geolocation=(), payment=(), usb=(), magnetometer=(), gyroscope=(), accelerometer=()" always;
|
||||
add_header Cache-Control $sb_cache always;
|
||||
# Edge-cache directive for Cloudflare/CDNs (empty value → header is omitted).
|
||||
add_header CDN-Cache-Control $sb_cdn_cache always;
|
||||
|
||||
Vendored
+1384
-2
File diff suppressed because it is too large
Load Diff
Vendored
+4
-4
File diff suppressed because one or more lines are too long
Vendored
+83
-15
@@ -2168,9 +2168,8 @@ var SecureBitChatHeader = ({ status, onDisconnect, webrtcManager, title, isOffli
|
||||
]);
|
||||
const headerResponsiveCss = React.createElement("style", { key: "hdr-css", dangerouslySetInnerHTML: {
|
||||
__html: (
|
||||
// Mobile: leave room for the drawer hamburger and shed non-essential header
|
||||
// chrome so avatar + name + status + Disconnect fit a narrow screen.
|
||||
"@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;}}"
|
||||
// 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;}@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;}}"
|
||||
)
|
||||
} });
|
||||
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: "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("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"))
|
||||
])
|
||||
]),
|
||||
secBtn,
|
||||
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("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)
|
||||
@@ -2525,7 +2558,7 @@ var EnhancedChatInterface = ({
|
||||
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: () => {
|
||||
if (showFileTransfer && fileSendMode) {
|
||||
setShowFileTransfer(false);
|
||||
@@ -2538,7 +2571,7 @@ var EnhancedChatInterface = ({
|
||||
React.createElement("i", { key: "i", className: "fas fa-paperclip", style: { fontSize: "13px" } }),
|
||||
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("i", { key: "i", className: "fas fa-code", style: { fontSize: "13px" } }),
|
||||
"Code"
|
||||
@@ -2623,7 +2656,7 @@ var EnhancedChatInterface = ({
|
||||
);
|
||||
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)" } },
|
||||
{ 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" } },
|
||||
@@ -2645,10 +2678,15 @@ var EnhancedChatInterface = ({
|
||||
peerPresence,
|
||||
onRenameTitle
|
||||
});
|
||||
const callOverlay = window.CallUIComponent && React.createElement(window.CallUIComponent, {
|
||||
key: "call-overlay",
|
||||
webrtcManager,
|
||||
peerTitle: title
|
||||
});
|
||||
return React.createElement("div", {
|
||||
className: "chat-container",
|
||||
style: { display: "flex", flexDirection: "column", height: "100vh", background: "#0f0f11", color: "#e8e8eb" }
|
||||
}, [chatHeader, messagesArea, scrollBtn, composer]);
|
||||
style: { position: "relative", display: "flex", flexDirection: "column", height: "100vh", background: "#0f0f11", color: "#e8e8eb" }
|
||||
}, [chatHeader, messagesArea, scrollBtn, composer, callOverlay]);
|
||||
};
|
||||
var buildSessionMessage = (message, type, opts = {}) => ({
|
||||
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" } },
|
||||
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 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>',
|
||||
@@ -2847,7 +2885,7 @@ var SessionsSidebar = ({ chats, collapsed, drawerOpen, onToggleCollapse, onSelec
|
||||
const inner = collapsed ? collapsedInner : expandedInner;
|
||||
return h(React.Fragment, null, [
|
||||
// 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
|
||||
h("aside", { key: "rail", className: "sb-rail", style: railStyle }, inner),
|
||||
// Mobile drawer overlay
|
||||
@@ -2856,7 +2894,13 @@ var SessionsSidebar = ({ chats, collapsed, drawerOpen, onToggleCollapse, onSelec
|
||||
className: "sb-drawer-overlay",
|
||||
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" }
|
||||
}, 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 = () => {
|
||||
@@ -2995,6 +3039,28 @@ var EnhancedSecureP2PChat = () => {
|
||||
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(() => {
|
||||
try {
|
||||
return localStorage.getItem("securebit_relay_only_mode") === "true";
|
||||
@@ -3523,7 +3589,7 @@ var EnhancedSecureP2PChat = () => {
|
||||
} 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(
|
||||
// Progress callback — drives the voice-note upload/download ring.
|
||||
(progress) => {
|
||||
@@ -5139,11 +5205,13 @@ var EnhancedSecureP2PChat = () => {
|
||||
return s && s.sas && s.sas.isVerified;
|
||||
});
|
||||
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
|
||||
// fill the viewport, design-style). Otherwise it's the scrollable landing.
|
||||
// flexDirection:'row' is explicit — the .minimal-bg class forces
|
||||
// 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" }
|
||||
}, [
|
||||
showSidebar && React.createElement(SessionsSidebar, {
|
||||
@@ -5169,7 +5237,7 @@ var EnhancedSecureP2PChat = () => {
|
||||
}),
|
||||
React.createElement("div", {
|
||||
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" } : {}
|
||||
}, [
|
||||
// Advanced network settings now render inside the connection
|
||||
|
||||
Vendored
+2
-2
File diff suppressed because one or more lines are too long
@@ -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 816–817. |
|
||||
| `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` | 13087–13088 | 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 13146–13158.
|
||||
- **`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?
|
||||
@@ -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 15–20% 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 ~1–2 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`).
|
||||
@@ -14,7 +14,7 @@ primary_region = "fra"
|
||||
force_https = true # matches the app's upgrade-insecure-requests CSP
|
||||
auto_stop_machines = "stop"
|
||||
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]
|
||||
type = "requests"
|
||||
|
||||
+23
-23
@@ -2,7 +2,7 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<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"
|
||||
content="default-src 'self';
|
||||
script-src 'self';
|
||||
@@ -23,7 +23,7 @@
|
||||
|
||||
<!-- PWA Manifest -->
|
||||
<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 -->
|
||||
<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">
|
||||
|
||||
<!-- 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="60x60" href="./logo/icon-60x60.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="144x144" href="./logo/icon-144x144.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 -->
|
||||
<meta name="msapplication-TileColor" content="#ff6b35">
|
||||
@@ -182,7 +182,7 @@
|
||||
<!-- Render-blocking JS is deferred: classic deferred scripts and module scripts
|
||||
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. -->
|
||||
<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-dom/react-dom.production.min.js"></script>
|
||||
<!-- 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). -->
|
||||
<script defer src="libs/prism/prism.js"></script>
|
||||
<!-- Critical, paint-defining CSS stays render-blocking (avoids FOUC / layout shift). -->
|
||||
<link rel="stylesheet" href="assets/tailwind.css?v=1784757947117">
|
||||
<link rel="icon" type="image/x-icon" href="/logo/favicon.ico?v=1784757947117">
|
||||
<link rel="stylesheet" href="assets/tailwind.css?v=1784825757783">
|
||||
<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
|
||||
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. -->
|
||||
@@ -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-700.woff2" as="font" type="font/woff2" crossorigin>
|
||||
<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/animations.css?v=1784757947117">
|
||||
<link rel="stylesheet" href="src/styles/components.css?v=1784757947117">
|
||||
<link rel="stylesheet" href="src/styles/main.css?v=1784825757783">
|
||||
<link rel="stylesheet" href="src/styles/animations.css?v=1784825757783">
|
||||
<link rel="stylesheet" href="src/styles/components.css?v=1784825757783">
|
||||
<!-- 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>
|
||||
<link rel="stylesheet" href="/assets/fontawesome/css/all.min.css">
|
||||
<link rel="stylesheet" href="libs/prism/prism.css">
|
||||
</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 - система принудительного обновления -->
|
||||
<script defer src="src/utils/updateManager.js?v=1784757947117"></script>
|
||||
<script type="module" src="src/components/UpdateChecker.jsx?v=1784757947117"></script>
|
||||
<script type="module" src="dist/qr-local.js?v=1784757947117"></script>
|
||||
<script type="module" src="src/components/QRScanner.js?v=1784757947117"></script>
|
||||
<script defer src="src/utils/updateManager.js?v=1784825757783"></script>
|
||||
<script type="module" src="src/components/UpdateChecker.jsx?v=1784825757783"></script>
|
||||
<script type="module" src="dist/qr-local.js?v=1784825757783"></script>
|
||||
<script type="module" src="src/components/QRScanner.js?v=1784825757783"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="dist/app-boot.js?v=1784757947117"></script>
|
||||
<script type="module" src="dist/app.js?v=1784757947117"></script>
|
||||
<script type="module" src="dist/app-boot.js?v=1784825757783"></script>
|
||||
<script type="module" src="dist/app.js?v=1784825757783"></script>
|
||||
|
||||
<script defer src="src/scripts/pwa-register.js?v=1784757947117"></script>
|
||||
<script src="./src/pwa/install-prompt.js?v=1784757947117" type="module"></script>
|
||||
<script src="./src/pwa/pwa-manager.js?v=1784757947117" type="module"></script>
|
||||
<script defer src="./src/scripts/pwa-offline-test.js?v=1784757947117"></script>
|
||||
<link rel="stylesheet" href="./src/styles/pwa.css?v=1784757947117">
|
||||
<script defer src="src/scripts/pwa-register.js?v=1784825757783"></script>
|
||||
<script src="./src/pwa/install-prompt.js?v=1784825757783" 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=1784825757783"></script>
|
||||
<link rel="stylesheet" href="./src/styles/pwa.css?v=1784825757783">
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,10 +1,10 @@
|
||||
{
|
||||
"version": "1784757947117",
|
||||
"buildVersion": "1784757947117",
|
||||
"appVersion": "5.4.10",
|
||||
"buildTime": "2026-07-22T22:05:47.156Z",
|
||||
"buildId": "1784757947117-c98a01b",
|
||||
"gitHash": "c98a01b",
|
||||
"version": "1784825757783",
|
||||
"buildVersion": "1784825757783",
|
||||
"appVersion": "5.5.0",
|
||||
"buildTime": "2026-07-23T16:55:57.822Z",
|
||||
"buildId": "1784825757783-e544593",
|
||||
"gitHash": "e544593",
|
||||
"generated": true,
|
||||
"generatedAt": "2026-07-22T22:05:47.157Z"
|
||||
"generatedAt": "2026-07-23T16:55:57.823Z"
|
||||
}
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "securebit-chat",
|
||||
"version": "5.4.10",
|
||||
"version": "5.5.0",
|
||||
"description": "Secure P2P Communication Application with End-to-End Encryption",
|
||||
"main": "index.html",
|
||||
"scripts": {
|
||||
@@ -11,7 +11,7 @@
|
||||
"dev": "npm run build && python -m http.server 8000",
|
||||
"watch": "npx tailwindcss -i src/styles/tw-input.css -o assets/tailwind.css --watch",
|
||||
"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": [
|
||||
"p2p",
|
||||
|
||||
+105
-17
@@ -1947,17 +1947,31 @@ import {
|
||||
]);
|
||||
|
||||
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
|
||||
// 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){' +
|
||||
'.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{padding-left:58px !important;gap:8px !important;}' +
|
||||
// Remove the security/verification pill on mobile (per request) — the
|
||||
// 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{padding:9px !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', {
|
||||
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: '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('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…')))))
|
||||
])
|
||||
]),
|
||||
secBtn,
|
||||
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('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)
|
||||
@@ -2295,7 +2334,7 @@ import {
|
||||
);
|
||||
|
||||
// ---- 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: () => {
|
||||
if (showFileTransfer && fileSendMode) { setShowFileTransfer(false); setFileSendMode(false); }
|
||||
else { setShowFileTransfer(true); setFileSendMode(true); }
|
||||
@@ -2303,7 +2342,7 @@ import {
|
||||
React.createElement('i', { key: 'i', className: 'fas fa-paperclip', style: { fontSize: '13px' } }),
|
||||
(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('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' } },
|
||||
isRecording
|
||||
? [recordingBar]
|
||||
@@ -2397,10 +2436,17 @@ import {
|
||||
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', {
|
||||
className: 'chat-container',
|
||||
style: { display: 'flex', flexDirection: 'column', height: '100vh', background: '#0f0f11', color: '#e8e8eb' }
|
||||
}, [chatHeader, messagesArea, scrollBtn, composer]);
|
||||
style: { position: 'relative', display: 'flex', flexDirection: 'column', height: '100vh', background: '#0f0f11', color: '#e8e8eb' }
|
||||
}, [chatHeader, messagesArea, scrollBtn, composer, callOverlay]);
|
||||
};
|
||||
|
||||
|
||||
@@ -2501,7 +2547,7 @@ import {
|
||||
// transparent background — no black tile.
|
||||
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' } }));
|
||||
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 ----
|
||||
// ---- Presence ("You" status) panel ----
|
||||
@@ -2605,7 +2651,20 @@ import {
|
||||
|
||||
return h(React.Fragment, null, [
|
||||
// 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
|
||||
h('aside', { key: 'rail', className: 'sb-rail', style: railStyle }, inner),
|
||||
// Mobile drawer overlay
|
||||
@@ -2613,7 +2672,13 @@ import {
|
||||
key: 'drawer', className: 'sb-drawer-overlay',
|
||||
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' }
|
||||
}, 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);
|
||||
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(() => {
|
||||
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).
|
||||
manager.setFileTransferCallbacks(
|
||||
@@ -5331,11 +5417,13 @@ import {
|
||||
});
|
||||
|
||||
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
|
||||
// fill the viewport, design-style). Otherwise it's the scrollable landing.
|
||||
// flexDirection:'row' is explicit — the .minimal-bg class forces
|
||||
// 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' }
|
||||
}, [
|
||||
showSidebar && React.createElement(SessionsSidebar, {
|
||||
@@ -5361,7 +5449,7 @@ import {
|
||||
}),
|
||||
React.createElement('div', {
|
||||
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' } : {}
|
||||
}, [
|
||||
// Advanced network settings now render inside the connection
|
||||
|
||||
@@ -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 };
|
||||
@@ -559,7 +559,7 @@ const EnhancedMinimalHeader = ({
|
||||
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('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')
|
||||
])
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
// Import EnhancedSecureFileTransfer
|
||||
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
|
||||
// ============================================
|
||||
// Issue: After introducing the Mutex system, messages stopped being delivered between users
|
||||
@@ -94,7 +101,17 @@ class EnhancedSecureWebRTCManager {
|
||||
CHUNK_CONFIRMATION: 'chunk_confirmation',
|
||||
FILE_TRANSFER_COMPLETE: 'file_transfer_complete',
|
||||
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: 'fake'
|
||||
};
|
||||
@@ -119,7 +136,7 @@ class EnhancedSecureWebRTCManager {
|
||||
]);
|
||||
|
||||
// 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 = {}) {
|
||||
@@ -364,7 +381,34 @@ this._secureLog('info', '🔒 Enhanced Mutex system fully initialized and valida
|
||||
};
|
||||
this.onFileReceived = 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
|
||||
this.keyRotationInterval = null; // отключаем таймерную ротацию
|
||||
this.lastKeyRotation = Date.now();
|
||||
@@ -6850,6 +6894,20 @@ async processMessage(data) {
|
||||
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)
|
||||
// ============================================
|
||||
@@ -7669,6 +7727,11 @@ async processMessage(data) {
|
||||
|
||||
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 = () => {
|
||||
const state = this.peerConnection.connectionState;
|
||||
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) => {
|
||||
|
||||
|
||||
// CRITICAL: Store the received data channel
|
||||
if (event.channel.label === 'securechat') {
|
||||
this.dataChannel = event.channel;
|
||||
@@ -7911,6 +7986,22 @@ async processMessage(data) {
|
||||
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)
|
||||
// ============================================
|
||||
@@ -12226,6 +12317,13 @@ async processMessage(data) {
|
||||
try {
|
||||
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.
|
||||
this.intentionalDisconnect = true;
|
||||
window.EnhancedSecureCryptoUtils.secureLog.log('info', 'Starting intentional disconnect');
|
||||
@@ -12841,6 +12939,505 @@ checkFileTransferReadiness() {
|
||||
}
|
||||
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 {
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 15–20% 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.
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import '../components/ui/Roadmap.jsx';
|
||||
import '../components/ui/CommunityCTA.jsx';
|
||||
import '../components/ui/FileTransfer.jsx';
|
||||
import '../components/ui/IceServerSettings.jsx';
|
||||
import '../components/ui/CallUI.jsx';
|
||||
|
||||
// Expose to global for legacy usage inside app code
|
||||
window.EnhancedSecureCryptoUtils = EnhancedSecureCryptoUtils;
|
||||
|
||||
Vendored
+1
@@ -37,6 +37,7 @@
|
||||
import(`../components/ui/Roadmap.jsx?v=${timestamp}`),
|
||||
import(`../components/ui/FileTransfer.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
|
||||
|
||||
@@ -39,3 +39,15 @@
|
||||
0% { transform: scale(1); opacity: 0.5; }
|
||||
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%; }
|
||||
}
|
||||
|
||||
@@ -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
|
||||
// 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.
|
||||
const SW_BUILD_VERSION = '1784757947117';
|
||||
const SW_BUILD_VERSION = '1784825757783';
|
||||
|
||||
// Load version from meta.json on install
|
||||
async function getAppVersion() {
|
||||
|
||||
@@ -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');
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
Reference in New Issue
Block a user