Support right click to download pictures and open links

This commit is contained in:
Tw93
2025-08-23 14:11:05 +08:00
parent 42e39645a0
commit 212cd6afb7
8 changed files with 367 additions and 205 deletions

View File

@@ -26,6 +26,7 @@
- 🎐 Nearly 20 times smaller than an Electron package (around 5M!)
- 🚀 With Rust Tauri, Pake is much more lightweight and faster than JS-based frameworks.
- 📦 Battery-included package — shortcut pass-through, immersive windows, and minimalist customization.
- 🖱️ Smart right-click context menus with download support for images, videos, and files.
- 👻 Pake is just a simple tool — replaces the old bundle approach with Tauri (though PWA is also a good alternative).
## Popular Packages
@@ -526,10 +527,6 @@ Pake's development can not be without these Hackers. They contributed a lot of c
</table>
<!-- readme: contributors -end -->
## Frequently Asked Questions
1. Right-clicking on an image element in the page to open the menu and select download image or other events does not work (common in MacOS systems). This issue is due to the MacOS built-in webview not supporting this feature.
## Support
1. I have two cats, TangYuan and Coke. If you think Pake delights your life, you can feed them <a href="https://miaoyan.app/cats.html?name=Pake" target="_blank">some canned food 🥩</a>.

View File

@@ -25,6 +25,7 @@
- 🎐 相比传统的 Electron 套壳打包,要小将近 20 倍5M 上下。
- 🚀 Pake 的底层使用的 Rust Tauri 框架,性能体验较 JS 框架要轻快不少,内存小很多。
- 📦 不是单纯打包,实现了快捷键透传、沉浸式窗口、拖动、样式改写、去广告、产品极简风格定制。
- 🖱️ 智能右键菜单,支持图片、视频、文件的下载和操作功能。
- 👻 只是一个很简单的小玩具,用 Tauri 替代之前套壳网页打包的老思路,其实 PWA 也很好。
## 常用包下载
@@ -525,10 +526,6 @@ Pake 的发展离不开这些 Hacker 们,一起贡献了大量能力,也欢
</table>
<!-- readme: contributors -end -->
## 常见问题
1. 页面中对图片元素鼠标右键打开菜单中选择下载图片或者其他事件不生效(常见于 MacOS 系统)。该问题是因为 MacOS 内置的 webview 无法支持该功能。
## 支持
1. 我有两只猫,一只叫汤圆,一只叫可乐,假如 Pake 让你生活更美好,可以给汤圆可乐 <a href="https://miaoyan.app/cats.html?name=Pake" target="_blank">喂罐头 🥩</a>。

View File

@@ -26,6 +26,7 @@
- 🎐 Electron パッケージと比較して約 20 倍小さい(約 5M
- 🚀 Rust Tauri を使用しているため、Pake は JS ベースのフレームワークよりもはるかに軽量で高速です。
- 📦 パッケージにはショートカットの透過、没入型ウィンドウ、ミニマリストのカスタマイズが含まれています。
- 🖱️ 画像、動画、ファイルのダウンロードをサポートするスマートな右クリックコンテキストメニュー。
- 👻 Pake は単なるシンプルなツールです — Tauri を使用して古いバンドルアプローチを置き換えますPWA も十分に良い代替手段です)。
## 人気のパッケージ
@@ -526,10 +527,6 @@ Pake の開発はこれらのハッカーたちなしにはあり得ませんで
</table>
<!-- readme: contributors -end -->
## よくある質問
1. ページ内の画像要素を右クリックしてメニューを開き、「画像をダウンロード」または他のイベントを選択しても機能しないMacOS システムで一般的。この問題は、MacOS の組み込み webview がこの機能をサポートしていないためです。
## サポート
1. 私には汤圆と可乐という 2 匹の猫がいます。Pake があなたの生活をより良くしてくれると思ったら、<a href="https://miaoyan.app/cats.html?name=Pake" target="_blank">缶詰をあげてください 🥩</a>。

33
dist/cli.js vendored
View File

