const { app, BrowserWindow, session, desktopCapturer, ipcMain, clipboard } = require('electron'); const { autoUpdater } = require('electron-updater'); const path = require('path'); const fs = require('fs'); const url = require('url'); const updateState = { status: 'idle', // idle | checking | available | downloading | downloaded | not-available | error info: null, error: null, }; function broadcastUpdateState() { const wins = BrowserWindow.getAllWindows(); for (const win of wins) { if (!win.isDestroyed()) { win.webContents.send('update-state', updateState); } } } function getStorageFilePath() { return path.join(app.getPath('userData'), 'renderer-storage.json'); } function readPersistentStore() { const filePath = getStorageFilePath(); try { if (!fs.existsSync(filePath)) return {}; const raw = fs.readFileSync(filePath, 'utf8'); const parsed = JSON.parse(raw); return parsed && typeof parsed === 'object' ? parsed : {}; } catch (err) { console.error('Failed to read persistent store', err); return {}; } } function writePersistentStore(store) { const filePath = getStorageFilePath(); try { fs.mkdirSync(path.dirname(filePath), { recursive: true }); fs.writeFileSync(filePath, JSON.stringify(store), 'utf8'); } catch (err) { console.error('Failed to write persistent store', err); } } function isNewerVersionAvailable(info) { const next = info && typeof info.version === 'string' ? info.version : ''; return Boolean(next) && next !== app.getVersion(); } let updateCheckInProgress = false; async function runUpdateCheck(reason = 'manual') { if (updateCheckInProgress) { console.log(`Update check skipped (${reason}): another check is already in progress`); return; } updateCheckInProgress = true; updateState.status = 'checking'; updateState.error = null; try { const result = await autoUpdater.checkForUpdates(); const info = result && result.updateInfo ? result.updateInfo : null; if (isNewerVersionAvailable(info)) { updateState.status = 'available'; updateState.info = info; updateState.error = null; } else { updateState.status = 'not-available'; updateState.info = info; updateState.error = null; } } catch (err) { updateState.status = 'error'; updateState.error = err && err.message ? err.message : String(err); console.error(`Update check failed (${reason})`, err); } finally { updateCheckInProgress = false; broadcastUpdateState(); } } function createWindow() { // Create a persistent session for chattz to keep the user logged in const sess = session.fromPartition('persist:chattz'); const win = new BrowserWindow({ width: 1200, height: 800, title: "Chattz Desktop", webPreferences: { nodeIntegration: false, contextIsolation: true, webSecurity: false, // Required for cross-origin fetch/ws from file:// with cookies session: sess, preload: path.join(__dirname, 'desktop', 'preload.js') } }); win.setAutoHideMenuBar(true); win.setMenuBarVisibility(true); // Auto-approve media permissions (camera, microphone) sess.setPermissionCheckHandler((webContents, permission) => { if (permission === 'media' || permission === 'clipboard-read' || permission === 'clipboard-write') { return true; } return false; }); sess.setPermissionRequestHandler((webContents, permission, callback) => { if (permission === 'media' || permission === 'clipboard-read' || permission === 'clipboard-write') { callback(true); } else { callback(false); } }); // Handle screen share requests sess.setDisplayMediaRequestHandler((request, callback) => { desktopCapturer.getSources({ types: ['screen', 'window'] }).then((sources) => { // Provide the first screen source by default, or implement a picker window here if (sources && sources.length > 0) { // We prefer a screen over a window if available, simple heuristic const screenSource = sources.find(s => s.id.startsWith('screen')) || sources[0]; callback({ video: screenSource, audio: 'loopback' }); } else { callback(null); // Reject safely } }).catch(err => { console.error("Failed to get desktop sources for screen share", err); callback(null); }); }); const backendUrl = (process.env.CHATTZ_URL || 'https://discord.flegr.me').replace(/\/$/, ''); const indexPath = path.join(__dirname, 'desktop', 'index.html'); const loadDesktopApp = (queryParams = '') => { const options = {}; if (queryParams) { try { options.query = Object.fromEntries(new URLSearchParams(queryParams)); } catch (e) { console.error("Failed to parse query params", e); } } // Use setImmediate to ensure the current navigation tick is cleared, // which prevents ERR_ABORTED (-3) on some platforms when interrupting a redirect. setImmediate(() => { if (win.isDestroyed()) return; win.loadFile(indexPath, options).catch((err) => { // Ignore aborted errors as they often happen during fast redirects if (err.toString().includes('-3') || err.code === -3) return; console.error(`Failed to load desktop file: ${err}`); }); }); }; // Intercept navigations/redirects that return to the backend with a token // after the OAuth login flow. Two handlers are needed because: // - will-navigate fires for client-side navigations (location.href, link clicks) // - will-redirect fires for server-side 302 redirects (OAuth callback chain) const interceptLoginRedirect = (event, navigatedUrl) => { try { const urlObj = new URL(navigatedUrl); const backendObj = new URL(backendUrl); if (urlObj.origin === backendObj.origin && urlObj.pathname === '/') { if (urlObj.searchParams.has('token')) { console.log("Detected login success redirect, returning to desktop UI..."); event.preventDefault(); loadDesktopApp(urlObj.search.slice(1)); } } } catch (e) { console.error(e); } }; win.webContents.on('will-navigate', interceptLoginRedirect); win.webContents.on('will-redirect', interceptLoginRedirect); loadDesktopApp(); // Ensure renderer receives latest updater state after any (re)load. // Delay broadcast by 300ms to give the renderer time to register its // onUpdateState IPC listener before we push state. win.webContents.on('did-finish-load', () => { setTimeout(() => { if (!win.isDestroyed()) broadcastUpdateState(); }, 300); }); // Uncomment to debug // win.webContents.openDevTools(); } app.commandLine.appendSwitch('disable-webrtc-hw-encoding'); // Sometime helps resolve codec mismatch behavior app.commandLine.appendSwitch('log-level', '3'); // Silences standard Chromium warning logs (like SRTP transport unprotect logs which are benign timing issues) app.whenReady().then(() => { // Register IPC handlers once (before creating any windows) ipcMain.handle('get-config', () => { return { backendUrl: (process.env.CHATTZ_URL || 'https://discord.flegr.me').replace(/\/$/, '') }; }); ipcMain.handle('clipboard-write', (event, text) => { clipboard.writeText(text); return true; }); ipcMain.handle('storage-get', (event, key) => { if (typeof key !== 'string' || key.length === 0) return null; const store = readPersistentStore(); return Object.prototype.hasOwnProperty.call(store, key) ? store[key] : null; }); ipcMain.handle('storage-set', (event, key, value) => { if (typeof key !== 'string' || key.length === 0) return false; const store = readPersistentStore(); store[key] = value; writePersistentStore(store); return true; }); ipcMain.handle('storage-remove', (event, key) => { if (typeof key !== 'string' || key.length === 0) return false; const store = readPersistentStore(); delete store[key]; writePersistentStore(store); return true; }); ipcMain.handle('get-update-state', () => ({ ...updateState })); ipcMain.handle('check-for-updates-now', async () => { await runUpdateCheck('renderer-direct'); return { ...updateState }; }); createWindow(); // Configure Auto-Updater autoUpdater.autoDownload = false; autoUpdater.logger = console; autoUpdater.on('update-available', (info) => { updateState.status = 'available'; updateState.info = info; updateState.error = null; broadcastUpdateState(); const wins = BrowserWindow.getAllWindows(); if (wins.length > 0) wins[0].webContents.send('update-available', info); }); autoUpdater.on('update-not-available', (info) => { updateState.status = 'not-available'; updateState.info = info || null; updateState.error = null; broadcastUpdateState(); }); autoUpdater.on('update-downloaded', (info) => { updateState.status = 'downloaded'; updateState.info = info; updateState.error = null; broadcastUpdateState(); const wins = BrowserWindow.getAllWindows(); if (wins.length > 0) wins[0].webContents.send('update-downloaded', info); }); autoUpdater.on('error', (err) => { updateState.status = 'error'; updateState.error = err.message; broadcastUpdateState(); const wins = BrowserWindow.getAllWindows(); if (wins.length > 0) wins[0].webContents.send('update-error', err.message); }); ipcMain.on('check-for-updates', () => { void runUpdateCheck('manual'); }); ipcMain.on('download-update', () => { updateState.status = 'downloading'; updateState.error = null; broadcastUpdateState(); autoUpdater.downloadUpdate(); }); ipcMain.on('quit-and-install', () => { autoUpdater.quitAndInstall(); }); // Check once on startup setTimeout(() => { void runUpdateCheck('startup'); }, 5000); app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) { createWindow(); } }); }); app.on('window-all-closed', () => { if (process.platform !== 'darwin') { app.quit(); } });