Skip to content

Latest commit

 

History

History
986 lines (843 loc) · 87.8 KB

AGENTS.md

File metadata and controls

986 lines (843 loc) · 87.8 KB

AGENTS.md

qRemote is an iOS-only React Native (Expo SDK 57) app for remotely controlling qBittorrent servers over the WebUI API v2.

Read this file top to bottom once. The File Index is a complete map — trust it instead of re-exploring, and open only the files you're actually changing.

This is a living document, not a snapshot. It drifts — a rename lands and a File Index entry doesn't follow, a branch gets retired and the branching section still names it. §8 Rule 8 says not to trust it blindly; the flip side is: when you hit something it got wrong, or something that cost you real time because nothing here warned you, fix it in the same change — correct the stale claim where it lives, or add a line to §10 Gotchas if it doesn't have a natural home. A five-minute fix now is cheaper than every future session re-learning the same thing.

How to work a task

  1. Read it as an API question first — §1: which endpoint, does it break stored data, does it still work on qBittorrent 4.x?
  2. Locate files in the File Index. Don't search the tree.
  3. Copy the nearest sibling. Whatever you're adding — a screen, a test, a settings row, a loading or empty state — one like it already exists. Match it instead of inventing a pattern.
  4. Edit, checking as you go. Run the narrow thing for what you touched — that one suite, or tsc after a type change — piped through tail (§1). Trivial edits need nothing.
  5. Reply in a few lines: what changed, file links, anything surprising.
  6. Stop. Commit only when asked — and when asked, go all the way to a PR (§1), which starts with the full commit-time batch.

Decide, don't ask. When something's ambiguous, make the reasonable call and name it in your reply so it can be corrected. Stop only when genuinely blocked.

Don't spawn subagents unless the user asks for one by name or says something like "thorough" or "review." Each one starts cold and re-derives context you already have.


1. Working Agreement

How to work in this repo, before anything about the code itself.

Think in API terms first

The target is the qBittorrent WebUI API v2 (5.0 reference). Almost every request the user makes is an API question wearing a UI costume. Work through these three before writing code, and raise a problem early rather than shipping a plausible-looking guess:

1. What API will I be using? Name the specific endpoint(s) and confirm they exist in the 5.0 wiki, along with their exact parameter names and response fields. If nothing in the API supports the request, say so up front — that's far more useful than an implementation built on an endpoint that isn't there.

2. Will this break existing users? The app has users with data on their devices. Anything that renames or changes the meaning of a stored key silently breaks them, and there is no migration system. Check against: preference keys (types/preferences.ts), colors keys (ThemeContext), stored ServerConfig records, and saved color themes. Adding is safe; renaming and repurposing are not. See §8 Critical Rules.

3. Does it still work on qBittorrent 4.x? (4.1 reference) Not every feature has to — some endpoints genuinely only exist in 5.0 — but the main features must stay usable on 4.x. When a capability is version-dependent, gate it rather than dropping 4.x support.

Gating lives in utils/apiVersion.ts (ApiFeatures), keyed on the WebAPI version rather than the qBittorrent version:

  • WebAPI ≥ 2.11.0 = qBittorrent 5.0 — the main boundary. Start/stop endpoints, content_path, inactive-seeding limits, cookies, search/downloadTorrent, getDirectoryContent.
  • WebAPI ≥ 2.8.0 = qBittorrent 4.3.x — ratio and seeding-time limit fields.
  • Unknown or unparseable version → assume the 5.0 feature set, so a detection failure doesn't silently downgrade a modern server.

⚠️ A wrong parameter name looks like success. qBittorrent silently drops form fields it doesn't recognize and still returns 200. So a 5.0-only parameter sent to a 4.x server doesn't error — the feature just quietly does nothing. qBit 5.0 renamed several things (paused → stopped on torrents/add, start_paused_enabled → add_stopped_enabled in preferences), and these renames are per-site: handling one does not handle the others. When you add a version-dependent parameter, gate it and verify against the older wiki.

Run checks freely, quietly

The checks are fast. The output is not. Measured on this repo:

Command Wall time
One suite — npm test -- tests/utils/format.test.ts ~1s
npm test (both projects, 1000+ tests) ~5s
npx tsc --noEmit ~1s warm, ~3s cold (incremental via .tsbuildinfo)
npm run lint ~6s

So wall time is not the constraint — context is. npm test prints hundreds of lines of unrelated act() warnings and Animated stack traces from Confetti / PathAutocompleteInput, and every line of that stays in the conversation and is re-sent on every later turn.

Therefore: run whatever you need, but pipe it.

npm test 2>&1 | tail -5          # summary only
npx tsc --noEmit; echo "EXIT:$?" # silent when clean
npm run lint 2>&1 | tail -3      # the problem count

Run the narrow thing while you work — one suite after touching one module, tsc after a type change. Catching a broken test one second after you write it is far cheaper than discovering it at commit time and re-deriving what you did. Trivial edits still need nothing — a comment, a copy tweak, a doc line.

Only widen to a full unpiped run when something actually fails and you need the detail; then read the failure, not the whole log.

Before a commit → the full batch. See Commit-time checks.

Read narrowly

