- Create shared command utility in ts/utils/command.ts - Update index.ts, process-manager.ts, system-info.ts to use shared utility - Remove duplicate runCommand implementations from all files - Remove legacy Chrome wrapper methods (startChrome, stopChrome, isChromeRunning) - Consolidate Sway window selectors from 5 to 2 - Remove unused isobuild TypeScript files (mod.ts, deno.json, ts/index.ts) - Make getStatus() async to properly await system info - Add disk and system info sections to UI
305 lines
9.3 KiB
TypeScript
305 lines
9.3 KiB
TypeScript
/**
|
|
* 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 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}`);
|
|
}
|
|
}
|
|
}
|
|
}
|