Skip to content

Latest commit

 

History

History
782 lines (642 loc) · 41.2 KB

File metadata and controls

782 lines (642 loc) · 41.2 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 (pausedstopped on torrents/add, start_paused_enabledadd_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 installnpx expo prebuild -p iospod 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, six 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.

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). Legacy records that only ever set bypassAuth keep working; when both useApiKey and bypassAuth are set, API key wins.


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].
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/ 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.tsxuseTheme(), 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 / pickersActionMenu (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).
  • Torrent / search UITorrentCard (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), 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).
  • VisualsSpeedGraph, CircularProgress, AnimatedProgressBar, AnimatedButton, Confetti.
  • Chrome / diagnosticsFocusAwareStatusBar, SettingRow, QuickConnectPanel, LogViewer, DebugRow, SuperDebugPanel.

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.
  • 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.

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).
  • 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.
  • connectivity-log.ts — in-memory ring log (clogDebug/Info/Warn/Error(tag, msg)).
  • log-storage.ts — persisted entries for the Logs screen.

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).

Hooks (hooks/)

  • 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 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) · 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, 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). 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}/translation.json, holding every namespace: common, states, screens, placeholders, actions, alerts, server, torrentDetail, filters, sort, toast, errors. Keys read like t('actions.pause').

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 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 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 logictests/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 contexttests/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 i18nconst { 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.