pake-cli
Version:
๐คฑ๐ป Turn any webpage into a desktop app with one command. ๐คฑ๐ป ไธ้ฎๆๅ ็ฝ้กต็ๆ่ฝป้ๆก้ขๅบ็จใ
1,307 lines (1,292 loc) โข 103 kB
JavaScript
#!/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();