1965 lines
73 KiB
JavaScript
Executable File
Vendored
1965 lines
73 KiB
JavaScript
Executable File
Vendored
#!/usr/bin/env node
|
||
import chalk from 'chalk';
|
||
import { InvalidArgumentError, program, Option } from 'commander';
|
||
import log from 'loglevel';
|
||
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 dns from 'dns';
|
||
import http from 'http';
|
||
import { promisify } from 'util';
|
||
import fs from 'fs';
|
||
import updateNotifier from 'update-notifier';
|
||
import axios from 'axios';
|
||
import { dir } from 'tmp-promise';
|
||
import { fileTypeFromBuffer } from 'file-type';
|
||
import icongen from 'icon-gen';
|
||
import sharp from 'sharp';
|
||
import * as psl from 'psl';
|
||
|
||
var name = "pake-cli";
|
||
var version = "3.5.3";
|
||
var description = "🤱🏻 Turn any webpage into a desktop app with one command. 🤱🏻 一键打包网页生成轻量桌面应用。";
|
||
var engines = {
|
||
node: ">=18.0.0"
|
||
};
|
||
var packageManager = "pnpm@10.15.0";
|
||
var bin = {
|
||
pake: "./dist/cli.js"
|
||
};
|
||
var repository = {
|
||
type: "git",
|
||
url: "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",
|
||
"build:config": "chmod +x scripts/configure-tauri.mjs && node scripts/configure-tauri.mjs",
|
||
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",
|
||
update: "pnpm update --verbose && cd src-tauri && cargo update",
|
||
prepublishOnly: "pnpm run cli:build"
|
||
};
|
||
var type = "module";
|
||
var exports = "./dist/cli.js";
|
||
var license = "MIT";
|
||
var dependencies = {
|
||
"@tauri-apps/api": "^2.9.0",
|
||
"@tauri-apps/cli": "^2.9.0",
|
||
axios: "^1.12.2",
|
||
chalk: "^5.6.2",
|
||
commander: "^12.1.0",
|
||
execa: "^9.6.0",
|
||
"file-type": "^18.7.0",
|
||
"fs-extra": "^11.3.2",
|
||
"icon-gen": "^5.0.0",
|
||
loglevel: "^1.9.2",
|
||
ora: "^8.2.0",
|
||
prompts: "^2.4.2",
|
||
psl: "^1.15.0",
|
||
sharp: "^0.33.5",
|
||
"tmp-promise": "^3.0.3",
|
||
"update-notifier": "^7.3.1"
|
||
};
|
||
var devDependencies = {
|
||
"@rollup/plugin-alias": "^5.1.1",
|
||
"@rollup/plugin-commonjs": "^28.0.8",
|
||
"@rollup/plugin-json": "^6.1.0",
|
||
"@rollup/plugin-replace": "^6.0.2",
|
||
"@rollup/plugin-terser": "^0.4.4",
|
||
"@types/fs-extra": "^11.0.4",
|
||
"@types/node": "^20.19.23",
|
||
"@types/page-icon": "^0.3.6",
|
||
"@types/prompts": "^2.4.9",
|
||
"@types/tmp": "^0.2.6",
|
||
"@types/update-notifier": "^6.0.8",
|
||
"app-root-path": "^3.1.0",
|
||
"cross-env": "^7.0.3",
|
||
prettier: "^3.6.2",
|
||
rollup: "^4.52.5",
|
||
"rollup-plugin-typescript2": "^0.36.0",
|
||
tslib: "^2.8.1",
|
||
typescript: "^5.9.3"
|
||
};
|
||
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,
|
||
license: license,
|
||
dependencies: dependencies,
|
||
devDependencies: devDependencies
|
||
};
|
||
|
||
// 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 an identifier based on the given URL.
|
||
function getIdentifier(url) {
|
||
const postFixHash = crypto
|
||
.createHash('md5')
|
||
.update(url)
|
||
.digest('hex')
|
||
.substring(0, 6);
|
||
return `com.pake.${postFixHash}`;
|
||
}
|
||
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 { 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);
|
||
}
|
||
}
|
||
|
||
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.info(...msg.map((m) => chalk.yellow(m)));
|
||
},
|
||
success(...msg) {
|
||
log.info(...msg.map((m) => chalk.green(m)));
|
||
},
|
||
};
|
||
|
||
const resolve = promisify(dns.resolve);
|
||
const ping = async (host) => {
|
||
const lookup = promisify(dns.lookup);
|
||
const ip = await lookup(host);
|
||
const start = new Date();
|
||
// Prevent timeouts from affecting user experience.
|
||
const requestPromise = new Promise((resolve, reject) => {
|
||
const req = http.get(`http://${ip.address}`, (res) => {
|
||
const delay = new Date().getTime() - start.getTime();
|
||
res.resume();
|
||
resolve(delay);
|
||
});
|
||
req.on('error', (err) => {
|
||
reject(err);
|
||
});
|
||
});
|
||
const timeoutPromise = new Promise((_, reject) => {
|
||
setTimeout(() => {
|
||
reject(new Error('Request timed out after 3 seconds'));
|
||
}, 1000);
|
||
});
|
||
return Promise.race([requestPromise, timeoutPromise]);
|
||
};
|
||
async function isChinaDomain(domain) {
|
||
try {
|
||
const [ip] = await resolve(domain);
|
||
return await isChinaIP(ip, domain);
|
||
}
|
||
catch (error) {
|
||
logger.debug(`${domain} can't be parse!`);
|
||
return true;
|
||
}
|
||
}
|
||
async function isChinaIP(ip, domain) {
|
||
try {
|
||
const delay = await ping(ip);
|
||
logger.debug(`${domain} latency is ${delay} ms`);
|
||
return delay > 1000;
|
||
}
|
||
catch (error) {
|
||
logger.debug(`ping ${domain} failed!`);
|
||
return true;
|
||
}
|
||
}
|
||
|
||
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 isActions = process.env.GITHUB_ACTIONS;
|
||
const isInChina = await isChinaDomain('sh.rustup.rs');
|
||
const rustInstallScriptForMac = isInChina && !isActions
|
||
? '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 : rustInstallScriptForMac, 300000, undefined);
|
||
spinner.succeed(chalk.green('✔ Rust installed successfully!'));
|
||
ensureRustEnv();
|
||
}
|
||
catch (error) {
|
||
spinner.fail(chalk.red('✕ Rust installation failed!'));
|
||
console.error(error.message);
|
||
process.exit(1);
|
||
}
|
||
}
|
||
function checkRustInstalled() {
|
||
ensureCargoBinOnPath();
|
||
try {
|
||
execaSync('rustc', ['--version']);
|
||
return true;
|
||
}
|
||
catch {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
async function combineFiles(files, output) {
|
||
const contents = files.map((file) => {
|
||
const fileContent = fs.readFileSync(file);
|
||
if (file.endsWith('.css')) {
|
||
return ("window.addEventListener('DOMContentLoaded', (_event) => { const css = `" +
|
||
fileContent +
|
||
"`; const style = document.createElement('style'); style.innerHTML = css; document.head.appendChild(style); });");
|
||
}
|
||
return ("window.addEventListener('DOMContentLoaded', (_event) => { " +
|
||
fileContent +
|
||
' });');
|
||
});
|
||
fs.writeFileSync(output, contents.join('\n'));
|
||
return files;
|
||
}
|
||
|
||
function generateSafeFilename(name) {
|
||
return name
|
||
.replace(/[<>:"/\\|?*]/g, '_')
|
||
.replace(/\s+/g, '_')
|
||
.replace(/\.+$/g, '')
|
||
.slice(0, 255);
|
||
}
|
||
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;
|
||
}
|
||
|
||
/**
|
||
* Helper function to generate safe lowercase app name for file paths
|
||
*/
|
||
function getSafeAppName(name) {
|
||
return generateSafeFilename(name).toLowerCase();
|
||
}
|
||
async function mergeConfig(url, options, tauriConf) {
|
||
// Ensure .pake directory exists and copy source templates if needed
|
||
const srcTauriDir = path.join(npmDirectory, 'src-tauri');
|
||
await fsExtra.ensureDir(tauriConfigDirectory);
|
||
// Copy source config files to .pake directory (as templates)
|
||
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);
|
||
}
|
||
}));
|
||
const { width, height, fullscreen, maximize, hideTitleBar, alwaysOnTop, appVersion, darkMode, disabledWebShortcuts, activationShortcut, userAgent, showSystemTray, systemTrayIcon, useLocalFile, identifier, name, resizable = true, inject, proxyUrl, installerLanguage, hideOnClose, incognito, title, wasm, enableDragDrop, multiInstance, startToTray, forceInternalNavigation, zoom, minWidth, minHeight, ignoreCertificateErrors, } = options;
|
||
const { platform } = process;
|
||
const platformHideOnClose = hideOnClose ?? platform === 'darwin';
|
||
const tauriConfWindowOptions = {
|
||
width,
|
||
height,
|
||
fullscreen,
|
||
maximize,
|
||
resizable,
|
||
hide_title_bar: hideTitleBar,
|
||
activation_shortcut: activationShortcut,
|
||
always_on_top: alwaysOnTop,
|
||
dark_mode: darkMode,
|
||
disabled_web_shortcuts: disabledWebShortcuts,
|
||
hide_on_close: platformHideOnClose,
|
||
incognito: incognito,
|
||
title: title || null,
|
||
enable_wasm: wasm,
|
||
enable_drag_drop: enableDragDrop,
|
||
start_to_tray: startToTray && showSystemTray,
|
||
force_internal_navigation: forceInternalNavigation,
|
||
zoom,
|
||
min_width: minWidth,
|
||
min_height: minHeight,
|
||
ignore_certificate_errors: ignoreCertificateErrors,
|
||
};
|
||
Object.assign(tauriConf.pake.windows[0], { url, ...tauriConfWindowOptions });
|
||
tauriConf.productName = name;
|
||
tauriConf.identifier = identifier;
|
||
tauriConf.version = appVersion;
|
||
if (platform === 'linux') {
|
||
tauriConf.mainBinaryName = `pake-${generateIdentifierSafeName(name)}`;
|
||
}
|
||
if (platform == 'win32') {
|
||
tauriConf.bundle.windows.wix.language[0] = installerLanguage;
|
||
}
|
||
const pathExists = await fsExtra.pathExists(url);
|
||
if (pathExists) {
|
||
logger.warn('✼ Your input might be a local file.');
|
||
tauriConf.pake.windows[0].url_type = 'local';
|
||
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 });
|
||
// ignore it, because about_pake.html have be erased.
|
||
// const filesToCopyBack = ['cli.js', 'about_pake.html'];
|
||
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';
|
||
}
|
||
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;
|
||
// Processing targets are currently only open to Linux.
|
||
if (platform === 'linux') {
|
||
// Remove hardcoded desktop files and regenerate with correct app name
|
||
delete tauriConf.bundle.linux.deb.files;
|
||
// Generate correct desktop file configuration
|
||
const appNameSafe = getSafeAppName(name);
|
||
const identifier = `com.pake.${appNameSafe}`;
|
||
const desktopFileName = `${identifier}.desktop`;
|
||
// Create desktop file content
|
||
const desktopContent = `[Desktop Entry]
|
||
Version=1.0
|
||
Type=Application
|
||
Name=${name}
|
||
Comment=${name}
|
||
Exec=pake-${appNameSafe}
|
||
Icon=${appNameSafe}_512
|
||
Categories=Network;WebBrowser;
|
||
MimeType=text/html;text/xml;application/xhtml_xml;
|
||
StartupNotify=true
|
||
`;
|
||
// Write desktop file to src-tauri/assets directory where Tauri expects it
|
||
const srcAssetsDir = path.join(npmDirectory, 'src-tauri/assets');
|
||
const srcDesktopFilePath = path.join(srcAssetsDir, desktopFileName);
|
||
await fsExtra.ensureDir(srcAssetsDir);
|
||
await fsExtra.writeFile(srcDesktopFilePath, desktopContent);
|
||
// Set up desktop file in bundle configuration
|
||
// Use absolute path from src-tauri directory to assets
|
||
tauriConf.bundle.linux.deb.files = {
|
||
[`/usr/share/applications/${desktopFileName}`]: `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.`);
|
||
}
|
||
}
|
||
// Set macOS bundle targets (for app vs dmg)
|
||
if (platform === 'darwin') {
|
||
const validMacTargets = ['app', 'dmg'];
|
||
if (validMacTargets.includes(options.targets)) {
|
||
tauriConf.bundle.targets = [options.targets];
|
||
}
|
||
}
|
||
// Set icon.
|
||
const safeAppName = getSafeAppName(name);
|
||
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/${safeAppName}_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;
|
||
let 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];
|
||
// Avoid copying if source and destination are the same
|
||
const absoluteDestPath = path.resolve(iconPath);
|
||
if (resolvedIconPath !== absoluteDestPath) {
|
||
await fsExtra.copy(resolvedIconPath, iconPath);
|
||
}
|
||
}
|
||
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 (systemTrayIcon.length > 0) {
|
||
try {
|
||
await fsExtra.pathExists(systemTrayIcon);
|
||
// 需要判断图标格式,默认只支持ico和png两种
|
||
let iconExt = path.extname(systemTrayIcon).toLowerCase();
|
||
if (iconExt == '.png' || iconExt == '.ico') {
|
||
const trayIcoPath = path.join(npmDirectory, `src-tauri/png/${safeAppName}${iconExt}`);
|
||
trayIconPath = `png/${safeAppName}${iconExt}`;
|
||
await fsExtra.copy(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 {
|
||
logger.warn(`✼ ${systemTrayIcon} not exists!`);
|
||
logger.warn(`✼ Default system tray icon will remain unchanged.`);
|
||
}
|
||
}
|
||
tauriConf.app.trayIcon.iconPath = trayIconPath;
|
||
tauriConf.pake.system_tray_path = trayIconPath;
|
||
delete tauriConf.app.trayIcon;
|
||
const injectFilePath = path.join(npmDirectory, `src-tauri/src/inject/custom.js`);
|
||
// inject js or css files
|
||
if (inject?.length > 0) {
|
||
// Ensure inject is an array before calling .every()
|
||
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;
|
||
// Configure WASM support with required HTTP headers
|
||
if (wasm) {
|
||
tauriConf.app.security = {
|
||
headers: {
|
||
'Cross-Origin-Opener-Policy': 'same-origin',
|
||
'Cross-Origin-Embedder-Policy': 'require-corp',
|
||
},
|
||
};
|
||
}
|
||
// Save config file.
|
||
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 });
|
||
let tauriConf2 = JSON.parse(JSON.stringify(tauriConf));
|
||
delete tauriConf2.pake;
|
||
const configJsonPath = path.join(tauriConfigDirectory, 'tauri.conf.json');
|
||
await fsExtra.outputJSON(configJsonPath, tauriConf2, { spaces: 4 });
|
||
}
|
||
|
||
class BaseBuilder {
|
||
constructor(options) {
|
||
this.options = options;
|
||
}
|
||
getBuildEnvironment() {
|
||
return IS_MAC
|
||
? {
|
||
CFLAGS: '-fno-modules',
|
||
CXXFLAGS: '-fno-modules',
|
||
MACOSX_DEPLOYMENT_TARGET: '14.0',
|
||
}
|
||
: undefined;
|
||
}
|
||
getInstallTimeout() {
|
||
// Windows needs more time due to native compilation and antivirus scanning
|
||
return process.platform === 'win32' ? 900000 : 600000;
|
||
}
|
||
getBuildTimeout() {
|
||
return 900000;
|
||
}
|
||
async detectPackageManager() {
|
||
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) {
|
||
// 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) {
|
||
await this.buildAndCopy(url, this.options.targets);
|
||
}
|
||
async start(url) {
|
||
await mergeConfig(url, this.options, tauriConfig);
|
||
}
|
||
async buildAndCopy(url, target) {
|
||
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 = {
|
||
...(baseEnv ?? {}),
|
||
...(process.env.NO_STRIP ? { NO_STRIP: process.env.NO_STRIP } : {}),
|
||
};
|
||
const resolveExecEnv = () => Object.keys(buildEnv).length > 0 ? buildEnv : undefined;
|
||
// Warn users about potential AppImage build failures on modern Linux systems.
|
||
// The linuxdeploy tool bundled in Tauri uses an older strip tool that doesn't
|
||
// recognize the .relr.dyn section introduced in glibc 2.38+.
|
||
if (process.platform === 'linux' && this.options.targets === 'appimage') {
|
||
if (!buildEnv.NO_STRIP) {
|
||
logger.warn('⚠ Building AppImage on Linux may fail due to strip incompatibility with glibc 2.38+');
|
||
logger.warn('⚠ If build fails, retry with: NO_STRIP=1 pake <url> --targets appimage');
|
||
}
|
||
}
|
||
const buildCommand = `cd "${npmDirectory}" && ${this.getBuildCommand(packageManager)}`;
|
||
const buildTimeout = this.getBuildTimeout();
|
||
try {
|
||
await shellExec(buildCommand, buildTimeout, resolveExecEnv());
|
||
}
|
||
catch (error) {
|
||
const shouldRetryWithoutStrip = process.platform === 'linux' &&
|
||
this.options.targets === 'appimage' &&
|
||
!buildEnv.NO_STRIP &&
|
||
this.isLinuxDeployStripError(error);
|
||
if (shouldRetryWithoutStrip) {
|
||
logger.warn('⚠ AppImage build failed during linuxdeploy strip step, retrying with NO_STRIP=1 automatically.');
|
||
buildEnv = {
|
||
...buildEnv,
|
||
NO_STRIP: '1',
|
||
};
|
||
await shellExec(buildCommand, buildTimeout, resolveExecEnv());
|
||
}
|
||
else {
|
||
throw error;
|
||
}
|
||
}
|
||
// Copy app
|
||
const fileName = this.getFileName();
|
||
const fileType = this.getFileType(target);
|
||
const appPath = this.getBuildAppPath(npmDirectory, fileName, fileType);
|
||
const distPath = path.resolve(`${name}.${fileType}`);
|
||
await fsExtra.copy(appPath, distPath);
|
||
// Copy raw binary if requested
|
||
if (this.options.keepBinary) {
|
||
await this.copyRawBinary(npmDirectory, name);
|
||
}
|
||
await fsExtra.remove(appPath);
|
||
logger.success('✔ Build success!');
|
||
logger.success('✔ App installer located in', distPath);
|
||
// Log binary location if preserved
|
||
if (this.options.keepBinary) {
|
||
const binaryPath = this.getRawBinaryPath(name);
|
||
logger.success('✔ Raw binary located in', path.resolve(binaryPath));
|
||
}
|
||
}
|
||
getFileType(target) {
|
||
return target;
|
||
}
|
||
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'));
|
||
}
|
||
/**
|
||
* 解析目标架构
|
||
*/
|
||
resolveTargetArch(requestedArch) {
|
||
if (requestedArch === 'auto' || !requestedArch) {
|
||
return process.arch;
|
||
}
|
||
return requestedArch;
|
||
}
|
||
/**
|
||
* 获取Tauri构建目标
|
||
*/
|
||
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';
|
||
}
|
||
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';
|
||
}
|
||
// Add features
|
||
const features = this.getBuildFeatures();
|
||
if (features.length > 0) {
|
||
fullCommand += ` --features ${features.join(',')}`;
|
||
}
|
||
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' : '';
|
||
// 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
|
||
*/
|
||
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.packageManagerCache = null;
|
||
// 架构映射配置
|
||
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 (process.env.PAKE_CREATE_APP === '1') {
|
||
this.buildFormat = 'app';
|
||
}
|
||
else {
|
||
this.buildFormat = 'dmg';
|
||
}
|
||
this.options.targets = this.buildFormat;
|
||
}
|
||
getFileName() {
|
||
const { name } = 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();
|
||
const buildTarget = this.getTauriTarget(actualArch, 'darwin');
|
||
if (!buildTarget) {
|
||
throw new Error(`Unsupported architecture: ${actualArch} for macOS`);
|
||
}
|
||
let fullCommand = this.buildBaseCommand(packageManager, configPath, buildTarget);
|
||
const features = this.getBuildFeatures();
|
||
if (features.length > 0) {
|
||
fullCommand += ` --features ${features.join(',')}`;
|
||
}
|
||
return fullCommand;
|
||
}
|
||
getBasePath() {
|
||
const basePath = this.options.debug ? 'debug' : 'release';
|
||
const actualArch = this.getActualArch();
|
||
const target = this.getTauriTarget(actualArch, 'darwin');
|
||
return `src-tauri/target/${target}/${basePath}/bundle`;
|
||
}
|
||
hasArchSpecificTarget() {
|
||
return true;
|
||
}
|
||
getArchSpecificPath() {
|
||
const actualArch = this.getActualArch();
|
||
const target = this.getTauriTarget(actualArch, 'darwin');
|
||
return `src-tauri/target/${target}`;
|
||
}
|
||
}
|
||
|
||
class WinBuilder extends BaseBuilder {
|
||
constructor(options) {
|
||
super(options);
|
||
this.buildFormat = 'msi';
|
||
const validArchs = ['x64', 'arm64', 'auto'];
|
||
this.buildArch = validArchs.includes(options.targets || '')
|
||
? this.resolveTargetArch(options.targets)
|
||
: this.resolveTargetArch('auto');
|
||
this.options.targets = this.buildFormat;
|
||
}
|
||
getFileName() {
|
||
const { name } = this.options;
|
||
const language = tauriConfig.bundle.windows.wix.language[0];
|
||
const targetArch = this.getArchDisplayName(this.buildArch);
|
||
return `${name}_${tauriConfig.version}_${targetArch}_${language}`;
|
||
}
|
||
getBuildCommand(packageManager = 'pnpm') {
|
||
const configPath = path.join('src-tauri', '.pake', 'tauri.conf.json');
|
||
const buildTarget = this.getTauriTarget(this.buildArch, 'win32');
|
||
if (!buildTarget) {
|
||
throw new Error(`Unsupported architecture: ${this.buildArch} for Windows`);
|
||
}
|
||
let fullCommand = this.buildBaseCommand(packageManager, configPath, buildTarget);
|
||
const features = this.getBuildFeatures();
|
||
if (features.length > 0) {
|
||
fullCommand += ` --features ${features.join(',')}`;
|
||
}
|
||
return fullCommand;
|
||
}
|
||
getBasePath() {
|
||
const basePath = this.options.debug ? 'debug' : 'release';
|
||
const target = this.getTauriTarget(this.buildArch, 'win32');
|
||
return `src-tauri/target/${target}/${basePath}/bundle/`;
|
||
}
|
||
hasArchSpecificTarget() {
|
||
return true;
|
||
}
|
||
getArchSpecificPath() {
|
||
const target = this.getTauriTarget(this.buildArch, 'win32');
|
||
return `src-tauri/target/${target}`;
|
||
}
|
||
}
|
||
|
||
class LinuxBuilder extends BaseBuilder {
|
||
constructor(options) {
|
||
super(options);
|
||
const target = options.targets || 'deb';
|
||
if (target.includes('-arm64')) {
|
||
this.buildFormat = target.replace('-arm64', '');
|
||
this.buildArch = 'arm64';
|
||
}
|
||
else {
|
||
this.buildFormat = target;
|
||
this.buildArch = this.resolveTargetArch('auto');
|
||
}
|
||
this.options.targets = this.buildFormat;
|
||
}
|
||
getFileName() {
|
||
const { name, targets } = this.options;
|
||
const version = tauriConfig.version;
|
||
let arch;
|
||
if (this.buildArch === 'arm64') {
|
||
arch = targets === 'rpm' || targets === 'appimage' ? 'aarch64' : 'arm64';
|
||
}
|
||
else {
|
||
if (this.buildArch === 'x64') {
|
||
arch = targets === 'rpm' ? 'x86_64' : 'amd64';
|
||
}
|
||
else {
|
||
arch = this.buildArch;
|
||
if (this.buildArch === 'arm64' &&
|
||
(targets === 'rpm' || targets === 'appimage')) {
|
||
arch = 'aarch64';
|
||
}
|
||
}
|
||
}
|
||
if (targets === 'rpm') {
|
||
return `${name}-${version}-1.${arch}`;
|
||
}
|
||
return `${name}_${version}_${arch}`;
|
||
}
|
||
async build(url) {
|
||
const targetTypes = ['deb', 'appimage', 'rpm'];
|
||
for (const target of targetTypes) {
|
||
if (this.options.targets === target) {
|
||
await this.buildAndCopy(url, target);
|
||
}
|
||
}
|
||
}
|
||
getBuildCommand(packageManager = 'pnpm') {
|
||
const configPath = path.join('src-tauri', '.pake', 'tauri.conf.json');
|
||
const buildTarget = this.buildArch === 'arm64'
|
||
? this.getTauriTarget(this.buildArch, 'linux')
|
||
: undefined;
|
||
let fullCommand = this.buildBaseCommand(packageManager, configPath, buildTarget);
|
||
const features = this.getBuildFeatures();
|
||
if (features.length > 0) {
|
||
fullCommand += ` --features ${features.join(',')}`;
|
||
}
|
||
// Enable verbose output for AppImage builds when debugging or PAKE_VERBOSE is set.
|
||
// AppImage builds often fail with minimal error messages from linuxdeploy,
|
||
// so verbose mode helps diagnose issues like strip failures and missing dependencies.
|
||
if (this.options.targets === 'appimage' &&
|
||
(this.options.debug || process.env.PAKE_VERBOSE)) {
|
||
fullCommand += ' --verbose';
|
||
}
|
||
return fullCommand;
|
||
}
|
||
getBasePath() {
|
||
const basePath = this.options.debug ? 'debug' : 'release';
|
||
if (this.buildArch === 'arm64') {
|
||
const target = this.getTauriTarget(this.buildArch, 'linux');
|
||
return `src-tauri/target/${target}/${basePath}/bundle/`;
|
||
}
|
||
return super.getBasePath();
|
||
}
|
||
getFileType(target) {
|
||
if (target === 'appimage') {
|
||
return 'AppImage';
|
||
}
|
||
return super.getFileType(target);
|
||
}
|
||
hasArchSpecificTarget() {
|
||
return this.buildArch === 'arm64';
|
||
}
|
||
getArchSpecificPath() {
|
||
if (this.buildArch === 'arm64') {
|
||
const target = this.getTauriTarget(this.buildArch, 'linux');
|
||
return `src-tauri/target/${target}`;
|
||
}
|
||
return super.getArchSpecificPath();
|
||
}
|
||
}
|
||
|
||
const { platform } = process;
|
||
const buildersMap = {
|
||
darwin: MacBuilder,
|
||
win32: WinBuilder,
|
||
linux: LinuxBuilder,
|
||
};
|
||
class BuilderProvider {
|
||
static create(options) {
|
||
const Builder = buildersMap[platform];
|
||
if (!Builder) {
|
||
throw new Error('The current system is not supported!');
|
||
}
|
||
return new Builder(options);
|
||
}
|
||
}
|
||
|
||
const DEFAULT_PAKE_OPTIONS = {
|
||
icon: '',
|
||
height: 780,
|
||
width: 1200,
|
||
fullscreen: false,
|
||
maximize: false,
|
||
hideTitleBar: false,
|
||
alwaysOnTop: false,
|
||
appVersion: '1.0.0',
|
||
darkMode: false,
|
||
disabledWebShortcuts: false,
|
||
activationShortcut: '',
|
||
userAgent: '',
|
||
showSystemTray: false,
|
||
multiArch: false,
|
||
targets: 'deb',
|
||
useLocalFile: false,
|
||
systemTrayIcon: '',
|
||
proxyUrl: '',
|
||
debug: false,
|
||
inject: [],
|
||
installerLanguage: 'en-US',
|
||
hideOnClose: undefined, // Platform-specific: true for macOS, false for others
|
||
incognito: false,
|
||
wasm: false,
|
||
enableDragDrop: false,
|
||
keepBinary: false,
|
||
multiInstance: false,
|
||
startToTray: false,
|
||
forceInternalNavigation: false,
|
||
zoom: 100,
|
||
minWidth: 0,
|
||
minHeight: 0,
|
||
ignoreCertificateErrors: false,
|
||
};
|
||
|
||
async function checkUpdateTips() {
|
||
updateNotifier({ pkg: packageJson, updateCheckInterval: 1000 * 60 }).notify({
|
||
isGlobal: true,
|
||
});
|
||
}
|
||
|
||
const ICON_CONFIG = {
|
||
minFileSize: 100,
|
||
supportedFormats: ['png', 'ico', 'jpeg', 'jpg', 'webp', 'icns'],
|
||
whiteBackground: { r: 255, g: 255, b: 255 },
|
||
transparentBackground: { r: 255, g: 255, b: 255, alpha: 0 },
|
||
downloadTimeout: {
|
||
ci: 5000,
|
||
default: 15000,
|
||
},
|
||
};
|
||
const PLATFORM_CONFIG = {
|
||
win: { format: '.ico', sizes: [16, 32, 48, 64, 128, 256] },
|
||
linux: { format: '.png', size: 512 },
|
||
macos: { format: '.icns', sizes: [16, 32, 64, 128, 256, 512, 1024] },
|
||
};
|
||
const API_KEYS = {
|
||
logoDev: ['pk_JLLMUKGZRpaG5YclhXaTkg', 'pk_Ph745P8mQSeYFfW2Wk039A'],
|
||
brandfetch: ['1idqvJC0CeFSeyp3Yf7', '1idej-yhU_ThggIHFyG'],
|
||
};
|
||
function generateIconPath(appName, isDefault = false) {
|
||
const safeName = isDefault
|
||
? 'icon'
|
||
: generateSafeFilename(appName).toLowerCase();
|
||
const baseName = safeName;
|
||
if (IS_WIN) {
|
||
return path.join(npmDirectory, 'src-tauri', 'png', `${baseName}_256.ico`);
|
||
}
|
||
if (IS_LINUX) {
|
||
return path.join(npmDirectory, 'src-tauri', 'png', `${baseName}_512.png`);
|
||
}
|
||
return path.join(npmDirectory, 'src-tauri', 'icons', `${baseName}.icns`);
|
||
}
|
||
async function copyWindowsIconIfNeeded(convertedPath, appName) {
|
||
if (!IS_WIN || !convertedPath.endsWith('.ico')) {
|
||
return convertedPath;
|
||
}
|
||
try {
|
||
const finalIconPath = generateIconPath(appName);
|
||
await fsExtra.ensureDir(path.dirname(finalIconPath));
|
||
await fsExtra.copy(convertedPath, finalIconPath);
|
||
return finalIconPath;
|
||
}
|
||
catch (error) {
|
||
logger.warn(`Failed to copy Windows icon: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||
return convertedPath;
|
||
}
|
||
}
|
||
/**
|
||
* Adds white background to transparent icons only
|
||
*/
|
||
async function preprocessIcon(inputPath) {
|
||
try {
|
||
const metadata = await sharp(inputPath).metadata();
|
||
if (metadata.channels !== 4)
|
||
return inputPath; // No transparency
|
||
const { path: tempDir } = await dir();
|
||
const outputPath = path.join(tempDir, 'icon-with-background.png');
|
||
await sharp({
|
||
create: {
|
||
width: metadata.width || 512,
|
||
height: metadata.height || 512,
|
||
channels: 3,
|
||
background: ICON_CONFIG.whiteBackground,
|
||
},
|
||
})
|
||
.composite([{ input: inputPath }])
|
||
.png()
|
||
.toFile(outputPath);
|
||
return outputPath;
|
||
}
|
||
catch (error) {
|
||
logger.warn(`Failed to add background to icon: ${error.message}`);
|
||
return inputPath;
|
||
}
|
||
}
|
||
/**
|
||
* Converts icon to platform-specific format
|
||
*/
|
||
async function convertIconFormat(inputPath, appName) {
|
||
try {
|
||
if (!(await fsExtra.pathExists(inputPath)))
|
||
return null;
|
||
const { path: outputDir } = await dir();
|
||
const platformOutputDir = path.join(outputDir, 'converted-icons');
|
||
await fsExtra.ensureDir(platformOutputDir);
|
||
const processedInputPath = await preprocessIcon(inputPath);
|
||
const iconName = generateSafeFilename(appName).toLowerCase();
|
||
// Generate platform-specific format
|
||
if (IS_WIN) {
|
||
// Support multiple sizes for better Windows compatibility
|
||
await icongen(processedInputPath, platformOutputDir, {
|
||
report: false,
|
||
ico: {
|
||
name: `${iconName}_256`,
|
||
sizes: PLATFORM_CONFIG.win.sizes,
|
||
},
|
||
});
|
||
return path.join(platformOutputDir, `${iconName}_256${PLATFORM_CONFIG.win.format}`);
|
||
}
|
||
if (IS_LINUX) {
|
||
const outputPath = path.join(platformOutputDir, `${iconName}_${PLATFORM_CONFIG.linux.size}${PLATFORM_CONFIG.linux.format}`);
|
||
// Ensure we convert to proper PNG format with correct size
|
||
await sharp(processedInputPath)
|
||
.resize(PLATFORM_CONFIG.linux.size, PLATFORM_CONFIG.linux.size, {
|
||
fit: 'contain',
|
||
background: ICON_CONFIG.transparentBackground,
|
||
})
|
||
.png()
|
||
.toFile(outputPath);
|
||
return outputPath;
|
||
}
|
||
// macOS
|
||
await icongen(processedInputPath, platformOutputDir, {
|
||
report: false,
|
||
icns: { name: iconName, sizes: PLATFORM_CONFIG.macos.sizes },
|
||
});
|
||
const outputPath = path.join(platformOutputDir, `${iconName}${PLATFORM_CONFIG.macos.format}`);
|
||
return (await fsExtra.pathExists(outputPath)) ? outputPath : null;
|
||
}
|
||
catch (error) {
|
||
logger.warn(`Icon format conversion failed: ${error.message}`);
|
||
return null;
|
||
}
|
||
}
|
||
/**
|
||
* Processes downloaded or local icon for platform-specific format
|
||
*/
|
||
async function processIcon(iconPath, appName) {
|
||
if (!iconPath || !appName)
|
||
return iconPath;
|
||
// Check if already in correct platform format
|
||
const ext = path.extname(iconPath).toLowerCase();
|
||
const isCorrectFormat = (IS_WIN && ext === '.ico') ||
|
||
(IS_LINUX && ext === '.png') ||
|
||
(!IS_WIN && !IS_LINUX && ext === '.icns');
|
||
if (isCorrectFormat) {
|
||
return await copyWindowsIconIfNeeded(iconPath, appName);
|
||
}
|
||
// Convert to platform format
|
||
const convertedPath = await convertIconFormat(iconPath, appName);
|
||
if (convertedPath) {
|
||
return await copyWindowsIconIfNeeded(convertedPath, appName);
|
||
}
|
||
return iconPath;
|
||
}
|
||
/**
|
||
* Gets default icon with platform-specific fallback logic
|
||
*/
|
||
async function getDefaultIcon() {
|
||
logger.info('✼ No icon provided, using default icon.');
|
||
if (IS_WIN) {
|
||
const defaultIcoPath = generateIconPath('icon', true);
|
||
const defaultPngPath = path.join(npmDirectory, 'src-tauri/png/icon_512.png');
|
||
// Try default ico first
|
||
if (await fsExtra.pathExists(defaultIcoPath)) {
|
||
return defaultIcoPath;
|
||
}
|
||
// Convert from png if ico doesn't exist
|
||
if (await fsExtra.pathExists(defaultPngPath)) {
|
||
logger.info('✼ Default ico not found, converting from png...');
|
||
try {
|
||
const convertedPath = await convertIconFormat(defaultPngPath, 'icon');
|
||
if (convertedPath && (await fsExtra.pathExists(convertedPath))) {
|
||
return await copyWindowsIconIfNeeded(convertedPath, 'icon');
|
||
}
|
||
}
|
||
catch (error) {
|
||
logger.warn(`Failed to convert default png to ico: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||
}
|
||
}
|
||
// Fallback to png or empty
|
||
if (await fsExtra.pathExists(defaultPngPath)) {
|
||
logger.warn('✼ Using png as fallback for Windows (may cause issues).');
|
||
return defaultPngPath;
|
||
}
|
||
logger.warn('✼ No default icon found, will use pake default.');
|
||
return '';
|
||
}
|
||
// Linux and macOS defaults
|
||
const iconPath = IS_LINUX
|
||
? 'src-tauri/png/icon_512.png'
|
||
: 'src-tauri/icons/icon.icns';
|
||
return path.join(npmDirectory, iconPath);
|
||
}
|
||
/**
|
||
* Main icon handling function with simplified logic flow
|
||
*/
|
||
async function handleIcon(options, url) {
|
||
// Handle custom icon (local file or remote URL)
|
||
if (options.icon) {
|
||
if (options.icon.startsWith('http')) {
|
||
const downloadedPath = await downloadIcon(options.icon);
|
||
if (downloadedPath) {
|
||
const result = await processIcon(downloadedPath, options.name || '');
|
||
if (result)
|
||
return result;
|
||
}
|
||
return '';
|
||
}
|
||
// Local file path
|
||
const resolvedPath = path.resolve(options.icon);
|
||
const result = await processIcon(resolvedPath, options.name || '');
|
||
return result || resolvedPath;
|
||
}
|
||
// Try favicon from website
|
||
if (url && options.name) {
|
||
const faviconPath = await tryGetFavicon(url, options.name);
|
||
if (faviconPath)
|
||
return faviconPath;
|
||
}
|
||
// Use default icon
|
||
return await getDefaultIcon();
|
||
}
|
||
/**
|
||
* Generates icon service URLs for a domain
|
||
*/
|
||
function generateIconServiceUrls(domain) {
|
||
const logoDevUrls = API_KEYS.logoDev
|
||
.sort(() => Math.random() - 0.5)
|
||
.map((token) => `https://img.logo.dev/${domain}?token=${token}&format=png&size=256`);
|
||
const brandfetchUrls = API_KEYS.brandfetch
|
||
.sort(() => Math.random() - 0.5)
|
||
.map((key) => `https://cdn.brandfetch.io/${domain}/w/400/h/400?c=${key}`);
|
||
return [
|
||
...logoDevUrls,
|
||
...brandfetchUrls,
|
||
`https://logo.clearbit.com/${domain}?size=256`,
|
||
`https://www.google.com/s2/favicons?domain=${domain}&sz=256`,
|
||
`https://favicon.is/${domain}`,
|
||
`https://${domain}/favicon.ico`,
|
||
`https://www.${domain}/favicon.ico`,
|
||
];
|
||
}
|
||
/**
|
||
* Attempts to fetch favicon from website
|
||
*/
|
||
async function tryGetFavicon(url, appName) {
|
||
try {
|
||
const domain = new URL(url).hostname;
|
||
const spinner = getSpinner(`Fetching icon from ${domain}...`);
|
||
const serviceUrls = generateIconServiceUrls(domain);
|
||
const isCI = process.env.CI === 'true' || process.env.GITHUB_ACTIONS === 'true';
|
||
const downloadTimeout = isCI
|
||
? ICON_CONFIG.downloadTimeout.ci
|
||
: ICON_CONFIG.downloadTimeout.default;
|
||
for (const serviceUrl of serviceUrls) {
|
||
try {
|
||
const faviconPath = await downloadIcon(serviceUrl, false, downloadTimeout);
|
||
if (!faviconPath)
|
||
continue;
|
||
const convertedPath = await convertIconFormat(faviconPath, appName);
|
||
if (convertedPath) {
|
||
const finalPath = await copyWindowsIconIfNeeded(convertedPath, appName);
|
||
spinner.succeed(chalk.green('Icon fetched and converted successfully!'));
|
||
return finalPath;
|
||
}
|
||
}
|
||
catch (error) {
|
||
logger.debug(`Icon service ${serviceUrl} failed: ${error.message}`);
|
||
// Platform-specific error handling
|
||
if ((IS_LINUX || IS_WIN) && error.code === 'ENOTFOUND') {
|
||
logger.debug(`DNS resolution failed for ${serviceUrl}, trying next service...`);
|
||
}
|
||
// Windows-specific icon conversion errors
|
||
if (IS_WIN && error.message.includes('icongen')) {
|
||
logger.debug(`Windows icon conversion failed for ${serviceUrl}, trying next service...`);
|
||
}
|
||
continue;
|
||
}
|
||
}
|
||
spinner.warn(`No favicon found for ${domain}. Using default.`);
|
||
return null;
|
||
}
|
||
catch (error) {
|
||
logger.warn(`Failed to fetch favicon: ${error.message}`);
|
||
return null;
|
||
}
|
||
}
|
||
/**
|
||
* Downloads icon from URL
|
||
*/
|
||
async function downloadIcon(iconUrl, showSpinner = true, customTimeout) {
|
||
try {
|
||
const response = await axios.get(iconUrl, {
|
||
responseType: 'arraybuffer',
|
||
timeout: customTimeout || 10000,
|
||
});
|
||
const iconData = response.data;
|
||
if (!iconData || iconData.byteLength < ICON_CONFIG.minFileSize)
|
||
return null;
|
||
const fileDetails = await fileTypeFromBuffer(iconData);
|
||
if (!fileDetails ||
|
||
!ICON_CONFIG.supportedFormats.includes(fileDetails.ext)) {
|
||
return null;
|
||
}
|
||
return await saveIconFile(iconData, fileDetails.ext);
|
||
}
|
||
catch (error) {
|
||
if (showSpinner && !(error.response?.status === 404)) {
|
||
throw error;
|
||
}
|
||
return null;
|
||
}
|
||
}
|
||
/**
|
||
* Saves icon file to temporary location
|
||
*/
|
||
async function saveIconFile(iconData, extension) {
|
||
const buffer = Buffer.from(iconData);
|
||
const { path: tempPath } = await dir();
|
||
// Always save with the original extension first
|
||
const originalIconPath = path.join(tempPath, `icon.${extension}`);
|
||
await fsExtra.outputFile(originalIconPath, buffer);
|
||
return originalIconPath;
|
||
}
|
||
|
||
// Extracts the domain from a given URL.
|
||
function getDomain(inputUrl) {
|
||
try {
|
||
const url = new URL(inputUrl);
|
||
// Use PSL to parse domain names.
|
||
const parsed = psl.parse(url.hostname);
|
||
// If domain is available, split it and return the SLD.
|
||
if ('domain' in parsed && parsed.domain) {
|
||
return parsed.domain.split('.')[0];
|
||
}
|
||
else {
|
||
return null;
|
||
}
|
||
}
|
||
catch (error) {
|
||
return null;
|
||
}
|
||
}
|
||
// Appends 'https://' protocol to the URL if not present.
|
||
function appendProtocol(inputUrl) {
|
||
try {
|
||
new URL(inputUrl);
|
||
return inputUrl;
|
||
}
|
||
catch {
|
||
return `https://${inputUrl}`;
|
||
}
|
||
}
|
||
// Normalizes the URL by ensuring it has a protocol and is valid.
|
||
function normalizeUrl(urlToNormalize) {
|
||
const urlWithProtocol = appendProtocol(urlToNormalize);
|
||
try {
|
||
new URL(urlWithProtocol);
|
||
return urlWithProtocol;
|
||
}
|
||
catch (err) {
|
||
throw new Error(`Your url "${urlWithProtocol}" is invalid: ${err.message}`);
|
||
}
|
||
}
|
||
|
||
function resolveAppName(name, platform) {
|
||
const domain = getDomain(name) || 'pake';
|
||
return platform !== 'linux' ? capitalizeFirstLetter(domain) : domain;
|
||
}
|
||
function isValidName(name, platform) {
|
||
const platformRegexMapping = {
|
||
linux: /^[a-z0-9\u4e00-\u9fff][a-z0-9\u4e00-\u9fff-]*$/,
|
||
default: /^[a-zA-Z0-9\u4e00-\u9fff][a-zA-Z0-9\u4e00-\u9fff- ]*$/,
|
||
};
|
||
const reg = platformRegexMapping[platform] || platformRegexMapping.default;
|
||
return !!name && reg.test(name);
|
||
}
|
||
async function handleOptions(options, url) {
|
||
const { platform } = process;
|
||
const isActions = process.env.GITHUB_ACTIONS;
|
||
let name = options.name;
|
||
const pathExists = await fsExtra.pathExists(url);
|
||
if (!options.name) {
|
||
const defaultName = pathExists ? '' : resolveAppName(url, platform);
|
||
const promptMessage = 'Enter your application name';
|
||
const namePrompt = await promptText(promptMessage, defaultName);
|
||
name = namePrompt || defaultName;
|
||
}
|
||
if (name && platform === 'linux') {
|
||
name = generateLinuxPackageName(name);
|
||
}
|
||
if (!isValidName(name, platform)) {
|
||
const LINUX_NAME_ERROR = `✕ Name should only include lowercase letters, numbers, and dashes (not leading dashes). Examples: com-123-xxx, 123pan, pan123, weread, we-read, 123.`;
|
||
const DEFAULT_NAME_ERROR = `✕ Name should only include letters, numbers, dashes, and spaces (not leading dashes and spaces). Examples: 123pan, 123Pan, Pan123, weread, WeRead, WERead, we-read, We Read, 123.`;
|
||
const errorMsg = platform === 'linux' ? LINUX_NAME_ERROR : DEFAULT_NAME_ERROR;
|
||
logger.error(errorMsg);
|
||
if (isActions) {
|
||
name = resolveAppName(url, platform);
|
||
logger.warn(`✼ Inside github actions, use the default name: ${name}`);
|
||
}
|
||
else {
|
||
process.exit(1);
|
||
}
|
||
}
|
||
const appOptions = {
|
||
...options,
|
||
name,
|
||
identifier: getIdentifier(url),
|
||
};
|
||
const iconPath = await handleIcon(appOptions, url);
|
||
appOptions.icon = iconPath || undefined;
|
||
return appOptions;
|
||
}
|
||
|
||
function validateNumberInput(value) {
|
||
const parsedValue = Number(value);
|
||
if (isNaN(parsedValue)) {
|
||
throw new InvalidArgumentError('Not a number.');
|
||
}
|
||
return parsedValue;
|
||
}
|
||
function validateUrlInput(url) {
|
||
const isFile = fs.existsSync(url);
|
||
if (!isFile) {
|
||
try {
|
||
return normalizeUrl(url);
|
||
}
|
||
catch (error) {
|
||
throw new InvalidArgumentError(error.message);
|
||
}
|
||
}
|
||
return url;
|
||
}
|
||
|
||
const { green, yellow } = chalk;
|
||
const logo = `${chalk.green(' ____ _')}
|
||
${green('| _ \\ __ _| | _____')}
|
||
${green('| |_) / _` | |/ / _ \\')}
|
||
${green('| __/ (_| | < __/')} ${yellow('https://github.com/tw93/pake')}
|
||
${green('|_| \\__,_|_|\\_\\___| can turn any webpage into a desktop app with Rust.')}
|
||
`;
|
||
program
|
||
.addHelpText('beforeAll', logo)
|
||
.usage(`[url] [options]`)
|
||
.showHelpAfterError();
|
||
program
|
||
.argument('[url]', 'The web URL you want to package', validateUrlInput)
|
||
// Refer to https://github.com/tj/commander.js#custom-option-processing, turn string array into a string connected with custom connectors.
|
||
// If the platform is Linux, use `-` as the connector, and convert all characters to lowercase.
|
||
// For example, Google Translate will become google-translate.
|
||
.option('--name <string>', 'Application name')
|
||
.option('--icon <string>', 'Application icon', DEFAULT_PAKE_OPTIONS.icon)
|
||
.option('--width <number>', 'Window width', validateNumberInput, DEFAULT_PAKE_OPTIONS.width)
|
||
.option('--height <number>', 'Window height', validateNumberInput, DEFAULT_PAKE_OPTIONS.height)
|
||
.option('--use-local-file', 'Use local file packaging', DEFAULT_PAKE_OPTIONS.useLocalFile)
|
||
.option('--fullscreen', 'Start in full screen', DEFAULT_PAKE_OPTIONS.fullscreen)
|
||
.option('--hide-title-bar', 'For Mac, hide title bar', DEFAULT_PAKE_OPTIONS.hideTitleBar)
|
||
.option('--multi-arch', 'For Mac, both Intel and M1', DEFAULT_PAKE_OPTIONS.multiArch)
|
||
.option('--inject <files>', 'Inject local CSS/JS files into the page', (val, previous) => {
|
||
if (!val)
|
||
return DEFAULT_PAKE_OPTIONS.inject;
|
||
// Split by comma and trim whitespace, filter out empty strings
|
||
const files = val
|
||
.split(',')
|
||
.map((item) => item.trim())
|
||
.filter((item) => item.length > 0);
|
||
// If previous values exist (from multiple --inject options), merge them
|
||
return previous ? [...previous, ...files] : files;
|
||
}, DEFAULT_PAKE_OPTIONS.inject)
|
||
.option('--debug', 'Debug build and more output', DEFAULT_PAKE_OPTIONS.debug)
|
||
.addOption(new Option('--proxy-url <url>', 'Proxy URL for all network requests (http://, https://, socks5://)')
|
||
.default(DEFAULT_PAKE_OPTIONS.proxyUrl)
|
||
.hideHelp())
|
||
.addOption(new Option('--user-agent <string>', 'Custom user agent')
|
||
.default(DEFAULT_PAKE_OPTIONS.userAgent)
|
||
.hideHelp())
|
||
.addOption(new Option('--targets <string>', 'Build target format for your system').default(DEFAULT_PAKE_OPTIONS.targets))
|
||
.addOption(new Option('--app-version <string>', 'App version, the same as package.json version')
|
||
.default(DEFAULT_PAKE_OPTIONS.appVersion)
|
||
.hideHelp())
|
||
.addOption(new Option('--always-on-top', 'Always on the top level')
|
||
.default(DEFAULT_PAKE_OPTIONS.alwaysOnTop)
|
||
.hideHelp())
|
||
.addOption(new Option('--maximize', 'Start window maximized')
|
||
.default(DEFAULT_PAKE_OPTIONS.maximize)
|
||
.hideHelp())
|
||
.addOption(new Option('--dark-mode', 'Force Mac app to use dark mode')
|
||
.default(DEFAULT_PAKE_OPTIONS.darkMode)
|
||
.hideHelp())
|
||
.addOption(new Option('--disabled-web-shortcuts', 'Disabled webPage shortcuts')
|
||
.default(DEFAULT_PAKE_OPTIONS.disabledWebShortcuts)
|
||
.hideHelp())
|
||
.addOption(new Option('--activation-shortcut <string>', 'Shortcut key to active App')
|
||
.default(DEFAULT_PAKE_OPTIONS.activationShortcut)
|
||
.hideHelp())
|
||
.addOption(new Option('--show-system-tray', 'Show system tray in app')
|
||
.default(DEFAULT_PAKE_OPTIONS.showSystemTray)
|
||
.hideHelp())
|
||
.addOption(new Option('--system-tray-icon <string>', 'Custom system tray icon')
|
||
.default(DEFAULT_PAKE_OPTIONS.systemTrayIcon)
|
||
.hideHelp())
|
||
.addOption(new Option('--hide-on-close [boolean]', 'Hide window on close instead of exiting (default: true for macOS, false for others)')
|
||
.default(DEFAULT_PAKE_OPTIONS.hideOnClose)
|
||
.argParser((value) => {
|
||
if (value === undefined)
|
||
return true; // --hide-on-close without value
|
||
if (value === 'true')
|
||
return true;
|
||
if (value === 'false')
|
||
return false;
|
||
throw new Error('--hide-on-close must be true or false');
|
||
})
|
||
.hideHelp())
|
||
.addOption(new Option('--title <string>', 'Window title').hideHelp())
|
||
.addOption(new Option('--incognito', 'Launch app in incognito/private mode')
|
||
.default(DEFAULT_PAKE_OPTIONS.incognito)
|
||
.hideHelp())
|
||
.addOption(new Option('--wasm', 'Enable WebAssembly support (Flutter Web, etc.)')
|
||
.default(DEFAULT_PAKE_OPTIONS.wasm)
|
||
.hideHelp())
|
||
.addOption(new Option('--enable-drag-drop', 'Enable drag and drop functionality')
|
||
.default(DEFAULT_PAKE_OPTIONS.enableDragDrop)
|
||
.hideHelp())
|
||
.addOption(new Option('--keep-binary', 'Keep raw binary file alongside installer')
|
||
.default(DEFAULT_PAKE_OPTIONS.keepBinary)
|
||
.hideHelp())
|
||
.addOption(new Option('--multi-instance', 'Allow multiple app instances')
|
||
.default(DEFAULT_PAKE_OPTIONS.multiInstance)
|
||
.hideHelp())
|
||
.addOption(new Option('--start-to-tray', 'Start app minimized to tray')
|
||
.default(DEFAULT_PAKE_OPTIONS.startToTray)
|
||
.hideHelp())
|
||
.addOption(new Option('--force-internal-navigation', 'Keep every link inside the Pake window instead of opening external handlers')
|
||
.default(DEFAULT_PAKE_OPTIONS.forceInternalNavigation)
|
||
.hideHelp())
|
||
.addOption(new Option('--installer-language <string>', 'Installer language')
|
||
.default(DEFAULT_PAKE_OPTIONS.installerLanguage)
|
||
.hideHelp())
|
||
.addOption(new Option('--zoom <number>', 'Initial page zoom level (50-200)')
|
||
.default(DEFAULT_PAKE_OPTIONS.zoom)
|
||
.argParser((value) => {
|
||
const zoom = parseInt(value);
|
||
if (isNaN(zoom) || zoom < 50 || zoom > 200) {
|
||
throw new Error('--zoom must be a number between 50 and 200');
|
||
}
|
||
return zoom;
|
||
})
|
||
.hideHelp())
|
||
.addOption(new Option('--min-width <number>', 'Minimum window width')
|
||
.default(DEFAULT_PAKE_OPTIONS.minWidth)
|
||
.argParser(validateNumberInput)
|
||
.hideHelp())
|
||
.addOption(new Option('--min-height <number>', 'Minimum window height')
|
||
.default(DEFAULT_PAKE_OPTIONS.minHeight)
|
||
.argParser(validateNumberInput)
|
||
.hideHelp())
|
||
.addOption(new Option('--ignore-certificate-errors', 'Ignore certificate errors (for self-signed certificates)')
|
||
.default(DEFAULT_PAKE_OPTIONS.ignoreCertificateErrors)
|
||
.hideHelp())
|
||
.version(packageJson.version, '-v, --version')
|
||
.configureHelp({
|
||
sortSubcommands: true,
|
||
optionTerm: (option) => {
|
||
if (option.flags === '-v, --version' || option.flags === '-h, --help')
|
||
return '';
|
||
return option.flags;
|
||
},
|
||
optionDescription: (option) => {
|
||
if (option.flags === '-v, --version' || option.flags === '-h, --help')
|
||
return '';
|
||
return option.description;
|
||
},
|
||
})
|
||
.action(async (url, options) => {
|
||
await checkUpdateTips();
|
||
if (!url) {
|
||
program.help({
|
||
error: false,
|
||
});
|
||
return;
|
||
}
|
||
log.setDefaultLevel('info');
|
||
log.setLevel('info');
|
||
if (options.debug) {
|
||
log.setLevel('debug');
|
||
}
|
||
const appOptions = await handleOptions(options, url);
|
||
const builder = BuilderProvider.create(appOptions);
|
||
await builder.prepare();
|
||
await builder.build(url);
|
||
});
|
||
program.parse();
|