const { app, BrowserWindow, ipcMain, shell, screen, safeStorage } = 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 } = require('./updater.cjs'); // --- 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; 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, 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); if (settings.isMaximized) { mainWindow.maximize(); } // Save window state on close mainWindow.on('close', () => { 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('http://localhost:5173'); mainWindow.webContents.openDevTools(); } else { // Production: Load the built file // dist-react is in the same directory as main.cjs 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 noUpdate = await checkForUpdates(splash); if (noUpdate === false) { if (!splash.isDestroyed()) splash.close(); createWindow(); } // If update downloaded, quitAndInstall handles restart } 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(); }); // 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 = {}; // Match both orderings: property/name before content AND content before property/name const metaRegex = /]*?)\/?\s*>/gi; let match; while ((match = metaRegex.exec(html)) !== null) { const attrs = match[1]; let name = null, content = null; // Extract property or name attribute 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; } // Extract tag const titleMatch = html.match(/<title[^>]*>([\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) { // Fetch oEmbed JSON and page HTML in parallel 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 { // Standard HTML fetch 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; } }); 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) => { /* args are (err, publicKey, privateKey) */ 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) => { // message should be a string or buffer 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) => { // data can be string or buffer. Returns hex. 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(); // Assuming utf8 string output }); 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'); // Key can be hex string or buffer. 32 bytes for AES-256 const keyBuffer = typeof key === 'string' ? Buffer.from(key, 'hex') : key; const iv = crypto.randomBytes(12); // 96-bit IV for GCM const cipher = crypto.createCipheriv('aes-256-gcm', keyBuffer, iv); // Use Buffer concatenation to handle both string and Buffer inputs correctly without encoding confusion 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'; // If encoding is 'buffer', don't pass an encoding to update/final 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; }); // Batch decrypt: accepts array of { ciphertext, key, iv, tag }, returns array of { success, data } 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 }; } }); }); // Batch verify: accepts array of { publicKey, message, signature }, returns array of { success, verified } 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 }; } }); }); });