- Electron main process subscribes to Frigate's MQTT topics (frigate/<camera>/person and frigate/events) directly via mqtt.js, bypassing the broken HA MQTT integration - Watched cameras: Front_Porch, FPE, Porch_Downstairs, Driveway_door - On person detection, exits photo-frame idle and shows full-screen camera feed for 30 seconds - Removed HA entity-based person detection code (entityToCameraName, personDetectionEntities config dependency) - Deleted unused useFrigateDetection HTTP polling hook (superseded)
287 lines
8.5 KiB
TypeScript
287 lines
8.5 KiB
TypeScript
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 <img src>. 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);
|
|
});
|