From 2a7142c7223a4f182b140c6d3feab5b2d0ab3d6e Mon Sep 17 00:00:00 2001 From: lockbitchat Date: Sun, 2 Aug 2026 21:16:13 -0400 Subject: [PATCH] feat(webrtc): recover a dropped connection without a signalling server; release v5.6.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A chat no longer dies when the network moves under it. A NAT rebind, a lift, a Wi-Fi radio parking itself, a phone that dozed: the session repairs its own network path in place, and the messages typed meanwhile go out when it returns. Recovery is an ICE restart, which renegotiates only the transport path — the DTLS handshake, the session keys and the SCTP association carrying the data channel all sit above ICE and survive it. The renegotiation SDP therefore travels over the existing end-to-end encrypted, SAS-verified channel: no signalling service enters the design, and an attacker who cannot already decrypt the session cannot inject a reconnection. A restart is refused outright unless the DTLS fingerprint in the incoming SDP matches the live session's, so recovery can never re-point a conversation at a different peer. When the path is gone for good the session is ended and its data wiped rather than left half-alive: with no server there is nothing to re-signal through, and a conversation whose transport is gone should not leave its plaintext in an open tab. The two cases where that is already certain are recognised in seconds instead of being retried for two minutes — a channel that has delivered nothing at all since the drop cannot carry a renegotiation, and an ICE agent left bound to a network that no longer exists reports zero candidate pairs on every restart. Judging liveness was the hard part. Silence is not evidence of death: browsers freeze backgrounded tabs outright, and a frozen peer answers nothing while being perfectly healthy. What survives that freeze is ICE consent, which the browser runs in its network stack rather than on the page's thread — so a connected ICE state means a silent peer is asleep, and only a degraded one turns an unanswered probe into a teardown. The grace window before a restart is sized to the browser's own timings: 'disconnected' arrives after ~5s of missed consent responses and is held ~25s before 'failed', and that window exists for self-healing, so restarting at the start of it broke connections that were about to recover. Several long-standing bugs surfaced along the way and are fixed here: - handleHeartbeat() was dispatched to but never defined, so every inbound heartbeat threw a TypeError and peer liveness was never observed at all. - Heartbeats were folded into the 5-minute maintenance cycle instead of running on their own timer, far too coarse to notice a dead path. - ondatachannel can hand over a channel that is already open, so the answering side's 'open' event had been dispatched before the handler was assigned and never fired, leaving that side with no heartbeat, no watchdog and no file-transfer init. The peer whose network was fine kept showing "connected" indefinitely because nothing was running to notice. - Answering a heartbeat required the peer to have finished verifying, but the two sides confirm a SAS code at different moments; for that whole window one of them could not reply and was declared dead on a healthy connection. - Sending on a channel that was not ready returned in silence: the text stayed in the box, nothing was transmitted, and nothing said why. - The send path gated on navigator.onLine and the offline/online events, which report whether an interface exists rather than whether anything is reachable. A tab the OS froze misses the 'online' edge, and this side then queued every message forever: one tick on everything it sent, while incoming messages kept arriving. Sending is now decided by the data channel, and queues drain by polling rather than on an edge, so a missed event cannot strand them. - A false offline modal appeared on a working session, because the offline event was taken at face value. tests/session-recovery.test.mjs covers the state machine, the backoff and its serialisation, the offline hold, the sleeping-peer discriminator and the identity check. --- CHANGELOG.md | 90 ++ README.md | 2 +- dist/app-boot.js | 737 ++++++++++++++- dist/app-boot.js.map | 4 +- dist/app.js | 135 ++- dist/app.js.map | 4 +- index.html | 44 +- meta.json | 14 +- package.json | 4 +- src/app.jsx | 232 +++-- src/components/ui/Header.jsx | 2 +- src/network/EnhancedSecureWebRTCManager.js | 947 ++++++++++++++++++-- src/pwa/pwa-manager.js | 15 +- src/state/sessionsStore.js | 13 +- sw.js | 2 +- tests/inbound-frame-authentication.test.mjs | 11 + tests/session-recovery.test.mjs | 745 +++++++++++++++ tests/sessions-reducer.test.mjs | 17 + 18 files changed, 2794 insertions(+), 224 deletions(-) create mode 100644 tests/session-recovery.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e0d78b..80c04dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,95 @@ # Changelog +## v5.6.0 — Survive a dropped connection + +A chat no longer dies when the network moves under it. Switching Wi-Fi → LTE, +a NAT rebind, a lift, a tunnel: the session now repairs its own network path and +the messages you typed meanwhile go out when it comes back. + +This is done without adding any server. An ICE restart renegotiates *only* the +transport path; the DTLS handshake, the session keys and the SCTP association +carrying the data channel all sit above ICE and survive it. So the renegotiation +SDP travels over the existing end-to-end encrypted, SAS-verified channel — there +is still no signalling service anywhere in the design, and an attacker who cannot +already decrypt the session cannot inject a reconnection. + +### Added + +- **Automatic session recovery.** A broken path is repaired in place with an + in-band ICE restart, retried with a 1/2/4/8/15/30 s backoff for up to two + minutes. Keys, SAS verification and message history are all preserved — no + re-handshake, no comparing codes again. +- **Liveness detection that understands sleeping devices.** A data channel keeps + reporting `readyState: 'open'` long after the path underneath it has died — the + classic Wi-Fi → LTE switch, where nothing closes and nothing errors, packets + just stop. Silence alone is deliberately not treated as death, because a + browser freezes a backgrounded tab outright and a healthy peer then answers + nothing at all. What survives that freeze is ICE consent, which the browser + runs in its network stack rather than on the page's thread — so a connected ICE + state means a silent peer is asleep, not gone, and the session is left alone. + Only when ICE itself is degraded does an unanswered probe end the session. +- **Recovery is given up promptly when it cannot possibly work.** Every route out + of a broken path runs over the data channel, so if nothing at all has reached + us since the drop, no further attempt can succeed. Likewise an ICE agent left + bound to a network that is gone — every candidate times out, every restart ends + with zero candidate pairs — cannot be repaired by restarting it. Both are now + recognised in seconds instead of being retried for two minutes. +- **A session that cannot be recovered is closed, not left half-alive.** When the + path is gone for good, the chat is ended and its data wiped — keys, queued + messages and transcript together. There is deliberately no manual fallback: a + conversation whose transport is gone should not leave its plaintext sitting in + a tab, and starting a fresh one is a single, honest step. +- **Store-and-forward while reconnecting.** Messages typed during a repair are + queued and delivered when the path returns, in order. A send that races a + still-settling path is re-queued rather than marked failed. +- **The conversation stays on screen** during a repair, with a "Restoring + connection…" state, instead of dropping you back to the connect screen. +- **A device with no network holds the session open.** Five minutes underground + no longer costs a session: the give-up deadline does not run while this device + has no connectivity, and recovery retries the moment the radio returns or the + tab comes back to the foreground. +- `tests/session-recovery.test.mjs` covers the state machine, the backoff, the + offline hold and the identity check below. + +### Security + +- **A reconnection cannot re-point a session at a different peer.** The DTLS + fingerprint in an incoming restart offer or answer is checked against the + fingerprint of the live, already-verified session *before* anything is applied + to the peer connection. A mismatch aborts recovery. If there is no live + fingerprint to compare against, the restart is refused rather than trusted. +- Only the side that created the original offer may drive a restart; the other + side asks. With no signalling server there is no referee to resolve glare. +- Calls cannot be placed onto a path that is mid-repair, where the media + renegotiation would race the ICE restart on the same connection. + +### Fixed + +- **Every inbound heartbeat threw a `TypeError`.** `handleHeartbeat()` was + dispatched to but never defined, so peer liveness was never actually observed. +- **Heartbeats were sent every 5 minutes, not the intended interval** — the send + was folded into the general maintenance cycle, far too coarse to notice a dead + path. It now runs on its own timer, and answering one no longer requires the + peer to have finished verifying: the two sides confirm a SAS code at different + moments, and for that whole window one of them could not reply and was being + declared dead on a healthy connection. +- **The answering side never started its watchdog.** `ondatachannel` can hand over + a channel that is already open, so the `open` event had been dispatched before + the handler was assigned and never fired — leaving that side with no heartbeat, + no liveness watchdog and no file-transfer init. The peer whose network was fine + kept showing "connected" indefinitely because nothing was running to notice. +- **A failed send no longer fails silently.** Sending on a channel that was not + ready simply returned: the text stayed in the box, nothing was transmitted and + nothing said why. +- **A transient `disconnected` no longer tears down the session.** ICE reports it + routinely and the browser usually recovers unaided; it is now given a grace + window before a restart is spent, and it never clears verification on its own. +- A reconnected session no longer re-announces "secure connection established" — + it is the same session resuming, and no handshake took place. +- Liveness bookkeeping can no longer throw ahead of message routing, where the + surrounding catch would have swallowed it and silently dropped every inbound + message. + ## v5.5.4 — Fix the desktop download buttons ### Fixed diff --git a/README.md b/README.md index b37c4f6..38d5c69 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.5.4-3ecf8e.svg)](CHANGELOG.md) +[![Version](https://img.shields.io/badge/version-5.6.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) diff --git a/dist/app-boot.js b/dist/app-boot.js index ac182ed..012da5c 100644 --- a/dist/app-boot.js +++ b/dist/app-boot.js @@ -6694,8 +6694,10 @@ var EnhancedSecureWebRTCManager = class _EnhancedSecureWebRTCManager { // 5 minutes CONNECTION_TIMEOUT: 1e4, // 10 seconds - HEARTBEAT_INTERVAL: 3e4, - // 30 seconds + // Kept below LIVENESS_PROBE_AFTER so a healthy peer's own heartbeats keep + // the liveness clock fresh and probing never happens on a working link. + HEARTBEAT_INTERVAL: 1e4, + // 10 seconds SECURITY_CALC_DELAY: 1e3, // 1 second SECURITY_CALC_RETRY_DELAY: 3e3, @@ -6730,11 +6732,71 @@ var EnhancedSecureWebRTCManager = class _EnhancedSecureWebRTCManager { // 25 seconds REORDER_TIMEOUT: 3e3, // 3 seconds - RETRY_CONNECTION_DELAY: 2e3 + RETRY_CONNECTION_DELAY: 2e3, + // 2 seconds + // --- Session recovery --- + // How long to let a 'disconnected' path heal itself before renegotiating. + // + // The browser enters 'disconnected' after only ~5 s without a consent + // binding response, which ordinary packet loss produces, and then holds + // that state for roughly 25 s before declaring 'failed'. That whole + // window exists precisely so the connection can come back on its own — + // and it very often does, especially against a phone whose screen went + // off, which generates these episodes constantly. + // + // So restart LATE in the window, not at the start: early enough to still + // beat 'failed', late enough that self-healing has had its chance. An + // earlier 3 s value meant every backgrounded phone was answered with a + // renegotiation — tearing down a connection that was about to recover. + // See https://blog.mozilla.org/webrtc/ice-disconnected-not/ + ICE_DISCONNECT_GRACE: 8e3, + // 8 seconds + // How long one restart round-trip (offer → gather → answer) may take. No + // new attempt is launched while one is in flight: the round-trip is far + // longer than the head of the backoff, so retrying blindly cancels the + // attempt already running and recovery never converges. + ICE_RESTART_TIMEOUT: 2e4, + // 20 seconds + // Gathering budget inside a restart. Deliberately far below the initial + // handshake's 10 s: host and server-reflexive candidates arrive in well + // under a second, and waiting out the full budget for a relay candidate + // that may never come would blow the round-trip deadline above. + ICE_RESTART_GATHERING: 4e3, + // 4 seconds + // Give up on automatic recovery after this long. There is no manual + // fallback: the session is ended and its data wiped. + RECONNECT_MAX_DURATION: 12e4, + // 2 minutes + // In-band recovery needs the data channel to carry the renegotiation. If + // nothing at all arrives from the peer for this long once recovery has + // started, it cannot — and no number of further attempts will change + // that, so the session is ended promptly instead of after a two-minute + // wait that was never going to succeed. + RECOVERY_SILENCE_LIMIT: 15e3, + // 15 seconds + // Liveness is established by an explicit probe/ack, not by silence alone. + // Silence on its own is not proof of death: a browser throttles timers in a + // backgrounded tab (Chrome down to roughly one per minute, iOS Safari + // freezes them outright), so a perfectly healthy peer can stop sending for + // a long time. Inbound message handling is NOT throttled that way, so a + // live peer — even a backgrounded one — answers a probe within milliseconds + // while a peer whose network is gone cannot answer at all. + LIVENESS_PROBE_AFTER: 12e3, + // silence before probing the peer + LIVENESS_PROBE_TIMEOUT: 5e3, + // how long the ack may take + LIVENESS_CHECK_INTERVAL: 2e3 // 2 seconds }; + // Backoff between automatic ICE-restart attempts (ms). Deliberately short at + // the head: most real drops recover on the first or second try. + static RECONNECT_BACKOFF = Object.freeze([1e3, 2e3, 4e3, 8e3, 15e3, 3e4]); static LIMITS = { MAX_CONNECTION_ATTEMPTS: 3, + // Consecutive ICE failures that produced zero candidate pairs before + // concluding the PeerConnection itself is unusable, rather than the path + // merely being flaky. + MAX_BARREN_ICE_FAILURES: 2, MAX_OLD_KEYS: 3, MAX_PROCESSED_MESSAGE_IDS: 1e3, MAX_OUT_OF_ORDER_PACKETS: 5, @@ -6797,6 +6859,18 @@ var EnhancedSecureWebRTCManager = class _EnhancedSecureWebRTCManager { CALL_ICE: "call_ice", CALL_DECLINE: "call_decline", CALL_END: "call_end", + // Session recovery. An ICE restart renegotiates ONLY the transport path + // (new candidates after a NAT rebind / IP change); the DTLS handshake and + // the SCTP association that carries this data channel survive it, so the + // session keys, the SAS verification and the message history all stay + // valid. The renegotiation SDP therefore rides the existing E2E channel — + // still no signalling server, and an attacker cannot inject a restart + // without already holding the session keys. + ICE_RESTART_OFFER: "ice_restart_offer", + ICE_RESTART_ANSWER: "ice_restart_answer", + // Sent by the answerer side, which must not create offers itself (glare): + // it asks the offerer to drive the restart. + ICE_RESTART_REQUEST: "ice_restart_request", // Fake traffic FAKE: "fake" }; @@ -6910,6 +6984,25 @@ var EnhancedSecureWebRTCManager = class _EnhancedSecureWebRTCManager { this._secureLog("info", "\u{1F512} Enhanced Mutex system fully initialized and validated"); this.heartbeatInterval = null; this.messageQueue = []; + this._reconnect = { + phase: "idle", + // idle | grace | restarting | waiting | exhausted + attempts: 0, + startedAt: 0, + graceTimer: null, + retryTimer: null, + restartTimer: null, + inFlightAt: 0, + // when the current restart round-trip was launched + barrenFailures: 0, + // consecutive failures that produced no candidate pairs + pendingRole: null + // 'offerer' | 'answerer' during a restart round-trip + }; + this._lastInboundAt = 0; + this._livenessProbeAt = 0; + this._livenessTimer = null; + this._heartbeatTimer = null; this.ecdhKeyPair = null; this.ecdsaKeyPair = null; if (this.fileTransferSystem) { @@ -7715,9 +7808,6 @@ var EnhancedSecureWebRTCManager = class _EnhancedSecureWebRTCManager { if (this._debugMode) { this._monitorGlobalExposure(); } - if (this._heartbeatConfig && this._heartbeatConfig.enabled && this.isConnected()) { - this._sendHeartbeat(); - } this._secureLog("info", "\u{1F527} Maintenance cycle completed successfully"); } catch (error) { this._secureLog("error", "\u274C Maintenance cycle failed", { @@ -7884,21 +7974,29 @@ var EnhancedSecureWebRTCManager = class _EnhancedSecureWebRTCManager { /** * Send heartbeat message (called by unified scheduler) */ - _sendHeartbeat() { + /** + * @param {boolean} ack - true when replying to a peer's probe. An ack is never + * itself acked, otherwise the two sides would ping-pong forever. + */ + _sendHeartbeat(ack = false) { try { - if (this.isConnected() && this.dataChannel && this.dataChannel.readyState === "open") { + if (this.dataChannel && this.dataChannel.readyState === "open") { this.dataChannel.send(JSON.stringify({ type: _EnhancedSecureWebRTCManager.MESSAGE_TYPES.HEARTBEAT, + ack, timestamp: Date.now() })); this._heartbeatConfig.lastHeartbeat = Date.now(); - this._secureLog("debug", "\u{1F493} Heartbeat sent"); + this._secureLog("debug", ack ? "\u{1F493} Heartbeat ack sent" : "\u{1F493} Heartbeat sent"); + return true; } + return false; } catch (error) { this._secureLog("error", "\u274C Heartbeat failed:", { errorType: error?.constructor?.name || "Unknown", message: error?.message || "Unknown error" }); + return false; } } /** @@ -11806,7 +11904,7 @@ var EnhancedSecureWebRTCManager = class _EnhancedSecureWebRTCManager { } return "FAKE_MESSAGE_FILTERED"; } - if (jsonData.type && ["heartbeat", "verification", "verification_response", "peer_disconnect", "key_rotation_signal", "key_rotation_ready", "security_upgrade"].includes(jsonData.type)) { + if (jsonData.type && ["heartbeat", "verification", "verification_response", "peer_disconnect", "key_rotation_signal", "key_rotation_ready", "security_upgrade", "ice_restart_offer", "ice_restart_answer", "ice_restart_request"].includes(jsonData.type)) { return "SYSTEM_MESSAGE_FILTERED"; } if (jsonData.type && ["file_transfer_start", "file_transfer_response", "file_chunk", "chunk_confirmation", "file_transfer_complete", "file_transfer_error"].includes(jsonData.type)) { @@ -11875,7 +11973,7 @@ var EnhancedSecureWebRTCManager = class _EnhancedSecureWebRTCManager { } return data; } - if (!jsonData.type || jsonData.type !== "fake" && !["heartbeat", "verification", "verification_response", "peer_disconnect", "key_rotation_signal", "key_rotation_ready", "enhanced_message", "security_upgrade", "file_transfer_start", "file_transfer_response", "file_chunk", "chunk_confirmation", "file_transfer_complete", "file_transfer_error"].includes(jsonData.type)) { + if (!jsonData.type || jsonData.type !== "fake" && !["heartbeat", "verification", "verification_response", "peer_disconnect", "key_rotation_signal", "key_rotation_ready", "enhanced_message", "security_upgrade", "ice_restart_offer", "ice_restart_answer", "ice_restart_request", "file_transfer_start", "file_transfer_response", "file_chunk", "chunk_confirmation", "file_transfer_complete", "file_transfer_error"].includes(jsonData.type)) { if (this._debugMode) { this._secureLog("debug", "\u{1F4DD} Regular message detected, returning for display"); } @@ -12215,6 +12313,7 @@ var EnhancedSecureWebRTCManager = class _EnhancedSecureWebRTCManager { // FIX 1: Simplified mutex system for message processing async processMessage(data) { try { + this._noteInboundActivity?.(); this._secureLog("debug", "\uFFFD\uFFFD Processing message", { dataType: typeof data, isArrayBuffer: data instanceof ArrayBuffer, @@ -12329,6 +12428,18 @@ var EnhancedSecureWebRTCManager = class _EnhancedSecureWebRTCManager { } return; } + if (parsed.type && [ + _EnhancedSecureWebRTCManager.MESSAGE_TYPES.ICE_RESTART_OFFER, + _EnhancedSecureWebRTCManager.MESSAGE_TYPES.ICE_RESTART_ANSWER, + _EnhancedSecureWebRTCManager.MESSAGE_TYPES.ICE_RESTART_REQUEST + ].includes(parsed.type)) { + try { + await this._handleIceRestartSignal(parsed.type, parsed.data || {}); + } catch (e) { + this._secureLog("error", "\u274C ICE restart 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; @@ -12410,7 +12521,10 @@ var EnhancedSecureWebRTCManager = class _EnhancedSecureWebRTCManager { "peer_disconnect", "key_rotation_signal", "key_rotation_ready", - "security_upgrade" + "security_upgrade", + "ice_restart_offer", + "ice_restart_answer", + "ice_restart_request" ]; if (finalCheck.type && blockedTypes.includes(finalCheck.type)) { this._secureLog("warn", `\u{1F4C1} Final system/file message check blocked: ${finalCheck.type}`); @@ -12486,7 +12600,7 @@ var EnhancedSecureWebRTCManager = class _EnhancedSecureWebRTCManager { this._secureLog("debug", "\u{1F527} Handling system message:", { type: message.type }); switch (message.type) { case "heartbeat": - this.handleHeartbeat(); + this.handleHeartbeat(message); break; case "verification": this.handleVerificationRequest(message.data); @@ -12983,28 +13097,32 @@ var EnhancedSecureWebRTCManager = class _EnhancedSecureWebRTCManager { if (state === "connected" && !this.isVerified) { this._notifyVerificationReadyIfPossible(); } else if (state === "connected" && this.isVerified) { - this.onStatusChange("connected"); - } else if (state === "disconnected" || state === "closed") { + if (!this._onPathRecovered()) this.onStatusChange("connected"); + } else if (state === "disconnected") { if (this.intentionalDisconnect) { this.onStatusChange("disconnected"); setTimeout(() => this.disconnect(), 100); + } else if (this.isVerified) { + this._onPathDegraded("ice_disconnected"); } else { - if (this.isVerified || state === "closed") { - this.onStatusChange("disconnected"); - this._clearVerificationStates(); - } else { - console.warn(`[SecureBit ICE] State is ${state} but not verified yet. Keeping session open for manual exchange.`); - } + console.warn(`[SecureBit ICE] State is ${state} but not verified yet. Keeping session open for manual exchange.`); } + } else if (state === "closed") { + this._resetReconnectState(); + this.onStatusChange("disconnected"); + this._clearVerificationStates(); + if (this.intentionalDisconnect) setTimeout(() => this.disconnect(), 100); } else if (state === "failed") { this._collectIceFailureDiagnostics().then((diagnostics) => { console.warn("[SecureBit ICE] failure diagnostics", diagnostics); + this._noteIceFailureDiagnostics(diagnostics); }); if (this.isVerified) { - this.onStatusChange("disconnected"); + this._onPathLost("ice_failed"); } else { console.warn("[SecureBit ICE] State is failed but not verified yet. Keeping session open for manual exchange."); } + } else if (this.isReconnecting() && (state === "connecting" || state === "new")) { } else { this.onStatusChange(state); } @@ -13043,7 +13161,10 @@ var EnhancedSecureWebRTCManager = class _EnhancedSecureWebRTCManager { } setupDataChannel(channel) { this.dataChannel = channel; - this.dataChannel.onopen = async () => { + let openHandled = false; + const handleChannelOpen = async () => { + if (openHandled) return; + openHandled = true; try { if (this.dataChannel && typeof this.dataChannel.bufferedAmountLowThreshold === "number") { this.dataChannel.bufferedAmountLowThreshold = 1024 * 1024; @@ -13087,7 +13208,17 @@ var EnhancedSecureWebRTCManager = class _EnhancedSecureWebRTCManager { } this.startHeartbeat(); }; + this.dataChannel.onopen = handleChannelOpen; + if (this.dataChannel.readyState === "open") { + Promise.resolve().then(() => handleChannelOpen()).catch((error) => { + this._secureLog("error", "Deferred data channel open handling failed", { + errorType: error?.constructor?.name || "Unknown" + }); + }); + } this.dataChannel.onclose = () => { + this._resetReconnectState?.(); + this._teardownRecoveryLifecycleListeners?.(); if (!this.intentionalDisconnect) { this.onStatusChange("disconnected"); this._clearVerificationStates(); @@ -13109,6 +13240,7 @@ var EnhancedSecureWebRTCManager = class _EnhancedSecureWebRTCManager { }; this.dataChannel.onmessage = async (event) => { try { + this._noteInboundActivity?.(); if (typeof event.data === "string") { try { const parsed = JSON.parse(event.data); @@ -13192,6 +13324,18 @@ var EnhancedSecureWebRTCManager = class _EnhancedSecureWebRTCManager { } return; } + if (parsed.type && [ + _EnhancedSecureWebRTCManager.MESSAGE_TYPES.ICE_RESTART_OFFER, + _EnhancedSecureWebRTCManager.MESSAGE_TYPES.ICE_RESTART_ANSWER, + _EnhancedSecureWebRTCManager.MESSAGE_TYPES.ICE_RESTART_REQUEST + ].includes(parsed.type)) { + try { + await this._handleIceRestartSignal(parsed.type, parsed.data || {}); + } catch (e) { + this._secureLog("error", "\u274C ICE restart signal handling failed", { errorType: e?.constructor?.name }); + } + 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; @@ -16256,18 +16400,520 @@ var EnhancedSecureWebRTCManager = class _EnhancedSecureWebRTCManager { this.sendSecureMessage(message).catch(console.error); } } + // Heartbeat runs on its own HEARTBEAT_INTERVAL timer. It used to be folded + // into the unified maintenance cycle, which ticks every 5 minutes — far too + // coarse to notice a dead path, and long enough that a drop looked like + // silence. The maintenance cycle no longer sends heartbeats. startHeartbeat() { - this._secureLog("info", "Heartbeat moved to unified scheduler"); this._heartbeatConfig = { enabled: true, interval: _EnhancedSecureWebRTCManager.TIMEOUTS.HEARTBEAT_INTERVAL, lastHeartbeat: 0 }; + this.stopHeartbeat( + /* keepConfig */ + true + ); + this._heartbeatTimer = setInterval(() => { + if (!this._heartbeatConfig?.enabled) return; + if (this.dataChannel?.readyState === "open") { + this._sendHeartbeat(); + } + }, _EnhancedSecureWebRTCManager.TIMEOUTS.HEARTBEAT_INTERVAL); + this._trackActiveTimer(this._heartbeatTimer); + this._lastInboundAt = Date.now(); + this._livenessProbeAt = 0; + this._livenessArmed = false; + this._startLivenessWatchdog(); + this._setupRecoveryLifecycleListeners(); + this._secureLog("info", "\u{1F504} Liveness watchdog started", { + heartbeatMs: _EnhancedSecureWebRTCManager.TIMEOUTS.HEARTBEAT_INTERVAL, + probeAfterMs: _EnhancedSecureWebRTCManager.TIMEOUTS.LIVENESS_PROBE_AFTER, + probeTimeoutMs: _EnhancedSecureWebRTCManager.TIMEOUTS.LIVENESS_PROBE_TIMEOUT + }); } - stopHeartbeat() { - if (this._heartbeatConfig) { + stopHeartbeat(keepConfig = false) { + if (!keepConfig && this._heartbeatConfig) { this._heartbeatConfig.enabled = false; } + if (this._heartbeatTimer) { + clearInterval(this._heartbeatTimer); + this._activeTimers?.delete(this._heartbeatTimer); + this._heartbeatTimer = null; + } + if (!keepConfig) this._stopLivenessWatchdog(); + } + /** + * Inbound heartbeat from the peer. This method used to be missing entirely + * while handleSystemMessage still dispatched to it, so every heartbeat threw + * a TypeError and liveness was never actually observed. + * + * A non-ack heartbeat is a probe and must be answered immediately: that reply + * is what proves this side is alive even when its tab is backgrounded and its + * own timers have been throttled to a standstill. + */ + handleHeartbeat(message) { + this._lastInboundAt = Date.now(); + this._livenessProbeAt = 0; + const isAck = message?.ack === true || message?.data?.ack === true; + if (!isAck) this._sendHeartbeat(true); + this._secureLog("debug", isAck ? "\u{1F493} Heartbeat ack received" : "\u{1F493} Heartbeat probe received"); + } + /** + * Any authenticated inbound frame proves the path is alive, not just + * heartbeats — a busy conversation must never trip the watchdog. + */ + _noteInboundActivity() { + this._lastInboundAt = Date.now(); + this._livenessProbeAt = 0; + this._livenessArmed = true; + } + _startLivenessWatchdog() { + this._stopLivenessWatchdog(); + this._livenessTimer = setInterval(() => { + try { + this._checkLiveness(); + } catch (error) { + this._secureLog("error", "\u274C Liveness check failed", { + errorType: error?.constructor?.name || "Unknown" + }); + } + }, _EnhancedSecureWebRTCManager.TIMEOUTS.LIVENESS_CHECK_INTERVAL); + this._trackActiveTimer(this._livenessTimer); + } + _stopLivenessWatchdog() { + if (this._livenessTimer) { + clearInterval(this._livenessTimer); + this._activeTimers?.delete(this._livenessTimer); + this._livenessTimer = null; + } + } + /** + * A data channel keeps reporting readyState === 'open' long after the + * underlying path has died (the classic Wi-Fi → LTE switch: nothing closes, + * nothing errors, packets simply stop). Nothing tells us — so we ask. + * + * Two steps, because silence alone is not evidence of death. A backgrounded + * tab has its timers throttled to roughly one tick per minute (frozen + * outright on iOS), so a healthy peer routinely goes quiet. What a healthy + * peer cannot do is fail to ANSWER: inbound message handling is not throttled + * the way timers are. So after a period of silence we send a probe, and only + * an unanswered probe is treated as a dead path. + */ + _checkLiveness() { + if (!this.isVerified) return; + if (this._reconnect.phase !== "idle") return; + if (this.dataChannel?.readyState !== "open") return; + if (!this._lastInboundAt) return; + if (!this._livenessArmed) return; + const T = _EnhancedSecureWebRTCManager.TIMEOUTS; + const now = Date.now(); + const iceHealthy = this.peerConnection?.connectionState === "connected"; + if (iceHealthy) { + this._livenessProbeAt = 0; + return; + } + if (this._livenessProbeAt) { + if (now - this._livenessProbeAt < T.LIVENESS_PROBE_TIMEOUT) return; + this._livenessProbeAt = 0; + this._secureLog("warn", "\u26A0\uFE0F liveness probe unanswered and ICE is not connected \u2014 path presumed dead"); + this._onPathLost("liveness_probe_timeout"); + return; + } + if (now - this._lastInboundAt < T.LIVENESS_PROBE_AFTER) return; + this._livenessProbeAt = now; + const delivered = this._sendHeartbeat(false); + this._secureLog("info", "\u{1F504} peer silent and ICE degraded, probing", { + silentForMs: now - this._lastInboundAt, + connectionState: this.peerConnection?.connectionState, + probeSent: delivered + }); + } + // ============================================ + // SESSION RECOVERY (serverless, in-band) + // ============================================ + // + // What survives an ICE restart and what does not: + // + // ICE restart replaces the candidate pair — i.e. the network path. The + // DTLS handshake, the negotiated keys and the SCTP association that the + // data channel rides on are all layered ABOVE ICE and survive untouched. + // That is why a restart can recover a Wi-Fi → LTE switch without a new + // handshake, without a new SAS, and without losing message history. + // + // The restart SDP travels over that same still-established data channel, + // so it inherits the channel's authentication: an attacker who cannot + // already decrypt the session cannot inject one. No signalling server is + // involved at any point. + // + // The one thing a restart must never do is change peer identity, so the + // DTLS fingerprint in the incoming SDP is checked against the fingerprint + // of the live session before anything is applied. A mismatch is treated + // as an attack and aborts recovery rather than re-keying to a stranger. + // + // What it cannot recover: a closed data channel (SCTP gone) or a path so + // dead that the restart offer itself cannot be delivered. Those fall + // through to _giveUpAutoReconnect and require a fresh, manually exchanged + // handshake — the existing offer/answer flow. + isReconnecting() { + return this._reconnect.phase !== "idle" && this._reconnect.phase !== "exhausted"; + } + /** + * Device-level signals that a path is worth re-checking right now, instead of + * waiting out a backoff: this device regained network, or a mobile browser + * brought the tab back to the foreground (where it may have frozen the + * connection while backgrounded). + */ + _setupRecoveryLifecycleListeners() { + if (typeof window === "undefined" || this._recoveryLifecycleBound) return; + this._recoveryLifecycleBound = true; + this._onDeviceOnline = () => { + if (!this.isVerified) return; + if (this.isReconnecting()) { + this._secureLog("info", "\u{1F504} Device back online \u2014 retrying immediately"); + this._attemptIceRestart(); + } else { + this._checkLiveness(); + } + }; + this._onVisibilityRestored = () => { + if (typeof document === "undefined" || document.visibilityState !== "visible") return; + if (!this.isVerified) return; + this._lastInboundAt = Date.now(); + this._livenessProbeAt = 0; + if (this._reconnect.phase === "idle" && this.dataChannel?.readyState === "open") { + this._livenessProbeAt = Date.now(); + this._sendHeartbeat(false); + this._secureLog("info", "\u{1F504} returned to foreground, probing peer"); + } + }; + window.addEventListener("online", this._onDeviceOnline); + if (typeof document !== "undefined") { + document.addEventListener("visibilitychange", this._onVisibilityRestored); + } + } + _teardownRecoveryLifecycleListeners() { + if (!this._recoveryLifecycleBound || typeof window === "undefined") return; + this._recoveryLifecycleBound = false; + if (this._onDeviceOnline) window.removeEventListener("online", this._onDeviceOnline); + if (this._onVisibilityRestored && typeof document !== "undefined") { + document.removeEventListener("visibilitychange", this._onVisibilityRestored); + } + this._onDeviceOnline = null; + this._onVisibilityRestored = null; + } + _resetReconnectState() { + const r = this._reconnect; + if (!r) return; + if (r.graceTimer) { + clearTimeout(r.graceTimer); + this._activeTimers?.delete(r.graceTimer); + } + if (r.retryTimer) { + clearTimeout(r.retryTimer); + this._activeTimers?.delete(r.retryTimer); + } + if (r.restartTimer) { + clearTimeout(r.restartTimer); + this._activeTimers?.delete(r.restartTimer); + } + r.graceTimer = null; + r.retryTimer = null; + r.restartTimer = null; + r.phase = "idle"; + r.attempts = 0; + r.startedAt = 0; + r.inFlightAt = 0; + r.barrenFailures = 0; + r.pendingRole = null; + } + /** + * ICE reported 'disconnected'. This is usually transient — the browser's own + * consent freshness checks recover it within a couple of seconds — so hold a + * grace window before spending a restart, but tell the UI right away so the + * user sees "reconnecting" rather than a silently stalled chat. + */ + _onPathDegraded(reason = "ice_disconnected") { + if (!this.isVerified) return; + if (this._reconnect.phase !== "idle") return; + this._reconnect.phase = "grace"; + this._reconnect.startedAt = Date.now(); + this._secureLog("info", "\u{1F504} path degraded, holding grace window", { reason }); + this.onStatusChange("reconnecting"); + this._reconnect.graceTimer = setTimeout(() => { + this._reconnect.graceTimer = null; + if (this.peerConnection?.connectionState === "connected") { + this._onPathRecovered(); + return; + } + this._attemptIceRestart(); + }, _EnhancedSecureWebRTCManager.TIMEOUTS.ICE_DISCONNECT_GRACE); + this._trackActiveTimer(this._reconnect.graceTimer); + } + /** ICE failed outright, or the peer went silent — restart without waiting. */ + _onPathLost(reason = "ice_failed") { + if (!this.isVerified) return; + if (this._reconnect.phase === "restarting" || this._reconnect.phase === "exhausted") return; + if (this._reconnect.phase === "idle") { + this._reconnect.startedAt = Date.now(); + this.onStatusChange("reconnecting"); + } + if (this._reconnect.graceTimer) { + clearTimeout(this._reconnect.graceTimer); + this._activeTimers?.delete(this._reconnect.graceTimer); + this._reconnect.graceTimer = null; + } + this._secureLog("info", "\u{1F504} path lost, restarting ICE", { reason }); + this._attemptIceRestart(); + } + /** + * A restart is only worth trying while the ICE agent can still produce + * candidates. After the device changes network, a PeerConnection is often + * left bound to interfaces that no longer exist: every STUN binding and TURN + * allocation times out, gathering yields nothing, and each restart fails with + * zero candidate pairs. restartIce() does not rebind it — only a brand-new + * PeerConnection will, and building one needs a whole new handshake. + * + * Recognising that early matters: retrying it for the full two-minute + * deadline is two minutes of the user watching nothing happen, when the way + * out was available immediately. + */ + _noteIceFailureDiagnostics(diagnostics) { + if (!this.isReconnecting()) return; + if (!diagnostics) return; + if (diagnostics.pairCount > 0) { + this._reconnect.barrenFailures = 0; + return; + } + this._reconnect.barrenFailures = (this._reconnect.barrenFailures || 0) + 1; + if (this._reconnect.barrenFailures < _EnhancedSecureWebRTCManager.LIMITS.MAX_BARREN_ICE_FAILURES) return; + this._secureLog("warn", "\u26A0\uFE0F ICE cannot gather any usable candidate \u2014 this connection is bound to a network that is gone", { + consecutiveBarrenFailures: this._reconnect.barrenFailures + }); + this._giveUpAutoReconnect("ice_agent_unusable"); + } + /** + * Path is back. Same keys, same verification, same history — carry on. + * Returns true if it actually handled a recovery (and therefore already + * emitted 'connected'), so the caller does not emit it twice. + */ + _onPathRecovered() { + const wasRecovering = this.isReconnecting(); + this._resetReconnectState(); + this._lastInboundAt = Date.now(); + this._livenessProbeAt = 0; + if (!wasRecovering) return false; + this._secureLog("info", "\u{1F504} connection recovered, session preserved"); + this.onStatusChange("connected"); + this.processMessageQueue(); + try { + document.dispatchEvent(new CustomEvent("connection-recovered", { + detail: { timestamp: Date.now() } + })); + } catch (_) { + } + return true; + } + /** + * Only the side that created the original offer drives restarts. Both sides + * offering at once produces glare, and with no signalling server there is no + * referee to break the tie — so the answerer asks instead of acting. + */ + async _attemptIceRestart() { + if (!this.isVerified || !this.peerConnection) return; + if (this.peerConnection.connectionState === "connected") { + this._onPathRecovered(); + return; + } + const r = this._reconnect; + if (typeof navigator !== "undefined" && navigator.onLine === false) { + r.phase = "waiting"; + r.startedAt = Date.now(); + this._secureLog("debug", "\u{1F504} Device offline \u2014 holding recovery open"); + this._scheduleReconnectRetry(); + return; + } + const elapsed = Date.now() - (r.startedAt || Date.now()); + if (elapsed > _EnhancedSecureWebRTCManager.TIMEOUTS.RECONNECT_MAX_DURATION) { + this._giveUpAutoReconnect("timeout"); + return; + } + if (r.inFlightAt && Date.now() - r.inFlightAt < _EnhancedSecureWebRTCManager.TIMEOUTS.ICE_RESTART_TIMEOUT) { + this._scheduleReconnectRetry(); + return; + } + if (this.dataChannel?.readyState !== "open") { + this._giveUpAutoReconnect("data_channel_closed"); + return; + } + const silentFor = Date.now() - Math.max(this._lastInboundAt || 0, r.startedAt); + if (r.attempts >= 2 && silentFor > _EnhancedSecureWebRTCManager.TIMEOUTS.RECOVERY_SILENCE_LIMIT) { + this._secureLog("warn", "\u26A0\uFE0F nothing has reached us since the drop \u2014 the channel cannot carry a renegotiation", { + silentForMs: silentFor, + attempts: r.attempts + }); + this._giveUpAutoReconnect("no_signalling_path"); + return; + } + r.phase = "restarting"; + r.attempts += 1; + r.inFlightAt = Date.now(); + this._secureLog("info", "\u{1F504} ICE restart attempt", { + attempt: r.attempts, + role: this.isInitiator ? "offerer" : "answerer" + }); + try { + if (this.isInitiator) { + await this._sendIceRestartOffer(); + } else { + await this.sendSystemMessage({ + type: _EnhancedSecureWebRTCManager.MESSAGE_TYPES.ICE_RESTART_REQUEST, + timestamp: Date.now() + }); + } + } catch (error) { + this._secureLog("warn", "\u26A0\uFE0F ICE restart attempt failed to send", { + errorType: error?.constructor?.name || "Unknown" + }); + } + this._scheduleReconnectRetry(); + } + _scheduleReconnectRetry() { + const r = this._reconnect; + if (r.retryTimer) { + clearTimeout(r.retryTimer); + this._activeTimers?.delete(r.retryTimer); + } + const backoff = _EnhancedSecureWebRTCManager.RECONNECT_BACKOFF; + const delay = backoff[Math.min(Math.max(r.attempts - 1, 0), backoff.length - 1)]; + r.retryTimer = setTimeout(() => { + r.retryTimer = null; + if (this.peerConnection?.connectionState === "connected") { + this._onPathRecovered(); + return; + } + this._attemptIceRestart(); + }, delay); + this._trackActiveTimer(r.retryTimer); + } + async _sendIceRestartOffer() { + const pc = this.peerConnection; + if (!pc) return; + if (pc.signalingState === "have-local-offer") { + try { + await pc.setLocalDescription({ type: "rollback" }); + } catch (_) { + } + } + const offer = await pc.createOffer({ iceRestart: true }); + await pc.setLocalDescription(offer); + await this.waitForIceGathering(_EnhancedSecureWebRTCManager.TIMEOUTS.ICE_RESTART_GATHERING); + await this.sendSystemMessage({ + type: _EnhancedSecureWebRTCManager.MESSAGE_TYPES.ICE_RESTART_OFFER, + sdp: pc.localDescription.sdp, + timestamp: Date.now() + }); + this._secureLog("debug", "\u{1F504} ICE restart offer sent"); + } + /** + * The fingerprint of the live, already-SAS-verified session. Recovery must + * re-point the path at the SAME peer, never re-key to a new one. + */ + _currentRemoteDtlsFingerprint() { + const sdp = this.peerConnection?.currentRemoteDescription?.sdp || this.peerConnection?.remoteDescription?.sdp; + if (!sdp) return null; + try { + return this._extractDTLSFingerprintFromSDP(sdp); + } catch (_) { + return null; + } + } + async _assertSameRemoteIdentity(sdp, context) { + const expected = this._currentRemoteDtlsFingerprint(); + if (!expected) { + throw new Error(`Cannot verify peer identity for ${context}`); + } + const received = this._extractDTLSFingerprintFromSDP(sdp); + await this._validateDTLSFingerprint(received, expected, context); + } + /** Inbound recovery signalling, routed from processMessage. */ + async _handleIceRestartSignal(type, data) { + const T = _EnhancedSecureWebRTCManager.MESSAGE_TYPES; + const pc = this.peerConnection; + if (!pc) return; + this._noteInboundActivity(); + switch (type) { + case T.ICE_RESTART_REQUEST: { + if (!this.isInitiator) return; + if (this._reconnect.phase === "idle") { + this._reconnect.startedAt = Date.now(); + this._reconnect.phase = "restarting"; + this.onStatusChange("reconnecting"); + } + await this._sendIceRestartOffer(); + return; + } + case T.ICE_RESTART_OFFER: { + if (!data.sdp) return; + await this._assertSameRemoteIdentity(data.sdp, "ice_restart_offer"); + if (this._reconnect.phase === "idle") { + this._reconnect.startedAt = Date.now(); + this.onStatusChange("reconnecting"); + } + this._reconnect.phase = "restarting"; + await pc.setRemoteDescription({ type: "offer", sdp: data.sdp }); + const answer = await pc.createAnswer(); + await pc.setLocalDescription(answer); + await this.waitForIceGathering(_EnhancedSecureWebRTCManager.TIMEOUTS.ICE_RESTART_GATHERING); + await this.sendSystemMessage({ + type: T.ICE_RESTART_ANSWER, + sdp: pc.localDescription.sdp, + timestamp: Date.now() + }); + this._secureLog("debug", "\u{1F504} ICE restart answer sent"); + return; + } + case T.ICE_RESTART_ANSWER: { + if (!data.sdp) return; + if (pc.signalingState !== "have-local-offer") { + this._secureLog("warn", "\u26A0\uFE0F Ignoring restart answer in unexpected state", { + signalingState: pc.signalingState + }); + return; + } + await this._assertSameRemoteIdentity(data.sdp, "ice_restart_answer"); + await pc.setRemoteDescription({ type: "answer", sdp: data.sdp }); + this._reconnect.inFlightAt = 0; + this._secureLog("debug", "\u{1F504} ICE restart answer applied"); + return; + } + default: + } + } + /** + * Automatic recovery is out of road, and there is no fallback: with no + * signalling server, a path that cannot carry a renegotiation cannot be + * rebuilt without a fresh, manually exchanged handshake. + * + * So the session ends here rather than lingering half-alive. Everything goes + * with it — keys, queued messages, transcript — which is also the safer + * default: a conversation whose transport is gone should not leave its + * plaintext sitting in a tab the user has stopped watching. + */ + _giveUpAutoReconnect(reason) { + this._resetReconnectState(); + this._reconnect.phase = "exhausted"; + this._teardownRecoveryLifecycleListeners?.(); + this._secureLog("warn", "\u26A0\uFE0F automatic reconnection exhausted \u2014 ending session", { reason }); + if (!this.reconnectionFailedNotificationSent) { + this.reconnectionFailedNotificationSent = true; + this.deliverMessageToUI( + "Could not restore the connection. This chat is being closed and its data wiped \u2014 start a new one to continue.", + "system" + ); + } + this.onStatusChange("recovery_failed"); + this._clearVerificationStates(); } /** * Stop all active timers and cleanup scheduler @@ -16278,9 +16924,8 @@ var EnhancedSecureWebRTCManager = class _EnhancedSecureWebRTCManager { clearInterval(this._maintenanceScheduler); this._maintenanceScheduler = null; } - if (this._heartbeatConfig) { - this._heartbeatConfig.enabled = false; - } + this.stopHeartbeat?.(); + this._resetReconnectState?.(); if (this._activeTimers) { this._activeTimers.forEach((timer) => { if (timer) { @@ -16296,7 +16941,12 @@ var EnhancedSecureWebRTCManager = class _EnhancedSecureWebRTCManager { this._logCleanupInterval = null; this._secureLog("info", "All timers stopped successfully"); } - waitForIceGathering() { + /** + * @param {number} [timeoutMs] - gathering budget. Recovery uses a much shorter + * one than the initial handshake: a restart round-trip must finish well + * inside the retry backoff, or the next attempt cancels the one in flight. + */ + waitForIceGathering(timeoutMs = _EnhancedSecureWebRTCManager.TIMEOUTS.ICE_GATHERING_TIMEOUT) { return new Promise((resolve) => { if (this.peerConnection.iceGatheringState === "complete") { resolve(true); @@ -16314,7 +16964,7 @@ var EnhancedSecureWebRTCManager = class _EnhancedSecureWebRTCManager { this.peerConnection.removeEventListener("icegatheringstatechange", checkState); } resolve(this.peerConnection?.iceGatheringState === "complete"); - }, _EnhancedSecureWebRTCManager.TIMEOUTS.ICE_GATHERING_TIMEOUT); + }, timeoutMs); }); } retryConnection() { @@ -16391,11 +17041,25 @@ var EnhancedSecureWebRTCManager = class _EnhancedSecureWebRTCManager { }); } } + /** + * Manual "try again" from the UI. Restarts the automatic recovery cycle from + * scratch (fresh attempt counter and deadline) as long as the data channel is + * still there to carry the renegotiation. + */ attemptReconnection() { - if (!this.reconnectionFailedNotificationSent) { - this.reconnectionFailedNotificationSent = true; - this.deliverMessageToUI("Unable to reconnect. A new connection is required.", "system"); + if (!this.isVerified || this.dataChannel?.readyState !== "open") { + if (!this.reconnectionFailedNotificationSent) { + this.reconnectionFailedNotificationSent = true; + this.deliverMessageToUI("Unable to reconnect. A new connection is required.", "system"); + } + return false; } + this._resetReconnectState(); + this.reconnectionFailedNotificationSent = false; + this._reconnect.startedAt = Date.now(); + this.onStatusChange("reconnecting"); + this._attemptIceRestart(); + return true; } handlePeerDisconnectNotification(data) { const reason = data.reason || "unknown"; @@ -16448,6 +17112,7 @@ var EnhancedSecureWebRTCManager = class _EnhancedSecureWebRTCManager { this.intentionalDisconnect = true; window.EnhancedSecureCryptoUtils.secureLog.log("info", "Starting intentional disconnect"); this.sendDisconnectNotification(); + this._teardownRecoveryLifecycleListeners?.(); this._stopAllTimers(); this._peerDisconnectCleanupTimer = null; this.stopHeartbeat(); @@ -17024,7 +17689,7 @@ var EnhancedSecureWebRTCManager = class _EnhancedSecureWebRTCManager { _callCanStart() { const connected = typeof this.isConnected === "function" ? this.isConnected() : false; const channelOpen = this.dataChannel && this.dataChannel.readyState === "open"; - const ok = !!(connected && channelOpen && this.isVerified); + const ok = !!(connected && channelOpen && this.isVerified && !this.isReconnecting()); return ok; } async _sendCallSignal(type, data) { @@ -18910,7 +19575,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.5.4") + React.createElement("span", { key: "v", style: { fontFamily: MONO, fontSize: "10px", fontWeight: 500, color: "#56565e" } }, "v5.6.0") ]), React.createElement("div", { key: "r2", className: "hidden sm:block", style: { fontSize: "11px", color: "#6b6b73", fontWeight: 500 } }, "End-to-end encrypted") ]) diff --git a/dist/app-boot.js.map b/dist/app-boot.js.map index d44b86f..75d67ba 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/webrtc/config.js", "../src/network/webrtc/sdp.js", "../src/network/webrtc/audio.js", "../src/network/webrtc/video.js", "../src/network/webrtc/adaptation/metrics.js", "../src/network/webrtc/adaptation/controller.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", "../src/components/ui/CallUI.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