feat(webrtc): recover a dropped connection without a signalling server; release v5.6.0
CodeQL Analysis / Analyze CodeQL (push) Canceled after 0s
Deploy Application / deploy (push) Canceled after 0s
Mirror to Codeberg / mirror (push) Canceled after 0s
Mirror to PrivacyGuides / mirror (push) Canceled after 0s

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.
This commit is contained in:
lockbitchat
2026-08-02 21:16:13 -04:00
parent 60bf037ef9
commit 2a7142c722
18 changed files with 2794 additions and 224 deletions
+701 -36
View File
@@ -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")
])
+2 -2
View File
File diff suppressed because one or more lines are too long
Vendored
+97 -38
View File
@@ -201,6 +201,8 @@ function statusSub(status) {
case "connecting":
case "new":
return "Connecting\u2026";
case "reconnecting":
return "Reconnecting\u2026";
case "peer_disconnected":
return "Peer disconnected";
default:
@@ -290,7 +292,7 @@ function sessionsReducer(state, action) {
case A.SET_STATUS: {
const session = state.sessions[action.id];
if (!session || session.status === action.status) return state;
const connected = action.status === "connected" || action.status === "verified";
const connected = action.status === "connected" || action.status === "verified" || action.status === "reconnecting";
const patch = !connected && session.peerPresence !== null ? { status: action.status, peerPresence: null } : { status: action.status };
return patchSession(state, action.id, patch);
}
@@ -403,7 +405,7 @@ function decorateSession(session, activeSessionId) {
const lastMessage = [...session.messages].reverse().find((m) => !m.expired && (typeof m.message === "string" && m.message.trim() || m.voice));
const s = session.status;
const isUp = s === "connected" || s === "verified";
const isPending = s === "connecting" || s === "verifying" || s === "new";
const isPending = s === "connecting" || s === "verifying" || s === "new" || s === "reconnecting";
let dot, headerSub;
if (isPending) {
dot = "#e3b341";
@@ -2198,7 +2200,7 @@ var SecureBitChatHeader = ({ status, onDisconnect, webrtcManager, title, isOffli
React.createElement("span", { key: "n", style: { fontSize: "15px", fontWeight: 800, letterSpacing: "-0.3px", color: "#f4f4f6", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" } }, title || "Secure chat"),
React.createElement("button", { key: "edit", className: "sb-rename-btn", onClick: startRename, title: "Rename chat (local only)", style: { flex: "none", width: "24px", height: "24px", borderRadius: "7px", display: "grid", placeItems: "center", border: "none", background: "transparent", color: "#56565e", cursor: "pointer" } }, React.createElement("i", { className: "fas fa-pen", style: { fontSize: "11px" } }))
]),
React.createElement("div", { key: "r2", className: "sb-hdr-sub", style: { fontSize: "11px", color: "#6b6b73", fontWeight: 500, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" } }, isOffline ? "No network \xB7 reconnecting" : peerPresenceWord || (onlineConnected ? "P2P \xB7 end-to-end encrypted" : status === "peer_disconnected" ? "Peer disconnected" : status === "disconnected" ? "Disconnected" : "Connecting\u2026"))
React.createElement("div", { key: "r2", className: "sb-hdr-sub", style: { fontSize: "11px", color: "#6b6b73", fontWeight: 500, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" } }, isOffline ? "No network \xB7 reconnecting" : status === "reconnecting" ? "Restoring connection\u2026" : peerPresenceWord || (onlineConnected ? "P2P \xB7 end-to-end encrypted" : status === "peer_disconnected" ? "Peer disconnected" : status === "disconnected" ? "Disconnected" : "Connecting\u2026"))
])
]),
secBtn,
@@ -2915,6 +2917,7 @@ var EnhancedSecureP2PChat = () => {
const managersRef = React.useRef(/* @__PURE__ */ new Map());
const integrationsRef = React.useRef(/* @__PURE__ */ new Map());
const queuesRef = React.useRef(/* @__PURE__ */ new Map());
const statusRef = React.useRef(/* @__PURE__ */ new Map());
const dispatchActive = React.useCallback((build) => {
const id = activeIdRef.current;
if (!id) return;
@@ -3034,11 +3037,17 @@ var EnhancedSecureP2PChat = () => {
React.useEffect(() => {
const goOffline = () => setIsOffline(true);
const goOnline = () => setIsOffline(false);
const resync = () => {
if (document.visibilityState !== "visible") return;
setIsOffline(navigator.onLine === false);
};
window.addEventListener("offline", goOffline);
window.addEventListener("online", goOnline);
document.addEventListener("visibilitychange", resync);
return () => {
window.removeEventListener("offline", goOffline);
window.removeEventListener("online", goOnline);
document.removeEventListener("visibilitychange", resync);
};
}, []);
React.useEffect(() => {
@@ -3217,43 +3226,68 @@ var EnhancedSecureP2PChat = () => {
if (!fileId) return;
setMessages((prev) => prev.map((m) => m.fileId && String(m.fileId) === String(fileId) ? { ...m, ...typeof patch === "function" ? patch(m) : patch } : m));
}, []);
const flushOfflineQueues = React.useCallback(() => {
for (const [id, q] of queuesRef.current.entries()) {
const mgr = managersRef.current.get(id);
const out = q.outgoing;
q.outgoing = [];
for (const item of out) {
const send = mgr?.sendMessage?.(item.outText, item.meta);
const flushSessionQueue = React.useCallback((id) => {
const q = queuesRef.current.get(id);
if (!q) return;
const mgr = managersRef.current.get(id);
const out = q.outgoing;
q.outgoing = [];
const deferred = [];
for (const item of out) {
if (!mgr || mgr.isConnected?.() !== true) {
deferred.push(item);
continue;
}
try {
const send = mgr.sendMessage?.(item.outText, item.meta);
if (send && typeof send.then === "function") {
send.then(() => dispatch({ type: SESSION_ACTIONS.UPDATE_MESSAGE_STATUS, id, mid: item.mid, status: "delivered" })).catch(() => dispatch({ type: SESSION_ACTIONS.UPDATE_MESSAGE_STATUS, id, mid: item.mid, status: "failed" }));
}
} catch (_) {
deferred.push(item);
}
const inc = q.incoming;
q.incoming = [];
if (inc.length > 0) {
dispatch({ type: SESSION_ACTIONS.ADD_MESSAGE, id, message: buildSessionMessage(
`Connection restored \u2014 ${inc.length} message${inc.length === 1 ? "" : "s"} received while you were offline.`,
"notice"
) });
}
const viewing = id === activeIdRef.current && (typeof document === "undefined" || document.visibilityState === "visible");
for (const item of inc) {
dispatch({ type: SESSION_ACTIONS.ADD_MESSAGE, id, message: buildSessionMessage(item.message, item.type, item.opts) });
if (item.opts && item.opts.mid && item.type === "received") {
if (viewing) {
try {
mgr?.sendDeliveryReceipt?.(item.opts.mid);
} catch (_) {
}
} else if (q.pendingReadAcks) q.pendingReadAcks.push(item.opts.mid);
}
}
if (deferred.length) q.outgoing = deferred.concat(q.outgoing);
const inc = q.incoming;
q.incoming = [];
if (inc.length > 0) {
dispatch({ type: SESSION_ACTIONS.ADD_MESSAGE, id, message: buildSessionMessage(
`Connection restored \u2014 ${inc.length} message${inc.length === 1 ? "" : "s"} received while you were offline.`,
"notice"
) });
}
const viewing = id === activeIdRef.current && (typeof document === "undefined" || document.visibilityState === "visible");
for (const item of inc) {
dispatch({ type: SESSION_ACTIONS.ADD_MESSAGE, id, message: buildSessionMessage(item.message, item.type, item.opts) });
if (item.opts && item.opts.mid && item.type === "received") {
if (viewing) {
try {
mgr?.sendDeliveryReceipt?.(item.opts.mid);
} catch (_) {
}
} else if (q.pendingReadAcks) q.pendingReadAcks.push(item.opts.mid);
}
}
}, []);
const flushOfflineQueues = React.useCallback(() => {
for (const id of queuesRef.current.keys()) flushSessionQueue(id);
}, [flushSessionQueue]);
React.useEffect(() => {
if (isOffline) return;
flushOfflineQueues();
}, [isOffline, flushOfflineQueues]);
React.useEffect(() => {
const timer = setInterval(() => {
for (const [id, q] of queuesRef.current.entries()) {
if (!q.outgoing.length && !q.incoming.length) continue;
const mgr = managersRef.current.get(id);
if (mgr?.isConnected?.() !== true) continue;
if (mgr?.isReconnecting?.() === true) continue;
flushSessionQueue(id);
}
}, 2e3);
return () => clearInterval(timer);
}, [flushSessionQueue]);
const updateSecurityLevel = React.useCallback(async () => {
if (window.isUpdatingSecurity) {
return;
@@ -3458,7 +3492,22 @@ var EnhancedSecureP2PChat = () => {
}
};
const handleStatusChange = (status) => {
const prevStatus = statusRef.current.get(id);
statusRef.current.set(id, status);
setConnectionStatus2(status);
if (status === "reconnecting") return;
if (status === "connected" && prevStatus === "reconnecting") {
flushSessionQueue(id);
}
if (status === "recovery_failed") {
setConnectionStatus2("disconnected");
if (id === activeIdRef.current) {
document.dispatchEvent(new CustomEvent("peer-disconnect"));
document.dispatchEvent(new CustomEvent("disconnected"));
}
setTimeout(() => destroySession(id), 2500);
return;
}
if (status === "connected") {
document.dispatchEvent(new CustomEvent("new-connection"));
if (!window.isUpdatingSecurity) {
@@ -3591,7 +3640,7 @@ var EnhancedSecureP2PChat = () => {
} catch (error) {
}
}
handleMessage(" SecureBit.chat Enhanced Security Edition v5.5.4 - ECDH + DTLS + SAS initialized. Ready to establish a secure connection with ECDH key exchange, DTLS fingerprint verification, and SAS authentication to prevent MITM attacks.", "system");
handleMessage(" SecureBit.chat Enhanced Security Edition v5.6.0 - ECDH + DTLS + SAS initialized. Ready to establish a secure connection with ECDH key exchange, DTLS fingerprint verification, and SAS authentication to prevent MITM attacks.", "system");
manager.setFileTransferCallbacks(
// Progress callback — drives the voice-note upload/download ring.
(progress) => {
@@ -3705,6 +3754,7 @@ var EnhancedSecureP2PChat = () => {
integrationsRef.current.delete(id);
}
queuesRef.current.delete(id);
statusRef.current.delete(id);
dispatch({ type: SESSION_ACTIONS.REMOVE_SESSION, id });
} finally {
destroyingRef.current.delete(id);
@@ -3830,6 +3880,7 @@ var EnhancedSecureP2PChat = () => {
}
integrationsRef.current.clear();
queuesRef.current.clear();
statusRef.current.clear();
};
}, []);
const compressOfferData = (offerData2) => {
@@ -4923,8 +4974,9 @@ var EnhancedSecureP2PChat = () => {
}
const baseTextEarly = messageInput.trim();
const midEarly = `m_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
const offlineNow = isOffline || typeof navigator !== "undefined" && navigator.onLine === false || window.pwaOfflineManager && window.pwaOfflineManager.isOnline === false;
if (offlineNow) {
const mgr = webrtcManagerRef.current;
const channelUsable = mgr?.isConnected?.() === true && mgr?.isReconnecting?.() !== true;
if (!channelUsable && mgr?.isConnected) {
const outTextOff = codeMode ? "```\n" + baseTextEarly + "\n```" : baseTextEarly;
const tsOff = Date.now();
const metaOff = { mid: midEarly, ts: tsOff };
@@ -4943,7 +4995,8 @@ var EnhancedSecureP2PChat = () => {
if (viewOnceMode) setViewOnceMode(false);
return;
}
if (!webrtcManagerRef.current.isConnected()) {
if (!channelUsable) {
addMessageWithAutoScroll("Not sent \u2014 the secure channel is not ready. Reconnect to continue.", "system");
return;
}
try {
@@ -4990,8 +5043,8 @@ var EnhancedSecureP2PChat = () => {
localUrl = URL.createObjectURL(blob);
} catch (_) {
}
const offlineNow = isOffline || typeof navigator !== "undefined" && navigator.onLine === false || window.pwaOfflineManager && window.pwaOfflineManager.isOnline === false;
const notReady = offlineNow || !webrtcManagerRef.current.isConnected || !webrtcManagerRef.current.isConnected();
const reconnecting = webrtcManagerRef.current?.isReconnecting?.() === true;
const notReady = reconnecting || webrtcManagerRef.current?.isConnected?.() !== true;
if (notReady) {
if (localUrl) {
try {
@@ -4999,7 +5052,10 @@ var EnhancedSecureP2PChat = () => {
} catch (_) {
}
}
addMessageWithAutoScroll("Voice message needs an active secure connection. Reconnect and try again.", "system");
addMessageWithAutoScroll(
reconnecting ? "Restoring the connection \u2014 try sending the voice message again in a moment." : "Voice message needs an active secure connection. Reconnect and try again.",
"system"
);
return;
}
const voiceMeta = {
@@ -5101,12 +5157,15 @@ var EnhancedSecureP2PChat = () => {
}
addMessageWithAutoScroll(message, "system");
};
const prevConnStatusRef = React.useRef(connectionStatus);
React.useEffect(() => {
if (connectionStatus === "connected" && isVerified) {
const resumed = prevConnStatusRef.current === "reconnecting";
prevConnStatusRef.current = connectionStatus;
if (connectionStatus === "connected" && isVerified && !resumed) {
addMessageWithAutoScroll(" Secure connection successfully established and verified! You can now communicate safely with full protection against MITM attacks and Perfect Forward Secrecy..", "system");
}
}, [connectionStatus, isVerified]);
const isConnectedAndVerified = (connectionStatus === "connected" || connectionStatus === "verified") && isVerified;
const isConnectedAndVerified = (connectionStatus === "connected" || connectionStatus === "verified" || connectionStatus === "reconnecting") && isVerified;
React.useEffect(() => {
document.body.classList.toggle("sb-in-chat", isConnectedAndVerified);
return () => document.body.classList.remove("sb-in-chat");
+2 -2
View File
File diff suppressed because one or more lines are too long