Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 98398e962f | |||
| 06cea4bb37 |
@@ -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
|
||||
|
||||
|
||||
@@ -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...');
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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",
|
||||
"version": "0.3.8",
|
||||
"version": "0.4.0",
|
||||
"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",
|
||||
|
||||
Reference in New Issue
Block a user