/** * Process Manager * * Manages spawning and monitoring of Sway and Chromium processes */ import { runCommand } from '../utils/command.ts'; export interface SwayConfig { runtimeDir: string; backends: string; allowSoftwareRendering: boolean; headless?: boolean; headlessOutputs?: number; renderer?: string; resolution?: string; } export interface BrowserConfig { runtimeDir: string; waylandDisplay: string; url: string; kiosk?: boolean; } export class ProcessManager { private user: string; private swayProcess: Deno.ChildProcess | null = null; private browserProcess: Deno.ChildProcess | null = null; constructor(user: string) { this.user = user; } /** * Generate Sway configuration content for kiosk mode */ private generateSwayConfig(config: SwayConfig): string { const resolution = config.resolution || '1920x1080'; return `# EcoOS Sway Configuration (generated by eco-daemon) # Kiosk mode operation # Variables set $mod Mod4 # Output configuration output * { bg #000000 solid_color resolution ${resolution} } # For headless backend virtual outputs output HEADLESS-1 { resolution ${resolution} position 0 0 } # Input configuration input * { events enabled } # Disable screen blanking for kiosk mode exec_always swaymsg "output * dpms on" # No window decorations for fullscreen kiosk default_border none default_floating_border none # Focus follows mouse focus_follows_mouse yes # Force all windows fullscreen for kiosk mode for_window [app_id=".*"] fullscreen enable for_window [app_id="chromium-browser"] fullscreen enable `; } /** * Write Sway config to user's config directory */ private async writeSwayConfig(config: SwayConfig): Promise { const configDir = `/home/${this.user}/.config/sway`; const configPath = `${configDir}/config`; // Create config directory await runCommand('mkdir', ['-p', configDir]); await runCommand('chown', [`${this.user}:${this.user}`, `/home/${this.user}/.config`]); await runCommand('chown', [`${this.user}:${this.user}`, configDir]); // Write config file const configContent = this.generateSwayConfig(config); await Deno.writeTextFile(configPath, configContent); await runCommand('chown', [`${this.user}:${this.user}`, configPath]); console.log(`[sway] Config written to ${configPath}`); return configPath; } async startSway(config: SwayConfig): Promise { // Write sway config before starting const configPath = await this.writeSwayConfig(config); const env: Record = { XDG_RUNTIME_DIR: config.runtimeDir, WLR_BACKENDS: config.backends, }; if (config.allowSoftwareRendering) { env.WLR_RENDERER_ALLOW_SOFTWARE = '1'; } // Headless mode configuration if (config.headless) { env.WLR_HEADLESS_OUTPUTS = String(config.headlessOutputs || 1); // Use libinput for input even in headless mode (for VNC/remote input) env.WLR_LIBINPUT_NO_DEVICES = '1'; } // Force specific renderer (pixman for software rendering) if (config.renderer) { env.WLR_RENDERER = config.renderer; } // Build environment string for runuser const envString = Object.entries(env) .map(([k, v]) => `${k}=${v}`) .join(' '); const command = new Deno.Command('runuser', { args: ['-u', this.user, '--', 'sh', '-c', `${envString} sway -c ${configPath}`], stdout: 'piped', stderr: 'piped', stdin: 'null', }); this.swayProcess = command.spawn(); // Log output in background this.pipeOutput(this.swayProcess, 'sway'); } /** * Run a swaymsg command to control Sway */ async swaymsg(config: { runtimeDir: string; waylandDisplay: string }, command: string): Promise { const env: Record = { XDG_RUNTIME_DIR: config.runtimeDir, WAYLAND_DISPLAY: config.waylandDisplay, }; const envString = Object.entries(env) .map(([k, v]) => `${k}=${v}`) .join(' '); const cmd = new Deno.Command('runuser', { args: [ '-u', this.user, '--', 'sh', '-c', `${envString} swaymsg '${command}'`, ], stdout: 'piped', stderr: 'piped', }); try { const result = await cmd.output(); if (!result.success) { const stderr = new TextDecoder().decode(result.stderr); console.error(`[swaymsg] Command failed: ${stderr}`); } return result.success; } catch (error) { console.error(`[swaymsg] Error: ${error}`); return false; } } /** * Start Chromium browser in kiosk mode */ async startBrowser(config: BrowserConfig): Promise { const env: Record = { HOME: `/home/${this.user}`, XDG_RUNTIME_DIR: config.runtimeDir, WAYLAND_DISPLAY: config.waylandDisplay, XDG_CONFIG_HOME: `/home/${this.user}/.config`, XDG_DATA_HOME: `/home/${this.user}/.local/share`, }; // Chromium arguments for kiosk mode on Wayland const browserArgs = [ '--ozone-platform=wayland', '--enable-features=UseOzonePlatform', '--kiosk', '--no-first-run', '--disable-infobars', '--disable-session-crashed-bubble', '--disable-restore-session-state', '--disable-background-networking', '--disable-sync', '--disable-translate', '--noerrdialogs', // Required for VM/headless/sandboxed environments '--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage', // GPU/rendering flags for VM environments '--disable-gpu', '--disable-gpu-compositing', '--disable-gpu-sandbox', '--disable-software-rasterizer', '--disable-accelerated-2d-canvas', '--disable-accelerated-video-decode', '--use-gl=swiftshader', '--in-process-gpu', // Disable features that may cause issues in kiosk mode '--disable-features=TranslateUI,VizDisplayCompositor', '--disable-hang-monitor', '--disable-breakpad', '--disable-component-update', config.url, ]; // Build environment string for runuser const envString = Object.entries(env) .map(([k, v]) => `${k}=${v}`) .join(' '); const command = new Deno.Command('runuser', { args: [ '-u', this.user, '--', 'sh', '-c', `${envString} chromium-browser ${browserArgs.join(' ')}`, ], stdout: 'piped', stderr: 'piped', stdin: 'null', }); this.browserProcess = command.spawn(); // Log output in background this.pipeOutput(this.browserProcess, 'chromium'); // Force fullscreen via swaymsg after Chromium window appears (backup) this.forceFullscreenAfterDelay(config); } /** * Force fullscreen for Chromium window after a delay (backup for kiosk mode) */ private async forceFullscreenAfterDelay(config: { runtimeDir: string; waylandDisplay: string }): Promise { // Wait for Chromium window to appear await new Promise((resolve) => setTimeout(resolve, 3000)); console.log('[chromium] Forcing fullscreen via swaymsg'); // Try multiple selectors to ensure we catch the window const selectors = [ '[app_id="chromium-browser"]', '[app_id="chromium"]', ]; for (const selector of selectors) { await this.swaymsg(config, `${selector} fullscreen enable`); } // Also try to focus the window await this.swaymsg(config, '[app_id="chromium-browser"] focus'); } isSwayRunning(): boolean { return this.swayProcess !== null; } isBrowserRunning(): boolean { return this.browserProcess !== null; } async stopSway(): Promise { if (this.swayProcess) { try { this.swayProcess.kill('SIGTERM'); await this.swayProcess.status; } catch { // Process may already be dead } this.swayProcess = null; } } async stopBrowser(): Promise { if (this.browserProcess) { try { this.browserProcess.kill('SIGTERM'); await this.browserProcess.status; } catch { // Process may already be dead } this.browserProcess = null; } } private async pipeOutput( process: Deno.ChildProcess, name: string ): Promise { const decoder = new TextDecoder(); // Pipe stdout (async () => { for await (const chunk of process.stdout) { const text = decoder.decode(chunk); for (const line of text.split('\n').filter((l) => l.trim())) { console.log(`[${name}] ${line}`); } } })(); // Pipe stderr (async () => { for await (const chunk of process.stderr) { const text = decoder.decode(chunk); for (const line of text.split('\n').filter((l) => l.trim())) { console.error(`[${name}:err] ${line}`); } } })(); // Monitor process exit process.status.then((status) => { console.log(`[${name}] Process exited with code ${status.code}`); if (name === 'sway') { this.swayProcess = null; } else if (name === 'chromium') { this.browserProcess = null; } }); } }