From 6f9450d5980bd61c7a21fe3ea4a0f1905a9bdcfc Mon Sep 17 00:00:00 2001 From: Tw93 Date: Sat, 16 Aug 2025 22:37:08 +0800 Subject: [PATCH] :sparkles: beta --- bin/builders/BaseBuilder.ts | 4 +- bin/helpers/merge.ts | 142 ++++++++++++--- bin/options/icon.ts | 256 ++++++++++++++++++++++----- bin/options/index.ts | 10 +- bin/utils/dir.ts | 5 + dist/cli.js | 337 +++++++++++++++++++++++++++++------- package.json | 3 +- rollup.config.js | 2 +- src-tauri/src/app/setup.rs | 41 ++++- 9 files changed, 653 insertions(+), 147 deletions(-) diff --git a/bin/builders/BaseBuilder.ts b/bin/builders/BaseBuilder.ts index a22db05..6776e50 100644 --- a/bin/builders/BaseBuilder.ts +++ b/bin/builders/BaseBuilder.ts @@ -129,7 +129,7 @@ export default abstract class BaseBuilder { '.pake', 'tauri.conf.json', ); - let fullCommand = `${baseCommand} -- -c "${configPath}"`; + let fullCommand = `${baseCommand} -- -c "${configPath}" --features cli-build`; // For macOS, use app bundles by default unless DMG is explicitly requested if (IS_MAC && this.options.targets === 'app') { @@ -140,7 +140,7 @@ export default abstract class BaseBuilder { if (IS_MAC) { const macOSVersion = this.getMacOSMajorVersion(); if (macOSVersion >= 23) { - fullCommand += ' --features macos-proxy'; + fullCommand += ',macos-proxy'; } } diff --git a/bin/helpers/merge.ts b/bin/helpers/merge.ts index 906258a..2ec947a 100644 --- a/bin/helpers/merge.ts +++ b/bin/helpers/merge.ts @@ -4,7 +4,11 @@ import fsExtra from 'fs-extra'; import combineFiles from '@/utils/combine'; import logger from '@/options/logger'; import { PakeAppOptions, PlatformMap } from '@/types'; -import { tauriConfigDirectory, npmDirectory } from '@/utils/dir'; +import { + tauriConfigDirectory, + npmDirectory, + getUserHomeDir, +} from '@/utils/dir'; export async function mergeConfig( url: string, @@ -182,8 +186,10 @@ export async function mergeConfig( 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]; + // Save icon to .pake directory instead of src-tauri + const iconPath = path.join(tauriConfigDirectory, iconInfo.path); + await fsExtra.ensureDir(path.dirname(iconPath)); + tauriConf.bundle.resources = [`.pake/${iconInfo.path}`]; await fsExtra.copy(options.icon, iconPath); } @@ -199,30 +205,20 @@ export async function mergeConfig( 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.`); + // Set system tray icon path + let trayIconPath = 'icons/icon.png'; // default fallback + + if (showSystemTray) { + if (systemTrayIcon.length > 0) { + // User provided custom system tray icon + trayIconPath = await handleCustomTrayIcon( + systemTrayIcon, + name, + tauriConfigDirectory, + ); + } else { + // Use original downloaded PNG icon for system tray + trayIconPath = await handleDownloadedTrayIcon(name, tauriConfigDirectory); } } @@ -268,7 +264,6 @@ export async function mergeConfig( ); 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 }); @@ -283,3 +278,94 @@ export async function mergeConfig( const configJsonPath = path.join(tauriConfigDirectory, 'tauri.conf.json'); await fsExtra.outputJSON(configJsonPath, tauriConf2, { spaces: 4 }); } + +/** + * Handle custom system tray icon provided by user + */ +async function handleCustomTrayIcon( + systemTrayIcon: string, + appName: string, + configDir: string, +): Promise { + const defaultPath = 'icons/icon.png'; + + if (!(await fsExtra.pathExists(systemTrayIcon))) { + logger.warn(`✼ Custom tray icon ${systemTrayIcon} not found!`); + logger.warn(`✼ Using default icon for system tray.`); + return defaultPath; + } + + const iconExt = path.extname(systemTrayIcon).toLowerCase(); + if (iconExt !== '.png' && iconExt !== '.ico') { + logger.warn( + `✼ System tray icon must be .png or .ico, but you provided ${iconExt}.`, + ); + logger.warn(`✼ Using default icon for system tray.`); + return defaultPath; + } + + try { + const trayIconPath = path.join( + configDir, + `png/${appName.toLowerCase()}${iconExt}`, + ); + await fsExtra.ensureDir(path.dirname(trayIconPath)); + await fsExtra.copy(systemTrayIcon, trayIconPath); + + const relativePath = `.pake/png/${appName.toLowerCase()}${iconExt}`; + logger.info(`✓ Using custom system tray icon: ${systemTrayIcon}`); + return relativePath; + } catch (error) { + logger.warn(`✼ Failed to copy custom tray icon: ${error}`); + logger.warn(`✼ Using default icon for system tray.`); + return defaultPath; + } +} + +/** + * Handle system tray icon from downloaded app icon + */ +async function handleDownloadedTrayIcon( + appName: string, + configDir: string, +): Promise { + const defaultPath = 'icons/icon.png'; + const homeDir = getUserHomeDir(); + const downloadedIconPath = path.join( + homeDir, + '.pake', + 'icons', + 'downloaded-icon.png', + ); + + if (!(await fsExtra.pathExists(downloadedIconPath))) { + logger.warn( + `✼ No downloaded icon found, using default icon for system tray.`, + ); + return defaultPath; + } + + try { + const trayPngPath = path.join( + configDir, + `png/${appName.toLowerCase()}_tray.png`, + ); + await fsExtra.ensureDir(path.dirname(trayPngPath)); + + // Resize the original PNG to appropriate tray size (32x32 for optimal display) + const sharp = await import('sharp'); + await sharp + .default(downloadedIconPath) + .resize(32, 32) + .png() + .toFile(trayPngPath); + + const relativePath = `.pake/png/${appName.toLowerCase()}_tray.png`; + logger.info(`✓ Using downloaded app icon for system tray: ${relativePath}`); + return relativePath; + } catch (error) { + logger.warn(`✼ Failed to process downloaded icon for tray: ${error}`); + logger.warn(`✼ Using default icon for system tray.`); + return defaultPath; + } +} diff --git a/bin/options/icon.ts b/bin/options/icon.ts index d8e47f4..162b162 100644 --- a/bin/options/icon.ts +++ b/bin/options/icon.ts @@ -1,73 +1,243 @@ import path from 'path'; import axios from 'axios'; import fsExtra from 'fs-extra'; -import chalk from 'chalk'; import { dir } from 'tmp-promise'; +import { fileTypeFromBuffer } from 'file-type'; +import icongen from 'icon-gen'; +import sharp from 'sharp'; import logger from './logger'; -import { npmDirectory } from '@/utils/dir'; +import { npmDirectory, getUserHomeDir } from '@/utils/dir'; import { IS_LINUX, IS_WIN } from '@/utils/platform'; -import { getSpinner } from '@/utils/info'; -import { fileTypeFromBuffer } from 'file-type'; import { PakeAppOptions } from '@/types'; -export async function handleIcon(options: PakeAppOptions) { +// Constants +const ICON_CONFIG = { + minFileSize: 100, + downloadTimeout: 10000, + supportedFormats: ['png', 'ico', 'jpeg', 'jpg', 'webp'] as const, + whiteBackground: { r: 255, g: 255, b: 255 }, +}; + +// API Configuration +const API_TOKENS = { + // cspell:disable-next-line + logoDev: ['pk_JLLMUKGZRpaG5YclhXaTkg', 'pk_Ph745P8mQSeYFfW2Wk039A'], + // cspell:disable-next-line + brandfetch: ['1idqvJC0CeFSeyp3Yf7', '1idej-yhU_ThggIHFyG'], +}; + +/** + * Adds white background to transparent icons only + */ +async function preprocessIcon(inputPath: string): Promise { + 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 { + return inputPath; + } +} + +/** + * Converts icon to platform-specific format + */ +async function convertIconFormat( + inputPath: string, + appName: string, +): Promise { + 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 = appName.toLowerCase(); + + // Generate platform-specific format + if (IS_WIN) { + await icongen(processedInputPath, platformOutputDir, { + report: false, + ico: { name: `${iconName}_256`, sizes: [256] }, + }); + return path.join(platformOutputDir, `${iconName}_256.ico`); + } + + if (IS_LINUX) { + const outputPath = path.join(platformOutputDir, `${iconName}_512.png`); + await fsExtra.copy(processedInputPath, outputPath); + return outputPath; + } + + // macOS + await icongen(processedInputPath, platformOutputDir, { + report: false, + icns: { name: iconName, sizes: [16, 32, 64, 128, 256, 512, 1024] }, + }); + const outputPath = path.join(platformOutputDir, `${iconName}.icns`); + return (await fsExtra.pathExists(outputPath)) ? outputPath : null; + } catch (error) { + logger.warn(`Icon format conversion failed: ${error.message}`); + return null; + } +} + +export async function handleIcon(options: PakeAppOptions, url?: string) { 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.', + return path.resolve(options.icon); + } + + // Try to get favicon from website if URL is provided + if (url && url.startsWith('http') && options.name) { + const faviconPath = await tryGetFavicon(url, options.name); + if (faviconPath) return faviconPath; + } + + logger.info('✼ No icon provided, using default icon.'); + 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); +} + +/** + * Generates icon service URLs for a domain + */ +function generateIconServiceUrls(domain: string): string[] { + const logoDevUrls = API_TOKENS.logoDev + .sort(() => Math.random() - 0.5) + .map( + (token) => + `https://img.logo.dev/${domain}?token=${token}&format=png&size=256`, ); - 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); + + const brandfetchUrls = API_TOKENS.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://logo.uplead.com/${domain}`, + `https://www.google.com/s2/favicons?domain=${domain}&sz=256`, + `https://favicon.is/${domain}`, + `https://icons.duckduckgo.com/ip3/${domain}.ico`, + `https://icon.horse/icon/${domain}`, + `https://${domain}/favicon.ico`, + `https://www.${domain}/favicon.ico`, + `https://${domain}/apple-touch-icon.png`, + `https://${domain}/apple-touch-icon-precomposed.png`, + ]; +} + +/** + * Attempts to fetch favicon from website + */ +async function tryGetFavicon( + url: string, + appName: string, +): Promise { + try { + const domain = new URL(url).hostname; + logger.info(`Auto-fetching favicon for ${domain}...`); + + const serviceUrls = generateIconServiceUrls(domain); + + for (const serviceUrl of serviceUrls) { + try { + const faviconPath = await downloadIcon(serviceUrl, false); + if (!faviconPath) continue; + + const convertedPath = await convertIconFormat(faviconPath, appName); + if (convertedPath) { + logger.info(`Favicon ready for ${domain}`); + return convertedPath; + } + } catch { + continue; + } + } + + logger.info(`No favicon found for ${domain}. Using default.`); + return null; + } catch { + return null; } } -export async function downloadIcon(iconUrl: string) { - const spinner = getSpinner('Downloading icon...'); +/** + * Downloads icon from URL + */ +export async function downloadIcon( + iconUrl: string, + showSpinner = true, +): Promise { try { - const iconResponse = await axios.get(iconUrl, { + const response = await axios.get(iconUrl, { responseType: 'arraybuffer', + timeout: ICON_CONFIG.downloadTimeout, }); - const iconData = await iconResponse.data; - if (!iconData) { - return null; - } + const iconData = response.data; + if (!iconData || iconData.byteLength < ICON_CONFIG.minFileSize) return null; const fileDetails = await fileTypeFromBuffer(iconData); - if (!fileDetails) { + if ( + !fileDetails || + !ICON_CONFIG.supportedFormats.includes(fileDetails.ext as any) + ) { 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; + return await saveIconFile(iconData, fileDetails.ext); } catch (error) { - spinner.fail(chalk.red('Icon download failed!')); - if (error.response && error.response.status === 404) { - return null; + if (showSpinner && !(error.response?.status === 404)) { + throw error; } - throw error; + return null; } } + +/** + * Saves icon file to .pake directory + */ +async function saveIconFile( + iconData: ArrayBuffer, + extension: string, +): Promise { + const buffer = Buffer.from(iconData); + const homeDir = getUserHomeDir(); + const pakeDir = path.join(homeDir, '.pake', 'icons'); + + // Ensure .pake/icons directory exists + await fsExtra.ensureDir(pakeDir); + + const iconPath = path.join(pakeDir, `downloaded-icon.${extension}`); + await fsExtra.outputFile(iconPath, buffer); + return iconPath; +} diff --git a/bin/options/index.ts b/bin/options/index.ts index 564ac0c..347573d 100644 --- a/bin/options/index.ts +++ b/bin/options/index.ts @@ -43,11 +43,13 @@ export default async function handleOptions( } 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; + platform === 'linux' + ? `✕ Name should only include letters, numbers, dashes, and spaces. Spaces will be converted to dashes. Examples: Google Translate → google-translate, 123pan, weread.` + : `✕ Name should only include letters, numbers, dashes, and spaces (not leading dashes and spaces). Examples: Google Translate, 123pan, WeRead, we-read.`; + logger.error(errorMsg); + if (isActions) { name = resolveAppName(url, platform); logger.warn(`✼ Inside github actions, use the default name: ${name}`); @@ -62,7 +64,7 @@ export default async function handleOptions( identifier: getIdentifier(url), }; - appOptions.icon = await handleIcon(appOptions); + appOptions.icon = await handleIcon(appOptions, url); return appOptions; } diff --git a/bin/utils/dir.ts b/bin/utils/dir.ts index c261667..0936fb2 100644 --- a/bin/utils/dir.ts +++ b/bin/utils/dir.ts @@ -1,4 +1,5 @@ import path from 'path'; +import os from 'os'; import { fileURLToPath } from 'url'; // Convert the current module URL to a file path @@ -12,3 +13,7 @@ export const tauriConfigDirectory = path.join( 'src-tauri', '.pake', ); + +export function getUserHomeDir(): string { + return os.homedir(); +} diff --git a/dist/cli.js b/dist/cli.js index 3b4aea0..c762b05 100644 --- a/dist/cli.js +++ b/dist/cli.js @@ -7,6 +7,7 @@ import prompts from 'prompts'; import { execa, execaSync } from 'execa'; import crypto from 'crypto'; import ora from 'ora'; +import os from 'os'; import { fileURLToPath } from 'url'; import dns from 'dns'; import http from 'http'; @@ -16,10 +17,12 @@ 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$1 = "3.2.0-beta1"; +var version$1 = "3.2.0-beta2"; var description = "🤱🏻 Turn any webpage into a desktop app with Rust. 🤱🏻 利用 Rust 轻松构建轻量级多端桌面应用。"; var engines = { node: ">=16.0.0" @@ -77,6 +80,7 @@ var dependencies = { execa: "^9.6.0", "file-type": "^18.7.0", "fs-extra": "^11.3.1", + "icon-gen": "^5.0.0", loglevel: "^1.9.2", ora: "^8.2.0", prompts: "^2.4.2", @@ -316,6 +320,9 @@ 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'); +function getUserHomeDir() { + return os.homedir(); +} async function shellExec(command, timeout = 300000) { try { @@ -449,11 +456,18 @@ async function mergeConfig(url, options, tauriConf) { 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']; + 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))) { + if ((await fsExtra.pathExists(sourcePath)) && + !(await fsExtra.pathExists(destPath))) { await fsExtra.copy(sourcePath, destPath); } })); @@ -561,8 +575,10 @@ async function mergeConfig(url, options, tauriConf) { tauriConf.bundle.icon = [iconInfo.defaultIcon]; } else { - const iconPath = path.join(npmDirectory, 'src-tauri/', iconInfo.path); - tauriConf.bundle.resources = [iconInfo.path]; + // Save icon to .pake directory instead of src-tauri + const iconPath = path.join(tauriConfigDirectory, iconInfo.path); + await fsExtra.ensureDir(path.dirname(iconPath)); + tauriConf.bundle.resources = [`.pake/${iconInfo.path}`]; await fsExtra.copy(options.icon, iconPath); } if (updateIconPath) { @@ -576,26 +592,16 @@ async function mergeConfig(url, options, tauriConf) { 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.`); - } + // Set system tray icon path + let trayIconPath = 'icons/icon.png'; // default fallback + if (showSystemTray) { + if (systemTrayIcon.length > 0) { + // User provided custom system tray icon + trayIconPath = await handleCustomTrayIcon(systemTrayIcon, name, tauriConfigDirectory); } - catch { - logger.warn(`✼ ${systemTrayIcon} not exists!`); - logger.warn(`✼ Default system tray icon will remain unchanged.`); + else { + // Use original downloaded PNG icon for system tray + trayIconPath = await handleDownloadedTrayIcon(name, tauriConfigDirectory); } } tauriConf.app.trayIcon.iconPath = trayIconPath; @@ -625,7 +631,6 @@ async function mergeConfig(url, options, tauriConf) { }; 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 }); @@ -634,6 +639,66 @@ async function mergeConfig(url, options, tauriConf) { const configJsonPath = path.join(tauriConfigDirectory, 'tauri.conf.json'); await fsExtra.outputJSON(configJsonPath, tauriConf2, { spaces: 4 }); } +/** + * Handle custom system tray icon provided by user + */ +async function handleCustomTrayIcon(systemTrayIcon, appName, configDir) { + const defaultPath = 'icons/icon.png'; + if (!(await fsExtra.pathExists(systemTrayIcon))) { + logger.warn(`✼ Custom tray icon ${systemTrayIcon} not found!`); + logger.warn(`✼ Using default icon for system tray.`); + return defaultPath; + } + const iconExt = path.extname(systemTrayIcon).toLowerCase(); + if (iconExt !== '.png' && iconExt !== '.ico') { + logger.warn(`✼ System tray icon must be .png or .ico, but you provided ${iconExt}.`); + logger.warn(`✼ Using default icon for system tray.`); + return defaultPath; + } + try { + const trayIconPath = path.join(configDir, `png/${appName.toLowerCase()}${iconExt}`); + await fsExtra.ensureDir(path.dirname(trayIconPath)); + await fsExtra.copy(systemTrayIcon, trayIconPath); + const relativePath = `.pake/png/${appName.toLowerCase()}${iconExt}`; + logger.info(`✓ Using custom system tray icon: ${systemTrayIcon}`); + return relativePath; + } + catch (error) { + logger.warn(`✼ Failed to copy custom tray icon: ${error}`); + logger.warn(`✼ Using default icon for system tray.`); + return defaultPath; + } +} +/** + * Handle system tray icon from downloaded app icon + */ +async function handleDownloadedTrayIcon(appName, configDir) { + const defaultPath = 'icons/icon.png'; + const homeDir = getUserHomeDir(); + const downloadedIconPath = path.join(homeDir, '.pake', 'icons', 'downloaded-icon.png'); + if (!(await fsExtra.pathExists(downloadedIconPath))) { + logger.warn(`✼ No downloaded icon found, using default icon for system tray.`); + return defaultPath; + } + try { + const trayPngPath = path.join(configDir, `png/${appName.toLowerCase()}_tray.png`); + await fsExtra.ensureDir(path.dirname(trayPngPath)); + // Resize the original PNG to appropriate tray size (32x32 for optimal display) + const sharp = await import('sharp'); + await sharp.default(downloadedIconPath) + .resize(32, 32) + .png() + .toFile(trayPngPath); + const relativePath = `.pake/png/${appName.toLowerCase()}_tray.png`; + logger.info(`✓ Using downloaded app icon for system tray: ${relativePath}`); + return relativePath; + } + catch (error) { + logger.warn(`✼ Failed to process downloaded icon for tray: ${error}`); + logger.warn(`✼ Using default icon for system tray.`); + return defaultPath; + } +} class BaseBuilder { constructor(options) { @@ -719,7 +784,7 @@ class BaseBuilder { : 'npm run build'; // Use temporary config directory to avoid modifying source files const configPath = path.join(npmDirectory, 'src-tauri', '.pake', 'tauri.conf.json'); - let fullCommand = `${baseCommand} -- -c "${configPath}"`; + let fullCommand = `${baseCommand} -- -c "${configPath}" --features cli-build`; // For macOS, use app bundles by default unless DMG is explicitly requested if (IS_MAC && this.options.targets === 'app') { fullCommand += ' --bundles app'; @@ -728,7 +793,7 @@ class BaseBuilder { if (IS_MAC) { const macOSVersion = this.getMacOSMajorVersion(); if (macOSVersion >= 23) { - fullCommand += ' --features macos-proxy'; + fullCommand += ',macos-proxy'; } } return fullCommand; @@ -889,61 +954,199 @@ async function checkUpdateTips() { }); } -async function handleIcon(options) { +// Constants +const ICON_CONFIG = { + minFileSize: 100, + downloadTimeout: 10000, + supportedFormats: ['png', 'ico', 'jpeg', 'jpg', 'webp'], + whiteBackground: { r: 255, g: 255, b: 255 }, +}; +// API Configuration +const API_TOKENS = { + // cspell:disable-next-line + logoDev: ['pk_JLLMUKGZRpaG5YclhXaTkg', 'pk_Ph745P8mQSeYFfW2Wk039A'], + // cspell:disable-next-line + brandfetch: ['1idqvJC0CeFSeyp3Yf7', '1idej-yhU_ThggIHFyG'], +}; +/** + * 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 { + 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 = appName.toLowerCase(); + // Generate platform-specific format + if (IS_WIN) { + await icongen(processedInputPath, platformOutputDir, { + report: false, + ico: { name: `${iconName}_256`, sizes: [256] }, + }); + return path.join(platformOutputDir, `${iconName}_256.ico`); + } + if (IS_LINUX) { + const outputPath = path.join(platformOutputDir, `${iconName}_512.png`); + await fsExtra.copy(processedInputPath, outputPath); + return outputPath; + } + // macOS + await icongen(processedInputPath, platformOutputDir, { + report: false, + icns: { name: iconName, sizes: [16, 32, 64, 128, 256, 512, 1024] }, + }); + const outputPath = path.join(platformOutputDir, `${iconName}.icns`); + return (await fsExtra.pathExists(outputPath)) ? outputPath : null; + } + catch (error) { + logger.warn(`Icon format conversion failed: ${error.message}`); + return null; + } +} +async function handleIcon(options, url) { if (options.icon) { if (options.icon.startsWith('http')) { return downloadIcon(options.icon); } - else { - return path.resolve(options.icon); - } + 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); + // Try to get favicon from website if URL is provided + if (url && url.startsWith('http') && options.name) { + const faviconPath = await tryGetFavicon(url, options.name); + if (faviconPath) + return faviconPath; + } + logger.info('✼ No icon provided, using default icon.'); + 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); +} +/** + * Generates icon service URLs for a domain + */ +function generateIconServiceUrls(domain) { + const logoDevUrls = API_TOKENS.logoDev + .sort(() => Math.random() - 0.5) + .map(token => `https://img.logo.dev/${domain}?token=${token}&format=png&size=256`); + const brandfetchUrls = API_TOKENS.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://logo.uplead.com/${domain}`, + `https://www.google.com/s2/favicons?domain=${domain}&sz=256`, + `https://favicon.is/${domain}`, + `https://icons.duckduckgo.com/ip3/${domain}.ico`, + `https://icon.horse/icon/${domain}`, + `https://${domain}/favicon.ico`, + `https://www.${domain}/favicon.ico`, + `https://${domain}/apple-touch-icon.png`, + `https://${domain}/apple-touch-icon-precomposed.png`, + ]; +} +/** + * Attempts to fetch favicon from website + */ +async function tryGetFavicon(url, appName) { + try { + const domain = new URL(url).hostname; + logger.info(`Auto-fetching favicon for ${domain}...`); + const serviceUrls = generateIconServiceUrls(domain); + for (const serviceUrl of serviceUrls) { + try { + const faviconPath = await downloadIcon(serviceUrl, false); + if (!faviconPath) + continue; + const convertedPath = await convertIconFormat(faviconPath, appName); + if (convertedPath) { + logger.info(`Favicon ready for ${domain}`); + return convertedPath; + } + } + catch { + continue; + } + } + logger.info(`No favicon found for ${domain}. Using default.`); + return null; + } + catch { + return null; } } -async function downloadIcon(iconUrl) { - const spinner = getSpinner('Downloading icon...'); +/** + * Downloads icon from URL + */ +async function downloadIcon(iconUrl, showSpinner = true) { try { - const iconResponse = await axios.get(iconUrl, { + const response = await axios.get(iconUrl, { responseType: 'arraybuffer', + timeout: ICON_CONFIG.downloadTimeout, }); - const iconData = await iconResponse.data; - if (!iconData) { + const iconData = response.data; + if (!iconData || iconData.byteLength < ICON_CONFIG.minFileSize) return null; - } const fileDetails = await fileTypeFromBuffer(iconData); - if (!fileDetails) { + if (!fileDetails || !ICON_CONFIG.supportedFormats.includes(fileDetails.ext)) { 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; + return await saveIconFile(iconData, fileDetails.ext); } catch (error) { - spinner.fail(chalk.red('Icon download failed!')); - if (error.response && error.response.status === 404) { - return null; + if (showSpinner && !(error.response?.status === 404)) { + throw error; } - throw error; + return null; } } +/** + * Saves icon file to .pake directory + */ +async function saveIconFile(iconData, extension) { + const buffer = Buffer.from(iconData); + const homeDir = getUserHomeDir(); + const pakeDir = path.join(homeDir, '.pake', 'icons'); + // Ensure .pake/icons directory exists + await fsExtra.ensureDir(pakeDir); + const iconPath = path.join(pakeDir, `downloaded-icon.${extension}`); + await fsExtra.outputFile(iconPath, buffer); + return iconPath; +} // Extracts the domain from a given URL. function getDomain(inputUrl) { @@ -1014,9 +1217,9 @@ async function handleOptions(options, url) { name = name.toLowerCase().replace(/\s+/g, '-'); } 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; + const errorMsg = platform === 'linux' + ? `✕ Name should only include letters, numbers, dashes, and spaces. Spaces will be converted to dashes. Examples: Google Translate → google-translate, 123pan, weread.` + : `✕ Name should only include letters, numbers, dashes, and spaces (not leading dashes and spaces). Examples: Google Translate, 123pan, WeRead, we-read.`; logger.error(errorMsg); if (isActions) { name = resolveAppName(url, platform); @@ -1031,7 +1234,7 @@ async function handleOptions(options, url) { name, identifier: getIdentifier(url), }; - appOptions.icon = await handleIcon(appOptions); + appOptions.icon = await handleIcon(appOptions, url); return appOptions; } diff --git a/package.json b/package.json index 20c4c57..7c19c98 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "pake-cli", - "version": "3.2.0-beta1", + "version": "3.2.0-beta3", "description": "🤱🏻 Turn any webpage into a desktop app with Rust. 🤱🏻 利用 Rust 轻松构建轻量级多端桌面应用。", "engines": { "node": ">=16.0.0" @@ -58,6 +58,7 @@ "execa": "^9.6.0", "file-type": "^18.7.0", "fs-extra": "^11.3.1", + "icon-gen": "^5.0.0", "loglevel": "^1.9.2", "ora": "^8.2.0", "prompts": "^2.4.2", diff --git a/rollup.config.js b/rollup.config.js index d7da4f3..715698d 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -7,6 +7,7 @@ import json from "@rollup/plugin-json"; import replace from "@rollup/plugin-replace"; import chalk from "chalk"; import { spawn, exec } from "child_process"; +import fs from "fs"; const isProduction = process.env.NODE_ENV === "production"; const devPlugins = !isProduction ? [pakeCliDevPlugin()] : []; @@ -48,7 +49,6 @@ function pakeCliDevPlugin() { // 智能检测包管理器 const detectPackageManager = () => { - const fs = require("fs"); if (fs.existsSync("pnpm-lock.yaml")) return "pnpm"; if (fs.existsSync("yarn.lock")) return "yarn"; return "npm"; diff --git a/src-tauri/src/app/setup.rs b/src-tauri/src/app/setup.rs index a66a427..16dc083 100644 --- a/src-tauri/src/app/setup.rs +++ b/src-tauri/src/app/setup.rs @@ -2,6 +2,7 @@ use std::str::FromStr; use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; use tauri::{ + image::Image, menu::{MenuBuilder, MenuItemBuilder}, tray::TrayIconBuilder, AppHandle, Manager, @@ -9,6 +10,8 @@ use tauri::{ use tauri_plugin_global_shortcut::{GlobalShortcutExt, Shortcut}; use tauri_plugin_window_state::{AppHandleExt, StateFlags}; +use crate::util::get_pake_config; + pub fn set_system_tray(app: &AppHandle, show_system_tray: bool) -> tauri::Result<()> { if !show_system_tray { app.remove_tray_by_id("pake-tray"); @@ -25,6 +28,42 @@ pub fn set_system_tray(app: &AppHandle, show_system_tray: bool) -> tauri::Result app.app_handle().remove_tray_by_id("pake-tray"); + // Get tray icon - use custom tray icon if provided, otherwise use app icon + let tray_icon = { + let (config, _) = get_pake_config(); + let tray_path = &config.system_tray_path; + + // Check if this is a custom tray icon or app icon + if !tray_path.is_empty() && tray_path != "icons/icon.png" { + // Try to load the tray icon - could be relative or absolute path + let icon_path = if tray_path.starts_with("/") { + // Absolute path - use as is + tray_path.to_string() + } else if tray_path.starts_with(".pake/") || tray_path.starts_with("png/") { + // Relative path - prepend manifest dir + format!("{}/{}", env!("CARGO_MANIFEST_DIR"), tray_path) + } else { + // Default fallback path + format!("{}/{}", env!("CARGO_MANIFEST_DIR"), tray_path) + }; + + match Image::from_path(&icon_path) { + Ok(icon) => icon, + Err(e) => { + println!( + "Failed to load tray icon from {}: {}, using app icon", + icon_path, e + ); + app.default_window_icon().unwrap().clone() + } + } + } else { + // No custom tray icon, use app icon (which could be downloaded custom icon) + println!("Using app icon for system tray (path: {})", tray_path); + app.default_window_icon().unwrap().clone() + } + }; + let tray = TrayIconBuilder::new() .menu(&menu) .on_menu_event(move |app, event| match event.id().as_ref() { @@ -44,7 +83,7 @@ pub fn set_system_tray(app: &AppHandle, show_system_tray: bool) -> tauri::Result } _ => (), }) - .icon(app.default_window_icon().unwrap().clone()) + .icon(tray_icon) .build(app)?; tray.set_icon_as_template(false)?;