/** * EcoOS Daemon * * Main daemon class that orchestrates system services */ import { ProcessManager } from './process-manager.ts'; import { SystemInfo, type DisplayInfo } from './system-info.ts'; import { Updater } from './updater.ts'; import { UIServer } from '../ui/server.ts'; import { runCommand } from '../utils/command.ts'; import { VERSION } from '../version.ts'; export interface DaemonConfig { uiPort: number; user: string; waylandDisplay: string; } export type ServiceState = 'stopped' | 'starting' | 'running' | 'failed'; export interface ServiceStatus { state: ServiceState; error?: string; lastAttempt?: string; } export class EcoDaemon { private config: DaemonConfig; private processManager: ProcessManager; private systemInfo: SystemInfo; private updater: Updater; private uiServer: UIServer; private logs: string[] = []; private systemLogs: string[] = []; private swayStatus: ServiceStatus = { state: 'stopped' }; private chromiumStatus: ServiceStatus = { state: 'stopped' }; private manualRestartUntil: number = 0; // Timestamp until which auto-restart is disabled private lastAutoUpgradeCheck: number = 0; // Timestamp of last auto-upgrade check constructor(config?: Partial) { this.config = { uiPort: 3006, user: 'ecouser', waylandDisplay: 'wayland-1', ...config, }; this.processManager = new ProcessManager(this.config.user); this.systemInfo = new SystemInfo(); this.updater = new Updater((msg) => this.log(msg)); this.uiServer = new UIServer(this.config.uiPort, this); } log(message: string): void { const timestamp = new Date().toISOString(); const entry = `[${timestamp}] ${message}`; this.logs.push(entry); console.log(entry); // Keep last 1000 log entries if (this.logs.length > 1000) { this.logs = this.logs.slice(-1000); } } getLogs(): string[] { return [...this.logs]; } getSystemLogs(): string[] { return [...this.systemLogs]; } async getStatus(): Promise> { const systemInfo = await this.systemInfo.getInfo(); return { version: VERSION, sway: this.swayStatus.state === 'running', swayStatus: this.swayStatus, chromium: this.chromiumStatus.state === 'running', chromiumStatus: this.chromiumStatus, systemInfo, logs: this.logs.slice(-50), systemLogs: this.systemLogs.slice(-50), }; } async rebootSystem(): Promise<{ success: boolean; message: string }> { this.log('System reboot requested...'); try { const result = await runCommand('systemctl', ['reboot']); if (result.success) { return { success: true, message: 'System is rebooting...' }; } return { success: false, message: 'Failed to reboot: ' + result.stderr }; } catch (error) { this.log(`Reboot failed: ${error}`); return { success: false, message: String(error) }; } } async restartChromium(): Promise<{ success: boolean; message: string }> { this.log('Chromium restart requested...'); if (this.swayStatus.state !== 'running') { return { success: false, message: 'Cannot restart Chromium: Sway is not running' }; } // Disable auto-restart for 15 seconds to prevent restart loop this.manualRestartUntil = Date.now() + 15000; try { // Stop existing Chromium await this.processManager.stopBrowser(); this.chromiumStatus = { state: 'stopped' }; // Wait a moment before restarting await new Promise((resolve) => setTimeout(resolve, 1000)); // Start Chromium again await this.startChromium(); this.chromiumStatus = { state: 'running' }; this.log('Chromium browser restarted successfully'); return { success: true, message: 'Chromium restarted successfully' }; } catch (error) { this.chromiumStatus = { state: 'failed', error: String(error), lastAttempt: new Date().toISOString() }; this.log(`Failed to restart Chromium: ${error}`); return { success: false, message: String(error) }; } } async getUpdateInfo(): Promise { return this.updater.getUpdateInfo(); } async checkForUpdates(): Promise { await this.updater.checkForUpdates(); } async upgradeToVersion(version: string): Promise<{ success: boolean; message: string }> { return this.updater.upgradeToVersion(version); } async getDisplays(): Promise { if (this.swayStatus.state !== 'running') { return []; } const uid = await this.getUserUid(); return this.processManager.getDisplays({ runtimeDir: `/run/user/${uid}`, waylandDisplay: this.config.waylandDisplay, }); } async setDisplayEnabled(name: string, enabled: boolean): Promise<{ success: boolean; message: string }> { if (this.swayStatus.state !== 'running') { return { success: false, message: 'Sway is not running' }; } this.log(`${enabled ? 'Enabling' : 'Disabling'} display ${name}`); const uid = await this.getUserUid(); const result = await this.processManager.setDisplayEnabled( { runtimeDir: `/run/user/${uid}`, waylandDisplay: this.config.waylandDisplay }, name, enabled ); return { success: result, message: result ? `Display ${name} ${enabled ? 'enabled' : 'disabled'}` : 'Failed' }; } async setKioskDisplay(name: string): Promise<{ success: boolean; message: string }> { if (this.swayStatus.state !== 'running') { return { success: false, message: 'Sway is not running' }; } if (this.chromiumStatus.state !== 'running') { return { success: false, message: 'Chromium is not running' }; } this.log(`Moving kiosk to display ${name}`); const uid = await this.getUserUid(); const result = await this.processManager.setKioskDisplay( { runtimeDir: `/run/user/${uid}`, waylandDisplay: this.config.waylandDisplay }, name ); return { success: result, message: result ? `Kiosk moved to ${name}` : 'Failed' }; } async start(): Promise { this.log('EcoOS Daemon starting...'); // Start UI server first - this must always succeed and remain responsive this.log('Starting management UI on port ' + this.config.uiPort); await this.uiServer.start(); this.log('Management UI started successfully'); // Start system journal reader in the background this.startJournalReader(); // Check for updates on startup this.updater.checkForUpdates().catch((e) => this.log(`Initial update check failed: ${e}`)); // Start the Sway/Chromium initialization in the background // This allows the UI server to remain responsive even if Sway fails this.startServicesInBackground(); // Keep the daemon running await this.runForever(); } private async startServicesInBackground(): Promise { // Run service initialization without blocking the main thread (async () => { try { // Ensure seatd is running this.log('Checking seatd service...'); await this.ensureSeatd(); // Try to start Sway and Chromium await this.tryStartSwayAndChromium(); } catch (error) { this.log(`Service initialization error: ${error}`); } })(); } private async tryStartSwayAndChromium(): Promise { // Try DRM mode first, fall back to headless if it fails const modes = ['drm', 'headless'] as const; for (const mode of modes) { // Stop any existing Sway process await this.processManager.stopSway(); this.swayStatus = { state: 'starting', lastAttempt: new Date().toISOString() }; this.log(`Trying Sway with ${mode} backend...`); try { await this.startSwayWithMode(mode); } catch (error) { this.log(`Failed to start Sway with ${mode}: ${error}`); continue; } // Wait for Wayland socket this.log('Waiting for Wayland socket...'); const waylandReady = await this.waitForWayland(); if (waylandReady) { this.swayStatus = { state: 'running' }; this.log(`Sway compositor running with ${mode} backend`); // Start Chromium in kiosk mode await this.startChromiumAfterSway(); return; } else { this.log(`Sway ${mode} backend failed - Wayland socket did not appear`); await this.processManager.stopSway(); } } // All modes failed this.swayStatus = { state: 'failed', error: 'All Sway backends failed (tried drm and headless)', lastAttempt: new Date().toISOString() }; this.log('All Sway backend modes failed'); } private async startChromiumAfterSway(): Promise { this.chromiumStatus = { state: 'starting', lastAttempt: new Date().toISOString() }; this.log('Starting Chromium browser...'); try { await this.startChromium(); this.chromiumStatus = { state: 'running' }; this.log('Chromium browser started'); } catch (error) { this.chromiumStatus = { state: 'failed', error: String(error), lastAttempt: new Date().toISOString() }; this.log(`Failed to start Chromium: ${error}`); } } private async ensureSeatd(): Promise { const status = await runCommand('systemctl', ['is-active', 'seatd']); if (status.success && status.stdout.trim() === 'active') { this.log('seatd is already running'); return; } this.log('Starting seatd service...'); const result = await runCommand('systemctl', ['start', 'seatd']); if (!result.success) { this.log('Warning: Failed to start seatd: ' + result.stderr); } } private async startSwayWithMode(mode: 'drm' | 'headless'): Promise { const uid = await this.getUserUid(); // Ensure XDG_RUNTIME_DIR exists const runtimeDir = `/run/user/${uid}`; await runCommand('mkdir', ['-p', runtimeDir]); await runCommand('chown', [`${this.config.user}:${this.config.user}`, runtimeDir]); await runCommand('chmod', ['700', runtimeDir]); if (mode === 'drm') { this.log('Starting Sway with DRM backend (hardware rendering)'); await this.processManager.startSway({ runtimeDir, backends: 'drm,libinput', allowSoftwareRendering: true, headless: false, }); } else { this.log('Starting Sway with headless backend (software rendering)'); await this.processManager.startSway({ runtimeDir, backends: 'headless', allowSoftwareRendering: true, headless: true, headlessOutputs: 1, renderer: 'pixman', }); } } private async waitForWayland(): Promise { const uid = await this.getUserUid(); const socketPath = `/run/user/${uid}/${this.config.waylandDisplay}`; // Wait up to 10 seconds for Wayland socket to appear for (let i = 0; i < 10; i++) { try { const stat = await Deno.stat(socketPath); if (stat.isFile || stat.isSymlink || stat.mode !== undefined) { this.log('Wayland socket ready: ' + socketPath); return true; } } catch { // Socket doesn't exist yet } await new Promise((resolve) => setTimeout(resolve, 1000)); } return false; } private async startChromium(): Promise { const uid = await this.getUserUid(); const runtimeDir = `/run/user/${uid}`; await this.processManager.startBrowser({ runtimeDir, waylandDisplay: this.config.waylandDisplay, url: 'http://localhost:' + this.config.uiPort, kiosk: true, }); } private async getUserUid(): Promise { const result = await runCommand('id', ['-u', this.config.user]); if (!result.success) { throw new Error('Failed to get user UID: ' + result.stderr); } return parseInt(result.stdout.trim(), 10); } private startJournalReader(): void { (async () => { try { const cmd = new Deno.Command('journalctl', { args: ['-f', '--no-pager', '-n', '100', '-o', 'short-iso'], stdout: 'piped', stderr: 'piped', }); const process = cmd.spawn(); this.log('System journal reader started'); const reader = process.stdout.getReader(); const decoder = new TextDecoder(); while (true) { const { value, done } = await reader.read(); if (done) break; const text = decoder.decode(value); for (const line of text.split('\n').filter((l) => l.trim())) { this.systemLogs.push(line); if (this.systemLogs.length > 1000) { this.systemLogs = this.systemLogs.slice(-1000); } } } } catch (error) { this.log(`Journal reader not available: ${error}`); } })(); } private async runForever(): Promise { // Monitor processes and restart if needed while (true) { await new Promise((resolve) => setTimeout(resolve, 5000)); try { // If Sway was running but died, try to restart if (this.swayStatus.state === 'running' && !this.processManager.isSwayRunning()) { this.log('Sway process died, attempting restart...'); this.swayStatus = { state: 'stopped' }; this.chromiumStatus = { state: 'stopped' }; await this.tryStartSwayAndChromium(); } // If Sway is running but Chromium died, restart Chromium // Skip if manual restart is in progress (prevents restart loop) if (this.swayStatus.state === 'running' && this.chromiumStatus.state === 'running' && !(await this.processManager.isBrowserRunning()) && Date.now() > this.manualRestartUntil) { this.log('Chromium process died, attempting restart...'); this.chromiumStatus = { state: 'starting', lastAttempt: new Date().toISOString() }; try { await this.startChromium(); this.chromiumStatus = { state: 'running' }; this.log('Chromium browser restarted'); } catch (error) { this.chromiumStatus = { state: 'failed', error: String(error), lastAttempt: new Date().toISOString() }; this.log(`Failed to restart Chromium: ${error}`); } } // If Sway failed, retry every 30 seconds if (this.swayStatus.state === 'failed') { const lastAttempt = this.swayStatus.lastAttempt ? new Date(this.swayStatus.lastAttempt).getTime() : 0; const now = Date.now(); if (now - lastAttempt > 30000) { this.log('Retrying Sway startup...'); await this.tryStartSwayAndChromium(); } } // Check for auto-upgrades every hour const now = Date.now(); const oneHour = 60 * 60 * 1000; if (now - this.lastAutoUpgradeCheck > oneHour) { this.lastAutoUpgradeCheck = now; this.updater.checkAutoUpgrade().catch((e) => this.log(`Auto-upgrade check failed: ${e}`) ); } } catch (error) { this.log(`Error in monitoring loop: ${error}`); } } } }