diff --git a/main.js b/main.js
index 1baa6e2..030c413 100644
--- a/main.js
+++ b/main.js
@@ -1,9 +1,11 @@
const { app, BrowserWindow, session, desktopCapturer, ipcMain, clipboard } = require('electron');
const { autoUpdater } = require('electron-updater');
const path = require('path');
+const packageJson = require('./package.json');
+const PERIODIC_UPDATE_CHECK_INTERVAL_MS = 1 * 60 * 1000;
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,
error: null,
};
@@ -23,8 +25,37 @@ function isNewerVersionAvailable(info) {
}
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') {
+ if (installInProgress) {
+ console.log(`Update check skipped (${reason}): install already in progress`);
+ return;
+ }
if (updateCheckInProgress) {
console.log(`Update check skipped (${reason}): another check is already in progress`);
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() {
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;
+ console.log(`Desktop backend URL: ${backendUrl}`);
const win = new BrowserWindow({
width: 1200,
@@ -75,13 +158,13 @@ function createWindow() {
win.setMenuBarVisibility(true);
sess.setPermissionCheckHandler((webContents, permission) => {
- const origin = safeOrigin(webContents.getURL());
+ const origin = webContentsOrigin(webContents);
if (origin !== backendOrigin) return false;
return permission === 'media' || permission === 'clipboard-write';
});
sess.setPermissionRequestHandler((webContents, permission, callback) => {
- const origin = safeOrigin(webContents.getURL());
+ const origin = webContentsOrigin(webContents);
if (origin === backendOrigin && (permission === 'media' || permission === 'clipboard-write')) {
callback(true);
} else {
@@ -184,6 +267,7 @@ app.whenReady().then(() => {
});
ipcMain.on('download-update', () => {
+ if (installInProgress) return;
updateState.status = 'downloading';
updateState.error = null;
broadcastUpdateState();
@@ -191,13 +275,14 @@ app.whenReady().then(() => {
});
ipcMain.on('quit-and-install', () => {
- autoUpdater.quitAndInstall();
+ installDownloadedUpdate();
});
// Check once on startup
setTimeout(() => {
void runUpdateCheck('startup');
}, 5000);
+ startPeriodicUpdateChecks();
app.on('activate', () => {
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) {
try {
return new URL(value).origin;
diff --git a/static/shared/app-core.js b/static/shared/app-core.js
index aaaa0b4..8531b48 100644
--- a/static/shared/app-core.js
+++ b/static/shared/app-core.js
@@ -29,6 +29,7 @@ const state = {
viewMode: 'chat', // 'chat' or 'video'
iceServers: [{ urls: "stun:stun.l.google.com:19302" }],
peerGainNodes: new Map(), // userId -> GainNode
+ peerAudioNodes: new Map(), // userId -> { sourceNode, compressorNode, gainNode }
visibleVolumeSliders: new Set(), // userIds whose sliders are visible
},
voicePresencePollId: null,
@@ -576,50 +577,103 @@ function renderChannels() {
pList.style.paddingLeft = "24px";
for (const p of participants) {
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.flexWrap = "wrap";
- let sliderHtml = '';
- if (p.user_id !== state.me.id) {
- const vol = state.userVolumes.get(p.user_id) ?? 1.0;
- const isVisible = state.voice.visibleVolumeSliders.has(p.user_id);
- sliderHtml = `
-
-
-
- ${Math.round(vol * 100)}%
-
- `;
+ const topRow = document.createElement("div");
+ topRow.style.display = "flex";
+ topRow.style.alignItems = "center";
+ topRow.style.gap = "8px";
+ topRow.style.width = "100%";
+
+ const avatar = document.createElement("div");
+ avatar.className = "avatar";
+ avatar.style.width = "20px";
+ 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 = '';
+ topRow.appendChild(volumeToggle);
+
+ volumeToggle.addEventListener('click', (e) => {
+ toggleVolumeSlider(e);
+ });
}
- pRow.innerHTML = `
-
-
${shortName(p.display_name)}
-
${escapeHtml(p.display_name)}
- ${p.is_muted ? '
' : ''}
-
- ${sliderHtml}
- `;
+ if (p.is_muted) {
+ const muteIcon = document.createElement("i");
+ muteIcon.setAttribute("data-lucide", "mic-off");
+ muteIcon.className = "voice-muted-icon";
+ topRow.appendChild(muteIcon);
+ }
+
+ 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) => {
const val = parseFloat(e.target.value);
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);
- if (gainNode) {
+ if (gainNode && state.voice.audioContext) {
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) => {
- if (p.user_id === state.me.id) return;
+ if (!isRemoteParticipant) return;
e.preventDefault();
e.stopPropagation();
if (state.voice.visibleVolumeSliders.has(p.user_id)) {
@@ -633,6 +687,9 @@ function renderChannels() {
// Electron can swallow contextmenu events on some platforms; use
// right-button mousedown as a reliable fallback for slider toggle.
pRow.addEventListener('contextmenu', toggleVolumeSlider);
+ pRow.addEventListener('auxclick', (e) => {
+ if (e.button === 2) toggleVolumeSlider(e);
+ });
pRow.addEventListener('mousedown', (e) => {
if (e.button === 2) toggleVolumeSlider(e);
});
@@ -983,7 +1040,24 @@ function shouldInitiateOffer(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() {
+ for (const peerId of state.voice.peerAudioNodes.keys()) {
+ disconnectPeerAudioNodes(peerId);
+ }
if (state.voice.deepFilterProcessor) {
state.voice.deepFilterProcessor.destroy();
state.voice.deepFilterProcessor = null;
@@ -1209,7 +1283,7 @@ async function createLocalVoiceStream() {
sampleRate: 48000,
echoCancellation: true,
noiseSuppression: false,
- autoGainControl: false,
+ autoGainControl: true,
},
video: false,
};
@@ -1332,16 +1406,27 @@ function ensurePeerConnection(peerId) {
const ctx = state.voice.audioContext;
if (ctx.state === 'suspended') ctx.resume();
+ disconnectPeerAudioNodes(peerId);
+
const sourceNode = ctx.createMediaStreamSource(audio.srcObject);
+ const compressorNode = ctx.createDynamicsCompressor();
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;
gainNode.gain.setValueAtTime(currentVolume, ctx.currentTime);
- sourceNode.connect(gainNode);
+ sourceNode.connect(compressorNode);
+ compressorNode.connect(gainNode);
gainNode.connect(ctx.destination);
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
audio.volume = 0;
@@ -1526,6 +1611,7 @@ async function joinVoice() {
pc.close();
state.voice.peerConnections.delete(msg.user_id);
}
+ disconnectPeerAudioNodes(msg.user_id);
document.getElementById(`audio-${msg.user_id}`)?.remove();
} else if (msg.type === "signal") {
await handleSignal(msg.from_user_id, msg.kind, msg.data);