Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| de10e1dd1f | |||
| 21f7a44a53 | |||
| 98398e962f | |||
| 06cea4bb37 | |||
| ee631c21c4 | |||
| 50d437aed7 | |||
| dd5ea36636 | |||
| 7d6aace6d9 | |||
| 99a04df8b0 | |||
| ee3b6dd6ae |
108
.gitea/release-upload.ts
Normal file
108
.gitea/release-upload.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* Release asset uploader for Gitea
|
||||
* Streams large files without loading them into memory (bypasses curl's 2GB multipart limit)
|
||||
*
|
||||
* Usage: GITEA_TOKEN=xxx RELEASE_ID=123 GITEA_REPO=owner/repo tsx release-upload.ts
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as https from 'https';
|
||||
|
||||
const token = process.env.GITEA_TOKEN;
|
||||
const releaseId = process.env.RELEASE_ID;
|
||||
const repo = process.env.GITEA_REPO;
|
||||
|
||||
if (!token || !releaseId || !repo) {
|
||||
console.error('Missing required env vars: GITEA_TOKEN, RELEASE_ID, GITEA_REPO');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const boundary = '----FormBoundary' + Date.now().toString(16);
|
||||
|
||||
async function uploadFile(filepath: string): Promise<void> {
|
||||
const filename = path.basename(filepath);
|
||||
const stats = fs.statSync(filepath);
|
||||
console.log(`Uploading ${filename} (${stats.size} bytes)...`);
|
||||
|
||||
const header = Buffer.from(
|
||||
`--${boundary}\r\n` +
|
||||
`Content-Disposition: form-data; name="attachment"; filename="${filename}"\r\n` +
|
||||
`Content-Type: application/octet-stream\r\n\r\n`
|
||||
);
|
||||
const footer = Buffer.from(`\r\n--${boundary}--\r\n`);
|
||||
const contentLength = header.length + stats.size + footer.length;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = https.request({
|
||||
hostname: 'code.foss.global',
|
||||
path: `/api/v1/repos/${repo}/releases/${releaseId}/assets?name=${encodeURIComponent(filename)}`,
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `token ${token}`,
|
||||
'Content-Type': `multipart/form-data; boundary=${boundary}`,
|
||||
'Content-Length': contentLength
|
||||
}
|
||||
}, (res) => {
|
||||
let data = '';
|
||||
res.on('data', chunk => data += chunk);
|
||||
res.on('end', () => {
|
||||
clearInterval(progressInterval);
|
||||
console.log(data);
|
||||
if (res.statusCode && res.statusCode >= 200 && res.statusCode < 300) {
|
||||
console.log(`✓ ${filename} uploaded successfully`);
|
||||
resolve();
|
||||
} else {
|
||||
reject(new Error(`Upload failed: ${res.statusCode} ${data}`));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
req.on('error', (err) => {
|
||||
clearInterval(progressInterval);
|
||||
reject(err);
|
||||
});
|
||||
|
||||
// Track upload progress
|
||||
let bytesWritten = header.length;
|
||||
const progressInterval = setInterval(() => {
|
||||
const percent = Math.round((bytesWritten / contentLength) * 100);
|
||||
console.log(` ${filename}: ${percent}% (${Math.round(bytesWritten / 1024 / 1024)}MB / ${Math.round(contentLength / 1024 / 1024)}MB)`);
|
||||
}, 10000);
|
||||
|
||||
// Stream: write header, pipe file, write footer
|
||||
req.write(header);
|
||||
const stream = fs.createReadStream(filepath);
|
||||
stream.on('data', (chunk) => {
|
||||
bytesWritten += chunk.length;
|
||||
});
|
||||
stream.on('error', (err) => {
|
||||
clearInterval(progressInterval);
|
||||
reject(err);
|
||||
});
|
||||
stream.on('end', () => {
|
||||
bytesWritten += footer.length;
|
||||
req.write(footer);
|
||||
req.end();
|
||||
});
|
||||
stream.pipe(req, { end: false });
|
||||
});
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const distDir = 'dist';
|
||||
const files = fs.readdirSync(distDir)
|
||||
.map(f => path.join(distDir, f))
|
||||
.filter(f => fs.statSync(f).isFile());
|
||||
|
||||
for (const file of files) {
|
||||
await uploadFile(file);
|
||||
}
|
||||
|
||||
console.log('All assets uploaded successfully');
|
||||
}
|
||||
|
||||
main().catch(err => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -75,16 +75,11 @@ jobs:
|
||||
|
||||
echo "Created release with ID: $RELEASE_ID"
|
||||
|
||||
# Upload assets
|
||||
for asset in dist/*; do
|
||||
filename=$(basename "$asset")
|
||||
echo "Uploading $filename..."
|
||||
curl -X POST -s \
|
||||
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
|
||||
-H "Content-Type: application/octet-stream" \
|
||||
-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: |
|
||||
|
||||
40
changelog.md
40
changelog.md
@@ -1,5 +1,45 @@
|
||||
# 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)
|
||||
bump package and daemon to v0.3.1, add project README, and fix Gitea release upload flag
|
||||
|
||||
|
||||
@@ -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...');
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -52,6 +52,18 @@ export interface AudioDevice {
|
||||
isDefault: boolean;
|
||||
}
|
||||
|
||||
export interface DisplayInfo {
|
||||
name: string; // e.g., "DP-1", "HDMI-A-1", "HEADLESS-1"
|
||||
make: string; // Manufacturer
|
||||
model: string; // Model name
|
||||
serial: string; // Serial number
|
||||
active: boolean; // Currently enabled
|
||||
width: number; // Resolution width
|
||||
height: number; // Resolution height
|
||||
refreshRate: number; // Hz
|
||||
isPrimary: boolean; // Has the focused window (kiosk)
|
||||
}
|
||||
|
||||
export interface SystemInfoData {
|
||||
hostname: string;
|
||||
cpu: CpuInfo;
|
||||
|
||||
@@ -48,7 +48,7 @@ interface GiteaAsset {
|
||||
}
|
||||
|
||||
export class Updater {
|
||||
private repoApiUrl = 'https://code.foss.global/api/v1/repos/ecobridge/eco-os/releases';
|
||||
private repoApiUrl = 'https://code.foss.global/api/v1/repos/ecobridge.xyz/eco_os/releases';
|
||||
private binaryPath = '/opt/eco/bin/eco-daemon';
|
||||
private releases: Release[] = [];
|
||||
private lastCheck: Date | null = null;
|
||||
|
||||
@@ -129,6 +129,28 @@ export class UIServer {
|
||||
}
|
||||
}
|
||||
|
||||
if (path === '/api/displays') {
|
||||
const displays = await this.daemon.getDisplays();
|
||||
return new Response(JSON.stringify({ displays }), { headers });
|
||||
}
|
||||
|
||||
// Display control endpoints: /api/displays/{name}/{action}
|
||||
const displayMatch = path.match(/^\/api\/displays\/([^/]+)\/(enable|disable|primary)$/);
|
||||
if (displayMatch && req.method === 'POST') {
|
||||
const name = decodeURIComponent(displayMatch[1]);
|
||||
const action = displayMatch[2];
|
||||
|
||||
let result;
|
||||
if (action === 'enable') {
|
||||
result = await this.daemon.setDisplayEnabled(name, true);
|
||||
} else if (action === 'disable') {
|
||||
result = await this.daemon.setDisplayEnabled(name, false);
|
||||
} else if (action === 'primary') {
|
||||
result = await this.daemon.setKioskDisplay(name);
|
||||
}
|
||||
return new Response(JSON.stringify(result), { headers });
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify({ error: 'Not Found' }), {
|
||||
status: 404,
|
||||
headers,
|
||||
@@ -384,6 +406,10 @@ export class UIServer {
|
||||
Check for Updates
|
||||
</button>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2>Displays</h2>
|
||||
<div id="displays-list"></div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2>Input Devices</h2>
|
||||
<div id="input-devices-list"></div>
|
||||
@@ -698,6 +724,64 @@ export class UIServer {
|
||||
fetchUpdates();
|
||||
setInterval(fetchUpdates, 60000); // Check every minute
|
||||
|
||||
// Display management
|
||||
function updateDisplaysUI(data) {
|
||||
const list = document.getElementById('displays-list');
|
||||
if (!data.displays || data.displays.length === 0) {
|
||||
list.innerHTML = '<div style="color: var(--text-dim);">No displays detected</div>';
|
||||
return;
|
||||
}
|
||||
list.innerHTML = data.displays.map(d =>
|
||||
'<div class="device-item" style="flex-wrap: wrap; gap: 8px;">' +
|
||||
'<div style="flex: 1; min-width: 150px;">' +
|
||||
'<div class="device-name">' + d.name + '</div>' +
|
||||
'<div style="font-size: 11px; color: var(--text-dim);">' +
|
||||
d.width + 'x' + d.height + ' @ ' + d.refreshRate + 'Hz' +
|
||||
(d.make !== 'Unknown' ? ' • ' + d.make : '') +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<div style="display: flex; gap: 4px;">' +
|
||||
(d.isPrimary
|
||||
? '<span class="device-default">Primary</span>'
|
||||
: '<button class="btn btn-primary" style="padding: 2px 8px; margin: 0; font-size: 11px;" onclick="setKioskDisplay(\\'' + d.name + '\\')">Set Primary</button>') +
|
||||
'<button class="btn ' + (d.active ? 'btn-danger' : 'btn-primary') + '" style="padding: 2px 8px; margin: 0; font-size: 11px;" onclick="toggleDisplay(\\'' + d.name + '\\', ' + !d.active + ')">' +
|
||||
(d.active ? 'Disable' : 'Enable') +
|
||||
'</button>' +
|
||||
'</div>' +
|
||||
'</div>'
|
||||
).join('');
|
||||
}
|
||||
|
||||
function fetchDisplays() {
|
||||
fetch('/api/displays')
|
||||
.then(r => r.json())
|
||||
.then(updateDisplaysUI)
|
||||
.catch(err => console.error('Failed to fetch displays:', err));
|
||||
}
|
||||
|
||||
function toggleDisplay(name, enable) {
|
||||
fetch('/api/displays/' + encodeURIComponent(name) + '/' + (enable ? 'enable' : 'disable'), { method: 'POST' })
|
||||
.then(r => r.json())
|
||||
.then(result => {
|
||||
if (!result.success) alert(result.message);
|
||||
fetchDisplays();
|
||||
})
|
||||
.catch(err => alert('Error: ' + err));
|
||||
}
|
||||
|
||||
function setKioskDisplay(name) {
|
||||
fetch('/api/displays/' + encodeURIComponent(name) + '/primary', { method: 'POST' })
|
||||
.then(r => r.json())
|
||||
.then(result => {
|
||||
if (!result.success) alert(result.message);
|
||||
fetchDisplays();
|
||||
})
|
||||
.catch(err => alert('Error: ' + err));
|
||||
}
|
||||
|
||||
fetchDisplays();
|
||||
setInterval(fetchDisplays, 5000); // Refresh every 5 seconds
|
||||
|
||||
// Initial fetch
|
||||
fetch('/api/status')
|
||||
.then(r => r.json())
|
||||
|
||||
@@ -1 +1 @@
|
||||
export const VERSION = "0.3.1";
|
||||
export const VERSION = "0.3.9";
|
||||
|
||||
Binary file not shown.
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@ecobridge/eco-os",
|
||||
"version": "0.3.2",
|
||||
"version": "0.4.1",
|
||||
"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",
|
||||
|
||||
Reference in New Issue
Block a user