feat(desktop): modernize browser extension integration

This commit is contained in:
NimBold
2026-06-13 12:00:28 +03:30
parent 9119cafdcd
commit f8c89e2f88
14 changed files with 1145 additions and 125 deletions
+42 -7
View File
@@ -9,6 +9,7 @@ import { useDownloadStore, MAIN_QUEUE_ID } from './store/useDownloadStore';
import { useSettingsStore } from "./store/useSettingsStore";
import { isPermissionGranted, requestPermission, sendNotification } from '@tauri-apps/plugin-notification';
import { invoke } from "@tauri-apps/api/core";
import { getCurrent, onOpenUrl } from "@tauri-apps/plugin-deep-link";
import SchedulerView from "./components/SchedulerView";
import SpeedLimiterView from "./components/SpeedLimiterView";
@@ -19,6 +20,22 @@ const localDateKey = (date: Date) => {
return `${year}-${month}-${day}`;
};
const handleDeepLinks = (deepLinks: string[]) => {
for (const rawDeepLink of deepLinks) {
try {
const deepLink = new URL(rawDeepLink);
if (deepLink.protocol !== 'firelink:' || deepLink.hostname !== 'add') continue;
const urls = deepLink.searchParams.get('url') || '';
if (urls.length > 0 && urls.length < 65_536) {
useDownloadStore.getState().openAddModalWithUrls(urls);
return;
}
} catch (error) {
console.warn('Ignored invalid Firelink deep link:', error);
}
}
};
function App() {
const [filter, setFilter] = useState<SidebarFilter>('all');
const updateDownload = useDownloadStore(state => state.updateDownload);
@@ -28,6 +45,7 @@ function App() {
const appFontSize = useSettingsStore(state => state.appFontSize);
const showDockBadge = useSettingsStore(state => state.showDockBadge);
const showMenuBarIcon = useSettingsStore(state => state.showMenuBarIcon);
const extensionPairingToken = useSettingsStore(state => state.extensionPairingToken);
const downloads = useDownloadStore(state => state.downloads);
const activeDownloadCount = downloads.filter(download => download.status === 'downloading').length;
const queuedCount = downloads.filter(download => download.status === 'queued').length;
@@ -48,6 +66,25 @@ function App() {
invoke('toggle_tray_icon', { show: showMenuBarIcon }).catch(console.error);
}, [showMenuBarIcon]);
useEffect(() => {
invoke('set_extension_pairing_token', { token: extensionPairingToken }).catch(error => {
console.error('Failed to configure browser extension pairing token:', error);
});
}, [extensionPairingToken]);
useEffect(() => {
const unlisten = onOpenUrl(handleDeepLinks);
getCurrent()
.then(urls => {
if (urls) handleDeepLinks(urls);
})
.catch(error => console.error('Failed to read startup deep link:', error));
return () => {
unlisten.then(dispose => dispose());
};
}, []);
useEffect(() => {
if (previousSpeedLimit.current === globalSpeedLimit) return;
previousSpeedLimit.current = globalSpeedLimit;
@@ -178,16 +215,14 @@ function App() {
});
const unlistenExtension = listen('extension-add-download', (event: any) => {
const { url, token } = event.payload;
const settings = useSettingsStore.getState();
if (settings.extensionPairingToken && token === settings.extensionPairingToken) {
useDownloadStore.getState().openAddModalWithUrls(url);
} else {
console.warn('Extension add download rejected: invalid token');
}
useDownloadStore.getState().handleExtensionDownload(event.payload);
});
unlistenExtension
.then(() => invoke('set_extension_frontend_ready', { ready: true }))
.catch(error => console.error('Failed to activate browser extension integration:', error));
return () => {
invoke('set_extension_frontend_ready', { ready: false }).catch(() => {});
unlistenProgress.then(f => f());
unlistenComplete.then(f => f());
unlistenFailed.then(f => f());
@@ -1,22 +1,11 @@
import { useState, useEffect } from 'react';
import { useDownloadStore, MAIN_QUEUE_ID, getSiteLogin } from '../store/useDownloadStore';
import { useSettingsStore } from '../store/useSettingsStore';
import { DownloadCategory } from '../store/useDownloadStore';
import { FolderPlus, Settings, Shield, RefreshCw, FileText, HardDrive, Database, Link, ArrowRight, Play, ChevronDown, ChevronRight, Video, Film, Music } from 'lucide-react';
import { open } from '@tauri-apps/plugin-dialog';
import { invoke } from '@tauri-apps/api/core';
import { DuplicateResolutionModal, DuplicateConflict } from './DuplicateResolutionModal';
function determineCategory(fileName: string): DownloadCategory {
const ext = fileName.split('.').pop()?.toLowerCase() || '';
if (['mp4', 'mkv', 'avi', 'mov', 'wmv', 'flv', 'webm', 'm4v'].includes(ext)) return 'Movies';
if (['mp3', 'wav', 'aac', 'flac', 'ogg', 'm4a', 'wma'].includes(ext)) return 'Musics';
if (['pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'txt', 'rtf'].includes(ext)) return 'Documents';
if (['exe', 'dmg', 'apk', 'app', 'pkg', 'deb', 'rpm', 'msi', 'iso', 'bin', 'run'].includes(ext)) return 'Applications';
if (['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'tiff', 'svg'].includes(ext)) return 'Pictures';
if (['zip', 'rar', '7z', 'tar', 'gz', 'xz', 'bz2'].includes(ext)) return 'Compressed';
return 'Other';
}
import { categoryForFileName, fileNameFromUrl, isMediaUrl } from '../utils/downloads';
interface RawMediaFormat {
format_id?: string;
@@ -238,19 +227,16 @@ const parseMediaFormats = (jsonStr: string) => {
}
};
const MEDIA_DOMAINS = ['youtube.com', 'youtu.be', 'twitter.com', 'x.com', 'twitch.tv', 'vimeo.com', 'instagram.com', 'tiktok.com', 'reddit.com', 'soundcloud.com', 'facebook.com'];
const isMediaUrl = (url: string) => {
try {
const u = new URL(url);
return MEDIA_DOMAINS.some(d => u.hostname.includes(d));
} catch {
return false;
}
};
export const AddDownloadsModal = () => {
const { isAddModalOpen, pendingAddUrls, toggleAddModal, addDownload, queues } = useDownloadStore();
const {
isAddModalOpen,
pendingAddUrls,
pendingAddReferer,
pendingAddFilename,
toggleAddModal,
addDownload,
queues
} = useDownloadStore();
const { defaultDownloadPath } = useSettingsStore();
const [selectedQueueId, setSelectedQueueId] = useState<string>(MAIN_QUEUE_ID);
@@ -290,8 +276,24 @@ export const AddDownloadsModal = () => {
setParsedItems([]);
setSelectedItemIndex(null);
setSelectedQueueId(queues.find(q => q.isMain)?.id || MAIN_QUEUE_ID);
setUseAuth(false);
setUsername('');
setPassword('');
setAdvancedExpanded(false);
setChecksumEnabled(false);
setChecksumAlgo('SHA-256');
setChecksumValue('');
setHeaders(pendingAddReferer ? `Referer: ${pendingAddReferer}` : '');
setCookies('');
setMirrors('');
}
}, [isAddModalOpen, pendingAddUrls, defaultDownloadPath]);
}, [
isAddModalOpen,
pendingAddUrls,
pendingAddReferer,
defaultDownloadPath,
queues
]);
useEffect(() => {
if (!saveLocation) return;
@@ -306,8 +308,9 @@ export const AddDownloadsModal = () => {
// Immediately display items in loading state
const initialItems: ParsedDownloadItem[] = lines.map(url => {
let fallbackFile = 'URL';
try { fallbackFile = new URL(url).pathname.split('/').pop() || 'download'; } catch {}
const fallbackFile = lines.length === 1 && pendingAddFilename
? pendingAddFilename
: fileNameFromUrl(url);
return { url, file: fallbackFile, size: '-', status: 'Loading', isMedia: isMediaUrl(url) };
});
setParsedItems(initialItems);
@@ -378,7 +381,13 @@ export const AddDownloadsModal = () => {
username: login?.username || null,
password: keychainPassword
});
updatedItems[i] = { url, file: meta.filename, size: meta.size, sizeBytes: meta.size_bytes, status: 'Ready' };
updatedItems[i] = {
url,
file: lines.length === 1 && pendingAddFilename ? pendingAddFilename : meta.filename,
size: meta.size,
sizeBytes: meta.size_bytes,
status: 'Ready'
};
}
if (firstReadyIndex === null) firstReadyIndex = i;
} catch (e) {
@@ -394,7 +403,7 @@ export const AddDownloadsModal = () => {
}, 400);
return () => clearTimeout(timer);
}, [urls]); // Re-fetch only on urls change
}, [urls, pendingAddFilename]);
if (!isAddModalOpen) return null;
@@ -563,13 +572,18 @@ export const AddDownloadsModal = () => {
url: item.url,
fileName: finalFile,
status: startImmediately ? 'queued' : 'paused',
category: determineCategory(finalFile),
category: categoryForFileName(finalFile),
dateAdded: new Date().toISOString(),
connections: Number(connections),
speedLimit: speedLimitEnabled ? `${speedLimit}K` : undefined,
username: useAuth ? username.trim() : undefined,
password: useAuth ? password.trim() : undefined,
headers: headers.trim() || undefined,
checksum: checksumEnabled && checksumValue.trim()
? `${checksumAlgo}=${checksumValue.trim()}`
: undefined,
cookies: cookies.trim() || undefined,
mirrors: mirrors.trim() || undefined,
destination: finalLocation,
isMedia: item.isMedia,
mediaFormatSelector: formatSelector,
+1 -1
View File
@@ -777,7 +777,7 @@ export default function SettingsView() {
<Puzzle size={16} className="text-green-500" />
</div>
<h4 className="text-[13px] font-bold text-text-primary mb-1">Paste & Connect</h4>
<p className="text-text-muted text-[11px] leading-relaxed">Click the Firelink icon in your browser's toolbar and paste thecopied token.</p>
<p className="text-text-muted text-[11px] leading-relaxed">Click the Firelink icon in your browser's toolbar and paste the copied token.</p>
</div>
</div>
+73 -6
View File
@@ -1,6 +1,14 @@
import { create } from 'zustand';
import { invoke } from '@tauri-apps/api/core';
import { useSettingsStore } from './useSettingsStore';
import {
categoryForFileName,
fileNameFromUrl,
isMediaUrl,
type DownloadCategory
} from '../utils/downloads';
export type { DownloadCategory } from '../utils/downloads';
const getProxyArgs = (settings: ReturnType<typeof useSettingsStore.getState>) => {
if (settings.proxyMode === 'custom' && settings.proxyHost) {
@@ -77,8 +85,6 @@ const effectiveSpeedLimit = (
};
export type DownloadStatus = 'downloading' | 'paused' | 'completed' | 'failed' | 'queued';
export type DownloadCategory = 'Musics' | 'Movies' | 'Compressed' | 'Documents' | 'Pictures' | 'Applications' | 'Other';
export const MAIN_QUEUE_ID = '00000000-0000-0000-0000-000000000001';
export interface Queue {
@@ -113,14 +119,24 @@ export interface DownloadItem {
queueId: string;
}
export interface ExtensionDownloadRequest {
urls: string[];
referer?: string | null;
silent?: boolean;
filename?: string | null;
}
interface DownloadState {
downloads: DownloadItem[];
queues: Queue[];
isAddModalOpen: boolean;
pendingAddUrls: string;
pendingAddReferer: string;
pendingAddFilename: string;
selectedPropertiesDownloadId: string | null;
toggleAddModal: (isOpen: boolean) => void;
openAddModalWithUrls: (urls: string) => void;
openAddModalWithUrls: (urls: string, referer?: string | null, filename?: string | null) => void;
handleExtensionDownload: (request: ExtensionDownloadRequest) => void;
setSelectedPropertiesDownloadId: (id: string | null) => void;
addDownload: (item: DownloadItem) => void;
updateDownload: (id: string, updates: Partial<DownloadItem>) => void;
@@ -141,9 +157,59 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
queues: [{ id: MAIN_QUEUE_ID, name: 'Main Queue', isMain: true }],
isAddModalOpen: false,
pendingAddUrls: '',
pendingAddReferer: '',
pendingAddFilename: '',
selectedPropertiesDownloadId: null,
toggleAddModal: (isOpen) => set({ isAddModalOpen: isOpen }),
openAddModalWithUrls: (urls) => set({ isAddModalOpen: true, pendingAddUrls: urls }),
toggleAddModal: (isOpen) => set({
isAddModalOpen: isOpen,
pendingAddUrls: '',
pendingAddReferer: '',
pendingAddFilename: ''
}),
openAddModalWithUrls: (urls, referer, filename) => set({
isAddModalOpen: true,
pendingAddUrls: urls,
pendingAddReferer: referer?.trim() || '',
pendingAddFilename: filename?.trim() || ''
}),
handleExtensionDownload: (request) => {
const urls = [...new Set(request.urls.map(url => url.trim()).filter(Boolean))];
if (urls.length === 0) return;
const settings = useSettingsStore.getState();
if (!request.silent || settings.askWhereToSaveEachFile) {
get().openAddModalWithUrls(
urls.join('\n'),
request.referer,
urls.length === 1 ? request.filename : null
);
return;
}
const referer = request.referer?.trim();
const headers = referer ? `Referer: ${referer}` : undefined;
const dateAdded = new Date().toISOString();
const downloads = urls.map((url, index): DownloadItem => {
const fileName = index === 0 && urls.length === 1 && request.filename?.trim()
? request.filename.trim()
: fileNameFromUrl(url);
return {
id: crypto.randomUUID(),
url,
fileName,
status: 'queued',
category: categoryForFileName(fileName),
dateAdded,
connections: settings.perServerConnections,
headers,
isMedia: isMediaUrl(url),
queueId: MAIN_QUEUE_ID
};
});
set(state => ({ downloads: [...state.downloads, ...downloads] }));
void get().processQueue();
},
setSelectedPropertiesDownloadId: (id) => set({ selectedPropertiesDownloadId: id }),
addDownload: (item) => {
set((state) => ({ downloads: [...state.downloads, item] }));
@@ -327,7 +393,8 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
cookieSource: settings.mediaCookieSource !== 'none' ? settings.mediaCookieSource : null,
speedLimit,
username: item.username || (login ? login.username : null),
password: item.password || keychainPassword
password: item.password || keychainPassword,
headers: item.headers || null
});
} else {
const speedLimit = effectiveSpeedLimit(
+26 -12
View File
@@ -102,6 +102,30 @@ const defaultDirectories = {
Other: '~/Downloads/Other'
};
const normalizeDownloadDirectories = (directories: unknown): Record<string, string> => {
if (!directories || typeof directories !== 'object') {
return { ...defaultDirectories };
}
const values = directories as Record<string, unknown>;
const directory = (current: string, legacy?: string) => {
const value = values[current] ?? (legacy ? values[legacy] : undefined);
return typeof value === 'string' && value.length > 0
? value
: defaultDirectories[current as keyof typeof defaultDirectories];
};
return {
Musics: directory('Musics', 'Audio'),
Movies: directory('Movies', 'Video'),
Compressed: directory('Compressed', 'Archives'),
Documents: directory('Documents'),
Pictures: directory('Pictures', 'Images'),
Applications: directory('Applications', 'Apps'),
Other: directory('Other')
};
};
const generateSecureToken = () => {
try {
const cryptoObj = typeof window !== 'undefined' ? (window.crypto || (window as any).msCrypto) : null;
@@ -165,15 +189,7 @@ export const useSettingsStore = create<SettingsState>()(
askWhereToSaveEachFile: false,
preventsSleepWhileDownloading: true,
mediaCookieSource: 'none',
downloadDirectories: {
'Video': '~/Downloads/Video',
'Audio': '~/Downloads/Audio',
'Documents': '~/Downloads/Documents',
'Apps': '~/Downloads/Apps',
'Images': '~/Downloads/Images',
'Archives': '~/Downloads/Compressed',
'Other': '~/Downloads/Other'
},
downloadDirectories: { ...defaultDirectories },
siteLogins: [],
extensionPairingToken: generateSecureToken(),
@@ -255,9 +271,7 @@ export const useSettingsStore = create<SettingsState>()(
...persistedState,
appFontSize: persistedState?.appFontSize === 'extra-large' ? 'large' : (persistedState?.appFontSize || currentState.appFontSize),
listRowDensity: persistedState?.listRowDensity === 'spacious' ? 'relaxed' : (persistedState?.listRowDensity || currentState.listRowDensity),
downloadDirectories: (persistedState && typeof persistedState === 'object' && persistedState.downloadDirectories)
? persistedState.downloadDirectories
: currentState.downloadDirectories,
downloadDirectories: normalizeDownloadDirectories(persistedState?.downloadDirectories),
siteLogins: (persistedState && typeof persistedState === 'object' && Array.isArray(persistedState.siteLogins))
? persistedState.siteLogins
: currentState.siteLogins
+60
View File
@@ -0,0 +1,60 @@
export type DownloadCategory =
| 'Musics'
| 'Movies'
| 'Compressed'
| 'Documents'
| 'Pictures'
| 'Applications'
| 'Other';
const MEDIA_DOMAINS = [
'youtube.com',
'youtu.be',
'twitter.com',
'x.com',
'twitch.tv',
'vimeo.com',
'instagram.com',
'tiktok.com',
'reddit.com',
'soundcloud.com',
'facebook.com'
];
export const categoryForFileName = (fileName: string): DownloadCategory => {
const ext = fileName.split('.').pop()?.toLowerCase() || '';
if (['mp4', 'mkv', 'avi', 'mov', 'wmv', 'flv', 'webm', 'm4v'].includes(ext)) return 'Movies';
if (['mp3', 'wav', 'aac', 'flac', 'ogg', 'm4a', 'wma'].includes(ext)) return 'Musics';
if (['pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'txt', 'rtf'].includes(ext)) return 'Documents';
if (['exe', 'dmg', 'apk', 'app', 'pkg', 'deb', 'rpm', 'msi', 'iso', 'bin', 'run'].includes(ext)) return 'Applications';
if (['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'tiff', 'svg'].includes(ext)) return 'Pictures';
if (['zip', 'rar', '7z', 'tar', 'gz', 'xz', 'bz2'].includes(ext)) return 'Compressed';
return 'Other';
};
export const fileNameFromUrl = (rawUrl: string): string => {
try {
const url = new URL(rawUrl);
const pathName = url.pathname.split('/').filter(Boolean).pop();
if (pathName) {
const decoded = decodeURIComponent(pathName).trim();
if (decoded && decoded !== '.' && decoded !== '..') {
return decoded.replace(/[\/\\?%*:|"<>]/g, '-');
}
}
} catch {
// Fall through to the stable generic name.
}
return 'download';
};
export const isMediaUrl = (rawUrl: string): boolean => {
try {
const url = new URL(rawUrl);
return MEDIA_DOMAINS.some(domain =>
url.hostname === domain || url.hostname.endsWith(`.${domain}`)
);
} catch {
return false;
}
};