10 Commits

Author SHA1 Message Date
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
708917bb9b v0.3.2
All checks were successful
CI / build (push) Successful in 15s
Release / release (push) Successful in 8m46s
2026-01-09 17:22:31 +00:00
5c6d4f4802 fix(release): bump package and daemon to v0.3.1, add project README, and fix Gitea release upload flag 2026-01-09 17:22:31 +00:00
31cc3af1b4 v0.3.0
Some checks failed
CI / build (push) Successful in 15s
Release / release (push) Failing after 7m29s
2026-01-09 16:55:43 +00:00
6a3be55cee feat(daemon): add automatic update mechanism (Updater), switch to system journal logs, and expose update controls in the UI 2026-01-09 16:55:43 +00:00
10 changed files with 844 additions and 43 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

@@ -32,16 +32,8 @@ jobs:
npm version ${{ steps.version.outputs.version_number }} --no-git-tag-version --allow-same-version
echo "export const VERSION = \"${{ steps.version.outputs.version_number }}\";" > ecoos_daemon/ts/version.ts
- name: Build daemon binary
run: pnpm run daemon:bundle
- name: Build ISO with Docker
run: |
cp ecoos_daemon/bundle/eco-daemon isobuild/config/includes.chroot/opt/eco/bin/
mkdir -p .nogit/iso
docker build -t ecoos-builder -f isobuild/Dockerfile .
docker run --rm --privileged -v ${{ github.workspace }}/.nogit/iso:/output ecoos-builder
ls -la .nogit/iso/
- name: Build ISO
run: pnpm run build
- name: Prepare release assets
run: |
@@ -83,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: |

View File

@@ -1,5 +1,47 @@
# Changelog
## 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
- Introduce Updater class: fetches releases from Gitea, computes auto-upgrade eligibility, downloads daemon binary, replaces binary and restarts service.
- Integrate updater into EcoDaemon: new methods getUpdateInfo, checkForUpdates, upgradeToVersion; run initial update check on startup and periodic auto-upgrade checks (hourly).
- Replace serial console reader with a journalctl-based system journal reader; rename serialLogs → systemLogs and update related logic and limits.
- UI/server: add API endpoints /api/updates, /api/updates/check and /api/upgrade; add an Updates panel to show current version, available releases, auto-upgrade status, and client-side actions to check and trigger upgrades; poll update info periodically.
- Version bump to 0.2.2 (package.json and ecoos_daemon/ts/version.ts).
- Build/workflow changes: release workflow now runs build step (Build ISO) and package.json build script adjusted for CI and updated Docker build/run handling.
## 2026-01-09 - 0.2.1 - fix(ci)
use GitHub Actions workspace for docker volume and add listing of build output directory for debugging

View File

