chore: dev-stack switches, shared e2e harness, and desktop shell rules

- `LIVE_RELOAD=false npm run dev` keeps the renderer alive across a machine
  suspend; the reload client otherwise destroys the session under test.
- `dev-peer.sh` plus a separate userdata dir runs a second local peer.
- `tools/voice-probe.js` samples peer state and RTP counters from a live
  window, persisting to localStorage so a renderer reload cannot erase it.
- e2e helpers for voice pairs, peer-role election, and a TURN relay.
- Electron single-instance and dev-client-load decisions move into rules
  files with colocated specs.
This commit is contained in:
2026-08-14 03:19:29 +02:00
parent d71e3a98da
commit e49b3ec112
41 changed files with 35947 additions and 50 deletions
+392
View File
@@ -0,0 +1,392 @@
/**
* Live voice diagnostic probe - paste into the DevTools console of a running
* dev window (Ctrl+Shift+I). Requires a development build, because it reads the
* Angular debug API (`window.ng`).
*
* Sampling starts on paste. Every 5s it records signaling state, per-peer
* connection/ICE/data-channel state, inbound + outbound audio RTP counters, the
* selected candidate pair, and the peer connection's creation timestamp.
*
* History lives in localStorage, not in the JS heap, so it survives a renderer
* reload and an app restart - both of which happen around a machine suspend.
* After any reload, paste the probe again: it resumes the same history, and the
* reload itself shows up as an event in the report.
*
* Usage:
* (paste) // loads and starts sampling
* __voiceProbe.summary() // compact report, safe to copy into chat
* __voiceProbe.stop()
* __voiceProbe.reset() // discard stored history before a new run
*/
(() => {
const INTERVAL_MS = 5_000;
/** Console heartbeat cadence, in samples, between which only changes are printed. */
const HEARTBEAT_EVERY = 12;
const STORAGE_KEY = 'metoyou_voice_probe_v1';
const MAX_SAMPLES = 1_500;
const HOST_SELECTORS = [
'app-rooms-side-panel',
'app-chat-room',
'app-dm-workspace'
];
/** Distinct per renderer load, so a reload is visible in the stored history. */
const LOAD_ID = Math.round(performance.timeOrigin);
function loadStoredSamples() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
const parsed = raw ? JSON.parse(raw) : [];
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
}
function saveStoredSamples(samples) {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(samples.slice(-MAX_SAMPLES)));
} catch (error) {
console.warn('[voice-probe] could not persist samples', error);
}
}
function findRealtime() {
const debugApi = window.ng;
if (!debugApi || typeof debugApi.getComponent !== 'function')
return null;
for (const selector of HOST_SELECTORS) {
for (const host of Array.from(document.querySelectorAll(selector))) {
let component = null;
try {
component = debugApi.getComponent(host);
} catch {
continue;
}
const realtime = component && component.realtime;
if (realtime && realtime.peerManager && realtime.peerManager.activePeerConnections)
return realtime;
}
}
return null;
}
async function readPeer(peerId, peerData) {
const connection = peerData.connection;
const sample = {
createdAt: peerData.createdAt || 0,
dataChannel: peerData.dataChannel ? peerData.dataChannel.readyState : 'none',
iceState: connection ? connection.iceConnectionState : 'none',
inPackets: 0,
isInitiator: peerData.isInitiator === true,
outPackets: 0,
pair: '?',
peerId: String(peerId).slice(0, 8),
state: connection ? connection.connectionState : 'none'
};
if (!connection)
return sample;
let stats = null;
try {
stats = await connection.getStats();
} catch {
return sample;
}
const candidates = new Map();
let selectedPair = null;
stats.forEach((report) => {
if (report.type === 'local-candidate' || report.type === 'remote-candidate')
candidates.set(report.id, report.candidateType);
if (report.type === 'candidate-pair' && report.state === 'succeeded' && (report.selected || report.nominated))
selectedPair = report;
const kind = report.kind || report.mediaType;
if (report.type === 'inbound-rtp' && kind === 'audio')
sample.inPackets += report.packetsReceived || 0;
if (report.type === 'outbound-rtp' && kind === 'audio')
sample.outPackets += report.packetsSent || 0;
});
if (selectedPair) {
const localType = candidates.get(selectedPair.localCandidateId) || '?';
const remoteType = candidates.get(selectedPair.remoteCandidateId) || '?';
sample.pair = `${localType}/${remoteType}`;
}
return sample;
}
/**
* The local capture state. Without this, "zero RTP" is ambiguous: it looks identical
* whether the call died or the user simply is not in one any more.
*/
function readLocalMedia(realtime) {
let voice = 'unknown';
let mic = 'unknown';
try {
voice = realtime.isVoiceConnected() ? 'in-voice' : 'not-in-voice';
} catch {}
try {
const stream = realtime.getLocalStream();
mic = stream
? stream.getAudioTracks()
.map((track) => `${track.readyState}${track.enabled ? '' : '/disabled'}${track.muted ? '/muted' : ''}`)
.join('+') || 'no-audio-tracks'
: 'no-stream';
} catch {}
return {
mic,
voice
};
}
async function takeSample(realtime) {
const peers = [];
const entries = Array.from(realtime.peerManager.activePeerConnections.entries());
for (const [peerId, peerData] of entries) {
peers.push(await readPeer(peerId, peerData));
}
let signaling = 'unknown';
try {
signaling = realtime.isConnected() ? 'up' : 'DOWN';
} catch {}
const local = readLocalMedia(realtime);
return {
at: Date.now(),
load: LOAD_ID,
mic: local.mic,
peers,
signaling,
voice: local.voice
};
}
let timerId = null;
let lastFingerprint = '';
function start() {
if (timerId !== null) {
console.log('[voice-probe] already running');
return;
}
const realtime = findRealtime();
if (!realtime) {
console.error('[voice-probe] no realtime service found. Open a room/voice view on a dev build first.');
return;
}
const tick = () => {
takeSample(realtime)
.then((sample) => {
const samples = loadStoredSamples();
samples.push(sample);
saveStoredSamples(samples);
const peerText = sample.peers
.map((peer) => `${peer.peerId} ${peer.state} in=${peer.inPackets} out=${peer.outPackets}`)
.join(' | ');
const fingerprint = `${sample.signaling}|${sample.voice}|${sample.mic}|`
+ sample.peers.map((peer) => `${peer.peerId}:${peer.state}:${peer.dataChannel}:${peer.createdAt}`).join(',');
// Every console line is retained by DevTools for the life of the session, and a
// long soak with an open inspector is how the debugger itself ends up using
// gigabytes. Print on change, plus a periodic heartbeat.
const isHeartbeat = samples.length % HEARTBEAT_EVERY === 1;
if (isHeartbeat || fingerprint !== lastFingerprint) {
console.log(
`[voice-probe] #${samples.length} signaling=${sample.signaling} ${sample.voice} mic=${sample.mic}`
+ ` ${peerText || '(no peers)'}`
);
}
lastFingerprint = fingerprint;
})
.catch((error) => console.error('[voice-probe] sample failed', error));
};
tick();
timerId = setInterval(tick, INTERVAL_MS);
}
function stop() {
if (timerId !== null) {
clearInterval(timerId);
timerId = null;
}
console.log(`[voice-probe] stopped with ${loadStoredSamples().length} samples stored`);
}
function reset() {
stop();
try {
localStorage.removeItem(STORAGE_KEY);
} catch {}
console.log('[voice-probe] history cleared');
}
function describeGap(previous, current) {
const gapMs = current.at - previous.at;
if (gapMs < INTERVAL_MS * 2.5)
return null;
return `GAP ${Math.round(gapMs / 1000)}s with no samples`;
}
function peerLines(previous, current) {
const lines = [];
for (const peer of current.peers) {
const before = previous.peers.find((candidate) => candidate.peerId === peer.peerId);
if (!before) {
lines.push(` + peer ${peer.peerId} appeared (state=${peer.state}, initiator=${peer.isInitiator})`);
continue;
}
if (before.createdAt !== peer.createdAt) {
lines.push(
` ! peer ${peer.peerId} REBUILT - connection now created at ${new Date(peer.createdAt).toISOString()}`
+ ` (was ${new Date(before.createdAt).toISOString()})`
);
}
if (before.state !== peer.state)
lines.push(` ~ peer ${peer.peerId} state ${before.state} -> ${peer.state}`);
if (before.dataChannel !== peer.dataChannel)
lines.push(` ~ peer ${peer.peerId} data channel ${before.dataChannel} -> ${peer.dataChannel}`);
if (before.pair !== peer.pair)
lines.push(` ~ peer ${peer.peerId} candidate pair ${before.pair} -> ${peer.pair}`);
// Only a peer that is supposed to be carrying voice can "stall"; outside a call
// zero RTP is the correct reading, not a fault.
if (peer.state === 'connected' && before.state === 'connected' && current.voice === 'in-voice') {
const inDelta = peer.inPackets - before.inPackets;
const outDelta = peer.outPackets - before.outPackets;
if (inDelta <= 0 || outDelta <= 0)
lines.push(` x peer ${peer.peerId} audio stalled (in +${inDelta}, out +${outDelta})`);
}
}
for (const peer of previous.peers) {
if (!current.peers.some((candidate) => candidate.peerId === peer.peerId))
lines.push(` - peer ${peer.peerId} disappeared`);
}
return lines;
}
function describePeers(sample) {
if (sample.peers.length === 0)
return 'none';
return sample.peers
.map((peer) => `${peer.peerId}(${peer.state}, born ${new Date(peer.createdAt).toISOString()})`)
.join(', ');
}
function summary() {
const samples = loadStoredSamples();
if (samples.length === 0)
return '[voice-probe] no samples stored';
const first = samples[0];
const last = samples[samples.length - 1];
const loads = Array.from(new Set(samples.map((sample) => sample.load)));
const lines = [
'=== voice-probe summary ===',
`samples: ${samples.length} over ${Math.round((last.at - first.at) / 1000)}s, renderer loads: ${loads.length}`,
`voice at start: ${first.voice}, mic ${first.mic}`,
`voice at end: ${last.voice}, mic ${last.mic}`,
`peers at start: ${describePeers(first)}`,
`peers at end: ${describePeers(last)}`,
`audio totals end: ${last.peers.map((peer) => `${peer.peerId} in=${peer.inPackets} out=${peer.outPackets}`).join(', ')}`,
'--- events ---'
];
const eventCountBefore = lines.length;
for (let index = 1; index < samples.length; index++) {
const previous = samples[index - 1];
const current = samples[index];
const gap = describeGap(previous, current);
const reloaded = previous.load !== current.load;
// Counters and peer identity are meaningless across a reload: the whole app restarted.
const events = reloaded ? [] : peerLines(previous, current);
if (reloaded)
events.push(' ! RENDERER RELOADED - the app restarted, so peers below are new by definition');
if (previous.mic !== current.mic)
events.unshift(` ~ local mic ${previous.mic} -> ${current.mic}`);
if (previous.voice !== current.voice)
events.unshift(` ~ voice ${previous.voice} -> ${current.voice}`);
if (previous.signaling !== current.signaling)
events.unshift(` ~ signaling ${previous.signaling} -> ${current.signaling}`);
if (!gap && events.length === 0)
continue;
lines.push(`#${index + 1} +${Math.round((current.at - first.at) / 1000)}s${gap ? ` ${gap}` : ''}`);
lines.push(...events);
}
if (lines.length === eventCountBefore)
lines.push(' (no state changes, no rebuilds, no audio stalls, no reloads)');
const text = lines.join('\n');
console.log(text);
return text;
}
window.__voiceProbe = {
reset,
start,
stop,
summary
};
const stored = loadStoredSamples();
console.log(
`[voice-probe] loaded (load id ${LOAD_ID}). ${stored.length} samples already stored.`
+ ` Sampling every ${INTERVAL_MS / 1000}s - call __voiceProbe.summary() when done.`
);
start();
})();