10 Commits

Author SHA1 Message Date
de10e1dd1f v0.4.1
All checks were successful
CI / build (push) Successful in 17s
Release / release (push) Successful in 9m31s
2026-01-09 18:19:30 +00:00
21f7a44a53 fix(release-upload): clear progress timer on upload completion/error and add periodic upload progress reporting 2026-01-09 18:19:30 +00:00
98398e962f v0.4.0
Some checks failed
CI / build (push) Successful in 20s
Release / release (push) Has been cancelled
2026-01-09 18:14:26 +00:00
06cea4bb37 feat(displays): add display detection and management (sway) with daemon APIs and UI controls 2026-01-09 18:14:26 +00:00
ee631c21c4 v0.3.8
Some checks failed
CI / build (push) Successful in 15s
Release / release (push) Failing after 8m54s
2026-01-09 18:06:22 +00:00
50d437aed7 fix(ci(release-workflow)): use npx tsx to run release-upload.ts in the Gitea release workflow instead of installing tsx globally 2026-01-09 18:06:22 +00:00
dd5ea36636 v0.3.7
Some checks failed
CI / build (push) Successful in 17s
Release / release (push) Failing after 1m1s
2026-01-09 17:52:55 +00:00
7d6aace6d9 fix(daemon): Point updater at the correct repository API (code.foss.global ecobridge.xyz/eco_os) and bump project/daemon versions to 0.3.6 2026-01-09 17:52:55 +00:00
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 384 additions and 14 deletions

108
.gitea/release-upload.ts Normal file
View 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);
});

View File

@@ -75,16 +75,11 @@ jobs:
echo "Created release with ID: $RELEASE_ID" echo "Created release with ID: $RELEASE_ID"
# Upload assets # Upload assets using TypeScript (curl has 2GB multipart limit)
for asset in dist/*; do GITEA_TOKEN="${{ secrets.GITHUB_TOKEN }}" \
filename=$(basename "$asset") GITEA_REPO="${{ gitea.repository }}" \
echo "Uploading $filename..." RELEASE_ID="$RELEASE_ID" \
curl -X POST -s \ npx tsx .gitea/release-upload.ts
-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
- name: Cleanup old releases (keep 3 latest) - name: Cleanup old releases (keep 3 latest)
run: | run: |

View File

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

View File

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

View File

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

View File

@@ -48,7 +48,7 @@ interface GiteaAsset {
} }
export class Updater { 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 binaryPath = '/opt/eco/bin/eco-daemon';
private releases: Release[] = []; private releases: Release[] = [];
private lastCheck: Date | null = null; 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' }), { return new Response(JSON.stringify({ error: 'Not Found' }), {
status: 404, status: 404,
headers, headers,
@@ -384,6 +406,10 @@ export class UIServer {
Check for Updates Check for Updates
</button> </button>
</div> </div>
<div class="card">
<h2>Displays</h2>
<div id="displays-list"></div>
</div>
<div class="card"> <div class="card">
<h2>Input Devices</h2> <h2>Input Devices</h2>
<div id="input-devices-list"></div> <div id="input-devices-list"></div>
@@ -698,6 +724,64 @@ export class UIServer {
fetchUpdates(); fetchUpdates();
setInterval(fetchUpdates, 60000); // Check every minute setInterval(fetchUpdates, 60000); // Check every minute
// Display management
function updateDisplaysUI(data) {
const list = document.getElementById('displays-list');
if (!data.displays || data.displays.length === 0) {
list.innerHTML = '<div style="color: var(--text-dim);">No displays detected</div>';
return;
}
list.innerHTML = data.displays.map(d =>
'<div class="device-item" style="flex-wrap: wrap; gap: 8px;">' +
'<div style="flex: 1; min-width: 150px;">' +
'<div class="device-name">' + d.name + '</div>' +
'<div style="font-size: 11px; color: var(--text-dim);">' +
d.width + 'x' + d.height + ' @ ' + d.refreshRate + 'Hz' +
(d.make !== 'Unknown' ? ' • ' + d.make : '') +
'</div>' +
'</div>' +
'<div style="display: flex; gap: 4px;">' +
(d.isPrimary
? '<span class="device-default">Primary</span>'
: '<button class="btn btn-primary" style="padding: 2px 8px; margin: 0; font-size: 11px;" onclick="setKioskDisplay(\\'' + d.name + '\\')">Set Primary</button>') +
'<button class="btn ' + (d.active ? 'btn-danger' : 'btn-primary') + '" style="padding: 2px 8px; margin: 0; font-size: 11px;" onclick="toggleDisplay(\\'' + d.name + '\\', ' + !d.active + ')">' +
(d.active ? 'Disable' : 'Enable') +
'</button>' +
'</div>' +
'</div>'
).join('');
}
function fetchDisplays() {
fetch('/api/displays')
.then(r => r.json())
.then(updateDisplaysUI)
.catch(err => console.error('Failed to fetch displays:', err));
}
function toggleDisplay(name, enable) {
fetch('/api/displays/' + encodeURIComponent(name) + '/' + (enable ? 'enable' : 'disable'), { method: 'POST' })
.then(r => r.json())
.then(result => {
if (!result.success) alert(result.message);
fetchDisplays();
})
.catch(err => alert('Error: ' + err));
}
function setKioskDisplay(name) {
fetch('/api/displays/' + encodeURIComponent(name) + '/primary', { method: 'POST' })
.then(r => r.json())
.then(result => {
if (!result.success) alert(result.message);
fetchDisplays();
})
.catch(err => alert('Error: ' + err));
}
fetchDisplays();
setInterval(fetchDisplays, 5000); // Refresh every 5 seconds
// Initial fetch // Initial fetch
fetch('/api/status') fetch('/api/status')
.then(r => r.json()) .then(r => r.json())

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