mirror of
https://github.com/nimbold/Firelink.git
synced 2026-08-11 12:07:54 +00:00
feat(desktop): modernize browser extension integration
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user