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

@@ -1,5 +1,14 @@
# Changelog
## 2026-01-09 - 0.4.0 - feat(displays)
add display detection and management (sway) with daemon APIs and UI controls
- Introduce DisplayInfo type in system-info.ts
- Add ProcessManager methods: getDisplays, setDisplayEnabled, setKioskDisplay (invoke swaymsg via runuser)
- Add daemon methods to expose getDisplays, setDisplayEnabled and setKioskDisplay with runtime/Wayland context and status checks
- Add UI server endpoints: GET /api/displays and POST /api/displays/{name}/(enable|disable|primary) and frontend UI to list and control displays (polling + buttons)
- Bump VERSION and package.json to 0.3.9
## 2026-01-09 - 0.3.8 - fix(ci(release-workflow))
use npx tsx to run release-upload.ts in the Gitea release workflow instead of installing tsx globally

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;

View File

@@ -129,6 +129,28 @@ export class UIServer {
}
}
if (path === '/api/displays') {
const displays = await this.daemon.getDisplays();
return new Response(JSON.stringify({ displays }), { headers });
}
// Display control endpoints: /api/displays/{name}/{action}
const displayMatch = path.match(/^\/api\/displays\/([^/]+)\/(enable|disable|primary)$/);
if (displayMatch && req.method === 'POST') {
const name = decodeURIComponent(displayMatch[1]);
const action = displayMatch[2];
let result;
if (action === 'enable') {
result = await this.daemon.setDisplayEnabled(name, true);
} else if (action === 'disable') {
result = await this.daemon.setDisplayEnabled(name, false);
} else if (action === 'primary') {
result = await this.daemon.setKioskDisplay(name);
}
return new Response(JSON.stringify(result), { headers });
}
return new Response(JSON.stringify({ error: 'Not Found' }), {
status: 404,
headers,
@@ -384,6 +406,10 @@ export class UIServer {
Check for Updates
</button>
</div>
<div class="card">
<h2>Displays</h2>
<div id="displays-list"></div>
</div>
<div class="card">
<h2>Input Devices</h2>
<div id="input-devices-list"></div>
@@ -698,6 +724,64 @@ export class UIServer {
fetchUpdates();
setInterval(fetchUpdates, 60000); // Check every minute
// Display management
function updateDisplaysUI(data) {
const list = document.getElementById('displays-list');
if (!data.displays || data.displays.length === 0) {
list.innerHTML = '<div style="color: var(--text-dim);">No displays detected</div>';
return;
}
list.innerHTML = data.displays.map(d =>
'<div class="device-item" style="flex-wrap: wrap; gap: 8px;">' +
'<div style="flex: 1; min-width: 150px;">' +
'<div class="device-name">' + d.name + '</div>' +
'<div style="font-size: 11px; color: var(--text-dim);">' +
d.width + 'x' + d.height + ' @ ' + d.refreshRate + 'Hz' +
(d.make !== 'Unknown' ? ' • ' + d.make : '') +
'</div>' +
'</div>' +
'<div style="display: flex; gap: 4px;">' +
(d.isPrimary
? '<span class="device-default">Primary</span>'
: '<button class="btn btn-primary" style="padding: 2px 8px; margin: 0; font-size: 11px;" onclick="setKioskDisplay(\\'' + d.name + '\\')">Set Primary</button>') +
'<button class="btn ' + (d.active ? 'btn-danger' : 'btn-primary') + '" style="padding: 2px 8px; margin: 0; font-size: 11px;" onclick="toggleDisplay(\\'' + d.name + '\\', ' + !d.active + ')">' +
(d.active ? 'Disable' : 'Enable') +
'</button>' +
'</div>' +
'</div>'
).join('');
}
function fetchDisplays() {
fetch('/api/displays')
.then(r => r.json())
.then(updateDisplaysUI)
.catch(err => console.error('Failed to fetch displays:', err));
}
function toggleDisplay(name, enable) {
fetch('/api/displays/' + encodeURIComponent(name) + '/' + (enable ? 'enable' : 'disable'), { method: 'POST' })
.then(r => r.json())
.then(result => {
if (!result.success) alert(result.message);
fetchDisplays();
})
.catch(err => alert('Error: ' + err));
}
function setKioskDisplay(name) {
fetch('/api/displays/' + encodeURIComponent(name) + '/primary', { method: 'POST' })
.then(r => r.json())
.then(result => {
if (!result.success) alert(result.message);
fetchDisplays();
})
.catch(err => alert('Error: ' + err));
}
fetchDisplays();
setInterval(fetchDisplays, 5000); // Refresh every 5 seconds
// Initial fetch
fetch('/api/status')
.then(r => r.json())

View File

@@ -1 +1 @@
export const VERSION = "0.3.6";
export const VERSION = "0.3.9";

View File

@@ -1,6 +1,6 @@
{
"name": "@ecobridge/eco-os",
"version": "0.3.8",
"version": "0.3.9",
"private": true,
"scripts": {
"build": "[ -z \"$CI\" ] && npm version patch --no-git-tag-version || true && node -e \"const v=require('./package.json').version; require('fs').writeFileSync('ecoos_daemon/ts/version.ts', 'export const VERSION = \\\"'+v+'\\\";\\n');\" && pnpm run daemon:bundle && cp ecoos_daemon/bundle/eco-daemon isobuild/config/includes.chroot/opt/eco/bin/ && mkdir -p .nogit/iso && docker build --no-cache -t ecoos-builder -f isobuild/Dockerfile . && docker run --privileged --name ecoos-build ecoos-builder && docker cp ecoos-build:/output/ecoos.iso .nogit/iso/ecoos.iso && docker rm ecoos-build",