test
All checks were successful
/ upload (release) Successful in 22s

This commit is contained in:
pavel 2026-02-24 22:28:02 +01:00
commit a5af009ba8
3 changed files with 176 additions and 151 deletions

View file

@ -17,8 +17,9 @@ const state = {
rawStream: null,
videoStream: null,
screenStream: null,
audioContext: null,
peerConnections: new Map(),
videoSenders: new Map(),
screenSenders: new Map(),
muted: false,
sharingVideo: false,
sharingScreen: false,
@ -789,37 +790,37 @@ function ensurePeerConnection(peerId) {
return state.voice.peerConnections.get(peerId);
}
const pc = new RTCPeerConnection({ iceServers: state.voice.iceServers });
const pc = new RTCPeerConnection({
iceServers: state.voice.iceServers
});
pc.makingOffer = false;
pc.ignoreOffer = false;
pc.polite = !shouldInitiateOffer(peerId);
// Add transceivers in a fixed order to ensure stable SDP m-lines: [Audio, Camera, Screen]
// 1. Audio
if (state.voice.localStream) {
pc.addTransceiver(state.voice.localStream.getAudioTracks()[0], {
direction: 'sendrecv',
streams: [state.voice.localStream]
state.voice.localStream.getAudioTracks().forEach(track => {
pc.addTrack(track, state.voice.localStream);
});
} else {
pc.addTransceiver('audio', { direction: 'sendrecv' });
pc.addTransceiver('audio', { direction: 'recvonly' });
}
// 2. Camera Video
if (state.voice.videoStream) {
pc.addTransceiver(state.voice.videoStream.getVideoTracks()[0], {
direction: 'sendrecv',
streams: [state.voice.videoStream]
state.voice.videoStream.getVideoTracks().forEach(track => {
const sender = pc.addTrack(track, state.voice.videoStream);
state.voice.videoSenders.set(peerId, sender);
});
} else {
pc.addTransceiver('video', { direction: 'sendrecv' });
}
// 3. Screen Video
if (state.voice.screenStream) {
pc.addTransceiver(state.voice.screenStream.getVideoTracks()[0], {
direction: 'sendrecv',
streams: [state.voice.screenStream]
state.voice.screenStream.getVideoTracks().forEach(track => {
const sender = pc.addTrack(track, state.voice.screenStream);
state.voice.screenSenders.set(peerId, sender);
});
} else {
pc.addTransceiver('video', { direction: 'sendrecv' });
}
pc.onicecandidate = (event) => {
@ -833,6 +834,7 @@ function ensurePeerConnection(peerId) {
};
pc.ontrack = (event) => {
const stream = event.streams[0];
if (event.track.kind === "audio") {
let audio = document.getElementById(`audio-${peerId}`);
if (!audio) {
@ -842,24 +844,35 @@ function ensurePeerConnection(peerId) {
audio.playsInline = true;
document.body.appendChild(audio);
}
audio.srcObject = event.streams[0];
audio.srcObject = stream;
} else if (event.track.kind === "video") {
const peer = state.members.find(m => m.id === peerId) || { display_name: "Unknown" };
// Distinguish screen share from camera by checking track labels or signaling
// For simplicity, we can use the stream index or a dedicated track check.
// A more robust way is to have separate transceivers/signaling.
// Here we check if the label suggests a screen.
const isScreen = event.track.label.toLowerCase().includes('screen') ||
event.track.label.toLowerCase().includes('monitor');
renderVideo(peerId, peer.display_name, event.streams[0], isScreen ? 'screen' : 'camera');
renderVideo(peerId, peer.display_name, stream, event.track.id);
event.track.onmute = () => {
document.getElementById(`video-${peerId}-${event.track.id}`)?.parentElement?.remove();
};
event.track.onended = () => {
document.getElementById(`video-${peerId}-${event.track.id}`)?.parentElement?.remove();
};
}
};
pc.onnegotiationneeded = async () => {
try {
await sendOffer(peerId);
pc.makingOffer = true;
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
state.voice.ws.send(JSON.stringify({
type: "signal",
to_user_id: peerId,
kind: "offer",
data: pc.localDescription,
}));
} catch (err) {
console.error("negotiation failed", err);
} finally {
pc.makingOffer = false;
}
};
@ -867,41 +880,23 @@ function ensurePeerConnection(peerId) {
return pc;
}
async function sendOffer(peerId) {
const pc = ensurePeerConnection(peerId);
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
state.voice.ws.send(JSON.stringify({
type: "signal",
to_user_id: peerId,
kind: "offer",
data: offer,
}));
}
async function handleSignal(fromPeerId, kind, data) {
const pc = ensurePeerConnection(fromPeerId);
const polite = !shouldInitiateOffer(fromPeerId);
try {
if (kind === "offer") {
const offerCollision = (pc.signalingState !== "stable");
if (offerCollision) {
if (!polite) return;
await Promise.all([
pc.setLocalDescription({ type: "rollback" }),
pc.setRemoteDescription(new RTCSessionDescription(data))
]);
} else {
const offerCollision = pc.makingOffer || pc.signalingState !== "stable";
pc.ignoreOffer = !pc.polite && offerCollision;
if (pc.ignoreOffer) return;
await pc.setRemoteDescription(new RTCSessionDescription(data));
}
const answer = await pc.createAnswer();
await pc.setLocalDescription(answer);
state.voice.ws.send(JSON.stringify({
type: "signal",
to_user_id: fromPeerId,
kind: "answer",
data: answer,
data: pc.localDescription,
}));
} else if (kind === "answer") {
await pc.setRemoteDescription(new RTCSessionDescription(data));
@ -909,9 +904,7 @@ async function handleSignal(fromPeerId, kind, data) {
try {
await pc.addIceCandidate(data ? new RTCIceCandidate(data) : null);
} catch (err) {
if (pc.signalingState !== "stable") {
// Ignore ICE candidates during negotiation
} else {
if (!pc.ignoreOffer) {
console.warn("failed to add ice candidate", err);
}
}
@ -952,11 +945,11 @@ async function joinVoice() {
const msg = JSON.parse(event.data);
if (msg.type === "peers") {
for (const peer of msg.peers) {
if (shouldInitiateOffer(peer.user_id)) await sendOffer(peer.user_id);
ensurePeerConnection(peer.user_id);
}
} else if (msg.type === "peer_joined") {
playSound('peer-join');
if (shouldInitiateOffer(msg.user_id)) await sendOffer(msg.user_id);
ensurePeerConnection(msg.user_id);
} else if (msg.type === "peer_left") {
playSound('peer-leave');
const pc = state.voice.peerConnections.get(msg.user_id);
@ -996,6 +989,8 @@ async function joinVoice() {
el.soundboard.classList.add("hidden");
for (const pc of state.voice.peerConnections.values()) pc.close();
state.voice.peerConnections.clear();
state.voice.videoSenders.clear();
state.voice.screenSenders.clear();
stopAndClearAudioPipeline();
state.voice.joinedChannelId = null;
state.voice.ws = null;
@ -1025,11 +1020,11 @@ async function toggleVideo() {
if (state.voice.ws) {
state.voice.ws.send(JSON.stringify({ type: "set_video_status", is_sharing_video: false }));
}
for (const pc of state.voice.peerConnections.values()) {
const transceivers = pc.getTransceivers();
const cameraTransceiver = transceivers.find(t => t.receiver.track.kind === 'video' && t.mid === transceivers[1].mid);
if (cameraTransceiver) {
cameraTransceiver.sender.replaceTrack(null);
for (const [peerId, pc] of state.voice.peerConnections) {
const sender = state.voice.videoSenders.get(peerId);
if (sender) {
pc.removeTrack(sender);
state.voice.videoSenders.delete(peerId);
}
}
} else {
@ -1043,11 +1038,11 @@ async function toggleVideo() {
state.voice.ws.send(JSON.stringify({ type: "set_video_status", is_sharing_video: true }));
}
for (const pc of state.voice.peerConnections.values()) {
const transceivers = pc.getTransceivers();
if (transceivers[1]) {
transceivers[1].sender.replaceTrack(stream.getVideoTracks()[0]);
}
for (const [peerId, pc] of state.voice.peerConnections) {
stream.getVideoTracks().forEach(track => {
const sender = pc.addTrack(track, stream);
state.voice.videoSenders.set(peerId, sender);
});
}
} catch (err) {
console.error("camera denied", err);
@ -1067,15 +1062,16 @@ async function toggleScreenShare() {
if (state.voice.ws) {
state.voice.ws.send(JSON.stringify({ type: "set_screen_status", is_sharing_screen: false }));
}
for (const pc of state.voice.peerConnections.values()) {
const transceivers = pc.getTransceivers();
if (transceivers[2]) {
transceivers[2].sender.replaceTrack(null);
for (const [peerId, pc] of state.voice.peerConnections) {
const sender = state.voice.screenSenders.get(peerId);
if (sender) {
pc.removeTrack(sender);
state.voice.screenSenders.delete(peerId);
}
}
} else {
try {
const stream = await navigator.mediaDevices.getDisplayMedia({ video: true });
const stream = await navigator.mediaDevices.getDisplayMedia({ video: true, audio: true });
state.voice.screenStream = stream;
state.voice.sharingScreen = true;
renderVideo(state.me.id, state.me.display_name, stream, 'screen');
@ -1089,11 +1085,11 @@ async function toggleScreenShare() {
state.voice.ws.send(JSON.stringify({ type: "set_screen_status", is_sharing_screen: true }));
}
for (const pc of state.voice.peerConnections.values()) {
const transceivers = pc.getTransceivers();
if (transceivers[2]) {
transceivers[2].sender.replaceTrack(stream.getVideoTracks()[0]);
}
for (const [peerId, pc] of state.voice.peerConnections) {
stream.getVideoTracks().forEach(track => {
const sender = pc.addTrack(track, stream);
state.voice.screenSenders.set(peerId, sender);
});
}
} catch (err) {
console.error("screen share denied", err);

35
main.js
View file

@ -1,4 +1,4 @@
const { app, BrowserWindow, session } = require('electron');
const { app, BrowserWindow, session, desktopCapturer } = require('electron');
const path = require('path');
const url = require('url');
@ -18,6 +18,39 @@ function createWindow() {
}
});
// Auto-approve media permissions (camera, microphone)
sess.setPermissionCheckHandler((webContents, permission) => {
if (permission === 'media') {
return true;
}
return false;
});
sess.setPermissionRequestHandler((webContents, permission, callback) => {
if (permission === 'media') {
callback(true);
} else {
callback(false);
}
});
// Handle screen share requests
sess.setDisplayMediaRequestHandler((request, callback) => {
desktopCapturer.getSources({ types: ['screen', 'window'] }).then((sources) => {
// Provide the first screen source by default, or implement a picker window here
if (sources && sources.length > 0) {
// We prefer a screen over a window if available, simple heuristic
const screenSource = sources.find(s => s.id.startsWith('screen')) || sources[0];
callback({ video: screenSource, audio: 'loopback' });
} else {
callback(null); // Reject safely
}
}).catch(err => {
console.error("Failed to get desktop sources for screen share", err);
callback(null);
});
});
const backendUrl = (process.env.CHATTZ_URL || 'https://discord.flegr.me').replace(/\/$/, '');
const indexPath = path.join(__dirname, 'desktop', 'index.html');

View file

@ -17,8 +17,9 @@ const state = {
rawStream: null,
videoStream: null,
screenStream: null,
audioContext: null,
peerConnections: new Map(),
videoSenders: new Map(),
screenSenders: new Map(),
muted: false,
sharingVideo: false,
sharingScreen: false,
@ -771,37 +772,37 @@ function ensurePeerConnection(peerId) {
return state.voice.peerConnections.get(peerId);
}
const pc = new RTCPeerConnection({ iceServers: state.voice.iceServers });
const pc = new RTCPeerConnection({
iceServers: state.voice.iceServers
});
pc.makingOffer = false;
pc.ignoreOffer = false;
pc.polite = !shouldInitiateOffer(peerId);
// Add transceivers in a fixed order to ensure stable SDP m-lines: [Audio, Camera, Screen]
// 1. Audio
if (state.voice.localStream) {
pc.addTransceiver(state.voice.localStream.getAudioTracks()[0], {
direction: 'sendrecv',
streams: [state.voice.localStream]
state.voice.localStream.getAudioTracks().forEach(track => {
pc.addTrack(track, state.voice.localStream);
});
} else {
pc.addTransceiver('audio', { direction: 'sendrecv' });
pc.addTransceiver('audio', { direction: 'recvonly' });
}
// 2. Camera Video
if (state.voice.videoStream) {
pc.addTransceiver(state.voice.videoStream.getVideoTracks()[0], {
direction: 'sendrecv',
streams: [state.voice.videoStream]
state.voice.videoStream.getVideoTracks().forEach(track => {
const sender = pc.addTrack(track, state.voice.videoStream);
state.voice.videoSenders.set(peerId, sender);
});
} else {
pc.addTransceiver('video', { direction: 'sendrecv' });
}
// 3. Screen Video
if (state.voice.screenStream) {
pc.addTransceiver(state.voice.screenStream.getVideoTracks()[0], {
direction: 'sendrecv',
streams: [state.voice.screenStream]
state.voice.screenStream.getVideoTracks().forEach(track => {
const sender = pc.addTrack(track, state.voice.screenStream);
state.voice.screenSenders.set(peerId, sender);
});
} else {
pc.addTransceiver('video', { direction: 'sendrecv' });
}
pc.onicecandidate = (event) => {
@ -815,6 +816,7 @@ function ensurePeerConnection(peerId) {
};
pc.ontrack = (event) => {
const stream = event.streams[0];
if (event.track.kind === "audio") {
let audio = document.getElementById(`audio-${peerId}`);
if (!audio) {
@ -824,24 +826,35 @@ function ensurePeerConnection(peerId) {
audio.playsInline = true;
document.body.appendChild(audio);
}
audio.srcObject = event.streams[0];
audio.srcObject = stream;
} else if (event.track.kind === "video") {
const peer = state.members.find(m => m.id === peerId) || { display_name: "Unknown" };
// Distinguish screen share from camera by checking track labels or signaling
// For simplicity, we can use the stream index or a dedicated track check.
// A more robust way is to have separate transceivers/signaling.
// Here we check if the label suggests a screen.
const isScreen = event.track.label.toLowerCase().includes('screen') ||
event.track.label.toLowerCase().includes('monitor');
renderVideo(peerId, peer.display_name, event.streams[0], isScreen ? 'screen' : 'camera');
renderVideo(peerId, peer.display_name, stream, event.track.id);
event.track.onmute = () => {
document.getElementById(`video-${peerId}-${event.track.id}`)?.parentElement?.remove();
};
event.track.onended = () => {
document.getElementById(`video-${peerId}-${event.track.id}`)?.parentElement?.remove();
};
}
};
pc.onnegotiationneeded = async () => {
try {
await sendOffer(peerId);
pc.makingOffer = true;
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
state.voice.ws.send(JSON.stringify({
type: "signal",
to_user_id: peerId,
kind: "offer",
data: pc.localDescription,
}));
} catch (err) {
console.error("negotiation failed", err);
} finally {
pc.makingOffer = false;
}
};
@ -849,41 +862,23 @@ function ensurePeerConnection(peerId) {
return pc;
}
async function sendOffer(peerId) {
const pc = ensurePeerConnection(peerId);
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
state.voice.ws.send(JSON.stringify({
type: "signal",
to_user_id: peerId,
kind: "offer",
data: offer,
}));
}
async function handleSignal(fromPeerId, kind, data) {
const pc = ensurePeerConnection(fromPeerId);
const polite = !shouldInitiateOffer(fromPeerId);
try {
if (kind === "offer") {
const offerCollision = (pc.signalingState !== "stable");
if (offerCollision) {
if (!polite) return;
await Promise.all([
pc.setLocalDescription({ type: "rollback" }),
pc.setRemoteDescription(new RTCSessionDescription(data))
]);
} else {
const offerCollision = pc.makingOffer || pc.signalingState !== "stable";
pc.ignoreOffer = !pc.polite && offerCollision;
if (pc.ignoreOffer) return;
await pc.setRemoteDescription(new RTCSessionDescription(data));
}
const answer = await pc.createAnswer();
await pc.setLocalDescription(answer);
state.voice.ws.send(JSON.stringify({
type: "signal",
to_user_id: fromPeerId,
kind: "answer",
data: answer,
data: pc.localDescription,
}));
} else if (kind === "answer") {
await pc.setRemoteDescription(new RTCSessionDescription(data));
@ -891,9 +886,7 @@ async function handleSignal(fromPeerId, kind, data) {
try {
await pc.addIceCandidate(data ? new RTCIceCandidate(data) : null);
} catch (err) {
if (pc.signalingState !== "stable") {
// Ignore ICE candidates during negotiation
} else {
if (!pc.ignoreOffer) {
console.warn("failed to add ice candidate", err);
}
}
@ -934,11 +927,11 @@ async function joinVoice() {
const msg = JSON.parse(event.data);
if (msg.type === "peers") {
for (const peer of msg.peers) {
if (shouldInitiateOffer(peer.user_id)) await sendOffer(peer.user_id);
ensurePeerConnection(peer.user_id);
}
} else if (msg.type === "peer_joined") {
playSound('peer-join');
if (shouldInitiateOffer(msg.user_id)) await sendOffer(msg.user_id);
ensurePeerConnection(msg.user_id);
} else if (msg.type === "peer_left") {
playSound('peer-leave');
const pc = state.voice.peerConnections.get(msg.user_id);
@ -978,6 +971,8 @@ async function joinVoice() {
el.soundboard.classList.add("hidden");
for (const pc of state.voice.peerConnections.values()) pc.close();
state.voice.peerConnections.clear();
state.voice.videoSenders.clear();
state.voice.screenSenders.clear();
stopAndClearAudioPipeline();
state.voice.joinedChannelId = null;
state.voice.ws = null;
@ -1007,11 +1002,11 @@ async function toggleVideo() {
if (state.voice.ws) {
state.voice.ws.send(JSON.stringify({ type: "set_video_status", is_sharing_video: false }));
}
for (const pc of state.voice.peerConnections.values()) {
const transceivers = pc.getTransceivers();
const cameraTransceiver = transceivers.find(t => t.receiver.track.kind === 'video' && t.mid === transceivers[1].mid);
if (cameraTransceiver) {
cameraTransceiver.sender.replaceTrack(null);
for (const [peerId, pc] of state.voice.peerConnections) {
const sender = state.voice.videoSenders.get(peerId);
if (sender) {
pc.removeTrack(sender);
state.voice.videoSenders.delete(peerId);
}
}
} else {
@ -1025,11 +1020,11 @@ async function toggleVideo() {
state.voice.ws.send(JSON.stringify({ type: "set_video_status", is_sharing_video: true }));
}
for (const pc of state.voice.peerConnections.values()) {
const transceivers = pc.getTransceivers();
if (transceivers[1]) {
transceivers[1].sender.replaceTrack(stream.getVideoTracks()[0]);
}
for (const [peerId, pc] of state.voice.peerConnections) {
stream.getVideoTracks().forEach(track => {
const sender = pc.addTrack(track, stream);
state.voice.videoSenders.set(peerId, sender);
});
}
} catch (err) {
console.error("camera denied", err);
@ -1049,15 +1044,16 @@ async function toggleScreenShare() {
if (state.voice.ws) {
state.voice.ws.send(JSON.stringify({ type: "set_screen_status", is_sharing_screen: false }));
}
for (const pc of state.voice.peerConnections.values()) {
const transceivers = pc.getTransceivers();
if (transceivers[2]) {
transceivers[2].sender.replaceTrack(null);
for (const [peerId, pc] of state.voice.peerConnections) {
const sender = state.voice.screenSenders.get(peerId);
if (sender) {
pc.removeTrack(sender);
state.voice.screenSenders.delete(peerId);
}
}
} else {
try {
const stream = await navigator.mediaDevices.getDisplayMedia({ video: true });
const stream = await navigator.mediaDevices.getDisplayMedia({ video: true, audio: true });
state.voice.screenStream = stream;
state.voice.sharingScreen = true;
renderVideo(state.me.id, state.me.display_name, stream, 'screen');
@ -1071,11 +1067,11 @@ async function toggleScreenShare() {
state.voice.ws.send(JSON.stringify({ type: "set_screen_status", is_sharing_screen: true }));
}
for (const pc of state.voice.peerConnections.values()) {
const transceivers = pc.getTransceivers();
if (transceivers[2]) {
transceivers[2].sender.replaceTrack(stream.getVideoTracks()[0]);
}
for (const [peerId, pc] of state.voice.peerConnections) {
stream.getVideoTracks().forEach(track => {
const sender = pc.addTrack(track, stream);
state.voice.screenSenders.set(peerId, sender);
});
}
} catch (err) {
console.error("screen share denied", err);