8 Commits

Author SHA1 Message Date
98398e962f v0.4.0
Some checks failed
CI / build (push) Successful in 20s
Release / release (push) Has been cancelled
2026-01-09 18:14:26 +00:00
06cea4bb37 feat(displays): add display detection and management (sway) with daemon APIs and UI controls 2026-01-09 18:14:26 +00:00
ee631c21c4 v0.3.8
Some checks failed
CI / build (push) Successful in 15s
Release / release (push) Failing after 8m54s
2026-01-09 18:06:22 +00:00
50d437aed7 fix(ci(release-workflow)): use npx tsx to run release-upload.ts in the Gitea release workflow instead of installing tsx globally 2026-01-09 18:06:22 +00:00
dd5ea36636 v0.3.7
Some checks failed
CI / build (push) Successful in 17s
Release / release (push) Failing after 1m1s
2026-01-09 17:52:55 +00:00
7d6aace6d9 fix(daemon): Point updater at the correct repository API (code.foss.global ecobridge.xyz/eco_os) and bump project/daemon versions to 0.3.6 2026-01-09 17:52:55 +00:00
99a04df8b0 v0.3.5
Some checks failed
CI / build (push) Successful in 16s
Release / release (push) Failing after 6m56s
2026-01-09 17:48:32 +00:00
ee3b6dd6ae fix(ci): add Gitea release asset uploader and switch release workflow to use it; bump package and daemon versions to 0.3.4 2026-01-09 17:48:32 +00:00
11 changed files with 358 additions and 14 deletions

90
.gitea/release-upload.ts Normal file
View File

