Compare commits

...

6 commits

Author SHA1 Message Date
39eb7c77ad fix: volume
All checks were successful
/ upload (release) Successful in 6m5s
2026-03-27 22:41:54 +01:00
9d7f658fc0 periodic update check
All checks were successful
/ upload (release) Successful in 3m39s
2026-02-28 15:15:42 +01:00
c9592a9cb0 fix updater
All checks were successful
/ upload (release) Successful in 3m41s
2026-02-28 15:05:09 +01:00
76097f3294 fix crash
All checks were successful
/ upload (release) Successful in 3m45s
2026-02-28 14:46:38 +01:00
6dbd5e77c4 fix desktop backend url
All checks were successful
/ upload (release) Successful in 3m40s
2026-02-28 13:44:57 +01:00
afbaa8f7d1 fix volume slider 2026-02-28 13:43:03 +01:00
2 changed files with 213 additions and 35 deletions

102
main.js
View file

@ -1,9 +1,11 @@
const { app, BrowserWindow, session, desktopCapturer, ipcMain, clipboard } = require('electron'); const { app, BrowserWindow, session, desktopCapturer, ipcMain, clipboard } = require('electron');
const { autoUpdater } = require('electron-updater'); const { autoUpdater } = require('electron-updater');
const path = require('path'); const path = require('path');
const packageJson = require('./package.json');
const PERIODIC_UPDATE_CHECK_INTERVAL_MS = 1 * 60 * 1000;
const updateState = { const updateState = {
status: 'idle', // idle | checking | available | downloading | downloaded | not-available | error status: 'idle', // idle | checking | available | downloading | downloaded | installing | not-available | error
info: null, info: null,
error: null, error: null,
}; };
@ -23,8 +25,37 @@ function isNewerVersionAvailable(info) {
} }
let updateCheckInProgress = false; let updateCheckInProgress = false;
let installInProgress = false;
let periodicUpdateTimer = null;
function resolveBackendUrl() {
const configuredUrl = (process.env.CHATTZ_URL || process.env.APP_BASE_URL || '').trim();
if (configuredUrl) {
return configuredUrl.replace(/\/$/, '');
}
const packagedFallback = typeof packageJson.homepage === 'string'
? packageJson.homepage.trim()
: '';
if (app.isPackaged && packagedFallback) {
return packagedFallback.replace(/\/$/, '');
}
return 'http://localhost:3000';
}
function webContentsOrigin(webContents) {
if (!webContents || typeof webContents.getURL !== 'function') {
return null;
}
return safeOrigin(webContents.getURL());
}
async function runUpdateCheck(reason = 'manual') { async function runUpdateCheck(reason = 'manual') {
if (installInProgress) {
console.log(`Update check skipped (${reason}): install already in progress`);
return;
}
if (updateCheckInProgress) { if (updateCheckInProgress) {
console.log(`Update check skipped (${reason}): another check is already in progress`); console.log(`Update check skipped (${reason}): another check is already in progress`);
return; return;
@ -54,10 +85,62 @@ async function runUpdateCheck(reason = 'manual') {
} }
} }
function installDownloadedUpdate() {
if (installInProgress) {
console.log('Update install already in progress');
return;
}
installInProgress = true;
updateState.status = 'installing';
updateState.error = null;
broadcastUpdateState();
const wins = BrowserWindow.getAllWindows();
for (const win of wins) {
if (win.isDestroyed()) continue;
try {
win.removeAllListeners('close');
win.destroy();
} catch (err) {
console.warn('Failed to destroy window before update install', err);
}
}
setImmediate(() => {
try {
autoUpdater.quitAndInstall(false, true);
} catch (err) {
installInProgress = false;
updateState.status = 'error';
updateState.error = err && err.message ? err.message : String(err);
broadcastUpdateState();
console.error('quitAndInstall failed', err);
}
});
setTimeout(() => {
if (installInProgress) {
console.warn('Update install is still waiting for app shutdown');
}
}, 15000);
}
function startPeriodicUpdateChecks() {
if (periodicUpdateTimer || !app.isPackaged) {
return;
}
periodicUpdateTimer = setInterval(() => {
void runUpdateCheck('periodic');
}, PERIODIC_UPDATE_CHECK_INTERVAL_MS);
}
function createWindow() { function createWindow() {
const sess = session.fromPartition('persist:chattz'); const sess = session.fromPartition('persist:chattz');
const backendUrl = (process.env.CHATTZ_URL || process.env.APP_BASE_URL || 'http://localhost:3000').replace(/\/$/, ''); const backendUrl = resolveBackendUrl();
const backendOrigin = new URL(backendUrl).origin; const backendOrigin = new URL(backendUrl).origin;
console.log(`Desktop backend URL: ${backendUrl}`);
const win = new BrowserWindow({ const win = new BrowserWindow({
width: 1200, width: 1200,
@ -75,13 +158,13 @@ function createWindow() {
win.setMenuBarVisibility(true); win.setMenuBarVisibility(true);
sess.setPermissionCheckHandler((webContents, permission) => { sess.setPermissionCheckHandler((webContents, permission) => {
const origin = safeOrigin(webContents.getURL()); const origin = webContentsOrigin(webContents);
if (origin !== backendOrigin) return false; if (origin !== backendOrigin) return false;
return permission === 'media' || permission === 'clipboard-write'; return permission === 'media' || permission === 'clipboard-write';
}); });
sess.setPermissionRequestHandler((webContents, permission, callback) => { sess.setPermissionRequestHandler((webContents, permission, callback) => {
const origin = safeOrigin(webContents.getURL()); const origin = webContentsOrigin(webContents);
if (origin === backendOrigin && (permission === 'media' || permission === 'clipboard-write')) { if (origin === backendOrigin && (permission === 'media' || permission === 'clipboard-write')) {
callback(true); callback(true);
} else { } else {
@ -184,6 +267,7 @@ app.whenReady().then(() => {
}); });
ipcMain.on('download-update', () => { ipcMain.on('download-update', () => {
if (installInProgress) return;
updateState.status = 'downloading'; updateState.status = 'downloading';
updateState.error = null; updateState.error = null;
broadcastUpdateState(); broadcastUpdateState();
@ -191,13 +275,14 @@ app.whenReady().then(() => {
}); });
ipcMain.on('quit-and-install', () => { ipcMain.on('quit-and-install', () => {
autoUpdater.quitAndInstall(); installDownloadedUpdate();
}); });
// Check once on startup // Check once on startup
setTimeout(() => { setTimeout(() => {
void runUpdateCheck('startup'); void runUpdateCheck('startup');
}, 5000); }, 5000);
startPeriodicUpdateChecks();
app.on('activate', () => { app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) { if (BrowserWindow.getAllWindows().length === 0) {
@ -212,6 +297,13 @@ app.on('window-all-closed', () => {
} }
}); });
app.on('before-quit', () => {
if (periodicUpdateTimer) {
clearInterval(periodicUpdateTimer);
periodicUpdateTimer = null;
}
});
function safeOrigin(value) { function safeOrigin(value) {
try { try {
return new URL(value).origin; return new URL(value).origin;

View file

@ -29,6 +29,7 @@ const state = {
viewMode: 'chat', // 'chat' or 'video' viewMode: 'chat', // 'chat' or 'video'
iceServers: [{ urls: "stun:stun.l.google.com:19302" }], iceServers: [{ urls: "stun:stun.l.google.com:19302" }],
peerGainNodes: new Map(), // userId -> GainNode peerGainNodes: new Map(), // userId -> GainNode
peerAudioNodes: new Map(), // userId -> { sourceNode, compressorNode, gainNode }
visibleVolumeSliders: new Set(), // userIds whose sliders are visible visibleVolumeSliders: new Set(), // userIds whose sliders are visible
}, },
voicePresencePollId: null, voicePresencePollId: null,
@ -576,50 +577,103 @@ function renderChannels() {
pList.style.paddingLeft = "24px"; pList.style.paddingLeft = "24px";
for (const p of participants) { for (const p of participants) {
const pRow = document.createElement("div"); const pRow = document.createElement("div");
pRow.className = `channel-row ${p.is_speaking ? 'voice-speaking' : ''}`; pRow.className = `channel-row voice-participant-row ${p.is_speaking ? 'voice-speaking' : ''}`;
pRow.style.display = "block";
pRow.style.padding = "2px 8px"; pRow.style.padding = "2px 8px";
pRow.style.flexWrap = "wrap"; pRow.style.flexWrap = "wrap";
let sliderHtml = ''; const topRow = document.createElement("div");
if (p.user_id !== state.me.id) { topRow.style.display = "flex";
const vol = state.userVolumes.get(p.user_id) ?? 1.0; topRow.style.alignItems = "center";
const isVisible = state.voice.visibleVolumeSliders.has(p.user_id); topRow.style.gap = "8px";
sliderHtml = ` topRow.style.width = "100%";
<div class="user-volume-control ${isVisible ? 'show-volume' : ''}" data-user-id="${p.user_id}">
<i data-lucide="volume-2" style="width: 12px; height: 12px; opacity: 0.6;"></i> const avatar = document.createElement("div");
<input type="range" min="0" max="2" step="0.1" value="${vol}" class="volume-slider"> avatar.className = "avatar";
<span class="vol-pct">${Math.round(vol * 100)}%</span> avatar.style.width = "20px";
</div> avatar.style.height = "20px";
`; avatar.style.fontSize = "10px";
avatar.textContent = shortName(p.display_name);
topRow.appendChild(avatar);
const name = document.createElement("span");
name.style.flex = "1";
name.style.overflow = "hidden";
name.style.textOverflow = "ellipsis";
name.textContent = p.display_name;
topRow.appendChild(name);
const isRemoteParticipant = p.user_id !== state.me.id;
if (isRemoteParticipant) {
const volumeToggle = document.createElement("button");
volumeToggle.type = "button";
volumeToggle.title = "Toggle volume slider";
volumeToggle.style.display = "grid";
volumeToggle.style.placeItems = "center";
volumeToggle.style.width = "20px";
volumeToggle.style.height = "20px";
volumeToggle.style.color = "var(--text-muted)";
volumeToggle.innerHTML = '<i data-lucide="volume-2" style="width: 14px; height: 14px;"></i>';
topRow.appendChild(volumeToggle);
volumeToggle.addEventListener('click', (e) => {
toggleVolumeSlider(e);
});
} }
pRow.innerHTML = ` if (p.is_muted) {
<div style="display: flex; align-items: center; gap: 8px; width: 100%;"> const muteIcon = document.createElement("i");
<div class="avatar" style="width:20px;height:20px;font-size:10px">${shortName(p.display_name)}</div> muteIcon.setAttribute("data-lucide", "mic-off");
<span style="flex: 1; overflow: hidden; text-overflow: ellipsis;">${escapeHtml(p.display_name)}</span> muteIcon.className = "voice-muted-icon";
${p.is_muted ? '<i data-lucide="mic-off" class="voice-muted-icon"></i>' : ''} topRow.appendChild(muteIcon);
</div> }
${sliderHtml}
`; pRow.appendChild(topRow);
const volumeControl = document.createElement("div");
volumeControl.className = `user-volume-control ${state.voice.visibleVolumeSliders.has(p.user_id) ? 'show-volume' : ''}`;
volumeControl.dataset.userId = p.user_id;
if (isRemoteParticipant) {
const volumeIcon = document.createElement("i");
volumeIcon.setAttribute("data-lucide", "volume-2");
volumeIcon.style.width = "12px";
volumeIcon.style.height = "12px";
volumeIcon.style.opacity = "0.6";
volumeControl.appendChild(volumeIcon);
const slider = document.createElement("input");
slider.type = "range";
slider.min = "0";
slider.max = "2";
slider.step = "0.1";
slider.value = String(state.userVolumes.get(p.user_id) ?? 1.0);
slider.className = "volume-slider";
volumeControl.appendChild(slider);
const volumePct = document.createElement("span");
volumePct.className = "vol-pct";
volumePct.textContent = `${Math.round((state.userVolumes.get(p.user_id) ?? 1.0) * 100)}%`;
volumeControl.appendChild(volumePct);
const slider = pRow.querySelector('.volume-slider');
if (slider) {
slider.addEventListener('input', (e) => { slider.addEventListener('input', (e) => {
const val = parseFloat(e.target.value); const val = parseFloat(e.target.value);
state.userVolumes.set(p.user_id, val); state.userVolumes.set(p.user_id, val);
pRow.querySelector('.vol-pct').textContent = `${Math.round(val * 100)}%`; volumePct.textContent = `${Math.round(val * 100)}%`;
const gainNode = state.voice.peerGainNodes.get(p.user_id); const gainNode = state.voice.peerGainNodes.get(p.user_id);
if (gainNode) { if (gainNode && state.voice.audioContext) {
gainNode.gain.setTargetAtTime(val, state.voice.audioContext.currentTime, 0.05); gainNode.gain.setTargetAtTime(val, state.voice.audioContext.currentTime, 0.05);
} }
}); });
// Stop propagation to prevent joining channel again when clicking slider
slider.addEventListener('click', (e) => e.stopPropagation()); volumeControl.addEventListener('click', (e) => e.stopPropagation());
volumeControl.addEventListener('mousedown', (e) => e.stopPropagation());
pRow.appendChild(volumeControl);
} }
const toggleVolumeSlider = (e) => { const toggleVolumeSlider = (e) => {
if (p.user_id === state.me.id) return; if (!isRemoteParticipant) return;
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
if (state.voice.visibleVolumeSliders.has(p.user_id)) { if (state.voice.visibleVolumeSliders.has(p.user_id)) {
@ -633,6 +687,9 @@ function renderChannels() {
// Electron can swallow contextmenu events on some platforms; use // Electron can swallow contextmenu events on some platforms; use
// right-button mousedown as a reliable fallback for slider toggle. // right-button mousedown as a reliable fallback for slider toggle.
pRow.addEventListener('contextmenu', toggleVolumeSlider); pRow.addEventListener('contextmenu', toggleVolumeSlider);
pRow.addEventListener('auxclick', (e) => {
if (e.button === 2) toggleVolumeSlider(e);
});
pRow.addEventListener('mousedown', (e) => { pRow.addEventListener('mousedown', (e) => {
if (e.button === 2) toggleVolumeSlider(e); if (e.button === 2) toggleVolumeSlider(e);
}); });
@ -983,7 +1040,24 @@ function shouldInitiateOffer(peerId) {
return state.me.id > peerId; return state.me.id > peerId;
} }
function disconnectPeerAudioNodes(peerId) {
const nodes = state.voice.peerAudioNodes.get(peerId);
if (!nodes) return;
for (const node of [nodes.sourceNode, nodes.compressorNode, nodes.gainNode]) {
try {
node?.disconnect();
} catch { }
}
state.voice.peerAudioNodes.delete(peerId);
state.voice.peerGainNodes.delete(peerId);
}
function stopAndClearAudioPipeline() { function stopAndClearAudioPipeline() {
for (const peerId of state.voice.peerAudioNodes.keys()) {
disconnectPeerAudioNodes(peerId);
}
if (state.voice.deepFilterProcessor) { if (state.voice.deepFilterProcessor) {
state.voice.deepFilterProcessor.destroy(); state.voice.deepFilterProcessor.destroy();
state.voice.deepFilterProcessor = null; state.voice.deepFilterProcessor = null;
@ -1209,7 +1283,7 @@ async function createLocalVoiceStream() {
sampleRate: 48000, sampleRate: 48000,
echoCancellation: true, echoCancellation: true,
noiseSuppression: false, noiseSuppression: false,
autoGainControl: false, autoGainControl: true,
}, },
video: false, video: false,
}; };
@ -1332,16 +1406,27 @@ function ensurePeerConnection(peerId) {
const ctx = state.voice.audioContext; const ctx = state.voice.audioContext;
if (ctx.state === 'suspended') ctx.resume(); if (ctx.state === 'suspended') ctx.resume();
disconnectPeerAudioNodes(peerId);
const sourceNode = ctx.createMediaStreamSource(audio.srcObject); const sourceNode = ctx.createMediaStreamSource(audio.srcObject);
const compressorNode = ctx.createDynamicsCompressor();
const gainNode = ctx.createGain(); const gainNode = ctx.createGain();
compressorNode.threshold.setValueAtTime(-24, ctx.currentTime);
compressorNode.knee.setValueAtTime(18, ctx.currentTime);
compressorNode.ratio.setValueAtTime(4, ctx.currentTime);
compressorNode.attack.setValueAtTime(0.003, ctx.currentTime);
compressorNode.release.setValueAtTime(0.25, ctx.currentTime);
const currentVolume = state.userVolumes.get(peerId) ?? 1.0; const currentVolume = state.userVolumes.get(peerId) ?? 1.0;
gainNode.gain.setValueAtTime(currentVolume, ctx.currentTime); gainNode.gain.setValueAtTime(currentVolume, ctx.currentTime);
sourceNode.connect(gainNode); sourceNode.connect(compressorNode);
compressorNode.connect(gainNode);
gainNode.connect(ctx.destination); gainNode.connect(ctx.destination);
state.voice.peerGainNodes.set(peerId, gainNode); state.voice.peerGainNodes.set(peerId, gainNode);
state.voice.peerAudioNodes.set(peerId, { sourceNode, compressorNode, gainNode });
// Mute the original element as we play through Web Audio destination // Mute the original element as we play through Web Audio destination
audio.volume = 0; audio.volume = 0;
@ -1526,6 +1611,7 @@ async function joinVoice() {
pc.close(); pc.close();
state.voice.peerConnections.delete(msg.user_id); state.voice.peerConnections.delete(msg.user_id);
} }
disconnectPeerAudioNodes(msg.user_id);
document.getElementById(`audio-${msg.user_id}`)?.remove(); document.getElementById(`audio-${msg.user_id}`)?.remove();
} else if (msg.type === "signal") { } else if (msg.type === "signal") {
await handleSignal(msg.from_user_id, msg.kind, msg.data); await handleSignal(msg.from_user_id, msg.kind, msg.data);