const { app, BrowserWindow, dialog, ipcMain, shell, screen, safeStorage, powerMonitor, Notification, nativeImage, Tray, Menu } = 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',
// Power features. All default-off so users aren't surprised by
// invisible state on first upgrade; the Launch tab exposes toggles
// for each.
launchAtStartup: false,
startMinimized: false,
minimizeToTrayOnClose: false,
};
let mainWindow = null;
let tray = null;
// Flipped to true by the tray's Quit action (and any other explicit
// quit path) so the `close` handler knows to actually exit instead of
// hiding the window. Without this, tray users who pick Quit would
// just hide the window again.
let isQuitting = false;
// Screen-share source picked by the renderer right before LiveKit's
// setScreenShareEnabled(true) call triggers getDisplayMedia. The
// setDisplayMediaRequestHandler reads + clears this on each fire,
// then resolves the callback with the matching DesktopCapturerSource.
let pendingScreenSourceSelection = 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 });
});
}
// Electron 20+ refuses navigator.mediaDevices.getDisplayMedia()
// unless a handler is registered here. LiveKit's
// setScreenShareEnabled(true) hits getDisplayMedia internally,
// so without this the renderer sees "NotSupportedError: Not
// supported". The renderer pre-stashes its picked source via the
// `screen-share:set-pending-source` IPC, then triggers the LiveKit
// call — we read + clear the pending selection here.
mainWindow.webContents.session.setDisplayMediaRequestHandler(async (_request, callback) => {
const pending = pendingScreenSourceSelection;
pendingScreenSourceSelection = null;
if (!pending) {
callback({});
return;
}
try {
const { desktopCapturer } = require('electron');
const sources = await desktopCapturer.getSources({ types: ['window', 'screen'] });
const source = sources.find(s => s.id === pending.sourceId);
if (!source) { callback({}); return; }
callback({
video: source,
audio: pending.includeAudio ? 'loopback' : undefined,
});
} catch {
callback({});
}
}, { useSystemPicker: false });
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 {}
});
// Intercept close when the user has opted into minimize-to-tray —
// the tray still shows the app and a Show / Quit menu keeps the
// window accessible. Triggered before the normal close-cleanup
// below so the window stays alive.
mainWindow.on('close', (event) => {
const current = loadSettings();
if (current.minimizeToTrayOnClose && !isQuitting && tray) {
event.preventDefault();
mainWindow.hide();
return;
}
});
// Save window state on close
mainWindow.on('close', () => {
if (!mainWindow || mainWindow.isDestroyed()) return;
// 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 showMainWindow() {
if (!mainWindow || mainWindow.isDestroyed()) return;
if (mainWindow.isMinimized()) mainWindow.restore();
mainWindow.show();
mainWindow.focus();
}
function toggleMainWindow() {
if (!mainWindow || mainWindow.isDestroyed()) return;
if (mainWindow.isVisible() && mainWindow.isFocused()) {
mainWindow.hide();
} else {
showMainWindow();
}
}
// Tray icon + menu. The menu mirrors what users expect from a chat
// app that runs in the background: quick window toggle, a mute /
// deafen pair that just routes through to the renderer via IPC (so
// the existing keybind handlers do the work), and an explicit Quit
// that sets `isQuitting` so the `close` interceptor doesn't fight us.
function createTray() {
if (tray) return;
try {
const iconPath = path.join(__dirname, 'icon.png');
const img = nativeImage.createFromPath(iconPath);
tray = new Tray(img.isEmpty() ? nativeImage.createEmpty() : img);
tray.setToolTip('Brycord');
const menu = Menu.buildFromTemplate([
{
label: 'Show Brycord',
click: () => showMainWindow(),
},
{ type: 'separator' },
{
label: 'Toggle Mute',
click: () => {
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('tray:action', 'toggle-mute');
}
},
},
{
label: 'Toggle Deafen',
click: () => {
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('tray:action', 'toggle-deafen');
}
},
},
{ type: 'separator' },
{
label: 'Quit',
click: () => {
isQuitting = true;
app.quit();
},
},
]);
tray.setContextMenu(menu);
tray.on('click', () => toggleMainWindow());
tray.on('double-click', () => showMainWindow());
} catch (err) {
console.error('Failed to create tray:', err);
tray = null;
}
}
function destroyTray() {
if (tray) {
try { tray.destroy(); } catch {}
tray = null;
}
}
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());
// ── Lifecycle / power features ──────────────────────────────
// Auto-start is implemented via Electron's cross-platform
// `setLoginItemSettings`. Works on Windows + macOS natively; on
// Linux it expects a .desktop file, which electron-builder ships
// with the packaged app. Unpackaged dev builds just flip the flag
// in-memory and don't actually register with the OS.
const applyLaunchAtStartup = (enabled, startMinimized) => {
try {
if (app.isPackaged) {
app.setLoginItemSettings({
openAtLogin: !!enabled,
openAsHidden: !!startMinimized,
args: startMinimized ? ['--start-minimized'] : [],
});
}
} catch (err) {
console.warn('setLoginItemSettings failed:', err);
}
};
// Apply whatever's in settings.json on every launch so a change
// persists across upgrades even if the OS forgot the entry.
const bootSettings = loadSettings();
applyLaunchAtStartup(bootSettings.launchAtStartup, bootSettings.startMinimized);
if (bootSettings.launchAtStartup && bootSettings.startMinimized &&
(process.argv.includes('--start-minimized') || process.argv.includes('--hidden'))) {
// Honour the hidden-launch arg: if the tray option is on,
// keep the window hidden until the user clicks the tray. If
// the tray option is off, still hide briefly so the first
// paint doesn't flash.
if (mainWindow && !mainWindow.isDestroyed()) mainWindow.hide();
}
ipcMain.handle('lifecycle:get', () => {
const current = loadSettings();
return {
launchAtStartup: !!current.launchAtStartup,
startMinimized: !!current.startMinimized,
minimizeToTrayOnClose: !!current.minimizeToTrayOnClose,
};
});
ipcMain.handle('lifecycle:set', (_event, patch) => {
const current = loadSettings();
const next = { ...current };
if (typeof patch?.launchAtStartup === 'boolean') next.launchAtStartup = patch.launchAtStartup;
if (typeof patch?.startMinimized === 'boolean') next.startMinimized = patch.startMinimized;
if (typeof patch?.minimizeToTrayOnClose === 'boolean') next.minimizeToTrayOnClose = patch.minimizeToTrayOnClose;
saveSettings(next);
applyLaunchAtStartup(next.launchAtStartup, next.startMinimized);
// Tray is only required when minimize-to-tray is on — destroy
// it when turned off to free the icon slot, recreate on next
// enable. It's cheap either way.
if (next.minimizeToTrayOnClose) createTray();
else destroyTray();
return {
launchAtStartup: !!next.launchAtStartup,
startMinimized: !!next.startMinimized,
minimizeToTrayOnClose: !!next.minimizeToTrayOnClose,
};
});
// Expose a way for the renderer to show the window programmatically
// (e.g. after a desktop notification click) — complements the tray.
ipcMain.on('window:show', () => showMainWindow());
// Create the tray up front if the user has minimize-to-tray enabled
// so their first close works as expected after a cold launch.
if (bootSettings.minimizeToTrayOnClose) {
createTray();
}
app.on('before-quit', () => {
isQuitting = true;
});
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(/([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
}));
});
ipcMain.handle('screen-share:set-pending-source', (_event, payload) => {
pendingScreenSourceSelection = payload && typeof payload.sourceId === 'string'
? { sourceId: payload.sourceId, includeAudio: !!payload.includeAudio }
: null;
return true;
});
// 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' };
}
});
// --- Discord backup importer ---
// The renderer picks the backup SQLite file, then we hand back
// its bytes + the parent directory that contains the
// `attachments////` tree. sql.js parses
// the db in-renderer so we don't need a native sqlite binding
// (Electron 33 ships Node 20, which predates node:sqlite).
ipcMain.handle('importer:pick-database', async () => {
try {
const win = BrowserWindow.getFocusedWindow();
const result = await dialog.showOpenDialog(win ?? undefined, {
title: 'Choose Discord backup database',
properties: ['openFile'],
filters: [
{ name: 'SQLite database', extensions: ['db', 'sqlite', 'sqlite3'] },
{ name: 'All files', extensions: ['*'] },
],
});
if (result.canceled || result.filePaths.length === 0) {
return { ok: false, path: null };
}
return { ok: true, path: result.filePaths[0] };
} catch (err) {
return { ok: false, error: err?.message ?? 'pick failed' };
}
});
ipcMain.handle('importer:read-database', async (_event, dbPath) => {
if (typeof dbPath !== 'string' || !dbPath) {
return { ok: false, error: 'missing path' };
}
try {
const buf = await fs.promises.readFile(dbPath);
// The backup bot writes attachments next to the db in a
// sibling `attachments/` folder — surface the db's dir so
// the renderer can resolve relative `local_path` values
// from the `attachments` table.
const dataDir = path.dirname(dbPath);
return {
ok: true,
bytes: buf.buffer.slice(
buf.byteOffset,
buf.byteOffset + buf.byteLength,
),
dataDir,
};
} catch (err) {
return { ok: false, error: err?.message ?? 'read failed' };
}
});
ipcMain.handle('importer:read-attachment', async (_event, payload) => {
const { dataDir, localPath } = payload || {};
if (typeof dataDir !== 'string' || typeof localPath !== 'string') {
return { ok: false, error: 'missing dataDir/localPath' };
}
try {
// Defence-in-depth: resolve the absolute path and refuse
// anything that escapes `dataDir`. The renderer is trusted
// but a corrupt backup.db with `..`-laden `local_path`
// values could otherwise cough up arbitrary host files.
const abs = path.resolve(dataDir, localPath);
const rootWithSep = path.resolve(dataDir) + path.sep;
if (!abs.startsWith(rootWithSep) && abs !== path.resolve(dataDir)) {
return { ok: false, error: 'path escapes dataDir' };
}
const buf = await fs.promises.readFile(abs);
return {
ok: true,
bytes: buf.buffer.slice(
buf.byteOffset,
buf.byteOffset + buf.byteLength,
),
};
} catch (err) {
return { ok: false, error: err?.message ?? 'read 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);
});