Same reason as above — output is permanent and re-sent every turn:

  • Never cat a whole file to answer a narrow question. Use grep -n (with -A/-B for context) or a ranged read. Reach for the file's shape first — grep -n "^export\|^## " beats reading it.
  • Especially never dump generated, lock, or config-heavy files: package-lock.json, anything under ios/, .github/workflows/* (they embed long inline scripts), coverage output, dist/.
  • Don't re-list the tree. The File Index is complete and maintained. Use it instead of find or a recursive ls.
  • Don't re-read a file you just edited to confirm the edit — the edit tool already failed loudly if it didn't apply.
  • Read the whole file when you're about to change it substantially. This rule is about avoiding incidental bulk, not about editing blind.

Commit-time checks

Only now do you run the whole thing — once, as a batch:

Command Bar
npx tsc --noEmit Exit 0. Currently clean. Incremental via .tsbuildinfo.
npm test All passing, both projects — see Testing.
npm run lint Zero errors. Warnings are baseline noise (currently 37); the count drifts, don't chase it.
npm run format Prettier. Run it last, so it also formats anything you just changed.

The whole batch is ~12s. Pipe each through tail unless it fails.

Then, in the same pre-commit pass:

  • Update the File Index if you added, removed, or renamed a module. The index promises to be complete; a stale entry is what forces the next session to re-explore, which costs far more than this edit.
  • Changelog — only if the user explicitly asked (see above).

Changelog is opt-in

Do not touch constants/changelog.ts on your own initiative — not even for a user-facing change. Only edit it when the user explicitly asks ("update the changelog", "add a changelog entry"). When they do ask, follow docs/RELEASING.md exactly — read it at that point, not before.

Branches — always work on one

Never commit to main. It's protected by convention: work reaches it only through a PR. There is no exception for a one-line fix or a "quick" change.

Every change starts on its own branch cut from main:

git switch main && git pull && git switch -c bugfix/#123-short-description

Naming follows what's already in the repo — feature/…, bugfix/…, or fix/…, usually carrying the issue number (bugfix/#177, feature/#121). Agent-created branches use a claude/… prefix.

There is no hotfix exception. However urgent a fix is, it goes branch → PR → main. Never commit straight to main.

Branch Role
your branch Where every commit goes. Cut from main, merged back by PR.
main The only long-lived branch. Receives work by PR. Pushing it with "easBuild": true in package.json builds and submits to the App Store — see docs/RELEASING.md.
release/vX.Y.ZZ Occasionally used to gather a release's work before one PR to main. Only when the user sets one up — don't create one on your own.

develop and preview are retired (as of 2026-08-14). Neither branch exists. Older PRs (#218 and earlier) targeted preview; everything since #219 goes straight to main. If you find guidance anywhere referring to either, it's stale.

Commit and push only when asked. If you're asked to commit and you're sitting on main, branch first, then commit — don't ask whether the rule applies this time. If you're already on an unrelated branch (one cut for different work), cut a fresh one rather than mixing the histories.

When asked to commit, go all the way to a PR

"Commit this" means the full sequence, not just the commit:

  1. Run the commit-time checks and the File Index pass.
  2. Branch if you aren't already on one.
  3. Commit. No Co-Authored-By: Claude trailer on this repo.
  4. git push -u origin <branch>
  5. gh pr create --base main with a short summary and a test-plan line covering what you ran.

Stop there. Never merge the PR — review and merge are the user's.


2. Dev Commands

  • npm run xcode — the one command that gets you building. Fresh-clone-safe: npm install → npx expo prebuild -p ios → pod install → opens ios/qRemote.xcworkspace. Safe to re-run any time (after pulling native or dependency changes). Build/run with Cmd+R once Xcode is open.
  • npm start — Metro for the dev-client build (expo start --dev-client). Not --go: this app is bare with custom native code, so plain Expo Go can't run it. Run alongside a dev-client build for JS fast refresh.

3. iOS Native Workflow

ios/ is generated, not committed. It (and android/) have been gitignored since #154 (commit ba511cd). npm run xcode regenerates it from scratch, so a fresh clone builds with no committed native files. Whatever sits under ios/ locally is machine-local and disposable.

Consequences:

  • Native config goes in app.config.js, not in ios/. Info.plist keys, entitlements, URL schemes and document types all live in the ios.infoPlist block, which prebuild applies when generating the project. Hand-edits under ios/ are wiped by the next prebuild.
  • When app.config.js can't express it, write a config plugin. There's one precedent: plugins/withNativeTorrentFileCopy.js, which patches AppDelegate so an incoming .torrent is copied natively inside the open-URL callback — while the security-scoped access is still valid — instead of racing the async JS Linking bridge on cold launch.
  • When you need actual new native code (not a patch to a generated file), write a local Expo module under modules/. Precedent: modules/insecure-cert-allowlist — autolinked by Expo from modules/* with no config plugin needed, discovered automatically on npm run xcode's prebuild.
  • app.config.js is tested. tests/utils/app-config.test.ts asserts the magnet scheme and .torrent document-type registration survive. Update it if you change those blocks; the comments in app.config.js explain why each key is set the way it is (several encode hard-won App Store / Files.app fixes — read them before flipping a value).
  • Expo SDK upgrades (57 → 58…) are the normal managed path: bump the packages, re-run npm run xcode, and prebuild regenerates from the new template.

Android support was removed entirely — no platform, no build target, no plan to re-add one without being asked.


4. Architecture

  • Routing — Expo Router, file-based, in app/. Parenthesized (groupname) segments are route groups: they organize files but are omitted from URLs. The tab bar lives in app/(tabs)/_layout.tsx. Screens that must keep the tab bar visible live in nested Stacks under a tab, never as siblings of (tabs) on the root stack. The root stack (app/_layout.tsx) anchors on (tabs), so dismissing a modal doesn't wipe the tab navigator.
  • State — React Context + TanStack Query.
  • Data sync — TanStack Query with refetchInterval (2–3s); torrents use rid-based incremental sync through a custom queryFn.
  • Storage — AsyncStorage for preferences, expo-secure-store for secrets.
  • API — thin wrapper objects in services/api/ over one axios singleton (apiClient).
  • Styling — every color comes from useTheme(). Users can override any color via the in-app picker.
  • i18n — react-i18next, eight locales.
  • Deep links — magnet URLs and .torrent files arrive via a Linking listener in app/_layout.tsx. app/+native-intent.ts returns null for those URLs so Expo Router doesn't try to treat a file://…torrent path as a route and land the user on "Unmatched Route".

Per-server secrets

Reverse-proxy Basic Auth (useBasicAuth, #118) shows the pattern every per-server secret must follow:

  • Non-secret half (basicAuthUsername) → plain AsyncStorage via services/storage.ts.
  • Secret half (basicAuthPassword) → expo-secure-store under server_basic_auth_password_{id}, and forced to '' before the server record is written to AsyncStorage.
  • services/api/client.ts reads it back off the in-memory ServerConfig to build the header via utils/basicAuth.ts.

Custom headers (useCustomHeaders, #228) follow the same rule: the flag is plain, but the whole customHeaders array is a secret (values are tokens), JSON-stringified into server_custom_headers_{id} and forced to [] in AsyncStorage.

qui Client Proxy (useQuiProxy, #272) again: the flag is plain — and must be listed in saveServer's explicit field map — while quiProxyKey is a secret in server_qui_proxy_key_{id}, forced to '' in AsyncStorage (including deleteServer's whole-list rewrite), stripped by utils/server-export.ts, kept per-device by importServers, and blanked in the Settings → Advanced settings-import path. The key is a URL path segment, so it can also leak through anything that logs a URL: every clog / thrown message / debug-report site in services/api/client.ts and components/SuperDebugPanel.tsx runs URLs through redactQuiProxyKey (utils/quiProxy.ts). The client does this only for requests it stamped as qui (config.__quiProxy), because /proxy/<x> is also a legitimate qBittorrent base path that diagnostics must keep showing.

Be deliberate whenever you touch code that persists a ServerConfig — including paths that rewrite the whole server list, such as add, edit, delete, and import. A secret must never reach AsyncStorage, and bulk rewrites are the easiest place to let one slip through.

Auth modes

A server's auth mode is derived, not stored — see utils/authMode.ts (password | apiKey | none | quiProxy). Legacy records that only ever set bypassAuth keep working; when several flags are set the more specific wins: qui proxy > API key > none > password.

quiProxy (#272) connects through a qui Client Proxy URL http(s)://host[:port][/qui-base]/proxy/<key> with no username/password. There is no URL-path field on ServerConfig, so the pasted URL is parsed (utils/quiProxy.ts parseQuiProxyUrl) into host/port/useHttps/basePath (= the qui base path) plus the secret quiProxyKey, and services/api/client.ts re-joins <basePath>/proxy/<key> per request. qui's /auth/login is a no-op, so ServerManager takes the no-login path (like apiKey), skips logout on disconnect, sends no Bearer header (Basic Auth / custom headers still apply for a gateway in front of qui), and maps qui's 401 "Invalid API key" / "Missing API key" to QUI_KEY_REJECTED_MESSAGE. A qui server's fallback endpoint is a second qui URL for the same instance: only its host/port/HTTPS/base path are used and the primary's key is reused (no second secret). The add/edit forms hide username/password/API key and host/port/HTTPS in this mode and show one masked QuiProxyUrlField; the edit screen repopulates it with buildQuiProxyUrl (ending at /proxy/ for an imported server whose key was stripped). tests/services/qui-proxy-e2e.test.ts drives real axios against a local qui-shaped server — the other qui tests mock axios, so it is the only one that proves the key reaches the wire intact.


5. File Index

Complete map. Trust it.

Screens (app/, Expo Router)

Path Notes
app/(tabs)/(torrents)/ Torrents tab as a nested stack: index list, torrent/[hash], torrent/files, torrent/manage-trackers. Group is omitted from URLs → /, /torrent/[hash]. The index search field has a clear ✕ and a recent-searches dropdown (#282; prefs torrentSearchHistory, torrentSearchPinned, torrentSearchHistoryLimit; Settings → Appearance → Recent searches).
app/(tabs)/search.tsx Search tab: job polling UI, plugin/category/indexer filter chips, client-side sort (keys: seeders/leechers/size/name/date/quality — Quality, #268, ranks by the resolution in the release name via utils/video-quality.ts and keeps unknown-quality results last in both directions; the sort dropdown also hosts the persisted "Hide zero seeders" toggle, searchHideZeroSeeders pref, and the persisted "Group duplicates" toggle, searchGroupDuplicates pref, default on — #267), a funnel-button filter panel (components/SearchFilterPanel.tsx: "Search in" names-only/everywhere — persisted searchInMode pref — plus session-only text, seeders and size filters, WebUI parity, #266), collapsing header. Result pipeline (filteredResults → listItems): dedupe → tracker chips → panel filters → hide zero seeders → group duplicates → sort by each group's primary (with grouping off every result is a group of one, so the list is exactly as before). Grouping (#267) collapses results that look like one torrent — same magnet btih, or same normalized name + exact size, confirmed on qBit 5.2+ by resolving duplicate-looking .torrent links through torrents/fetchMetadata — into one SearchResultGroupRow; the row's + adds the whole group (one merged magnet carrying every source's trackers, or the primary .torrent plus an addTrackers top-up once the torrent exists), a source's own + adds only that source. "names only" matches the last submitted pattern, not the live query box. Optional auto-tag-by-tracker on add (autoCategorizeByTracker pref — tags Search downloads only; the key name is historical). Tapping a plugin chip's icon pins/unpins it (FilterChip onIconPress, searchPinnedPlugins pref); chips show an outline star when unpinned and a filled star when pinned; pinned chips sort first, and the row scrolls back to the start after pinning (#283). + hands a plugin-resolved result's plugin to both add dialogs as the sourcePlugin route param, and cart items carry downloader (#284).
app/(tabs)/transfer.tsx Transfer stats, global speed and seeding limits.
app/(tabs)/logs.tsx qBittorrent's own server-side application + peer log viewer (logs/main, logs/peers via services/api/logs.ts) — needs a live connection, shows a "not connected" placeholder otherwise. href: null — reached from Settings → Advanced ("Server Logs" row), not a visible tab. Not the app's own connectivity/diagnostic log — see components/LogViewer.tsx for that.
app/(tabs)/rss/ RSS Feeds tab (index tree + feed detail). href is null until connected and the server's rss_processing_enabled is on. Rules and settings screens do not go here — they live under Settings.
app/(tabs)/settings/ Settings tab as a nested stack. See sub-screens below.
app/(tabs)/_layout.tsx Tab bar and tab gating.
app/_layout.tsx Root providers, theme, deep-link handling. Anchors on (tabs).
app/+native-intent.ts Suppresses Router navigation for magnet / .torrent URLs.
app/torrents/add.tsx Add-torrent flow (magnet or file, plus options). Root stack → no tab bar. Uses PathAutocompleteInput.
app/search/plugins.tsx Search plugin install/enable/uninstall (app/search/_layout.tsx stack). Root stack. Also linked from the Settings hub.
app/server/add.tsx, app/server/[id].tsx Server add/edit, presented as native modal sheets → they mount <ModalToast/> locally. The two are near-duplicates — a change to the form (auth methods, validation, the ServerConfig they build) must be made in both. qui Proxy auth mode (#272) swaps the host/port/HTTPS/credential fields for one QuiProxyUrlField.

Settings sub-screens — hub order on index is Servers → Appearance → Server Settings → Connection → RSS → Search Plugins → Advanced, then What's New → About, then Community links (source / issues / Beer Fund / Rate). Notifications & Feedback is nested under advanced, not on the hub.

about · add-torrent-dialogue · advanced · appearance · category-tag-colors · connection (qBit-side network settings, live from app/preferences — listen port, random port, UPnP, global/per-torrent connection and upload-slot limits, proxy server incl. auth, IP filtering/banned IPs, I2P (qBit 5.0+ / ApiFeatures.supportsI2p) — #233) · detailed-card-fields · notifications · rss · rss-rules · rss-rule · servers (list + secret-free export/import) · server-settings-advanced (qBit email/automation) · theme · torrent-defaults (nav label is Server Settings; route path unchanged) · whats-new

Do not recreate top-level app/settings/ or app/torrent/ trees, the old app/(tabs)/index.tsx / app/(tabs)/settings.tsx hub files, or the deleted app/(tabs)/rss/rule.tsx / rules.tsx. They were moved deliberately so the tab bar stays visible.

Contexts (context/)

  • ServerContext.tsx — connection lifecycle. checkAndReconnect() always does a full re-login (no session-validity probe) and de-dupes concurrent calls behind a shared in-flight promise. qBittorrent ties search jobs to the session, so never call it eagerly on foreground/AppState events — only reactively, after a request has actually failed. disconnect() clears the session but keeps currentServer for one-tap reconnect from Settings; call forgetCurrentServer() when that server is deleted, and updateCurrentServer() after editing it so one-tap Connect doesn't retry stale credentials. connectedAt tracks when the current connection began (derived from isConnected transitions, not each setIsConnected call site) for a client-side "Connected For" display — qBittorrent's server_state has no session-uptime field of its own (#232). isReconnecting is true only while checkAndReconnect() is in flight, set by the run that owns the shared promise — deliberately not folded into isLoading/isConnecting, whose consumers shouldn't change behavior for an automatic recovery. It's what lets the torrents list and torrent detail show a skeleton instead of stale data or a raw auth error during that window.
  • TorrentContext.tsx — rid-based incremental sync, plus the reactive auto-reconnect effect the other providers piggyback on. isRecoveringFromBackground covers the foreground re-sync; it's cleared only once a fetch newer than the pre-recovery dataUpdatedAt lands, never by that timestamp merely being nonzero (which cleared it instantly, since it holds the last success from potentially hours ago). A foreground return backgrounded for less than LONG_BACKGROUND_THRESHOLD_MS (constants/timing.ts, 10s) is treated as a quick app-switch: it skips the recovery dance entirely and just nudges an incremental refresh, so a brief glance at another app doesn't flash the recovery skeleton.
  • TransferContext.tsx — transfer-info poll; relies on TorrentContext's reconnect. Mirrors TorrentContext's quick-app-switch threshold (LONG_BACKGROUND_THRESHOLD_MS) for its own foreground recovery.
  • ToastContext.tsx + components/Toast.tsx — the global toast is a plain view. Never wrap it in an RN <Modal> — a Modal captures all touches and freezes the UI. Native-modal-sheet screens mount ModalToast locally instead.
  • ThemeContext.tsx — useTheme(), the colors object, user overrides.
  • ApiVersionContext.tsx — detected qBittorrent API version → feature gating through utils/apiVersion.ts.
  • SearchCartContext.tsx — session-scoped queue of Search results the user wants to add together (#217). Not persisted — plugin download URLs are often session/token-bound. Dedupes by fileUrl. Consumed by app/torrents/add.tsx (gated on the fromCart route param) and grouped for submission via utils/search-cart.ts.

Components (components/)

All PascalCase function components taking a …Props interface.

  • Modals / pickers — ActionMenu (anchored popover), ConfirmModal (themed confirm), InputModal (themed text input; optional pathAutocomplete prop), OptionPicker, MultiSelectPicker, CategoryModal, TagsModal, ColorPicker, IconPicker (grid picker for a server's badge icon, previewed in its badge color — see constants/serverIcons.ts), PathAutocompleteInput (live directory suggestions via app/getDirectoryContent, qBit 5.0+ / WebAPI ≥ 2.11 — silent no-op when unsupported; also renders the browse button), SavePathPickerModal (filterable list of save paths already in use, derived from TorrentContext via utils/save-paths.ts — works on any qBittorrent version), SearchCartModal (review sheet for the Search tab's add queue — list, per-item remove, Clear all, Checkout — see SearchCartContext.tsx), SearchFilterPanel (floating filter panel under the Search tab's search row — "Search in" chips, text filter, seeders and size min/max with cycling unit chips, Clear; controlled, state lives in search.tsx as a SearchFilterDraft — #266), ServerSwitcherModal (quick server switcher sheet, #249 — opened from a compact badge+name in the torrents screen header; lists saved servers, marks the connected one, taps another to call connectToServer directly; owns its own transient switching-id/error state rather than threading it through the screen, mirroring QuickConnectPanel's row treatment), SearchHistoryDropdown (#282 — recent/pinned searches popover under the Torrents tab search field; rows are pin/clock icon + query + pin toggle + remove, footer "Clear recent" keeps pinned; with no entries it explains how searches get saved, because the clock button shows whenever history is on (limit > 0), not only once something is saved. Presentational only; app/(tabs)/(torrents)/index.tsx owns open state, positioning (measured onLayout, mutually exclusive with the sort menu) and persistence).
  • Torrent / search UI — TorrentCard (React.memo with a custom comparator — keep it in sync when you add a rendered field, or the card silently stops updating; category/tag stickers use categoryColors/tagColors then defaults then the avatarColor fallback), SearchResultRow (+ internal ActionPill; the + button and the cart-toggle button are independent — see its header comment; takes an optional footer node), SearchResultGroupRow (#267 — one list row for a SearchResultGroup: a group of one is the plain SearchResultRow, a group of 2+ is its primary plus an "N sources" toggle and compact per-source add/cart rows in the card's footer), FilterChip, EmptyState, SkeletonLoader (+ SkeletonTorrentCard, SkeletonTorrentDetail — the latter covers the detail screen while a dead session reconnects), PieceMap, ServerIconBadge (per-server tinted icon badge — ServerConfig.icon/iconColor via utils/server.ts getServerIcon/getServerIconColor, falling back to a default icon and DEFAULT_AVATAR_COLOR — never the name-derived avatarColor, so a badge's color only ever changes when the user picks one), ServerAppearanceSection (icon + badge-color editor used by both app/server/add.tsx and app/server/[id].tsx — quick AVATAR_PALETTE swatches plus a "custom color" swatch that opens the full ColorPicker), CustomHeadersSection (per-server custom HTTP header key/value rows, max 5, used by the same two screens — see utils/customHeaders.ts), QuiProxyUrlField (the single masked "qui Proxy URL" input — eye toggle, inline parse error, key-masked preview, hint — shown by the same two screens for the quiProxy auth mode and for its fallback URL; see utils/quiProxy.ts).
  • Visuals — SpeedGraph, CircularProgress, AnimatedProgressBar, AnimatedButton, Confetti.
  • Chrome / diagnostics — FocusAwareStatusBar, SettingRow, QuickConnectPanel, LogViewer (modal viewer for the app's own in-memory connectivity log, services/connectivity-log.ts — copy-to-clipboard via formatConnectivityLog(); opened from Settings → Advanced's "View Connectivity Logs" row; works with no live server connection, unlike app/(tabs)/logs.tsx), DebugRow, SuperDebugPanel, ThemePresetsSection (Settings → Theme "Presets" row, #286 — horizontally scrolling swatch cards previewing each preset for the current light/dark mode, built from constants/themePresets.ts; default previews the live darkColors/lightColors. Presentational: highlights from a ThemePresetSelection prop and calls onSelect; the screen owns applying and the confirm over custom colors).

API wrappers (services/api/)

Thin objects over apiClient.

  • client.ts — the axios singleton. Holds server config, cookies, API version and the Basic Auth header, and normalizes HTTP failures into human-readable Errors. Callers substring-match those messages — grep before rewording one. postUrlEncodedWithStatus is postUrlEncoded plus the 2xx status, for endpoints that signal progress with 202 vs 200.
  • auth.ts (login/logout) · sync.ts (getMainData rid-sync, getTorrentPeers) · transfer.ts (global speed + seeding limits, alt-speed toggle, banPeers) · application.ts (version/buildInfo/preferences/cookies, getDirectoryContent) · categories.ts · tags.ts · logs.ts (main + peer logs) · rss.ts (feeds, folders, rules, moveItem) · search.ts (job start/stop/status/results/delete, plugin management, downloadTorrent).
  • torrents.ts — everything per-torrent: list/properties/trackers/webseeds/ contents/pieces; pause/resume/delete/recheck/reannounce; add (URL + file); tracker and peer edits; queue and file priorities; limits and share-limits; location/name/category/tags; AMM, sequential, first/last piece, force start, super seeding; renameFile/renameFolder; fetchMetadata (qBit 5.2+ / WebAPI ≥ 2.11.9, ApiFeatures.supportsFetchMetadata — resolve a URL/magnet to info hash + trackers without adding it; 202 = still downloading, 200 = ready).

Services (services/)

  • server-manager.ts — server CRUD, connect/reconnect/test (ConnectionTestResult, isNetworkError). exportServers()/importServers() back the server-list export/import; import preserves this device's stored secrets when an id already exists.
  • storage.ts — AsyncStorage preferences (typed shape and defaults in types/preferences.ts). Prefer updatePreferences(patch) for new writers: it queues writes and re-reads inside the queue, so overlapping saves can't drop each other's keys the way a bare getPreferences → savePreferences can.
  • incoming-file.ts — copies an incoming .torrent into the app cache before the iOS security-scoped access can lapse.
  • query-client.ts — the shared TanStack QueryClient.
  • color-theme-manager.ts — save/load/apply user color themes. applyThemePreset (#286) writes both modes and themePresetId in one savePreferences call (which overwrites, so deleting a key removes it).
  • connectivity-log.ts — in-memory ring log (clogDebug/Info/Warn/Error(tag, msg)), displayed by components/LogViewer.tsx.
  • search-tracker-topup.ts — #267: after a Search group of .torrent links is added, waits (up to ~3 min, abortable) for the torrent to appear by ID and calls addTrackers with the other sources' trackers. prepareTrackerTopUp first checks the client doesn't already have the torrent; everything is silent best-effort. Also exports abortableDelay and tagNewlyDownloadedTorrent (the best-effort tag-by-name poll after a search/downloadTorrent add, used by Search instant add and 5.0–5.1 cart checkout).

Native modules (modules/)

  • insecure-cert-allowlist — local Expo module (Swift + an Obj-C category on RCTHTTPRequestHandler) backing the per-server "Allow Self-Signed Certificate" toggle. RN's default iOS HTTP handler never implements the TLS challenge delegate method, so it always falls through to default system trust evaluation with zero override point — not even for a certificate the user has manually trusted on-device. The category fills in that delegate method and accepts the connection only for hosts JS has explicitly allow-listed (ServerConfig.allowInsecureCert, synced from 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). isInsecureCertAllowlistAvailable() (#256) reports whether the native side is actually present in the running binary — see §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/)

  • useSearchJob.ts — search job lifecycle: start/stop/delete, 2s status+results polling, unmount cleanup.
  • useSearchHashResolver.ts — #267: for Search results that look like duplicates, asks torrents/fetchMetadata (qBit 5.2+) for the .torrent links' info hash + trackers, returning fileUrl → ResolvedSource for groupSearchResults. Politely capped: 3 in flight, 30 per search job (most-seeded first), re-poll a 202 every 2s up to 5 calls, errors are silent, nothing is requested twice, and a new job or unmount aborts and ignores stale replies. downloader=<engineName> only when supported and the source isn't an aggregator (Prowlarr/Jackett).
  • useTorrentActions.ts — builds the per-torrent action menu for the list screen only. Delete exposes deleteConfirmVisible for a caller-mounted ConfirmModal. The detail screen does not use this hook — it hand-rolls its own parallel handlePauseResume/handleDelete/etc. and its own deleteConfirmVisible state. A new action (or a change to an existing one) needs both places touched, or list and detail silently diverge.
  • useReactiveReconnect.ts — feeds query errors into ServerContext reconnect (isReconnectableError).
  • useGracefulError.ts — suppresses a transient error until it has persisted ~2.5s, so a self-healing poll failure doesn't flash error UI.
  • useRssFeeds.ts / useRssRules.ts — RSS tree and auto-download rule state.
  • useSpeedTracker.ts / useSpeedHistory.ts — sampling for SpeedGraph.

Utils (utils/)

Pure and well-tested. Put logic here whenever it doesn't need React.

format.ts (size/speed/time/ratio/percent/progress/availability/date — progress 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) · localize-error.ts (localizeErrorMessage(message, t) — display-time translation of the fixed English errors thrown by services/api/client.ts / auth.ts / server-manager.ts, which callers substring-match and so can't be translated at the source; unknown text passes through. Its test fails if a mapped message is reworded in the services) · 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 every locale 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 legitimately saved retryAttempts: 0) · server.ts (endpoint resolution incl. fallback URL, avatar colors, and getServerIcon/getServerIconColor for the per-server badge — #224) · authMode.ts (derives password/apiKey/none/quiProxy) · basicAuth.ts · quiProxy.ts (qui Client Proxy URLs, #272 — parseQuiProxyUrl/buildQuiProxyUrl for the pasted …/proxy/<key> URL, withQuiProxyPath for the request path, redactQuiProxyKey for logs/errors/debug text, detectQuiProxyUrlInHost (#278 — a qui URL pasted into the plain Host field flips the add/edit form to qui Proxy mode), and the QUI_KEY_* messages ServerManager matches) · customHeaders.ts (per-server custom HTTP headers — sanitize/validate, and the reserved-name set the app manages itself: Authorization, Cookie, Referer, Origin, Content-Type, Host — #228) · magnet.ts / torrent-file.ts (incoming link and file parsing) · rss.ts (RSS tree flattening; paths join with \; getRssFeedDisplayName — a feed's name is its path, which qBit defaults to the URL, so a never-renamed feed shows its own title and a rename wins, #273) · searchResult.ts (indexer-label heuristics) · search-filters.ts (filterSearchResults(results, opts) — client-side Search result filters: hideZeroSeeders (hides only an explicit nbSeeders === 0, never the -1 "unknown" sentinel — #270) and the #266 WebUI-parity filters nameTerms / filterText (all whitespace-separated terms must be in the name, case-insensitive, -term excludes — no quotes, no accent folding, same as WebUI containsAllTerms), seeders and size ranges (0/unset = no bound, swapped min/max are reordered; unlike the WebUI, unknown -1/0 values are kept by a range), and qualities (#268 — keep only 720p / 1080p / 2160p names via video-quality.ts; unrecognized quality is hidden while one is selected). Also tokenizeTerms, parseSizeInput/parseSeedersInput, hasActiveFilters, and the SearchFilterDraft ⇄ options helpers behind SearchFilterPanel. Runs before the sort in search.tsx) · search-grouping.ts (#267 — pure Search-result grouping: extractBtih (hex or base32 magnet hash → lowercase hex), normalizeName/nameSizeKey, findResolutionCandidates (duplicate-looking .torrent links worth resolving), groupSearchResults(results, resolved) → SearchResultGroup[] (primary = most seeders; known hashes always win over the name+size guess, so two different hashes never merge), mergeMagnets (union of tr=), planGroupAdd/collectGroupTrackers (how adding a group works)) · video-quality.ts (getVideoQualityRank(fileName) — resolution rank parsed from a release name by whole alphanumeric token: 2160p/4K/UHD = 4, 1440p = 3.5, 1080p/1080i/FHD = 3, 720p = 2, 576p–240p/SD/DVD = 1, unknown = 0; never reads x264/h264 or a bare 1080/year as a resolution, and deliberately skips ambiguous "2K". getVideoQualityLabel(rank) for display, and compareByQuality(a, b, dir) — the Search "Quality" sort comparator: unknown last in both directions, ties by seeders desc with -1 as 0 — #268) · search-history.ts (#282 — pure Torrents-tab recent-search lists: recordQuery (trim, ignore <2 chars, case-insensitive dedupe keeping latest casing, front, trim to limit, limit 0 = off, never duplicates a pinned entry), togglePin (recent ⇄ pinned, max MAX_PINNED_SEARCHES = 10, pinned entries don't count toward the limit), removeQuery, clearUnpinned, applyHistoryLimit, getHistoryEntries (pinned first); SEARCH_HISTORY_LIMIT_OPTIONS backs the Appearance picker) · search-plugins.ts (#283 — Search plugin pinning: orderPluginsWithPins(sortedPlugins, pinned) moves pinned plugins to the front of the already-sorted list, keeping the incoming enabled-then-name order within each group and ignoring pinned names that aren't installed; prunePinnedPlugins(pinned, installed) drops uninstalled and duplicate names; togglePinnedPlugin(pinned, name, installed) is the chip-icon tap — unpins a pinned plugin, otherwise pins an installed one — and the result is what the searchPinnedPlugins pref saves) · search-download.ts (#284 — how a Search result's URL is added through the plugin that produced it. isAggregatorResult(result) is true for Jackett/Prowlarr results, detected per result by engineName matching /jackett|prowlarr/i or an apikey= / jackett_apikey= query param in the fileUrl; it deliberately does not infer this from "all results share one site". getResultDownloader(result) returns the result's engineName for non-magnet, non-aggregator results with an engine name, and is the single rule shared by search.tsx's instant add, the sourcePlugin handoff to both add dialogs, cart items' downloader, and useSearchHashResolver. planPluginUrlAdd(downloader, features) returns addWithDownloader (torrents/add + downloader, WebAPI ≥ 2.13.1, all options kept), searchDownloadTorrent (5.0–5.1, default options only) or plainAdd (4.x). getPrefilledDownloader(urls, {url, plugin}) lets the dialogs apply a Search-supplied plugin only while the submitted URLs are still exactly the pre-filled one) · themePresets.ts (#286 — getThemePresetSelection(customColors, themePresetId) → default | preset | custom (+ basedOn), derived by deep-comparing stored overrides with the preset and never from the id alone, so existing hand-picked colors are always custom; findThemePreset; presetApplyNeedsConfirm — true only for custom) · login-response.ts (qBittorrent login body/cookie interpretation) · haptics.ts (global toggle + wrappers) · tags.ts (CSV tag parsing) · add-torrent-dialogue.ts (compact vs full variant, plus getSearchAddOpensDialogue for the Search tab's + behavior — #217) · search-cart.ts (groupCartItemsForAdd — splits a SearchCartContext cart into one torrents/add batch per indexer when auto-tag-by-tracker is on, since that endpoint applies one tags value per request) · server-export.ts (strips password/basicAuthPassword/apiKey/quiProxyKey on export, forces them empty on import) · save-paths.ts (getKnownSavePaths, derived from live data — no API call) · version.ts (APP_VERSION) · trackers.ts (isRealTracker — filters qBittorrent's DHT/PeX/LSD pseudo-tracker entries out of torrents/trackers; getPseudoTrackerStates reads each channel's on/off/working state from those same entries — #234, #236) · color.ts (withAlpha — applies an alpha channel to a hex/rgb/rgba color string; colors.* defaults mix formats, so appending a hex alpha suffix silently no-ops on an rgba() base; contrastRatio(a, b) — WCAG 2 contrast ratio for hex/rgb/rgba strings, alpha ignored, unparsable → 1).

Types, constants, i18n

  • types/api.ts — every qBittorrent API shape (TorrentInfo, ServerConfig, RSS types, preference fields).
  • types/preferences.ts — typed preferences + defaults.
  • constants/ — changelog.ts (don't edit unless asked; see docs/RELEASING.md), spacing.ts, typography.ts, shadows.ts, buttons.ts, serverIcons.ts (SERVER_ICON_OPTIONS, DEFAULT_SERVER_ICON — the curated Ionicons set for a server's badge, #224), timing.ts (LONG_BACKGROUND_THRESHOLD_MS — shared by TorrentContext and TransferContext for their quick-app-switch gate), themePresets.ts (THEME_PRESETS, ThemePreset, DEFAULT_THEME_PRESET_ID — #286: default plus oled/nord/dracula/solarized/highContrast/qbittorrentClassic, each defining EVERY ColorTheme key (incl. preset-only primaryOpac/onAccent) for both dark and light, 6-digit hex except primaryOpac. Applied through colorThemeManager.applyThemePreset. Never rename a preset id — it is persisted as themePresetId. Contrast is enforced by tests/utils/themePresets.test.ts). Use these tokens; don't invent ad-hoc spacing.
  • i18n/index.ts initializes react-i18next. Each locale is ONE file, locales/{en,es,zh,fr,de,ru,pl,tr}/translation.json, holding every namespace: common, states, screens, placeholders, actions, alerts, server, torrentDetail, filters, sort, toast, errors. Keys read like t('actions.pause').
  • i18n/index.ts imports the intl-pluralrules polyfill first. Hermes has no Intl.PluralRules, and without it i18next only ever picks _one/_other, so Polish and Russian _few/_many keys were never used. tests/locales/plurals.test.ts reproduces that by deleting Intl.PluralRules, and requires pl/ru plural keys to define all four forms.

6. Task Recipes

Exact touch-lists for recurring work. Follow them; don't rediscover.

Add or change a user-facing string Add the key to all eight locales/*/translation.json and use it via t('ns.key'). Actually translate — the parity test rejects English copied verbatim into another locale (for strings ≥16 chars). npm test names any file you missed.

