diff --git a/App.tsx b/App.tsx index 844089f45..1192d3dc1 100644 --- a/App.tsx +++ b/App.tsx @@ -23,6 +23,7 @@ import { hydrateDownloadStore } from './src/services/downloadHydration'; import { initActiveDownloadPersistence } from './src/services/activeDownloadPersistence'; import { restoreQueuedDownloads } from './src/services/restoreQueuedDownloads'; import { startLoadPolicySync } from './src/services/loadPolicySync'; +import { startNetworkReconnectWatcher, stopNetworkReconnectWatcher } from './src/services/networkReconnect'; import { registerCoreDownloadProviders } from './src/services/modelDownloadService/registerProviders'; import { useDownloadListeners } from './src/hooks/useDownloads'; import { KeyboardProvider } from 'react-native-keyboard-controller'; @@ -262,6 +263,9 @@ function App() { logger.error('[App] Failed to initialize remote server providers:', err); }); + // Watch for network changes and auto-recover the active remote connection (no manual rescan). + startNetworkReconnectWatcher(); + // Check if passphrase is set and lock app if needed logger.log('[BOOT] auth passphrase check'); const hasPassphrase = await authService.hasPassphrase(); @@ -316,6 +320,7 @@ function App() { useEffect(() => { initializeApp(); + return () => stopNetworkReconnectWatcher(); }, [initializeApp]); const handleUnlock = useCallback(() => { diff --git a/src/screens/HomeScreen/hooks/useLANDiscovery.ts b/src/screens/HomeScreen/hooks/useLANDiscovery.ts index 93839fce4..63deb370f 100644 --- a/src/screens/HomeScreen/hooks/useLANDiscovery.ts +++ b/src/screens/HomeScreen/hooks/useLANDiscovery.ts @@ -1,38 +1,17 @@ import { useCallback } from 'react'; import { showAlert, hideAlert } from '../../../components'; -import { useRemoteServerStore } from '../../../stores/remoteServerStore'; import { remoteServerManager } from '../../../services'; import { discoverLANServers } from '../../../services/networkDiscovery'; import { useAppStore } from '../../../stores/appStore'; import { shouldAutoDiscoverRemoteModels } from '../../../utils/remoteAutoDiscovery'; import type { HomeScreenNavigationProp } from './types'; -import type { RemoteServer } from '../../../types'; import logger from '../../../utils/logger'; -const getPort = (endpoint: string): string | null => { - try { return new URL(endpoint).port; } catch { return null; } -}; - interface LANDiscoveryParams { navigation: HomeScreenNavigationProp; setAlertState: (state: any) => void; } -async function updateMovedServer( - samePortServer: RemoteServer, - d: { endpoint: string; name: string }, - store: ReturnType, -): Promise { - logger.log('[HomeScreen] Server moved to new IP, updating:', samePortServer.name, '->', d.endpoint); - await remoteServerManager.updateServer(samePortServer.id, { endpoint: d.endpoint, name: d.name }); - try { await store.discoverModels(samePortServer.id); } catch { /* offline */ } - if (store.activeServerId === samePortServer.id && store.activeRemoteTextModelId) { - try { - await remoteServerManager.setActiveRemoteTextModel(samePortServer.id, store.activeRemoteTextModelId); - } catch { /* user can re-select */ } - } -} - export function useLANDiscovery({ navigation, setAlertState }: LANDiscoveryParams) { const addNewServersAndNotify = useCallback(async ( newServersToAdd: Awaited> @@ -77,37 +56,10 @@ export function useLANDiscovery({ navigation, setAlertState }: LANDiscoveryParam return; } logger.log('[HomeScreen] LAN auto-discovery enabled — scanning'); - let discovered: Awaited>; - try { - discovered = await discoverLANServers(); - } catch (error) { - logger.warn('[HomeScreen] LAN discovery skipped:', (error as Error).message); - return; - } - if (discovered.length === 0) return; - - const store = useRemoteServerStore.getState(); - const existingServers = store.servers; - const existingEndpoints = new Set(existingServers.map(s => s.endpoint.replace(/\/$/, ''))); - - const newServersToAdd: typeof discovered = []; - - for (const d of discovered) { - if (existingEndpoints.has(d.endpoint.replace(/\/$/, ''))) continue; - - const dPort = getPort(d.endpoint); - const samePortServer = dPort - ? existingServers.find(s => getPort(s.endpoint) === dPort) - : null; - - if (samePortServer) { - await updateMovedServer(samePortServer, d, store); - } else { - newServersToAdd.push(d); - } - } - - await addNewServersAndNotify(newServersToAdd); + // remoteServerManager owns the scan + moved-server reconciliation (one source of truth); the + // hook only surfaces the genuinely-new servers it finds. + const { found } = await remoteServerManager.scanAndReconcile(); + await addNewServersAndNotify(found); }, [addNewServersAndNotify]); return { runLANDiscovery }; diff --git a/src/services/networkReconnect.ts b/src/services/networkReconnect.ts new file mode 100644 index 000000000..4083334b5 --- /dev/null +++ b/src/services/networkReconnect.ts @@ -0,0 +1,104 @@ +/** + * Network Reconnect Watcher + * + * Fixes the "connected at the office, changed WiFi, must manually rescan" drop. The remote-model + * (HTTP gateway) path has no liveness check: after the desktop's LAN IP moves or the phone changes + * network, the saved endpoint is stale but the app still believes it is connected and only fails on + * the next message. This watcher detects a network change and asks the service to recover. + * + * Native-dep-free by design: it reuses `getIpAddress()` (already a dependency via + * react-native-device-info) and AppState, rather than adding @react-native-community/netinfo (a new + * native module + rebuild). The device's own IP changing is a reliable proxy for "the network + * changed"; the actual re-discover / re-select decision lives in `remoteServerManager`, not here. + */ + +import { AppState, AppStateStatus } from 'react-native'; +import { getIpAddress } from 'react-native-device-info'; +import { remoteServerManager } from './remoteServerManager'; +import logger from '../utils/logger'; + +/** How often to poll the device IP while the app is foregrounded. */ +const IP_POLL_MS = 15_000; +/** Wait for a WiFi handoff to settle before acting, and coalesce rapid changes into one recovery. */ +const DEBOUNCE_MS = 2_500; + +let appStateSub: { remove: () => void } | null = null; +let pollTimer: ReturnType | null = null; +let debounceTimer: ReturnType | null = null; +let lastIp: string | null = null; +let started = false; + +function isUsableIp(ip: string | null | undefined): ip is string { + return !!ip && ip !== '0.0.0.0'; +} + +function scheduleRecovery(reason: string): void { + if (debounceTimer) clearTimeout(debounceTimer); + debounceTimer = setTimeout(() => { + debounceTimer = null; + logger.log(`[NetReconnect] recovering after ${reason}`); + remoteServerManager + .recoverActiveConnection() + .catch((err) => logger.warn('[NetReconnect] recovery failed:', (err as Error).message)); + }, DEBOUNCE_MS); +} + +async function checkIpChanged(): Promise { + let ip: string; + try { + ip = await getIpAddress(); + } catch { + return; + } + if (!isUsableIp(ip)) return; + if (isUsableIp(lastIp) && ip !== lastIp) { + logger.log(`[NetReconnect] device IP changed ${lastIp} -> ${ip}`); + scheduleRecovery('network change'); + } + lastIp = ip; +} + +function startPoll(): void { + if (pollTimer) return; + pollTimer = setInterval(() => { checkIpChanged().catch(() => { /* checkIpChanged never rejects */ }); }, IP_POLL_MS); +} + +function stopPoll(): void { + if (pollTimer) { + clearInterval(pollTimer); + pollTimer = null; + } +} + +function handleAppState(state: AppStateStatus): void { + if (state === 'active') { + // The network may have changed while backgrounded; compare current IP to the last one we saw. + checkIpChanged().catch(() => { /* checkIpChanged never rejects */ }); + startPoll(); + } else { + stopPoll(); + } +} + +/** Start watching for network changes. Idempotent — safe to call once at app boot. */ +export function startNetworkReconnectWatcher(): void { + if (started) return; + started = true; + // Seed the baseline IP without triggering a recovery on first launch. + getIpAddress().then((ip) => { if (isUsableIp(ip)) lastIp = ip; }).catch(() => { /* no network yet */ }); + appStateSub = AppState.addEventListener('change', handleAppState); + if (AppState.currentState === 'active') startPoll(); + logger.log('[NetReconnect] watcher started'); +} + +/** Stop watching. Used on teardown; the watcher is otherwise app-lifetime. */ +export function stopNetworkReconnectWatcher(): void { + appStateSub?.remove(); + appStateSub = null; + stopPoll(); + if (debounceTimer) { + clearTimeout(debounceTimer); + debounceTimer = null; + } + started = false; +} diff --git a/src/services/remoteServerManager.ts b/src/services/remoteServerManager.ts index 0369ee603..ab8228449 100644 --- a/src/services/remoteServerManager.ts +++ b/src/services/remoteServerManager.ts @@ -9,8 +9,11 @@ import { RemoteServer, RemoteModel, ServerTestResult } from '../types'; import { useRemoteServerStore } from '../stores/remoteServerStore'; +import { useAppStore } from '../stores/appStore'; import { OpenAICompatibleProvider } from './providers/openAICompatibleProvider'; import { providerRegistry } from './providers/registry'; +import { discoverLANServers, DiscoveredServer } from './networkDiscovery'; +import { shouldAutoDiscoverRemoteModels } from '../utils/remoteAutoDiscovery'; import logger from '../utils/logger'; import { storeApiKeyImpl, @@ -22,6 +25,18 @@ import { initializeProvidersImpl, } from './remoteServerManagerUtils'; +/** Normalize an endpoint for identity comparison (lowercase, no trailing slashes). */ +const trimSlash = (url: string): string => { + let s = url.toLowerCase(); + while (s.endsWith('/')) s = s.slice(0, -1); + return s; +}; + +/** Extract the port from an endpoint, or null if it cannot be parsed. */ +const portOf = (endpoint: string): string | null => { + try { return new URL(endpoint).port; } catch { return null; } +}; + class RemoteServerManager { /** * Add a new remote server @@ -192,6 +207,88 @@ class RemoteServerManager { return initializeProvidersImpl(() => this.getServers()); } + /** + * Scan the LAN and reconcile the result against saved servers. For each discovered endpoint that + * matches a saved server on the same port but a new IP, update it in place and re-select the active + * model. Returns the genuinely-new servers (not remaps) so a caller can surface them, plus the ids + * of servers that moved. Does NOT gate on settings — the caller decides whether a scan is allowed. + * This is the single owner of the "server moved to a new IP" reconciliation; UI callers delegate here. + */ + async scanAndReconcile(): Promise<{ moved: string[]; found: DiscoveredServer[] }> { + let discovered: DiscoveredServer[]; + try { + discovered = await discoverLANServers(); + } catch (error) { + logger.warn('[RemoteServerManager] LAN scan failed:', (error as Error).message); + return { moved: [], found: [] }; + } + if (discovered.length === 0) return { moved: [], found: [] }; + + const store = useRemoteServerStore.getState(); + const existingServers = store.servers; + const existingEndpoints = new Set(existingServers.map((s) => trimSlash(s.endpoint))); + const moved: string[] = []; + const found: DiscoveredServer[] = []; + + for (const d of discovered) { + if (existingEndpoints.has(trimSlash(d.endpoint))) continue; + + const dPort = portOf(d.endpoint); + const samePortServer = dPort + ? existingServers.find((s) => portOf(s.endpoint) === dPort) + : null; + + if (samePortServer) { + await this.applyMovedServer(samePortServer, d.endpoint, d.name); + moved.push(samePortServer.id); + } else { + found.push(d); + } + } + + return { moved, found }; + } + + /** Update a saved server that has moved to a new endpoint, and re-select it if it was active. */ + private async applyMovedServer(server: RemoteServer, endpoint: string, name: string): Promise { + logger.log('[RemoteServerManager] Server moved to new IP, updating:', server.name, '->', endpoint); + await this.updateServer(server.id, { endpoint, name }); + try { await this.discoverModels(server.id); } catch { /* offline — models repopulate on next reach */ } + const store = useRemoteServerStore.getState(); + if (store.activeServerId === server.id && store.activeRemoteTextModelId) { + try { + await this.setActiveRemoteTextModel(server.id, store.activeRemoteTextModelId); + } catch { /* user can re-select from the picker */ } + } + } + + /** + * Recover the active remote connection after a network change. Cheap-first: if there is an active + * server and it is still reachable at its known endpoint, do nothing. Only when it is unreachable + * (or the user has enabled auto-discovery) do we scan the LAN to find where it moved and reconnect. + * This keeps LAN scanning off unless the user is actually relying on a remote server, and makes the + * "connected" state honest again after the peer's IP changes. + */ + async recoverActiveConnection(): Promise { + const activeId = useRemoteServerStore.getState().activeServerId; + + if (activeId) { + const result = await this.testConnection(activeId).catch(() => ({ success: false })); + if (result.success) { + logger.log('[RemoteServerManager] Active server still reachable; no rescan needed'); + return; + } + logger.log('[RemoteServerManager] Active server unreachable; rescanning to recover'); + } + + const allowScan = + shouldAutoDiscoverRemoteModels(useAppStore.getState().settings) || !!activeId; + if (!allowScan) return; + + const { moved, found } = await this.scanAndReconcile(); + logger.log(`[RemoteServerManager] Recovery scan complete: ${moved.length} moved, ${found.length} new`); + } + /** * Clear all servers */