mirror of
https://github.com/UNITRONIX/BetterDesk.git
synced 2026-09-10 17:45:42 +00:00
a070eb0bc3
Reject stub-only template packages, hide legacy CDAP bundles, and fix the slug pattern / builds title i18n.
539 lines
19 KiB
JavaScript
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,
|
|
},
|
|
};
|