import { useState, useCallback, useEffect, useRef } from 'react' import { View, Text, StyleSheet, Pressable, ScrollView, ActivityIndicator, Platform } from 'react-native' import { useSafeAreaInsets } from 'react-native-safe-area-context' import { useRouter } from 'expo-router' import { ChevronLeft, ChevronDown, ChevronUp, WifiOff, Shield, Monitor, Clock, Globe, Activity, CheckCircle2, XCircle, AlertTriangle } from 'lucide-react-native' import { colors, spacing, typography } from '../src/theme/mobile-theme' import { loadHosts } from '../src/transport/host-store' type DiagnosticStatus = 'idle' | 'running' | 'done' type CheckResult = { label: string status: 'pass' | 'fail' | 'warn' detail: string } type TroubleshootSection = { id: string icon: React.ReactNode title: string steps: string[] } const sections: TroubleshootSection[] = [ { id: 'wifi', icon: , title: 'Different WiFi Networks', steps: [ 'Both devices must be on the same local network.', 'Ethernet and WiFi must share the same subnet.', 'Try reconnecting WiFi on both devices.' ] }, { id: 'firewall', icon: , title: 'Firewall Blocking Port 6768', steps: [ 'macOS: System Settings → Network → Firewall — allow Orca.', 'Windows: Defender Firewall → Allow app — enable Orca for Private networks.', 'Linux: sudo ufw allow 6768', 'Corporate/school networks may block P2P — try a personal hotspot.' ] }, { id: 'desktop', icon: , title: 'Desktop App Not Running', steps: [ 'Orca must be open on your desktop to accept connections.', 'Try restarting Orca — the companion server starts on launch.', 'After an update, you may need to re-pair via QR code.' ] }, { id: 'timeout', icon: , title: 'Connection Timeout', steps: [ 'Check WiFi signal strength on your phone.', 'Go back to the host list and tap your host to retry.', 'Restart both apps if timeouts persist.' ] }, { id: 'vpn', icon: , title: 'VPN Interference', steps: [ 'VPNs can route local traffic through a remote server.', 'Disable the VPN or enable split tunneling / "Allow LAN".' ] } ] function StatusIcon({ status }: { status: CheckResult['status'] }) { switch (status) { case 'pass': return case 'fail': return case 'warn': return } } export default function TroubleshootScreen() { const router = useRouter() const insets = useSafeAreaInsets() const [expandedId, setExpandedId] = useState(null) const [diagnosticStatus, setDiagnosticStatus] = useState('idle') const [checks, setChecks] = useState([]) const abortRef = useRef(false) useEffect(() => { return () => { abortRef.current = true } }, []) const toggleSection = useCallback((id: string) => { setExpandedId((prev) => (prev === id ? null : id)) }, []) const runDiagnostics = useCallback(async () => { abortRef.current = false setDiagnosticStatus('running') setChecks([]) const results: CheckResult[] = [] try { const hosts = await loadHosts() results.push( hosts.length > 0 ? { label: 'Paired hosts', status: 'pass', detail: `${hosts.length} paired` } : { label: 'Paired hosts', status: 'fail', detail: 'None — scan a QR to pair' } ) } catch { results.push({ label: 'Paired hosts', status: 'warn', detail: 'Could not read host data' }) } if (abortRef.current) return setChecks([...results]) try { const controller = new AbortController() const timeout = setTimeout(() => controller.abort(), 5000) const resp = await fetch('https://dns.google/resolve?name=example.com&type=A', { signal: controller.signal }) clearTimeout(timeout) results.push( resp.ok ? { label: 'Internet', status: 'pass', detail: 'Connected' } : { label: 'Internet', status: 'warn', detail: 'Unexpected response' } ) } catch { results.push({ label: 'Internet', status: 'fail', detail: 'No connection' }) } if (abortRef.current) return setChecks([...results]) try { const hosts = await loadHosts() for (const host of hosts) { if (abortRef.current) return const reachable = await testHostReachability(host.endpoint) results.push({ label: host.name, status: reachable ? 'pass' : 'fail', detail: reachable ? `Reachable at ${formatEndpoint(host.endpoint)}` : `Cannot reach ${formatEndpoint(host.endpoint)}` }) setChecks([...results]) } } catch { results.push({ label: 'Hosts', status: 'warn', detail: 'Could not test' }) } if (abortRef.current) return results.push({ label: 'Platform', status: 'pass', detail: `${Platform.OS} ${Platform.Version ?? ''}` }) setChecks([...results]) setDiagnosticStatus('done') }, []) return ( router.back()}> Troubleshooting [ styles.diagnosticButton, pressed && styles.diagnosticButtonPressed, diagnosticStatus === 'running' && styles.diagnosticButtonDisabled ]} onPress={runDiagnostics} disabled={diagnosticStatus === 'running'} > {diagnosticStatus === 'running' ? ( ) : ( )} {diagnosticStatus === 'running' ? 'Running…' : diagnosticStatus === 'done' ? 'Run again' : 'Run diagnostics'} {checks.length > 0 && ( {checks.map((check, i) => ( {i > 0 && } {check.label} {check.detail} ))} )} Common issues {sections.map((section, i) => ( {i > 0 && } [styles.accordionHeader, pressed && styles.rowPressed]} onPress={() => toggleSection(section.id)} > {section.icon} {section.title} {expandedId === section.id ? ( ) : ( )} {expandedId === section.id && ( {section.steps.map((step, j) => ( • {step} ))} )} ))} ) } // Why: WebSocket reachability is tested by opening a connection and waiting for // the server to respond with any frame, or timing out after 4 seconds. // We don't complete the E2EE handshake — just verify the endpoint is listening. async function testHostReachability(endpoint: string): Promise { return new Promise((resolve) => { const timeout = setTimeout(() => { ws.close() resolve(false) }, 4000) const ws = new WebSocket(endpoint) ws.onopen = () => { clearTimeout(timeout) ws.close() resolve(true) } ws.onerror = () => { clearTimeout(timeout) resolve(false) } }) } function formatEndpoint(endpoint: string): string { try { const url = new URL(endpoint) return url.host } catch { return endpoint } } const styles = StyleSheet.create({ container: { flex: 1, backgroundColor: colors.bgBase, padding: spacing.lg }, topRow: { flexDirection: 'row', alignItems: 'center', marginBottom: spacing.lg }, backButton: { width: 36, height: 36, borderRadius: 18, alignItems: 'center', justifyContent: 'center', marginRight: spacing.sm }, heading: { fontSize: 20, fontWeight: '700', color: colors.textPrimary }, scroll: { flex: 1 }, scrollContent: { paddingBottom: spacing.xl }, diagnosticButton: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: spacing.sm, backgroundColor: colors.bgRaised, borderRadius: 10, paddingVertical: spacing.md, paddingHorizontal: spacing.lg, marginBottom: spacing.lg }, diagnosticButtonPressed: { opacity: 0.7 }, diagnosticButtonDisabled: { opacity: 0.5 }, diagnosticButtonLabel: { fontSize: typography.bodySize, fontWeight: '600', color: colors.textPrimary }, checkRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm, paddingVertical: spacing.sm + 2, paddingHorizontal: spacing.md + 2 }, checkLabel: { fontSize: typography.bodySize, fontWeight: '500', color: colors.textPrimary }, checkDetail: { flex: 1, textAlign: 'right', fontSize: typography.metaSize, color: colors.textMuted }, checkDetailFail: { color: colors.statusRed }, sectionHeading: { fontSize: typography.metaSize, fontWeight: '600', color: colors.textMuted, textTransform: 'uppercase', letterSpacing: 0.5, marginBottom: spacing.sm, marginTop: spacing.sm, paddingHorizontal: spacing.xs }, section: { backgroundColor: colors.bgPanel, borderRadius: 12, overflow: 'hidden', marginBottom: spacing.lg }, separator: { height: StyleSheet.hairlineWidth, backgroundColor: colors.borderSubtle, marginHorizontal: spacing.md }, rowPressed: { backgroundColor: colors.bgRaised }, accordionHeader: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm + 2, paddingVertical: spacing.md, paddingHorizontal: spacing.md + 2 }, accordionTitle: { flex: 1, fontSize: typography.bodySize, fontWeight: '500', color: colors.textPrimary }, accordionBody: { paddingHorizontal: spacing.md + 2, paddingBottom: spacing.md, gap: spacing.xs + 2 }, stepRow: { flexDirection: 'row', gap: spacing.sm }, bullet: { fontSize: typography.metaSize, color: colors.textMuted, lineHeight: 18 }, stepText: { flex: 1, fontSize: typography.metaSize, color: colors.textMuted, lineHeight: 18 } })