feat(daemon): add automatic update mechanism (Updater), switch to system journal logs, and expose update controls in the UI

This commit is contained in:
2026-01-09 16:55:43 +00:00
parent 1e86acff55
commit 6a3be55cee
8 changed files with 449 additions and 33 deletions

View File

@@ -32,16 +32,8 @@ jobs:
npm version ${{ steps.version.outputs.version_number }} --no-git-tag-version --allow-same-version 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 echo "export const VERSION = \"${{ steps.version.outputs.version_number }}\";" > ecoos_daemon/ts/version.ts
- name: Build daemon binary - name: Build ISO
run: pnpm run daemon:bundle run: pnpm run build
- 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: Prepare release assets - name: Prepare release assets
run: | run: |

View File

@@ -1,5 +1,15 @@
# Changelog # Changelog
## 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) ## 2026-01-09 - 0.2.1 - fix(ci)
use GitHub Actions workspace for docker volume and add listing of build output directory for debugging 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 { ProcessManager } from './process-manager.ts';
import { SystemInfo } from './system-info.ts'; import { SystemInfo } from './system-info.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';
import { VERSION } from '../version.ts'; import { VERSION } from '../version.ts';
@@ -28,12 +29,14 @@ export class EcoDaemon {
private config: DaemonConfig; private config: DaemonConfig;
private processManager: ProcessManager; private processManager: ProcessManager;
private systemInfo: SystemInfo; private systemInfo: SystemInfo;
private updater: Updater;
private uiServer: UIServer; private uiServer: UIServer;
private logs: string[] = []; private logs: string[] = [];
private serialLogs: string[] = []; private systemLogs: string[] = [];
private swayStatus: ServiceStatus = { state: 'stopped' }; private swayStatus: ServiceStatus = { state: 'stopped' };
private chromiumStatus: ServiceStatus = { state: 'stopped' }; private chromiumStatus: ServiceStatus = { state: 'stopped' };
private manualRestartUntil: number = 0; // Timestamp until which auto-restart is disabled 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>) { constructor(config?: Partial<DaemonConfig>) {
this.config = { this.config = {
@@ -45,6 +48,7 @@ export class EcoDaemon {
this.processManager = new ProcessManager(this.config.user); this.processManager = new ProcessManager(this.config.user);
this.systemInfo = new SystemInfo(); this.systemInfo = new SystemInfo();
this.updater = new Updater((msg) => this.log(msg));
this.uiServer = new UIServer(this.config.uiPort, this); this.uiServer = new UIServer(this.config.uiPort, this);
} }
@@ -64,8 +68,8 @@ export class EcoDaemon {
return [...this.logs]; return [...this.logs];
} }
getSerialLogs(): string[] { getSystemLogs(): string[] {
return [...this.serialLogs]; return [...this.systemLogs];
} }
async getStatus(): Promise<Record<string, unknown>> { async getStatus(): Promise<Record<string, unknown>> {
@@ -78,7 +82,7 @@ export class EcoDaemon {
chromiumStatus: this.chromiumStatus, chromiumStatus: this.chromiumStatus,
systemInfo, systemInfo,
logs: this.logs.slice(-50), 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> { async start(): Promise<void> {
this.log('EcoOS Daemon starting...'); this.log('EcoOS Daemon starting...');
@@ -139,8 +155,11 @@ export class EcoDaemon {
await this.uiServer.start(); await this.uiServer.start();
this.log('Management UI started successfully'); this.log('Management UI started successfully');
// Start serial console reader in the background // Start system journal reader in the background
this.startSerialReader(); 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 // Start the Sway/Chromium initialization in the background
// This allows the UI server to remain responsive even if Sway fails // 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); return parseInt(result.stdout.trim(), 10);
} }
private startSerialReader(): void { private startJournalReader(): void {
(async () => { (async () => {
try { try {
const file = await Deno.open('/dev/ttyS0', { read: true }); const cmd = new Deno.Command('journalctl', {
this.log('Serial console reader started on /dev/ttyS0'); args: ['-f', '--no-pager', '-n', '100', '-o', 'short-iso'],
const reader = file.readable.getReader(); stdout: 'piped',
stderr: 'piped',
});
const process = cmd.spawn();
this.log('System journal reader started');
const reader = process.stdout.getReader();
const decoder = new TextDecoder(); const decoder = new TextDecoder();
while (true) { while (true) {
@@ -326,14 +352,14 @@ export class EcoDaemon {
if (done) break; if (done) break;
const text = decoder.decode(value); const text = decoder.decode(value);
for (const line of text.split('\n').filter((l) => l.trim())) { for (const line of text.split('\n').filter((l) => l.trim())) {
this.serialLogs.push(line); this.systemLogs.push(line);
if (this.serialLogs.length > 1000) { if (this.systemLogs.length > 1000) {
this.serialLogs = this.serialLogs.slice(-1000); this.systemLogs = this.systemLogs.slice(-1000);
} }
} }
} }
} catch (error) { } 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(); 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) { } catch (error) {
this.log(`Error in monitoring loop: ${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/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 }); 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' }), { return new Response(JSON.stringify({ error: 'Not Found' }), {
status: 404, status: 404,
headers, headers,
@@ -347,6 +372,18 @@ export class UIServer {
</button> </button>
<div id="control-status" style="margin-top: 8px; font-size: 12px; color: var(--text-dim);"></div> <div id="control-status" style="margin-top: 8px; font-size: 12px; color: var(--text-dim);"></div>
</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"> <div class="card">
<h2>Input Devices</h2> <h2>Input Devices</h2>
<div id="input-devices-list"></div> <div id="input-devices-list"></div>
@@ -362,7 +399,7 @@ export class UIServer {
<div class="card" style="grid-column: 1 / -1;"> <div class="card" style="grid-column: 1 / -1;">
<div class="tabs"> <div class="tabs">
<div class="tab active" onclick="switchTab('daemon')">Daemon Logs</div> <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>
<div id="daemon-tab" class="tab-content active"> <div id="daemon-tab" class="tab-content active">
<div class="logs" id="logs"></div> <div class="logs" id="logs"></div>
@@ -522,13 +559,13 @@ export class UIServer {
logsEl.scrollTop = logsEl.scrollHeight; logsEl.scrollTop = logsEl.scrollHeight;
} }
// Serial Logs // System Logs
if (data.serialLogs) { if (data.systemLogs) {
const serialEl = document.getElementById('serial-logs'); const serialEl = document.getElementById('serial-logs');
if (data.serialLogs.length === 0) { if (data.systemLogs.length === 0) {
serialEl.innerHTML = '<div style="color: var(--text-dim);">No serial data available</div>'; serialEl.innerHTML = '<div style="color: var(--text-dim);">No system logs available</div>';
} else { } else {
serialEl.innerHTML = data.serialLogs.map(l => serialEl.innerHTML = data.systemLogs.map(l =>
'<div class="log-entry">' + l + '</div>' '<div class="log-entry">' + l + '</div>'
).join(''); ).join('');
serialEl.scrollTop = serialEl.scrollHeight; 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 // Initial fetch
fetch('/api/status') fetch('/api/status')
.then(r => r.json()) .then(r => r.json())

View File

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

View File

@@ -1,9 +1,9 @@
{ {
"name": "@ecobridge/eco-os", "name": "@ecobridge/eco-os",
"version": "0.2.1", "version": "0.2.3",
"private": true, "private": true,
"scripts": { "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:dev": "cd ecoos_daemon && deno run --allow-all --watch mod.ts",
"daemon:start": "cd ecoos_daemon && deno run --allow-all mod.ts", "daemon:start": "cd ecoos_daemon && deno run --allow-all mod.ts",
"daemon:typecheck": "cd ecoos_daemon && deno check mod.ts", "daemon:typecheck": "cd ecoos_daemon && deno check mod.ts",