Add a preference Typed shape + default in types/preferences.ts → read/write through storageService (services/storage.ts) → UI in the relevant app/(tabs)/settings/* screen using SettingRow + OptionPicker/switch → i18n the label. Never rename an existing key.

Add a qBittorrent API call Confirm the endpoint, its exact parameter names and its response shape against the 5.0 wiki and, for anything that isn't 5.0-only, the 4.1 wiki → method on the matching services/api/*.ts object (follow its neighbors' style) → request/response types in types/api.ts → if availability or spelling depends on the server version, add a flag to ApiFeatures in utils/apiVersion.ts and branch on it. Remember a misspelled param is dropped silently, not rejected — see §1.

Add a torrent action API method (above), then both screens — they don't share this logic (see the hook's note):

  • List → menu item in hooks/useTorrentActions.ts. For a destructive confirm, expose visibility state from the hook and mount ConfirmModal in the screen.
  • Detail (torrent/[hash].tsx) → its own handle* function and, for a destructive confirm, its own *ConfirmVisible state + ConfirmModal, following its existing handleDelete/deleteConfirmVisible pair.

Strings go in the actions / toast namespaces either way.

Add a settings sub-screen Create app/(tabs)/settings/<name>.tsx by copying a sibling's structure — the route registers itself, _layout.tsx needs no edit — then link it from app/(tabs)/settings/index.tsx.

Keep the tab bar on a pushed screen Put the screen under app/(tabs)/(torrents)/ or app/(tabs)/settings/. The root stack means no tab bar, and is for modals and full-screen flows only.

Add a rendered field to TorrentCard Update the React.memo comparator in the same edit, or the card won't re-render when that field changes.

Add a test Pick the project by what you're testing, and copy the nearest sibling — they already carry the right imports and mocks:

  • Pure logic → tests/utils/<name>.test.ts or tests/services/<name>.test.ts. Plain ts-jest, no React. API wrappers have a shared tests/services/api-test-helpers.ts.
  • Component, hook, or context → tests/rn/{components,hooks,context}/<Name>.test.tsx. Runs under jest-expo with @testing-library/react-native; tests/rn/setup.ts loads automatically and tests/rn/components/theme-mock.ts stands in for ThemeContext.

Import app code as @/… — both projects map it to the repo root.

Two rn-project traps that will cost you an hour each if you meet them cold:

  • Don't wrap a trigger call in synchronous act(() => …). In this React/RTL version that silently swallows the state update — the component never re-renders and your assertion sees the old value, with no warning. It looks exactly like a product bug. Call the function bare and let waitFor observe the result:

    const pending = getLatest().checkAndReconnect(); // NOT inside act()
    await waitFor(() => expect(getLatest().isReconnecting).toBe(true));
    await act(async () => {
      resolveIt(true);
      await pending;
    }); // async act is fine

    await act(async () => …) around a fully awaited operation works normally. It's the sync form wrapping a promise-returning call that breaks.

  • render() returns a promise here. Tests await render(...), and a component that throws during render gives you a rejected promise, not a synchronous throw. The "hook used outside its provider" test therefore reads:

    await expect(render(<BadConsumer />)).rejects.toThrow('must be used within');

7. Testing

Tests live in tests/ at the repo root (not __tests__/), split across two Jest projects configured in jest.config.js:

  • node (ts-jest, node env) — tests/utils/, tests/services/, tests/locales/. Pure logic and API wrappers.
  • rn (jest-expo + @testing-library/react-native) — tests/rn/, with components/, context/ and hooks/ subtrees and a shared tests/rn/setup.ts / theme-mock.ts.

Both map @/… to the repo root. npm test runs both projects.

How much to test

Coverage sits around 90% and must not drop below it. That's the whole bar — don't chase a higher number, and don't write tests for their own sake.

In practice: a substantial piece of new logic ships with a test; a small change doesn't. New utils/ and services/ modules are the clear yes — they're pure, fast to test, and that's what the existing tests/utils / tests/services trees already cover. A copy tweak, a style fix, or a small branch in existing code is a clear no.

Don't run coverage locally. jest --coverage means the full suite plus instrumentation — expensive, and it tells you what CI already reports. The coverage.yml workflow computes it on every push to main and publishes the README badge. Trust that.

The locale parity test earns its keep. tests/locales/i18n-parity.test.ts fails if a locale adds or drops keys relative to en, or if a long string (≥16 chars) is byte-identical to the English source — exactly the drift that let the torrentDetail namespace regress to ~170 untranslated keys per locale in v3.5.1. Genuine coincidental matches (loanwords like "tracker"/"Status"/"OK", literal URL and path placeholders) live in that test's COINCIDENTAL_MATCH_ALLOWLIST. Extend the allowlist only after checking by hand that the match is intentional rather than a translation gap.


8. Critical Rules

  1. Never hardcode a color. Always useTheme() → colors.*.
  2. Never rename a key in the colors object. Users' saved overrides are keyed by name in AsyncStorage; renaming silently breaks their customizations.
  3. Never rename a preference key. There is no migration system — old keys just become orphaned.
  4. Color defaults mix formats (rgb, rgba, hex) and the picker only handles 6-digit hex. Changing an rgba(...) default to #hex drops the alpha channel and visibly changes the UI.
  5. All user-facing strings go through i18n — const { t } = useTranslation().
  6. Prefer themed dialogs: InputModal over Alert.prompt, ConfirmModal over Alert.alert. Native alerts ignore the app theme. Don't add a new one. Known deviations — nine existing calls across eight files: settings/advanced, settings/torrent-defaults ×2, search/plugins, server/[id], TagsModal, CategoryModal, SuperDebugPanel, and ConfirmModal itself. Converting one while you're already in that file is welcome, but it's never required.
  7. Delete superseded files in the same change. When a component is replaced by a route-level screen or vice versa, remove the old one rather than leaving dead code. Precedent: components/TorrentDetails.tsx was deleted once its markup moved into app/(tabs)/(torrents)/torrent/[hash].tsx.
  8. Don't trust a static bug list — including this file's. Read the code and run the checks before concluding a defect exists.

Naming conventions

  • Components: PascalCase — TorrentCard.tsx
  • Utilities and hooks: camelCase — format.ts, useTorrentActions.ts
  • Services: kebab-case — server-manager.ts, color-theme-manager.ts
  • (group), [param] and _layout.tsx are Expo Router syntax, not style choices. They can't be renamed.

9. Environment & Headless Agents

  • iOS-only. iOS-specific APIs (ActionSheetIOS, Alert.prompt, …) are fine without platform gating.
  • expo-* packages are pre-approved, even ones needing expo-dev-client. Third-party native modules (e.g. react-native-ios-context-menu, lottie-react-native) need explicit approval first.
  • Don't run the app. It needs a device or simulator via Xcode, which is the user's to drive. Never start the web target (npm run web) either — there is no qBittorrent server configured for an agent to talk to, so it proves nothing.
  • Verify with npx tsc --noEmit and npm test instead, batched at commit time per §1. The bar is exit 0, tests passing, lint 0 errors.

10. Gotchas

Surprises that don't have a natural home in a specific recipe, rule, or File Index entry above — things that cost real time because nothing here flagged them, usually because they look exactly like a product bug until you dig in.

Append here, don't just fix and move on. If you burn more than a few minutes on something that turned out to be a tooling quirk, an environment default, or a misleading error rather than an actual bug, add a short entry (2-4 lines: what it looks like, what's actually happening, the fix or workaround) so the next session doesn't pay the same cost. If the surprise belongs to one specific file, function, or recipe instead, put it there instead of here — this section is for things that don't fit anywhere else. Keep entries factual and current; if you find one that's no longer true (fixed upstream, no longer applies), remove it rather than leaving it to rot.

  • A synchronous act(() => …) wrapping a promise-returning call in an RN test can silently swallow the resulting state update — no warning, the component just never re-renders, and it looks exactly like a product bug. Full detail and the working pattern live in §6's "Add a test" recipe, under the rn-project traps.
  • The 5.0 wiki's torrents/add page still documents a root_folder param that no supported qBittorrent version reads. It looks like it "works" — the request 200s, the field is just silently dropped — but it has done nothing since qBit 4.3.2 (WebAPI 2.7.0). The live parameter is contentLayout (Original/Subfolder/NoSubfolder), gated by ApiFeatures.useContentLayoutAddParam in utils/apiVersion.ts. When a 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.
  • torrents/fetchMetadata (qBit 5.2+) is easy to misread. HTTP status is the progress signal: 202 = still working, 200 = ready. For an http(s) URL the first call only queues the download (202, body {}); call again with the same source until 200 or an error. The torrent ID in the body is hash (not id), alongside infohash_v1 / infohash_v2 (empty string when absent) and trackers as {url, tier} objects. The server percent-decodes source a second time, so torrentsApi.fetchMetadata pre-encodes it. Search results carry no hash at all (only a magnet fileUrl reveals one), which is why the Search grouping resolves candidates instead of reading a field. Source: torrentscontroller.cpp fetchMetadataAction — it is not in the 5.0 or 4.1 wikis.
  • 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.
  • "View Connectivity Logs" used to open qBittorrent's own server-log viewer (app/(tabs)/logs.tsx), which needs a live connection and shows nothing when the app can't connect — exactly the scenario it's needed for (issue #256). The app's real diagnostic trail (services/connectivity-log.ts) was never wired to any screen; it's now shown by components/LogViewer.tsx, opened from its own "View Connectivity Logs" row in Settings → Advanced.
  • services/storage.ts's saveServer persists servers through an explicit field-by-field map (to keep secrets out of AsyncStorage), so any new ServerConfig field is silently dropped on save unless it's added to that map. allowInsecureCert (#206) was missing from it for two months (#256): the toggle "worked" for the session because the in-memory object still had it, then came back OFF on every cold launch and the native TLS allowlist was fed an empty list — a 46ms ERR_NETWORK that reported as "Connection timeout". Every layer above (sync calls, native store, host matching) was verified correct twice before anyone read the serializer. When adding a ServerConfig field: add it to this map and to tests/services/storage.test.ts's save→reload round-trip in the same change.
  • RN's XMLHttpRequest.response returns '' once _hasError is set; only .responseText still carries the native error description. Any code sniffing the text of a failed request (utils/error.ts extractErrorText) must read .responseText (it throws for non-text responseType, so guard it). Tests that mock request.response directly pass while the real path silently sees nothing — mock the RN shape (response: '', responseText: '…') instead.
  • A path typed into the plain Host field lands before the port. The non-qui server forms have no base-path field, and the client builds <host><:port><basePath>, so Host qui:7476/proxy/<key> plus Port 7476 requests /proxy/<key>:7476/api/v2/…. Against qui that reads as "Invalid client API key" in qui's log and an auth failure in the app (#278) — it looks exactly like a wrong key. qui URLs are now caught at input (detectQuiProxyUrlInHost), and services/api/client.ts (plus the debug panel's URL builder) now splits a path off Host so the port lands before it.
  • A qui proxy that accepts the key can still 401 every request. qui forwards Origin/Referer to qBittorrent untouched but rewrites Host to the instance (qbittorrent:8081), and qBit's CSRF check (reverse-proxy support off by default, so it ignores X-Forwarded-Host) answers the mismatch with a 12-byte Unauthorized. qui's trace log shows "Rewriting proxy request" (key accepted) followed by status=401 — unlike qui's own 16-byte Invalid API key. services/api/client.ts therefore sends neither header in qui mode (#278); the debug panel never sent them, which is why its diagnostic passed while the app's connect failed.
  • A Search result whose link is an HTML page "adds" successfully and nothing appears. Some plugins (LimeTorrents, …) return a description page as fileUrl and can only resolve it via their own download_torrent(). A plain torrents/add makes qBittorrent download the HTML, fail to parse it and still return 200, so the app reports success (#284). Such URLs must be fetched by their plugin: torrents/add with downloader=<engineName> (WebAPI ≥ 2.13.1, keeps all options), else search/downloadTorrent (5.0–5.1, default options only); 4.x has neither. See utils/search-download.ts. Aggregator results (Prowlarr/Jackett) are excluded per result — their links embed an apikey and often redirect to a magnet, which core follows fine. Detect them with isAggregatorResult, not the screen-level isAggregatedSource flag: that one ("all results share one siteUrl") is only a label heuristic and also fires for any single-plugin search.