diff --git a/app/(onboarding)/add-device.tsx b/app/(onboarding)/add-device.tsx index 39783ce..ff3d0e4 100644 --- a/app/(onboarding)/add-device.tsx +++ b/app/(onboarding)/add-device.tsx @@ -50,24 +50,53 @@ export default function AddDeviceScreen() { return; } - const addr = relayAddress.trim(); - if (!addr) { - alert.warning('Relay Address Required', 'Enter the relay address shown in your terminal (e.g., 192.168.1.5:3000).'); - return; - } - try { - let relayUrl = addr; - if (!relayUrl.startsWith('ws://') && !relayUrl.startsWith('wss://')) { - relayUrl = `ws://${relayUrl}`; + const addr = relayAddress.trim(); + + if (addr) { + let relayUrl = addr; + if (!relayUrl.startsWith('ws://') && !relayUrl.startsWith('wss://')) { + relayUrl = `ws://${relayUrl}`; + } + await pairingService.setRelayUrl(relayUrl); + } else { + // No relay address — try tunnel URL from Supabase via pairing code + const tunnelUrl = await wsService.fetchTunnelUrlByPairingCode(pairingCode.toUpperCase()); + if (tunnelUrl) { + let wsUrl = tunnelUrl; + if (wsUrl.startsWith('https://')) { + wsUrl = wsUrl.replace('https://', 'wss://'); + } else if (wsUrl.startsWith('http://')) { + wsUrl = wsUrl.replace('http://', 'ws://'); + } + await pairingService.setRelayUrl(wsUrl); + } else { + await pairingService.setRelayUrl(null); + } } - await pairingService.setRelayUrl(relayUrl); - await connectAndWait(); - await pairDevice(pairingCode); + wsService.disconnect(); + await wsService.connect(); + + if (!wsService.isConnected) { + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + unsub(); + reject(new Error('Connection timed out')); + }, 10000); + const unsub = wsService.on('connected', () => { + clearTimeout(timeout); + unsub(); + resolve(); + }); + }); + } + + await pairDevice(pairingCode.toUpperCase()); setIsPaired(true); - } catch (error) { - alert.error('Pairing Failed', 'Could not connect to CLI. Check the relay address and pairing code.'); + } catch (error: any) { + const msg = error?.message || String(error); + alert.error('Pairing Failed', msg); } }; @@ -275,95 +304,30 @@ export default function AddDeviceScreen() { ) : ( - - - - Pairing Code + + + + Enter the pairing code shown in your terminal after running{'\n'} + forkoff pair - setPairingCode(text.toUpperCase())} - autoCapitalize="characters" - maxLength={8} + router.push('/device/pair?method=code')} style={{ - backgroundColor: theme.backgroundSecondary, - borderWidth: 1, - borderColor: theme.border, + backgroundColor: theme.primary, borderRadius: 12, - paddingHorizontal: 16, - paddingVertical: 16, - color: theme.text, - fontSize: 18, - fontFamily: 'monospace', - textAlign: 'center', - letterSpacing: 4, + padding: 16, + width: '100%', + alignItems: 'center', + shadowColor: theme.primary, + shadowOffset: { width: 0, height: 0 }, + shadowOpacity: 0.2, + shadowRadius: 12, + elevation: 5, }} - /> - - - - - Relay Address - - - - - - - First, install ForkOff CLI:{'\n'} - - npm i -g forkoff - - {'\n\n'}Then run:{'\n'} - - forkoff pair - - {'\n'}Enter the code and relay address shown - + > + Enter Pairing Code + - - - - {isLoading ? 'Pairing...' : 'Pair Device'} - - )} diff --git a/app/_layout.tsx b/app/_layout.tsx index d3e7f69..e20bacc 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -1,4 +1,5 @@ import { useEffect, useCallback, useState, useRef } from 'react'; +import { AppState } from 'react-native'; import { Stack, useRouter } from 'expo-router'; import { StatusBar } from 'expo-status-bar'; import { GestureHandlerRootView } from 'react-native-gesture-handler'; @@ -15,6 +16,7 @@ import { useConnectionStore } from '@/stores/connection.store'; import { useVersionStore } from '@/stores/version.store'; import { useAchievementsStore } from '@/stores/achievements.store'; import { wsService } from '@/services/websocket.service'; +import { pairingService } from '@/services/pairing.service'; import { notificationService } from '@/services/notification.service'; import { sentryService } from '@/services/sentry.service'; import { analyticsService } from '@/services/analytics.service'; @@ -155,14 +157,14 @@ function ThemedApp({ export default function RootLayout() { const router = useRouter(); - const { initialize, isReady, isPaired, mobileDeviceId } = useIdentityStore(); + const { initialize, isReady, isPaired, mobileDeviceId, pairedDevices } = useIdentityStore(); const { currentApproval, hideApproval, respondToApproval, subscribeToApprovals, } = useApprovalStore(); - const { initialize: initializeConnection } = useConnectionStore(); + const { initialize: initializeConnection, setServerConnected } = useConnectionStore(); const { needsUpdate } = useVersionStore(); const { recentUnlock, setRecentUnlock } = useAchievementsStore(); const [showSplash, setShowSplash] = useState(true); @@ -226,17 +228,103 @@ export default function RootLayout() { // Connect/disconnect WebSocket based on pairing state useEffect(() => { - if (isReady) { - if (isPaired) { - wsService.connect(); - - // Register for push notifications when paired - notificationService.registerForPushNotifications(); - } else { - wsService.disconnect(); + async function connectWithTunnelUrl() { + if (isReady) { + if (isPaired) { + // Cold start: fetch current tunnel URL from Supabase before connecting + if (pairedDevices.length > 0) { + for (const device of pairedDevices) { + const tunnelUrl = await wsService.fetchCurrentTunnelUrl(device.id); + if (tunnelUrl) { + let wsUrl = tunnelUrl; + if (wsUrl.startsWith('https://')) { + wsUrl = wsUrl.replace('https://', 'wss://'); + } else if (wsUrl.startsWith('http://')) { + wsUrl = wsUrl.replace('http://', 'ws://'); + } + await pairingService.setRelayUrl(wsUrl); + } + } + } + + wsService.connect(); + + // Register for push notifications when paired + notificationService.registerForPushNotifications(); + } else { + wsService.disconnect(); + wsService.unsubscribeTunnelUpdates(); + } + } + } + connectWithTunnelUrl(); + }, [isPaired, isReady, pairedDevices]); + + // Subscribe to tunnel URL changes when paired (for auto-reconnect on tunnel restart) + useEffect(() => { + if (isPaired && isReady && pairedDevices.length > 0) { + for (const device of pairedDevices) { + wsService.subscribeToTunnelUpdates(device.id); } + + return () => { + wsService.unsubscribeTunnelUpdates(); + }; } - }, [isPaired, isReady]); + }, [isPaired, isReady, pairedDevices]); + + // Reconnect on app foreground when connection was lost + const appStateRef = useRef(AppState.currentState); + useEffect(() => { + const subscription = AppState.addEventListener('change', async (nextAppState) => { + if (appStateRef.current.match(/inactive|background/) && nextAppState === 'active') { + if (isPaired && isReady && pairedDevices.length > 0) { + // Retry reconnect up to 6 times (30 seconds total) + for (let attempt = 0; attempt < 6; attempt++) { + if (wsService.isConnected) { + setServerConnected(true); + break; + } + + for (const device of pairedDevices) { + const tunnelUrl = await wsService.fetchCurrentTunnelUrl(device.id); + if (tunnelUrl) { + let wsUrl = tunnelUrl; + if (wsUrl.startsWith('https://')) { + wsUrl = wsUrl.replace('https://', 'wss://'); + } else if (wsUrl.startsWith('http://')) { + wsUrl = wsUrl.replace('http://', 'ws://'); + } + const currentUrl = await pairingService.getRelayUrl(); + if (wsUrl !== currentUrl) { + await pairingService.setRelayUrl(wsUrl); + } + if (!wsService.isConnected) { + wsService.disconnect(); + wsService.connect(); + // Wait 5 seconds for connection result + await new Promise((resolve) => { + const timeout = setTimeout(() => resolve(), 5000); + const unsub = wsService.on('connected', () => { + clearTimeout(timeout); + unsub(); + resolve(); + }); + }); + // Force update connection state after attempt + setServerConnected(wsService.isConnected); + } + } + } + if (wsService.isConnected) break; + await new Promise(r => setTimeout(r, 5000)); + } + } + } + appStateRef.current = nextAppState; + }); + return () => subscription.remove(); + }, [isPaired, isReady, pairedDevices]); // Subscribe to approval events when WebSocket is connected useEffect(() => { @@ -313,9 +401,10 @@ export default function RootLayout() { diff --git a/app/device/pair.tsx b/app/device/pair.tsx index 98d5dd4..310a5b3 100644 --- a/app/device/pair.tsx +++ b/app/device/pair.tsx @@ -1,7 +1,7 @@ import { useState, useEffect, useRef } from 'react'; import { View, Text, TouchableOpacity, StyleSheet, TextInput, Switch } from 'react-native'; import { alert } from '@/components/ui/AlertModal'; -import { router } from 'expo-router'; +import { router, useLocalSearchParams } from 'expo-router'; import { SafeAreaView } from 'react-native-safe-area-context'; import { Camera, CameraView } from 'expo-camera'; import { X, CheckCircle, Flashlight, FlashlightOff, Keyboard, QrCode, ArrowLeft } from 'lucide-react-native'; @@ -38,7 +38,8 @@ type PairMethod = 'qr' | 'code'; export default function PairDeviceScreen() { const { theme } = useTheme(); const { pairDevice, isLoading } = useDeviceStore(); - const [method, setMethod] = useState('qr'); + const params = useLocalSearchParams<{ method?: string }>(); + const [method, setMethod] = useState(params.method === 'code' ? 'code' : 'qr'); const [manualCode, setManualCode] = useState(''); const [hasPermission, setHasPermission] = useState(null); const [scanned, setScanned] = useState(false); @@ -126,17 +127,47 @@ export default function PairDeviceScreen() { } await pairingService.setRelayUrl(relayUrl); } else { - // Cloud mode: no relay address → use default cloud connection - await pairingService.setRelayUrl(null); + // Try to find tunnel URL from Supabase via pairing code + const tunnelUrl = await wsService.fetchTunnelUrlByPairingCode(manualCode.toUpperCase()); + if (tunnelUrl) { + let wsUrl = tunnelUrl; + if (wsUrl.startsWith('https://')) { + wsUrl = wsUrl.replace('https://', 'wss://'); + } else if (wsUrl.startsWith('http://')) { + wsUrl = wsUrl.replace('http://', 'ws://'); + } + await pairingService.setRelayUrl(wsUrl); + } else { + // No tunnel found → use default cloud connection + await pairingService.setRelayUrl(null); + } } - await connectAndWait(); + // Disconnect any existing connection from auto-connect to prevent conflicts + wsService.disconnect(); + await wsService.connect(); + + if (!wsService.isConnected) { + // Wait up to 10s for connection + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + unsub(); + reject(new Error('Connection timed out')); + }, 10000); + const unsub = wsService.on('connected', () => { + clearTimeout(timeout); + unsub(); + resolve(); + }); + }); + } const device = await pairDevice(manualCode.toUpperCase()); setPairedDeviceName(device.name); setIsPaired(true); - } catch (error) { - alert.error('Pairing Failed', 'Could not connect. Check the pairing code and try again.'); + } catch (error: any) { + const msg = error?.message || 'Could not connect. Check the pairing code and try again.'; + alert.error('Pairing Failed', msg); } }; diff --git a/docs/RECONNECTION.md b/docs/RECONNECTION.md new file mode 100644 index 0000000..1235667 --- /dev/null +++ b/docs/RECONNECTION.md @@ -0,0 +1,372 @@ +# ForkOff Reconnection Mechanism Design + +## Background + +ForkOff is a React Native app that controls Claude Code CLI via cloudflare tunnels. The mobile app communicates with the CLI through: + +``` +Mobile App ↔ Socket.IO ↔ Cloudflare Tunnel ↔ CLI ↔ Claude Code +``` + +The tunnel URL is ephemeral — it changes every time cloudflared restarts. This document describes the reconnection mechanism that handles various disconnect scenarios. + +--- + +## Architecture Overview + +### Components + +| Component | File | Role | +|-----------|------|------| +| WebSocket Service | `forkoff-react-native/services/websocket.service.ts` | Socket.IO client, reconnection logic, tunnel URL polling | +| Root Layout | `forkoff-react-native/app/_layout.tsx` | AppState monitoring, foreground reconnection, cold start | +| Connection Store | `forkoff-react-native/stores/connection.store.ts` | Zustand store tracking `isServerConnected` state | +| Tunnel Notifier | `forkoff-cli/src/tunnel-notifier.ts` | Writes tunnel URL to Supabase `tunnel_sessions` table | +| CLI Index | `forkoff-cli/src/index.ts` | Session TTL, graceful vs network disconnect handling | +| Pairing Service | `forkoff-react-native/services/pairing.service.ts` | Stores relay URL in SecureStore | + +### Key State + +- `tunnel_sessions` Supabase table: stores `device_id`, `tunnel_url`, `pairing_code`, `expires_at` +- `pairingService.relayUrl` (SecureStore): the ws/wss URL the mobile is currently using +- `wsService.lastKnownTunnelUrl`: last tunnel URL seen from Supabase polling +- `wsService._lastReconnectAttemptAt`: timestamp of last reconnect attempt (cooldown guard) + +--- + +## Disconnect Scenarios + +### Scenario 1: Tunnel Restart (cloudflared killed) + +**Sequence:** +1. cloudflared process dies → tunnel URL becomes invalid +2. CLI detects tunnel failure → restarts cloudflared → gets new URL +3. `TunnelNotifier.notifyTunnelUrl()` writes new URL to Supabase +4. Mobile `pollTunnelUrl` (every 10s) detects URL change → `handleTunnelUrlChange()` → `disconnect()` + `connect()` + +**Recovery time:** ~10-30 seconds (tunnel restart ~10s + poll interval 10s) + +### Scenario 2: App Backgrounded / Foregrounded + +**Sequence:** +1. User switches app to background +2. iOS/Android suspends timers — `pollTunnelUrl` may pause +3. User switches back to app → AppState fires `inactive|background → active` +4. `_layout.tsx` foreground handler runs reconnect loop: + - Fetch current tunnel URL from Supabase + - If URL changed → update SecureStore relay URL + - `wsService.disconnect()` + `wsService.connect()` + - Wait up to 5 seconds for connection result + - If failed → retry every 5 seconds, up to 6 attempts (30 seconds total) +5. Direct `setServerConnected(wsService.isConnected)` updates UI immediately + +**Recovery time:** 0-30 seconds depending on network availability + +### Scenario 3: Network Interruption (WiFi off/on) + +**Sequence:** +1. WiFi disconnects → Socket.IO detects connection loss +2. Socket.IO auto-reconnect kicks in (up to 10 attempts, 2s-30s backoff) +3. If Socket.IO gives up → `reconnect_failed` event → `isConnecting = false` +4. `pollTunnelUrl` detects disconnected state → triggers reconnect (with 30s cooldown) +5. When WiFi returns → next poll or Socket.IO retry connects + +**Recovery time:** 2-60 seconds + +### Scenario 4: App Killed + Relaunch (Cold Start) + +**Sequence:** +1. App killed → all in-memory state lost +2. App relaunch → `_layout.tsx` `initializeApp()` runs +3. `useIdentityStore.initialize()` loads device ID + paired devices from SecureStore +4. If `isPaired && isReady` → fetch tunnel URL from Supabase → `wsService.connect()` +5. E2EE re-establishes via TOFU (key exchange on `mobile_connected`) + +**Recovery time:** 3-10 seconds + +### Scenario 5: Long-Term Disconnect (30+ minutes background) + +**Sequence:** +1. App backgrounded for 30+ minutes +2. CLI-side: session TTL (30 minutes) may expire → taken-over sessions released +3. Mobile foregrounded → same as Scenario 2 (foreground handler) +4. Sessions need re-take-over, but connection re-establishes normally + +**Recovery time:** 0-30 seconds for connection; sessions need re-take-over if TTL expired + +--- + +## Reconnection Mechanisms (Layered) + +Three independent mechanisms provide reconnection coverage: + +### Layer 1: Socket.IO Auto-Reconnect + +```typescript +// websocket.service.ts - connect() +this.socket = io(socketUrl, { + transports: ['websocket'], + reconnection: true, + reconnectionAttempts: 10, // Up to 10 retries + reconnectionDelay: 2000, // Start at 2 seconds + reconnectionDelayMax: 30000, // Max 30 seconds between retries + randomizationFactor: 0.5, // ±50% jitter to avoid thundering herd +}); +``` + +Handles: transient network glitches, brief WiFi drops. Socket.IO tries up to 10 times with exponential backoff (2s → 4s → 8s → ... → 30s max). + +On `reconnect_failed`: resets `isConnecting` flag so other layers can take over. + +### Layer 2: Tunnel URL Polling (`pollTunnelUrl`) + +```typescript +// websocket.service.ts - subscribeToTunnelUpdates() +this.tunnelPollTimer = setInterval(() => { + this.pollTunnelUrl(deviceId); +}, 10000); // Every 10 seconds +``` + +Polls Supabase `tunnel_sessions` table every 10 seconds. **Only handles two cases:** + +1. **URL changed** → always reconnect immediately via `handleTunnelUrlChange()` +2. **Socket.IO gave up** (`_socketIoGaveUp = true`) → fallback reconnect to same URL + +```typescript +private async pollTunnelUrl(deviceId: string): Promise { + const tunnelUrl = await this.fetchCurrentTunnelUrl(deviceId); + if (!tunnelUrl) return; + + if (this.lastKnownTunnelUrl && tunnelUrl !== this.lastKnownTunnelUrl) { + // URL changed — always reconnect immediately + await this.handleTunnelUrlChange(tunnelUrl); + } else if (!this.socket?.connected && this._socketIoGaveUp && this.lastKnownTunnelUrl) { + // Socket.IO gave up — poll takes over as fallback + this._socketIoGaveUp = false; + this.disconnect(); + this.connect(); + } + this.lastKnownTunnelUrl = tunnelUrl; +} +``` + +**Critical design decision:** `pollTunnelUrl` does NOT do same-URL reconnect while Socket.IO is still trying. This prevents the connect/disconnect loop where poll kills in-progress Socket.IO connections. Socket.IO handles same-URL reconnects; poll only takes over after Socket.IO gives up (`reconnect_failed` event sets `_socketIoGaveUp = true`). + +### Layer 3: AppState Foreground Handler (`_layout.tsx`) + +```typescript +// _layout.tsx - AppState.addEventListener('change', ...) +if (appStateRef.current.match(/inactive|background/) && nextAppState === 'active') { + if (isPaired && isReady && pairedDevices.length > 0) { + for (let attempt = 0; attempt < 6; attempt++) { + if (wsService.isConnected) { + setServerConnected(true); + break; + } + for (const device of pairedDevices) { + const tunnelUrl = await wsService.fetchCurrentTunnelUrl(device.id); + if (tunnelUrl) { + // URL conversion and SecureStore update + wsService.disconnect(); + wsService.connect(); + // Wait up to 5s for connection + await waitForConnection(5000); + setServerConnected(wsService.isConnected); + } + } + if (wsService.isConnected) break; + await new Promise(r => setTimeout(r, 5000)); + } + } +} +``` + +Handles: app returning from background. Fetches latest tunnel URL from Supabase, reconnects with 6 retries at 5-second intervals (30 seconds total). Directly updates `connectionStore.isServerConnected` for immediate UI feedback. + +### Layer 4: Cold Start (`_layout.tsx`) + +```typescript +// _layout.tsx - useEffect([isPaired, isReady, pairedDevices]) +if (isPaired) { + if (pairedDevices.length > 0) { + for (const device of pairedDevices) { + const tunnelUrl = await wsService.fetchCurrentTunnelUrl(device.id); + if (tunnelUrl) { + await pairingService.setRelayUrl(wsUrl); + } + } + } + wsService.connect(); + notificationService.registerForPushNotifications(); +} +``` + +Handles: fresh app launch. Loads identity from SecureStore, fetches tunnel URL from Supabase, connects. + +--- + +## CLI-Side Session Preservation + +### Session TTL (30 minutes) + +When mobile disconnects due to network interruption (not graceful close), the CLI preserves taken-over sessions for 30 minutes: + +```typescript +// index.ts - wsClient.on('disconnected') +const isGraceful = reason === 'client namespace disconnect'; +if (isGraceful) { + claudeProcessManager.cleanupAllPermissionState(); + claudeProcessManager.clearAllTakenOver(); +} else { + // Network interruption — keep sessions for 30 min + claudeProcessManager.startSessionTTL(30 * 60 * 1000); +} +``` + +When mobile reconnects, the CLI cancels the TTL: + +```typescript +// index.ts - wsClient.on('connected') +claudeProcessManager.cancelSessionTTL(); +``` + +### Pairing Code Preservation + +On tunnel restart, `TunnelNotifier` preserves the existing pairing code: + +```typescript +// tunnel-notifier.ts - notifyTunnelUrl() +const upsertData: any = { + device_id: deviceId, + tunnel_url: normalizedUrl, + provider: 'cloudflared', + expires_at: new Date(Date.now() + 4 * 60 * 60 * 1000).toISOString(), +}; +// Only set pairing_code when provided — don't overwrite existing code +if (pairingCode) { + upsertData.pairing_code = pairingCode; +} +``` + +--- + +## UI State Management + +### Connection Store + +`connection.store.ts` tracks `isServerConnected`: + +```typescript +// Updated by: +// 1. wsService.on('connected') → isServerConnected = true +// 2. wsService.on('disconnected') → isServerConnected = false +// 3. _layout.tsx foreground handler → setServerConnected(wsService.isConnected) +``` + +### OfflineBanner + +Shows "Connecting to server..." when `isPaired && !isServerConnected`. + +The Socket.IO `connect` event handler calls `this.emitInternal('connected')`, which triggers the connection store to set `isServerConnected = true`. No manual `emitInternal('connected')` calls are needed elsewhere. + +--- + +## What Was Fixed (Changelog) + +### Fix 1: Connect/Disconnect Loop (Critical) + +**Problem:** `pollTunnelUrl` ran every 10 seconds and called `disconnect() + connect()` when socket was disconnected. This killed Socket.IO's in-progress auto-reconnect attempts, creating an infinite connect/disconnect loop (connect → poll fires → disconnect → connect → poll fires → ...). + +**Solution:** Separated responsibilities clearly: +- Socket.IO `reconnection` handles same-URL reconnects (network glitches) +- `pollTunnelUrl` only handles **URL changes** (tunnel restarts) +- `pollTunnelUrl` only does same-URL reconnect when `_socketIoGaveUp = true` (Socket.IO exhausted all 10 retries) + +Added `_socketIoGaveUp` flag: set by `reconnect_failed` event, cleared on successful `connect`. + +**File:** `forkoff-react-native/services/websocket.service.ts` + +### Fix 2: Manual emitInternal('connected') Hack Removed + +**Problem:** Previous fix added `await new Promise(r => setTimeout(r, 3000)); this.emitInternal('connected')` after reconnect in `pollTunnelUrl`. This was a race-prone hack that could fire before connection was actually established, or fire duplicate events. + +**Solution:** Removed the hack entirely. The Socket.IO `connect` event handler already calls `this.emitInternal('connected')` reliably. + +**File:** `forkoff-react-native/services/websocket.service.ts` + +### Fix 3: Socket.IO Reconnect Configuration + +**Problem:** Original config had `maxReconnectAttempts: Infinity` conflicting with manual reconnect logic, later changed to 3 which was too few for mobile networks. + +**Solution:** Set to 10 attempts with proper backoff: +- `reconnectionAttempts: 10` +- `reconnectionDelay: 2000` (start at 2s) +- `reconnectionDelayMax: 30000` (max 30s) +- `randomizationFactor: 0.5` (jitter) + +**File:** `forkoff-react-native/services/websocket.service.ts` + +### Fix 4: Foreground Reconnection with Retry + +**Problem:** Original AppState handler only tried reconnecting once. If network wasn't ready yet, the app stayed disconnected. + +**Solution:** Added 6-retry loop with 5-second intervals (30 seconds total), fetching fresh tunnel URL each time and directly updating connection store state. + +**File:** `forkoff-react-native/app/_layout.tsx` + +### Fix 5: Session TTL Extended to 30 Minutes + +**Problem:** Original 5-minute TTL was too short — users who put their phone away for 10-15 minutes would lose all taken-over sessions. + +**Solution:** Extended to 30 minutes, covering most "phone in pocket" scenarios. + +**File:** `forkoff-cli/src/index.ts` + +### Fix 6: Pairing Code Flow + +**Problem:** The onboarding `add-device.tsx` page required both relay address and pairing code, confusing users who just had a code. + +**Solution:** "Enter Code" button now redirects to `device/pair.tsx?method=code`, which has the proper pairing flow with only a code field. + +**File:** `forkoff-react-native/app/(onboarding)/add-device.tsx`, `forkoff-react-native/app/device/pair.tsx` + +### Fix 7: Pairing Code Preservation on Tunnel Restart + +**Problem:** `TunnelNotifier.notifyTunnelUrl()` always set `pairing_code` in the upsert, overwriting it with `null` on tunnel restart (no code provided on restart). + +**Solution:** Only include `pairing_code` in upsert data when explicitly provided. + +**File:** `forkoff-cli/src/tunnel-notifier.ts` + +### Fix 8: Dead Code Removed + +**Removed:** `checkTunnelUrlOnDisconnect()` method — caused race conditions with `pollTunnelUrl` (both tried to handle reconnection simultaneously). All reconnect handling now goes through `pollTunnelUrl` + AppState foreground handler. + +**Removed:** `_tunnelCheckInProgress` flag (only used by deleted method). + +**File:** `forkoff-react-native/services/websocket.service.ts` + +### Fix 9: Environment Variable Configuration + +**Problem:** `tunnel-notifier.ts` had hardcoded Supabase credentials. + +**Solution:** Changed to use environment variables with fallback chain: +```typescript +const supabaseUrl = process.env.FORKOFF_SUPABASE_URL || process.env.SUPABASE_URL || ''; +const supabaseAnonKey = process.env.FORKOFF_SUPABASE_ANON_KEY || process.env.SUPABASE_ANON_KEY || ''; +``` + +**File:** `forkoff-cli/src/tunnel-notifier.ts` + +--- + +## Testing Checklist + +1. **Normal pairing** — QR code scan, confirm E2EE established, sessions usable +2. **Manual pairing code** — Enter code on `device/pair.tsx?method=code`, confirm connection +3. **App background/foreground** — Switch to background 20+ seconds, return → auto-reconnect +4. **WiFi off/on** — Disable WiFi 10+ seconds, re-enable → auto-reconnect +5. **Tunnel restart** — Kill cloudflared process → CLI restarts tunnel → mobile reconnects to new URL within 30s +6. **App kill + relaunch** — Force close app, reopen → cold start connects, sessions preserved (within 30 min TTL) +7. **Long-term background (30+ min)** — Background app for 30+ minutes → foreground → connection re-establishes, sessions may need re-take-over +8. **UI state** — After any reconnect scenario, OfflineBanner should not show "Connecting to server..." diff --git a/package-lock.json b/package-lock.json index d4a18cd..848f40a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,13 @@ { "name": "forkoff", - "version": "1.0.0", + "version": "1.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "forkoff", - "version": "1.0.0", + "version": "1.1.0", + "license": "MIT", "dependencies": { "@noble/hashes": "^1.8.0", "@react-native-async-storage/async-storage": "^2.2.0", diff --git a/services/appConfig.service.ts b/services/appConfig.service.ts index 746325e..ea6568d 100644 --- a/services/appConfig.service.ts +++ b/services/appConfig.service.ts @@ -1,4 +1,4 @@ -import { createClient } from '@supabase/supabase-js'; +import { createClient, SupabaseClient } from '@supabase/supabase-js'; import { sentryService } from './sentry.service'; const supabaseUrl = process.env.EXPO_PUBLIC_SUPABASE_URL || ''; @@ -6,6 +6,9 @@ const supabaseAnonKey = process.env.EXPO_PUBLIC_SUPABASE_ANON_KEY || ''; const supabase = createClient(supabaseUrl, supabaseAnonKey); +// Export the client for other services to use (e.g., tunnel subscription) +export const getSupabaseClient = (): SupabaseClient => supabase; + export interface AppVersionConfig { minVersion: string; forceUpdate: boolean; diff --git a/services/websocket.service.ts b/services/websocket.service.ts index 4791eca..5b895bd 100644 --- a/services/websocket.service.ts +++ b/services/websocket.service.ts @@ -7,6 +7,8 @@ import { DeviceStatus, ServerStatus, ApprovalRequest, CodeChange, ClaudeSession, import { KeyExchangeInit, KeyExchangeAck, EncryptedMessage } from '@/services/crypto/types'; import { E2EEManager } from '@/services/crypto/e2eeManager'; import { useE2EEStore } from '@/stores/e2ee.store'; +import { RealtimeChannel } from '@supabase/supabase-js'; +import { getSupabaseClient } from './appConfig.service'; // SECURITY: Determine if we're in development mode const IS_DEV_BUILD = typeof __DEV__ !== 'undefined' ? __DEV__ : process.env.NODE_ENV === 'development'; @@ -467,8 +469,8 @@ class WebSocketService { private socket: Socket | null = null; private isConnecting = false; private reconnectAttempts = 0; - private maxReconnectAttempts = Infinity; - private reconnectDelay = 1000; + private maxReconnectAttempts = 10; + private reconnectDelay = 2000; // Track subscriptions so we can re-subscribe after reconnect private subscribedDevices = new Set(); // Critical message queue — buffers messages when disconnected, flushes on reconnect @@ -484,6 +486,12 @@ class WebSocketService { private _anyE2EESessionEstablished = false; // Tracks devices with key exchange in progress (async guard against duplicate inits) private _pendingKeyExchangeInits = new Set(); + // Tunnel URL subscription for auto-reconnect when tunnel restarts + private tunnelSubscription: RealtimeChannel | null = null; + private tunnelPollTimer: ReturnType | null = null; + private lastKnownTunnelUrl: string | null = null; + private connectedRelayUrl: string | null = null; // URL we're currently connected to + private _socketIoGaveUp = false; // set true when Socket.IO reconnect_failed fires private callbacks: EventCallbacks = { device_status: [], terminal_output: [], @@ -560,7 +568,16 @@ class WebSocketService { // Load relay token for cloud relay authentication (if available) const relayToken = await pairingService.getRelayToken(); - this.socket = io(WS_URL, { + // Socket.IO client expects http:// URL (handles WebSocket upgrade internally) + // Convert ws:// → http:// and wss:// → https:// + let socketUrl = WS_URL; + if (socketUrl.startsWith('ws://')) { + socketUrl = socketUrl.replace('ws://', 'http://'); + } else if (socketUrl.startsWith('wss://')) { + socketUrl = socketUrl.replace('wss://', 'https://'); + } + + this.socket = io(socketUrl, { auth: { mobileDeviceId, clientType: 'mobile', @@ -571,7 +588,7 @@ class WebSocketService { reconnection: true, reconnectionAttempts: this.maxReconnectAttempts, reconnectionDelay: this.reconnectDelay, - reconnectionDelayMax: 15000, + reconnectionDelayMax: 30000, randomizationFactor: 0.5, }); @@ -591,6 +608,9 @@ class WebSocketService { const reconnectAttempts = this.reconnectAttempts; this.isConnecting = false; this.reconnectAttempts = 0; + this._socketIoGaveUp = false; + // Track the URL we successfully connected to + this.connectedRelayUrl = WS_URL; console.log('[WS] Mobile app connected to WebSocket'); console.log('[WS] Socket ID:', this.socket?.id); @@ -625,7 +645,14 @@ class WebSocketService { // Clear pending key exchange locks so reconnect can re-initiate this._pendingKeyExchangeInits.clear(); + // Clear E2EE state on disconnect to prevent stale key decryption errors on reconnect + this._anyE2EESessionEstablished = false; + this.e2eeManager?.clearAllSessions(); + this.emitInternal('disconnected'); + + // Note: pollTunnelUrl (via subscribeToTunnelUpdates) handles reconnect when URL changes + // _layout.tsx AppState handler handles reconnect when app comes to foreground }); this.socket.on('connect_error', (error) => { @@ -641,6 +668,12 @@ class WebSocketService { this.emitInternal('error', error); }); + this.socket.on('reconnect_failed', () => { + console.log('[WS] Socket.IO gave up reconnecting — pollTunnelUrl will take over'); + this.isConnecting = false; + this._socketIoGaveUp = true; + }); + // Application events this.socket.on('device_status', (data) => { this.emitInternal('device_status', data); @@ -1354,6 +1387,7 @@ class WebSocketService { } this.isConnecting = false; this.reconnectAttempts = 0; + this._socketIoGaveUp = false; this.pendingMessages = []; this._anyE2EESessionEstablished = false; this.e2eeManager?.clearAllSessions(); @@ -1372,6 +1406,114 @@ class WebSocketService { get isConnected(): boolean { return this.socket?.connected ?? false; } + + /** Subscribe to tunnel URL changes via polling (more reliable than Realtime on mobile) */ + subscribeToTunnelUpdates(deviceId: string): void { + this.unsubscribeTunnelUpdates(); + + // Initial fetch + this.pollTunnelUrl(deviceId); + + // Poll every 10 seconds + this.tunnelPollTimer = setInterval(() => { + this.pollTunnelUrl(deviceId); + }, 10000); + } + + /** Unsubscribe from tunnel URL changes */ + unsubscribeTunnelUpdates(): void { + if (this.tunnelPollTimer) { + clearInterval(this.tunnelPollTimer); + this.tunnelPollTimer = null; + } + if (this.tunnelSubscription) { + try { + this.tunnelSubscription.unsubscribe(); + } catch {} + this.tunnelSubscription = null; + } + } + + /** Poll Supabase for current tunnel URL */ + private async pollTunnelUrl(deviceId: string): Promise { + try { + const tunnelUrl = await this.fetchCurrentTunnelUrl(deviceId); + const socketConnected = this.socket?.connected ?? false; + console.log(`[WS] pollTunnelUrl: devId=${deviceId.substring(0, 8)}..., url=${tunnelUrl?.substring(0, 40) ?? 'null'}, lastKnown=${this.lastKnownTunnelUrl?.substring(0, 40) ?? 'null'}, connected=${socketConnected}, gaveUp=${this._socketIoGaveUp}`); + + if (!tunnelUrl) { + console.log('[WS] pollTunnelUrl: no tunnel URL found, skipping'); + return; + } + + if (this.lastKnownTunnelUrl && tunnelUrl !== this.lastKnownTunnelUrl) { + // URL changed (tunnel restarted) — always reconnect immediately + console.log('[WS] Tunnel URL changed, reconnecting...'); + await this.handleTunnelUrlChange(tunnelUrl); + } else if (!socketConnected && this._socketIoGaveUp && this.lastKnownTunnelUrl) { + // Socket.IO gave up auto-reconnect — poll takes over as fallback + console.log('[WS] Socket.IO gave up, pollTunnelUrl reconnecting to same tunnel'); + this._socketIoGaveUp = false; + this.disconnect(); + this.connect(); + } + this.lastKnownTunnelUrl = tunnelUrl; + } catch (err: any) { + console.warn('[WS] Tunnel poll error:', err.message); + } + } + + /** Handle tunnel URL change — reconnect WebSocket to new URL */ + private async handleTunnelUrlChange(tunnelUrl: string): Promise { + // Convert https:// to wss:// for WebSocket + let wsUrl = tunnelUrl; + if (wsUrl.startsWith('https://')) { + wsUrl = wsUrl.replace('https://', 'wss://'); + } else if (wsUrl.startsWith('http://')) { + wsUrl = wsUrl.replace('http://', 'ws://'); + } + + // Store the new relay URL + await pairingService.setRelayUrl(wsUrl); + + // Reconnect with new URL + this.disconnect(); + this.connect(); + } + + /** Fetch current tunnel URL from Supabase (for cold start) */ + async fetchCurrentTunnelUrl(deviceId: string): Promise { + try { + const supabase = getSupabaseClient(); + const { data, error } = await supabase + .from('tunnel_sessions') + .select('tunnel_url') + .eq('device_id', deviceId) + .single(); + + if (error || !data?.tunnel_url) return null; + return data.tunnel_url; + } catch { + return null; + } + } + + /** Fetch tunnel URL by pairing code (for manual pairing without QR) */ + async fetchTunnelUrlByPairingCode(pairingCode: string): Promise { + try { + const supabase = getSupabaseClient(); + const { data, error } = await supabase + .from('tunnel_sessions') + .select('tunnel_url') + .eq('pairing_code', pairingCode.toUpperCase()) + .single(); + + if (error || !data?.tunnel_url) return null; + return data.tunnel_url; + } catch { + return null; + } + } } export const wsService = new WebSocketService();