Initial commit: Electron + React touchscreen kiosk dashboard for Home Assistant
This commit is contained in:
229
electron/main.ts
Normal file
229
electron/main.ts
Normal file
@@ -0,0 +1,229 @@
|
||||
import { app, BrowserWindow, ipcMain, screen, powerSaveBlocker } from 'electron';
|
||||
import * as path from 'path';
|
||||
import * as fs from 'fs';
|
||||
import { ScreenManager } from './services/ScreenManager';
|
||||
import { PresenceDetector } from './services/PresenceDetector';
|
||||
import { FrigateStreamer } from './services/FrigateStreamer';
|
||||
import { MotionDetector } from './services/MotionDetector';
|
||||
|
||||
let mainWindow: BrowserWindow | null = null;
|
||||
let screenManager: ScreenManager | null = null;
|
||||
let presenceDetector: PresenceDetector | null = null;
|
||||
let frigateStreamer: FrigateStreamer | null = null;
|
||||
let motionDetector: MotionDetector | null = null;
|
||||
let powerSaveBlockerId: number | null = null;
|
||||
|
||||
// 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: '#0a0a0a',
|
||||
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);
|
||||
|
||||
// Initialize motion detector (runs in main process, not throttled by browser)
|
||||
// Uses file size comparison which is more reliable for JPEG streams
|
||||
motionDetector = new MotionDetector({
|
||||
go2rtcUrl: 'http://192.168.1.241:1985',
|
||||
cameraName: 'Kitchen_Panel',
|
||||
sensitivityThreshold: 5, // % file size change to trigger (5% = significant motion)
|
||||
checkIntervalMs: 2000, // Check every 2 seconds for responsiveness
|
||||
});
|
||||
|
||||
motionDetector.on('motion', () => {
|
||||
console.log('MotionDetector: Motion detected, waking screen');
|
||||
screenManager?.wakeScreen();
|
||||
mainWindow?.webContents.send('motion:detected');
|
||||
});
|
||||
|
||||
motionDetector.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;
|
||||
}
|
||||
});
|
||||
|
||||
// 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();
|
||||
motionDetector?.stop();
|
||||
|
||||
if (process.platform !== 'darwin') {
|
||||
app.quit();
|
||||
}
|
||||
});
|
||||
|
||||
// Handle uncaught exceptions
|
||||
process.on('uncaughtException', (error) => {
|
||||
console.error('Uncaught exception:', error);
|
||||
});
|
||||
|
||||
process.on('unhandledRejection', (reason) => {
|
||||
console.error('Unhandled rejection:', reason);
|
||||
});
|
||||
Reference in New Issue
Block a user