/** * BetterDesk Console — Support Agent Build Worker (Go / Wails UI) * * Builds branded betterdesk-support-agent binaries for product_type=support-agent. * Agent-client (Tauri) builds are handled by agentClientBuildWorker.js. */ '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 { resolveBundleSigningKeyFile } = require('./bundleSigningKey'); const supportProfile = require('./supportAgentProfile'); const config = require('../config/config'); const { readProductVersion } = require('../lib/productVersion'); const { PRODUCT_TYPES, normalizeProductType, isQueuedBuildStatus, } = require('../lib/generatorBuildTypes'); 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 || 'betterdesk'; 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/Wails 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'; /** Monorepo root when developing from git; may be wrong on flat console deploys. */ const PROJECT_ROOT = path.resolve(__dirname, '..', '..'); /** Console install root (`web-nodejs/` in git, `/opt/BetterDeskConsole` when flattened). */ const CONSOLE_ROOT = path.resolve(__dirname, '..'); const VENDORED_GO_BIN = path.join( config.dataDir || path.join(__dirname, '..', 'data'), 'go-toolchain', 'go', 'bin', IS_WINDOWS ? 'go.exe' : 'go' ); const MESA_DIR_CANDIDATES = [ path.join(config.dataDir || path.join(__dirname, '..', 'data'), 'mesa-win64'), path.join(__dirname, '..', 'vendor', 'mesa-win64'), ]; /** Mesa opengl32.dll alone is unloadable without libgallium_wgl.dll — never ship incomplete sets. */ const MESA_REQUIRED_DLLS = ['opengl32.dll', 'libgallium_wgl.dll']; function _mesaDirPath() { for (const dir of MESA_DIR_CANDIDATES) { if (MESA_REQUIRED_DLLS.every((name) => fs.existsSync(path.join(dir, name)))) { return dir; } } return null; } function _mesaDllPath() { const dir = _mesaDirPath(); return dir ? path.join(dir, 'opengl32.dll') : null; } function _mesaCompanionFiles() { const dir = _mesaDirPath(); if (!dir) return []; return MESA_REQUIRED_DLLS.map((name) => ({ name, src: path.join(dir, name), })); } /** True when legacy Fyne dual X11/Wayland artifacts are present alongside the launcher. */ function _hasDualLinuxUI(distDir) { return fs.existsSync(path.join(distDir, 'betterdesk-support-x11')) && fs.existsSync(path.join(distDir, 'betterdesk-support-wayland')); } /** * Stage Linux UI binaries into a package directory. * Wails (default) ships a single binary; Fyne dual layout is used only when * betterdesk-support-x11 + betterdesk-support-wayland exist in distDir. * @returns {'single'|'dual'} */ async function _stageLinuxUI(distDir, stageDir, launcherName) { const launcher = path.join(distDir, launcherName); await fsp.copyFile(launcher, path.join(stageDir, launcherName)); await fsp.chmod(path.join(stageDir, launcherName), 0o755); if (!_hasDualLinuxUI(distDir)) { return 'single'; } const x11 = path.join(distDir, 'betterdesk-support-x11'); const wl = path.join(distDir, 'betterdesk-support-wayland'); 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); return 'dual'; } 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.26.6' }); 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; let _startupReady = false; 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 _isSupportAgentBundle(bundle) { return normalizeProductType(bundle?.product_type) === PRODUCT_TYPES.SUPPORT_AGENT; } function _resolveProductRootDir() { // Flat/packaged console keeps VERSION next to server.js (CONSOLE_ROOT). // Git checkout keeps VERSION at the monorepo root (PROJECT_ROOT). if (fs.existsSync(path.join(CONSOLE_ROOT, 'VERSION')) || fs.existsSync(path.join(CONSOLE_ROOT, 'package.json'))) { return CONSOLE_ROOT; } return PROJECT_ROOT; } function _getSupportAgentBuildVersion(opts = {}) { const rootDir = opts.rootDir || _resolveProductRootDir(); return readProductVersion({ rootDir, consoleDir: opts.consoleDir || CONSOLE_ROOT, fallback: '0.1.0', }); } function _getAgentSourceStamp() { try { return fs.readFileSync(AGENT_SOURCE_STAMP_FILE, 'utf8').trim() || 'unversioned'; } catch (_) { return 'unversioned'; } } function _buildFingerprint( brandingHash, version = _getSupportAgentBuildVersion(), signingKeyFingerprint = '' ) { return JSON.stringify({ brandingHash, sourceStamp: _getAgentSourceStamp(), version, signingKeyFingerprint, }); } async function _injectSupportAgentVersion(workDir, opts = {}) { const version = opts.version || _getSupportAgentBuildVersion(opts); const mainPath = path.join(workDir, 'main.go'); const source = await fsp.readFile(mainPath, 'utf8'); const next = source.replace( /^(\s*var\s+version\s*=\s*)"[^"]*"/m, `$1"${version}"` ); if (next === source) { // Already at the target version (common when fallback matches placeholder). const escaped = String(version).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); if (new RegExp(`^\\s*var\\s+version\\s*=\\s*"${escaped}"`, 'm').test(source)) { return version; } throw new Error('support-agent version variable not found in build workspace'); } await fsp.writeFile(mainPath, next, 'utf8'); return version; } 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 _which(cmd) { const { execSync } = require('child_process'); try { const found = execSync(`command -v ${cmd} 2>/dev/null`, { encoding: 'utf8' }).trim(); return found || null; } catch (_) { return null; } } function _resolveMsiBuilder() { // wixl compiles .wxs → .msi. msibuild (same msitools package) is a different // tool for editing MSI databases and must not be used here. const candidates = ['wixl', '/usr/bin/wixl']; for (const c of candidates) { if (c.includes('/') && fs.existsSync(c)) return c; } return _which('wixl'); } /** Prefer an extracted (non-FUSE) appimagetool so the betterdesk user can run it. */ function _resolveAppImageTool() { const candidates = [ process.env.APPIMAGETOOL_BIN, '/usr/local/lib/appimagetool/AppRun', '/usr/local/lib/appimagetool/usr/bin/appimagetool', '/usr/local/bin/appimagetool', ].filter(Boolean); for (const c of candidates) { if (fs.existsSync(c)) return c; } return _which('appimagetool'); } function _resolveMingwGcc() { const candidates = [ process.env.MINGW_CC, '/usr/bin/x86_64-w64-mingw32-gcc', 'x86_64-w64-mingw32-gcc', ].filter(Boolean); for (const c of candidates) { if (c.includes('/') && fs.existsSync(c)) return c; if (!c.includes('/')) { const found = _which(c); if (found) return found; } } return null; } /** Normalize optional platform filter from API ({platform,arch,format}[]). */ function _filterPlatforms(only) { const all = bundleService.PLATFORMS || []; if (!Array.isArray(only) || only.length === 0) return all; const filtered = all.filter((p) => only.some((o) => ( String(o.platform || o.os || '') === p.platform && String(o.arch || 'x64') === p.arch && String(o.format || '') === p.format ))); return filtered.length ? filtered : all; } 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, platforms: onlyPlatforms = null } = {}) { if (!brandingHash) throw new Error('brandingHash required'); const platforms = _filterPlatforms(onlyPlatforms); 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: 'queued', artifactPath: existing?.artifact_path || null, artifactSize: existing?.artifact_size || 0, artifactSha256: existing?.artifact_sha256 || null, errorMessage: '', }); } } function _parseBundleBranding(raw) { if (!raw) return {}; if (typeof raw === 'object') return raw; try { return JSON.parse(raw); } catch (_) { return {}; } } /** * Re-issue signed profile fields when rebuild/requeue would otherwise fail the * release-profile gate. Valid profiles keep their branding_hash unchanged. */ async function _ensureFreshSupportProfile(bundleRow) { if (!bundleRow || !_isSupportAgentBundle(bundleRow)) { return { brandingHash: bundleRow?.branding_hash || null, refreshed: false, }; } const branding = _parseBundleBranding(bundleRow.branding); if (supportProfile.isReleaseSupportProfileValid(branding)) { return { brandingHash: bundleRow.branding_hash, refreshed: false }; } const refreshed = await supportProfile.refreshSupportAgentBranding(branding); refreshed.bundle_id = bundleRow.bundle_id; refreshed.product_name = refreshed.company_name || 'BetterDesk Support'; supportProfile.addSupportProfileValidity(refreshed); const brandingHash = bundleService.hashBranding(refreshed); await db.updateAgentBundle(bundleRow.bundle_id, { name: bundleRow.name, slug: bundleRow.slug, branding: JSON.stringify(refreshed), brandingHash, }); console.log( `[agentBuildWorker] refreshed Support Agent profile for bundle ${bundleRow.bundle_id}` + ` (hash ${String(bundleRow.branding_hash || '').slice(0, 8)}… → ${String(brandingHash).slice(0, 8)}…)` ); return { brandingHash, refreshed: true }; } /** Queue Support Agent rebuilds after a Support Agent source update. */ async function requeueAllBundleBuilds() { const bundles = await db.listAgentBundles({ includeRevoked: false }); const hashes = []; for (const bundle of bundles) { if (bundle.revoked || !_isSupportAgentBundle(bundle)) continue; const { brandingHash } = await _ensureFreshSupportProfile(bundle); if (!brandingHash) continue; hashes.push(brandingHash); await enqueueBuildsForHash(brandingHash, { force: true }); } return { bundles: [...new Set(hashes)].length }; } /** Force-requeue platform builds for one generator bundle (optional filter). */ async function rebuildBundleById(bundleId, { platforms: onlyPlatforms = null } = {}) { const row = await db.getAgentBundle(bundleId); if (!row) return { success: false, error: 'not_found' }; if (!_isSupportAgentBundle(row)) return { success: false, error: 'not_support_agent' }; const { brandingHash } = await _ensureFreshSupportProfile(row); if (!brandingHash) return { success: false, error: 'missing_hash' }; const platforms = _filterPlatforms(onlyPlatforms); await enqueueBuildsForHash(brandingHash, { force: true, platforms }); return { success: true, platforms: platforms.length, brandingHash }; } /** 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 || !_isSupportAgentBundle(b)) 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: 'queued', 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', meta = {}) { fs.mkdirSync(path.dirname(REBUILD_FLAG_FILE), { recursive: true }); fs.writeFileSync(REBUILD_FLAG_FILE, JSON.stringify({ reason, at: new Date().toISOString(), remoteSHA: meta.remoteSHA || null, })); } /** * On console startup, stage the exact deployed source before queueing builds. * This is deliberately outside updateService.applyUpdate(): the console * update can finish and restart before the potentially large agent sync runs. */ 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 */ } const shaFile = path.join(config.dataDir || path.join(__dirname, '..', 'data'), '.update_sha'); const remoteSHA = meta.remoteSHA || ( fs.existsSync(shaFile) ? fs.readFileSync(shaFile, 'utf8').trim() : '' ); let source = null; if (remoteSHA) { const updateService = require('./updateService'); source = await updateService.syncAgentSourceAtSha(remoteSHA); } // Delete the flag only after source sync and requeue both succeed so a // crash mid-update does not lose the pending rebuild. const result = await requeueAllBundleBuilds(); try { fs.unlinkSync(REBUILD_FLAG_FILE); } catch (_) { /* ok */ } console.log( `[agentBuildWorker] auto-rebuild queued for ${result.bundles} bundle(s)` + (meta.reason ? ` (reason: ${meta.reason})` : '') ); return { ...result, source, remoteSHA: remoteSHA || null, reason: meta.reason || 'pending', }; } /** Classify a build stderr / error_message for UI hints. */ function classifyBuildError(msg) { const s = String(msg || ''); // Branding seal must win over incidental cgo/gcc noise from a failed go run. if (/branding signing|sealbranding|refusing to embed plaintext|signed branding profile could not/i.test(s)) { return { kind: 'branding_seal', hintKey: 'generator.toolchain_branding_seal' }; } if (/not in std|Go toolchain|stdlib verification|go:|cannot find package/i.test(s)) { return { kind: 'go', hintKey: 'generator.toolchain_go' }; } if (/wixl|msitools|\.wxs/i.test(s)) { return { kind: 'wixl', hintKey: 'generator.toolchain_wixl' }; } if (/appimagetool|AppImage|Failed to extract AppImage|could not create symlink/i.test(s)) { return { kind: 'appimage', hintKey: 'generator.toolchain_appimage' }; } if (/dpkg-deb|fakeroot|\.deb/i.test(s)) { return { kind: 'deb', hintKey: 'generator.toolchain_deb' }; } if (/rpmbuild|\.rpm/i.test(s)) { return { kind: 'rpm', hintKey: 'generator.toolchain_rpm' }; } if (/mesa|opengl|libGL|WGL/i.test(s)) { return { kind: 'mesa', hintKey: 'generator.toolchain_mesa' }; } if (/mingw|x86_64-w64-mingw|cgo: C compiler|CC=.*mingw/i.test(s)) { return { kind: 'cgo', hintKey: 'generator.toolchain_cgo' }; } return { kind: 'compile', hintKey: 'generator.build_error_hint' }; } /** Force-requeue a single platform build for a branding hash. */ async function requeuePlatformBuild(brandingHash, platform, arch, format) { if (!brandingHash || !platform || !arch || !format) { return { success: false, error: 'missing_args' }; } const allowed = (bundleService.PLATFORMS || []).some( (p) => p.platform === platform && p.arch === arch && p.format === format ); if (!allowed) return { success: false, error: 'unsupported_platform' }; const bundle = await _findBundleForHash(brandingHash); let hash = brandingHash; if (bundle) { const ensured = await _ensureFreshSupportProfile(bundle); if (ensured.brandingHash) hash = ensured.brandingHash; } await db.upsertAgentBundleBuild({ brandingHash: hash, platform, arch, format, status: 'queued', artifactPath: null, artifactSize: 0, artifactSha256: null, errorMessage: '', }); return { success: true, brandingHash: hash }; } /** Diagnostics for Generator / Settings panels. */ function getBuildWorkerStatus() { let rebuildPending = null; if (fs.existsSync(REBUILD_FLAG_FILE)) { try { rebuildPending = JSON.parse(fs.readFileSync(REBUILD_FLAG_FILE, 'utf8')); } catch (_) { rebuildPending = { reason: 'unknown' }; } } let sourceStamp = null; if (fs.existsSync(AGENT_SOURCE_STAMP_FILE)) { try { sourceStamp = fs.readFileSync(AGENT_SOURCE_STAMP_FILE, 'utf8').trim(); } catch (_) { /* ok */ } } const goBin = getGoBin(); return { workerEnabled: process.env.AGENT_BUILD_WORKER !== 'off', goBin, goHealthy: _goBinaryHealthy(goBin), sourceRoot: SOURCE_ROOT, sourceStamp, buildVersion: _getSupportAgentBuildVersion(), rebuildPending, mesaDll: _mesaDllPath() || null, msiBuilder: _resolveMsiBuilder(), mingwGcc: _resolveMingwGcc(), appimagetool: _resolveAppImageTool(), platforms: (bundleService.PLATFORMS || []).map((p) => ({ platform: p.platform, arch: p.arch, format: p.format, label: p.label, })), }; } /** * 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((bundle) => !bundle.revoked && _isSupportAgentBundle(bundle)); 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() : ''; markRebuildPending('agent-source drift', { remoteSHA: deployedSha || null }); return processPendingRebuildOnStartup(); } function startWorker() { if (_pollHandle) return; console.log(`[agentBuildWorker] Go support-agent source=${SOURCE_ROOT} server=${SERVER_LIB_ROOT} go=${getGoBin()}`); _startupReady = false; _pollHandle = setInterval(() => { _tick().catch((e) => console.error('[agentBuildWorker] tick error:', e.message)); }, POLL_INTERVAL_MS); const prepareStartup = async () => { try { await _ensureDirs(); await processPendingRebuildOnStartup(); await reconcileAgentSourceDrift(); _startupReady = true; } catch (e) { console.error('[agentBuildWorker] startup preparation failed:', e.message); _startupReady = false; if (_pollHandle) { setTimeout(() => { if (_pollHandle && !_startupReady) prepareStartup(); }, 30000); } } }; prepareStartup(); (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; } _startupReady = false; } 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 (!_startupReady) return; 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 || !_isSupportAgentBundle(b)) 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 bundleRow = await _findBundleForHash(row.branding_hash); if (!bundleRow || !_isSupportAgentBundle(bundleRow)) continue; 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 || !_isSupportAgentBundle(b)) continue; const builds = await db.listAgentBundleBuildsForHash(b.branding_hash); for (const r of builds) { if (isQueuedBuildStatus(r.status)) 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 || '{}'); _assertReleaseSupportProfile(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 buildVersion = _getSupportAgentBuildVersion(); const signingKeyFile = await resolveBundleSigningKeyFile({ keysPath: config.keysPath }); const signingKeyFingerprint = await _sha256OfFile(signingKeyFile); const buildFingerprint = _buildFingerprint( buildRow.branding_hash, buildVersion, signingKeyFingerprint ); const shouldCompile = await _needsCompile( compileDir, buildFingerprint, binaryPath, profile.os ); await _materialiseWorkspace(compileDir, branding, { refreshSources: shouldCompile }); await _ensureGoToolchain(); if (shouldCompile) { await _injectSupportAgentVersion(compileDir, { version: buildVersion }); await _runGoBuild(compileDir, brandingFile, binaryPath, profile.os, signingKeyFile); await fsp.writeFile( path.join(compileDir, '.built_for'), buildFingerprint, '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(() => {}); } } function _assertReleaseSupportProfile(branding) { supportProfile.assertReleaseSupportProfile(branding); } async function _findBundleForHash(hash) { const all = await db.listAgentBundles(); return all.find(b => b.branding_hash === hash) || null; } async function _needsCompile(workDir, buildFingerprint, binaryPath, _targetOS) { try { const stamp = (await fsp.readFile(path.join(workDir, '.built_for'), 'utf8')).trim(); if (stamp !== buildFingerprint) return true; // Wails ships a single Linux binary; dual X11/Wayland is optional (Fyne). await fsp.access(binaryPath, 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 companions = _mesaCompanionFiles(); if (!companions.length) { console.warn( '[agentBuildWorker] complete Mesa set not found (need opengl32.dll + libgallium_wgl.dll) — ' + 'skipping software OpenGL embed. Run scripts/fetch-mesa-windows.sh' ); return false; } const destDir = path.join(workDir, 'windows'); await fsp.mkdir(destDir, { recursive: true }); for (const f of companions) { await fsp.copyFile(f.src, path.join(destDir, f.name)); } return true; } async function _runGoBuild(workDir, brandingPath, outputPath, targetOS, signingKeyFile) { 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'); } if (!signingKeyFile) { throw new Error('Support Agent branding signing key is required'); } await _runProcess('/bin/bash', [buildScript, ...args], { cwd: workDir, env: { BETTERDESK_BUNDLE_SIGNING_KEY_FILE: signingKeyFile }, }); // Wails (default) produces a single Linux binary; dual X11/Wayland only for Fyne. await fsp.access(outputPath, 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( 'wixl not found — install wixl (apt install wixl / dnf install msitools wixl) 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 msiIcon = path.join(msiDir, 'betterdesk-support.ico'); await _runProcess('go', [ 'run', './cmd/winicon', '-branding', 'resources/branding.json', '-out', msiIcon, ], { cwd: workDir }); const mesaFiles = _mesaCompanionFiles(); let mesaComponent = ''; let mesaFeatureRef = ''; if (mesaFiles.length) { const componentXml = []; const refs = []; for (const f of mesaFiles) { await fsp.copyFile(f.src, path.join(msiDir, f.name)); const id = f.name.replace(/[^A-Za-z0-9]/g, ''); componentXml.push(` `); refs.push(`\n `); } mesaComponent = componentXml.join(''); mesaFeatureRef = refs.join(''); } 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 }); const layout = await _stageLinuxUI(distDir, stage, baseName); await fsp.writeFile(path.join(stage, 'portable'), '', 'utf8'); const readme = layout === 'dual' ? ( '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' ) : ( 'BetterDesk Support Agent (portable)\r\n\r\n' + 'Run ./betterdesk-support\r\n' ); await fsp.writeFile(path.join(stage, 'README.txt'), readme, 'utf8'); const tarPath = path.join(packDir, `betterdesk-support-${label}-portable.tar.gz`); const tarMembers = layout === 'dual' ? ['betterdesk-support', 'betterdesk-support-x11', 'betterdesk-support-wayland', 'portable', 'README.txt'] : ['betterdesk-support', '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); const layout = await _stageLinuxUI(distDir, libDir, 'betterdesk-support'); const binDest = path.join(binDir, 'betterdesk-support'); if (layout === 'dual') { 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 }); } else { await fsp.writeFile(binDest, `#!/bin/sh exec /usr/lib/betterdesk-support/betterdesk-support "$@" `, { mode: 0o755 }); } const postinst = layout === 'dual' ? `#!/bin/sh\n/usr/lib/betterdesk-support/betterdesk-support-x11 -install || true\n` : `#!/bin/sh\n/usr/lib/betterdesk-support/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 }); } 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); const layout = await _stageLinuxUI(distDir, libDir, 'betterdesk-support'); const wrapperPath = path.join(binDir, 'betterdesk-support'); if (layout === 'dual') { 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 }); } else { await fsp.writeFile(wrapperPath, `#!/bin/sh exec /usr/lib/betterdesk-support/betterdesk-support "$@" `, { mode: 0o755 }); } const filesSection = layout === 'dual' ? `/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 ` : `/usr/lib/betterdesk-support/betterdesk-support /usr/local/bin/betterdesk-support `; const postInstall = layout === 'dual' ? '/usr/lib/betterdesk-support/betterdesk-support-x11 -install || true' : '/usr/lib/betterdesk-support/betterdesk-support -install || true'; const spec = `Name: betterdesk-support Version: 1.0.0 Release: 1%{?dist} Summary: BetterDesk Support Agent License: AGPL-3.0 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 ${postInstall} %files ${filesSection}`; 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 }); const layout = 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); const appRun = layout === 'dual' ? `#!/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" "$@" ` : `#!/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 exec "$HERE/usr/bin/betterdesk-support" "$@" `; await fsp.writeFile(path.join(appDir, 'AppRun'), appRun, { 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`); const appimagetool = _resolveAppImageTool(); if (!appimagetool) { throw new Error( 'appimagetool not found — install via scripts/install-build-toolchain.sh (extracted wrapper)' ); } const tmpDir = path.join(BUILD_CACHE_DIR, 'tmp'); await fsp.mkdir(tmpDir, { recursive: true }); await _runProcess(appimagetool, ['--no-appstream', appDir, outPath], { cwd: packDir, env: { ARCH: 'x86_64', APPIMAGE_EXTRACT_AND_RUN: '1', HOME: BUILD_CACHE_DIR, TMPDIR: tmpDir, TEMP: tmpDir, TMP: tmpDir, }, }); 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, requeuePlatformBuild, markRebuildPending, processPendingRebuildOnStartup, reconcileAgentSourceDrift, stageSourcesFromGitHub, syncFullAgentSourceFromGitHub, startWorker, stopWorker, getReadyArtifact, getGoBin, getBuildWorkerStatus, classifyBuildError, _internals: { BUILD_PROFILES, BUILD_CACHE_DIR, ARTIFACT_ROOT, SOURCE_ROOT, isSupportAgentBundle: _isSupportAgentBundle, listPendingBuilds: _listPendingBuilds, getSupportAgentBuildVersion: _getSupportAgentBuildVersion, injectSupportAgentVersion: _injectSupportAgentVersion, buildFingerprint: _buildFingerprint, assertReleaseSupportProfile: _assertReleaseSupportProfile, ensureFreshSupportProfile: _ensureFreshSupportProfile, parseBundleBranding: _parseBundleBranding, hasDualLinuxUI: _hasDualLinuxUI, stageLinuxUI: _stageLinuxUI, needsCompile: _needsCompile, }, };