@@ -435,9 +435,19 @@ StartupNotify=true
tauriConf.bundle.linux.deb.files = {
[`/usr/share/applications/${desktopFileName}`]: `assets/${desktopFileName}`,
};
const validTargets = ['deb', 'appimage', 'rpm'];
const validTargets = [
'deb',
'appimage',
'rpm',
'deb-arm64',
'appimage-arm64',
'rpm-arm64',
];
const baseTarget = options.targets.includes('-arm64')
? options.targets.replace('-arm64', '')
: options.targets;
if (validTargets.includes(options.targets)) {
tauriConf.bundle.targets = [options.targets];
tauriConf.bundle.targets = [baseTarget];
}
else {
logger.warn(`✼ The target must be one of ${validTargets.join(', ')}, the default 'deb' will be used.`);
@@ -792,7 +802,7 @@ class MacBuilder extends BaseBuilder {
: 'npm run tauri build --';
const configPath = path.join('src-tauri', '.pake', 'tauri.conf.json');
let fullCommand = `${baseCommand} --target x86_64-apple-darwin -c "${configPath}"`;
// Add features
// Add features
const features = ['cli-build'];
const macOSVersion = this.getMacOSMajorVersion();
if (macOSVersion >= 23) {
@@ -844,8 +854,8 @@ class WinBuilder extends BaseBuilder {
else {
// Auto-detect based on current architecture if no explicit target
const archMap = {
'x64': 'x64',
'arm64': 'aarch64',
x64: 'x64',
arm64: 'aarch64',
};
targetArch = archMap[process.arch] || process.arch;
}
@@ -868,7 +878,10 @@ class WinBuilder extends BaseBuilder {
}
else {
// Auto-detect based on current architecture if no explicit target
buildTarget = process.arch === 'arm64' ? 'aarch64-pc-windows-msvc' : 'x86_64-pc-windows-msvc';
buildTarget =
process.arch === 'arm64'
? 'aarch64-pc-windows-msvc'
: 'x86_64-pc-windows-msvc';
}
fullCommand += ` --target ${buildTarget}`;
// Add features
@@ -890,7 +903,10 @@ class WinBuilder extends BaseBuilder {
}
else {
// Auto-detect based on current architecture if no explicit target
target = process.arch === 'arm64' ? 'aarch64-pc-windows-msvc' : 'x86_64-pc-windows-msvc';
target =
process.arch === 'arm64'
? 'aarch64-pc-windows-msvc'
: 'x86_64-pc-windows-msvc';
}
return `src-tauri/target/${target}/${basePath}/bundle/`;
}
@@ -1436,8 +1452,7 @@ program
.addOption(new Option('--user-agent <string>', 'Custom user agent')
.default(DEFAULT_PAKE_OPTIONS.userAgent)
.hideHelp())
.addOption(new Option('--targets <string>', 'Build target: Linux: "deb", "appimage", "deb-arm64", "appimage-arm64"; Windows: "x64", "arm64"; macOS: "intel", "apple", "universal"')
.default(DEFAULT_PAKE_OPTIONS.targets))
.addOption(new Option('--targets <string>', 'Build target: Linux: "deb", "rpm", "appimage", "deb-arm64", "rpm-arm64", "appimage-arm64"; Windows: "x64", "arm64"; macOS: "intel", "apple", "universal"').default(DEFAULT_PAKE_OPTIONS.targets))
.addOption(new Option('--app-version <string>', 'App version, the same as package.json version')
.default(DEFAULT_PAKE_OPTIONS.appVersion)
.hideHelp())

View File

@@ -30,7 +30,10 @@ 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_with_lang(MessageType::Start, params.language.clone()));
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());
@@ -49,11 +52,17 @@ 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_with_lang(MessageType::Success, params.language.clone()));
show_toast(
&window,
&get_download_message_with_lang(MessageType::Success, params.language.clone()),
);
Ok(())
}
Err(e) => {
show_toast(&window, &get_download_message_with_lang(MessageType::Failure, params.language));
show_toast(
&window,
&get_download_message_with_lang(MessageType::Failure, params.language),
);
Err(e.to_string())
}
}
@@ -65,17 +74,26 @@ 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_with_lang(MessageType::Start, params.language.clone()));
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, &params.binary);
match download_file_result {
Ok(_) => {
show_toast(&window, &get_download_message_with_lang(MessageType::Success, params.language.clone()));
show_toast(
&window,
&get_download_message_with_lang(MessageType::Success, params.language.clone()),
);
Ok(())
}
Err(e) => {
show_toast(&window, &get_download_message_with_lang(MessageType::Failure, params.language));
show_toast(
&window,
&get_download_message_with_lang(MessageType::Failure, params.language),
);
Err(e.to_string())
}
}

