diff --git a/AGENTS.md b/AGENTS.md
index a37790f..3fd858f 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -491,7 +491,13 @@ Thin objects over `apiClient`.
`services/server-manager.ts`); every other host and every non-server-trust
challenge (Basic Auth, client cert) falls through to default handling
unchanged. iOS only; requires `npm run xcode` to pick up (new native code,
- not just a generated-file patch).
+ not just a generated-file patch). `isInsecureCertAllowlistAvailable()` (#256)
+ reports whether the native side is actually present in the running binary —
+ see [§10 Gotchas](#10-gotchas) for why that can differ from the JS wrapper
+ loading fine. `services/server-manager.ts`'s `syncInsecureCertAllowlist`
+ warns via `clogWarn('CERT', …)` when a server wants the flag but it's
+ unavailable; the server add/edit screens show a matching hint under the
+ toggle.
### Hooks (`hooks/`)
@@ -518,7 +524,10 @@ Pure and well-tested. **Put logic here whenever it doesn't need React.**
and availability **FLOOR**, never round up) · `torrent-state.ts` (state → color/
label, completion and ETA rules) · `limit-input.ts` (share-limit sentinels:
`-2` = follow global, `-1` = unlimited; own-vs-effective limit resolution) ·
-`error.ts` (`getErrorMessage`) · `apiVersion.ts` (parse + `ApiFeatures` gating) ·
+`error.ts` (`getErrorMessage`, `isTlsRejection` — recognizes iOS rejecting a
+server's TLS certificate from the free-text error description RN's XHR
+bridge exposes, matched across all six locales since that text is localized
+to the device language — #256) · `apiVersion.ts` (parse + `ApiFeatures` gating) ·
`connection-settings.ts` (`resolveConnectionSettings` — resolves the axios
connection timeout / retry count from raw stored preferences, falling back to
`DEFAULT_PREFERENCES` on missing or corrupt values while still honoring a
@@ -780,3 +789,15 @@ Keep entries factual and current; if you find one that's no longer true
parameter "works" in the UI but has no visible server-side effect, check it
against qBittorrent's `torrentscontroller.cpp` source, not the wiki — the
wiki is not reliably kept in sync with parameter renames.
+- **A feature backed by a local Expo native module (`modules/*`) can be
+ rendered by an OTA update on a binary that predates that module, and the
+ JS wrapper no-ops silently instead of erroring.** OTA JS updates ship
+ independently of the native binary (`app.config.js`'s `runtimeVersion.policy:
+ 'appVersion'` ties an OTA update to any binary on the same app version,
+ native code included or not), so a device can receive a feature's JS
+ without ever having its native half. `modules/insecure-cert-allowlist`
+ hit exactly this (#256): the toggle looked like it did nothing, with no
+ error anywhere. The fix is an explicit availability check
+ (`isInsecureCertAllowlistAvailable()`) that callers use to warn or hint in
+ the UI — don't assume a native module is present just because requiring it
+ didn't throw at JS-parse time.
diff --git a/app/server/[id].tsx b/app/server/[id].tsx
index d0e6ca0..14e201e 100644
--- a/app/server/[id].tsx
+++ b/app/server/[id].tsx
@@ -47,6 +47,7 @@ import {
sanitizeCustomHeaders,
validateCustomHeaders,
} from '@/utils/customHeaders';
+import { isInsecureCertAllowlistAvailable } from '@/modules/insecure-cert-allowlist';
export default function EditServerScreen() {
const router = useRouter();
@@ -890,6 +891,11 @@ App Version: ${APP_VERSION}`;
{t('server.allowInsecureCertHint')}
+ {!isInsecureCertAllowlistAvailable() && (
+
+ {t('server.allowInsecureCertUnavailable')}
+
+ )}
>
)}
diff --git a/app/server/add.tsx b/app/server/add.tsx
index 25219d1..4f8b7cf 100644
--- a/app/server/add.tsx
+++ b/app/server/add.tsx
@@ -46,6 +46,7 @@ import {
sanitizeCustomHeaders,
validateCustomHeaders,
} from '@/utils/customHeaders';
+import { isInsecureCertAllowlistAvailable } from '@/modules/insecure-cert-allowlist';
export default function AddServerScreen() {
const router = useRouter();
@@ -810,6 +811,11 @@ App Version: ${APP_VERSION}`;
{t('server.allowInsecureCertHint')}
+ {!isInsecureCertAllowlistAvailable() && (
+
+ {t('server.allowInsecureCertUnavailable')}
+
+ )}
>
)}
diff --git a/components/SuperDebugPanel.tsx b/components/SuperDebugPanel.tsx
index 1400812..9d6f6be 100644
--- a/components/SuperDebugPanel.tsx
+++ b/components/SuperDebugPanel.tsx
@@ -27,7 +27,7 @@ import { APP_VERSION } from '@/utils/version';
import { getConnectivityLog, formatConnectivityLog } from '@/services/connectivity-log';
import { logsApi } from '@/services/api/logs';
import { apiClient } from '@/services/api/client';
-import { getErrorMessage } from '@/utils/error';
+import { getErrorMessage, isTlsRejection } from '@/utils/error';
import { CustomHeaderPair, sanitizeCustomHeaders } from '@/utils/customHeaders';
import { isLoginBodyFail, isLoginSuccess } from '@/utils/login-response';
@@ -66,6 +66,67 @@ const diagnosticHttp = axios.create({
validateStatus: () => true,
});
+/** Minimal fetch()-`Response`-shaped result for the REACH probes below —
+ * only `status` is ever read off it. */
+interface ReachProbeResponse {
+ status: number;
+}
+
+/**
+ * Raw-XHR mirror of `fetch(url, { method, headers, signal })`, used only by
+ * the REACH probes (Feature 1 "Ping Host" and Step 1 of the full run below).
+ * Deliberately not routed through `diagnosticHttp` or the app's `apiClient`
+ * — the REACH step exists specifically to stay independent of the app's own
+ * HTTP stack (see this file's header comment).
+ *
+ * It replaces a plain `fetch()` call because RN's `whatwg-fetch` polyfill
+ * collapses every network-level failure — including a rejected TLS
+ * certificate — into a bare `TypeError('Network request failed')`
+ * (fetch.umd.js), discarding the native NSError's localizedDescription. That
+ * made the certificate-specific guidance below unreachable: every REACH
+ * failure looked like "Network request failed" regardless of cause. A raw
+ * XHR keeps the detail — RN's XHR bridge puts it in the response body on
+ * error — which is exactly how `utils/error.ts`'s `isTlsRejection` (also
+ * used by `services/api/client.ts`) tells a TLS rejection apart from a
+ * genuinely unreachable host.
+ */
+function reachProbeRequest(
+ url: string,
+ method: 'HEAD' | 'GET',
+ headers: Record,
+ signal: AbortSignal,
+): Promise {
+ return new Promise((resolve, reject) => {
+ if (signal.aborted) {
+ reject(new Error('Timed out after 15s'));
+ return;
+ }
+ const xhr = new XMLHttpRequest();
+ const onAbort = () => xhr.abort();
+ const cleanup = () => signal.removeEventListener('abort', onAbort);
+ signal.addEventListener('abort', onAbort);
+ xhr.open(method, url, true);
+ Object.entries(headers).forEach(([key, value]) => xhr.setRequestHeader(key, value));
+ xhr.onload = () => {
+ cleanup();
+ resolve({ status: xhr.status });
+ };
+ xhr.onabort = () => {
+ cleanup();
+ reject(new Error('Timed out after 15s'));
+ };
+ xhr.onerror = () => {
+ cleanup();
+ // Mirror axios's error shape (`error.request.response`) so
+ // isTlsRejection can read the native error description straight off it.
+ const err = new Error('Network request failed') as Error & { request?: XMLHttpRequest };
+ err.request = xhr;
+ reject(err);
+ };
+ xhr.send();
+ });
+}
+
/** Reads the Set-Cookie value(s) off an axios response's headers, tolerant of
* the array shape (duplicate headers) and casing quirks — mirrors the proven
* extraction in services/api/client.ts's response interceptor. Returns one
@@ -325,21 +386,13 @@ export function SuperDebugPanel({
const authHeader = buildAuthHeader();
const reachHeaders: Record = { ...buildCustomHeaders() };
if (authHeader) reachHeaders['Authorization'] = authHeader;
- let response: Response;
+ let response: ReachProbeResponse;
try {
- response = await fetch(url, {
- method: 'HEAD',
- headers: reachHeaders,
- signal: controller.signal,
- });
+ response = await reachProbeRequest(url, 'HEAD', reachHeaders, controller.signal);
} catch {
// Some servers reject HEAD — fall back to GET
if (controller.signal.aborted) throw new Error('Timed out after 15s');
- response = await fetch(url, {
- method: 'GET',
- headers: reachHeaders,
- signal: controller.signal,
- });
+ response = await reachProbeRequest(url, 'GET', reachHeaders, controller.signal);
}
const latency = Date.now() - start;
@@ -367,23 +420,28 @@ export function SuperDebugPanel({
const msg = getErrorMessage(err) || 'Unknown error';
addEntry('REACH', `Host unreachable after ${latency}ms`, 'error');
- // Provide specific guidance based on error type
- if (msg.includes('Network request failed') || msg.includes('Failed to connect')) {
+ // Provide specific guidance based on error type. The TLS check runs
+ // first: a rejected certificate reaches here as the same generic
+ // "Network request failed" text a genuinely unreachable host produces
+ // (see reachProbeRequest's onerror above), so isTlsRejection — which
+ // inspects the XHR's response body for the native error description
+ // rather than the generic message — is the only way to tell them apart.
+ if (isTlsRejection(err)) {
addEntry(
'WARN',
- 'The device cannot reach the server at all. Possible causes:\n 1. IP address or domain is wrong\n 2. Server is off or qBittorrent is not running\n 3. Port is incorrect (qBittorrent default: 8080)\n 4. Firewall is blocking the connection\n 5. If remote: VPN/port forwarding not configured',
+ 'The server was reached, but iOS rejected its TLS certificate. If you do not have HTTPS set up, turn off the "Use HTTPS" toggle. If you are intentionally using a self-signed certificate, enable "Allow Self-Signed Certificate" in the Security section above — trusting the certificate on this device alone is not enough for a third-party app to accept it.',
'warning',
);
- } else if (msg.includes('Timed out') || msg.includes('timeout') || msg.includes('aborted')) {
+ } else if (msg.includes('Network request failed') || msg.includes('Failed to connect')) {
addEntry(
'WARN',
- 'Connection timed out. The server did not respond within 15 seconds. Possible causes:\n 1. Server is behind a firewall that silently drops packets\n 2. Wrong port (packets go nowhere)\n 3. Network latency too high (weak connection)',
+ 'The device cannot reach the server at all. Possible causes:\n 1. IP address or domain is wrong\n 2. Server is off or qBittorrent is not running\n 3. Port is incorrect (qBittorrent default: 8080)\n 4. Firewall is blocking the connection\n 5. If remote: VPN/port forwarding not configured',
'warning',
);
- } else if (msg.includes('SSL') || msg.includes('certificate') || msg.includes('TLS')) {
+ } else if (msg.includes('Timed out') || msg.includes('timeout') || msg.includes('aborted')) {
addEntry(
'WARN',
- 'The server was reached, but iOS rejected its TLS certificate. If you do not have HTTPS set up, turn off the "Use HTTPS" toggle. If you are intentionally using a self-signed certificate, enable "Allow Self-Signed Certificate" in the Security section above — trusting the certificate on this device alone is not enough for a third-party app to accept it.',
+ 'Connection timed out. The server did not respond within 15 seconds. Possible causes:\n 1. Server is behind a firewall that silently drops packets\n 2. Wrong port (packets go nowhere)\n 3. Network latency too high (weak connection)',
'warning',
);
} else {
@@ -521,20 +579,12 @@ export function SuperDebugPanel({
}
try {
- let reachResp: Response;
+ let reachResp: ReachProbeResponse;
try {
- reachResp = await fetch(baseUrl, {
- method: 'HEAD',
- headers: diagHeaders,
- signal: controller.signal,
- });
+ reachResp = await reachProbeRequest(baseUrl, 'HEAD', diagHeaders, controller.signal);
} catch {
if (controller.signal.aborted) throw new Error('Timed out after 15s');
- reachResp = await fetch(baseUrl, {
- method: 'GET',
- headers: diagHeaders,
- signal: controller.signal,
- });
+ reachResp = await reachProbeRequest(baseUrl, 'GET', diagHeaders, controller.signal);
}
clearTimeout(reachTimeout);
const reachLatency = Date.now() - reachStart;
@@ -560,7 +610,16 @@ export function SuperDebugPanel({
const msg = getErrorMessage(err) || 'Unknown error';
addEntry('REACH', `FAILED — Server unreachable after ${reachLatency}ms`, 'error');
- if (msg.includes('Network request failed') || msg.includes('Failed to connect')) {
+ // TLS check first — see the matching comment on the Feature 1 probe
+ // above; the same generic "Network request failed" text covers both
+ // a rejected certificate and a genuinely unreachable host here.
+ if (isTlsRejection(err)) {
+ addEntry(
+ 'WARN',
+ 'The server was reached, but iOS rejected its TLS certificate. If you do not have HTTPS configured, turn off the "Use HTTPS" toggle. If you are intentionally using a self-signed certificate, enable "Allow Self-Signed Certificate" in the Security section above — trusting the certificate on this device alone is not enough for a third-party app to accept it.',
+ 'warning',
+ );
+ } else if (msg.includes('Network request failed') || msg.includes('Failed to connect')) {
addEntry(
'WARN',
'Your device cannot establish a connection to this address.\n\nChecklist:\n 1. Is the IP/domain correct?\n 2. Is qBittorrent running with WebUI enabled?\n 3. Is the port correct? (default: 8080)\n 4. Is a firewall blocking the connection?\n 5. If accessing remotely: is port forwarding or VPN set up?\n 6. Try "Open WebUI" above to test in a browser.',
@@ -576,12 +635,6 @@ export function SuperDebugPanel({
'The server did not respond within 15 seconds.\n\nThis usually means:\n 1. A firewall is silently dropping packets\n 2. The port is wrong (nothing is listening)\n 3. The server is too slow or overloaded\n\nTry "Open WebUI" above to verify in a browser.',
'warning',
);
- } else if (msg.includes('SSL') || msg.includes('certificate') || msg.includes('TLS')) {
- addEntry(
- 'WARN',
- 'The server was reached, but iOS rejected its TLS certificate. If you do not have HTTPS configured, turn off the "Use HTTPS" toggle. If you are intentionally using a self-signed certificate, enable "Allow Self-Signed Certificate" in the Security section above — trusting the certificate on this device alone is not enough for a third-party app to accept it.',
- 'warning',
- );
} else {
addEntry('WARN', `Error: ${msg}`, 'warning');
}
diff --git a/locales/de/translation.json b/locales/de/translation.json
index d62a255..7eb8923 100644
--- a/locales/de/translation.json
+++ b/locales/de/translation.json
@@ -776,6 +776,7 @@
"useHttps": "HTTPS verwenden",
"allowInsecureCert": "Nicht vertrauenswürdiges, selbstsigniertes Zertifikat zulassen",
"allowInsecureCertHint": "Akzeptiert das Zertifikat dieses Servers, auch wenn iOS ihm nicht vertraut. Aktiviere dies nur für einen Server, den du selbst kontrollierst — der Schutz vor einer gefälschten oder abgefangenen Verbindung entfällt dadurch.",
+ "allowInsecureCertUnavailable": "Erfordert die neueste Version aus dem App Store.",
"authMethod": "Authentifizierungsmethode",
"authMethodPassword": "Benutzername und Passwort",
"authMethodApiKey": "API-Schlüssel",
diff --git a/locales/en/translation.json b/locales/en/translation.json
index 0734e69..a177053 100644
--- a/locales/en/translation.json
+++ b/locales/en/translation.json
@@ -797,6 +797,7 @@
"useHttps": "Use HTTPS",
"allowInsecureCert": "Allow Untrusted, Self-Signed Certificate",
"allowInsecureCertHint": "Accepts this server's certificate even if iOS does not trust it. Only enable this for a server you control — it removes protection against a spoofed or intercepted connection.",
+ "allowInsecureCertUnavailable": "Requires the latest App Store version.",
"authMethod": "Authentication Method",
"authMethodPassword": "Username & Password",
"authMethodApiKey": "API Key",
diff --git a/locales/es/translation.json b/locales/es/translation.json
index 9236dab..8daf507 100644
--- a/locales/es/translation.json
+++ b/locales/es/translation.json
@@ -776,6 +776,7 @@
"useHttps": "Usar HTTPS",
"allowInsecureCert": "Permitir certificado autofirmado y no confiable",
"allowInsecureCertHint": "Acepta el certificado de este servidor aunque iOS no confíe en él. Actívalo solo para un servidor que controles: elimina la protección frente a una conexión suplantada o interceptada.",
+ "allowInsecureCertUnavailable": "Requiere la última versión de la App Store.",
"authMethod": "Método de autenticación",
"authMethodPassword": "Usuario y contraseña",
"authMethodApiKey": "Clave de API",
diff --git a/locales/fr/translation.json b/locales/fr/translation.json
index 1a6b797..5298f61 100644
--- a/locales/fr/translation.json
+++ b/locales/fr/translation.json
@@ -776,6 +776,7 @@
"useHttps": "Utiliser HTTPS",
"allowInsecureCert": "Autoriser un certificat auto-signé non approuvé",
"allowInsecureCertHint": "Accepte le certificat de ce serveur même si iOS ne lui fait pas confiance. N'activez ceci que pour un serveur que vous contrôlez : cela supprime la protection contre une connexion usurpée ou interceptée.",
+ "allowInsecureCertUnavailable": "Nécessite la dernière version de l'App Store.",
"authMethod": "Méthode d'authentification",
"authMethodPassword": "Nom d'utilisateur et mot de passe",
"authMethodApiKey": "Clé API",
diff --git a/locales/ru/translation.json b/locales/ru/translation.json
index ca184d9..dcfc914 100644
--- a/locales/ru/translation.json
+++ b/locales/ru/translation.json
@@ -797,6 +797,7 @@
"useHttps": "Использовать HTTPS",
"allowInsecureCert": "Разрешить недоверенный самоподписанный сертификат",
"allowInsecureCertHint": "Принимает сертификат этого сервера, даже если iOS ему не доверяет. Включайте только для сервера, который контролируете вы сами — это отключает защиту от подмены или перехвата соединения.",
+ "allowInsecureCertUnavailable": "Требуется последняя версия из App Store.",
"authMethod": "Способ авторизации",
"authMethodPassword": "Имя пользователя и пароль",
"authMethodApiKey": "API-ключ",
diff --git a/locales/zh/translation.json b/locales/zh/translation.json
index 1bb8b0d..cb81c04 100644
--- a/locales/zh/translation.json
+++ b/locales/zh/translation.json
@@ -776,6 +776,7 @@
"useHttps": "使用 HTTPS",
"allowInsecureCert": "允许不受信任的自签名证书",
"allowInsecureCertHint": "即使 iOS 不信任此服务器的证书,也接受该证书。仅对您自己掌控的服务器启用此选项——它会移除对伪造或被拦截连接的防护。",
+ "allowInsecureCertUnavailable": "需要最新的 App Store 版本。",
"authMethod": "认证方式",
"authMethodPassword": "用户名和密码",
"authMethodApiKey": "API 密钥",
diff --git a/modules/insecure-cert-allowlist/index.ts b/modules/insecure-cert-allowlist/index.ts
index 0203d14..bba1c1b 100644
--- a/modules/insecure-cert-allowlist/index.ts
+++ b/modules/insecure-cert-allowlist/index.ts
@@ -35,3 +35,22 @@ try {
export function setInsecureCertAllowedHosts(hosts: string[]): void {
nativeModule?.setAllowedHosts(hosts);
}
+
+/**
+ * True only when the native module is actually linked into this running
+ * binary — not just when the JS wrapper above loaded without throwing.
+ *
+ * OTA (over-the-air) JS updates ship independently of the native binary
+ * (`app.config.js`'s `runtimeVersion.policy: 'appVersion'` ties an OTA
+ * update to *any* binary on the same app version, native code included or
+ * not). So a device that installed this feature's JS via an OTA update, but
+ * whose binary predates the native module being added, gets a
+ * `nativeModule` of `null` here forever — `setInsecureCertAllowedHosts`
+ * above silently no-ops, and the "Allow Untrusted, Self-Signed Certificate"
+ * toggle looks like it does nothing. Callers use this flag to warn (see
+ * `services/server-manager.ts`'s `syncInsecureCertAllowlist`) or hint in the
+ * UI instead of failing silently (#256).
+ */
+export function isInsecureCertAllowlistAvailable(): boolean {
+ return nativeModule !== null;
+}
diff --git a/services/api/client.ts b/services/api/client.ts
index d67b223..a7b45dc 100644
--- a/services/api/client.ts
+++ b/services/api/client.ts
@@ -7,6 +7,7 @@ import axios, { AxiosInstance, AxiosError, AxiosHeaders, InternalAxiosRequestCon
import { ServerConfig } from '@/types/api';
import { clogDebug, clogInfo, clogWarn, clogError } from '@/services/connectivity-log';
import { ApiFeatures, getApiFeatures } from '@/utils/apiVersion';
+import { isTlsRejection } from '@/utils/error';
import { basicAuthHeader } from '@/utils/basicAuth';
import { isReservedHeaderName } from '@/utils/customHeaders';
@@ -243,6 +244,21 @@ class ApiClient {
// Handle network errors
if (error.code === 'ECONNABORTED' || error.code === 'ERR_NETWORK') {
+ // iOS's TLS-certificate rejection (NSURLErrorServerCertificateUntrusted,
+ // -1202, and friends) surfaces here too — as a plain ERR_NETWORK with
+ // no distinguishing code (#256). Without this, a rejected self-signed
+ // certificate is indistinguishable from a genuinely dead server, so
+ // nobody can tell what's wrong from the app alone. isTlsRejection reads
+ // the native error description RN stashes on the XHR (see utils/error.ts)
+ // to tell the two apart. This is a *new*, separate message — do not fold
+ // it into 'Connection timeout...' below, which callers substring-match.
+ if (isTlsRejection(error)) {
+ clogWarn('TLS', `Certificate rejected — ${reqUrl}`);
+ throw apiError(
+ 'Certificate rejected. Enable "Allow Untrusted, Self-Signed Certificate" for this server if you trust it.',
+ status,
+ );
+ }
clogError('HTTP', `Network error (${error.code}) — ${reqUrl}`);
throw apiError('Connection timeout. Please check your server connection.', status);
}
diff --git a/services/server-manager.ts b/services/server-manager.ts
index 75b0e97..2ac1d10 100644
--- a/services/server-manager.ts
+++ b/services/server-manager.ts
@@ -14,7 +14,10 @@ import { apiClient } from './api/client';
import { authApi } from './api/auth';
import { applicationApi } from './api/application';
import { clogInfo, clogWarn, clogError } from './connectivity-log';
-import { setInsecureCertAllowedHosts } from '@/modules/insecure-cert-allowlist';
+import {
+ setInsecureCertAllowedHosts,
+ isInsecureCertAllowlistAvailable,
+} from '@/modules/insecure-cert-allowlist';
/**
* Pushes every host opted into `allowInsecureCert` to the native TLS
@@ -23,8 +26,18 @@ import { setInsecureCertAllowedHosts } from '@/modules/insecure-cert-allowlist';
* change before the next connection attempt.
*/
function syncInsecureCertAllowlist(servers: ServerConfig[]): void {
- const hosts = servers
- .filter((s) => s.allowInsecureCert)
+ const wantAllowlist = servers.filter((s) => s.allowInsecureCert);
+ // A server can have this flag set while the native module is absent from
+ // the running binary (OTA JS on a pre-#256 binary — see
+ // modules/insecure-cert-allowlist/index.ts). The toggle silently no-ops in
+ // that case; warn so a connection failure doesn't look unexplained.
+ if (wantAllowlist.length > 0 && !isInsecureCertAllowlistAvailable()) {
+ clogWarn(
+ 'CERT',
+ `${wantAllowlist.length} server(s) have "Allow Untrusted, Self-Signed Certificate" enabled, but this build has no native allowlist module — the toggle will not take effect until the app is updated from the App Store.`,
+ );
+ }
+ const hosts = wantAllowlist
.flatMap((s) => [s.host, s.fallbackHost])
.filter((h): h is string => !!h);
setInsecureCertAllowedHosts(hosts);
diff --git a/tests/services/client.test.ts b/tests/services/client.test.ts
index a5d613f..bb7a2db 100644
--- a/tests/services/client.test.ts
+++ b/tests/services/client.test.ts
@@ -49,6 +49,7 @@ class MockAxiosError extends Error {
code?: string;
config?: Record;
response?: { status?: number; data?: unknown; headers?: Record };
+ request?: { response?: unknown };
isAxiosError = true;
constructor(message?: string) {
super(message);
@@ -430,6 +431,26 @@ describe('apiClient', () => {
);
});
+ it('normalizes an ERR_NETWORK carrying a TLS-rejection description to a distinct certificate error (#256)', () => {
+ const err = makeErr({
+ code: 'ERR_NETWORK',
+ request: {
+ response:
+ 'The certificate for this server is invalid. You might be connecting to a server that is pretending to be "example.com".',
+ },
+ });
+ expect(() => capturedResponseInterceptorError!(err)).toThrow(
+ 'Certificate rejected. Enable "Allow Untrusted, Self-Signed Certificate" for this server if you trust it.',
+ );
+ });
+
+ it('does not mistake an ordinary ERR_NETWORK for a TLS rejection (#256)', () => {
+ const err = makeErr({ code: 'ERR_NETWORK', request: { response: '' } });
+ expect(() => capturedResponseInterceptorError!(err)).toThrow(
+ 'Connection timeout. Please check your server connection.',
+ );
+ });
+
it('normalizes ERR_CANCELED to a distinct canceled error, not the timeout message (#254)', () => {
const err = makeErr({ code: 'ERR_CANCELED' });
expect(() => capturedResponseInterceptorError!(err)).toThrow('Request canceled.');
diff --git a/tests/utils/error.test.ts b/tests/utils/error.test.ts
index 4f42287..126217a 100644
--- a/tests/utils/error.test.ts
+++ b/tests/utils/error.test.ts
@@ -1,5 +1,5 @@
import { AxiosError } from 'axios';
-import { getErrorMessage, isAxiosError } from '@/utils/error';
+import { getErrorMessage, isAxiosError, isTlsRejection } from '@/utils/error';
describe('getErrorMessage', () => {
it('returns the message of an Error instance', () => {
@@ -38,3 +38,63 @@ describe('isAxiosError', () => {
expect(isAxiosError({ isAxiosError: true })).toBe(false);
});
});
+
+describe('isTlsRejection', () => {
+ it('recognizes an English TLS-rejection description on error.request.response', () => {
+ const err = {
+ message: 'Network Error',
+ request: {
+ response:
+ 'The certificate for this server is invalid. You might be connecting to a server that is pretending to be "example.com" which could put your confidential information at risk.',
+ },
+ };
+ expect(isTlsRejection(err)).toBe(true);
+ });
+
+ it('recognizes a non-English (Spanish) description, since the text is localized (#256)', () => {
+ const err = {
+ message: 'Network Error',
+ request: {
+ response: 'El certificado de este servidor no es válido.',
+ },
+ };
+ expect(isTlsRejection(err)).toBe(true);
+ });
+
+ it('recognizes a language-neutral SSL/TLS keyword even without the word "certificate"', () => {
+ const err = { message: 'An SSL error occurred while establishing a connection.' };
+ expect(isTlsRejection(err)).toBe(true);
+ });
+
+ it('falls back to error.message when error.request.response is absent', () => {
+ const err = new Error('The certificate for this server is invalid.');
+ expect(isTlsRejection(err)).toBe(true);
+ });
+
+ it('is case-insensitive', () => {
+ const err = { message: 'TLS handshake failed' };
+ expect(isTlsRejection(err)).toBe(true);
+ });
+
+ it('returns false for a generic network error with no cert wording', () => {
+ const err = { message: 'Network Error', request: { response: '' } };
+ expect(isTlsRejection(err)).toBe(false);
+ });
+
+ it('returns false for a plain timeout', () => {
+ expect(
+ isTlsRejection(new Error('Connection timeout. Please check your server connection.')),
+ ).toBe(false);
+ });
+
+ it('returns false for non-object values', () => {
+ expect(isTlsRejection('string')).toBe(false);
+ expect(isTlsRejection(null)).toBe(false);
+ expect(isTlsRejection(undefined)).toBe(false);
+ });
+
+ it('ignores a non-string error.request.response', () => {
+ const err = { message: 'Network Error', request: { response: { some: 'object' } } };
+ expect(isTlsRejection(err)).toBe(false);
+ });
+});
diff --git a/utils/error.ts b/utils/error.ts
index db83c96..9cb42b7 100644
--- a/utils/error.ts
+++ b/utils/error.ts
@@ -21,3 +21,51 @@ export function getErrorStatus(error: unknown): number | undefined {
}
return undefined;
}
+
+/**
+ * "certificate" in each of the six locales this app ships (see
+ * locales/*\/translation.json), plus the language-neutral technical terms
+ * "SSL"/"TLS" that iOS's error descriptions tend to carry regardless of
+ * device language. Used to recognize a TLS-rejection failure (NSURLError
+ * -1202 and friends) from free-text error descriptions that are localized
+ * to the device's language — matching only the English word would miss
+ * most non-English users.
+ */
+const TLS_REJECTION_KEYWORDS = [
+ 'ssl',
+ 'tls',
+ 'certificate', // en
+ 'certificado', // es
+ '证书', // zh
+ 'certificat', // fr
+ 'zertifikat', // de
+ 'сертификат', // ru
+];
+
+/**
+ * Best-effort text to sniff for TLS-rejection wording. iOS's rejected-cert
+ * failure surfaces to JS as a plain ERR_NETWORK with no error code
+ * preserved — the useful detail (the native NSError's localizedDescription)
+ * is not on the error at all, but React Native's XHR bridge stashes it in
+ * the response body on error instead of forwarding the NSURLErrorDomain
+ * code (see XMLHttpRequest.js / RCTNetworking.mm). Axios attaches that XHR
+ * as `error.request` and leaves `error.request.response` holding that text
+ * for a non-JSON request, so that's checked first; `.message` is the
+ * fallback for callers that don't go through axios's XHR adapter.
+ */
+function extractErrorText(error: unknown): string {
+ if (!error || typeof error !== 'object') return '';
+ const request = (error as { request?: { response?: unknown } }).request;
+ const responseText = request && typeof request.response === 'string' ? request.response : '';
+ if (responseText) return responseText;
+ const message = (error as { message?: unknown }).message;
+ return typeof message === 'string' ? message : '';
+}
+
+/** True when `error` looks like iOS rejecting the server's TLS certificate
+ * rather than a genuinely unreachable server — see `extractErrorText`. */
+export function isTlsRejection(error: unknown): boolean {
+ const text = extractErrorText(error).toLowerCase();
+ if (!text) return false;
+ return TLS_REJECTION_KEYWORDS.some((keyword) => text.includes(keyword));
+}