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 { 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 = { ...(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 --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 > = { 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 = { 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 { 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 } }