diff --git a/app/(tabs)/settings.tsx b/app/(tabs)/settings.tsx index 413e015..037d03e 100644 --- a/app/(tabs)/settings.tsx +++ b/app/(tabs)/settings.tsx @@ -21,6 +21,7 @@ import { useServer } from '@/context/ServerContext'; import { useTheme, ThemeColors } from '@/context/ThemeContext'; import { FocusAwareStatusBar } from '@/components/FocusAwareStatusBar'; import { APP_VERSION } from '@/utils/version'; +import { hasFallback } from '@/utils/server'; import { spacing, borderRadius } from '@/constants/spacing'; import { shadows } from '@/constants/shadows'; import { typography } from '@/constants/typography'; @@ -55,7 +56,7 @@ function NavRow({ icon, label, onPress, colors, isLast, iconColor }: NavRowProps export default function SettingsScreen() { const { t } = useTranslation(); const router = useRouter(); - const { currentServer, isConnected, disconnect } = useServer(); + const { currentServer, isConnected, activeEndpoint, disconnect } = useServer(); const { isDark, colors } = useTheme(); const disconnectBadgeBackground = colorThemeManager.hexToRgba( colorThemeManager.rgbaToHex(colors.error), @@ -114,6 +115,13 @@ export default function SettingsScreen() { {currentServer.host} {currentServer.port != null && currentServer.port > 0 ? `:${currentServer.port}` : ''} + {isConnected && hasFallback(currentServer) && activeEndpoint && ( + + {activeEndpoint === 'fallback' + ? t('server.connectedViaFallback') + : t('server.connectedViaPrimary')} + + )} {isConnected && ( (undefined); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); const [testing, setTesting] = useState(false); @@ -194,6 +199,13 @@ App Version: ${APP_VERSION}`; setBypassAuth(server.bypassAuth || false); // Preserve existing basePath for backward compatibility setPreservedBasePath(server.basePath || '/'); + // Round-trip fallback fields + setUseFallback(server.useFallback === true); + setFallbackHost(server.fallbackHost || ''); + const hasFallbackPort = server.fallbackPort != null && server.fallbackPort > 0; + setFallbackPort(hasFallbackPort ? server.fallbackPort!.toString() : ''); + setFallbackUseHttps(server.fallbackUseHttps || false); + setPreservedFallbackBasePath(server.fallbackBasePath); } else { showToast(t('toast.serverNotFound'), 'error'); router.back(); @@ -223,6 +235,18 @@ App Version: ${APP_VERSION}`; return; } + const fallbackPortNum = fallbackPort.trim() ? parseInt(fallbackPort, 10) : undefined; + if (useFallback) { + if (!fallbackHost.trim()) { + showToast(t('errors.fillFallbackHost'), 'error'); + return; + } + if (fallbackPortNum !== undefined && (isNaN(fallbackPortNum) || fallbackPortNum < 1 || fallbackPortNum > 65535)) { + showToast(t('errors.validPort'), 'error'); + return; + } + } + try { setSaving(true); @@ -241,6 +265,11 @@ App Version: ${APP_VERSION}`; password: bypassAuth ? '' : password.trim(), useHttps, bypassAuth, + useFallback, + fallbackHost: useFallback ? stripProtocol(fallbackHost.trim()) : '', + fallbackPort: useFallback ? fallbackPortNum : undefined, + fallbackUseHttps: useFallback ? fallbackUseHttps : false, + fallbackBasePath: preservedFallbackBasePath, // Preserve existing fallback base path }; await ServerManager.saveServer(server); @@ -293,6 +322,18 @@ App Version: ${APP_VERSION}`; return; } + const fallbackPortNum = fallbackPort.trim() ? parseInt(fallbackPort, 10) : undefined; + if (useFallback) { + if (!fallbackHost.trim()) { + showToast(t('errors.fillFallbackHost'), 'error'); + return; + } + if (fallbackPortNum !== undefined && (isNaN(fallbackPortNum) || fallbackPortNum < 1 || fallbackPortNum > 65535)) { + showToast(t('errors.validPort'), 'error'); + return; + } + } + try { setTesting(true); testAbortController.current = new AbortController(); @@ -306,11 +347,24 @@ App Version: ${APP_VERSION}`; password: bypassAuth ? '' : password.trim(), useHttps, bypassAuth, + useFallback, + fallbackHost: useFallback ? stripProtocol(fallbackHost.trim()) : '', + fallbackPort: useFallback ? fallbackPortNum : undefined, + fallbackUseHttps: useFallback ? fallbackUseHttps : false, }; const result = await ServerManager.testConnection(server, testAbortController.current.signal); - - if (result.success) { + + if (result.primary || result.fallback) { + const primaryOk = result.primary?.success; + const fallbackOk = result.fallback?.success; + const ok = (label: string) => t('server.testEndpointOk', { endpoint: label }); + const fail = (label: string) => t('server.testEndpointFail', { endpoint: label }); + const primaryLabel = t('server.endpointPrimary'); + const fallbackLabel = t('server.endpointFallback'); + const summary = `${primaryOk ? ok(primaryLabel) : fail(primaryLabel)} · ${fallbackOk ? ok(fallbackLabel) : fail(fallbackLabel)}`; + showToast(summary, result.success ? 'success' : 'error'); + } else if (result.success) { showToast(t('toast.connectionTestSuccess'), 'success'); } else { showToast(result.error || t('errors.connectionTestFailed'), 'error'); @@ -435,6 +489,71 @@ App Version: ${APP_VERSION}`; + {/* Fallback URL Section */} + + {t('server.fallbackUrl')} + + + + + + {t('server.useFallback')} + {t('server.useFallbackHint')} + + + + + {useFallback && ( + <> + + + + + + + + + + + + + + + {t('server.fallbackUseHttps')} + + + + + )} + + + {/* Authentication Section */} {!bypassAuth && ( diff --git a/app/server/add.tsx b/app/server/add.tsx index a550a65..20df362 100644 --- a/app/server/add.tsx +++ b/app/server/add.tsx @@ -48,6 +48,10 @@ export default function AddServerScreen() { const [password, setPassword] = useState(''); const [useHttps, setUseHttps] = useState(false); const [bypassAuth, setBypassAuth] = useState(false); + const [useFallback, setUseFallback] = useState(false); + const [fallbackHost, setFallbackHost] = useState(''); + const [fallbackPort, setFallbackPort] = useState(''); + const [fallbackUseHttps, setFallbackUseHttps] = useState(false); const [loading, setLoading] = useState(false); const [testing, setTesting] = useState(false); const [showHostTooltip, setShowHostTooltip] = useState(false); @@ -187,6 +191,18 @@ App Version: ${APP_VERSION}`; return; } + const fallbackPortNum = (fallbackPort.trim() ? parseInt(fallbackPort, 10) : undefined); + if (useFallback) { + if (!fallbackHost.trim()) { + showToast(t('errors.fillFallbackHost'), 'error'); + return; + } + if (fallbackPortNum !== undefined && (isNaN(fallbackPortNum) || fallbackPortNum < 1 || fallbackPortNum > 65535)) { + showToast(t('errors.validPort'), 'error'); + return; + } + } + try { setLoading(true); @@ -204,6 +220,10 @@ App Version: ${APP_VERSION}`; password: bypassAuth ? '' : password.trim(), useHttps, bypassAuth, + useFallback, + fallbackHost: useFallback ? stripProtocol(fallbackHost.trim()) : '', + fallbackPort: useFallback ? fallbackPortNum : undefined, + fallbackUseHttps: useFallback ? fallbackUseHttps : false, }; await ServerManager.saveServer(server); @@ -246,6 +266,18 @@ App Version: ${APP_VERSION}`; return; } + const fallbackPortNum = (fallbackPort.trim() ? parseInt(fallbackPort, 10) : undefined); + if (useFallback) { + if (!fallbackHost.trim()) { + showToast(t('errors.fillFallbackHost'), 'error'); + return; + } + if (fallbackPortNum !== undefined && (isNaN(fallbackPortNum) || fallbackPortNum < 1 || fallbackPortNum > 65535)) { + showToast(t('errors.validPort'), 'error'); + return; + } + } + try { setTesting(true); testAbortController.current = new AbortController(); @@ -259,11 +291,25 @@ App Version: ${APP_VERSION}`; password: bypassAuth ? '' : password.trim(), useHttps, bypassAuth, + useFallback, + fallbackHost: useFallback ? stripProtocol(fallbackHost.trim()) : '', + fallbackPort: useFallback ? fallbackPortNum : undefined, + fallbackUseHttps: useFallback ? fallbackUseHttps : false, }; const result = await ServerManager.testConnection(server, testAbortController.current.signal); - - if (result.success) { + + if (result.primary || result.fallback) { + // Fallback was tested too — surface per-endpoint outcome. + const primaryOk = result.primary?.success; + const fallbackOk = result.fallback?.success; + const ok = (label: string) => t('server.testEndpointOk', { endpoint: label }); + const fail = (label: string) => t('server.testEndpointFail', { endpoint: label }); + const primaryLabel = t('server.endpointPrimary'); + const fallbackLabel = t('server.endpointFallback'); + const summary = `${primaryOk ? ok(primaryLabel) : fail(primaryLabel)} · ${fallbackOk ? ok(fallbackLabel) : fail(fallbackLabel)}`; + showToast(summary, result.success ? 'success' : 'error'); + } else if (result.success) { showToast(t('toast.connectionTestSuccess'), 'success'); } else { showToast(result.error || t('errors.connectionTestFailed'), 'error'); @@ -379,6 +425,71 @@ App Version: ${APP_VERSION}`; + {/* Fallback URL Section */} + + {t('server.fallbackUrl')} + + + + + + {t('server.useFallback')} + {t('server.useFallbackHint')} + + + + + {useFallback && ( + <> + + + + + + + + + + + + + + + {t('server.fallbackUseHttps')} + + + + + )} + + + {/* Authentication Section */} {!bypassAuth && ( diff --git a/context/ServerContext.tsx b/context/ServerContext.tsx index 9029a3f..72d5c8a 100644 --- a/context/ServerContext.tsx +++ b/context/ServerContext.tsx @@ -1,14 +1,21 @@ import React, { createContext, useContext, useState, useEffect, useCallback, ReactNode } from 'react'; import { useMutation } from '@tanstack/react-query'; -import { ServerConfig } from '@/types/api'; +import { ServerConfig, ServerEndpointKind } from '@/types/api'; import { ServerManager } from '@/services/server-manager'; import { apiClient } from '@/services/api/client'; import { storageService } from '@/services/storage'; +import { getActiveEndpoint } from '@/utils/server'; interface ServerContextType { currentServer: ServerConfig | null; isConnected: boolean; isLoading: boolean; + /** + * Which endpoint of `currentServer` is currently active in `apiClient`. + * Null when not connected or when the server has no fallback configured + * and the endpoint is unambiguous (callers can treat null as "primary"). + */ + activeEndpoint: ServerEndpointKind | null; connectToServer: (server: ServerConfig) => Promise; disconnect: () => Promise; reconnect: () => Promise; @@ -20,9 +27,21 @@ const ServerContext = createContext(undefined); export function ServerProvider({ children }: { children: ReactNode }) { const [currentServer, setCurrentServer] = useState(null); const [isConnected, setIsConnected] = useState(false); + const [activeEndpoint, setActiveEndpoint] = useState(null); const [initLoading, setInitLoading] = useState(true); const [reconnecting, setReconnecting] = useState(false); + // Derive the active endpoint from the server config + the endpoint the + // apiClient ended up on after a (re)connect. Called from each connection + // flow rather than via subscription because apiClient doesn't emit events. + const refreshActiveEndpoint = useCallback((server: ServerConfig | null, connected: boolean) => { + if (!server || !connected) { + setActiveEndpoint(null); + return; + } + setActiveEndpoint(getActiveEndpoint(server, apiClient.getServer())); + }, []); + useEffect(() => { async function autoConnect() { try { @@ -47,28 +66,32 @@ export function ServerProvider({ children }: { children: ReactNode }) { try { const connected = await ServerManager.connectToServer(server); setIsConnected(connected); + refreshActiveEndpoint(server, connected); if (!connected) { setCurrentServer(null); apiClient.setServer(null); } } catch { setIsConnected(false); + setActiveEndpoint(null); setCurrentServer(null); apiClient.setServer(null); } } else { setIsConnected(false); + setActiveEndpoint(null); apiClient.setServer(null); } } catch { setIsConnected(false); + setActiveEndpoint(null); apiClient.setServer(null); } finally { setInitLoading(false); } } autoConnect(); - }, []); + }, [refreshActiveEndpoint]); const connectMutation = useMutation({ mutationFn: (server: ServerConfig) => ServerManager.connectToServer(server), @@ -76,12 +99,15 @@ export function ServerProvider({ children }: { children: ReactNode }) { if (success) { setCurrentServer(server); setIsConnected(true); + refreshActiveEndpoint(server, true); } else { setIsConnected(false); + setActiveEndpoint(null); } }, onError: () => { setIsConnected(false); + setActiveEndpoint(null); }, }); @@ -101,6 +127,7 @@ export function ServerProvider({ children }: { children: ReactNode }) { onSuccess: () => { setCurrentServer(null); setIsConnected(false); + setActiveEndpoint(null); }, }); @@ -113,36 +140,42 @@ export function ServerProvider({ children }: { children: ReactNode }) { setReconnecting(true); const success = await ServerManager.reconnect(); setIsConnected(success); + refreshActiveEndpoint(currentServer, success); return success; } catch { setIsConnected(false); + setActiveEndpoint(null); return false; } finally { setReconnecting(false); } - }, []); + }, [currentServer, refreshActiveEndpoint]); const checkAndReconnect = useCallback(async (): Promise => { if (!currentServer) { setIsConnected(false); + setActiveEndpoint(null); return false; } try { const success = await ServerManager.reconnect(); setIsConnected(success); + refreshActiveEndpoint(currentServer, success); return success; } catch { try { const reconnected = await ServerManager.connectToServer(currentServer); setIsConnected(reconnected); + refreshActiveEndpoint(currentServer, reconnected); return reconnected; } catch { setIsConnected(false); + setActiveEndpoint(null); return false; } } - }, [currentServer]); + }, [currentServer, refreshActiveEndpoint]); const isLoading = initLoading || connectMutation.isPending || disconnectMutation.isPending || reconnecting; @@ -153,6 +186,7 @@ export function ServerProvider({ children }: { children: ReactNode }) { currentServer, isConnected, isLoading, + activeEndpoint, connectToServer, disconnect, reconnect, diff --git a/locales/de/translation.json b/locales/de/translation.json index 59bcc5c..645f5d8 100644 --- a/locales/de/translation.json +++ b/locales/de/translation.json @@ -368,7 +368,8 @@ "username": "Benutzername", "password": "Passwort", "trackerUrl": "https://tracker.example.com/announce", - "enterKbs": "KiB/s eingeben" + "enterKbs": "KiB/s eingeben", + "fallbackHost": "Fallback-Host oder IP" }, "actions": { "resume": "Fortsetzen", @@ -442,7 +443,17 @@ "deleteServerConfirm": "Remove \"{{name}}\" from your server list?", "hostAddress": "Host-Adresse", "port": "Port", - "gotIt": "Verstanden" + "gotIt": "Verstanden", + "fallbackUrl": "FALLBACK-URL", + "useFallback": "Fallback-URL verwenden", + "useFallbackHint": "Diese URL versuchen, wenn die primäre fehlschlägt", + "fallbackUseHttps": "Fallback HTTPS", + "endpointPrimary": "Primär", + "endpointFallback": "Fallback", + "testEndpointOk": "{{endpoint}}: OK", + "testEndpointFail": "{{endpoint}}: Fehler", + "connectedViaPrimary": "Verbunden über Primär", + "connectedViaFallback": "Verbunden über Fallback" }, "torrentDetail": { "notFound": "Torrent nicht gefunden", @@ -711,6 +722,7 @@ "fillNameAndHost": "Bitte Server-Name und Host ausfüllen", "fillUsernamePassword": "Bitte Benutzername und Passwort eingeben oder Auth-Umgehung aktivieren", "validPort": "Bitte gültige Portnummer eingeben (1-65535)", + "fillFallbackHost": "Bitte einen Fallback-Host angeben", "selectTorrentFile": "Bitte eine .torrent-Datei auswählen", "enterUrlOrMagnet": "Bitte URL/Magnet-Link eingeben oder .torrent-Datei auswählen", "enterCategoryName": "Bitte Kategoriename eingeben", diff --git a/locales/en/translation.json b/locales/en/translation.json index c0d291e..e543519 100644 --- a/locales/en/translation.json +++ b/locales/en/translation.json @@ -388,7 +388,8 @@ "username": "Username", "password": "Password", "trackerUrl": "https://tracker.example.com/announce", - "enterKbs": "Enter KiB/s" + "enterKbs": "Enter KiB/s", + "fallbackHost": "Fallback host or IP" }, "actions": { "resume": "Resume", @@ -462,7 +463,17 @@ "deleteServerConfirm": "Remove \"{{name}}\" from your server list?", "hostAddress": "Host Address", "port": "Port", - "gotIt": "Got it" + "gotIt": "Got it", + "fallbackUrl": "FALLBACK URL", + "useFallback": "Use fallback URL", + "useFallbackHint": "Try this URL if the primary fails", + "fallbackUseHttps": "Fallback HTTPS", + "endpointPrimary": "Primary", + "endpointFallback": "Fallback", + "testEndpointOk": "{{endpoint}}: OK", + "testEndpointFail": "{{endpoint}}: failed", + "connectedViaPrimary": "Connected via primary", + "connectedViaFallback": "Connected via fallback" }, "torrentDetail": { "notFound": "Torrent not found", @@ -731,6 +742,7 @@ "fillNameAndHost": "Please fill in server name and host", "fillUsernamePassword": "Please fill in username and password, or enable bypass authentication", "validPort": "Please enter a valid port number (1-65535)", + "fillFallbackHost": "Please enter a fallback host", "selectTorrentFile": "Please select a .torrent file", "enterUrlOrMagnet": "Please enter a URL/magnet link or select a .torrent file", "enterCategoryName": "Please enter a category name", diff --git a/locales/es/translation.json b/locales/es/translation.json index 123bfd3..40196c2 100644 --- a/locales/es/translation.json +++ b/locales/es/translation.json @@ -368,7 +368,8 @@ "username": "Usuario", "password": "Contraseña", "trackerUrl": "https://tracker.example.com/announce", - "enterKbs": "Introducir KiB/s" + "enterKbs": "Introducir KiB/s", + "fallbackHost": "Host o IP de respaldo" }, "actions": { "resume": "Reanudar", @@ -442,7 +443,17 @@ "deleteServerConfirm": "Remove \"{{name}}\" from your server list?", "hostAddress": "Dirección del host", "port": "Puerto", - "gotIt": "Entendido" + "gotIt": "Entendido", + "fallbackUrl": "URL DE RESPALDO", + "useFallback": "Usar URL de respaldo", + "useFallbackHint": "Probar esta URL si la principal falla", + "fallbackUseHttps": "HTTPS de respaldo", + "endpointPrimary": "Principal", + "endpointFallback": "Respaldo", + "testEndpointOk": "{{endpoint}}: OK", + "testEndpointFail": "{{endpoint}}: error", + "connectedViaPrimary": "Conectado por principal", + "connectedViaFallback": "Conectado por respaldo" }, "torrentDetail": { "notFound": "Torrent no encontrado", @@ -711,6 +722,7 @@ "fillNameAndHost": "Rellena el nombre y host del servidor", "fillUsernamePassword": "Rellena usuario y contraseña, o activa bypass de autenticación", "validPort": "Introduce un número de puerto válido (1-65535)", + "fillFallbackHost": "Introduce un host de respaldo", "selectTorrentFile": "Selecciona un archivo .torrent", "enterUrlOrMagnet": "Introduce una URL/enlace magnético o selecciona un .torrent", "enterCategoryName": "Introduce un nombre de categoría", diff --git a/locales/fr/translation.json b/locales/fr/translation.json index c755553..fa172d2 100644 --- a/locales/fr/translation.json +++ b/locales/fr/translation.json @@ -368,7 +368,8 @@ "username": "Nom d'utilisateur", "password": "Mot de passe", "trackerUrl": "https://tracker.example.com/announce", - "enterKbs": "Entrer KiB/s" + "enterKbs": "Entrer KiB/s", + "fallbackHost": "Hôte ou IP de secours" }, "actions": { "resume": "Reprendre", @@ -442,7 +443,17 @@ "deleteServerConfirm": "Remove \"{{name}}\" from your server list?", "hostAddress": "Adresse de l'hôte", "port": "Port", - "gotIt": "Compris" + "gotIt": "Compris", + "fallbackUrl": "URL DE SECOURS", + "useFallback": "Utiliser une URL de secours", + "useFallbackHint": "Essayer cette URL si la principale échoue", + "fallbackUseHttps": "HTTPS de secours", + "endpointPrimary": "Principal", + "endpointFallback": "Secours", + "testEndpointOk": "{{endpoint}} : OK", + "testEndpointFail": "{{endpoint}} : échec", + "connectedViaPrimary": "Connecté via le principal", + "connectedViaFallback": "Connecté via le secours" }, "torrentDetail": { "notFound": "Torrent introuvable", @@ -711,6 +722,7 @@ "fillNameAndHost": "Remplissez le nom et l'hôte du serveur", "fillUsernamePassword": "Remplissez le nom d'utilisateur et le mot de passe, ou activez le contournement d'authentification", "validPort": "Entrez un numéro de port valide (1-65535)", + "fillFallbackHost": "Saisissez un hôte de secours", "selectTorrentFile": "Sélectionnez un fichier .torrent", "enterUrlOrMagnet": "Entrez une URL/lien magnétique ou sélectionnez un fichier .torrent", "enterCategoryName": "Entrez un nom de catégorie", diff --git a/locales/ru/translation.json b/locales/ru/translation.json index f05d422..b6f2cb5 100644 --- a/locales/ru/translation.json +++ b/locales/ru/translation.json @@ -366,7 +366,8 @@ "username": "Имя пользователя", "password": "Пароль", "trackerUrl": "https://tracker.example.com/announce", - "enterKbs": "КиБ/с" + "enterKbs": "КиБ/с", + "fallbackHost": "Резервный хост или IP" }, "actions": { "resume": "Запустить", @@ -440,7 +441,17 @@ "deleteServerConfirm": "Удалить «{{name}}» из списка?", "hostAddress": "Адрес хоста", "port": "Порт", - "gotIt": "Понятно" + "gotIt": "Понятно", + "fallbackUrl": "РЕЗЕРВНЫЙ URL", + "useFallback": "Использовать резервный URL", + "useFallbackHint": "Пробовать его, если основной недоступен", + "fallbackUseHttps": "Резервный HTTPS", + "endpointPrimary": "Основной", + "endpointFallback": "Резервный", + "testEndpointOk": "{{endpoint}}: OK", + "testEndpointFail": "{{endpoint}}: ошибка", + "connectedViaPrimary": "Подключено через основной", + "connectedViaFallback": "Подключено через резервный" }, "torrentDetail": { "notFound": "Торрент не найден", @@ -709,6 +720,7 @@ "fillNameAndHost": "Укажите имя сервера и хост", "fillUsernamePassword": "Укажите имя пользователя и пароль или включите обход авторизации", "validPort": "Укажите порт от 1 до 65535", + "fillFallbackHost": "Введите резервный хост", "selectTorrentFile": "Выберите .torrent-файл", "enterUrlOrMagnet": "Введите URL или magnet либо выберите .torrent-файл", "enterCategoryName": "Введите имя категории", diff --git a/locales/zh/translation.json b/locales/zh/translation.json index 76a7797..ad8b817 100644 --- a/locales/zh/translation.json +++ b/locales/zh/translation.json @@ -368,7 +368,8 @@ "username": "用户名", "password": "密码", "trackerUrl": "https://tracker.example.com/announce", - "enterKbs": "输入 KiB/s" + "enterKbs": "输入 KiB/s", + "fallbackHost": "备用主机或 IP" }, "actions": { "resume": "恢复", @@ -442,7 +443,17 @@ "deleteServerConfirm": "Remove \"{{name}}\" from your server list?", "hostAddress": "主机地址", "port": "端口", - "gotIt": "知道了" + "gotIt": "知道了", + "fallbackUrl": "备用 URL", + "useFallback": "使用备用 URL", + "useFallbackHint": "主 URL 失败时尝试备用 URL", + "fallbackUseHttps": "备用 HTTPS", + "endpointPrimary": "主", + "endpointFallback": "备用", + "testEndpointOk": "{{endpoint}}:成功", + "testEndpointFail": "{{endpoint}}:失败", + "connectedViaPrimary": "通过主 URL 连接", + "connectedViaFallback": "通过备用 URL 连接" }, "torrentDetail": { "notFound": "未找到种子", @@ -711,6 +722,7 @@ "fillNameAndHost": "请填写服务器名称和主机", "fillUsernamePassword": "请填写用户名和密码,或启用绕过认证", "validPort": "请输入有效端口号 (1-65535)", + "fillFallbackHost": "请输入备用主机", "selectTorrentFile": "请选择 .torrent 文件", "enterUrlOrMagnet": "请输入 URL/磁力链接或选择 .torrent 文件", "enterCategoryName": "请输入分类名称", diff --git a/services/server-manager.ts b/services/server-manager.ts index f6a80ac..36956dc 100644 --- a/services/server-manager.ts +++ b/services/server-manager.ts @@ -5,13 +5,30 @@ * Known issues: isNetworkError was duplicated inline 3× (deduplicated in Task 1.6). */ import { AxiosError } from 'axios'; -import { ServerConfig } from '@/types/api'; +import { ServerConfig, ServerEndpointKind } from '@/types/api'; +import { hasFallback, resolveServerEndpoint } from '@/utils/server'; import { storageService } from './storage'; import { apiClient } from './api/client'; import { authApi } from './api/auth'; import { applicationApi } from './api/application'; import { clogInfo, clogWarn, clogError } from './connectivity-log'; +/** + * Per-endpoint outcome from a connection test. Used to surface granular + * primary/fallback feedback in the UI without forcing every caller to handle + * a richer shape — the simple primary-only servers still see a single result. + */ +export interface EndpointTestResult { + success: boolean; + error?: string; +} + +export interface ConnectionTestResult extends EndpointTestResult { + /** When fallback was attempted, the per-endpoint outcomes. */ + primary?: EndpointTestResult; + fallback?: EndpointTestResult; +} + export function isNetworkError(error: unknown): boolean { if (error instanceof AxiosError) { return ( @@ -70,26 +87,71 @@ export class ServerManager { } /** - * Connect to a server (set as current and authenticate) + * Connect to a server (set as current and authenticate). When the server + * has a fallback endpoint configured, the primary endpoint is attempted + * first; if it fails with a network error, the fallback endpoint is tried. + * Authentication errors are not retried against the fallback because they + * share credentials with the primary endpoint. */ static async connectToServer(server: ServerConfig): Promise { + const primaryResolved = resolveServerEndpoint(server, 'primary'); + try { - clogInfo('CONN', `Connecting to ${server.host}:${server.port || 'default'} (bypassAuth=${server.bypassAuth})`); - // Set server in API client - apiClient.setServer(server); + const success = await this.connectToEndpoint(server, primaryResolved, 'primary'); + if (success) return true; + // Login returned Fails (auth) — don't try fallback with the same creds. + return false; + } catch (primaryError: unknown) { + if (!hasFallback(server) || !isNetworkError(primaryError)) { + // Not eligible for fallback — surface the original error. + throw primaryError; + } + + const fallbackResolved = resolveServerEndpoint(server, 'fallback'); + const primaryMessage = primaryError instanceof Error ? primaryError.message : String(primaryError); + clogWarn('CONN', `Primary endpoint failed (${primaryMessage}); trying fallback ${fallbackResolved.host}:${fallbackResolved.port || 'default'}`); + + try { + const success = await this.connectToEndpoint(server, fallbackResolved, 'fallback'); + if (success) { + clogInfo('CONN', 'Connected via fallback endpoint'); + return true; + } + return false; + } catch (fallbackError: unknown) { + const fallbackMessage = fallbackError instanceof Error ? fallbackError.message : String(fallbackError); + clogError('CONN', `Fallback endpoint also failed: ${fallbackMessage}`); + // Surface the fallback error since both routes failed; it's the + // most recent and usually the most informative. + throw fallbackError; + } + } + } + + /** + * Internal: attempt a connection against a single resolved endpoint. The + * resolved config carries the chosen host/port/useHttps while keeping the + * shared id/credentials so cookies and stored IDs remain consistent. + */ + private static async connectToEndpoint( + server: ServerConfig, + resolved: ServerConfig, + endpoint: ServerEndpointKind + ): Promise { + clogInfo('CONN', `Connecting to ${resolved.host}:${resolved.port || 'default'} via ${endpoint} (bypassAuth=${resolved.bypassAuth})`); + apiClient.setServer(resolved); - // Skip authentication if bypassAuth is enabled - if (server.bypassAuth) { - // For bypass auth, still verify the connection works by making a test API call + try { + if (resolved.bypassAuth) { try { await applicationApi.getVersion(); await storageService.setCurrentServerId(server.id); - clogInfo('CONN', 'Connected successfully (bypass auth)'); + clogInfo('CONN', `Connected successfully via ${endpoint} (bypass auth)`); return true; } catch (error: unknown) { apiClient.setServer(null); const message = error instanceof Error ? error.message : String(error); - clogError('CONN', `Bypass-auth connect failed: ${message}`); + clogError('CONN', `Bypass-auth connect failed (${endpoint}): ${message}`); if (isNetworkError(error)) { throw error; } @@ -97,21 +159,19 @@ export class ServerManager { } } - // Attempt login - const loginResult = await authApi.login(server.username, server.password); - + const loginResult = await authApi.login(resolved.username, resolved.password); + if (loginResult.status === 'Ok') { - // Verify connection by making a test API call try { await applicationApi.getVersion(); await storageService.setCurrentServerId(server.id); - clogInfo('CONN', 'Connected successfully (authenticated)'); + clogInfo('CONN', `Connected successfully via ${endpoint} (authenticated)`); return true; } catch (error: unknown) { apiClient.setServer(null); const message = error instanceof Error ? error.message : String(error); const axiosErr = error instanceof AxiosError ? error : undefined; - clogError('CONN', `Post-login API check failed: ${message}`); + clogError('CONN', `Post-login API check failed (${endpoint}): ${message}`); if (isNetworkError(error)) { throw error; } @@ -121,22 +181,15 @@ export class ServerManager { throw new Error('Failed to connect to server. Please check your settings.'); } } - - // Login failed, clear server - clogWarn('CONN', 'Login returned Fails — clearing server'); + + clogWarn('CONN', `Login returned Fails (${endpoint}) — clearing server`); apiClient.setServer(null); return false; } catch (error: unknown) { const message = error instanceof Error ? error.message : String(error); - clogError('CONN', `connectToServer error: ${message}`); + clogError('CONN', `connectToEndpoint(${endpoint}) error: ${message}`); apiClient.setServer(null); - if (isNetworkError(error)) { - throw error; - } - if (message.includes('Failed to connect') || message.includes('Authentication failed')) { - throw error; - } - return false; + throw error; } } @@ -172,36 +225,62 @@ export class ServerManager { } /** - * Test connection to a server without saving it - * Returns a result object with success status and error message if failed + * Test connection to a server without saving it. When the server has a + * fallback endpoint configured, both endpoints are tested and per-endpoint + * results are returned alongside the top-level success flag, which is true + * when *either* endpoint succeeds. */ - static async testConnection(server: ServerConfig, signal?: AbortSignal): Promise<{ success: boolean; error?: string }> { + static async testConnection(server: ServerConfig, signal?: AbortSignal): Promise { + if (!hasFallback(server)) { + // Simple primary-only path — preserves the original shape for callers + // that don't need per-endpoint detail. + const result = await this.testEndpoint(resolveServerEndpoint(server, 'primary'), signal); + return result; + } + + const primary = await this.testEndpoint(resolveServerEndpoint(server, 'primary'), signal); + if (signal?.aborted) { + throw new Error('Test cancelled'); + } + const fallback = await this.testEndpoint(resolveServerEndpoint(server, 'fallback'), signal); + + const success = primary.success || fallback.success; + return { + success, + error: success ? undefined : (fallback.error || primary.error), + primary, + fallback, + }; + } + + /** + * Internal: test a single resolved endpoint and translate errors into the + * EndpointTestResult shape. Cancellation propagates so the caller can stop + * the whole test sequence in flight. + */ + private static async testEndpoint(resolved: ServerConfig, signal?: AbortSignal): Promise { const previousServer = apiClient.getServer(); - clogInfo('CONN', `testConnection to ${server.host}:${server.port || 'default'}`); - + clogInfo('CONN', `testEndpoint to ${resolved.host}:${resolved.port || 'default'}`); + try { - // Set server temporarily for testing - apiClient.setServer(server); + apiClient.setServer(resolved); try { - if (!server.bypassAuth) { - // Attempt login with abort signal - const loginResult = await authApi.login(server.username, server.password, signal); + if (!resolved.bypassAuth) { + const loginResult = await authApi.login(resolved.username, resolved.password, signal); if (loginResult.status !== 'Ok') { - clogWarn('CONN', 'testConnection: auth failed'); + clogWarn('CONN', 'testEndpoint: auth failed'); return { success: false, error: 'Authentication failed. Please check your username and password.' }; } } - // Check if aborted if (signal?.aborted) { throw new Error('Test cancelled'); } - // Verify connection by making a test API call with abort signal await applicationApi.getVersion(signal); - - clogInfo('CONN', 'testConnection succeeded'); + + clogInfo('CONN', 'testEndpoint succeeded'); return { success: true }; } catch (error: unknown) { const message = error instanceof Error ? error.message : String(error); @@ -213,19 +292,18 @@ export class ServerManager { if (axiosErr?.code === 'ERR_CANCELED' || message === 'Test cancelled' || message.includes('cancel')) { throw error; } - + if (axiosErr?.response?.status === 403 || axiosErr?.response?.status === 401 || message.includes('Authentication')) { - clogWarn('CONN', `testConnection failed: auth error (${axiosErr?.response?.status || message})`); + clogWarn('CONN', `testEndpoint failed: auth error (${axiosErr?.response?.status || message})`); return { success: false, error: 'Authentication failed. Please check your credentials.' }; } else if (isNetworkError(error)) { - clogError('CONN', `testConnection failed: network error (${axiosErr?.code || message})`); + clogError('CONN', `testEndpoint failed: network error (${axiosErr?.code || message})`); return { success: false, error: 'Connection failed. Please check your server address and network connection.' }; } else { - clogError('CONN', `testConnection failed: ${message}`); + clogError('CONN', `testEndpoint failed: ${message}`); return { success: false, error: message || 'Connection test failed. Please check your settings.' }; } } finally { - // Restore previous server state apiClient.setServer(previousServer); } } catch (error: unknown) { diff --git a/services/storage.ts b/services/storage.ts index 66a4212..1581558 100644 --- a/services/storage.ts +++ b/services/storage.ts @@ -41,6 +41,13 @@ export const storageService = { password: '', // Don't store password in AsyncStorage useHttps: s.useHttps || false, bypassAuth: s.bypassAuth || false, + // Fallback endpoint (optional). Only persisted when a fallback host + // is configured; absent fields naturally mean fallback is disabled. + useFallback: s.useFallback || false, + fallbackHost: stripProtocol(s.fallbackHost || ''), + fallbackPort: (s.fallbackPort && s.fallbackPort > 0) ? s.fallbackPort : undefined, + fallbackUseHttps: s.fallbackUseHttps || false, + fallbackBasePath: s.fallbackBasePath || undefined, })); await AsyncStorage.setItem(STORAGE_KEYS.SERVERS, JSON.stringify(serversWithoutPasswords)); @@ -66,7 +73,12 @@ export const storageService = { const serversWithPasswords = await Promise.all( servers.map(async (server) => { const password = await SecureStore.getItemAsync(`server_password_${server.id}`) || ''; - return { ...server, password, host: stripProtocol(server.host || '') }; + return { + ...server, + password, + host: stripProtocol(server.host || ''), + fallbackHost: server.fallbackHost ? stripProtocol(server.fallbackHost) : server.fallbackHost, + }; }) ); diff --git a/types/api.ts b/types/api.ts index b50299b..41471c8 100644 --- a/types/api.ts +++ b/types/api.ts @@ -17,8 +17,26 @@ export interface ServerConfig { password: string; useHttps?: boolean; bypassAuth?: boolean; // Skip authentication when local network auth is disabled + + /** + * When true, the connection flow will attempt the fallback endpoint after the + * primary endpoint fails with a network error. Authentication is shared with + * the primary endpoint since fallback represents an alternate route to the + * same qBittorrent instance (e.g. LAN vs WAN, DDNS vs static IP). + */ + useFallback?: boolean; + /** Fallback host (IP or domain). Required when useFallback is true. */ + fallbackHost?: string; + /** Fallback port. Same 1–65535 validation as the primary port. */ + fallbackPort?: number; + /** Whether the fallback endpoint should be reached over HTTPS. */ + fallbackUseHttps?: boolean; + /** Reserved for future fallback base path UI; not surfaced in settings yet. */ + fallbackBasePath?: string; } +export type ServerEndpointKind = 'primary' | 'fallback'; + // Authentication export interface LoginResponse { status: 'Ok' | 'Fails'; diff --git a/utils/server.ts b/utils/server.ts index b9fb5e7..6906ef4 100644 --- a/utils/server.ts +++ b/utils/server.ts @@ -1,4 +1,4 @@ -import { ServerConfig } from '@/types/api'; +import { ServerConfig, ServerEndpointKind } from '@/types/api'; export const AVATAR_PALETTE = [ '#0A84FF', '#30D158', '#FF9F0A', '#FF453A', @@ -15,3 +15,64 @@ export function serverAddress(server: ServerConfig): string { const port = server.port && server.port > 0 ? `:${server.port}` : ''; return `${server.host}${port}`; } + +export function hasFallback(server: ServerConfig): boolean { + return server.useFallback === true && !!server.fallbackHost?.trim(); +} + +export function resolveServerEndpoint( + server: ServerConfig, + endpoint: ServerEndpointKind +): ServerConfig { + if (endpoint === 'fallback' && hasFallback(server)) { + return { + ...server, + host: server.fallbackHost!.trim(), + port: server.fallbackPort, + useHttps: server.fallbackUseHttps ?? server.useHttps, + basePath: server.fallbackBasePath || server.basePath || '/', + }; + } + return { + ...server, + host: server.host.trim(), + basePath: server.basePath || '/', + }; +} + +export function getServerEndpointLabel(server: ServerConfig, endpoint: ServerEndpointKind): string { + return serverAddress(resolveServerEndpoint(server, endpoint)); +} + +export function getActiveEndpoint( + server: ServerConfig, + activeServer: ServerConfig | null +): ServerEndpointKind | null { + if (!activeServer || activeServer.id !== server.id) { + return null; + } + + const primary = resolveServerEndpoint(server, 'primary'); + if ( + activeServer.host === primary.host && + (activeServer.port || undefined) === (primary.port || undefined) && + !!activeServer.useHttps === !!primary.useHttps + ) { + return 'primary'; + } + + if (!hasFallback(server)) { + return null; + } + + const fallback = resolveServerEndpoint(server, 'fallback'); + if ( + activeServer.host === fallback.host && + (activeServer.port || undefined) === (fallback.port || undefined) && + !!activeServer.useHttps === !!fallback.useHttps + ) { + return 'fallback'; + } + + return null; +}