# Architecture ## Module overview ``` src/ ├── main.c CLI parsing, switches between TUI mode and --add mode ├── net.c/.h raw TCP connection (connect w/ timeout) ├── http.c/.h minimal HTTP/1.1 client on top of net.h (POST, headers, Content-Length and chunked response parsing) ├── rpc.c/.h Transmission JSON-RPC layer: session-id/409 handling ├── torrent.c/.h data model + parsing of torrent-get responses into structs ├── config.c/.h config.ini reading/writing, CLI flags ├── daemon.c/.h start local transmission-daemon, fix RPC auth (sudo) ├── util.c/.h string formatting, base64, file I/O, small string utils └── ui/ ├── ui.c/.h shared types (AppState), main loop, colors, frames ├── ui_list.c list view: sidebar, columns, sort/filter/search ├── ui_details.c detail view: General/Files/Peers/Trackers ├── ui_settings.c settings screen ├── ui_dialogs.c confirmations/text prompts/messages ├── ui_splash.c splash screen └── ui_help.c help screen third_party/cJSON.c/.h vendored JSON parser (github.com/DaveGamble/cJSON) ``` ## Design decisions ### No `libcurl`/`jansson`/`json-c` Linking in `libcurl` would have pulled in openssl/zlib/nghttp2 and friends and made the binary considerably larger for a protocol that's fundamentally just "a POST with a JSON body". `http.c` implements exactly what Transmission's RPC spec requires: an HTTP/1.1 POST, reading the response with either `Content-Length` or `Transfer-Encoding: chunked`, and parsing the `X-Transmission-Session-Id` header. `cJSON` is vendored as a single `.c`/`.h` file instead of a system dependency. Result: the binary lands around 70-90 KB stripped, with only `libncursesw` as a runtime dependency. ### Transmission's CSRF handshake Transmission's RPC requires an `X-Transmission-Session-Id` header; a call without one (or with the wrong session id) gets a `409 Conflict` back with the correct id in the response header. `rpc_call()` in `rpc.c` makes this transparent: on `409` the new session id is saved and the call is retried once automatically - the rest of the code never has to think about it. ### Single-threaded event loop No background thread. The main loop (`ui_run()` in `ui.c`) sets `timeout(200)` on `getch()`; every time it times out (no keypress), it checks whether `poll_interval_ms` has elapsed since the last list refresh, and if so does a new RPC refresh. All RPC calls are synchronous - on a local network that's fast enough to feel instant, and it avoids the whole class of race conditions a threaded solution would have introduced. ### Read on demand `torrent-get` for the full list only fetches summary fields (name, size, status, speeds, ...). Files/peers/trackers are only fetched when the detail view opens for a specific torrent, and are only kept in memory while that view is open - not for every torrent on every refresh. ### Memory reuse in the list `TorrentList` in `torrent.c` only reallocates its array when it needs to grow, never shrinks, and never reallocates fresh on every refresh. Selection (`space` marking) in the UI is tied to the torrent `id`, not the array index, and is restored after every refresh since the daemon's response order isn't guaranteed stable. ### Shared UI building blocks `ui_box()`/`ui_box_title()` (rounded frames) and `ui_draw_footer()` (key chips) in `ui.c` are used by every view for a consistent look. Column positions in the list view are computed once per render into a `ContentCols` struct and reused for both the header and the rows, so they can never drift out of sync with each other. Popup views (dialogs, confirmations, prompts) render with `open_box()`/`close_box()` in `ui_dialogs.c`, which call `touchwin(stdscr)` after closing so the next full-screen redraw doesn't skip cells it thinks already match. The settings view used to render as a popup layered over the list view as a backdrop, but that turned out unreliable on some real terminals (the view state changed correctly but didn't always visibly redraw) - it's now a dedicated full-screen view instead (same pattern as the help screen), which sidesteps that whole class of overlapping-window issue. ### Session settings survive an unclean daemon restart `ui_settings.c` reads/writes download dir, speed limits, alt-speed schedule, and ratio limit directly against the daemon via `session-get`/`session-set` - deliberately not cached locally as the source of truth, since the daemon is the shared, authoritative state. There's one durability gap in `transmission-daemon` itself though: it only writes `settings.json` to disk on a clean shutdown, not on every RPC change, so an unclean kill (crash, power loss, `kill -9`) before the next clean stop silently reverts any RPC-set session value. `reconcile_cache()` papers over this: `Config` keeps a `cache_*` snapshot of the last value the user explicitly set through `transtui` (see [Configuration](Configuration.md#session-settings-cache-cache_-keys)), and on every connect, if the daemon's current session doesn't match it, the cached value is pushed back and a status message reports what was restored. This is a deliberate one-way sync in `transtui`'s favor - it does not try to detect or preserve intentional changes made by other RPC clients since it was last used. ### Two pinned environment details - **`LC_NUMERIC` pinned to `"C"`.** `setlocale(LC_ALL, "")` (needed for correct UTF-8 rendering in ncursesw) also changes the decimal separator according to the system locale. cJSON's number parsing uses `strtod()` under the active locale, so under e.g. a locale with a comma decimal separator, `"1.0"` in a JSON response would be parsed incorrectly and desync the whole parser. `LC_NUMERIC` is therefore reset to `"C"` immediately after, while the rest of the locale (for UTF-8/character width) is left as the system's own. - **`set_escdelay(25)`.** ncurses waits 1 second by default after a lone `Esc` keypress to decide whether it's a standalone Esc or the start of a function-key sequence. That makes Esc feel noticeably sluggish; the value is lowered to 25 ms at startup. ### `daemon.c`: never shells out a password When `transtui` rewrites `/etc/transmission-daemon/settings.json` (see [Daemon: setup & troubleshooting](Daemon-Setup-and-Troubleshooting.md)), the file is read/modified/written via cJSON to a temp file, and `sudo systemctl stop/start` plus `sudo cp` are then each run separately via `fork()`+`execvp()` with argv arrays - never through a shell string. The user-chosen password therefore never passes through a shell or a `sed` expression, so there's nothing to escape and no injection surface regardless of what characters it contains.