feat(displays): add display detection and management (sway) with daemon APIs and UI controls

This commit is contained in:
2026-01-09 18:14:26 +00:00
parent ee631c21c4
commit 06cea4bb37
8 changed files with 239 additions and 3 deletions

View File

@@ -5,7 +5,7 @@
*/
import { ProcessManager } from './process-manager.ts';
import { SystemInfo } from './system-info.ts';
import { SystemInfo, type DisplayInfo } from './system-info.ts';
import { Updater } from './updater.ts';
import { UIServer } from '../ui/server.ts';
import { runCommand } from '../utils/command.ts';
@@ -147,6 +147,47 @@ export class EcoDaemon {
return this.updater.upgradeToVersion(version);
}
async getDisplays(): Promise<DisplayInfo[]> {
if (this.swayStatus.state !== 'running') {
return [];
}
const uid = await this.getUserUid();
return this.processManager.getDisplays({
runtimeDir: `/run/user/${uid}`,
waylandDisplay: this.config.waylandDisplay,
});
}
async setDisplayEnabled(name: string, enabled: boolean): Promise<{ success: boolean; message: string }> {
if (this.swayStatus.state !== 'running') {
return { success: false, message: 'Sway is not running' };
}
this.log(`${enabled ? 'Enabling' : 'Disabling'} display ${name}`);
const uid = await this.getUserUid();
const result = await this.processManager.setDisplayEnabled(
{ runtimeDir: `/run/user/${uid}`, waylandDisplay: this.config.waylandDisplay },
name,
enabled
);
return { success: result, message: result ? `Display ${name} ${enabled ? 'enabled' : 'disabled'}` : 'Failed' };
}
async setKioskDisplay(name: string): Promise<{ success: boolean; message: string }> {
if (this.swayStatus.state !== 'running') {
return { success: false, message: 'Sway is not running' };
}
if (this.chromiumStatus.state !== 'running') {
return { success: false, message: 'Chromium is not running' };
}
this.log(`Moving kiosk to display ${name}`);
const uid = await this.getUserUid();
const result = await this.processManager.setKioskDisplay(
{ runtimeDir: `/run/user/${uid}`, waylandDisplay: this.config.waylandDisplay },
name
);
return { success: result, message: result ? `Kiosk moved to ${name}` : 'Failed' };
}
async start(): Promise<void> {
this.log('EcoOS Daemon starting...');

View File

@@ -5,6 +5,7 @@
*/
import { runCommand } from '../utils/command.ts';
import type { DisplayInfo } from './system-info.ts';
export interface SwayConfig {
runtimeDir: string;
@@ -306,6 +307,95 @@ for_window [app_id="chromium-browser"] fullscreen enable
}
}
/**
* Get connected displays via swaymsg
*/
async getDisplays(config: { runtimeDir: string; waylandDisplay: string }): Promise<DisplayInfo[]> {
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 -t get_outputs`],
stdout: 'piped',
stderr: 'piped',
});
try {
const result = await cmd.output();
if (!result.success) {
console.error('[displays] Failed to get outputs');
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> {
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<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

View File

@@ -52,6 +52,18 @@ export interface AudioDevice {
isDefault: boolean;
}
export interface DisplayInfo {
name: string; // e.g., "DP-1", "HDMI-A-1", "HEADLESS-1"
make: string; // Manufacturer
model: string; // Model name
serial: string; // Serial number
active: boolean; // Currently enabled
width: number; // Resolution width
height: number; // Resolution height
refreshRate: number; // Hz
isPrimary: boolean; // Has the focused window (kiosk)
}
export interface SystemInfoData {
hostname: string;
cpu: CpuInfo;