initial
This commit is contained in:
47
.gitignore
vendored
Normal file
47
.gitignore
vendored
Normal 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
19
ecoos_daemon/deno.json
Normal 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
14
ecoos_daemon/mod.ts
Normal 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();
|
||||
350
ecoos_daemon/ts/daemon/index.ts
Normal file
350
ecoos_daemon/ts/daemon/index.ts
Normal 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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
348
ecoos_daemon/ts/daemon/process-manager.ts
Normal file
348
ecoos_daemon/ts/daemon/process-manager.ts
Normal 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;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
384
ecoos_daemon/ts/daemon/system-info.ts
Normal file
384
ecoos_daemon/ts/daemon/system-info.ts
Normal 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 [];
|
||||
}
|
||||
}
|
||||
}
|
||||
539
ecoos_daemon/ts/ui/server.ts
Normal file
539
ecoos_daemon/ts/ui/server.ts
Normal 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' },
|
||||
});
|
||||
}
|
||||
}
|
||||
27
ecoos_daemon/ts/utils/command.ts
Normal file
27
ecoos_daemon/ts/utils/command.ts
Normal 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
346
isobuild/Dockerfile
Normal 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"]
|
||||
72
isobuild/config/autoinstall/user-data
Normal file
72
isobuild/config/autoinstall/user-data
Normal 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
|
||||
27
isobuild/config/hooks/normal/0050-setup-ecouser.hook.chroot
Executable file
27
isobuild/config/hooks/normal/0050-setup-ecouser.hook.chroot
Executable 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."
|
||||
14
isobuild/config/hooks/normal/0055-fix-networkmanager.hook.chroot
Executable file
14
isobuild/config/hooks/normal/0055-fix-networkmanager.hook.chroot
Executable 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."
|
||||
54
isobuild/config/hooks/normal/0060-install-chromium.hook.chroot
Executable file
54
isobuild/config/hooks/normal/0060-install-chromium.hook.chroot
Executable 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."
|
||||
29
isobuild/config/hooks/normal/0100-enable-services.hook.chroot
Executable file
29
isobuild/config/hooks/normal/0100-enable-services.hook.chroot
Executable 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."
|
||||
52
isobuild/config/hooks/normal/0200-fix-permissions.hook.chroot
Executable file
52
isobuild/config/hooks/normal/0200-fix-permissions.hook.chroot
Executable 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."
|
||||
@@ -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
|
||||
@@ -0,0 +1,5 @@
|
||||
[Match]
|
||||
Name=ens* enp* eth*
|
||||
|
||||
[Network]
|
||||
DHCP=yes
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
/etc/systemd/system/ecoos-installer.service
|
||||
BIN
isobuild/config/includes.chroot/opt/eco/bin/eco-daemon
Executable file
BIN
isobuild/config/includes.chroot/opt/eco/bin/eco-daemon
Executable file
Binary file not shown.
22
isobuild/config/includes.chroot/opt/eco/debug-network.sh
Executable file
22
isobuild/config/includes.chroot/opt/eco/debug-network.sh
Executable 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 ==="
|
||||
545
isobuild/config/includes.chroot/opt/eco/installer/install.sh
Executable file
545
isobuild/config/includes.chroot/opt/eco/installer/install.sh
Executable 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 "$@"
|
||||
21
isobuild/config/live-build/auto/config
Executable file
21
isobuild/config/live-build/auto/config
Executable 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" \
|
||||
"${@}"
|
||||
57
isobuild/config/live-build/package-lists/base.list.chroot
Normal file
57
isobuild/config/live-build/package-lists/base.list.chroot
Normal 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
|
||||
32
isobuild/config/live-build/package-lists/desktop.list.chroot
Normal file
32
isobuild/config/live-build/package-lists/desktop.list.chroot
Normal 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
|
||||
39
isobuild/config/live-build/package-lists/drivers.list.chroot
Normal file
39
isobuild/config/live-build/package-lists/drivers.list.chroot
Normal 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
|
||||
24
isobuild/config/systemd/eco-daemon.service
Normal file
24
isobuild/config/systemd/eco-daemon.service
Normal 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
129
isobuild/scripts/build-iso.sh
Executable 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"
|
||||
34
isobuild/scripts/docker-build.sh
Executable file
34
isobuild/scripts/docker-build.sh
Executable 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
45
isobuild/scripts/test-qemu.sh
Executable 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
80
isotest/run-test.sh
Executable 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
49
isotest/screenshot.sh
Executable 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
21
isotest/stop.sh
Executable 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
15
package.json
Normal 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
135
readme.hints.md
Normal 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
229
readme.plan.md
Normal 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)
|
||||
Reference in New Issue
Block a user