@@ -6,6 +6,7 @@
import { ProcessManager } from './process-manager.ts';
import { SystemInfo } from './system-info.ts';
import { Updater } from './updater.ts';
import { UIServer } from '../ui/server.ts';
import { runCommand } from '../utils/command.ts';
import { VERSION } from '../version.ts';
@@ -28,12 +29,14 @@ export class EcoDaemon {
private config: DaemonConfig;
private processManager: ProcessManager;
private systemInfo: SystemInfo;
private updater: Updater;
private uiServer: UIServer;
private logs: string[] = [];
private serialLogs: string[] = [];
private systemLogs: string[] = [];
private swayStatus: ServiceStatus = { state: 'stopped' };
private chromiumStatus: ServiceStatus = { state: 'stopped' };
private manualRestartUntil: number = 0; // Timestamp until which auto-restart is disabled
private lastAutoUpgradeCheck: number = 0; // Timestamp of last auto-upgrade check
constructor(config?: Partial<DaemonConfig>) {
this.config = {
@@ -45,6 +48,7 @@ export class EcoDaemon {
this.processManager = new ProcessManager(this.config.user);
this.systemInfo = new SystemInfo();
this.updater = new Updater((msg) => this.log(msg));
this.uiServer = new UIServer(this.config.uiPort, this);
}
@@ -64,8 +68,8 @@ export class EcoDaemon {
return [...this.logs];
}
getSerialLogs(): string[] {
return [...this.serialLogs];
getSystemLogs(): string[] {
return [...this.systemLogs];
}
async getStatus(): Promise<Record<string, unknown>> {
@@ -78,7 +82,7 @@ export class EcoDaemon {
chromiumStatus: this.chromiumStatus,
systemInfo,
logs: this.logs.slice(-50),
serialLogs: this.serialLogs.slice(-50),
systemLogs: this.systemLogs.slice(-50),
};
}
@@ -131,6 +135,18 @@ export class EcoDaemon {
}
}
async getUpdateInfo(): Promise<unknown> {
return this.updater.getUpdateInfo();
}
async checkForUpdates(): Promise<void> {
await this.updater.checkForUpdates();
}
async upgradeToVersion(version: string): Promise<{ success: boolean; message: string }> {
return this.updater.upgradeToVersion(version);
}
async start(): Promise<void> {
this.log('EcoOS Daemon starting...');
@@ -139,8 +155,11 @@ export class EcoDaemon {
await this.uiServer.start();
this.log('Management UI started successfully');
// Start serial console reader in the background
this.startSerialReader();
// Start system journal reader in the background
this.startJournalReader();
// Check for updates on startup
this.updater.checkForUpdates().catch((e) => this.log(`Initial update check failed: ${e}`));
// Start the Sway/Chromium initialization in the background
// This allows the UI server to remain responsive even if Sway fails
@@ -313,12 +332,19 @@ export class EcoDaemon {
return parseInt(result.stdout.trim(), 10);
}
private startSerialReader(): void {
private startJournalReader(): void {
(async () => {
try {
const file = await Deno.open('/dev/ttyS0', { read: true });
this.log('Serial console reader started on /dev/ttyS0');
const reader = file.readable.getReader();
const cmd = new Deno.Command('journalctl', {
args: ['-f', '--no-pager', '-n', '100', '-o', 'short-iso'],
stdout: 'piped',
stderr: 'piped',
});
const process = cmd.spawn();
this.log('System journal reader started');
const reader = process.stdout.getReader();
const decoder = new TextDecoder();
while (true) {
@@ -326,14 +352,14 @@ export class EcoDaemon {
if (done) break;
const text = decoder.decode(value);
for (const line of text.split('\n').filter((l) => l.trim())) {
this.serialLogs.push(line);
if (this.serialLogs.length > 1000) {
this.serialLogs = this.serialLogs.slice(-1000);
this.systemLogs.push(line);
if (this.systemLogs.length > 1000) {
this.systemLogs = this.systemLogs.slice(-1000);
}
}
}
} catch (error) {
this.log(`Serial reader not available: ${error}`);
this.log(`Journal reader not available: ${error}`);
}
})();
}
@@ -383,6 +409,16 @@ export class EcoDaemon {
await this.tryStartSwayAndChromium();
}
}
// Check for auto-upgrades every hour
const now = Date.now();
const oneHour = 60 * 60 * 1000;
if (now - this.lastAutoUpgradeCheck > oneHour) {
this.lastAutoUpgradeCheck = now;
this.updater.checkAutoUpgrade().catch((e) =>
this.log(`Auto-upgrade check failed: ${e}`)
);
}
} catch (error) {
this.log(`Error in monitoring loop: ${error}`);
}

View File

@@ -0,0 +1,270 @@
/**
* Updater
*
* Handles checking for updates, downloading new versions, and performing upgrades
*/
import { VERSION } from '../version.ts';
import { runCommand } from '../utils/command.ts';
export interface Release {
version: string;
tagName: string;
publishedAt: Date;
downloadUrl: string;
isCurrent: boolean;
isNewer: boolean;
ageHours: number;
}
export interface AutoUpgradeStatus {
enabled: boolean;
targetVersion: string | null;
scheduledIn: string | null;
waitingForStability: boolean;
}
export interface UpdateInfo {
currentVersion: string;
releases: Release[];
autoUpgrade: AutoUpgradeStatus;
lastCheck: string | null;
}
interface GiteaRelease {
id: number;
tag_name: string;
name: string;
body: string;
published_at: string;
assets: GiteaAsset[];
}
interface GiteaAsset {
id: number;
name: string;
browser_download_url: string;
size: number;
}
export class Updater {
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;
private logFn: (msg: string) => void;
constructor(logFn: (msg: string) => void) {
this.logFn = logFn;
}
private log(message: string): void {
this.logFn(`[Updater] ${message}`);
}
/**
* Compare semantic versions
* Returns: -1 if a < b, 0 if a == b, 1 if a > b
*/
private compareVersions(a: string, b: string): number {
const partsA = a.replace(/^v/, '').split('.').map(Number);
const partsB = b.replace(/^v/, '').split('.').map(Number);
for (let i = 0; i < Math.max(partsA.length, partsB.length); i++) {
const numA = partsA[i] || 0;
const numB = partsB[i] || 0;
if (numA < numB) return -1;
if (numA > numB) return 1;
}
return 0;
}
/**
* Fetch available releases from Gitea
*/
async checkForUpdates(): Promise<Release[]> {
this.log('Checking for updates...');
try {
const response = await fetch(this.repoApiUrl);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const giteaReleases: GiteaRelease[] = await response.json();
const currentVersion = VERSION;
const now = new Date();
this.releases = giteaReleases
.filter((r) => r.tag_name.startsWith('v'))
.map((r) => {
const version = r.tag_name.replace(/^v/, '');
const publishedAt = new Date(r.published_at);
const ageMs = now.getTime() - publishedAt.getTime();
const ageHours = ageMs / (1000 * 60 * 60);
// Find the daemon binary asset
const daemonAsset = r.assets.find((a) =>
a.name.includes('eco-daemon')
);
return {
version,
tagName: r.tag_name,
publishedAt,
downloadUrl: daemonAsset?.browser_download_url || '',
isCurrent: version === currentVersion,
isNewer: this.compareVersions(version, currentVersion) > 0,
ageHours: Math.round(ageHours * 10) / 10,
};
})
.filter((r) => r.downloadUrl) // Only include releases with daemon binary
.sort((a, b) => this.compareVersions(b.version, a.version)); // Newest first
this.lastCheck = now;
this.log(`Found ${this.releases.length} releases, ${this.releases.filter((r) => r.isNewer).length} newer than current`);
return this.releases;
} catch (error) {
this.log(`Failed to check for updates: ${error}`);
return this.releases;
}
}
/**
* Get cached releases (call checkForUpdates first)
*/
getReleases(): Release[] {
return this.releases;
}
/**
* Determine if auto-upgrade should happen and to which version
*/
getAutoUpgradeStatus(): AutoUpgradeStatus {
const newerReleases = this.releases.filter((r) => r.isNewer);
if (newerReleases.length === 0) {
return {
enabled: true,
targetVersion: null,
scheduledIn: null,
waitingForStability: false,
};
}
// Find the latest newer release
const latest = newerReleases[0];
const hoursUntilUpgrade = 24 - latest.ageHours;
if (hoursUntilUpgrade <= 0) {
// Ready to upgrade now
return {
enabled: true,
targetVersion: latest.version,
scheduledIn: 'now',
waitingForStability: false,
};
}
// Still waiting for stability period
const hours = Math.floor(hoursUntilUpgrade);
const minutes = Math.round((hoursUntilUpgrade - hours) * 60);
const scheduledIn = hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`;
return {
enabled: true,
targetVersion: latest.version,
scheduledIn,
waitingForStability: true,
};
}
/**
* Get full update info for API response
*/
getUpdateInfo(): UpdateInfo {
return {
currentVersion: VERSION,
releases: this.releases,
autoUpgrade: this.getAutoUpgradeStatus(),
lastCheck: this.lastCheck?.toISOString() || null,
};
}
/**
* Download and install a specific version
*/
async upgradeToVersion(version: string): Promise<{ success: boolean; message: string }> {
const release = this.releases.find((r) => r.version === version);
if (!release) {
return { success: false, message: `Version ${version} not found` };
}
if (release.isCurrent) {
return { success: false, message: `Already running version ${version}` };
}
this.log(`Starting upgrade to version ${version}...`);
try {
// Download new binary
const tempPath = '/tmp/eco-daemon-new';
this.log(`Downloading from ${release.downloadUrl}...`);
const response = await fetch(release.downloadUrl);
if (!response.ok) {
throw new Error(`Download failed: HTTP ${response.status}`);
}
const data = await response.arrayBuffer();
await Deno.writeFile(tempPath, new Uint8Array(data));
// Verify download
const stat = await Deno.stat(tempPath);
if (stat.size < 1000000) {
// Daemon should be at least 1MB
throw new Error(`Downloaded file too small: ${stat.size} bytes`);
}
this.log(`Downloaded ${stat.size} bytes`);
// Make executable
await Deno.chmod(tempPath, 0o755);
// Replace binary
this.log('Replacing binary...');
await runCommand('mv', [tempPath, this.binaryPath]);
await Deno.chmod(this.binaryPath, 0o755);
// Restart daemon via systemd
this.log('Restarting daemon...');
// Use spawn to avoid waiting for the restart
const restartCmd = new Deno.Command('systemctl', {
args: ['restart', 'eco-daemon'],
stdout: 'null',
stderr: 'null',
});
restartCmd.spawn();
return { success: true, message: `Upgrading to v${version}...` };
} catch (error) {
this.log(`Upgrade failed: ${error}`);
return { success: false, message: String(error) };
}
}
/**
* Check and perform auto-upgrade if conditions are met
*/
async checkAutoUpgrade(): Promise<void> {
await this.checkForUpdates();
const status = this.getAutoUpgradeStatus();
if (status.targetVersion && status.scheduledIn === 'now') {
this.log(`Auto-upgrading to version ${status.targetVersion}...`);
await this.upgradeToVersion(status.targetVersion);
}
}
}

View File

@@ -104,6 +104,31 @@ export class UIServer {
return new Response(JSON.stringify(result), { headers });
}
if (path === '/api/updates') {
const updates = await this.daemon.getUpdateInfo();
return new Response(JSON.stringify(updates), { headers });
}
if (path === '/api/updates/check' && req.method === 'POST') {
await this.daemon.checkForUpdates();
const updates = await this.daemon.getUpdateInfo();
return new Response(JSON.stringify(updates), { headers });
}
if (path === '/api/upgrade' && req.method === 'POST') {
try {
const body = await req.json();
const version = body.version;
if (!version) {
return new Response(JSON.stringify({ success: false, message: 'Version required' }), { headers });
}
const result = await this.daemon.upgradeToVersion(version);
return new Response(JSON.stringify(result), { headers });
} catch (error) {
return new Response(JSON.stringify({ success: false, message: String(error) }), { headers });
}
}
return new Response(JSON.stringify({ error: 'Not Found' }), {
status: 404,
headers,
@@ -347,6 +372,18 @@ export class UIServer {
</button>
<div id="control-status" style="margin-top: 8px; font-size: 12px; color: var(--text-dim);"></div>
</div>
<div class="card">
<h2>Updates</h2>
<div class="stat">
<div class="stat-label">Current Version</div>
<div class="stat-value" id="current-version">-</div>
</div>
<div id="updates-list" style="margin: 12px 0;"></div>
<div id="auto-upgrade-status" style="font-size: 12px; color: var(--text-dim);"></div>
<button class="btn btn-primary" onclick="checkForUpdates()" style="margin-top: 8px;">
Check for Updates
</button>
</div>
<div class="card">
<h2>Input Devices</h2>
<div id="input-devices-list"></div>
@@ -362,7 +399,7 @@ export class UIServer {
<div class="card" style="grid-column: 1 / -1;">
<div class="tabs">
<div class="tab active" onclick="switchTab('daemon')">Daemon Logs</div>
<div class="tab" onclick="switchTab('serial')">Serial Console</div>
<div class="tab" onclick="switchTab('serial')">System Logs</div>
</div>
<div id="daemon-tab" class="tab-content active">
<div class="logs" id="logs"></div>
@@ -522,13 +559,13 @@ export class UIServer {
logsEl.scrollTop = logsEl.scrollHeight;
}
// Serial Logs
if (data.serialLogs) {
// System Logs
if (data.systemLogs) {
const serialEl = document.getElementById('serial-logs');
if (data.serialLogs.length === 0) {
serialEl.innerHTML = '<div style="color: var(--text-dim);">No serial data available</div>';
if (data.systemLogs.length === 0) {
serialEl.innerHTML = '<div style="color: var(--text-dim);">No system logs available</div>';
} else {
serialEl.innerHTML = data.serialLogs.map(l =>
serialEl.innerHTML = data.systemLogs.map(l =>
'<div class="log-entry">' + l + '</div>'
).join('');
serialEl.scrollTop = serialEl.scrollHeight;
@@ -590,6 +627,77 @@ export class UIServer {
});
}
function checkForUpdates() {
fetch('/api/updates/check', { method: 'POST' })
.then(r => r.json())
.then(updateUpdatesUI)
.catch(err => console.error('Failed to check updates:', err));
}
function upgradeToVersion(version) {
if (!confirm('Upgrade to version ' + version + '? The daemon will restart.')) return;
fetch('/api/upgrade', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ version: version })
})
.then(r => r.json())
.then(result => {
if (result.success) {
document.getElementById('auto-upgrade-status').textContent = result.message;
} else {
alert('Upgrade failed: ' + result.message);
}
})
.catch(err => alert('Upgrade error: ' + err));
}
function updateUpdatesUI(data) {
document.getElementById('current-version').textContent = 'v' + data.currentVersion;
const list = document.getElementById('updates-list');
const newerReleases = data.releases.filter(r => r.isNewer);
if (newerReleases.length === 0) {
list.innerHTML = '<div style="color: var(--text-dim);">No updates available</div>';
} else {
list.innerHTML = newerReleases.map(r =>
'<div style="display: flex; justify-content: space-between; align-items: center; padding: 6px 0; border-bottom: 1px solid var(--border);">' +
'<span>v' + r.version + ' <span style="color: var(--text-dim);">(' + formatAge(r.ageHours) + ')</span></span>' +
'<button class="btn btn-primary" style="padding: 4px 12px; margin: 0;" onclick="upgradeToVersion(\\'' + r.version + '\\')">Upgrade</button>' +
'</div>'
).join('');
}
const autoStatus = document.getElementById('auto-upgrade-status');
if (data.autoUpgrade.targetVersion) {
if (data.autoUpgrade.waitingForStability) {
autoStatus.textContent = 'Auto-upgrade to v' + data.autoUpgrade.targetVersion + ' in ' + data.autoUpgrade.scheduledIn + ' (stability period)';
} else {
autoStatus.textContent = 'Auto-upgrade to v' + data.autoUpgrade.targetVersion + ' pending...';
}
} else {
autoStatus.textContent = data.lastCheck ? 'Last checked: ' + new Date(data.lastCheck).toLocaleTimeString() : '';
}
}
function formatAge(hours) {
if (hours < 1) return Math.round(hours * 60) + 'm ago';
if (hours < 24) return Math.round(hours) + 'h ago';
return Math.round(hours / 24) + 'd ago';
}
// Fetch updates info periodically
function fetchUpdates() {
fetch('/api/updates')
.then(r => r.json())
.then(updateUpdatesUI)
.catch(err => console.error('Failed to fetch updates:', err));
}
fetchUpdates();
setInterval(fetchUpdates, 60000); // Check every minute
// Initial fetch
fetch('/api/status')
.then(r => r.json())

View File

@@ -1 +1 @@
export const VERSION = "0.1.3";
export const VERSION = "0.3.6";

View File

@@ -1,9 +1,9 @@
{
"name": "@ecobridge/eco-os",
"version": "0.2.1",
"version": "0.3.8",
"private": true,
"scripts": {
"build": "npm version patch --no-git-tag-version && 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 --rm --privileged -v $(pwd)/.nogit/iso:/output ecoos-builder",
"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",
"daemon:dev": "cd ecoos_daemon && deno run --allow-all --watch mod.ts",
"daemon:start": "cd ecoos_daemon && deno run --allow-all mod.ts",
"daemon:typecheck": "cd ecoos_daemon && deno check mod.ts",

268
readme.md Normal file
View 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.