UNPKG

pake-cli

Version:

🤱🏻 Turn any webpage into a desktop app with Rust. 🤱🏻 利用 Rust 轻松构建轻量级多端桌面应用。

821 lines (795 loc) 27 kB
import log from 'loglevel'; import fsExtra from 'fs-extra'; import chalk from 'chalk'; import path from 'path'; import axios from 'axios'; import { dir } from 'tmp-promise'; import { fileURLToPath } from 'url'; import crypto from 'crypto'; import prompts from 'prompts'; import ora from 'ora'; import { fileTypeFromBuffer } from 'file-type'; import * as psl from 'psl'; import 'is-url'; import shelljs from 'shelljs'; import dns from 'dns'; import http from 'http'; import { promisify } from 'util'; import fs from 'fs'; const DEFAULT_PAKE_OPTIONS = { icon: '', height: 780, width: 1200, fullscreen: false, resizable: true, 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', }; // Just for cli development const DEFAULT_DEV_PAKE_OPTIONS = { ...DEFAULT_PAKE_OPTIONS, url: 'https://weread.qq.com', name: 'WeRead', hideTitleBar: true, }; 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))); }, }; // 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'); const { platform: platform$2 } = process; const IS_MAC = platform$2 === 'darwin'; const IS_WIN = platform$2 === 'win32'; const IS_LINUX = platform$2 === 'linux'; // 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(); } async function handleIcon(options) { if (options.icon) { if (options.icon.startsWith('http')) { return downloadIcon(options.icon); } else { return path.resolve(options.icon); } } else { logger.warn('✼ No icon given, default in use. For a custom icon, use --icon option.'); const iconPath = IS_WIN ? 'src-tauri/png/icon_256.ico' : IS_LINUX ? 'src-tauri/png/icon_512.png' : 'src-tauri/icons/icon.icns'; return path.join(npmDirectory, iconPath); } } async function downloadIcon(iconUrl) { const spinner = getSpinner('Downloading icon...'); try { const iconResponse = await axios.get(iconUrl, { responseType: 'arraybuffer' }); const iconData = await iconResponse.data; if (!iconData) { return null; } const fileDetails = await fileTypeFromBuffer(iconData); if (!fileDetails) { return null; } const { path: tempPath } = await dir(); let iconPath = `${tempPath}/icon.${fileDetails.ext}`; // Fix this for linux if (IS_LINUX) { iconPath = 'png/linux_temp.png'; await fsExtra.outputFile(`${npmDirectory}/src-tauri/${iconPath}`, iconData); } else { await fsExtra.outputFile(iconPath, iconData); } await fsExtra.outputFile(iconPath, iconData); spinner.succeed(chalk.green('Icon downloaded successfully!')); return iconPath; } catch (error) { spinner.fail(chalk.red('Icon download failed!')); if (error.response && error.response.status === 404) { return null; } throw error; } } // 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; } } 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]+(-[a-z0-9]+)*$/, default: /^[a-zA-Z0-9]+([-a-zA-Z0-9])*$/, }; 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 (!isValidName(name, platform)) { const LINUX_NAME_ERROR = `✕ name should only include lowercase letters, numbers, and dashes, and must contain at least one lowercase letter. Examples: com-123-xxx, 123pan, pan123, weread, we-read.`; const DEFAULT_NAME_ERROR = `✕ Name should only include letters and numbers, and dashes (dashes must not at the beginning), and must contain at least one letter. Examples: 123pan, 123Pan, Pan123, weread, WeRead, WERead, we-read.`; 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), }; appOptions.icon = await handleIcon(appOptions); return appOptions; } var windows = [ { url: "https://weread.qq.com", url_type: "web", hide_title_bar: true, fullscreen: false, width: 1200, height: 780, resizable: true, always_on_top: false, dark_mode: false, activation_shortcut: "", disabled_web_shortcuts: false } ]; var user_agent = { macos: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.1 Safari/605.1.15", linux: "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36", windows: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36" }; var system_tray = { macos: false, linux: true, windows: true }; var system_tray_path = "icons/icon.png"; var inject = [ ]; var proxy_url = ""; var pakeConf = { windows: windows, user_agent: user_agent, system_tray: system_tray, system_tray_path: system_tray_path, inject: inject, proxy_url: proxy_url }; var productName$1 = "WeRead"; var identifier = "com.pake.weread"; var version = "1.0.0"; var app = { withGlobalTauri: true, trayIcon: { iconPath: "png/weread_512.png", iconAsTemplate: false, id: "pake-tray" } }; var build = { frontendDist: "../dist" }; var CommonConf = { productName: productName$1, identifier: identifier, version: version, app: app, build: build }; var bundle$2 = { icon: [ "png/weread_256.ico", "png/weread_32.ico" ], active: true, resources: [ "png/weread_32.ico" ], targets: [ "msi" ], windows: { digestAlgorithm: "sha256", wix: { language: [ "en-US" ], template: "assets/main.wxs" } } }; var WinConf = { bundle: bundle$2 }; var bundle$1 = { icon: [ "icons/weread.icns" ], active: true, macOS: { }, targets: [ "dmg" ] }; var MacConf = { bundle: bundle$1 }; var productName = "we-read"; var bundle = { icon: [ "png/weread_512.png" ], active: true, linux: { deb: { depends: [ "curl", "wget" ], files: { "/usr/share/applications/com-pake-weread.desktop": "assets/com-pake-weread.desktop" } } }, targets: [ "deb", "appimage" ] }; var LinuxConf = { productName: productName, bundle: bundle }; const platformConfigs = { win32: WinConf, darwin: MacConf, linux: LinuxConf, }; const { platform: platform$1 } = process; // @ts-ignore const platformConfig = platformConfigs[platform$1]; let tauriConfig = { ...CommonConf, bundle: platformConfig.bundle, app: { ...CommonConf.app, trayIcon: { ...(platformConfig?.app?.trayIcon ?? {}), }, }, build: CommonConf.build, pake: pakeConf, }; function shellExec(command) { return new Promise((resolve, reject) => { shelljs.exec(command, { async: true, silent: false, cwd: npmDirectory }, code => { if (code === 0) { resolve(0); } else { reject(new Error(`Error occurred while executing command "${command}". Exit code: ${code}`)); } }); }); } 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; } } 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); spinner.succeed(chalk.green('Rust installed successfully!')); } catch (error) { console.error('Error installing Rust:', error.message); spinner.fail(chalk.red('Rust installation failed!')); process.exit(1); } } function checkRustInstalled() { return shelljs.exec('rustc --version', { silent: true }).code === 0; } 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; } async function mergeConfig(url, options, tauriConf) { const { width, height, fullscreen, hideTitleBar, alwaysOnTop, appVersion, darkMode, disabledWebShortcuts, activationShortcut, userAgent, showSystemTray, systemTrayIcon, useLocalFile, identifier, name, resizable = true, inject, proxyUrl, installerLanguage, } = options; const { platform } = process; // Set Windows parameters. const tauriConfWindowOptions = { width, height, fullscreen, resizable, hide_title_bar: hideTitleBar, activation_shortcut: activationShortcut, always_on_top: alwaysOnTop, dark_mode: darkMode, disabled_web_shortcuts: disabledWebShortcuts, }; Object.assign(tauriConf.pake.windows[0], { url, ...tauriConfWindowOptions }); tauriConf.productName = name; tauriConf.identifier = identifier; tauriConf.version = appVersion; if (platform == 'win32') { tauriConf.bundle.windows.wix.language[0] = installerLanguage; } //Judge the type of URL, whether it is a file or a website. 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') { delete tauriConf.bundle.linux.deb.files; const validTargets = ['all', 'deb', 'appimage', 'rpm']; if (validTargets.includes(options.targets)) { tauriConf.bundle.targets = options.targets === 'all' ? ['deb', 'appimage', 'rpm'] : [options.targets]; } else { logger.warn(`✼ The target must be one of ${validTargets.join(', ')}, the default 'deb' will be used.`); } } // Set icon. const platformIconMap = { win32: { fileExt: '.ico', path: `png/${name.toLowerCase()}_256.ico`, defaultIcon: 'png/icon_256.ico', message: 'Windows icon must be .ico and 256x256px.', }, linux: { fileExt: '.png', path: `png/${name.toLowerCase()}_512.png`, defaultIcon: 'png/icon_512.png', message: 'Linux icon must be .png and 512x512px.', }, darwin: { fileExt: '.icns', path: `icons/${name.toLowerCase()}.icns`, defaultIcon: 'icons/icon.icns', message: 'macOS icon must be .icns type.', }, }; const iconInfo = platformIconMap[platform]; const exists = await fsExtra.pathExists(options.icon); if (exists) { let updateIconPath = true; let customIconExt = path.extname(options.icon).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]; await fsExtra.copy(options.icon, iconPath); } if (updateIconPath) { tauriConf.bundle.icon = [options.icon]; } 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/${name.toLowerCase()}${iconExt}`); trayIconPath = `png/${name.toLowerCase()}${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) { if (!inject.every(item => item.endsWith('.css') || item.endsWith('.js'))) { logger.error('The injected file must be in either CSS or JS format.'); return; } const files = inject.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 || ''; // 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 }; console.log('pakeConfig', tauriConf.pake); 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; } 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/.'); } 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); if (isChina) { logger.info('✺ Located in China, using npm/rsProxy CN mirror.'); const projectCnConf = path.join(tauriSrcPath, 'rust_proxy.toml'); await fsExtra.copy(projectCnConf, projectConf); await shellExec(`cd "${npmDirectory}" && npm install --registry=https://registry.npmmirror.com`); } else { await shellExec(`cd "${npmDirectory}" && npm install`); } spinner.succeed(chalk.green('Package installed!')); 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); // Build app const spinner = getSpinner('Building app...'); setTimeout(() => spinner.stop(), 3000); await shellExec(`cd "${npmDirectory}" && ${this.getBuildCommand()}`); // 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); await fsExtra.remove(appPath); logger.success('✔ Build success!'); logger.success('✔ App installer located in', distPath); } getFileType(target) { return target; } getBuildCommand() { // the debug option should support `--debug` and `--release` return this.options.debug ? 'npm run build:debug' : 'npm run build'; } getBasePath() { const basePath = this.options.debug ? 'debug' : 'release'; return `src-tauri/target/${basePath}/bundle/`; } getBuildAppPath(npmDirectory, fileName, fileType) { return path.join(npmDirectory, this.getBasePath(), fileType.toLowerCase(), `${fileName}.${fileType}`); } } class MacBuilder extends BaseBuilder { constructor(options) { super(options); this.options.targets = 'dmg'; } getFileName() { const { name } = this.options; let arch; if (this.options.multiArch) { arch = 'universal'; } else { arch = process.arch === 'arm64' ? 'aarch64' : process.arch; } return `${name}_${tauriConfig.version}_${arch}`; } getBuildCommand() { return this.options.multiArch ? 'npm run build:mac' : super.getBuildCommand(); } getBasePath() { return this.options.multiArch ? 'src-tauri/target/universal-apple-darwin/release/bundle' : super.getBasePath(); } } class WinBuilder extends BaseBuilder { constructor(options) { super(options); this.options.targets = 'msi'; } getFileName() { const { name } = this.options; const { arch } = process; const language = tauriConfig.bundle.windows.wix.language[0]; return `${name}_${tauriConfig.version}_${arch}_${language}`; } } class LinuxBuilder extends BaseBuilder { constructor(options) { super(options); } getFileName() { const { name } = this.options; const arch = process.arch === 'x64' ? 'amd64' : process.arch; return `${name}_${tauriConfig.version}_${arch}`; } // Customize it, considering that there are all targets. async build(url) { const targetTypes = ['deb', 'appimage']; for (const target of targetTypes) { if (this.options.targets === target || this.options.targets === 'all') { await this.buildAndCopy(url, target); } } } getFileType(target) { if (target === 'appimage') { return 'AppImage'; } return super.getFileType(target); } } 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); } } async function startBuild() { log.setDefaultLevel('debug'); const appOptions = await handleOptions(DEFAULT_DEV_PAKE_OPTIONS, DEFAULT_DEV_PAKE_OPTIONS.url); log.debug('PakeAppOptions', appOptions); const builder = BuilderProvider.create(appOptions); await builder.prepare(); await builder.start(DEFAULT_DEV_PAKE_OPTIONS.url); } startBuild(); //# sourceMappingURL=dev.js.map