Files
eco_os/ecoos_daemon/ts/daemon/index.ts

351 lines
11 KiB
TypeScript
Raw Normal View History

2026-01-08 18:33:14 +00:00
/**
* EcoOS Daemon
*
* Main daemon class that orchestrates system services
*/
import { ProcessManager } from './process-manager.ts';
import { SystemInfo } from './system-info.ts';
import { UIServer } from '../ui/server.ts';
import { runCommand } from '../utils/command.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 uiServer: UIServer;
private logs: string[] = [];
private swayStatus: ServiceStatus = { state: 'stopped' };
private chromiumStatus: ServiceStatus = { state: 'stopped' };
constructor(config?: Partial<DaemonConfig>) {
this.config = {
uiPort: 3006,
user: 'ecouser',
waylandDisplay: 'wayland-1',
...config,
};
this.processManager = new ProcessManager(this.config.user);
this.systemInfo = new SystemInfo();
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];
}
async getStatus(): Promise<Record<string, unknown>> {
const systemInfo = await this.systemInfo.getInfo();
return {
sway: this.swayStatus.state === 'running',
swayStatus: this.swayStatus,
chromium: this.chromiumStatus.state === 'running',
chromiumStatus: this.chromiumStatus,
systemInfo,
logs: this.logs.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' };
}
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 start(): Promise<void> {
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 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<void> {
// 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<void> {
// 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<void> {
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<void> {
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<void> {
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<boolean> {
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<void> {
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<number> {
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 async runForever(): Promise<void> {
// 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
if (this.swayStatus.state === 'running' && this.chromiumStatus.state === 'running'
&& !this.processManager.isBrowserRunning()) {
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();
}
}
} catch (error) {
this.log(`Error in monitoring loop: ${error}`);
}
}
}
}