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), 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_NUMERICpinned to"C".setlocale(LC_ALL, "")(needed forcorrect 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_NUMERICis 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 loneEsckeypress 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), 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.