import { useEffect, useState, useCallback, useRef } from 'react'; import { useSearchParams, useNavigate } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { useQuery } from '@tanstack/react-query'; import { brandingApi } from '../api/branding'; type Status = 'countdown' | 'fallback' | 'error'; // App schemes configuration - same as miniapp const appSchemes = [ { scheme: 'happ://', icon: 'H', name: 'Happ' }, { scheme: 'flclash://', icon: 'F', name: 'FlClash' }, { scheme: 'clash://', icon: 'C', name: 'Clash Meta' }, { scheme: 'sing-box://', icon: 'S', name: 'sing-box' }, { scheme: 'v2rayng://', icon: 'V', name: 'v2rayNG' }, { scheme: 'sub://', icon: 'R', name: 'Shadowrocket' }, { scheme: 'shadowrocket://', icon: 'R', name: 'Shadowrocket' }, { scheme: 'hiddify://', icon: 'H', name: 'Hiddify' }, { scheme: 'streisand://', icon: 'S', name: 'Streisand' }, { scheme: 'quantumult://', icon: 'Q', name: 'Quantumult X' }, { scheme: 'surge://', icon: 'S', name: 'Surge' }, { scheme: 'loon://', icon: 'L', name: 'Loon' }, { scheme: 'nekobox://', icon: 'N', name: 'NekoBox' }, { scheme: 'v2box://', icon: 'V', name: 'V2Box' }, ]; const COUNTDOWN_SECONDS = 5; // Validate deep link to prevent javascript: and other dangerous schemes const isValidDeepLink = (url: string): boolean => { if (!url) return false; const lowerUrl = url.toLowerCase().trim(); // Block dangerous schemes // eslint-disable-next-line no-script-url -- listing dangerous schemes to block them const dangerousSchemes = ['javascript:', 'data:', 'vbscript:', 'file:']; if (dangerousSchemes.some((scheme) => lowerUrl.startsWith(scheme))) { return false; } // Only allow known app schemes return appSchemes.some((app) => lowerUrl.startsWith(app.scheme)); }; export default function DeepLinkRedirect() { const { t } = useTranslation(); const navigate = useNavigate(); const [searchParams] = useSearchParams(); const [status, setStatus] = useState('countdown'); const [countdown, setCountdown] = useState(COUNTDOWN_SECONDS); const [copied, setCopied] = useState(false); const fallbackTimeoutRef = useRef | null>(null); const copiedTimeoutRef = useRef | null>(null); // Get branding const { data: branding } = useQuery({ queryKey: ['branding'], queryFn: brandingApi.getBranding, staleTime: 60000, }); const projectName = branding ? branding.name : import.meta.env.VITE_APP_NAME || 'VPN'; const logoLetter = branding?.logo_letter || import.meta.env.VITE_APP_LOGO || 'V'; const logoUrl = branding ? brandingApi.getLogoUrl(branding) : null; // Get parameters const deepLink = searchParams.get('url') || searchParams.get('deeplink') || ''; const subscriptionUrl = searchParams.get('sub') || ''; const appParam = searchParams.get('app') || ''; // Detect app from deep link const appInfo = deepLink ? appSchemes.find((a) => deepLink.toLowerCase().startsWith(a.scheme)) : null; const appName = appInfo?.name || appParam || 'VPN'; const appIcon = appInfo?.icon || appName[0]?.toUpperCase() || 'V'; // Open deep link - same as miniapp, just window.location.href const openDeepLink = useCallback(() => { if (!deepLink || !isValidDeepLink(deepLink)) return; window.location.href = deepLink; }, [deepLink]); // Countdown timer effect useEffect(() => { if (!deepLink || !isValidDeepLink(deepLink)) { setStatus('error'); return; } if (status !== 'countdown') return; const timer = setInterval(() => { setCountdown((prev) => { if (prev <= 1) { clearInterval(timer); openDeepLink(); // Show fallback after a delay - store ref for cleanup fallbackTimeoutRef.current = setTimeout(() => setStatus('fallback'), 2000); return 0; } return prev - 1; }); }, 1000); return () => { clearInterval(timer); // Cleanup fallback timeout on unmount if (fallbackTimeoutRef.current) { clearTimeout(fallbackTimeoutRef.current); fallbackTimeoutRef.current = null; } }; }, [deepLink, status, openDeepLink]); const handleCopyLink = async () => { const linkToCopy = subscriptionUrl || deepLink; // Clear previous timeout to prevent stacking if (copiedTimeoutRef.current) { clearTimeout(copiedTimeoutRef.current); } try { await navigator.clipboard.writeText(linkToCopy); setCopied(true); copiedTimeoutRef.current = setTimeout(() => setCopied(false), 2000); } catch { const textarea = document.createElement('textarea'); textarea.value = linkToCopy; document.body.appendChild(textarea); textarea.select(); document.execCommand('copy'); document.body.removeChild(textarea); setCopied(true); copiedTimeoutRef.current = setTimeout(() => setCopied(false), 2000); } }; // Cleanup copied timeout on unmount useEffect(() => { return () => { if (copiedTimeoutRef.current) { clearTimeout(copiedTimeoutRef.current); } }; }, []); // Progress percentage const progress = ((COUNTDOWN_SECONDS - countdown) / COUNTDOWN_SECONDS) * 100; return (
{/* Background */}
{/* Logo with pulse animation */}
{branding?.has_custom_logo && logoUrl ? ( {projectName ) : ( {logoLetter} )}

{projectName || 'VPN'}

{status !== 'error' && (

{t('deepLink.connecting')} {appName}...

)} {/* Countdown State */} {status === 'countdown' && (
{/* App icon */}
{appIcon}
{/* Spinner */}
{/* Timer */}

{t('deepLink.redirecting')}

{countdown} {t('deepLink.seconds')}
{/* Progress bar */}

{t('deepLink.manual')}

{/* Open now button */}
)} {/* Fallback State - App didn't open */} {status === 'fallback' && (
{/* App icon */}
{appIcon}
{/* Copy subscription link */} {/* Try again button */} {/* Back to cabinet */}
{/* Instructions */}

{t('deepLink.howToAdd')}

  1. {t('deepLink.step1')}
  2. {t('deepLink.step2')} {appName}
  3. {t('deepLink.step3')}
  4. {t('deepLink.step4')}
)} {/* Error State */} {status === 'error' && (

{t('deepLink.errorTitle')}

{t('deepLink.errorDesc')}

)} {/* Footer */}
VPN Config Redirect
); }