17 Commits

Author SHA1 Message Date
f85241dcd5 v0.4.15
All checks were successful
CI / build (push) Successful in 16s
Release / release (push) Successful in 8m44s
2026-01-10 09:23:30 +00:00
45b593cd7c fix(isotest): Improve robustness of SPICE display enabler: add logging, wait-for-port and URI parsing, retries and reconnection logic, stabilization delay before configuring, and verification/retry of monitor configuration 2026-01-10 09:23:30 +00:00
352562b1a5 update
All checks were successful
CI / build (push) Successful in 17s
2026-01-10 08:42:37 +00:00
e02b5b7046 fix(multi-display): fix runtime directory race condition and SPICE display enabling
- Fix tmpfs race condition in daemon by mounting runtime directory explicitly
  before starting Sway, preventing sockets from being hidden when systemd-logind
  mounts over them later
- Fix enable-displays.py to use correct SpiceClientGLib API methods
  (update_display_enabled/update_display instead of set_display_enabled/set_display)
- Fix virt-viewer monitor-mapping to use 1-indexed client monitors
- Add virt-viewer config setup and automatic display enabling to test script
- Multi-display now works correctly with 3 QXL devices
2026-01-10 08:23:50 +00:00
7727fafeec update 2026-01-10 00:03:22 +00:00
0539d183b1 update 2026-01-09 23:28:33 +00:00
ec4eed38e4 update 2026-01-09 19:45:25 +00:00
c8ab9afbc6 update 2026-01-09 19:39:14 +00:00
3125b77020 update 2026-01-09 18:51:22 +00:00
de10e1dd1f v0.4.1
All checks were successful
CI / build (push) Successful in 17s
Release / release (push) Successful in 9m31s
2026-01-09 18:19:30 +00:00
21f7a44a53 fix(release-upload): clear progress timer on upload completion/error and add periodic upload progress reporting 2026-01-09 18:19:30 +00:00
98398e962f v0.4.0
Some checks failed
CI / build (push) Successful in 20s
Release / release (push) Has been cancelled
2026-01-09 18:14:26 +00:00
06cea4bb37 feat(displays): add display detection and management (sway) with daemon APIs and UI controls 2026-01-09 18:14:26 +00:00
ee631c21c4 v0.3.8
Some checks failed
CI / build (push) Successful in 15s
Release / release (push) Failing after 8m54s
2026-01-09 18:06:22 +00:00
50d437aed7 fix(ci(release-workflow)): use npx tsx to run release-upload.ts in the Gitea release workflow instead of installing tsx globally 2026-01-09 18:06:22 +00:00
dd5ea36636 v0.3.7
Some checks failed
CI / build (push) Successful in 17s
Release / release (push) Failing after 1m1s
2026-01-09 17:52:55 +00:00
7d6aace6d9 fix(daemon): Point updater at the correct repository API (code.foss.global ecobridge.xyz/eco_os) and bump project/daemon versions to 0.3.6 2026-01-09 17:52:55 +00:00
23 changed files with 1927 additions and 46 deletions

View File

