diff --git a/changelog.md b/changelog.md index c8b4b3b..746c0ba 100644 --- a/changelog.md +++ b/changelog.md @@ -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 diff --git a/ecoos_daemon/ts/daemon/index.ts b/ecoos_daemon/ts/daemon/index.ts index e0a31aa..93e0498 100644 --- a/ecoos_daemon/ts/daemon/index.ts +++ b/ecoos_daemon/ts/daemon/index.ts @@ -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 { + 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 { this.log('EcoOS Daemon starting...'); diff --git a/ecoos_daemon/ts/daemon/process-manager.ts b/ecoos_daemon/ts/daemon/process-manager.ts index ba9b9bc..729d71c 100644 --- a/ecoos_daemon/ts/daemon/process-manager.ts +++ b/ecoos_daemon/ts/daemon/process-manager.ts @@ -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 { + const env: Record = { + 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 { + 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 { + 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 diff --git a/ecoos_daemon/ts/daemon/system-info.ts b/ecoos_daemon/ts/daemon/system-info.ts index 7990548..624c094 100644 --- a/ecoos_daemon/ts/daemon/system-info.ts +++ b/ecoos_daemon/ts/daemon/system-info.ts @@ -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; diff --git a/ecoos_daemon/ts/ui/server.ts b/ecoos_daemon/ts/ui/server.ts index 0219324..95ded38 100644 --- a/ecoos_daemon/ts/ui/server.ts +++ b/ecoos_daemon/ts/ui/server.ts @@ -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 +
+

Displays

+
+

Input Devices

@@ -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 = '
No displays detected
'; + return; + } + list.innerHTML = data.displays.map(d => + '
' + + '
' + + '
' + d.name + '
' + + '
' + + d.width + 'x' + d.height + ' @ ' + d.refreshRate + 'Hz' + + (d.make !== 'Unknown' ? ' • ' + d.make : '') + + '
' + + '
' + + '
' + + (d.isPrimary + ? 'Primary' + : '') + + '' + + '
' + + '
' + ).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()) diff --git a/ecoos_daemon/ts/version.ts b/ecoos_daemon/ts/version.ts index 685b359..0b28a75 100644 --- a/ecoos_daemon/ts/version.ts +++ b/ecoos_daemon/ts/version.ts @@ -1 +1 @@ -export const VERSION = "0.3.6"; +export const VERSION = "0.3.9"; diff --git a/isobuild/config/includes.chroot/opt/eco/bin/eco-daemon b/isobuild/config/includes.chroot/opt/eco/bin/eco-daemon index f170848..64d4bd1 100755 Binary files a/isobuild/config/includes.chroot/opt/eco/bin/eco-daemon and b/isobuild/config/includes.chroot/opt/eco/bin/eco-daemon differ diff --git a/package.json b/package.json index c0cb90a..d959c3f 100644 --- a/package.json +++ b/package.json @@ -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",