feat(displays): add display detection and management (sway) with daemon APIs and UI controls
This commit is contained in:
@@ -1,5 +1,14 @@
|
|||||||
# Changelog
|
# 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))
|
## 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
|
use npx tsx to run release-upload.ts in the Gitea release workflow instead of installing tsx globally
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { ProcessManager } from './process-manager.ts';
|
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 { Updater } from './updater.ts';
|
||||||
import { UIServer } from '../ui/server.ts';
|
import { UIServer } from '../ui/server.ts';
|
||||||
import { runCommand } from '../utils/command.ts';
|
import { runCommand } from '../utils/command.ts';
|
||||||
@@ -147,6 +147,47 @@ export class EcoDaemon {
|
|||||||
return this.updater.upgradeToVersion(version);
|
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> {
|
async start(): Promise<void> {
|
||||||
this.log('EcoOS Daemon starting...');
|
this.log('EcoOS Daemon starting...');
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { runCommand } from '../utils/command.ts';
|
import { runCommand } from '../utils/command.ts';
|
||||||
|
import type { DisplayInfo } from './system-info.ts';
|
||||||
|
|
||||||
export interface SwayConfig {
|
export interface SwayConfig {
|
||||||
runtimeDir: string;
|
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(
|
private async pipeOutput(
|
||||||
process: Deno.ChildProcess,
|
process: Deno.ChildProcess,
|
||||||
name: string
|
name: string
|
||||||
|
|||||||
@@ -52,6 +52,18 @@ export interface AudioDevice {
|
|||||||
isDefault: boolean;
|
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 {
|
export interface SystemInfoData {
|
||||||
hostname: string;
|
hostname: string;
|
||||||
cpu: CpuInfo;
|
cpu: CpuInfo;
|
||||||
|
|||||||
@@ -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' }), {
|
return new Response(JSON.stringify({ error: 'Not Found' }), {
|
||||||
status: 404,
|
status: 404,
|
||||||
headers,
|
headers,
|
||||||
@@ -384,6 +406,10 @@ export class UIServer {
|
|||||||
Check for Updates
|
Check for Updates
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<h2>Displays</h2>
|
||||||
|
<div id="displays-list"></div>
|
||||||
|
</div>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>Input Devices</h2>
|
<h2>Input Devices</h2>
|
||||||
<div id="input-devices-list"></div>
|
<div id="input-devices-list"></div>
|
||||||
@@ -698,6 +724,64 @@ export class UIServer {
|
|||||||
fetchUpdates();
|
fetchUpdates();
|
||||||
setInterval(fetchUpdates, 60000); // Check every minute
|
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
|
// Initial fetch
|
||||||
fetch('/api/status')
|
fetch('/api/status')
|
||||||
.then(r => r.json())
|
.then(r => r.json())
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
export const VERSION = "0.3.6";
|
export const VERSION = "0.3.9";
|
||||||
|
|||||||
Binary file not shown.
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@ecobridge/eco-os",
|
"name": "@ecobridge/eco-os",
|
||||||
"version": "0.3.8",
|
"version": "0.3.9",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"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",
|
"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",
|
||||||
|
|||||||
Reference in New Issue
Block a user