Files
eco_os/ecoos_daemon/ts/daemon/process-manager.ts
2026-01-09 19:39:14 +00:00

493 lines
14 KiB
TypeScript

/**
* 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<string | null> {
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<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> {
// 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<string, string> = {
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<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
// 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<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;
}
async isBrowserRunning(): Promise<boolean> {
// 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<void> {
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<void> {
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<DisplayInfo[]> {
// 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<string, string> = {
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<boolean> {
if (enabled) {
console.log(`[displays] Enabling ${name}`);
// First try to set resolution, then enable
await this.swaymsg(config, `output ${name} resolution 1920x1080`);
return this.swaymsg(config, `output ${name} enable`);
} else {
console.log(`[displays] Disabling ${name}`);
return this.swaymsg(config, `output ${name} disable`);
}
}
/**
* Move the kiosk browser to a specific display
*/
async setKioskDisplay(
config: { runtimeDir: string; waylandDisplay: string },
name: string
): Promise<boolean> {
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<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 - 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;
}
});
}
}