This commit is contained in:
2026-01-08 18:33:14 +00:00
commit 51c83f846a
38 changed files with 3876 additions and 0 deletions

47
.gitignore vendored Normal file
View File

@@ -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

19
ecoos_daemon/deno.json Normal file
View File

@@ -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
}
}

14
ecoos_daemon/mod.ts Normal file
View File

@@ -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();

View File

@@ -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<DaemonConfig>) {
this.config = {
uiPort: 3006,
user: 'ecouser',
waylandDisplay: 'wayland-1',
...config,
};
this.processManager = new ProcessManager(this.config.user);
this.systemInfo = new SystemInfo();
this.uiServer = new UIServer(this.config.uiPort, this);
}
log(message: string): void {
const timestamp = new Date().toISOString();
const entry = `[${timestamp}] ${message}`;
this.logs.push(entry);
console.log(entry);
// Keep last 1000 log entries
if (this.logs.length > 1000) {
this.logs = this.logs.slice(-1000);
}
}
getLogs(): string[] {
return [...this.logs];
}
async getStatus(): Promise<Record<string, unknown>> {
const systemInfo = await this.systemInfo.getInfo();
return {
sway: this.swayStatus.state === 'running',
swayStatus: this.swayStatus,
chromium: this.chromiumStatus.state === 'running',
chromiumStatus: this.chromiumStatus,
systemInfo,
logs: this.logs.slice(-50),
};
}
async rebootSystem(): Promise<{ success: boolean; message: string }> {
this.log('System reboot requested...');
try {
const result = await runCommand('systemctl', ['reboot']);
if (result.success) {
return { success: true, message: 'System is rebooting...' };
}
return { success: false, message: 'Failed to reboot: ' + result.stderr };
} catch (error) {
this.log(`Reboot failed: ${error}`);
return { success: false, message: String(error) };
}
}
async restartChromium(): Promise<{ success: boolean; message: string }> {
this.log('Chromium restart requested...');
if (this.swayStatus.state !== 'running') {
return { success: false, message: 'Cannot restart Chromium: Sway is not running' };
}
try {
// Stop existing Chromium
await this.processManager.stopBrowser();
this.chromiumStatus = { state: 'stopped' };
// Wait a moment before restarting
await new Promise((resolve) => setTimeout(resolve, 1000));
// Start Chromium again
await this.startChromium();
this.chromiumStatus = { state: 'running' };
this.log('Chromium browser restarted successfully');
return { success: true, message: 'Chromium restarted successfully' };
} catch (error) {
this.chromiumStatus = {
state: 'failed',
error: String(error),
lastAttempt: new Date().toISOString()
};
this.log(`Failed to restart Chromium: ${error}`);
return { success: false, message: String(error) };
}
}
async start(): Promise<void> {
this.log('EcoOS Daemon starting...');
// Start UI server first - this must always succeed and remain responsive
this.log('Starting management UI on port ' + this.config.uiPort);
await this.uiServer.start();
this.log('Management UI started successfully');
// Start the Sway/Chromium initialization in the background
// This allows the UI server to remain responsive even if Sway fails
this.startServicesInBackground();
// Keep the daemon running
await this.runForever();
}
private async startServicesInBackground(): Promise<void> {
// Run service initialization without blocking the main thread
(async () => {
try {
// Ensure seatd is running
this.log('Checking seatd service...');
await this.ensureSeatd();
// Try to start Sway and Chromium
await this.tryStartSwayAndChromium();
} catch (error) {
this.log(`Service initialization error: ${error}`);
}
})();
}
private async tryStartSwayAndChromium(): Promise<void> {
// Try DRM mode first, fall back to headless if it fails
const modes = ['drm', 'headless'] as const;
for (const mode of modes) {
// Stop any existing Sway process
await this.processManager.stopSway();
this.swayStatus = { state: 'starting', lastAttempt: new Date().toISOString() };
this.log(`Trying Sway with ${mode} backend...`);
try {
await this.startSwayWithMode(mode);
} catch (error) {
this.log(`Failed to start Sway with ${mode}: ${error}`);
continue;
}
// Wait for Wayland socket
this.log('Waiting for Wayland socket...');
const waylandReady = await this.waitForWayland();
if (waylandReady) {
this.swayStatus = { state: 'running' };
this.log(`Sway compositor running with ${mode} backend`);
// Start Chromium in kiosk mode
await this.startChromiumAfterSway();
return;
} else {
this.log(`Sway ${mode} backend failed - Wayland socket did not appear`);
await this.processManager.stopSway();
}
}
// All modes failed
this.swayStatus = {
state: 'failed',
error: 'All Sway backends failed (tried drm and headless)',
lastAttempt: new Date().toISOString()
};
this.log('All Sway backend modes failed');
}
private async startChromiumAfterSway(): Promise<void> {
this.chromiumStatus = { state: 'starting', lastAttempt: new Date().toISOString() };
this.log('Starting Chromium browser...');
try {
await this.startChromium();
this.chromiumStatus = { state: 'running' };
this.log('Chromium browser started');
} catch (error) {
this.chromiumStatus = {
state: 'failed',
error: String(error),
lastAttempt: new Date().toISOString()
};
this.log(`Failed to start Chromium: ${error}`);
}
}
private async ensureSeatd(): Promise<void> {
const status = await runCommand('systemctl', ['is-active', 'seatd']);
if (status.success && status.stdout.trim() === 'active') {
this.log('seatd is already running');
return;
}
this.log('Starting seatd service...');
const result = await runCommand('systemctl', ['start', 'seatd']);
if (!result.success) {
this.log('Warning: Failed to start seatd: ' + result.stderr);
}
}
private async startSwayWithMode(mode: 'drm' | 'headless'): Promise<void> {
const uid = await this.getUserUid();
// Ensure XDG_RUNTIME_DIR exists
const runtimeDir = `/run/user/${uid}`;
await runCommand('mkdir', ['-p', runtimeDir]);
await runCommand('chown', [`${this.config.user}:${this.config.user}`, runtimeDir]);
await runCommand('chmod', ['700', runtimeDir]);
if (mode === 'drm') {
this.log('Starting Sway with DRM backend (hardware rendering)');
await this.processManager.startSway({
runtimeDir,
backends: 'drm,libinput',
allowSoftwareRendering: true,
headless: false,
});
} else {
this.log('Starting Sway with headless backend (software rendering)');
await this.processManager.startSway({
runtimeDir,
backends: 'headless',
allowSoftwareRendering: true,
headless: true,
headlessOutputs: 1,
renderer: 'pixman',
});
}
}
private async waitForWayland(): Promise<boolean> {
const uid = await this.getUserUid();
const socketPath = `/run/user/${uid}/${this.config.waylandDisplay}`;
// Wait up to 10 seconds for Wayland socket to appear
for (let i = 0; i < 10; i++) {
try {
const stat = await Deno.stat(socketPath);
if (stat.isFile || stat.isSymlink || stat.mode !== undefined) {
this.log('Wayland socket ready: ' + socketPath);
return true;
}
} catch {
// Socket doesn't exist yet
}
await new Promise((resolve) => setTimeout(resolve, 1000));
}
return false;
}
private async startChromium(): Promise<void> {
const uid = await this.getUserUid();
const runtimeDir = `/run/user/${uid}`;
await this.processManager.startBrowser({
runtimeDir,
waylandDisplay: this.config.waylandDisplay,
url: 'http://localhost:' + this.config.uiPort,
kiosk: true,
});
}
private async getUserUid(): Promise<number> {
const result = await runCommand('id', ['-u', this.config.user]);
if (!result.success) {
throw new Error('Failed to get user UID: ' + result.stderr);
}
return parseInt(result.stdout.trim(), 10);
}
private async runForever(): Promise<void> {
// Monitor processes and restart if needed
while (true) {
await new Promise((resolve) => setTimeout(resolve, 5000));
try {
// If Sway was running but died, try to restart
if (this.swayStatus.state === 'running' && !this.processManager.isSwayRunning()) {
this.log('Sway process died, attempting restart...');
this.swayStatus = { state: 'stopped' };
this.chromiumStatus = { state: 'stopped' };
await this.tryStartSwayAndChromium();
}
// If Sway is running but Chromium died, restart Chromium
if (this.swayStatus.state === 'running' && this.chromiumStatus.state === 'running'
&& !this.processManager.isBrowserRunning()) {
this.log('Chromium process died, attempting restart...');
this.chromiumStatus = { state: 'starting', lastAttempt: new Date().toISOString() };
try {
await this.startChromium();
this.chromiumStatus = { state: 'running' };
this.log('Chromium browser restarted');
} catch (error) {
this.chromiumStatus = {
state: 'failed',
error: String(error),
lastAttempt: new Date().toISOString()
};
this.log(`Failed to restart Chromium: ${error}`);
}
}
// If Sway failed, retry every 30 seconds
if (this.swayStatus.state === 'failed') {
const lastAttempt = this.swayStatus.lastAttempt
? new Date(this.swayStatus.lastAttempt).getTime()
: 0;
const now = Date.now();
if (now - lastAttempt > 30000) {
this.log('Retrying Sway startup...');
await this.tryStartSwayAndChromium();
}
}
} catch (error) {
this.log(`Error in monitoring loop: ${error}`);
}
}
}
}

