mirror of
https://github.com/UNITRONIX/BetterDesk.git
synced 2026-10-26 06:19:53 +00:00
44221f721b
Confine multer unlink to reconstructed safe paths, rate-limit public agent downloads, allowlist GitHub fetch hosts and local builder tools. Expand CodeQL query filters for validated false positives. Thanks: INSOLVE (Honorary); Marco Jakobs (@jacotec); MyNameisStitch (@MyNameisStitch); Redspin (@playerumpknow); muopc (@muopc)
757 lines
30 KiB
JavaScript
757 lines
30 KiB
JavaScript
/**
|
|
* Local BetterDesk-Client build provider.
|
|
*
|
|
* Invoked by clientBuilderService with one request JSON path. The source
|
|
* tree is always the runtime snapshot outside the BetterDesk checkout.
|
|
* This provider never builds or publishes the full client profile: it
|
|
* requires the signed Support payload and passes it to Cargo through the
|
|
* compile-time environment.
|
|
*/
|
|
|
|
'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 IS_WINDOWS = process.platform === 'win32';
|
|
const requestPath = path.resolve(process.argv[2] || '');
|
|
const sourceDir = path.resolve(process.env.BETTERDESK_CLIENT_SOURCE_DIR || '');
|
|
const outputDir = path.resolve(process.env.BETTERDESK_CLIENT_OUTPUT_DIR || '');
|
|
|
|
function executableNames(name) {
|
|
if (!IS_WINDOWS || /\.(exe|bat|cmd)$/i.test(name)) return [name];
|
|
return [`${name}.exe`, `${name}.bat`, `${name}.cmd`, name];
|
|
}
|
|
|
|
function candidatePaths(name, root) {
|
|
if (!root) return [];
|
|
return executableNames(name).map((executable) => path.join(root, executable));
|
|
}
|
|
|
|
function toolPath(name, configured) {
|
|
const configuredCandidate = configured ? [configured] : [];
|
|
const roots = [
|
|
process.env.BETTERDESK_CLIENT_TOOLCHAIN_BIN,
|
|
...(IS_WINDOWS
|
|
? [
|
|
process.env.BETTERDESK_CLIENT_TOOLCHAIN_ROOT
|
|
? path.join(process.env.BETTERDESK_CLIENT_TOOLCHAIN_ROOT, 'bin')
|
|
: '',
|
|
'C:\\tools\\flutter-3.24.5\\bin',
|
|
'C:\\tools\\LLVM-15.0.6\\bin',
|
|
]
|
|
: [
|
|
'/opt/betterdesk-toolchain/bin',
|
|
'/opt/betterdesk-toolchain/cargo/bin',
|
|
'/opt/flutter-3.24.5/bin',
|
|
]),
|
|
...(process.env.PATH || '')
|
|
.split(path.delimiter)
|
|
.filter(Boolean),
|
|
].filter(Boolean);
|
|
const candidates = configuredCandidate.concat(
|
|
roots.flatMap((root) => candidatePaths(name, root))
|
|
);
|
|
return candidates.find((candidate) => fs.existsSync(candidate)) || '';
|
|
}
|
|
|
|
function resolveVcpkgRoot() {
|
|
const configured = String(process.env.VCPKG_ROOT || '').trim();
|
|
if (configured) {
|
|
return /(?:^|[\\/])vcpkg(?:\.exe)?$/i.test(configured)
|
|
? path.dirname(configured)
|
|
: configured;
|
|
}
|
|
const conventional = IS_WINDOWS ? 'C:\\vcpkg' : '/opt/vcpkg';
|
|
return fs.existsSync(path.join(conventional, process.platform === 'win32' ? 'vcpkg.exe' : 'vcpkg'))
|
|
? conventional
|
|
: '';
|
|
}
|
|
|
|
function buildEnvironment() {
|
|
const cargo = toolPath('cargo', process.env.BETTERDESK_CLIENT_CARGO);
|
|
const flutter = toolPath('flutter', process.env.BETTERDESK_CLIENT_FLUTTER);
|
|
const python = toolPath(
|
|
IS_WINDOWS ? 'python' : 'python3',
|
|
process.env.BETTERDESK_CLIENT_PYTHON
|
|
);
|
|
if (!cargo) throw new Error('local_builder_toolchain_missing:cargo');
|
|
if (!flutter) throw new Error('local_builder_toolchain_missing:flutter');
|
|
if (!python) throw new Error('local_builder_toolchain_missing:python');
|
|
const vcpkgRoot = resolveVcpkgRoot();
|
|
if (!vcpkgRoot) {
|
|
throw new Error('local_builder_toolchain_missing:VCPKG_ROOT');
|
|
}
|
|
if (!fs.existsSync(path.join(vcpkgRoot, process.platform === 'win32' ? 'vcpkg.exe' : 'vcpkg'))) {
|
|
throw new Error('local_builder_toolchain_invalid:VCPKG_ROOT');
|
|
}
|
|
const binDir = path.dirname(cargo);
|
|
const flutterDir = path.dirname(flutter);
|
|
const dartDir = path.join(flutterDir, 'cache', 'dart-sdk', 'bin');
|
|
const pubCache = process.env.PUB_CACHE
|
|
|| path.join(
|
|
process.env.BETTERDESK_CLIENT_TOOLCHAIN_ROOT
|
|
|| (IS_WINDOWS ? 'C:\\ProgramData\\BetterDesk\\toolchain' : '/opt/betterdesk-toolchain'),
|
|
'flutter-pub-cache'
|
|
);
|
|
const pubBin = path.join(pubCache, 'bin');
|
|
const configuredLibclang = String(process.env.LIBCLANG_PATH || '').trim();
|
|
const defaultLibclang = IS_WINDOWS
|
|
? path.join(process.env.BETTERDESK_CLIENT_LLVM_ROOT || 'C:\\tools\\LLVM-15.0.6', 'bin')
|
|
: (fs.existsSync('/usr/lib/llvm-18/lib') ? '/usr/lib/llvm-18/lib' : '');
|
|
const configuredLibclangReady = IS_WINDOWS
|
|
? fs.existsSync(path.join(configuredLibclang, 'libclang.dll'))
|
|
: fs.existsSync(configuredLibclang);
|
|
const defaultLibclangReady = IS_WINDOWS
|
|
? fs.existsSync(path.join(defaultLibclang, 'libclang.dll'))
|
|
: fs.existsSync(defaultLibclang);
|
|
const libclangPath = IS_WINDOWS
|
|
? (defaultLibclangReady ? defaultLibclang : (configuredLibclangReady ? configuredLibclang : ''))
|
|
: (configuredLibclangReady ? configuredLibclang : (defaultLibclangReady ? defaultLibclang : ''));
|
|
return {
|
|
...process.env,
|
|
VCPKG_ROOT: vcpkgRoot,
|
|
PUB_CACHE: pubCache,
|
|
BETTERDESK_CLIENT_PYTHON: python,
|
|
...(libclangPath ? { LIBCLANG_PATH: libclangPath } : {}),
|
|
PATH: [
|
|
binDir,
|
|
flutterDir,
|
|
path.dirname(python),
|
|
libclangPath ? (IS_WINDOWS ? libclangPath : path.dirname(libclangPath)) : '',
|
|
fs.existsSync(dartDir) ? dartDir : '',
|
|
pubBin,
|
|
process.env.PATH || '',
|
|
].filter(Boolean).join(path.delimiter),
|
|
BETTERDESK_CLIENT_SUPPORT_BUILD: '1',
|
|
};
|
|
}
|
|
|
|
async function ensureFlutterRustBridge(sourceDir, environment) {
|
|
const bridgeRust = path.join(sourceDir, 'src', 'bridge_generated.rs');
|
|
const bridgeDart = path.join(sourceDir, 'flutter', 'lib', 'generated_bridge.dart');
|
|
if (fs.existsSync(bridgeRust) && fs.existsSync(bridgeDart)) return;
|
|
|
|
const codegenRoot = process.env.BETTERDESK_CLIENT_BRIDGE_CODEGEN_ROOT
|
|
|| path.join(
|
|
process.env.BETTERDESK_CLIENT_TOOLCHAIN_ROOT
|
|
|| (IS_WINDOWS ? 'C:\\ProgramData\\BetterDesk\\toolchain' : '/opt/betterdesk-toolchain'),
|
|
'bridge-codegen'
|
|
);
|
|
const codegen = toolPath(
|
|
'flutter_rust_bridge_codegen',
|
|
path.join(codegenRoot, 'bin', 'flutter_rust_bridge_codegen')
|
|
);
|
|
const cargo = toolPath('cargo', environment.BETTERDESK_CLIENT_CARGO);
|
|
const flutter = toolPath('flutter', environment.BETTERDESK_CLIENT_FLUTTER);
|
|
const dart = toolPath('dart', path.join(path.dirname(flutter), 'cache', 'dart-sdk', 'bin', 'dart'));
|
|
await fsp.mkdir(codegenRoot, { recursive: true });
|
|
if (!fs.existsSync(codegen)) {
|
|
const install = await run(cargo, [
|
|
'install', 'flutter_rust_bridge_codegen',
|
|
'--version', '1.80.1',
|
|
'--features', 'uuid',
|
|
'--locked',
|
|
'--root', codegenRoot,
|
|
], { cwd: sourceDir, env: environment });
|
|
if (install.code !== 0) {
|
|
throw new Error(`local_builder_bridge_codegen_install_failed:${install.stderr.slice(-4000)}`);
|
|
}
|
|
}
|
|
|
|
const pub = await run(flutter, ['pub', 'get'], {
|
|
cwd: path.join(sourceDir, 'flutter'),
|
|
env: environment,
|
|
});
|
|
if (pub.code !== 0) {
|
|
throw new Error(`local_builder_flutter_pub_get_failed:${pub.stderr.slice(-4000)}`);
|
|
}
|
|
const ffigen = await run(dart, [
|
|
'pub', 'global', 'activate', 'ffigen', '--version', '5.0.1',
|
|
], { cwd: sourceDir, env: environment });
|
|
if (ffigen.code !== 0) {
|
|
throw new Error(`local_builder_ffigen_install_failed:${ffigen.stderr.slice(-4000)}`);
|
|
}
|
|
const codegenArgs = [
|
|
'--rust-input', './src/flutter_ffi.rs',
|
|
'--dart-output', './flutter/lib/generated_bridge.dart',
|
|
'--c-output', './flutter/macos/Runner/bridge_generated.h',
|
|
];
|
|
if (IS_WINDOWS && environment.LIBCLANG_PATH) {
|
|
codegenArgs.push('--llvm-path', path.dirname(environment.LIBCLANG_PATH));
|
|
} else if (fs.existsSync('/usr/lib/llvm-18')) {
|
|
codegenArgs.push('--llvm-path', '/usr/lib/llvm-18');
|
|
}
|
|
const generated = await run(codegen, codegenArgs, { cwd: sourceDir, env: environment });
|
|
if (generated.code !== 0) {
|
|
throw new Error(`local_builder_bridge_codegen_failed:${generated.stderr.slice(-4000)}`);
|
|
}
|
|
const macHeader = path.join(sourceDir, 'flutter', 'macos', 'Runner', 'bridge_generated.h');
|
|
const iosHeader = path.join(sourceDir, 'flutter', 'ios', 'Runner', 'bridge_generated.h');
|
|
if (fs.existsSync(macHeader)) await fsp.copyFile(macHeader, iosHeader);
|
|
if (!fs.existsSync(bridgeRust) || !fs.existsSync(bridgeDart)) {
|
|
throw new Error('local_builder_bridge_codegen_incomplete');
|
|
}
|
|
}
|
|
|
|
async function ensureBuildCapacity() {
|
|
const minimumMb = Math.max(
|
|
512,
|
|
Number.parseInt(process.env.BETTERDESK_CLIENT_BUILD_MIN_FREE_MB || '3072', 10)
|
|
);
|
|
let stats;
|
|
try {
|
|
stats = fs.statfsSync(outputDir);
|
|
} catch (error) {
|
|
if (!IS_WINDOWS) throw error;
|
|
const drive = path.parse(outputDir).root.replace(/[\\:]/g, '');
|
|
const result = await run(
|
|
'powershell.exe',
|
|
['-NoProfile', '-NonInteractive', '-Command', `(Get-PSDrive -Name ${drive}).Free`],
|
|
{ cwd: outputDir, env: process.env }
|
|
);
|
|
const freeBytes = Number.parseInt(result.stdout.trim(), 10);
|
|
if (result.code !== 0 || !Number.isFinite(freeBytes)) {
|
|
throw new Error(`local_builder_disk_space_check_failed:${error.message}`);
|
|
}
|
|
const freeMb = Math.floor(freeBytes / (1024 * 1024));
|
|
if (freeMb < minimumMb) {
|
|
throw new Error(
|
|
`local_builder_disk_space_insufficient:${freeMb}MB<${minimumMb}MB`
|
|
);
|
|
}
|
|
return freeMb;
|
|
}
|
|
const freeMb = Math.floor((Number(stats.bavail) * Number(stats.bsize)) / (1024 * 1024));
|
|
if (freeMb < minimumMb) {
|
|
throw new Error(
|
|
`local_builder_disk_space_insufficient:${freeMb}MB<${minimumMb}MB`
|
|
);
|
|
}
|
|
return freeMb;
|
|
}
|
|
|
|
async function loadVisualStudioEnvironment(environment) {
|
|
if (!IS_WINDOWS) return environment;
|
|
const vsDevCmd = environment.BETTERDESK_CLIENT_VSDEVCMD
|
|
|| process.env.BETTERDESK_CLIENT_VSDEVCMD;
|
|
if (!vsDevCmd) return environment;
|
|
if (!fs.existsSync(vsDevCmd)) {
|
|
throw new Error('local_builder_toolchain_invalid:BETTERDESK_CLIENT_VSDEVCMD');
|
|
}
|
|
const arch = hostTarget().arch === 'arm64' ? 'arm64' : 'x64';
|
|
const command = `call ${path.basename(vsDevCmd)} -arch=${arch} -host_arch=${arch} && set`;
|
|
const result = await run(
|
|
process.env.ComSpec || 'cmd.exe',
|
|
['/d', '/c', command],
|
|
{ cwd: path.dirname(vsDevCmd), env: environment }
|
|
);
|
|
if (result.code !== 0) {
|
|
throw new Error(`local_builder_visual_studio_environment_failed:${result.stderr.slice(-4000)}`);
|
|
}
|
|
const visualStudioEnvironment = {};
|
|
for (const line of result.stdout.split(/\r?\n/)) {
|
|
const separator = line.indexOf('=');
|
|
if (separator > 0) {
|
|
visualStudioEnvironment[line.slice(0, separator)] = line.slice(separator + 1);
|
|
}
|
|
}
|
|
const visualStudioPath = [
|
|
environment.LIBCLANG_PATH,
|
|
visualStudioEnvironment.PATH || environment.PATH || '',
|
|
]
|
|
.join(path.delimiter)
|
|
.split(path.delimiter)
|
|
.filter((entry) => entry && !/msys|mingw/i.test(entry))
|
|
.join(path.delimiter);
|
|
return {
|
|
...environment,
|
|
...visualStudioEnvironment,
|
|
// VsDevCmd may expose an unrelated MSYS libclang. Keep the pinned
|
|
// LLVM/vcpkg/tool paths selected by the BetterDesk provider.
|
|
...(environment.LIBCLANG_PATH ? { LIBCLANG_PATH: environment.LIBCLANG_PATH } : {}),
|
|
...(environment.VCPKG_ROOT ? { VCPKG_ROOT: environment.VCPKG_ROOT } : {}),
|
|
...(environment.PUB_CACHE ? { PUB_CACHE: environment.PUB_CACHE } : {}),
|
|
PATH: visualStudioPath,
|
|
};
|
|
}
|
|
|
|
function fail(message) {
|
|
console.error(message);
|
|
process.exitCode = 1;
|
|
}
|
|
|
|
function hostTarget() {
|
|
const platform = process.platform === 'win32'
|
|
? 'windows'
|
|
: process.platform === 'darwin' ? 'macos' : 'linux';
|
|
const arch = process.arch === 'arm64' ? 'arm64' : 'x64';
|
|
return { platform, arch };
|
|
}
|
|
|
|
function providerArch(arch) {
|
|
return arch === 'x64' ? 'x86_64' : 'aarch64';
|
|
}
|
|
|
|
function commandAvailable(name) {
|
|
return Boolean(toolPath(name));
|
|
}
|
|
|
|
function linuxPackageFormats() {
|
|
if (process.platform !== 'linux') return [];
|
|
const formats = ['portable'];
|
|
if (commandAvailable('dpkg-deb')) formats.push('deb');
|
|
if (commandAvailable('rpmbuild')) formats.push('rpm');
|
|
if (commandAvailable('tar') && commandAvailable('zstd')) formats.push('arch');
|
|
return formats;
|
|
}
|
|
|
|
function localToolchainStatus() {
|
|
const missing = [];
|
|
if (!toolPath('cargo', process.env.BETTERDESK_CLIENT_CARGO)) missing.push('cargo');
|
|
if (!toolPath('flutter', process.env.BETTERDESK_CLIENT_FLUTTER)) missing.push('flutter');
|
|
if (!toolPath(IS_WINDOWS ? 'python' : 'python3', process.env.BETTERDESK_CLIENT_PYTHON)) {
|
|
missing.push('python');
|
|
}
|
|
const vcpkgRoot = resolveVcpkgRoot();
|
|
if (!vcpkgRoot) {
|
|
missing.push('VCPKG_ROOT');
|
|
} else if (!fs.existsSync(path.join(vcpkgRoot, process.platform === 'win32' ? 'vcpkg.exe' : 'vcpkg'))) {
|
|
missing.push('VCPKG_ROOT');
|
|
}
|
|
if (IS_WINDOWS) {
|
|
const vsDevCmd = process.env.BETTERDESK_CLIENT_VSDEVCMD;
|
|
const msvcAvailable = Boolean(
|
|
toolPath('cl', process.env.BETTERDESK_CLIENT_CL)
|
|
|| process.env.VCINSTALLDIR
|
|
|| process.env.VSINSTALLDIR
|
|
|| (vsDevCmd && fs.existsSync(vsDevCmd))
|
|
);
|
|
if (!msvcAvailable) missing.push('msvc');
|
|
const sdkRoots = [
|
|
process.env.BETTERDESK_CLIENT_WINDOWS_SDK_DIR,
|
|
process.env.WindowsSdkDir,
|
|
process.env['ProgramFiles(x86)']
|
|
? path.join(process.env['ProgramFiles(x86)'], 'Windows Kits', '10')
|
|
: '',
|
|
process.env.ProgramFiles
|
|
? path.join(process.env.ProgramFiles, 'Windows Kits', '10')
|
|
: '',
|
|
].filter(Boolean);
|
|
if (!sdkRoots.some((root) => fs.existsSync(path.join(root, 'Include')))) {
|
|
missing.push('windows-sdk');
|
|
}
|
|
const llvmRoot = process.env.BETTERDESK_CLIENT_LLVM_ROOT
|
|
|| (process.env.ProgramFiles ? path.join(process.env.ProgramFiles, 'LLVM') : '');
|
|
const llvmBins = [
|
|
process.env.LIBCLANG_PATH,
|
|
llvmRoot ? path.join(llvmRoot, 'bin') : '',
|
|
path.join('C:\\tools\\LLVM-15.0.6', 'bin'),
|
|
].filter(Boolean);
|
|
if (!llvmBins.some((llvmBin) => (
|
|
fs.existsSync(path.join(llvmBin, 'clang.exe'))
|
|
&& fs.existsSync(path.join(llvmBin, 'libclang.dll'))
|
|
))) {
|
|
missing.push('llvm');
|
|
}
|
|
}
|
|
return {
|
|
ready: missing.length === 0,
|
|
missing,
|
|
packageFormats: linuxPackageFormats(),
|
|
};
|
|
}
|
|
|
|
function getCapabilities() {
|
|
const host = hostTarget();
|
|
const toolchain = localToolchainStatus();
|
|
const formats = host.platform === 'linux'
|
|
? toolchain.packageFormats
|
|
: ['portable'];
|
|
return {
|
|
mode: 'local',
|
|
host,
|
|
toolchain,
|
|
targets: toolchain.ready ? formats.map((format) => ({
|
|
platform: host.platform,
|
|
arch: host.arch,
|
|
format,
|
|
})) : [],
|
|
};
|
|
}
|
|
|
|
/** Basenames allowed for local Support Client builds (host toolchain only). */
|
|
const ALLOWED_BUILDER_TOOLS = new Set([
|
|
'cargo', 'rustc', 'flutter', 'dart', 'python', 'python3', 'py',
|
|
'dpkg-deb', 'rpmbuild', 'tar', 'zip', 'unzip', 'strip', 'objcopy',
|
|
]);
|
|
|
|
function assertAllowedBuilderCommand(command) {
|
|
const raw = String(command || '');
|
|
const base = path.basename(raw)
|
|
.replace(/\.(exe|bat|cmd)$/i, '')
|
|
.toLowerCase();
|
|
if (!ALLOWED_BUILDER_TOOLS.has(base)) {
|
|
throw new Error(`Refusing to spawn non-allowlisted builder tool: ${base || '(empty)'}`);
|
|
}
|
|
return raw;
|
|
}
|
|
|
|
function run(command, args, options = {}) {
|
|
const safeCommand = assertAllowedBuilderCommand(command);
|
|
const isBatchCommand = IS_WINDOWS && /\.(bat|cmd)$/i.test(safeCommand);
|
|
return new Promise((resolve, reject) => {
|
|
// Prefer argv spawn; only use shell for Windows .bat/.cmd wrappers.
|
|
const child = spawn(safeCommand, args, {
|
|
cwd: options.cwd,
|
|
env: options.env,
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
windowsHide: true,
|
|
...(isBatchCommand ? { shell: 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, stderr }));
|
|
});
|
|
}
|
|
|
|
async function sha256(filePath) {
|
|
const hash = crypto.createHash('sha256');
|
|
const stream = fs.createReadStream(filePath);
|
|
for await (const chunk of stream) hash.update(chunk);
|
|
return hash.digest('hex');
|
|
}
|
|
|
|
function assertNoExternalConfig(root) {
|
|
const stack = [root];
|
|
while (stack.length) {
|
|
const current = stack.pop();
|
|
for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
|
|
const full = path.join(current, entry.name);
|
|
if (entry.isDirectory()) stack.push(full);
|
|
else if (
|
|
entry.name.toLowerCase() === 'custom.txt'
|
|
|| entry.name === '.custom-txt-here'
|
|
) {
|
|
throw new Error('local_builder_external_config_present');
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function packageVersion(request) {
|
|
const commit = String(request.clientCommit || '').toLowerCase();
|
|
return `0.0.0.${commit.slice(0, 12).replace(/[^a-f0-9]/g, '') || 'local'}`;
|
|
}
|
|
|
|
async function prepareLinuxBundle(source, target) {
|
|
const arch = providerArch(target.arch);
|
|
const targetName = `linux-${arch}-${target.format}`;
|
|
const stage = path.join(outputDir, 'stage', targetName);
|
|
await fsp.rm(stage, { recursive: true, force: true });
|
|
await fsp.mkdir(stage, { recursive: true });
|
|
await fsp.cp(source, path.join(stage, 'bundle'), { recursive: true });
|
|
assertNoExternalConfig(stage);
|
|
return { arch, targetName, stage };
|
|
}
|
|
|
|
async function packageLinuxDeb(request, target, bundle) {
|
|
const root = path.join(outputDir, 'packages', `${bundle.targetName}-deb`);
|
|
await fsp.rm(root, { recursive: true, force: true });
|
|
await fsp.mkdir(path.join(root, 'DEBIAN'), { recursive: true });
|
|
await fsp.mkdir(path.join(root, 'opt', 'betterdesk-support'), { recursive: true });
|
|
await fsp.cp(path.join(bundle.stage, 'bundle'), path.join(root, 'opt', 'betterdesk-support'), { recursive: true });
|
|
await fsp.mkdir(path.join(root, 'usr', 'bin'), { recursive: true });
|
|
await fsp.symlink('/opt/betterdesk-support/betterdesk', path.join(root, 'usr', 'bin', 'betterdesk'));
|
|
await fsp.writeFile(path.join(root, 'DEBIAN', 'control'), [
|
|
'Package: betterdesk-support',
|
|
`Version: ${packageVersion(request)}`,
|
|
`Architecture: ${target.arch === 'arm64' ? 'arm64' : 'amd64'}`,
|
|
'Section: net',
|
|
'Priority: optional',
|
|
'Maintainer: BetterDesk',
|
|
'Description: BetterDesk Support client',
|
|
' Embedded support client with signed server configuration.',
|
|
'',
|
|
].join('\n'));
|
|
const artifact = path.join(
|
|
outputDir,
|
|
`betterdesk-support-${request.generationId}-linux-${bundle.arch}.deb`
|
|
);
|
|
const result = await run('dpkg-deb', ['--build', root, artifact], { env: process.env });
|
|
if (result.code !== 0) throw new Error(`local_builder_deb_failed:${result.stderr.slice(-2000)}`);
|
|
return artifact;
|
|
}
|
|
|
|
async function packageLinuxRpm(request, target, bundle) {
|
|
const top = path.join(outputDir, 'packages', `${bundle.targetName}-rpm`);
|
|
const specDir = path.join(top, 'SPECS');
|
|
const sourceDirForRpm = path.join(top, 'SOURCES');
|
|
await fsp.rm(top, { recursive: true, force: true });
|
|
await fsp.mkdir(specDir, { recursive: true });
|
|
await fsp.mkdir(sourceDirForRpm, { recursive: true });
|
|
await fsp.cp(bundle.stage, path.join(sourceDirForRpm, 'bundle'), { recursive: true });
|
|
const version = packageVersion(request);
|
|
const spec = [
|
|
'Name: betterdesk-support',
|
|
`Version: ${version}`,
|
|
'Release: 1',
|
|
'Summary: BetterDesk Support client',
|
|
'License: AGPL-3.0',
|
|
`BuildArch: ${target.arch === 'arm64' ? 'aarch64' : 'x86_64'}`,
|
|
'%description',
|
|
'BetterDesk Support client with signed server configuration.',
|
|
'%prep',
|
|
'%build',
|
|
'%install',
|
|
'mkdir -p %{buildroot}/opt/betterdesk-support',
|
|
'cp -a %{_sourcedir}/bundle/bundle/. %{buildroot}/opt/betterdesk-support/',
|
|
'mkdir -p %{buildroot}/usr/bin',
|
|
'ln -s /opt/betterdesk-support/betterdesk %{buildroot}/usr/bin/betterdesk',
|
|
'%files',
|
|
'/opt/betterdesk-support',
|
|
'/usr/bin/betterdesk',
|
|
'%changelog',
|
|
'* Mon Oct 05 2026 BetterDesk <support@betterdesk.local> - 0.0.0-1',
|
|
'- Build signed BetterDesk Support client.',
|
|
'',
|
|
].join('\n');
|
|
const specPath = path.join(specDir, 'betterdesk-support.spec');
|
|
await fsp.writeFile(specPath, spec);
|
|
const result = await run('rpmbuild', [
|
|
'--define', `_topdir ${top}`,
|
|
'-bb',
|
|
specPath,
|
|
], { env: process.env });
|
|
if (result.code !== 0) throw new Error(`local_builder_rpm_failed:${result.stderr.slice(-2000)}`);
|
|
const rpmArch = target.arch === 'arm64' ? 'aarch64' : 'x86_64';
|
|
const artifact = path.join(
|
|
outputDir,
|
|
`betterdesk-support-${request.generationId}-linux-${bundle.arch}.rpm`
|
|
);
|
|
const built = path.join(top, 'RPMS', rpmArch, `betterdesk-support-${version}-1.${rpmArch}.rpm`);
|
|
if (!fs.existsSync(built)) throw new Error(`local_builder_rpm_output_missing:${built}`);
|
|
await fsp.rename(built, artifact);
|
|
return artifact;
|
|
}
|
|
|
|
async function packageLinuxArch(request, target, bundle) {
|
|
const root = path.join(outputDir, 'packages', `${bundle.targetName}-arch`);
|
|
await fsp.rm(root, { recursive: true, force: true });
|
|
await fsp.mkdir(path.join(root, 'opt', 'betterdesk-support'), { recursive: true });
|
|
await fsp.cp(
|
|
path.join(bundle.stage, 'bundle'),
|
|
path.join(root, 'opt', 'betterdesk-support'),
|
|
{ recursive: true }
|
|
);
|
|
await fsp.mkdir(path.join(root, 'usr', 'bin'), { recursive: true });
|
|
await fsp.symlink('/opt/betterdesk-support/betterdesk', path.join(root, 'usr', 'bin', 'betterdesk'));
|
|
const arch = target.arch === 'arm64' ? 'aarch64' : 'x86_64';
|
|
await fsp.writeFile(path.join(root, '.PKGINFO'), [
|
|
'pkgname = betterdesk-support',
|
|
'pkgbase = betterdesk-support',
|
|
'pkgver = 0.0.0-1',
|
|
'pkgdesc = BetterDesk Support client',
|
|
'url = https://betterdesk.eu',
|
|
'builddate = 1791158400',
|
|
'packager = BetterDesk',
|
|
`arch = ${arch}`,
|
|
'',
|
|
].join('\n'));
|
|
const artifact = path.join(
|
|
outputDir,
|
|
`betterdesk-support-${request.generationId}-linux-${bundle.arch}.pkg.tar.zst`
|
|
);
|
|
const result = await run('tar', ['--zstd', '-cf', artifact, '.'], {
|
|
cwd: root,
|
|
env: process.env,
|
|
});
|
|
if (result.code !== 0) throw new Error(`local_builder_arch_failed:${result.stderr.slice(-2000)}`);
|
|
return artifact;
|
|
}
|
|
|
|
async function packageTarget(request, target) {
|
|
const arch = providerArch(target.arch);
|
|
const targetName = `${target.platform}-${arch}-${target.format}`;
|
|
const stage = path.join(outputDir, 'stage', targetName);
|
|
await fsp.rm(stage, { recursive: true, force: true });
|
|
await fsp.mkdir(stage, { recursive: true });
|
|
|
|
if (target.platform === 'windows') {
|
|
const source = path.join(
|
|
sourceDir,
|
|
'flutter',
|
|
'build',
|
|
'windows',
|
|
target.arch,
|
|
'runner',
|
|
'Release'
|
|
);
|
|
if (!fs.existsSync(source)) throw new Error(`local_builder_output_missing:${source}`);
|
|
await fsp.cp(source, stage, { recursive: true });
|
|
assertNoExternalConfig(stage);
|
|
const zipPath = path.join(
|
|
outputDir,
|
|
`betterdesk-support-${request.generationId}-${targetName}.zip`
|
|
);
|
|
const zip = new AdmZip();
|
|
zip.addLocalFolder(stage, targetName);
|
|
zip.writeZip(zipPath);
|
|
return zipPath;
|
|
}
|
|
|
|
let source;
|
|
if (target.platform === 'linux') {
|
|
const linuxBundleCandidates = [
|
|
path.join(sourceDir, 'flutter', 'build', 'linux', target.arch, 'release', 'bundle'),
|
|
// BetterDesk-Client's build.py currently uses the Flutter x64
|
|
// directory name for Linux even when the native host is ARM64.
|
|
path.join(sourceDir, 'flutter', 'build', 'linux', 'x64', 'release', 'bundle'),
|
|
];
|
|
source = linuxBundleCandidates.find((candidate) => fs.existsSync(candidate))
|
|
|| linuxBundleCandidates[0];
|
|
} else {
|
|
source = path.join(
|
|
sourceDir,
|
|
'flutter',
|
|
'build',
|
|
'macos',
|
|
'Build',
|
|
'Products',
|
|
'Release',
|
|
'BetterDesk Client.app'
|
|
);
|
|
}
|
|
if (!fs.existsSync(source)) throw new Error(`local_builder_output_missing:${source}`);
|
|
if (target.platform === 'linux' && target.format !== 'portable') {
|
|
const bundle = await prepareLinuxBundle(source, target);
|
|
if (target.format === 'deb') return packageLinuxDeb(request, target, bundle);
|
|
if (target.format === 'rpm') return packageLinuxRpm(request, target, bundle);
|
|
if (target.format === 'arch') return packageLinuxArch(request, target, bundle);
|
|
throw new Error(`local_builder_format_unsupported:${target.format}`);
|
|
}
|
|
await fsp.cp(source, path.join(stage, path.basename(source)), { recursive: true });
|
|
assertNoExternalConfig(stage);
|
|
const archivePath = path.join(
|
|
outputDir,
|
|
`betterdesk-support-${request.generationId}-${targetName}.tar.gz`
|
|
);
|
|
const result = await run('tar', ['-czf', archivePath, targetName], {
|
|
cwd: path.dirname(stage),
|
|
env: process.env,
|
|
});
|
|
if (result.code !== 0) throw new Error(`local_builder_tar_failed:${result.stderr.slice(-2000)}`);
|
|
return archivePath;
|
|
}
|
|
|
|
async function main() {
|
|
if (!fs.existsSync(requestPath) || !fs.existsSync(path.join(sourceDir, 'build.py'))) {
|
|
throw new Error('local_builder_source_or_request_missing');
|
|
}
|
|
const requestText = (await fsp.readFile(requestPath, 'utf8')).replace(/^\uFEFF/, '');
|
|
const request = JSON.parse(requestText);
|
|
if (request.product_sku !== 'betterdesk-support' || !request.signed_config_b64) {
|
|
throw new Error('local_builder_support_payload_required');
|
|
}
|
|
if (!Array.isArray(request.targets) || request.targets.length === 0) {
|
|
throw new Error('local_builder_targets_required');
|
|
}
|
|
const host = hostTarget();
|
|
const targets = request.targets
|
|
.filter((candidate) => (
|
|
candidate.platform === host.platform && candidate.arch === host.arch
|
|
))
|
|
.map((candidate) => ({ ...candidate, format: candidate.format || 'portable' }));
|
|
if (!targets.length) {
|
|
throw new Error(`local_builder_target_unsupported:${request.targets[0]?.platform || 'unknown'}/${request.targets[0]?.arch || 'unknown'}`);
|
|
}
|
|
const available = new Set(getCapabilities().targets.map((candidate) => (
|
|
`${candidate.platform}/${candidate.arch}/${candidate.format}`
|
|
)));
|
|
for (const target of targets) {
|
|
if (!available.has(`${target.platform}/${target.arch}/${target.format}`)) {
|
|
throw new Error(`local_builder_format_unsupported:${target.format || 'portable'}`);
|
|
}
|
|
}
|
|
await fsp.mkdir(outputDir, { recursive: true });
|
|
await ensureBuildCapacity();
|
|
let toolchainEnv = buildEnvironment();
|
|
toolchainEnv = await loadVisualStudioEnvironment(toolchainEnv);
|
|
const progress = (percent, phase) => {
|
|
console.log(`BETTERDESK_BUILD_PROGRESS|${percent}|${phase}`);
|
|
};
|
|
progress(5, 'preparing');
|
|
await ensureFlutterRustBridge(sourceDir, toolchainEnv);
|
|
const python = toolPath(
|
|
IS_WINDOWS ? 'python' : 'python3',
|
|
toolchainEnv.BETTERDESK_CLIENT_PYTHON
|
|
);
|
|
const args = ['build.py', '--flutter'];
|
|
if (
|
|
String(process.env.BETTERDESK_CLIENT_BUILD_HWCODEC || '').trim().toLowerCase() === 'on'
|
|
&& toolchainEnv.VCPKG_ROOT
|
|
) {
|
|
args.push('--hwcodec');
|
|
}
|
|
if (process.platform === 'win32') args.push('--skip-portable-pack');
|
|
progress(10, 'compiling');
|
|
const build = await run(python, args, {
|
|
cwd: sourceDir,
|
|
env: {
|
|
...toolchainEnv,
|
|
BETTERDESK_SUPPORT_CONFIG_B64: request.signed_config_b64,
|
|
BETTERDESK_FORCE_LOCAL_CARGO_TARGET: '1',
|
|
},
|
|
});
|
|
if (build.code !== 0) {
|
|
throw new Error(`local_builder_compile_failed:${build.stderr.slice(-4000)}`);
|
|
}
|
|
progress(78, 'packaging');
|
|
const artifacts = [];
|
|
for (const target of targets) {
|
|
artifacts.push({ target, path: await packageTarget(request, target) });
|
|
}
|
|
progress(94, 'verifying');
|
|
const manifest = {
|
|
schema_version: 1,
|
|
sku: 'betterdesk-support',
|
|
generation_id: request.generationId,
|
|
client_commit: request.clientCommit || null,
|
|
external_config: false,
|
|
assets: await Promise.all(artifacts.map(async ({ target, path: artifact }) => ({
|
|
name: path.basename(artifact),
|
|
sha256: await sha256(artifact),
|
|
size: (await fsp.stat(artifact)).size,
|
|
platform: target.platform,
|
|
arch: providerArch(target.arch),
|
|
format: target.format || 'portable',
|
|
}))),
|
|
builder: 'betterdesk-local',
|
|
};
|
|
await fsp.writeFile(
|
|
path.join(outputDir, 'manifest.json'),
|
|
JSON.stringify(manifest, null, 2) + '\n',
|
|
{ encoding: 'utf8', mode: 0o600 }
|
|
);
|
|
progress(100, 'complete');
|
|
}
|
|
|
|
if (require.main === module) {
|
|
main().catch(fail);
|
|
}
|
|
|
|
module.exports = {
|
|
hostTarget,
|
|
providerArch,
|
|
buildEnvironment,
|
|
loadVisualStudioEnvironment,
|
|
getCapabilities,
|
|
};
|