import { invoke } from "@tauri-apps/api/core"; import type { Event as TauriEvent } from "@tauri-apps/api/event"; import { listen } from "@tauri-apps/api/event"; import { useCallback, useEffect, useState } from "react"; import i18n from "@/i18n"; import { getBrowserDisplayName } from "@/lib/browser-utils"; import { isOnboardingActive } from "@/lib/onboarding-signal"; import { dismissToast, showDownloadToast, showErrorToast, showSuccessToast, showToast, } from "@/lib/toast-utils"; interface GithubRelease { tag_name: string; assets: { name: string; browser_download_url: string; hash?: string; }[]; published_at: string; is_nightly: boolean; } interface BrowserVersionInfo { version: string; is_prerelease: boolean; date: string; } interface DownloadProgress { browser: string; version: string; downloaded_bytes: number; total_bytes?: number; percentage: number; speed_bytes_per_sec: number; eta_seconds?: number; stage: string; } interface BrowserVersionsResult { versions: string[]; new_versions_count?: number; total_versions_count: number; } export function useBrowserDownload() { const [availableVersions, setAvailableVersions] = useState( [], ); const [downloadedVersionsMap, setDownloadedVersionsMap] = useState< Record >({}); const [downloadingBrowsers, setDownloadingBrowsers] = useState>( new Set(), ); const [downloadProgress, setDownloadProgress] = useState(null); const formatTime = useCallback((seconds: number): string => { if (seconds < 60) { return `${Math.round(seconds)}s`; } if (seconds < 3600) { const minutes = Math.floor(seconds / 60); const remainingSeconds = Math.round(seconds % 60); return `${minutes}m ${remainingSeconds}s`; } const hours = Math.floor(seconds / 3600); const minutes = Math.floor((seconds % 3600) / 60); return `${hours}h ${minutes}m`; }, []); const formatBytes = useCallback((bytes: number): string => { if (bytes === 0) return "0 B"; const k = 1024; const sizes = ["B", "KB", "MB", "GB"]; const i = Math.floor(Math.log(bytes) / Math.log(k)); return `${Number.parseFloat((bytes / k ** i).toFixed(1))} ${sizes[i]}`; }, []); const loadVersions = useCallback(async (browserStr: string) => { const browserName = getBrowserDisplayName(browserStr); // Use a simple loading state instead of toast for version fetching console.log(`Fetching ${browserName} versions...`); try { const versionInfos = await invoke( "fetch_browser_versions_cached_first", { browserStr }, ); // Convert BrowserVersionInfo to GithubRelease format for compatibility const githubReleases: GithubRelease[] = versionInfos.map( (versionInfo) => ({ tag_name: versionInfo.version, assets: [], published_at: versionInfo.date, is_nightly: versionInfo.is_prerelease, }), ); setAvailableVersions(githubReleases); return githubReleases; } catch (error) { console.error("Failed to load versions:", error); showErrorToast( i18n.t("browserDownload.toast.fetchVersionsFailed", { browser: browserName, }), { description: error instanceof Error ? error.message : i18n.t("common.errors.unknown"), duration: 4000, }, ); throw error; } }, []); const loadVersionsWithNewCount = useCallback(async (browserStr: string) => { const browserName = getBrowserDisplayName(browserStr); try { // Get versions with new count info and cached detailed info const result = await invoke( "fetch_browser_versions_with_count_cached_first", { browserStr }, ); // Get detailed version info for compatibility const versionInfos = await invoke( "fetch_browser_versions_cached_first", { browserStr }, ); // Convert BrowserVersionInfo to GithubRelease format for compatibility const githubReleases: GithubRelease[] = versionInfos.map( (versionInfo) => ({ tag_name: versionInfo.version, assets: [], published_at: versionInfo.date, is_nightly: versionInfo.is_prerelease, }), ); setAvailableVersions(githubReleases); // Show notification about new versions if any were found if (result.new_versions_count && result.new_versions_count > 0) { showSuccessToast( i18n.t("browserDownload.toast.foundNewVersions", { count: result.new_versions_count, browser: browserName, }), { duration: 3000, description: i18n.t( "browserDownload.toast.totalAvailableVersions", { count: result.total_versions_count }, ), }, ); } return githubReleases; } catch (error) { console.error("Failed to load versions:", error); showErrorToast( i18n.t("browserDownload.toast.fetchVersionsFailed", { browser: browserName, }), { description: error instanceof Error ? error.message : i18n.t("common.errors.unknown"), duration: 4000, }, ); throw error; } }, []); const loadDownloadedVersions = useCallback(async (browserStr: string) => { try { const versions = await invoke( "get_downloaded_browser_versions", { browserStr }, ); setDownloadedVersionsMap((prev) => ({ ...prev, [browserStr]: versions })); return versions; } catch (error) { console.error("Failed to load downloaded versions:", error); throw error; } }, []); const downloadBrowser = useCallback( async ( browserStr: string, version: string, suppressNotifications = false, ) => { const browserName = getBrowserDisplayName(browserStr); setDownloadingBrowsers((prev) => new Set(prev).add(browserStr)); try { // Check browser compatibility before attempting download const isSupported = await invoke( "is_browser_supported_on_platform", { browserStr }, ); if (!isSupported) { const supportedBrowsers = await invoke( "get_supported_browsers", ); throw new Error( `${browserName} is not supported on your platform. Supported browsers: ${supportedBrowsers .map(getBrowserDisplayName) .join(", ")}`, ); } await invoke("download_browser", { browserStr, version }); await loadDownloadedVersions(browserStr); } catch (error) { console.error("Failed to download browser:", error); if (!suppressNotifications) { // Dismiss any existing download toast and show error dismissToast(`download-${browserStr}-${version}`); let errorMessage = i18n.t("common.errors.unknown"); if (error instanceof Error) { errorMessage = error.message; } else if (typeof error === "string") { errorMessage = error; } else if (error && typeof error === "object" && "message" in error) { errorMessage = String(error.message); } // Ensure the long-running download toast is dismissed, and show a finite error toast dismissToast(`download-${browserStr}-${version}`); showErrorToast( i18n.t("browserDownload.toast.downloadFailed", { browser: browserName, version, }), { description: errorMessage, duration: 8000, }, ); } throw error; } finally { setDownloadingBrowsers((prev) => { const next = new Set(prev); next.delete(browserStr); return next; }); } }, [loadDownloadedVersions], ); const isVersionDownloaded = useCallback( (version: string) => { return Object.values(downloadedVersionsMap).some((versions) => versions.includes(version), ); }, [downloadedVersionsMap], ); // Check if a browser type is currently downloading const isBrowserDownloading = useCallback( (browserStr: string) => { return downloadingBrowsers.has(browserStr); }, [downloadingBrowsers], ); // Legacy isDownloading for backwards compatibility const isDownloading = downloadingBrowsers.size > 0; // Listen for download progress events (browsers) and GeoIP progress events useEffect(() => { let unlistenBrowser: (() => void) | null = null; let unlistenGeoip: (() => void) | null = null; const setupListeners = async () => { try { // Browser binaries download progress unlistenBrowser = await listen( "download-progress", async (event: TauriEvent) => { const progress = event.payload; setDownloadProgress(progress); if ( progress.stage === "downloading" || progress.stage === "extracting" || progress.stage === "verifying" ) { setDownloadingBrowsers((prev) => { if (prev.has(progress.browser)) return prev; return new Set(prev).add(progress.browser); }); } const browserName = getBrowserDisplayName(progress.browser); if (progress.stage === "downloading") { const speedMBps = ( progress.speed_bytes_per_sec / (1024 * 1024) ).toFixed(1); const etaText = progress.eta_seconds ? formatTime(progress.eta_seconds) : i18n.t("browserDownload.toast.calculating"); const toastId = `download-${browserName.toLowerCase()}-${progress.version}`; // During first-run onboarding the welcome dialog shows browser // setup progress itself, so suppress the global download toast. if (!isOnboardingActive()) { showDownloadToast( browserName, progress.version, "downloading", { percentage: progress.percentage, speed: speedMBps, eta: etaText, }, { onCancel: () => { invoke("cancel_download", { browserStr: progress.browser, version: progress.version, }).catch((err) => { console.error("Failed to cancel download:", err); }); dismissToast(toastId); }, }, ); } } else if (progress.stage === "extracting") { if (!isOnboardingActive()) { showDownloadToast(browserName, progress.version, "extracting"); } } else if (progress.stage === "verifying") { if (!isOnboardingActive()) { showDownloadToast(browserName, progress.version, "verifying"); } } else if (progress.stage === "cancelled") { setDownloadingBrowsers((prev) => { const next = new Set(prev); next.delete(progress.browser); return next; }); dismissToast( `download-${browserName.toLowerCase()}-${progress.version}`, ); setDownloadProgress(null); } else if (progress.stage === "error") { setDownloadingBrowsers((prev) => { const next = new Set(prev); next.delete(progress.browser); return next; }); dismissToast( `download-${browserName.toLowerCase()}-${progress.version}`, ); setDownloadProgress(null); // During first-run onboarding the welcome dialog surfaces a // concrete setup error itself, so suppress the global toast. if (!isOnboardingActive()) { showErrorToast( i18n.t("browserDownload.toast.extractionFailed", { browser: browserName, version: progress.version, }), { description: i18n.t( "browserDownload.toast.extractionFailedDescription", ), }, ); } } else if (progress.stage === "completed") { setDownloadingBrowsers((prev) => { const next = new Set(prev); next.delete(progress.browser); return next; }); // On completion, refresh the downloaded versions for this browser and also refresh camoufox, // since the Create dialog implicitly uses camoufox on the anti-detect tab try { await Promise.all([ loadDownloadedVersions(progress.browser), progress.browser !== "camoufox" ? loadDownloadedVersions("camoufox") : Promise.resolve([]), ]); } catch { /* empty */ } if (!isOnboardingActive()) { showDownloadToast(browserName, progress.version, "completed"); } setDownloadProgress(null); } }, ); } catch (error) { console.error("Failed to setup download progress listener:", error); } try { // GeoIP database download progress unlistenGeoip = await listen<{ stage: string; percentage: number; message: string; downloaded_bytes?: number; total_bytes?: number; speed_bytes_per_sec?: number; eta_seconds?: number; }>( "geoip-download-progress", ( event: TauriEvent<{ stage: string; percentage: number; message: string; downloaded_bytes?: number; total_bytes?: number; speed_bytes_per_sec?: number; eta_seconds?: number; }>, ) => { const { stage, percentage, speed_bytes_per_sec, eta_seconds } = event.payload; if (stage === "downloading") { const speedMBps = speed_bytes_per_sec ? (speed_bytes_per_sec / (1024 * 1024)).toFixed(1) : undefined; const etaText = eta_seconds ? formatTime(eta_seconds) : undefined; showToast({ id: "geoip-download", type: "download", title: i18n.t("browserDownload.toast.geoipDownloading"), stage: "downloading", progress: { percentage, ...(speedMBps ? { speed: speedMBps } : {}), ...(etaText ? { eta: etaText } : {}), }, }); } else if (stage === "completed") { showToast({ id: "geoip-download", type: "download", title: i18n.t("browserDownload.toast.geoipDownloaded"), stage: "completed", }); } }, ); } catch (error) { console.error("Failed to setup GeoIP progress listener:", error); } }; void setupListeners(); return () => { if (unlistenBrowser) { try { unlistenBrowser(); } catch (error) { console.error("Failed to cleanup browser download listener:", error); } } if (unlistenGeoip) { try { unlistenGeoip(); } catch (error) { console.error("Failed to cleanup GeoIP progress listener:", error); } } }; }, [formatTime, loadDownloadedVersions]); return { availableVersions, downloadedVersionsMap, isDownloading, isBrowserDownloading, downloadingBrowsers, downloadProgress, loadVersions, loadVersionsWithNewCount, loadDownloadedVersions, downloadBrowser, isVersionDownloaded, formatBytes, formatTime, }; }