import { app, BrowserWindow, ipcMain, screen, powerSaveBlocker } from 'electron'; import * as path from 'path'; import * as fs from 'fs'; import * as http from 'http'; import { ScreenManager } from './services/ScreenManager'; import { PresenceDetector } from './services/PresenceDetector'; import { FrigateStreamer } from './services/FrigateStreamer'; import { PhotoManager } from './services/PhotoManager'; import { FrigateDetector } from './services/FrigateDetector'; let mainWindow: BrowserWindow | null = null; let screenManager: ScreenManager | null = null; let presenceDetector: PresenceDetector | null = null; let frigateStreamer: FrigateStreamer | null = null; let photoManager: PhotoManager | null = null; let frigateDetector: FrigateDetector | null = null; let powerSaveBlockerId: number | null = null; // Photos directory: env var PHOTOS_PATH wins, else fall back to ~/Pictures/dashboard function resolvePhotosDir(): string { const env = process.env.PHOTOS_PATH; if (env && env.trim()) return env.trim(); return path.join(app.getPath('home'), 'Pictures', 'dashboard'); } // Check if we're in dev mode: explicitly set NODE_ENV or running from source with vite dev server // When running from unpacked folder, app.isPackaged is false but we still want production behavior const isDev = process.env.NODE_ENV === 'development'; function createWindow(): void { const primaryDisplay = screen.getPrimaryDisplay(); const { width, height } = primaryDisplay.workAreaSize; mainWindow = new BrowserWindow({ width, height, x: 0, y: 0, fullscreen: true, kiosk: !isDev, // Kiosk mode in production frame: false, autoHideMenuBar: true, backgroundColor: '#faf6f0', webPreferences: { preload: path.join(__dirname, 'preload.js'), nodeIntegration: false, contextIsolation: true, sandbox: false, // Required for some electron features }, }); // Load the app if (isDev) { mainWindow.loadURL('http://localhost:5173'); mainWindow.webContents.openDevTools({ mode: 'detach' }); } else { // __dirname is dist-electron, so go up one level to reach dist mainWindow.loadFile(path.join(__dirname, '../dist/index.html')); } // Prevent power save mode powerSaveBlockerId = powerSaveBlocker.start('prevent-display-sleep'); // Initialize services screenManager = new ScreenManager(mainWindow); // Photo frame slideshow source photoManager = new PhotoManager(resolvePhotosDir()); console.log(`PhotoManager: watching ${photoManager.getDir()}`); // Frigate person detection via MQTT (bypasses HA entities) frigateDetector = new FrigateDetector({ mqttUrl: 'mqtt://192.168.1.50:1883', mqttUser: 'mqtt', mqttPassword: '11xpfcryan', topicPrefix: 'frigate', cameras: ['Front_Porch', 'FPE', 'Porch_Downstairs', 'Driveway_door'], }); frigateDetector.on('personDetected', (camera: string) => { console.log(`FrigateDetector: person on ${camera}`); mainWindow?.webContents.send('frigate:personDetected', camera); }); frigateDetector.start(); // Handle window close mainWindow.on('closed', () => { mainWindow = null; }); // Prevent accidental navigation mainWindow.webContents.on('will-navigate', (event) => { event.preventDefault(); }); // Handle external links mainWindow.webContents.setWindowOpenHandler(() => { return { action: 'deny' }; }); } // IPC handlers function setupIpcHandlers(): void { // Screen management ipcMain.handle('screen:wake', async () => { await screenManager?.wakeScreen(); return true; }); ipcMain.handle('screen:sleep', async () => { await screenManager?.sleepScreen(); return true; }); ipcMain.handle('screen:setIdleTimeout', async (_event, timeout: number) => { screenManager?.setIdleTimeout(timeout); return true; }); // Handle touch/click activity from renderer (for Wayland touch support) ipcMain.handle('screen:activity', async () => { screenManager?.handleUserActivity(); return true; }); // Presence detection control ipcMain.handle('presence:start', async () => { if (!presenceDetector) { presenceDetector = new PresenceDetector(); presenceDetector.on('personDetected', () => { mainWindow?.webContents.send('presence:detected'); screenManager?.wakeScreen(); }); presenceDetector.on('noPersonDetected', () => { mainWindow?.webContents.send('presence:cleared'); }); } await presenceDetector.start(); return true; }); ipcMain.handle('presence:stop', async () => { await presenceDetector?.stop(); return true; }); // Frigate streaming control ipcMain.handle('frigate:startStream', async (_event, rtspUrl: string) => { if (!frigateStreamer) { frigateStreamer = new FrigateStreamer(); } await frigateStreamer.start(rtspUrl); return true; }); ipcMain.handle('frigate:stopStream', async () => { await frigateStreamer?.stop(); return true; }); // Config - read stored token from file ipcMain.handle('config:getStoredToken', async () => { try { const tokenPath = path.join(app.getPath('userData'), 'ha_token.txt'); if (fs.existsSync(tokenPath)) { const token = fs.readFileSync(tokenPath, 'utf-8').trim(); return token || null; } return null; } catch { return null; } }); // Config - read Jellyfin API key from file ipcMain.handle('config:getJellyfinApiKey', async () => { try { const keyPath = path.join(app.getPath('userData'), 'jellyfin_api_key.txt'); if (fs.existsSync(keyPath)) { const key = fs.readFileSync(keyPath, 'utf-8').trim(); return key || null; } return null; } catch { return null; } }); // Photo frame ipcMain.handle('photos:list', async () => { return photoManager?.list() ?? []; }); ipcMain.handle('photos:getDir', async () => { return photoManager?.getDir() ?? ''; }); // Given a relative filename returned by photos:list, return an absolute // file:// URL the renderer can drop into an . Returns null if the // file is missing or resolves outside the photos dir (traversal guard). ipcMain.handle('photos:getUrl', async (_event, rel: string) => { const full = photoManager?.resolve(rel); if (!full || !fs.existsSync(full)) return null; return 'file://' + full.split(path.sep).map((p, i) => (i === 0 && p === '' ? '' : encodeURIComponent(p))).join('/'); }); // App control ipcMain.handle('app:quit', () => { app.quit(); }); ipcMain.handle('app:toggleFullscreen', () => { if (mainWindow) { const isFullScreen = mainWindow.isFullScreen(); mainWindow.setFullScreen(!isFullScreen); } }); ipcMain.handle('app:toggleDevTools', () => { if (isDev && mainWindow) { mainWindow.webContents.toggleDevTools(); } }); } // App lifecycle app.whenReady().then(() => { setupIpcHandlers(); createWindow(); app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) { createWindow(); } }); }); app.on('window-all-closed', () => { // Clean up if (powerSaveBlockerId !== null) { powerSaveBlocker.stop(powerSaveBlockerId); } presenceDetector?.stop(); frigateStreamer?.stop(); frigateDetector?.stop(); if (process.platform !== 'darwin') { app.quit(); } }); // Localhost-only HTTP endpoint for self-screenshots. GNOME Wayland blocks // external screen capture, but webContents.capturePage() works from inside. // Usage: curl -s http://127.0.0.1:9990/screenshot > out.png http .createServer(async (req, res) => { if (req.url === '/screenshot' && mainWindow) { try { const wc = mainWindow.webContents; if (!wc.debugger.isAttached()) wc.debugger.attach('1.3'); const result = (await wc.debugger.sendCommand('Page.captureScreenshot', { format: 'png', captureBeyondViewport: false, })) as { data: string }; res.setHeader('Content-Type', 'image/png'); res.end(Buffer.from(result.data, 'base64')); } catch (err) { res.statusCode = 500; res.end(String(err)); } } else { res.statusCode = 404; res.end(); } }) .listen(9990, '127.0.0.1', () => { console.log('Screenshot endpoint on http://127.0.0.1:9990/screenshot'); }); // Handle uncaught exceptions process.on('uncaughtException', (error) => { console.error('Uncaught exception:', error); }); process.on('unhandledRejection', (reason) => { console.error('Unhandled rejection:', reason); });