import { useEffect, useMemo, useRef, useState } from 'react'; import { ActivityIndicator, Image, Linking, Pressable, Share, Text, TouchableOpacity, View, } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; import * as Clipboard from 'expo-clipboard'; import { useTranslation } from 'react-i18next'; import type { ColorScheme } from '../../lib/theme'; import { apiFetch } from '../../lib/api'; import { useUserPlan } from '../../hooks/useUserPlan'; import { FormSheet } from '../FormSheet'; import { deviceImage } from './deviceIcon'; type PairResponse = { code: string; expiresAt: string; expiresInSeconds: number; }; type MagicDevice = { deviceId: string; hostname: string; model: string | null; osVersion: string | null; magicEnrolledAt: string; /** "magic" = Desktop-Binding (Mac/Win), "locked" = Mobile, "protected" = Legacy-DNS */ source?: 'magic' | 'locked' | 'protected'; }; type MagicInfo = { latestVersion: string; downloadUrl: string; dmgUrl: string; minMacosVersion: string; windowsInstallerUrl?: string; minWindowsVersion?: string; }; type DesktopPlatform = 'mac' | 'windows'; /** * MagicSheet — Rebreak-Magic-Pairing-Flow als geteiltes FormSheet * (Bottom-Sheet mit Titel-Header). Wird aus settings.tsx getriggert. */ export function MagicSheet({ visible, onClose, colors, initialPlatform = 'mac', }: { visible: boolean; onClose: () => void; colors: ColorScheme; /** Vorauswahl wenn aus dem "Gerät hinzufügen"-Menü (Mac/Windows) geöffnet */ initialPlatform?: DesktopPlatform; }) { const [info, setInfo] = useState(null); const [platform, setPlatform] = useState(initialPlatform); // Bei jedem Öffnen auf die gewünschte Plattform zurücksetzen useEffect(() => { if (visible) setPlatform(initialPlatform); }, [visible, initialPlatform]); const [pair, setPair] = useState(null); const [pairLoading, setPairLoading] = useState(false); const [pairError, setPairError] = useState(null); const [now, setNow] = useState(Date.now()); const [devices, setDevices] = useState(null); const tickRef = useRef | null>(null); const { t } = useTranslation(); const { plan } = useUserPlan(); // Desktop-Slots (Mac/Windows) — Mirror von backend plan-features.maxProtectedDevices const desktopLimit = plan === 'legend' ? 2 : 1; useEffect(() => { (async () => { try { const i = await apiFetch('/api/magic/info'); setInfo(i); } catch { setInfo({ latestVersion: '0.1.0', downloadUrl: 'https://staging.rebreak.org/download/rebreakmagic', dmgUrl: 'https://staging.rebreak.org/downloads/RebreakMagic-latest.dmg', minMacosVersion: '13.0', windowsInstallerUrl: 'https://staging.rebreak.org/downloads/RebreakMagic-Setup.exe', minWindowsVersion: '10 (21H2)', }); } loadDevices(); })(); return () => { if (tickRef.current) clearInterval(tickRef.current); }; }, []); useEffect(() => { if (pair) { if (tickRef.current) clearInterval(tickRef.current); tickRef.current = setInterval(() => setNow(Date.now()), 1000); return () => { if (tickRef.current) clearInterval(tickRef.current); }; } }, [pair]); async function loadDevices() { try { const d = await apiFetch('/api/magic/devices'); setDevices(d); } catch { setDevices([]); } } async function handleGenerateCode() { setPairLoading(true); setPairError(null); try { const res = await apiFetch('/api/magic/pair/create', { method: 'POST', body: {}, }); setPair(res); setNow(Date.now()); } catch (e: any) { setPairError(e?.message ?? t('magic.generate_error')); } finally { setPairLoading(false); } } async function handleCopyCode() { if (!pair) return; await Clipboard.setStringAsync(pair.code); } const remaining = useMemo(() => { if (!pair) return 0; const exp = new Date(pair.expiresAt).getTime(); return Math.max(0, Math.floor((exp - now) / 1000)); }, [pair, now]); const codeExpired = pair !== null && remaining <= 0; // Desktop-Geräte (Mac/Windows) — "locked" sind Mobile-Devices aus dem // merged /api/magic/devices-Endpoint und zählen NICHT auf Desktop-Slots. const desktopDevices = useMemo( () => (devices ?? []).filter((d) => d.source !== 'locked'), [devices], ); const limitReached = desktopDevices.length >= desktopLimit; const isMac = platform === 'mac'; const appName = t(isMac ? 'magic.app_mac' : 'magic.app_windows'); const downloadHref = isMac ? info?.downloadUrl : (info?.windowsInstallerUrl ?? info?.downloadUrl); return ( {/* Sub-Header (Tagline) */} {t(isMac ? 'magic.tagline_mac' : 'magic.tagline_windows')} {/* Plattform-Wahl */} setPlatform('mac')} colors={colors} /> setPlatform('windows')} colors={colors} /> {/* Step 1 — Download */} {isMac ? t('magic.step1_body_mac', { version: info?.minMacosVersion ?? '13.0' }) : t('magic.step1_body_windows', { version: info?.minWindowsVersion ?? '10 (21H2)' })} downloadHref && Linking.openURL(downloadHref)} /> downloadHref && Share.share({ message: downloadHref })} style={{ marginTop: 10, alignSelf: 'flex-start' }} > {t(isMac ? 'magic.send_link_mac' : 'magic.send_link_windows')} {/* Step 2 — Pairing-Code */} {limitReached && (!pair || codeExpired) ? ( {t('magic.limit_reached', { count: desktopDevices.length, max: desktopLimit, })}{' '} {t(plan === 'legend' ? 'magic.limit_hint_legend' : 'magic.limit_hint_pro')} ) : !pair || codeExpired ? ( <> {t('magic.code_explainer', { app: appName })} {pairError && ( {pairError} )} ) : ( <> {t('magic.enter_in_app', { app: appName })} {pair.code.split('').map((d, i) => ( {d} ))} {t('magic.expires_in', { time: formatRemaining(remaining) })} {t('magic.copy')} { setPair(null); setPairError(null); }} style={{ marginTop: 14, alignSelf: 'center' }} > {t('magic.discard_code')} )} {/* Verbundene Computer + Slot-Anzeige */} {devices === null ? ( ) : desktopDevices.length === 0 ? ( {t('magic.connected_empty')} ) : ( desktopDevices.map((d, i) => ( {d.hostname} {d.model && ( {d.model} )} )) )} ); } // ─── Helpers ─────────────────────────────────────────────────────────────── function cardStyle(colors: ColorScheme) { return { backgroundColor: colors.card, borderRadius: 14, padding: 16, marginBottom: 16, } as const; } function SectionTitle({ text, colors }: { text: string; colors: ColorScheme }) { return ( {text} ); } function PrimaryButton({ icon, label, onPress, loading, }: { icon: React.ComponentProps['name']; label: string; onPress: () => void; loading?: boolean; }) { return ( {loading ? ( ) : ( )} {label} ); } function PlatformOption({ icon, label, selected, onPress, colors, }: { icon: React.ComponentProps['name']; label: string; selected: boolean; onPress: () => void; colors: ColorScheme; }) { return ( {label} ); } function formatRemaining(seconds: number): string { const m = Math.floor(seconds / 60); const s = seconds % 60; return `${m}:${String(s).padStart(2, '0')}`; }