Files
BetterDesk/web-nodejs/services/clientBuilderService.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

943 lines
33 KiB
JavaScript

/**
* BetterDesk-Client source synchronisation and embedded-build provider.
*
* This service deliberately keeps the Client repository outside the
* BetterDesk checkout. It only stores a pinned source snapshot and invokes an
* built-in local build provider (or an explicitly configured replacement)
* with a request file. The provider is responsible for producing platform
* artifacts with the signed Support payload compiled into the binary.
*/
'use strict';
const fs = require('fs');
const fsp = fs.promises;
const path = require('path');
const crypto = require('crypto');
const https = require('https');
const http = require('http');
const { spawn } = require('child_process');
const AdmZip = require('adm-zip');
const nacl = require('tweetnacl');
const config = require('../config/config');
function loadBuildEnvironment() {
const envFile = process.env.BETTERDESK_BUILD_ENV_FILE || '/etc/betterdesk/build.env';
try {
if (!fs.existsSync(envFile)) return;
const text = fs.readFileSync(envFile, 'utf8');
for (const line of text.split(/\r?\n/)) {
const match = /^\s*([A-Z_][A-Z0-9_]*)\s*=\s*(.*?)\s*$/.exec(line);
if (!match) continue;
const [, key, value] = match;
if (process.env[key] === undefined) process.env[key] = value;
}
} catch (err) {
console.warn('[clientBuilderService] could not load build env file:', err.message);
}
}
loadBuildEnvironment();
const localClientBuilder = require('./localClientBuilder');
const DEFAULT_REPO = 'UNITRONIX/BetterDesk-Client';
const DEFAULT_REF = 'master';
const MAX_RESPONSE_BYTES = 64 * 1024 * 1024;
const PROVIDER_TARGETS_ENV = 'BETTERDESK_CLIENT_PROVIDER_TARGETS_JSON';
const PROVIDER_TARGETS_FILE_ENV = 'BETTERDESK_CLIENT_PROVIDER_TARGETS_FILE';
const PROVIDER_TARGETS = new Set([
'windows/x64/portable',
'windows/arm64/portable',
'linux/x64/portable',
'linux/x64/deb',
'linux/x64/rpm',
'linux/x64/arch',
'linux/arm64/portable',
'linux/arm64/deb',
'linux/arm64/rpm',
'linux/arm64/arch',
'macos/x64/portable',
'macos/arm64/portable',
]);
const BUILD_TIMEOUT_MS = Math.max(
60_000,
Number.parseInt(process.env.BETTERDESK_CLIENT_BUILD_TIMEOUT_MS || '3600000', 10)
);
function dataRoot() {
return path.join(
config.dataDir || path.join(__dirname, '..', 'data'),
'modules',
'betterdesk-client-builder'
);
}
function sourceRoot() {
return path.join(dataRoot(), 'source');
}
function statePath() {
return path.join(dataRoot(), 'state.json');
}
function requestRoot() {
return path.join(dataRoot(), 'requests');
}
function signingSeedPath() {
return require('./supportGeneratorModule').signingSeedPath();
}
function signingPublicKeyPath() {
return path.join(dataRoot(), 'custom-client-signing.pub');
}
function outputRoot() {
return path.join(dataRoot(), 'outputs');
}
function normalizeRepo(value) {
const repo = String(value || process.env.BETTERDESK_CLIENT_REPO || DEFAULT_REPO).trim();
if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repo)) {
throw new Error('betterdesk_client_repo_invalid');
}
const allowed = String(
process.env.BETTERDESK_CLIENT_ALLOWED_REPOS || DEFAULT_REPO
).split(',').map((entry) => entry.trim()).filter(Boolean);
if (!allowed.includes(repo)) {
throw new Error('betterdesk_client_repo_not_allowed');
}
return repo;
}
function clientRef(value) {
const ref = String(value || process.env.BETTERDESK_CLIENT_REF || DEFAULT_REF).trim();
if (!ref || ref.length > 200 || /[\0\r\n]/.test(ref)) {
throw new Error('betterdesk_client_ref_invalid');
}
return ref;
}
function decodeSigningSeed(value) {
const text = String(value || '').replace(/\s+/g, '');
if (!/^[A-Za-z0-9+/]+={0,2}$/.test(text)) return null;
try {
const seed = Buffer.from(text, 'base64');
return seed.length === 32 ? seed : null;
} catch (_) {
return null;
}
}
function readStoredSigningSeed() {
try {
return decodeSigningSeed(fs.readFileSync(signingSeedPath(), 'utf8'));
} catch (_) {
return null;
}
}
function writeSigningFile(filePath, value, mode) {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
const temporary = `${filePath}.tmp-${process.pid}-${Date.now()}`;
fs.writeFileSync(temporary, `${value}\n`, { encoding: 'utf8', mode });
try { fs.chmodSync(temporary, mode); } catch (_) { /* Windows */ }
try { if (fs.existsSync(filePath)) fs.unlinkSync(filePath); } catch (_) { /* replace below */ }
fs.renameSync(temporary, filePath);
}
/**
* Generate an instance-specific signing key on first use. A configured
* environment seed remains supported for managed deployments, but the
* default path never downloads or reuses a public test seed.
*/
function ensureSigningKeySync() {
const configured = decodeSigningSeed(process.env.BETTERDESK_CUSTOM_CLIENT_SIGNING_SEED);
const existing = readStoredSigningSeed();
const seed = configured || existing || Buffer.from(nacl.randomBytes(32));
const generated = !configured && !existing;
const publicKey = Buffer.from(
nacl.sign.keyPair.fromSeed(new Uint8Array(seed)).publicKey
).toString('base64');
const seedText = seed.toString('base64');
const currentSeed = existing ? existing.toString('base64') : '';
if (configured || !existing || currentSeed !== seedText) {
writeSigningFile(signingSeedPath(), seedText, 0o600);
}
const currentPublic = fs.existsSync(signingPublicKeyPath())
? fs.readFileSync(signingPublicKeyPath(), 'utf8').trim()
: '';
if (currentPublic !== publicKey) {
writeSigningFile(signingPublicKeyPath(), publicKey, 0o644);
}
return { seed: seedText, publicKey, generated };
}
function prepareClientSigningPublicKey() {
const key = ensureSigningKeySync();
const target = path.join(sourceRoot(), 'res', 'betterdesk', 'custom-client-signing.pub');
if (!fs.existsSync(sourceRoot()) || !fs.existsSync(path.dirname(target))) {
throw new Error('betterdesk_client_source_missing_for_signing_key');
}
const stat = fs.lstatSync(target);
if (stat.isSymbolicLink()) throw new Error('betterdesk_client_signing_key_symlink');
const current = fs.readFileSync(target, 'utf8').trim();
if (current !== key.publicKey) {
const temporary = `${target}.tmp-${process.pid}-${Date.now()}`;
fs.writeFileSync(temporary, `${key.publicKey}\n`, { encoding: 'utf8', mode: 0o644 });
try { if (fs.existsSync(target)) fs.unlinkSync(target); } catch (_) { /* replace below */ }
fs.renameSync(temporary, target);
}
return key;
}
function githubHeaders(accept = 'application/vnd.github+json', requestUrl = 'https://api.github.com') {
const headers = {
'User-Agent': 'BetterDesk-Console-Client-Builder',
Accept: accept,
'X-GitHub-Api-Version': '2022-11-28',
};
const token = String(
process.env.BETTERDESK_GITHUB_TOKEN || process.env.GITHUB_TOKEN || ''
).trim();
let host = '';
try { host = new URL(requestUrl).hostname.toLowerCase(); } catch (_) { /* use no token */ }
if (token && host === 'api.github.com') headers.Authorization = `Bearer ${token}`;
return headers;
}
function githubJson(url) {
return new Promise((resolve, reject) => {
const lib = String(url).startsWith('https:') ? https : http;
const req = lib.get(url, { headers: githubHeaders(), timeout: 30_000 }, (res) => {
const chunks = [];
let size = 0;
res.on('data', (chunk) => {
size += chunk.length;
if (size <= MAX_RESPONSE_BYTES) chunks.push(chunk);
});
res.on('end', () => {
if (res.statusCode !== 200) {
const err = new Error(`GitHub HTTP ${res.statusCode}`);
err.statusCode = res.statusCode;
reject(err);
return;
}
if (size > MAX_RESPONSE_BYTES) {
reject(new Error('github_response_too_large'));
return;
}
try {
resolve(JSON.parse(Buffer.concat(chunks).toString('utf8')));
} catch (err) {
reject(new Error(`github_json_invalid:${err.message}`));
}
});
res.on('error', reject);
});
req.on('error', reject);
req.on('timeout', () => {
req.destroy();
reject(new Error('github_request_timeout'));
});
});
}
function githubRequest(method, url, body = null) {
return new Promise((resolve, reject) => {
const target = new URL(url);
const lib = target.protocol === 'https:' ? https : http;
const payload = body === null ? null : Buffer.from(JSON.stringify(body));
const req = lib.request(target, {
method,
headers: {
...githubHeaders('application/vnd.github+json'),
...(payload ? {
'Content-Type': 'application/json',
'Content-Length': payload.length,
} : {}),
},
timeout: 30_000,
}, (res) => {
const chunks = [];
let size = 0;
res.on('data', (chunk) => {
size += chunk.length;
if (size <= MAX_RESPONSE_BYTES) chunks.push(chunk);
});
res.on('end', () => {
const response = Buffer.concat(chunks);
if (res.statusCode < 200 || res.statusCode >= 300) {
const err = new Error(`GitHub HTTP ${res.statusCode}`);
err.statusCode = res.statusCode;
err.body = response.toString('utf8').slice(0, 4000);
reject(err);
return;
}
if (!response.length) {
resolve(null);
return;
}
try {
resolve(JSON.parse(response.toString('utf8')));
} catch (_) {
resolve(response);
}
});
res.on('error', reject);
});
req.on('error', reject);
req.on('timeout', () => {
req.destroy();
reject(new Error('github_request_timeout'));
});
if (payload) req.write(payload);
req.end();
});
}
function downloadToFile(url, destination, redirects = 0) {
return new Promise((resolve, reject) => {
if (redirects > 8) {
reject(new Error('too_many_redirects'));
return;
}
const lib = String(url).startsWith('https:') ? https : http;
const req = lib.get(url, {
headers: githubHeaders('application/octet-stream', url),
timeout: 120_000,
}, (res) => {
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
res.resume();
downloadToFile(res.headers.location, destination, redirects + 1).then(resolve, reject);
return;
}
if (res.statusCode !== 200) {
res.resume();
reject(new Error(`GitHub HTTP ${res.statusCode}`));
return;
}
const output = fs.createWriteStream(destination, { mode: 0o600 });
let settled = false;
const fail = (err) => {
if (settled) return;
settled = true;
output.destroy();
reject(err);
};
res.on('error', fail);
output.on('error', fail);
output.on('finish', () => {
if (!settled) {
settled = true;
resolve();
}
});
res.pipe(output);
});
req.on('error', reject);
req.on('timeout', () => {
req.destroy();
reject(new Error('github_download_timeout'));
});
});
}
function runCommand(command, args, options = {}) {
return new Promise((resolve, reject) => {
const child = spawn(command, args, {
cwd: options.cwd,
env: options.env || process.env,
stdio: ['ignore', 'pipe', 'pipe'],
windowsHide: true,
});
let stdout = '';
let stderr = '';
child.stdout.on('data', (chunk) => { stdout += chunk.toString(); });
child.stderr.on('data', (chunk) => { stderr += chunk.toString(); });
child.on('error', reject);
child.on('close', (code) => resolve({
code,
stdout: stdout.slice(-16_000),
stderr: stderr.slice(-16_000),
}));
});
}
function runTar(args, cwd) {
return new Promise((resolve, reject) => {
const child = spawn('tar', args, {
cwd,
stdio: ['ignore', 'pipe', 'pipe'],
windowsHide: true,
});
let stderr = '';
child.stderr.on('data', (chunk) => {
stderr += chunk.toString();
if (stderr.length > 16_000) stderr = stderr.slice(-16_000);
});
child.on('error', reject);
child.on('close', (code) => {
if (code === 0) resolve();
else reject(new Error(`tar_failed:${code}:${stderr.trim() || 'unknown'}`));
});
});
}
async function safeExtractTarGz(archivePath, destination) {
const listing = await new Promise((resolve, reject) => {
const child = spawn('tar', ['-tzf', archivePath], {
cwd: path.dirname(archivePath),
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) => {
if (code === 0) resolve(output);
else reject(new Error(`tar_list_failed:${code}:${error.trim() || 'unknown'}`));
});
});
for (const raw of listing.split(/\r?\n/).filter(Boolean)) {
const entry = raw.replace(/\\/g, '/');
if (
entry.startsWith('/')
|| /^[A-Za-z]:/.test(entry)
|| entry.split('/').includes('..')
) {
throw new Error('betterdesk_client_archive_path_traversal');
}
}
await fsp.mkdir(destination, { recursive: true });
await runTar(['-xzf', archivePath, '-C', destination], path.dirname(archivePath));
}
function readState() {
try {
return JSON.parse(fs.readFileSync(statePath(), 'utf8'));
} catch (_) {
return {
repo: normalizeRepo(),
ref: clientRef(),
termsAccepted: false,
installedCommit: null,
status: 'not_installed',
error: null,
};
}
}
async function writeState(patch) {
await fsp.mkdir(dataRoot(), { recursive: true });
const next = { ...readState(), ...patch };
const temporary = `${statePath()}.tmp-${process.pid}-${Date.now()}`;
await fsp.writeFile(temporary, JSON.stringify(next, null, 2) + '\n', {
encoding: 'utf8',
mode: 0o600,
});
await fsp.rename(temporary, statePath());
return next;
}
async function getRemoteRevision({ repo, ref } = {}) {
const targetRepo = normalizeRepo(repo);
const targetRef = clientRef(ref);
const commit = await githubJson(
`https://api.github.com/repos/${targetRepo}/commits/${encodeURIComponent(targetRef)}`
);
const sha = String(commit?.sha || '').trim();
if (!/^[a-f0-9]{40}$/i.test(sha)) throw new Error('betterdesk_client_commit_invalid');
return { repo: targetRepo, ref: targetRef, sha };
}
async function syncSource({ repo, ref, force = false } = {}) {
const remote = await getRemoteRevision({ repo, ref });
const state = readState();
if (
!force
&& state.installedCommit === remote.sha
&& fs.existsSync(path.join(sourceRoot(), 'Cargo.toml'))
) {
if (state.status !== 'ready' || state.phase !== 'ready') {
await writeState({ status: 'ready', phase: 'ready', error: null });
}
return { changed: false, ...remote, sourceDir: sourceRoot() };
}
const temporary = path.join(dataRoot(), `.sync-${process.pid}-${Date.now()}`);
const stagedSource = path.join(temporary, 'source');
try {
await writeState({
status: 'downloading',
phase: 'syncing_source',
error: null,
syncStartedAt: new Date().toISOString(),
});
await fsp.rm(temporary, { recursive: true, force: true });
await fsp.mkdir(temporary, { recursive: true });
const clone = await runCommand('git', [
'clone',
'--filter=blob:none',
'--no-tags',
'--no-checkout',
`https://github.com/${remote.repo}.git`,
stagedSource,
]);
if (clone.code !== 0) {
throw new Error(`betterdesk_client_git_clone_failed:${clone.stderr}`);
}
await writeState({ phase: 'syncing_submodules' });
let checkout = await runCommand('git', [
'-C', stagedSource, 'checkout', '--detach', remote.sha,
]);
if (checkout.code !== 0) {
const fetch = await runCommand('git', [
'-C', stagedSource, 'fetch', '--depth', '1', 'origin', remote.sha,
]);
if (fetch.code === 0) {
checkout = await runCommand('git', [
'-C', stagedSource, 'checkout', '--detach', remote.sha,
]);
}
}
if (checkout.code !== 0) {
throw new Error(`betterdesk_client_git_checkout_failed:${checkout.stderr}`);
}
const submodules = await runCommand('git', [
'-C', stagedSource, 'submodule', 'update', '--init', '--recursive',
]);
if (submodules.code !== 0) {
throw new Error(`betterdesk_client_submodule_failed:${submodules.stderr}`);
}
if (
!fs.existsSync(path.join(stagedSource, 'Cargo.toml'))
|| !fs.existsSync(path.join(stagedSource, 'libs', 'hbb_common', 'Cargo.toml'))
) {
throw new Error('betterdesk_client_source_invalid');
}
const previous = path.join(dataRoot(), '.source.previous');
await fsp.rm(previous, { recursive: true, force: true });
if (fs.existsSync(sourceRoot())) await fsp.rename(sourceRoot(), previous);
try {
await fsp.rename(stagedSource, sourceRoot());
} catch (err) {
if (fs.existsSync(previous) && !fs.existsSync(sourceRoot())) {
await fsp.rename(previous, sourceRoot()).catch(() => {});
}
throw err;
}
await fsp.rm(previous, { recursive: true, force: true });
await writeState({
repo: remote.repo,
ref: remote.ref,
installedCommit: remote.sha,
syncedAt: new Date().toISOString(),
status: 'ready',
phase: 'ready',
error: null,
});
return { changed: state.installedCommit !== remote.sha, ...remote, sourceDir: sourceRoot() };
} catch (err) {
await writeState({
status: 'error',
phase: 'error',
error: err.message || String(err),
});
throw err;
} finally {
await fsp.rm(temporary, { recursive: true, force: true }).catch(() => {});
}
}
/**
* Startup/update hook. Source synchronisation is completed before requests
* are queued, but the actual six-target builds remain in the background
* worker. Existing active generations are never changed by this function.
*/
async function syncAndQueueRebuilds({ force = false } = {}) {
const result = await syncSource({ force });
if (!result.changed && !force) {
return { ...result, queued: false };
}
const worker = require('./clientTemplateWorker');
const rebuild = await worker.rebuildAllForClientRevision(result.sha, { force: false });
return { ...result, queued: true, rebuild };
}
function providerCommand() {
return String(process.env.BETTERDESK_CLIENT_BUILD_COMMAND || '').trim()
|| path.join(__dirname, 'localClientBuilder.js');
}
function externalProviderCommand() {
return String(process.env.BETTERDESK_CLIENT_BUILD_COMMAND || '').trim();
}
function normalizeProviderTarget(raw) {
const platform = String(raw?.platform || raw?.os || '').trim().toLowerCase();
const arch = String(raw?.arch || 'x64').trim().toLowerCase();
const format = String(raw?.format || 'portable').trim().toLowerCase();
const key = `${platform}/${arch}/${format}`;
if (!PROVIDER_TARGETS.has(key)) return null;
return { platform, arch, format };
}
function configuredProviderTargets() {
let raw = String(process.env[PROVIDER_TARGETS_ENV] || '').trim();
if (!raw) {
const filePath = String(process.env[PROVIDER_TARGETS_FILE_ENV] || '').trim();
if (filePath) {
try {
raw = fs.readFileSync(filePath, 'utf8');
} catch (err) {
return {
targets: [],
error: `provider_targets_file_unreadable:${err.code || 'unknown'}`,
};
}
}
}
if (!raw) return { targets: [], error: 'provider_targets_missing' };
try {
const parsed = JSON.parse(raw);
const values = Array.isArray(parsed) ? parsed : parsed?.targets;
if (!Array.isArray(values)) return { targets: [], error: 'provider_targets_invalid' };
const targets = [...new Map(
values.map(normalizeProviderTarget)
.filter(Boolean)
.map((target) => [`${target.platform}/${target.arch}/${target.format}`, target])
).values()];
if (!targets.length) return { targets: [], error: 'provider_targets_invalid' };
return { targets, error: null };
} catch (_) {
return { targets: [], error: 'provider_targets_invalid' };
}
}
function githubActionsEnabled() {
return String(process.env.BETTERDESK_CLIENT_GITHUB_ACTIONS || 'off').trim().toLowerCase() !== 'off';
}
function githubTokenPresent() {
return !!String(
process.env.BETTERDESK_GITHUB_TOKEN || process.env.GITHUB_TOKEN || ''
).trim();
}
function usesRemoteProvider() {
return !!(
externalProviderCommand()
|| (githubActionsEnabled() && githubTokenPresent())
);
}
function getBuildCapabilities() {
if (usesRemoteProvider()) {
const configured = configuredProviderTargets();
return {
mode: 'remote',
provider: externalProviderCommand() ? 'command' : 'github-actions',
targets: configured.targets,
host: null,
toolchain: {
ready: configured.targets.length > 0,
missing: configured.targets.length ? [] : [configured.error || 'provider_targets_missing'],
},
buildReady: configured.targets.length > 0,
reason: configured.targets.length ? null : configured.error || 'provider_targets_missing',
};
}
const capabilities = localClientBuilder.getCapabilities();
return {
...capabilities,
buildReady: !!(
capabilities.toolchain?.ready
&& Array.isArray(capabilities.targets)
&& capabilities.targets.length > 0
),
reason: capabilities.toolchain?.ready
? (capabilities.targets?.length ? null : 'local_builder_no_supported_targets')
: 'local_builder_toolchain_missing',
};
}
function getBuildReadiness() {
const sourceReady = isReady();
const capabilities = getBuildCapabilities();
const buildReady = sourceReady && capabilities.buildReady;
return {
sourceReady,
buildReady,
reason: sourceReady ? capabilities.reason : 'betterdesk_client_builder_not_ready',
capabilities,
};
}
function signingSeedPresent() {
try {
ensureSigningKeySync();
return require('./supportGeneratorModule').isSigningSeedValid();
} catch (_) {
return false;
}
}
function isReady() {
const state = readState();
return !!(
state.termsAccepted
&& state.installedCommit
&& state.status === 'ready'
&& fs.existsSync(path.join(sourceRoot(), 'Cargo.toml'))
&& fs.existsSync(path.join(__dirname, 'localClientBuilder.js'))
&& signingSeedPresent()
);
}
async function acceptTerms() {
return writeState({ termsAccepted: true, error: null });
}
async function runBuild(request, { onProgress } = {}) {
const readiness = getBuildReadiness();
if (!readiness.sourceReady) throw new Error('betterdesk_client_builder_not_ready');
if (!readiness.buildReady) {
throw new Error(readiness.reason || 'betterdesk_client_builder_no_supported_targets');
}
const signingKey = prepareClientSigningPublicKey();
request = { ...request, signing_public_key: signingKey.publicKey };
const requestId = String(request?.generationId || '').trim();
if (!/^[a-f0-9-]{8,128}$/i.test(requestId)) {
throw new Error('betterdesk_client_generation_id_invalid');
}
await fsp.mkdir(requestRoot(), { recursive: true });
await fsp.mkdir(outputRoot(), { recursive: true });
const requestPath = path.join(requestRoot(), `${requestId}.json`);
const outputDir = path.join(outputRoot(), requestId);
await fsp.rm(outputDir, { recursive: true, force: true });
await fsp.mkdir(outputDir, { recursive: true });
await fsp.writeFile(requestPath, JSON.stringify(request, null, 2) + '\n', {
encoding: 'utf8',
mode: 0o600,
});
if (!externalProviderCommand() && githubActionsEnabled() && githubTokenPresent()) {
onProgress?.({ percent: 10, phase: 'queued_remote_build' });
const remoteResult = await runGithubActionsBuild(request, requestPath, outputDir);
onProgress?.({ percent: 100, phase: 'complete' });
return remoteResult;
}
const externalCommand = externalProviderCommand();
const command = externalCommand || process.execPath;
const args = externalCommand
? [requestPath]
: [path.join(__dirname, 'localClientBuilder.js'), requestPath];
const child = spawn(command, args, {
cwd: sourceRoot(),
env: {
...process.env,
BETTERDESK_CLIENT_SOURCE_DIR: sourceRoot(),
BETTERDESK_CLIENT_OUTPUT_DIR: outputDir,
BETTERDESK_CLIENT_BUILD_REQUEST: requestPath,
},
stdio: ['ignore', 'pipe', 'pipe'],
windowsHide: true,
});
let stdout = '';
let stderr = '';
child.stdout.on('data', (chunk) => {
const text = chunk.toString();
stdout += text;
for (const line of text.split(/\r?\n/)) {
const match = line.match(/^BETTERDESK_BUILD_PROGRESS\|(\d{1,3})\|(.+)$/);
if (!match) continue;
onProgress?.({
percent: Math.max(0, Math.min(100, Number(match[1]))),
phase: match[2].trim(),
});
}
});
child.stderr.on('data', (chunk) => { stderr += chunk.toString(); });
const result = await new Promise((resolve, reject) => {
const timer = setTimeout(() => {
child.kill();
reject(new Error('betterdesk_client_build_timeout'));
}, BUILD_TIMEOUT_MS);
child.on('error', reject);
child.on('close', (code) => {
clearTimeout(timer);
resolve({ code, stdout, stderr });
});
});
if (result.code !== 0) {
throw new Error(
`betterdesk_client_build_failed:${result.code}:${result.stderr.slice(-4000)}`
);
}
return { outputDir, stdout: result.stdout.slice(-4000), stderr: result.stderr.slice(-4000) };
}
async function runGithubActionsBuild(request, requestPath, outputDir) {
const state = readState();
const repo = normalizeRepo(state.repo);
const workflow = String(
process.env.BETTERDESK_CLIENT_BUILD_WORKFLOW || 'support-build.yml'
).trim();
if (!/^[A-Za-z0-9_.-]+$/.test(workflow)) {
throw new Error('betterdesk_client_workflow_invalid');
}
const startedAt = Date.now();
await githubRequest(
'POST',
`https://api.github.com/repos/${repo}/actions/workflows/${workflow}/dispatches`,
{
ref: state.ref || DEFAULT_REF,
inputs: {
generation_id: request.generationId,
config_b64: request.signed_config_b64,
client_commit: request.clientCommit || state.installedCommit,
targets_json: JSON.stringify(request.targets || []),
},
}
);
const runsUrl = `https://api.github.com/repos/${repo}/actions/workflows/${workflow}/runs?event=workflow_dispatch&per_page=20`;
const deadline = Date.now() + BUILD_TIMEOUT_MS;
let run = null;
while (Date.now() < deadline) {
const data = await githubJson(runsUrl);
const runs = Array.isArray(data?.workflow_runs) ? data.workflow_runs : [];
run = runs.find((candidate) => (
String(candidate.head_sha || '').toLowerCase()
=== String(request.clientCommit || state.installedCommit).toLowerCase()
&& new Date(candidate.created_at || 0).getTime() >= startedAt - 90_000
)) || null;
if (run) break;
await new Promise((resolve) => setTimeout(resolve, 5000));
}
if (!run) throw new Error('betterdesk_client_workflow_run_not_found');
let runState = run;
while (Date.now() < deadline) {
runState = await githubJson(
`https://api.github.com/repos/${repo}/actions/runs/${run.id}`
);
if (runState.status === 'completed') break;
await new Promise((resolve) => setTimeout(resolve, 10_000));
}
if (runState.status !== 'completed' || runState.conclusion !== 'success') {
throw new Error(
`betterdesk_client_workflow_failed:${runState.status || 'unknown'}:${runState.conclusion || 'unknown'}`
);
}
const artifacts = await githubJson(
`https://api.github.com/repos/${repo}/actions/runs/${run.id}/artifacts?per_page=100`
);
const artifact = (artifacts?.artifacts || []).find((candidate) => (
candidate.name === `betterdesk-support-${request.generationId}` && !candidate.expired
));
if (!artifact?.archive_download_url) {
throw new Error('betterdesk_client_workflow_artifact_missing');
}
const archivePath = path.join(requestRoot(), `${request.generationId}.actions.zip`);
await downloadToFile(artifact.archive_download_url, archivePath);
const zip = new AdmZip(archivePath);
for (const entry of zip.getEntries()) {
const name = String(entry.entryName || '').replace(/\\/g, '/');
if (
name.startsWith('/')
|| /^[A-Za-z]:/.test(name)
|| name.split('/').includes('..')
|| name.toLowerCase().endsWith('/custom.txt')
) {
throw new Error('betterdesk_client_workflow_artifact_invalid');
}
}
zip.extractAllTo(outputDir, true);
await fsp.rm(archivePath, { force: true });
return {
outputDir,
stdout: `GitHub Actions run ${run.id}`,
stderr: '',
requestPath,
};
}
async function getStatus() {
const state = readState();
const signingKey = ensureSigningKeySync();
const buildReadiness = getBuildReadiness();
let remote = null;
let updateError = null;
try {
remote = await getRemoteRevision({ repo: state.repo, ref: state.ref });
} catch (err) {
updateError = err.message || String(err);
}
return {
...state,
mode: 'embedded',
sourceDir: sourceRoot(),
providerConfigured: !!(providerCommand() || (githubActionsEnabled() && githubTokenPresent())),
buildCapabilities: getBuildCapabilities(),
templatesPresent: fs.existsSync(path.join(sourceRoot(), 'Cargo.toml')),
binariesPresent: fs.existsSync(path.join(sourceRoot(), 'Cargo.toml')),
signingSeedPresent: signingSeedPresent(),
signingKeyGenerated: !!signingKey.generated,
signingPublicKey: signingKey.publicKey,
signingSeedPath: signingSeedPath(),
ready: buildReadiness.sourceReady,
buildReady: buildReadiness.buildReady,
buildReadiness,
remoteCommit: remote?.sha || null,
updateAvailable: !!(remote?.sha && remote.sha !== state.installedCommit),
updateError,
};
}
function generationKey(configFingerprint, clientCommit) {
return crypto.createHash('sha256')
.update(`${String(configFingerprint || '')}:${String(clientCommit || '')}`)
.digest('hex');
}
module.exports = {
DEFAULT_REPO,
DEFAULT_REF,
dataRoot,
sourceRoot,
statePath,
requestRoot,
outputRoot,
normalizeRepo,
clientRef,
ensureSigningKeySync,
readState,
writeState,
acceptTerms,
getRemoteRevision,
syncSource,
syncAndQueueRebuilds,
getStatus,
getBuildCapabilities,
getBuildReadiness,
configuredProviderTargets,
isReady,
runBuild,
generationKey,
_internals: {
safeExtractTarGz,
providerCommand,
githubActionsEnabled,
githubTokenPresent,
usesRemoteProvider,
},
};