Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 23 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/`)

Expand All @@ -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
Expand Down Expand Up @@ -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.
6 changes: 6 additions & 0 deletions app/server/[id].tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ import {
sanitizeCustomHeaders,
validateCustomHeaders,
} from '@/utils/customHeaders';
import { isInsecureCertAllowlistAvailable } from '@/modules/insecure-cert-allowlist';

export default function EditServerScreen() {
const router = useRouter();
Expand Down Expand Up @@ -890,6 +891,11 @@ App Version: ${APP_VERSION}`;
<Text style={[styles.hintText, { color: colors.textSecondary }]}>
{t('server.allowInsecureCertHint')}
</Text>
{!isInsecureCertAllowlistAvailable() && (
<Text style={[styles.hintText, { color: colors.warning }]}>
{t('server.allowInsecureCertUnavailable')}
</Text>
)}
</>
)}
</View>
Expand Down
6 changes: 6 additions & 0 deletions app/server/add.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ import {
sanitizeCustomHeaders,
validateCustomHeaders,
} from '@/utils/customHeaders';
import { isInsecureCertAllowlistAvailable } from '@/modules/insecure-cert-allowlist';

export default function AddServerScreen() {
const router = useRouter();
Expand Down Expand Up @@ -810,6 +811,11 @@ App Version: ${APP_VERSION}`;
<Text style={[styles.hintText, { color: colors.textSecondary }]}>
{t('server.allowInsecureCertHint')}
</Text>
{!isInsecureCertAllowlistAvailable() && (
<Text style={[styles.hintText, { color: colors.warning }]}>
{t('server.allowInsecureCertUnavailable')}
</Text>
)}
</>
)}
</View>
Expand Down
127 changes: 90 additions & 37 deletions components/SuperDebugPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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<string, string>,
signal: AbortSignal,
): Promise<ReachProbeResponse> {
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
Expand Down Expand Up @@ -325,21 +386,13 @@ export function SuperDebugPanel({
const authHeader = buildAuthHeader();
const reachHeaders: Record<string, string> = { ...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;

Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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;
Expand All @@ -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.',
Expand All @@ -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');
}
Expand Down
1 change: 1 addition & 0 deletions locales/de/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions locales/en/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions locales/es/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions locales/fr/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions locales/ru/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -797,6 +797,7 @@
"useHttps": "Использовать HTTPS",
"allowInsecureCert": "Разрешить недоверенный самоподписанный сертификат",
"allowInsecureCertHint": "Принимает сертификат этого сервера, даже если iOS ему не доверяет. Включайте только для сервера, который контролируете вы сами — это отключает защиту от подмены или перехвата соединения.",
"allowInsecureCertUnavailable": "Требуется последняя версия из App Store.",
"authMethod": "Способ авторизации",
"authMethodPassword": "Имя пользователя и пароль",
"authMethodApiKey": "API-ключ",
Expand Down
1 change: 1 addition & 0 deletions locales/zh/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -776,6 +776,7 @@
"useHttps": "使用 HTTPS",
"allowInsecureCert": "允许不受信任的自签名证书",
"allowInsecureCertHint": "即使 iOS 不信任此服务器的证书,也接受该证书。仅对您自己掌控的服务器启用此选项——它会移除对伪造或被拦截连接的防护。",
"allowInsecureCertUnavailable": "需要最新的 App Store 版本。",
"authMethod": "认证方式",
"authMethodPassword": "用户名和密码",
"authMethodApiKey": "API 密钥",
Expand Down
Loading
Loading