@@ -47,6 +47,7 @@ async function uploadFile(filepath: string): Promise<void> {
let data = '';
res.on('data', chunk => data += chunk);
res.on('end', () => {
clearInterval(progressInterval);
console.log(data);
if (res.statusCode && res.statusCode >= 200 && res.statusCode < 300) {
console.log(`${filename} uploaded successfully`);
@@ -57,13 +58,30 @@ async function uploadFile(filepath: string): Promise<void> {
});
});
req.on('error', reject);
req.on('error', (err) => {
clearInterval(progressInterval);
reject(err);
});
// Track upload progress
let bytesWritten = header.length;
const progressInterval = setInterval(() => {
const percent = Math.round((bytesWritten / contentLength) * 100);
console.log(` ${filename}: ${percent}% (${Math.round(bytesWritten / 1024 / 1024)}MB / ${Math.round(contentLength / 1024 / 1024)}MB)`);
}, 10000);
// Stream: write header, pipe file, write footer
req.write(header);
const stream = fs.createReadStream(filepath);
stream.on('error', reject);
stream.on('data', (chunk) => {
bytesWritten += chunk.length;
});
stream.on('error', (err) => {
clearInterval(progressInterval);
reject(err);
});
stream.on('end', () => {
bytesWritten += footer.length;
req.write(footer);
req.end();
});

View File

@@ -76,12 +76,10 @@ jobs:
echo "Created release with ID: $RELEASE_ID"
# Upload assets using TypeScript (curl has 2GB multipart limit)
pnpm install -g tsx
GITEA_TOKEN="${{ secrets.GITHUB_TOKEN }}" \
GITEA_REPO="${{ gitea.repository }}" \
RELEASE_ID="$RELEASE_ID" \
tsx .gitea/release-upload.ts
npx tsx .gitea/release-upload.ts
- name: Cleanup old releases (keep 3 latest)
run: |

View File

@@ -1,5 +1,46 @@
# Changelog
## 2026-01-10 - 0.4.15 - fix(isotest)
Improve robustness of SPICE display enabler: add logging, wait-for-port and URI parsing, retries and reconnection logic, stabilization delay before configuring, and verification/retry of monitor configuration
- Add immediate-flush logging helper for clearer background output
- Wait for SPICE TCP port (wait_for_port) and parse spice:// URIs before connecting
- Add stabilization delay before sending monitor config and track retry counts
- Add verify_and_retry to confirm configuration or retry up to configurable attempts
- Detect agent disconnects (VM reboots) and keep running to reconfigure on reconnect; add reconnect and periodic health checks
## 2026-01-09 - 0.4.1 - fix(release-upload)
clear progress timer on upload completion/error and add periodic upload progress reporting
- Clear the progress interval on response end and on stream/error to avoid leaking timers.
- Track bytesWritten (header + stream chunks + footer) to compute accurate progress percentages.
- Log upload progress (percent and MB) every 10 seconds for visibility.
- Handle stream errors by clearing the progress timer and rejecting with the error.
## 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
- Removed 'pnpm install -g tsx' to avoid global installs in CI
- Replaced direct 'tsx' invocation with 'npx tsx' to run .gitea/release-upload.ts
- Reduces CI image footprint and avoids unnecessary global package installation
## 2026-01-09 - 0.3.7 - fix(daemon)
Point updater at the correct repository API (code.foss.global ecobridge.xyz/eco_os) and bump project/daemon versions to 0.3.6
- Updated repo API URL in ecoos_daemon/ts/daemon/updater.ts from 'https://code.foss.global/api/v1/repos/ecobridge/eco-os/releases' to 'https://code.foss.global/api/v1/repos/ecobridge.xyz/eco_os/releases'
- Bumped daemon version in ecoos_daemon/ts/version.ts from 0.3.4 to 0.3.6
- Bumped package version in package.json from 0.3.5 to 0.3.6
- Included rebuilt daemon binary at isobuild/config/includes.chroot/opt/eco/bin/eco-daemon (bundle updated)
## 2026-01-09 - 0.3.5 - fix(ci)
add Gitea release asset uploader and switch release workflow to use it; bump package and daemon versions to 0.3.4

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,48 @@ export class EcoDaemon {
return this.updater.upgradeToVersion(version);
}
async getDisplays(): Promise<DisplayInfo[]> {
if (this.swayStatus.state !== 'running') {
this.log(`[displays] Sway not running (state: ${this.swayStatus.state}), skipping display query`);
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...');
@@ -263,12 +305,13 @@ export class EcoDaemon {
private async startSwayWithMode(mode: 'drm' | 'headless'): Promise<void> {
const uid = await this.getUserUid();
// Ensure XDG_RUNTIME_DIR exists
const gid = await this.getUserGid();
const runtimeDir = `/run/user/${uid}`;
await runCommand('mkdir', ['-p', runtimeDir]);
await runCommand('chown', [`${this.config.user}:${this.config.user}`, runtimeDir]);
await runCommand('chmod', ['700', runtimeDir]);
// Ensure XDG_RUNTIME_DIR exists as a proper tmpfs mount
// This is critical - if Sway creates sockets before the tmpfs is mounted,
// they become hidden when systemd-logind mounts the tmpfs later
await this.ensureRuntimeDirTmpfs(runtimeDir, uid, gid);
if (mode === 'drm') {
this.log('Starting Sway with DRM backend (hardware rendering)');
@@ -332,6 +375,56 @@ export class EcoDaemon {
return parseInt(result.stdout.trim(), 10);
}
private async getUserGid(): Promise<number> {
const result = await runCommand('id', ['-g', this.config.user]);
if (!result.success) {
throw new Error('Failed to get user GID: ' + result.stderr);
}
return parseInt(result.stdout.trim(), 10);
}
/**
* Ensure the user runtime directory exists as a proper tmpfs mount.
* This prevents race conditions where Sway creates sockets before
* systemd-logind mounts the tmpfs, causing sockets to be hidden.
*/
private async ensureRuntimeDirTmpfs(runtimeDir: string, uid: number, gid: number): Promise<void> {
// Check if runtime dir is already a tmpfs mount
const mountCheck = await runCommand('findmnt', ['-n', '-o', 'FSTYPE', runtimeDir]);
if (mountCheck.success && mountCheck.stdout.trim() === 'tmpfs') {
this.log(`Runtime directory ${runtimeDir} is already a tmpfs mount`);
return;
}
// Create the directory if it doesn't exist
await runCommand('mkdir', ['-p', runtimeDir]);
// Mount a tmpfs if not already mounted
this.log(`Mounting tmpfs on ${runtimeDir}`);
const mountResult = await runCommand('mount', [
'-t', 'tmpfs',
'-o', `mode=700,uid=${uid},gid=${gid},size=100M`,
'tmpfs',
runtimeDir
]);
if (!mountResult.success) {
// If mount fails, maybe it's already mounted by systemd-logind
// Double-check and continue if it's now a tmpfs
const recheckMount = await runCommand('findmnt', ['-n', '-o', 'FSTYPE', runtimeDir]);
if (recheckMount.success && recheckMount.stdout.trim() === 'tmpfs') {
this.log(`Runtime directory ${runtimeDir} was mounted by another process`);
return;
}
this.log(`Warning: Failed to mount tmpfs on ${runtimeDir}: ${mountResult.stderr}`);
// Fall back to just ensuring the directory exists with correct permissions
await runCommand('chown', [`${uid}:${gid}`, runtimeDir]);
await runCommand('chmod', ['700', runtimeDir]);
} else {
this.log(`Successfully mounted tmpfs on ${runtimeDir}`);
}
}
private startJournalReader(): void {
(async () => {
try {

View File

@@ -5,6 +5,7 @@
*/
import { runCommand } from '../utils/command.ts';
import type { DisplayInfo } from './system-info.ts';
export interface SwayConfig {
runtimeDir: string;
@@ -27,11 +28,39 @@ export class ProcessManager {
private user: string;
private swayProcess: Deno.ChildProcess | null = null;
private browserProcess: Deno.ChildProcess | null = null;
private swaySocket: string | null = null;
constructor(user: string) {
this.user = user;
}
/**
* Find the Sway IPC socket path in the runtime directory
* Sway creates sockets like: sway-ipc.$UID.$PID.sock
*/
async findSwaySocket(runtimeDir: string): Promise<string | null> {
try {
for await (const entry of Deno.readDir(runtimeDir)) {
if (entry.name.startsWith('sway-ipc.') && entry.name.endsWith('.sock')) {
const socketPath = `${runtimeDir}/${entry.name}`;
console.log(`[sway] Found IPC socket: ${socketPath}`);
return socketPath;
}
}
} catch (error) {
console.error(`[sway] Error finding socket: ${error}`);
}
return null;
}
getSwaySocket(): string | null {
return this.swaySocket;
}
setSwaySocket(socket: string | null): void {
this.swaySocket = socket;
}
/**
* Generate Sway configuration content for kiosk mode
*/
@@ -102,9 +131,14 @@ for_window [app_id="chromium-browser"] fullscreen enable
// Write sway config before starting
const configPath = await this.writeSwayConfig(config);
// Use a fixed socket path so we can reliably connect
const swaySocketPath = `${config.runtimeDir}/sway-ipc.sock`;
this.swaySocket = swaySocketPath;
const env: Record<string, string> = {
XDG_RUNTIME_DIR: config.runtimeDir,
WLR_BACKENDS: config.backends,
SWAYSOCK: swaySocketPath,
};
if (config.allowSoftwareRendering) {
@@ -145,9 +179,19 @@ for_window [app_id="chromium-browser"] fullscreen enable
* Run a swaymsg command to control Sway
*/
async swaymsg(config: { runtimeDir: string; waylandDisplay: string }, command: string): Promise<boolean> {
// Find socket if not already found
if (!this.swaySocket) {
this.swaySocket = await this.findSwaySocket(config.runtimeDir);
}
if (!this.swaySocket) {
console.error('[swaymsg] No Sway IPC socket found');
return false;
}
const env: Record<string, string> = {
XDG_RUNTIME_DIR: config.runtimeDir,
WAYLAND_DISPLAY: config.waylandDisplay,
SWAYSOCK: this.swaySocket,
};
const envString = Object.entries(env)
@@ -291,6 +335,7 @@ for_window [app_id="chromium-browser"] fullscreen enable
// Process may already be dead
}
this.swayProcess = null;
this.swaySocket = null; // Reset socket so we find new one on restart
}
}
@@ -306,6 +351,112 @@ for_window [app_id="chromium-browser"] fullscreen enable
}
}
/**
* Get connected displays via swaymsg
*/
async getDisplays(config: { runtimeDir: string; waylandDisplay: string }): Promise<DisplayInfo[]> {
// Find socket if not already found
if (!this.swaySocket) {
this.swaySocket = await this.findSwaySocket(config.runtimeDir);
}
if (!this.swaySocket) {
console.error('[displays] No Sway IPC socket found');
return [];
}
const env: Record<string, string> = {
XDG_RUNTIME_DIR: config.runtimeDir,
SWAYSOCK: this.swaySocket,
};
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) {
const stderr = new TextDecoder().decode(result.stderr);
console.error(`[displays] Failed to get outputs: ${stderr}`);
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> {
if (enabled) {
console.log(`[displays] Enabling ${name}`);
// First try to set resolution, then enable
await this.swaymsg(config, `output ${name} resolution 1920x1080`);
return this.swaymsg(config, `output ${name} enable`);
} else {
console.log(`[displays] Disabling ${name}`);
return this.swaymsg(config, `output ${name} disable`);
}
}
/**
* 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
@@ -337,6 +488,7 @@ for_window [app_id="chromium-browser"] fullscreen enable
console.log(`[${name}] Process exited with code ${status.code}`);
if (name === 'sway' && this.swayProcess === process) {
this.swayProcess = null;
this.swaySocket = null; // Reset socket so we find new one on restart
} else if (name === 'chromium' && this.browserProcess === process) {
this.browserProcess = null;
}

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

@@ -48,7 +48,7 @@ interface GiteaAsset {
}
export class Updater {
private repoApiUrl = 'https://code.foss.global/api/v1/repos/ecobridge/eco-os/releases';
private repoApiUrl = 'https://code.foss.global/api/v1/repos/ecobridge.xyz/eco_os/releases';
private binaryPath = '/opt/eco/bin/eco-daemon';
private releases: Release[] = [];
private lastCheck: Date | null = null;

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.4";
export const VERSION = "0.4.14";

View File

@@ -0,0 +1,455 @@
#!/usr/bin/env python3
"""
EcoOS Wayland Display Agent (eco-vdagent)
A Wayland-native replacement for spice-vdagent that uses swaymsg/wlr-output-management
instead of xrandr to configure displays.
Listens on the SPICE virtio-serial port for VD_AGENT_MONITORS_CONFIG messages
and applies the configuration to Sway outputs.
"""
import os
import sys
import struct
import subprocess
import json
import time
import signal
import logging
from pathlib import Path
# Configure logging
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - eco-vdagent - %(levelname)s - %(message)s'
)
log = logging.getLogger('eco-vdagent')
# SPICE VDAgent Protocol Constants
VD_AGENT_PROTOCOL = 1
# Message types
VD_AGENT_MOUSE_STATE = 1
VD_AGENT_MONITORS_CONFIG = 2
VD_AGENT_REPLY = 3
VD_AGENT_CLIPBOARD = 4
VD_AGENT_DISPLAY_CONFIG = 5
VD_AGENT_ANNOUNCE_CAPABILITIES = 6
VD_AGENT_CLIPBOARD_GRAB = 7
VD_AGENT_CLIPBOARD_REQUEST = 8
VD_AGENT_CLIPBOARD_RELEASE = 9
VD_AGENT_FILE_XFER_START = 10
VD_AGENT_FILE_XFER_STATUS = 11
VD_AGENT_FILE_XFER_DATA = 12
VD_AGENT_CLIENT_DISCONNECTED = 13
VD_AGENT_MAX_CLIPBOARD = 14
VD_AGENT_AUDIO_VOLUME_SYNC = 15
VD_AGENT_GRAPHICS_DEVICE_INFO = 16
# Reply error codes
VD_AGENT_SUCCESS = 1
VD_AGENT_ERROR = 2
# Capability bits
VD_AGENT_CAP_MOUSE_STATE = 0
VD_AGENT_CAP_MONITORS_CONFIG = 1
VD_AGENT_CAP_REPLY = 2
VD_AGENT_CAP_CLIPBOARD = 3
VD_AGENT_CAP_DISPLAY_CONFIG = 4
VD_AGENT_CAP_CLIPBOARD_BY_DEMAND = 5
VD_AGENT_CAP_CLIPBOARD_SELECTION = 6
VD_AGENT_CAP_SPARSE_MONITORS_CONFIG = 7
VD_AGENT_CAP_GUEST_LINEEND_LF = 8
VD_AGENT_CAP_GUEST_LINEEND_CRLF = 9
VD_AGENT_CAP_MAX_CLIPBOARD = 10
VD_AGENT_CAP_AUDIO_VOLUME_SYNC = 11
VD_AGENT_CAP_MONITORS_CONFIG_POSITION = 12
VD_AGENT_CAP_FILE_XFER_DISABLED = 13
VD_AGENT_CAP_FILE_XFER_DETAILED_ERRORS = 14
VD_AGENT_CAP_GRAPHICS_DEVICE_INFO = 15
VD_AGENT_CAP_CLIPBOARD_NO_RELEASE_ON_REGRAB = 16
VD_AGENT_CAP_CLIPBOARD_GRAB_SERIAL = 17
# Virtio serial port path
VIRTIO_PORT = '/dev/virtio-ports/com.redhat.spice.0'
# VDI Chunk header: port(4) + size(4) = 8 bytes
VDI_CHUNK_HEADER_SIZE = 8
VDI_CHUNK_HEADER_FMT = '<II' # port, size
# VDI Port constants
VDP_CLIENT_PORT = 1
VDP_SERVER_PORT = 2
# VDAgentMessage header: protocol(4) + type(4) + opaque(8) + size(4) = 20 bytes
VDAGENT_MSG_HEADER_SIZE = 20
VDAGENT_MSG_HEADER_FMT = '<IIQI' # little-endian: uint32, uint32, uint64, uint32
# VDAgentMonitorsConfig header: num_of_monitors(4) + flags(4) = 8 bytes
MONITORS_CONFIG_HEADER_SIZE = 8
MONITORS_CONFIG_HEADER_FMT = '<II'
# VDAgentMonConfig: height(4) + width(4) + depth(4) + x(4) + y(4) = 20 bytes
MON_CONFIG_SIZE = 20
MON_CONFIG_FMT = '<IIIii' # height, width, depth, x, y (x,y are signed)
class EcoVDAgent:
def __init__(self):
self.port_fd = None
self.running = True
self.sway_socket = None
def find_sway_socket(self):
"""Find the Sway IPC socket"""
# Check environment first
if 'SWAYSOCK' in os.environ:
return os.environ['SWAYSOCK']
# Search common locations
runtime_dir = os.environ.get('XDG_RUNTIME_DIR', '/run/user/1000')
# Try to find sway socket - check fixed path first, then glob patterns
import glob
# Check for fixed socket path first (set by eco-daemon)
fixed_socket = f'{runtime_dir}/sway-ipc.sock'
if os.path.exists(fixed_socket):
return fixed_socket
# Fall back to glob patterns for standard Sway socket naming
for pattern in [f'{runtime_dir}/sway-ipc.*.sock', '/run/user/*/sway-ipc.*.sock']:
sockets = glob.glob(pattern)
if sockets:
return sockets[0]
return None
def run_swaymsg(self, *args):
"""Run swaymsg command"""
cmd = ['swaymsg']
if self.sway_socket:
cmd.extend(['-s', self.sway_socket])
cmd.extend(args)
try:
result = subprocess.run(cmd, capture_output=True, text=True, timeout=5)
if result.returncode != 0:
log.warning(f"swaymsg failed: {result.stderr}")
return result.returncode == 0, result.stdout
except Exception as e:
log.error(f"Failed to run swaymsg: {e}")
return False, ""
def get_outputs(self):
"""Get current Sway outputs"""
success, output = self.run_swaymsg('-t', 'get_outputs', '-r')
if success:
try:
return json.loads(output)
except json.JSONDecodeError:
pass
return []
def configure_output(self, name, width, height, x, y, enable=True):
"""Configure a Sway output"""
if enable:
# Try to enable and position the output
# First, try setting mode
mode_cmd = f'output {name} mode {width}x{height} position {x} {y} enable'
success, _ = self.run_swaymsg(mode_cmd)
if not success:
# Try without explicit mode (use preferred)
pos_cmd = f'output {name} position {x} {y} enable'
success, _ = self.run_swaymsg(pos_cmd)
return success
else:
return self.run_swaymsg(f'output {name} disable')[0]
def apply_monitors_config(self, monitors):
"""Apply monitor configuration to Sway outputs"""
log.info(f"Applying configuration for {len(monitors)} monitors")
# Get current outputs
outputs = self.get_outputs()
output_names = [o.get('name') for o in outputs]
log.info(f"Available outputs: {output_names}")
# Sort monitors by x position to match with outputs
monitors_sorted = sorted(enumerate(monitors), key=lambda m: m[1]['x'])
# Match monitors to outputs
for i, (mon_idx, mon) in enumerate(monitors_sorted):
if i < len(output_names):
name = output_names[i]
log.info(f"Configuring {name}: {mon['width']}x{mon['height']} at ({mon['x']}, {mon['y']})")
self.configure_output(
name,
mon['width'],
mon['height'],
mon['x'],
mon['y'],
enable=True
)
else:
log.warning(f"No output available for monitor {mon_idx}")
# Disable extra outputs
for i in range(len(monitors), len(output_names)):
name = output_names[i]
log.info(f"Disabling unused output: {name}")
self.configure_output(name, 0, 0, 0, 0, enable=False)
def parse_monitors_config(self, data):
"""Parse VD_AGENT_MONITORS_CONFIG message"""
if len(data) < MONITORS_CONFIG_HEADER_SIZE:
log.error("Monitors config data too short")
return None
num_monitors, flags = struct.unpack(MONITORS_CONFIG_HEADER_FMT, data[:MONITORS_CONFIG_HEADER_SIZE])
log.info(f"Monitors config: {num_monitors} monitors, flags={flags}")
monitors = []
offset = MONITORS_CONFIG_HEADER_SIZE
for i in range(num_monitors):
if offset + MON_CONFIG_SIZE > len(data):
log.error(f"Truncated monitor config at index {i}")
break
height, width, depth, x, y = struct.unpack(
MON_CONFIG_FMT,
data[offset:offset + MON_CONFIG_SIZE]
)
monitors.append({
'width': width,
'height': height,
'depth': depth,
'x': x,
'y': y
})
log.info(f" Monitor {i}: {width}x{height}+{x}+{y} depth={depth}")
offset += MON_CONFIG_SIZE
return monitors
def send_reply(self, msg_type, error_code):
"""Send VD_AGENT_REPLY message"""
# Reply data: type(4) + error(4) = 8 bytes
reply_data = struct.pack('<II', msg_type, error_code)
if self.send_message(VD_AGENT_REPLY, reply_data):
log.debug(f"Sent reply for type {msg_type}: {'success' if error_code == VD_AGENT_SUCCESS else 'error'}")
else:
log.error(f"Failed to send reply for type {msg_type}")
def send_message(self, msg_type, data):
"""Send a VDAgent message with proper chunk header"""
if not self.port_fd:
return False
# Build VDAgentMessage header
msg_header = struct.pack(
VDAGENT_MSG_HEADER_FMT,
VD_AGENT_PROTOCOL,
msg_type,
0, # opaque
len(data)
)
# Full message = header + data
full_msg = msg_header + data
# Build VDI chunk header (port=SERVER, size=message size)
chunk_header = struct.pack(
VDI_CHUNK_HEADER_FMT,
VDP_SERVER_PORT,
len(full_msg)
)
# Retry writes with EAGAIN handling (non-blocking fd)
message = chunk_header + full_msg
retries = 10
while retries > 0:
try:
os.write(self.port_fd, message)
return True
except OSError as e:
if e.errno == 11: # EAGAIN - resource temporarily unavailable
retries -= 1
time.sleep(0.1)
continue
log.error(f"Failed to send message type {msg_type}: {e}")
return False
log.error(f"Failed to send message type {msg_type}: EAGAIN after retries")
return False
def announce_capabilities(self):
"""Send VD_AGENT_ANNOUNCE_CAPABILITIES to register with SPICE server"""
# Build capability bits - we support monitors config
caps = 0
caps |= (1 << VD_AGENT_CAP_MONITORS_CONFIG)
caps |= (1 << VD_AGENT_CAP_REPLY)
caps |= (1 << VD_AGENT_CAP_SPARSE_MONITORS_CONFIG)
caps |= (1 << VD_AGENT_CAP_MONITORS_CONFIG_POSITION)
# VDAgentAnnounceCapabilities: request(4) + caps(4) = 8 bytes
# request=1 means we want the server to send us its capabilities
announce_data = struct.pack('<II', 1, caps)
if self.send_message(VD_AGENT_ANNOUNCE_CAPABILITIES, announce_data):
log.info("Announced capabilities to SPICE server")
else:
log.error("Failed to announce capabilities")
def handle_message(self, msg_type, data):
"""Handle a VDAgent message"""
if msg_type == VD_AGENT_MONITORS_CONFIG:
log.info("Received VD_AGENT_MONITORS_CONFIG")
monitors = self.parse_monitors_config(data)
if monitors:
self.apply_monitors_config(monitors)
self.send_reply(VD_AGENT_MONITORS_CONFIG, VD_AGENT_SUCCESS)
else:
self.send_reply(VD_AGENT_MONITORS_CONFIG, VD_AGENT_ERROR)
elif msg_type == VD_AGENT_ANNOUNCE_CAPABILITIES:
log.info("Received VD_AGENT_ANNOUNCE_CAPABILITIES")
# We could respond with our capabilities here
# For now, just acknowledge
elif msg_type == VD_AGENT_DISPLAY_CONFIG:
log.info("Received VD_AGENT_DISPLAY_CONFIG")
# Display config for disabling client display changes
elif msg_type == VD_AGENT_CLIENT_DISCONNECTED:
log.info("Client disconnected")
else:
log.debug(f"Unhandled message type: {msg_type}")
def read_message(self):
"""Read a single VDAgent message from the port (with chunk header)"""
# Read VDI chunk header first
try:
chunk_header_data = os.read(self.port_fd, VDI_CHUNK_HEADER_SIZE)
except OSError as e:
if e.errno == 11: # EAGAIN
return None
raise
if len(chunk_header_data) < VDI_CHUNK_HEADER_SIZE:
if len(chunk_header_data) == 0:
return None
log.warning(f"Short chunk header read: {len(chunk_header_data)} bytes")
return None
port, chunk_size = struct.unpack(VDI_CHUNK_HEADER_FMT, chunk_header_data)
log.debug(f"Chunk header: port={port}, size={chunk_size}")
if chunk_size < VDAGENT_MSG_HEADER_SIZE:
log.warning(f"Chunk size too small: {chunk_size}")
return None
# Read VDAgent message header
try:
header_data = os.read(self.port_fd, VDAGENT_MSG_HEADER_SIZE)
except OSError as e:
if e.errno == 11: # EAGAIN
return None
raise
if len(header_data) < VDAGENT_MSG_HEADER_SIZE:
log.warning(f"Short message header read: {len(header_data)} bytes")
return None
protocol, msg_type, opaque, size = struct.unpack(VDAGENT_MSG_HEADER_FMT, header_data)
if protocol != VD_AGENT_PROTOCOL:
log.warning(f"Unknown protocol: {protocol}")
return None
# Read message data
data = b''
while len(data) < size:
try:
chunk = os.read(self.port_fd, size - len(data))
if not chunk:
break
data += chunk
except OSError as e:
if e.errno == 11: # EAGAIN
time.sleep(0.01)
continue
raise
return msg_type, data
def signal_handler(self, signum, frame):
"""Handle shutdown signals"""
log.info(f"Received signal {signum}, shutting down...")
self.running = False
def run(self):
"""Main loop"""
# Set up signal handlers
signal.signal(signal.SIGTERM, self.signal_handler)
signal.signal(signal.SIGINT, self.signal_handler)
# Find Sway socket
self.sway_socket = self.find_sway_socket()
if self.sway_socket:
log.info(f"Using Sway socket: {self.sway_socket}")
else:
log.warning("No Sway socket found, will retry...")
# Wait for virtio port
log.info(f"Waiting for virtio port: {VIRTIO_PORT}")
while self.running and not Path(VIRTIO_PORT).exists():
time.sleep(1)
if not self.running:
return
log.info("Opening virtio port...")
try:
self.port_fd = os.open(VIRTIO_PORT, os.O_RDWR | os.O_NONBLOCK)
except OSError as e:
log.error(f"Failed to open virtio port: {e}")
return
log.info("eco-vdagent started, announcing capabilities...")
# Announce our capabilities to the SPICE server
self.announce_capabilities()
log.info("Listening for SPICE agent messages...")
# Main loop
while self.running:
try:
# Try to find Sway socket if not found yet
if not self.sway_socket:
self.sway_socket = self.find_sway_socket()
result = self.read_message()
if result:
msg_type, data = result
self.handle_message(msg_type, data)
else:
time.sleep(0.1)
except Exception as e:
log.error(f"Error in main loop: {e}")
time.sleep(1)
if self.port_fd:
os.close(self.port_fd)
log.info("eco-vdagent stopped")
if __name__ == '__main__':
agent = EcoVDAgent()
agent.run()

View File

@@ -26,4 +26,7 @@ systemctl enable ssh.service || true
echo "Enabling debug service..."
systemctl enable debug-network.service || true
echo "Enabling eco-vdagent service (Wayland display agent for VMs)..."
systemctl enable eco-vdagent.service || true
echo "Services enabled."

View File

@@ -0,0 +1,16 @@
[Unit]
Description=EcoOS Wayland Display Agent
Documentation=https://ecobridge.xyz
After=seatd.service
Wants=seatd.service
ConditionVirtualization=vm
[Service]
Type=simple
ExecStart=/opt/eco/bin/eco-vdagent
Restart=always
RestartSec=5
Environment=XDG_RUNTIME_DIR=/run/user/1000
[Install]
WantedBy=multi-user.target

View File

@@ -0,0 +1 @@
/etc/systemd/system/eco-vdagent.service

View File

@@ -0,0 +1,455 @@
#!/usr/bin/env python3
"""
EcoOS Wayland Display Agent (eco-vdagent)
A Wayland-native replacement for spice-vdagent that uses swaymsg/wlr-output-management
instead of xrandr to configure displays.
Listens on the SPICE virtio-serial port for VD_AGENT_MONITORS_CONFIG messages
and applies the configuration to Sway outputs.
"""
import os
import sys
import struct
import subprocess
import json
import time
import signal
import logging
from pathlib import Path
# Configure logging
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - eco-vdagent - %(levelname)s - %(message)s'
)
log = logging.getLogger('eco-vdagent')
# SPICE VDAgent Protocol Constants
VD_AGENT_PROTOCOL = 1
# Message types
VD_AGENT_MOUSE_STATE = 1
VD_AGENT_MONITORS_CONFIG = 2
VD_AGENT_REPLY = 3
VD_AGENT_CLIPBOARD = 4
VD_AGENT_DISPLAY_CONFIG = 5
VD_AGENT_ANNOUNCE_CAPABILITIES = 6
VD_AGENT_CLIPBOARD_GRAB = 7
VD_AGENT_CLIPBOARD_REQUEST = 8
VD_AGENT_CLIPBOARD_RELEASE = 9
VD_AGENT_FILE_XFER_START = 10
VD_AGENT_FILE_XFER_STATUS = 11
VD_AGENT_FILE_XFER_DATA = 12
VD_AGENT_CLIENT_DISCONNECTED = 13
VD_AGENT_MAX_CLIPBOARD = 14
VD_AGENT_AUDIO_VOLUME_SYNC = 15
VD_AGENT_GRAPHICS_DEVICE_INFO = 16
# Reply error codes
VD_AGENT_SUCCESS = 1
VD_AGENT_ERROR = 2
# Capability bits
VD_AGENT_CAP_MOUSE_STATE = 0
VD_AGENT_CAP_MONITORS_CONFIG = 1
VD_AGENT_CAP_REPLY = 2
VD_AGENT_CAP_CLIPBOARD = 3
VD_AGENT_CAP_DISPLAY_CONFIG = 4
VD_AGENT_CAP_CLIPBOARD_BY_DEMAND = 5
VD_AGENT_CAP_CLIPBOARD_SELECTION = 6
VD_AGENT_CAP_SPARSE_MONITORS_CONFIG = 7
VD_AGENT_CAP_GUEST_LINEEND_LF = 8
VD_AGENT_CAP_GUEST_LINEEND_CRLF = 9
VD_AGENT_CAP_MAX_CLIPBOARD = 10
VD_AGENT_CAP_AUDIO_VOLUME_SYNC = 11
VD_AGENT_CAP_MONITORS_CONFIG_POSITION = 12
VD_AGENT_CAP_FILE_XFER_DISABLED = 13
VD_AGENT_CAP_FILE_XFER_DETAILED_ERRORS = 14
VD_AGENT_CAP_GRAPHICS_DEVICE_INFO = 15
VD_AGENT_CAP_CLIPBOARD_NO_RELEASE_ON_REGRAB = 16
VD_AGENT_CAP_CLIPBOARD_GRAB_SERIAL = 17
# Virtio serial port path
VIRTIO_PORT = '/dev/virtio-ports/com.redhat.spice.0'
# VDI Chunk header: port(4) + size(4) = 8 bytes
VDI_CHUNK_HEADER_SIZE = 8
VDI_CHUNK_HEADER_FMT = '<II' # port, size
# VDI Port constants
VDP_CLIENT_PORT = 1
VDP_SERVER_PORT = 2
# VDAgentMessage header: protocol(4) + type(4) + opaque(8) + size(4) = 20 bytes
VDAGENT_MSG_HEADER_SIZE = 20
VDAGENT_MSG_HEADER_FMT = '<IIQI' # little-endian: uint32, uint32, uint64, uint32
# VDAgentMonitorsConfig header: num_of_monitors(4) + flags(4) = 8 bytes
MONITORS_CONFIG_HEADER_SIZE = 8
MONITORS_CONFIG_HEADER_FMT = '<II'
# VDAgentMonConfig: height(4) + width(4) + depth(4) + x(4) + y(4) = 20 bytes
MON_CONFIG_SIZE = 20
MON_CONFIG_FMT = '<IIIii' # height, width, depth, x, y (x,y are signed)
class EcoVDAgent:
def __init__(self):
self.port_fd = None
self.running = True
self.sway_socket = None
def find_sway_socket(self):
"""Find the Sway IPC socket"""
# Check environment first
if 'SWAYSOCK' in os.environ:
return os.environ['SWAYSOCK']
# Search common locations
runtime_dir = os.environ.get('XDG_RUNTIME_DIR', '/run/user/1000')
# Try to find sway socket - check fixed path first, then glob patterns
import glob
# Check for fixed socket path first (set by eco-daemon)
fixed_socket = f'{runtime_dir}/sway-ipc.sock'
if os.path.exists(fixed_socket):
return fixed_socket
# Fall back to glob patterns for standard Sway socket naming
for pattern in [f'{runtime_dir}/sway-ipc.*.sock', '/run/user/*/sway-ipc.*.sock']:
sockets = glob.glob(pattern)
if sockets:
return sockets[0]
return None
def run_swaymsg(self, *args):
"""Run swaymsg command"""
cmd = ['swaymsg']
if self.sway_socket:
cmd.extend(['-s', self.sway_socket])
cmd.extend(args)
try:
result = subprocess.run(cmd, capture_output=True, text=True, timeout=5)
if result.returncode != 0:
log.warning(f"swaymsg failed: {result.stderr}")
return result.returncode == 0, result.stdout
except Exception as e:
log.error(f"Failed to run swaymsg: {e}")
return False, ""
def get_outputs(self):
"""Get current Sway outputs"""
success, output = self.run_swaymsg('-t', 'get_outputs', '-r')
if success:
try:
return json.loads(output)
except json.JSONDecodeError:
pass
return []
def configure_output(self, name, width, height, x, y, enable=True):
"""Configure a Sway output"""
if enable:
# Try to enable and position the output
# First, try setting mode
mode_cmd = f'output {name} mode {width}x{height} position {x} {y} enable'
success, _ = self.run_swaymsg(mode_cmd)
if not success:
# Try without explicit mode (use preferred)
pos_cmd = f'output {name} position {x} {y} enable'
success, _ = self.run_swaymsg(pos_cmd)
return success
else:
return self.run_swaymsg(f'output {name} disable')[0]
def apply_monitors_config(self, monitors):
"""Apply monitor configuration to Sway outputs"""
log.info(f"Applying configuration for {len(monitors)} monitors")
# Get current outputs
outputs = self.get_outputs()
output_names = [o.get('name') for o in outputs]
log.info(f"Available outputs: {output_names}")
# Sort monitors by x position to match with outputs
monitors_sorted = sorted(enumerate(monitors), key=lambda m: m[1]['x'])
# Match monitors to outputs
for i, (mon_idx, mon) in enumerate(monitors_sorted):
if i < len(output_names):
name = output_names[i]
log.info(f"Configuring {name}: {mon['width']}x{mon['height']} at ({mon['x']}, {mon['y']})")
self.configure_output(
name,
mon['width'],
mon['height'],
mon['x'],
mon['y'],
enable=True
)
else:
log.warning(f"No output available for monitor {mon_idx}")
# Disable extra outputs
for i in range(len(monitors), len(output_names)):
name = output_names[i]
log.info(f"Disabling unused output: {name}")
self.configure_output(name, 0, 0, 0, 0, enable=False)
def parse_monitors_config(self, data):
"""Parse VD_AGENT_MONITORS_CONFIG message"""
if len(data) < MONITORS_CONFIG_HEADER_SIZE:
log.error("Monitors config data too short")
return None
num_monitors, flags = struct.unpack(MONITORS_CONFIG_HEADER_FMT, data[:MONITORS_CONFIG_HEADER_SIZE])
log.info(f"Monitors config: {num_monitors} monitors, flags={flags}")
monitors = []
offset = MONITORS_CONFIG_HEADER_SIZE
for i in range(num_monitors):
if offset + MON_CONFIG_SIZE > len(data):
log.error(f"Truncated monitor config at index {i}")
break
height, width, depth, x, y = struct.unpack(
MON_CONFIG_FMT,
data[offset:offset + MON_CONFIG_SIZE]
)
monitors.append({
'width': width,
'height': height,
'depth': depth,
'x': x,
'y': y
})
log.info(f" Monitor {i}: {width}x{height}+{x}+{y} depth={depth}")
offset += MON_CONFIG_SIZE
return monitors
def send_reply(self, msg_type, error_code):
"""Send VD_AGENT_REPLY message"""
# Reply data: type(4) + error(4) = 8 bytes
reply_data = struct.pack('<II', msg_type, error_code)
if self.send_message(VD_AGENT_REPLY, reply_data):
log.debug(f"Sent reply for type {msg_type}: {'success' if error_code == VD_AGENT_SUCCESS else 'error'}")
else:
log.error(f"Failed to send reply for type {msg_type}")
def send_message(self, msg_type, data):
"""Send a VDAgent message with proper chunk header"""
if not self.port_fd:
return False
# Build VDAgentMessage header
msg_header = struct.pack(
VDAGENT_MSG_HEADER_FMT,
VD_AGENT_PROTOCOL,
msg_type,
0, # opaque
len(data)
)
# Full message = header + data
full_msg = msg_header + data
# Build VDI chunk header (port=SERVER, size=message size)
chunk_header = struct.pack(
VDI_CHUNK_HEADER_FMT,
VDP_SERVER_PORT,
len(full_msg)
)
# Retry writes with EAGAIN handling (non-blocking fd)
message = chunk_header + full_msg
retries = 10
while retries > 0:
try:
os.write(self.port_fd, message)
return True
except OSError as e:
if e.errno == 11: # EAGAIN - resource temporarily unavailable
retries -= 1
time.sleep(0.1)
continue
log.error(f"Failed to send message type {msg_type}: {e}")
return False
log.error(f"Failed to send message type {msg_type}: EAGAIN after retries")
return False
def announce_capabilities(self):
"""Send VD_AGENT_ANNOUNCE_CAPABILITIES to register with SPICE server"""
# Build capability bits - we support monitors config
caps = 0
caps |= (1 << VD_AGENT_CAP_MONITORS_CONFIG)
caps |= (1 << VD_AGENT_CAP_REPLY)
caps |= (1 << VD_AGENT_CAP_SPARSE_MONITORS_CONFIG)
caps |= (1 << VD_AGENT_CAP_MONITORS_CONFIG_POSITION)
# VDAgentAnnounceCapabilities: request(4) + caps(4) = 8 bytes
# request=1 means we want the server to send us its capabilities
announce_data = struct.pack('<II', 1, caps)
if self.send_message(VD_AGENT_ANNOUNCE_CAPABILITIES, announce_data):
log.info("Announced capabilities to SPICE server")
else:
log.error("Failed to announce capabilities")
def handle_message(self, msg_type, data):
"""Handle a VDAgent message"""
if msg_type == VD_AGENT_MONITORS_CONFIG:
log.info("Received VD_AGENT_MONITORS_CONFIG")
monitors = self.parse_monitors_config(data)
if monitors:
self.apply_monitors_config(monitors)
self.send_reply(VD_AGENT_MONITORS_CONFIG, VD_AGENT_SUCCESS)
else:
self.send_reply(VD_AGENT_MONITORS_CONFIG, VD_AGENT_ERROR)
elif msg_type == VD_AGENT_ANNOUNCE_CAPABILITIES:
log.info("Received VD_AGENT_ANNOUNCE_CAPABILITIES")
# We could respond with our capabilities here
# For now, just acknowledge
elif msg_type == VD_AGENT_DISPLAY_CONFIG:
log.info("Received VD_AGENT_DISPLAY_CONFIG")
# Display config for disabling client display changes
elif msg_type == VD_AGENT_CLIENT_DISCONNECTED:
log.info("Client disconnected")
else:
log.debug(f"Unhandled message type: {msg_type}")
def read_message(self):
"""Read a single VDAgent message from the port (with chunk header)"""
# Read VDI chunk header first
try:
chunk_header_data = os.read(self.port_fd, VDI_CHUNK_HEADER_SIZE)
except OSError as e:
if e.errno == 11: # EAGAIN
return None
raise
if len(chunk_header_data) < VDI_CHUNK_HEADER_SIZE:
if len(chunk_header_data) == 0:
return None
log.warning(f"Short chunk header read: {len(chunk_header_data)} bytes")
return None
port, chunk_size = struct.unpack(VDI_CHUNK_HEADER_FMT, chunk_header_data)
log.debug(f"Chunk header: port={port}, size={chunk_size}")
if chunk_size < VDAGENT_MSG_HEADER_SIZE:
log.warning(f"Chunk size too small: {chunk_size}")
return None
# Read VDAgent message header
try:
header_data = os.read(self.port_fd, VDAGENT_MSG_HEADER_SIZE)
except OSError as e:
if e.errno == 11: # EAGAIN
return None
raise
if len(header_data) < VDAGENT_MSG_HEADER_SIZE:
log.warning(f"Short message header read: {len(header_data)} bytes")
return None
protocol, msg_type, opaque, size = struct.unpack(VDAGENT_MSG_HEADER_FMT, header_data)
if protocol != VD_AGENT_PROTOCOL:
log.warning(f"Unknown protocol: {protocol}")
return None
# Read message data
data = b''
while len(data) < size:
try:
chunk = os.read(self.port_fd, size - len(data))
if not chunk:
break
data += chunk
except OSError as e:
if e.errno == 11: # EAGAIN
time.sleep(0.01)
continue
raise
return msg_type, data
def signal_handler(self, signum, frame):
"""Handle shutdown signals"""
log.info(f"Received signal {signum}, shutting down...")
self.running = False
def run(self):
"""Main loop"""
# Set up signal handlers
signal.signal(signal.SIGTERM, self.signal_handler)
signal.signal(signal.SIGINT, self.signal_handler)
# Find Sway socket
self.sway_socket = self.find_sway_socket()
if self.sway_socket:
log.info(f"Using Sway socket: {self.sway_socket}")
else:
log.warning("No Sway socket found, will retry...")
# Wait for virtio port
log.info(f"Waiting for virtio port: {VIRTIO_PORT}")
while self.running and not Path(VIRTIO_PORT).exists():
time.sleep(1)
if not self.running:
return
log.info("Opening virtio port...")
try:
self.port_fd = os.open(VIRTIO_PORT, os.O_RDWR | os.O_NONBLOCK)
except OSError as e:
log.error(f"Failed to open virtio port: {e}")
return
log.info("eco-vdagent started, announcing capabilities...")
# Announce our capabilities to the SPICE server
self.announce_capabilities()
log.info("Listening for SPICE agent messages...")
# Main loop
while self.running:
try:
# Try to find Sway socket if not found yet
if not self.sway_socket:
self.sway_socket = self.find_sway_socket()
result = self.read_message()
if result:
msg_type, data = result
self.handle_message(msg_type, data)
else:
time.sleep(0.1)
except Exception as e:
log.error(f"Error in main loop: {e}")
time.sleep(1)
if self.port_fd:
os.close(self.port_fd)
log.info("eco-vdagent stopped")
if __name__ == '__main__':
agent = EcoVDAgent()
agent.run()

View File

@@ -36,6 +36,7 @@ vim
nano
tmux
jq
python3
# System utilities
pciutils

View File

@@ -37,3 +37,4 @@ bluez-tools
# Virtualization support
qemu-guest-agent
open-vm-tools
# Note: Using eco-vdagent (Wayland-native) instead of spice-vdagent (X11-only)

326
isotest/enable-displays.py Executable file
View File

@@ -0,0 +1,326 @@
#!/usr/bin/env python3
"""
Enable multiple displays on a SPICE VM by sending monitor configuration.
Retries until the SPICE agent in the guest is connected.
"""
import gi
import sys
import time
import socket
import re
gi.require_version('SpiceClientGLib', '2.0')
from gi.repository import SpiceClientGLib, GLib
# Channel types (from spice-protocol)
CHANNEL_MAIN = 1
CHANNEL_DISPLAY = 2
def log(msg):
"""Print with flush for immediate output when backgrounded"""
print(msg, flush=True)
def wait_for_port(host, port, timeout=60):
"""Wait for a TCP port to be available"""
start = time.time()
while time.time() - start < timeout:
try:
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(1)
result = sock.connect_ex((host, port))
sock.close()
if result == 0:
return True
except:
pass
time.sleep(0.5)
return False
def parse_spice_uri(uri):
"""Parse spice://host:port URI"""
match = re.match(r'spice://([^:]+):(\d+)', uri)
if match:
return match.group(1), int(match.group(2))
return 'localhost', 5930
class SpiceDisplayEnabler:
def __init__(self, uri, num_displays=3, width=1920, height=1080, timeout=60):
self.uri = uri
self.num_displays = num_displays
self.width = width
self.height = height
self.timeout = timeout
self.session = None
self.main_channel = None
self.display_channels = []
self.loop = GLib.MainLoop()
self.configured = False
self.agent_connected = False
self.config_sent = False
self.config_retries = 0
self.max_retries = 3
self.stabilization_scheduled = False
self.connection_retries = 0
self.max_connection_retries = 30 # Try reconnecting for up to 5 minutes
self.agent_check_count = 0
self.configure_count = 0 # Track how many times we've configured (for reboots)
def on_channel_new(self, session, channel):
"""Handle new channel creation"""
channel_type = channel.get_property('channel-type')
channel_id = channel.get_property('channel-id')
if channel_type == CHANNEL_MAIN:
log(f"Main channel received (id={channel_id})")
self.main_channel = channel
channel.connect_after('channel-event', self.on_channel_event)
# Check agent status periodically
GLib.timeout_add(500, self.check_agent_and_configure)
elif channel_type == CHANNEL_DISPLAY:
log(f"Display channel received (id={channel_id})")
self.display_channels.append((channel_id, channel))
def on_channel_event(self, channel, event):
"""Handle channel events"""
log(f"Channel event: {event}")
if event == SpiceClientGLib.ChannelEvent.OPENED:
# Start checking for agent
GLib.timeout_add(100, self.check_agent_and_configure)
def check_agent_and_configure(self):
"""Check if agent is connected and configure if ready"""
if self.stabilization_scheduled:
return True # Keep checking but don't act yet
if not self.main_channel:
return True # Keep checking
was_connected = self.agent_connected
self.agent_connected = self.main_channel.get_property('agent-connected')
self.agent_check_count += 1
# Detect agent disconnect (VM reboot)
if was_connected and not self.agent_connected:
log(f"Agent disconnected (VM may be rebooting)...")
self.configured = False
self.config_sent = False
self.config_retries = 0
# Log every 10 checks (5 seconds)
if self.agent_check_count % 10 == 0:
status = "connected" if self.agent_connected else "waiting"
log(f"Agent {status} (check #{self.agent_check_count}, configured={self.configure_count}x)")
if self.agent_connected and not self.config_sent and not self.stabilization_scheduled:
log(f"Agent connected! Waiting 2s for stabilization...")
self.stabilization_scheduled = True
# Wait 2 seconds for agent to fully initialize before configuring
GLib.timeout_add(2000, self.configure_monitors)
return True # Always keep checking for reboots
def configure_monitors(self):
"""Configure multiple monitors via SPICE protocol"""
if self.configured:
return False # Already done
if not self.main_channel:
log("No main channel!")
return False
self.config_retries += 1
attempt_str = f" (attempt {self.config_retries}/{self.max_retries})" if self.config_retries > 1 else ""
log(f"Configuring {self.num_displays} displays{attempt_str}...")
# Enable and configure each display
for i in range(self.num_displays):
x = i * self.width # Position displays side by side
y = 0
try:
self.main_channel.update_display_enabled(i, True, False)
self.main_channel.update_display(i, x, y, self.width, self.height, False)
except Exception as e:
log(f" Error setting display {i}: {e}")
# Send the configuration
try:
self.main_channel.send_monitor_config()
self.config_sent = True
log(f"Sent config for {self.num_displays} displays at {self.width}x{self.height}")
except Exception as e:
log(f"Error sending config: {e}")
# Schedule verification/retry after 3 seconds
GLib.timeout_add(3000, self.verify_and_retry)
return False # Don't repeat this timeout
def verify_and_retry(self):
"""Verify configuration was applied, retry if needed"""
if self.configured:
return False # Already done
# Check if displays are actually enabled by re-checking agent state
if not self.main_channel:
log("Lost main channel during verification")
self.quit()
return False
# The SPICE protocol doesn't provide a direct way to verify display config
# was applied. We assume success if we sent config and agent is still connected.
agent_still_connected = self.main_channel.get_property('agent-connected')
if agent_still_connected and self.config_sent:
# Mark as configured and send again for good measure
if self.config_retries < self.max_retries:
log(f"Sending config again to ensure it takes effect...")
self.config_sent = False # Allow retry
self.configure_monitors()
else:
# We've tried enough, assume success
self.configured = True
self.configure_count += 1
self.stabilization_scheduled = False # Allow reconfiguration after reboot
log(f"Configuration complete (configured {self.configure_count}x total)")
# Don't quit - keep running to handle VM reboots
elif not agent_still_connected:
log("Agent disconnected during verification - will retry when reconnected")
self.config_sent = False
self.config_retries = 0
self.stabilization_scheduled = False
# Don't quit - agent will reconnect after reboot
else:
# Config not sent but agent connected - try again
if self.config_retries < self.max_retries:
log(f"Config not sent, retrying...")
self.configure_monitors()
else:
log(f"Failed after {self.config_retries} attempts")
self.quit()
return False # Don't repeat this timeout
def quit(self):
self.loop.quit()
return False
def on_timeout(self):
"""Handle overall timeout"""
if not self.configured:
log(f"Timeout after {self.timeout}s - agent not connected (checks={self.agent_check_count})")
self.quit()
return False
def check_connection_health(self):
"""Check if connection is healthy, reconnect if needed"""
log(f"Health check: configured={self.configure_count}x, main_channel={self.main_channel is not None}, agent={self.agent_connected}")
# Don't stop checking - we need to handle reboots
if self.stabilization_scheduled:
return True # Keep checking but don't reconnect during stabilization
# If we don't have a main channel after 10 seconds, reconnect
if not self.main_channel:
self.connection_retries += 1
if self.connection_retries > self.max_connection_retries:
log(f"Giving up after {self.connection_retries} connection attempts")
return False
log(f"No main channel received, reconnecting (attempt {self.connection_retries})...")
self.reconnect()
return True # Keep checking
return True # Keep checking connection health
def reconnect(self):
"""Disconnect and reconnect to SPICE"""
if self.session:
try:
self.session.disconnect()
except:
pass
# Reset state for new connection
self.main_channel = None
self.display_channels = []
# Create new session
self.session = SpiceClientGLib.Session()
self.session.set_property('uri', self.uri)
self.session.connect_after('channel-new', self.on_channel_new)
if not self.session.connect():
log(" Reconnection failed, will retry...")
def run(self):
log(f"Connecting to {self.uri}...")
log(f"Waiting up to {self.timeout}s for agent...")
# Wait for SPICE port to be available before connecting
host, port = parse_spice_uri(self.uri)
log(f"Waiting for SPICE server at {host}:{port}...")
if not wait_for_port(host, port, timeout=60):
log(f"SPICE server not available after 60s")
return False
log(f"SPICE port {port} is open, connecting...")
# Give SPICE server a moment to fully initialize after port opens
time.sleep(1)
self.session = SpiceClientGLib.Session()
self.session.set_property('uri', self.uri)
self.session.connect_after('channel-new', self.on_channel_new)
if not self.session.connect():
log("Initial connection failed, will retry...")
# Check connection health every 10 seconds
GLib.timeout_add(10000, self.check_connection_health)
# Set overall timeout
GLib.timeout_add(self.timeout * 1000, self.on_timeout)
log("Entering main loop...")
self.loop.run()
log("Main loop exited")
if self.configured:
log(f"Success: {self.num_displays} displays enabled")
else:
log("Failed: Could not enable displays")
return self.configured
def main():
import argparse
parser = argparse.ArgumentParser(description='Enable SPICE VM displays')
parser.add_argument('uri', nargs='?', default='spice://localhost:5930',
help='SPICE URI (default: spice://localhost:5930)')
parser.add_argument('num_displays', nargs='?', type=int, default=3,
help='Number of displays to enable (default: 3)')
parser.add_argument('--timeout', '-t', type=int, default=60,
help='Timeout in seconds (default: 60)')
parser.add_argument('--width', '-W', type=int, default=1920,
help='Display width (default: 1920)')
parser.add_argument('--height', '-H', type=int, default=1080,
help='Display height (default: 1080)')
args = parser.parse_args()
enabler = SpiceDisplayEnabler(
args.uri,
args.num_displays,
args.width,
args.height,
args.timeout
)
success = enabler.run()
sys.exit(0 if success else 1)
if __name__ == '__main__':
main()

View File

@@ -2,6 +2,17 @@
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
# Parse arguments
AUTO_MODE=false
for arg in "$@"; do
case $arg in
--auto)
AUTO_MODE=true
shift
;;
esac
done
PROJECT_ROOT="$SCRIPT_DIR/.."
VM_DIR="$PROJECT_ROOT/.nogit/vm"
ISO_PATH="$PROJECT_ROOT/.nogit/iso/ecoos.iso"
@@ -48,7 +59,31 @@ else
echo "KVM not available, using software emulation (slower)"
fi
# Start QEMU with VirtIO-GPU (VirGL OpenGL acceleration) and serial console
# Cleanup function
cleanup() {
echo ""
echo "Shutting down..."
if [ -n "$VIEWER_PID" ] && kill -0 "$VIEWER_PID" 2>/dev/null; then
kill "$VIEWER_PID" 2>/dev/null || true
fi
if [ -n "$TWM_PID" ] && kill -0 "$TWM_PID" 2>/dev/null; then
kill "$TWM_PID" 2>/dev/null || true
fi
if [ -n "$XORG_PID" ] && kill -0 "$XORG_PID" 2>/dev/null; then
kill "$XORG_PID" 2>/dev/null || true
fi
if [ -f "$PID_FILE" ]; then
PID=$(cat "$PID_FILE")
if kill -0 "$PID" 2>/dev/null; then
kill "$PID" 2>/dev/null || true
fi
rm -f "$PID_FILE"
fi
echo "Done"
}
trap cleanup EXIT INT TERM
# Start QEMU with virtio-gpu multi-head (3 outputs)
> "$SERIAL_LOG" # Clear old log
qemu-system-x86_64 \
$KVM_OPTS \
@@ -57,24 +92,164 @@ qemu-system-x86_64 \
-bios /usr/share/qemu/OVMF.fd \
-drive file="$ISO_PATH",media=cdrom \
-drive file="$DISK_PATH",format=qcow2,if=virtio \
-device virtio-vga \
-device qxl-vga,id=video0,ram_size=67108864,vram_size=67108864,vgamem_mb=64 \
-device qxl,id=video1,ram_size=67108864,vram_size=67108864,vgamem_mb=64 \
-device qxl,id=video2,ram_size=67108864,vram_size=67108864,vgamem_mb=64 \
-display none \
-spice port=5930,disable-ticketing=on \
-device virtio-serial-pci \
-chardev spicevmc,id=vdagent,name=vdagent \
-device virtserialport,chardev=vdagent,name=com.redhat.spice.0 \
-serial unix:"$SERIAL_SOCK",server,nowait \
-monitor unix:"$MONITOR_SOCK",server,nowait \
-nic user,model=virtio-net-pci,hostfwd=tcp::3006-:3006,hostfwd=tcp::2222-:22 \
-pidfile "$PID_FILE" &
QEMU_PID=$!
echo ""
sleep 1
echo "=== EcoOS Test VM Started ==="
echo "PID: $(cat $PID_FILE 2>/dev/null || echo 'running')"
echo "SPICE: spicy -h localhost -p 5930"
echo "Serial Log: $SERIAL_LOG"
echo "QEMU PID: $QEMU_PID"
echo "Management UI: http://localhost:3006"
echo ""
echo "Commands:"
echo " pnpm run test:screenshot - Take screenshot"
echo " pnpm run test:stop - Stop VM"
echo " tail -f $SERIAL_LOG - Watch serial console"
echo " socat - UNIX-CONNECT:$SERIAL_SOCK - Interactive serial"
# Wait for QEMU to start and SPICE to be ready
echo "Waiting for SPICE server..."
sleep 3
# Check if remote-viewer is available
if ! command -v remote-viewer &> /dev/null; then
echo "WARNING: remote-viewer not installed"
echo "Install with: sudo apt install virt-viewer"
echo ""
echo "Running without display viewer. Press Ctrl-C to stop."
wait $QEMU_PID
exit 0
fi
# Set up virt-viewer settings for multi-display
VIRT_VIEWER_CONFIG_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/virt-viewer"
mkdir -p "$VIRT_VIEWER_CONFIG_DIR"
if [ -f "$SCRIPT_DIR/virt-viewer-settings" ]; then
cp "$SCRIPT_DIR/virt-viewer-settings" "$VIRT_VIEWER_CONFIG_DIR/settings"
echo "Configured virt-viewer for 3 displays"
fi
# Detect DISPLAY if not set
if [ -z "$DISPLAY" ]; then
# Try to find an active X display
if [ -S /tmp/.X11-unix/X0 ]; then
export DISPLAY=:0
elif [ -S /tmp/.X11-unix/X1 ]; then
export DISPLAY=:1
fi
fi
# Detect WAYLAND_DISPLAY if not set
if [ -z "$WAYLAND_DISPLAY" ] && [ -z "$DISPLAY" ]; then
# Try common Wayland sockets
if [ -S "$XDG_RUNTIME_DIR/wayland-0" ]; then
export WAYLAND_DISPLAY=wayland-0
elif [ -S "/run/user/$(id -u)/wayland-0" ]; then
export XDG_RUNTIME_DIR="/run/user/$(id -u)"
export WAYLAND_DISPLAY=wayland-0
fi
fi
# Launch remote-viewer - use dummy X server with 3 monitors if no display available
if [ -z "$DISPLAY" ] && [ -z "$WAYLAND_DISPLAY" ]; then
echo "No display found, starting headless X server with 3 virtual monitors..."
# Find an available display number
XDISPLAY=99
while [ -S "/tmp/.X11-unix/X$XDISPLAY" ]; do
XDISPLAY=$((XDISPLAY + 1))
done
# Start Xorg with dummy driver config for 3 monitors
XORG_CONFIG="$SCRIPT_DIR/xorg-dummy.conf"
Xorg :$XDISPLAY -config "$XORG_CONFIG" -noreset +extension GLX +extension RANDR +extension RENDER &
XORG_PID=$!
sleep 2
export DISPLAY=:$XDISPLAY
# Configure 3 virtual monitors using xrandr
# Add mode to disconnected DUMMY outputs and position them
xrandr --newmode "1920x1080" 173.00 1920 2048 2248 2576 1080 1083 1088 1120 -hsync +vsync 2>/dev/null || true
# Add mode to DUMMY1 and DUMMY2, then enable them
xrandr --addmode DUMMY1 "1920x1080" 2>/dev/null || true
xrandr --addmode DUMMY2 "1920x1080" 2>/dev/null || true
# Position the outputs side by side
xrandr --output DUMMY0 --mode 1920x1080 --pos 0x0 --primary
xrandr --output DUMMY1 --mode 1920x1080 --pos 1920x0 2>/dev/null || true
xrandr --output DUMMY2 --mode 1920x1080 --pos 3840x0 2>/dev/null || true
echo "Headless X server started on :$XDISPLAY"
# Launch remote-viewer in fullscreen to request all monitors
remote-viewer --full-screen spice://localhost:5930 &
VIEWER_PID=$!
echo "remote-viewer running headlessly (PID: $VIEWER_PID)"
else
echo "Launching remote-viewer with fullscreen for multi-display (DISPLAY=$DISPLAY, WAYLAND_DISPLAY=$WAYLAND_DISPLAY)..."
remote-viewer --full-screen spice://localhost:5930 &
VIEWER_PID=$!
fi
echo ""
echo "=== Press Ctrl-C to stop ==="
echo ""
# Enable all 3 displays via SPICE protocol (waits for agent automatically)
# Using 300s timeout since ISO boot can take several minutes
if [ -f "$SCRIPT_DIR/enable-displays.py" ]; then
echo "Enabling displays (waiting for SPICE agent, up to 5 minutes)..."
python3 "$SCRIPT_DIR/enable-displays.py" --timeout 300 2>&1 &
ENABLE_PID=$!
fi
echo "Tips:"
echo " - pnpm run test:screenshot - Take screenshot"
echo " - http://localhost:3006 - Management UI"
echo " - socat - UNIX-CONNECT:.nogit/vm/serial.sock - Serial console (login: ecouser/ecouser)"
echo ""
if [ "$AUTO_MODE" = true ]; then
echo "=== Auto mode: waiting for display setup ==="
# Wait for enable-displays.py to complete
if [ -n "$ENABLE_PID" ]; then
wait $ENABLE_PID
ENABLE_EXIT=$?
if [ $ENABLE_EXIT -ne 0 ]; then
echo "FAIL: Could not enable displays (exit code: $ENABLE_EXIT)"
exit 1
fi
fi
# Take screenshot
echo "Taking screenshot..."
"$SCRIPT_DIR/screenshot.sh"
# Verify screenshot dimensions (should be 5760x1080 for 3 displays)
SCREENSHOT="$PROJECT_ROOT/.nogit/screenshots/latest.png"
if [ -f "$SCREENSHOT" ]; then
WIDTH=$(identify -format "%w" "$SCREENSHOT" 2>/dev/null || echo "0")
if [ "$WIDTH" -ge 5760 ]; then
echo "SUCCESS: Multi-display test passed (width: ${WIDTH}px)"
exit 0
else
echo "FAIL: Screenshot width is ${WIDTH}px, expected >= 5760px"
exit 1
fi
else
echo "FAIL: Screenshot not found"
exit 1
fi
else
# Interactive mode - wait for QEMU to exit
wait $QEMU_PID 2>/dev/null || true
fi

View File

@@ -4,6 +4,7 @@ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_ROOT="$SCRIPT_DIR/.."
VM_DIR="$PROJECT_ROOT/.nogit/vm"
SCREENSHOT_DIR="$PROJECT_ROOT/.nogit/screenshots"
TIMESTAMPED_DIR="$SCREENSHOT_DIR/timestamped"
MONITOR_SOCK="$VM_DIR/qemu-monitor.sock"
TIMESTAMP=$(date +%Y%m%d-%H%M%S)
@@ -15,35 +16,38 @@ if [ ! -S "$MONITOR_SOCK" ]; then
fi
mkdir -p "$SCREENSHOT_DIR"
PPM_FILE="$SCREENSHOT_DIR/ecoos-$TIMESTAMP.ppm"
PNG_FILE="$SCREENSHOT_DIR/ecoos-$TIMESTAMP.png"
LATEST_FILE="$SCREENSHOT_DIR/latest.png"
mkdir -p "$TIMESTAMPED_DIR"
echo "Taking screenshot..."
echo "screendump $PPM_FILE" | socat - UNIX-CONNECT:"$MONITOR_SOCK"
sleep 1
# Check if PPM was created
PPM_FILE="$SCREENSHOT_DIR/temp.ppm"
LATEST_FILE="$SCREENSHOT_DIR/latest.png"
TIMESTAMPED_FILE="$TIMESTAMPED_DIR/ecoos-$TIMESTAMP.png"
# Take screenshot (virtio-vga captures all outputs in one framebuffer)
echo "screendump $PPM_FILE" | socat - UNIX-CONNECT:"$MONITOR_SOCK" > /dev/null 2>&1
sleep 0.5
if [ ! -f "$PPM_FILE" ]; then
echo "ERROR: Screenshot failed"
echo "ERROR: Failed to capture screenshot"
exit 1
fi
# Convert to PNG if imagemagick is available
# Convert to PNG
if command -v convert &> /dev/null; then
convert "$PPM_FILE" "$PNG_FILE"
convert "$PPM_FILE" "$LATEST_FILE"
cp "$LATEST_FILE" "$TIMESTAMPED_FILE"
rm "$PPM_FILE"
# Copy to latest.png
cp "$PNG_FILE" "$LATEST_FILE"
echo "Screenshot saved: $PNG_FILE"
echo "Also saved as: $LATEST_FILE"
echo "Screenshot saved: $LATEST_FILE"
echo "Timestamped copy: $TIMESTAMPED_FILE"
else
echo "Screenshot saved: $PPM_FILE"
echo "(Install imagemagick to auto-convert to PNG)"
mv "$PPM_FILE" "$SCREENSHOT_DIR/latest.ppm"
cp "$SCREENSHOT_DIR/latest.ppm" "$TIMESTAMPED_DIR/ecoos-$TIMESTAMP.ppm"
echo "Screenshot saved: $SCREENSHOT_DIR/latest.ppm"
echo "(Install ImageMagick for PNG conversion)"
fi
# Keep only last 20 screenshots (excluding latest.png)
cd "$SCREENSHOT_DIR"
ls -t ecoos-*.png 2>/dev/null | tail -n +21 | xargs -r rm -f
# Keep only last 50 timestamped screenshots
cd "$TIMESTAMPED_DIR"
ls -t ecoos-*.png 2>/dev/null | tail -n +51 | xargs -r rm -f
ls -t ecoos-*.ppm 2>/dev/null | tail -n +51 | xargs -r rm -f

View File

@@ -0,0 +1,5 @@
[virt-viewer]
share-clipboard=true
[fallback]
monitor-mapping=1:1;2:2;3:3

41
isotest/xorg-dummy.conf Normal file
View File

@@ -0,0 +1,41 @@
# Xorg configuration for 3 virtual monitors using dummy driver with RandR
# Used for headless multi-display testing with SPICE/remote-viewer
Section "ServerFlags"
Option "DontVTSwitch" "true"
Option "AllowMouseOpenFail" "true"
Option "PciForceNone" "true"
Option "AutoEnableDevices" "false"
Option "AutoAddDevices" "false"
EndSection
Section "Device"
Identifier "dummy"
Driver "dummy"
VideoRam 768000
EndSection
Section "Monitor"
Identifier "Monitor0"
HorizSync 28.0-80.0
VertRefresh 48.0-75.0
# 1920x1080 @ 60Hz (CVT) modeline
Modeline "1920x1080" 173.00 1920 2048 2248 2576 1080 1083 1088 1120 -hsync +vsync
EndSection
Section "Screen"
Identifier "Screen0"
Device "dummy"
Monitor "Monitor0"
DefaultDepth 24
SubSection "Display"
Depth 24
Modes "1920x1080"
Virtual 5760 1080
EndSubSection
EndSection
Section "ServerLayout"
Identifier "Layout0"
Screen 0 "Screen0" 0 0
EndSection

View File

@@ -1,6 +1,6 @@
{
"name": "@ecobridge/eco-os",
"version": "0.3.5",
"version": "0.4.15",
"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",