UNPKG

pake-cli

Version:

๐Ÿคฑ๐Ÿป Turn any webpage into a desktop app with one command. ๐Ÿคฑ๐Ÿป ไธ€้”ฎๆ‰“ๅŒ…็ฝ‘้กต็”Ÿๆˆ่ฝป้‡ๆกŒ้ขๅบ”็”จใ€‚

1,307 lines (1,292 loc) โ€ข 103 kB
#!/usr/bin/env node import log from 'loglevel'; import chalk from 'chalk'; import updateNotifier from 'update-notifier'; import path from 'path'; import fsExtra from 'fs-extra'; import { fileURLToPath } from 'url'; import prompts from 'prompts'; import os from 'os'; import { execa, execaSync } from 'execa'; import crypto from 'crypto'; import ora from 'ora'; import fs from 'fs/promises'; import { dir } from 'tmp-promise'; import { fileTypeFromBuffer } from 'file-type'; import icongen from 'icon-gen'; import sharp from 'sharp'; import * as psl from 'psl'; import { InvalidArgumentError, program as program$1, Option } from 'commander'; import fs$1 from 'fs'; var name = "pake-cli"; var version = "3.11.7"; var description = "๐Ÿคฑ๐Ÿป Turn any webpage into a desktop app with one command. ๐Ÿคฑ๐Ÿป ไธ€้”ฎๆ‰“ๅŒ…็ฝ‘้กต็”Ÿๆˆ่ฝป้‡ๆกŒ้ขๅบ”็”จใ€‚"; var engines = { node: ">=18.0.0" }; var packageManager = "pnpm@10.26.2"; var bin = { pake: "dist/cli.js" }; var repository = { type: "git", url: "git+https://github.com/tw93/Pake.git" }; var author = { name: "Tw93", email: "tw93@qq.com" }; var keywords = [ "pake", "pake-cli", "rust", "tauri", "no-electron", "productivity" ]; var files = [ "dist", "src-tauri" ]; var scripts = { start: "pnpm run dev", dev: "pnpm run tauri dev", build: "tauri build", "build:debug": "tauri build --debug", "build:mac": "tauri build --target universal-apple-darwin", analyze: "cd src-tauri && cargo bloat --release --crates", tauri: "tauri", cli: "cross-env NODE_ENV=development rollup -c -w", "cli:build": "cross-env NODE_ENV=production rollup -c", test: "pnpm run cli:build && cross-env PAKE_CREATE_APP=1 node tests/index.js", format: "prettier --write . --ignore-unknown && find tests -name '*.js' -exec sed -i '' 's/[[:space:]]*$//' {} \\; && cd src-tauri && cargo fmt --verbose", "format:check": "prettier --check . --ignore-unknown", "release:check": "node scripts/check-release-version.mjs && pnpm run format:check && npx vitest run && pnpm run cli:build && npm pack --dry-run --ignore-scripts", update: "pnpm update --verbose && cd src-tauri && cargo update", prepublishOnly: "pnpm run cli:build" }; var type = "module"; var exports$1 = "./dist/cli.js"; var license = "MIT"; var dependencies = { "@tauri-apps/api": "~2.10.1", "@tauri-apps/cli": "^2.10.0", chalk: "^5.6.2", commander: "^14.0.3", execa: "^9.6.1", "file-type": "^21.3.0", "fs-extra": "^11.3.3", "icon-gen": "^5.0.0", loglevel: "^1.9.2", ora: "^9.3.0", prompts: "^2.4.2", psl: "^1.15.0", sharp: "^0.34.5", "tmp-promise": "^3.0.3", "update-notifier": "^7.3.1" }; var devDependencies = { "@rollup/plugin-alias": "^6.0.0", "@rollup/plugin-commonjs": "^29.0.0", "@rollup/plugin-json": "^6.1.0", "@rollup/plugin-replace": "^6.0.3", "@rollup/plugin-terser": "^0.4.4", "@types/fs-extra": "^11.0.4", "@types/node": "^25.3.2", "@types/prompts": "^2.4.9", "@types/tmp": "^0.2.6", "@types/update-notifier": "^6.0.8", "app-root-path": "^3.1.0", "cross-env": "^10.1.0", prettier: "^3.8.1", rollup: "^4.59.0", "rollup-plugin-typescript2": "^0.36.0", tslib: "^2.8.1", typescript: "^5.9.3", vitest: "^4.0.18" }; var pnpm = { overrides: { sharp: "^0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4" }, onlyBuiltDependencies: [ "esbuild", "sharp" ] }; var packageJson = { name: name, version: version, description: description, engines: engines, packageManager: packageManager, bin: bin, repository: repository, author: author, keywords: keywords, files: files, scripts: scripts, type: type, exports: exports$1, license: license, dependencies: dependencies, devDependencies: devDependencies, pnpm: pnpm }; // Convert the current module URL to a file path const currentModulePath = fileURLToPath(import.meta.url); // Resolve the parent directory of the current module const npmDirectory = path.join(path.dirname(currentModulePath), '..'); const tauriConfigDirectory = path.join(npmDirectory, 'src-tauri', '.pake'); // Load configs from npm package directory, not from project source const tauriSrcDir = path.join(npmDirectory, 'src-tauri'); const pakeConf = fsExtra.readJSONSync(path.join(tauriSrcDir, 'pake.json')); const CommonConf = fsExtra.readJSONSync(path.join(tauriSrcDir, 'tauri.conf.json')); const WinConf = fsExtra.readJSONSync(path.join(tauriSrcDir, 'tauri.windows.conf.json')); const MacConf = fsExtra.readJSONSync(path.join(tauriSrcDir, 'tauri.macos.conf.json')); const LinuxConf = fsExtra.readJSONSync(path.join(tauriSrcDir, 'tauri.linux.conf.json')); const platformConfigs = { win32: WinConf, darwin: MacConf, linux: LinuxConf, }; const { platform: platform$2 } = process; // @ts-ignore const platformConfig = platformConfigs[platform$2]; let tauriConfig = { ...CommonConf, bundle: platformConfig.bundle, app: { ...CommonConf.app, trayIcon: { ...(platformConfig?.app?.trayIcon ?? {}), }, }, build: CommonConf.build, pake: pakeConf, }; // Generates a stable identifier based on the app URL (and optionally name). // When name is provided it is included in the hash so two apps wrapping // the same URL can coexist. Omitting name preserves backward compatibility // with identifiers generated before V3.10.1. function getIdentifier(url, name) { const hashInput = name ? `${url}::${name}` : url; const postFixHash = crypto .createHash('md5') .update(hashInput) .digest('hex') .substring(0, 6); return `com.pake.a${postFixHash}`; } function resolveIdentifier(url, explicitName, customIdentifier) { const trimmedIdentifier = customIdentifier?.trim(); if (trimmedIdentifier) { if (!/^[a-zA-Z][a-zA-Z0-9.-]*[a-zA-Z0-9]$/.test(trimmedIdentifier)) { throw new Error(`Invalid identifier "${trimmedIdentifier}". Must start with a letter, ` + `contain only letters, digits, hyphens, and dots, and end with a letter or digit.`); } return trimmedIdentifier; } return getIdentifier(url, explicitName); } async function promptText(message, initial) { const response = await prompts({ type: 'text', name: 'content', message, initial, }); return response.content; } function capitalizeFirstLetter(string) { return string.charAt(0).toUpperCase() + string.slice(1); } function getSpinner(text) { const loadingType = { interval: 80, frames: ['โœฆ', 'โœถ', 'โœบ', 'โœต', 'โœธ', 'โœน', 'โœบ'], }; return ora({ text: `${chalk.cyan(text)}\n`, spinner: loadingType, color: 'cyan', }).start(); } const TRUE_VALUES = new Set(['1', 'true', 'yes', 'on']); const CN_MIRROR_ENV = 'PAKE_USE_CN_MIRROR'; function isCnMirrorEnabled(value = process.env[CN_MIRROR_ENV]) { return TRUE_VALUES.has((value ?? '').trim().toLowerCase()); } const { platform: platform$1 } = process; const IS_MAC = platform$1 === 'darwin'; const IS_WIN = platform$1 === 'win32'; const IS_LINUX = platform$1 === 'linux'; async function shellExec(command, timeout = 300000, env) { try { const { exitCode } = await execa(command, { cwd: npmDirectory, // Use 'inherit' to show all output directly to user in real-time. // This ensures linuxdeploy and other tool outputs are visible during builds. stdio: 'inherit', shell: true, timeout, env: env ? { ...process.env, ...env } : process.env, }); return exitCode; } catch (error) { const exitCode = error.exitCode ?? 'unknown'; const errorMessage = error.message || 'Unknown error occurred'; if (error.timedOut) { throw new Error(`Command timed out after ${timeout}ms: "${command}". Try increasing timeout or check network connectivity.`); } let errorMsg = `Error occurred while executing command "${command}". Exit code: ${exitCode}. Details: ${errorMessage}`; // Provide helpful guidance for common Linux AppImage build failures // caused by strip tool incompatibility with modern glibc (2.38+) const lowerError = errorMessage.toLowerCase(); if (process.platform === 'linux' && (lowerError.includes('linuxdeploy') || lowerError.includes('appimage') || lowerError.includes('strip'))) { errorMsg += '\n\nโ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”\n' + 'Linux AppImage Build Failed\n' + 'โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”\n\n' + 'Cause: Strip tool incompatibility with glibc 2.38+\n' + ' (affects Debian Trixie, Arch Linux, and other modern distros)\n\n' + 'Quick fix:\n' + ' NO_STRIP=1 pake <url> --targets appimage --debug\n\n' + 'Alternatives:\n' + ' โ€ข Use DEB format: pake <url> --targets deb\n' + ' โ€ข Update binutils: sudo apt install binutils (or pacman -S binutils)\n' + ' โ€ข Detailed guide: https://github.com/tw93/Pake/blob/main/docs/faq.md\n' + 'โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”'; if (lowerError.includes('fuse') || lowerError.includes('operation not permitted') || lowerError.includes('/dev/fuse')) { errorMsg += '\n\nDocker / Container hint:\n' + ' AppImage tooling needs access to /dev/fuse. When running inside Docker, add:\n' + ' --privileged --device /dev/fuse --security-opt apparmor=unconfined\n' + ' or run on the host directly.'; } } throw new Error(errorMsg); } } function normalizePathForComparison(targetPath) { const normalized = path.normalize(targetPath); return IS_WIN ? normalized.toLowerCase() : normalized; } function getCargoHomeCandidates() { const candidates = new Set(); if (process.env.CARGO_HOME) { candidates.add(process.env.CARGO_HOME); } const homeDir = os.homedir(); if (homeDir) { candidates.add(path.join(homeDir, '.cargo')); } if (IS_WIN && process.env.USERPROFILE) { candidates.add(path.join(process.env.USERPROFILE, '.cargo')); } return Array.from(candidates).filter(Boolean); } function ensureCargoBinOnPath() { const currentPath = process.env.PATH || ''; const segments = currentPath.split(path.delimiter).filter(Boolean); const normalizedSegments = new Set(segments.map((segment) => normalizePathForComparison(segment))); const additions = []; let cargoHomeSet = Boolean(process.env.CARGO_HOME); for (const cargoHome of getCargoHomeCandidates()) { const binDir = path.join(cargoHome, 'bin'); if (fsExtra.pathExistsSync(binDir) && !normalizedSegments.has(normalizePathForComparison(binDir))) { additions.push(binDir); normalizedSegments.add(normalizePathForComparison(binDir)); } if (!cargoHomeSet && fsExtra.pathExistsSync(cargoHome)) { process.env.CARGO_HOME = cargoHome; cargoHomeSet = true; } } if (additions.length) { const prefix = additions.join(path.delimiter); process.env.PATH = segments.length ? `${prefix}${path.delimiter}${segments.join(path.delimiter)}` : prefix; } } function ensureRustEnv() { ensureCargoBinOnPath(); } async function installRust() { const rustInstallScriptForUnix = isCnMirrorEnabled() ? 'export RUSTUP_DIST_SERVER="https://rsproxy.cn" && export RUSTUP_UPDATE_ROOT="https://rsproxy.cn/rustup" && curl --proto "=https" --tlsv1.2 -sSf https://rsproxy.cn/rustup-init.sh | sh' : "curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y"; const rustInstallScriptForWindows = 'winget install --id Rustlang.Rustup'; const spinner = getSpinner('Downloading Rust...'); try { await shellExec(IS_WIN ? rustInstallScriptForWindows : rustInstallScriptForUnix, 300000, undefined); spinner.succeed(chalk.green('โœ” Rust installed successfully!')); ensureRustEnv(); } catch (error) { spinner.fail(chalk.red('โœ• Rust installation failed!')); if (error instanceof Error) { console.error(error.message); } else { console.error(error); } process.exit(1); } } function checkRustInstalled() { ensureCargoBinOnPath(); try { execaSync('rustc', ['--version']); return true; } catch { return false; } } async function combineFiles(files, output) { const contents = await Promise.all(files.map(async (file) => { if (file.endsWith('.css')) { const fileContent = await fs.readFile(file, 'utf-8'); return `window.addEventListener('DOMContentLoaded', (_event) => { const css = ${JSON.stringify(fileContent)}; const style = document.createElement('style'); style.textContent = css; document.head.appendChild(style); });`; } const fileContent = await fs.readFile(file); return ("window.addEventListener('DOMContentLoaded', (_event) => { " + fileContent + ' });'); })); await fs.writeFile(output, contents.join('\n')); return files; } const logger = { info(...msg) { log.info(...msg.map((m) => chalk.white(m))); }, debug(...msg) { log.debug(...msg); }, error(...msg) { log.error(...msg.map((m) => chalk.red(m))); }, warn(...msg) { log.warn(...msg.map((m) => chalk.yellow(m))); }, success(...msg) { log.info(...msg.map((m) => chalk.green(m))); }, }; function generateSafeFilename(name) { return name .replace(/[<>:"/\\|?*]/g, '_') .replace(/\s+/g, '_') .replace(/\.+$/g, '') .slice(0, 255); } function getSafeAppName(name) { return generateSafeFilename(name).toLowerCase(); } function generateLinuxPackageName(name) { return name .toLowerCase() .replace(/[^a-z0-9\u4e00-\u9fff]+/g, '-') .replace(/^-+|-+$/g, '') .replace(/-+/g, '-'); } function generateIdentifierSafeName(name) { const cleaned = name.replace(/[^a-zA-Z0-9\u4e00-\u9fff]/g, '').toLowerCase(); if (cleaned === '') { const fallback = Array.from(name) .map((char) => { const code = char.charCodeAt(0); if ((code >= 48 && code <= 57) || (code >= 65 && code <= 90) || (code >= 97 && code <= 122)) { return char.toLowerCase(); } return code.toString(16); }) .join('') .slice(0, 50); return fallback || 'pake-app'; } return cleaned; } /** * Pure transform from CLI options to the window-config slice that gets * merged into pake.json. Exposed for snapshot testing so option drift * (e.g. a new flag added in cli-program.ts but forgotten here) is caught. * * Keep this function side-effect free. */ function buildWindowConfigOverrides(options, platform = asSupportedPlatform(process.platform)) { const platformHideOnClose = options.hideOnClose ?? platform === 'darwin'; return { width: options.width, height: options.height, fullscreen: options.fullscreen, maximize: options.maximize, resizable: options.resizable ?? true, hide_title_bar: options.hideTitleBar, activation_shortcut: options.activationShortcut, always_on_top: options.alwaysOnTop, dark_mode: options.darkMode, disabled_web_shortcuts: options.disabledWebShortcuts, hide_on_close: platformHideOnClose, incognito: options.incognito, title: options.title, enable_wasm: options.wasm, enable_drag_drop: options.enableDragDrop, start_to_tray: options.startToTray && options.showSystemTray, force_internal_navigation: options.forceInternalNavigation, internal_url_regex: options.internalUrlRegex, enable_find: options.enableFind, zoom: options.zoom, min_width: options.minWidth, min_height: options.minHeight, ignore_certificate_errors: options.ignoreCertificateErrors, new_window: options.newWindow, }; } function asSupportedPlatform(platform) { if (platform !== 'win32' && platform !== 'darwin' && platform !== 'linux') { throw new Error(`Pake only supports win32, darwin, and linux; detected '${platform}'.`); } return platform; } async function copyTemplateConfigs() { const srcTauriDir = path.join(npmDirectory, 'src-tauri'); await fsExtra.ensureDir(tauriConfigDirectory); const sourceFiles = [ 'tauri.conf.json', 'tauri.macos.conf.json', 'tauri.windows.conf.json', 'tauri.linux.conf.json', 'pake.json', ]; await Promise.all(sourceFiles.map(async (file) => { const sourcePath = path.join(srcTauriDir, file); const destPath = path.join(tauriConfigDirectory, file); if ((await fsExtra.pathExists(sourcePath)) && !(await fsExtra.pathExists(destPath))) { await fsExtra.copy(sourcePath, destPath); } })); } async function handleLocalFile(url, useLocalFile, tauriConf) { const pathExists = await fsExtra.pathExists(url); if (pathExists) { logger.warn('โœผ Your input might be a local file.'); const fileName = path.basename(url); const dirName = path.dirname(url); const distDir = path.join(npmDirectory, 'dist'); const distBakDir = path.join(npmDirectory, 'dist_bak'); if (!useLocalFile) { const urlPath = path.join(distDir, fileName); await fsExtra.copy(url, urlPath); } else { fsExtra.moveSync(distDir, distBakDir, { overwrite: true }); fsExtra.copySync(dirName, distDir, { overwrite: true }); const filesToCopyBack = ['cli.js']; await Promise.all(filesToCopyBack.map((file) => fsExtra.copy(path.join(distBakDir, file), path.join(distDir, file)))); } tauriConf.pake.windows[0].url = fileName; tauriConf.pake.windows[0].url_type = 'local'; } else { tauriConf.pake.windows[0].url_type = 'web'; } } async function mergeLinuxConfig(options, name, tauriConf, linuxBinaryName) { const linuxBundle = tauriConf.bundle.linux; if (!linuxBundle) { throw new Error('Linux bundle configuration is missing from tauri.linux.conf.json; cannot build Linux target.'); } delete linuxBundle.deb.files; const linuxName = generateLinuxPackageName(name); const desktopFileName = `com.pake.${linuxName}.desktop`; const iconName = `${linuxName}_512`; const { title } = options; const chineseName = title && /[\u4e00-\u9fa5]/.test(title) ? title : null; const desktopContent = `[Desktop Entry] Version=1.0 Type=Application Name=${name} ${chineseName ? `Name[zh_CN]=${chineseName}` : ''} Comment=${name} Exec=${linuxBinaryName} Icon=${iconName} Categories=Network;WebBrowser;Utility; MimeType=text/html;text/xml;application/xhtml_xml; StartupNotify=true Terminal=false `; const srcAssetsDir = path.join(npmDirectory, 'src-tauri/assets'); const srcDesktopFilePath = path.join(srcAssetsDir, desktopFileName); await fsExtra.ensureDir(srcAssetsDir); await fsExtra.writeFile(srcDesktopFilePath, desktopContent); const desktopInstallPath = `/usr/share/applications/${desktopFileName}`; linuxBundle.deb.files = { [desktopInstallPath]: `assets/${desktopFileName}`, }; if (!linuxBundle.rpm) { linuxBundle.rpm = {}; } linuxBundle.rpm.files = { [desktopInstallPath]: `assets/${desktopFileName}`, }; const validTargets = [ 'deb', 'appimage', 'rpm', 'deb-arm64', 'appimage-arm64', 'rpm-arm64', ]; const baseTarget = options.targets.includes('-arm64') ? options.targets.replace('-arm64', '') : options.targets; if (validTargets.includes(options.targets)) { tauriConf.bundle.targets = [baseTarget]; } else { logger.warn(`โœผ The target must be one of ${validTargets.join(', ')}, the default 'deb' will be used.`); } } async function mergeIcons(options, name, tauriConf, platform, safeAppName) { const platformIconMap = { win32: { fileExt: '.ico', path: `png/${safeAppName}_256.ico`, defaultIcon: 'png/icon_256.ico', message: 'Windows icon must be .ico and 256x256px.', }, linux: { fileExt: '.png', path: `png/${generateLinuxPackageName(name)}_512.png`, defaultIcon: 'png/icon_512.png', message: 'Linux icon must be .png and 512x512px.', }, darwin: { fileExt: '.icns', path: `icons/${safeAppName}.icns`, defaultIcon: 'icons/icon.icns', message: 'macOS icon must be .icns type.', }, }; const iconInfo = platformIconMap[platform]; const resolvedIconPath = options.icon ? path.resolve(options.icon) : null; const exists = resolvedIconPath && (await fsExtra.pathExists(resolvedIconPath)); if (exists) { let updateIconPath = true; const customIconExt = path.extname(resolvedIconPath).toLowerCase(); if (customIconExt !== iconInfo.fileExt) { updateIconPath = false; logger.warn(`โœผ ${iconInfo.message}, but you give ${customIconExt}`); tauriConf.bundle.icon = [iconInfo.defaultIcon]; } else { const iconPath = path.join(npmDirectory, 'src-tauri/', iconInfo.path); tauriConf.bundle.resources = [iconInfo.path]; const absoluteDestPath = path.resolve(iconPath); if (resolvedIconPath !== absoluteDestPath) { try { await fsExtra.copy(resolvedIconPath, iconPath); } catch (error) { if (!(error instanceof Error && error.message.includes('Source and destination must not be the same'))) { throw error; } } } } if (updateIconPath) { tauriConf.bundle.icon = [iconInfo.path]; } else { logger.warn(`โœผ Icon will remain as default.`); } } else { logger.warn('โœผ Custom icon path may be invalid, default icon will be used instead.'); tauriConf.bundle.icon = [iconInfo.defaultIcon]; } // Set tray icon path. let trayIconPath = platform === 'darwin' ? 'png/icon_512.png' : tauriConf.bundle.icon[0]; if (options.systemTrayIcon.length > 0) { try { await fsExtra.pathExists(options.systemTrayIcon); const iconExt = path.extname(options.systemTrayIcon).toLowerCase(); if (iconExt === '.png' || iconExt === '.ico') { const trayIcoPath = path.join(npmDirectory, `src-tauri/png/${safeAppName}${iconExt}`); trayIconPath = `png/${safeAppName}${iconExt}`; await fsExtra.copy(options.systemTrayIcon, trayIcoPath); } else { logger.warn(`โœผ System tray icon must be .ico or .png, but you provided ${iconExt}.`); logger.warn(`โœผ Default system tray icon will be used.`); } } catch (err) { logger.warn(`โœผ Failed to apply system tray icon "${options.systemTrayIcon}": ${err instanceof Error ? err.message : String(err)}`); logger.warn(`โœผ Default system tray icon will remain unchanged.`); } } tauriConf.pake.system_tray_path = trayIconPath; delete tauriConf.app.trayIcon; } async function injectCustomCode(options, tauriConf) { const { inject, proxyUrl, multiInstance, multiWindow, wasm } = options; const injectFilePath = path.join(npmDirectory, 'src-tauri/src/inject/custom.js'); if (inject?.length > 0) { const injectArray = Array.isArray(inject) ? inject : [inject]; if (!injectArray.every((item) => item.endsWith('.css') || item.endsWith('.js'))) { logger.error('The injected file must be in either CSS or JS format.'); return; } const files = injectArray.map((filepath) => path.isAbsolute(filepath) ? filepath : path.join(process.cwd(), filepath)); tauriConf.pake.inject = files; await combineFiles(files, injectFilePath); } else { tauriConf.pake.inject = []; await fsExtra.writeFile(injectFilePath, ''); } tauriConf.pake.proxy_url = proxyUrl || ''; tauriConf.pake.multi_instance = multiInstance; tauriConf.pake.multi_window = multiWindow; if (wasm) { tauriConf.app.security = { headers: { 'Cross-Origin-Opener-Policy': 'same-origin', 'Cross-Origin-Embedder-Policy': 'require-corp', }, }; } } async function generateMacEntitlements(camera, microphone) { const entitlementEntries = []; if (camera) { entitlementEntries.push(' <key>com.apple.security.device.camera</key>\n <true/>'); } if (microphone) { entitlementEntries.push(' <key>com.apple.security.device.audio-input</key>\n <true/>'); } const entitlementsContent = `<?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <plist version="1.0"> <dict> ${entitlementEntries.join('\n')} </dict> </plist> `; const entitlementsPath = path.join(npmDirectory, 'src-tauri', 'entitlements.plist'); await fsExtra.writeFile(entitlementsPath, entitlementsContent); } async function writeAllConfigs(tauriConf, platform) { const platformConfigPaths = { win32: 'tauri.windows.conf.json', darwin: 'tauri.macos.conf.json', linux: 'tauri.linux.conf.json', }; const configPath = path.join(tauriConfigDirectory, platformConfigPaths[platform]); const bundleConf = { bundle: tauriConf.bundle }; await fsExtra.outputJSON(configPath, bundleConf, { spaces: 4 }); const pakeConfigPath = path.join(tauriConfigDirectory, 'pake.json'); await fsExtra.outputJSON(pakeConfigPath, tauriConf.pake, { spaces: 4 }); const tauriConf2 = JSON.parse(JSON.stringify(tauriConf)); delete tauriConf2.pake; const configJsonPath = path.join(tauriConfigDirectory, 'tauri.conf.json'); await fsExtra.outputJSON(configJsonPath, tauriConf2, { spaces: 4 }); } async function mergeConfig(url, options, tauriConf) { await copyTemplateConfigs(); const { appVersion, userAgent, showSystemTray, useLocalFile, identifier, name = 'pake-app', installerLanguage, wasm, camera, microphone, } = options; const platform = asSupportedPlatform(process.platform); const tauriConfWindowOptions = buildWindowConfigOverrides(options, platform); Object.assign(tauriConf.pake.windows[0], { url, ...tauriConfWindowOptions }); tauriConf.productName = name; tauriConf.identifier = identifier; tauriConf.version = appVersion; const linuxBinaryName = `pake-${generateLinuxPackageName(name)}`; tauriConf.mainBinaryName = platform === 'linux' ? linuxBinaryName : `pake-${generateIdentifierSafeName(name)}`; if (platform === 'win32') { const windowsBundle = tauriConf.bundle.windows; if (!windowsBundle) { throw new Error('Windows bundle configuration is missing from tauri.windows.conf.json; cannot build Windows target.'); } windowsBundle.wix.language[0] = installerLanguage; } await handleLocalFile(url, useLocalFile, tauriConf); const platformMap = { win32: 'windows', linux: 'linux', darwin: 'macos', }; const currentPlatform = platformMap[platform]; if (userAgent.length > 0) { tauriConf.pake.user_agent[currentPlatform] = userAgent; } tauriConf.pake.system_tray[currentPlatform] = showSystemTray; if (platform === 'linux') { await mergeLinuxConfig(options, name, tauriConf, linuxBinaryName); } if (platform === 'darwin') { const validMacTargets = ['app', 'dmg']; if (validMacTargets.includes(options.targets)) { tauriConf.bundle.targets = [options.targets]; } } const safeAppName = getSafeAppName(name); await mergeIcons(options, name, tauriConf, platform, safeAppName); await injectCustomCode(options, tauriConf); if (platform === 'darwin') { await generateMacEntitlements(camera, microphone); } await writeAllConfigs(tauriConf, platform); } /** * Returns build environment variables overrides for macOS, where Rust crates * sometimes need explicit C/C++ flags and a deterministic SDK target. Other * platforms inherit `process.env` unchanged. */ function getBuildEnvironment() { if (!IS_MAC) { return undefined; } const currentPath = process.env.PATH || ''; const systemToolsPath = '/usr/bin'; const buildPath = currentPath.startsWith(`${systemToolsPath}:`) ? currentPath : `${systemToolsPath}:${currentPath}`; return { CFLAGS: '-fno-modules', CXXFLAGS: '-fno-modules', MACOSX_DEPLOYMENT_TARGET: '14.0', PATH: buildPath, }; } /** * Windows needs more time due to native compilation and antivirus scanning. */ function getInstallTimeout() { return process.platform === 'win32' ? 900000 : 600000; } function getBuildTimeout() { return 900000; } let packageManagerCache = null; function parseMajorVersion(version) { const match = version.match(/^(\d+)/); return match ? Number(match[1]) : null; } function getPinnedPnpmMajorVersion() { const packageManager = packageJson.packageManager; const match = packageManager?.match(/^pnpm@(\d+)/); return match ? Number(match[1]) : null; } async function detectNpm(execa) { try { await execa('npm', ['--version'], { stdio: 'ignore' }); return true; } catch { return false; } } /** * Returns 'pnpm' when available, otherwise 'npm'. Throws if neither is found. * Cached after the first successful detection so tests can call repeatedly. */ async function detectPackageManager() { if (packageManagerCache) { return packageManagerCache; } const { execa } = await import('execa'); try { const { stdout } = await execa('pnpm', ['--version']); const pnpmMajor = parseMajorVersion(stdout.trim()); const pinnedPnpmMajor = getPinnedPnpmMajorVersion(); if (pnpmMajor !== null && pinnedPnpmMajor !== null && pnpmMajor !== pinnedPnpmMajor && (await detectNpm(execa))) { logger.warn(`โœผ Detected pnpm v${stdout.trim()}, but Pake is pinned to ${packageJson.packageManager}; using npm for package installation instead.`); packageManagerCache = 'npm'; return 'npm'; } logger.info('โœบ Using pnpm for package management.'); packageManagerCache = 'pnpm'; return 'pnpm'; } catch { if (await detectNpm(execa)) { logger.info('โœบ pnpm not available, using npm for package management.'); packageManagerCache = 'npm'; return 'npm'; } throw new Error('Neither pnpm nor npm is available. Please install a package manager.'); } } function getInstallCommand(packageManager, useCnMirror) { const registryOption = useCnMirror ? ' --registry=https://registry.npmmirror.com' : ''; const peerDepsOption = packageManager === 'npm' ? ' --legacy-peer-deps' : ''; return `cd "${npmDirectory}" && ${packageManager} install${registryOption}${peerDepsOption}`; } async function copyFileWithSamePathGuard(sourcePath, destinationPath) { if (path.resolve(sourcePath) === path.resolve(destinationPath)) { return; } try { await fsExtra.copy(sourcePath, destinationPath, { overwrite: true }); } catch (error) { if (error instanceof Error && error.message.includes('Source and destination must not be the same')) { return; } throw error; } } function isGeneratedCnMirrorConfig(projectConfig, cnMirrorConfig) { return projectConfig.trim() === cnMirrorConfig.trim(); } /** * Toggles `.cargo/config.toml` to point at rsproxy.cn when the user opts in * via `PAKE_USE_CN_MIRROR=1`, and removes the auto-generated mirror config * (or warns about a manual one) when they opt out. */ async function configureCargoRegistry(tauriSrcPath, useCnMirror) { const rustProjectDir = path.join(tauriSrcPath, '.cargo'); const projectConf = path.join(rustProjectDir, 'config.toml'); const projectCnConf = path.join(tauriSrcPath, 'rust_proxy.toml'); if (useCnMirror) { await fsExtra.ensureDir(rustProjectDir); await copyFileWithSamePathGuard(projectCnConf, projectConf); return; } if (!(await fsExtra.pathExists(projectConf))) { return; } const [projectConfig, cnMirrorConfig] = await Promise.all([ fsExtra.readFile(projectConf, 'utf8'), fsExtra.readFile(projectCnConf, 'utf8'), ]); if (isGeneratedCnMirrorConfig(projectConfig, cnMirrorConfig)) { await fsExtra.remove(projectConf); return; } if (projectConfig.includes('rsproxy.cn')) { logger.warn(`โœผ ${projectConf} still references rsproxy.cn. Remove it or set ${CN_MIRROR_ENV}=1 if you want to use the CN mirror.`); } } /** * Returns true when an error string looks like the well-known Tauri+linuxdeploy * strip failure that we automatically retry with NO_STRIP=1. */ function isLinuxDeployStripError(error) { 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')); } class BaseBuilder { constructor(options) { this.options = options; } 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(1); } } const spinner = getSpinner('Installing package...'); const useCnMirror = isCnMirrorEnabled(); await configureCargoRegistry(tauriSrcPath, useCnMirror); const packageManager = await detectPackageManager(); const timeout = getInstallTimeout(); const buildEnv = 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)...'); } if (useCnMirror) { logger.info(`โœบ ${CN_MIRROR_ENV}=1 detected, using ${packageManager}/rsProxy CN mirror.`); } try { await shellExec(getInstallCommand(packageManager, useCnMirror), timeout, { ...buildEnv, CI: 'true', }); spinner.succeed(chalk.green('Package installed!')); } catch (error) { spinner.fail(chalk.red('Installation failed')); if (!useCnMirror) { logger.info(`โœบ If downloads are slow in China, retry with ${CN_MIRROR_ENV}=1 to use CN mirrors.`); } 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) { await this.buildAndCopy(url, this.options.targets); } async start(url) { logger.info('Pake dev server starting...'); await mergeConfig(url, this.options, tauriConfig); const packageManager = await detectPackageManager(); const configPath = path.join(npmDirectory, 'src-tauri', '.pake', 'tauri.conf.json'); const features = this.getBuildFeatures(); const featureArgs = features.length > 0 ? `--features ${features.join(',')}` : ''; const argSeparator = packageManager === 'npm' ? ' --' : ''; const command = `cd "${npmDirectory}" && ${packageManager} run tauri${argSeparator} dev --config "${configPath}" ${featureArgs}`; await shellExec(command); } async buildAndCopy(url, target) { const { name = 'pake-app' } = this.options; await mergeConfig(url, this.options, tauriConfig); const packageManager = await 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 = getBuildEnvironment(); let buildEnv = { ...(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' && target === '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 = getBuildTimeout(); try { await shellExec(buildCommand, buildTimeout, resolveExecEnv()); } catch (error) { const shouldRetryWithoutStrip = process.platform === 'linux' && target === 'appimage' && !buildEnv.NO_STRIP && 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)); } if (IS_MAC && fileType === 'app' && this.options.install) { await this.installAppToApplications(distPath, name); } } async installAppToApplications(appBundlePath, appName) { try { logger.info(`- Installing ${appName} to /Applications...`); const appBundleName = path.basename(appBundlePath); const appDest = path.join('/Applications', appBundleName); if (await fsExtra.pathExists(appDest)) { logger.warn(` Existing ${appBundleName} in /Applications will be replaced.`); } // fsExtra.move uses fs.rename (atomic on same filesystem) and falls back // to copy+remove only when moving across volumes. await fsExtra.move(appBundlePath, appDest, { overwrite: true }); logger.success(`โœ” ${appBundleName.replace(/\.app$/, '')} installed to /Applications`); } catch (error) { logger.error(`โœ• Failed to install ${appName}: ${error}`); logger.info(` App bundle still available at: ${appBundlePath}`); } } getFileType(target) { return target; } resolveTargetArch(requestedArch) { if (requestedArch === 'auto' || !requestedArch) { return process.arch; } return requestedArch; } getTauriTarget(arch, platform = process.platform) { const platformMappings = BaseBuilder.ARCH_MAPPINGS[platform]; if (!platformMappings) return null; return platformMappings[arch] || null; } getArchDisplayName(arch) { return BaseBuilder.ARCH_DISPLAY_NAMES[arch] || arch; } buildBaseCommand(packageManager, configPath, target) { 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'; } const features = this.getBuildFeatures(); if (features.length > 0) { fullCommand += ` --features ${features.join(',')}`; } return fullCommand; } getBuildFeatures() { 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; } getBuildCommand(packageManager = 'pnpm') { // 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'; } return fullCommand; } getMacOSMajorVersion() { 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 } } getBasePath() { const basePath = this.options.debug ? 'debug' : 'release'; return `src-tauri/target/${basePath}/bundle/`; } getBuildAppPath(npmDirectory, fileName, fileType) { // 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 */ async copyRawBinary(npmDirectory, appName) { 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 */ getRawBinarySourcePath(npmDirectory, appName) { 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 */ getRawBinaryPath(appName) { 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 */ getBinaryName(appName) { const extension = process.platform === 'win32' ? '.exe' : ''; // Use unique binary name for all platforms to avoid conflicts const nameToUse = process.platform === 'linux' ? generateLinuxPackageName(appName) : generateIdentifierSafeName(appName); return `pake-${nameToUse}${extension}`; } /** * Check if this build has architecture-specific target */ hasArchSpecificTarget() { return false; // Override in subclasses if needed } /** * Get architecture-specific path for binary */ getArchSpecificPath() { return 'src-tauri/target'; // Override in subclasses if needed } } BaseBuilder.ARCH_MAPPINGS = { 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', }, }; BaseBuilder.ARCH_DISPLAY_NAMES = { arm64: 'aarch64', x64: 'x64', universal: 'universal', }; class MacBuilder extends BaseBuilder { constructor(options) { super(options); const validArchs = ['intel', 'apple', 'universal', 'auto', 'x64', 'arm64']; this.buildArch = validArchs.includes(options.targets || '') ? options.targets : 'auto'; if (options.iterativeBuild || options.install || process.env.PAKE_CREATE_APP === '1') { this.buildFormat = 'app'; } else { this.buildFormat = 'dmg'; } this.options.targets = this.buildFormat; } getFileName() { const { name = 'pake-app' } = this.options; if (this.buildFormat === 'app') { return name; } let arch; if (this.buildArch === 'universal' || this.options.multiArch) { arch = 'universal'; } else if (this.buildArch === 'apple') { arch = 'aarch64'; } else if (this.buildArch === 'intel') { arch = 'x64'; } else { arch = this.getArchDisplayName(this.resolveTargetArch(this.buildArch)); } return `${name}_${tauriConfig.version}_${arch}`; } getActualArch() { if (this.buildArch === 'universal' || this.options.multiArch) { return 'universal'; } else if (this.buildArch === 'apple') { return 'arm64'; } else if (this.buildArch === 'intel') { return 'x64'; } return this.resolveTargetArch(this.buildArch); } getBuildCommand(packageManager = 'pnpm') { const configPath = path.join('src-tauri', '.pake', 'tauri.conf.json'); const actualArch = this.getActualArch();