const { app, BrowserWindow, dialog, ipcMain, shell, screen, safeStorage, powerMonitor, Notification, nativeImage } = require('electron'); const path = require('path'); const fs = require('fs'); // --- Secure session persistence --- const SESSION_FILE = path.join(app.getPath('userData'), 'secure-session.dat'); const https = require('https'); const http = require('http'); const { checkForUpdates, getStatus: getUpdateStatus, onStatus: onUpdateStatus, downloadAndInstall: downloadAndInstallUpdate } = require('./updater.cjs'); function loadEnvVar(varName) { const envFiles = [ path.join(__dirname, '../../.env.local'), path.join(__dirname, '../../.env'), ]; for (const envFile of envFiles) { try { const content = fs.readFileSync(envFile, 'utf8'); const match = content.match(new RegExp(`^${varName}=(.+)$`, 'm')); if (match) return match[1].trim().replace(/^['"]|['"]$/g, ''); } catch {} } return null; } // --- Settings persistence --- const SETTINGS_FILE = path.join(app.getPath('userData'), 'settings.json'); const DEFAULT_SETTINGS = { windowX: undefined, windowY: undefined, windowWidth: 1200, windowHeight: 800, isMaximized: false, theme: 'theme-dark', }; let mainWindow = null; // ─────────────────────────────────────────────────────────────── // Voice recording — per-participant audio capture // ─────────────────────────────────────────────────────────────── // // Each active session owns: // - A root directory (user-configurable, defaults to // `userData/recordings`). // - A manifest file (session.json) atomically rewritten whenever // participants join/leave or the session ends. // - One append-only WebM file per participant. // // `recordingSessions` keyed by sessionId → { rootDir, dir, streams } // tracks per-session state. `streams` is a map keyed by // participantId → fs.WriteStream so append/close can find the // right handle. Crash safety: every 20 chunks we call stream.sync // via fsync for the open fd, so at most ~10 seconds of audio is // unflushed at any moment. const DEFAULT_RECORDING_ROOT = path.join(app.getPath('userData'), 'recordings'); const RECORDING_FSYNC_INTERVAL = 20; // chunks between fsyncs const recordingSessions = new Map(); function sanitizeFilenameSegment(s) { return String(s || 'unknown').replace(/[^a-zA-Z0-9._-]/g, '_').slice(0, 64); } async function atomicWriteJson(filePath, obj) { // Write to a sibling tempfile first, then rename — on a crash // we either see the previous valid JSON or the new valid JSON, // never a half-written file. const tmp = `${filePath}.tmp-${Date.now()}`; await fs.promises.writeFile(tmp, JSON.stringify(obj, null, 2), 'utf8'); await fs.promises.rename(tmp, filePath); } async function readManifest(sessionDir) { try { const raw = await fs.promises.readFile(path.join(sessionDir, 'session.json'), 'utf8'); return JSON.parse(raw); } catch { return null; } } async function writeManifest(sessionDir, manifest) { await atomicWriteJson(path.join(sessionDir, 'session.json'), manifest); } async function updateManifest(sessionDir, updater) { const current = await readManifest(sessionDir); if (!current) return; const next = updater(current) ?? current; await writeManifest(sessionDir, next); } async function isWritableDir(dir) { try { await fs.promises.mkdir(dir, { recursive: true }); const probe = path.join(dir, `.brycord-write-test-${Date.now()}`); await fs.promises.writeFile(probe, 'ok'); await fs.promises.unlink(probe); return true; } catch { return false; } } function closeSessionStreamsSync(sessionId) { const session = recordingSessions.get(sessionId); if (!session) return; for (const [, entry] of session.streams) { try { entry.stream.end(); } catch {} } session.streams.clear(); } function loadSettings() { try { const data = fs.readFileSync(SETTINGS_FILE, 'utf8'); return { ...DEFAULT_SETTINGS, ...JSON.parse(data) }; } catch { return { ...DEFAULT_SETTINGS }; } } function saveSettings(settings) { try { fs.writeFileSync(SETTINGS_FILE, JSON.stringify(settings, null, 2), 'utf8'); } catch (err) { console.error('Failed to save settings:', err.message); } } function isPositionOnScreen(x, y, w, h) { const displays = screen.getAllDisplays(); const MIN_OVERLAP = 100; return displays.some(display => { const { x: dx, y: dy, width: dw, height: dh } = display.bounds; const overlapX = Math.max(0, Math.min(x + w, dx + dw) - Math.max(x, dx)); const overlapY = Math.max(0, Math.min(y + h, dy + dh) - Math.max(y, dy)); return overlapX >= MIN_OVERLAP && overlapY >= MIN_OVERLAP; }); } function createWindow() { const settings = loadSettings(); const windowOptions = { width: settings.windowWidth, height: settings.windowHeight, icon: path.join(__dirname, 'icon.png'), frame: false, webPreferences: { nodeIntegration: false, contextIsolation: true, preload: path.join(__dirname, 'preload.cjs'), sandbox: false } }; // Only restore position if it's valid on a connected display if (settings.windowX !== undefined && settings.windowY !== undefined && isPositionOnScreen(settings.windowX, settings.windowY, settings.windowWidth, settings.windowHeight)) { windowOptions.x = settings.windowX; windowOptions.y = settings.windowY; } mainWindow = new BrowserWindow(windowOptions); // Fix LiveKit WebSocket/CORS for dev mode — rewrite Origin header and inject CORS response headers const livekitWssUrl = loadEnvVar('VITE_LIVEKIT_URL'); if (livekitWssUrl) { const livekitHttpsOrigin = livekitWssUrl.replace(/^wss:\/\//, 'https://').replace(/^ws:\/\//, 'http://'); const livekitHost = new URL(livekitHttpsOrigin).hostname; const filter = { urls: [`*://${livekitHost}/*`] }; mainWindow.webContents.session.webRequest.onBeforeSendHeaders(filter, (details, callback) => { details.requestHeaders['Origin'] = livekitHttpsOrigin; callback({ requestHeaders: details.requestHeaders }); }); mainWindow.webContents.session.webRequest.onHeadersReceived(filter, (details, callback) => { const headers = details.responseHeaders || {}; headers['Access-Control-Allow-Origin'] = ['*']; headers['Access-Control-Allow-Headers'] = ['*']; headers['Access-Control-Allow-Methods'] = ['GET, POST, OPTIONS']; callback({ responseHeaders: headers }); }); } if (settings.isMaximized) { mainWindow.maximize(); } // Auto-clear attention state whenever the user comes back — no // point in flashing / badging a window the user is already // looking at. mainWindow.on('focus', () => { if (mainWindow.isDestroyed()) return; mainWindow.flashFrame(false); mainWindow.setOverlayIcon(null, ''); try { app.setBadgeCount(0); } catch {} }); // Save window state on close mainWindow.on('close', () => { // Flush localStorage/sessionStorage to disk before renderer is destroyed mainWindow.webContents.session.flushStorageData(); // Close any still-open voice recording streams so buffered // chunks are flushed. Manifests will still show endedAt: // null (the recovery modal will catch them on next launch). for (const [sessionId] of recordingSessions) { closeSessionStreamsSync(sessionId); } const current = loadSettings(); // re-read to preserve theme changes if (!mainWindow.isMaximized()) { const bounds = mainWindow.getBounds(); current.windowX = bounds.x; current.windowY = bounds.y; current.windowWidth = bounds.width; current.windowHeight = bounds.height; } current.isMaximized = mainWindow.isMaximized(); saveSettings(current); }); const isDev = process.env.npm_lifecycle_event === 'electron:dev'; if (isDev) { mainWindow.loadURL(process.env.VITE_DEV_URL || 'http://localhost:5173'); mainWindow.webContents.openDevTools(); } else { // Production: Load the built file mainWindow.loadFile(path.join(__dirname, 'dist-react', 'index.html')); } } function createSplashWindow() { const splash = new BrowserWindow({ width: 300, height: 350, frame: false, resizable: false, alwaysOnTop: true, webPreferences: { nodeIntegration: false, contextIsolation: true } }); splash.loadFile(path.join(__dirname, 'splash.html')); return splash; } app.whenReady().then(async () => { const isDev = !app.isPackaged; if (isDev) { createWindow(); } else { const splash = createSplashWindow(); const shouldOpenMain = await checkForUpdates(splash); if (shouldOpenMain) { if (!splash.isDestroyed()) splash.close(); createWindow(); } // Required-update path: splash stays; updater runs quitAndInstall itself. } // Forward updater status changes to the renderer so the header // icon and the required-update blocker can react in real time. onUpdateStatus((status) => { if (mainWindow && !mainWindow.isDestroyed()) { mainWindow.webContents.send('update:status', status); } }); ipcMain.handle('update:get-status', () => getUpdateStatus()); ipcMain.handle('update:download-and-install', () => downloadAndInstallUpdate()); ipcMain.on('window-minimize', () => { const win = BrowserWindow.getFocusedWindow(); if (win) win.minimize(); }); ipcMain.on('window-maximize', () => { const win = BrowserWindow.getFocusedWindow(); if (win) { if (win.isMaximized()) win.unmaximize(); else win.maximize(); } }); ipcMain.on('window-close', () => { const win = BrowserWindow.getFocusedWindow(); if (win) win.close(); }); ipcMain.on('flash-frame', (_event, on) => { if (!mainWindow || mainWindow.isDestroyed()) return; // `on` optional for backward compat — legacy call sites pass // no arg and expect a one-shot flash. mainWindow.flashFrame(on !== false); }); ipcMain.on('notification:show', (_event, opts) => { if (!Notification.isSupported()) return; const { title, body, silent } = opts || {}; const n = new Notification({ title: String(title ?? 'Brycord'), body: String(body ?? ''), silent: !!silent, icon: path.join(__dirname, 'icon.png'), }); n.on('click', () => { if (mainWindow && !mainWindow.isDestroyed()) { if (mainWindow.isMinimized()) mainWindow.restore(); mainWindow.show(); mainWindow.focus(); } }); n.show(); }); // Windows: overlay icon on the taskbar button (1x1 badge). Clearing // is `(null, '')`. `count <= 0` clears. Other OSes fall back to // `app.setBadgeCount` which is a no-op on platforms that don't // support it. ipcMain.on('notification:set-badge', (_event, count) => { if (!mainWindow || mainWindow.isDestroyed()) return; const n = Math.max(0, Math.floor(Number(count) || 0)); if (n === 0) { mainWindow.setOverlayIcon(null, ''); } else { const badgePath = path.join(__dirname, 'icon.png'); try { const img = nativeImage.createFromPath(badgePath); mainWindow.setOverlayIcon(img, `${n} unread`); } catch {} } try { app.setBadgeCount(n); } catch {} }); // Helper to fetch metadata (Zero-Knowledge: Client fetches previews) const OEMBED_PROVIDERS = { 'twitter.com': (url) => url.includes('/status/') ? `https://publish.twitter.com/oembed?url=${encodeURIComponent(url)}&format=json` : null, 'x.com': (url) => url.includes('/status/') ? `https://publish.twitter.com/oembed?url=${encodeURIComponent(url)}&format=json` : null, 'youtube.com': (url) => `https://www.youtube.com/oembed?url=${encodeURIComponent(url)}&format=json`, 'www.youtube.com': (url) => `https://www.youtube.com/oembed?url=${encodeURIComponent(url)}&format=json`, 'youtu.be': (url) => `https://www.youtube.com/oembed?url=${encodeURIComponent(url)}&format=json`, 'open.spotify.com': (url) => `https://open.spotify.com/oembed?url=${encodeURIComponent(url)}`, 'www.tiktok.com': (url) => `https://www.tiktok.com/oembed?url=${encodeURIComponent(url)}`, 'tiktok.com': (url) => `https://www.tiktok.com/oembed?url=${encodeURIComponent(url)}`, 'www.reddit.com': (url) => `https://www.reddit.com/oembed?url=${encodeURIComponent(url)}&format=json`, 'reddit.com': (url) => `https://www.reddit.com/oembed?url=${encodeURIComponent(url)}&format=json`, }; const FETCH_HEADERS = { 'User-Agent': 'Mozilla/5.0 (compatible; DiscordBot/1.0)', 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', 'Accept-Language': 'en-US,en;q=0.5', }; const MAX_RESPONSE_SIZE = 256 * 1024; // 256KB const FETCH_TIMEOUT = 8000; const MAX_REDIRECTS = 5; function httpGet(url, redirectsLeft = MAX_REDIRECTS) { return new Promise((resolve, reject) => { const client = url.startsWith('https') ? https : http; const req = client.get(url, { headers: FETCH_HEADERS, timeout: FETCH_TIMEOUT }, (res) => { if ([301, 302, 303, 307, 308].includes(res.statusCode) && res.headers.location) { if (redirectsLeft <= 0) { resolve(''); return; } let redirectUrl = res.headers.location; if (redirectUrl.startsWith('/')) { const parsed = new URL(url); redirectUrl = parsed.origin + redirectUrl; } res.resume(); httpGet(redirectUrl, redirectsLeft - 1).then(resolve).catch(reject); return; } let data = ''; let size = 0; res.setEncoding('utf8'); res.on('data', (chunk) => { size += Buffer.byteLength(chunk); if (size > MAX_RESPONSE_SIZE) { res.destroy(); resolve(data); return; } data += chunk; }); res.on('end', () => resolve(data)); res.on('error', () => resolve(data)); }); req.on('timeout', () => { req.destroy(); resolve(''); }); req.on('error', (err) => { console.error('httpGet error:', err.message); resolve(''); }); }); } function fetchJson(url, redirectsLeft = MAX_REDIRECTS) { return new Promise((resolve, reject) => { const client = url.startsWith('https') ? https : http; const req = client.get(url, { headers: { 'User-Agent': FETCH_HEADERS['User-Agent'], 'Accept': 'application/json' }, timeout: FETCH_TIMEOUT }, (res) => { if ([301, 302, 303, 307, 308].includes(res.statusCode) && res.headers.location) { if (redirectsLeft <= 0) { resolve(null); return; } let redirectUrl = res.headers.location; if (redirectUrl.startsWith('/')) { const parsed = new URL(url); redirectUrl = parsed.origin + redirectUrl; } res.resume(); fetchJson(redirectUrl, redirectsLeft - 1).then(resolve).catch(reject); return; } let data = ''; res.setEncoding('utf8'); res.on('data', (chunk) => { data += chunk; }); res.on('end', () => { try { resolve(JSON.parse(data)); } catch { resolve(null); } }); res.on('error', () => resolve(null)); }); req.on('timeout', () => { req.destroy(); resolve(null); }); req.on('error', () => resolve(null)); }); } function parseMetaTags(html) { const meta = {}; const metaRegex = /]*?)\/?\s*>/gi; let match; while ((match = metaRegex.exec(html)) !== null) { const attrs = match[1]; let name = null, content = null; const propMatch = attrs.match(/(?:property|name)\s*=\s*["']([^"']+)["']/i); const contentMatch = attrs.match(/content\s*=\s*["']([^"']*?)["']/i); if (propMatch) name = propMatch[1].toLowerCase(); if (contentMatch) content = contentMatch[1]; if (name && content !== null) meta[name] = content; } const titleMatch = html.match(/]*>([\s\S]*?)<\/title>/i); if (titleMatch) meta['_title'] = titleMatch[1].trim(); return meta; } function buildMetadata(meta) { const get = (...keys) => { for (const k of keys) { if (meta[k]) return meta[k]; } return null; }; return { title: get('og:title', 'twitter:title', '_title'), description: get('og:description', 'twitter:description', 'description'), image: get('og:image', 'og:image:secure_url', 'twitter:image', 'twitter:image:src'), siteName: get('og:site_name'), themeColor: get('theme-color'), video: get('og:video:secure_url', 'og:video:url', 'og:video'), type: get('og:type', 'twitter:card'), author: get('article:author', 'author'), }; } function sanitizeMetadata(metadata) { const decodeEntities = (str) => { if (!str) return str; return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>') .replace(/"/g, '"').replace(/'/g, "'").replace(/'/g, "'") .replace(/&#(\d+);/g, (_, n) => String.fromCharCode(n)) .replace(/&#x([0-9a-fA-F]+);/g, (_, n) => String.fromCharCode(parseInt(n, 16))); }; const stripTags = (str) => str ? str.replace(/<[^>]*>/g, '') : str; const limit = (str, max = 1000) => str && str.length > max ? str.substring(0, max) : str; const result = {}; for (const [key, value] of Object.entries(metadata)) { if (typeof value === 'string') { result[key] = limit(decodeEntities(stripTags(value)).trim()); } else { result[key] = value; } } return result; } ipcMain.handle('fetch-metadata', async (event, url) => { try { // Check for direct video links const videoExtensions = ['.mp4', '.webm', '.ogg', '.mov']; const imageExtensions = ['.gif', '.png', '.jpg', '.jpeg', '.webp']; const lowerUrl = url.toLowerCase(); if (videoExtensions.some(ext => lowerUrl.endsWith(ext))) { return { title: url.split('/').pop(), siteName: new URL(url).hostname, video: url, image: null, description: 'Video File' }; } if (imageExtensions.some(ext => lowerUrl.endsWith(ext))) { return { title: url.split('/').pop(), siteName: new URL(url).hostname, video: null, image: url, description: 'Image File' }; } const hostname = new URL(url).hostname.replace(/^www\./, ''); const oembedBuilder = OEMBED_PROVIDERS[hostname] || OEMBED_PROVIDERS['www.' + hostname]; const oembedUrl = oembedBuilder ? oembedBuilder(url) : null; let metadata; if (oembedUrl) { const [oembedResult, htmlResult] = await Promise.allSettled([ fetchJson(oembedUrl), httpGet(url), ]); const oembed = oembedResult.status === 'fulfilled' ? oembedResult.value : null; const html = htmlResult.status === 'fulfilled' ? htmlResult.value : ''; const meta = html ? parseMetaTags(html) : {}; const ogData = buildMetadata(meta); metadata = { title: oembed?.title || ogData.title, description: ogData.description, image: oembed?.thumbnail_url || ogData.image, siteName: oembed?.provider_name || ogData.siteName, themeColor: ogData.themeColor, video: ogData.video, type: ogData.type, author: oembed?.author_name || ogData.author, }; } else { const html = await httpGet(url); if (!html) return null; const meta = parseMetaTags(html); metadata = buildMetadata(meta); } return sanitizeMetadata(metadata); } catch (err) { console.error('Metadata fetch error:', err); return null; } }); // Flatpak update check ipcMain.handle('check-flatpak-update', async () => { const isFlatpak = fs.existsSync('/.flatpak-info') || !!process.env.FLATPAK_ID; if (!isFlatpak) return { isFlatpak: false }; try { const yaml = await httpGet('https://gitea.moyettes.com/Moyettes/DiscordClone/releases/download/latest/latest-linux.yml'); if (!yaml) return { isFlatpak: true, updateAvailable: false }; const versionMatch = yaml.match(/^version:\s*(.+)$/m); if (!versionMatch) return { isFlatpak: true, updateAvailable: false }; const latestVersion = versionMatch[1].trim(); const currentVersion = app.getVersion(); const latest = latestVersion.split('.').map(Number); const current = currentVersion.split('.').map(Number); let updateAvailable = false; let updateType = 'patch'; for (let i = 0; i < Math.max(latest.length, current.length); i++) { const l = latest[i] || 0; const c = current[i] || 0; if (l > c) { updateAvailable = true; updateType = i === 0 ? 'major' : i === 1 ? 'minor' : 'patch'; break; } if (l < c) break; } return { isFlatpak: true, updateAvailable, updateType, latestVersion, currentVersion }; } catch (err) { console.error('Flatpak update check error:', err.message); return { isFlatpak: true, updateAvailable: false }; } }); ipcMain.handle('open-external', async (event, url) => { await shell.openExternal(url); }); // Settings IPC handlers ipcMain.handle('get-setting', (event, key) => { const settings = loadSettings(); return settings[key]; }); ipcMain.handle('set-setting', (event, key, value) => { const settings = loadSettings(); settings[key] = value; saveSettings(settings); }); // Secure session persistence handlers ipcMain.handle('save-session', (event, data) => { try { if (!safeStorage.isEncryptionAvailable()) return false; const encrypted = safeStorage.encryptString(JSON.stringify(data)); fs.writeFileSync(SESSION_FILE, encrypted); return true; } catch (err) { console.error('Failed to save session:', err.message); return false; } }); ipcMain.handle('load-session', () => { try { if (!safeStorage.isEncryptionAvailable()) return null; if (!fs.existsSync(SESSION_FILE)) return null; const encrypted = fs.readFileSync(SESSION_FILE); const decrypted = safeStorage.decryptString(encrypted); return JSON.parse(decrypted); } catch (err) { console.error('Failed to load session (clearing corrupt file):', err.message); try { fs.unlinkSync(SESSION_FILE); } catch {} return null; } }); ipcMain.handle('clear-session', () => { try { if (fs.existsSync(SESSION_FILE)) fs.unlinkSync(SESSION_FILE); return true; } catch (err) { console.error('Failed to clear session:', err.message); return false; } }); ipcMain.handle('get-screen-sources', async () => { const { desktopCapturer } = require('electron'); const sources = await desktopCapturer.getSources({ types: ['window', 'screen'], thumbnailSize: { width: 450, height: 250, borderRadius: '8px' }, fetchWindowIcons: true }); return sources.map(source => ({ id: source.id, name: source.name, thumbnail: source.thumbnail.toDataURL(), appIcon: source.appIcon ? source.appIcon.toDataURL() : null })); }); // Crypto Handlers const crypto = require('crypto'); ipcMain.handle('generate-keys', async () => { const generateRSA = () => new Promise((resolve, reject) => { crypto.generateKeyPair('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } }, (err, pub, priv) => { if (err) reject(err); else resolve({ pub, priv }); }); }); const generateEd = () => new Promise((resolve, reject) => { crypto.generateKeyPair('ed25519', { publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } }, (err, pub, priv) => { if (err) reject(err); else resolve({ pub, priv }); }); }); const [rsa, ed] = await Promise.all([generateRSA(), generateEd()]); return { rsaPub: rsa.pub, rsaPriv: rsa.priv, edPub: ed.pub, edPriv: ed.priv }; }); ipcMain.handle('random-bytes', (event, size) => { return crypto.randomBytes(size).toString('hex'); }); ipcMain.handle('sha256', (event, data) => { return crypto.createHash('sha256').update(data).digest('hex'); }); ipcMain.handle('sign-message', (event, privateKeyPem, message) => { return crypto.sign(null, Buffer.from(message), privateKeyPem).toString('hex'); }); ipcMain.handle('verify-signature', (event, publicKeyPem, message, signature) => { return crypto.verify(null, Buffer.from(message), publicKeyPem, Buffer.from(signature, 'hex')); }); ipcMain.handle('derive-auth-keys', (event, password, salt) => { return new Promise((resolve, reject) => { crypto.scrypt(password, salt, 64, (err, derivedKey) => { if (err) reject(err); else { const dak = derivedKey.subarray(0, 32).toString('hex'); const dek = derivedKey.subarray(32, 64); resolve({ dak, dek }); } }); }); }); ipcMain.handle('public-encrypt', (event, publicKeyPem, data) => { const buffer = Buffer.from(data); return crypto.publicEncrypt({ key: publicKeyPem, padding: crypto.constants.RSA_PKCS1_OAEP_PADDING, oaepHash: "sha256", }, buffer).toString('hex'); }); ipcMain.handle('private-decrypt', (event, privateKeyPem, encryptedHex) => { const buffer = Buffer.from(encryptedHex, 'hex'); return crypto.privateDecrypt({ key: privateKeyPem, padding: crypto.constants.RSA_PKCS1_OAEP_PADDING, oaepHash: "sha256", }, buffer).toString(); }); ipcMain.handle('encrypt-data', (event, plaintext, key) => { console.log('encrypt-data called with:', { plaintextType: typeof plaintext, isBuffer: Buffer.isBuffer(plaintext), keyType: typeof key, keyIsBuffer: Buffer.isBuffer(plaintext) }); if (plaintext === undefined) throw new TypeError('plaintext is undefined'); if (key === undefined) throw new TypeError('key is undefined'); const keyBuffer = typeof key === 'string' ? Buffer.from(key, 'hex') : key; const iv = crypto.randomBytes(12); const cipher = crypto.createCipheriv('aes-256-gcm', keyBuffer, iv); const encryptedBuffer = Buffer.concat([ cipher.update(plaintext), cipher.final() ]); const tag = cipher.getAuthTag().toString('hex'); return { content: encryptedBuffer.toString('hex'), iv: iv.toString('hex'), tag }; }); ipcMain.handle('decrypt-data', (event, ciphertext, key, iv, tag, options = {}) => { const keyBuffer = typeof key === 'string' ? Buffer.from(key, 'hex') : key; const ivBuffer = Buffer.from(iv, 'hex'); const tagBuffer = Buffer.from(tag, 'hex'); const decipher = crypto.createDecipheriv('aes-256-gcm', keyBuffer, ivBuffer); decipher.setAuthTag(tagBuffer); const outputEncoding = options.encoding || 'utf8'; const updateEncoding = outputEncoding === 'buffer' ? undefined : outputEncoding; let decrypted; if (outputEncoding === 'buffer') { decrypted = Buffer.concat([ decipher.update(ciphertext, 'hex'), decipher.final() ]); } else { decrypted = decipher.update(ciphertext, 'hex', outputEncoding); decrypted += decipher.final(outputEncoding); } return decrypted; }); ipcMain.handle('decrypt-batch', (event, items) => { return items.map(({ ciphertext, key, iv, tag }) => { try { const keyBuffer = typeof key === 'string' ? Buffer.from(key, 'hex') : key; const ivBuffer = Buffer.from(iv, 'hex'); const tagBuffer = Buffer.from(tag, 'hex'); const decipher = crypto.createDecipheriv('aes-256-gcm', keyBuffer, ivBuffer); decipher.setAuthTag(tagBuffer); let decrypted = decipher.update(ciphertext, 'hex', 'utf8'); decrypted += decipher.final('utf8'); return { success: true, data: decrypted }; } catch (err) { return { success: false, data: null }; } }); }); ipcMain.handle('verify-batch', (event, items) => { return items.map(({ publicKey, message, signature }) => { try { const verified = crypto.verify(null, Buffer.from(message), publicKey, Buffer.from(signature, 'hex')); return { success: true, verified }; } catch (err) { return { success: false, verified: false }; } }); }); // --- Search DB file storage --- const SEARCH_DIR = path.join(app.getPath('userData'), 'search'); ipcMain.handle('search-db-load', (event, userId) => { try { const filePath = path.join(SEARCH_DIR, `search-${userId}.db.enc`); if (!fs.existsSync(filePath)) return null; return new Uint8Array(fs.readFileSync(filePath)); } catch (err) { console.error('Search DB load error:', err.message); return null; } }); ipcMain.handle('search-db-save', (event, userId, data) => { try { if (!fs.existsSync(SEARCH_DIR)) fs.mkdirSync(SEARCH_DIR, { recursive: true }); const filePath = path.join(SEARCH_DIR, `search-${userId}.db.enc`); fs.writeFileSync(filePath, Buffer.from(data)); return true; } catch (err) { console.error('Search DB save error:', err.message); return false; } }); ipcMain.handle('search-db-clear', (event, userId) => { try { const filePath = path.join(SEARCH_DIR, `search-${userId}.db.enc`); if (fs.existsSync(filePath)) fs.unlinkSync(filePath); return true; } catch (err) { console.error('Search DB clear error:', err.message); return false; } }); // AFK voice channel: expose system idle time to renderer ipcMain.handle('get-system-idle-time', () => powerMonitor.getSystemIdleTime()); // ── Voice recording IPC ───────────────────────────────────── // See the module-level comment block for the session / manifest // model. All handlers are per-session keyed by sessionId; the // root directory is chosen on `start-session` and remembered // for the lifetime of the session so later calls can resolve // file paths without re-reading the user's settings. ipcMain.handle('recording-get-default-folder', () => DEFAULT_RECORDING_ROOT); ipcMain.handle('recording-pick-folder', async () => { const win = BrowserWindow.getFocusedWindow(); const result = await dialog.showOpenDialog(win ?? undefined, { title: 'Choose recording folder', properties: ['openDirectory', 'createDirectory'], defaultPath: DEFAULT_RECORDING_ROOT, }); if (result.canceled || result.filePaths.length === 0) { return { ok: false, path: null }; } return { ok: true, path: result.filePaths[0] }; }); ipcMain.handle('recording-validate-folder', async (_event, dir) => { try { const ok = await isWritableDir(dir); return { valid: ok, error: ok ? null : 'Folder is not writable.' }; } catch (err) { return { valid: false, error: err?.message ?? 'Unknown error' }; } }); ipcMain.handle('recording-open-folder', async (_event, dir) => { const target = dir || DEFAULT_RECORDING_ROOT; try { await fs.promises.mkdir(target, { recursive: true }); } catch {} const err = await shell.openPath(target); return { ok: !err, error: err || null }; }); ipcMain.handle('recording-open-session-folder', async (_event, { rootDir, sessionId }) => { const root = rootDir || DEFAULT_RECORDING_ROOT; const sessionDir = path.join(root, sanitizeFilenameSegment(sessionId)); const err = await shell.openPath(sessionDir); return { ok: !err, error: err || null }; }); ipcMain.handle('recording-start-session', async (_event, payload) => { const { sessionId, rootDir, channelId, channelName, startedAt } = payload; if (!sessionId) return { ok: false, error: 'missing sessionId' }; const safeSession = sanitizeFilenameSegment(sessionId); const root = rootDir || DEFAULT_RECORDING_ROOT; const writable = await isWritableDir(root); const resolvedRoot = writable ? root : DEFAULT_RECORDING_ROOT; const sessionDir = path.join(resolvedRoot, safeSession); await fs.promises.mkdir(sessionDir, { recursive: true }); const manifest = { sessionId, channelId: channelId ?? null, channelName: channelName ?? null, startedAt: startedAt ?? Date.now(), endedAt: null, participants: [], }; await writeManifest(sessionDir, manifest); recordingSessions.set(sessionId, { rootDir: resolvedRoot, dir: sessionDir, streams: new Map(), }); return { ok: true, rootDir: resolvedRoot, sessionDir }; }); ipcMain.handle('recording-open-track', async (_event, payload) => { const { sessionId, participantId, displayName, joinedOffsetMs } = payload; const session = recordingSessions.get(sessionId); if (!session) return { ok: false, error: 'session not started' }; const safePart = sanitizeFilenameSegment(participantId); // Unique filename if the same participant rejoined mid-session. let baseName = safePart; let suffix = 1; let fileName = `${baseName}.webm`; while (session.streams.has(`${participantId}::${suffix}`) || fs.existsSync(path.join(session.dir, fileName))) { suffix += 1; fileName = `${baseName}-${suffix}.webm`; } const filePath = path.join(session.dir, fileName); const stream = fs.createWriteStream(filePath, { flags: 'a' }); const key = `${participantId}::${suffix}`; session.streams.set(key, { stream, filePath, fileName, participantId, chunkCount: 0, }); await updateManifest(session.dir, (m) => { m.participants.push({ id: participantId, displayName: displayName ?? null, file: fileName, joinedOffsetMs: joinedOffsetMs ?? 0, leftOffsetMs: null, }); return m; }); return { ok: true, trackKey: key, fileName }; }); ipcMain.handle('recording-append', async (_event, payload) => { const { sessionId, trackKey, chunk } = payload; const session = recordingSessions.get(sessionId); if (!session) return { ok: false, error: 'session not started' }; const entry = session.streams.get(trackKey); if (!entry) return { ok: false, error: 'track not open' }; return new Promise((resolve) => { entry.stream.write(Buffer.from(chunk), (err) => { if (err) { resolve({ ok: false, error: err.message }); return; } entry.chunkCount += 1; if (entry.chunkCount % RECORDING_FSYNC_INTERVAL === 0) { // Best-effort fsync on the underlying fd so that a // power-cut after this return gives us up-to-date // bytes on disk. The `fd` property is set once the // write stream's 'open' event fires; ignore the // call if it isn't ready yet. const fd = entry.stream.fd; if (typeof fd === 'number') { fs.fsync(fd, () => {}); } } resolve({ ok: true }); }); }); }); ipcMain.handle('recording-close-track', async (_event, payload) => { const { sessionId, trackKey, leftOffsetMs } = payload; const session = recordingSessions.get(sessionId); if (!session) return { ok: false, error: 'session not started' }; const entry = session.streams.get(trackKey); if (!entry) return { ok: false, error: 'track not open' }; await new Promise((resolve) => entry.stream.end(resolve)); session.streams.delete(trackKey); await updateManifest(session.dir, (m) => { // Match the last participant row with this id that has // no leftOffsetMs yet (handles rejoin entries). for (let i = m.participants.length - 1; i >= 0; i--) { if ( m.participants[i].id === entry.participantId && m.participants[i].file === entry.fileName && m.participants[i].leftOffsetMs == null ) { m.participants[i].leftOffsetMs = leftOffsetMs ?? null; break; } } return m; }); return { ok: true }; }); ipcMain.handle('recording-finalize', async (_event, payload) => { const { sessionId, endedAt } = payload; const session = recordingSessions.get(sessionId); if (!session) return { ok: false, error: 'session not started' }; // Close any stragglers. for (const [key, entry] of session.streams) { await new Promise((resolve) => entry.stream.end(resolve)); session.streams.delete(key); } await updateManifest(session.dir, (m) => { m.endedAt = endedAt ?? Date.now(); return m; }); const result = { ok: true, dir: session.dir }; recordingSessions.delete(sessionId); return result; }); ipcMain.handle('recording-list-recoverable', async (_event, payload) => { const { rootDir } = payload ?? {}; const root = rootDir || DEFAULT_RECORDING_ROOT; try { const entries = await fs.promises.readdir(root, { withFileTypes: true }); const out = []; for (const entry of entries) { if (!entry.isDirectory()) continue; const sessionDir = path.join(root, entry.name); const manifest = await readManifest(sessionDir); if (!manifest) continue; if (manifest.endedAt != null) continue; // Only surface sessions that actually have at least // one track file — empty dirs are noise. const files = await fs.promises.readdir(sessionDir); const tracks = files.filter((f) => f.endsWith('.webm')); if (tracks.length === 0) continue; out.push({ sessionId: manifest.sessionId, sessionDir, channelId: manifest.channelId, channelName: manifest.channelName, startedAt: manifest.startedAt, participantCount: manifest.participants?.length ?? tracks.length, trackCount: tracks.length, }); } // Newest first. out.sort((a, b) => (b.startedAt ?? 0) - (a.startedAt ?? 0)); return { ok: true, sessions: out }; } catch (err) { if (err?.code === 'ENOENT') return { ok: true, sessions: [] }; return { ok: false, error: err?.message ?? 'unknown', sessions: [] }; } }); ipcMain.handle('recording-recover-session', async (_event, payload) => { // Caller already decided what to do: 'keep' stamps endedAt // and leaves files in place; 'delete' removes the session // directory entirely. const { sessionDir, action } = payload; if (!sessionDir) return { ok: false, error: 'missing sessionDir' }; if (action === 'delete') { try { await fs.promises.rm(sessionDir, { recursive: true, force: true }); return { ok: true }; } catch (err) { return { ok: false, error: err?.message ?? 'delete failed' }; } } // Keep: stamp endedAt to the newest mtime among track files. try { const files = await fs.promises.readdir(sessionDir); let newest = 0; for (const f of files) { if (!f.endsWith('.webm')) continue; const st = await fs.promises.stat(path.join(sessionDir, f)); if (st.mtimeMs > newest) newest = st.mtimeMs; } await updateManifest(sessionDir, (m) => { m.endedAt = newest || Date.now(); return m; }); return { ok: true }; } catch (err) { return { ok: false, error: err?.message ?? 'recover failed' }; } }); // --- Auto-idle detection --- const IDLE_THRESHOLD_SECONDS = 300; // 5 minutes let wasIdle = false; setInterval(() => { if (!mainWindow || mainWindow.isDestroyed()) return; const idleTime = powerMonitor.getSystemIdleTime(); if (!wasIdle && idleTime >= IDLE_THRESHOLD_SECONDS) { wasIdle = true; mainWindow.webContents.send('idle-state-changed', { isIdle: true }); } else if (wasIdle && idleTime < IDLE_THRESHOLD_SECONDS) { wasIdle = false; mainWindow.webContents.send('idle-state-changed', { isIdle: false }); } }, 15000); });