View File

@@ -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<string> {
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<void> {
// Write sway config before starting
const configPath = await this.writeSwayConfig(config);
const env: Record<string, string> = {
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<boolean> {
const env: Record<string, string> = {
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<void> {
const env: Record<string, string> = {
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<void> {
// 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<void> {
if (this.swayProcess) {
try {
this.swayProcess.kill('SIGTERM');
await this.swayProcess.status;
} catch {
// Process may already be dead
}
this.swayProcess = null;
}
}
async stopBrowser(): Promise<void> {
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<void> {
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;
}
});
}
}

View File

@@ -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<SystemInfoData> {
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<string> {
try {
const result = await runCommand('hostname', []);
if (!result.success) return 'unknown';
return result.stdout.trim();
} catch {
return 'unknown';
}
}
private async getCpuInfo(): Promise<CpuInfo> {
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<MemoryInfo> {
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<DiskInfo[]> {
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<NetworkInterface[]> {
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<GpuInfo[]> {
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<number> {
try {
const uptime = await Deno.readTextFile('/proc/uptime');
return Math.floor(parseFloat(uptime.split(' ')[0]));
} catch {
return 0;
}
}
private async getInputDevices(): Promise<InputDevice[]> {
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<AudioDevice[]> {
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<AudioDevice[]> {
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<AudioDevice[]> {
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<AudioDevice[]> {
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 [];
}
}
}

View File

@@ -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<WebSocket> = new Set();
constructor(port: number, daemon: EcoDaemon) {
this.port = port;
this.daemon = daemon;
}
async start(): Promise<void> {
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<Response> {
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<Response> {
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 = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>EcoOS Management</title>
<style>
:root {
--bg: #0a0a0a;
--card: #141414;
--border: #2a2a2a;
--text: #e0e0e0;
--text-dim: #888;
--accent: #3b82f6;
--success: #22c55e;
--warning: #f59e0b;
--error: #ef4444;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100vh;
padding: 20px;
}
.container { max-width: 1200px; margin: 0 auto; }
h1 { font-size: 24px; margin-bottom: 20px; }
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 20px;
}
.card {
background: var(--card);
border: 1px solid var(--border);
border-radius: 8px;
padding: 16px;
}
.card h2 {
font-size: 14px;
text-transform: uppercase;
color: var(--text-dim);
margin-bottom: 12px;
}
.stat { margin-bottom: 8px; }
.stat-label { color: var(--text-dim); font-size: 12px; }
.stat-value { font-size: 18px; font-weight: 600; }
.progress-bar {
background: var(--border);
height: 6px;
border-radius: 3px;
margin-top: 4px;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: var(--accent);
border-radius: 3px;
transition: width 0.3s;
}
.logs {
height: 300px;
overflow-y: auto;
font-family: 'SF Mono', Monaco, monospace;
font-size: 12px;
line-height: 1.6;
background: #0d0d0d;
padding: 12px;
border-radius: 4px;
}
.log-entry { white-space: pre-wrap; word-break: break-all; }
.status-dot {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
margin-right: 8px;
}
.status-dot.running { background: var(--success); }
.status-dot.stopped { background: var(--error); }
.network-item {
display: flex;
justify-content: space-between;
padding: 8px 0;
border-bottom: 1px solid var(--border);
}
.network-item:last-child { border-bottom: none; }
.btn {
padding: 10px 16px;
border: none;
border-radius: 6px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: opacity 0.2s;
margin-right: 8px;
margin-bottom: 8px;
}
.btn:hover { opacity: 0.85; }
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
.btn-primary { background: var(--accent); color: white; }
.btn-danger { background: var(--error); color: white; }
.device-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 0;
border-bottom: 1px solid var(--border);
}
.device-item:last-child { border-bottom: none; }
.device-name { font-weight: 500; }
.device-type {
font-size: 11px;
padding: 2px 6px;
border-radius: 4px;
background: var(--border);
color: var(--text-dim);
}
.device-default {
font-size: 11px;
padding: 2px 6px;
border-radius: 4px;
background: var(--success);
color: white;
}
</style>
</head>
<body>
<div class="container">
<h1>EcoOS Management</h1>
<div class="grid">
<div class="card">
<h2>Services</h2>
<div class="stat">
<span class="status-dot" id="sway-status"></span>
Sway Compositor
</div>
<div class="stat">
<span class="status-dot" id="chromium-status"></span>
Chromium Browser
</div>
</div>
<div class="card">
<h2>CPU</h2>
<div class="stat">
<div class="stat-label">Model</div>
<div class="stat-value" id="cpu-model">-</div>
</div>
<div class="stat">
<div class="stat-label">Cores</div>
<div class="stat-value" id="cpu-cores">-</div>
</div>
<div class="stat">
<div class="stat-label">Usage</div>
<div class="stat-value" id="cpu-usage">-</div>
<div class="progress-bar"><div class="progress-fill" id="cpu-bar"></div></div>
</div>
</div>
<div class="card">
<h2>Memory</h2>
<div class="stat">
<div class="stat-label">Used / Total</div>
<div class="stat-value" id="memory-usage">-</div>
<div class="progress-bar"><div class="progress-fill" id="memory-bar"></div></div>
</div>
</div>
<div class="card">
<h2>Network</h2>
<div id="network-list"></div>
</div>
<div class="card">
<h2>Disks</h2>
<div id="disk-list"></div>
</div>
<div class="card">
<h2>System</h2>
<div class="stat">
<div class="stat-label">Hostname</div>
<div class="stat-value" id="hostname">-</div>
</div>
<div class="stat">
<div class="stat-label">Uptime</div>
<div class="stat-value" id="uptime">-</div>
</div>
<div class="stat">
<div class="stat-label">GPU</div>
<div class="stat-value" id="gpu">-</div>
</div>
</div>
<div class="card">
<h2>Controls</h2>
<button class="btn btn-primary" id="btn-restart-chromium" onclick="restartChromium()">
Restart Browser
</button>
<button class="btn btn-danger" id="btn-reboot" onclick="rebootSystem()">
Reboot System
</button>
<div id="control-status" style="margin-top: 8px; font-size: 12px; color: var(--text-dim);"></div>
</div>
<div class="card">
<h2>Input Devices</h2>
<div id="input-devices-list"></div>
</div>
<div class="card">
<h2>Speakers</h2>
<div id="speakers-list"></div>
</div>
<div class="card">
<h2>Microphones</h2>
<div id="microphones-list"></div>
</div>
<div class="card" style="grid-column: 1 / -1;">
<h2>Logs</h2>
<div class="logs" id="logs"></div>
</div>
</div>
</div>
<script>
function formatBytes(bytes) {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
}
function formatUptime(seconds) {
const days = Math.floor(seconds / 86400);
const hours = Math.floor((seconds % 86400) / 3600);
const mins = Math.floor((seconds % 3600) / 60);
if (days > 0) return days + 'd ' + hours + 'h ' + mins + 'm';
if (hours > 0) return hours + 'h ' + mins + 'm';
return mins + 'm';
}
function updateStatus(data) {
// Services
document.getElementById('sway-status').className =
'status-dot ' + (data.sway ? 'running' : 'stopped');
document.getElementById('chromium-status').className =
'status-dot ' + (data.chromium ? 'running' : 'stopped');
// System info
if (data.systemInfo) {
const info = data.systemInfo;
// CPU
if (info.cpu) {
document.getElementById('cpu-model').textContent = info.cpu.model;
document.getElementById('cpu-cores').textContent = info.cpu.cores;
document.getElementById('cpu-usage').textContent = info.cpu.usage + '%';
document.getElementById('cpu-bar').style.width = info.cpu.usage + '%';
}
// Memory
if (info.memory) {
document.getElementById('memory-usage').textContent =
formatBytes(info.memory.used) + ' / ' + formatBytes(info.memory.total);
document.getElementById('memory-bar').style.width = info.memory.usagePercent + '%';
}
// Network
if (info.network) {
const list = document.getElementById('network-list');
list.innerHTML = info.network.map(n =>
'<div class="network-item"><span>' + n.name + '</span><span>' + n.ip + '</span></div>'
).join('');
}
// Disks
if (info.disks) {
const list = document.getElementById('disk-list');
list.innerHTML = info.disks.map(d =>
'<div class="stat" style="margin-bottom: 12px;">' +
'<div class="stat-label">' + d.mountpoint + '</div>' +
'<div class="stat-value">' + formatBytes(d.used) + ' / ' + formatBytes(d.total) + '</div>' +
'<div class="progress-bar"><div class="progress-fill" style="width: ' + d.usagePercent + '%"></div></div>' +
'</div>'
).join('');
}
// Hostname
if (info.hostname) {
document.getElementById('hostname').textContent = info.hostname;
}
// Uptime
if (info.uptime !== undefined) {
document.getElementById('uptime').textContent = formatUptime(info.uptime);
}
// GPU
if (info.gpu && info.gpu.length > 0) {
document.getElementById('gpu').textContent = info.gpu.map(g => g.name).join(', ');
} else {
document.getElementById('gpu').textContent = 'None detected';
}
// Input Devices
if (info.inputDevices) {
const list = document.getElementById('input-devices-list');
if (info.inputDevices.length === 0) {
list.innerHTML = '<div style="color: var(--text-dim);">No input devices detected</div>';
} else {
list.innerHTML = info.inputDevices.map(d =>
'<div class="device-item">' +
'<span class="device-name">' + d.name + '</span>' +
'<span class="device-type">' + d.type + '</span>' +
'</div>'
).join('');
}
}
// Speakers
if (info.speakers) {
const list = document.getElementById('speakers-list');
if (info.speakers.length === 0) {
list.innerHTML = '<div style="color: var(--text-dim);">No speakers detected</div>';
} else {
list.innerHTML = info.speakers.map(s =>
'<div class="device-item">' +
'<span class="device-name">' + s.description + '</span>' +
(s.isDefault ? '<span class="device-default">Default</span>' : '') +
'</div>'
).join('');
}
}
// Microphones
if (info.microphones) {
const list = document.getElementById('microphones-list');
if (info.microphones.length === 0) {
list.innerHTML = '<div style="color: var(--text-dim);">No microphones detected</div>';
} else {
list.innerHTML = info.microphones.map(m =>
'<div class="device-item">' +
'<span class="device-name">' + m.description + '</span>' +
(m.isDefault ? '<span class="device-default">Default</span>' : '') +
'</div>'
).join('');
}
}
}
// Logs
if (data.logs) {
const logsEl = document.getElementById('logs');
logsEl.innerHTML = data.logs.map(l =>
'<div class="log-entry">' + l + '</div>'
).join('');
logsEl.scrollTop = logsEl.scrollHeight;
}
}
function setControlStatus(msg, isError) {
const el = document.getElementById('control-status');
el.textContent = msg;
el.style.color = isError ? 'var(--error)' : 'var(--success)';
}
function restartChromium() {
const btn = document.getElementById('btn-restart-chromium');
btn.disabled = true;
setControlStatus('Restarting browser...', false);
fetch('/api/restart-chromium', { method: 'POST' })
.then(r => r.json())
.then(result => {
setControlStatus(result.message, !result.success);
btn.disabled = false;
})
.catch(err => {
setControlStatus('Error: ' + err, true);
btn.disabled = false;
});
}
function rebootSystem() {
if (!confirm('Are you sure you want to reboot the system?')) return;
const btn = document.getElementById('btn-reboot');
btn.disabled = true;
setControlStatus('Rebooting system...', false);
fetch('/api/reboot', { method: 'POST' })
.then(r => r.json())
.then(result => {
setControlStatus(result.message, !result.success);
if (!result.success) btn.disabled = false;
})
.catch(err => {
setControlStatus('Error: ' + err, true);
btn.disabled = false;
});
}
// Initial fetch
fetch('/api/status')
.then(r => r.json())
.then(updateStatus)
.catch(console.error);
// Periodic refresh
setInterval(() => {
fetch('/api/status')
.then(r => r.json())
.then(updateStatus)
.catch(console.error);
}, 3000);
// WebSocket for live updates
const ws = new WebSocket('ws://' + location.host + '/ws');
ws.onmessage = (e) => {
try {
updateStatus(JSON.parse(e.data));
} catch {}
};
</script>
</body>
</html>`;
return new Response(html, {
headers: { 'Content-Type': 'text/html; charset=utf-8' },
});
}
}

View File

@@ -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<CommandResult> {
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),
};
}

346
isobuild/Dockerfile Normal file
View File

@@ -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"]

View File

@@ -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

View File

@@ -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."

View File

@@ -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."

View File

@@ -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."

View File

@@ -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."

View File

@@ -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."

View File

@@ -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

View File

@@ -0,0 +1,5 @@
[Match]
Name=ens* enp* eth*
[Network]
DHCP=yes

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -0,0 +1 @@
/etc/systemd/system/ecoos-installer.service

Binary file not shown.

View File

@@ -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 ==="

View File

@@ -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 "$@"

View File

@@ -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" \
"${@}"

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

129
isobuild/scripts/build-iso.sh Executable file
View File

@@ -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"

View File

@@ -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"

45
isobuild/scripts/test-qemu.sh Executable file
View File

@@ -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."

80
isotest/run-test.sh Executable file
View File

@@ -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"

49
isotest/screenshot.sh Executable file
View File

@@ -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

21
isotest/stop.sh Executable file
View File

@@ -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

15
package.json Normal file
View File

@@ -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/*"
}
}

135
readme.hints.md Normal file
View File

@@ -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

229
readme.plan.md Normal file
View File

@@ -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)