commit 51c83f846aab34b69b199b49a012cd4159893b03 Author: Juergen Kunz Date: Thu Jan 8 18:33:14 2026 +0000 initial diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1140d6a --- /dev/null +++ b/.gitignore @@ -0,0 +1,47 @@ +# Build outputs +isobuild/output/ +*.iso +*.qcow2 + +# Live-build working directories +isobuild/chroot/ +isobuild/binary/ +isobuild/cache/ +isobuild/.build/ +isobuild/config/binary +isobuild/config/bootstrap +isobuild/config/chroot +isobuild/config/common +isobuild/config/source +isobuild/local/ +isobuild/*.log + +# Deno +.deno/ +deno.lock + +# Daemon bundle (compiled) +ecoos_daemon/bundle/ + +# OS/Editor +.DS_Store +*.swp +*.swo +*~ +.vscode/ +.idea/ + +# Logs +*.log +logs/ + +# Temporary files +*.tmp +*.temp +.nogit/ + +# ISO testing +isotest/*.qcow2 +isotest/screenshots/ +isotest/*.pid +isotest/*.sock diff --git a/ecoos_daemon/deno.json b/ecoos_daemon/deno.json new file mode 100644 index 0000000..4e717b2 --- /dev/null +++ b/ecoos_daemon/deno.json @@ -0,0 +1,19 @@ +{ + "name": "@ecobridge/ecoos-daemon", + "version": "0.0.1", + "exports": "./mod.ts", + "tasks": { + "dev": "deno run --allow-all --watch mod.ts", + "start": "deno run --allow-all mod.ts", + "bundle": "deno compile --allow-all --output bundle/eco-daemon mod.ts" + }, + "imports": { + "@std/http": "jsr:@std/http@^1.0.0", + "@std/fs": "jsr:@std/fs@^1.0.0", + "@std/path": "jsr:@std/path@^1.0.0", + "@std/async": "jsr:@std/async@^1.0.0" + }, + "compilerOptions": { + "strict": true + } +} diff --git a/ecoos_daemon/mod.ts b/ecoos_daemon/mod.ts new file mode 100644 index 0000000..0c96936 --- /dev/null +++ b/ecoos_daemon/mod.ts @@ -0,0 +1,14 @@ +/** + * EcoOS Daemon + * + * Main entry point for the EcoOS system daemon. + * Runs as root via systemd and orchestrates: + * - Management UI on :3006 + * - Sway compositor as ecouser + * - Chromium browser in kiosk mode + */ + +import { EcoDaemon } from './ts/daemon/index.ts'; + +const daemon = new EcoDaemon(); +await daemon.start(); diff --git a/ecoos_daemon/ts/daemon/index.ts b/ecoos_daemon/ts/daemon/index.ts new file mode 100644 index 0000000..239f7b7 --- /dev/null +++ b/ecoos_daemon/ts/daemon/index.ts @@ -0,0 +1,350 @@ +/** + * 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) { + 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> { + 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 { + 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 { + // 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 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 + 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}`); + } + } + } +} diff --git a/ecoos_daemon/ts/daemon/process-manager.ts b/ecoos_daemon/ts/daemon/process-manager.ts new file mode 100644 index 0000000..dd59388 --- /dev/null +++ b/ecoos_daemon/ts/daemon/process-manager.ts @@ -0,0 +1,348 @@ +/** + * Process Manager + * + * Manages spawning and monitoring of Sway and Chromium processes + */ + +import { runCommand } from '../utils/command.ts'; + +export interface SwayConfig { + runtimeDir: string; + backends: string; + allowSoftwareRendering: boolean; + headless?: boolean; + headlessOutputs?: number; + renderer?: string; + resolution?: string; +} + +export interface BrowserConfig { + runtimeDir: string; + waylandDisplay: string; + url: string; + kiosk?: boolean; +} + +export class ProcessManager { + private user: string; + private swayProcess: Deno.ChildProcess | null = null; + private browserProcess: Deno.ChildProcess | null = null; + + constructor(user: string) { + this.user = user; + } + + /** + * Generate Sway configuration content for kiosk mode + */ + private generateSwayConfig(config: SwayConfig): string { + const resolution = config.resolution || '1920x1080'; + + return `# EcoOS Sway Configuration (generated by eco-daemon) +# Kiosk mode operation + +# Variables +set $mod Mod4 + +# Output configuration +output * { + bg #000000 solid_color + resolution ${resolution} +} + +# For headless backend virtual outputs +output HEADLESS-1 { + resolution ${resolution} + position 0 0 +} + +# Input configuration +input * { + events enabled +} + +# Disable screen blanking for kiosk mode +exec_always swaymsg "output * dpms on" + +# No window decorations for fullscreen kiosk +default_border none +default_floating_border none + +# Focus follows mouse +focus_follows_mouse yes + +# Force all windows fullscreen for kiosk mode +for_window [app_id=".*"] fullscreen enable +for_window [app_id="chromium-browser"] fullscreen enable +`; + } + + /** + * Write Sway config to user's config directory + */ + private async writeSwayConfig(config: SwayConfig): Promise { + const configDir = `/home/${this.user}/.config/sway`; + const configPath = `${configDir}/config`; + + // Create config directory + await runCommand('mkdir', ['-p', configDir]); + await runCommand('chown', [`${this.user}:${this.user}`, `/home/${this.user}/.config`]); + await runCommand('chown', [`${this.user}:${this.user}`, configDir]); + + // Write config file + const configContent = this.generateSwayConfig(config); + await Deno.writeTextFile(configPath, configContent); + await runCommand('chown', [`${this.user}:${this.user}`, configPath]); + + console.log(`[sway] Config written to ${configPath}`); + return configPath; + } + + async startSway(config: SwayConfig): Promise { + // Write sway config before starting + const configPath = await this.writeSwayConfig(config); + + const env: Record = { + XDG_RUNTIME_DIR: config.runtimeDir, + WLR_BACKENDS: config.backends, + }; + + if (config.allowSoftwareRendering) { + env.WLR_RENDERER_ALLOW_SOFTWARE = '1'; + } + + // Headless mode configuration + if (config.headless) { + env.WLR_HEADLESS_OUTPUTS = String(config.headlessOutputs || 1); + // Use libinput for input even in headless mode (for VNC/remote input) + env.WLR_LIBINPUT_NO_DEVICES = '1'; + } + + // Force specific renderer (pixman for software rendering) + if (config.renderer) { + env.WLR_RENDERER = config.renderer; + } + + // Build environment string for runuser + const envString = Object.entries(env) + .map(([k, v]) => `${k}=${v}`) + .join(' '); + + const command = new Deno.Command('runuser', { + args: ['-u', this.user, '--', 'sh', '-c', `${envString} sway -c ${configPath}`], + stdout: 'piped', + stderr: 'piped', + stdin: 'null', + }); + + this.swayProcess = command.spawn(); + + // Log output in background + this.pipeOutput(this.swayProcess, 'sway'); + } + + /** + * Run a swaymsg command to control Sway + */ + async swaymsg(config: { runtimeDir: string; waylandDisplay: string }, command: string): Promise { + const env: Record = { + XDG_RUNTIME_DIR: config.runtimeDir, + WAYLAND_DISPLAY: config.waylandDisplay, + }; + + const envString = Object.entries(env) + .map(([k, v]) => `${k}=${v}`) + .join(' '); + + const cmd = new Deno.Command('runuser', { + args: [ + '-u', + this.user, + '--', + 'sh', + '-c', + `${envString} swaymsg '${command}'`, + ], + stdout: 'piped', + stderr: 'piped', + }); + + try { + const result = await cmd.output(); + if (!result.success) { + const stderr = new TextDecoder().decode(result.stderr); + console.error(`[swaymsg] Command failed: ${stderr}`); + } + return result.success; + } catch (error) { + console.error(`[swaymsg] Error: ${error}`); + return false; + } + } + + /** + * Start Chromium browser in kiosk mode + */ + async startBrowser(config: BrowserConfig): Promise { + const env: Record = { + HOME: `/home/${this.user}`, + XDG_RUNTIME_DIR: config.runtimeDir, + WAYLAND_DISPLAY: config.waylandDisplay, + XDG_CONFIG_HOME: `/home/${this.user}/.config`, + XDG_DATA_HOME: `/home/${this.user}/.local/share`, + }; + + // Chromium arguments for kiosk mode on Wayland + const browserArgs = [ + '--ozone-platform=wayland', + '--enable-features=UseOzonePlatform', + '--kiosk', + '--no-first-run', + '--disable-infobars', + '--disable-session-crashed-bubble', + '--disable-restore-session-state', + '--disable-background-networking', + '--disable-sync', + '--disable-translate', + '--noerrdialogs', + // Required for VM/headless/sandboxed environments + '--no-sandbox', + '--disable-setuid-sandbox', + '--disable-dev-shm-usage', + // GPU/rendering flags for VM environments + '--disable-gpu', + '--disable-gpu-compositing', + '--disable-gpu-sandbox', + '--disable-software-rasterizer', + '--disable-accelerated-2d-canvas', + '--disable-accelerated-video-decode', + '--use-gl=swiftshader', + '--in-process-gpu', + // Disable features that may cause issues in kiosk mode + '--disable-features=TranslateUI,VizDisplayCompositor', + '--disable-hang-monitor', + '--disable-breakpad', + '--disable-component-update', + config.url, + ]; + + // Build environment string for runuser + const envString = Object.entries(env) + .map(([k, v]) => `${k}=${v}`) + .join(' '); + + const command = new Deno.Command('runuser', { + args: [ + '-u', + this.user, + '--', + 'sh', + '-c', + `${envString} chromium-browser ${browserArgs.join(' ')}`, + ], + stdout: 'piped', + stderr: 'piped', + stdin: 'null', + }); + + this.browserProcess = command.spawn(); + + // Log output in background + this.pipeOutput(this.browserProcess, 'chromium'); + + // Force fullscreen via swaymsg after Chromium window appears (backup) + this.forceFullscreenAfterDelay(config); + } + + /** + * Force fullscreen for Chromium window after a delay (backup for kiosk mode) + */ + private async forceFullscreenAfterDelay(config: { runtimeDir: string; waylandDisplay: string }): Promise { + // Wait for Chromium window to appear + await new Promise((resolve) => setTimeout(resolve, 3000)); + + console.log('[chromium] Forcing fullscreen via swaymsg'); + + // Try multiple selectors to ensure we catch the window + const selectors = [ + '[app_id="chromium-browser"]', + '[app_id="chromium"]', + ]; + + for (const selector of selectors) { + await this.swaymsg(config, `${selector} fullscreen enable`); + } + + // Also try to focus the window + await this.swaymsg(config, '[app_id="chromium-browser"] focus'); + } + + isSwayRunning(): boolean { + return this.swayProcess !== null; + } + + isBrowserRunning(): boolean { + return this.browserProcess !== null; + } + + async stopSway(): Promise { + if (this.swayProcess) { + try { + this.swayProcess.kill('SIGTERM'); + await this.swayProcess.status; + } catch { + // Process may already be dead + } + this.swayProcess = null; + } + } + + async stopBrowser(): Promise { + if (this.browserProcess) { + try { + this.browserProcess.kill('SIGTERM'); + await this.browserProcess.status; + } catch { + // Process may already be dead + } + this.browserProcess = null; + } + } + + private async pipeOutput( + process: Deno.ChildProcess, + name: string + ): Promise { + const decoder = new TextDecoder(); + + // Pipe stdout + (async () => { + for await (const chunk of process.stdout) { + const text = decoder.decode(chunk); + for (const line of text.split('\n').filter((l) => l.trim())) { + console.log(`[${name}] ${line}`); + } + } + })(); + + // Pipe stderr + (async () => { + for await (const chunk of process.stderr) { + const text = decoder.decode(chunk); + for (const line of text.split('\n').filter((l) => l.trim())) { + console.error(`[${name}:err] ${line}`); + } + } + })(); + + // Monitor process exit + process.status.then((status) => { + console.log(`[${name}] Process exited with code ${status.code}`); + if (name === 'sway') { + this.swayProcess = null; + } else if (name === 'chromium') { + this.browserProcess = null; + } + }); + } +} diff --git a/ecoos_daemon/ts/daemon/system-info.ts b/ecoos_daemon/ts/daemon/system-info.ts new file mode 100644 index 0000000..7990548 --- /dev/null +++ b/ecoos_daemon/ts/daemon/system-info.ts @@ -0,0 +1,384 @@ +/** + * System Information + * + * Gathers CPU, RAM, disk, network, and GPU information + */ + +import { runCommand } from '../utils/command.ts'; + +export interface CpuInfo { + model: string; + cores: number; + usage: number; +} + +export interface MemoryInfo { + total: number; + used: number; + free: number; + usagePercent: number; +} + +export interface DiskInfo { + device: string; + mountpoint: string; + total: number; + used: number; + free: number; + usagePercent: number; +} + +export interface NetworkInterface { + name: string; + ip: string; + mac: string; + state: 'up' | 'down'; +} + +export interface GpuInfo { + name: string; + driver: string; +} + +export interface InputDevice { + name: string; + type: 'keyboard' | 'mouse' | 'touchpad' | 'other'; + path: string; +} + +export interface AudioDevice { + name: string; + description: string; + isDefault: boolean; +} + +export interface SystemInfoData { + hostname: string; + cpu: CpuInfo; + memory: MemoryInfo; + disks: DiskInfo[]; + network: NetworkInterface[]; + gpu: GpuInfo[]; + uptime: number; + inputDevices: InputDevice[]; + speakers: AudioDevice[]; + microphones: AudioDevice[]; +} + +export class SystemInfo { + async getInfo(): Promise { + const [hostname, cpu, memory, disks, network, gpu, uptime, inputDevices, speakers, microphones] = + await Promise.all([ + this.getHostname(), + this.getCpuInfo(), + this.getMemoryInfo(), + this.getDiskInfo(), + this.getNetworkInfo(), + this.getGpuInfo(), + this.getUptime(), + this.getInputDevices(), + this.getSpeakers(), + this.getMicrophones(), + ]); + + return { hostname, cpu, memory, disks, network, gpu, uptime, inputDevices, speakers, microphones }; + } + + private async getHostname(): Promise { + try { + const result = await runCommand('hostname', []); + if (!result.success) return 'unknown'; + return result.stdout.trim(); + } catch { + return 'unknown'; + } + } + + private async getCpuInfo(): Promise { + try { + const cpuinfo = await Deno.readTextFile('/proc/cpuinfo'); + const modelMatch = cpuinfo.match(/model name\s*:\s*(.+)/); + const coreMatches = cpuinfo.match(/processor\s*:/g); + + // Get CPU usage from /proc/stat + const stat = await Deno.readTextFile('/proc/stat'); + const cpuLine = stat.split('\n')[0]; + const values = cpuLine.split(/\s+/).slice(1).map(Number); + const total = values.reduce((a, b) => a + b, 0); + const idle = values[3]; + const usage = ((total - idle) / total) * 100; + + return { + model: modelMatch ? modelMatch[1] : 'Unknown', + cores: coreMatches ? coreMatches.length : 1, + usage: Math.round(usage * 10) / 10, + }; + } catch { + return { model: 'Unknown', cores: 1, usage: 0 }; + } + } + + private async getMemoryInfo(): Promise { + try { + const meminfo = await Deno.readTextFile('/proc/meminfo'); + const totalMatch = meminfo.match(/MemTotal:\s*(\d+)/); + const freeMatch = meminfo.match(/MemAvailable:\s*(\d+)/); + + const total = totalMatch ? parseInt(totalMatch[1], 10) * 1024 : 0; + const free = freeMatch ? parseInt(freeMatch[1], 10) * 1024 : 0; + const used = total - free; + + return { + total, + used, + free, + usagePercent: total > 0 ? Math.round((used / total) * 1000) / 10 : 0, + }; + } catch { + return { total: 0, used: 0, free: 0, usagePercent: 0 }; + } + } + + private async getDiskInfo(): Promise { + try { + const result = await runCommand('df', ['-B1', '--output=source,target,size,used,avail']); + if (!result.success) return []; + const lines = result.stdout.trim().split('\n').slice(1); + const disks: DiskInfo[] = []; + + for (const line of lines) { + const parts = line.trim().split(/\s+/); + if (parts.length >= 5 && parts[0].startsWith('/dev/')) { + const total = parseInt(parts[2], 10); + const used = parseInt(parts[3], 10); + const free = parseInt(parts[4], 10); + + disks.push({ + device: parts[0], + mountpoint: parts[1], + total, + used, + free, + usagePercent: total > 0 ? Math.round((used / total) * 1000) / 10 : 0, + }); + } + } + + return disks; + } catch { + return []; + } + } + + private async getNetworkInfo(): Promise { + try { + const cmdResult = await runCommand('ip', ['-j', 'addr']); + if (!cmdResult.success) return []; + const interfaces = JSON.parse(cmdResult.stdout); + const result: NetworkInterface[] = []; + + for (const iface of interfaces) { + if (iface.ifname === 'lo') continue; + + let ip = ''; + let mac = iface.address || ''; + + for (const addr of iface.addr_info || []) { + if (addr.family === 'inet') { + ip = addr.local; + break; + } + } + + result.push({ + name: iface.ifname, + ip: ip || 'No IP', + mac, + state: iface.operstate === 'UP' ? 'up' : 'down', + }); + } + + return result; + } catch { + return []; + } + } + + private async getGpuInfo(): Promise { + try { + const result = await runCommand('lspci', ['-mm']); + if (!result.success) return []; + const lines = result.stdout.split('\n'); + const gpus: GpuInfo[] = []; + + for (const line of lines) { + if (line.includes('VGA') || line.includes('3D') || line.includes('Display')) { + const parts = line.split('"'); + if (parts.length >= 6) { + gpus.push({ + name: parts[5] || 'Unknown GPU', + driver: 'unknown', + }); + } + } + } + + return gpus; + } catch { + return []; + } + } + + private async getUptime(): Promise { + try { + const uptime = await Deno.readTextFile('/proc/uptime'); + return Math.floor(parseFloat(uptime.split(' ')[0])); + } catch { + return 0; + } + } + + private async getInputDevices(): Promise { + try { + const content = await Deno.readTextFile('/proc/bus/input/devices'); + const devices: InputDevice[] = []; + const blocks = content.split('\n\n'); + + for (const block of blocks) { + if (!block.trim()) continue; + + const nameMatch = block.match(/N: Name="(.+)"/); + const handlersMatch = block.match(/H: Handlers=(.+)/); + + if (nameMatch && handlersMatch) { + const name = nameMatch[1]; + const handlers = handlersMatch[1]; + + // Find event device path + const eventMatch = handlers.match(/event\d+/); + const path = eventMatch ? `/dev/input/${eventMatch[0]}` : ''; + + // Determine device type + let type: 'keyboard' | 'mouse' | 'touchpad' | 'other' = 'other'; + const nameLower = name.toLowerCase(); + if (handlers.includes('kbd') || nameLower.includes('keyboard')) { + type = 'keyboard'; + } else if (nameLower.includes('touchpad') || nameLower.includes('trackpad')) { + type = 'touchpad'; + } else if (handlers.includes('mouse') || nameLower.includes('mouse')) { + type = 'mouse'; + } + + // Filter out system/virtual devices + if (!nameLower.includes('power button') && + !nameLower.includes('sleep button') && + !nameLower.includes('pc speaker') && + path) { + devices.push({ name, type, path }); + } + } + } + + return devices; + } catch { + return []; + } + } + + private async getSpeakers(): Promise { + try { + const result = await runCommand('pactl', ['-f', 'json', 'list', 'sinks']); + if (!result.success) { + // Fallback to aplay if pactl not available + return this.getSpeakersFromAplay(); + } + + const sinks = JSON.parse(result.stdout); + const defaultResult = await runCommand('pactl', ['get-default-sink']); + const defaultSink = defaultResult.success ? defaultResult.stdout.trim() : ''; + + return sinks.map((sink: { name: string; description: string }) => ({ + name: sink.name, + description: sink.description || sink.name, + isDefault: sink.name === defaultSink, + })); + } catch { + return this.getSpeakersFromAplay(); + } + } + + private async getSpeakersFromAplay(): Promise { + try { + const result = await runCommand('aplay', ['-l']); + if (!result.success) return []; + + const devices: AudioDevice[] = []; + const lines = result.stdout.split('\n'); + + for (const line of lines) { + const match = line.match(/card (\d+): (.+?) \[(.+?)\], device (\d+): (.+?) \[(.+?)\]/); + if (match) { + devices.push({ + name: `hw:${match[1]},${match[4]}`, + description: `${match[3]} - ${match[6]}`, + isDefault: devices.length === 0, + }); + } + } + + return devices; + } catch { + return []; + } + } + + private async getMicrophones(): Promise { + try { + const result = await runCommand('pactl', ['-f', 'json', 'list', 'sources']); + if (!result.success) { + // Fallback to arecord if pactl not available + return this.getMicrophonesFromArecord(); + } + + const sources = JSON.parse(result.stdout); + const defaultResult = await runCommand('pactl', ['get-default-source']); + const defaultSource = defaultResult.success ? defaultResult.stdout.trim() : ''; + + // Filter out monitor sources (they echo speaker output) + return sources + .filter((source: { name: string }) => !source.name.includes('.monitor')) + .map((source: { name: string; description: string }) => ({ + name: source.name, + description: source.description || source.name, + isDefault: source.name === defaultSource, + })); + } catch { + return this.getMicrophonesFromArecord(); + } + } + + private async getMicrophonesFromArecord(): Promise { + try { + const result = await runCommand('arecord', ['-l']); + if (!result.success) return []; + + const devices: AudioDevice[] = []; + const lines = result.stdout.split('\n'); + + for (const line of lines) { + const match = line.match(/card (\d+): (.+?) \[(.+?)\], device (\d+): (.+?) \[(.+?)\]/); + if (match) { + devices.push({ + name: `hw:${match[1]},${match[4]}`, + description: `${match[3]} - ${match[6]}`, + isDefault: devices.length === 0, + }); + } + } + + return devices; + } catch { + return []; + } + } +} diff --git a/ecoos_daemon/ts/ui/server.ts b/ecoos_daemon/ts/ui/server.ts new file mode 100644 index 0000000..bd1f576 --- /dev/null +++ b/ecoos_daemon/ts/ui/server.ts @@ -0,0 +1,539 @@ +/** + * UI Server + * + * HTTP server for the management UI on port 3006 + */ + +import type { EcoDaemon } from '../daemon/index.ts'; + +export class UIServer { + private port: number; + private daemon: EcoDaemon; + private clients: Set = new Set(); + + constructor(port: number, daemon: EcoDaemon) { + this.port = port; + this.daemon = daemon; + } + + async start(): Promise { + Deno.serve({ port: this.port, hostname: '0.0.0.0' }, (req) => + this.handleRequest(req) + ); + console.log(`Management UI running on http://0.0.0.0:${this.port}`); + } + + private async handleRequest(req: Request): Promise { + const url = new URL(req.url); + const path = url.pathname; + + // Handle WebSocket upgrade + if (path === '/ws') { + return this.handleWebSocket(req); + } + + // API routes + if (path.startsWith('/api/')) { + return this.handleApi(req, path); + } + + // Static files / UI + if (path === '/' || path === '/index.html') { + return this.serveHtml(); + } + + return new Response('Not Found', { status: 404 }); + } + + private handleWebSocket(req: Request): Response { + const { socket, response } = Deno.upgradeWebSocket(req); + + socket.onopen = () => { + this.clients.add(socket); + console.log('WebSocket client connected'); + }; + + socket.onclose = () => { + this.clients.delete(socket); + console.log('WebSocket client disconnected'); + }; + + socket.onerror = (e) => { + console.error('WebSocket error:', e); + this.clients.delete(socket); + }; + + return response; + } + + broadcast(data: unknown): void { + const message = JSON.stringify(data); + for (const client of this.clients) { + try { + client.send(message); + } catch { + this.clients.delete(client); + } + } + } + + private async handleApi(req: Request, path: string): Promise { + const headers = { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*', + }; + + if (path === '/api/status') { + const status = await this.daemon.getStatus(); + return new Response(JSON.stringify(status), { headers }); + } + + if (path === '/api/logs') { + const logs = this.daemon.getLogs(); + return new Response(JSON.stringify({ logs }), { headers }); + } + + if (path === '/api/reboot' && req.method === 'POST') { + const result = await this.daemon.rebootSystem(); + return new Response(JSON.stringify(result), { headers }); + } + + if (path === '/api/restart-chromium' && req.method === 'POST') { + const result = await this.daemon.restartChromium(); + return new Response(JSON.stringify(result), { headers }); + } + + return new Response(JSON.stringify({ error: 'Not Found' }), { + status: 404, + headers, + }); + } + + private serveHtml(): Response { + const html = ` + + + + + EcoOS Management + + + +
+

