/** * Process Manager * * Manages spawning and monitoring of Sway and Chromium processes */ import { runCommand } from '../utils/command.ts'; import type { DisplayInfo } from './system-info.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; private swaySocket: string | null = null; constructor(user: string) { this.user = user; } /** * Find the Sway IPC socket path in the runtime directory * Sway creates sockets like: sway-ipc.$UID.$PID.sock */ async findSwaySocket(runtimeDir: string): Promise { try { for await (const entry of Deno.readDir(runtimeDir)) { if (entry.name.startsWith('sway-ipc.') && entry.name.endsWith('.sock')) { const socketPath = `${runtimeDir}/${entry.name}`; console.log(`[sway] Found IPC socket: ${socketPath}`); return socketPath; } } } catch (error) { console.error(`[sway] Error finding socket: ${error}`); } return null; } getSwaySocket(): string | null { return this.swaySocket; } setSwaySocket(socket: string | null): void { this.swaySocket = socket; } /** * 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 { // Find socket if not already found if (!this.swaySocket) { this.swaySocket = await this.findSwaySocket(config.runtimeDir); } if (!this.swaySocket) { console.error('[swaymsg] No Sway IPC socket found'); return false; } const env: Record = { XDG_RUNTIME_DIR: config.runtimeDir, SWAYSOCK: this.swaySocket, }; 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 // Hardware acceleration is enabled where available but falls back gracefully const browserArgs = [ // Wayland/Ozone configuration '--ozone-platform=wayland', '--enable-features=UseOzonePlatform', // Kiosk mode settings '--kiosk', '--no-first-run', '--disable-infobars', '--disable-session-crashed-bubble', '--disable-restore-session-state', '--noerrdialogs', // Disable unnecessary features for kiosk '--disable-background-networking', '--disable-sync', '--disable-translate', '--disable-features=TranslateUI', '--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; } async isBrowserRunning(): Promise { // Check if any chromium process is running (Chromium forks, so we can't just track the parent) try { const cmd = new Deno.Command('pgrep', { args: ['-f', 'chromium'], stdout: 'null', stderr: 'null' }); const result = await cmd.output(); return result.success; } catch { return false; } } async stopSway(): Promise { if (this.swayProcess) { try { this.swayProcess.kill('SIGTERM'); await this.swayProcess.status; } catch { // Process may already be dead } this.swayProcess = null; this.swaySocket = null; // Reset socket so we find new one on restart } } async stopBrowser(): Promise { if (this.browserProcess) { try { this.browserProcess.kill('SIGTERM'); await this.browserProcess.status; } catch { // Process may already be dead } this.browserProcess = null; } } /** * Get connected displays via swaymsg */ async getDisplays(config: { runtimeDir: string; waylandDisplay: string }): Promise { // Find socket if not already found if (!this.swaySocket) { this.swaySocket = await this.findSwaySocket(config.runtimeDir); } if (!this.swaySocket) { console.error('[displays] No Sway IPC socket found'); return []; } const env: Record = { XDG_RUNTIME_DIR: config.runtimeDir, SWAYSOCK: this.swaySocket, }; 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 -t get_outputs`], stdout: 'piped', stderr: 'piped', }); try { const result = await cmd.output(); if (!result.success) { const stderr = new TextDecoder().decode(result.stderr); console.error(`[displays] Failed to get outputs: ${stderr}`); return []; } const outputs = JSON.parse(new TextDecoder().decode(result.stdout)); return outputs.map((output: { name: string; make: string; model: string; serial: string; active: boolean; current_mode?: { width: number; height: number; refresh: number }; focused: boolean; }) => ({ name: output.name, make: output.make || 'Unknown', model: output.model || 'Unknown', serial: output.serial || '', active: output.active, width: output.current_mode?.width || 0, height: output.current_mode?.height || 0, refreshRate: Math.round((output.current_mode?.refresh || 0) / 1000), isPrimary: output.focused, })); } catch (error) { console.error(`[displays] Error: ${error}`); return []; } } /** * Enable or disable a display */ async setDisplayEnabled( config: { runtimeDir: string; waylandDisplay: string }, name: string, enabled: boolean ): Promise { const command = `output ${name} ${enabled ? 'enable' : 'disable'}`; console.log(`[displays] ${command}`); return this.swaymsg(config, command); } /** * Move the kiosk browser to a specific display */ async setKioskDisplay( config: { runtimeDir: string; waylandDisplay: string }, name: string ): Promise { console.log(`[displays] Setting primary display to ${name}`); // Focus the chromium window and move it to the target output const commands = [ `[app_id="chromium-browser"] focus`, `move container to output ${name}`, `focus output ${name}`, `[app_id="chromium-browser"] fullscreen enable`, ]; for (const cmd of commands) { await this.swaymsg(config, cmd); } return true; } 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 - only nullify if still the same process (prevents race condition on restart) process.status.then((status) => { console.log(`[${name}] Process exited with code ${status.code}`); if (name === 'sway' && this.swayProcess === process) { this.swayProcess = null; this.swaySocket = null; // Reset socket so we find new one on restart } else if (name === 'chromium' && this.browserProcess === process) { this.browserProcess = null; } }); } }