Files
imperial-command-center/electron/main.ts
root 1dd32c6afe Simplify home view and drop motion detection
- Top nav tabs (Home/Media/Cameras/Settings) replace the right-side button cluster
- Home view now shows calendar 90% / todo 10% vertically; lights, locks,
  alarm, thermostats removed from the dashboard since the photo frame now
  owns the idle space and the nav covers the remaining sections
- Motion detection deleted: the go2rtc-based Kitchen_Panel poll was only
  there to wake the screen before idle timeout, which photo-frame exit on
  touch replaces
2026-04-14 13:27:20 -05:00

241 lines
6.8 KiB
TypeScript

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 { PhotoManager } from './services/PhotoManager';
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 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()}`);
// 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();
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);
});