@@ -0,0 +1,90 @@
/**
* 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', () => {
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', reject);
// Stream: write header, pipe file, write footer
req.write(header);
const stream = fs.createReadStream(filepath);
stream.on('error', reject);
stream.on('end', () => {
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);
});

View File

@@ -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" \
-T "$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: |

View File

@@ -1,5 +1,37 @@
# Changelog
## 2026-01-09 - 0.4.0 - feat(displays)
add display detection and management (sway) with daemon APIs and UI controls
- Introduce DisplayInfo type in system-info.ts
- Add ProcessManager methods: getDisplays, setDisplayEnabled, setKioskDisplay (invoke swaymsg via runuser)
- Add daemon methods to expose getDisplays, setDisplayEnabled and setKioskDisplay with runtime/Wayland context and status checks
- Add UI server endpoints: GET /api/displays and POST /api/displays/{name}/(enable|disable|primary) and frontend UI to list and control displays (polling + buttons)
- Bump VERSION and package.json to 0.3.9
## 2026-01-09 - 0.3.8 - fix(ci(release-workflow))
use npx tsx to run release-upload.ts in the Gitea release workflow instead of installing tsx globally
- 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

View File

@@ -5,7 +5,7 @@
*/
import { ProcessManager } from './process-manager.ts';
import { SystemInfo } from './system-info.ts';
import { SystemInfo, type DisplayInfo } from './system-info.ts';
import { Updater } from './updater.ts';
import { UIServer } from '../ui/server.ts';
import { runCommand } from '../utils/command.ts';
@@ -147,6 +147,47 @@ export class EcoDaemon {
return this.updater.upgradeToVersion(version);
}
async getDisplays(): Promise<DisplayInfo[]> {
if (this.swayStatus.state !== 'running') {
return [];
}
const uid = await this.getUserUid();
return this.processManager.getDisplays({
runtimeDir: `/run/user/${uid}`,
waylandDisplay: this.config.waylandDisplay,
});
}
async setDisplayEnabled(name: string, enabled: boolean): Promise<{ success: boolean; message: string }> {
if (this.swayStatus.state !== 'running') {
return { success: false, message: 'Sway is not running' };
}
this.log(`${enabled ? 'Enabling' : 'Disabling'} display ${name}`);
const uid = await this.getUserUid();
const result = await this.processManager.setDisplayEnabled(
{ runtimeDir: `/run/user/${uid}`, waylandDisplay: this.config.waylandDisplay },
name,
enabled
);
return { success: result, message: result ? `Display ${name} ${enabled ? 'enabled' : 'disabled'}` : 'Failed' };
}
async setKioskDisplay(name: string): Promise<{ success: boolean; message: string }> {
if (this.swayStatus.state !== 'running') {
return { success: false, message: 'Sway is not running' };
}
if (this.chromiumStatus.state !== 'running') {
return { success: false, message: 'Chromium is not running' };
}
this.log(`Moving kiosk to display ${name}`);
const uid = await this.getUserUid();
const result = await this.processManager.setKioskDisplay(
{ runtimeDir: `/run/user/${uid}`, waylandDisplay: this.config.waylandDisplay },
name
);
return { success: result, message: result ? `Kiosk moved to ${name}` : 'Failed' };
}
async start(): Promise<void> {
this.log('EcoOS Daemon starting...');

View File

@@ -5,6 +5,7 @@
*/
import { runCommand } from '../utils/command.ts';
import type { DisplayInfo } from './system-info.ts';
export interface SwayConfig {
runtimeDir: string;
@@ -306,6 +307,95 @@ for_window [app_id="chromium-browser"] fullscreen enable
}
}
/**
* Get connected displays via swaymsg
*/
async getDisplays(config: { runtimeDir: string; waylandDisplay: string }): Promise<DisplayInfo[]> {
const env: Record<string, string> = {
XDG_RUNTIME_DIR: config.runtimeDir,
WAYLAND_DISPLAY: config.waylandDisplay,
};
const envString = Object.entries(env)
.map(([k, v]) => `${k}=${v}`)
.join(' ');
const cmd = new Deno.Command('runuser', {
args: ['-u', this.user, '--', 'sh', '-c', `${envString} swaymsg -t get_outputs`],
stdout: 'piped',
stderr: 'piped',
});
try {
const result = await cmd.output();
if (!result.success) {
console.error('[displays] Failed to get outputs');
return [];
}
const outputs = JSON.parse(new TextDecoder().decode(result.stdout));
return outputs.map((output: {
name: string;
make: string;
model: string;
serial: string;
active: boolean;
current_mode?: { width: number; height: number; refresh: number };
focused: boolean;
}) => ({
name: output.name,
make: output.make || 'Unknown',
model: output.model || 'Unknown',
serial: output.serial || '',
active: output.active,
width: output.current_mode?.width || 0,
height: output.current_mode?.height || 0,
refreshRate: Math.round((output.current_mode?.refresh || 0) / 1000),
isPrimary: output.focused,
}));
} catch (error) {
console.error(`[displays] Error: ${error}`);
return [];
}
}
/**
* Enable or disable a display
*/
async setDisplayEnabled(
config: { runtimeDir: string; waylandDisplay: string },
name: string,
enabled: boolean
): Promise<boolean> {
const command = `output ${name} ${enabled ? 'enable' : 'disable'}`;
console.log(`[displays] ${command}`);
return this.swaymsg(config, command);
}
/**
* Move the kiosk browser to a specific display
*/
async setKioskDisplay(
config: { runtimeDir: string; waylandDisplay: string },
name: string
): Promise<boolean> {
console.log(`[displays] Setting primary display to ${name}`);
// Focus the chromium window and move it to the target output
const commands = [
`[app_id="chromium-browser"] focus`,
`move container to output ${name}`,
`focus output ${name}`,
`[app_id="chromium-browser"] fullscreen enable`,
];
for (const cmd of commands) {
await this.swaymsg(config, cmd);
}
return true;
}
private async pipeOutput(
process: Deno.ChildProcess,
name: string

View File

@@ -52,6 +52,18 @@ export interface AudioDevice {
isDefault: boolean;
}
export interface DisplayInfo {
name: string; // e.g., "DP-1", "HDMI-A-1", "HEADLESS-1"
make: string; // Manufacturer
model: string; // Model name
serial: string; // Serial number
active: boolean; // Currently enabled
width: number; // Resolution width
height: number; // Resolution height
refreshRate: number; // Hz
isPrimary: boolean; // Has the focused window (kiosk)
}
export interface SystemInfoData {
hostname: string;
cpu: CpuInfo;

View File

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

View File

@@ -129,6 +129,28 @@ export class UIServer {
}
}
if (path === '/api/displays') {
const displays = await this.daemon.getDisplays();
return new Response(JSON.stringify({ displays }), { headers });
}
// Display control endpoints: /api/displays/{name}/{action}
const displayMatch = path.match(/^\/api\/displays\/([^/]+)\/(enable|disable|primary)$/);
if (displayMatch && req.method === 'POST') {
const name = decodeURIComponent(displayMatch[1]);
const action = displayMatch[2];
let result;
if (action === 'enable') {
result = await this.daemon.setDisplayEnabled(name, true);
} else if (action === 'disable') {
result = await this.daemon.setDisplayEnabled(name, false);
} else if (action === 'primary') {
result = await this.daemon.setKioskDisplay(name);
}
return new Response(JSON.stringify(result), { headers });
}
return new Response(JSON.stringify({ error: 'Not Found' }), {
status: 404,
headers,
@@ -384,6 +406,10 @@ export class UIServer {
Check for Updates
</button>
</div>
<div class="card">
<h2>Displays</h2>
<div id="displays-list"></div>
</div>
<div class="card">
<h2>Input Devices</h2>
<div id="input-devices-list"></div>
@@ -698,6 +724,64 @@ export class UIServer {
fetchUpdates();
setInterval(fetchUpdates, 60000); // Check every minute
// Display management
function updateDisplaysUI(data) {
const list = document.getElementById('displays-list');
if (!data.displays || data.displays.length === 0) {
list.innerHTML = '<div style="color: var(--text-dim);">No displays detected</div>';
return;
}
list.innerHTML = data.displays.map(d =>
'<div class="device-item" style="flex-wrap: wrap; gap: 8px;">' +
'<div style="flex: 1; min-width: 150px;">' +
'<div class="device-name">' + d.name + '</div>' +
'<div style="font-size: 11px; color: var(--text-dim);">' +
d.width + 'x' + d.height + ' @ ' + d.refreshRate + 'Hz' +
(d.make !== 'Unknown' ? ' • ' + d.make : '') +
'</div>' +
'</div>' +
'<div style="display: flex; gap: 4px;">' +
(d.isPrimary
? '<span class="device-default">Primary</span>'
: '<button class="btn btn-primary" style="padding: 2px 8px; margin: 0; font-size: 11px;" onclick="setKioskDisplay(\\'' + d.name + '\\')">Set Primary</button>') +
'<button class="btn ' + (d.active ? 'btn-danger' : 'btn-primary') + '" style="padding: 2px 8px; margin: 0; font-size: 11px;" onclick="toggleDisplay(\\'' + d.name + '\\', ' + !d.active + ')">' +
(d.active ? 'Disable' : 'Enable') +
'</button>' +
'</div>' +
'</div>'
).join('');
}
function fetchDisplays() {
fetch('/api/displays')
.then(r => r.json())
.then(updateDisplaysUI)
.catch(err => console.error('Failed to fetch displays:', err));
}
function toggleDisplay(name, enable) {
fetch('/api/displays/' + encodeURIComponent(name) + '/' + (enable ? 'enable' : 'disable'), { method: 'POST' })
.then(r => r.json())
.then(result => {
if (!result.success) alert(result.message);
fetchDisplays();
})
.catch(err => alert('Error: ' + err));
}
function setKioskDisplay(name) {
fetch('/api/displays/' + encodeURIComponent(name) + '/primary', { method: 'POST' })
.then(r => r.json())
.then(result => {
if (!result.success) alert(result.message);
fetchDisplays();
})
.catch(err => alert('Error: ' + err));
}
fetchDisplays();
setInterval(fetchDisplays, 5000); // Refresh every 5 seconds
// Initial fetch
fetch('/api/status')
.then(r => r.json())

View File

@@ -1 +1 @@
export const VERSION = "0.3.1";
export const VERSION = "0.3.9";

View File

@@ -1,6 +1,6 @@
{
"name": "@ecobridge/eco-os",
"version": "0.3.2",
"version": "0.4.0",
"private": true,
"scripts": {
"build": "[ -z \"$CI\" ] && npm version patch --no-git-tag-version || true && node -e \"const v=require('./package.json').version; require('fs').writeFileSync('ecoos_daemon/ts/version.ts', 'export const VERSION = \\\"'+v+'\\\";\\n');\" && pnpm run daemon:bundle && cp ecoos_daemon/bundle/eco-daemon isobuild/config/includes.chroot/opt/eco/bin/ && mkdir -p .nogit/iso && docker build --no-cache -t ecoos-builder -f isobuild/Dockerfile . && docker run --privileged --name ecoos-build ecoos-builder && docker cp ecoos-build:/output/ecoos.iso .nogit/iso/ecoos.iso && docker rm ecoos-build",