mirror of
https://github.com/UNITRONIX/BetterDesk.git
synced 2026-09-10 17:45:42 +00:00
5de22d8ce6
- Added functionality to track the remote SHA of the agent source, improving consistency during updates. - Implemented a new method to sync the full support-agent source from GitHub, ensuring all necessary files are staged for rebuilds. - Introduced a mechanism to check for agent source drift, allowing for automatic repairs when discrepancies are detected. - Updated the update service to trigger agent source synchronization and rebuilds based on specific file changes, streamlining the update workflow.
711 lines
27 KiB
JavaScript
711 lines
27 KiB
JavaScript
/**
|
||
* BetterDesk Console — Agent Build Worker (Go support-agent)
|
||
*
|
||
* Compiles branded betterdesk-support-agent binaries per bundle.
|
||
*/
|
||
|
||
'use strict';
|
||
|
||
const fs = require('fs');
|
||
const fsp = fs.promises;
|
||
const path = require('path');
|
||
const crypto = require('crypto');
|
||
const { spawn } = require('child_process');
|
||
|
||
const db = require('./database');
|
||
const bundleService = require('./agentBundleService');
|
||
const config = require('../config/config');
|
||
|
||
try {
|
||
const envFile = process.env.BETTERDESK_BUILD_ENV_FILE || '/etc/betterdesk/build.env';
|
||
if (fs.existsSync(envFile)) {
|
||
const txt = fs.readFileSync(envFile, 'utf8');
|
||
for (const line of txt.split(/\r?\n/)) {
|
||
const m = /^\s*([A-Z_][A-Z0-9_]*)\s*=\s*(.*?)\s*$/.exec(line);
|
||
if (!m) continue;
|
||
const [, key, val] = m;
|
||
if (process.env[key] === undefined) process.env[key] = val;
|
||
}
|
||
}
|
||
} catch (e) {
|
||
console.warn('[agentBuildWorker] could not load build env file:', e.message);
|
||
}
|
||
|
||
const BUILD_USER = process.env.BUILD_USER || 'unitronix';
|
||
const BUILD_CACHE_DIR = process.env.BUILD_CACHE_DIR
|
||
|| path.join(config.dataDir || '/opt/BetterDeskConsole/data', 'build-cache');
|
||
const WORK_ROOT = path.join(BUILD_CACHE_DIR, 'work');
|
||
const ARTIFACT_ROOT = process.env.AGENT_ARTIFACT_DIR
|
||
|| path.join(config.dataDir || '/opt/BetterDeskConsole/data', 'agent-builds');
|
||
const SOURCE_ROOT = _resolveSourceRoot();
|
||
const AGENT_LIB_ROOT = _resolveAgentLibRoot();
|
||
const POLL_INTERVAL_MS = parseInt(process.env.AGENT_BUILD_POLL_MS || '5000', 10);
|
||
const WORKER_CONCURRENCY = parseInt(process.env.AGENT_BUILD_CONCURRENCY || '1', 10);
|
||
const BUILD_TIMEOUT_MS = parseInt(process.env.AGENT_BUILD_TIMEOUT_MS || (30 * 60 * 1000), 10);
|
||
const IS_WINDOWS = process.platform === 'win32';
|
||
|
||
function _resolveSourceRoot() {
|
||
if (process.env.AGENT_SOURCE_DIR) return process.env.AGENT_SOURCE_DIR;
|
||
const candidates = [
|
||
'/opt/BetterDeskConsole/agent-source/betterdesk-support-agent',
|
||
path.resolve(__dirname, '..', '..', 'betterdesk-support-agent'),
|
||
path.resolve(process.cwd(), 'betterdesk-support-agent'),
|
||
];
|
||
for (const c of candidates) {
|
||
if (fs.existsSync(path.join(c, 'build.sh'))) return c;
|
||
}
|
||
return candidates[0];
|
||
}
|
||
|
||
function _resolveAgentLibRoot() {
|
||
const candidates = [
|
||
path.join(path.dirname(SOURCE_ROOT), 'betterdesk-agent'),
|
||
path.resolve(__dirname, '..', '..', 'betterdesk-agent'),
|
||
];
|
||
for (const c of candidates) {
|
||
if (fs.existsSync(path.join(c, 'go.mod'))) return c;
|
||
}
|
||
return candidates[1];
|
||
}
|
||
|
||
const GO_BIN = process.env.GO_BIN || _resolveBin(['/usr/bin/go', '/usr/local/go/bin/go']);
|
||
const BASE_BUILD_ENV = {
|
||
PATH: `/usr/local/go/bin:/usr/bin:/bin:${process.env.PATH || ''}`,
|
||
HOME: `/home/${BUILD_USER}`,
|
||
CGO_ENABLED: '1',
|
||
GOPATH: process.env.GOPATH || `/home/${BUILD_USER}/go`,
|
||
};
|
||
|
||
const BUILD_PROFILES = {
|
||
'windows/x64/portable': { os: 'windows', ext: '.zip', pack: 'zip-portable' },
|
||
'windows/x64/installed': { os: 'windows', ext: '.exe', pack: 'raw' },
|
||
'linux/x64/portable': { os: 'linux', ext: '.tar.gz', pack: 'tar-portable' },
|
||
'linux/x64/appimage': { os: 'linux', ext: '.AppImage', pack: 'appimage' },
|
||
'linux/x64/installed': { os: 'linux', ext: '.deb', pack: 'deb' },
|
||
'linux/x64/rpm': { os: 'linux', ext: '.rpm', pack: 'rpm' },
|
||
};
|
||
|
||
function _resolveBin(candidates) {
|
||
for (const c of candidates) { if (fs.existsSync(c)) return c; }
|
||
return candidates[0];
|
||
}
|
||
|
||
let _running = false;
|
||
let _activeBuilds = 0;
|
||
let _pollHandle = null;
|
||
|
||
const REBUILD_FLAG_FILE = path.join(config.dataDir || path.join(__dirname, '..', 'data'), '.agent_rebuild_pending');
|
||
const AGENT_SOURCE_STAMP_FILE = path.join(config.dataDir || path.join(__dirname, '..', 'data'), '.agent_source_sha');
|
||
const AGENT_SOURCE_PREFIXES = ['betterdesk-support-agent/', 'betterdesk-agent/'];
|
||
|
||
function _agentSourceDirs() {
|
||
const supportAgent = SOURCE_ROOT;
|
||
const base = path.dirname(supportAgent);
|
||
return {
|
||
base,
|
||
supportAgent,
|
||
agentLib: path.join(base, 'betterdesk-agent'),
|
||
};
|
||
}
|
||
|
||
async function enqueueBuildsForHash(brandingHash, { force = false } = {}) {
|
||
if (!brandingHash) throw new Error('brandingHash required');
|
||
const platforms = bundleService.PLATFORMS || [];
|
||
for (const p of platforms) {
|
||
const existing = await db.getAgentBundleBuild({
|
||
brandingHash, platform: p.platform, arch: p.arch, format: p.format,
|
||
});
|
||
if (!force && existing && (existing.status === 'ready' || existing.status === 'building')) {
|
||
continue;
|
||
}
|
||
await db.upsertAgentBundleBuild({
|
||
brandingHash,
|
||
platform: p.platform,
|
||
arch: p.arch,
|
||
format: p.format,
|
||
status: 'pending',
|
||
artifactPath: existing?.artifact_path || null,
|
||
artifactSize: existing?.artifact_size || 0,
|
||
artifactSha256: existing?.artifact_sha256 || null,
|
||
errorMessage: '',
|
||
});
|
||
}
|
||
}
|
||
|
||
/** Queue rebuilds for every non-revoked generator bundle (e.g. after agent source update). */
|
||
async function requeueAllBundleBuilds() {
|
||
const bundles = await db.listAgentBundles({ includeRevoked: false });
|
||
const hashes = [...new Set(
|
||
bundles.filter(b => !b.revoked).map(b => b.branding_hash).filter(Boolean)
|
||
)];
|
||
for (const hash of hashes) {
|
||
await enqueueBuildsForHash(hash, { force: true });
|
||
}
|
||
return { bundles: hashes.length };
|
||
}
|
||
|
||
function markRebuildPending(reason = 'update') {
|
||
fs.mkdirSync(path.dirname(REBUILD_FLAG_FILE), { recursive: true });
|
||
fs.writeFileSync(REBUILD_FLAG_FILE, JSON.stringify({
|
||
reason,
|
||
at: new Date().toISOString(),
|
||
}));
|
||
}
|
||
|
||
/** On console startup, rebuild generator clients when an update left a pending flag. */
|
||
async function processPendingRebuildOnStartup() {
|
||
if (!fs.existsSync(REBUILD_FLAG_FILE)) return null;
|
||
let meta = {};
|
||
try {
|
||
meta = JSON.parse(fs.readFileSync(REBUILD_FLAG_FILE, 'utf8'));
|
||
} catch (_) { /* use defaults */ }
|
||
try {
|
||
fs.unlinkSync(REBUILD_FLAG_FILE);
|
||
} catch (_) { /* ok */ }
|
||
|
||
const result = await requeueAllBundleBuilds();
|
||
console.log(
|
||
`[agentBuildWorker] auto-rebuild queued for ${result.bundles} bundle(s)`
|
||
+ (meta.reason ? ` (reason: ${meta.reason})` : '')
|
||
);
|
||
return { ...result, reason: meta.reason || 'pending' };
|
||
}
|
||
|
||
/**
|
||
* Stage support-agent / betterdesk-agent files downloaded during an in-app update
|
||
* into the build worker source tree (agent-source/).
|
||
*/
|
||
async function stageSourcesFromGitHub({ remoteSHA, files, download }) {
|
||
if (!files?.length || typeof download !== 'function') {
|
||
return { staged: 0 };
|
||
}
|
||
const owner = process.env.UPDATE_GITHUB_OWNER || 'UNITRONIX';
|
||
const repo = process.env.UPDATE_GITHUB_REPO || 'BetterDesk';
|
||
let staged = 0;
|
||
for (const file of files) {
|
||
if (file.status === 'removed') continue;
|
||
if (await _writeAgentSourceFile(owner, repo, remoteSHA, file.path, download)) {
|
||
staged++;
|
||
}
|
||
}
|
||
return { staged };
|
||
}
|
||
|
||
/**
|
||
* Download the full support-agent + betterdesk-agent trees at remoteSHA.
|
||
* Used after updates so agent-source/ stays consistent even when an individual
|
||
* commit diff only touches the build worker or generator routes.
|
||
*/
|
||
async function syncFullAgentSourceFromGitHub({ remoteSHA, download, listPaths }) {
|
||
if (typeof download !== 'function' || typeof listPaths !== 'function') {
|
||
throw new Error('download and listPaths are required');
|
||
}
|
||
const owner = process.env.UPDATE_GITHUB_OWNER || 'UNITRONIX';
|
||
const repo = process.env.UPDATE_GITHUB_REPO || 'BetterDesk';
|
||
const allPaths = await listPaths(remoteSHA);
|
||
const agentPaths = allPaths.filter((fp) =>
|
||
AGENT_SOURCE_PREFIXES.some((pref) => fp.startsWith(pref))
|
||
);
|
||
|
||
let staged = 0;
|
||
for (const fp of agentPaths) {
|
||
if (await _writeAgentSourceFile(owner, repo, remoteSHA, fp, download)) {
|
||
staged++;
|
||
}
|
||
}
|
||
|
||
if (remoteSHA) {
|
||
fs.mkdirSync(path.dirname(AGENT_SOURCE_STAMP_FILE), { recursive: true });
|
||
fs.writeFileSync(AGENT_SOURCE_STAMP_FILE, String(remoteSHA).trim());
|
||
}
|
||
return { staged, paths: agentPaths.length };
|
||
}
|
||
|
||
async function _writeAgentSourceFile(owner, repo, remoteSHA, fp, download) {
|
||
let destRoot;
|
||
let rel;
|
||
if (fp.startsWith('betterdesk-support-agent/')) {
|
||
destRoot = _agentSourceDirs().supportAgent;
|
||
rel = fp.slice('betterdesk-support-agent/'.length);
|
||
} else if (fp.startsWith('betterdesk-agent/')) {
|
||
destRoot = _agentSourceDirs().agentLib;
|
||
rel = fp.slice('betterdesk-agent/'.length);
|
||
} else {
|
||
return false;
|
||
}
|
||
if (!rel) return false;
|
||
|
||
const dest = path.join(destRoot, rel);
|
||
const content = await download(owner, repo, remoteSHA, fp);
|
||
await fsp.mkdir(path.dirname(dest), { recursive: true });
|
||
await fsp.writeFile(dest, content);
|
||
if (!IS_WINDOWS && (rel.endsWith('.sh') || rel === 'build.sh')) {
|
||
try { await fsp.chmod(dest, 0o755); } catch (_) { /* ok */ }
|
||
}
|
||
return true;
|
||
}
|
||
|
||
/**
|
||
* If agent-source was never stamped or is missing expected files while bundles
|
||
* exist, sync from the deployed commit SHA and queue rebuilds.
|
||
*/
|
||
async function reconcileAgentSourceDrift() {
|
||
if (fs.existsSync(REBUILD_FLAG_FILE)) return null;
|
||
|
||
const bundles = await db.listAgentBundles({ includeRevoked: false });
|
||
const active = bundles.filter((b) => !b.revoked);
|
||
if (active.length === 0) return null;
|
||
|
||
const supportRoot = _agentSourceDirs().supportAgent;
|
||
const missingCore = !fs.existsSync(path.join(supportRoot, 'build.sh'))
|
||
|| !fs.existsSync(path.join(supportRoot, 'urls.go'));
|
||
const stampedSha = fs.existsSync(AGENT_SOURCE_STAMP_FILE)
|
||
? fs.readFileSync(AGENT_SOURCE_STAMP_FILE, 'utf8').trim()
|
||
: '';
|
||
|
||
if (!missingCore && stampedSha) return null;
|
||
|
||
const shaFile = path.join(config.dataDir || path.join(__dirname, '..', 'data'), '.update_sha');
|
||
const deployedSha = fs.existsSync(shaFile)
|
||
? fs.readFileSync(shaFile, 'utf8').trim()
|
||
: '';
|
||
|
||
if (deployedSha) {
|
||
try {
|
||
const updateService = require('./updateService');
|
||
const synced = await updateService.syncAgentSourceAtSha(deployedSha);
|
||
console.log(
|
||
`[agentBuildWorker] agent-source repaired from ${deployedSha.slice(0, 7)}`
|
||
+ ` (${synced.staged}/${synced.paths} files)`
|
||
);
|
||
} catch (err) {
|
||
console.warn(`[agentBuildWorker] agent-source repair sync failed: ${err.message}`);
|
||
}
|
||
}
|
||
|
||
markRebuildPending('agent-source drift');
|
||
return processPendingRebuildOnStartup();
|
||
}
|
||
|
||
function startWorker() {
|
||
if (_pollHandle) return;
|
||
console.log(`[agentBuildWorker] Go support-agent source=${SOURCE_ROOT}`);
|
||
_ensureDirs().catch((e) => console.error('[agentBuildWorker] dir init failed:', e));
|
||
_pollHandle = setInterval(() => {
|
||
_tick().catch((e) => console.error('[agentBuildWorker] tick error:', e.message));
|
||
}, POLL_INTERVAL_MS);
|
||
processPendingRebuildOnStartup().catch((e) => {
|
||
console.error('[agentBuildWorker] pending rebuild failed:', e.message);
|
||
});
|
||
reconcileAgentSourceDrift().catch((e) => {
|
||
console.warn('[agentBuildWorker] agent-source drift check skipped:', e.message);
|
||
});
|
||
console.log(`[agentBuildWorker] started (poll ${POLL_INTERVAL_MS}ms)`);
|
||
}
|
||
|
||
function stopWorker() {
|
||
if (_pollHandle) { clearInterval(_pollHandle); _pollHandle = null; }
|
||
}
|
||
|
||
async function getReadyArtifact({ brandingHash, platform, arch, format }) {
|
||
const row = await db.getAgentBundleBuild({ brandingHash, platform, arch, format });
|
||
if (!row || row.status !== 'ready' || !row.artifact_path) return null;
|
||
try {
|
||
await fsp.access(row.artifact_path, fs.constants.R_OK);
|
||
} catch {
|
||
return null;
|
||
}
|
||
return row;
|
||
}
|
||
|
||
async function _ensureDirs() {
|
||
await fsp.mkdir(WORK_ROOT, { recursive: true });
|
||
await fsp.mkdir(ARTIFACT_ROOT, { recursive: true });
|
||
}
|
||
|
||
async function _tick() {
|
||
if (_running) return;
|
||
if (_activeBuilds >= WORKER_CONCURRENCY) return;
|
||
_running = true;
|
||
try {
|
||
const claimed = await _claimNextBuild();
|
||
if (!claimed) return;
|
||
_activeBuilds++;
|
||
_runOne(claimed)
|
||
.catch((e) => console.error('[agentBuildWorker] build crashed:', e))
|
||
.finally(() => { _activeBuilds--; });
|
||
} finally {
|
||
_running = false;
|
||
}
|
||
}
|
||
|
||
async function _claimNextBuild() {
|
||
const candidates = await _listPendingBuilds(10);
|
||
for (const row of candidates) {
|
||
const profile = BUILD_PROFILES[`${row.platform}/${row.arch}/${row.format}`];
|
||
if (!profile) continue;
|
||
await db.upsertAgentBundleBuild({
|
||
brandingHash: row.branding_hash,
|
||
platform: row.platform,
|
||
arch: row.arch,
|
||
format: row.format,
|
||
status: 'building',
|
||
artifactPath: row.artifact_path || null,
|
||
artifactSize: row.artifact_size || 0,
|
||
artifactSha256: row.artifact_sha256 || null,
|
||
errorMessage: '',
|
||
});
|
||
return row;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
async function _listPendingBuilds(limit) {
|
||
const bundles = await db.listAgentBundles();
|
||
const out = [];
|
||
for (const b of bundles) {
|
||
if (b.revoked) continue;
|
||
const builds = await db.listAgentBundleBuildsForHash(b.branding_hash);
|
||
for (const r of builds) {
|
||
if (r.status === 'pending') out.push(r);
|
||
if (out.length >= limit) break;
|
||
}
|
||
if (out.length >= limit) break;
|
||
}
|
||
out.sort((a, b) => String(a.created_at).localeCompare(String(b.created_at)));
|
||
return out.slice(0, limit);
|
||
}
|
||
|
||
async function _runOne(buildRow) {
|
||
const key = `${buildRow.platform}/${buildRow.arch}/${buildRow.format}`;
|
||
const profile = BUILD_PROFILES[key];
|
||
const startTs = Date.now();
|
||
console.log(`[agentBuildWorker] build start hash=${buildRow.branding_hash.slice(0, 12)} ${key}`);
|
||
|
||
try {
|
||
const bundleRow = await _findBundleForHash(buildRow.branding_hash);
|
||
if (!bundleRow) throw new Error(`no bundle with hash ${buildRow.branding_hash}`);
|
||
|
||
const branding = JSON.parse(bundleRow.branding || '{}');
|
||
const workDir = path.join(WORK_ROOT, buildRow.branding_hash, key.replace(/\//g, '_'));
|
||
const brandingFile = path.join(workDir, 'resources', 'branding.json');
|
||
const binaryName = profile.os === 'windows' ? 'betterdesk-support.exe' : 'betterdesk-support';
|
||
const binaryPath = path.join(workDir, 'dist', binaryName);
|
||
|
||
await _materialiseWorkspace(workDir, branding);
|
||
await _runGoBuild(workDir, brandingFile, binaryPath, profile.os);
|
||
|
||
const packed = await _packArtifact(workDir, binaryPath, profile, buildRow.branding_hash.slice(0, 8), branding);
|
||
const finalDir = path.join(ARTIFACT_ROOT, buildRow.branding_hash);
|
||
await fsp.mkdir(finalDir, { recursive: true });
|
||
const dest = path.join(finalDir, `${buildRow.platform}-${buildRow.arch}-${buildRow.format}${profile.ext}`);
|
||
await fsp.copyFile(packed, dest);
|
||
|
||
const stat = await fsp.stat(dest);
|
||
const sha = await _sha256OfFile(dest);
|
||
|
||
await db.upsertAgentBundleBuild({
|
||
brandingHash: buildRow.branding_hash,
|
||
platform: buildRow.platform,
|
||
arch: buildRow.arch,
|
||
format: buildRow.format,
|
||
status: 'ready',
|
||
artifactPath: dest,
|
||
artifactSize: stat.size,
|
||
artifactSha256: sha,
|
||
errorMessage: '',
|
||
});
|
||
console.log(`[agentBuildWorker] build ready ${key} (${(stat.size / 1024 / 1024).toFixed(2)} MB, ${((Date.now() - startTs) / 1000).toFixed(1)}s)`);
|
||
} catch (err) {
|
||
const msg = (err && err.message) ? err.message.slice(0, 800) : String(err).slice(0, 800);
|
||
console.error(`[agentBuildWorker] build FAILED ${key}: ${msg}`);
|
||
await db.upsertAgentBundleBuild({
|
||
brandingHash: buildRow.branding_hash,
|
||
platform: buildRow.platform,
|
||
arch: buildRow.arch,
|
||
format: buildRow.format,
|
||
status: 'failed',
|
||
artifactPath: null,
|
||
artifactSize: 0,
|
||
artifactSha256: null,
|
||
errorMessage: msg,
|
||
}).catch(() => {});
|
||
}
|
||
}
|
||
|
||
async function _findBundleForHash(hash) {
|
||
const all = await db.listAgentBundles();
|
||
return all.find(b => b.branding_hash === hash) || null;
|
||
}
|
||
|
||
async function _materialiseWorkspace(workDir, branding) {
|
||
if (fs.existsSync(workDir)) {
|
||
await fsp.rm(workDir, { recursive: true, force: true });
|
||
}
|
||
await fsp.mkdir(workDir, { recursive: true });
|
||
await _copyDir(SOURCE_ROOT, workDir);
|
||
// go.mod replace => ../betterdesk-agent — refresh sibling lib each build
|
||
// (legacy Tauri sidecar caches may leave binaries without go.mod here).
|
||
const agentLibDest = path.join(workDir, '..', 'betterdesk-agent');
|
||
if (fs.existsSync(agentLibDest)) {
|
||
await fsp.rm(agentLibDest, { recursive: true, force: true });
|
||
}
|
||
await fsp.mkdir(path.dirname(agentLibDest), { recursive: true });
|
||
await _copyDir(AGENT_LIB_ROOT, agentLibDest);
|
||
await fsp.mkdir(path.join(workDir, 'resources'), { recursive: true });
|
||
await fsp.writeFile(
|
||
path.join(workDir, 'resources', 'branding.json'),
|
||
JSON.stringify(branding, null, 2),
|
||
'utf8'
|
||
);
|
||
}
|
||
|
||
async function _copyDir(src, dst) {
|
||
const SKIP = new Set(['node_modules', 'target', 'dist', '.git', 'data']);
|
||
const entries = await fsp.readdir(src, { withFileTypes: true });
|
||
await fsp.mkdir(dst, { recursive: true });
|
||
for (const entry of entries) {
|
||
if (SKIP.has(entry.name)) continue;
|
||
const s = path.join(src, entry.name);
|
||
const d = path.join(dst, entry.name);
|
||
if (entry.isDirectory()) {
|
||
await _copyDir(s, d);
|
||
} else {
|
||
await fsp.copyFile(s, d);
|
||
}
|
||
}
|
||
}
|
||
|
||
async function _runGoBuild(workDir, brandingPath, outputPath, targetOS) {
|
||
await fsp.mkdir(path.dirname(outputPath), { recursive: true });
|
||
const buildScript = path.join(workDir, 'build.sh');
|
||
await _runProcess('/bin/bash', [
|
||
buildScript,
|
||
'-b', brandingPath,
|
||
'-o', outputPath,
|
||
'-p', targetOS,
|
||
], { cwd: workDir });
|
||
await fsp.access(outputPath, fs.constants.R_OK);
|
||
}
|
||
|
||
async function _packArtifact(workDir, binaryPath, profile, label, branding = {}) {
|
||
const packDir = path.join(workDir, 'pack');
|
||
await fsp.mkdir(packDir, { recursive: true });
|
||
const baseName = path.basename(binaryPath);
|
||
|
||
switch (profile.pack) {
|
||
case 'raw': {
|
||
const out = path.join(packDir, baseName);
|
||
await fsp.copyFile(binaryPath, out);
|
||
return out;
|
||
}
|
||
case 'zip-portable': {
|
||
const stage = path.join(packDir, 'stage');
|
||
await fsp.mkdir(stage, { recursive: true });
|
||
await fsp.copyFile(binaryPath, path.join(stage, baseName));
|
||
await fsp.writeFile(path.join(stage, 'portable'), '', 'utf8');
|
||
await fsp.writeFile(path.join(stage, 'README.txt'),
|
||
'BetterDesk Support Agent (portable)\r\n\r\n' +
|
||
'If the window fails with an OpenGL/WGL error (common in VMs or RDP):\r\n' +
|
||
' 1. Install "OpenGL Compatibility Pack" from Microsoft Store, or\r\n' +
|
||
' 2. Run: betterdesk-support.exe -nogui\r\n' +
|
||
' (or use Uruchom-bez-okna.bat)\r\n\r\n' +
|
||
'-nogui runs the agent in the background without a window.\r\n' +
|
||
'Supervised session prompts require the normal GUI build.\r\n',
|
||
'utf8');
|
||
await fsp.writeFile(path.join(stage, 'Uruchom-bez-okna.bat'),
|
||
'@echo off\r\nstart "" "%~dp0betterdesk-support.exe" -nogui\r\n',
|
||
'utf8');
|
||
const zipPath = path.join(packDir, `betterdesk-support-${label}-portable.zip`);
|
||
await _runProcess('zip', ['-j', zipPath,
|
||
path.join(stage, baseName),
|
||
path.join(stage, 'portable'),
|
||
path.join(stage, 'README.txt'),
|
||
path.join(stage, 'Uruchom-bez-okna.bat'),
|
||
], { cwd: packDir });
|
||
return zipPath;
|
||
}
|
||
case 'tar-portable': {
|
||
const stage = path.join(packDir, 'stage');
|
||
await fsp.mkdir(stage, { recursive: true });
|
||
await fsp.copyFile(binaryPath, path.join(stage, baseName));
|
||
await fsp.chmod(path.join(stage, baseName), 0o755);
|
||
await fsp.writeFile(path.join(stage, 'portable'), '', 'utf8');
|
||
const tarPath = path.join(packDir, `betterdesk-support-${label}-portable.tar.gz`);
|
||
await _runProcess('tar', ['-czf', tarPath, '-C', stage, baseName, 'portable'], { cwd: packDir });
|
||
return tarPath;
|
||
}
|
||
case 'deb': {
|
||
const pkgRoot = path.join(packDir, 'pkg');
|
||
const binDest = path.join(pkgRoot, 'usr', 'local', 'bin', 'betterdesk-support');
|
||
await fsp.mkdir(path.dirname(binDest), { recursive: true });
|
||
await fsp.copyFile(binaryPath, binDest);
|
||
await fsp.chmod(binDest, 0o755);
|
||
const postinst = `#!/bin/sh\n/usr/local/bin/betterdesk-support -install || true\n`;
|
||
const debianDir = path.join(pkgRoot, 'DEBIAN');
|
||
await fsp.mkdir(debianDir, { recursive: true });
|
||
await fsp.writeFile(path.join(debianDir, 'postinst'), postinst, { mode: 0o755 });
|
||
await fsp.writeFile(path.join(debianDir, 'control'),
|
||
`Package: betterdesk-support\nVersion: 1.0.0\nArchitecture: amd64\nMaintainer: BetterDesk\nDescription: BetterDesk Support Agent\n`,
|
||
'utf8');
|
||
const debPath = path.join(packDir, `betterdesk-support-${label}.deb`);
|
||
await _runProcess('dpkg-deb', ['--build', pkgRoot, debPath], { cwd: packDir });
|
||
return debPath;
|
||
}
|
||
case 'rpm': {
|
||
const topdir = path.join(packDir, 'rpmbuild');
|
||
for (const sub of ['BUILD', 'RPMS', 'SOURCES', 'SPECS', 'SRPMS', 'BUILDROOT']) {
|
||
await fsp.mkdir(path.join(topdir, sub), { recursive: true });
|
||
}
|
||
await fsp.copyFile(binaryPath, path.join(topdir, 'SOURCES', 'betterdesk-support'));
|
||
const spec = `Name: betterdesk-support
|
||
Version: 1.0.0
|
||
Release: 1%{?dist}
|
||
Summary: BetterDesk Support Agent
|
||
License: Proprietary
|
||
BuildArch: x86_64
|
||
AutoReqProv: no
|
||
Source0: betterdesk-support
|
||
|
||
%description
|
||
BetterDesk branded support agent for end-user workstations.
|
||
|
||
%prep
|
||
# binary-only package
|
||
|
||
%build
|
||
# binary-only package
|
||
|
||
%install
|
||
mkdir -p %{buildroot}/usr/local/bin
|
||
install -m 755 %{SOURCE0} %{buildroot}/usr/local/bin/betterdesk-support
|
||
|
||
%post
|
||
/usr/local/bin/betterdesk-support -install || true
|
||
|
||
%files
|
||
/usr/local/bin/betterdesk-support
|
||
`;
|
||
const specPath = path.join(topdir, 'SPECS', 'betterdesk-support.spec');
|
||
await fsp.writeFile(specPath, spec, 'utf8');
|
||
await _runProcess('rpmbuild', [
|
||
'-bb',
|
||
'--define', `_topdir ${topdir}`,
|
||
specPath,
|
||
], { cwd: packDir });
|
||
const rpmsDir = path.join(topdir, 'RPMS', 'x86_64');
|
||
const files = await fsp.readdir(rpmsDir);
|
||
const rpmFile = files.find((f) => f.endsWith('.rpm'));
|
||
if (!rpmFile) throw new Error('rpmbuild produced no .rpm artifact');
|
||
return path.join(rpmsDir, rpmFile);
|
||
}
|
||
case 'appimage': {
|
||
const appDir = path.join(packDir, 'BetterDeskSupport.AppDir');
|
||
const binDir = path.join(appDir, 'usr', 'bin');
|
||
await fsp.mkdir(binDir, { recursive: true });
|
||
const binDest = path.join(binDir, 'betterdesk-support');
|
||
await fsp.copyFile(binaryPath, binDest);
|
||
await fsp.chmod(binDest, 0o755);
|
||
await fsp.writeFile(path.join(binDir, 'portable'), '', 'utf8');
|
||
|
||
const displayName = String(branding.product_name || branding.company_name || 'BetterDesk Support')
|
||
.replace(/[\r\n\t]/g, ' ')
|
||
.trim()
|
||
.slice(0, 80);
|
||
|
||
await fsp.writeFile(path.join(appDir, 'AppRun'), `#!/bin/sh
|
||
HERE="$(dirname "$(readlink -f "$0")")"
|
||
export PATH="$HERE/usr/bin:$PATH"
|
||
exec "$HERE/usr/bin/betterdesk-support" "$@"
|
||
`, { mode: 0o755 });
|
||
|
||
await fsp.writeFile(path.join(appDir, 'betterdesk-support.desktop'),
|
||
`[Desktop Entry]
|
||
Type=Application
|
||
Name=${displayName}
|
||
Comment=Remote support agent
|
||
Exec=betterdesk-support
|
||
Icon=betterdesk-support
|
||
Categories=Network;Utility;
|
||
Terminal=false
|
||
StartupNotify=true
|
||
`, 'utf8');
|
||
|
||
await _writeAppImageIcon(appDir, branding);
|
||
|
||
const outPath = path.join(packDir, `betterdesk-support-${label}-portable.AppImage`);
|
||
await _runProcess('appimagetool', ['--no-appstream', appDir, outPath], {
|
||
cwd: packDir,
|
||
env: { ARCH: 'x86_64', APPIMAGE_EXTRACT_AND_RUN: '1' },
|
||
});
|
||
return outPath;
|
||
}
|
||
default:
|
||
throw new Error(`unknown pack profile ${profile.pack}`);
|
||
}
|
||
}
|
||
|
||
function _runProcess(cmd, args, opts = {}) {
|
||
return new Promise((resolve, reject) => {
|
||
const child = spawn(cmd, args, {
|
||
cwd: opts.cwd,
|
||
env: { ...BASE_BUILD_ENV, ...(opts.env || {}) },
|
||
stdio: ['ignore', 'pipe', 'pipe'],
|
||
});
|
||
let stderrTail = '';
|
||
child.stderr.on('data', (chunk) => { stderrTail = (stderrTail + chunk.toString()).slice(-8192); });
|
||
child.stdout.on('data', () => {});
|
||
const timeout = setTimeout(() => {
|
||
try { child.kill('SIGTERM'); } catch (_) { /* ignore */ }
|
||
reject(new Error(`${cmd} timed out`));
|
||
}, BUILD_TIMEOUT_MS);
|
||
child.once('error', (e) => { clearTimeout(timeout); reject(e); });
|
||
child.once('exit', (code) => {
|
||
clearTimeout(timeout);
|
||
if (code === 0) return resolve();
|
||
reject(new Error(`${cmd} exited ${code}\n${stderrTail}`));
|
||
});
|
||
});
|
||
}
|
||
|
||
// 1×1 PNG fallback when branding logo is missing or unsupported for AppImage.
|
||
const DEFAULT_APPIMAGE_ICON = Buffer.from(
|
||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==',
|
||
'base64',
|
||
);
|
||
|
||
async function _writeAppImageIcon(appDir, branding) {
|
||
const iconPath = path.join(appDir, 'betterdesk-support.png');
|
||
const logo = branding?.logo_data_url || '';
|
||
const match = logo.match(/^data:image\/(png|jpe?g);base64,(.+)$/i);
|
||
if (match) {
|
||
await fsp.writeFile(iconPath, Buffer.from(match[2], 'base64'));
|
||
return;
|
||
}
|
||
await fsp.writeFile(iconPath, DEFAULT_APPIMAGE_ICON);
|
||
}
|
||
|
||
function _sha256OfFile(filePath) {
|
||
return new Promise((resolve, reject) => {
|
||
const h = crypto.createHash('sha256');
|
||
const s = fs.createReadStream(filePath);
|
||
s.on('error', reject);
|
||
s.on('data', (d) => h.update(d));
|
||
s.on('end', () => resolve(h.digest('hex')));
|
||
});
|
||
}
|
||
|
||
module.exports = {
|
||
enqueueBuildsForHash,
|
||
requeueAllBundleBuilds,
|
||
markRebuildPending,
|
||
processPendingRebuildOnStartup,
|
||
reconcileAgentSourceDrift,
|
||
stageSourcesFromGitHub,
|
||
syncFullAgentSourceFromGitHub,
|
||
startWorker,
|
||
stopWorker,
|
||
getReadyArtifact,
|
||
_internals: { BUILD_PROFILES, BUILD_CACHE_DIR, ARTIFACT_ROOT, SOURCE_ROOT },
|
||
};
|