Files
BetterDesk/web-nodejs/services/clientTemplateWorker.js
UNITRONIX d57fe3dc49 feat(generator): add generic agent releases and QR activation
Adds local verification and distribution of signed generic incoming-only agent releases, mobile-friendly QR activation, and the supporting generator interfaces. Includes the current staged console, documentation, localization, and builder changes.\n\nThanks: INSOLVE (Honorary); Marco Jakobs (@jacotec); MyNameisStitch (@MyNameisStitch); Redspin (@playerumpknow)
2026-10-11 00:43:26 +02:00

1098 lines
42 KiB
JavaScript

/**
* BetterDesk Support Generator — embedded local build worker
*
* Builds BetterDesk-Client with a signed Support payload embedded at compile
* time and packages artifacts under data/agent-builds/.
* The legacy template path remains available only when explicitly selected.
*/
'use strict';
const fs = require('fs');
const fsp = fs.promises;
const path = require('path');
const crypto = require('crypto');
const { spawn } = require('child_process');
const AdmZip = require('adm-zip');
const db = require('./database');
const bundleService = require('./agentBundleService');
const supportModule = require('./supportGeneratorModule');
const clientBuilder = require('./clientBuilderService');
const windowsSupportInstaller = require('./windowsSupportInstaller');
const customTxt = require('./customTxtBuilder');
const keyService = require('./keyService');
const conn = require('./agentBundleConnection');
const platformSupportInstaller = require('./platformSupportInstaller');
const config = require('../config/config');
const {
PRODUCT_TYPES,
normalizeProductType,
isQueuedBuildStatus,
} = require('../lib/generatorBuildTypes');
const ARTIFACT_ROOT = process.env.AGENT_ARTIFACT_DIR
|| path.join(config.dataDir || '/opt/BetterDeskConsole/data', 'agent-builds');
const WORK_ROOT = path.join(
config.dataDir || path.join(__dirname, '..', 'data'),
'build-cache',
'support-templates'
);
const POLL_INTERVAL_MS = parseInt(process.env.AGENT_BUILD_POLL_MS || '5000', 10);
const BUILD_COOLDOWN_MS = parseInt(process.env.AGENT_BUILD_COOLDOWN_MS || '1000', 10);
const IS_WINDOWS = process.platform === 'win32';
const EMBEDDED_MODE = String(
process.env.BETTERDESK_CLIENT_GENERATOR_MODE || 'embedded'
).trim().toLowerCase() === 'embedded';
let _pollHandle = null;
let _running = false;
let _activeBuilds = 0;
let _lastBuildFinishedAt = 0;
let _startupReady = false;
function _isSupportProduct(row) {
const pt = normalizeProductType(row?.product_type);
return pt === PRODUCT_TYPES.BETTERDESK_SUPPORT;
}
function _parseBranding(raw) {
if (!raw) return {};
if (typeof raw === 'object') return raw;
try { return JSON.parse(raw); } catch (_) { return {}; }
}
function _availablePlatforms() {
const all = bundleService.PLATFORMS || [];
if (!EMBEDDED_MODE) {
return all.filter((p) => p.format === 'portable' && (() => {
try {
return !!supportModule.resolveTemplateDir(p.platform, p.arch);
} catch (_) {
return false;
}
})());
}
const capabilities = clientBuilder.getBuildCapabilities();
const supported = new Set((capabilities.targets || []).map((p) => (
`${p.platform}/${p.arch}/${p.format}`
)));
return all.filter((p) => supported.has(`${p.platform}/${p.arch}/${p.format}`));
}
function _platformKey(platform) {
return `${platform.platform}/${platform.arch}/${platform.format}`;
}
function _platformStatusReason(platform, capabilities) {
if (!EMBEDDED_MODE) return 'template_missing';
if (capabilities.mode === 'remote') {
return capabilities.targets?.length
? 'provider_target_unavailable'
: 'provider_targets_missing';
}
if (!capabilities.toolchain?.ready) {
return capabilities.toolchain?.missing?.length
? `local_builder_toolchain_missing:${capabilities.toolchain.missing.join(',')}`
: 'local_builder_toolchain_missing';
}
const supported = new Set((capabilities.targets || []).map(_platformKey));
return supported.has(_platformKey(platform)) ? null : 'provider_host_only';
}
function getPlatformStatus() {
const all = bundleService.PLATFORMS || [];
const capabilities = EMBEDDED_MODE
? clientBuilder.getBuildCapabilities()
: { mode: 'legacy-template', targets: null, toolchain: { ready: true, missing: [] } };
const available = new Set(_availablePlatforms().map(_platformKey));
return all.map((platform) => ({
...platform,
available: available.has(_platformKey(platform)),
reason: available.has(_platformKey(platform))
? null
: _platformStatusReason(platform, capabilities),
provider: capabilities.mode,
}));
}
function _filterPlatforms(only) {
const all = _availablePlatforms();
if (only === null || only === undefined) return all;
if (!Array.isArray(only) || only.length === 0) return [];
return all.filter((p) => only.some((o) => (
String(o.platform || o.os || '') === p.platform
&& String(o.arch || 'x64') === p.arch
&& String(o.format || 'portable') === p.format
)));
}
function _buildError(code, details = []) {
const error = new Error(code);
error.code = code;
error.details = details;
return error;
}
function validatePlatformSelection(selection) {
const available = _availablePlatforms();
if (!available.length) {
return {
valid: false,
error: 'local_builder_no_supported_targets',
details: [],
available,
};
}
if (selection === null || selection === undefined) {
return { valid: true, selected: available, unsupported: [], available };
}
if (!Array.isArray(selection) || selection.length === 0) {
return {
valid: false,
error: 'platforms_required',
details: ['platforms_required'],
available,
};
}
const requested = selection.map((platform) => ({
platform: String(platform?.platform || platform?.os || ''),
arch: String(platform?.arch || 'x64'),
format: String(platform?.format || 'portable'),
}));
const availableKeys = new Set(available.map(_platformKey));
const requestedKeys = [...new Set(requested.map(_platformKey))];
const unsupported = requestedKeys.filter((key) => !availableKeys.has(key));
if (unsupported.length) {
return {
valid: false,
error: 'unsupported_platform',
details: unsupported,
unsupported,
available,
};
}
return {
valid: true,
selected: available.filter((platform) => requestedKeys.includes(_platformKey(platform))),
unsupported: [],
available,
};
}
function _hostPlatformKey() {
const platform = process.platform === 'win32'
? 'windows'
: process.platform === 'darwin' ? 'macos' : 'linux';
const arch = process.arch === 'arm64' ? 'arm64' : 'x64';
return `${platform}/${arch}/portable`;
}
async function enqueueBuildsForHash(brandingHash, { force = false, platforms: onlyPlatforms = null } = {}) {
if (!brandingHash) throw new Error('brandingHash required');
if (EMBEDDED_MODE && !clientBuilder.isReady()) {
throw _buildError('betterdesk_client_builder_not_ready');
}
if (!EMBEDDED_MODE && !supportModule.isReady()) {
throw _buildError('support_generator_module_not_ready');
}
if (!EMBEDDED_MODE && !supportModule.templatesHaveBinaries()) {
throw new Error(
'support_generator_templates_incomplete: installed templates have no BetterDesk desktop binaries. '
+ 'Publish a full Client release with generator-templates, then reinstall the module.'
);
}
const platformSelection = validatePlatformSelection(onlyPlatforms);
if (!platformSelection.valid) {
throw _buildError(platformSelection.error, platformSelection.details);
}
const platforms = platformSelection.selected;
if (!platforms.length) {
throw _buildError('local_builder_no_supported_targets');
}
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: '',
});
}
}
async function rebuildBundleById(bundleId, { platforms: onlyPlatforms = null } = {}) {
const row = await db.getAgentBundle(bundleId);
if (!row) return { success: false, error: 'not_found' };
if (!_isSupportProduct(row)) return { success: false, error: 'not_betterdesk_support' };
if (!row.branding_hash) return { success: false, error: 'missing_hash' };
const platforms = _filterPlatforms(onlyPlatforms);
await enqueueBuildsForHash(row.branding_hash, { force: true, platforms });
return { success: true, platforms: platforms.length, brandingHash: row.branding_hash };
}
/**
* Prepare a new Client generation for every non-revoked Support config.
* Existing active_generation_hash values are deliberately left untouched
* until all target builds for the new generation are ready.
*/
async function rebuildAllForClientRevision(clientCommit, { force = false } = {}) {
const commit = String(clientCommit || '').trim();
if (!/^[a-f0-9]{40}$/i.test(commit)) {
throw new Error('betterdesk_client_commit_invalid');
}
const bundles = await db.listAgentBundles();
const queued = [];
for (const bundle of bundles || []) {
if (bundle.revoked || !_isSupportProduct(bundle)) continue;
const configFingerprint = bundle.config_fingerprint || bundle.branding_hash;
const generationHash = clientBuilder.generationKey(configFingerprint, commit);
if (!force && bundle.branding_hash === generationHash) continue;
await db.prepareAgentBundleGeneration(bundle.bundle_id, {
brandingHash: generationHash,
configFingerprint,
clientCommit: commit,
});
await enqueueBuildsForHash(generationHash, { force, platforms: null });
queued.push({
bundleId: bundle.bundle_id,
configFingerprint,
brandingHash: generationHash,
});
}
return { clientCommit: commit, queued };
}
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 available = _availablePlatforms().some(
(p) => p.platform === platform && p.arch === arch && p.format === format
);
if (!available) return { success: false, error: 'unsupported_platform' };
await db.upsertAgentBundleBuild({
brandingHash,
platform,
arch,
format,
status: 'queued',
artifactPath: null,
artifactSize: 0,
artifactSha256: null,
errorMessage: '',
});
return { success: true, brandingHash };
}
function getBuildWorkerStatus() {
const moduleStatus = (() => {
try {
return {
ready: supportModule.isReady(),
templatesPresent: supportModule.templatesExist(),
};
} catch (_) {
return { ready: false, templatesPresent: false };
}
})();
return {
workerEnabled: process.env.AGENT_BUILD_WORKER !== 'off',
kind: 'client-template',
mode: EMBEDDED_MODE ? 'embedded' : 'legacy-custom-txt',
clientBuilderReady: EMBEDDED_MODE ? clientBuilder.isReady() : false,
moduleReady: moduleStatus.ready,
templatesPresent: moduleStatus.templatesPresent,
templatesDir: supportModule.templatesDir(),
artifactRoot: ARTIFACT_ROOT,
platforms: _availablePlatforms().map((p) => ({
platform: p.platform,
arch: p.arch,
format: p.format,
label: p.label,
})),
capabilities: EMBEDDED_MODE ? clientBuilder.getBuildCapabilities() : null,
platformCatalog: getPlatformStatus(),
buildReady: EMBEDDED_MODE
? !!clientBuilder.getBuildReadiness().buildReady
: !!moduleStatus.ready,
activeBuilds: _activeBuilds,
startupReady: _startupReady,
};
}
function _isArtifactPathSafe(filePath) {
if (!filePath) return false;
const root = path.resolve(ARTIFACT_ROOT);
const candidate = path.resolve(String(filePath));
const relative = path.relative(root, candidate);
return !!relative && !relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative);
}
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;
if (!_isArtifactPathSafe(row.artifact_path)) return null;
try {
await fsp.access(row.artifact_path, fs.constants.R_OK);
} catch {
return null;
}
return row;
}
async function recoverInterruptedBuilds() {
const bundles = await db.listAgentBundles({ includeRevoked: true });
let recovered = 0;
for (const bundle of bundles || []) {
const builds = await db.listAgentBundleBuildsForHash(bundle.branding_hash);
for (const build of builds || []) {
if (build.status !== 'building') continue;
await db.upsertAgentBundleBuild({
brandingHash: build.branding_hash,
platform: build.platform,
arch: build.arch,
format: build.format,
status: 'queued',
artifactPath: null,
artifactSize: 0,
artifactSha256: null,
errorMessage: 'requeued_after_worker_restart',
clientCommit: build.client_commit || bundle.client_commit || null,
generationId: build.generation_id || null,
configFingerprint: build.config_fingerprint || bundle.config_fingerprint || null,
progressPercent: 0,
progressPhase: 'requeued_after_restart',
});
recovered++;
}
}
if (recovered) {
console.log(`[clientTemplateWorker] recovered ${recovered} interrupted build(s)`);
}
}
function startWorker() {
if (_pollHandle) return;
console.log(`[clientTemplateWorker] templates=${supportModule.templatesDir()}`);
_startupReady = false;
_pollHandle = setInterval(() => {
_tick().catch((e) => console.error('[clientTemplateWorker] tick error:', e.message));
}, POLL_INTERVAL_MS);
recoverInterruptedBuilds()
.catch((err) => console.error('[clientTemplateWorker] recovery error:', err.message))
.finally(() => {
_startupReady = true;
_tick().catch(() => {});
});
console.log(`[clientTemplateWorker] started (poll ${POLL_INTERVAL_MS}ms)`);
}
function stopWorker() {
if (_pollHandle) {
clearInterval(_pollHandle);
_pollHandle = null;
}
_startupReady = false;
}
async function _tick() {
if (!_startupReady) return;
if (_running || _activeBuilds > 0) return;
if (BUILD_COOLDOWN_MS > 0 && Date.now() - _lastBuildFinishedAt < BUILD_COOLDOWN_MS) return;
_running = true;
try {
const claimed = await _claimNextBuild();
if (!claimed) return;
_activeBuilds++;
try {
await _runOne(claimed);
} catch (e) {
console.error('[clientTemplateWorker] build crashed:', e);
} finally {
_activeBuilds--;
_lastBuildFinishedAt = Date.now();
}
} finally {
_running = false;
}
}
async function _findBundleForHash(brandingHash) {
const bundles = await db.listAgentBundles({ includeRevoked: true });
return (bundles || []).find((b) => b.branding_hash === brandingHash) || null;
}
async function _listPendingBuilds(limit = 50) {
const bundles = await db.listAgentBundles();
const out = [];
for (const b of bundles || []) {
if (b.revoked || !_isSupportProduct(b)) continue;
const builds = await db.listAgentBundleBuildsForHash(b.branding_hash);
for (const row of builds || []) {
if (isQueuedBuildStatus(row.status)) out.push(row);
if (out.length >= limit) return out;
}
}
return out;
}
async function _claimNextBuild() {
if (EMBEDDED_MODE ? !clientBuilder.isReady() : !supportModule.isReady()) return null;
const candidates = await _listPendingBuilds(50);
const hostKey = _hostPlatformKey();
candidates.sort((a, b) => {
const aHost = `${a.platform}/${a.arch}/${a.format}` === hostKey ? 0 : 1;
const bHost = `${b.platform}/${b.arch}/${b.format}` === hostKey ? 0 : 1;
return aHost - bHost
|| String(a.created_at || '').localeCompare(String(b.created_at || ''));
});
for (const row of candidates) {
const bundleRow = await _findBundleForHash(row.branding_hash);
if (!bundleRow || !_isSupportProduct(bundleRow)) continue;
if (!EMBEDDED_MODE && !supportModule.resolveTemplateDir(row.platform, row.arch)) {
await db.upsertAgentBundleBuild({
brandingHash: row.branding_hash,
platform: row.platform,
arch: row.arch,
format: row.format,
status: 'failed',
artifactPath: null,
artifactSize: 0,
artifactSha256: null,
errorMessage: `template_missing:${row.platform}/${row.arch}`,
});
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, _bundle: bundleRow };
}
return null;
}
function _buildApiServer(branding) {
if (branding.api_server) return String(branding.api_server).trim();
const host = branding.server_host || conn.defaultServerHost();
const useHttps = branding.use_https ?? true;
const port = String(branding.api_port || conn.defaultApiPort());
const scheme = useHttps ? 'https' : 'http';
const omit = (scheme === 'https' && port === '443') || (scheme === 'http' && port === '80');
return omit ? `${scheme}://${host}` : `${scheme}://${host}:${port}`;
}
async function _buildCustomTxtContent(branding) {
const host = branding.server_host || conn.defaultServerHost();
const key = branding.public_key
|| branding.server_key
|| branding.server?.public_key
|| (await keyService.resolvePublicKey())
|| '';
const built = customTxt.buildAndSignSupportCustomTxt({
appName: branding.app_name || branding.company_name || branding.product_name || 'BetterDesk Support Agent',
host,
relay: branding.relay_host || branding.relay_server || host,
api: _buildApiServer(branding),
key,
disableSettings: branding.disable_settings !== false,
}, supportModule.getSigningSeedBase64());
return built;
}
async function _copyDir(src, dest) {
await fsp.cp(src, dest, { recursive: true });
}
function _findCustomTxtTarget(stageDir, platform) {
if (String(platform).toLowerCase() === 'macos') {
const marker = _findFile(stageDir, '.custom-txt-here');
if (marker) return path.dirname(marker);
const app = _findDirEnding(stageDir, '.app');
if (app) {
const macos = path.join(app, 'Contents', 'MacOS');
if (fs.existsSync(macos)) return macos;
}
const contentsMac = _findDirNamed(stageDir, 'MacOS');
if (contentsMac) return contentsMac;
}
const marker = path.join(stageDir, '.custom-txt-here');
if (fs.existsSync(marker)) return stageDir;
return stageDir;
}
function _findFile(root, name) {
const stack = [root];
while (stack.length) {
const dir = stack.pop();
let entries;
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch (_) { continue; }
for (const e of entries) {
const full = path.join(dir, e.name);
if (e.isDirectory()) stack.push(full);
else if (e.name === name) return full;
}
}
return null;
}
function _findDirEnding(root, suffix) {
const stack = [root];
while (stack.length) {
const dir = stack.pop();
let entries;
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch (_) { continue; }
for (const e of entries) {
if (!e.isDirectory()) continue;
const full = path.join(dir, e.name);
if (e.name.endsWith(suffix)) return full;
stack.push(full);
}
}
return null;
}
function _findDirNamed(root, name) {
const stack = [root];
while (stack.length) {
const dir = stack.pop();
let entries;
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch (_) { continue; }
for (const e of entries) {
if (!e.isDirectory()) continue;
const full = path.join(dir, e.name);
if (e.name === name) return full;
stack.push(full);
}
}
return null;
}
function _runTar(args, cwd) {
return new Promise((resolve, reject) => {
const child = spawn('tar', args, { cwd, stdio: ['ignore', 'pipe', 'pipe'] });
let stderr = '';
child.stderr.on('data', (d) => { stderr += d.toString(); });
child.on('error', reject);
child.on('close', (code) => {
if (code === 0) resolve();
else reject(new Error(`tar failed (${code}): ${stderr.trim() || 'unknown'}`));
});
});
}
async function _packArtifact(stageDir, outPath, platform) {
await fsp.mkdir(path.dirname(outPath), { recursive: true });
if (fs.existsSync(outPath)) await fsp.unlink(outPath);
if (String(platform).toLowerCase() === 'windows' || outPath.endsWith('.zip')) {
const zip = new AdmZip();
zip.addLocalFolder(stageDir, path.basename(stageDir));
zip.writeZip(outPath);
return;
}
// tar.gz — archive the stage directory contents under a single top-level folder
const parent = path.dirname(stageDir);
const base = path.basename(stageDir);
await _runTar(['-czf', outPath, base], parent);
}
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')));
});
}
function _templateHasBinary(stageDir, platform) {
const p = String(platform || '').toLowerCase();
if (p === 'macos') {
if (_findDirEnding(stageDir, '.app')) return true;
}
const names = new Set(['betterdesk.exe', 'rustdesk.exe', 'betterdesk', 'rustdesk']);
const stack = [stageDir];
while (stack.length) {
const dir = stack.pop();
let entries;
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch (_) { continue; }
for (const e of entries) {
const full = path.join(dir, e.name);
if (e.isDirectory()) {
if (e.name.endsWith('.app')) return true;
stack.push(full);
} else if (names.has(e.name) || names.has(e.name.toLowerCase())) {
try {
if (fs.statSync(full).size > 100 * 1024) return true;
} catch (_) { /* ignore */ }
}
}
}
return false;
}
async function _runOne(buildRow) {
if (EMBEDDED_MODE) {
return _runEmbeddedOne(buildRow);
}
const key = `${buildRow.platform}/${buildRow.arch}/${buildRow.format}`;
const startTs = Date.now();
console.log(`[clientTemplateWorker] build start hash=${String(buildRow.branding_hash).slice(0, 12)} ${key}`);
const workDir = path.join(WORK_ROOT, `${buildRow.branding_hash.slice(0, 16)}_${buildRow.platform}_${buildRow.arch}`);
try {
await fsp.rm(workDir, { recursive: true, force: true });
await fsp.mkdir(workDir, { recursive: true });
await fsp.mkdir(ARTIFACT_ROOT, { recursive: true });
const templateDir = supportModule.resolveTemplateDir(buildRow.platform, buildRow.arch);
if (!templateDir) throw new Error(`template_missing:${key}`);
const stageDir = path.join(workDir, `betterdesk-support-${buildRow.platform}-${buildRow.arch}`);
await _copyDir(templateDir, stageDir);
if (!_templateHasBinary(stageDir, buildRow.platform)) {
throw new Error(
`template_missing_binary:${key} — installed templates are a stub without betterdesk.exe / binary. `
+ 'Publish a full BetterDesk-Client release (workflow betterdesk-desktop-release.yml) '
+ 'with generator-templates containing desktop binaries, then reinstall the module.'
);
}
const branding = _parseBranding(buildRow._bundle?.branding);
const { content } = await _buildCustomTxtContent(branding);
const injectDir = _findCustomTxtTarget(stageDir, buildRow.platform);
await fsp.mkdir(injectDir, { recursive: true });
await fsp.writeFile(path.join(injectDir, 'custom.txt'), content, 'utf8');
// Drop helper markers from shipped artifacts
try { await fsp.unlink(path.join(stageDir, '.custom-txt-here')); } catch (_) { /* ok */ }
const nestedMarker = _findFile(stageDir, '.custom-txt-here');
if (nestedMarker) {
try { await fsp.unlink(nestedMarker); } catch (_) { /* ok */ }
}
if (buildRow.platform === 'windows') {
await windowsSupportInstaller.writeWindowsSupportInstallers(stageDir, {
installService: branding.install_service,
autostart: branding.autostart,
});
} else {
await platformSupportInstaller.writeUnixSupportInstallers(stageDir, buildRow.platform, {
installService: branding.install_service,
autostart: branding.autostart,
});
}
const ext = buildRow.platform === 'windows' ? 'zip' : 'tar.gz';
const artifactName = `betterdesk-support-${buildRow.branding_hash.slice(0, 12)}-${buildRow.platform}-${buildRow.arch}.${ext}`;
const artifactPath = path.join(ARTIFACT_ROOT, artifactName);
await _packArtifact(stageDir, artifactPath, buildRow.platform);
const stat = await fsp.stat(artifactPath);
const sha = await _sha256OfFile(artifactPath);
await db.upsertAgentBundleBuild({
brandingHash: buildRow.branding_hash,
platform: buildRow.platform,
arch: buildRow.arch,
format: buildRow.format,
status: 'ready',
artifactPath,
artifactSize: stat.size,
artifactSha256: sha,
errorMessage: '',
});
console.log(
`[clientTemplateWorker] build ready ${key} signed=true`
+ ` (${(stat.size / 1024 / 1024).toFixed(2)} MB, ${((Date.now() - startTs) / 1000).toFixed(1)}s)`
);
} catch (err) {
const msg = err.message || String(err);
console.error(`[clientTemplateWorker] 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.slice(0, 2000),
});
} finally {
try { await fsp.rm(workDir, { recursive: true, force: true }); } catch (_) { /* ok */ }
}
}
function _artifactExtension(platform, format) {
if (String(platform).toLowerCase() === 'windows') return '.zip';
if (format === 'deb') return '.deb';
if (format === 'rpm') return '.rpm';
if (format === 'arch') return '.pkg.tar.zst';
return '.tar.gz';
}
function _findManifestArtifact(root, manifest, platform, arch, format = 'portable') {
const providerArch = {
x64: 'x86_64',
amd64: 'x86_64',
arm64: 'aarch64',
}[String(arch).toLowerCase()] || String(arch).toLowerCase();
const target = `${String(platform).toLowerCase()}/${providerArch}/${format}`;
const assets = (manifest?.assets || []).filter((asset) => (
`${String(asset.platform || '').toLowerCase()}/${String(asset.arch || '').toLowerCase()}/${String(asset.format || 'portable').toLowerCase()}` === target
));
if (assets.length !== 1) {
throw new Error(`embedded_manifest_asset_target_count_invalid:${platform}/${arch}`);
}
const assetName = assets[0].name;
const candidate = path.resolve(root, assetName);
const relative = path.relative(path.resolve(root), candidate);
const expectedExt = _artifactExtension(platform, format);
if (
!relative
|| relative.startsWith(`..${path.sep}`)
|| relative === '..'
|| path.isAbsolute(relative)
|| !candidate.toLowerCase().endsWith(expectedExt)
|| !fs.existsSync(candidate)
|| !fs.statSync(candidate).isFile()
) {
throw new Error(`embedded_manifest_asset_invalid:${assetName}`);
}
return candidate;
}
function _failedPlatformsForError(message, buildRow) {
const localSingleTarget = EMBEDDED_MODE
&& clientBuilder.getBuildCapabilities().mode === 'local';
return localSingleTarget || String(message).startsWith('local_builder')
? [{
platform: buildRow.platform,
arch: buildRow.arch,
format: buildRow.format,
}]
: bundleService.PLATFORMS;
}
async function _assertEmbeddedArtifact(artifactPath, format = 'portable') {
if (artifactPath.endsWith('.zip')) {
const zip = new AdmZip(artifactPath);
for (const entry of zip.getEntries()) {
const name = String(entry.entryName || '').replace(/\\/g, '/').toLowerCase();
if (name.endsWith('/custom.txt') || name.endsWith('/.custom-txt-here')) {
throw new Error('embedded_artifact_contains_external_config');
}
}
return;
}
const command = format === 'deb' ? 'dpkg-deb'
: format === 'rpm' ? 'rpm'
: 'tar';
const args = format === 'deb' ? ['-c', artifactPath]
: format === 'rpm' ? ['-qlp', artifactPath]
: format === 'arch' ? ['--zstd', '-tf', artifactPath]
: ['-tzf', artifactPath];
const listing = await new Promise((resolve, reject) => {
const child = spawn(command, args, {
cwd: path.dirname(artifactPath),
stdio: ['ignore', 'pipe', 'pipe'],
windowsHide: true,
});
let output = '';
let error = '';
child.stdout.on('data', (chunk) => { output += chunk.toString(); });
child.stderr.on('data', (chunk) => { error += chunk.toString(); });
child.on('error', reject);
child.on('close', (code) => code === 0
? resolve(output)
: reject(new Error(`embedded_artifact_list_failed:${error.trim() || code}`)));
});
for (const line of listing.split(/\r?\n/)) {
const name = line.replace(/\\/g, '/').toLowerCase();
if (name.endsWith('/custom.txt') || name.endsWith('/.custom-txt-here')) {
throw new Error('embedded_artifact_contains_external_config');
}
}
}
async function _validateEmbeddedManifest(outputDir, buildRow, requiredPlatforms = []) {
const manifestPath = path.join(outputDir, 'manifest.json');
let manifest;
try {
manifest = JSON.parse(await fsp.readFile(manifestPath, 'utf8'));
} catch (err) {
throw new Error(`embedded_manifest_invalid:${err.message || String(err)}`);
}
if (manifest.schema_version !== 1 || manifest.sku !== 'betterdesk-support') {
throw new Error('embedded_manifest_contract_invalid');
}
if (manifest.external_config !== false) {
throw new Error('embedded_manifest_external_config');
}
if (
buildRow._bundle?.client_commit
&& String(manifest.client_commit || '').toLowerCase()
!== String(buildRow._bundle.client_commit).toLowerCase()
) {
throw new Error('embedded_manifest_client_commit_mismatch');
}
if (!Array.isArray(manifest.assets) || manifest.assets.length === 0) {
throw new Error('embedded_manifest_assets_missing');
}
const validTargets = new Set((bundleService.PLATFORMS || []).map((platform) => (
`${platform.platform}/${platform.arch === 'x64' ? 'x86_64' : 'aarch64'}/${platform.format}`
)));
const names = new Set();
const targetKeys = new Set();
for (const asset of manifest.assets) {
if (
!asset
|| typeof asset.name !== 'string'
|| !/^[a-zA-Z0-9._-]+$/.test(asset.name)
|| names.has(asset.name)
|| typeof asset.platform !== 'string'
|| typeof asset.arch !== 'string'
|| typeof asset.format !== 'string'
) {
throw new Error('embedded_manifest_asset_invalid');
}
names.add(asset.name);
const targetKey = `${asset.platform.toLowerCase()}/${asset.arch.toLowerCase()}/${asset.format.toLowerCase()}`;
if (!validTargets.has(targetKey) || targetKeys.has(targetKey)) {
throw new Error(`embedded_manifest_asset_target_invalid:${targetKey}`);
}
targetKeys.add(targetKey);
if (!/^[a-f0-9]{64}$/i.test(String(asset.sha256 || ''))) {
throw new Error(`embedded_manifest_asset_checksum_missing:${asset.name}`);
}
if (!Number.isSafeInteger(asset.size) || asset.size < 1) {
throw new Error(`embedded_manifest_asset_size_invalid:${asset.name}`);
}
const assetPath = path.join(outputDir, asset.name);
if (!fs.existsSync(assetPath) || !fs.statSync(assetPath).isFile()) {
throw new Error(`embedded_manifest_asset_missing:${asset.name}`);
}
const stat = fs.statSync(assetPath);
if (stat.size !== asset.size) {
throw new Error(`embedded_manifest_asset_size_mismatch:${asset.name}`);
}
const actual = await _sha256OfFile(assetPath);
if (actual.toLowerCase() !== String(asset.sha256).toLowerCase()) {
throw new Error(`embedded_manifest_asset_checksum_mismatch:${asset.name}`);
}
}
for (const platform of requiredPlatforms) {
const targetKey = `${platform.platform}/${platform.arch === 'x64' ? 'x86_64' : 'aarch64'}/${platform.format}`;
if (!targetKeys.has(targetKey)) {
throw new Error(`embedded_manifest_required_target_missing:${targetKey}`);
}
}
return manifest;
}
async function _runEmbeddedOne(buildRow) {
const key = `${buildRow.platform}/${buildRow.arch}/${buildRow.format}`;
const startTs = Date.now();
const generationId = buildRow.branding_hash.slice(0, 48);
try {
await db.upsertAgentBundleBuild({
brandingHash: buildRow.branding_hash,
platform: buildRow.platform,
arch: buildRow.arch,
format: buildRow.format,
status: 'building',
artifactPath: null,
artifactSize: 0,
artifactSha256: null,
errorMessage: '',
clientCommit: buildRow._bundle?.client_commit || null,
generationId,
configFingerprint: buildRow._bundle?.config_fingerprint || null,
progressPercent: 0,
progressPhase: 'preparing',
});
const branding = _parseBranding(buildRow._bundle?.branding);
const { content } = await _buildCustomTxtContent(branding);
const buildPlatforms = _availablePlatforms();
if (!buildPlatforms.length) {
throw new Error('local_builder_no_supported_targets');
}
const result = await clientBuilder.runBuild({
schema_version: 1,
product_sku: 'betterdesk-support',
generationId,
clientCommit: buildRow._bundle?.client_commit || null,
configFingerprint: buildRow._bundle?.config_fingerprint || null,
targets: buildPlatforms.map((platform) => ({
platform: platform.platform,
arch: platform.arch,
format: platform.format,
})),
signed_config_b64: content,
}, {
onProgress: async ({ percent, phase }) => {
await db.upsertAgentBundleBuild({
brandingHash: buildRow.branding_hash,
platform: buildRow.platform,
arch: buildRow.arch,
format: buildRow.format,
status: 'building',
artifactPath: null,
artifactSize: 0,
artifactSha256: null,
errorMessage: '',
clientCommit: buildRow._bundle?.client_commit || null,
generationId,
configFingerprint: buildRow._bundle?.config_fingerprint || null,
progressPercent: percent,
progressPhase: phase,
});
},
});
const manifest = await _validateEmbeddedManifest(result.outputDir, buildRow, buildPlatforms);
await fsp.mkdir(ARTIFACT_ROOT, { recursive: true });
const availableTargets = new Set((manifest.assets || []).map((asset) => (
`${asset.platform}/${asset.arch}/${asset.format || 'portable'}`
)));
for (const platform of buildPlatforms) {
const targetKey = `${platform.platform}/${platform.arch === 'x64' ? 'x86_64' : 'aarch64'}/${platform.format}`;
if (!availableTargets.has(targetKey)) {
throw new Error(`embedded_build_target_missing:${targetKey}`);
}
const sourceArtifact = _findManifestArtifact(
result.outputDir,
manifest,
platform.platform,
platform.arch,
platform.format
);
await _assertEmbeddedArtifact(sourceArtifact, platform.format);
const ext = _artifactExtension(platform.platform, platform.format).slice(1);
const artifactPath = path.join(
ARTIFACT_ROOT,
`betterdesk-support-${buildRow.branding_hash.slice(0, 12)}-${platform.platform}-${platform.arch}.${ext}`
);
const temporaryArtifact = `${artifactPath}.tmp-${process.pid}-${Date.now()}`;
await fsp.copyFile(sourceArtifact, temporaryArtifact);
await fsp.rename(temporaryArtifact, artifactPath);
const stat = await fsp.stat(artifactPath);
const sha = await _sha256OfFile(artifactPath);
await db.upsertAgentBundleBuild({
brandingHash: buildRow.branding_hash,
platform: platform.platform,
arch: platform.arch,
format: platform.format,
status: 'ready',
artifactPath,
artifactSize: stat.size,
artifactSha256: sha,
errorMessage: '',
clientCommit: buildRow._bundle?.client_commit || null,
generationId,
configFingerprint: buildRow._bundle?.config_fingerprint || null,
progressPercent: 100,
progressPhase: 'complete',
});
}
await _promoteIfComplete(buildRow.branding_hash);
console.log(
`[clientTemplateWorker] embedded build ready ${key}`
+ ` (${((Date.now() - startTs) / 1000).toFixed(1)}s)`
);
} catch (err) {
const msg = err.message || String(err);
console.error(`[clientTemplateWorker] embedded build FAILED ${key}: ${msg}`);
const failedPlatforms = _failedPlatformsForError(msg, buildRow);
await Promise.all(failedPlatforms.map((platform) => (
db.upsertAgentBundleBuild({
brandingHash: buildRow.branding_hash,
platform: platform.platform,
arch: platform.arch,
format: platform.format,
status: 'failed',
artifactPath: null,
artifactSize: 0,
artifactSha256: null,
errorMessage: msg.slice(0, 2000),
clientCommit: buildRow._bundle?.client_commit || null,
generationId,
configFingerprint: buildRow._bundle?.config_fingerprint || null,
progressPercent: 0,
progressPhase: 'failed',
})
)));
}
}
async function _promoteIfComplete(brandingHash) {
const builds = await db.listAgentBundleBuildsForHash(brandingHash);
const required = _availablePlatforms().map((p) => (
`${p.platform}/${p.arch}/${p.format}`
));
const ready = new Set((builds || [])
.filter((row) => row.status === 'ready')
.map((row) => `${row.platform}/${row.arch}/${row.format}`));
if (required.every((key) => ready.has(key))) {
await db.promoteAgentBundleGeneration(brandingHash);
}
}
module.exports = {
enqueueBuildsForHash,
rebuildBundleById,
rebuildAllForClientRevision,
requeuePlatformBuild,
startWorker,
stopWorker,
getBuildWorkerStatus,
getAvailablePlatforms: _availablePlatforms,
getPlatformStatus,
validatePlatformSelection,
getReadyArtifact,
_internals: {
isSupportProduct: _isSupportProduct,
buildCustomTxtContent: _buildCustomTxtContent,
findCustomTxtTarget: _findCustomTxtTarget,
filterPlatforms: _filterPlatforms,
platformStatus: getPlatformStatus,
findManifestArtifact: _findManifestArtifact,
validateEmbeddedManifest: _validateEmbeddedManifest,
failedPlatformsForError: _failedPlatformsForError,
hasWindowsSupportInstallers: windowsSupportInstaller.hasWindowsSupportInstallers,
isArtifactPathSafe: _isArtifactPathSafe,
ARTIFACT_ROOT,
WORK_ROOT,
IS_WINDOWS,
},
};