Files
eco_os/ecoos_daemon/ts/daemon/process-manager.ts
2026-01-08 18:33:14 +00:00

349 lines
9.1 KiB
TypeScript

/**
* 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;
}
});
}
}