Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f85241dcd5 | |||
| 45b593cd7c | |||
| 352562b1a5 | |||
| e02b5b7046 | |||
| 7727fafeec | |||
| 0539d183b1 | |||
| ec4eed38e4 | |||
| c8ab9afbc6 | |||
| 3125b77020 | |||
| de10e1dd1f | |||
| 21f7a44a53 | |||
| 98398e962f | |||
| 06cea4bb37 | |||
| ee631c21c4 | |||
| 50d437aed7 | |||
| dd5ea36636 | |||
| 7d6aace6d9 | |||
| 99a04df8b0 | |||
| ee3b6dd6ae | |||
| 708917bb9b | |||
| 5c6d4f4802 |
108
.gitea/release-upload.ts
Normal file
108
.gitea/release-upload.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* Release asset uploader for Gitea
|
||||
* Streams large files without loading them into memory (bypasses curl's 2GB multipart limit)
|
||||
*
|
||||
* Usage: GITEA_TOKEN=xxx RELEASE_ID=123 GITEA_REPO=owner/repo tsx release-upload.ts
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as https from 'https';
|
||||
|
||||
const token = process.env.GITEA_TOKEN;
|
||||
const releaseId = process.env.RELEASE_ID;
|
||||
const repo = process.env.GITEA_REPO;
|
||||
|
||||
if (!token || !releaseId || !repo) {
|
||||
console.error('Missing required env vars: GITEA_TOKEN, RELEASE_ID, GITEA_REPO');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const boundary = '----FormBoundary' + Date.now().toString(16);
|
||||
|
||||
async function uploadFile(filepath: string): Promise<void> {
|
||||
const filename = path.basename(filepath);
|
||||
const stats = fs.statSync(filepath);
|
||||
console.log(`Uploading ${filename} (${stats.size} bytes)...`);
|
||||
|
||||
const header = Buffer.from(
|
||||
`--${boundary}\r\n` +
|
||||
`Content-Disposition: form-data; name="attachment"; filename="${filename}"\r\n` +
|
||||
`Content-Type: application/octet-stream\r\n\r\n`
|
||||
);
|
||||
const footer = Buffer.from(`\r\n--${boundary}--\r\n`);
|
||||
const contentLength = header.length + stats.size + footer.length;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = https.request({
|
||||
hostname: 'code.foss.global',
|
||||
path: `/api/v1/repos/${repo}/releases/${releaseId}/assets?name=${encodeURIComponent(filename)}`,
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `token ${token}`,
|
||||
'Content-Type': `multipart/form-data; boundary=${boundary}`,
|
||||
'Content-Length': contentLength
|
||||
}
|
||||
}, (res) => {
|
||||
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`);
|
||||
resolve();
|
||||
} else {
|
||||
reject(new Error(`Upload failed: ${res.statusCode} ${data}`));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
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('data', (chunk) => {
|
||||
bytesWritten += chunk.length;
|
||||
});
|
||||
stream.on('error', (err) => {
|
||||
clearInterval(progressInterval);
|
||||
reject(err);
|
||||
});
|
||||
stream.on('end', () => {
|
||||
bytesWritten += footer.length;
|
||||
req.write(footer);
|
||||
req.end();
|
||||
});
|
||||
stream.pipe(req, { end: false });
|
||||
});
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const distDir = 'dist';
|
||||
const files = fs.readdirSync(distDir)
|
||||
.map(f => path.join(distDir, f))
|
||||
.filter(f => fs.statSync(f).isFile());
|
||||
|
||||
for (const file of files) {
|
||||
await uploadFile(file);
|
||||
}
|
||||
|
||||
console.log('All assets uploaded successfully');
|
||||
}
|
||||
|
||||
main().catch(err => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -75,16 +75,11 @@ jobs:
|
||||
|
||||
echo "Created release with ID: $RELEASE_ID"
|
||||
|
||||
# Upload assets
|
||||
for asset in dist/*; do
|
||||
filename=$(basename "$asset")
|
||||
echo "Uploading $filename..."
|
||||
curl -X POST -s \
|
||||
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
|
||||
-H "Content-Type: application/octet-stream" \
|
||||
--data-binary "@$asset" \
|
||||
"https://code.foss.global/api/v1/repos/${{ gitea.repository }}/releases/$RELEASE_ID/assets?name=$filename"
|
||||
done
|
||||
# Upload assets using TypeScript (curl has 2GB multipart limit)
|
||||
GITEA_TOKEN="${{ secrets.GITHUB_TOKEN }}" \
|
||||
GITEA_REPO="${{ gitea.repository }}" \
|
||||
RELEASE_ID="$RELEASE_ID" \
|
||||
npx tsx .gitea/release-upload.ts
|
||||
|
||||
- name: Cleanup old releases (keep 3 latest)
|
||||
run: |
|
||||
|
||||
58
changelog.md
58
changelog.md
@@ -1,5 +1,63 @@
|
||||
# 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
|
||||
|
||||
- Add .gitea/release-upload.ts: streams assets to Gitea to avoid curl's 2GB multipart limit
|
||||
- Update CI workflow (.gitea/workflows/release.yml) to run the TypeScript uploader via tsx
|
||||
- Bump package.json and ecoos_daemon/ts/version.ts to 0.3.4
|
||||
- Update bundled eco-daemon binary in isobuild/config/includes.chroot/opt/eco/bin/
|
||||
|
||||
## 2026-01-09 - 0.3.2 - fix(release)
|
||||
bump package and daemon to v0.3.1, add project README, and fix Gitea release upload flag
|
||||
|
||||
- package.json version updated from 0.3.0 to 0.3.1
|
||||
- ecoos_daemon/ts/version.ts updated to export VERSION = "0.3.1"
|
||||
- Added comprehensive readme.md documenting the project, development and release workflow
|
||||
- Fix .gitea/workflows/release.yml: use curl -T for uploading release assets instead of --data-binary
|
||||
- Updated bundled eco-daemon binary in isobuild/config/includes.chroot/opt/eco/bin/ (new build artifact)
|
||||
|
||||
## 2026-01-09 - 0.3.0 - feat(daemon)
|
||||
add automatic update mechanism (Updater), switch to system journal logs, and expose update controls in the UI
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.2.3";
|
||||
export const VERSION = "0.4.14";
|
||||
|
||||
455
ecoos_daemon/vdagent/eco-vdagent.py
Normal file
455
ecoos_daemon/vdagent/eco-vdagent.py
Normal 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()
|
||||
@@ -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."
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
/etc/systemd/system/eco-vdagent.service
|
||||
Binary file not shown.
455
isobuild/config/includes.chroot/opt/eco/bin/eco-vdagent
Executable file
455
isobuild/config/includes.chroot/opt/eco/bin/eco-vdagent
Executable 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()
|
||||
@@ -36,6 +36,7 @@ vim
|
||||
nano
|
||||
tmux
|
||||
jq
|
||||
python3
|
||||
|
||||
# System utilities
|
||||
pciutils
|
||||
|
||||
@@ -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
326
isotest/enable-displays.py
Executable 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()
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
5
isotest/virt-viewer-settings
Normal file
5
isotest/virt-viewer-settings
Normal 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
41
isotest/xorg-dummy.conf
Normal 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
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@ecobridge/eco-os",
|
||||
"version": "0.3.0",
|
||||
"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",
|
||||
|
||||
268
readme.md
Normal file
268
readme.md
Normal file
@@ -0,0 +1,268 @@
|
||||
# 🌍 EcoOS
|
||||
|
||||
> **A purpose-built, minimal Linux distribution for kiosk and digital signage deployments.**
|
||||
|
||||
EcoOS is a streamlined operating system that boots directly into a full-screen Chromium browser, managed by a powerful daemon with a built-in web UI. Perfect for digital signage, interactive kiosks, info displays, and any scenario where you need a locked-down, browser-based interface.
|
||||
|
||||
## Issue Reporting and Security
|
||||
|
||||
For reporting bugs, issues, or security vulnerabilities, please visit [community.foss.global/](https://community.foss.global/). This is the central community hub for all issue reporting. Developers who sign and comply with our contribution agreement and go through identification can also get a [code.foss.global/](https://code.foss.global/) account to submit Pull Requests directly.
|
||||
|
||||
---
|
||||
|
||||
## ✨ Features
|
||||
|
||||
- **🚀 Zero-Config Boot** — Boots straight into a Wayland-based kiosk browser
|
||||
- **🖥️ Sway Compositor** — Modern, tiling Wayland compositor with automatic fallback modes
|
||||
- **🌐 Chromium Kiosk** — Full-screen browser in locked-down kiosk mode
|
||||
- **🎛️ Management UI** — Real-time system monitoring and control via web interface on port 3006
|
||||
- **🔄 Auto-Updates** — Daemon self-updates with smart stability checking
|
||||
- **📊 System Monitoring** — CPU, memory, disk, network, GPU, and audio device stats
|
||||
- **📝 Live Logs** — System journal and daemon logs accessible from the UI
|
||||
- **🔌 Hardware Support** — Input devices, speakers, microphones detection and display
|
||||
- **⚡ Rapid Recovery** — Auto-restart of crashed services within seconds
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ EcoOS ISO │
|
||||
├─────────────────────────────────────────────────────────┤
|
||||
│ ┌─────────────────────────────────────────────────┐ │
|
||||
│ │ eco-daemon (systemd) │ │
|
||||
│ │ ┌─────────┐ ┌─────────┐ ┌─────────────────┐ │ │
|
||||
│ │ │ Process │ │ System │ │ Updater │ │ │
|
||||
│ │ │ Manager │ │ Info │ │ (auto-upgrade) │ │ │
|
||||
│ │ └────┬────┘ └────┬────┘ └────────┬────────┘ │ │
|
||||
│ │ │ │ │ │ │
|
||||
│ │ ▼ ▼ ▼ │ │
|
||||
│ │ ┌─────────────────────────────────────────┐ │ │
|
||||
│ │ │ UI Server (:3006) │ │ │
|
||||
│ │ │ REST API │ WebSocket │ Dashboard │ │ │
|
||||
│ │ └─────────────────────────────────────────┘ │ │
|
||||
│ └─────────────────────────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ ┌──────────────────┐ ┌─────────────────────────┐ │
|
||||
│ │ Sway Compositor │───│ Chromium (kiosk mode) │ │
|
||||
│ │ (Wayland) │ │ → localhost:3006 │ │
|
||||
│ └──────────────────┘ └─────────────────────────┘ │
|
||||
├─────────────────────────────────────────────────────────┤
|
||||
│ Ubuntu 24.04 Base │ systemd │ seatd │ pipewire │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Development
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- **Docker** (for ISO building)
|
||||
- **pnpm** (package manager)
|
||||
- **Deno** v2.x (for daemon development)
|
||||
- **QEMU** (for testing)
|
||||
|
||||
### Project Structure
|
||||
|
||||
```
|
||||
eco_os/
|
||||
├── ecoos_daemon/ # Daemon source (Deno/TypeScript)
|
||||
│ ├── mod.ts # Entry point
|
||||
│ └── ts/
|
||||
│ ├── daemon/ # Core daemon logic
|
||||
│ │ ├── index.ts # EcoDaemon class
|
||||
│ │ ├── process-manager.ts # Sway/Chromium management
|
||||
│ │ ├── system-info.ts # Hardware detection
|
||||
│ │ └── updater.ts # Auto-update system
|
||||
│ ├── ui/ # Web UI server
|
||||
│ └── utils/ # Utilities
|
||||
├── isobuild/ # ISO build configuration
|
||||
│ ├── Dockerfile # Build container
|
||||
│ ├── config/ # live-build config
|
||||
│ └── scripts/ # Build scripts
|
||||
├── isotest/ # QEMU test scripts
|
||||
└── .nogit/ # Generated artifacts (not in git)
|
||||
├── iso/ # Built ISO
|
||||
├── vm/ # QEMU files
|
||||
└── screenshots/ # VM screenshots
|
||||
```
|
||||
|
||||
### Commands
|
||||
|
||||
```bash
|
||||
# Build the full ISO (auto-rebuilds daemon first)
|
||||
pnpm run build
|
||||
|
||||
# Test ISO in QEMU virtual machine
|
||||
pnpm run test
|
||||
|
||||
# Take screenshot of running VM
|
||||
pnpm run test:screenshot
|
||||
|
||||
# Stop the QEMU VM
|
||||
pnpm run test:stop
|
||||
|
||||
# Clean all build artifacts
|
||||
pnpm run clean
|
||||
|
||||
# Daemon development (watch mode)
|
||||
pnpm run daemon:dev
|
||||
|
||||
# Bundle daemon to standalone binary
|
||||
pnpm run daemon:bundle
|
||||
|
||||
# Type-check daemon code
|
||||
pnpm run daemon:typecheck
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🖥️ Management UI
|
||||
|
||||
The daemon exposes a management interface at `http://localhost:3006` (or the device's IP on port 3006).
|
||||
|
||||
### Dashboard Features
|
||||
|
||||
| Panel | Description |
|
||||
|-------|-------------|
|
||||
| **Services** | Status of Sway compositor and Chromium browser |
|
||||
| **CPU** | Model, core count, real-time usage |
|
||||
| **Memory** | Used/total with visual progress bar |
|
||||
| **Network** | Interface names and IP addresses |
|
||||
| **Disks** | Mount points, usage, and capacity |
|
||||
| **System** | Hostname, uptime, GPU info |
|
||||
| **Controls** | Restart browser, reboot system buttons |
|
||||
| **Updates** | Version info, available updates, upgrade controls |
|
||||
| **Input Devices** | Keyboards, mice, touchscreens |
|
||||
| **Audio** | Detected speakers and microphones |
|
||||
| **Logs** | Daemon logs and system journal viewer |
|
||||
|
||||
### API Endpoints
|
||||
|
||||
| Endpoint | Method | Description |
|
||||
|----------|--------|-------------|
|
||||
| `/api/status` | GET | Full system status |
|
||||
| `/api/logs` | GET | Daemon logs |
|
||||
| `/api/reboot` | POST | Reboot the system |
|
||||
| `/api/restart-chromium` | POST | Restart the kiosk browser |
|
||||
| `/api/updates` | GET | Update information |
|
||||
| `/api/updates/check` | POST | Check for new updates |
|
||||
| `/api/upgrade` | POST | Upgrade to specific version |
|
||||
| `/ws` | WebSocket | Real-time status updates |
|
||||
|
||||
---
|
||||
|
||||
## 🔄 Update System
|
||||
|
||||
EcoOS features a smart auto-update mechanism:
|
||||
|
||||
1. **Hourly Checks** — Daemon polls for new releases every hour
|
||||
2. **Stability Period** — New releases wait 24 hours before auto-upgrade (prevents deploying unstable releases)
|
||||
3. **Seamless Upgrade** — Downloads new daemon binary, replaces, and restarts service
|
||||
4. **Manual Override** — Force immediate upgrade via UI or API
|
||||
5. **Version Tracking** — UI auto-reloads when daemon version changes
|
||||
|
||||
---
|
||||
|
||||
## 🧪 Testing
|
||||
|
||||
### QEMU Virtual Machine
|
||||
|
||||
```bash
|
||||
# Start VM (creates disk, boots ISO)
|
||||
pnpm run test
|
||||
|
||||
# Take screenshots to monitor progress
|
||||
pnpm run test:screenshot
|
||||
|
||||
# Screenshot loop (every 5 seconds)
|
||||
pnpm run test:screenshot:loop
|
||||
|
||||
# Stop VM
|
||||
pnpm run test:stop
|
||||
|
||||
# Clean and restart fresh
|
||||
pnpm run test:clean && pnpm run test
|
||||
```
|
||||
|
||||
### Serial Console
|
||||
|
||||
For debugging without graphics:
|
||||
|
||||
```bash
|
||||
socat - UNIX-CONNECT:.nogit/vm/serial.sock
|
||||
# Login: ecouser / ecouser
|
||||
# Root: sudo -i
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📦 Release Assets
|
||||
|
||||
Each release includes:
|
||||
|
||||
| File | Description |
|
||||
|------|-------------|
|
||||
| `ecoos-vX.X.X.iso` | Full bootable ISO image (~2GB) |
|
||||
| `eco-daemon-vX.X.X` | Standalone daemon binary for in-place upgrades |
|
||||
| `SHA256SUMS.txt` | Checksums for verification |
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Boot Menu Options
|
||||
|
||||
1. **Install EcoOS** *(default, auto-selects in 10s)* — Full installation to disk
|
||||
2. **EcoOS Live** — Try without installing (runs from RAM)
|
||||
3. **EcoOS Live (Safe Mode)** — Minimal boot for troubleshooting
|
||||
|
||||
---
|
||||
|
||||
## ⚙️ Technical Details
|
||||
|
||||
### Daemon
|
||||
|
||||
- **Runtime**: Deno (compiled to standalone binary)
|
||||
- **Process Management**: Spawns and monitors Sway + Chromium
|
||||
- **Backend Fallback**: Tries DRM first, falls back to headless/pixman
|
||||
- **Auto-Recovery**: Restarts crashed services within 5 seconds
|
||||
- **Logging**: Integrates with systemd journal
|
||||
|
||||
### Kiosk Browser
|
||||
|
||||
- **Browser**: Chromium (official snapshots, not snap)
|
||||
- **Flags**: `--ozone-platform=wayland --kiosk --no-first-run --disable-infobars`
|
||||
- **Default URL**: `http://localhost:3006` (management UI)
|
||||
|
||||
### System Stack
|
||||
|
||||
- **Base**: Ubuntu 24.04 LTS
|
||||
- **Init**: systemd
|
||||
- **Display**: Sway (Wayland compositor)
|
||||
- **Seat Manager**: seatd
|
||||
- **Audio**: PipeWire
|
||||
|
||||
---
|
||||
|
||||
## License and Legal Information
|
||||
|
||||
This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the [LICENSE](./LICENSE) file.
|
||||
|
||||
**Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.
|
||||
|
||||
### Trademarks
|
||||
|
||||
This project is owned and maintained by Task Venture Capital GmbH. The names and logos associated with Task Venture Capital GmbH and any related products or services are trademarks of Task Venture Capital GmbH or third parties, and are not included within the scope of the MIT license granted herein.
|
||||
|
||||
Use of these trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines or the guidelines of the respective third-party owners, and any usage must be approved in writing. Third-party trademarks used herein are the property of their respective owners and used only in a descriptive manner, e.g. for an implementation of an API or similar.
|
||||
|
||||
### Company Information
|
||||
|
||||
Task Venture Capital GmbH
|
||||
Registered at District Court Bremen HRB 35230 HB, Germany
|
||||
|
||||
For any legal inquiries or further information, please contact us via email at hello@task.vc.
|
||||
|
||||
By using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works.
|
||||
Reference in New Issue
Block a user