This commit is contained in:
Tw93
2025-08-16 22:37:08 +08:00
parent f76d567895
commit 6f9450d598
9 changed files with 653 additions and 147 deletions

View File

@@ -129,7 +129,7 @@ export default abstract class BaseBuilder {
'.pake', '.pake',
'tauri.conf.json', '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 // For macOS, use app bundles by default unless DMG is explicitly requested
if (IS_MAC && this.options.targets === 'app') { if (IS_MAC && this.options.targets === 'app') {
@@ -140,7 +140,7 @@ export default abstract class BaseBuilder {
if (IS_MAC) { if (IS_MAC) {
const macOSVersion = this.getMacOSMajorVersion(); const macOSVersion = this.getMacOSMajorVersion();
if (macOSVersion >= 23) { if (macOSVersion >= 23) {
fullCommand += ' --features macos-proxy'; fullCommand += ',macos-proxy';
} }
} }

142
bin/helpers/merge.ts vendored
View File

@@ -4,7 +4,11 @@ import fsExtra from 'fs-extra';
import combineFiles from '@/utils/combine'; import combineFiles from '@/utils/combine';
import logger from '@/options/logger'; import logger from '@/options/logger';
import { PakeAppOptions, PlatformMap } from '@/types'; import { PakeAppOptions, PlatformMap } from '@/types';
import { tauriConfigDirectory, npmDirectory } from '@/utils/dir'; import {
tauriConfigDirectory,
npmDirectory,
getUserHomeDir,
} from '@/utils/dir';
export async function mergeConfig( export async function mergeConfig(
url: string, url: string,
@@ -182,8 +186,10 @@ export async function mergeConfig(
logger.warn(`${iconInfo.message}, but you give ${customIconExt}`); logger.warn(`${iconInfo.message}, but you give ${customIconExt}`);
tauriConf.bundle.icon = [iconInfo.defaultIcon]; tauriConf.bundle.icon = [iconInfo.defaultIcon];
} else { } else {
const iconPath = path.join(npmDirectory, 'src-tauri/', iconInfo.path); // Save icon to .pake directory instead of src-tauri
tauriConf.bundle.resources = [iconInfo.path]; 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); await fsExtra.copy(options.icon, iconPath);
} }
@@ -199,30 +205,20 @@ export async function mergeConfig(
tauriConf.bundle.icon = [iconInfo.defaultIcon]; tauriConf.bundle.icon = [iconInfo.defaultIcon];
} }
// Set tray icon path. // Set system tray icon path
let trayIconPath = let trayIconPath = 'icons/icon.png'; // default fallback
platform === 'darwin' ? 'png/icon_512.png' : tauriConf.bundle.icon[0];
if (systemTrayIcon.length > 0) { if (showSystemTray) {
try { if (systemTrayIcon.length > 0) {
await fsExtra.pathExists(systemTrayIcon); // User provided custom system tray icon
// 需要判断图标格式默认只支持ico和png两种 trayIconPath = await handleCustomTrayIcon(
let iconExt = path.extname(systemTrayIcon).toLowerCase(); systemTrayIcon,
if (iconExt == '.png' || iconExt == '.ico') { name,
const trayIcoPath = path.join( tauriConfigDirectory,
npmDirectory, );
`src-tauri/png/${name.toLowerCase()}${iconExt}`, } else {
); // Use original downloaded PNG icon for system tray
trayIconPath = `png/${name.toLowerCase()}${iconExt}`; trayIconPath = await handleDownloadedTrayIcon(name, tauriConfigDirectory);
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.`);
} }
} }
@@ -268,7 +264,6 @@ export async function mergeConfig(
); );
const bundleConf = { bundle: tauriConf.bundle }; const bundleConf = { bundle: tauriConf.bundle };
console.log('pakeConfig', tauriConf.pake);
await fsExtra.outputJSON(configPath, bundleConf, { spaces: 4 }); await fsExtra.outputJSON(configPath, bundleConf, { spaces: 4 });
const pakeConfigPath = path.join(tauriConfigDirectory, 'pake.json'); const pakeConfigPath = path.join(tauriConfigDirectory, 'pake.json');
await fsExtra.outputJSON(pakeConfigPath, tauriConf.pake, { spaces: 4 }); await fsExtra.outputJSON(pakeConfigPath, tauriConf.pake, { spaces: 4 });
@@ -283,3 +278,94 @@ export async function mergeConfig(
const configJsonPath = path.join(tauriConfigDirectory, 'tauri.conf.json'); const configJsonPath = path.join(tauriConfigDirectory, 'tauri.conf.json');
await fsExtra.outputJSON(configJsonPath, tauriConf2, { spaces: 4 }); 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<string> {
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<string> {
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;
}
}

256
bin/options/icon.ts vendored
View File

@@ -1,73 +1,243 @@
import path from 'path'; import path from 'path';
import axios from 'axios'; import axios from 'axios';
import fsExtra from 'fs-extra'; import fsExtra from 'fs-extra';
import chalk from 'chalk';
import { dir } from 'tmp-promise'; import { dir } from 'tmp-promise';
import { fileTypeFromBuffer } from 'file-type';
import icongen from 'icon-gen';
import sharp from 'sharp';
import logger from './logger'; import logger from './logger';
import { npmDirectory } from '@/utils/dir'; import { npmDirectory, getUserHomeDir } from '@/utils/dir';
import { IS_LINUX, IS_WIN } from '@/utils/platform'; import { IS_LINUX, IS_WIN } from '@/utils/platform';
import { getSpinner } from '@/utils/info';
import { fileTypeFromBuffer } from 'file-type';
import { PakeAppOptions } from '@/types'; 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<string> {
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<string | null> {
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) {
if (options.icon.startsWith('http')) { if (options.icon.startsWith('http')) {
return downloadIcon(options.icon); return downloadIcon(options.icon);
} else {
return path.resolve(options.icon);
} }
} else { return path.resolve(options.icon);
logger.warn( }
'✼ No icon given, default in use. For a custom icon, use --icon option.',
// 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' const brandfetchUrls = API_TOKENS.brandfetch
: IS_LINUX .sort(() => Math.random() - 0.5)
? 'src-tauri/png/icon_512.png' .map((key) => `https://cdn.brandfetch.io/${domain}/w/400/h/400?c=${key}`);
: 'src-tauri/icons/icon.icns';
return path.join(npmDirectory, iconPath); 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<string | null> {
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<string | null> {
try { try {
const iconResponse = await axios.get(iconUrl, { const response = await axios.get(iconUrl, {
responseType: 'arraybuffer', responseType: 'arraybuffer',
timeout: ICON_CONFIG.downloadTimeout,
}); });
const iconData = await iconResponse.data;
if (!iconData) { const iconData = response.data;
return null; if (!iconData || iconData.byteLength < ICON_CONFIG.minFileSize) return null;
}
const fileDetails = await fileTypeFromBuffer(iconData); const fileDetails = await fileTypeFromBuffer(iconData);
if (!fileDetails) { if (
!fileDetails ||
!ICON_CONFIG.supportedFormats.includes(fileDetails.ext as any)
) {
return null; return null;
} }
const { path: tempPath } = await dir(); return await saveIconFile(iconData, fileDetails.ext);
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) { } catch (error) {
spinner.fail(chalk.red('Icon download failed!')); if (showSpinner && !(error.response?.status === 404)) {
if (error.response && error.response.status === 404) { throw error;
return null;
} }
throw error; return null;
} }
} }
/**
* Saves icon file to .pake directory
*/
async function saveIconFile(
iconData: ArrayBuffer,
extension: string,
): Promise<string> {
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;
}

10
bin/options/index.ts vendored
View File

@@ -43,11 +43,13 @@ export default async function handleOptions(
} }
if (!isValidName(name, platform)) { 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 = 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); logger.error(errorMsg);
if (isActions) { if (isActions) {
name = resolveAppName(url, platform); name = resolveAppName(url, platform);
logger.warn(`✼ Inside github actions, use the default name: ${name}`); logger.warn(`✼ Inside github actions, use the default name: ${name}`);
@@ -62,7 +64,7 @@ export default async function handleOptions(
identifier: getIdentifier(url), identifier: getIdentifier(url),
}; };
appOptions.icon = await handleIcon(appOptions); appOptions.icon = await handleIcon(appOptions, url);
return appOptions; return appOptions;
} }

5
bin/utils/dir.ts vendored
View File

@@ -1,4 +1,5 @@
import path from 'path'; import path from 'path';
import os from 'os';
import { fileURLToPath } from 'url'; import { fileURLToPath } from 'url';
// Convert the current module URL to a file path // Convert the current module URL to a file path
@@ -12,3 +13,7 @@ export const tauriConfigDirectory = path.join(
'src-tauri', 'src-tauri',
'.pake', '.pake',
); );
export function getUserHomeDir(): string {
return os.homedir();
}

337
dist/cli.js vendored
View File

@@ -7,6 +7,7 @@ import prompts from 'prompts';
import { execa, execaSync } from 'execa'; import { execa, execaSync } from 'execa';
import crypto from 'crypto'; import crypto from 'crypto';
import ora from 'ora'; import ora from 'ora';
import os from 'os';
import { fileURLToPath } from 'url'; import { fileURLToPath } from 'url';
import dns from 'dns'; import dns from 'dns';
import http from 'http'; import http from 'http';
@@ -16,10 +17,12 @@ import updateNotifier from 'update-notifier';
import axios from 'axios'; import axios from 'axios';
import { dir } from 'tmp-promise'; import { dir } from 'tmp-promise';
import { fileTypeFromBuffer } from 'file-type'; import { fileTypeFromBuffer } from 'file-type';
import icongen from 'icon-gen';
import sharp from 'sharp';
import * as psl from 'psl'; import * as psl from 'psl';
var name = "pake-cli"; 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 description = "🤱🏻 Turn any webpage into a desktop app with Rust. 🤱🏻 利用 Rust 轻松构建轻量级多端桌面应用。";
var engines = { var engines = {
node: ">=16.0.0" node: ">=16.0.0"
@@ -77,6 +80,7 @@ var dependencies = {
execa: "^9.6.0", execa: "^9.6.0",
"file-type": "^18.7.0", "file-type": "^18.7.0",
"fs-extra": "^11.3.1", "fs-extra": "^11.3.1",
"icon-gen": "^5.0.0",
loglevel: "^1.9.2", loglevel: "^1.9.2",
ora: "^8.2.0", ora: "^8.2.0",
prompts: "^2.4.2", prompts: "^2.4.2",
@@ -316,6 +320,9 @@ const currentModulePath = fileURLToPath(import.meta.url);
// Resolve the parent directory of the current module // Resolve the parent directory of the current module
const npmDirectory = path.join(path.dirname(currentModulePath), '..'); const npmDirectory = path.join(path.dirname(currentModulePath), '..');
const tauriConfigDirectory = path.join(npmDirectory, 'src-tauri', '.pake'); const tauriConfigDirectory = path.join(npmDirectory, 'src-tauri', '.pake');
function getUserHomeDir() {
return os.homedir();
}
async function shellExec(command, timeout = 300000) { async function shellExec(command, timeout = 300000) {
try { try {
@@ -449,11 +456,18 @@ async function mergeConfig(url, options, tauriConf) {
const srcTauriDir = path.join(npmDirectory, 'src-tauri'); const srcTauriDir = path.join(npmDirectory, 'src-tauri');
await fsExtra.ensureDir(tauriConfigDirectory); await fsExtra.ensureDir(tauriConfigDirectory);
// Copy source config files to .pake directory (as templates) // 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) => { await Promise.all(sourceFiles.map(async (file) => {
const sourcePath = path.join(srcTauriDir, file); const sourcePath = path.join(srcTauriDir, file);
const destPath = path.join(tauriConfigDirectory, 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); await fsExtra.copy(sourcePath, destPath);
} }
})); }));
@@ -561,8 +575,10 @@ async function mergeConfig(url, options, tauriConf) {
tauriConf.bundle.icon = [iconInfo.defaultIcon]; tauriConf.bundle.icon = [iconInfo.defaultIcon];
} }
else { else {
const iconPath = path.join(npmDirectory, 'src-tauri/', iconInfo.path); // Save icon to .pake directory instead of src-tauri
tauriConf.bundle.resources = [iconInfo.path]; 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); await fsExtra.copy(options.icon, iconPath);
} }
if (updateIconPath) { 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.'); logger.warn('✼ Custom icon path may be invalid, default icon will be used instead.');
tauriConf.bundle.icon = [iconInfo.defaultIcon]; tauriConf.bundle.icon = [iconInfo.defaultIcon];
} }
// Set tray icon path. // Set system tray icon path
let trayIconPath = platform === 'darwin' ? 'png/icon_512.png' : tauriConf.bundle.icon[0]; let trayIconPath = 'icons/icon.png'; // default fallback
if (systemTrayIcon.length > 0) { if (showSystemTray) {
try { if (systemTrayIcon.length > 0) {
await fsExtra.pathExists(systemTrayIcon); // User provided custom system tray icon
// 需要判断图标格式默认只支持ico和png两种 trayIconPath = await handleCustomTrayIcon(systemTrayIcon, name, tauriConfigDirectory);
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 { else {
logger.warn(`${systemTrayIcon} not exists!`); // Use original downloaded PNG icon for system tray
logger.warn(`✼ Default system tray icon will remain unchanged.`); trayIconPath = await handleDownloadedTrayIcon(name, tauriConfigDirectory);
} }
} }
tauriConf.app.trayIcon.iconPath = trayIconPath; tauriConf.app.trayIcon.iconPath = trayIconPath;
@@ -625,7 +631,6 @@ async function mergeConfig(url, options, tauriConf) {
}; };
const configPath = path.join(tauriConfigDirectory, platformConfigPaths[platform]); const configPath = path.join(tauriConfigDirectory, platformConfigPaths[platform]);
const bundleConf = { bundle: tauriConf.bundle }; const bundleConf = { bundle: tauriConf.bundle };
console.log('pakeConfig', tauriConf.pake);
await fsExtra.outputJSON(configPath, bundleConf, { spaces: 4 }); await fsExtra.outputJSON(configPath, bundleConf, { spaces: 4 });
const pakeConfigPath = path.join(tauriConfigDirectory, 'pake.json'); const pakeConfigPath = path.join(tauriConfigDirectory, 'pake.json');
await fsExtra.outputJSON(pakeConfigPath, tauriConf.pake, { spaces: 4 }); 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'); const configJsonPath = path.join(tauriConfigDirectory, 'tauri.conf.json');
await fsExtra.outputJSON(configJsonPath, tauriConf2, { spaces: 4 }); 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 { class BaseBuilder {
constructor(options) { constructor(options) {
@@ -719,7 +784,7 @@ class BaseBuilder {
: 'npm run build'; : 'npm run build';
// Use temporary config directory to avoid modifying source files // Use temporary config directory to avoid modifying source files
const configPath = path.join(npmDirectory, 'src-tauri', '.pake', 'tauri.conf.json'); 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 // For macOS, use app bundles by default unless DMG is explicitly requested
if (IS_MAC && this.options.targets === 'app') { if (IS_MAC && this.options.targets === 'app') {
fullCommand += ' --bundles app'; fullCommand += ' --bundles app';
@@ -728,7 +793,7 @@ class BaseBuilder {
if (IS_MAC) { if (IS_MAC) {
const macOSVersion = this.getMacOSMajorVersion(); const macOSVersion = this.getMacOSMajorVersion();
if (macOSVersion >= 23) { if (macOSVersion >= 23) {
fullCommand += ' --features macos-proxy'; fullCommand += ',macos-proxy';
} }
} }
return fullCommand; 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) {
if (options.icon.startsWith('http')) { if (options.icon.startsWith('http')) {
return downloadIcon(options.icon); return downloadIcon(options.icon);
} }
else { return path.resolve(options.icon);
return path.resolve(options.icon);
}
} }
else { // Try to get favicon from website if URL is provided
logger.warn('✼ No icon given, default in use. For a custom icon, use --icon option.'); if (url && url.startsWith('http') && options.name) {
const iconPath = IS_WIN const faviconPath = await tryGetFavicon(url, options.name);
? 'src-tauri/png/icon_256.ico' if (faviconPath)
: IS_LINUX return faviconPath;
? 'src-tauri/png/icon_512.png' }
: 'src-tauri/icons/icon.icns'; logger.info('✼ No icon provided, using default icon.');
return path.join(npmDirectory, iconPath); 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 { try {
const iconResponse = await axios.get(iconUrl, { const response = await axios.get(iconUrl, {
responseType: 'arraybuffer', responseType: 'arraybuffer',
timeout: ICON_CONFIG.downloadTimeout,
}); });
const iconData = await iconResponse.data; const iconData = response.data;
if (!iconData) { if (!iconData || iconData.byteLength < ICON_CONFIG.minFileSize)
return null; return null;
}
const fileDetails = await fileTypeFromBuffer(iconData); const fileDetails = await fileTypeFromBuffer(iconData);
if (!fileDetails) { if (!fileDetails || !ICON_CONFIG.supportedFormats.includes(fileDetails.ext)) {
return null; return null;
} }
const { path: tempPath } = await dir(); return await saveIconFile(iconData, fileDetails.ext);
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) { catch (error) {
spinner.fail(chalk.red('Icon download failed!')); if (showSpinner && !(error.response?.status === 404)) {
if (error.response && error.response.status === 404) { throw error;
return null;
} }
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. // Extracts the domain from a given URL.
function getDomain(inputUrl) { function getDomain(inputUrl) {
@@ -1014,9 +1217,9 @@ async function handleOptions(options, url) {
name = name.toLowerCase().replace(/\s+/g, '-'); name = name.toLowerCase().replace(/\s+/g, '-');
} }
if (!isValidName(name, platform)) { 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 errorMsg = platform === 'linux'
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.`; ? `✕ Name should only include letters, numbers, dashes, and spaces. Spaces will be converted to dashes. Examples: Google Translate → google-translate, 123pan, weread.`
const errorMsg = platform === 'linux' ? LINUX_NAME_ERROR : DEFAULT_NAME_ERROR; : `✕ Name should only include letters, numbers, dashes, and spaces (not leading dashes and spaces). Examples: Google Translate, 123pan, WeRead, we-read.`;
logger.error(errorMsg); logger.error(errorMsg);
if (isActions) { if (isActions) {
name = resolveAppName(url, platform); name = resolveAppName(url, platform);
@@ -1031,7 +1234,7 @@ async function handleOptions(options, url) {
name, name,
identifier: getIdentifier(url), identifier: getIdentifier(url),
}; };
appOptions.icon = await handleIcon(appOptions); appOptions.icon = await handleIcon(appOptions, url);
return appOptions; return appOptions;
} }

View File

@@ -1,6 +1,6 @@
{ {
"name": "pake-cli", "name": "pake-cli",
"version": "3.2.0-beta1", "version": "3.2.0-beta3",
"description": "🤱🏻 Turn any webpage into a desktop app with Rust. 🤱🏻 利用 Rust 轻松构建轻量级多端桌面应用。", "description": "🤱🏻 Turn any webpage into a desktop app with Rust. 🤱🏻 利用 Rust 轻松构建轻量级多端桌面应用。",
"engines": { "engines": {
"node": ">=16.0.0" "node": ">=16.0.0"
@@ -58,6 +58,7 @@
"execa": "^9.6.0", "execa": "^9.6.0",
"file-type": "^18.7.0", "file-type": "^18.7.0",
"fs-extra": "^11.3.1", "fs-extra": "^11.3.1",
"icon-gen": "^5.0.0",
"loglevel": "^1.9.2", "loglevel": "^1.9.2",
"ora": "^8.2.0", "ora": "^8.2.0",
"prompts": "^2.4.2", "prompts": "^2.4.2",

2
rollup.config.js vendored
View File

@@ -7,6 +7,7 @@ import json from "@rollup/plugin-json";
import replace from "@rollup/plugin-replace"; import replace from "@rollup/plugin-replace";
import chalk from "chalk"; import chalk from "chalk";
import { spawn, exec } from "child_process"; import { spawn, exec } from "child_process";
import fs from "fs";
const isProduction = process.env.NODE_ENV === "production"; const isProduction = process.env.NODE_ENV === "production";
const devPlugins = !isProduction ? [pakeCliDevPlugin()] : []; const devPlugins = !isProduction ? [pakeCliDevPlugin()] : [];
@@ -48,7 +49,6 @@ function pakeCliDevPlugin() {
// 智能检测包管理器 // 智能检测包管理器
const detectPackageManager = () => { const detectPackageManager = () => {
const fs = require("fs");
if (fs.existsSync("pnpm-lock.yaml")) return "pnpm"; if (fs.existsSync("pnpm-lock.yaml")) return "pnpm";
if (fs.existsSync("yarn.lock")) return "yarn"; if (fs.existsSync("yarn.lock")) return "yarn";
return "npm"; return "npm";

View File

@@ -2,6 +2,7 @@ use std::str::FromStr;
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use tauri::{ use tauri::{
image::Image,
menu::{MenuBuilder, MenuItemBuilder}, menu::{MenuBuilder, MenuItemBuilder},
tray::TrayIconBuilder, tray::TrayIconBuilder,
AppHandle, Manager, AppHandle, Manager,
@@ -9,6 +10,8 @@ use tauri::{
use tauri_plugin_global_shortcut::{GlobalShortcutExt, Shortcut}; use tauri_plugin_global_shortcut::{GlobalShortcutExt, Shortcut};
use tauri_plugin_window_state::{AppHandleExt, StateFlags}; 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<()> { pub fn set_system_tray(app: &AppHandle, show_system_tray: bool) -> tauri::Result<()> {
if !show_system_tray { if !show_system_tray {
app.remove_tray_by_id("pake-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"); 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() let tray = TrayIconBuilder::new()
.menu(&menu) .menu(&menu)
.on_menu_event(move |app, event| match event.id().as_ref() { .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)?; .build(app)?;
tray.set_icon_as_template(false)?; tray.set_icon_as_template(false)?;