Files
UNITRONIX a070eb0bc3 Fix Support Generator builds and UI validation
Reject stub-only template packages, hide legacy CDAP bundles, and fix the slug pattern / builds title i18n.
2026-09-07 06:55:25 +02:00

539 lines
19 KiB
JavaScript

/**
* BetterDesk Support Generator — template worker
*
* Patches portable desktop templates from the Support Generator module with
* a signed/plain custom.txt and packages artifacts under data/agent-builds/.
* Replaces agentBuildWorker for product_type=betterdesk-support.
*/
'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 customTxt = require('./customTxtBuilder');
const keyService = require('./keyService');
const conn = require('./agentBundleConnection');
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';
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 _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 || 'portable') === p.format
)));
return filtered.length ? filtered : all;
}
async function enqueueBuildsForHash(brandingHash, { force = false, platforms: onlyPlatforms = null } = {}) {
if (!brandingHash) throw new Error('brandingHash required');
if (!supportModule.isReady()) {
throw new Error('support_generator_module_not_ready');
}
if (!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 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: '',
});
}
}
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 };
}
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' };
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',
moduleReady: moduleStatus.ready,
templatesPresent: moduleStatus.templatesPresent,
templatesDir: supportModule.templatesDir(),
artifactRoot: ARTIFACT_ROOT,
platforms: (bundleService.PLATFORMS || []).map((p) => ({
platform: p.platform,
arch: p.arch,
format: p.format,
label: p.label,
})),
activeBuilds: _activeBuilds,
startupReady: _startupReady,
};
}
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;
}
function startWorker() {
if (_pollHandle) return;
console.log(`[clientTemplateWorker] templates=${supportModule.templatesDir()}`);
_startupReady = true;
_pollHandle = setInterval(() => {
_tick().catch((e) => console.error('[clientTemplateWorker] tick error:', e.message));
}, POLL_INTERVAL_MS);
_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 (!supportModule.isReady()) return null;
const candidates = await _listPendingBuilds(50);
candidates.sort((a, b) => 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 (!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) {
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, signed } = await _buildCustomTxtContent(branding);
if (!signed) {
console.warn(
'[clientTemplateWorker] custom.txt is unsigned (Phase A). '
+ 'Set BETTERDESK_CUSTOM_CLIENT_SIGNING_SEED for signed bake-in.'
);
}
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 */ }
}
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=${signed}`
+ ` (${(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 */ }
}
}
module.exports = {
enqueueBuildsForHash,
rebuildBundleById,
requeuePlatformBuild,
startWorker,
stopWorker,
getBuildWorkerStatus,
getReadyArtifact,
_internals: {
isSupportProduct: _isSupportProduct,
buildCustomTxtContent: _buildCustomTxtContent,
findCustomTxtTarget: _findCustomTxtTarget,
filterPlatforms: _filterPlatforms,
ARTIFACT_ROOT,
WORK_ROOT,
IS_WINDOWS,
},
};