View File

@@ -38,18 +38,115 @@ function handleShortcut(event) {
// 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']
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 ALL_DOWNLOADABLE_EXTENSIONS = Object.values(
DOWNLOADABLE_FILE_EXTENSIONS,
).flat();
const DOWNLOAD_PATH_PATTERNS = ['/download/', '/files/', '/attachments/', '/assets/', '/releases/', '/dist/'];
const DOWNLOAD_PATH_PATTERNS = [
"/download/",
"/files/",
"/attachments/",
"/assets/",
"/releases/",
"/dist/",
];
// Language detection utilities
function getUserLanguage() {
@@ -57,7 +154,13 @@ function getUserLanguage() {
}
function isChineseLanguage(language = getUserLanguage()) {
return language && (language.startsWith('zh') || language.includes('CN') || language.includes('TW') || language.includes('HK'));
return (
language &&
(language.startsWith("zh") ||
language.includes("CN") ||
language.includes("TW") ||
language.includes("HK"))
);
}
// Unified file detection - replaces both isDownloadLink and isFileLink
@@ -65,18 +168,20 @@ 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 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));
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;
}
@@ -287,7 +392,9 @@ document.addEventListener("DOMContentLoaded", () => {
e.preventDefault();
e.stopImmediatePropagation();
const userLanguage = getUserLanguage();
invoke("download_file", { params: { url: absoluteUrl, filename, language: userLanguage } });
invoke("download_file", {
params: { url: absoluteUrl, filename, language: userLanguage },
});
return;
}
@@ -363,55 +470,57 @@ document.addEventListener("DOMContentLoaded", () => {
document.addEventListener(
"keydown",
(e) => {
if (e.key === 'Process') 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'
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)'
background: "#2d2d2d",
border: "1px solid #404040",
color: "#ffffff",
shadow: "0 4px 16px rgba(0, 0, 0, 0.4)",
},
item: {
divider: '#404040',
hoverBg: '#404040'
}
divider: "#404040",
hoverBg: "#404040",
},
},
light: {
menu: {
background: '#ffffff',
border: '1px solid #e0e0e0',
color: '#333333',
shadow: '0 4px 16px rgba(0, 0, 0, 0.15)'
background: "#ffffff",
border: "1px solid #e0e0e0",
color: "#333333",
shadow: "0 4px 16px rgba(0, 0, 0, 0.15)",
},
item: {
divider: '#f0f0f0',
hoverBg: '#d0d0d0'
}
}
divider: "#f0f0f0",
hoverBg: "#d0d0d0",
},
},
};
// Theme detection and menu styles
function getTheme() {
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
return prefersDark ? 'dark' : 'light';
const prefersDark = window.matchMedia(
"(prefers-color-scheme: dark)",
).matches;
return prefersDark ? "dark" : "light";
}
function getMenuStyles(theme = getTheme()) {
@@ -420,25 +529,26 @@ document.addEventListener("DOMContentLoaded", () => {
// 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',
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'
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');
const contextMenu = document.createElement("div");
contextMenu.id = MENU_CONFIG.id;
const styles = getMenuStyles();
contextMenu.style.cssText = `
position: fixed;
background: ${styles.menu.background};
@@ -459,9 +569,9 @@ document.addEventListener("DOMContentLoaded", () => {
}
function createMenuItem(text, onClick, divider = false) {
const item = document.createElement('div');
const item = document.createElement("div");
const styles = getMenuStyles();
item.style.cssText = `
padding: ${MENU_CONFIG.itemPadding};
cursor: pointer;
@@ -472,201 +582,225 @@ document.addEventListener("DOMContentLoaded", () => {
white-space: nowrap;
border-radius: ${MENU_CONFIG.itemBorderRadius};
margin: 2px 4px;
border-bottom: ${divider ? `1px solid ${styles.item.divider}` : 'none'};
border-bottom: ${divider ? `1px solid ${styles.item.divider}` : "none"};
`;
item.textContent = text;
item.addEventListener('mouseenter', () => {
item.addEventListener("mouseenter", () => {
item.style.backgroundColor = styles.item.hoverBg;
});
item.addEventListener('mouseleave', () => {
item.style.backgroundColor = 'transparent';
item.addEventListener("mouseleave", () => {
item.style.backgroundColor = "transparent";
});
item.addEventListener('click', (e) => {
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 => {
items.forEach((item) => {
contextMenu.appendChild(item);
});
contextMenu.style.left = x + 'px';
contextMenu.style.top = y + 'px';
contextMenu.style.display = 'block';
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';
contextMenu.style.left = x - rect.width + "px";
}
if (rect.bottom > window.innerHeight) {
contextMenu.style.top = (y - rect.height) + 'px';
contextMenu.style.top = y - rect.height + "px";
}
}
function hideContextMenu() {
const contextMenu = document.getElementById(MENU_CONFIG.id);
if (contextMenu) {
contextMenu.style.display = 'none';
contextMenu.style.display = "none";
}
}
function downloadImage(imageUrl) {
// Convert relative URLs to absolute
if (imageUrl.startsWith('/')) {
if (imageUrl.startsWith("/")) {
imageUrl = window.location.origin + imageUrl;
} else if (imageUrl.startsWith('./')) {
} else if (imageUrl.startsWith("./")) {
imageUrl = new URL(imageUrl, window.location.href).href;
} else if (!imageUrl.startsWith('http') && !imageUrl.startsWith('data:') && !imageUrl.startsWith('blob:')) {
} 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';
const filename = getFilenameFromUrl(imageUrl) || "image";
// Handle different URL types
if (imageUrl.startsWith('data:')) {
if (imageUrl.startsWith("data:")) {
downloadFromDataUri(imageUrl, filename);
} else if (imageUrl.startsWith('blob:')) {
} 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,
invoke("download_file", {
params: {
url: imageUrl,
filename: filename,
language: userLanguage
}
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' };
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' };
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' };
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: true, url: urlMatch[1], type: "image" };
}
}
return { isMedia: false, url: '', type: '' };
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;
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 }))
createMenuItem(menuTexts.copyAddress, () =>
navigator.clipboard.writeText(data.url),
),
createMenuItem(menuTexts.openInBrowser, () =>
invoke("plugin:shell|open", { path: data.url }),
),
);
break;
case 'link':
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.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 }))
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)
});
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);
}
showContextMenu(event.clientX, event.clientY, menuItems);
}
// For all other elements, let browser's default context menu handle it
}, true);
// 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') {
document.addEventListener("click", hideContextMenu);
document.addEventListener("keydown", (e) => {
if (e.key === "Escape") {
hideContextMenu();
}
});
@@ -715,37 +849,37 @@ function getFilenameFromUrl(url) {
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, '-');
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(';'));
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')) {
if (url.includes("jpg") || url.includes("jpeg")) {
filename = `image-${timestamp}.jpg`;
} else if (url.includes('png')) {
} else if (url.includes("png")) {
filename = `image-${timestamp}.png`;
} else if (url.includes('gif')) {
} else if (url.includes("gif")) {
filename = `image-${timestamp}.gif`;
} else if (url.includes('webp')) {
} else if (url.includes("webp")) {
filename = `image-${timestamp}.webp`;
} else if (url.includes('svg')) {
} 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, '-');
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
return `image-${timestamp}.png`;
}
}

View File

@@ -50,8 +50,10 @@ pub enum MessageType {
Failure,
}
pub fn get_download_message_with_lang(message_type: MessageType, language: Option<String>) -> String {
pub fn get_download_message_with_lang(
message_type: MessageType,
language: Option<String>,
) -> String {
let default_start_message = "Start downloading~";
let chinese_start_message = "开始下载中~";
@@ -63,13 +65,23 @@ pub fn get_download_message_with_lang(message_type: MessageType, language: Optio
let is_chinese = language
.as_ref()
.map(|lang| lang.starts_with("zh") || lang.contains("CN") || lang.contains("TW") || lang.contains("HK"))
.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"))
.map(|lang| {
lang.starts_with("zh")
|| lang.contains("CN")
|| lang.contains("TW")
|| lang.contains("HK")
})
.unwrap_or(false)
});

View File

@@ -2,14 +2,6 @@
"bundle": {
"icon": ["icons/weekly.icns"],
"active": true,
"macOS": {
"frameworks": [],
"minimumSystemVersion": "10.13",
"exceptionDomain": "",
"signingIdentity": null,
"hardenedRuntime": true,
"entitlements": null
},
"targets": ["dmg"]
}
}