mirror of
https://github.com/ancsemi/Haven.git
synced 2026-11-04 21:12:50 +00:00
leave() called stopScreenShare() and stopWebcam() without awaiting them, so their renegotiations fired at peers that were closed a few lines later, and their continuations ran up to eight seconds after the user had gone. If the user rejoined and started a new share in that window, the stale continuation nulled the new screenStream and flipped isScreenSharing off. Both stop methods now take a teardown option that skips the renegotiation round and releases the capture immediately; leave() uses it. shareScreen, startWebcam, switchCamera and switchInputDevice awaited each peer in turn. Each peer is an independent RTCPeerConnection, so they now run concurrently and the last viewer no longer waits on everyone before them. The stream debug logger kept appending peer state changes after disable(), because its peer and screenSharers patches never checked the flag. It now records nothing while disabled and caps the buffer at 5000 entries while enabled.
325 lines
12 KiB
JavaScript
325 lines
12 KiB
JavaScript
// ═══════════════════════════════════════════════════════════
|
|
// Haven — Stream/Voice Debug Logger
|
|
// ═══════════════════════════════════════════════════════════
|
|
//
|
|
// Attach via: window._streamDebug = new StreamDebugLogger(voiceManager);
|
|
//
|
|
// The logger patches VoiceManager internals non-destructively so it can
|
|
// be toggled on/off at runtime without reloading the page.
|
|
//
|
|
// In the browser console:
|
|
// window._streamDebug.enable() — start recording
|
|
// window._streamDebug.disable() — stop recording
|
|
// window._streamDebug.dump() — print full log to console
|
|
// window._streamDebug.download() — save log as a .txt file
|
|
// window._streamDebug.clear() — wipe the log
|
|
|
|
// Cap on retained entries while enabled. A long call with a few peers can
|
|
// emit thousands of state changes; keep the newest and drop the oldest.
|
|
const STREAM_DEBUG_MAX_ENTRIES = 5000;
|
|
|
|
class StreamDebugLogger {
|
|
constructor(voice) {
|
|
this._voice = voice;
|
|
this._entries = [];
|
|
this._enabled = false;
|
|
this._patchedPeers = new Set(); // peerIds we've already instrumented
|
|
this._origAddPeer = null;
|
|
this._socketWatchers = [];
|
|
|
|
// Make it easy to find in the console
|
|
console.log('[StreamDebug] Logger attached. Call window._streamDebug.enable() to start.');
|
|
}
|
|
|
|
// ── Public API ────────────────────────────────────────────
|
|
|
|
enable() {
|
|
if (this._enabled) { console.log('[StreamDebug] Already enabled.'); return; }
|
|
this._enabled = true;
|
|
this._patchSocketEvents();
|
|
this._patchScreenSharers();
|
|
this._patchExistingPeers();
|
|
this._patchCreatePeer();
|
|
this._log('system', 'Logger ENABLED');
|
|
console.log('[StreamDebug] Enabled. Reproducing the bug will now capture everything. Call .dump() or .download() afterwards.');
|
|
}
|
|
|
|
disable() {
|
|
this._enabled = false;
|
|
this._unpatchSocketEvents();
|
|
this._log('system', 'Logger DISABLED');
|
|
console.log('[StreamDebug] Disabled.');
|
|
}
|
|
|
|
dump() {
|
|
const out = this._format();
|
|
console.log(out);
|
|
return out;
|
|
}
|
|
|
|
download() {
|
|
const text = this._format();
|
|
const blob = new Blob([text], { type: 'text/plain' });
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = `haven-stream-debug-${new Date().toISOString().replace(/[:.]/g, '-')}.txt`;
|
|
a.click();
|
|
setTimeout(() => URL.revokeObjectURL(url), 5000);
|
|
console.log('[StreamDebug] Log downloaded.');
|
|
}
|
|
|
|
clear() {
|
|
this._entries = [];
|
|
this._patchedPeers.clear();
|
|
console.log('[StreamDebug] Log cleared.');
|
|
}
|
|
|
|
// ── Internal helpers ──────────────────────────────────────
|
|
|
|
_ts() {
|
|
return new Date().toISOString().slice(11, 23); // HH:MM:SS.mmm
|
|
}
|
|
|
|
_log(category, msg, extra) {
|
|
// The peer and screenSharers patches stay installed after disable(), so
|
|
// without this check they kept appending every state change to _entries
|
|
// for the rest of the session. Nothing is recorded while disabled, and
|
|
// the buffer is bounded while enabled. (#5426)
|
|
if (!this._enabled && category !== 'system') return;
|
|
const entry = { ts: this._ts(), cat: category, msg };
|
|
if (extra !== undefined) entry.extra = extra;
|
|
this._entries.push(entry);
|
|
if (this._entries.length > STREAM_DEBUG_MAX_ENTRIES) {
|
|
this._entries.splice(0, this._entries.length - STREAM_DEBUG_MAX_ENTRIES);
|
|
}
|
|
if (this._enabled) {
|
|
const extraStr = extra !== undefined ? ' ' + JSON.stringify(extra) : '';
|
|
console.debug(`[StreamDebug][${entry.ts}][${category}] ${msg}${extraStr}`);
|
|
}
|
|
}
|
|
|
|
_format() {
|
|
const lines = [
|
|
'═══════════════════════════════════════════════',
|
|
' Haven Stream Debug Log',
|
|
` Generated: ${new Date().toISOString()}`,
|
|
` Local user: ${this._voice.localUserId ?? '?'}`,
|
|
` Current channel: ${this._voice.currentChannel ?? '?'}`,
|
|
` inVoice: ${this._voice.inVoice}`,
|
|
` isScreenSharing: ${this._voice.isScreenSharing}`,
|
|
` screenSharers: [${[...this._voice.screenSharers].join(', ')}]`,
|
|
` peers: [${[...this._voice.peers.keys()].join(', ')}]`,
|
|
'═══════════════════════════════════════════════',
|
|
'',
|
|
];
|
|
for (const e of this._entries) {
|
|
const extra = e.extra !== undefined ? ' ' + JSON.stringify(e.extra) : '';
|
|
lines.push(`[${e.ts}][${e.cat.padEnd(12)}] ${e.msg}${extra}`);
|
|
}
|
|
lines.push('');
|
|
lines.push('═══════════════════════════════════════════════');
|
|
return lines.join('\n');
|
|
}
|
|
|
|
// ── Socket event patching ─────────────────────────────────
|
|
|
|
_patchSocketEvents() {
|
|
const WATCH = [
|
|
'screen-share-started', 'screen-share-stopped',
|
|
'renegotiate-screen', 'renegotiate-webcam',
|
|
'active-screen-sharers', 'active-webcam-users',
|
|
'voice-offer', 'voice-answer', 'voice-ice-candidate',
|
|
'voice-user-joined', 'voice-user-left', 'voice-existing-users',
|
|
'stream-viewers-update',
|
|
];
|
|
|
|
const sock = this._voice.socket;
|
|
const originalOn = sock.constructor.prototype.on;
|
|
const originalEmit = sock.constructor.prototype.emit;
|
|
const self = this;
|
|
|
|
WATCH.forEach(event => {
|
|
const handler = (data) => {
|
|
if (!self._enabled) return;
|
|
self._log('socket-in', `← ${event}`, self._summarize(data));
|
|
};
|
|
sock.on(event, handler);
|
|
this._socketWatchers.push({ event, handler });
|
|
});
|
|
|
|
// Patch outgoing emit for the events we care about
|
|
const _origEmit = sock.emit.bind(sock);
|
|
this._origEmit = _origEmit;
|
|
sock.emit = function(event, ...args) {
|
|
if (self._enabled && WATCH.includes(event)) {
|
|
self._log('socket-out', `→ ${event}`, self._summarize(args[0]));
|
|
}
|
|
return _origEmit(event, ...args);
|
|
};
|
|
}
|
|
|
|
_unpatchSocketEvents() {
|
|
const sock = this._voice.socket;
|
|
this._socketWatchers.forEach(({ event, handler }) => {
|
|
sock.off(event, handler);
|
|
});
|
|
this._socketWatchers = [];
|
|
if (this._origEmit) {
|
|
sock.emit = this._origEmit;
|
|
this._origEmit = null;
|
|
}
|
|
}
|
|
|
|
// ── screenSharers Set patching ────────────────────────────
|
|
|
|
_patchScreenSharers() {
|
|
const self = this;
|
|
const orig = this._voice.screenSharers;
|
|
const proxy = new Proxy(orig, {
|
|
get(target, prop) {
|
|
const val = Reflect.get(target, prop);
|
|
if (prop === 'add') {
|
|
return (id) => {
|
|
self._log('sharers', `screenSharers.add(${id})`);
|
|
return Reflect.apply(val, target, [id]);
|
|
};
|
|
}
|
|
if (prop === 'delete') {
|
|
return (id) => {
|
|
self._log('sharers', `screenSharers.delete(${id})`);
|
|
return Reflect.apply(val, target, [id]);
|
|
};
|
|
}
|
|
if (prop === 'clear') {
|
|
return () => {
|
|
self._log('sharers', 'screenSharers.clear()');
|
|
return Reflect.apply(val, target, []);
|
|
};
|
|
}
|
|
if (typeof val === 'function') return val.bind(target);
|
|
return val;
|
|
}
|
|
});
|
|
this._voice.screenSharers = proxy;
|
|
}
|
|
|
|
// ── Peer connection instrumentation ──────────────────────
|
|
|
|
_patchExistingPeers() {
|
|
for (const [userId, peer] of this._voice.peers.entries()) {
|
|
this._instrumentPeer(userId, peer.connection);
|
|
}
|
|
}
|
|
|
|
_patchCreatePeer() {
|
|
// Wrap _createPeer so every new connection gets logged
|
|
const self = this;
|
|
const voice = this._voice;
|
|
const origCreate = voice._createPeer.bind(voice);
|
|
voice._createPeer = async function(userId, createOffer) {
|
|
self._log('peer', `_createPeer(${userId}, createOffer=${createOffer})`);
|
|
const result = await origCreate(userId, createOffer);
|
|
// After creation, instrument the new peer
|
|
const peer = voice.peers.get(userId);
|
|
if (peer) self._instrumentPeer(userId, peer.connection);
|
|
return result;
|
|
};
|
|
this._origCreatePeer = origCreate;
|
|
}
|
|
|
|
_instrumentPeer(userId, conn) {
|
|
if (this._patchedPeers.has(userId)) return;
|
|
this._patchedPeers.add(userId);
|
|
const self = this;
|
|
|
|
// Connection state
|
|
const origConnChange = conn.onconnectionstatechange;
|
|
conn.addEventListener('connectionstatechange', () => {
|
|
self._log('peer', `[${userId}] connectionState → ${conn.connectionState}`);
|
|
});
|
|
|
|
// ICE state
|
|
conn.addEventListener('icegatheringstatechange', () => {
|
|
self._log('peer', `[${userId}] iceGatheringState → ${conn.iceGatheringState}`);
|
|
});
|
|
conn.addEventListener('iceconnectionstatechange', () => {
|
|
self._log('peer', `[${userId}] iceConnectionState → ${conn.iceConnectionState}`);
|
|
});
|
|
conn.addEventListener('signalingstatechange', () => {
|
|
self._log('peer', `[${userId}] signalingState → ${conn.signalingState}`);
|
|
});
|
|
|
|
// Track events — the critical part for diagnosing stream issues
|
|
conn.addEventListener('track', (event) => {
|
|
const track = event.track;
|
|
const src = event.streams?.[0];
|
|
const settings = track.kind === 'video' && track.getSettings ? track.getSettings() : {};
|
|
const peerIsSharing = self._voice.screenSharers.has(userId);
|
|
const streamHasVideo = src && src.getVideoTracks().length > 0;
|
|
const info = {
|
|
kind: track.kind,
|
|
id: track.id.slice(0, 8),
|
|
streamId: src?.id?.slice(0, 8) ?? 'none',
|
|
muted: track.muted,
|
|
readyState: track.readyState,
|
|
displaySurface: settings.displaySurface ?? 'n/a',
|
|
peerIsSharing,
|
|
streamHasVideo,
|
|
};
|
|
self._log('track', `[${userId}] ontrack — ${track.kind} track received`, info);
|
|
|
|
// Also log when the track unmutes / ends
|
|
track.addEventListener('unmute', () => {
|
|
self._log('track', `[${userId}] track UNMUTED kind=${track.kind} id=${track.id.slice(0,8)}`);
|
|
});
|
|
track.addEventListener('ended', () => {
|
|
self._log('track', `[${userId}] track ENDED kind=${track.kind} id=${track.id.slice(0,8)}`);
|
|
});
|
|
track.addEventListener('mute', () => {
|
|
self._log('track', `[${userId}] track MUTED kind=${track.kind} id=${track.id.slice(0,8)}`);
|
|
});
|
|
});
|
|
|
|
this._log('peer', `[${userId}] instrumented (state=${conn.connectionState}, ice=${conn.iceConnectionState})`);
|
|
}
|
|
|
|
// ── Summarize data for logging ────────────────────────────
|
|
|
|
_summarize(obj) {
|
|
if (!obj || typeof obj !== 'object') return obj;
|
|
const safe = {};
|
|
for (const [k, v] of Object.entries(obj)) {
|
|
if (k === 'offer' || k === 'answer') {
|
|
safe[k] = `[SDP ${v?.type ?? '?'}]`;
|
|
} else if (k === 'candidate' && v) {
|
|
safe[k] = `[ICE ${v.candidate?.slice(0, 40) ?? '?'}...]`;
|
|
} else {
|
|
safe[k] = v;
|
|
}
|
|
}
|
|
return safe;
|
|
}
|
|
}
|
|
|
|
// Auto-attach once voice manager is available
|
|
(function autoAttach() {
|
|
const tryAttach = () => {
|
|
// VoiceManager is typically stored on window.app.voice or window._voice
|
|
const voice = window.app?.voice || window._voice || window.voiceManager;
|
|
if (voice) {
|
|
window._streamDebug = new StreamDebugLogger(voice);
|
|
return true;
|
|
}
|
|
return false;
|
|
};
|
|
|
|
if (!tryAttach()) {
|
|
// Poll briefly until app initializes
|
|
let tries = 0;
|
|
const interval = setInterval(() => {
|
|
if (tryAttach() || ++tries > 60) clearInterval(interval);
|
|
}, 500);
|
|
}
|
|
})();
|