Merge branch 'dev'

This commit is contained in:
Tw93
2025-10-16 12:45:27 +08:00
31 changed files with 1672 additions and 554 deletions

178
CLAUDE.md
View File

@@ -9,6 +9,17 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
- **Clear intent over clever code**: Prioritize readability and maintainability
- **Simple over complex**: Keep all implementations simple and straightforward - prioritize solving problems and ease of maintenance over complex solutions
## Claude Code Eight Honors and Eight Shames
- **Shame** in guessing APIs, **Honor** in careful research
- **Shame** in vague execution, **Honor** in seeking confirmation
- **Shame** in assuming business logic, **Honor** in human verification
- **Shame** in creating interfaces, **Honor** in reusing existing ones
- **Shame** in skipping validation, **Honor** in proactive testing
- **Shame** in breaking architecture, **Honor** in following specifications
- **Shame** in pretending to understand, **Honor** in honest ignorance
- **Shame** in blind modification, **Honor** in careful refactoring
## Project Overview
Pake transforms any webpage into a lightweight desktop app using Rust and Tauri. It's significantly lighter than Electron (~5M vs ~100M+) with better performance.
@@ -21,146 +32,43 @@ Pake transforms any webpage into a lightweight desktop app using Rust and Tauri.
## Development Workflow
### 1. Planning Phase
1. **Understand**: Study existing patterns in codebase
2. **Plan**: Break complex work into 3-5 stages
3. **Test**: Write tests first (when applicable)
4. **Implement**: Minimal working solution
5. **Refactor**: Optimize and clean up
Break complex work into 3-5 stages:
1. Understand existing patterns in codebase
2. Plan implementation approach
3. Write tests first (when applicable)
4. Implement minimal working solution
5. Refactor and optimize
### 2. Implementation Flow
**Understanding First:**
**Key Commands:**
```bash
# Explore codebase structure
find src-tauri/src -name "*.rs" | head -10
grep -r "window_config" src-tauri/src/
pnpm test # Run comprehensive test suite
pnpm run cli:build # Build CLI for testing
pnpm run dev # Development with hot reload
```
**Development Commands:**
**Testing:**
```bash
# Install dependencies
pnpm i
# Development with hot reload (for testing app functionality)
pnpm run dev
# CLI development
pnpm run cli:dev
# Production build
pnpm run build
```
### 3. Testing and Validation
**Key Testing Commands:**
```bash
# Run comprehensive test suite (unit + integration + builder)
pnpm test
# Build CLI for testing
pnpm run cli:build
# Debug build for development
pnpm run build:debug
# Multi-platform testing
pnpm run build:mac # macOS universal build
```
**Testing Checklist:**
- [ ] Run `npm test` for comprehensive validation (35 tests)
- [ ] Test on target platforms
- [ ] Verify injection system works
- [ ] Check system tray integration
- [ ] Validate window behavior
- [ ] Test with weekly.tw93.fun URL
- [ ] Verify remote icon functionality (https://cdn.tw93.fun/pake/weekly.icns)
**Testing Notes:**
- Do NOT use `PAKE_NO_CONFIG_OVERWRITE=1` - this environment variable is not implemented
- Always run `pnpm test` before committing
- For CLI testing: `node dist/cli.js https://example.com --name TestApp --debug`
- **For app functionality testing**: Use `pnpm run dev` to start development server with hot reload. This allows real-time testing of injected JavaScript changes without rebuilding the entire app.
- The dev server automatically reloads when you modify files in `src-tauri/src/inject/` directory
- For app functionality testing: Use `pnpm run dev` for hot reload
## Core Components
### CLI Tool (`bin/`)
- `bin/cli.ts` - Main entry point with Commander.js
- `bin/builders/` - Platform-specific builders (Mac, Windows, Linux)
- `bin/options/` - CLI option processing and validation
- `bin/helpers/merge.ts` - Configuration merging (name setting at line 55)
### Tauri Application (`src-tauri/`)
- `src/lib.rs` - Application entry point
- `src/app/` - Core modules (window, tray, shortcuts)
- `src/inject/` - Web page injection logic
- **CLI Tool** (`bin/`): Main entry point, builders, options processing
- **Tauri App** (`src-tauri/`): Rust application, window/tray management, injection logic
- **Config Files**: `pake.json`, `tauri.conf.json`, platform-specific configs
## Documentation Guidelines
- **Main README**: Only include common, frequently-used parameters to avoid clutter
- **CLI Documentation** (`docs/cli-usage.md`): Include ALL parameters with detailed usage examples
- **Rare/Advanced Parameters**: Should have full documentation in CLI docs but minimal/no mention in main README
- **Examples of rare parameters**: `--title`, `--incognito`, `--system-tray-icon`, etc.
- **Main README**: Common parameters only
- **CLI Documentation** (`docs/cli-usage.md`): ALL parameters with examples
- **Rare parameters**: Full docs in CLI usage, minimal in main README
### Key Configuration Files
## Platform Specifics
- `pake.json` - App configuration
- `tauri.conf.json` - Tauri settings
- Platform configs: `tauri.{macos,windows,linux}.conf.json`
## Problem-Solving Approach
**When stuck:**
1. **Limit attempts to 3** before stopping to reassess
2. **Document what doesn't work** and why
3. **Research alternative approaches** in similar projects
4. **Question assumptions** - is there a simpler way?
**Example debugging flow:**
```bash
# 1. Check logs
pnpm run dev 2>&1 | grep -i error
# 2. Verify dependencies
cargo check --manifest-path=src-tauri/Cargo.toml
# 3. Test minimal reproduction
# Create simple test case isolating the issue
```
## Platform-Specific Development
### macOS
- Universal builds: `--multi-arch` flag
- Uses `.icns` icons
- Title bar customization available
### Windows
- Requires Visual Studio Build Tools
- Uses `.ico` icons
- MSI installer support
### Linux
- Multiple formats: deb, AppImage, rpm
- Requires `libwebkit2gtk` and dependencies
- Uses `.png` icons
- **macOS**: `.icns` icons, universal builds with `--multi-arch`
- **Windows**: `.ico` icons, requires Visual Studio Build Tools
- **Linux**: `.png` icons, multiple formats (deb, AppImage, rpm)
## Quality Standards
@@ -170,15 +78,15 @@ cargo check --manifest-path=src-tauri/Cargo.toml
- Use explicit types over implicit
- Write self-documenting code
- Follow existing patterns consistently
- **NO Chinese comments** - Use English only
- **NO unnecessary comments** - For simple, obvious code, let the code speak for itself
**Git and Commit Guidelines:**
**Git Guidelines:**
- **NEVER commit code automatically** - User handles all git operations
- **NEVER generate commit messages** - User writes their own commit messages
- **NEVER run npm publish automatically** - Always remind user to run it manually
- **NEVER execute git tag or push operations** - User handles all tag creation and GitHub pushes
- Only make code changes, user decides when and how to commit
- Test before user commits
- **NEVER commit automatically** - User handles all git operations
- **NEVER generate commit messages** - User writes their own
- Only make code changes, user decides when/how to commit
- Always test before user commits
## Branch Strategy
@@ -187,6 +95,6 @@ cargo check --manifest-path=src-tauri/Cargo.toml
## Prerequisites
- Node.js ≥22.0.0 (recommended LTS, older versions ≥18.0.0 may work)
- Rust ≥1.89.0 (recommended stable, older versions ≥1.78.0 may work)
- Platform build tools (see CONTRIBUTING.md for details)
- Node.js ≥22.0.0 (≥18.0.0 may work)
- Rust ≥1.89.0 (≥1.78.0 may work)
- Platform build tools (see CONTRIBUTING.md)

View File

@@ -70,17 +70,18 @@ If you're running macOS 26 Beta and encounter compilation errors related to `mac
[env]
# Fix for macOS 26 Beta compatibility issues
# Forces use of compatible SDK when building on macOS 26 Beta
MACOSX_DEPLOYMENT_TARGET = "15.5"
SDKROOT = "/Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX15.5.sdk"
MACOSX_DEPLOYMENT_TARGET = "15.0"
SDKROOT = "/Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX.sdk"
```
This file is already in `.gitignore` and should not be committed to the repository.
**Root Cause**: macOS 26 Beta uses newer system frameworks that aren't yet supported by the current Xcode SDK (15.5). This configuration forces the build to use the compatible SDK version.
**Root Cause**: macOS 26 Beta uses newer system frameworks that aren't yet fully compatible with Tauri's dependencies. This configuration uses the universal SDK symlink which automatically points to your system's available SDK version.
### Common Build Issues
- **Rust compilation errors**: Run `cargo clean` in `src-tauri/` directory
- **`cargo` command not found after installation**: Pake CLI now reloads the Rust environment automatically, but if the issue persists reopen your terminal or run `source ~/.cargo/env` (macOS/Linux) / `call %USERPROFILE%\.cargo\env` (Windows) before retrying
- **Node dependency issues**: Delete `node_modules` and run `pnpm install`
- **Permission errors on macOS**: Run `sudo xcode-select --reset`

View File

@@ -29,6 +29,7 @@
- **Beginners**: Download ready-made [Popular Packages](#popular-packages) or use [Online Building](docs/github-actions-usage.md) with no environment setup required
- **Developers**: Install [CLI Tool](docs/cli-usage.md) for one-command packaging of any website with customizable icons, window settings, and more
- **Advanced Users**: Clone the project locally for [Custom Development](#development), or check [Advanced Usage](docs/advanced-usage.md) for style customization and feature enhancement
- **Troubleshooting**: Check [FAQ](docs/faq.md) for common issues and solutions
## Popular Packages

View File

@@ -29,6 +29,7 @@
- **新手用户**:直接下载现成的 [常用包](#常用包下载),或通过 [在线构建](docs/github-actions-usage_CN.md) 无需环境配置即可打包
- **开发者**:安装 [CLI 工具](docs/cli-usage_CN.md) 后一行命令打包任意网站,支持自定义图标、窗口等参数
- **高级用户**:本地克隆项目进行 [定制开发](#定制开发),或查看 [高级用法](docs/advanced-usage_CN.md) 实现样式定制、功能增强
- **遇到问题**:查看 [常见问题](docs/faq_CN.md) 获取常见问题的解决方案
## 常用包下载

View File

@@ -4,9 +4,10 @@ import chalk from 'chalk';
import prompts from 'prompts';
import { PakeAppOptions } from '@/types';
import { checkRustInstalled, installRust } from '@/helpers/rust';
import { checkRustInstalled, ensureRustEnv, installRust } from '@/helpers/rust';
import { mergeConfig } from '@/helpers/merge';
import tauriConfig from '@/helpers/tauriConfig';
import { generateIdentifierSafeName } from '@/utils/name';
import { npmDirectory } from '@/utils/dir';
import { getSpinner } from '@/utils/info';
import { shellExec } from '@/utils/shell';
@@ -76,6 +77,8 @@ export default abstract class BaseBuilder {
logger.warn('✼ See more in https://tauri.app/start/prerequisites/.');
}
ensureRustEnv();
if (!checkRustInstalled()) {
const res = await prompts({
type: 'confirm',
@@ -117,15 +120,17 @@ export default abstract class BaseBuilder {
const projectCnConf = path.join(tauriSrcPath, 'rust_proxy.toml');
await fsExtra.copy(projectCnConf, projectConf);
await shellExec(
`cd "${npmDirectory}" && ${packageManager} install${registryOption}${peerDepsOption} --silent`,
`cd "${npmDirectory}" && ${packageManager} install${registryOption}${peerDepsOption}`,
timeout,
buildEnv,
this.options.debug,
);
} else {
await shellExec(
`cd "${npmDirectory}" && ${packageManager} install${peerDepsOption} --silent`,
`cd "${npmDirectory}" && ${packageManager} install${peerDepsOption}`,
timeout,
buildEnv,
this.options.debug,
);
}
spinner.succeed(chalk.green('Package installed!'));
@@ -159,12 +164,16 @@ export default abstract class BaseBuilder {
// Show static message to keep the status visible
logger.warn('✸ Building app...');
const buildEnv = this.getBuildEnvironment();
const buildEnv = {
...this.getBuildEnvironment(),
...(process.env.NO_STRIP && { NO_STRIP: process.env.NO_STRIP }),
};
await shellExec(
`cd "${npmDirectory}" && ${this.getBuildCommand(packageManager)}`,
this.getBuildTimeout(),
buildEnv,
this.options.debug,
);
// Copy app
@@ -409,7 +418,7 @@ export default abstract class BaseBuilder {
// Linux uses the unique binary name we set in merge.ts
if (process.platform === 'linux') {
return `pake-${appName.toLowerCase()}${extension}`;
return `pake-${generateIdentifierSafeName(appName)}${extension}`;
}
// Windows and macOS use 'pake' as binary name

22
bin/cli.ts vendored
View File

@@ -24,8 +24,7 @@ ${green('|_| \\__,_|_|\\_\\___| can turn any webpage into a desktop app with
program
.addHelpText('beforeAll', logo)
.usage(`[url] [options]`)
.showHelpAfterError()
.helpOption(false);
.showHelpAfterError();
program
.argument('[url]', 'The web URL you want to package', validateUrlInput)
@@ -127,10 +126,16 @@ program
)
.addOption(
new Option(
'--hide-on-close',
'--hide-on-close [boolean]',
'Hide window on close instead of exiting (default: true for macOS, false for others)',
)
.default(DEFAULT.hideOnClose)
.argParser((value) => {
if (value === undefined) return true; // --hide-on-close without value
if (value === 'true') return true;
if (value === 'false') return false;
throw new Error('--hide-on-close must be true or false');
})
.hideHelp(),
)
.addOption(new Option('--title <string>', 'Window title').hideHelp())
@@ -154,6 +159,11 @@ program
.default(DEFAULT.keepBinary)
.hideHelp(),
)
.addOption(
new Option('--multi-instance', 'Allow multiple app instances')
.default(DEFAULT.multiInstance)
.hideHelp(),
)
.addOption(
new Option('--installer-language <string>', 'Installer language')
.default(DEFAULT.installerLanguage)
@@ -163,11 +173,13 @@ program
.configureHelp({
sortSubcommands: true,
optionTerm: (option) => {
if (option.flags === '-v, --version') return '';
if (option.flags === '-v, --version' || option.flags === '-h, --help')
return '';
return option.flags;
},
optionDescription: (option) => {
if (option.flags === '-v, --version') return '';
if (option.flags === '-v, --version' || option.flags === '-h, --help')
return '';
return option.description;
},
})

1
bin/defaults.ts vendored
View File

@@ -27,6 +27,7 @@ export const DEFAULT_PAKE_OPTIONS: PakeCliOptions = {
wasm: false,
enableDragDrop: false,
keepBinary: false,
multiInstance: false,
};
// Just for cli development

42
bin/helpers/merge.ts vendored
View File

@@ -3,9 +3,17 @@ import fsExtra from 'fs-extra';
import combineFiles from '@/utils/combine';
import logger from '@/options/logger';
import { generateSafeFilename, generateIdentifierSafeName } from '@/utils/name';
import { PakeAppOptions, PlatformMap } from '@/types';
import { tauriConfigDirectory, npmDirectory } from '@/utils/dir';
/**
* Helper function to generate safe lowercase app name for file paths
*/
function getSafeAppName(name: string): string {
return generateSafeFilename(name).toLowerCase();
}
export async function mergeConfig(
url: string,
options: PakeAppOptions,
@@ -62,6 +70,7 @@ export async function mergeConfig(
title,
wasm,
enableDragDrop,
multiInstance,
} = options;
const { platform } = process;
@@ -91,7 +100,7 @@ export async function mergeConfig(
tauriConf.version = appVersion;
if (platform === 'linux') {
tauriConf.mainBinaryName = `pake-${name.toLowerCase()}`;
tauriConf.mainBinaryName = `pake-${generateIdentifierSafeName(name)}`;
}
if (platform == 'win32') {
@@ -151,8 +160,8 @@ export async function mergeConfig(
delete tauriConf.bundle.linux.deb.files;
// Generate correct desktop file configuration
const appNameLower = name.toLowerCase();
const identifier = `com.pake.${appNameLower}`;
const appNameSafe = getSafeAppName(name);
const identifier = `com.pake.${appNameSafe}`;
const desktopFileName = `${identifier}.desktop`;
// Create desktop file content
@@ -161,8 +170,8 @@ Version=1.0
Type=Application
Name=${name}
Comment=${name}
Exec=pake-${appNameLower}
Icon=${appNameLower}_512
Exec=pake-${appNameSafe}
Icon=${appNameSafe}_512
Categories=Network;WebBrowser;
MimeType=text/html;text/xml;application/xhtml_xml;
StartupNotify=true
@@ -210,31 +219,34 @@ StartupNotify=true
}
// Set icon.
const safeAppName = getSafeAppName(name);
const platformIconMap: PlatformMap = {
win32: {
fileExt: '.ico',
path: `png/${name.toLowerCase()}_256.ico`,
path: `png/${safeAppName}_256.ico`,
defaultIcon: 'png/icon_256.ico',
message: 'Windows icon must be .ico and 256x256px.',
},
linux: {
fileExt: '.png',
path: `png/${name.toLowerCase()}_512.png`,
path: `png/${safeAppName}_512.png`,
defaultIcon: 'png/icon_512.png',
message: 'Linux icon must be .png and 512x512px.',
},
darwin: {
fileExt: '.icns',
path: `icons/${name.toLowerCase()}.icns`,
path: `icons/${safeAppName}.icns`,
defaultIcon: 'icons/icon.icns',
message: 'macOS icon must be .icns type.',
},
};
const iconInfo = platformIconMap[platform];
const exists = options.icon && (await fsExtra.pathExists(options.icon));
const resolvedIconPath = options.icon ? path.resolve(options.icon) : null;
const exists =
resolvedIconPath && (await fsExtra.pathExists(resolvedIconPath));
if (exists) {
let updateIconPath = true;
let customIconExt = path.extname(options.icon).toLowerCase();
let customIconExt = path.extname(resolvedIconPath).toLowerCase();
if (customIconExt !== iconInfo.fileExt) {
updateIconPath = false;
@@ -245,10 +257,9 @@ StartupNotify=true
tauriConf.bundle.resources = [iconInfo.path];
// Avoid copying if source and destination are the same
const absoluteIconPath = path.resolve(options.icon);
const absoluteDestPath = path.resolve(iconPath);
if (absoluteIconPath !== absoluteDestPath) {
await fsExtra.copy(options.icon, iconPath);
if (resolvedIconPath !== absoluteDestPath) {
await fsExtra.copy(resolvedIconPath, iconPath);
}
}
@@ -275,9 +286,9 @@ StartupNotify=true
if (iconExt == '.png' || iconExt == '.ico') {
const trayIcoPath = path.join(
npmDirectory,
`src-tauri/png/${name.toLowerCase()}${iconExt}`,
`src-tauri/png/${safeAppName}${iconExt}`,
);
trayIconPath = `png/${name.toLowerCase()}${iconExt}`;
trayIconPath = `png/${safeAppName}${iconExt}`;
await fsExtra.copy(systemTrayIcon, trayIcoPath);
} else {
logger.warn(
@@ -323,6 +334,7 @@ StartupNotify=true
await fsExtra.writeFile(injectFilePath, '');
}
tauriConf.pake.proxy_url = proxyUrl || '';
tauriConf.pake.multi_instance = multiInstance;
// Configure WASM support with required HTTP headers
if (wasm) {

66
bin/helpers/rust.ts vendored
View File

@@ -1,3 +1,6 @@
import os from 'os';
import path from 'path';
import fsExtra from 'fs-extra';
import chalk from 'chalk';
import { execaSync } from 'execa';
@@ -6,6 +9,64 @@ import { IS_WIN } from '@/utils/platform';
import { shellExec } from '@/utils/shell';
import { isChinaDomain } from '@/utils/ip';
function normalizePathForComparison(targetPath: string) {
const normalized = path.normalize(targetPath);
return IS_WIN ? normalized.toLowerCase() : normalized;
}
function getCargoHomeCandidates(): string[] {
const candidates = new Set<string>();
if (process.env.CARGO_HOME) {
candidates.add(process.env.CARGO_HOME);
}
const homeDir = os.homedir();
if (homeDir) {
candidates.add(path.join(homeDir, '.cargo'));
}
if (IS_WIN && process.env.USERPROFILE) {
candidates.add(path.join(process.env.USERPROFILE, '.cargo'));
}
return Array.from(candidates).filter(Boolean);
}
function ensureCargoBinOnPath() {
const currentPath = process.env.PATH || '';
const segments = currentPath.split(path.delimiter).filter(Boolean);
const normalizedSegments = new Set(
segments.map((segment) => normalizePathForComparison(segment)),
);
const additions: string[] = [];
let cargoHomeSet = Boolean(process.env.CARGO_HOME);
for (const cargoHome of getCargoHomeCandidates()) {
const binDir = path.join(cargoHome, 'bin');
if (
fsExtra.pathExistsSync(binDir) &&
!normalizedSegments.has(normalizePathForComparison(binDir))
) {
additions.push(binDir);
normalizedSegments.add(normalizePathForComparison(binDir));
}
if (!cargoHomeSet && fsExtra.pathExistsSync(cargoHome)) {
process.env.CARGO_HOME = cargoHome;
cargoHomeSet = true;
}
}
if (additions.length) {
const prefix = additions.join(path.delimiter);
process.env.PATH = segments.length
? `${prefix}${path.delimiter}${segments.join(path.delimiter)}`
: prefix;
}
}
export function ensureRustEnv() {
ensureCargoBinOnPath();
}
export async function installRust() {
const isActions = process.env.GITHUB_ACTIONS;
const isInChina = await isChinaDomain('sh.rustup.rs');
@@ -20,8 +81,12 @@ export async function installRust() {
try {
await shellExec(
IS_WIN ? rustInstallScriptForWindows : rustInstallScriptForMac,
300000,
undefined,
true,
);
spinner.succeed(chalk.green('✔ Rust installed successfully!'));
ensureRustEnv();
} catch (error) {
spinner.fail(chalk.red('✕ Rust installation failed!'));
console.error(error.message);
@@ -30,6 +95,7 @@ export async function installRust() {
}
export function checkRustInstalled() {
ensureCargoBinOnPath();
try {
execaSync('rustc', ['--version']);
return true;

10
bin/options/icon.ts vendored
View File

@@ -43,9 +43,13 @@ const API_KEYS = {
/**
* Generates platform-specific icon paths and handles copying for Windows
*/
import { generateSafeFilename } from '@/utils/name';
function generateIconPath(appName: string, isDefault = false): string {
const safeName = appName.toLowerCase().replace(/[^a-z0-9-_]/g, '_');
const baseName = isDefault ? 'icon' : safeName;
const safeName = isDefault
? 'icon'
: generateSafeFilename(appName).toLowerCase();
const baseName = safeName;
if (IS_WIN) {
return path.join(npmDirectory, 'src-tauri', 'png', `${baseName}_256.ico`);
@@ -122,7 +126,7 @@ async function convertIconFormat(
await fsExtra.ensureDir(platformOutputDir);
const processedInputPath = await preprocessIcon(inputPath);
const iconName = appName.toLowerCase();
const iconName = generateSafeFilename(appName).toLowerCase();
// Generate platform-specific format
if (IS_WIN) {

View File

@@ -4,6 +4,7 @@ import logger from '@/options/logger';
import { handleIcon } from './icon';
import { getDomain } from '@/utils/url';
import { getIdentifier, promptText, capitalizeFirstLetter } from '@/utils/info';
import { generateLinuxPackageName } from '@/utils/name';
import { PakeAppOptions, PakeCliOptions, PlatformMap } from '@/types';
function resolveAppName(name: string, platform: NodeJS.Platform): string {
@@ -13,8 +14,8 @@ function resolveAppName(name: string, platform: NodeJS.Platform): string {
function isValidName(name: string, platform: NodeJS.Platform): boolean {
const platformRegexMapping: PlatformMap = {
linux: /^[a-z0-9][a-z0-9-]*$/,
default: /^[a-zA-Z0-9][a-zA-Z0-9- ]*$/,
linux: /^[a-z0-9\u4e00-\u9fff][a-z0-9\u4e00-\u9fff-]*$/,
default: /^[a-zA-Z0-9\u4e00-\u9fff][a-zA-Z0-9\u4e00-\u9fff- ]*$/,
};
const reg = platformRegexMapping[platform] || platformRegexMapping.default;
return !!name && reg.test(name);
@@ -36,10 +37,8 @@ export default async function handleOptions(
name = namePrompt || defaultName;
}
// Handle platform-specific name formatting
if (name && platform === 'linux') {
// Convert to lowercase and replace spaces with dashes for Linux
name = name.toLowerCase().replace(/\s+/g, '-');
name = generateLinuxPackageName(name);
}
if (!isValidName(name, platform)) {

3
bin/types.ts vendored
View File

@@ -87,6 +87,9 @@ export interface PakeCliOptions {
// Keep raw binary file alongside installer, default false
keepBinary: boolean;
// Allow multiple instances, default false (single instance)
multiInstance: boolean;
}
export interface PakeAppOptions extends PakeCliOptions {

51
bin/utils/name.ts vendored Normal file
View File

@@ -0,0 +1,51 @@
export function generateSafeFilename(name: string): string {
return name
.replace(/[<>:"/\\|?*]/g, '_')
.replace(/\s+/g, '_')
.replace(/\.+$/g, '')
.slice(0, 255);
}
export function generateLinuxPackageName(name: string): string {
return name
.toLowerCase()
.replace(/[^a-z0-9\u4e00-\u9fff]+/g, '-')
.replace(/^-+|-+$/g, '')
.replace(/-+/g, '-');
}
export function generateIdentifierSafeName(name: string): string {
const cleaned = name.replace(/[^a-zA-Z0-9\u4e00-\u9fff]/g, '').toLowerCase();
if (cleaned === '') {
const fallback = Array.from(name)
.map((char) => {
const code = char.charCodeAt(0);
if (
(code >= 48 && code <= 57) ||
(code >= 65 && code <= 90) ||
(code >= 97 && code <= 122)
) {
return char.toLowerCase();
}
return code.toString(16);
})
.join('')
.slice(0, 50);
return fallback || 'pake-app';
}
return cleaned;
}
export function generateWindowsFilename(name: string): string {
return name
.replace(/[<>:"/\\|?*]/g, '_')
.replace(/^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])$/i, '${name}_')
.slice(0, 255);
}
export function generateMacOSFilename(name: string): string {
return name.replace(/[:]/g, '_').slice(0, 255);
}

22
bin/utils/shell.ts vendored
View File

@@ -5,11 +5,12 @@ export async function shellExec(
command: string,
timeout: number = 300000,
env?: Record<string, string>,
showOutput: boolean = false,
) {
try {
const { exitCode } = await execa(command, {
cwd: npmDirectory,
stdio: ['inherit', 'pipe', 'inherit'], // Hide stdout verbose, keep stderr
stdio: showOutput ? 'inherit' : ['inherit', 'pipe', 'inherit'],
shell: true,
timeout,
env: env ? { ...process.env, ...env } : process.env,
@@ -25,8 +26,21 @@ export async function shellExec(
);
}
throw new Error(
`Error occurred while executing command "${command}". Exit code: ${exitCode}. Details: ${errorMessage}`,
);
let errorMsg = `Error occurred while executing command "${command}". Exit code: ${exitCode}. Details: ${errorMessage}`;
if (
process.platform === 'linux' &&
(errorMessage.includes('linuxdeploy') ||
errorMessage.includes('appimage') ||
errorMessage.includes('strip'))
) {
errorMsg +=
'\n\nLinux AppImage build error. Try one of these solutions:\n' +
' 1. Run with: NO_STRIP=true pake <url> --targets appimage\n' +
' 2. Use DEB format instead: pake <url> --targets deb\n' +
' 3. See detailed solutions: https://github.com/tw93/Pake/blob/main/docs/faq.md';
}
throw new Error(errorMsg);
}
}

211
dist/cli.js vendored
View File

@@ -6,6 +6,7 @@ import path from 'path';
import fsExtra from 'fs-extra';
import { fileURLToPath } from 'url';
import prompts from 'prompts';
import os from 'os';
import { execa, execaSync } from 'execa';
import crypto from 'crypto';
import ora from 'ora';
@@ -22,7 +23,7 @@ import sharp from 'sharp';
import * as psl from 'psl';
var name = "pake-cli";
var version = "3.3.5";
var version = "3.4.0";
var description = "🤱🏻 Turn any webpage into a desktop app with one command. 🤱🏻 一键打包网页生成轻量桌面应用。";
var engines = {
node: ">=18.0.0"
@@ -74,12 +75,12 @@ var license = "MIT";
var dependencies = {
"@tauri-apps/api": "^2.8.0",
"@tauri-apps/cli": "^2.8.4",
axios: "^1.11.0",
chalk: "^5.6.0",
axios: "^1.12.2",
chalk: "^5.6.2",
commander: "^12.1.0",
execa: "^9.6.0",
"file-type": "^18.7.0",
"fs-extra": "^11.3.1",
"fs-extra": "^11.3.2",
"icon-gen": "^5.0.0",
loglevel: "^1.9.2",
ora: "^8.2.0",
@@ -96,7 +97,7 @@ var devDependencies = {
"@rollup/plugin-replace": "^6.0.2",
"@rollup/plugin-terser": "^0.4.4",
"@types/fs-extra": "^11.0.4",
"@types/node": "^20.19.13",
"@types/node": "^20.19.21",
"@types/page-icon": "^0.3.6",
"@types/prompts": "^2.4.9",
"@types/tmp": "^0.2.6",
@@ -104,10 +105,10 @@ var devDependencies = {
"app-root-path": "^3.1.0",
"cross-env": "^7.0.3",
prettier: "^3.6.2",
rollup: "^4.50.0",
rollup: "^4.52.4",
"rollup-plugin-typescript2": "^0.36.0",
tslib: "^2.8.1",
typescript: "^5.9.2"
typescript: "^5.9.3"
};
var packageJson = {
name: name,
@@ -200,11 +201,11 @@ const IS_MAC = platform$1 === 'darwin';
const IS_WIN = platform$1 === 'win32';
const IS_LINUX = platform$1 === 'linux';
async function shellExec(command, timeout = 300000, env) {
async function shellExec(command, timeout = 300000, env, showOutput = false) {
try {
const { exitCode } = await execa(command, {
cwd: npmDirectory,
stdio: ['inherit', 'pipe', 'inherit'], // Hide stdout verbose, keep stderr
stdio: showOutput ? 'inherit' : ['inherit', 'pipe', 'inherit'],
shell: true,
timeout,
env: env ? { ...process.env, ...env } : process.env,
@@ -217,7 +218,18 @@ async function shellExec(command, timeout = 300000, env) {
if (error.timedOut) {
throw new Error(`Command timed out after ${timeout}ms: "${command}". Try increasing timeout or check network connectivity.`);
}
throw new Error(`Error occurred while executing command "${command}". Exit code: ${exitCode}. Details: ${errorMessage}`);
let errorMsg = `Error occurred while executing command "${command}". Exit code: ${exitCode}. Details: ${errorMessage}`;
if (process.platform === 'linux' &&
(errorMessage.includes('linuxdeploy') ||
errorMessage.includes('appimage') ||
errorMessage.includes('strip'))) {
errorMsg +=
'\n\nLinux AppImage build error. Try one of these solutions:\n' +
' 1. Run with: NO_STRIP=true pake <url> --targets appimage\n' +
' 2. Use DEB format instead: pake <url> --targets deb\n' +
' 3. See detailed solutions: https://github.com/tw93/Pake/blob/main/docs/faq.md';
}
throw new Error(errorMsg);
}
}
@@ -284,6 +296,52 @@ async function isChinaIP(ip, domain) {
}
}
function normalizePathForComparison(targetPath) {
const normalized = path.normalize(targetPath);
return IS_WIN ? normalized.toLowerCase() : normalized;
}
function getCargoHomeCandidates() {
const candidates = new Set();
if (process.env.CARGO_HOME) {
candidates.add(process.env.CARGO_HOME);
}
const homeDir = os.homedir();
if (homeDir) {
candidates.add(path.join(homeDir, '.cargo'));
}
if (IS_WIN && process.env.USERPROFILE) {
candidates.add(path.join(process.env.USERPROFILE, '.cargo'));
}
return Array.from(candidates).filter(Boolean);
}
function ensureCargoBinOnPath() {
const currentPath = process.env.PATH || '';
const segments = currentPath.split(path.delimiter).filter(Boolean);
const normalizedSegments = new Set(segments.map((segment) => normalizePathForComparison(segment)));
const additions = [];
let cargoHomeSet = Boolean(process.env.CARGO_HOME);
for (const cargoHome of getCargoHomeCandidates()) {
const binDir = path.join(cargoHome, 'bin');
if (fsExtra.pathExistsSync(binDir) &&
!normalizedSegments.has(normalizePathForComparison(binDir))) {
additions.push(binDir);
normalizedSegments.add(normalizePathForComparison(binDir));
}
if (!cargoHomeSet && fsExtra.pathExistsSync(cargoHome)) {
process.env.CARGO_HOME = cargoHome;
cargoHomeSet = true;
}
}
if (additions.length) {
const prefix = additions.join(path.delimiter);
process.env.PATH = segments.length
? `${prefix}${path.delimiter}${segments.join(path.delimiter)}`
: prefix;
}
}
function ensureRustEnv() {
ensureCargoBinOnPath();
}
async function installRust() {
const isActions = process.env.GITHUB_ACTIONS;
const isInChina = await isChinaDomain('sh.rustup.rs');
@@ -293,8 +351,9 @@ async function installRust() {
const rustInstallScriptForWindows = 'winget install --id Rustlang.Rustup';
const spinner = getSpinner('Downloading Rust...');
try {
await shellExec(IS_WIN ? rustInstallScriptForWindows : rustInstallScriptForMac);
await shellExec(IS_WIN ? rustInstallScriptForWindows : rustInstallScriptForMac, 300000, undefined, true);
spinner.succeed(chalk.green('✔ Rust installed successfully!'));
ensureRustEnv();
}
catch (error) {
spinner.fail(chalk.red('✕ Rust installation failed!'));
@@ -303,6 +362,7 @@ async function installRust() {
}
}
function checkRustInstalled() {
ensureCargoBinOnPath();
try {
execaSync('rustc', ['--version']);
return true;
@@ -328,6 +388,46 @@ async function combineFiles(files, output) {
return files;
}
function generateSafeFilename(name) {
return name
.replace(/[<>:"/\\|?*]/g, '_')
.replace(/\s+/g, '_')
.replace(/\.+$/g, '')
.slice(0, 255);
}
function generateLinuxPackageName(name) {
return name
.toLowerCase()
.replace(/[^a-z0-9\u4e00-\u9fff]+/g, '-')
.replace(/^-+|-+$/g, '')
.replace(/-+/g, '-');
}
function generateIdentifierSafeName(name) {
const cleaned = name.replace(/[^a-zA-Z0-9\u4e00-\u9fff]/g, '').toLowerCase();
if (cleaned === '') {
const fallback = Array.from(name)
.map((char) => {
const code = char.charCodeAt(0);
if ((code >= 48 && code <= 57) ||
(code >= 65 && code <= 90) ||
(code >= 97 && code <= 122)) {
return char.toLowerCase();
}
return code.toString(16);
})
.join('')
.slice(0, 50);
return fallback || 'pake-app';
}
return cleaned;
}
/**
* Helper function to generate safe lowercase app name for file paths
*/
function getSafeAppName(name) {
return generateSafeFilename(name).toLowerCase();
}
async function mergeConfig(url, options, tauriConf) {
// Ensure .pake directory exists and copy source templates if needed
const srcTauriDir = path.join(npmDirectory, 'src-tauri');
@@ -348,7 +448,7 @@ async function mergeConfig(url, options, tauriConf) {
await fsExtra.copy(sourcePath, destPath);
}
}));
const { width, height, fullscreen, hideTitleBar, alwaysOnTop, appVersion, darkMode, disabledWebShortcuts, activationShortcut, userAgent, showSystemTray, systemTrayIcon, useLocalFile, identifier, name, resizable = true, inject, proxyUrl, installerLanguage, hideOnClose, incognito, title, wasm, enableDragDrop, } = options;
const { width, height, fullscreen, hideTitleBar, alwaysOnTop, appVersion, darkMode, disabledWebShortcuts, activationShortcut, userAgent, showSystemTray, systemTrayIcon, useLocalFile, identifier, name, resizable = true, inject, proxyUrl, installerLanguage, hideOnClose, incognito, title, wasm, enableDragDrop, multiInstance, } = options;
const { platform } = process;
const platformHideOnClose = hideOnClose ?? platform === 'darwin';
const tauriConfWindowOptions = {
@@ -372,7 +472,7 @@ async function mergeConfig(url, options, tauriConf) {
tauriConf.identifier = identifier;
tauriConf.version = appVersion;
if (platform === 'linux') {
tauriConf.mainBinaryName = `pake-${name.toLowerCase()}`;
tauriConf.mainBinaryName = `pake-${generateIdentifierSafeName(name)}`;
}
if (platform == 'win32') {
tauriConf.bundle.windows.wix.language[0] = installerLanguage;
@@ -418,8 +518,8 @@ async function mergeConfig(url, options, tauriConf) {
// Remove hardcoded desktop files and regenerate with correct app name
delete tauriConf.bundle.linux.deb.files;
// Generate correct desktop file configuration
const appNameLower = name.toLowerCase();
const identifier = `com.pake.${appNameLower}`;
const appNameSafe = getSafeAppName(name);
const identifier = `com.pake.${appNameSafe}`;
const desktopFileName = `${identifier}.desktop`;
// Create desktop file content
const desktopContent = `[Desktop Entry]
@@ -427,8 +527,8 @@ Version=1.0
Type=Application
Name=${name}
Comment=${name}
Exec=pake-${appNameLower}
Icon=${appNameLower}_512
Exec=pake-${appNameSafe}
Icon=${appNameSafe}_512
Categories=Network;WebBrowser;
MimeType=text/html;text/xml;application/xhtml_xml;
StartupNotify=true
@@ -469,31 +569,33 @@ StartupNotify=true
}
}
// Set icon.
const safeAppName = getSafeAppName(name);
const platformIconMap = {
win32: {
fileExt: '.ico',
path: `png/${name.toLowerCase()}_256.ico`,
path: `png/${safeAppName}_256.ico`,
defaultIcon: 'png/icon_256.ico',
message: 'Windows icon must be .ico and 256x256px.',
},
linux: {
fileExt: '.png',
path: `png/${name.toLowerCase()}_512.png`,
path: `png/${safeAppName}_512.png`,
defaultIcon: 'png/icon_512.png',
message: 'Linux icon must be .png and 512x512px.',
},
darwin: {
fileExt: '.icns',
path: `icons/${name.toLowerCase()}.icns`,
path: `icons/${safeAppName}.icns`,
defaultIcon: 'icons/icon.icns',
message: 'macOS icon must be .icns type.',
},
};
const iconInfo = platformIconMap[platform];
const exists = options.icon && (await fsExtra.pathExists(options.icon));
const resolvedIconPath = options.icon ? path.resolve(options.icon) : null;
const exists = resolvedIconPath && (await fsExtra.pathExists(resolvedIconPath));
if (exists) {
let updateIconPath = true;
let customIconExt = path.extname(options.icon).toLowerCase();
let customIconExt = path.extname(resolvedIconPath).toLowerCase();
if (customIconExt !== iconInfo.fileExt) {
updateIconPath = false;
logger.warn(`${iconInfo.message}, but you give ${customIconExt}`);
@@ -503,10 +605,9 @@ StartupNotify=true
const iconPath = path.join(npmDirectory, 'src-tauri/', iconInfo.path);
tauriConf.bundle.resources = [iconInfo.path];
// Avoid copying if source and destination are the same
const absoluteIconPath = path.resolve(options.icon);
const absoluteDestPath = path.resolve(iconPath);
if (absoluteIconPath !== absoluteDestPath) {
await fsExtra.copy(options.icon, iconPath);
if (resolvedIconPath !== absoluteDestPath) {
await fsExtra.copy(resolvedIconPath, iconPath);
}
}
if (updateIconPath) {
@@ -528,8 +629,8 @@ StartupNotify=true
// 需要判断图标格式默认只支持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}`;
const trayIcoPath = path.join(npmDirectory, `src-tauri/png/${safeAppName}${iconExt}`);
trayIconPath = `png/${safeAppName}${iconExt}`;
await fsExtra.copy(systemTrayIcon, trayIcoPath);
}
else {
@@ -563,6 +664,7 @@ StartupNotify=true
await fsExtra.writeFile(injectFilePath, '');
}
tauriConf.pake.proxy_url = proxyUrl || '';
tauriConf.pake.multi_instance = multiInstance;
// Configure WASM support with required HTTP headers
if (wasm) {
tauriConf.app.security = {
@@ -639,6 +741,7 @@ class BaseBuilder {
logger.warn('✼ The first use requires installing system dependencies.');
logger.warn('✼ See more in https://tauri.app/start/prerequisites/.');
}
ensureRustEnv();
if (!checkRustInstalled()) {
const res = await prompts({
type: 'confirm',
@@ -671,10 +774,10 @@ class BaseBuilder {
logger.info(`✺ Located in China, using ${packageManager}/rsProxy CN mirror.`);
const projectCnConf = path.join(tauriSrcPath, 'rust_proxy.toml');
await fsExtra.copy(projectCnConf, projectConf);
await shellExec(`cd "${npmDirectory}" && ${packageManager} install${registryOption}${peerDepsOption} --silent`, timeout, buildEnv);
await shellExec(`cd "${npmDirectory}" && ${packageManager} install${registryOption}${peerDepsOption}`, timeout, buildEnv, this.options.debug);
}
else {
await shellExec(`cd "${npmDirectory}" && ${packageManager} install${peerDepsOption} --silent`, timeout, buildEnv);
await shellExec(`cd "${npmDirectory}" && ${packageManager} install${peerDepsOption}`, timeout, buildEnv, this.options.debug);
}
spinner.succeed(chalk.green('Package installed!'));
if (!tauriTargetPathExists) {
@@ -699,8 +802,11 @@ class BaseBuilder {
buildSpinner.stop();
// Show static message to keep the status visible
logger.warn('✸ Building app...');
const buildEnv = this.getBuildEnvironment();
await shellExec(`cd "${npmDirectory}" && ${this.getBuildCommand(packageManager)}`, this.getBuildTimeout(), buildEnv);
const buildEnv = {
...this.getBuildEnvironment(),
...(process.env.NO_STRIP && { NO_STRIP: process.env.NO_STRIP }),
};
await shellExec(`cd "${npmDirectory}" && ${this.getBuildCommand(packageManager)}`, this.getBuildTimeout(), buildEnv, this.options.debug);
// Copy app
const fileName = this.getFileName();
const fileType = this.getFileType(target);
@@ -854,7 +960,7 @@ class BaseBuilder {
const extension = process.platform === 'win32' ? '.exe' : '';
// Linux uses the unique binary name we set in merge.ts
if (process.platform === 'linux') {
return `pake-${appName.toLowerCase()}${extension}`;
return `pake-${generateIdentifierSafeName(appName)}${extension}`;
}
// Windows and macOS use 'pake' as binary name
return `pake${extension}`;
@@ -1142,6 +1248,7 @@ const DEFAULT_PAKE_OPTIONS = {
wasm: false,
enableDragDrop: false,
keepBinary: false,
multiInstance: false,
};
async function checkUpdateTips() {
@@ -1169,12 +1276,11 @@ const API_KEYS = {
logoDev: ['pk_JLLMUKGZRpaG5YclhXaTkg', 'pk_Ph745P8mQSeYFfW2Wk039A'],
brandfetch: ['1idqvJC0CeFSeyp3Yf7', '1idej-yhU_ThggIHFyG'],
};
/**
* Generates platform-specific icon paths and handles copying for Windows
*/
function generateIconPath(appName, isDefault = false) {
const safeName = appName.toLowerCase().replace(/[^a-z0-9-_]/g, '_');
const baseName = isDefault ? 'icon' : safeName;
const safeName = isDefault
? 'icon'
: generateSafeFilename(appName).toLowerCase();
const baseName = safeName;
if (IS_WIN) {
return path.join(npmDirectory, 'src-tauri', 'png', `${baseName}_256.ico`);
}
@@ -1237,7 +1343,7 @@ async function convertIconFormat(inputPath, appName) {
const platformOutputDir = path.join(outputDir, 'converted-icons');
await fsExtra.ensureDir(platformOutputDir);
const processedInputPath = await preprocessIcon(inputPath);
const iconName = appName.toLowerCase();
const iconName = generateSafeFilename(appName).toLowerCase();
// Generate platform-specific format
if (IS_WIN) {
// Support multiple sizes for better Windows compatibility
@@ -1513,8 +1619,8 @@ function resolveAppName(name, platform) {
}
function isValidName(name, platform) {
const platformRegexMapping = {
linux: /^[a-z0-9][a-z0-9-]*$/,
default: /^[a-zA-Z0-9][a-zA-Z0-9- ]*$/,
linux: /^[a-z0-9\u4e00-\u9fff][a-z0-9\u4e00-\u9fff-]*$/,
default: /^[a-zA-Z0-9\u4e00-\u9fff][a-zA-Z0-9\u4e00-\u9fff- ]*$/,
};
const reg = platformRegexMapping[platform] || platformRegexMapping.default;
return !!name && reg.test(name);
@@ -1530,10 +1636,8 @@ async function handleOptions(options, url) {
const namePrompt = await promptText(promptMessage, defaultName);
name = namePrompt || defaultName;
}
// Handle platform-specific name formatting
if (name && platform === 'linux') {
// Convert to lowercase and replace spaces with dashes for Linux
name = name.toLowerCase().replace(/\s+/g, '-');
name = generateLinuxPackageName(name);
}
if (!isValidName(name, platform)) {
const LINUX_NAME_ERROR = `✕ Name should only include lowercase letters, numbers, and dashes (not leading dashes). Examples: com-123-xxx, 123pan, pan123, weread, we-read, 123.`;
@@ -1588,8 +1692,7 @@ ${green('|_| \\__,_|_|\\_\\___| can turn any webpage into a desktop app with
program
.addHelpText('beforeAll', logo)
.usage(`[url] [options]`)
.showHelpAfterError()
.helpOption(false);
.showHelpAfterError();
program
.argument('[url]', 'The web URL you want to package', validateUrlInput)
// Refer to https://github.com/tj/commander.js#custom-option-processing, turn string array into a string connected with custom connectors.
@@ -1643,8 +1746,17 @@ program
.addOption(new Option('--system-tray-icon <string>', 'Custom system tray icon')
.default(DEFAULT_PAKE_OPTIONS.systemTrayIcon)
.hideHelp())
.addOption(new Option('--hide-on-close', 'Hide window on close instead of exiting (default: true for macOS, false for others)')
.addOption(new Option('--hide-on-close [boolean]', 'Hide window on close instead of exiting (default: true for macOS, false for others)')
.default(DEFAULT_PAKE_OPTIONS.hideOnClose)
.argParser((value) => {
if (value === undefined)
return true; // --hide-on-close without value
if (value === 'true')
return true;
if (value === 'false')
return false;
throw new Error('--hide-on-close must be true or false');
})
.hideHelp())
.addOption(new Option('--title <string>', 'Window title').hideHelp())
.addOption(new Option('--incognito', 'Launch app in incognito/private mode')
@@ -1659,6 +1771,9 @@ program
.addOption(new Option('--keep-binary', 'Keep raw binary file alongside installer')
.default(DEFAULT_PAKE_OPTIONS.keepBinary)
.hideHelp())
.addOption(new Option('--multi-instance', 'Allow multiple app instances')
.default(DEFAULT_PAKE_OPTIONS.multiInstance)
.hideHelp())
.addOption(new Option('--installer-language <string>', 'Installer language')
.default(DEFAULT_PAKE_OPTIONS.installerLanguage)
.hideHelp())
@@ -1666,12 +1781,12 @@ program
.configureHelp({
sortSubcommands: true,
optionTerm: (option) => {
if (option.flags === '-v, --version')
if (option.flags === '-v, --version' || option.flags === '-h, --help')
return '';
return option.flags;
},
optionDescription: (option) => {
if (option.flags === '-v, --version')
if (option.flags === '-v, --version' || option.flags === '-h, --help')
return '';
return option.description;
},

View File

@@ -38,6 +38,44 @@ document.addEventListener("keydown", (e) => {
});
```
## Built-in Features
### Download Error Notifications
Pake automatically provides user-friendly download error notifications:
**Features:**
- **Bilingual Support**: Automatically detects browser language (Chinese/English)
- **System Notifications**: Uses native OS notifications when permission is granted
- **Graceful Fallback**: Falls back to console logging if notifications are unavailable
- **Comprehensive Coverage**: Handles all download types (HTTP, Data URI, Blob)
**User Experience:**
When a download fails, users will see a notification:
- English: "Download Error - Download failed: filename.pdf"
- Chinese: "下载错误 - 下载失败: filename.pdf"
**Requesting Notification Permission:**
To enable notifications, add this to your injected JavaScript:
```javascript
// Request notification permission on app start
if (window.Notification && Notification.permission === "default") {
Notification.requestPermission();
}
```
The download system automatically handles:
- Regular HTTP(S) downloads
- Data URI downloads (base64 encoded files)
- Blob URL downloads (dynamically generated files)
- Context menu initiated downloads
## Container Communication
Send messages between web content and Pake container.

View File

@@ -38,6 +38,41 @@ document.addEventListener("keydown", (e) => {
});
```
## 内置功能
### 下载错误通知
Pake 自动提供用户友好的下载错误通知:
**功能特性:**
- **双语支持**:自动检测浏览器语言(中文/英文)
- **系统通知**:在授予权限后使用原生操作系统通知
- **优雅降级**:如果通知不可用则降级到控制台日志
- **全面覆盖**处理所有下载类型HTTP、Data URI、Blob
**用户体验:**
当下载失败时,用户将看到通知:
- 英文:"Download Error - Download failed: filename.pdf"
- 中文:"下载错误 - 下载失败: filename.pdf"
**请求通知权限:**
要启用通知,请在注入的 JavaScript 中添加:
```javascript
// 在应用启动时请求通知权限
if (window.Notification && Notification.permission === "default") {
Notification.requestPermission();
}
```
下载系统自动处理:
- 常规 HTTP(S) 下载
- Data URI 下载base64 编码文件)
- Blob URL 下载(动态生成的文件)
- 右键菜单发起的下载
## 容器通信
在网页内容和 Pake 容器之间发送消息。

16
docs/cli-usage.md vendored
View File

@@ -280,7 +280,12 @@ Specify the system tray icon. This is only effective when the system tray is ena
Hide window instead of closing the application when clicking close button. Platform-specific default: `true` for macOS, `false` for Windows/Linux.
```shell
# Hide on close (default behavior on macOS)
--hide-on-close
--hide-on-close true
# Close application immediately (default behavior on Windows/Linux)
--hide-on-close false
```
#### [title]
@@ -340,6 +345,17 @@ pake https://github.com --name GitHub --keep-binary
**Output**: Creates both installer and standalone executable (`AppName-binary` on Unix, `AppName.exe` on Windows).
#### [multi-instance]
Allow the packaged app to run more than one instance at the same time. Default is `false`, which means launching a second instance simply focuses the existing window. Enable this when you need to open several windows of the same app simultaneously.
```shell
--multi-instance
# Example: Allow multiple chat windows
pake https://chat.example.com --name ChatApp --multi-instance
```
#### [installer-language]
Set the Windows Installer language. Options include `zh-CN`, `ja-JP`, More at [Tauri Document](https://tauri.app/distribute/windows-installer/#internationalization). Default is `en-US`.

16
docs/cli-usage_CN.md vendored
View File

@@ -279,7 +279,12 @@ pake https://github.com --name GitHub
点击关闭按钮时隐藏窗口而不是退出应用程序。平台特定默认值macOS 为 `true`Windows/Linux 为 `false`。
```shell
# 关闭时隐藏macOS 默认行为)
--hide-on-close
--hide-on-close true
# 立即关闭应用程序Windows/Linux 默认行为)
--hide-on-close false
```
#### [title]
@@ -339,6 +344,17 @@ pake https://github.com --name GitHub --keep-binary
**输出结果**同时创建安装包和独立可执行文件Unix 系统为 `AppName-binary`Windows 为 `AppName.exe`)。
#### [multi-instance]
允许打包后的应用同时运行多个实例。默认为 `false`,此时再次启动只会聚焦已有窗口。启用该选项后,可以同时打开同一个应用的多个窗口。
```shell
--multi-instance
# 示例:允许聊天应用同时开多个窗口
pake https://chat.example.com --name ChatApp --multi-instance
```
#### [installer-language]
设置 Windows 安装包语言。支持 `zh-CN`、`ja-JP`,更多在 [Tauri 文档](https://tauri.app/distribute/windows-installer/#internationalization)。默认为 `en-US`。

248
docs/faq.md vendored Normal file
View File

@@ -0,0 +1,248 @@
# Frequently Asked Questions (FAQ)
<h4 align="right"><strong>English</strong> | <a href="faq_CN.md">简体中文</a></h4>
Common issues and solutions when using Pake.
## Build Issues
### Linux: AppImage Build Fails with "failed to run linuxdeploy"
**Problem:**
When building AppImage on Linux (Debian, Ubuntu, Arch, etc.), you may encounter errors like:
```txt
Error: failed to run linuxdeploy
Error: strip: Unable to recognise the format of the input file
```
**Solution 1: Use NO_STRIP (Recommended)**
Simply add `NO_STRIP=true` before your build command:
```bash
NO_STRIP=true pake https://example.com --name MyApp --targets appimage
```
This bypasses the library stripping process that often causes issues on certain Linux distributions.
**Solution 2: Install System Dependencies**
If NO_STRIP doesn't work, ensure you have all required system dependencies:
```bash
sudo apt update
sudo apt install -y \
libdbus-1-dev \
libsoup-3.0-dev \
libjavascriptcoregtk-4.1-dev \
libwebkit2gtk-4.1-dev \
build-essential \
curl wget file \
libxdo-dev \
libssl-dev \
libgtk-3-dev \
libayatana-appindicator3-dev \
librsvg2-dev \
gnome-video-effects \
libglib2.0-dev \
libgirepository1.0-dev \
pkg-config
```
Then try building again with `NO_STRIP=true`.
**Solution 3: Use DEB Format Instead**
DEB packages are more stable on Debian-based systems:
```bash
pake https://example.com --name MyApp --targets deb
```
**Solution 4: Use Docker**
Build in a clean environment without installing dependencies:
```bash
docker run --rm -v $(pwd)/output:/app/output \
ghcr.io/tw93/pake:latest \
pake https://example.com --name MyApp --targets appimage
```
**Why This Happens:**
This is a known issue with Tauri's linuxdeploy tool, which can fail when:
- System libraries have incompatible formats for stripping
- Building on newer distributions (Arch, Debian Trixie, etc.)
- Missing WebKit2GTK or GTK development libraries
The `NO_STRIP=true` environment variable is the official workaround recommended by the Tauri community.
---
### Linux: "cargo: command not found" After Installing Rust
**Problem:**
You installed Rust but Pake still reports "cargo: command not found".
**Solution:**
Pake CLI automatically reloads the Rust environment, but if issues persist:
```bash
# Reload environment in current terminal
source ~/.cargo/env
# Or restart your terminal
```
Then try building again.
---
### macOS: Build Fails with Module Compilation Errors
**Problem:**
On macOS 26 Beta or newer, you may see errors related to `CoreFoundation` or `_Builtin_float` modules.
**Solution:**
Create a configuration file to use compatible SDK:
```bash
cat > src-tauri/.cargo/config.toml << 'EOF'
[env]
MACOSX_DEPLOYMENT_TARGET = "15.0"
SDKROOT = "/Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX.sdk"
EOF
```
This file is already in `.gitignore` and won't be committed.
---
### Windows: Missing Visual Studio Build Tools
**Problem:**
Build fails with errors about missing MSVC or Windows SDK.
**Solution:**
Install Visual Studio Build Tools:
1. Download [Visual Studio Build Tools](https://visualstudio.microsoft.com/downloads/#build-tools-for-visual-studio-2022)
2. During installation, select "Desktop development with C++"
3. For ARM64 support: Also select "MSVC v143 - VS 2022 C++ ARM64 build tools" under Individual Components
---
## Runtime Issues
### App Window is Too Small/Large
**Solution:**
Specify custom dimensions when building:
```bash
pake https://example.com --width 1200 --height 800
```
See [CLI Usage Guide](cli-usage.md#window-options) for all window options.
---
### App Icon Not Showing Correctly
**Problem:**
Custom icon doesn't appear or shows default icon.
**Solution:**
Ensure you're using the correct icon format for your platform:
- **macOS**: `.icns` format
- **Windows**: `.ico` format
- **Linux**: `.png` format
```bash
# macOS
pake https://example.com --icon ./icon.icns
# Windows
pake https://example.com --icon ./icon.ico
# Linux
pake https://example.com --icon ./icon.png
```
Pake can automatically convert icons, but providing the correct format is more reliable.
---
### Website Features Not Working (Login, Upload, etc.)
**Problem:**
Some website features don't work in the Pake app.
**Solution:**
This is usually due to web compatibility issues. Try:
1. **Set custom User Agent:**
```bash
pake https://example.com --user-agent "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
```
2. **Inject custom JavaScript:**
```bash
pake https://example.com --inject ./fix.js
```
3. **Check if the site requires specific permissions** that may not be available in WebView
---
## Installation Issues
### Permission Denied When Installing Globally
**Problem:**
`npm install -g pake-cli` fails with permission errors.
**Solution:**
Use one of these approaches:
```bash
# Option 1: Use npx (no installation needed)
npx pake-cli https://example.com
# Option 2: Fix npm permissions
npm config set prefix ~/.npm-global
echo 'export PATH=~/.npm-global/bin:$PATH' >> ~/.bashrc
source ~/.bashrc
npm install -g pake-cli
# Option 3: Use pnpm (recommended)
pnpm install -g pake-cli
```
---
## Getting Help
If your issue isn't covered here:
1. Check the [CLI Usage Guide](cli-usage.md) for detailed parameter documentation
2. See [Advanced Usage](advanced-usage.md) for prerequisites and system setup
3. Search [existing GitHub issues](https://github.com/tw93/Pake/issues)
4. [Open a new issue](https://github.com/tw93/Pake/issues/new) with:
- Your OS and version
- Node.js and Rust versions (`node --version`, `rustc --version`)
- Complete error message
- Build command you used

248
docs/faq_CN.md vendored Normal file
View File

@@ -0,0 +1,248 @@
# 常见问题 (FAQ)
<h4 align="right"><a href="faq.md">English</a> | <strong>简体中文</strong></h4>
使用 Pake 时的常见问题和解决方案。
## 构建问题
### LinuxAppImage 构建失败,提示 "failed to run linuxdeploy"
**问题描述:**
在 Linux 系统Debian、Ubuntu、Arch 等)上构建 AppImage 时,可能遇到如下错误:
```txt
Error: failed to run linuxdeploy
Error: strip: Unable to recognise the format of the input file
```
**解决方案 1使用 NO_STRIP推荐**
在构建命令前加上 `NO_STRIP=true`
```bash
NO_STRIP=true pake https://example.com --name MyApp --targets appimage
```
这会绕过经常在某些 Linux 发行版上出现问题的库文件剥离过程。
**解决方案 2安装系统依赖**
如果 NO_STRIP 不起作用,确保已安装所有必需的系统依赖:
```bash
sudo apt update
sudo apt install -y \
libdbus-1-dev \
libsoup-3.0-dev \
libjavascriptcoregtk-4.1-dev \
libwebkit2gtk-4.1-dev \
build-essential \
curl wget file \
libxdo-dev \
libssl-dev \
libgtk-3-dev \
libayatana-appindicator3-dev \
librsvg2-dev \
gnome-video-effects \
libglib2.0-dev \
libgirepository1.0-dev \
pkg-config
```
然后使用 `NO_STRIP=true` 再次尝试构建。
**解决方案 3改用 DEB 格式**
DEB 包在基于 Debian 的系统上更稳定:
```bash
pake https://example.com --name MyApp --targets deb
```
**解决方案 4使用 Docker**
在干净的环境中构建,无需安装依赖:
```bash
docker run --rm -v $(pwd)/output:/app/output \
ghcr.io/tw93/pake:latest \
pake https://example.com --name MyApp --targets appimage
```
**原因:**
这是 Tauri 的 linuxdeploy 工具的已知问题,在以下情况下可能失败:
- 系统库的格式不兼容剥离操作
- 在较新的发行版上构建Arch、Debian Trixie 等)
- 缺少 WebKit2GTK 或 GTK 开发库
`NO_STRIP=true` 环境变量是 Tauri 社区推荐的官方解决方法。
---
### Linux"cargo: command not found" 即使已安装 Rust
**问题描述:**
已安装 Rust 但 Pake 仍然提示 "cargo: command not found"。
**解决方案:**
Pake CLI 会自动重新加载 Rust 环境,但如果问题仍然存在:
```bash
# 在当前终端重新加载环境
source ~/.cargo/env
# 或者重启终端
```
然后再次尝试构建。
---
### macOS构建失败出现模块编译错误
**问题描述:**
在 macOS 26 Beta 或更新版本上,可能看到与 `CoreFoundation``_Builtin_float` 模块相关的错误。
**解决方案:**
创建配置文件以使用兼容的 SDK
```bash
cat > src-tauri/.cargo/config.toml << 'EOF'
[env]
MACOSX_DEPLOYMENT_TARGET = "15.0"
SDKROOT = "/Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX.sdk"
EOF
```
此文件已在 `.gitignore` 中,不会被提交。
---
### Windows缺少 Visual Studio 构建工具
**问题描述:**
构建失败,提示缺少 MSVC 或 Windows SDK。
**解决方案:**
安装 Visual Studio 构建工具:
1. 下载 [Visual Studio Build Tools](https://visualstudio.microsoft.com/zh-hans/downloads/#build-tools-for-visual-studio-2022)
2. 安装时选择"使用 C++ 的桌面开发"
3. ARM64 支持:在"单个组件"下额外选择"MSVC v143 - VS 2022 C++ ARM64 构建工具"
---
## 运行时问题
### 应用窗口太小/太大
**解决方案:**
构建时指定自定义尺寸:
```bash
pake https://example.com --width 1200 --height 800
```
查看 [CLI 使用指南](cli-usage_CN.md#窗口选项) 了解所有窗口选项。
---
### 应用图标显示不正确
**问题描述:**
自定义图标没有显示或显示默认图标。
**解决方案:**
确保为您的平台使用正确的图标格式:
- **macOS**`.icns` 格式
- **Windows**`.ico` 格式
- **Linux**`.png` 格式
```bash
# macOS
pake https://example.com --icon ./icon.icns
# Windows
pake https://example.com --icon ./icon.ico
# Linux
pake https://example.com --icon ./icon.png
```
Pake 可以自动转换图标,但提供正确的格式更可靠。
---
### 网站功能不工作(登录、上传等)
**问题描述:**
某些网站功能在 Pake 应用中无法工作。
**解决方案:**
这通常是由于 Web 兼容性问题。尝试:
1. **设置自定义 User Agent**
```bash
pake https://example.com --user-agent "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
```
2. **注入自定义 JavaScript**
```bash
pake https://example.com --inject ./fix.js
```
3. **检查网站是否需要 WebView 中可能不可用的特定权限**
---
## 安装问题
### 全局安装时权限被拒绝
**问题描述:**
`npm install -g pake-cli` 失败,提示权限错误。
**解决方案:**
使用以下方法之一:
```bash
# 方案 1使用 npx无需安装
npx pake-cli https://example.com
# 方案 2修复 npm 权限
npm config set prefix ~/.npm-global
echo 'export PATH=~/.npm-global/bin:$PATH' >> ~/.bashrc
source ~/.bashrc
npm install -g pake-cli
# 方案 3使用 pnpm推荐
pnpm install -g pake-cli
```
---
## 获取帮助
如果您的问题未在此处涵盖:
1. 查看 [CLI 使用指南](cli-usage_CN.md) 了解详细参数文档
2. 参阅 [高级用法](advanced-usage_CN.md) 了解前置条件和系统设置
3. 搜索 [现有的 GitHub issues](https://github.com/tw93/Pake/issues)
4. [提交新 issue](https://github.com/tw93/Pake/issues/new) 时请包含:
- 您的操作系统和版本
- Node.js 和 Rust 版本(`node --version`、`rustc --version`
- 完整的错误信息
- 您使用的构建命令

14
package.json vendored
View File

@@ -1,6 +1,6 @@
{
"name": "pake-cli",
"version": "3.3.5",
"version": "3.4.0",
"description": "🤱🏻 Turn any webpage into a desktop app with one command. 🤱🏻 一键打包网页生成轻量桌面应用。",
"engines": {
"node": ">=18.0.0"
@@ -52,12 +52,12 @@
"dependencies": {
"@tauri-apps/api": "^2.8.0",
"@tauri-apps/cli": "^2.8.4",
"axios": "^1.11.0",
"chalk": "^5.6.0",
"axios": "^1.12.2",
"chalk": "^5.6.2",
"commander": "^12.1.0",
"execa": "^9.6.0",
"file-type": "^18.7.0",
"fs-extra": "^11.3.1",
"fs-extra": "^11.3.2",
"icon-gen": "^5.0.0",
"loglevel": "^1.9.2",
"ora": "^8.2.0",
@@ -74,7 +74,7 @@
"@rollup/plugin-replace": "^6.0.2",
"@rollup/plugin-terser": "^0.4.4",
"@types/fs-extra": "^11.0.4",
"@types/node": "^20.19.13",
"@types/node": "^20.19.21",
"@types/page-icon": "^0.3.6",
"@types/prompts": "^2.4.9",
"@types/tmp": "^0.2.6",
@@ -82,9 +82,9 @@
"app-root-path": "^3.1.0",
"cross-env": "^7.0.3",
"prettier": "^3.6.2",
"rollup": "^4.50.0",
"rollup": "^4.52.4",
"rollup-plugin-typescript2": "^0.36.0",
"tslib": "^2.8.1",
"typescript": "^5.9.2"
"typescript": "^5.9.3"
}
}

459
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

27
src-tauri/Cargo.lock generated
View File

@@ -1,6 +1,6 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 3
version = 4
[[package]]
name = "addr2line"
@@ -3546,10 +3546,11 @@ dependencies = [
[[package]]
name = "serde"
version = "1.0.219"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6"
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
dependencies = [
"serde_core",
"serde_derive",
]
@@ -3565,10 +3566,19 @@ dependencies = [
]
[[package]]
name = "serde_derive"
version = "1.0.219"
name = "serde_core"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00"
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
dependencies = [
"proc-macro2",
"quote",
@@ -3588,14 +3598,15 @@ dependencies = [
[[package]]
name = "serde_json"
version = "1.0.143"
version = "1.0.145"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d401abef1d108fbd9cbaebc3e46611f4b1021f714a0597a71f41ee463f5f4a5a"
checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c"
dependencies = [
"itoa",
"memchr",
"ryu",
"serde",
"serde_core",
]
[[package]]

View File

@@ -15,19 +15,19 @@ crate-type = ["staticlib", "cdylib", "lib"]
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[build-dependencies]
tauri-build = { version = "2.4.0", features = [] }
tauri-build = { version = "2.4.1", features = [] }
[dependencies]
serde_json = "1.0.143"
serde = { version = "1.0.219", features = ["derive"] }
serde_json = "1.0.145"
serde = { version = "1.0.228", features = ["derive"] }
tokio = { version = "1.47.1", features = ["full"] }
tauri = { version = "2.8.4", features = ["tray-icon", "image-ico", "image-png", "macos-proxy"] }
tauri = { version = "2.8.5", features = ["tray-icon", "image-ico", "image-png", "macos-proxy"] }
tauri-plugin-window-state = "2.4.0"
tauri-plugin-oauth = "2.0.0"
tauri-plugin-http = "2.5.2"
tauri-plugin-global-shortcut = { version = "2.3.0" }
tauri-plugin-shell = "2.3.1"
tauri-plugin-single-instance = "2.3.3"
tauri-plugin-single-instance = "2.3.4"
tauri-plugin-notification = "2.3.1"
[features]

3
src-tauri/pake.json vendored
View File

@@ -30,5 +30,6 @@
},
"system_tray_path": "icons/icon.png",
"inject": [],
"proxy_url": ""
"proxy_url": "",
"multi_instance": false
}

View File

@@ -59,6 +59,8 @@ pub struct PakeConfig {
pub system_tray: FunctionON,
pub system_tray_path: String,
pub proxy_url: String,
#[serde(default)]
pub multi_instance: bool,
}
impl PakeConfig {

View File

@@ -100,21 +100,6 @@ const DOWNLOADABLE_FILE_EXTENSIONS = {
"scss",
"sass",
"less",
"html",
"htm",
"php",
"py",
"java",
"cpp",
"c",
"h",
"cs",
"rb",
"go",
"rs",
"swift",
"kt",
"scala",
"sh",
"bat",
"ps1",
@@ -163,6 +148,22 @@ function isChineseLanguage(language = getUserLanguage()) {
);
}
// User notification helper
function showDownloadError(filename) {
const isChinese = isChineseLanguage();
const message = isChinese
? `下载失败: ${filename}`
: `Download failed: ${filename}`;
if (window.Notification && Notification.permission === "granted") {
new Notification(isChinese ? "下载错误" : "Download Error", {
body: message,
});
} else {
console.error(message);
}
}
// Unified file detection - replaces both isDownloadLink and isFileLink
function isDownloadableFile(url) {
try {
@@ -252,40 +253,56 @@ document.addEventListener("DOMContentLoaded", () => {
}
function downloadFromDataUri(dataURI, filename) {
const byteString = atob(dataURI.split(",")[1]);
// write the bytes of the string to an ArrayBuffer
const bufferArray = new ArrayBuffer(byteString.length);
try {
const byteString = atob(dataURI.split(",")[1]);
// write the bytes of the string to an ArrayBuffer
const bufferArray = new ArrayBuffer(byteString.length);
// create a view into the buffer
const binary = new Uint8Array(bufferArray);
// create a view into the buffer
const binary = new Uint8Array(bufferArray);
// set the bytes of the buffer to the correct values
for (let i = 0; i < byteString.length; i++) {
binary[i] = byteString.charCodeAt(i);
}
// set the bytes of the buffer to the correct values
for (let i = 0; i < byteString.length; i++) {
binary[i] = byteString.charCodeAt(i);
}
// write the ArrayBuffer to a binary, and you're done
const userLanguage = navigator.language || navigator.userLanguage;
invoke("download_file_by_binary", {
params: {
filename,
binary: Array.from(binary),
language: userLanguage,
},
});
}
function downloadFromBlobUrl(blobUrl, filename) {
convertBlobUrlToBinary(blobUrl).then((binary) => {
const userLanguage = navigator.language || navigator.userLanguage;
// write the ArrayBuffer to a binary, and you're done
const userLanguage = getUserLanguage();
invoke("download_file_by_binary", {
params: {
filename,
binary,
binary: Array.from(binary),
language: userLanguage,
},
}).catch((error) => {
console.error("Failed to download data URI file:", filename, error);
showDownloadError(filename);
});
} catch (error) {
console.error("Failed to process data URI:", dataURI, error);
showDownloadError(filename || "file");
}
}
function downloadFromBlobUrl(blobUrl, filename) {
convertBlobUrlToBinary(blobUrl)
.then((binary) => {
const userLanguage = getUserLanguage();
invoke("download_file_by_binary", {
params: {
filename,
binary,
language: userLanguage,
},
}).catch((error) => {
console.error("Failed to download blob file:", filename, error);
showDownloadError(filename);
});
})
.catch((error) => {
console.error("Failed to convert blob to binary:", blobUrl, error);
showDownloadError(filename);
});
});
}
// detect blob download by createElement("a")
@@ -323,8 +340,16 @@ document.addEventListener("DOMContentLoaded", () => {
anchorElement.download || e.metaKey || e.ctrlKey || isDownloadableFile(url);
const handleExternalLink = (url) => {
// Don't try to open blob: or data: URLs with shell
if (isSpecialDownload(url)) {
console.warn("Cannot open special URL with shell:", url);
return;
}
invoke("plugin:shell|open", {
path: url,
}).catch((error) => {
console.error("Failed to open URL with shell:", url, error);
});
};
@@ -351,6 +376,10 @@ document.addEventListener("DOMContentLoaded", () => {
};
const detectAnchorElementClick = (e) => {
// Safety check: ensure e.target exists and is an Element with closest method
if (!e.target || typeof e.target.closest !== "function") {
return;
}
const anchorElement = e.target.closest("a");
if (anchorElement && anchorElement.href) {
@@ -361,20 +390,21 @@ document.addEventListener("DOMContentLoaded", () => {
// Handle _blank links: same domain navigates in-app, cross-domain opens new window
if (target === "_blank") {
if (isSameDomain(absoluteUrl)) {
// For same-domain links, let the browser/SPA handle it naturally
// This prevents full page reload in SPA apps like Discord
return;
}
e.preventDefault();
e.stopImmediatePropagation();
if (isSameDomain(absoluteUrl)) {
window.location.href = absoluteUrl;
} else {
const newWindow = originalWindowOpen.call(
window,
absoluteUrl,
"_blank",
"width=1200,height=800,scrollbars=yes,resizable=yes",
);
if (!newWindow) handleExternalLink(absoluteUrl);
}
const newWindow = originalWindowOpen.call(
window,
absoluteUrl,
"_blank",
"width=1200,height=800,scrollbars=yes,resizable=yes",
);
if (!newWindow) handleExternalLink(absoluteUrl);
return;
}
@@ -424,39 +454,24 @@ document.addEventListener("DOMContentLoaded", () => {
// Rewrite the window.open function.
const originalWindowOpen = window.open;
window.open = function (url, name, specs) {
// Apple login and google login
if (name === "AppleAuthentication") {
//do nothing
} else if (
specs &&
(specs.includes("height=") || specs.includes("width="))
) {
location.href = url;
} else {
return originalWindowOpen.call(window, url, name, specs);
}
try {
const baseUrl = window.location.origin + window.location.pathname;
const hrefUrl = new URL(url, baseUrl);
const absoluteUrl = hrefUrl.href;
// Apply same domain logic as anchor links
if (isSameDomain(absoluteUrl)) {
// Same domain: navigate in app or open new window based on specs
if (name === "_blank" || !name) {
return originalWindowOpen.call(
window,
absoluteUrl,
"_blank",
"width=1200,height=800,scrollbars=yes,resizable=yes",
);
} else {
location.href = absoluteUrl;
}
} else {
// Cross domain: open in external browser
if (!isSameDomain(absoluteUrl)) {
handleExternalLink(absoluteUrl);
return null;
}
return originalWindowOpen.call(window, absoluteUrl, name, specs);
} catch (error) {
return originalWindowOpen.call(window, url, name, specs);
}
// Call the original window.open function to maintain its normal functionality.
return originalWindowOpen.call(window, url, name, specs);
};
// Set the default zoom, There are problems with Loop without using try-catch.
@@ -664,13 +679,16 @@ document.addEventListener("DOMContentLoaded", () => {
}
} else {
// Regular HTTP(S) image
const userLanguage = navigator.language || navigator.userLanguage;
const userLanguage = getUserLanguage();
invoke("download_file", {
params: {
url: imageUrl,
filename: filename,
language: userLanguage,
},
}).catch((error) => {
console.error("Failed to download image:", filename, error);
showDownloadError(filename);
});
}
}
@@ -701,7 +719,10 @@ document.addEventListener("DOMContentLoaded", () => {
}
// Check for parent elements with background images
const parentWithBg = target.closest('[style*="background-image"]');
const parentWithBg =
target && typeof target.closest === "function"
? target.closest('[style*="background-image"]')
: null;
if (parentWithBg) {
const bgImage = parentWithBg.style.backgroundImage;
const urlMatch = bgImage.match(/url\(["']?([^"')]+)["']?\)/);
@@ -715,7 +736,7 @@ document.addEventListener("DOMContentLoaded", () => {
// Simplified menu builder
function buildMenuItems(type, data) {
const userLanguage = navigator.language || navigator.userLanguage;
const userLanguage = getUserLanguage();
const items = [];
switch (type) {
@@ -742,6 +763,9 @@ document.addEventListener("DOMContentLoaded", () => {
const filename = getFilenameFromUrl(data.url);
invoke("download_file", {
params: { url: data.url, filename, language: userLanguage },
}).catch((error) => {
console.error("Failed to download file:", filename, error);
showDownloadError(filename);
});
}),
);
@@ -770,7 +794,10 @@ document.addEventListener("DOMContentLoaded", () => {
const mediaInfo = getMediaInfo(target);
// Check for links (but not if it's media)
const linkElement = target.closest("a");
const linkElement =
target && typeof target.closest === "function"
? target.closest("a")
: null;
const isLink = linkElement && linkElement.href && !mediaInfo.isMedia;
// Only show custom menu for media or links

View File

@@ -24,6 +24,7 @@ pub fn run_app() {
let hide_on_close = pake_config.windows[0].hide_on_close;
let activation_shortcut = pake_config.windows[0].activation_shortcut.clone();
let init_fullscreen = pake_config.windows[0].fullscreen;
let multi_instance = pake_config.multi_instance;
let window_state_plugin = WindowStatePlugin::default()
.with_state_flags(if init_fullscreen {
@@ -35,19 +36,25 @@ pub fn run_app() {
.build();
#[allow(deprecated)]
tauri_app
let mut app_builder = tauri_app
.plugin(window_state_plugin)
.plugin(tauri_plugin_oauth::init())
.plugin(tauri_plugin_http::init())
.plugin(tauri_plugin_shell::init())
.plugin(tauri_plugin_notification::init())
.plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| {
.plugin(tauri_plugin_notification::init());
// Only add single instance plugin if multiple instances are not allowed
if !multi_instance {
app_builder = app_builder.plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| {
if let Some(window) = app.get_webview_window("pake") {
let _ = window.unminimize();
let _ = window.show();
let _ = window.set_focus();
}
}))
}));
}
app_builder
.invoke_handler(tauri::generate_handler![
download_file,
download_file_by_binary,

7
tests/index.js vendored
View File

@@ -12,6 +12,7 @@ import fs from "fs";
import path from "path";
import ora from "ora";
import config, { TIMEOUTS, TEST_URLS } from "./config.js";
import { runHelperTests } from "./unit/helpers.test.js";
class PakeTestRunner {
constructor() {
@@ -42,6 +43,12 @@ class PakeTestRunner {
console.log("📋 Running Unit Tests...");
await this.runUnitTests();
testCount++;
// Run helper function tests
const helperTestsPassed = await runHelperTests();
if (!helperTestsPassed) {
console.log("⚠️ Some helper tests failed");
}
}
if (integration && !quick) {

256
tests/unit/helpers.test.js vendored Normal file
View File

@@ -0,0 +1,256 @@
#!/usr/bin/env node
/**
* Unit Tests for Helper Functions
*
* Tests for newly added helper functions to ensure code quality
* and prevent regressions.
*/
import { strict as assert } from "assert";
/**
* Helper function to generate safe filename
* Mirrors the implementation in bin/utils/name.ts
*/
function generateSafeFilename(name) {
return name
.replace(/[<>:"/\\|?*]/g, "_")
.replace(/\s+/g, "_")
.replace(/\.+$/g, "")
.slice(0, 255);
}
/**
* Helper function to generate safe lowercase app name for file paths
* Mirrors the implementation in bin/helpers/merge.ts
*/
function getSafeAppName(name) {
return generateSafeFilename(name).toLowerCase();
}
/**
* Test suite for getSafeAppName()
*/
export function testGetSafeAppName() {
const tests = [
// Basic cases
{ input: "MyApp", expected: "myapp", description: "Simple name" },
{ input: "My App", expected: "my_app", description: "Name with space" },
{ input: "my-app", expected: "my-app", description: "Name with hyphen" },
// Chinese characters
{ input: "我的应用", expected: "我的应用", description: "Chinese name" },
{
input: "我的 App",
expected: "我的_app",
description: "Mixed Chinese and English",
},
// Special characters
{
input: "App@2024",
expected: "app@2024",
description: "Special character @ (preserved)",
},
{ input: "My/App", expected: "my_app", description: "Forward slash" },
{ input: "My\\App", expected: "my_app", description: "Backslash" },
{ input: "App:Name", expected: "app_name", description: "Colon" },
{ input: "App*Name", expected: "app_name", description: "Asterisk" },
{ input: "App?Name", expected: "app_name", description: "Question mark" },
{ input: 'App"Name', expected: "app_name", description: "Double quote" },
{
input: "App<Name>",
expected: "app_name_",
description: "Angle brackets",
},
{ input: "App|Name", expected: "app_name", description: "Pipe" },
// Edge cases
{ input: "APP", expected: "app", description: "All uppercase" },
{ input: "a", expected: "a", description: "Single character" },
{ input: "123", expected: "123", description: "Numbers only" },
{
input: " App ",
expected: "_app_",
description: "Leading/trailing spaces (collapsed)",
},
{ input: "App...", expected: "app", description: "Trailing dots" },
// Long names
{
input: "A".repeat(300),
expected: "a".repeat(255),
description: "Very long name (should truncate to 255)",
},
];
let passed = 0;
let failed = 0;
console.log("\n🧪 Testing getSafeAppName()");
console.log("─".repeat(50));
tests.forEach((test, index) => {
try {
const result = getSafeAppName(test.input);
assert.equal(
result,
test.expected,
`Expected "${test.expected}", got "${result}"`,
);
console.log(` ✓ Test ${index + 1}: ${test.description}`);
passed++;
} catch (error) {
console.log(` ✗ Test ${index + 1}: ${test.description}`);
console.log(` Input: "${test.input}"`);
console.log(` Expected: "${test.expected}"`);
console.log(` Error: ${error.message}`);
failed++;
}
});
console.log("─".repeat(50));
console.log(`Results: ${passed} passed, ${failed} failed\n`);
return failed === 0;
}
/**
* Test suite for download error notification (browser environment simulation)
*/
export function testDownloadErrorNotification() {
console.log("\n🧪 Testing showDownloadError() Logic");
console.log("─".repeat(50));
const tests = [
{
name: "Chinese language detection",
language: "zh-CN",
filename: "test.pdf",
expectedTitle: "下载错误",
expectedBody: "下载失败: test.pdf",
},
{
name: "English language detection",
language: "en-US",
filename: "document.docx",
expectedTitle: "Download Error",
expectedBody: "Download failed: document.docx",
},
{
name: "Traditional Chinese",
language: "zh-TW",
filename: "file.zip",
expectedTitle: "下载错误",
expectedBody: "下载失败: file.zip",
},
{
name: "Hong Kong Chinese",
language: "zh-HK",
filename: "image.png",
expectedTitle: "下载错误",
expectedBody: "下载失败: image.png",
},
{
name: "Special characters in filename",
language: "en-US",
filename: "my file (1).pdf",
expectedTitle: "Download Error",
expectedBody: "Download failed: my file (1).pdf",
},
];
let passed = 0;
let failed = 0;
tests.forEach((test, index) => {
try {
// Simulate language detection
const isChineseLanguage = (lang) =>
lang &&
(lang.startsWith("zh") ||
lang.includes("CN") ||
lang.includes("TW") ||
lang.includes("HK"));
const isChinese = isChineseLanguage(test.language);
const title = isChinese ? "下载错误" : "Download Error";
const body = isChinese
? `下载失败: ${test.filename}`
: `Download failed: ${test.filename}`;
assert.equal(
title,
test.expectedTitle,
`Title mismatch for ${test.name}`,
);
assert.equal(body, test.expectedBody, `Body mismatch for ${test.name}`);
console.log(` ✓ Test ${index + 1}: ${test.name}`);
console.log(` Language: ${test.language}`);
console.log(` Title: "${title}"`);
console.log(` Body: "${body}"`);
passed++;
} catch (error) {
console.log(` ✗ Test ${index + 1}: ${test.name}`);
console.log(` Error: ${error.message}`);
failed++;
}
});
console.log("─".repeat(50));
console.log(`Results: ${passed} passed, ${failed} failed\n`);
return failed === 0;
}
/**
* Run all tests
*/
export async function runHelperTests() {
console.log("\n📦 Helper Functions Unit Tests");
console.log("=".repeat(50));
const results = [];
// Test getSafeAppName
results.push({
name: "getSafeAppName()",
passed: testGetSafeAppName(),
});
// Test download error notification
results.push({
name: "showDownloadError() Logic",
passed: testDownloadErrorNotification(),
});
// Summary
const allPassed = results.every((r) => r.passed);
const passedCount = results.filter((r) => r.passed).length;
const totalCount = results.length;
console.log("\n📊 Helper Tests Summary");
console.log("=".repeat(50));
results.forEach((result) => {
const icon = result.passed ? "✅" : "❌";
console.log(`${icon} ${result.name}`);
});
console.log("=".repeat(50));
console.log(`Total: ${passedCount}/${totalCount} test suites passed\n`);
return allPassed;
}
// Run tests if executed directly
if (import.meta.url === `file://${process.argv[1]}`) {
runHelperTests()
.then((success) => {
process.exit(success ? 0 : 1);
})
.catch((error) => {
console.error("Test execution failed:", error);
process.exit(1);
});
}