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.
- Read it as an API question first — §1: which endpoint, does it break stored data, does it still work on qBittorrent 4.x?
- Locate files in the File Index. Don't search the tree.
- 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.
- Edit, checking as you go. Run the narrow thing for what you touched —
that one suite, or
tscafter a type change — piped throughtail(§1). Trivial edits need nothing. - Reply in a few lines: what changed, file links, anything surprising.
- 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.
How to work in this repo, before anything about the code itself.
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→stoppedontorrents/add,start_paused_enabled→add_stopped_enabledin 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.
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 countRun 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.
Same reason as above — output is permanent and re-sent every turn:
- Never
cata whole file to answer a narrow question. Usegrep -n(with-A/-Bfor 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 underios/,.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
findor a recursivels. - 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.
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).
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.
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-descriptionNaming 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. |
developandprevieware retired (as of 2026-08-14). Neither branch exists. Older PRs (#218 and earlier) targetedpreview; everything since #219 goes straight tomain. 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.
"Commit this" means the full sequence, not just the commit:
- Run the commit-time checks and the File Index pass.
- Branch if you aren't already on one.
- Commit. No
Co-Authored-By: Claudetrailer on this repo. git push -u origin <branch>gh pr create --base mainwith 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.
npm run xcode— the one command that gets you building. Fresh-clone-safe:npm install→npx expo prebuild -p ios→pod install→ opensios/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.
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 inios/. Info.plist keys, entitlements, URL schemes and document types all live in theios.infoPlistblock, which prebuild applies when generating the project. Hand-edits underios/are wiped by the next prebuild. - When
app.config.jscan't express it, write a config plugin. There's one precedent:plugins/withNativeTorrentFileCopy.js, which patchesAppDelegateso an incoming.torrentis 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 frommodules/*with no config plugin needed, discovered automatically onnpm run xcode's prebuild. app.config.jsis tested.tests/utils/app-config.test.tsasserts the magnet scheme and.torrentdocument-type registration survive. Update it if you change those blocks; the comments inapp.config.jsexplain 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.
- 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 inapp/(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 customqueryFn. - Storage — AsyncStorage for preferences,
expo-secure-storefor 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, six locales.
- Deep links — magnet URLs and
.torrentfiles arrive via aLinkinglistener inapp/_layout.tsx.app/+native-intent.tsreturnsnullfor those URLs so Expo Router doesn't try to treat afile://…torrentpath as a route and land the user on "Unmatched Route".
Reverse-proxy Basic Auth (useBasicAuth, #118) shows the pattern every
per-server secret must follow:
- Non-secret half (
basicAuthUsername) → plain AsyncStorage viaservices/storage.ts. - Secret half (
basicAuthPassword) →expo-secure-storeunderserver_basic_auth_password_{id}, and forced to''before the server record is written to AsyncStorage. services/api/client.tsreads it back off the in-memoryServerConfigto build the header viautils/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.
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.
A server's auth mode is derived, not stored — see utils/authMode.ts
(password | apiKey | none). Legacy records that only ever set bypassAuth
keep working; when both useApiKey and bypassAuth are set, API key wins.
Complete map. Trust it.
| 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]. |
app/(tabs)/search.tsx |
Search tab: job polling UI, plugin/category/indexer filter chips, client-side sort, collapsing header. Optional auto-tag-by-tracker on add (autoCategorizeByTracker pref — tags Search downloads only; the key name is historical). |
app/(tabs)/transfer.tsx |
Transfer stats, global speed and seeding limits. |
app/(tabs)/logs.tsx |
Connectivity logs. href: null — reached from Settings → Advanced, not a visible tab. |
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. |
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/orapp/torrent/trees, the oldapp/(tabs)/index.tsx/app/(tabs)/settings.tsxhub files, or the deletedapp/(tabs)/rss/rule.tsx/rules.tsx. They were moved deliberately so the tab bar stays visible.
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 keepscurrentServerfor one-tap reconnect from Settings; callforgetCurrentServer()when that server is deleted, andupdateCurrentServer()after editing it so one-tap Connect doesn't retry stale credentials.connectedAttracks when the current connection began (derived fromisConnectedtransitions, not eachsetIsConnectedcall site) for a client-side "Connected For" display — qBittorrent'sserver_statehas no session-uptime field of its own (#232).isReconnectingis true only whilecheckAndReconnect()is in flight, set by the run that owns the shared promise — deliberately not folded intoisLoading/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.isRecoveringFromBackgroundcovers the foreground re-sync; it's cleared only once a fetch newer than the pre-recoverydataUpdatedAtlands, 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 thanLONG_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 mountModalToastlocally instead.ThemeContext.tsx—useTheme(), thecolorsobject, user overrides.ApiVersionContext.tsx— detected qBittorrent API version → feature gating throughutils/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 byfileUrl. Consumed byapp/torrents/add.tsx(gated on thefromCartroute param) and grouped for submission viautils/search-cart.ts.
All PascalCase function components taking a …Props interface.
- Modals / pickers —
ActionMenu(anchored popover),ConfirmModal(themed confirm),InputModal(themed text input; optionalpathAutocompleteprop),OptionPicker,MultiSelectPicker,CategoryModal,TagsModal,ColorPicker,IconPicker(grid picker for a server's badge icon, previewed in its badge color — seeconstants/serverIcons.ts),PathAutocompleteInput(live directory suggestions viaapp/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 viautils/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 — seeSearchCartContext.tsx). - Torrent / search UI —
TorrentCard(React.memowith a custom comparator — keep it in sync when you add a rendered field, or the card silently stops updating; category/tag stickers usecategoryColors/tagColorsthen defaults then theavatarColorfallback),SearchResultRow(+ internal ActionPill; the+button and the cart-toggle button are independent — see its header comment),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/iconColorviautils/server.tsgetServerIcon/getServerIconColor, falling back to a default icon andDEFAULT_AVATAR_COLOR— never the name-derivedavatarColor, so a badge's color only ever changes when the user picks one),ServerAppearanceSection(icon + badge-color editor used by bothapp/server/add.tsxandapp/server/[id].tsx— quickAVATAR_PALETTEswatches plus a "custom color" swatch that opens the fullColorPicker),CustomHeadersSection(per-server custom HTTP header key/value rows, max 5, used by the same two screens — seeutils/customHeaders.ts). - Visuals —
SpeedGraph,CircularProgress,AnimatedProgressBar,AnimatedButton,Confetti. - Chrome / diagnostics —
FocusAwareStatusBar,SettingRow,QuickConnectPanel,LogViewer,DebugRow,SuperDebugPanel.
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-readableErrors. Callers substring-match those messages — grep before rewording one.auth.ts(login/logout) ·sync.ts(getMainDatarid-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.
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 intypes/preferences.ts).incoming-file.ts— copies an incoming.torrentinto the app cache before the iOS security-scoped access can lapse.query-client.ts— the shared TanStackQueryClient.color-theme-manager.ts— save/load/apply user color themes.connectivity-log.ts— in-memory ring log (clogDebug/Info/Warn/Error(tag, msg)).log-storage.ts— persisted entries for the Logs screen.
insecure-cert-allowlist— local Expo module (Swift + an Obj-C category onRCTHTTPRequestHandler) 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 fromservices/server-manager.ts); every other host and every non-server-trust challenge (Basic Auth, client cert) falls through to default handling unchanged. iOS only; requiresnpm run xcodeto pick up (new native code, not just a generated-file patch).
useSearchJob.ts— search job lifecycle: start/stop/delete, 2s status+results polling, unmount cleanup.useTorrentActions.ts— builds the per-torrent action menu for the list screen only. Delete exposesdeleteConfirmVisiblefor a caller-mountedConfirmModal. The detail screen does not use this hook — it hand-rolls its own parallelhandlePauseResume/handleDelete/etc. and its owndeleteConfirmVisiblestate. 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 forSpeedGraph.
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) ·
error.ts (getErrorMessage) · 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) · basicAuth.ts ·
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 \) · searchResult.ts (indexer-label
heuristics) · 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 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).
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 byTorrentContextandTransferContextfor their quick-app-switch gate). Use these tokens; don't invent ad-hoc spacing.i18n/index.tsinitializes react-i18next. Each locale is ONE file,locales/{en,es,zh,fr,de,ru}/translation.json, holding every namespace:common,states,screens,placeholders,actions,alerts,server,torrentDetail,filters,sort,toast,errors. Keys read liket('actions.pause').
Exact touch-lists for recurring work. Follow them; don't rediscover.
Add or change a user-facing string
Add the key to all six 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 mountConfirmModalin the screen. - Detail (
torrent/[hash].tsx) → its ownhandle*function and, for a destructive confirm, its own*ConfirmVisiblestate +ConfirmModal, following its existinghandleDelete/deleteConfirmVisiblepair.
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.tsortests/services/<name>.test.ts. Plain ts-jest, no React. API wrappers have a sharedtests/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.tsloads automatically andtests/rn/components/theme-mock.tsstands 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 letwaitForobserve 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. Testsawait 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');
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/, withcomponents/,context/andhooks/subtrees and a sharedtests/rn/setup.ts/theme-mock.ts.
Both map @/… to the repo root. npm test runs both projects.
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.
- Never hardcode a color. Always
useTheme()→colors.*. - Never rename a key in the
colorsobject. Users' saved overrides are keyed by name in AsyncStorage; renaming silently breaks their customizations. - Never rename a preference key. There is no migration system — old keys just become orphaned.
- Color defaults mix formats (rgb, rgba, hex) and the picker only handles
6-digit hex. Changing an
rgba(...)default to#hexdrops the alpha channel and visibly changes the UI. - All user-facing strings go through i18n —
const { t } = useTranslation(). - Prefer themed dialogs:
InputModaloverAlert.prompt,ConfirmModaloverAlert.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, andConfirmModalitself. Converting one while you're already in that file is welcome, but it's never required. - 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.tsxwas deleted once its markup moved intoapp/(tabs)/(torrents)/torrent/[hash].tsx. - Don't trust a static bug list — including this file's. Read the code and run the checks before concluding a defect exists.
- 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.tsxare Expo Router syntax, not style choices. They can't be renamed.
- iOS-only. iOS-specific APIs (
ActionSheetIOS,Alert.prompt, …) are fine without platform gating. expo-*packages are pre-approved, even ones needingexpo-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 --noEmitandnpm testinstead, batched at commit time per §1. The bar is exit 0, tests passing, lint 0 errors.
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 thern-project traps. - The 5.0 wiki's
torrents/addpage still documents aroot_folderparam 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 iscontentLayout(Original/Subfolder/NoSubfolder), gated byApiFeatures.useContentLayoutAddParaminutils/apiVersion.ts. When a parameter "works" in the UI but has no visible server-side effect, check it against qBittorrent'storrentscontroller.cppsource, not the wiki — the wiki is not reliably kept in sync with parameter renames.