Files
Pake/bin/builders/BaseBuilder.ts
2025-11-13 06:36:01 +00:00

541 lines
16 KiB
TypeScript
Vendored

import path from 'path';
import fsExtra from 'fs-extra';
import chalk from 'chalk';
import prompts from 'prompts';
import { PakeAppOptions } from '@/types';
import { checkRustInstalled, ensureRustEnv, installRust } from '@/helpers/rust';
import { mergeConfig } from '@/helpers/merge';
import tauriConfig from '@/helpers/tauriConfig';
import { generateIdentifierSafeName } from '@/utils/name';
import { npmDirectory } from '@/utils/dir';
import { getSpinner } from '@/utils/info';
import { shellExec } from '@/utils/shell';
import { isChinaDomain } from '@/utils/ip';
import { IS_MAC } from '@/utils/platform';
import logger from '@/options/logger';
export default abstract class BaseBuilder {
protected options: PakeAppOptions;
private static packageManagerCache: string | null = null;
protected constructor(options: PakeAppOptions) {
this.options = options;
}
private getBuildEnvironment() {
return IS_MAC
? {
CFLAGS: '-fno-modules',
CXXFLAGS: '-fno-modules',
MACOSX_DEPLOYMENT_TARGET: '14.0',
}
: undefined;
}
private getInstallTimeout(): number {
// Windows needs more time due to native compilation and antivirus scanning
return process.platform === 'win32' ? 900000 : 600000;
}
private getBuildTimeout(): number {
return 900000;
}
private async detectPackageManager(): Promise<string> {
if (BaseBuilder.packageManagerCache) {
return BaseBuilder.packageManagerCache;
}
const { execa } = await import('execa');
try {
await execa('pnpm', ['--version'], { stdio: 'ignore' });
logger.info('✺ Using pnpm for package management.');
BaseBuilder.packageManagerCache = 'pnpm';
return 'pnpm';
} catch {
try {
await execa('npm', ['--version'], { stdio: 'ignore' });
logger.info('✺ pnpm not available, using npm for package management.');
BaseBuilder.packageManagerCache = 'npm';
return 'npm';
} catch {
throw new Error(
'Neither pnpm nor npm is available. Please install a package manager.',
);
}
}
}
async prepare() {
const tauriSrcPath = path.join(npmDirectory, 'src-tauri');
const tauriTargetPath = path.join(tauriSrcPath, 'target');
const tauriTargetPathExists = await fsExtra.pathExists(tauriTargetPath);
if (!IS_MAC && !tauriTargetPathExists) {
logger.warn('✼ The first use requires installing system dependencies.');
logger.warn('✼ See more in https://tauri.app/start/prerequisites/.');
}
ensureRustEnv();
if (!checkRustInstalled()) {
const res = await prompts({
type: 'confirm',
message: 'Rust not detected. Install now?',
name: 'value',
});
if (res.value) {
await installRust();
} else {
logger.error('✕ Rust required to package your webapp.');
process.exit(0);
}
}
const isChina = await isChinaDomain('www.npmjs.com');
const spinner = getSpinner('Installing package...');
const rustProjectDir = path.join(tauriSrcPath, '.cargo');
const projectConf = path.join(rustProjectDir, 'config.toml');
await fsExtra.ensureDir(rustProjectDir);
// Detect available package manager
const packageManager = await this.detectPackageManager();
const registryOption = ' --registry=https://registry.npmmirror.com';
const peerDepsOption =
packageManager === 'npm' ? ' --legacy-peer-deps' : '';
const timeout = this.getInstallTimeout();
const buildEnv = this.getBuildEnvironment();
// Show helpful message for first-time users
if (!tauriTargetPathExists) {
logger.info(
process.platform === 'win32'
? '✺ First-time setup may take 10-15 minutes on Windows (compiling dependencies)...'
: '✺ First-time setup may take 5-10 minutes (installing dependencies)...',
);
}
let usedMirror = isChina;
try {
if (isChina) {
logger.info(
`✺ Located in China, using ${packageManager}/rsProxy CN mirror.`,
);
const projectCnConf = path.join(tauriSrcPath, 'rust_proxy.toml');
await fsExtra.copy(projectCnConf, projectConf);
await shellExec(
`cd "${npmDirectory}" && ${packageManager} install${registryOption}${peerDepsOption}`,
timeout,
buildEnv,
);
} else {
await shellExec(
`cd "${npmDirectory}" && ${packageManager} install${peerDepsOption}`,
timeout,
buildEnv,
);
}
spinner.succeed(chalk.green('Package installed!'));
} catch (error: any) {
// If installation times out and we haven't tried the mirror yet, retry with mirror
if (error.message?.includes('timed out') && !usedMirror) {
spinner.fail(
chalk.yellow('Installation timed out, retrying with CN mirror...'),
);
logger.info(
'✺ Retrying installation with CN mirror for better speed...',
);
const retrySpinner = getSpinner('Retrying installation...');
usedMirror = true;
try {
const projectCnConf = path.join(tauriSrcPath, 'rust_proxy.toml');
await fsExtra.copy(projectCnConf, projectConf);
await shellExec(
`cd "${npmDirectory}" && ${packageManager} install${registryOption}${peerDepsOption}`,
timeout,
buildEnv,
);
retrySpinner.succeed(
chalk.green('Package installed with CN mirror!'),
);
} catch (retryError) {
retrySpinner.fail(chalk.red('Installation failed'));
throw retryError;
}
} else {
spinner.fail(chalk.red('Installation failed'));
throw error;
}
}
if (!tauriTargetPathExists) {
logger.warn(
'✼ The first packaging may be slow, please be patient and wait, it will be faster afterwards.',
);
}
}
async build(url: string) {
await this.buildAndCopy(url, this.options.targets);
}
async start(url: string) {
await mergeConfig(url, this.options, tauriConfig);
}
async buildAndCopy(url: string, target: string) {
const { name } = this.options;
await mergeConfig(url, this.options, tauriConfig);
// Detect available package manager
const packageManager = await this.detectPackageManager();
// Build app
const buildSpinner = getSpinner('Building app...');
// Let spinner run for a moment so user can see it, then stop before package manager command
await new Promise((resolve) => setTimeout(resolve, 500));
buildSpinner.stop();
// Show static message to keep the status visible
logger.warn('✸ Building app...');
const baseEnv = this.getBuildEnvironment();
let buildEnv: Record<string, string> = {
...(baseEnv ?? {}),
...(process.env.NO_STRIP ? { NO_STRIP: process.env.NO_STRIP } : {}),
};
const resolveExecEnv = () =>
Object.keys(buildEnv).length > 0 ? buildEnv : undefined;
// Warn users about potential AppImage build failures on modern Linux systems.
// The linuxdeploy tool bundled in Tauri uses an older strip tool that doesn't
// recognize the .relr.dyn section introduced in glibc 2.38+.
if (process.platform === 'linux' && this.options.targets === 'appimage') {
if (!buildEnv.NO_STRIP) {
logger.warn(
'⚠ Building AppImage on Linux may fail due to strip incompatibility with glibc 2.38+',
);
logger.warn(
'⚠ If build fails, retry with: NO_STRIP=1 pake <url> --targets appimage',
);
}
}
const buildCommand = `cd "${npmDirectory}" && ${this.getBuildCommand(packageManager)}`;
const buildTimeout = this.getBuildTimeout();
try {
await shellExec(buildCommand, buildTimeout, resolveExecEnv());
} catch (error) {
const shouldRetryWithoutStrip =
process.platform === 'linux' &&
this.options.targets === 'appimage' &&
!buildEnv.NO_STRIP &&
this.isLinuxDeployStripError(error);
if (shouldRetryWithoutStrip) {
logger.warn(
'⚠ AppImage build failed during linuxdeploy strip step, retrying with NO_STRIP=1 automatically.',
);
buildEnv = {
...buildEnv,
NO_STRIP: '1',
};
await shellExec(buildCommand, buildTimeout, resolveExecEnv());
} else {
throw error;
}
}
// Copy app
const fileName = this.getFileName();
const fileType = this.getFileType(target);
const appPath = this.getBuildAppPath(npmDirectory, fileName, fileType);
const distPath = path.resolve(`${name}.${fileType}`);
await fsExtra.copy(appPath, distPath);
// Copy raw binary if requested
if (this.options.keepBinary) {
await this.copyRawBinary(npmDirectory, name);
}
await fsExtra.remove(appPath);
logger.success('✔ Build success!');
logger.success('✔ App installer located in', distPath);
// Log binary location if preserved
if (this.options.keepBinary) {
const binaryPath = this.getRawBinaryPath(name);
logger.success('✔ Raw binary located in', path.resolve(binaryPath));
}
}
protected getFileType(target: string): string {
return target;
}
abstract getFileName(): string;
private isLinuxDeployStripError(error: unknown): boolean {
if (!(error instanceof Error) || !error.message) {
return false;
}
const message = error.message.toLowerCase();
return (
message.includes('linuxdeploy') ||
message.includes('failed to run linuxdeploy') ||
message.includes('strip:') ||
message.includes('unable to recognise the format of the input file') ||
message.includes('appimage tool failed') ||
message.includes('strip tool')
);
}
// 架构映射配置
protected static readonly ARCH_MAPPINGS: Record<
string,
Record<string, string>
> = {
darwin: {
arm64: 'aarch64-apple-darwin',
x64: 'x86_64-apple-darwin',
universal: 'universal-apple-darwin',
},
win32: {
arm64: 'aarch64-pc-windows-msvc',
x64: 'x86_64-pc-windows-msvc',
},
linux: {
arm64: 'aarch64-unknown-linux-gnu',
x64: 'x86_64-unknown-linux-gnu',
},
};
// 架构名称映射(用于文件名生成)
protected static readonly ARCH_DISPLAY_NAMES: Record<string, string> = {
arm64: 'aarch64',
x64: 'x64',
universal: 'universal',
};
/**
* 解析目标架构
*/
protected resolveTargetArch(requestedArch?: string): string {
if (requestedArch === 'auto' || !requestedArch) {
return process.arch;
}
return requestedArch;
}
/**
* 获取Tauri构建目标
*/
protected getTauriTarget(
arch: string,
platform: NodeJS.Platform = process.platform,
): string | null {
const platformMappings = BaseBuilder.ARCH_MAPPINGS[platform];
if (!platformMappings) return null;
return platformMappings[arch] || null;
}
/**
* 获取架构显示名称(用于文件名)
*/
protected getArchDisplayName(arch: string): string {
return BaseBuilder.ARCH_DISPLAY_NAMES[arch] || arch;
}
/**
* 构建基础构建命令
*/
protected buildBaseCommand(
packageManager: string,
configPath: string,
target?: string,
): string {
const baseCommand = this.options.debug
? `${packageManager} run build:debug`
: `${packageManager} run build`;
const argSeparator = packageManager === 'npm' ? ' --' : '';
let fullCommand = `${baseCommand}${argSeparator} -c "${configPath}"`;
if (target) {
fullCommand += ` --target ${target}`;
}
// Enable verbose output in debug mode to help diagnose build issues.
// This provides detailed logs from Tauri CLI and bundler tools.
if (this.options.debug) {
fullCommand += ' --verbose';
}
return fullCommand;
}
/**
* 获取构建特性列表
*/
protected getBuildFeatures(): string[] {
const features = ['cli-build'];
// Add macos-proxy feature for modern macOS (Darwin 23+ = macOS 14+)
if (IS_MAC) {
const macOSVersion = this.getMacOSMajorVersion();
if (macOSVersion >= 23) {
features.push('macos-proxy');
}
}
return features;
}
protected getBuildCommand(packageManager: string = 'pnpm'): string {
// Use temporary config directory to avoid modifying source files
const configPath = path.join(
npmDirectory,
'src-tauri',
'.pake',
'tauri.conf.json',
);
let fullCommand = this.buildBaseCommand(packageManager, configPath);
// For macOS, use app bundles by default unless DMG is explicitly requested
if (IS_MAC && this.options.targets === 'app') {
fullCommand += ' --bundles app';
}
// Add features
const features = this.getBuildFeatures();
if (features.length > 0) {
fullCommand += ` --features ${features.join(',')}`;
}
return fullCommand;
}
protected getMacOSMajorVersion(): number {
try {
const os = require('os');
const release = os.release();
const majorVersion = parseInt(release.split('.')[0], 10);
return majorVersion;
} catch (error) {
return 0; // Disable proxy feature if version detection fails
}
}
protected getBasePath(): string {
const basePath = this.options.debug ? 'debug' : 'release';
return `src-tauri/target/${basePath}/bundle/`;
}
protected getBuildAppPath(
npmDirectory: string,
fileName: string,
fileType: string,
): string {
// For app bundles on macOS, the directory is 'macos', not 'app'
const bundleDir =
fileType.toLowerCase() === 'app' ? 'macos' : fileType.toLowerCase();
return path.join(
npmDirectory,
this.getBasePath(),
bundleDir,
`${fileName}.${fileType}`,
);
}
/**
* Copy raw binary file to output directory
*/
protected async copyRawBinary(
npmDirectory: string,
appName: string,
): Promise<void> {
const binaryPath = this.getRawBinarySourcePath(npmDirectory, appName);
const outputPath = this.getRawBinaryPath(appName);
if (await fsExtra.pathExists(binaryPath)) {
await fsExtra.copy(binaryPath, outputPath);
// Make binary executable on Unix-like systems
if (process.platform !== 'win32') {
await fsExtra.chmod(outputPath, 0o755);
}
} else {
logger.warn(`✼ Raw binary not found at ${binaryPath}, skipping...`);
}
}
/**
* Get the source path of the raw binary file in the build directory
*/
protected getRawBinarySourcePath(
npmDirectory: string,
appName: string,
): string {
const basePath = this.options.debug ? 'debug' : 'release';
const binaryName = this.getBinaryName(appName);
// Handle cross-platform builds
if (this.options.multiArch || this.hasArchSpecificTarget()) {
return path.join(
npmDirectory,
this.getArchSpecificPath(),
basePath,
binaryName,
);
}
return path.join(npmDirectory, 'src-tauri/target', basePath, binaryName);
}
/**
* Get the output path for the raw binary file
*/
protected getRawBinaryPath(appName: string): string {
const extension = process.platform === 'win32' ? '.exe' : '';
const suffix = process.platform === 'win32' ? '' : '-binary';
return `${appName}${suffix}${extension}`;
}
/**
* Get the binary name based on app name and platform
*/
protected getBinaryName(appName: string): string {
const extension = process.platform === 'win32' ? '.exe' : '';
// Linux uses the unique binary name we set in merge.ts
if (process.platform === 'linux') {
return `pake-${generateIdentifierSafeName(appName)}${extension}`;
}
// Windows and macOS use 'pake' as binary name
return `pake${extension}`;
}
/**
* Check if this build has architecture-specific target
*/
protected hasArchSpecificTarget(): boolean {
return false; // Override in subclasses if needed
}
/**
* Get architecture-specific path for binary
*/
protected getArchSpecificPath(): string {
return 'src-tauri/target'; // Override in subclasses if needed
}
}