Compare commits
No commits in common. "main" and "0.0.55" have entirely different histories.
2 changed files with 35 additions and 213 deletions
102
main.js
102
main.js
|
|
@ -1,11 +1,9 @@
|
|||
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 | installing | not-available | error
|
||||
status: 'idle', // idle | checking | available | downloading | downloaded | not-available | error
|
||||
info: null,
|
||||
error: null,
|
||||
};
|
||||
|
|
@ -25,37 +23,8 @@ 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;
|
||||
|
|
@ -85,62 +54,10 @@ 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 = resolveBackendUrl();
|
||||
const backendUrl = (process.env.CHATTZ_URL || process.env.APP_BASE_URL || 'http://localhost:3000').replace(/\/$/, '');
|
||||
const backendOrigin = new URL(backendUrl).origin;
|
||||
console.log(`Desktop backend URL: ${backendUrl}`);
|
||||
|
||||
const win = new BrowserWindow({
|
||||
width: 1200,
|
||||
|
|
@ -158,13 +75,13 @@ function createWindow() {
|
|||
win.setMenuBarVisibility(true);
|
||||
|
||||
sess.setPermissionCheckHandler((webContents, permission) => {
|
||||
const origin = webContentsOrigin(webContents);
|
||||
const origin = safeOrigin(webContents.getURL());
|
||||
if (origin !== backendOrigin) return false;
|
||||
return permission === 'media' || permission === 'clipboard-write';
|
||||
});
|
||||
|
||||
sess.setPermissionRequestHandler((webContents, permission, callback) => {
|
||||
const origin = webContentsOrigin(webContents);
|
||||
const origin = safeOrigin(webContents.getURL());
|
||||
if (origin === backendOrigin && (permission === 'media' || permission === 'clipboard-write')) {
|
||||
callback(true);
|
||||
} else {
|
||||
|
|
@ -267,7 +184,6 @@ app.whenReady().then(() => {
|
|||
});
|
||||
|
||||
ipcMain.on('download-update', () => {
|
||||
if (installInProgress) return;
|
||||
updateState.status = 'downloading';
|
||||
updateState.error = null;
|
||||
broadcastUpdateState();
|
||||
|
|
@ -275,14 +191,13 @@ app.whenReady().then(() => {
|
|||
});
|
||||
|
||||
ipcMain.on('quit-and-install', () => {
|
||||
installDownloadedUpdate();
|
||||
autoUpdater.quitAndInstall();
|
||||
});
|
||||
|
||||
// Check once on startup
|
||||
setTimeout(() => {
|
||||
void runUpdateCheck('startup');
|
||||
}, 5000);
|
||||
startPeriodicUpdateChecks();
|
||||
|
||||
app.on('activate', () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) {
|
||||
|
|
@ -297,13 +212,6 @@ app.on('window-all-closed', () => {
|
|||
}
|
||||
});
|
||||
|
||||
app.on('before-quit', () => {
|
||||
if (periodicUpdateTimer) {
|
||||
clearInterval(periodicUpdateTimer);
|
||||
periodicUpdateTimer = null;
|
||||
}
|
||||
});
|
||||
|
||||
function safeOrigin(value) {
|
||||
try {
|
||||
return new URL(value).origin;
|
||||
|
|
|
|||
|
|
@ -29,7 +29,6 @@ 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,
|
||||
|
|
@ -577,103 +576,50 @@ function renderChannels() {
|
|||
pList.style.paddingLeft = "24px";
|
||||
for (const p of participants) {
|
||||
const pRow = document.createElement("div");
|
||||
pRow.className = `channel-row voice-participant-row ${p.is_speaking ? 'voice-speaking' : ''}`;
|
||||
pRow.style.display = "block";
|
||||
pRow.className = `channel-row ${p.is_speaking ? 'voice-speaking' : ''}`;
|
||||
pRow.style.padding = "2px 8px";
|
||||
pRow.style.flexWrap = "wrap";
|
||||
|
||||
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 = '<i data-lucide="volume-2" style="width: 14px; height: 14px;"></i>';
|
||||
topRow.appendChild(volumeToggle);
|
||||
|
||||
volumeToggle.addEventListener('click', (e) => {
|
||||
toggleVolumeSlider(e);
|
||||
});
|
||||
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 = `
|
||||
<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>
|
||||
<input type="range" min="0" max="2" step="0.1" value="${vol}" class="volume-slider">
|
||||
<span class="vol-pct">${Math.round(vol * 100)}%</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
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);
|
||||
pRow.innerHTML = `
|
||||
<div style="display: flex; align-items: center; gap: 8px; width: 100%;">
|
||||
<div class="avatar" style="width:20px;height:20px;font-size:10px">${shortName(p.display_name)}</div>
|
||||
<span style="flex: 1; overflow: hidden; text-overflow: ellipsis;">${escapeHtml(p.display_name)}</span>
|
||||
${p.is_muted ? '<i data-lucide="mic-off" class="voice-muted-icon"></i>' : ''}
|
||||
</div>
|
||||
${sliderHtml}
|
||||
`;
|
||||
|
||||
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);
|
||||
volumePct.textContent = `${Math.round(val * 100)}%`;
|
||||
pRow.querySelector('.vol-pct').textContent = `${Math.round(val * 100)}%`;
|
||||
|
||||
const gainNode = state.voice.peerGainNodes.get(p.user_id);
|
||||
if (gainNode && state.voice.audioContext) {
|
||||
if (gainNode) {
|
||||
gainNode.gain.setTargetAtTime(val, state.voice.audioContext.currentTime, 0.05);
|
||||
}
|
||||
});
|
||||
|
||||
volumeControl.addEventListener('click', (e) => e.stopPropagation());
|
||||
volumeControl.addEventListener('mousedown', (e) => e.stopPropagation());
|
||||
pRow.appendChild(volumeControl);
|
||||
// Stop propagation to prevent joining channel again when clicking slider
|
||||
slider.addEventListener('click', (e) => e.stopPropagation());
|
||||
}
|
||||
|
||||
const toggleVolumeSlider = (e) => {
|
||||
if (!isRemoteParticipant) return;
|
||||
if (p.user_id === state.me.id) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (state.voice.visibleVolumeSliders.has(p.user_id)) {
|
||||
|
|
@ -687,9 +633,6 @@ 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);
|
||||
});
|
||||
|
|
@ -1040,24 +983,7 @@ 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;
|
||||
|
|
@ -1283,7 +1209,7 @@ async function createLocalVoiceStream() {
|
|||
sampleRate: 48000,
|
||||
echoCancellation: true,
|
||||
noiseSuppression: false,
|
||||
autoGainControl: true,
|
||||
autoGainControl: false,
|
||||
},
|
||||
video: false,
|
||||
};
|
||||
|
|
@ -1406,27 +1332,16 @@ 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(compressorNode);
|
||||
compressorNode.connect(gainNode);
|
||||
sourceNode.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;
|
||||
|
|
@ -1611,7 +1526,6 @@ 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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue