orca/mobile/app/troubleshoot.tsx

464 lines
13 KiB
TypeScript

import { useState, useCallback, 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'
import {
startDiagnosticFetchTimeout,
type DiagnosticFetchTimeout
} from '../src/diagnostics/diagnostic-fetch-timeout'
import { formatEndpoint, testHostReachability } from '../src/diagnostics/host-reachability'
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: <WifiOff size={16} color={colors.textSecondary} />,
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: <Shield size={16} color={colors.textSecondary} />,
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: <Monitor size={16} color={colors.textSecondary} />,
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: <Clock size={16} color={colors.textSecondary} />,
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: <Globe size={16} color={colors.textSecondary} />,
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 <CheckCircle2 size={14} color={colors.statusGreen} />
case 'fail':
return <XCircle size={14} color={colors.statusRed} />
case 'warn':
return <AlertTriangle size={14} color={colors.textMuted} />
}
}
export default function TroubleshootScreen() {
const router = useRouter()
const insets = useSafeAreaInsets()
const [expandedId, setExpandedId] = useState<string | null>(null)
const [diagnosticStatus, setDiagnosticStatus] = useState<DiagnosticStatus>('idle')
const [checks, setChecks] = useState<CheckResult[]>([])
const abortRef = useRef(false)
const diagnosticRunRef = useRef(0)
const activeInternetCheckRef = useRef<DiagnosticFetchTimeout | null>(null)
const setTroubleshootRootRef = useCallback((node: View | null): void => {
if (node !== null) {
return
}
// Why: diagnostics can outlive the screen; cancel the active run when the
// route detaches without a passive cleanup-only Effect.
abortRef.current = true
diagnosticRunRef.current += 1
activeInternetCheckRef.current?.dispose()
activeInternetCheckRef.current = null
}, [])
const toggleSection = useCallback((id: string) => {
setExpandedId((prev) => (prev === id ? null : id))
}, [])
const runDiagnostics = useCallback(async () => {
const runId = diagnosticRunRef.current + 1
diagnosticRunRef.current = runId
abortRef.current = false
activeInternetCheckRef.current?.dispose()
activeInternetCheckRef.current = null
setDiagnosticStatus('running')
setChecks([])
const results: CheckResult[] = []
const isCurrentRun = () => !abortRef.current && diagnosticRunRef.current === runId
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 (!isCurrentRun()) {
return
}
setChecks([...results])
const internetCheck = startDiagnosticFetchTimeout(5000)
activeInternetCheckRef.current = internetCheck
try {
const resp = await fetch('https://dns.google/resolve?name=example.com&type=A', {
signal: internetCheck.signal
})
if (!isCurrentRun()) {
return
}
results.push(
resp.ok
? { label: 'Internet', status: 'pass', detail: 'Connected' }
: { label: 'Internet', status: 'warn', detail: 'Unexpected response' }
)
} catch {
if (!isCurrentRun()) {
return
}
results.push({ label: 'Internet', status: 'fail', detail: 'No connection' })
} finally {
internetCheck.dispose()
if (activeInternetCheckRef.current === internetCheck) {
activeInternetCheckRef.current = null
}
}
if (!isCurrentRun()) {
return
}
setChecks([...results])
try {
const hosts = await loadHosts()
for (const host of hosts) {
if (!isCurrentRun()) {
return
}
const reachable = await testHostReachability(host.endpoint)
if (!isCurrentRun()) {
return
}
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 (!isCurrentRun()) {
return
}
results.push({
label: 'Platform',
status: 'pass',
detail: `${Platform.OS} ${Platform.Version ?? ''}`
})
setChecks([...results])
setDiagnosticStatus('done')
}, [])
return (
<View
ref={setTroubleshootRootRef}
style={[styles.container, { paddingTop: insets.top + spacing.sm }]}
>
<View style={styles.topRow}>
<Pressable style={styles.backButton} onPress={() => router.back()}>
<ChevronLeft size={22} color={colors.textSecondary} />
</Pressable>
<Text style={styles.heading}>Troubleshooting</Text>
</View>
<ScrollView
style={styles.scroll}
contentContainerStyle={styles.scrollContent}
showsVerticalScrollIndicator={false}
>
<Pressable
style={({ pressed }) => [
styles.diagnosticButton,
pressed && styles.diagnosticButtonPressed,
diagnosticStatus === 'running' && styles.diagnosticButtonDisabled
]}
onPress={runDiagnostics}
disabled={diagnosticStatus === 'running'}
>
{diagnosticStatus === 'running' ? (
<ActivityIndicator size="small" color={colors.textPrimary} />
) : (
<Activity size={16} color={colors.textPrimary} />
)}
<Text style={styles.diagnosticButtonLabel}>
{diagnosticStatus === 'running'
? 'Running…'
: diagnosticStatus === 'done'
? 'Run again'
: 'Run diagnostics'}
</Text>
</Pressable>
{checks.length > 0 && (
<View style={styles.section}>
{checks.map((check, i) => (
<View key={i}>
{i > 0 && <View style={styles.separator} />}
<View style={styles.checkRow}>
<StatusIcon status={check.status} />
<Text style={styles.checkLabel}>{check.label}</Text>
<Text
style={[styles.checkDetail, check.status === 'fail' && styles.checkDetailFail]}
>
{check.detail}
</Text>
</View>
</View>
))}
</View>
)}
<Text style={styles.sectionHeading}>Common issues</Text>
<View style={styles.section}>
{sections.map((section, i) => (
<View key={section.id}>
{i > 0 && <View style={styles.separator} />}
<Pressable
style={({ pressed }) => [styles.accordionHeader, pressed && styles.rowPressed]}
onPress={() => toggleSection(section.id)}
>
{section.icon}
<Text style={styles.accordionTitle}>{section.title}</Text>
{expandedId === section.id ? (
<ChevronUp size={16} color={colors.textMuted} />
) : (
<ChevronDown size={16} color={colors.textMuted} />
)}
</Pressable>
{expandedId === section.id && (
<View style={styles.accordionBody}>
{section.steps.map((step, j) => (
<View key={j} style={styles.stepRow}>
<Text style={styles.bullet}></Text>
<Text style={styles.stepText}>{step}</Text>
</View>
))}
</View>
)}
</View>
))}
</View>
<View style={{ height: spacing.xl }} />
</ScrollView>
</View>
)
}
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
}
})