diff --git a/.htaccess b/.htaccess index 1740af0..06d3e39 100644 --- a/.htaccess +++ b/.htaccess @@ -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=()" # Content Security Policy (frame-ancestors and report-uri only work in HTTP headers, not meta tags) diff --git a/CHANGELOG.md b/CHANGELOG.md index 05d7ca8..0987dd7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/README.md b/README.md index 6b117e0..9c1513a 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ No accounts. No servers storing your messages. No installation required. [![License: MIT](https://img.shields.io/badge/License-MIT-f0892a.svg)](LICENSE) -[![Version](https://img.shields.io/badge/version-5.4.5-3ecf8e.svg)](CHANGELOG.md) +[![Version](https://img.shields.io/badge/version-5.5.0-3ecf8e.svg)](CHANGELOG.md) [![PWA](https://img.shields.io/badge/PWA-installable-3ecf8e.svg)](#install-as-an-app) [![Encryption](https://img.shields.io/badge/crypto-ECDH%20P--384%20%C2%B7%20AES--256--GCM-blue.svg)](#security-model) @@ -42,6 +42,12 @@ It is designed for people who need a small, auditable, zero-infrastructure way t - Optional **relay-only mode** routes traffic through your own TURN server so your IP is never exposed to the peer. - 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. diff --git a/assets/tailwind.css b/assets/tailwind.css index 30fb34e..301bef1 100644 --- a/assets/tailwind.css +++ b/assets/tailwind.css @@ -1 +1 @@ -*,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }/*! tailwindcss v3.4.17 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}.\!container{width:100%!important}.container{width:100%}@media (min-width:640px){.\!container{max-width:640px!important}.container{max-width:640px}}@media (min-width:768px){.\!container{max-width:768px!important}.container{max-width:768px}}@media (min-width:1024px){.\!container{max-width:1024px!important}.container{max-width:1024px}}@media (min-width:1280px){.\!container{max-width:1280px!important}.container{max-width:1280px}}@media (min-width:1536px){.\!container{max-width:1536px!important}.container{max-width:1536px}}.pointer-events-none{pointer-events:none}.visible{visibility:visible}.invisible{visibility:hidden}.collapse{visibility:collapse}.static{position:static}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.sticky{position:sticky}.inset-0{inset:0}.bottom-0{bottom:0}.bottom-4{bottom:1rem}.left-0{left:0}.left-1\/2{left:50%}.left-4{left:1rem}.right-0{right:0}.right-4{right:1rem}.top-0{top:0}.top-1\/2{top:50%}.top-4{top:1rem}.z-10{z-index:10}.z-20{z-index:20}.z-30{z-index:30}.z-40{z-index:40}.z-50{z-index:50}.mx-4{margin-left:1rem;margin-right:1rem}.mx-auto{margin-left:auto;margin-right:auto}.my-1{margin-top:.25rem}.mb-1,.my-1{margin-bottom:.25rem}.mb-12{margin-bottom:3rem}.mb-2{margin-bottom:.5rem}.mb-3{margin-bottom:.75rem}.mb-4{margin-bottom:1rem}.mb-5{margin-bottom:1.25rem}.mb-6{margin-bottom:1.5rem}.mb-8{margin-bottom:2rem}.ml-2{margin-left:.5rem}.ml-6{margin-left:1.5rem}.mr-1{margin-right:.25rem}.mr-2{margin-right:.5rem}.mr-3{margin-right:.75rem}.mr-4{margin-right:1rem}.mt-0\.5{margin-top:.125rem}.mt-1{margin-top:.25rem}.mt-16{margin-top:4rem}.mt-20{margin-top:5rem}.mt-4{margin-top:1rem}.mt-6{margin-top:1.5rem}.mt-8{margin-top:2rem}.block{display:block}.inline-block{display:inline-block}.inline{display:inline}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.grid{display:grid}.contents{display:contents}.hidden{display:none}.h-10{height:2.5rem}.h-12{height:3rem}.h-16{height:4rem}.h-28{height:7rem}.h-8{height:2rem}.h-80{height:20rem}.h-9{height:2.25rem}.h-\[420px\]{height:420px}.max-h-\[80vh\]{max-height:80vh}.min-h-screen{min-height:100vh}.w-10{width:2.5rem}.w-12{width:3rem}.w-16{width:4rem}.w-28{width:7rem}.w-4{width:1rem}.w-72{width:18rem}.w-8{width:2rem}.w-full{width:100%}.min-w-\[160px\]{min-width:160px}.min-w-\[180px\]{min-width:180px}.min-w-\[240px\]{min-width:240px}.max-w-2xl{max-width:42rem}.max-w-3xl{max-width:48rem}.max-w-4xl{max-width:56rem}.max-w-5xl{max-width:64rem}.max-w-6xl{max-width:72rem}.max-w-7xl{max-width:80rem}.max-w-\[78vw\]{max-width:78vw}.max-w-lg{max-width:32rem}.max-w-md{max-width:28rem}.max-w-sm{max-width:24rem}.flex-1{flex:1 1 0%}.flex-shrink-0{flex-shrink:0}.border-collapse{border-collapse:collapse}.-translate-x-1\/2{--tw-translate-x:-50%}.-translate-x-1\/2,.-translate-y-1\/2{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.-translate-y-1\/2{--tw-translate-y:-50%}.translate-x-full{--tw-translate-x:100%}.translate-x-full,.translate-y-full{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.translate-y-full{--tw-translate-y:100%}.transform{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}@keyframes spin{to{transform:rotate(1turn)}}.animate-spin{animation:spin 1s linear infinite}.cursor-not-allowed{cursor:not-allowed}.cursor-pointer{cursor:pointer}.resize{resize:both}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-start{align-items:flex-start}.items-center{align-items:center}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-1{gap:.25rem}.gap-12{gap:3rem}.gap-2{gap:.5rem}.gap-3{gap:.75rem}.gap-4{gap:1rem}.gap-6{gap:1.5rem}.space-x-2>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-right:calc(.5rem*var(--tw-space-x-reverse));margin-left:calc(.5rem*(1 - var(--tw-space-x-reverse)))}.space-x-3>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-right:calc(.75rem*var(--tw-space-x-reverse));margin-left:calc(.75rem*(1 - var(--tw-space-x-reverse)))}.space-x-4>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-right:calc(1rem*var(--tw-space-x-reverse));margin-left:calc(1rem*(1 - var(--tw-space-x-reverse)))}.space-y-1>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.25rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.25rem*var(--tw-space-y-reverse))}.space-y-2>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.5rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.5rem*var(--tw-space-y-reverse))}.space-y-3>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.75rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.75rem*var(--tw-space-y-reverse))}.space-y-4>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(1rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1rem*var(--tw-space-y-reverse))}.space-y-6>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(1.5rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1.5rem*var(--tw-space-y-reverse))}.self-center{align-self:center}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.break-words{overflow-wrap:break-word}.break-all{word-break:break-all}.rounded{border-radius:.25rem}.rounded-2xl{border-radius:1rem}.rounded-full{border-radius:9999px}.rounded-lg{border-radius:.5rem}.rounded-xl{border-radius:.75rem}.border{border-width:1px}.border-b{border-bottom-width:1px}.border-t{border-top-width:1px}.border-blue-500\/20{border-color:rgba(59,130,246,.2)}.border-gray-500\/20{border-color:hsla(220,9%,46%,.2)}.border-gray-600{--tw-border-opacity:1;border-color:rgb(75 85 99/var(--tw-border-opacity,1))}.border-gray-600\/30{border-color:rgba(75,85,99,.3)}.border-gray-700\/30{border-color:rgba(55,65,81,.3)}.border-green-500\/20{border-color:rgba(34,197,94,.2)}.border-orange-500\/20{border-color:rgba(249,115,22,.2)}.border-purple-500\/20{border-color:rgba(168,85,247,.2)}.border-purple-500\/30{border-color:rgba(168,85,247,.3)}.border-red-500\/20{border-color:rgba(239,68,68,.2)}.border-yellow-500\/20{border-color:rgba(234,179,8,.2)}.bg-\[rgb\(20_20_20_\/50\%\)\]{background-color:hsla(0,0%,8%,.5)}.bg-black\/20{background-color:rgba(0,0,0,.2)}.bg-black\/30{background-color:rgba(0,0,0,.3)}.bg-black\/50{background-color:rgba(0,0,0,.5)}.bg-black\/80{background-color:rgba(0,0,0,.8)}.bg-blue-500{--tw-bg-opacity:1;background-color:rgb(59 130 246/var(--tw-bg-opacity,1))}.bg-blue-500\/10{background-color:rgba(59,130,246,.1)}.bg-blue-500\/90{background-color:rgba(59,130,246,.9)}.bg-emerald-500{--tw-bg-opacity:1;background-color:rgb(16 185 129/var(--tw-bg-opacity,1))}.bg-gray-500\/10{background-color:hsla(220,9%,46%,.1)}.bg-gray-500\/20{background-color:hsla(220,9%,46%,.2)}.bg-gray-600{--tw-bg-opacity:1;background-color:rgb(75 85 99/var(--tw-bg-opacity,1))}.bg-gray-700{--tw-bg-opacity:1;background-color:rgb(55 65 81/var(--tw-bg-opacity,1))}.bg-gray-700\/50{background-color:rgba(55,65,81,.5)}.bg-gray-800{--tw-bg-opacity:1;background-color:rgb(31 41 55/var(--tw-bg-opacity,1))}.bg-gray-800\/50{background-color:rgba(31,41,55,.5)}.bg-gray-800\/95{background-color:rgba(31,41,55,.95)}.bg-gray-900\/30{background-color:rgba(17,24,39,.3)}.bg-green-500{--tw-bg-opacity:1;background-color:rgb(34 197 94/var(--tw-bg-opacity,1))}.bg-green-500\/10{background-color:rgba(34,197,94,.1)}.bg-green-500\/90{background-color:rgba(34,197,94,.9)}.bg-neutral-900{--tw-bg-opacity:1;background-color:rgb(23 23 23/var(--tw-bg-opacity,1))}.bg-orange-500{--tw-bg-opacity:1;background-color:rgb(249 115 22/var(--tw-bg-opacity,1))}.bg-orange-500\/10{background-color:rgba(249,115,22,.1)}.bg-purple-500\/10{background-color:rgba(168,85,247,.1)}.bg-red-500{--tw-bg-opacity:1;background-color:rgb(239 68 68/var(--tw-bg-opacity,1))}.bg-red-500\/10{background-color:rgba(239,68,68,.1)}.bg-red-500\/20{background-color:rgba(239,68,68,.2)}.bg-red-900\/50{background-color:rgba(127,29,29,.5)}.bg-transparent{background-color:transparent}.bg-white\/20{background-color:hsla(0,0%,100%,.2)}.bg-yellow-500{--tw-bg-opacity:1;background-color:rgb(234 179 8/var(--tw-bg-opacity,1))}.bg-yellow-500\/10{background-color:rgba(234,179,8,.1)}.bg-yellow-500\/90{background-color:rgba(234,179,8,.9)}.bg-gradient-to-b{background-image:linear-gradient(to bottom,var(--tw-gradient-stops))}.bg-gradient-to-r{background-image:linear-gradient(to right,var(--tw-gradient-stops))}.bg-gradient-to-t{background-image:linear-gradient(to top,var(--tw-gradient-stops))}.from-\[\#1f1f1f\]\/90{--tw-gradient-from:rgba(31,31,31,.9) var(--tw-gradient-from-position);--tw-gradient-to:rgba(31,31,31,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.from-gray-800\/20{--tw-gradient-from:rgba(31,41,55,.2) var(--tw-gradient-from-position);--tw-gradient-to:rgba(31,41,55,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.to-gray-900\/20{--tw-gradient-to:rgba(17,24,39,.2) var(--tw-gradient-to-position)}.to-transparent{--tw-gradient-to:transparent var(--tw-gradient-to-position)}.fill-blue-500{fill:#3b82f6}.fill-white{fill:#fff}.p-2{padding:.5rem}.p-3{padding:.75rem}.p-4{padding:1rem}.p-5{padding:1.25rem}.p-6{padding:1.5rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-4{padding-left:1rem;padding-right:1rem}.px-6{padding-left:1.5rem;padding-right:1.5rem}.py-1{padding-top:.25rem;padding-bottom:.25rem}.py-1\.5{padding-top:.375rem;padding-bottom:.375rem}.py-14{padding-top:3.5rem;padding-bottom:3.5rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.py-3{padding-top:.75rem;padding-bottom:.75rem}.py-4{padding-top:1rem;padding-bottom:1rem}.text-left{text-align:left}.text-center{text-align:center}.font-mono{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace}.text-2xl{font-size:1.5rem;line-height:2rem}.text-3xl{font-size:1.875rem;line-height:2.25rem}.text-\[11px\]{font-size:11px}.text-\[13px\]{font-size:13px}.text-\[3rem\]{font-size:3rem}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xl{font-size:1.25rem;line-height:1.75rem}.text-xs{font-size:.75rem;line-height:1rem}.font-bold{font-weight:700}.font-medium{font-weight:500}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.lowercase{text-transform:lowercase}.italic{font-style:italic}.leading-none{line-height:1}.leading-relaxed{line-height:1.625}.leading-snug{line-height:1.375}.leading-tight{line-height:1.25}.tracking-\[0\.3em\]{letter-spacing:.3em}.tracking-wide{letter-spacing:.025em}.text-amber-300{--tw-text-opacity:1;color:rgb(252 211 77/var(--tw-text-opacity,1))}.text-black{--tw-text-opacity:1;color:rgb(0 0 0/var(--tw-text-opacity,1))}.text-blue-200{--tw-text-opacity:1;color:rgb(191 219 254/var(--tw-text-opacity,1))}.text-blue-300{--tw-text-opacity:1;color:rgb(147 197 253/var(--tw-text-opacity,1))}.text-blue-400{--tw-text-opacity:1;color:rgb(96 165 250/var(--tw-text-opacity,1))}.text-blue-500{--tw-text-opacity:1;color:rgb(59 130 246/var(--tw-text-opacity,1))}.text-cyan-300{--tw-text-opacity:1;color:rgb(103 232 249/var(--tw-text-opacity,1))}.text-cyan-400{--tw-text-opacity:1;color:rgb(34 211 238/var(--tw-text-opacity,1))}.text-gray-200{--tw-text-opacity:1;color:rgb(229 231 235/var(--tw-text-opacity,1))}.text-gray-300{--tw-text-opacity:1;color:rgb(209 213 219/var(--tw-text-opacity,1))}.text-gray-400{--tw-text-opacity:1;color:rgb(156 163 175/var(--tw-text-opacity,1))}.text-gray-500{--tw-text-opacity:1;color:rgb(107 114 128/var(--tw-text-opacity,1))}.text-green-200{--tw-text-opacity:1;color:rgb(187 247 208/var(--tw-text-opacity,1))}.text-green-300{--tw-text-opacity:1;color:rgb(134 239 172/var(--tw-text-opacity,1))}.text-green-400{--tw-text-opacity:1;color:rgb(74 222 128/var(--tw-text-opacity,1))}.text-green-500{--tw-text-opacity:1;color:rgb(34 197 94/var(--tw-text-opacity,1))}.text-orange-200{--tw-text-opacity:1;color:rgb(254 215 170/var(--tw-text-opacity,1))}.text-orange-300{--tw-text-opacity:1;color:rgb(253 186 116/var(--tw-text-opacity,1))}.text-orange-400{--tw-text-opacity:1;color:rgb(251 146 60/var(--tw-text-opacity,1))}.text-orange-500{--tw-text-opacity:1;color:rgb(249 115 22/var(--tw-text-opacity,1))}.text-purple-300{--tw-text-opacity:1;color:rgb(216 180 254/var(--tw-text-opacity,1))}.text-purple-400{--tw-text-opacity:1;color:rgb(192 132 252/var(--tw-text-opacity,1))}.text-red-200{--tw-text-opacity:1;color:rgb(254 202 202/var(--tw-text-opacity,1))}.text-red-300{--tw-text-opacity:1;color:rgb(252 165 165/var(--tw-text-opacity,1))}.text-red-400{--tw-text-opacity:1;color:rgb(248 113 113/var(--tw-text-opacity,1))}.text-red-500{--tw-text-opacity:1;color:rgb(239 68 68/var(--tw-text-opacity,1))}.text-sky-300{--tw-text-opacity:1;color:rgb(125 211 252/var(--tw-text-opacity,1))}.text-white{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity,1))}.text-white\/10{color:hsla(0,0%,100%,.1)}.text-yellow-200{--tw-text-opacity:1;color:rgb(254 240 138/var(--tw-text-opacity,1))}.text-yellow-300{--tw-text-opacity:1;color:rgb(253 224 71/var(--tw-text-opacity,1))}.text-yellow-400{--tw-text-opacity:1;color:rgb(250 204 21/var(--tw-text-opacity,1))}.text-yellow-600{--tw-text-opacity:1;color:rgb(202 138 4/var(--tw-text-opacity,1))}.placeholder-gray-500::-moz-placeholder{--tw-placeholder-opacity:1;color:rgb(107 114 128/var(--tw-placeholder-opacity,1))}.placeholder-gray-500::placeholder{--tw-placeholder-opacity:1;color:rgb(107 114 128/var(--tw-placeholder-opacity,1))}.opacity-0{opacity:0}.opacity-60{opacity:.6}.opacity-90{opacity:.9}.shadow{--tw-shadow:0 1px 3px 0 rgba(0,0,0,.1),0 1px 2px -1px rgba(0,0,0,.1);--tw-shadow-colored:0 1px 3px 0 var(--tw-shadow-color),0 1px 2px -1px var(--tw-shadow-color)}.shadow,.shadow-2xl{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-2xl{--tw-shadow:0 25px 50px -12px rgba(0,0,0,.25);--tw-shadow-colored:0 25px 50px -12px var(--tw-shadow-color)}.shadow-lg{--tw-shadow:0 10px 15px -3px rgba(0,0,0,.1),0 4px 6px -4px rgba(0,0,0,.1);--tw-shadow-colored:0 10px 15px -3px var(--tw-shadow-color),0 4px 6px -4px var(--tw-shadow-color)}.shadow-lg,.shadow-md{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-md{--tw-shadow:0 4px 6px -1px rgba(0,0,0,.1),0 2px 4px -2px rgba(0,0,0,.1);--tw-shadow-colored:0 4px 6px -1px var(--tw-shadow-color),0 2px 4px -2px var(--tw-shadow-color)}.outline{outline-style:solid}.ring{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(3px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000)}.blur{--tw-blur:blur(8px)}.blur,.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.backdrop-blur-md{--tw-backdrop-blur:blur(12px)}.backdrop-blur-md,.backdrop-blur-sm{-webkit-backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia);backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia)}.backdrop-blur-sm{--tw-backdrop-blur:blur(4px)}.backdrop-filter{-webkit-backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia);backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia)}.transition{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-all{transition-property:all;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-colors{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-opacity{transition-property:opacity;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-transform{transition-property:transform;transition-timing-function:cubic-bezier(.4,0,.2,1)}.duration-150,.transition-transform{transition-duration:.15s}.duration-200{transition-duration:.2s}.duration-300{transition-duration:.3s}.duration-500{transition-duration:.5s}.ease-in-out{transition-timing-function:cubic-bezier(.4,0,.2,1)}.ease-out{transition-timing-function:cubic-bezier(0,0,.2,1)}.\[checksum\:4\]{checksum:4}.\[data\:variable\]{data:variable}.\[name\:4\]{name:4}.\[size\:4\]{size:4}.hover\:bg-\[rgb\(20_20_20_\/30\%\)\]:hover{background-color:hsla(0,0%,8%,.3)}.hover\:bg-blue-600:hover{--tw-bg-opacity:1;background-color:rgb(37 99 235/var(--tw-bg-opacity,1))}.hover\:bg-emerald-600:hover{--tw-bg-opacity:1;background-color:rgb(5 150 105/var(--tw-bg-opacity,1))}.hover\:bg-gray-500:hover{--tw-bg-opacity:1;background-color:rgb(107 114 128/var(--tw-bg-opacity,1))}.hover\:bg-gray-700\/40:hover{background-color:rgba(55,65,81,.4)}.hover\:bg-gray-700\/50:hover{background-color:rgba(55,65,81,.5)}.hover\:bg-green-600\/40:hover{background-color:rgba(22,163,74,.4)}.hover\:bg-orange-500\/40:hover{background-color:rgba(249,115,22,.4)}.hover\:bg-orange-600:hover{--tw-bg-opacity:1;background-color:rgb(234 88 12/var(--tw-bg-opacity,1))}.hover\:bg-red-500\/20:hover{background-color:rgba(239,68,68,.2)}.hover\:bg-red-500\/30:hover{background-color:rgba(239,68,68,.3)}.hover\:bg-red-600\/40:hover{background-color:rgba(220,38,38,.4)}.hover\:bg-white\/30:hover{background-color:hsla(0,0%,100%,.3)}.hover\:bg-yellow-600\/40:hover{background-color:rgba(202,138,4,.4)}.hover\:text-gray-200:hover{--tw-text-opacity:1;color:rgb(229 231 235/var(--tw-text-opacity,1))}.hover\:text-gray-700:hover{--tw-text-opacity:1;color:rgb(55 65 81/var(--tw-text-opacity,1))}.hover\:text-green-300:hover{--tw-text-opacity:1;color:rgb(134 239 172/var(--tw-text-opacity,1))}.hover\:text-green-400:hover{--tw-text-opacity:1;color:rgb(74 222 128/var(--tw-text-opacity,1))}.hover\:text-red-300:hover{--tw-text-opacity:1;color:rgb(252 165 165/var(--tw-text-opacity,1))}.hover\:text-white:hover{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity,1))}.focus\:border-purple-400:focus{--tw-border-opacity:1;border-color:rgb(192 132 252/var(--tw-border-opacity,1))}.focus\:border-purple-500\/40:focus{border-color:rgba(168,85,247,.4)}.focus\:outline-none:focus{outline:2px solid transparent;outline-offset:2px}.active\:bg-gray-700\/60:active{background-color:rgba(55,65,81,.6)}.disabled\:cursor-not-allowed:disabled{cursor:not-allowed}.disabled\:opacity-60:disabled{opacity:.6}.group:hover .group-hover\:scale-105{--tw-scale-x:1.05;--tw-scale-y:1.05;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.group:hover .group-hover\:opacity-100{opacity:1}@media (min-width:640px){.sm\:mb-3{margin-bottom:.75rem}.sm\:block{display:block}.sm\:inline{display:inline}.sm\:flex{display:flex}.sm\:h-12{height:3rem}.sm\:w-12{width:3rem}.sm\:gap-4{gap:1rem}.sm\:p-4{padding:1rem}.sm\:py-2\.5{padding-top:.625rem;padding-bottom:.625rem}.sm\:text-3xl{font-size:1.875rem;line-height:2.25rem}.sm\:text-sm{font-size:.875rem;line-height:1.25rem}}@media (min-width:768px){.md\:hidden{display:none}.md\:h-\[32rem\]{height:32rem}.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.md\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}}@media (min-width:1024px){.lg\:col-span-2{grid-column:span 2/span 2}.lg\:col-span-3{grid-column:span 3/span 3}.lg\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.lg\:grid-cols-5{grid-template-columns:repeat(5,minmax(0,1fr))}} \ No newline at end of file +*,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }/*! tailwindcss v3.4.17 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}.\!container{width:100%!important}.container{width:100%}@media (min-width:640px){.\!container{max-width:640px!important}.container{max-width:640px}}@media (min-width:768px){.\!container{max-width:768px!important}.container{max-width:768px}}@media (min-width:1024px){.\!container{max-width:1024px!important}.container{max-width:1024px}}@media (min-width:1280px){.\!container{max-width:1280px!important}.container{max-width:1280px}}@media (min-width:1536px){.\!container{max-width:1536px!important}.container{max-width:1536px}}.pointer-events-none{pointer-events:none}.visible{visibility:visible}.invisible{visibility:hidden}.collapse{visibility:collapse}.static{position:static}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.sticky{position:sticky}.inset-0{inset:0}.bottom-0{bottom:0}.bottom-4{bottom:1rem}.left-0{left:0}.left-1\/2{left:50%}.left-4{left:1rem}.right-0{right:0}.right-4{right:1rem}.top-0{top:0}.top-1\/2{top:50%}.top-4{top:1rem}.z-10{z-index:10}.z-20{z-index:20}.z-30{z-index:30}.z-40{z-index:40}.z-50{z-index:50}.mx-4{margin-left:1rem;margin-right:1rem}.mx-auto{margin-left:auto;margin-right:auto}.my-1{margin-top:.25rem}.mb-1,.my-1{margin-bottom:.25rem}.mb-12{margin-bottom:3rem}.mb-2{margin-bottom:.5rem}.mb-3{margin-bottom:.75rem}.mb-4{margin-bottom:1rem}.mb-5{margin-bottom:1.25rem}.mb-6{margin-bottom:1.5rem}.mb-8{margin-bottom:2rem}.ml-2{margin-left:.5rem}.ml-6{margin-left:1.5rem}.mr-1{margin-right:.25rem}.mr-2{margin-right:.5rem}.mr-3{margin-right:.75rem}.mr-4{margin-right:1rem}.mt-0\.5{margin-top:.125rem}.mt-1{margin-top:.25rem}.mt-16{margin-top:4rem}.mt-20{margin-top:5rem}.mt-4{margin-top:1rem}.mt-6{margin-top:1.5rem}.mt-8{margin-top:2rem}.block{display:block}.inline-block{display:inline-block}.inline{display:inline}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.grid{display:grid}.contents{display:contents}.hidden{display:none}.h-10{height:2.5rem}.h-12{height:3rem}.h-16{height:4rem}.h-28{height:7rem}.h-8{height:2rem}.h-80{height:20rem}.h-9{height:2.25rem}.h-\[420px\]{height:420px}.max-h-\[80vh\]{max-height:80vh}.min-h-screen{min-height:100vh}.w-10{width:2.5rem}.w-12{width:3rem}.w-16{width:4rem}.w-28{width:7rem}.w-4{width:1rem}.w-72{width:18rem}.w-8{width:2rem}.w-full{width:100%}.min-w-\[160px\]{min-width:160px}.min-w-\[180px\]{min-width:180px}.min-w-\[240px\]{min-width:240px}.max-w-2xl{max-width:42rem}.max-w-3xl{max-width:48rem}.max-w-4xl{max-width:56rem}.max-w-5xl{max-width:64rem}.max-w-6xl{max-width:72rem}.max-w-7xl{max-width:80rem}.max-w-\[78vw\]{max-width:78vw}.max-w-lg{max-width:32rem}.max-w-md{max-width:28rem}.max-w-sm{max-width:24rem}.flex-1{flex:1 1 0%}.flex-shrink-0{flex-shrink:0}.shrink{flex-shrink:1}.grow{flex-grow:1}.border-collapse{border-collapse:collapse}.-translate-x-1\/2{--tw-translate-x:-50%}.-translate-x-1\/2,.-translate-y-1\/2{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.-translate-y-1\/2{--tw-translate-y:-50%}.translate-x-full{--tw-translate-x:100%}.translate-x-full,.translate-y-full{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.translate-y-full{--tw-translate-y:100%}.transform{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}@keyframes spin{to{transform:rotate(1turn)}}.animate-spin{animation:spin 1s linear infinite}.cursor-not-allowed{cursor:not-allowed}.cursor-pointer{cursor:pointer}.resize{resize:both}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-start{align-items:flex-start}.items-center{align-items:center}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-1{gap:.25rem}.gap-12{gap:3rem}.gap-2{gap:.5rem}.gap-3{gap:.75rem}.gap-4{gap:1rem}.gap-6{gap:1.5rem}.space-x-2>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-right:calc(.5rem*var(--tw-space-x-reverse));margin-left:calc(.5rem*(1 - var(--tw-space-x-reverse)))}.space-x-3>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-right:calc(.75rem*var(--tw-space-x-reverse));margin-left:calc(.75rem*(1 - var(--tw-space-x-reverse)))}.space-x-4>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-right:calc(1rem*var(--tw-space-x-reverse));margin-left:calc(1rem*(1 - var(--tw-space-x-reverse)))}.space-y-1>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.25rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.25rem*var(--tw-space-y-reverse))}.space-y-2>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.5rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.5rem*var(--tw-space-y-reverse))}.space-y-3>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.75rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.75rem*var(--tw-space-y-reverse))}.space-y-4>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(1rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1rem*var(--tw-space-y-reverse))}.space-y-6>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(1.5rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1.5rem*var(--tw-space-y-reverse))}.self-center{align-self:center}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.break-words{overflow-wrap:break-word}.break-all{word-break:break-all}.rounded{border-radius:.25rem}.rounded-2xl{border-radius:1rem}.rounded-full{border-radius:9999px}.rounded-lg{border-radius:.5rem}.rounded-xl{border-radius:.75rem}.border{border-width:1px}.border-b{border-bottom-width:1px}.border-t{border-top-width:1px}.border-blue-500\/20{border-color:rgba(59,130,246,.2)}.border-gray-500\/20{border-color:hsla(220,9%,46%,.2)}.border-gray-600{--tw-border-opacity:1;border-color:rgb(75 85 99/var(--tw-border-opacity,1))}.border-gray-600\/30{border-color:rgba(75,85,99,.3)}.border-gray-700\/30{border-color:rgba(55,65,81,.3)}.border-green-500\/20{border-color:rgba(34,197,94,.2)}.border-orange-500\/20{border-color:rgba(249,115,22,.2)}.border-purple-500\/20{border-color:rgba(168,85,247,.2)}.border-purple-500\/30{border-color:rgba(168,85,247,.3)}.border-red-500\/20{border-color:rgba(239,68,68,.2)}.border-yellow-500\/20{border-color:rgba(234,179,8,.2)}.bg-\[rgb\(20_20_20_\/50\%\)\]{background-color:hsla(0,0%,8%,.5)}.bg-black\/20{background-color:rgba(0,0,0,.2)}.bg-black\/30{background-color:rgba(0,0,0,.3)}.bg-black\/50{background-color:rgba(0,0,0,.5)}.bg-black\/80{background-color:rgba(0,0,0,.8)}.bg-blue-500{--tw-bg-opacity:1;background-color:rgb(59 130 246/var(--tw-bg-opacity,1))}.bg-blue-500\/10{background-color:rgba(59,130,246,.1)}.bg-blue-500\/90{background-color:rgba(59,130,246,.9)}.bg-emerald-500{--tw-bg-opacity:1;background-color:rgb(16 185 129/var(--tw-bg-opacity,1))}.bg-gray-500\/10{background-color:hsla(220,9%,46%,.1)}.bg-gray-500\/20{background-color:hsla(220,9%,46%,.2)}.bg-gray-600{--tw-bg-opacity:1;background-color:rgb(75 85 99/var(--tw-bg-opacity,1))}.bg-gray-700{--tw-bg-opacity:1;background-color:rgb(55 65 81/var(--tw-bg-opacity,1))}.bg-gray-700\/50{background-color:rgba(55,65,81,.5)}.bg-gray-800{--tw-bg-opacity:1;background-color:rgb(31 41 55/var(--tw-bg-opacity,1))}.bg-gray-800\/50{background-color:rgba(31,41,55,.5)}.bg-gray-800\/95{background-color:rgba(31,41,55,.95)}.bg-gray-900\/30{background-color:rgba(17,24,39,.3)}.bg-green-500{--tw-bg-opacity:1;background-color:rgb(34 197 94/var(--tw-bg-opacity,1))}.bg-green-500\/10{background-color:rgba(34,197,94,.1)}.bg-green-500\/90{background-color:rgba(34,197,94,.9)}.bg-neutral-900{--tw-bg-opacity:1;background-color:rgb(23 23 23/var(--tw-bg-opacity,1))}.bg-orange-500{--tw-bg-opacity:1;background-color:rgb(249 115 22/var(--tw-bg-opacity,1))}.bg-orange-500\/10{background-color:rgba(249,115,22,.1)}.bg-purple-500\/10{background-color:rgba(168,85,247,.1)}.bg-red-500{--tw-bg-opacity:1;background-color:rgb(239 68 68/var(--tw-bg-opacity,1))}.bg-red-500\/10{background-color:rgba(239,68,68,.1)}.bg-red-500\/20{background-color:rgba(239,68,68,.2)}.bg-red-900\/50{background-color:rgba(127,29,29,.5)}.bg-transparent{background-color:transparent}.bg-white\/20{background-color:hsla(0,0%,100%,.2)}.bg-yellow-500{--tw-bg-opacity:1;background-color:rgb(234 179 8/var(--tw-bg-opacity,1))}.bg-yellow-500\/10{background-color:rgba(234,179,8,.1)}.bg-yellow-500\/90{background-color:rgba(234,179,8,.9)}.bg-gradient-to-b{background-image:linear-gradient(to bottom,var(--tw-gradient-stops))}.bg-gradient-to-r{background-image:linear-gradient(to right,var(--tw-gradient-stops))}.bg-gradient-to-t{background-image:linear-gradient(to top,var(--tw-gradient-stops))}.from-\[\#1f1f1f\]\/90{--tw-gradient-from:rgba(31,31,31,.9) var(--tw-gradient-from-position);--tw-gradient-to:rgba(31,31,31,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.from-gray-800\/20{--tw-gradient-from:rgba(31,41,55,.2) var(--tw-gradient-from-position);--tw-gradient-to:rgba(31,41,55,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.to-gray-900\/20{--tw-gradient-to:rgba(17,24,39,.2) var(--tw-gradient-to-position)}.to-transparent{--tw-gradient-to:transparent var(--tw-gradient-to-position)}.fill-blue-500{fill:#3b82f6}.fill-white{fill:#fff}.p-2{padding:.5rem}.p-3{padding:.75rem}.p-4{padding:1rem}.p-5{padding:1.25rem}.p-6{padding:1.5rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-4{padding-left:1rem;padding-right:1rem}.px-6{padding-left:1.5rem;padding-right:1.5rem}.py-1{padding-top:.25rem;padding-bottom:.25rem}.py-1\.5{padding-top:.375rem;padding-bottom:.375rem}.py-14{padding-top:3.5rem;padding-bottom:3.5rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.py-3{padding-top:.75rem;padding-bottom:.75rem}.py-4{padding-top:1rem;padding-bottom:1rem}.text-left{text-align:left}.text-center{text-align:center}.font-mono{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace}.text-2xl{font-size:1.5rem;line-height:2rem}.text-3xl{font-size:1.875rem;line-height:2.25rem}.text-\[11px\]{font-size:11px}.text-\[13px\]{font-size:13px}.text-\[3rem\]{font-size:3rem}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xl{font-size:1.25rem;line-height:1.75rem}.text-xs{font-size:.75rem;line-height:1rem}.font-bold{font-weight:700}.font-medium{font-weight:500}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.lowercase{text-transform:lowercase}.italic{font-style:italic}.leading-none{line-height:1}.leading-relaxed{line-height:1.625}.leading-snug{line-height:1.375}.leading-tight{line-height:1.25}.tracking-\[0\.3em\]{letter-spacing:.3em}.tracking-wide{letter-spacing:.025em}.text-amber-300{--tw-text-opacity:1;color:rgb(252 211 77/var(--tw-text-opacity,1))}.text-black{--tw-text-opacity:1;color:rgb(0 0 0/var(--tw-text-opacity,1))}.text-blue-200{--tw-text-opacity:1;color:rgb(191 219 254/var(--tw-text-opacity,1))}.text-blue-300{--tw-text-opacity:1;color:rgb(147 197 253/var(--tw-text-opacity,1))}.text-blue-400{--tw-text-opacity:1;color:rgb(96 165 250/var(--tw-text-opacity,1))}.text-blue-500{--tw-text-opacity:1;color:rgb(59 130 246/var(--tw-text-opacity,1))}.text-cyan-300{--tw-text-opacity:1;color:rgb(103 232 249/var(--tw-text-opacity,1))}.text-cyan-400{--tw-text-opacity:1;color:rgb(34 211 238/var(--tw-text-opacity,1))}.text-gray-200{--tw-text-opacity:1;color:rgb(229 231 235/var(--tw-text-opacity,1))}.text-gray-300{--tw-text-opacity:1;color:rgb(209 213 219/var(--tw-text-opacity,1))}.text-gray-400{--tw-text-opacity:1;color:rgb(156 163 175/var(--tw-text-opacity,1))}.text-gray-500{--tw-text-opacity:1;color:rgb(107 114 128/var(--tw-text-opacity,1))}.text-green-200{--tw-text-opacity:1;color:rgb(187 247 208/var(--tw-text-opacity,1))}.text-green-300{--tw-text-opacity:1;color:rgb(134 239 172/var(--tw-text-opacity,1))}.text-green-400{--tw-text-opacity:1;color:rgb(74 222 128/var(--tw-text-opacity,1))}.text-green-500{--tw-text-opacity:1;color:rgb(34 197 94/var(--tw-text-opacity,1))}.text-orange-200{--tw-text-opacity:1;color:rgb(254 215 170/var(--tw-text-opacity,1))}.text-orange-300{--tw-text-opacity:1;color:rgb(253 186 116/var(--tw-text-opacity,1))}.text-orange-400{--tw-text-opacity:1;color:rgb(251 146 60/var(--tw-text-opacity,1))}.text-orange-500{--tw-text-opacity:1;color:rgb(249 115 22/var(--tw-text-opacity,1))}.text-purple-300{--tw-text-opacity:1;color:rgb(216 180 254/var(--tw-text-opacity,1))}.text-purple-400{--tw-text-opacity:1;color:rgb(192 132 252/var(--tw-text-opacity,1))}.text-red-200{--tw-text-opacity:1;color:rgb(254 202 202/var(--tw-text-opacity,1))}.text-red-300{--tw-text-opacity:1;color:rgb(252 165 165/var(--tw-text-opacity,1))}.text-red-400{--tw-text-opacity:1;color:rgb(248 113 113/var(--tw-text-opacity,1))}.text-red-500{--tw-text-opacity:1;color:rgb(239 68 68/var(--tw-text-opacity,1))}.text-sky-300{--tw-text-opacity:1;color:rgb(125 211 252/var(--tw-text-opacity,1))}.text-white{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity,1))}.text-white\/10{color:hsla(0,0%,100%,.1)}.text-yellow-200{--tw-text-opacity:1;color:rgb(254 240 138/var(--tw-text-opacity,1))}.text-yellow-300{--tw-text-opacity:1;color:rgb(253 224 71/var(--tw-text-opacity,1))}.text-yellow-400{--tw-text-opacity:1;color:rgb(250 204 21/var(--tw-text-opacity,1))}.text-yellow-600{--tw-text-opacity:1;color:rgb(202 138 4/var(--tw-text-opacity,1))}.placeholder-gray-500::-moz-placeholder{--tw-placeholder-opacity:1;color:rgb(107 114 128/var(--tw-placeholder-opacity,1))}.placeholder-gray-500::placeholder{--tw-placeholder-opacity:1;color:rgb(107 114 128/var(--tw-placeholder-opacity,1))}.opacity-0{opacity:0}.opacity-60{opacity:.6}.opacity-90{opacity:.9}.shadow{--tw-shadow:0 1px 3px 0 rgba(0,0,0,.1),0 1px 2px -1px rgba(0,0,0,.1);--tw-shadow-colored:0 1px 3px 0 var(--tw-shadow-color),0 1px 2px -1px var(--tw-shadow-color)}.shadow,.shadow-2xl{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-2xl{--tw-shadow:0 25px 50px -12px rgba(0,0,0,.25);--tw-shadow-colored:0 25px 50px -12px var(--tw-shadow-color)}.shadow-lg{--tw-shadow:0 10px 15px -3px rgba(0,0,0,.1),0 4px 6px -4px rgba(0,0,0,.1);--tw-shadow-colored:0 10px 15px -3px var(--tw-shadow-color),0 4px 6px -4px var(--tw-shadow-color)}.shadow-lg,.shadow-md{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-md{--tw-shadow:0 4px 6px -1px rgba(0,0,0,.1),0 2px 4px -2px rgba(0,0,0,.1);--tw-shadow-colored:0 4px 6px -1px var(--tw-shadow-color),0 2px 4px -2px var(--tw-shadow-color)}.outline{outline-style:solid}.ring{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(3px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000)}.blur{--tw-blur:blur(8px)}.blur,.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.backdrop-blur-md{--tw-backdrop-blur:blur(12px)}.backdrop-blur-md,.backdrop-blur-sm{-webkit-backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia);backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia)}.backdrop-blur-sm{--tw-backdrop-blur:blur(4px)}.backdrop-filter{-webkit-backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia);backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia)}.transition{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-all{transition-property:all;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-colors{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-opacity{transition-property:opacity;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-transform{transition-property:transform;transition-timing-function:cubic-bezier(.4,0,.2,1)}.duration-150,.transition-transform{transition-duration:.15s}.duration-200{transition-duration:.2s}.duration-300{transition-duration:.3s}.duration-500{transition-duration:.5s}.ease-in-out{transition-timing-function:cubic-bezier(.4,0,.2,1)}.ease-out{transition-timing-function:cubic-bezier(0,0,.2,1)}.\[checksum\:4\]{checksum:4}.\[data\:variable\]{data:variable}.\[name\:4\]{name:4}.\[size\:4\]{size:4}.hover\:bg-\[rgb\(20_20_20_\/30\%\)\]:hover{background-color:hsla(0,0%,8%,.3)}.hover\:bg-blue-600:hover{--tw-bg-opacity:1;background-color:rgb(37 99 235/var(--tw-bg-opacity,1))}.hover\:bg-emerald-600:hover{--tw-bg-opacity:1;background-color:rgb(5 150 105/var(--tw-bg-opacity,1))}.hover\:bg-gray-500:hover{--tw-bg-opacity:1;background-color:rgb(107 114 128/var(--tw-bg-opacity,1))}.hover\:bg-gray-700\/40:hover{background-color:rgba(55,65,81,.4)}.hover\:bg-gray-700\/50:hover{background-color:rgba(55,65,81,.5)}.hover\:bg-green-600\/40:hover{background-color:rgba(22,163,74,.4)}.hover\:bg-orange-500\/40:hover{background-color:rgba(249,115,22,.4)}.hover\:bg-orange-600:hover{--tw-bg-opacity:1;background-color:rgb(234 88 12/var(--tw-bg-opacity,1))}.hover\:bg-red-500\/20:hover{background-color:rgba(239,68,68,.2)}.hover\:bg-red-500\/30:hover{background-color:rgba(239,68,68,.3)}.hover\:bg-red-600\/40:hover{background-color:rgba(220,38,38,.4)}.hover\:bg-white\/30:hover{background-color:hsla(0,0%,100%,.3)}.hover\:bg-yellow-600\/40:hover{background-color:rgba(202,138,4,.4)}.hover\:text-gray-200:hover{--tw-text-opacity:1;color:rgb(229 231 235/var(--tw-text-opacity,1))}.hover\:text-gray-700:hover{--tw-text-opacity:1;color:rgb(55 65 81/var(--tw-text-opacity,1))}.hover\:text-green-300:hover{--tw-text-opacity:1;color:rgb(134 239 172/var(--tw-text-opacity,1))}.hover\:text-green-400:hover{--tw-text-opacity:1;color:rgb(74 222 128/var(--tw-text-opacity,1))}.hover\:text-red-300:hover{--tw-text-opacity:1;color:rgb(252 165 165/var(--tw-text-opacity,1))}.hover\:text-white:hover{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity,1))}.focus\:border-purple-400:focus{--tw-border-opacity:1;border-color:rgb(192 132 252/var(--tw-border-opacity,1))}.focus\:border-purple-500\/40:focus{border-color:rgba(168,85,247,.4)}.focus\:outline-none:focus{outline:2px solid transparent;outline-offset:2px}.active\:bg-gray-700\/60:active{background-color:rgba(55,65,81,.6)}.disabled\:cursor-not-allowed:disabled{cursor:not-allowed}.disabled\:opacity-60:disabled{opacity:.6}.group:hover .group-hover\:scale-105{--tw-scale-x:1.05;--tw-scale-y:1.05;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.group:hover .group-hover\:opacity-100{opacity:1}@media (min-width:640px){.sm\:mb-3{margin-bottom:.75rem}.sm\:block{display:block}.sm\:inline{display:inline}.sm\:flex{display:flex}.sm\:h-12{height:3rem}.sm\:w-12{width:3rem}.sm\:gap-4{gap:1rem}.sm\:p-4{padding:1rem}.sm\:py-2\.5{padding-top:.625rem;padding-bottom:.625rem}.sm\:text-3xl{font-size:1.875rem;line-height:2.25rem}.sm\:text-sm{font-size:.875rem;line-height:1.25rem}}@media (min-width:768px){.md\:hidden{display:none}.md\:h-\[32rem\]{height:32rem}.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.md\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}}@media (min-width:1024px){.lg\:col-span-2{grid-column:span 2/span 2}.lg\:col-span-3{grid-column:span 3/span 3}.lg\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.lg\:grid-cols-5{grid-template-columns:repeat(5,minmax(0,1fr))}} \ No newline at end of file diff --git a/deploy/nginx.conf b/deploy/nginx.conf index 8601401..90a88f0 100644 --- a/deploy/nginx.conf +++ b/deploy/nginx.conf @@ -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; diff --git a/dist/app-boot.js b/dist/app-boot.js index ecf3491..119e797 100644 --- a/dist/app-boot.js +++ b/dist/app-boot.js @@ -6155,6 +6155,534 @@ var EnhancedSecureFileTransfer = class { } }; +// src/network/webrtc/config.js +var IS_WEBKIT = typeof navigator !== "undefined" && /AppleWebKit/.test(navigator.userAgent) && !/Chrome|Chromium|Edg\//.test(navigator.userAgent); +var 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: 32e3, + 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: 4e4, + // 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. + } +}; +var 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: 15e4, scalabilityMode: "L1T3" }, + { rid: "mid", scaleResolutionDownBy: 2, maxBitrate: 5e5, scalabilityMode: "L1T3" }, + { rid: "high", scaleResolutionDownBy: 1, maxBitrate: 15e5, scalabilityMode: "L1T3" } + ], + degradationPreference: "balanced" + }, + av1: { + scalabilityMode: "L1T3", + maxBitrate: 12e5, + degradationPreference: "maintain-framerate" + }, + // H.264 / VP8: ordinary simulcast, no SVC. + simulcast: [ + { rid: "low", scaleResolutionDownBy: 4, maxBitrate: 15e4 }, + { rid: "mid", scaleResolutionDownBy: 2, maxBitrate: 5e5 }, + { rid: "high", scaleResolutionDownBy: 1, maxBitrate: 15e5 } + ], + networkPriority: "medium" + // below audio's 'high'. +}; +var 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 + } +}; +var ADAPTATION_CONFIG = { + intervalMs: 1e3, + loss: { + highPct: 0.1, + // >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.2, + // shrink video maxBitrate by 20% per bad tick. + stepUpPct: 0.1, + // grow by 10% per good window. + minVideoBitrate: 1e5, + // floor for the low layer (bps). + recoverStableTicks: 5, + // consecutive good ticks before ramping up. + cpuScaleStep: 1.5 + // qualityLimitationReason 'cpu' → bump scaleResolutionDownBy ×1.5. +}; + +// src/network/webrtc/sdp.js +function detectEol(sdp) { + return sdp.indexOf("\r\n") !== -1 ? "\r\n" : "\n"; +} +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 }; +} +function joinSdp(parsed) { + const all = [...parsed.session]; + for (const m of parsed.media) all.push(...m.lines); + return all.join(parsed.eol); +} +function sectionKind(section) { + const m = section.lines[0].match(/^m=(\w+)/); + return m ? m[1] : null; +} +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; +} +function parseFmtpParams(value) { + const map = /* @__PURE__ */ 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, void 0); + else map.set(p.slice(0, eq).trim(), p.slice(eq + 1).trim()); + } + return map; +} +function serializeFmtpParams(map) { + const parts = []; + for (const [k, v] of map) parts.push(v === void 0 ? k : `${k}=${v}`); + return parts.join(";"); +} +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 map2 = parseFmtpParams(existing); + for (const [k, v] of Object.entries(params)) map2.set(k, String(v)); + section.lines[fmtpIdx] = `a=fmtp:${pt} ${serializeFmtpParams(map2)}`; + return; + } + const map = /* @__PURE__ */ 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); +} +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; +} +var AUX_CODEC = /^(rtx|red|ulpfec|flexfec-03|telephone-event|CN)$/i; +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; +} +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; + 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); + } + } +} +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) { + 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}`); +} +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; +} + +// src/network/webrtc/audio.js +function applyAudioCodecPreferences(transceiver) { + try { + if (IS_WEBKIT) return false; + 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; + 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; + } +} +async function configureAudioSender(sender, options = {}) { + try { + if (IS_WEBKIT) return false; + 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; + } +} + +// src/network/webrtc/video.js +var CODEC_RANK = { VP9: 0, AV1: 1, H264: 2, VP8: 3 }; +function codecShortName(mimeType) { + const sub = String(mimeType || "").split("/")[1] || ""; + return sub.toUpperCase(); +} +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).map((x) => x.c); +} +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; +} +function applyVideoCodecPreferences(transceiver) { + try { + if (IS_WEBKIT) return false; + 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; + } +} +function encodingPlanFor(codecName) { + if (codecName === "VP9") { + return { scalabilityMode: VIDEO_CONFIG.vp9.preferredScalabilityMode, maxBitrate: 15e5, degradationPreference: VIDEO_CONFIG.vp9.degradationPreference }; + } + if (codecName === "AV1") { + return { scalabilityMode: VIDEO_CONFIG.av1.scalabilityMode, maxBitrate: VIDEO_CONFIG.av1.maxBitrate, degradationPreference: VIDEO_CONFIG.av1.degradationPreference }; + } + return { scalabilityMode: void 0, maxBitrate: 15e5, degradationPreference: "balanced" }; +} +async function configureVideoSender(sender, options = {}) { + try { + if (IS_WEBKIT) return false; + 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) { + 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) { + 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; + } +} + +// src/network/webrtc/adaptation/metrics.js +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; + } 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; + const lossPct = denom > 0 ? Math.min(1, Math.max(0, dLost / denom)) : 0; + const rttSec = candidatePair?.currentRoundTripTime ?? remoteInbound?.roundTripTime ?? 0; + const rttMs = Number(rttSec) * 1e3; + const jitterMs = Number(remoteInbound?.jitter ?? 0) * 1e3; + 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)) + }; +} +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"; +} + +// src/network/webrtc/adaptation/controller.js +function decideAdaptation(m, state, cfg = ADAPTATION_CONFIG) { + let { targetBitrate, ceilingBitrate, scaleResolutionDownBy, goodTicks } = state; + let changed = false, reason = "steady"; + if (m.qualityLimitationReason === "cpu") { + 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) { + 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) { + 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; + } + return { targetBitrate, scaleResolutionDownBy, goodTicks, changed, reason }; +} +var NetworkAdaptationController = class { + /** + * @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 = void 0; + this.state = { + targetBitrate: opts.ceilingBitrate || 15e5, + ceilingBitrate: opts.ceilingBitrate || 15e5, + 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; + 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() { + 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) { + params.encodings[0].maxBitrate = this.state.targetBitrate; + params.encodings[0].scaleResolutionDownBy = this.state.scaleResolutionDownBy; + } else { + const top = params.encodings[params.encodings.length - 1]; + top.maxBitrate = this.state.targetBitrate; + } + await sender.setParameters(params); + } catch (e) { + } + } +}; + // src/network/EnhancedSecureWebRTCManager.js var EnhancedSecureWebRTCManager = class _EnhancedSecureWebRTCManager { // ============================================ @@ -6259,6 +6787,15 @@ var EnhancedSecureWebRTCManager = 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" }; @@ -6280,7 +6817,7 @@ var EnhancedSecureWebRTCManager = class _EnhancedSecureWebRTCManager { Object.freeze({ urls: "stun:stun4.l.google.com:19302" }) ]); // Static debug flag instead of this._debugMode - static DEBUG_MODE = true; + static DEBUG_MODE = false; // Set to true during development, false in production constructor(onMessage, onStatusChange, onKeyExchange, onVerificationRequired, onAnswerError = null, onVerificationStateChange = null, config = {}) { this._isProductionMode = this._detectProductionMode(); @@ -6485,6 +7022,30 @@ var EnhancedSecureWebRTCManager = class _EnhancedSecureWebRTCManager { }; this.onFileReceived = null; this.onFileError = null; + this.onCallStateChanged = null; + 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; + this._callMakingOffer = false; + this._callAudioSender = null; + this._callVideoSender = null; + this._callFacingMode = "user"; + this._adaptationController = null; this.keyRotationInterval = null; this.lastKeyRotation = Date.now(); this.currentKeyVersion = 0; @@ -11543,6 +12104,20 @@ var EnhancedSecureWebRTCManager = class _EnhancedSecureWebRTCManager { } return; } + 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", "\u274C Call signal handling failed", { errorType: e?.constructor?.name }); + } + return; + } if (parsed.type && ["heartbeat", "verification", "verification_response", "verification_confirmed", "verification_both_confirmed", "peer_disconnect", "security_upgrade"].includes(parsed.type)) { this.handleSystemMessage(parsed); return; @@ -12185,6 +12760,8 @@ var EnhancedSecureWebRTCManager = class _EnhancedSecureWebRTCManager { this._warnIfTurnMissing(); console.info("[SecureBit ICE] peer connection config", this._summarizeIceServerConfig(config.iceServers)); this.peerConnection = new RTCPeerConnection(config); + this._callAudioSender = null; + this._callVideoSender = null; this.peerConnection.onconnectionstatechange = () => { const state = this.peerConnection.connectionState; console.info("[SecureBit ICE] connection state changed", { @@ -12235,6 +12812,13 @@ var EnhancedSecureWebRTCManager = class _EnhancedSecureWebRTCManager { errorText: event.errorText }); }; + this.peerConnection.ontrack = (event) => { + try { + this._refreshRemoteStream(); + } catch (e) { + this._secureLog("warn", "\u26A0\uFE0F ontrack handling failed", { errorType: e?.constructor?.name }); + } + }; this.peerConnection.ondatachannel = (event) => { if (event.channel.label === "securechat") { this.dataChannel = event.channel; @@ -12378,6 +12962,19 @@ var EnhancedSecureWebRTCManager = class _EnhancedSecureWebRTCManager { } return; } + 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; + } if (parsed.type && ["heartbeat", "verification", "verification_response", "verification_confirmed", "verification_both_confirmed", "sas_code", "peer_disconnect", "security_upgrade"].includes(parsed.type)) { this.handleSystemMessage(parsed); return; @@ -15597,6 +16194,16 @@ var EnhancedSecureWebRTCManager = class _EnhancedSecureWebRTCManager { disconnect() { try { this._sessionAlive = false; + try { + this._stopAdaptation?.(); + } catch (_) { + } + try { + this._stopLocalMediaPermanently?.(); + } catch (_) { + } + this._callAudioSender = null; + this._callVideoSender = null; this.intentionalDisconnect = true; window.EnhancedSecureCryptoUtils.secureLog.log("info", "Starting intentional disconnect"); this.sendDisconnectNotification(); @@ -16103,6 +16710,523 @@ var EnhancedSecureWebRTCManager = class _EnhancedSecureWebRTCManager { } 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) { + 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(); + 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"; + 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) { + 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(); + 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: 15e5, + 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 = `\u26A0\uFE0F Call not started \u2014 ${dev} access is blocked. Allow it for this site in the browser, and enable your browser under System Settings \u2192 Privacy & Security \u2192 ${wantVideo ? "Camera/Microphone" : "Microphone"}, then try again.`; + } else if (name === "NotFoundError" || name === "OverconstrainedError") { + code = "device_not_found"; + msg = `\u26A0\uFE0F Call not started \u2014 no ${dev} found on this device.`; + } else if (name === "NotReadableError" || name === "AbortError") { + code = "device_busy"; + msg = `\u26A0\uFE0F Call not started \u2014 your ${dev} is in use by another app. Close it and try again.`; + } else { + code = "media_failed"; + msg = `\u26A0\uFE0F Call not started \u2014 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", "\u26A0\uFE0F startCall ignored \u2014 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", "\u274C 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) { + 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 }); + 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", "\u274C acceptCall failed", { errorType: error?.constructor?.name }); + const code = this._notifyCallMediaError(error, !!data.withVideo); + 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 { + if (this.localMediaStream) { + for (const track of this.localMediaStream.getTracks()) { + try { + track.stop(); + } catch (_) { + } + } + } + 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; + this._callMakingOffer = false; + try { + if (this.peerConnection && (this.peerConnection.signalingState === "have-local-offer" || this.peerConnection.signalingState === "have-local-pranswer")) { + await this.peerConnection.setLocalDescription({ type: "rollback" }); + } + } catch (_) { + } + } + // 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; + } + 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 { + 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", "\u274C 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", "\u26A0\uFE0F 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(); + } catch (e) { + this._secureLog("warn", "\u26A0\uFE0F 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; + } + } }; var SecureKeyStorage = class { constructor(masterKeyManager = null) { @@ -17721,7 +18845,7 @@ Right-click or Ctrl+click to disconnect`, 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") ]) @@ -19493,6 +20617,264 @@ var IceServerSettings = ({ isOpen, onClose, initial, hasSaved, onApply, onForget }; window.IceServerSettings = IceServerSettings; +// src/components/ui/CallUI.jsx +var CallUIComponent = ({ webrtcManager, peerTitle }) => { + const h = React.createElement; + const MONO = "'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, monospace"; + const ICON = { + lock: '', + minimize: '', + expand: '', + user: '', + micOn: '', + micOff: '', + camOn: '', + camOff: '', + flip: '', + phone: '', + phoneHangup: '' + }; + const svg2 = (inner, size, sw) => h("span", { + style: { display: "grid", placeItems: "center", width: size + "px", height: size + "px" }, + dangerouslySetInnerHTML: { __html: `${inner}` } + }); + 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); + 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; + 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(() => { + }); + }; + attach(remoteAudioRef.current, remoteStream, false, "remoteAudio"); + attach(remoteVideoRef.current, remoteStream, true, "remoteVideo"); + attach(selfVideoRef.current, localStream, true, "selfVideo"); + }); + React.useEffect(() => { + if (phase !== "active") { + setSeconds(0); + return; + } + const started = Date.now(); + const iv = setInterval(() => setSeconds(Math.floor((Date.now() - started) / 1e3)), 1e3); + 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\u2026" : phase === "connecting" ? "Connecting\u2026" : phase === "active" ? fmt(seconds) : "Ringing\u2026"; + const name = peerTitle || "Secure peer"; + 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" } }, + [svg2(ICON.lock, 11, 2), "Encrypted"] + ); + 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]); + }; + 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)] + ); + 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" } }, svg2(ICON.user, size, 1.6)) + ]); + 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" } }, [svg2(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" } }, svg2(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)" } }, svg2(ICON.phone, 24, 1.9)), "Accept") + ]) + ]); + } + 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" } }, svg2(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" } }, svg2(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 \xB7 " : "Voice \xB7 ") + 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" } }, svg2(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" } }, svg2(ICON.phoneHangup, 15, 2)) + ]) + ]); + } + 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" } }, svg2(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) } }, svg2(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" } }, [ + svg2(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 }, svg2(call.micEnabled ? ICON.micOn : ICON.micOff, 21, 1.9)), + h("button", { key: "cam", onClick: doCamera, title: "Camera", style: call.cameraEnabled ? ctrlBase : dangerCtrl }, svg2(call.cameraEnabled ? ICON.camOn : ICON.camOff, 21, 1.8)), + h("button", { key: "flip", onClick: doFlip, title: "Flip camera", style: ctrlBase }, svg2(ICON.flip, 21, 1.8)), + h("button", { key: "end", onClick: doEnd, title: "End call", style: endBtn }, svg2(ICON.phoneHangup, 22, 1.9)) + ]) + ]); + } + 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" } }, [svg2(ICON.lock, 13, 2), "Encrypted call"]), + h("button", { key: "min", onClick: () => setMinimized(true), title: "Minimize", style: minimizeBtn(false) }, svg2(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 }, svg2(call.micEnabled ? ICON.micOn : ICON.micOff, 22, 1.9)), call.micEnabled ? "Mute" : "Muted"), + labeled("video", h("button", { onClick: doUpgrade, title: "Add video", style: ctrlBase }, svg2(ICON.camOn, 22, 1.8)), "Video"), + labeled("end", h("button", { onClick: doEnd, title: "End call", style: endBtn }, svg2(ICON.phoneHangup, 22, 1.9)), "End") + ]) + ]); +}; +if (typeof window !== "undefined") { + window.CallUIComponent = CallUIComponent; +} + // src/scripts/app-boot.js window.EnhancedSecureCryptoUtils = EnhancedSecureCryptoUtils; window.EnhancedSecureWebRTCManager = EnhancedSecureWebRTCManager; diff --git a/dist/app-boot.js.map b/dist/app-boot.js.map index 298adcb..555bd8b 100644 --- a/dist/app-boot.js.map +++ b/dist/app-boot.js.map @@ -1,7 +1,7 @@ { "version": 3, - "sources": ["../src/notifications/SecureNotificationManager.js", "../src/notifications/NotificationIntegration.js", "../node_modules/dompurify/src/utils.ts", "../node_modules/dompurify/src/tags.ts", "../node_modules/dompurify/src/attrs.ts", "../node_modules/dompurify/src/regexp.ts", "../node_modules/dompurify/src/purify.ts", "../src/crypto/EnhancedSecureCryptoUtils.js", "../src/transfer/EnhancedSecureFileTransfer.js", "../src/network/EnhancedSecureWebRTCManager.js", "../src/scripts/app-boot.js", "../src/components/ui/Header.jsx", "../src/components/ui/DownloadApps.jsx", "../src/components/ui/BecomePartner.jsx", "../src/components/ui/UniqueFeatureSlider.jsx", "../src/components/ui/Roadmap.jsx", "../src/components/ui/CommunityCTA.jsx", "../src/components/ui/FileTransfer.jsx", "../src/network/iceServers.js", "../src/components/ui/IceServerSettings.jsx"], - "sourcesContent": ["/**\n * Secure and Reliable Notification Manager for P2P WebRTC Chat\n * Follows best practices: OWASP, MDN, Chrome DevRel\n * \n * @version 1.0.0\n * @author SecureBit Team\n * @license MIT\n */\n\nclass SecureChatNotificationManager {\n constructor(config = {}) {\n // Safely read Notification permission (iOS Safari may not define Notification)\n this.permission = (typeof Notification !== 'undefined' && Notification && typeof Notification.permission === 'string')\n ? Notification.permission\n : 'denied';\n this.isTabActive = this.checkTabActive(); // Initialize with proper check\n this.unreadCount = 0;\n this.originalTitle = document.title;\n this.notificationQueue = [];\n this.maxQueueSize = config.maxQueueSize || 5;\n this.rateLimitMs = config.rateLimitMs || 2000; // Spam protection\n this.lastNotificationTime = 0;\n this.trustedOrigins = config.trustedOrigins || [];\n \n // Secure context flag\n this.isSecureContext = window.isSecureContext;\n \n // Cross-browser compatibility for Page Visibility API\n this.hidden = this.getHiddenProperty();\n this.visibilityChange = this.getVisibilityChangeEvent();\n \n this.initVisibilityTracking();\n this.initSecurityChecks();\n }\n\n /**\n * Initialize security checks and validation\n * @private\n */\n initSecurityChecks() {\n // Security checks are performed silently\n }\n\n /**\n * Get hidden property name for cross-browser compatibility\n * @returns {string} Hidden property name\n * @private\n */\n getHiddenProperty() {\n if (typeof document.hidden !== \"undefined\") {\n return \"hidden\";\n } else if (typeof document.msHidden !== \"undefined\") {\n return \"msHidden\";\n } else if (typeof document.webkitHidden !== \"undefined\") {\n return \"webkitHidden\";\n }\n return \"hidden\"; // fallback\n }\n\n /**\n * Get visibility change event name for cross-browser compatibility\n * @returns {string} Visibility change event name\n * @private\n */\n getVisibilityChangeEvent() {\n if (typeof document.hidden !== \"undefined\") {\n return \"visibilitychange\";\n } else if (typeof document.msHidden !== \"undefined\") {\n return \"msvisibilitychange\";\n } else if (typeof document.webkitHidden !== \"undefined\") {\n return \"webkitvisibilitychange\";\n }\n return \"visibilitychange\"; // fallback\n }\n\n /**\n * Check if tab is currently active using multiple methods\n * @returns {boolean} True if tab is active\n * @private\n */\n checkTabActive() {\n // Primary method: Page Visibility API\n if (this.hidden && typeof document[this.hidden] !== \"undefined\") {\n return !document[this.hidden];\n }\n \n // Fallback method: document.hasFocus()\n if (typeof document.hasFocus === \"function\") {\n return document.hasFocus();\n }\n \n // Ultimate fallback: assume active\n return true;\n }\n\n /**\n * Initialize page visibility tracking (Page Visibility API)\n * @private\n */\n initVisibilityTracking() {\n // Primary method: Page Visibility API with cross-browser support\n if (typeof document.addEventListener !== \"undefined\" && typeof document[this.hidden] !== \"undefined\") {\n document.addEventListener(this.visibilityChange, () => {\n this.isTabActive = this.checkTabActive();\n \n if (this.isTabActive) {\n this.resetUnreadCount();\n this.clearNotificationQueue();\n }\n });\n }\n\n // Fallback method: Window focus/blur events\n window.addEventListener('focus', () => {\n this.isTabActive = this.checkTabActive();\n if (this.isTabActive) {\n this.resetUnreadCount();\n }\n });\n\n window.addEventListener('blur', () => {\n this.isTabActive = this.checkTabActive();\n });\n\n // Page unload cleanup\n window.addEventListener('beforeunload', () => {\n this.clearNotificationQueue();\n });\n }\n\n /**\n * Request notification permission (BEST PRACTICE: Only call in response to user action)\n * Never call on page load!\n * @returns {Promise} Permission granted status\n */\n async requestPermission() {\n // Secure context check\n if (!this.isSecureContext || !('Notification' in window)) {\n return false;\n }\n\n if (this.permission === 'granted') {\n return true;\n }\n\n if (this.permission === 'denied') {\n return false;\n }\n\n try {\n this.permission = await Notification.requestPermission();\n return this.permission === 'granted';\n } catch (error) {\n return false;\n }\n }\n\n /**\n * Update page title with unread count\n * @private\n */\n updateTitle() {\n if (this.unreadCount > 0) {\n document.title = `(${this.unreadCount}) ${this.originalTitle}`;\n } else {\n document.title = this.originalTitle;\n }\n }\n\n /**\n * XSS Protection: Sanitize input text\n * @param {string} text - Text to sanitize\n * @returns {string} Sanitized text\n * @private\n */\n sanitizeText(text) {\n if (typeof text !== 'string') {\n return '';\n }\n \n // Remove HTML tags and potentially dangerous characters\n const div = document.createElement('div');\n div.textContent = text;\n return div.innerHTML\n .replace(//g, '>')\n .replace(/\"/g, '"')\n .replace(/'/g, ''')\n .substring(0, 500); // Length limit\n }\n\n /**\n * Validate icon URL (XSS protection)\n * @param {string} url - URL to validate\n * @returns {string|null} Validated URL or null\n * @private\n */\n validateIconUrl(url) {\n if (!url) return null;\n \n try {\n const parsedUrl = new URL(url, window.location.origin);\n \n // Only allow HTTPS and data URLs\n if (parsedUrl.protocol === 'https:' || parsedUrl.protocol === 'data:') {\n // Check trusted origins if specified\n if (this.trustedOrigins.length > 0) {\n const isTrusted = this.trustedOrigins.some(origin => \n parsedUrl.origin === origin\n );\n return isTrusted ? parsedUrl.href : null;\n }\n return parsedUrl.href;\n }\n \n return null;\n } catch (error) {\n return null;\n }\n }\n\n /**\n * Rate limiting for spam protection\n * @returns {boolean} Rate limit check passed\n * @private\n */\n checkRateLimit() {\n const now = Date.now();\n if (now - this.lastNotificationTime < this.rateLimitMs) {\n return false;\n }\n this.lastNotificationTime = now;\n return true;\n }\n\n /**\n * Send secure notification\n * @param {string} senderName - Name of message sender\n * @param {string} message - Message content\n * @param {Object} options - Notification options\n * @returns {Notification|null} Created notification or null\n */\n notify(senderName, message, options = {}) {\n // Abort if Notifications API is not available (e.g., iOS Safari)\n if (typeof Notification === 'undefined') {\n return null;\n }\n // Update tab active state before checking\n this.isTabActive = this.checkTabActive();\n \n // Only show if tab is NOT active (user is on another tab or minimized)\n if (this.isTabActive) {\n return null;\n }\n\n // Permission check\n if (this.permission !== 'granted') {\n return null;\n }\n\n // Rate limiting\n if (!this.checkRateLimit()) {\n return null;\n }\n\n // Data sanitization (XSS Protection)\n const safeSenderName = this.sanitizeText(senderName || 'Unknown');\n const safeMessage = this.sanitizeText(message || '');\n const safeIcon = this.validateIconUrl(options.icon) || '/logo/icon-192x192.png';\n\n // Queue overflow protection\n if (this.notificationQueue.length >= this.maxQueueSize) {\n this.clearNotificationQueue();\n }\n\n try {\n \n const notification = new Notification(\n `${safeSenderName}`,\n {\n body: safeMessage.substring(0, 200), // Length limit\n icon: safeIcon,\n badge: safeIcon,\n tag: `chat-${options.senderId || 'unknown'}`, // Grouping\n requireInteraction: false, // Don't block user\n silent: options.silent || false,\n // Vibrate only for mobile and if supported\n vibrate: navigator.vibrate ? [200, 100, 200] : undefined,\n // Safe metadata\n data: {\n senderId: this.sanitizeText(options.senderId),\n timestamp: Date.now(),\n // Don't include sensitive data!\n }\n }\n );\n\n // Increment counter\n this.unreadCount++;\n this.updateTitle();\n\n // Add to queue for management\n this.notificationQueue.push(notification);\n\n // Safe click handler\n notification.onclick = (event) => {\n event.preventDefault(); // Prevent default behavior\n window.focus();\n notification.close();\n \n // Safe callback\n if (typeof options.onClick === 'function') {\n try {\n options.onClick(options.senderId);\n } catch (error) {\n console.error('[Notifications] Error in onClick handler:', error);\n }\n }\n };\n\n // Error handler\n notification.onerror = (event) => {\n console.error('[Notifications] Error showing notification:', event);\n };\n\n // Auto-close after reasonable time\n const autoCloseTimeout = Math.min(options.autoClose || 5000, 10000);\n setTimeout(() => {\n notification.close();\n this.removeFromQueue(notification);\n }, autoCloseTimeout);\n\n return notification;\n \n } catch (error) {\n console.error('[Notifications] Failed to create notification:', error);\n return null;\n }\n }\n\n /**\n * Remove notification from queue\n * @param {Notification} notification - Notification to remove\n * @private\n */\n removeFromQueue(notification) {\n const index = this.notificationQueue.indexOf(notification);\n if (index > -1) {\n this.notificationQueue.splice(index, 1);\n }\n }\n\n /**\n * Clear all notifications\n */\n clearNotificationQueue() {\n this.notificationQueue.forEach(notification => {\n try {\n notification.close();\n } catch (error) {\n // Ignore errors when closing\n }\n });\n this.notificationQueue = [];\n }\n\n /**\n * Reset unread counter\n */\n resetUnreadCount() {\n this.unreadCount = 0;\n this.updateTitle();\n }\n\n /**\n * Get current status\n * @returns {Object} Current notification status\n */\n getStatus() {\n return {\n permission: this.permission,\n isTabActive: this.isTabActive,\n unreadCount: this.unreadCount,\n isSecureContext: this.isSecureContext,\n queueSize: this.notificationQueue.length\n };\n }\n}\n\n/**\n * Secure integration with WebRTC\n */\nclass SecureP2PChat {\n constructor() {\n this.notificationManager = new SecureChatNotificationManager({\n maxQueueSize: 5,\n rateLimitMs: 2000,\n trustedOrigins: [\n window.location.origin,\n // Add other trusted origins for CDN icons\n ]\n });\n \n this.dataChannel = null;\n this.peerConnection = null;\n this.remotePeerName = 'Peer';\n this.messageHistory = [];\n this.maxHistorySize = 100;\n }\n\n /**\n * Initialize when user connects\n */\n async init() {\n // Initialize notification manager silently\n }\n\n /**\n * Method for manual permission request (called on click)\n * @returns {Promise} Permission granted status\n */\n async enableNotifications() {\n const granted = await this.notificationManager.requestPermission();\n return granted;\n }\n\n /**\n * Setup DataChannel with security checks\n * @param {RTCDataChannel} dataChannel - WebRTC data channel\n */\n setupDataChannel(dataChannel) {\n if (!dataChannel) {\n console.error('[Chat] Invalid DataChannel');\n return;\n }\n\n this.dataChannel = dataChannel;\n \n // Setup handlers\n this.dataChannel.onmessage = (event) => {\n this.handleIncomingMessage(event.data);\n };\n\n this.dataChannel.onerror = (error) => {\n // Handle error silently\n };\n }\n\n /**\n * XSS Protection: Validate incoming messages\n * @param {string|Object} data - Message data\n * @returns {Object|null} Validated message or null\n * @private\n */\n validateMessage(data) {\n try {\n const message = typeof data === 'string' ? JSON.parse(data) : data;\n \n // Check message structure\n if (!message || typeof message !== 'object') {\n throw new Error('Invalid message structure');\n }\n\n // Check required fields\n if (!message.text || typeof message.text !== 'string') {\n throw new Error('Invalid message text');\n }\n\n // Message length limit (DoS protection)\n if (message.text.length > 10000) {\n throw new Error('Message too long');\n }\n\n return {\n text: message.text,\n senderName: message.senderName || 'Unknown',\n senderId: message.senderId || 'unknown',\n timestamp: message.timestamp || Date.now(),\n senderAvatar: message.senderAvatar || null\n };\n \n } catch (error) {\n console.error('[Chat] Message validation failed:', error);\n return null;\n }\n }\n\n /**\n * Secure handling of incoming messages\n * @param {string|Object} data - Message data\n * @private\n */\n handleIncomingMessage(data) {\n const message = this.validateMessage(data);\n \n if (!message) {\n return;\n }\n\n // Save to history (with limit)\n this.messageHistory.push(message);\n if (this.messageHistory.length > this.maxHistorySize) {\n this.messageHistory.shift();\n }\n\n // Display in UI (with sanitization)\n this.displayMessage(message);\n\n // Send notification only if tab is inactive\n this.notificationManager.notify(\n message.senderName,\n message.text,\n {\n icon: message.senderAvatar,\n senderId: message.senderId,\n onClick: (senderId) => {\n this.scrollToLatestMessage();\n }\n }\n );\n\n // Optional: sound (with check)\n if (!this.notificationManager.isTabActive) {\n this.playNotificationSound();\n }\n }\n\n /**\n * XSS Protection: Safe message display\n * @param {Object} message - Message to display\n * @private\n */\n displayMessage(message) {\n const container = document.getElementById('messages');\n if (!container) {\n return;\n }\n\n const messageEl = document.createElement('div');\n messageEl.className = 'message';\n \n // Use textContent to prevent XSS\n const nameEl = document.createElement('strong');\n nameEl.textContent = message.senderName + ': ';\n \n const textEl = document.createElement('span');\n textEl.textContent = message.text;\n textEl.style.wordWrap = 'break-word';\n textEl.style.overflowWrap = 'break-word';\n textEl.style.whiteSpace = 'normal';\n \n const timeEl = document.createElement('small');\n timeEl.textContent = new Date(message.timestamp).toLocaleTimeString();\n \n messageEl.appendChild(nameEl);\n messageEl.appendChild(textEl);\n messageEl.appendChild(document.createElement('br'));\n messageEl.appendChild(timeEl);\n \n container.appendChild(messageEl);\n this.scrollToLatestMessage();\n }\n\n /**\n * Safe sound playback\n * @private\n */\n playNotificationSound() {\n try {\n // Use only local audio files\n const audio = new Audio('/assets/audio/notification.mp3');\n audio.volume = 0.3; // Moderate volume\n \n // Error handling\n audio.play().catch(error => {\n // Handle audio error silently\n });\n } catch (error) {\n // Handle audio creation error silently\n }\n }\n\n /**\n * Scroll to latest message\n * @private\n */\n scrollToLatestMessage() {\n const container = document.getElementById('messages');\n if (container) {\n container.scrollTop = container.scrollHeight;\n }\n }\n\n /**\n * Get status\n * @returns {Object} Current chat status\n */\n getStatus() {\n return {\n notifications: this.notificationManager.getStatus(),\n messageCount: this.messageHistory.length,\n connected: this.dataChannel?.readyState === 'open'\n };\n }\n}\n\n// Export for use in other modules\nif (typeof module !== 'undefined' && module.exports) {\n module.exports = { SecureChatNotificationManager, SecureP2PChat };\n}\n\n// Global export for browser usage\nif (typeof window !== 'undefined') {\n window.SecureChatNotificationManager = SecureChatNotificationManager;\n window.SecureP2PChat = SecureP2PChat;\n}\n", "/**\n * Notification Integration Module for SecureBit WebRTC Chat\n * Integrates secure notifications with existing WebRTC architecture\n * \n * @version 1.0.0\n * @author SecureBit Team\n * @license MIT\n */\n\nimport { SecureChatNotificationManager } from './SecureNotificationManager.js';\n\nclass NotificationIntegration {\n constructor(webrtcManager) {\n this.webrtcManager = webrtcManager;\n this.notificationManager = new SecureChatNotificationManager({\n maxQueueSize: 10,\n rateLimitMs: 1000, // Reduced from 2000ms to 1000ms\n trustedOrigins: [\n window.location.origin,\n // Add other trusted origins for CDN icons\n ]\n });\n \n this.isInitialized = false;\n this.originalOnMessage = null;\n this.originalOnStatusChange = null;\n this.processedMessages = new Set(); // Track processed messages to avoid duplicates\n }\n\n /**\n * Initialize notification integration\n * @returns {Promise} Initialization success\n */\n async init() {\n try {\n if (this.isInitialized) {\n return true;\n }\n\n // Store original callbacks\n this.originalOnMessage = this.webrtcManager.onMessage;\n this.originalOnStatusChange = this.webrtcManager.onStatusChange;\n\n\n // Wrap the original onMessage callback.\n // IMPORTANT: forward ALL arguments (incl. per-message `meta`) so the app\n // still receives view-once / disappearing / unsend metadata.\n this.webrtcManager.onMessage = (message, type, ...rest) => {\n this.handleIncomingMessage(message, type);\n\n // Call original callback if it exists\n if (this.originalOnMessage) {\n this.originalOnMessage(message, type, ...rest);\n }\n };\n\n // Wrap the original onStatusChange callback\n this.webrtcManager.onStatusChange = (status) => {\n this.handleStatusChange(status);\n \n // Call original callback if it exists\n if (this.originalOnStatusChange) {\n this.originalOnStatusChange(status);\n }\n };\n\n // Also hook into the deliverMessageToUI method if it exists.\n // IMPORTANT: forward ALL arguments (incl. per-message `meta`) to the\n // original, otherwise view-once / disappearing / unsend metadata is lost.\n if (this.webrtcManager.deliverMessageToUI) {\n this.originalDeliverMessageToUI = this.webrtcManager.deliverMessageToUI.bind(this.webrtcManager);\n this.webrtcManager.deliverMessageToUI = (message, type, ...rest) => {\n this.handleIncomingMessage(message, type);\n this.originalDeliverMessageToUI(message, type, ...rest);\n };\n }\n\n this.isInitialized = true;\n return true;\n\n } catch (error) {\n return false;\n }\n }\n\n /**\n * Handle incoming messages and trigger notifications\n * @param {*} message - Message content\n * @param {string} type - Message type\n * @private\n */\n handleIncomingMessage(message, type) {\n try {\n // Create a unique key for this message to avoid duplicates\n const messageKey = `${type}:${typeof message === 'string' ? message : JSON.stringify(message)}`;\n \n // Skip if we've already processed this message\n if (this.processedMessages.has(messageKey)) {\n return;\n }\n \n // Mark message as processed\n this.processedMessages.add(messageKey);\n \n // Clean up old processed messages (keep only last 100)\n if (this.processedMessages.size > 100) {\n const messagesArray = Array.from(this.processedMessages);\n this.processedMessages.clear();\n messagesArray.slice(-50).forEach(msg => this.processedMessages.add(msg));\n }\n \n \n // Only process chat messages, not system messages\n if (type === 'system' || type === 'file-transfer' || type === 'heartbeat') {\n return;\n }\n\n // Extract message information\n const messageInfo = this.extractMessageInfo(message, type);\n if (!messageInfo) {\n return;\n }\n\n // Send notification\n const notificationResult = this.notificationManager.notify(\n messageInfo.senderName,\n messageInfo.text,\n {\n icon: messageInfo.senderAvatar,\n senderId: messageInfo.senderId,\n onClick: (senderId) => {\n this.focusChatWindow();\n }\n }\n );\n\n } catch (error) {\n // Handle error silently\n }\n }\n\n /**\n * Handle status changes\n * @param {string} status - Connection status\n * @private\n */\n handleStatusChange(status) {\n try {\n // Clear notifications when connection is lost\n if (status === 'disconnected' || status === 'failed') {\n this.notificationManager.clearNotificationQueue();\n this.notificationManager.resetUnreadCount();\n }\n } catch (error) {\n // Handle error silently\n }\n }\n\n /**\n * Extract message information for notifications\n * @param {*} message - Message content\n * @param {string} type - Message type\n * @returns {Object|null} Extracted message info or null\n * @private\n */\n extractMessageInfo(message, type) {\n try {\n let messageData = message;\n\n // Handle different message formats\n if (typeof message === 'string') {\n try {\n messageData = JSON.parse(message);\n } catch (e) {\n // Plain text message\n return {\n senderName: 'Peer',\n text: message,\n senderId: 'peer',\n senderAvatar: null\n };\n }\n }\n\n // Handle structured message data\n if (typeof messageData === 'object' && messageData !== null) {\n return {\n senderName: messageData.senderName || messageData.name || 'Peer',\n text: messageData.text || messageData.message || messageData.content || '',\n senderId: messageData.senderId || messageData.id || 'peer',\n senderAvatar: messageData.senderAvatar || messageData.avatar || null\n };\n }\n\n return null;\n } catch (error) {\n return null;\n }\n }\n\n /**\n * Focus chat window when notification is clicked\n * @private\n */\n focusChatWindow() {\n try {\n window.focus();\n \n // Scroll to bottom of messages if container exists\n const messagesContainer = document.getElementById('messages');\n if (messagesContainer) {\n messagesContainer.scrollTop = messagesContainer.scrollHeight;\n }\n } catch (error) {\n // Handle error silently\n }\n }\n\n /**\n * Request notification permission\n * @returns {Promise} Permission granted status\n */\n async requestPermission() {\n try {\n return await this.notificationManager.requestPermission();\n } catch (error) {\n return false;\n }\n }\n\n /**\n * Get notification status\n * @returns {Object} Notification status\n */\n getStatus() {\n return this.notificationManager.getStatus();\n }\n\n /**\n * Clear all notifications\n */\n clearNotifications() {\n this.notificationManager.clearNotificationQueue();\n this.notificationManager.resetUnreadCount();\n }\n\n /**\n * Cleanup integration\n */\n cleanup() {\n try {\n if (this.isInitialized) {\n // Restore original callbacks\n if (this.originalOnMessage) {\n this.webrtcManager.onMessage = this.originalOnMessage;\n }\n if (this.originalOnStatusChange) {\n this.webrtcManager.onStatusChange = this.originalOnStatusChange;\n }\n if (this.originalDeliverMessageToUI) {\n this.webrtcManager.deliverMessageToUI = this.originalDeliverMessageToUI;\n }\n\n // Clear notifications\n this.clearNotifications();\n\n this.isInitialized = false;\n }\n } catch (error) {\n // Handle error silently\n }\n }\n}\n\n// Export for use in other modules\nif (typeof module !== 'undefined' && module.exports) {\n module.exports = { NotificationIntegration };\n}\n\n// Global export for browser usage\nif (typeof window !== 'undefined') {\n window.NotificationIntegration = NotificationIntegration;\n}\n", "const {\n entries,\n setPrototypeOf,\n isFrozen,\n getPrototypeOf,\n getOwnPropertyDescriptor,\n} = Object;\n\nlet { freeze, seal, create } = Object; // eslint-disable-line import/no-mutable-exports\nlet { apply, construct } = typeof Reflect !== 'undefined' && Reflect;\n\nif (!freeze) {\n freeze = function (x: T): T {\n return x;\n };\n}\n\nif (!seal) {\n seal = function (x: T): T {\n return x;\n };\n}\n\nif (!apply) {\n apply = function (\n func: (thisArg: any, ...args: any[]) => T,\n thisArg: any,\n ...args: any[]\n ): T {\n return func.apply(thisArg, args);\n };\n}\n\nif (!construct) {\n construct = function (Func: new (...args: any[]) => T, ...args: any[]): T {\n return new Func(...args);\n };\n}\n\nconst arrayForEach = unapply(Array.prototype.forEach);\nconst arrayIndexOf = unapply(Array.prototype.indexOf);\nconst arrayLastIndexOf = unapply(Array.prototype.lastIndexOf);\nconst arrayPop = unapply(Array.prototype.pop);\nconst arrayPush = unapply(Array.prototype.push);\nconst arraySlice = unapply(Array.prototype.slice);\nconst arraySplice = unapply(Array.prototype.splice);\nconst arrayIsArray = Array.isArray;\n\nconst stringToLowerCase = unapply(String.prototype.toLowerCase);\nconst stringToString = unapply(String.prototype.toString);\nconst stringMatch = unapply(String.prototype.match);\nconst stringReplace = unapply(String.prototype.replace);\nconst stringIndexOf = unapply(String.prototype.indexOf);\nconst stringTrim = unapply(String.prototype.trim);\n\nconst numberToString = unapply(Number.prototype.toString);\nconst booleanToString = unapply(Boolean.prototype.toString);\nconst bigintToString =\n typeof BigInt === 'undefined' ? null : unapply(BigInt.prototype.toString);\nconst symbolToString =\n typeof Symbol === 'undefined' ? null : unapply(Symbol.prototype.toString);\n\nconst objectHasOwnProperty = unapply(Object.prototype.hasOwnProperty);\nconst objectToString = unapply(Object.prototype.toString);\n\nconst regExpTest = unapply(RegExp.prototype.test);\n\nconst typeErrorCreate = unconstruct(TypeError);\n\n/**\n * Creates a new function that calls the given function with a specified thisArg and arguments.\n *\n * @param func - The function to be wrapped and called.\n * @returns A new function that calls the given function with a specified thisArg and arguments.\n */\nfunction unapply(\n func: (thisArg: any, ...args: any[]) => T\n): (thisArg: any, ...args: any[]) => T {\n return (thisArg: any, ...args: any[]): T => {\n if (thisArg instanceof RegExp) {\n thisArg.lastIndex = 0;\n }\n\n return apply(func, thisArg, args);\n };\n}\n\n/**\n * Creates a new function that constructs an instance of the given constructor function with the provided arguments.\n *\n * @param func - The constructor function to be wrapped and called.\n * @returns A new function that constructs an instance of the given constructor function with the provided arguments.\n */\nfunction unconstruct(\n Func: new (...args: any[]) => T\n): (...args: any[]) => T {\n return (...args: any[]): T => construct(Func, args);\n}\n\n/**\n * Add properties to a lookup table\n *\n * @param set - The set to which elements will be added.\n * @param array - The array containing elements to be added to the set.\n * @param transformCaseFunc - An optional function to transform the case of each element before adding to the set.\n * @returns The modified set with added elements.\n */\nfunction addToSet(\n set: Record,\n array: readonly unknown[],\n transformCaseFunc: ReturnType> = stringToLowerCase\n): Record {\n if (setPrototypeOf) {\n // Make 'in' and truthy checks like Boolean(set.constructor)\n // independent of any properties defined on Object.prototype.\n // Prevent prototype setters from intercepting set as a this value.\n setPrototypeOf(set, null);\n }\n\n if (!arrayIsArray(array)) {\n return set;\n }\n\n let l = array.length;\n while (l--) {\n let element = array[l];\n\n if (typeof element === 'string') {\n const lcElement = transformCaseFunc(element);\n\n if (lcElement !== element) {\n // Config presets (e.g. tags.js, attrs.js) are immutable.\n if (!isFrozen(array)) {\n (array as unknown[])[l] = lcElement;\n }\n\n element = lcElement;\n }\n }\n\n set[element as string] = true;\n }\n\n return set;\n}\n\n/**\n * Clean up an array to harden against CSPP\n *\n * @param array - The array to be cleaned.\n * @returns The cleaned version of the array\n */\nfunction cleanArray(array: T[]): Array {\n for (let index = 0; index < array.length; index++) {\n const isPropertyExist = objectHasOwnProperty(array, index);\n\n if (!isPropertyExist) {\n array[index] = null;\n }\n }\n\n return array;\n}\n\n/**\n * Shallow clone an object\n *\n * @param object - The object to be cloned.\n * @returns A new object that copies the original.\n */\nfunction clone>(object: T): T {\n const newObject = create(null);\n\n for (const [property, value] of entries(object)) {\n const isPropertyExist = objectHasOwnProperty(object, property);\n\n if (isPropertyExist) {\n if (arrayIsArray(value)) {\n newObject[property] = cleanArray(value);\n } else if (\n value &&\n typeof value === 'object' &&\n value.constructor === Object\n ) {\n newObject[property] = clone(value);\n } else {\n newObject[property] = value;\n }\n }\n }\n\n return newObject;\n}\n\n/**\n * Convert non-node values into strings without depending on direct property access.\n *\n * @param value - The value to stringify.\n * @returns A string representation of the provided value.\n */\nfunction stringifyValue(value: unknown): string {\n switch (typeof value) {\n case 'string': {\n return value;\n }\n\n case 'number': {\n return numberToString(value);\n }\n\n case 'boolean': {\n return booleanToString(value);\n }\n\n case 'bigint': {\n return bigintToString ? bigintToString(value) : '0';\n }\n\n case 'symbol': {\n return symbolToString ? symbolToString(value) : 'Symbol()';\n }\n\n case 'undefined': {\n return objectToString(value);\n }\n\n case 'function':\n case 'object': {\n if (value === null) {\n return objectToString(value);\n }\n\n const valueAsRecord = value as Record;\n const valueToString = lookupGetter(valueAsRecord, 'toString');\n\n if (typeof valueToString === 'function') {\n const stringified = valueToString(valueAsRecord);\n\n return typeof stringified === 'string'\n ? stringified\n : objectToString(stringified);\n }\n\n return objectToString(value);\n }\n\n default: {\n return objectToString(value);\n }\n }\n}\n\n/**\n * This method automatically checks if the prop is function or getter and behaves accordingly.\n *\n * @param object - The object to look up the getter function in its prototype chain.\n * @param prop - The property name for which to find the getter function.\n * @returns The getter function found in the prototype chain or a fallback function.\n */\nfunction lookupGetter>(\n object: T,\n prop: string\n): ReturnType> | (() => null) {\n while (object !== null) {\n const desc = getOwnPropertyDescriptor(object, prop);\n\n if (desc) {\n if (desc.get) {\n return unapply(desc.get);\n }\n\n if (typeof desc.value === 'function') {\n return unapply(desc.value);\n }\n }\n\n object = getPrototypeOf(object);\n }\n\n function fallbackValue(): null {\n return null;\n }\n\n return fallbackValue;\n}\n\nfunction isRegex(value: unknown): value is RegExp {\n try {\n regExpTest(value as RegExp, '');\n return true;\n } catch {\n return false;\n }\n}\n\nexport {\n // Array\n arrayForEach,\n arrayIndexOf,\n arrayIsArray,\n arrayLastIndexOf,\n arrayPop,\n arrayPush,\n arraySlice,\n arraySplice,\n // Object\n entries,\n freeze,\n getPrototypeOf,\n getOwnPropertyDescriptor,\n isFrozen,\n setPrototypeOf,\n seal,\n clone,\n create,\n objectHasOwnProperty,\n objectToString,\n // RegExp\n regExpTest,\n isRegex,\n // String\n stringIndexOf,\n stringMatch,\n stringReplace,\n stringToLowerCase,\n stringToString,\n stringTrim,\n // Other conversion\n stringifyValue,\n // Errors\n typeErrorCreate,\n // Other\n lookupGetter,\n addToSet,\n // Reflect\n unapply,\n unconstruct,\n};\n", "import { freeze } from './utils.js';\n\nexport const html = freeze([\n 'a',\n 'abbr',\n 'acronym',\n 'address',\n 'area',\n 'article',\n 'aside',\n 'audio',\n 'b',\n 'bdi',\n 'bdo',\n 'big',\n 'blink',\n 'blockquote',\n 'body',\n 'br',\n 'button',\n 'canvas',\n 'caption',\n 'center',\n 'cite',\n 'code',\n 'col',\n 'colgroup',\n 'content',\n 'data',\n 'datalist',\n 'dd',\n 'decorator',\n 'del',\n 'details',\n 'dfn',\n 'dialog',\n 'dir',\n 'div',\n 'dl',\n 'dt',\n 'element',\n 'em',\n 'fieldset',\n 'figcaption',\n 'figure',\n 'font',\n 'footer',\n 'form',\n 'h1',\n 'h2',\n 'h3',\n 'h4',\n 'h5',\n 'h6',\n 'head',\n 'header',\n 'hgroup',\n 'hr',\n 'html',\n 'i',\n 'img',\n 'input',\n 'ins',\n 'kbd',\n 'label',\n 'legend',\n 'li',\n 'main',\n 'map',\n 'mark',\n 'marquee',\n 'menu',\n 'menuitem',\n 'meter',\n 'nav',\n 'nobr',\n 'ol',\n 'optgroup',\n 'option',\n 'output',\n 'p',\n 'picture',\n 'pre',\n 'progress',\n 'q',\n 'rp',\n 'rt',\n 'ruby',\n 's',\n 'samp',\n 'search',\n 'section',\n 'select',\n 'shadow',\n 'slot',\n 'small',\n 'source',\n 'spacer',\n 'span',\n 'strike',\n 'strong',\n 'style',\n 'sub',\n 'summary',\n 'sup',\n 'table',\n 'tbody',\n 'td',\n 'template',\n 'textarea',\n 'tfoot',\n 'th',\n 'thead',\n 'time',\n 'tr',\n 'track',\n 'tt',\n 'u',\n 'ul',\n 'var',\n 'video',\n 'wbr',\n] as const);\n\nexport const svg = freeze([\n 'svg',\n 'a',\n 'altglyph',\n 'altglyphdef',\n 'altglyphitem',\n 'animatecolor',\n 'animatemotion',\n 'animatetransform',\n 'circle',\n 'clippath',\n 'defs',\n 'desc',\n 'ellipse',\n 'enterkeyhint',\n 'exportparts',\n 'filter',\n 'font',\n 'g',\n 'glyph',\n 'glyphref',\n 'hkern',\n 'image',\n 'inputmode',\n 'line',\n 'lineargradient',\n 'marker',\n 'mask',\n 'metadata',\n 'mpath',\n 'part',\n 'path',\n 'pattern',\n 'polygon',\n 'polyline',\n 'radialgradient',\n 'rect',\n 'stop',\n 'style',\n 'switch',\n 'symbol',\n 'text',\n 'textpath',\n 'title',\n 'tref',\n 'tspan',\n 'view',\n 'vkern',\n] as const);\n\nexport const svgFilters = freeze([\n 'feBlend',\n 'feColorMatrix',\n 'feComponentTransfer',\n 'feComposite',\n 'feConvolveMatrix',\n 'feDiffuseLighting',\n 'feDisplacementMap',\n 'feDistantLight',\n 'feDropShadow',\n 'feFlood',\n 'feFuncA',\n 'feFuncB',\n 'feFuncG',\n 'feFuncR',\n 'feGaussianBlur',\n 'feImage',\n 'feMerge',\n 'feMergeNode',\n 'feMorphology',\n 'feOffset',\n 'fePointLight',\n 'feSpecularLighting',\n 'feSpotLight',\n 'feTile',\n 'feTurbulence',\n] as const);\n\n// List of SVG elements that are disallowed by default.\n// We still need to know them so that we can do namespace\n// checks properly in case one wants to add them to\n// allow-list.\nexport const svgDisallowed = freeze([\n 'animate',\n 'color-profile',\n 'cursor',\n 'discard',\n 'font-face',\n 'font-face-format',\n 'font-face-name',\n 'font-face-src',\n 'font-face-uri',\n 'foreignobject',\n 'hatch',\n 'hatchpath',\n 'mesh',\n 'meshgradient',\n 'meshpatch',\n 'meshrow',\n 'missing-glyph',\n 'script',\n 'set',\n 'solidcolor',\n 'unknown',\n 'use',\n] as const);\n\nexport const mathMl = freeze([\n 'math',\n 'menclose',\n 'merror',\n 'mfenced',\n 'mfrac',\n 'mglyph',\n 'mi',\n 'mlabeledtr',\n 'mmultiscripts',\n 'mn',\n 'mo',\n 'mover',\n 'mpadded',\n 'mphantom',\n 'mroot',\n 'mrow',\n 'ms',\n 'mspace',\n 'msqrt',\n 'mstyle',\n 'msub',\n 'msup',\n 'msubsup',\n 'mtable',\n 'mtd',\n 'mtext',\n 'mtr',\n 'munder',\n 'munderover',\n 'mprescripts',\n] as const);\n\n// Similarly to SVG, we want to know all MathML elements,\n// even those that we disallow by default.\nexport const mathMlDisallowed = freeze([\n 'maction',\n 'maligngroup',\n 'malignmark',\n 'mlongdiv',\n 'mscarries',\n 'mscarry',\n 'msgroup',\n 'mstack',\n 'msline',\n 'msrow',\n 'semantics',\n 'annotation',\n 'annotation-xml',\n 'mprescripts',\n 'none',\n] as const);\n\nexport const text = freeze(['#text'] as const);\n", "import { freeze } from './utils.js';\n\nexport const html = freeze([\n 'accept',\n 'action',\n 'align',\n 'alt',\n 'autocapitalize',\n 'autocomplete',\n 'autopictureinpicture',\n 'autoplay',\n 'background',\n 'bgcolor',\n 'border',\n 'capture',\n 'cellpadding',\n 'cellspacing',\n 'checked',\n 'cite',\n 'class',\n 'clear',\n 'color',\n 'cols',\n 'colspan',\n 'command',\n 'commandfor',\n 'controls',\n 'controlslist',\n 'coords',\n 'crossorigin',\n 'datetime',\n 'decoding',\n 'default',\n 'dir',\n 'disabled',\n 'disablepictureinpicture',\n 'disableremoteplayback',\n 'download',\n 'draggable',\n 'enctype',\n 'enterkeyhint',\n 'exportparts',\n 'face',\n 'for',\n 'headers',\n 'height',\n 'hidden',\n 'high',\n 'href',\n 'hreflang',\n 'id',\n 'inert',\n 'inputmode',\n 'integrity',\n 'ismap',\n 'kind',\n 'label',\n 'lang',\n 'list',\n 'loading',\n 'loop',\n 'low',\n 'max',\n 'maxlength',\n 'media',\n 'method',\n 'min',\n 'minlength',\n 'multiple',\n 'muted',\n 'name',\n 'nonce',\n 'noshade',\n 'novalidate',\n 'nowrap',\n 'open',\n 'optimum',\n 'part',\n 'pattern',\n 'placeholder',\n 'playsinline',\n 'popover',\n 'popovertarget',\n 'popovertargetaction',\n 'poster',\n 'preload',\n 'pubdate',\n 'radiogroup',\n 'readonly',\n 'rel',\n 'required',\n 'rev',\n 'reversed',\n 'role',\n 'rows',\n 'rowspan',\n 'spellcheck',\n 'scope',\n 'selected',\n 'shape',\n 'size',\n 'sizes',\n 'slot',\n 'span',\n 'srclang',\n 'start',\n 'src',\n 'srcset',\n 'step',\n 'style',\n 'summary',\n 'tabindex',\n 'title',\n 'translate',\n 'type',\n 'usemap',\n 'valign',\n 'value',\n 'width',\n 'wrap',\n 'xmlns',\n] as const);\n\nexport const svg = freeze([\n 'accent-height',\n 'accumulate',\n 'additive',\n 'alignment-baseline',\n 'amplitude',\n 'ascent',\n 'attributename',\n 'attributetype',\n 'azimuth',\n 'basefrequency',\n 'baseline-shift',\n 'begin',\n 'bias',\n 'by',\n 'class',\n 'clip',\n 'clippathunits',\n 'clip-path',\n 'clip-rule',\n 'color',\n 'color-interpolation',\n 'color-interpolation-filters',\n 'color-profile',\n 'color-rendering',\n 'cx',\n 'cy',\n 'd',\n 'dx',\n 'dy',\n 'diffuseconstant',\n 'direction',\n 'display',\n 'divisor',\n 'dur',\n 'edgemode',\n 'elevation',\n 'end',\n 'exponent',\n 'fill',\n 'fill-opacity',\n 'fill-rule',\n 'filter',\n 'filterunits',\n 'flood-color',\n 'flood-opacity',\n 'font-family',\n 'font-size',\n 'font-size-adjust',\n 'font-stretch',\n 'font-style',\n 'font-variant',\n 'font-weight',\n 'fx',\n 'fy',\n 'g1',\n 'g2',\n 'glyph-name',\n 'glyphref',\n 'gradientunits',\n 'gradienttransform',\n 'height',\n 'href',\n 'id',\n 'image-rendering',\n 'in',\n 'in2',\n 'intercept',\n 'k',\n 'k1',\n 'k2',\n 'k3',\n 'k4',\n 'kerning',\n 'keypoints',\n 'keysplines',\n 'keytimes',\n 'lang',\n 'lengthadjust',\n 'letter-spacing',\n 'kernelmatrix',\n 'kernelunitlength',\n 'lighting-color',\n 'local',\n 'marker-end',\n 'marker-mid',\n 'marker-start',\n 'markerheight',\n 'markerunits',\n 'markerwidth',\n 'maskcontentunits',\n 'maskunits',\n 'max',\n 'mask',\n 'mask-type',\n 'media',\n 'method',\n 'mode',\n 'min',\n 'name',\n 'numoctaves',\n 'offset',\n 'operator',\n 'opacity',\n 'order',\n 'orient',\n 'orientation',\n 'origin',\n 'overflow',\n 'paint-order',\n 'path',\n 'pathlength',\n 'patterncontentunits',\n 'patterntransform',\n 'patternunits',\n 'points',\n 'preservealpha',\n 'preserveaspectratio',\n 'primitiveunits',\n 'r',\n 'rx',\n 'ry',\n 'radius',\n 'refx',\n 'refy',\n 'repeatcount',\n 'repeatdur',\n 'restart',\n 'result',\n 'rotate',\n 'scale',\n 'seed',\n 'shape-rendering',\n 'slope',\n 'specularconstant',\n 'specularexponent',\n 'spreadmethod',\n 'startoffset',\n 'stddeviation',\n 'stitchtiles',\n 'stop-color',\n 'stop-opacity',\n 'stroke-dasharray',\n 'stroke-dashoffset',\n 'stroke-linecap',\n 'stroke-linejoin',\n 'stroke-miterlimit',\n 'stroke-opacity',\n 'stroke',\n 'stroke-width',\n 'style',\n 'surfacescale',\n 'systemlanguage',\n 'tabindex',\n 'tablevalues',\n 'targetx',\n 'targety',\n 'transform',\n 'transform-origin',\n 'text-anchor',\n 'text-decoration',\n 'text-rendering',\n 'textlength',\n 'type',\n 'u1',\n 'u2',\n 'unicode',\n 'values',\n 'viewbox',\n 'visibility',\n 'version',\n 'vert-adv-y',\n 'vert-origin-x',\n 'vert-origin-y',\n 'width',\n 'word-spacing',\n 'wrap',\n 'writing-mode',\n 'xchannelselector',\n 'ychannelselector',\n 'x',\n 'x1',\n 'x2',\n 'xmlns',\n 'y',\n 'y1',\n 'y2',\n 'z',\n 'zoomandpan',\n] as const);\n\nexport const mathMl = freeze([\n 'accent',\n 'accentunder',\n 'align',\n 'bevelled',\n 'close',\n 'columnalign',\n 'columnlines',\n 'columnspacing',\n 'columnspan',\n 'denomalign',\n 'depth',\n 'dir',\n 'display',\n 'displaystyle',\n 'encoding',\n 'fence',\n 'frame',\n 'height',\n 'href',\n 'id',\n 'largeop',\n 'length',\n 'linethickness',\n 'lquote',\n 'lspace',\n 'mathbackground',\n 'mathcolor',\n 'mathsize',\n 'mathvariant',\n 'maxsize',\n 'minsize',\n 'movablelimits',\n 'notation',\n 'numalign',\n 'open',\n 'rowalign',\n 'rowlines',\n 'rowspacing',\n 'rowspan',\n 'rspace',\n 'rquote',\n 'scriptlevel',\n 'scriptminsize',\n 'scriptsizemultiplier',\n 'selection',\n 'separator',\n 'separators',\n 'stretchy',\n 'subscriptshift',\n 'supscriptshift',\n 'symmetric',\n 'voffset',\n 'width',\n 'xmlns',\n]);\n\nexport const xml = freeze([\n 'xlink:href',\n 'xml:id',\n 'xlink:title',\n 'xml:space',\n 'xmlns:xlink',\n] as const);\n", "import { seal } from './utils.js';\n\nexport const MUSTACHE_EXPR = seal(/{{[\\w\\W]*|^[\\w\\W]*}}/g);\nexport const ERB_EXPR = seal(/<%[\\w\\W]*|^[\\w\\W]*%>/g);\nexport const TMPLIT_EXPR = seal(/\\${[\\w\\W]*/g);\nexport const DATA_ATTR = seal(/^data-[\\-\\w.\\u00B7-\\uFFFF]+$/); // eslint-disable-line no-useless-escape\nexport const ARIA_ATTR = seal(/^aria-[\\-\\w]+$/); // eslint-disable-line no-useless-escape\nexport const IS_ALLOWED_URI = seal(\n /^(?:(?:(?:f|ht)tps?|mailto|tel|callto|sms|cid|xmpp|matrix):|[^a-z]|[a-z+.\\-]+(?:[^a-z+.\\-:]|$))/i // eslint-disable-line no-useless-escape\n);\nexport const IS_SCRIPT_OR_DATA = seal(/^(?:\\w+script|data):/i);\nexport const ATTR_WHITESPACE = seal(\n /[\\u0000-\\u0020\\u00A0\\u1680\\u180E\\u2000-\\u2029\\u205F\\u3000]/g // eslint-disable-line no-control-regex\n);\nexport const DOCTYPE_NAME = seal(/^html$/i);\nexport const CUSTOM_ELEMENT = seal(/^[a-z][.\\w]*(-[.\\w]+)+$/i);\n\n// Markup-significant character probes used by _sanitizeElements.\n// Shared module-level instances are safe despite the sticky /g flags:\n// unapply() resets lastIndex for RegExp receivers before every call.\nexport const ELEMENT_MARKUP_PROBE = seal(/<[/\\w!]/g);\nexport const COMMENT_MARKUP_PROBE = seal(/<[/\\w]/g);\nexport const FALLBACK_TAG_CLOSE = seal(/<\\/no(script|embed|frames)/i);\nexport const SELF_CLOSING_TAG = seal(/\\/>/i);\n", "/* eslint-disable @typescript-eslint/indent */\n\nimport type { Config, UseProfilesConfig } from './config';\nimport type { DOMPurify, HooksMap, HookFunction, WindowLike } from './types';\nimport * as TAGS from './tags.js';\nimport * as ATTRS from './attrs.js';\nimport * as EXPRESSIONS from './regexp.js';\nimport {\n addToSet,\n clone,\n entries,\n freeze,\n seal,\n arrayForEach,\n arrayIsArray,\n arrayLastIndexOf,\n arrayPop,\n arrayPush,\n arraySplice,\n stringMatch,\n stringReplace,\n stringToLowerCase,\n stringToString,\n stringIndexOf,\n stringTrim,\n regExpTest,\n isRegex,\n typeErrorCreate,\n lookupGetter,\n create,\n objectHasOwnProperty,\n stringifyValue,\n} from './utils.js';\n\nexport type { Config } from './config';\n\nexport type {\n DOMPurify,\n RemovedElement,\n RemovedAttribute,\n HookName,\n NodeHook,\n ElementHook,\n DocumentFragmentHook,\n UponSanitizeElementHook,\n UponSanitizeAttributeHook,\n UponSanitizeElementHookEvent,\n UponSanitizeAttributeHookEvent,\n WindowLike,\n} from './types';\n\ndeclare const VERSION: string;\n\n// https://developer.mozilla.org/en-US/docs/Web/API/Node/nodeType\nconst NODE_TYPE = {\n element: 1,\n attribute: 2,\n text: 3,\n cdataSection: 4,\n entityReference: 5, // Deprecated\n entityNode: 6, // Deprecated\n processingInstruction: 7,\n comment: 8,\n document: 9,\n documentType: 10,\n documentFragment: 11,\n notation: 12, // Deprecated\n};\n\nconst getGlobal = function (): WindowLike {\n return typeof window === 'undefined' ? null : window;\n};\n\n/**\n * Creates a no-op policy for internal use only.\n * Don't export this function outside this module!\n * @param trustedTypes The policy factory.\n * @param purifyHostElement The Script element used to load DOMPurify (to determine policy name suffix).\n * @return The policy created (or null, if Trusted Types\n * are not supported or creating the policy failed).\n */\nconst _createTrustedTypesPolicy = function (\n trustedTypes: TrustedTypePolicyFactory,\n purifyHostElement: HTMLScriptElement\n) {\n if (\n typeof trustedTypes !== 'object' ||\n typeof trustedTypes.createPolicy !== 'function'\n ) {\n return null;\n }\n\n // Allow the callers to control the unique policy name\n // by adding a data-tt-policy-suffix to the script element with the DOMPurify.\n // Policy creation with duplicate names throws in Trusted Types.\n let suffix = null;\n const ATTR_NAME = 'data-tt-policy-suffix';\n if (purifyHostElement && purifyHostElement.hasAttribute(ATTR_NAME)) {\n suffix = purifyHostElement.getAttribute(ATTR_NAME);\n }\n\n const policyName = 'dompurify' + (suffix ? '#' + suffix : '');\n\n try {\n return trustedTypes.createPolicy(policyName, {\n createHTML(html) {\n return html;\n },\n createScriptURL(scriptUrl) {\n return scriptUrl;\n },\n });\n } catch (_) {\n // Policy creation failed (most likely another DOMPurify script has\n // already run). Skip creating the policy, as this will only cause errors\n // if TT are enforced.\n console.warn(\n 'TrustedTypes policy ' + policyName + ' could not be created.'\n );\n return null;\n }\n};\n\nconst _createHooksMap = function (): HooksMap {\n return {\n afterSanitizeAttributes: [],\n afterSanitizeElements: [],\n afterSanitizeShadowDOM: [],\n beforeSanitizeAttributes: [],\n beforeSanitizeElements: [],\n beforeSanitizeShadowDOM: [],\n uponSanitizeAttribute: [],\n uponSanitizeElement: [],\n uponSanitizeShadowNode: [],\n };\n};\n\n/**\n * Resolve a set-valued configuration option: a fresh set built from\n * cfg[key] when it is an own array property (seeded with a clone of\n * options.base when given, case-normalized via options.transform),\n * the fallback set otherwise.\n *\n * @param cfg the cloned, prototype-free configuration object\n * @param key the configuration property to read\n * @param fallback the set to use when the option is absent or not an array\n * @param options transform and optional base set to merge into\n * @returns the resolved set\n */\nconst _resolveSetOption = function (\n cfg: Config,\n key: keyof Config,\n fallback: Record,\n options: {\n transform: Parameters[2];\n base?: Record;\n }\n): Record {\n return objectHasOwnProperty(cfg, key) && arrayIsArray(cfg[key])\n ? addToSet(\n options.base ? clone(options.base) : {},\n cfg[key] as readonly unknown[],\n options.transform\n )\n : fallback;\n};\n\nfunction createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {\n const DOMPurify: DOMPurify = (root: WindowLike) => createDOMPurify(root);\n\n DOMPurify.version = VERSION;\n\n DOMPurify.removed = [];\n\n if (\n !window ||\n !window.document ||\n window.document.nodeType !== NODE_TYPE.document ||\n !window.Element\n ) {\n // Not running in a browser, provide a factory function\n // so that you can pass your own Window\n DOMPurify.isSupported = false;\n\n return DOMPurify;\n }\n\n let { document } = window;\n\n const originalDocument = document;\n const currentScript: HTMLScriptElement =\n originalDocument.currentScript as HTMLScriptElement;\n const {\n DocumentFragment,\n HTMLTemplateElement,\n Node,\n Element,\n NodeFilter,\n NamedNodeMap = window.NamedNodeMap || (window as any).MozNamedAttrMap,\n HTMLFormElement,\n DOMParser,\n trustedTypes,\n } = window;\n\n const ElementPrototype = Element.prototype;\n\n const cloneNode = lookupGetter(ElementPrototype, 'cloneNode');\n const remove = lookupGetter(ElementPrototype, 'remove');\n const getNextSibling = lookupGetter(ElementPrototype, 'nextSibling');\n const getChildNodes = lookupGetter(ElementPrototype, 'childNodes');\n const getParentNode = lookupGetter(ElementPrototype, 'parentNode');\n const getShadowRoot = lookupGetter(ElementPrototype, 'shadowRoot');\n const getAttributes = lookupGetter(ElementPrototype, 'attributes');\n const getNodeType =\n Node && Node.prototype ? lookupGetter(Node.prototype, 'nodeType') : null;\n const getNodeName =\n Node && Node.prototype ? lookupGetter(Node.prototype, 'nodeName') : null;\n\n // As per issue #47, the web-components registry is inherited by a\n // new document created via createHTMLDocument. As per the spec\n // (http://w3c.github.io/webcomponents/spec/custom/#creating-and-passing-registries)\n // a new empty registry is used when creating a template contents owner\n // document, so we use that as our parent document to ensure nothing\n // is inherited.\n if (typeof HTMLTemplateElement === 'function') {\n const template = document.createElement('template');\n if (template.content && template.content.ownerDocument) {\n document = template.content.ownerDocument;\n }\n }\n\n let trustedTypesPolicy;\n let emptyHTML = '';\n\n // The instance's own internal Trusted Types policy. Unlike a caller-supplied\n // `TRUSTED_TYPES_POLICY`, this is created at most once \u2014 Trusted Types throws\n // on duplicate policy names \u2014 and is the only policy allowed to persist\n // across configurations and survive `clearConfig()`.\n let defaultTrustedTypesPolicy;\n let defaultTrustedTypesPolicyResolved = false;\n\n // Tracks whether we are already inside a call to the configured Trusted Types\n // policy (`createHTML` or `createScriptURL`). If a supplied policy callback\n // itself calls `DOMPurify.sanitize` (the cause of #1422), `sanitize` would\n // re-enter the policy and recurse until the stack overflows. We detect that\n // re-entry and throw a clear, actionable error instead. The guard is shared\n // across both callbacks, because either one re-entering `sanitize` triggers\n // the same unbounded recursion.\n let IN_TRUSTED_TYPES_POLICY = 0;\n const _assertNotInTrustedTypesPolicy = function (): void {\n if (IN_TRUSTED_TYPES_POLICY > 0) {\n throw typeErrorCreate(\n 'A configured TRUSTED_TYPES_POLICY callback (createHTML or ' +\n 'createScriptURL) must not call DOMPurify.sanitize, as that causes ' +\n 'infinite recursion. Do not pass a policy whose callbacks wrap ' +\n 'DOMPurify as TRUSTED_TYPES_POLICY; see the \"DOMPurify and Trusted ' +\n 'Types\" section of the README.'\n );\n }\n };\n\n const _createTrustedHTML = function (html: string): string {\n _assertNotInTrustedTypesPolicy();\n\n IN_TRUSTED_TYPES_POLICY++;\n try {\n return trustedTypesPolicy.createHTML(html);\n } finally {\n IN_TRUSTED_TYPES_POLICY--;\n }\n };\n\n const _createTrustedScriptURL = function (scriptUrl: string): string {\n _assertNotInTrustedTypesPolicy();\n\n IN_TRUSTED_TYPES_POLICY++;\n try {\n return trustedTypesPolicy.createScriptURL(scriptUrl);\n } finally {\n IN_TRUSTED_TYPES_POLICY--;\n }\n };\n\n // Lazily resolve (and cache) the instance's internal default policy.\n // Resolution is attempted at most once: a successful `createPolicy` cannot be\n // repeated (Trusted Types throws on duplicate names), and a failed or\n // unsupported attempt must not be retried on every parse.\n const _getDefaultTrustedTypesPolicy = function () {\n if (!defaultTrustedTypesPolicyResolved) {\n defaultTrustedTypesPolicy = _createTrustedTypesPolicy(\n trustedTypes,\n currentScript\n );\n defaultTrustedTypesPolicyResolved = true;\n }\n\n return defaultTrustedTypesPolicy;\n };\n\n const {\n implementation,\n createNodeIterator,\n createDocumentFragment,\n getElementsByTagName,\n } = document;\n const { importNode } = originalDocument;\n\n let hooks = _createHooksMap();\n\n /**\n * Expose whether this browser supports running the full DOMPurify.\n */\n DOMPurify.isSupported =\n typeof entries === 'function' &&\n typeof getParentNode === 'function' &&\n implementation &&\n implementation.createHTMLDocument !== undefined;\n\n const {\n MUSTACHE_EXPR,\n ERB_EXPR,\n TMPLIT_EXPR,\n DATA_ATTR,\n ARIA_ATTR,\n IS_SCRIPT_OR_DATA,\n ATTR_WHITESPACE,\n CUSTOM_ELEMENT,\n } = EXPRESSIONS;\n\n let { IS_ALLOWED_URI } = EXPRESSIONS;\n\n /**\n * We consider the elements and attributes below to be safe. Ideally\n * don't add any new ones but feel free to remove unwanted ones.\n */\n\n /* allowed element names */\n let ALLOWED_TAGS = null;\n const DEFAULT_ALLOWED_TAGS = addToSet({}, [\n ...TAGS.html,\n ...TAGS.svg,\n ...TAGS.svgFilters,\n ...TAGS.mathMl,\n ...TAGS.text,\n ]);\n\n /* Allowed attribute names */\n let ALLOWED_ATTR = null;\n const DEFAULT_ALLOWED_ATTR = addToSet({}, [\n ...ATTRS.html,\n ...ATTRS.svg,\n ...ATTRS.mathMl,\n ...ATTRS.xml,\n ]);\n\n /*\n * Configure how DOMPurify should handle custom elements and their attributes as well as customized built-in elements.\n * @property {RegExp|Function|null} tagNameCheck one of [null, regexPattern, predicate]. Default: `null` (disallow any custom elements)\n * @property {RegExp|Function|null} attributeNameCheck one of [null, regexPattern, predicate]. Default: `null` (disallow any attributes not on the allow list)\n * @property {boolean} allowCustomizedBuiltInElements allow custom elements derived from built-ins if they pass CUSTOM_ELEMENT_HANDLING.tagNameCheck. Default: `false`.\n */\n let CUSTOM_ELEMENT_HANDLING = Object.seal(\n create(null, {\n tagNameCheck: {\n writable: true,\n configurable: false,\n enumerable: true,\n value: null,\n },\n attributeNameCheck: {\n writable: true,\n configurable: false,\n enumerable: true,\n value: null,\n },\n allowCustomizedBuiltInElements: {\n writable: true,\n configurable: false,\n enumerable: true,\n value: false,\n },\n })\n );\n\n /* Explicitly forbidden tags (overrides ALLOWED_TAGS/ADD_TAGS) */\n let FORBID_TAGS = null;\n\n /* Explicitly forbidden attributes (overrides ALLOWED_ATTR/ADD_ATTR) */\n let FORBID_ATTR = null;\n\n /* Config object to store ADD_TAGS/ADD_ATTR functions (when used as functions) */\n const EXTRA_ELEMENT_HANDLING = Object.seal(\n create(null, {\n tagCheck: {\n writable: true,\n configurable: false,\n enumerable: true,\n value: null,\n },\n attributeCheck: {\n writable: true,\n configurable: false,\n enumerable: true,\n value: null,\n },\n })\n );\n\n /* Decide if ARIA attributes are okay */\n let ALLOW_ARIA_ATTR = true;\n\n /* Decide if custom data attributes are okay */\n let ALLOW_DATA_ATTR = true;\n\n /* Decide if unknown protocols are okay */\n let ALLOW_UNKNOWN_PROTOCOLS = false;\n\n /* Decide if self-closing tags in attributes are allowed.\n * Usually removed due to a mXSS issue in jQuery 3.0 */\n let ALLOW_SELF_CLOSE_IN_ATTR = true;\n\n /* Output should be safe for common template engines.\n * This means, DOMPurify removes data attributes, mustaches and ERB\n */\n let SAFE_FOR_TEMPLATES = false;\n\n /* Output should be safe even for XML used within HTML and alike.\n * This means, DOMPurify removes comments when containing risky content.\n */\n let SAFE_FOR_XML = true;\n\n /* Decide if document with ... should be returned */\n let WHOLE_DOCUMENT = false;\n\n /* Track whether config is already set on this instance of DOMPurify. */\n let SET_CONFIG = false;\n\n /* Decide if all elements (e.g. style, script) must be children of\n * document.body. By default, browsers might move them to document.head */\n let FORCE_BODY = false;\n\n /* Decide if a DOM `HTMLBodyElement` should be returned, instead of a html\n * string (or a TrustedHTML object if Trusted Types are supported).\n * If `WHOLE_DOCUMENT` is enabled a `HTMLHtmlElement` will be returned instead\n */\n let RETURN_DOM = false;\n\n /* Decide if a DOM `DocumentFragment` should be returned, instead of a html\n * string (or a TrustedHTML object if Trusted Types are supported) */\n let RETURN_DOM_FRAGMENT = false;\n\n /* Try to return a Trusted Type object instead of a string, return a string in\n * case Trusted Types are not supported */\n let RETURN_TRUSTED_TYPE = false;\n\n /* Output should be free from DOM clobbering attacks?\n * This sanitizes markups named with colliding, clobberable built-in DOM APIs.\n */\n let SANITIZE_DOM = true;\n\n /* Achieve full DOM Clobbering protection by isolating the namespace of named\n * properties and JS variables, mitigating attacks that abuse the HTML/DOM spec rules.\n *\n * HTML/DOM spec rules that enable DOM Clobbering:\n * - Named Access on Window (\u00A77.3.3)\n * - DOM Tree Accessors (\u00A73.1.5)\n * - Form Element Parent-Child Relations (\u00A74.10.3)\n * - Iframe srcdoc / Nested WindowProxies (\u00A74.8.5)\n * - HTMLCollection (\u00A74.2.10.2)\n *\n * Namespace isolation is implemented by prefixing `id` and `name` attributes\n * with a constant string, i.e., `user-content-`\n */\n let SANITIZE_NAMED_PROPS = false;\n const SANITIZE_NAMED_PROPS_PREFIX = 'user-content-';\n\n /* Keep element content when removing element? */\n let KEEP_CONTENT = true;\n\n /* If a `Node` is passed to sanitize(), then performs sanitization in-place instead\n * of importing it into a new Document and returning a sanitized copy */\n let IN_PLACE = false;\n\n /* Allow usage of profiles like html, svg and mathMl */\n let USE_PROFILES: UseProfilesConfig | false = {};\n\n /* Tags to ignore content of when KEEP_CONTENT is true */\n let FORBID_CONTENTS = null;\n const DEFAULT_FORBID_CONTENTS = addToSet({}, [\n 'annotation-xml',\n 'audio',\n 'colgroup',\n 'desc',\n 'foreignobject',\n 'head',\n 'iframe',\n 'math',\n 'mi',\n 'mn',\n 'mo',\n 'ms',\n 'mtext',\n 'noembed',\n 'noframes',\n 'noscript',\n 'plaintext',\n 'script',\n // mirrors the selected