diff --git a/CLAUDE.md b/CLAUDE.md index efc2ba6..6efa7fa 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -47,7 +47,7 @@ grep -r "window_config" src-tauri/src/ # Install dependencies npm i -# Development with hot reload +# Development with hot reload (for testing app functionality) npm run dev # CLI development @@ -88,7 +88,9 @@ npm run build:mac # macOS universal build **Testing Notes:** - Do NOT use `PAKE_NO_CONFIG_OVERWRITE=1` - this environment variable is not implemented -- For testing, simply use: `node dist/cli.js https://example.com --name TestApp --debug` +- For CLI testing: `node dist/cli.js https://example.com --name TestApp --debug` +- **For app functionality testing**: Use `npm 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 ## Core Components diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 6f9e316..337dc74 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -19,6 +19,7 @@ "notification:allow-get-active", "notification:allow-register-listener", "notification:allow-register-action-types", - "notification:default" + "notification:default", + "core:path:default" ] } diff --git a/src-tauri/src/app/invoke.rs b/src-tauri/src/app/invoke.rs index a07b358..646e071 100644 --- a/src-tauri/src/app/invoke.rs +++ b/src-tauri/src/app/invoke.rs @@ -1,4 +1,4 @@ -use crate::util::{check_file_or_append, get_download_message, show_toast, MessageType}; +use crate::util::{check_file_or_append, get_download_message_with_lang, show_toast, MessageType}; use std::fs::{self, File}; use std::io::Write; use std::str::FromStr; @@ -10,12 +10,14 @@ use tauri_plugin_http::reqwest::{ClientBuilder, Request}; pub struct DownloadFileParams { url: String, filename: String, + language: Option, } #[derive(serde::Deserialize)] pub struct BinaryDownloadParams { filename: String, binary: Vec, + language: Option, } #[derive(serde::Deserialize)] @@ -28,7 +30,7 @@ pub struct NotificationParams { #[command] pub async fn download_file(app: AppHandle, params: DownloadFileParams) -> Result<(), String> { let window: WebviewWindow = app.get_webview_window("pake").unwrap(); - show_toast(&window, &get_download_message(MessageType::Start)); + show_toast(&window, &get_download_message_with_lang(MessageType::Start, params.language.clone())); let output_path = app.path().download_dir().unwrap().join(params.filename); let file_path = check_file_or_append(output_path.to_str().unwrap()); @@ -47,11 +49,11 @@ pub async fn download_file(app: AppHandle, params: DownloadFileParams) -> Result let mut file = File::create(file_path).unwrap(); file.write_all(&bytes).unwrap(); - show_toast(&window, &get_download_message(MessageType::Success)); + show_toast(&window, &get_download_message_with_lang(MessageType::Success, params.language.clone())); Ok(()) } Err(e) => { - show_toast(&window, &get_download_message(MessageType::Failure)); + show_toast(&window, &get_download_message_with_lang(MessageType::Failure, params.language)); Err(e.to_string()) } } @@ -63,17 +65,17 @@ pub async fn download_file_by_binary( params: BinaryDownloadParams, ) -> Result<(), String> { let window: WebviewWindow = app.get_webview_window("pake").unwrap(); - show_toast(&window, &get_download_message(MessageType::Start)); + show_toast(&window, &get_download_message_with_lang(MessageType::Start, params.language.clone())); let output_path = app.path().download_dir().unwrap().join(params.filename); let file_path = check_file_or_append(output_path.to_str().unwrap()); let download_file_result = fs::write(file_path, ¶ms.binary); match download_file_result { Ok(_) => { - show_toast(&window, &get_download_message(MessageType::Success)); + show_toast(&window, &get_download_message_with_lang(MessageType::Success, params.language.clone())); Ok(()) } Err(e) => { - show_toast(&window, &get_download_message(MessageType::Failure)); + show_toast(&window, &get_download_message_with_lang(MessageType::Failure, params.language)); Err(e.to_string()) } } diff --git a/src-tauri/src/inject/event.js b/src-tauri/src/inject/event.js index 957ee56..0ced10c 100644 --- a/src-tauri/src/inject/event.js +++ b/src-tauri/src/inject/event.js @@ -36,22 +36,50 @@ function handleShortcut(event) { } } -// Judgment of file download. -function isDownloadLink(url) { - // prettier-ignore - const fileExtensions = [ - '3gp', '7z', 'ai', 'apk', 'avi', 'bmp', 'csv', 'dmg', 'doc', 'docx', - 'fla', 'flv', 'gif', 'gz', 'gzip', 'ico', 'iso', 'indd', 'jar', 'jpeg', - 'jpg', 'm3u8', 'mov', 'mp3', 'mp4', 'mpa', 'mpg', 'mpeg', 'msi', 'odt', - 'ogg', 'ogv', 'pdf', 'png', 'ppt', 'pptx', 'psd', 'rar', 'raw', - 'svg', 'swf', 'tar', 'tif', 'tiff', 'ts', 'txt', 'wav', 'webm', 'webp', - 'wma', 'wmv', 'xls', 'xlsx', 'xml', 'zip', 'json', 'yaml', '7zip', 'mkv', - ]; - const downloadLinkPattern = new RegExp( - `\\.(${fileExtensions.join("|")})$`, - "i", - ); - return downloadLinkPattern.test(url); +// Configuration constants +const DOWNLOADABLE_FILE_EXTENSIONS = { + documents: ['pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'txt', 'rtf', 'odt', 'ods', 'odp', 'pages', 'numbers', 'key', 'epub', 'mobi'], + archives: ['zip', 'rar', '7z', 'tar', 'gz', 'gzip', 'bz2', 'xz', 'lzma', 'deb', 'rpm', 'pkg', 'msi', 'exe', 'dmg', 'apk', 'ipa'], + data: ['json', 'xml', 'csv', 'sql', 'db', 'sqlite', 'yaml', 'yml', 'toml', 'ini', 'cfg', 'conf', 'log'], + code: ['js', 'ts', 'jsx', 'tsx', 'css', 'scss', 'sass', 'less', 'html', 'htm', 'php', 'py', 'java', 'cpp', 'c', 'h', 'cs', 'rb', 'go', 'rs', 'swift', 'kt', 'scala', 'sh', 'bat', 'ps1'], + fonts: ['ttf', 'otf', 'woff', 'woff2', 'eot'], + design: ['ai', 'psd', 'sketch', 'fig', 'xd'], + system: ['iso', 'img', 'bin', 'torrent', 'jar', 'war', 'indd', 'fla', 'swf', 'raw'] +}; + +const ALL_DOWNLOADABLE_EXTENSIONS = Object.values(DOWNLOADABLE_FILE_EXTENSIONS).flat(); + +const DOWNLOAD_PATH_PATTERNS = ['/download/', '/files/', '/attachments/', '/assets/', '/releases/', '/dist/']; + +// Language detection utilities +function getUserLanguage() { + return navigator.language || navigator.userLanguage; +} + +function isChineseLanguage(language = getUserLanguage()) { + return language && (language.startsWith('zh') || language.includes('CN') || language.includes('TW') || language.includes('HK')); +} + +// Unified file detection - replaces both isDownloadLink and isFileLink +function isDownloadableFile(url) { + try { + const urlObj = new URL(url); + const pathname = urlObj.pathname.toLowerCase(); + + // Get file extension + const extension = pathname.substring(pathname.lastIndexOf('.') + 1); + + const fileExtensions = ALL_DOWNLOADABLE_EXTENSIONS; + + return fileExtensions.includes(extension) || + // Check for download hints + urlObj.searchParams.has('download') || + urlObj.searchParams.has('attachment') || + // Check for common download paths + DOWNLOAD_PATH_PATTERNS.some(pattern => pathname.includes(pattern)); + } catch (e) { + return false; + } } document.addEventListener("DOMContentLoaded", () => { @@ -132,20 +160,24 @@ document.addEventListener("DOMContentLoaded", () => { } // 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; invoke("download_file_by_binary", { params: { filename, binary, + language: userLanguage, }, }); }); @@ -183,7 +215,7 @@ document.addEventListener("DOMContentLoaded", () => { ["blob", "data"].some((protocol) => url.startsWith(protocol)); const isDownloadRequired = (url, anchorElement, e) => - anchorElement.download || e.metaKey || e.ctrlKey || isDownloadLink(url); + anchorElement.download || e.metaKey || e.ctrlKey || isDownloadableFile(url); const handleExternalLink = (url) => { invoke("plugin:shell|open", { @@ -254,7 +286,8 @@ document.addEventListener("DOMContentLoaded", () => { ) { e.preventDefault(); e.stopImmediatePropagation(); - invoke("download_file", { params: { url: absoluteUrl, filename } }); + const userLanguage = getUserLanguage(); + invoke("download_file", { params: { url: absoluteUrl, filename, language: userLanguage } }); return; } @@ -330,10 +363,313 @@ document.addEventListener("DOMContentLoaded", () => { document.addEventListener( "keydown", (e) => { - if (e.keyCode === 229) e.stopPropagation(); + if (e.key === 'Process') e.stopPropagation(); }, true, ); + + // Language detection and texts + const isChinese = isChineseLanguage(); + + const menuTexts = { + // Media operations + downloadImage: isChinese ? '下载图片' : 'Download Image', + downloadVideo: isChinese ? '下载视频' : 'Download Video', + downloadFile: isChinese ? '下载文件' : 'Download File', + copyAddress: isChinese ? '复制地址' : 'Copy Address', + openInBrowser: isChinese ? '浏览器打开' : 'Open in Browser' + }; + + // Menu theme configuration + const MENU_THEMES = { + dark: { + menu: { + background: '#2d2d2d', + border: '1px solid #404040', + color: '#ffffff', + shadow: '0 4px 16px rgba(0, 0, 0, 0.4)' + }, + item: { + divider: '#404040', + hoverBg: '#404040' + } + }, + light: { + menu: { + background: '#ffffff', + border: '1px solid #e0e0e0', + color: '#333333', + shadow: '0 4px 16px rgba(0, 0, 0, 0.15)' + }, + item: { + divider: '#f0f0f0', + hoverBg: '#d0d0d0' + } + } + }; + + // Theme detection and menu styles + function getTheme() { + const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches; + return prefersDark ? 'dark' : 'light'; + } + + function getMenuStyles(theme = getTheme()) { + return MENU_THEMES[theme] || MENU_THEMES.light; + } + + // Menu configuration constants + const MENU_CONFIG = { + id: 'pake-context-menu', + minWidth: '120px', // Compact width for better UX + borderRadius: '6px', // Slightly more rounded for modern look + fontSize: '13px', + zIndex: '999999', + fontFamily: '-apple-system, BlinkMacSystemFont, \'Segoe UI\', Roboto, sans-serif', + // Menu item dimensions + itemPadding: '8px 16px', // Increased vertical padding for better comfort + itemLineHeight: '1.2', + itemBorderRadius: '3px', // Subtle rounded corners for menu items + itemTransition: 'background-color 0.1s ease' + }; + + // Create custom context menu + function createContextMenu() { + const contextMenu = document.createElement('div'); + contextMenu.id = MENU_CONFIG.id; + const styles = getMenuStyles(); + + contextMenu.style.cssText = ` + position: fixed; + background: ${styles.menu.background}; + border: ${styles.menu.border}; + border-radius: ${MENU_CONFIG.borderRadius}; + box-shadow: ${styles.menu.shadow}; + padding: 4px 0; + min-width: ${MENU_CONFIG.minWidth}; + font-family: ${MENU_CONFIG.fontFamily}; + font-size: ${MENU_CONFIG.fontSize}; + color: ${styles.menu.color}; + z-index: ${MENU_CONFIG.zIndex}; + display: none; + user-select: none; + `; + document.body.appendChild(contextMenu); + return contextMenu; + } + + function createMenuItem(text, onClick, divider = false) { + const item = document.createElement('div'); + const styles = getMenuStyles(); + + item.style.cssText = ` + padding: ${MENU_CONFIG.itemPadding}; + cursor: pointer; + user-select: none; + font-weight: 400; + line-height: ${MENU_CONFIG.itemLineHeight}; + transition: ${MENU_CONFIG.itemTransition}; + white-space: nowrap; + border-radius: ${MENU_CONFIG.itemBorderRadius}; + margin: 2px 4px; + border-bottom: ${divider ? `1px solid ${styles.item.divider}` : 'none'}; + `; + item.textContent = text; + + item.addEventListener('mouseenter', () => { + item.style.backgroundColor = styles.item.hoverBg; + }); + + item.addEventListener('mouseleave', () => { + item.style.backgroundColor = 'transparent'; + }); + + item.addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + onClick(); + hideContextMenu(); + }); + + return item; + } + + function showContextMenu(x, y, items) { + let contextMenu = document.getElementById(MENU_CONFIG.id); + + // Always recreate menu to ensure theme is up-to-date + if (contextMenu) { + contextMenu.remove(); + } + contextMenu = createContextMenu(); + + items.forEach(item => { + contextMenu.appendChild(item); + }); + + contextMenu.style.left = x + 'px'; + contextMenu.style.top = y + 'px'; + contextMenu.style.display = 'block'; + + // Adjust position if menu goes off screen + const rect = contextMenu.getBoundingClientRect(); + if (rect.right > window.innerWidth) { + contextMenu.style.left = (x - rect.width) + 'px'; + } + if (rect.bottom > window.innerHeight) { + contextMenu.style.top = (y - rect.height) + 'px'; + } + } + + function hideContextMenu() { + const contextMenu = document.getElementById(MENU_CONFIG.id); + if (contextMenu) { + contextMenu.style.display = 'none'; + } + } + + function downloadImage(imageUrl) { + // Convert relative URLs to absolute + if (imageUrl.startsWith('/')) { + imageUrl = window.location.origin + imageUrl; + } else if (imageUrl.startsWith('./')) { + imageUrl = new URL(imageUrl, window.location.href).href; + } else if (!imageUrl.startsWith('http') && !imageUrl.startsWith('data:') && !imageUrl.startsWith('blob:')) { + imageUrl = new URL(imageUrl, window.location.href).href; + } + + // Generate filename from URL + const filename = getFilenameFromUrl(imageUrl) || 'image'; + + // Handle different URL types + if (imageUrl.startsWith('data:')) { + downloadFromDataUri(imageUrl, filename); + } else if (imageUrl.startsWith('blob:')) { + if (window.blobToUrlCaches && window.blobToUrlCaches.has(imageUrl)) { + downloadFromBlobUrl(imageUrl, filename); + } + } else { + // Regular HTTP(S) image + const userLanguage = navigator.language || navigator.userLanguage; + invoke("download_file", { + params: { + url: imageUrl, + filename: filename, + language: userLanguage + } + }); + } + } + + + // Check if element is media (image or video) + function getMediaInfo(target) { + // Check for img tags + if (target.tagName.toLowerCase() === 'img') { + return { isMedia: true, url: target.src, type: 'image' }; + } + + // Check for video tags + if (target.tagName.toLowerCase() === 'video') { + return { isMedia: true, url: target.src || target.currentSrc, type: 'video' }; + } + + // Check for elements with background images + if (target.style && target.style.backgroundImage) { + const bgImage = target.style.backgroundImage; + const urlMatch = bgImage.match(/url\(["']?([^"')]+)["']?\)/); + if (urlMatch) { + return { isMedia: true, url: urlMatch[1], type: 'image' }; + } + } + + // Check for parent elements with background images + const parentWithBg = target.closest('[style*="background-image"]'); + if (parentWithBg) { + const bgImage = parentWithBg.style.backgroundImage; + const urlMatch = bgImage.match(/url\(["']?([^"')]+)["']?\)/); + if (urlMatch) { + return { isMedia: true, url: urlMatch[1], type: 'image' }; + } + } + + return { isMedia: false, url: '', type: '' }; + } + + // Simplified menu builder + function buildMenuItems(type, data) { + const userLanguage = navigator.language || navigator.userLanguage; + const items = []; + + switch (type) { + case 'media': + const downloadText = data.type === 'image' ? menuTexts.downloadImage : menuTexts.downloadVideo; + items.push( + createMenuItem(downloadText, () => downloadImage(data.url)), + createMenuItem(menuTexts.copyAddress, () => navigator.clipboard.writeText(data.url)), + createMenuItem(menuTexts.openInBrowser, () => invoke("plugin:shell|open", { path: data.url })) + ); + break; + + case 'link': + if (data.isFile) { + items.push(createMenuItem(menuTexts.downloadFile, () => { + const filename = getFilenameFromUrl(data.url); + invoke("download_file", { + params: { url: data.url, filename, language: userLanguage } + }); + })); + } + items.push( + createMenuItem(menuTexts.copyAddress, () => navigator.clipboard.writeText(data.url)), + createMenuItem(menuTexts.openInBrowser, () => invoke("plugin:shell|open", { path: data.url })) + ); + break; + } + + return items; + } + + // Handle right-click context menu + document.addEventListener('contextmenu', function(event) { + const target = event.target; + + // Check for media elements (images/videos) + const mediaInfo = getMediaInfo(target); + + // Check for links (but not if it's media) + const linkElement = target.closest('a'); + const isLink = linkElement && linkElement.href && !mediaInfo.isMedia; + + // Only show custom menu for media or links + if (mediaInfo.isMedia || isLink) { + event.preventDefault(); + event.stopPropagation(); + + let menuItems = []; + + if (mediaInfo.isMedia) { + menuItems = buildMenuItems('media', mediaInfo); + } else if (isLink) { + const linkUrl = linkElement.href; + menuItems = buildMenuItems('link', { + url: linkUrl, + isFile: isDownloadableFile(linkUrl) + }); + } + + showContextMenu(event.clientX, event.clientY, menuItems); + } + // For all other elements, let browser's default context menu handle it + }, true); + + // Hide context menu when clicking elsewhere + document.addEventListener('click', hideContextMenu); + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape') { + hideContextMenu(); + } + }); }); document.addEventListener("DOMContentLoaded", function () { @@ -376,6 +712,40 @@ function setDefaultZoom() { } function getFilenameFromUrl(url) { - const urlPath = new URL(url).pathname; - return urlPath.substring(urlPath.lastIndexOf("/") + 1); + try { + const urlPath = new URL(url).pathname; + let filename = urlPath.substring(urlPath.lastIndexOf("/") + 1); + + // If no filename or no extension, generate one + if (!filename || !filename.includes('.')) { + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + + // Detect image type from URL or data URI + if (url.startsWith('data:image/')) { + const mimeType = url.substring(11, url.indexOf(';')); + filename = `image-${timestamp}.${mimeType}`; + } else { + // Default to common image extensions based on common patterns + if (url.includes('jpg') || url.includes('jpeg')) { + filename = `image-${timestamp}.jpg`; + } else if (url.includes('png')) { + filename = `image-${timestamp}.png`; + } else if (url.includes('gif')) { + filename = `image-${timestamp}.gif`; + } else if (url.includes('webp')) { + filename = `image-${timestamp}.webp`; + } else if (url.includes('svg')) { + filename = `image-${timestamp}.svg`; + } else { + filename = `image-${timestamp}.png`; // default + } + } + } + + return filename; + } catch (e) { + // Fallback for invalid URLs + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + return `image-${timestamp}.png`; + } } diff --git a/src-tauri/src/util.rs b/src-tauri/src/util.rs index 7f063e4..552156b 100644 --- a/src-tauri/src/util.rs +++ b/src-tauri/src/util.rs @@ -50,7 +50,8 @@ pub enum MessageType { Failure, } -pub fn get_download_message(message_type: MessageType) -> String { + +pub fn get_download_message_with_lang(message_type: MessageType, language: Option) -> String { let default_start_message = "Start downloading~"; let chinese_start_message = "开始下载中~"; @@ -60,28 +61,32 @@ pub fn get_download_message(message_type: MessageType) -> String { let default_failure_message = "Download failed, please check your network connection~"; let chinese_failure_message = "下载失败,请检查你的网络连接~"; - env::var("LANG") - .map(|lang| { - if lang.starts_with("zh") { - match message_type { - MessageType::Start => chinese_start_message, - MessageType::Success => chinese_success_message, - MessageType::Failure => chinese_failure_message, - } - } else { - match message_type { - MessageType::Start => default_start_message, - MessageType::Success => default_success_message, - MessageType::Failure => default_failure_message, - } - } - }) - .unwrap_or_else(|_| match message_type { + let is_chinese = language + .as_ref() + .map(|lang| lang.starts_with("zh") || lang.contains("CN") || lang.contains("TW") || lang.contains("HK")) + .unwrap_or_else(|| { + // Try multiple environment variables for better system detection + ["LANG", "LC_ALL", "LC_MESSAGES", "LANGUAGE"] + .iter() + .find_map(|var| env::var(var).ok()) + .map(|lang| lang.starts_with("zh") || lang.contains("CN") || lang.contains("TW") || lang.contains("HK")) + .unwrap_or(false) + }); + + if is_chinese { + match message_type { + MessageType::Start => chinese_start_message, + MessageType::Success => chinese_success_message, + MessageType::Failure => chinese_failure_message, + } + } else { + match message_type { MessageType::Start => default_start_message, MessageType::Success => default_success_message, MessageType::Failure => default_failure_message, - }) - .to_string() + } + } + .to_string() } // Check if the file exists, if it exists, add a number to file name