349 lines
9.1 KiB
TypeScript
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;
|
|
}
|
|
});
|
|
}
|
|
}
|