mirror of
https://github.com/ancsemi/Haven.git
synced 2026-10-28 09:36:49 +00:00
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);
|
|
}
|
|
})();
|