diff --git a/bin/builders/BaseBuilder.ts b/bin/builders/BaseBuilder.ts index 0558741..e204c57 100644 --- a/bin/builders/BaseBuilder.ts +++ b/bin/builders/BaseBuilder.ts @@ -173,6 +173,7 @@ export default abstract class BaseBuilder { `cd "${npmDirectory}" && ${this.getBuildCommand(packageManager)}`, this.getBuildTimeout(), buildEnv, + this.options.debug, ); // Copy app diff --git a/bin/helpers/merge.ts b/bin/helpers/merge.ts index badf5ae..e9fb65a 100644 --- a/bin/helpers/merge.ts +++ b/bin/helpers/merge.ts @@ -7,6 +7,13 @@ 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, @@ -153,7 +160,7 @@ export async function mergeConfig( delete tauriConf.bundle.linux.deb.files; // Generate correct desktop file configuration - const appNameSafe = generateSafeFilename(name).toLowerCase(); + const appNameSafe = getSafeAppName(name); const identifier = `com.pake.${appNameSafe}`; const desktopFileName = `${identifier}.desktop`; @@ -212,22 +219,23 @@ StartupNotify=true } // Set icon. + const safeAppName = getSafeAppName(name); const platformIconMap: PlatformMap = { win32: { fileExt: '.ico', - path: `png/${generateSafeFilename(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/${generateSafeFilename(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/${generateSafeFilename(name).toLowerCase()}.icns`, + path: `icons/${safeAppName}.icns`, defaultIcon: 'icons/icon.icns', message: 'macOS icon must be .icns type.', }, @@ -278,9 +286,9 @@ StartupNotify=true if (iconExt == '.png' || iconExt == '.ico') { const trayIcoPath = path.join( npmDirectory, - `src-tauri/png/${generateSafeFilename(name).toLowerCase()}${iconExt}`, + `src-tauri/png/${safeAppName}${iconExt}`, ); - trayIconPath = `png/${generateSafeFilename(name).toLowerCase()}${iconExt}`; + trayIconPath = `png/${safeAppName}${iconExt}`; await fsExtra.copy(systemTrayIcon, trayIcoPath); } else { logger.warn( diff --git a/dist/cli.js b/dist/cli.js index f637d15..c7e948c 100755 --- a/dist/cli.js +++ b/dist/cli.js @@ -422,6 +422,12 @@ function generateIdentifierSafeName(name) { 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'); @@ -512,7 +518,7 @@ 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 appNameSafe = generateSafeFilename(name).toLowerCase(); + const appNameSafe = getSafeAppName(name); const identifier = `com.pake.${appNameSafe}`; const desktopFileName = `${identifier}.desktop`; // Create desktop file content @@ -563,22 +569,23 @@ StartupNotify=true } } // Set icon. + const safeAppName = getSafeAppName(name); const platformIconMap = { win32: { fileExt: '.ico', - path: `png/${generateSafeFilename(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/${generateSafeFilename(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/${generateSafeFilename(name).toLowerCase()}.icns`, + path: `icons/${safeAppName}.icns`, defaultIcon: 'icons/icon.icns', message: 'macOS icon must be .icns type.', }, @@ -622,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/${generateSafeFilename(name).toLowerCase()}${iconExt}`); - trayIconPath = `png/${generateSafeFilename(name).toLowerCase()}${iconExt}`; + const trayIcoPath = path.join(npmDirectory, `src-tauri/png/${safeAppName}${iconExt}`); + trayIconPath = `png/${safeAppName}${iconExt}`; await fsExtra.copy(systemTrayIcon, trayIcoPath); } else { @@ -799,7 +806,7 @@ class BaseBuilder { ...this.getBuildEnvironment(), ...(process.env.NO_STRIP && { NO_STRIP: process.env.NO_STRIP }), }; - await shellExec(`cd "${npmDirectory}" && ${this.getBuildCommand(packageManager)}`, this.getBuildTimeout(), buildEnv); + await shellExec(`cd "${npmDirectory}" && ${this.getBuildCommand(packageManager)}`, this.getBuildTimeout(), buildEnv, this.options.debug); // Copy app const fileName = this.getFileName(); const fileType = this.getFileType(target); diff --git a/docs/advanced-usage.md b/docs/advanced-usage.md index c4e0aca..4071638 100644 --- a/docs/advanced-usage.md +++ b/docs/advanced-usage.md @@ -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. diff --git a/docs/advanced-usage_CN.md b/docs/advanced-usage_CN.md index 5496f15..5cb1160 100644 --- a/docs/advanced-usage_CN.md +++ b/docs/advanced-usage_CN.md @@ -40,6 +40,41 @@ }); ``` +## 内置功能 + +### 下载错误通知 + +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 容器之间发送消息。 diff --git a/src-tauri/src/inject/event.js b/src-tauri/src/inject/event.js index 5d9b49d..ab9583c 100644 --- a/src-tauri/src/inject/event.js +++ b/src-tauri/src/inject/event.js @@ -148,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 { @@ -251,7 +267,7 @@ document.addEventListener("DOMContentLoaded", () => { } // write the ArrayBuffer to a binary, and you're done - const userLanguage = navigator.language || navigator.userLanguage; + const userLanguage = getUserLanguage(); invoke("download_file_by_binary", { params: { filename, @@ -260,16 +276,18 @@ document.addEventListener("DOMContentLoaded", () => { }, }).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 = navigator.language || navigator.userLanguage; + const userLanguage = getUserLanguage(); invoke("download_file_by_binary", { params: { filename, @@ -278,10 +296,12 @@ document.addEventListener("DOMContentLoaded", () => { }, }).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); }); } @@ -659,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); }); } } @@ -713,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) { @@ -740,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); }); }), ); diff --git a/tests/index.js b/tests/index.js index 186582d..82aeba3 100644 --- a/tests/index.js +++ b/tests/index.js @@ -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) { diff --git a/tests/unit/helpers.test.js b/tests/unit/helpers.test.js new file mode 100644 index 0000000..793a519 --- /dev/null +++ b/tests/unit/helpers.test.js @@ -0,0 +1,232 @@ +#!/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', 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); + }); +}