/**
* 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 GO_MOD_CACHE_DIR = path.join(BUILD_CACHE_DIR, 'gomod');
const GO_BUILD_CACHE_DIR = path.join(BUILD_CACHE_DIR, 'gocache');
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 POLL_INTERVAL_MS = parseInt(process.env.AGENT_BUILD_POLL_MS || '5000', 10);
/** Always 1 — Go/Fyne builds are CPU/RAM heavy; platforms run one after another. */
const WORKER_CONCURRENCY = 1;
const BUILD_COOLDOWN_MS = parseInt(process.env.AGENT_BUILD_COOLDOWN_MS || '3000', 10);
const BUILD_TIMEOUT_MS = parseInt(process.env.AGENT_BUILD_TIMEOUT_MS || (30 * 60 * 1000), 10);
const BUILD_ORDER = (bundleService.PLATFORMS || []).map(
(p) => `${p.platform}/${p.arch}/${p.format}`
);
const IS_WINDOWS = process.platform === 'win32';
const VENDORED_GO_BIN = path.join(
config.dataDir || path.join(__dirname, '..', 'data'),
'go-toolchain', 'go', 'bin', IS_WINDOWS ? 'go.exe' : 'go'
);
const MESA_DLL_CANDIDATES = [
path.join(config.dataDir || path.join(__dirname, '..', 'data'), 'mesa-win64', 'opengl32.dll'),
path.join(__dirname, '..', 'vendor', 'mesa-win64', 'opengl32.dll'),
];
function _mesaDllPath() {
for (const p of MESA_DLL_CANDIDATES) {
if (fs.existsSync(p)) return p;
}
return null;
}
async function _stageLinuxUI(distDir, stageDir, launcherName) {
const launcher = path.join(distDir, launcherName);
const x11 = path.join(distDir, 'betterdesk-support-x11');
const wl = path.join(distDir, 'betterdesk-support-wayland');
await fsp.copyFile(launcher, path.join(stageDir, launcherName));
await fsp.chmod(path.join(stageDir, launcherName), 0o755);
await fsp.copyFile(x11, path.join(stageDir, 'betterdesk-support-x11'));
await fsp.chmod(path.join(stageDir, 'betterdesk-support-x11'), 0o755);
await fsp.copyFile(wl, path.join(stageDir, 'betterdesk-support-wayland'));
await fsp.chmod(path.join(stageDir, 'betterdesk-support-wayland'), 0o755);
}
function _resolveBin(candidates) {
for (const c of candidates) { if (fs.existsSync(c)) return c; }
return candidates[0];
}
function _goBinaryHealthy(goBin) {
if (!goBin || !fs.existsSync(goBin)) return false;
try {
const { goStdlibHealthy } = require('./updateService');
return goStdlibHealthy(goBin);
} catch {
return false;
}
}
/** Prefer a working Go toolchain (vendored console install beats broken /usr/local/go). */
function _resolveGoBin() {
const fromEnv = process.env.GO_BIN;
const candidates = [
...(fromEnv && _goBinaryHealthy(fromEnv) ? [fromEnv] : []),
VENDORED_GO_BIN,
'/usr/local/go/bin/go',
'/usr/bin/go',
];
for (const c of candidates) {
if (_goBinaryHealthy(c)) return c;
}
return null;
}
let _activeGoBin = _resolveGoBin();
function getGoBin() {
if (_activeGoBin && _goBinaryHealthy(_activeGoBin)) return _activeGoBin;
_activeGoBin = _resolveGoBin();
return _activeGoBin;
}
let _ensureGoPromise = null;
async function _ensureGoToolchain() {
if (_goBinaryHealthy(getGoBin())) return getGoBin();
if (!_ensureGoPromise) {
_ensureGoPromise = (async () => {
const updateService = require('./updateService');
const result = await updateService.installGoToolchain(null, { maxVersion: '1.25.99' });
if (result.success && result.binPath && _goBinaryHealthy(result.binPath)) {
_activeGoBin = result.binPath;
process.env.GO_BIN = result.binPath;
console.log(`[agentBuildWorker] Go toolchain ready: ${result.version || result.binPath}`);
return _activeGoBin;
}
throw new Error(result.error || 'Go toolchain install failed');
})().finally(() => {
_ensureGoPromise = null;
});
}
return _ensureGoPromise;
}
const BASE_BUILD_ENV = {
CGO_ENABLED: '1',
GOMODCACHE: GO_MOD_CACHE_DIR,
GOCACHE: GO_BUILD_CACHE_DIR,
};
function _buildEnv(extra = {}) {
const goBin = getGoBin();
if (!goBin) {
throw new Error('Go toolchain not available');
}
const goDir = path.dirname(goBin);
const goroot = path.dirname(goDir);
return {
...BASE_BUILD_ENV,
...extra,
GO_BIN: goBin,
GOROOT: goroot,
HOME: BUILD_CACHE_DIR,
PATH: `${goDir}:/usr/bin:/bin:${process.env.PATH || ''}`,
};
}
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];
}
function _resolveServerLibRoot() {
const agentSourceBase = path.dirname(SOURCE_ROOT);
const consoleRoot = agentSourceBase.endsWith('agent-source')
? path.dirname(agentSourceBase)
: agentSourceBase;
const candidates = [
path.join(path.dirname(SOURCE_ROOT), 'betterdesk-server'),
path.join(consoleRoot, 'betterdesk-server'),
path.resolve(__dirname, '..', '..', 'betterdesk-server'),
];
for (const c of candidates) {
if (fs.existsSync(path.join(c, 'go.mod'))) return c;
}
return candidates[2];
}
const SOURCE_ROOT = _resolveSourceRoot();
const AGENT_LIB_ROOT = _resolveAgentLibRoot();
const SERVER_LIB_ROOT = _resolveServerLibRoot();
const BUILD_PROFILES = {
'windows/x64/portable': { os: 'windows', ext: '.exe', pack: 'exe-portable' },
'windows/x64/installed': { os: 'windows', ext: '.msi', pack: 'msi' },
'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' },
};
let _running = false;
let _activeBuilds = 0;
let _pollHandle = null;
let _lastBuildFinishedAt = 0;
function _buildOrderIndex(row) {
const key = `${row.platform}/${row.arch}/${row.format}`;
const idx = BUILD_ORDER.indexOf(key);
return idx >= 0 ? idx : BUILD_ORDER.length + 1;
}
function _compileRoot(brandingHash, osName) {
return path.join(WORK_ROOT, brandingHash, osName);
}
function _escapeXml(text) {
return String(text || '')
.replace(/&/g, '&')
.replace(//g, '>')
.replace(/"/g, '"');
}
function _upgradeGuidFromHash(brandingHash) {
const hex = crypto.createHash('sha256').update(String(brandingHash)).digest('hex');
return `{${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}}`.toUpperCase();
}
function _resolveMsiBuilder() {
const candidates = ['wixl', 'msibuild', '/usr/bin/wixl', '/usr/bin/msibuild'];
for (const c of candidates) {
if (fs.existsSync(c)) return c;
}
const { execSync } = require('child_process');
for (const cmd of ['wixl', 'msibuild']) {
try {
const found = execSync(`command -v ${cmd} 2>/dev/null`, { encoding: 'utf8' }).trim();
if (found) return found;
} catch (_) { /* ok */ }
}
return 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/',
'betterdesk-server/',
];
function _agentSourceDirs() {
const supportAgent = SOURCE_ROOT;
const base = path.dirname(supportAgent);
return {
base,
supportAgent,
agentLib: path.join(base, 'betterdesk-agent'),
serverLib: path.join(base, 'betterdesk-server'),
};
}
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 };
}
/** Force-requeue every platform build for one generator bundle. */
async function rebuildBundleById(bundleId) {
const row = await db.getAgentBundle(bundleId);
if (!row) return { success: false, error: 'not_found' };
if (!row.branding_hash) return { success: false, error: 'missing_hash' };
await enqueueBuildsForHash(row.branding_hash, { force: true });
const platforms = (bundleService.PLATFORMS || []).length;
return { success: true, platforms, brandingHash: row.branding_hash };
}
/** Re-queue builds that failed only because the host Go install was broken. */
async function requeueFailedToolchainBuilds() {
if (!_goBinaryHealthy(getGoBin())) return { requeued: 0 };
const bundles = await db.listAgentBundles({ includeRevoked: false });
let requeued = 0;
for (const b of bundles) {
if (b.revoked) continue;
const builds = await db.listAgentBundleBuildsForHash(b.branding_hash);
for (const row of builds) {
const err = String(row.error_message || '');
if (row.status !== 'failed') continue;
if (!/not in std|Go toolchain|stdlib verification/i.test(err)) continue;
await db.upsertAgentBundleBuild({
brandingHash: row.branding_hash,
platform: row.platform,
arch: row.arch,
format: row.format,
status: 'pending',
artifactPath: row.artifact_path || null,
artifactSize: row.artifact_size || 0,
artifactSha256: row.artifact_sha256 || null,
errorMessage: '',
});
requeued++;
}
}
if (requeued > 0) {
console.log(`[agentBuildWorker] requeued ${requeued} failed build(s) after Go toolchain recovery`);
}
return { requeued };
}
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 if (fp.startsWith('betterdesk-server/')) {
destRoot = _agentSourceDirs().serverLib;
rel = fp.slice('betterdesk-server/'.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 serverRoot = _agentSourceDirs().serverLib;
const missingCore = !fs.existsSync(path.join(supportRoot, 'build.sh'))
|| !fs.existsSync(path.join(supportRoot, 'urls.go'))
|| !fs.existsSync(path.join(serverRoot, 'go.mod'));
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} server=${SERVER_LIB_ROOT} go=${getGoBin()}`);
_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);
});
(async () => {
try {
await _ensureGoToolchain();
await requeueFailedToolchainBuilds();
} catch (e) {
console.warn('[agentBuildWorker] Go toolchain preflight:', 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 });
await fsp.mkdir(GO_MOD_CACHE_DIR, { recursive: true });
await fsp.mkdir(GO_BUILD_CACHE_DIR, { recursive: true });
}
async function _tick() {
if (_running || _activeBuilds >= WORKER_CONCURRENCY) return;
if (BUILD_COOLDOWN_MS > 0 && Date.now() - _lastBuildFinishedAt < BUILD_COOLDOWN_MS) {
return;
}
if (await _hasBuildInProgress()) return;
_running = true;
try {
const claimed = await _claimNextBuild();
if (!claimed) return;
_activeBuilds++;
try {
await _runOne(claimed);
} catch (e) {
console.error('[agentBuildWorker] build crashed:', e);
} finally {
_activeBuilds--;
_lastBuildFinishedAt = Date.now();
}
} finally {
_running = false;
}
}
async function _hasBuildInProgress() {
if (_activeBuilds > 0) return true;
const bundles = await db.listAgentBundles();
for (const b of bundles) {
if (b.revoked) continue;
const builds = await db.listAgentBundleBuildsForHash(b.branding_hash);
if (builds.some((r) => r.status === 'building')) return true;
}
return false;
}
async function _claimNextBuild() {
if (await _hasBuildInProgress()) return null;
const candidates = await _listPendingBuilds(50);
candidates.sort((a, b) => {
const order = _buildOrderIndex(a) - _buildOrderIndex(b);
if (order !== 0) return order;
return String(a.created_at || '').localeCompare(String(b.created_at || ''));
});
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) => _buildOrderIndex(a) - _buildOrderIndex(b));
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 compileDir = _compileRoot(buildRow.branding_hash, profile.os);
const brandingFile = path.join(compileDir, 'resources', 'branding.json');
const binaryName = profile.os === 'windows' ? 'betterdesk-support.exe' : 'betterdesk-support';
const binaryPath = path.join(compileDir, 'dist', binaryName);
const shouldCompile = await _needsCompile(
compileDir, buildRow.branding_hash, binaryPath, profile.os
);
await _materialiseWorkspace(compileDir, branding, { refreshSources: shouldCompile });
await _ensureGoToolchain();
if (shouldCompile) {
await _runGoBuild(compileDir, brandingFile, binaryPath, profile.os);
await fsp.writeFile(
path.join(compileDir, '.built_for'),
buildRow.branding_hash,
'utf8'
);
} else {
console.log(`[agentBuildWorker] reusing compiled ${profile.os} binary for ${key}`);
}
const packed = await _packArtifact(
compileDir,
binaryPath,
profile,
buildRow.branding_hash.slice(0, 8),
branding,
buildRow.branding_hash
);
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 _needsCompile(workDir, brandingHash, binaryPath, targetOS) {
try {
const stamp = (await fsp.readFile(path.join(workDir, '.built_for'), 'utf8')).trim();
if (stamp !== brandingHash) return true;
await fsp.access(binaryPath, fs.constants.R_OK);
if (targetOS === 'linux') {
const distDir = path.dirname(binaryPath);
await fsp.access(path.join(distDir, 'betterdesk-support-x11'), fs.constants.R_OK);
await fsp.access(path.join(distDir, 'betterdesk-support-wayland'), fs.constants.R_OK);
}
return false;
} catch {
return true;
}
}
async function _materialiseWorkspace(workDir, branding, { refreshSources = true } = {}) {
const mustRefresh = refreshSources || !fs.existsSync(path.join(workDir, 'build.sh'));
if (mustRefresh) {
if (fs.existsSync(workDir)) {
await fsp.rm(workDir, { recursive: true, force: true });
}
await fsp.mkdir(workDir, { recursive: true });
await _copyDir(SOURCE_ROOT, workDir);
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);
const serverLibDest = path.join(workDir, '..', 'betterdesk-server');
if (fs.existsSync(serverLibDest)) {
await fsp.rm(serverLibDest, { recursive: true, force: true });
}
await fsp.mkdir(path.dirname(serverLibDest), { recursive: true });
await _copyDir(SERVER_LIB_ROOT, serverLibDest);
}
await fsp.mkdir(path.join(workDir, 'resources'), { recursive: true });
const buildBranding = { ...branding };
delete buildBranding.enrollment_token;
delete buildBranding.has_enrollment_token;
delete buildBranding.enrollment_token_masked;
await fsp.writeFile(
path.join(workDir, 'resources', 'branding.json'),
JSON.stringify(buildBranding, 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 _ensureMesaForWindows(workDir) {
const mesa = _mesaDllPath();
if (!mesa) {
console.warn('[agentBuildWorker] mesa opengl32.dll not found — Windows GUI may fail on VMs/RDP. Run scripts/fetch-mesa-windows.sh');
return false;
}
const destDir = path.join(workDir, 'windows');
await fsp.mkdir(destDir, { recursive: true });
await fsp.copyFile(mesa, path.join(destDir, 'opengl32.dll'));
return true;
}
async function _runGoBuild(workDir, brandingPath, outputPath, targetOS) {
await fsp.mkdir(path.dirname(outputPath), { recursive: true });
if (targetOS === 'windows') {
await _ensureMesaForWindows(workDir);
}
const buildScript = path.join(workDir, 'build.sh');
const args = ['-b', brandingPath, '-o', outputPath, '-p', targetOS];
if (targetOS === 'linux') {
args.push('-d');
}
await _runProcess('/bin/bash', [buildScript, ...args], { cwd: workDir });
await fsp.access(outputPath, fs.constants.R_OK);
if (targetOS === 'linux') {
const distDir = path.dirname(outputPath);
for (const name of ['betterdesk-support-x11', 'betterdesk-support-wayland']) {
await fsp.access(path.join(distDir, name), fs.constants.R_OK);
}
}
}
async function _packArtifact(workDir, binaryPath, profile, label, branding = {}, brandingHash = label) {
const packDir = path.join(workDir, 'pack');
await fsp.mkdir(packDir, { recursive: true });
const baseName = path.basename(binaryPath);
switch (profile.pack) {
case 'exe-portable': {
const out = path.join(packDir, `betterdesk-support-${label}-portable.exe`);
await fsp.copyFile(binaryPath, out);
return out;
}
case 'msi': {
const msiBuilder = _resolveMsiBuilder();
if (!msiBuilder) {
throw new Error(
'msibuild/wixl not found — install msitools (dnf install msitools / apt install msitools) for Windows MSI builds'
);
}
const msiDir = path.join(packDir, 'msi');
await fsp.mkdir(msiDir, { recursive: true });
await fsp.copyFile(binaryPath, path.join(msiDir, 'betterdesk-support.exe'));
const mesa = _mesaDllPath();
let mesaComponent = '';
let mesaFeatureRef = '';
if (mesa) {
await fsp.copyFile(mesa, path.join(msiDir, 'opengl32.dll'));
mesaComponent = `
`;
mesaFeatureRef = '\n ';
}
const productName = _escapeXml(
branding.product_name || branding.company_name || 'BetterDesk Support'
);
const manufacturer = _escapeXml(branding.company_name || 'BetterDesk');
const upgradeCode = _upgradeGuidFromHash(brandingHash);
const wxs = `
${mesaComponent}
${mesaFeatureRef}
`;
const wxsPath = path.join(msiDir, 'installer.wxs');
await fsp.writeFile(wxsPath, wxs, 'utf8');
const msiPath = path.join(packDir, `betterdesk-support-${label}.msi`);
await _runProcess(msiBuilder, ['-o', msiPath, wxsPath], { cwd: msiDir, timeoutMs: 10 * 60 * 1000 });
return msiPath;
}
case 'raw': {
const out = path.join(packDir, baseName);
await fsp.copyFile(binaryPath, out);
return out;
}
case 'tar-portable': {
const stage = path.join(packDir, 'stage');
const distDir = path.dirname(binaryPath);
await fsp.mkdir(stage, { recursive: true });
await _stageLinuxUI(distDir, 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' +
'Run ./betterdesk-support — auto-selects Wayland or X11.\r\n' +
'Override: BETTERDESK_UI_BACKEND=wayland|x11\r\n',
'utf8');
const tarPath = path.join(packDir, `betterdesk-support-${label}-portable.tar.gz`);
const tarMembers = ['betterdesk-support', 'betterdesk-support-x11', 'betterdesk-support-wayland', 'portable', 'README.txt'];
await _runProcess('tar', ['-czf', tarPath, '-C', stage, ...tarMembers], { cwd: packDir });
return tarPath;
}
case 'deb': {
const pkgRoot = path.join(packDir, 'pkg');
const binDir = path.join(pkgRoot, 'usr', 'local', 'bin');
const libDir = path.join(pkgRoot, 'usr', 'lib', 'betterdesk-support');
await fsp.mkdir(binDir, { recursive: true });
await fsp.mkdir(libDir, { recursive: true });
const distDir = path.dirname(binaryPath);
await _stageLinuxUI(distDir, libDir, 'betterdesk-support');
const binDest = path.join(binDir, 'betterdesk-support');
await fsp.writeFile(binDest, `#!/bin/sh
LIB="/usr/lib/betterdesk-support"
if [ -n "$WAYLAND_DISPLAY" ] && [ -z "$DISPLAY" ] && [ -x "$LIB/betterdesk-support-wayland" ]; then
exec "$LIB/betterdesk-support-wayland" "$@"
fi
exec "$LIB/betterdesk-support-x11" "$@"
`, { mode: 0o755 });
const postinst = `#!/bin/sh\n/usr/lib/betterdesk-support/betterdesk-support-x11 -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 });
}
const buildDir = path.join(topdir, 'BUILD');
const libDir = path.join(buildDir, 'usr', 'lib', 'betterdesk-support');
const binDir = path.join(buildDir, 'usr', 'local', 'bin');
await fsp.mkdir(libDir, { recursive: true });
await fsp.mkdir(binDir, { recursive: true });
const distDir = path.dirname(binaryPath);
await _stageLinuxUI(distDir, libDir, 'betterdesk-support');
const wrapperPath = path.join(binDir, 'betterdesk-support');
await fsp.writeFile(wrapperPath, `#!/bin/sh
LIB="/usr/lib/betterdesk-support"
if [ -n "$WAYLAND_DISPLAY" ] && [ -z "$DISPLAY" ] && [ -x "$LIB/betterdesk-support-wayland" ]; then
exec "$LIB/betterdesk-support-wayland" "$@"
fi
exec "$LIB/betterdesk-support-x11" "$@"
`, { mode: 0o755 });
const spec = `Name: betterdesk-support
Version: 1.0.0
Release: 1%{?dist}
Summary: BetterDesk Support Agent
License: Proprietary
BuildArch: x86_64
AutoReqProv: no
%description
BetterDesk branded support agent for end-user workstations.
%install
mkdir -p %{buildroot}/usr/lib/betterdesk-support
mkdir -p %{buildroot}/usr/local/bin
cp -a %{_builddir}/usr/lib/betterdesk-support/. %{buildroot}/usr/lib/betterdesk-support/
install -m 755 %{_builddir}/usr/local/bin/betterdesk-support %{buildroot}/usr/local/bin/betterdesk-support
%post
/usr/lib/betterdesk-support/betterdesk-support-x11 -install || true
%files
/usr/lib/betterdesk-support/betterdesk-support
/usr/lib/betterdesk-support/betterdesk-support-x11
/usr/lib/betterdesk-support/betterdesk-support-wayland
/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}`,
'--define', `_builddir ${buildDir}`,
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');
const distDir = path.dirname(binaryPath);
await fsp.mkdir(binDir, { recursive: true });
await _stageLinuxUI(distDir, binDir, 'betterdesk-support');
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"
BD_UID="$(id -u 2>/dev/null || echo 0)"
export XDG_RUNTIME_DIR="\${XDG_RUNTIME_DIR:-/run/user/$BD_UID}"
if [ -z "\${DBUS_SESSION_BUS_ADDRESS:-}" ] && [ -S "\$XDG_RUNTIME_DIR/bus" ]; then
export DBUS_SESSION_BUS_ADDRESS="unix:path=\$XDG_RUNTIME_DIR/bus"
fi
LAUNCHER="$HERE/usr/bin/betterdesk-support"
if [ -x "$LAUNCHER" ]; then
exec "$LAUNCHER" "$@"
fi
exec "$HERE/usr/bin/betterdesk-support-x11" "$@"
`, { 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: _buildEnv(opts.env || {}),
stdio: ['ignore', 'pipe', 'pipe'],
});
let stderrTail = '';
child.stderr.on('data', (chunk) => { stderrTail = (stderrTail + chunk.toString()).slice(-8192); });
child.stdout.on('data', () => {});
const timeoutMs = opts.timeoutMs || BUILD_TIMEOUT_MS;
const timeout = setTimeout(() => {
try { child.kill('SIGTERM'); } catch (_) { /* ignore */ }
reject(new Error(`${cmd} timed out after ${timeoutMs}ms`));
}, timeoutMs);
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,
rebuildBundleById,
requeueFailedToolchainBuilds,
markRebuildPending,
processPendingRebuildOnStartup,
reconcileAgentSourceDrift,
stageSourcesFromGitHub,
syncFullAgentSourceFromGitHub,
startWorker,
stopWorker,
getReadyArtifact,
_internals: { BUILD_PROFILES, BUILD_CACHE_DIR, ARTIFACT_ROOT, SOURCE_ROOT },
};