From dd11c6dd7a9a013486c091d940d9809271ac1b0c Mon Sep 17 00:00:00 2001 From: Tw93 Date: Tue, 16 Sep 2025 10:19:36 +0800 Subject: [PATCH] :sparkles: Support downloading of blob scenes --- CLAUDE.md | 179 ++++++++-------------------------- dist/cli.js | 80 +++++++++------ src-tauri/src/inject/event.js | 62 ++++++++---- 3 files changed, 137 insertions(+), 184 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 6cd4e9b..427dda5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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,42 @@ 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:** - -```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 +**Testing:** +- 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 +77,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 +94,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) diff --git a/dist/cli.js b/dist/cli.js index 4e8d6e3..b4f0405 100755 --- a/dist/cli.js +++ b/dist/cli.js @@ -328,6 +328,26 @@ 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) { + return name + .replace(/[^a-zA-Z0-9]/g, '') + .toLowerCase(); +} + async function mergeConfig(url, options, tauriConf) { // Ensure .pake directory exists and copy source templates if needed const srcTauriDir = path.join(npmDirectory, 'src-tauri'); @@ -372,7 +392,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 +438,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 = generateSafeFilename(name).toLowerCase(); + const identifier = `com.pake.${appNameSafe}`; const desktopFileName = `${identifier}.desktop`; // Create desktop file content const desktopContent = `[Desktop Entry] @@ -427,8 +447,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 @@ -472,28 +492,29 @@ StartupNotify=true const platformIconMap = { win32: { fileExt: '.ico', - path: `png/${name.toLowerCase()}_256.ico`, + path: `png/${generateSafeFilename(name).toLowerCase()}_256.ico`, defaultIcon: 'png/icon_256.ico', message: 'Windows icon must be .ico and 256x256px.', }, linux: { fileExt: '.png', - path: `png/${name.toLowerCase()}_512.png`, + path: `png/${generateSafeFilename(name).toLowerCase()}_512.png`, defaultIcon: 'png/icon_512.png', message: 'Linux icon must be .png and 512x512px.', }, darwin: { fileExt: '.icns', - path: `icons/${name.toLowerCase()}.icns`, + path: `icons/${generateSafeFilename(name).toLowerCase()}.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 +524,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 +548,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/${generateSafeFilename(name).toLowerCase()}${iconExt}`); + trayIconPath = `png/${generateSafeFilename(name).toLowerCase()}${iconExt}`; await fsExtra.copy(systemTrayIcon, trayIcoPath); } else { @@ -854,7 +874,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}`; @@ -1169,12 +1189,9 @@ 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 +1254,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 +1530,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 +1547,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.`; @@ -1643,8 +1658,17 @@ program .addOption(new Option('--system-tray-icon ', '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 ', 'Window title').hideHelp()) .addOption(new Option('--incognito', 'Launch app in incognito/private mode') diff --git a/src-tauri/src/inject/event.js b/src-tauri/src/inject/event.js index 03864b4..e96346b 100644 --- a/src-tauri/src/inject/event.js +++ b/src-tauri/src/inject/event.js @@ -252,27 +252,33 @@ 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, + }, + }).catch(error => { + console.error('Failed to download data URI file:', filename, error); + }); + } catch (error) { + console.error('Failed to process data URI:', dataURI, error); } - - // 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) { @@ -284,7 +290,11 @@ document.addEventListener("DOMContentLoaded", () => { binary, language: userLanguage, }, + }).catch(error => { + console.error('Failed to download blob file:', filename, error); }); + }).catch(error => { + console.error('Failed to convert blob to binary:', blobUrl, error); }); } @@ -323,8 +333,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 +369,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) { @@ -701,7 +723,7 @@ 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\(["']?([^"')]+)["']?\)/); @@ -770,7 +792,7 @@ 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