EcoOS Management

+
+
+

Services

+
+ + Sway Compositor +
+
+ + Chromium Browser +
+
+
+

CPU

+
+
Model
+
-
+
+
+
Cores
+
-
+
+
+
Usage
+
-
+
+
+
+
+

Memory

+
+
Used / Total
+
-
+
+
+
+
+

Network

+
+
+
+

Disks

+
+
+
+

System

+
+
Hostname
+
-
+
+
+
Uptime
+
-
+
+
+
GPU
+
-
+
+
+
+

Controls

+ + +
+
+
+

Input Devices

+
+
+
+

Speakers

+
+
+
+

Microphones

+
+
+
+

Logs

+
+
+
+
+ + +`; + + return new Response(html, { + headers: { 'Content-Type': 'text/html; charset=utf-8' }, + }); + } +} diff --git a/ecoos_daemon/ts/utils/command.ts b/ecoos_daemon/ts/utils/command.ts new file mode 100644 index 0000000..a3b8e88 --- /dev/null +++ b/ecoos_daemon/ts/utils/command.ts @@ -0,0 +1,27 @@ +/** + * Shared command execution utility + */ + +export interface CommandResult { + success: boolean; + stdout: string; + stderr: string; +} + +export async function runCommand( + cmd: string, + args: string[] +): Promise { + const command = new Deno.Command(cmd, { + args, + stdout: 'piped', + stderr: 'piped', + }); + + const result = await command.output(); + return { + success: result.success, + stdout: new TextDecoder().decode(result.stdout), + stderr: new TextDecoder().decode(result.stderr), + }; +} diff --git a/isobuild/Dockerfile b/isobuild/Dockerfile new file mode 100644 index 0000000..876531c --- /dev/null +++ b/isobuild/Dockerfile @@ -0,0 +1,346 @@ +# EcoOS ISO Builder +# Build from eco_os directory: +# docker build -t ecoos-builder -f isobuild/Dockerfile . +# docker run --privileged -v $(pwd)/isobuild/output:/output ecoos-builder + +FROM ubuntu:24.04 + +ENV DEBIAN_FRONTEND=noninteractive + +# Install build dependencies +RUN apt-get update && apt-get install -y \ + live-build \ + debootstrap \ + xorriso \ + squashfs-tools \ + grub-efi-amd64-bin \ + grub-efi-amd64-signed \ + grub-pc-bin \ + shim-signed \ + mtools \ + dosfstools \ + syslinux-utils \ + syslinux \ + syslinux-common \ + isolinux \ + curl \ + unzip \ + git \ + && rm -rf /var/lib/apt/lists/* \ + && ln -sf /usr/bin/isohybrid /usr/local/bin/isohybrid 2>/dev/null || true + +# Install Deno +RUN curl -fsSL https://deno.land/install.sh | DENO_INSTALL=/usr/local sh + +# Create build directory +WORKDIR /build + +# Copy isobuild configuration +COPY isobuild/config/ /build/config/ +COPY isobuild/scripts/ /build/scripts/ + +# Copy hooks to enable services (already in config/, but put in separate dir for build script) +COPY isobuild/config/hooks/ /build/hooks/ + +# Copy daemon source (for bundling) +COPY ecoos_daemon/ /daemon/ + +# Bundle the daemon +RUN cd /daemon && deno compile --allow-all --output /build/daemon-bundle/eco-daemon mod.ts + +# Download Chromium during Docker build (network works here, not in chroot hooks) +RUN echo "Downloading Chromium from official snapshots..." && \ + cd /tmp && \ + LATEST=$(curl -fsSL "https://www.googleapis.com/download/storage/v1/b/chromium-browser-snapshots/o/Linux_x64%2FLAST_CHANGE?alt=media" 2>/dev/null || echo "1368529") && \ + echo "Using Chromium build: $LATEST" && \ + curl -fsSL "https://www.googleapis.com/download/storage/v1/b/chromium-browser-snapshots/o/Linux_x64%2F${LATEST}%2Fchrome-linux.zip?alt=media" -o chromium.zip || \ + curl -fsSL "https://www.googleapis.com/download/storage/v1/b/chromium-browser-snapshots/o/Linux_x64%2F1368529%2Fchrome-linux.zip?alt=media" -o chromium.zip && \ + mkdir -p /build/chromium && \ + unzip -q chromium.zip -d /tmp && \ + mv /tmp/chrome-linux/* /build/chromium/ && \ + rm -rf chromium.zip /tmp/chrome-linux && \ + chmod +x /build/chromium/chrome && \ + echo "Chromium downloaded to /build/chromium/" + +# Make scripts executable +RUN chmod +x /build/scripts/*.sh + +# Create dummy isohybrid for UEFI-only builds (real isohybrid needs BIOS boot record) +RUN echo '#!/bin/sh' > /usr/local/bin/isohybrid && \ + echo 'echo "Skipping isohybrid (UEFI-only build)"' >> /usr/local/bin/isohybrid && \ + echo 'exit 0' >> /usr/local/bin/isohybrid && \ + chmod +x /usr/local/bin/isohybrid + +# Build script +COPY <<'EOF' /build/docker-build.sh +#!/bin/bash +set -e + +export PATH="/usr/local/bin:/usr/bin:/usr/sbin:/bin:/sbin:$PATH" + +echo "=== EcoOS ISO Builder (Docker) ===" + +cd /build + +# Initialize live-build - UEFI only (no syslinux/BIOS) +# Using German mirror for faster/more stable downloads +lb config \ + --architectures amd64 \ + --distribution noble \ + --archive-areas "main restricted universe multiverse" \ + --mirror-bootstrap "http://ftp.halifax.rwth-aachen.de/ubuntu/" \ + --mirror-chroot "http://ftp.halifax.rwth-aachen.de/ubuntu/" \ + --mirror-chroot-security "http://ftp.halifax.rwth-aachen.de/ubuntu/" \ + --mirror-binary "http://ftp.halifax.rwth-aachen.de/ubuntu/" \ + --mirror-binary-security "http://ftp.halifax.rwth-aachen.de/ubuntu/" \ + --binary-images iso-hybrid \ + --debian-installer false \ + --memtest none \ + --bootloader grub-efi \ + --iso-application "EcoOS" \ + --iso-publisher "EcoBridge" \ + --iso-volume "EcoOS" + +# Copy package lists +cp /build/config/live-build/package-lists/*.list.chroot config/package-lists/ + +# Prepare includes.chroot +mkdir -p config/includes.chroot/opt/eco/bin +mkdir -p config/includes.chroot/opt/eco/daemon +mkdir -p config/includes.chroot/etc/systemd/system + +# Copy daemon bundle +cp /build/daemon-bundle/eco-daemon config/includes.chroot/opt/eco/bin/ +chmod +x config/includes.chroot/opt/eco/bin/eco-daemon + +# Copy pre-downloaded Chromium +echo "Installing pre-downloaded Chromium into chroot..." +mkdir -p config/includes.chroot/opt/chromium +cp -r /build/chromium/* config/includes.chroot/opt/chromium/ +chmod +x config/includes.chroot/opt/chromium/chrome + +# Create symlinks for chromium-browser command +mkdir -p config/includes.chroot/usr/bin +cat > config/includes.chroot/usr/bin/chromium-browser << 'CHROMEWRAPPER' +#!/bin/sh +exec /opt/chromium/chrome "$@" +CHROMEWRAPPER +chmod +x config/includes.chroot/usr/bin/chromium-browser +ln -sf /opt/chromium/chrome config/includes.chroot/usr/bin/chromium +echo "Chromium installed to /opt/chromium/" + +# Create dummy isohybrid in chroot (UEFI-only, no BIOS boot record) +mkdir -p config/includes.chroot/usr/bin +cat > config/includes.chroot/usr/bin/isohybrid << 'ISOHYBRID' +#!/bin/sh +echo "Skipping isohybrid (UEFI-only build)" +exit 0 +ISOHYBRID +chmod +x config/includes.chroot/usr/bin/isohybrid + +# Copy systemd services +cp /build/config/systemd/eco-daemon.service config/includes.chroot/etc/systemd/system/ + +# Copy installer (files are already in config/includes.chroot via COPY) +chmod +x config/includes.chroot/opt/eco/installer/install.sh 2>/dev/null || true + +# Copy hooks to enable services +mkdir -p config/hooks/normal +cp /build/hooks/normal/*.hook.chroot config/hooks/normal/ +chmod +x config/hooks/normal/*.hook.chroot + +# Copy autoinstall config +mkdir -p config/includes.binary/autoinstall +cp /build/config/autoinstall/user-data config/includes.binary/autoinstall/ +touch config/includes.binary/autoinstall/meta-data + +# Prepare EFI boot files in includes.binary +echo "Preparing EFI boot structure..." +mkdir -p config/includes.binary/EFI/BOOT +mkdir -p config/includes.binary/boot/grub + +# Copy signed EFI files from host (installed in Docker image) +cp /usr/lib/shim/shimx64.efi.signed.latest config/includes.binary/EFI/BOOT/BOOTX64.EFI || \ +cp /usr/lib/shim/shimx64.efi.signed config/includes.binary/EFI/BOOT/BOOTX64.EFI || \ +cp /usr/lib/shim/shimx64.efi config/includes.binary/EFI/BOOT/BOOTX64.EFI || true + +cp /usr/lib/grub/x86_64-efi-signed/grubx64.efi.signed config/includes.binary/EFI/BOOT/grubx64.efi || \ +cp /usr/lib/grub/x86_64-efi/grubx64.efi config/includes.binary/EFI/BOOT/grubx64.efi || true + +# Also provide mmx64.efi for some UEFI implementations +if [ -f config/includes.binary/EFI/BOOT/grubx64.efi ]; then + cp config/includes.binary/EFI/BOOT/grubx64.efi config/includes.binary/EFI/BOOT/mmx64.efi +fi + +# Create grub.cfg for live boot with installer option +cat > config/includes.binary/boot/grub/grub.cfg << 'GRUBCFG' +set default=0 +set timeout=10 + +insmod part_gpt +insmod fat +insmod efi_gop +insmod efi_uga + +menuentry "Install EcoOS (auto-selects in 10s)" { + linux /casper/vmlinuz boot=casper noprompt quiet splash ecoos_install=1 --- + initrd /casper/initrd +} + +menuentry "EcoOS Live (Try without installing)" { + linux /casper/vmlinuz boot=casper noprompt quiet splash --- + initrd /casper/initrd +} + +menuentry "EcoOS Live (Safe Mode)" { + linux /casper/vmlinuz boot=casper noprompt nomodeset --- + initrd /casper/initrd +} +GRUBCFG + +# Also put grub.cfg in EFI/BOOT for fallback +cp config/includes.binary/boot/grub/grub.cfg config/includes.binary/EFI/BOOT/grub.cfg + +# Build ISO - use individual lb stages to control the process +lb bootstrap +lb chroot + +# Try lb binary, but continue even if isohybrid fails +lb binary || { + echo "lb binary had errors, checking if ISO was created anyway..." + if ls /build/*.iso 2>/dev/null; then + echo "ISO exists despite errors, continuing..." + else + echo "No ISO found, build truly failed" + exit 1 + fi +} + +# Check if EFI was created properly +echo "Checking binary directory for EFI..." +ls -la binary/EFI/BOOT/ 2>/dev/null || echo "EFI/BOOT not found in binary dir" + +# Find the ISO file +echo "Searching for ISO file..." +find /build -name "*.iso" -type f 2>/dev/null +ls -la /build/*.iso 2>/dev/null || true + +ISO_FILE=$(find /build -name "*.iso" -type f 2>/dev/null | head -1) +if [ -z "$ISO_FILE" ]; then + echo "ERROR: No ISO file found in build directory" + echo "Listing /build contents:" + ls -la /build/ + exit 1 +fi + +echo "Found ISO: $ISO_FILE" + +# Always create proper EFI boot image and rebuild ISO +echo "Creating UEFI-bootable ISO..." + +# Extract ISO contents +mkdir -p /tmp/iso_extract +xorriso -osirrox on -indev "$ISO_FILE" -extract / /tmp/iso_extract + +# Find the actual kernel and initrd names +VMLINUZ=$(ls /tmp/iso_extract/casper/vmlinuz* 2>/dev/null | head -1 | xargs basename) +INITRD=$(ls /tmp/iso_extract/casper/initrd* 2>/dev/null | head -1 | xargs basename) + +echo "Found kernel: $VMLINUZ, initrd: $INITRD" + +# Ensure EFI structure exists with proper files +mkdir -p /tmp/iso_extract/EFI/BOOT +mkdir -p /tmp/iso_extract/boot/grub + +# Copy EFI files from host +cp /usr/lib/shim/shimx64.efi.signed.latest /tmp/iso_extract/EFI/BOOT/BOOTX64.EFI 2>/dev/null || \ +cp /usr/lib/shim/shimx64.efi.signed /tmp/iso_extract/EFI/BOOT/BOOTX64.EFI 2>/dev/null || \ +cp /usr/lib/shim/shimx64.efi /tmp/iso_extract/EFI/BOOT/BOOTX64.EFI 2>/dev/null || true + +cp /usr/lib/grub/x86_64-efi-signed/grubx64.efi.signed /tmp/iso_extract/EFI/BOOT/grubx64.efi 2>/dev/null || \ +cp /usr/lib/grub/x86_64-efi/grubx64.efi /tmp/iso_extract/EFI/BOOT/grubx64.efi 2>/dev/null || true + +# Copy mmx64.efi for secure boot compatibility +if [ -f /tmp/iso_extract/EFI/BOOT/grubx64.efi ]; then + cp /tmp/iso_extract/EFI/BOOT/grubx64.efi /tmp/iso_extract/EFI/BOOT/mmx64.efi +fi + +# Create grub.cfg with correct filenames and installer option +cat > /tmp/iso_extract/boot/grub/grub.cfg << GRUBCFG2 +set default=0 +set timeout=10 + +insmod part_gpt +insmod fat +insmod efi_gop +insmod efi_uga + +menuentry "Install EcoOS (auto-selects in 10s)" { + linux /casper/${VMLINUZ} boot=casper noprompt quiet splash ecoos_install=1 --- + initrd /casper/${INITRD} +} + +menuentry "EcoOS Live (Try without installing)" { + linux /casper/${VMLINUZ} boot=casper noprompt quiet splash --- + initrd /casper/${INITRD} +} + +menuentry "EcoOS Live (Safe Mode)" { + linux /casper/${VMLINUZ} boot=casper noprompt nomodeset --- + initrd /casper/${INITRD} +} +GRUBCFG2 + +cp /tmp/iso_extract/boot/grub/grub.cfg /tmp/iso_extract/EFI/BOOT/grub.cfg + +# Create EFI boot image (FAT filesystem for UEFI El Torito boot) +echo "Creating EFI boot image..." +dd if=/dev/zero of=/tmp/efi.img bs=1M count=10 +mkfs.fat -F 12 /tmp/efi.img +mmd -i /tmp/efi.img ::/EFI +mmd -i /tmp/efi.img ::/EFI/BOOT +mcopy -i /tmp/efi.img /tmp/iso_extract/EFI/BOOT/BOOTX64.EFI ::/EFI/BOOT/ +mcopy -i /tmp/efi.img /tmp/iso_extract/EFI/BOOT/grubx64.efi ::/EFI/BOOT/ 2>/dev/null || true +mcopy -i /tmp/efi.img /tmp/iso_extract/EFI/BOOT/mmx64.efi ::/EFI/BOOT/ 2>/dev/null || true +mcopy -i /tmp/efi.img /tmp/iso_extract/EFI/BOOT/grub.cfg ::/EFI/BOOT/ + +# Rebuild ISO with EFI boot support (UEFI-only, no BIOS boot) +echo "Rebuilding ISO with UEFI boot support..." +xorriso -as mkisofs \ + -r -V "EcoOS" \ + -o /tmp/ecoos-efi.iso \ + -J -joliet-long \ + -eltorito-alt-boot \ + -e --interval:appended_partition_2:all:: \ + -no-emul-boot -isohybrid-gpt-basdat \ + -append_partition 2 0xef /tmp/efi.img \ + /tmp/iso_extract + +if [ -f /tmp/ecoos-efi.iso ]; then + ISO_FILE=/tmp/ecoos-efi.iso + echo "Created UEFI-bootable ISO: $ISO_FILE" +else + echo "ERROR: Failed to create EFI ISO" + exit 1 +fi + +rm -rf /tmp/iso_extract + +# Copy to output +mkdir -p /output +cp "$ISO_FILE" /output/ecoos.iso + +# Final verification +echo "" +echo "=== Final ISO EFI check ===" +xorriso -indev /output/ecoos.iso -find / -maxdepth 2 -type d 2>/dev/null || true + +echo "" +echo "=== Build Complete ===" +echo "ISO: /output/ecoos.iso" +ls -lh /output/ecoos.iso +EOF + +RUN chmod +x /build/docker-build.sh + +CMD ["/build/docker-build.sh"] diff --git a/isobuild/config/autoinstall/user-data b/isobuild/config/autoinstall/user-data new file mode 100644 index 0000000..3c5487e --- /dev/null +++ b/isobuild/config/autoinstall/user-data @@ -0,0 +1,72 @@ +#cloud-config +autoinstall: + version: 1 + + # Locale and keyboard + locale: en_US.UTF-8 + keyboard: + layout: us + + # Network configuration - DHCP on all interfaces + network: + network: + version: 2 + ethernets: + id0: + match: + driver: "*" + dhcp4: true + + # Storage - use entire disk + storage: + layout: + name: direct + + # Identity - create ecouser + identity: + hostname: ecoos + username: ecouser + # Password: ecouser (hashed with mkpasswd -m sha-512) + password: "$6$rounds=4096$randomsalt$n8Y5TqMKJZ5kM3LN0Y5fZ5Y5kM3LN0Y5fZ5Y5kM3LN0Y5fZ5Y5kM3LN0Y5fZ5Y5kM3LN0Y5fZ5Y5kM3LN0Y5f" + + # SSH + ssh: + install-server: true + allow-pw: true + + # Additional packages + packages: + - sway + - seatd + - pipewire + - pipewire-pulse + - foot + - curl + - git + - htop + + # Late commands - run after installation + late-commands: + # Add ecouser to required groups + - curtin in-target -- usermod -aG sudo,video,render,input,seat ecouser + + # Enable passwordless sudo for ecouser + - echo "ecouser ALL=(ALL) NOPASSWD:ALL" > /target/etc/sudoers.d/ecouser + - chmod 440 /target/etc/sudoers.d/ecouser + + # Enable seatd + - curtin in-target -- systemctl enable seatd + + # Install Deno + - curtin in-target -- curl -fsSL https://deno.land/install.sh | DENO_INSTALL=/opt/eco sh + + # Copy eco-daemon files (assumes they're on the ISO) + - cp -r /cdrom/eco/* /target/opt/eco/ + + # Enable eco-daemon service + - curtin in-target -- systemctl enable eco-daemon + + # Chromium is already installed in the ISO via live-build hook + + # Reboot after installation + shutdown: reboot diff --git a/isobuild/config/hooks/normal/0050-setup-ecouser.hook.chroot b/isobuild/config/hooks/normal/0050-setup-ecouser.hook.chroot new file mode 100755 index 0000000..783bc9f --- /dev/null +++ b/isobuild/config/hooks/normal/0050-setup-ecouser.hook.chroot @@ -0,0 +1,27 @@ +#!/bin/sh +# Create ecouser for running Sway and Chromium + +set -e + +echo "Creating ecouser..." + +# Create ecouser with home directory and GECOS field (prevents "I have no name!" in terminal) +useradd -m -s /bin/bash -c "EcoOS User" ecouser || true + +# Add ecouser to necessary groups: +# video,render - GPU access +# audio - audio access +# input - input devices +# seat - seatd compositor access +# sudo - sudo privileges +# adm,cdrom,plugdev - standard Ubuntu groups +usermod -aG video,render,audio,input,seat,sudo,adm,cdrom,plugdev ecouser || true + +# Set a default password (ecouser:ecouser) +echo "ecouser:ecouser" | chpasswd + +# Enable sudo without password for ecouser +echo "ecouser ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/ecouser +chmod 440 /etc/sudoers.d/ecouser + +echo "ecouser created." diff --git a/isobuild/config/hooks/normal/0055-fix-networkmanager.hook.chroot b/isobuild/config/hooks/normal/0055-fix-networkmanager.hook.chroot new file mode 100755 index 0000000..08ebd4e --- /dev/null +++ b/isobuild/config/hooks/normal/0055-fix-networkmanager.hook.chroot @@ -0,0 +1,14 @@ +#!/bin/sh +# Fix NetworkManager connection file permissions + +set -e + +echo "Fixing NetworkManager connection permissions..." + +# NetworkManager requires connection files to be owned by root:root with 600 permissions +if [ -d /etc/NetworkManager/system-connections ]; then + chown -R root:root /etc/NetworkManager/system-connections + chmod 600 /etc/NetworkManager/system-connections/*.nmconnection 2>/dev/null || true +fi + +echo "NetworkManager permissions fixed." diff --git a/isobuild/config/hooks/normal/0060-install-chromium.hook.chroot b/isobuild/config/hooks/normal/0060-install-chromium.hook.chroot new file mode 100755 index 0000000..e717f21 --- /dev/null +++ b/isobuild/config/hooks/normal/0060-install-chromium.hook.chroot @@ -0,0 +1,54 @@ +#!/bin/sh +# Install Chromium dependencies +# Chromium itself is pre-installed from Docker build (network works there) + +set -e + +echo "Installing Chromium dependencies..." + +# Verify Chromium was pre-installed from Docker build +if [ ! -x /opt/chromium/chrome ]; then + echo "ERROR: Chromium not found at /opt/chromium/chrome" + echo "This should have been installed during Docker build" + exit 1 +fi + +# Install required runtime dependencies for Chromium +# Using --no-install-recommends to minimize size +DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ + libasound2t64 \ + libatk-bridge2.0-0t64 \ + libatk1.0-0t64 \ + libatspi2.0-0t64 \ + libcairo2 \ + libcups2t64 \ + libdrm2 \ + libgbm1 \ + libgtk-3-0t64 \ + libnspr4 \ + libnss3 \ + libpango-1.0-0 \ + libxcomposite1 \ + libxdamage1 \ + libxfixes3 \ + libxkbcommon0 \ + libxrandr2 \ + fonts-liberation \ + xdg-utils || true + +# Verify the symlink exists +if [ ! -x /usr/bin/chromium-browser ]; then + echo "Creating chromium-browser symlink..." + cat > /usr/bin/chromium-browser << 'WRAPPER' +#!/bin/sh +exec /opt/chromium/chrome "$@" +WRAPPER + chmod +x /usr/bin/chromium-browser +fi + +echo "Chromium dependencies installed." +echo "Chromium available at:" +ls -la /opt/chromium/chrome +ls -la /usr/bin/chromium-browser + +echo "Chromium setup complete." diff --git a/isobuild/config/hooks/normal/0100-enable-services.hook.chroot b/isobuild/config/hooks/normal/0100-enable-services.hook.chroot new file mode 100755 index 0000000..f94dbc4 --- /dev/null +++ b/isobuild/config/hooks/normal/0100-enable-services.hook.chroot @@ -0,0 +1,29 @@ +#!/bin/sh +# Enable EcoOS services + +set -e + +echo "Enabling systemd-networkd for static IP..." +systemctl enable systemd-networkd.service +systemctl enable systemd-networkd-wait-online.service + +echo "Disabling NetworkManager (using networkd instead)..." +systemctl disable NetworkManager.service 2>/dev/null || true +systemctl mask NetworkManager.service 2>/dev/null || true + +echo "Enabling seatd service..." +systemctl enable seatd.service + +echo "Enabling eco-daemon service..." +systemctl enable eco-daemon.service + +echo "Enabling installer service..." +systemctl enable ecoos-installer.service + +echo "Enabling SSH service..." +systemctl enable ssh.service || true + +echo "Enabling debug service..." +systemctl enable debug-network.service || true + +echo "Services enabled." diff --git a/isobuild/config/hooks/normal/0200-fix-permissions.hook.chroot b/isobuild/config/hooks/normal/0200-fix-permissions.hook.chroot new file mode 100755 index 0000000..bc617bd --- /dev/null +++ b/isobuild/config/hooks/normal/0200-fix-permissions.hook.chroot @@ -0,0 +1,52 @@ +#!/bin/sh +# Final permissions fix before squashfs creation +# Ensures /etc and critical directories have correct permissions +# This is CRITICAL - wrong permissions break login, networking, and services + +set -e + +echo "Fixing critical directory permissions..." + +# /etc must be world-readable for systemd and other services to work +chmod 755 /etc + +# Fix all subdirectories in /etc that need to be readable +for dir in /etc/systemd /etc/systemd/system /etc/systemd/network \ + /etc/default /etc/security /etc/pam.d /etc/skel \ + /etc/profile.d /etc/sudoers.d /etc/bash_completion.d \ + /etc/apt /etc/dpkg /etc/ssl /etc/ssh /etc/sway; do + if [ -d "$dir" ]; then + chmod 755 "$dir" + fi +done + +# Critical files that must be world-readable for system to function +# These are essential for user/group lookups and shell login +for file in /etc/passwd /etc/group /etc/hosts /etc/hostname \ + /etc/profile /etc/bash.bashrc /etc/environment \ + /etc/shells /etc/nsswitch.conf /etc/resolv.conf \ + /etc/machine-id /etc/ld.so.conf; do + if [ -f "$file" ]; then + chmod 644 "$file" + fi +done + +# Shadow files should be root-only readable +chmod 640 /etc/shadow 2>/dev/null || true +chmod 640 /etc/gshadow 2>/dev/null || true + +# Sudoers files need specific permissions +chmod 440 /etc/sudoers 2>/dev/null || true +if [ -d /etc/sudoers.d ]; then + find /etc/sudoers.d -type f -exec chmod 440 {} \; +fi + +# Fix network config file permissions +if [ -f /etc/systemd/network/10-wired.network ]; then + chmod 644 /etc/systemd/network/10-wired.network +fi + +# Recursively fix /etc - directories should be 755, files 644 (except special cases) +find /etc -type d -exec chmod 755 {} \; 2>/dev/null || true + +echo "Permissions fixed." diff --git a/isobuild/config/includes.chroot/etc/NetworkManager/system-connections/wired.nmconnection b/isobuild/config/includes.chroot/etc/NetworkManager/system-connections/wired.nmconnection new file mode 100644 index 0000000..2cd8923 --- /dev/null +++ b/isobuild/config/includes.chroot/etc/NetworkManager/system-connections/wired.nmconnection @@ -0,0 +1,17 @@ +[connection] +id=Wired connection +uuid=2b8f4e84-9c7d-4b3e-8f2a-1d5e6f7a8b9c +type=ethernet +autoconnect=true +autoconnect-priority=100 + +[ethernet] + +[ipv4] +method=manual +addresses=10.0.2.15/24 +gateway=10.0.2.2 +dns=10.0.2.3; + +[ipv6] +method=ignore diff --git a/isobuild/config/includes.chroot/etc/systemd/network/10-wired.network b/isobuild/config/includes.chroot/etc/systemd/network/10-wired.network new file mode 100644 index 0000000..74717a1 --- /dev/null +++ b/isobuild/config/includes.chroot/etc/systemd/network/10-wired.network @@ -0,0 +1,5 @@ +[Match] +Name=ens* enp* eth* + +[Network] +DHCP=yes diff --git a/isobuild/config/includes.chroot/etc/systemd/system/debug-network.service b/isobuild/config/includes.chroot/etc/systemd/system/debug-network.service new file mode 100644 index 0000000..42c892e --- /dev/null +++ b/isobuild/config/includes.chroot/etc/systemd/system/debug-network.service @@ -0,0 +1,12 @@ +[Unit] +Description=Debug Network Info to Serial +After=network-online.target eco-daemon.service +Wants=network-online.target + +[Service] +Type=oneshot +RemainAfterExit=yes +ExecStart=/bin/bash -c 'echo "=== NETWORK DEBUG ===" > /dev/ttyS0; ip addr >> /dev/ttyS0; echo "=== ROUTES ===" >> /dev/ttyS0; ip route >> /dev/ttyS0; echo "=== LISTENING ===" >> /dev/ttyS0; ss -tlnp >> /dev/ttyS0; echo "=== NM STATUS ===" >> /dev/ttyS0; nmcli device status >> /dev/ttyS0 2>&1; nmcli connection show >> /dev/ttyS0 2>&1; echo "=== END DEBUG ===" >> /dev/ttyS0' + +[Install] +WantedBy=multi-user.target diff --git a/isobuild/config/includes.chroot/etc/systemd/system/eco-daemon.service b/isobuild/config/includes.chroot/etc/systemd/system/eco-daemon.service new file mode 100644 index 0000000..8449a90 --- /dev/null +++ b/isobuild/config/includes.chroot/etc/systemd/system/eco-daemon.service @@ -0,0 +1,24 @@ +[Unit] +Description=EcoOS Daemon +After=network-online.target seatd.service systemd-networkd-wait-online.service +Wants=seatd.service network-online.target +Requires=seatd.service +StartLimitIntervalSec=0 + +[Service] +Type=simple +ExecStart=/opt/eco/bin/eco-daemon +Restart=always +RestartSec=5 +WorkingDirectory=/opt/eco + +# Give daemon enough capabilities +AmbientCapabilities=CAP_NET_BIND_SERVICE CAP_SYS_ADMIN + +# Logging +StandardOutput=journal +StandardError=journal +SyslogIdentifier=eco-daemon + +[Install] +WantedBy=multi-user.target diff --git a/isobuild/config/includes.chroot/etc/systemd/system/ecoos-installer.service b/isobuild/config/includes.chroot/etc/systemd/system/ecoos-installer.service new file mode 100644 index 0000000..f137415 --- /dev/null +++ b/isobuild/config/includes.chroot/etc/systemd/system/ecoos-installer.service @@ -0,0 +1,18 @@ +[Unit] +Description=EcoOS Installer +After=multi-user.target getty@tty1.service +ConditionKernelCommandLine=ecoos_install=1 +Conflicts=getty@tty1.service + +[Service] +Type=oneshot +ExecStart=/opt/eco/installer/install.sh +StandardInput=tty +StandardOutput=tty +StandardError=tty +TTYPath=/dev/tty1 +TTYReset=yes +TTYVHangup=yes + +[Install] +WantedBy=multi-user.target diff --git a/isobuild/config/includes.chroot/etc/systemd/system/multi-user.target.wants/ecoos-installer.service b/isobuild/config/includes.chroot/etc/systemd/system/multi-user.target.wants/ecoos-installer.service new file mode 120000 index 0000000..fcde062 --- /dev/null +++ b/isobuild/config/includes.chroot/etc/systemd/system/multi-user.target.wants/ecoos-installer.service @@ -0,0 +1 @@ +/etc/systemd/system/ecoos-installer.service \ No newline at end of file diff --git a/isobuild/config/includes.chroot/opt/eco/bin/eco-daemon b/isobuild/config/includes.chroot/opt/eco/bin/eco-daemon new file mode 100755 index 0000000..7016f6e Binary files /dev/null and b/isobuild/config/includes.chroot/opt/eco/bin/eco-daemon differ diff --git a/isobuild/config/includes.chroot/opt/eco/debug-network.sh b/isobuild/config/includes.chroot/opt/eco/debug-network.sh new file mode 100755 index 0000000..4838c63 --- /dev/null +++ b/isobuild/config/includes.chroot/opt/eco/debug-network.sh @@ -0,0 +1,22 @@ +#!/bin/bash +# Debug network info - outputs to serial console +exec > /dev/ttyS0 2>&1 + +echo "=== NETWORK DEBUG ===" +echo "Date: $(date)" +echo "" +echo "=== IP ADDRESSES ===" +ip addr +echo "" +echo "=== ROUTES ===" +ip route +echo "" +echo "=== NETWORKMANAGER CONNECTIONS ===" +nmcli connection show +echo "" +echo "=== NETWORKMANAGER DEVICES ===" +nmcli device status +echo "" +echo "=== LISTENING PORTS ===" +ss -tlnp +echo "=== END DEBUG ===" diff --git a/isobuild/config/includes.chroot/opt/eco/installer/install.sh b/isobuild/config/includes.chroot/opt/eco/installer/install.sh new file mode 100755 index 0000000..c250128 --- /dev/null +++ b/isobuild/config/includes.chroot/opt/eco/installer/install.sh @@ -0,0 +1,545 @@ +#!/bin/bash +# +# EcoOS Installer +# Installs EcoOS from live USB to disk +# + +set -e + +# Configuration +TIMEOUT=10 +HOSTNAME="ecoos" +USERNAME="ecouser" +SQUASHFS_PATH="/run/live/medium/live/filesystem.squashfs" + +# Colors +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +log() { + echo -e "${GREEN}[EcoOS]${NC} $1" >&2 +} + +warn() { + echo -e "${YELLOW}[WARNING]${NC} $1" >&2 +} + +error() { + echo -e "${RED}[ERROR]${NC} $1" >&2 + exit 1 +} + +# Get the device the live system is running from +get_live_device() { + local live_dev="" + # Find the device backing /run/live/medium + if mountpoint -q /run/live/medium 2>/dev/null; then + live_dev=$(findmnt -n -o SOURCE /run/live/medium | sed 's/[0-9]*$//') + fi + # Also check /cdrom for older casper + if [ -z "$live_dev" ] && mountpoint -q /cdrom 2>/dev/null; then + live_dev=$(findmnt -n -o SOURCE /cdrom | sed 's/[0-9]*$//') + fi + echo "$live_dev" +} + +# List available disks (excluding live media and loop devices) +list_disks() { + local live_dev=$(get_live_device) + local disks=() + + for disk in /sys/block/sd* /sys/block/nvme* /sys/block/vd*; do + [ -e "$disk" ] || continue + local name=$(basename "$disk") + local dev="/dev/$name" + + # Skip if this is the live media + if [ "$dev" = "$live_dev" ]; then + continue + fi + + # Skip removable devices (USB sticks) - but allow if it's the only option + local removable=$(cat "$disk/removable" 2>/dev/null || echo "0") + + # Get size in GB + local size_bytes=$(cat "$disk/size" 2>/dev/null || echo "0") + local size_gb=$((size_bytes * 512 / 1024 / 1024 / 1024)) + + # Skip disks smaller than 10GB + if [ "$size_gb" -lt 10 ]; then + continue + fi + + # Get model + local model=$(cat "$disk/device/model" 2>/dev/null | tr -d '\n' || echo "Unknown") + + disks+=("$dev|$size_gb|$model|$removable") + done + + printf '%s\n' "${disks[@]}" +} + +# Select disk with timeout +# All UI output goes to stderr so stdout only returns the device path +select_disk() { + local disks + mapfile -t disks < <(list_disks) + + if [ ${#disks[@]} -eq 0 ]; then + error "No suitable disks found for installation" + fi + + echo "" >&2 + echo -e "${BLUE}╔════════════════════════════════════════════════════════════╗${NC}" >&2 + echo -e "${BLUE}║${NC} ${GREEN}EcoOS Disk Installation${NC} ${BLUE}║${NC}" >&2 + echo -e "${BLUE}╚════════════════════════════════════════════════════════════╝${NC}" >&2 + echo "" >&2 + echo "Available disks:" >&2 + echo "" >&2 + + local i=1 + local default_disk="" + local default_idx=1 + local max_size=0 + + for disk_info in "${disks[@]}"; do + IFS='|' read -r dev size model removable <<< "$disk_info" + + local marker="" + if [ "$size" -gt "$max_size" ]; then + max_size=$size + default_disk=$dev + default_idx=$i + fi + + printf " ${YELLOW}%d)${NC} %-12s %4d GB %s\n" "$i" "$dev" "$size" "$model" >&2 + ((i++)) + done + + echo "" >&2 + echo -e "Default: ${GREEN}$default_disk${NC} (largest disk)" >&2 + echo "" >&2 + echo -e "${YELLOW}WARNING: Selected disk will be COMPLETELY ERASED!${NC}" >&2 + echo "" >&2 + + # Countdown with input check + local selected="" + local remaining=$TIMEOUT + + while [ $remaining -gt 0 ]; do + printf "\rSelect disk [1-%d] or press Enter for default (%ds remaining): " "${#disks[@]}" "$remaining" >&2 + + if read -t 1 -n 1 input; then + if [ -z "$input" ]; then + # Enter pressed - use default + selected=$default_idx + break + elif [[ "$input" =~ ^[0-9]$ ]] && [ "$input" -ge 1 ] && [ "$input" -le ${#disks[@]} ]; then + selected=$input + echo "" >&2 + break + else + echo "" >&2 + warn "Invalid selection. Please enter 1-${#disks[@]}" + remaining=$TIMEOUT + fi + fi + ((remaining--)) + done + + if [ -z "$selected" ]; then + selected=$default_idx + echo "" >&2 + log "Timeout - auto-selecting default disk" + fi + + # Get selected disk + local idx=$((selected - 1)) + IFS='|' read -r TARGET_DISK size model removable <<< "${disks[$idx]}" + + echo "" >&2 + log "Selected: $TARGET_DISK ($size GB - $model)" + echo "" >&2 + + # Final confirmation with shorter timeout + echo -e "${RED}╔════════════════════════════════════════════════════════════╗${NC}" >&2 + echo -e "${RED}║ ALL DATA ON $TARGET_DISK WILL BE PERMANENTLY DESTROYED! ║${NC}" >&2 + echo -e "${RED}╚════════════════════════════════════════════════════════════╝${NC}" >&2 + echo "" >&2 + + local confirm_timeout=10 + printf "Press 'y' to confirm, any other key to cancel (%ds): " "$confirm_timeout" >&2 + + if read -t $confirm_timeout -n 1 confirm; then + echo "" >&2 + if [ "$confirm" != "y" ] && [ "$confirm" != "Y" ]; then + error "Installation cancelled by user" + fi + else + echo "" >&2 + log "Auto-confirming installation..." + fi + + # Only this goes to stdout - the actual device path + echo "$TARGET_DISK" +} + +# Partition the disk +partition_disk() { + local disk=$1 + + log "Partitioning $disk..." + + # Wipe existing partition table + wipefs -a "$disk" >/dev/null 2>&1 || true + + # Create GPT partition table + parted -s "$disk" mklabel gpt + + # Create EFI partition (512MB) + parted -s "$disk" mkpart ESP fat32 1MiB 513MiB + parted -s "$disk" set 1 esp on + + # Create root partition (rest of disk) + parted -s "$disk" mkpart root ext4 513MiB 100% + + # Wait for partitions to appear + sleep 2 + partprobe "$disk" + sleep 1 + + # Determine partition names (nvme vs sd) + if [[ "$disk" == *"nvme"* ]]; then + EFI_PART="${disk}p1" + ROOT_PART="${disk}p2" + else + EFI_PART="${disk}1" + ROOT_PART="${disk}2" + fi + + log "Created partitions: EFI=$EFI_PART, Root=$ROOT_PART" +} + +# Format partitions +format_partitions() { + log "Formatting partitions..." + + mkfs.fat -F 32 -n "EFI" "$EFI_PART" + mkfs.ext4 -F -L "EcoOS" "$ROOT_PART" + + log "Partitions formatted" +} + +# Mount partitions +mount_partitions() { + log "Mounting partitions..." + + mkdir -p /mnt/target + mount "$ROOT_PART" /mnt/target + + mkdir -p /mnt/target/boot/efi + mount "$EFI_PART" /mnt/target/boot/efi + + log "Partitions mounted at /mnt/target" +} + +# Copy system files +copy_system() { + log "Copying system files (this may take several minutes)..." + + # Check if squashfs exists + if [ ! -f "$SQUASHFS_PATH" ]; then + # Try alternative paths (including casper paths for Ubuntu) + for path in /run/live/medium/live/filesystem.squashfs \ + /cdrom/casper/filesystem.squashfs \ + /cdrom/live/filesystem.squashfs \ + /isodevice/casper/filesystem.squashfs \ + /lib/live/mount/medium/live/filesystem.squashfs \ + /rofs/../casper/filesystem.squashfs; do + if [ -f "$path" ]; then + SQUASHFS_PATH="$path" + break + fi + done + fi + + # If still not found, try to find it + if [ ! -f "$SQUASHFS_PATH" ]; then + local found=$(find /cdrom /run /media -name "filesystem.squashfs" 2>/dev/null | head -1) + if [ -n "$found" ]; then + SQUASHFS_PATH="$found" + fi + fi + + if [ ! -f "$SQUASHFS_PATH" ]; then + error "Cannot find filesystem.squashfs" + fi + + log "Extracting from $SQUASHFS_PATH..." + + # Extract squashfs + unsquashfs -f -d /mnt/target "$SQUASHFS_PATH" + + log "System files copied" +} + +# Configure the installed system +configure_system() { + log "Configuring system..." + + # Get UUIDs + local root_uuid=$(blkid -s UUID -o value "$ROOT_PART") + local efi_uuid=$(blkid -s UUID -o value "$EFI_PART") + + # Create fstab + cat > /mnt/target/etc/fstab << EOF +# EcoOS fstab +UUID=$root_uuid / ext4 defaults,noatime 0 1 +UUID=$efi_uuid /boot/efi vfat umask=0077 0 1 +EOF + + # Set hostname + echo "$HOSTNAME" > /mnt/target/etc/hostname + cat > /mnt/target/etc/hosts << EOF +127.0.0.1 localhost +127.0.1.1 $HOSTNAME + +::1 localhost ip6-localhost ip6-loopback +ff02::1 ip6-allnodes +ff02::2 ip6-allrouters +EOF + + # Ensure seat group exists (for seatd) + if ! grep -q "^seat:" /mnt/target/etc/group; then + chroot /mnt/target groupadd seat 2>/dev/null || true + fi + + # Ensure render group exists (for GPU access) + if ! grep -q "^render:" /mnt/target/etc/group; then + chroot /mnt/target groupadd render 2>/dev/null || true + fi + + # Ensure ecouser exists and is configured + # Groups: video,render (GPU), audio, input (devices), sudo, seat (seatd) + if ! grep -q "^$USERNAME:" /mnt/target/etc/passwd; then + chroot /mnt/target useradd -m -s /bin/bash -c "EcoOS User" -G video,render,audio,input,sudo,adm,cdrom,plugdev,seat "$USERNAME" + else + # Add to required groups if user already exists + chroot /mnt/target usermod -a -G video,render,seat "$USERNAME" 2>/dev/null || true + # Set the GECOS/full name field if missing + chroot /mnt/target chfn -f "EcoOS User" "$USERNAME" 2>/dev/null || true + fi + + # Create Sway config directory for ecouser + mkdir -p /mnt/target/home/$USERNAME/.config/sway + if [ -f /mnt/target/etc/sway/config ]; then + cp /mnt/target/etc/sway/config /mnt/target/home/$USERNAME/.config/sway/config + fi + chroot /mnt/target chown -R $USERNAME:$USERNAME /home/$USERNAME/.config 2>/dev/null || true + + # Set a default password (ecouser:ecouser) - should be changed on first boot + echo "$USERNAME:ecouser" | chroot /mnt/target chpasswd + + # Enable sudo for ecouser + echo "$USERNAME ALL=(ALL) NOPASSWD:ALL" > /mnt/target/etc/sudoers.d/ecouser + chmod 440 /mnt/target/etc/sudoers.d/ecouser + + # Remove live-boot packages marker if present + rm -f /mnt/target/etc/live 2>/dev/null || true + + # Enable systemd-networkd for static IP (more reliable than NetworkManager) + chroot /mnt/target systemctl enable systemd-networkd.service 2>/dev/null || true + chroot /mnt/target systemctl enable systemd-networkd-wait-online.service 2>/dev/null || true + + # Disable NetworkManager to avoid conflicts + chroot /mnt/target systemctl disable NetworkManager.service 2>/dev/null || true + chroot /mnt/target systemctl mask NetworkManager.service 2>/dev/null || true + + # Create network config for systemd-networkd (DHCP for QEMU/VMs) + mkdir -p /mnt/target/etc/systemd/network + cat > /mnt/target/etc/systemd/network/10-wired.network << 'NETEOF' +[Match] +Name=ens* enp* eth* + +[Network] +DHCP=yes +NETEOF + + # Fix critical directory permissions - /etc must be world-readable + # for systemd-networkd and other services to read their config files + # This is CRITICAL - squashfs may have wrong permissions from Docker build + log "Fixing /etc permissions..." + + # Fix /etc and all subdirectories recursively + find /mnt/target/etc -type d -exec chmod 755 {} \; + + # Fix critical files that must be world-readable + for file in passwd group hosts hostname profile bash.bashrc environment \ + shells nsswitch.conf resolv.conf machine-id ld.so.conf; do + if [ -f "/mnt/target/etc/$file" ]; then + chmod 644 "/mnt/target/etc/$file" + fi + done + + # Shadow files should be root-only readable + chmod 640 /mnt/target/etc/shadow 2>/dev/null || true + chmod 640 /mnt/target/etc/gshadow 2>/dev/null || true + + # Sudoers files need specific permissions + chmod 440 /mnt/target/etc/sudoers 2>/dev/null || true + find /mnt/target/etc/sudoers.d -type f -exec chmod 440 {} \; 2>/dev/null || true + + # Network config + chmod 644 /mnt/target/etc/systemd/network/10-wired.network + + log "systemd-networkd enabled for networking" + + # Enable other services for installed system + chroot /mnt/target systemctl enable eco-daemon.service 2>/dev/null || true + chroot /mnt/target systemctl enable seatd.service 2>/dev/null || true + chroot /mnt/target systemctl enable ssh.service 2>/dev/null || true + chroot /mnt/target systemctl enable debug-network.service 2>/dev/null || true + + log "System configured" +} + +# Install bootloader +install_bootloader() { + log "Installing GRUB bootloader..." + + # Mount necessary filesystems for chroot + mount --bind /dev /mnt/target/dev + mount --bind /dev/pts /mnt/target/dev/pts + mount --bind /proc /mnt/target/proc + mount --bind /sys /mnt/target/sys + mount --bind /run /mnt/target/run + + # Fix GRUB default config - remove casper/live boot parameters and add serial console + if [ -f /mnt/target/etc/default/grub ]; then + # Remove any boot=casper or live-related parameters + sed -i 's/boot=casper//g' /mnt/target/etc/default/grub + # Update GRUB_CMDLINE_LINUX_DEFAULT with serial console for debugging + sed -i 's/^GRUB_CMDLINE_LINUX_DEFAULT=.*/GRUB_CMDLINE_LINUX_DEFAULT="console=tty1 console=ttyS0,115200n8"/' /mnt/target/etc/default/grub + # If line doesn't exist, add it + if ! grep -q "GRUB_CMDLINE_LINUX_DEFAULT" /mnt/target/etc/default/grub; then + echo 'GRUB_CMDLINE_LINUX_DEFAULT="console=tty1 console=ttyS0,115200n8"' >> /mnt/target/etc/default/grub + fi + # Enable serial terminal in GRUB + echo 'GRUB_TERMINAL="console serial"' >> /mnt/target/etc/default/grub + echo 'GRUB_SERIAL_COMMAND="serial --speed=115200 --unit=0 --word=8 --parity=no --stop=1"' >> /mnt/target/etc/default/grub + fi + + # Disable casper-related services + log "Disabling live boot services..." + chroot /mnt/target systemctl disable casper.service 2>/dev/null || true + chroot /mnt/target systemctl disable casper-md5check.service 2>/dev/null || true + chroot /mnt/target systemctl mask casper.service 2>/dev/null || true + chroot /mnt/target systemctl mask casper-md5check.service 2>/dev/null || true + + # Remove casper initramfs hooks to prevent live-boot behavior + log "Removing casper initramfs hooks..." + rm -rf /mnt/target/usr/share/initramfs-tools/scripts/casper 2>/dev/null || true + rm -rf /mnt/target/usr/share/initramfs-tools/scripts/casper-premount 2>/dev/null || true + rm -rf /mnt/target/usr/share/initramfs-tools/scripts/casper-bottom 2>/dev/null || true + rm -f /mnt/target/usr/share/initramfs-tools/hooks/casper 2>/dev/null || true + rm -f /mnt/target/etc/initramfs-tools/conf.d/casper.conf 2>/dev/null || true + + # Regenerate initramfs without casper hooks + log "Regenerating initramfs..." + chroot /mnt/target update-initramfs -u -k all + + # Ensure proper boot target + chroot /mnt/target systemctl set-default multi-user.target 2>/dev/null || true + + # Install GRUB + chroot /mnt/target grub-install --target=x86_64-efi --efi-directory=/boot/efi --bootloader-id=EcoOS --recheck + + # Generate GRUB config + chroot /mnt/target update-grub + + # Cleanup mounts (use lazy unmount for stubborn mounts, reverse order) + sync + umount -l /mnt/target/run 2>/dev/null || true + umount -l /mnt/target/sys 2>/dev/null || true + umount -l /mnt/target/proc 2>/dev/null || true + umount -l /mnt/target/dev/pts 2>/dev/null || true + umount -l /mnt/target/dev 2>/dev/null || true + + log "Bootloader installed" +} + +# Cleanup and reboot +cleanup_and_reboot() { + log "Cleaning up..." + + # Sync disks + sync + + # Unmount + umount /mnt/target/boot/efi + umount /mnt/target + + echo "" + echo -e "${GREEN}╔════════════════════════════════════════════════════════════╗${NC}" + echo -e "${GREEN}║ EcoOS Installation Complete! ║${NC}" + echo -e "${GREEN}╚════════════════════════════════════════════════════════════╝${NC}" + echo "" + echo "The system will reboot in 10 seconds..." + echo "Remove the USB drive when the screen goes blank." + echo "" + + sleep 10 + reboot +} + +# Main installation flow +main() { + clear + + echo "" + echo -e "${GREEN}" + echo " ███████╗ ██████╗ ██████╗ ██████╗ ███████╗" + echo " ██╔════╝██╔════╝██╔═══██╗██╔═══██╗██╔════╝" + echo " █████╗ ██║ ██║ ██║██║ ██║███████╗" + echo " ██╔══╝ ██║ ██║ ██║██║ ██║╚════██║" + echo " ███████╗╚██████╗╚██████╔╝╚██████╔╝███████║" + echo " ╚══════╝ ╚═════╝ ╚═════╝ ╚═════╝ ╚══════╝" + echo -e "${NC}" + echo " System Installer v1.0" + echo "" + + # Check if running as root + if [ "$(id -u)" -ne 0 ]; then + error "This script must be run as root" + fi + + # Select target disk + TARGET_DISK=$(select_disk) + + # Partition disk + partition_disk "$TARGET_DISK" + + # Format partitions + format_partitions + + # Mount partitions + mount_partitions + + # Copy system + copy_system + + # Configure system + configure_system + + # Install bootloader + install_bootloader + + # Cleanup and reboot + cleanup_and_reboot +} + +# Run main function +main "$@" diff --git a/isobuild/config/live-build/auto/config b/isobuild/config/live-build/auto/config new file mode 100755 index 0000000..87a9c32 --- /dev/null +++ b/isobuild/config/live-build/auto/config @@ -0,0 +1,21 @@ +#!/bin/sh + +# EcoOS live-build configuration +# Note: EFI boot is handled manually in the Dockerfile build script + +lb config noauto \ + --architectures amd64 \ + --distribution noble \ + --archive-areas "main restricted universe multiverse" \ + --binary-images iso-hybrid \ + --bootappend-live "boot=casper noprompt quiet splash" \ + --debian-installer false \ + --memtest none \ + --firmware-binary true \ + --firmware-chroot true \ + --updates true \ + --security true \ + --iso-application "EcoOS" \ + --iso-publisher "EcoBridge" \ + --iso-volume "EcoOS" \ + "${@}" diff --git a/isobuild/config/live-build/package-lists/base.list.chroot b/isobuild/config/live-build/package-lists/base.list.chroot new file mode 100644 index 0000000..939a648 --- /dev/null +++ b/isobuild/config/live-build/package-lists/base.list.chroot @@ -0,0 +1,57 @@ +# EcoOS Base Packages +# System essentials +linux-image-generic +linux-headers-generic +systemd +dbus +network-manager +openssh-server +sudo + +# EFI bootloader (required for UEFI boot) +grub-efi-amd64 +grub-efi-amd64-signed +shim-signed + +# Sway + Wayland +sway +swaybg +swaylock +swayidle +foot +wl-clipboard +xwayland + +# Seat management +seatd +libseat1 + +# Tools +curl +wget +git +unzip +htop +vim +nano +tmux +jq + +# System utilities +pciutils +usbutils +dmidecode +lshw + +# Installer requirements +parted +squashfs-tools +dosfstools +e2fsprogs + +# Live-build binary phase requirements (pre-install to avoid DNS issues) +mtools +syslinux +syslinux-common +isolinux +genisoimage diff --git a/isobuild/config/live-build/package-lists/desktop.list.chroot b/isobuild/config/live-build/package-lists/desktop.list.chroot new file mode 100644 index 0000000..f09ad09 --- /dev/null +++ b/isobuild/config/live-build/package-lists/desktop.list.chroot @@ -0,0 +1,32 @@ +# EcoOS Desktop Packages + +# Audio +pipewire +pipewire-pulse +pipewire-alsa +wireplumber +libspa-0.2-bluetooth + +# Fonts +fonts-noto +fonts-noto-color-emoji +fonts-liberation +fonts-dejavu + +# Browser dependencies (Chromium installed via Dockerfile) +libnss3 +libatk1.0-0 +libatk-bridge2.0-0 +libcups2 +libdrm2 +libxkbcommon0 +libxcomposite1 +libxdamage1 +libxfixes3 +libxrandr2 +libgbm1 +libasound2t64 + +# Utilities +grim +slurp diff --git a/isobuild/config/live-build/package-lists/drivers.list.chroot b/isobuild/config/live-build/package-lists/drivers.list.chroot new file mode 100644 index 0000000..2c46e01 --- /dev/null +++ b/isobuild/config/live-build/package-lists/drivers.list.chroot @@ -0,0 +1,39 @@ +# EcoOS Driver Packages + +# GPU drivers - Mesa (open source) +xserver-xorg-video-all +mesa-utils +mesa-vulkan-drivers +libgl1-mesa-dri +libgbm1 +libegl1 + +# Intel GPU +intel-media-va-driver +libva-drm2 +libva2 + +# AMD GPU +libdrm-amdgpu1 + +# All firmware (Ubuntu combines into linux-firmware) +linux-firmware + +# Storage +nvme-cli +smartmontools +mdadm +lvm2 +cryptsetup + +# USB/Input +libinput-tools +libinput-bin + +# Bluetooth +bluez +bluez-tools + +# Virtualization support +qemu-guest-agent +open-vm-tools diff --git a/isobuild/config/systemd/eco-daemon.service b/isobuild/config/systemd/eco-daemon.service new file mode 100644 index 0000000..8449a90 --- /dev/null +++ b/isobuild/config/systemd/eco-daemon.service @@ -0,0 +1,24 @@ +[Unit] +Description=EcoOS Daemon +After=network-online.target seatd.service systemd-networkd-wait-online.service +Wants=seatd.service network-online.target +Requires=seatd.service +StartLimitIntervalSec=0 + +[Service] +Type=simple +ExecStart=/opt/eco/bin/eco-daemon +Restart=always +RestartSec=5 +WorkingDirectory=/opt/eco + +# Give daemon enough capabilities +AmbientCapabilities=CAP_NET_BIND_SERVICE CAP_SYS_ADMIN + +# Logging +StandardOutput=journal +StandardError=journal +SyslogIdentifier=eco-daemon + +[Install] +WantedBy=multi-user.target diff --git a/isobuild/scripts/build-iso.sh b/isobuild/scripts/build-iso.sh new file mode 100755 index 0000000..ab05473 --- /dev/null +++ b/isobuild/scripts/build-iso.sh @@ -0,0 +1,129 @@ +#!/bin/bash +# +# EcoOS ISO Build Script +# Wrapper script for building the EcoOS ISO +# + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(dirname "$SCRIPT_DIR")" + +echo "=== EcoOS ISO Builder ===" +echo "" + +# Check if running as root (required for live-build) +if [ "$EUID" -ne 0 ]; then + echo "This script requires root privileges for live-build." + echo "Running with sudo..." + exec sudo "$0" "$@" +fi + +# Check prerequisites +echo "[1/6] Checking prerequisites..." + +check_command() { + if ! command -v "$1" &> /dev/null; then + echo "Error: $1 is not installed" + echo "Install with: apt install $2" + exit 1 + fi +} + +check_command lb live-build +check_command debootstrap debootstrap +check_command xorriso xorriso +check_command deno deno + +echo " All prerequisites found." + +# Check Ubuntu version +. /etc/os-release +if [[ "$VERSION_ID" != "24.04" && "$VERSION_ID" != "24.10" ]]; then + echo "Warning: This script is designed for Ubuntu 24.04+" + echo "Current version: $VERSION_ID" +fi + +# Bundle the daemon +echo "" +echo "[2/6] Bundling ecoos_daemon..." + +DAEMON_DIR="$(dirname "$ROOT_DIR")/ecoos_daemon" +cd "$DAEMON_DIR" +deno compile --allow-all --output bundle/eco-daemon mod.ts +echo " Daemon bundled." + +# Prepare build directory +echo "" +echo "[3/6] Preparing build directory..." + +BUILD_DIR="$ROOT_DIR/build" +rm -rf "$BUILD_DIR" +mkdir -p "$BUILD_DIR" +cd "$BUILD_DIR" + +# Configure live-build +echo "" +echo "[4/6] Configuring live-build..." + +lb config \ + --architectures amd64 \ + --distribution noble \ + --archive-areas "main restricted universe multiverse" \ + --binary-images iso-hybrid \ + --debian-installer false \ + --memtest none \ + --firmware-binary true \ + --firmware-chroot true \ + --updates true \ + --security true \ + --bootloaders "grub-efi" \ + --uefi-secure-boot enable \ + --iso-application "EcoOS" \ + --iso-publisher "EcoBridge" \ + --iso-volume "EcoOS" + +# Copy package lists +echo "" +echo "[5/6] Copying configuration files..." + +cp "$ROOT_DIR/config/live-build/package-lists/"*.list.chroot config/package-lists/ + +# Prepare includes.chroot +mkdir -p config/includes.chroot/opt/eco/bin +mkdir -p config/includes.chroot/opt/eco/daemon +mkdir -p config/includes.chroot/etc/systemd/system + +# Copy daemon +cp "$DAEMON_DIR/bundle/eco-daemon" config/includes.chroot/opt/eco/bin/ +chmod +x config/includes.chroot/opt/eco/bin/eco-daemon + +# Copy daemon source as backup +cp -r "$DAEMON_DIR/ts" config/includes.chroot/opt/eco/daemon/ +cp "$DAEMON_DIR/mod.ts" config/includes.chroot/opt/eco/daemon/ +cp "$DAEMON_DIR/deno.json" config/includes.chroot/opt/eco/daemon/ + +# Copy systemd service +cp "$ROOT_DIR/config/systemd/eco-daemon.service" config/includes.chroot/etc/systemd/system/ + +# Copy autoinstall config to binary includes +mkdir -p config/includes.binary/autoinstall +cp "$ROOT_DIR/config/autoinstall/user-data" config/includes.binary/autoinstall/ +touch config/includes.binary/autoinstall/meta-data + +echo " Configuration complete." + +# Build ISO +echo "" +echo "[6/6] Building ISO (this may take 15-30 minutes)..." +echo "" + +lb build + +# Move ISO to output +mkdir -p "$ROOT_DIR/output" +mv *.iso "$ROOT_DIR/output/ecoos.iso" 2>/dev/null || true + +echo "" +echo "=== Build Complete ===" +echo "ISO: $ROOT_DIR/output/ecoos.iso" diff --git a/isobuild/scripts/docker-build.sh b/isobuild/scripts/docker-build.sh new file mode 100755 index 0000000..0382cc9 --- /dev/null +++ b/isobuild/scripts/docker-build.sh @@ -0,0 +1,34 @@ +#!/bin/bash +# +# Build EcoOS ISO using Docker +# This avoids needing to install live-build on the host +# + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ISOBUILD_DIR="$(dirname "$SCRIPT_DIR")" +ECO_OS_DIR="$(dirname "$ISOBUILD_DIR")" + +echo "=== EcoOS ISO Builder (Docker) ===" +echo "" + +cd "$ECO_OS_DIR" + +# Build the Docker image +echo "[1/2] Building Docker image..." +docker build -t ecoos-builder -f isobuild/Dockerfile . + +# Run the build +echo "" +echo "[2/2] Building ISO (this may take 15-30 minutes)..." +mkdir -p "$ISOBUILD_DIR/output" + +docker run --rm \ + --privileged \ + -v "$ISOBUILD_DIR/output:/output" \ + ecoos-builder + +echo "" +echo "=== Build Complete ===" +echo "ISO: $ISOBUILD_DIR/output/ecoos.iso" diff --git a/isobuild/scripts/test-qemu.sh b/isobuild/scripts/test-qemu.sh new file mode 100755 index 0000000..d1b009a --- /dev/null +++ b/isobuild/scripts/test-qemu.sh @@ -0,0 +1,45 @@ +#!/bin/bash +# +# Test EcoOS ISO in QEMU +# + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(dirname "$SCRIPT_DIR")" +ISO_PATH="$ROOT_DIR/output/ecoos.iso" + +if [ ! -f "$ISO_PATH" ]; then + echo "Error: ISO not found at $ISO_PATH" + echo "Run build-iso.sh first." + exit 1 +fi + +echo "Testing EcoOS ISO in QEMU..." +echo "ISO: $ISO_PATH" +echo "" +echo "Management UI will be available at: http://localhost:3006" +echo "" + +# Create a temporary disk for installation testing +DISK_PATH="/tmp/ecoos-test.qcow2" +if [ ! -f "$DISK_PATH" ]; then + echo "Creating test disk..." + qemu-img create -f qcow2 "$DISK_PATH" 20G +fi + +qemu-system-x86_64 \ + -enable-kvm \ + -m 4G \ + -cpu host \ + -smp 2 \ + -cdrom "$ISO_PATH" \ + -drive file="$DISK_PATH",format=qcow2,if=virtio \ + -boot d \ + -vga virtio \ + -display gtk \ + -device usb-tablet \ + -device virtio-net-pci,netdev=net0 \ + -netdev user,id=net0,hostfwd=tcp::3006-:3006,hostfwd=tcp::2222-:22 \ + -bios /usr/share/ovmf/OVMF.fd + +echo "" +echo "QEMU session ended." diff --git a/isotest/run-test.sh b/isotest/run-test.sh new file mode 100755 index 0000000..d564a20 --- /dev/null +++ b/isotest/run-test.sh @@ -0,0 +1,80 @@ +#!/bin/bash +set -e + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PROJECT_ROOT="$SCRIPT_DIR/.." +VM_DIR="$PROJECT_ROOT/.nogit/vm" +ISO_PATH="$PROJECT_ROOT/.nogit/iso/ecoos.iso" +DISK_PATH="$VM_DIR/test-disk.qcow2" +MONITOR_SOCK="$VM_DIR/qemu-monitor.sock" +SERIAL_SOCK="$VM_DIR/serial.sock" +SERIAL_LOG="$VM_DIR/serial.log" +PID_FILE="$VM_DIR/qemu.pid" + +# Create VM directory if not exists +mkdir -p "$VM_DIR" + +# Check if ISO exists +if [ ! -f "$ISO_PATH" ]; then + echo "ERROR: ISO not found at $ISO_PATH" + echo "Run 'pnpm run build' first to create the ISO" + exit 1 +fi + +# Create test disk if not exists +if [ ! -f "$DISK_PATH" ]; then + echo "Creating test disk (20GB)..." + qemu-img create -f qcow2 "$DISK_PATH" 20G +fi + +# Check if already running +if [ -f "$PID_FILE" ]; then + PID=$(cat "$PID_FILE") + if kill -0 "$PID" 2>/dev/null; then + echo "QEMU already running (PID: $PID)" + echo "Run 'pnpm run test:stop' to stop it first" + exit 1 + fi +fi + +echo "Starting QEMU with EcoOS ISO..." + +# Check if KVM is available +KVM_OPTS="" +if [ -e /dev/kvm ] && [ -r /dev/kvm ] && [ -w /dev/kvm ]; then + KVM_OPTS="-enable-kvm -cpu host" + echo "Using KVM acceleration" +else + echo "KVM not available, using software emulation (slower)" +fi + +# Start QEMU headless with VNC and serial console +> "$SERIAL_LOG" # Clear old log +qemu-system-x86_64 \ + $KVM_OPTS \ + -m 4G \ + -smp 2 \ + -bios /usr/share/qemu/OVMF.fd \ + -drive file="$ISO_PATH",media=cdrom \ + -drive file="$DISK_PATH",format=qcow2,if=virtio \ + -vga qxl \ + -display none \ + -vnc :0 \ + -serial unix:"$SERIAL_SOCK",server,nowait \ + -monitor unix:"$MONITOR_SOCK",server,nowait \ + -nic user,model=virtio-net-pci,hostfwd=tcp::3006-:3006,hostfwd=tcp::2222-:22 \ + -daemonize \ + -pidfile "$PID_FILE" + +echo "" +echo "=== EcoOS Test VM Started ===" +echo "PID: $(cat $PID_FILE)" +echo "VNC: localhost:5900" +echo "Serial Log: $SERIAL_LOG" +echo "Management UI: http://localhost:3006" +echo "" +echo "Commands:" +echo " pnpm run test:screenshot - Take screenshot" +echo " pnpm run test:stop - Stop VM" +echo " tail -f $SERIAL_LOG - Watch serial console" +echo " socat - UNIX-CONNECT:$SERIAL_SOCK - Interactive serial" diff --git a/isotest/screenshot.sh b/isotest/screenshot.sh new file mode 100755 index 0000000..8372e89 --- /dev/null +++ b/isotest/screenshot.sh @@ -0,0 +1,49 @@ +#!/bin/bash + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PROJECT_ROOT="$SCRIPT_DIR/.." +VM_DIR="$PROJECT_ROOT/.nogit/vm" +SCREENSHOT_DIR="$PROJECT_ROOT/.nogit/screenshots" +MONITOR_SOCK="$VM_DIR/qemu-monitor.sock" +TIMESTAMP=$(date +%Y%m%d-%H%M%S) + +# Check if QEMU is running +if [ ! -S "$MONITOR_SOCK" ]; then + echo "ERROR: QEMU not running (no monitor socket)" + echo "Run 'pnpm run test' first to start the VM" + exit 1 +fi + +mkdir -p "$SCREENSHOT_DIR" +PPM_FILE="$SCREENSHOT_DIR/ecoos-$TIMESTAMP.ppm" +PNG_FILE="$SCREENSHOT_DIR/ecoos-$TIMESTAMP.png" +LATEST_FILE="$SCREENSHOT_DIR/latest.png" + +echo "Taking screenshot..." +echo "screendump $PPM_FILE" | socat - UNIX-CONNECT:"$MONITOR_SOCK" +sleep 1 + +# Check if PPM was created +if [ ! -f "$PPM_FILE" ]; then + echo "ERROR: Screenshot failed" + exit 1 +fi + +# Convert to PNG if imagemagick is available +if command -v convert &> /dev/null; then + convert "$PPM_FILE" "$PNG_FILE" + rm "$PPM_FILE" + + # Copy to latest.png + cp "$PNG_FILE" "$LATEST_FILE" + + echo "Screenshot saved: $PNG_FILE" + echo "Also saved as: $LATEST_FILE" +else + echo "Screenshot saved: $PPM_FILE" + echo "(Install imagemagick to auto-convert to PNG)" +fi + +# Keep only last 20 screenshots (excluding latest.png) +cd "$SCREENSHOT_DIR" +ls -t ecoos-*.png 2>/dev/null | tail -n +21 | xargs -r rm -f diff --git a/isotest/stop.sh b/isotest/stop.sh new file mode 100755 index 0000000..a2cba4f --- /dev/null +++ b/isotest/stop.sh @@ -0,0 +1,21 @@ +#!/bin/bash + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PROJECT_ROOT="$SCRIPT_DIR/.." +VM_DIR="$PROJECT_ROOT/.nogit/vm" +PID_FILE="$VM_DIR/qemu.pid" + +if [ -f "$PID_FILE" ]; then + PID=$(cat "$PID_FILE") + if kill -0 "$PID" 2>/dev/null; then + echo "Stopping QEMU (PID: $PID)..." + kill "$PID" + sleep 1 + fi + rm -f "$PID_FILE" + rm -f "$VM_DIR/qemu-monitor.sock" + rm -f "$VM_DIR/serial.sock" + echo "QEMU stopped" +else + echo "QEMU not running (no PID file)" +fi diff --git a/package.json b/package.json new file mode 100644 index 0000000..28835a4 --- /dev/null +++ b/package.json @@ -0,0 +1,15 @@ +{ + "name": "@ecobridge/eco-os", + "private": true, + "scripts": { + "build": "pnpm run daemon:bundle && cp ecoos_daemon/bundle/eco-daemon isobuild/config/includes.chroot/opt/eco/bin/ && mkdir -p .nogit/iso && docker build --no-cache -t ecoos-builder -f isobuild/Dockerfile . && docker run --rm --privileged -v $(pwd)/.nogit/iso:/output ecoos-builder", + "daemon:dev": "cd ecoos_daemon && deno run --allow-all --watch mod.ts", + "daemon:start": "cd ecoos_daemon && deno run --allow-all mod.ts", + "daemon:bundle": "cd ecoos_daemon && deno compile --allow-all --output bundle/eco-daemon mod.ts", + "test": "cd isotest && ./run-test.sh", + "test:screenshot": "cd isotest && ./screenshot.sh", + "test:screenshot:loop": "while true; do pnpm run test:screenshot; sleep 5; done", + "test:stop": "cd isotest && ./stop.sh", + "clean": "rm -rf .nogit/iso/*.iso .nogit/vm/*.qcow2 .nogit/screenshots/*" + } +} diff --git a/readme.hints.md b/readme.hints.md new file mode 100644 index 0000000..82d738c --- /dev/null +++ b/readme.hints.md @@ -0,0 +1,135 @@ +# EcoOS Project Hints + +## Project Structure + +``` +eco_os/ +├── ecoos_daemon/ # Daemon source code (Deno/TypeScript) +├── isobuild/ # ISO build configuration (Dockerfile, hooks, includes) +├── isotest/ # Test scripts (run-test.sh, screenshot.sh, stop.sh) +└── .nogit/ # Generated files (not in git) + ├── iso/ # Built ISO (ecoos.iso) + ├── vm/ # QEMU files (disk, sockets, logs) + └── screenshots/ # VM screenshots +``` + +## Build & Test Commands (package.json) + +```bash +# Build ISO (auto-rebuilds daemon first) +pnpm run build + +# Test ISO in QEMU +pnpm run test + +# Take screenshot +pnpm run test:screenshot + +# Stop QEMU VM +pnpm run test:stop + +# Clean build artifacts +pnpm run clean + +# Daemon development (watch mode) +pnpm run daemon:dev + +# Bundle daemon to binary +pnpm run daemon:bundle +``` + +## Current Network Configuration + +Using **systemd-networkd** (NOT NetworkManager) with DHCP: + +- Config file: `/etc/systemd/network/10-wired.network` +- Matches: `ens*`, `enp*`, `eth*` +- Uses DHCP (QEMU user networking provides DHCP) + +The installer (`install.sh`) creates this config explicitly during installation. + +## Browser: Chromium (not Firefox, not Google Chrome) + +The project uses **Chromium** for the kiosk browser. + +### Key Files +- `isobuild/config/hooks/normal/0060-install-chromium.hook.chroot` - Installs Chromium dependencies +- `ecoos_daemon/ts/daemon/process-manager.ts` - Launches Chromium with Wayland support + +### Why Chromium from Debian? +Ubuntu 24.04 made `chromium-browser` snap-only. Snap doesn't work in live-build chroot environments. +The hook adds Debian sid repo temporarily to install the real Chromium package. + +## Sway Configuration + +Sway config is **generated dynamically** by the daemon at runtime, NOT from a static file. + +The daemon writes the config to `~/.config/sway/config` before starting Sway with `-c` flag. + +### Chromium Kiosk Flags +``` +--ozone-platform=wayland +--kiosk +--no-first-run +--disable-infobars +--disable-session-crashed-bubble +--disable-restore-session-state +``` + +### Chromium app_id on Wayland +Chromium identifies as `chromium-browser` or `Chromium-browser` in Sway. + +## eco-daemon Binary + +The binary at `isobuild/config/includes.chroot/opt/eco/bin/eco-daemon` is compiled from `ecoos_daemon/` using Deno. + +**Note:** `pnpm run build` automatically rebuilds the daemon before building the ISO. + +## ISO Boot Menu + +1. Install EcoOS (auto-selects in 10s) - Default +2. EcoOS Live (Try without installing) +3. EcoOS Live (Safe Mode) + +## CRITICAL: Sway + QEMU Display Fix + +**ISSUE**: Sway shows gray/black screen with windows not rendering properly in QEMU. + +**FIX**: In `isotest/run-test.sh`, use `-vga qxl` instead of `-device virtio-vga` + +Source: ArchWiki Sway page, GitHub issue swaywm/sway#6210 + +## CRITICAL: Hook Permissions + +**ISSUE**: Chromium not installed in ISO. + +**FIX**: All hook files must be `chmod +x`: +```bash +chmod +x isobuild/config/hooks/normal/*.hook.chroot +``` + +If hooks aren't executable, live-build SKIPS them silently! + +## Serial Console Debugging + +Use socat to connect to QEMU serial console: +```bash +socat - UNIX-CONNECT:.nogit/vm/serial.sock +``` + +Login: `ecouser/ecouser`, then `sudo -i` for root. + +## Lesson: ALWAYS RESEARCH BEFORE FIXING + +When encountering issues: +1. DO NOT assume the same fix will work repeatedly +2. DO research the issue online first +3. Check ArchWiki, GitHub issues, official docs +4. Understand WHY the fix works +5. Document findings in readme.hints.md + +## Testing Protocol + +- Take screenshots every 10 seconds during testing, NOT 45+ seconds +- Use serial console for debugging network/service issues +- Check `ip addr` on guest to verify network interface has IP diff --git a/readme.plan.md b/readme.plan.md new file mode 100644 index 0000000..af84399 --- /dev/null +++ b/readme.plan.md @@ -0,0 +1,229 @@ +# EcoOS - System Documentation + +## What Is This? + +Custom Ubuntu 24.04 LTS ISO that boots into a kiosk mode with: +- **Sway** (Wayland compositor) - managed by the daemon +- **Chromium Browser** in kiosk mode - managed by the daemon +- **eco-daemon** - Deno binary that orchestrates everything +- **Management UI** on port 3006 + +## REQUIREMENT: Sway + Chromium Browser + +**The system MUST use Chromium browser, NOT Google Chrome.** + +### Challenge +Ubuntu 24.04 made `chromium-browser` snap-only. Snap doesn't work in live-build chroot environments. + +### Solution +Download Chromium directly from official builds or use Debian's chromium package: +- Option A: Download from https://download-chromium.appspot.com/ (Linux_x64) +- Option B: Use Debian's chromium .deb package from sid/unstable +- Option C: Use ungoogled-chromium from OBS (OpenSUSE Build Service) + +The daemon must call `chromium-browser` or `chromium` command. + +## CRITICAL: Boot Flow + +### GRUB Boot Menu (60s timeout) +When the ISO boots, GRUB shows: +1. **"Install EcoOS"** - DEFAULT, auto-selects after 60 seconds +2. "EcoOS Live" - Try without installing +3. "EcoOS Live (Safe Mode)" + +### Install Mode (`ecoos_install=1`) +When "Install EcoOS" is selected: +1. Kernel boots with `ecoos_install=1` parameter +2. `ecoos-installer.service` runs (has `ConditionKernelCommandLine=ecoos_install=1`) +3. Installer at `/opt/eco/installer/install.sh` runs interactively on tty1 +4. Installer writes to disk, sets up GRUB bootloader +5. System reboots +6. **Now boots from installed disk, NOT the ISO** + +### Live Mode (No install) +When "EcoOS Live" is selected: +1. Boots into RAM (squashfs) +2. eco-daemon starts via systemd +3. Sway + Chromium should start +4. **No persistent storage** - changes lost on reboot + +### After Installation (Normal Boot) +1. System boots from installed disk +2. eco-daemon.service starts +3. Daemon writes Sway config to `~/.config/sway/config` +4. Daemon starts Sway compositor +5. Daemon starts Chromium in kiosk mode pointing to localhost:3006 +6. Management UI serves on port 3006 + +## Architecture + +``` +eco_os/ +├── ecoos_daemon/ # Deno daemon source +│ └── ts/ +│ ├── daemon/ +│ │ ├── index.ts # Main daemon orchestration +│ │ ├── process-manager.ts # Sway/Chromium process control +│ │ └── system-info.ts # CPU/memory stats +│ └── ui/ +│ └── server.ts # HTTP server + WebSocket +├── isobuild/ +│ ├── Dockerfile # Docker build environment +│ ├── config/ +│ │ ├── hooks/normal/ # Chroot hooks (MUST BE EXECUTABLE) +│ │ │ ├── 0050-setup-ecouser.hook.chroot +│ │ │ ├── 0055-fix-networkmanager.hook.chroot +│ │ │ ├── 0060-install-chromium.hook.chroot # Installs Chromium +│ │ │ └── 0100-enable-services.hook.chroot +│ │ ├── includes.chroot/ # Files copied into ISO +│ │ │ ├── etc/ +│ │ │ │ ├── NetworkManager/system-connections/wired.nmconnection +│ │ │ │ └── systemd/system/ +│ │ │ │ ├── eco-daemon.service +│ │ │ │ └── ecoos-installer.service +│ │ │ └── opt/eco/ +│ │ │ ├── bin/eco-daemon # Compiled daemon binary +│ │ │ └── installer/install.sh +│ │ └── systemd/eco-daemon.service +│ └── output/ # Built ISOs +└── isotest/ # QEMU test environment + ├── run-test.sh + ├── test-disk.qcow2 # Persistent test disk + └── screenshots/ +``` + +## Daemon Behavior + +### Sway Config Generation +The daemon **generates Sway config at runtime** - there is NO static config file. + +Location: `~/.config/sway/config` (written before Sway starts) + +Features: +- Black background +- 1920x1080 resolution +- No window borders +- All windows forced fullscreen +- Chromium app_id rules for fullscreen + +### Chromium Launch +Command: `chromium-browser` (or `chromium`) +Flags: +- `--ozone-platform=wayland` +- `--kiosk` +- `--no-first-run` +- `--disable-infobars` +- URL: `http://localhost:3006` + +### Port 3006 Management UI +- Serves HTML dashboard +- Shows Sway/Chromium status +- Shows CPU/memory stats +- Shows daemon logs +- WebSocket for live updates + +## Build Process + +### Prerequisites +```bash +docker # For isolated build environment +``` + +### Build Commands +```bash +# 1. Build Docker image (includes daemon compilation) +npm run build:docker + +# 2. Build ISO (takes ~5-10 minutes) +npm run build + +# ISO output: isobuild/output/ecoos.iso +``` + +### IMPORTANT: Hook Permissions +All files in `isobuild/config/hooks/normal/*.hook.chroot` MUST be executable: +```bash +chmod +x isobuild/config/hooks/normal/*.hook.chroot +``` + +If hooks aren't executable, Chromium won't be installed! + +## Testing in QEMU + +### Start Test VM +```bash +npm run test +``` +This: +- Creates test-disk.qcow2 if needed (20GB) +- Boots ISO with UEFI +- Port forwards 3006 and 22 +- Runs headless with VNC on :0 + +### Access +- **VNC**: `localhost:5900` (view display) +- **SSH**: `localhost:2222` (after install, user: ecouser) +- **Management UI**: `localhost:3006` (after eco-daemon starts) + +### Check Progress +```bash +# View serial console +tail -f isotest/serial.log + +# Take screenshot +npm run test:screenshot + +# Stop VM +npm run test:stop +``` + +### Fresh Install Test +To test a fresh installation: +```bash +npm run test:stop +rm isotest/test-disk.qcow2 +npm run test +# Wait 60s for auto-install, or use VNC to watch +``` + +## Network Configuration + +### QEMU User Mode Networking +- VM gets IP via DHCP (usually 10.0.2.15) +- Port 3006 forwarded: host:3006 -> guest:3006 +- Port 2222 forwarded: host:2222 -> guest:22 + +### NetworkManager Config +File: `/etc/NetworkManager/system-connections/wired.nmconnection` +- Configures wired ethernet with DHCP +- Permissions fixed by 0055-fix-networkmanager.hook.chroot + +## Troubleshooting + +### Chromium Not Starting +1. Check if Chromium is installed: `which chromium-browser` +2. Check hook permissions: `ls -la isobuild/config/hooks/normal/` +3. Rebuild Docker image: `npm run build:docker` + +### Port 3006 Not Accessible +1. Check eco-daemon status: `systemctl status eco-daemon` +2. Check daemon logs: `journalctl -u eco-daemon -f` +3. Check NetworkManager: `nmcli device status` + +### Sway Not Starting +1. Check seatd: `systemctl status seatd` +2. Check daemon logs for Sway errors +3. Verify ecouser exists: `id ecouser` + +### Boot Stuck in Live Mode +- GRUB menu shows for 60 seconds +- Default option should be "Install EcoOS" +- Check if `ecoos_install=1` is in kernel cmdline + +## Version Info + +- Base: Ubuntu 24.04 LTS (Noble) +- Kernel: 6.8.0-90 +- Sway: From Ubuntu repos +- Chromium: From Debian sid/unstable or official Chromium builds +- Deno: Latest (for daemon compilation)