#include "ui.h"
#include "../daemon.h"
#include "../util.h"
#include <curses.h>
#include <locale.h>
#include <stdarg.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <strings.h>
#include <sys/ioctl.h>
#include <time.h>
#include <unistd.h>
#include <wchar.h>
void ui_init_colors(void)
{
if (!has_colors())
return;
start_color();
use_default_colors();
init_pair(CP_DEFAULT, -1, -1);
init_pair(CP_HEADER, COLOR_BLACK, COLOR_CYAN);
init_pair(CP_SELROW, COLOR_BLACK, COLOR_WHITE);
init_pair(CP_SEEDING, COLOR_GREEN, -1);
init_pair(CP_DOWNLOADING, COLOR_CYAN, -1);
init_pair(CP_PAUSED, COLOR_YELLOW, -1);
init_pair(CP_ERROR, COLOR_RED, -1);
init_pair(CP_CHECK, COLOR_MAGENTA, -1);
init_pair(CP_BORDER, COLOR_CYAN, -1);
init_pair(CP_ACCENT, COLOR_CYAN, -1);
init_pair(CP_SPLASH_WHITE, COLOR_WHITE, -1);
}
/* ---- shared panel/box/footer drawing ---------------------------------- */
void ui_box(WINDOW *win, int h, int w)
{
if (h < 2 || w < 2)
return;
wattron(win, COLOR_PAIR(CP_BORDER));
mvwaddwstr(win, 0, 0, L"╭");
for (int x = 1; x < w - 1; x++)
mvwaddwstr(win, 0, x, L"─");
mvwaddwstr(win, 0, w - 1, L"╮");
for (int y = 1; y < h - 1; y++) {
mvwaddwstr(win, y, 0, L"│");
mvwaddwstr(win, y, w - 1, L"│");
}
mvwaddwstr(win, h - 1, 0, L"╰");
for (int x = 1; x < w - 1; x++)
mvwaddwstr(win, h - 1, x, L"─");
mvwaddwstr(win, h - 1, w - 1, L"╯");
wattroff(win, COLOR_PAIR(CP_BORDER));
}
void ui_box_title(WINDOW *win, int w, const char *title)
{
int len = (int)strlen(title);
int x = 2;
if (x + len + 2 > w)
len = w - x - 2;
if (len <= 0)
return;
wattron(win, COLOR_PAIR(CP_ACCENT) | A_BOLD);
mvwprintw(win, 0, x, " %.*s ", len, title);
wattroff(win, COLOR_PAIR(CP_ACCENT) | A_BOLD);
}
void ui_draw_footer(const KeyHint *hints, size_t n, int nrows)
{
if (nrows > 2)
nrows = 2;
int rows, cols;
getmaxyx(stdscr, rows, cols);
for (int r = 0; r < nrows; r++) {
move(rows - nrows + r, 0);
clrtoeol();
}
int row = 0, x = 1;
for (size_t i = 0; i < n && row < nrows; i++) {
int chip_w = (int)(strlen(hints[i].key) + strlen(hints[i].desc)) + 3;
if (x + chip_w >= cols) {
row++;
x = 1;
if (row >= nrows)
break;
}
attron(COLOR_PAIR(CP_HEADER) | A_BOLD);
mvprintw(rows - nrows + row, x, " %s ", hints[i].key);
attroff(COLOR_PAIR(CP_HEADER) | A_BOLD);
x += (int)strlen(hints[i].key) + 2;
mvprintw(rows - nrows + row, x, " %s ", hints[i].desc);
x += (int)strlen(hints[i].desc) + 2;
}
}
int ui_color_for_status(int status)
{
switch (status) {
case TR_STATUS_SEED:
case TR_STATUS_SEED_WAIT:
return CP_SEEDING;
case TR_STATUS_DOWNLOAD:
case TR_STATUS_DOWNLOAD_WAIT:
return CP_DOWNLOADING;
case TR_STATUS_CHECK:
case TR_STATUS_CHECK_WAIT:
return CP_CHECK;
case TR_STATUS_STOPPED:
default:
return CP_PAUSED;
}
}
void ui_set_status(AppState *st, const char *fmt, ...)
{
va_list ap;
va_start(ap, fmt);
vsnprintf(st->status_msg, sizeof(st->status_msg), fmt, ap);
va_end(ap);
st->status_msg_until = time(NULL) + 5;
}
static void check_download_dir_perms(AppState *st);
int ui_refresh_list(AppState *st)
{
char err[256];
/* Always stamp last_poll, even on failure - otherwise a disconnected
* daemon leaves it at its zero-initialized value forever, and the main
* loop's "elapsed >= poll_interval_ms" check is true on every ~200ms
* tick, hammering connect() in a tight retry loop instead of backing
* off to the configured poll interval. */
clock_gettime(CLOCK_MONOTONIC, &st->last_poll);
if (torrent_list_refresh(st->rpc, &st->list, err, sizeof(err)) != 0) {
ui_set_status(st, "Connection error: %s", err);
return -1;
}
check_download_dir_perms(st);
return 0;
}
/* --- sorting/filtering ------------------------------------------------ */
static AppState *g_sort_ctx;
static int passes_filter(const Torrent *t, FilterMode f)
{
switch (f) {
case FILTER_DOWNLOADING:
return t->status == TR_STATUS_DOWNLOAD || t->status == TR_STATUS_DOWNLOAD_WAIT;
case FILTER_UPLOADING:
return t->status == TR_STATUS_SEED || t->status == TR_STATUS_SEED_WAIT;
case FILTER_PAUSED:
return t->status == TR_STATUS_STOPPED;
case FILTER_COMPLETED:
return t->percent_done >= 1.0;
case FILTER_ALL:
default:
return 1;
}
}
static int cmp_order(const void *pa, const void *pb)
{
const AppState *st = g_sort_ctx;
const Torrent *a = &st->list.items[*(const int *)pa];
const Torrent *b = &st->list.items[*(const int *)pb];
int r = 0;
switch (st->sort_col) {
case SORT_NAME:
r = strcasecmp(a->name, b->name);
break;
case SORT_SIZE:
r = (a->total_size > b->total_size) - (a->total_size < b->total_size);
break;
case SORT_PROGRESS:
r = (a->percent_done > b->percent_done) - (a->percent_done < b->percent_done);
break;
case SORT_STATUS:
r = a->status - b->status;
break;
case SORT_DOWN:
r = (a->rate_download > b->rate_download) - (a->rate_download < b->rate_download);
break;
case SORT_UP:
r = (a->rate_upload > b->rate_upload) - (a->rate_upload < b->rate_upload);
break;
case SORT_RATIO:
r = (a->upload_ratio > b->upload_ratio) - (a->upload_ratio < b->upload_ratio);
break;
case SORT_ETA:
r = a->eta - b->eta;
break;
default:
break;
}
if (r == 0)
r = strcasecmp(a->name, b->name);
return st->sort_desc ? -r : r;
}
void ui_rebuild_order(AppState *st)
{
if (st->order_capacity < st->list.count) {
size_t ncap = st->list.count;
int *no = realloc(st->order, ncap * sizeof(int));
if (!no)
return;
st->order = no;
st->order_capacity = ncap;
}
size_t n = 0;
for (size_t i = 0; i < st->list.count; i++) {
const Torrent *t = &st->list.items[i];
if (!passes_filter(t, st->filter))
continue;
if (st->search[0] && !str_ci_contains(t->name, st->search))
continue;
st->order[n++] = (int)i;
}
st->order_count = n;
g_sort_ctx = st;
if (n > 1)
qsort(st->order, n, sizeof(int), cmp_order);
if (st->cursor >= (int)st->order_count)
st->cursor = st->order_count ? (int)st->order_count - 1 : 0;
if (st->cursor < 0)
st->cursor = 0;
}
void ui_open_detail(AppState *st, int torrent_id)
{
st->detail_id = torrent_id;
st->detail_loaded = 0;
st->detail_tab = DETAIL_GENERAL;
st->detail_cursor = 0;
st->view = VIEW_DETAIL;
}
void ui_close_detail(AppState *st)
{
if (st->detail_loaded)
torrent_detail_free(&st->detail);
st->detail_loaded = 0;
st->view = VIEW_LIST;
}
/* --- main loop ---------------------------------------------------------- */
static long elapsed_ms(const struct timespec *since)
{
struct timespec now;
clock_gettime(CLOCK_MONOTONIC, &now);
return (now.tv_sec - since->tv_sec) * 1000 + (now.tv_nsec - since->tv_nsec) / 1000000;
}
/* Called once at startup if the initial connection failed. If the daemon is
* supposed to be on this machine, offer to launch it and give it a few
* seconds to come up before continuing into the normal UI either way. */
static void offer_daemon_start(AppState *st)
{
if (!daemon_host_is_local(st->cfg->host))
return;
char msg[512];
snprintf(msg, sizeof(msg), "No daemon is responding at %s:%d. Start transmission-daemon?",
st->cfg->host, st->cfg->port);
if (!ui_confirm("No daemon found", msg))
return;
char err[256];
if (daemon_start(err, sizeof(err)) != 0) {
ui_message("Could not start daemon", err);
return;
}
ui_set_status(st, "Started transmission-daemon, connecting...");
ui_list_render(st);
for (int attempt = 0; attempt < 10; attempt++) {
usleep(400000);
if (ui_refresh_list(st) == 0)
return;
}
ui_set_status(st, "Started the daemon but could not connect yet - try again with 'r'");
}
/* Called once at startup if the daemon answered but rejected us with 401 -
* it's up and reachable, we just don't know its RPC password (Debian's
* package generates a random one that's never shown in plaintext). Offers
* to either disable RPC auth or set a known password, via `sudo`. */
static void offer_fix_auth(AppState *st)
{
if (!daemon_host_is_local(st->cfg->host))
return;
char msg[512];
snprintf(msg, sizeof(msg),
"The daemon at %s:%d is responding but requires a password we don't know. "
"Fix it now (requires sudo)?",
st->cfg->host, st->cfg->port);
if (!ui_confirm("Login required", msg))
return;
int disable = !ui_confirm("Authentication", "Set a custom password? (No = disable password protection entirely)");
char pass[128] = "";
if (!disable) {
if (!ui_prompt("New RPC password (username will be 'transmission')", "", pass, sizeof(pass)) ||
!pass[0])
return;
}
ui_set_status(st, "Configuring transmission-daemon - check the terminal for a sudo prompt...");
ui_list_render(st);
/* sudo needs to prompt on the real terminal, which curses currently has
* in raw/alternate-screen mode - step out of its way and back in. */
def_prog_mode();
endwin();
char err[256];
int rc = daemon_fix_auth("transmission", disable ? NULL : pass, err, sizeof(err));
reset_prog_mode();
refresh();
if (rc != 0) {
ui_message("Could not configure the daemon", err);
return;
}
if (!disable) {
snprintf(st->cfg->username, sizeof(st->cfg->username), "transmission");
snprintf(st->cfg->password, sizeof(st->cfg->password), "%s", pass);
} else {
st->cfg->username[0] = '\0';
st->cfg->password[0] = '\0';
}
snprintf(st->rpc->user, sizeof(st->rpc->user), "%s", st->cfg->username);
snprintf(st->rpc->pass, sizeof(st->rpc->pass), "%s", st->cfg->password);
st->rpc->session_id[0] = '\0';
config_save(st->cfg, err, sizeof(err)); /* best effort */
ui_set_status(st, "Done, connecting...");
ui_list_render(st);
for (int attempt = 0; attempt < 10; attempt++) {
usleep(400000);
if (ui_refresh_list(st) == 0)
return;
}
ui_set_status(st, "Could not connect yet - try again with 'r'");
}
/* Called after each successful refresh - looks for a torrent whose
* errorString points at a permission problem on its download directory.
* That usually means the daemon runs as a separate system account (Debian's
* package uses `debian-transmission`) that can't get into a directory only
* the invoking user owns - a one-time group membership fix fixes it. Offers
* that fix at most once per session, whether accepted or declined, so a
* still-broken torrent doesn't re-prompt on every poll tick. */
static void check_download_dir_perms(AppState *st)
{
if (st->perm_fix_offered || !daemon_host_is_local(st->cfg->host))
return;
for (size_t i = 0; i < st->list.count; i++) {
Torrent *t = &st->list.items[i];
if (!t->error || !str_ci_contains(t->error_string, "permission denied"))
continue;
/* If our own user can't write there either, this isn't a "daemon
* needs to be let into a directory we own" situation - don't offer
* a fix that can't possibly help. */
if (!t->download_dir[0] || access(t->download_dir, W_OK) != 0)
continue;
st->perm_fix_offered = 1;
char msg[1024];
snprintf(msg, sizeof(msg),
"'%s' can't write to '%s' (permission denied) - looks like the daemon's "
"service account can't get into a directory only your user owns. "
"Fix it now (requires sudo)?",
t->name, t->download_dir);
if (!ui_confirm("Download directory permissions", msg))
return;
ui_set_status(st, "Fixing download directory permissions - check the terminal for a sudo prompt...");
ui_list_render(st);
def_prog_mode();
endwin();
char err[256];
int rc = daemon_fix_download_dir_perms(t->download_dir, err, sizeof(err));
reset_prog_mode();
refresh();
if (rc != 0) {
ui_message("Could not fix directory permissions", err);
return;
}
int id = t->id;
torrent_action(st->rpc, "torrent-start", &id, 1, err, sizeof(err)); /* best effort retry */
ui_set_status(st, "Fixed - retrying the download");
st->need_poll_now = 1;
return;
}
}
void ui_offer_reconnect_help(AppState *st)
{
if (st->rpc->last_http_status == 401)
offer_fix_auth(st);
else
offer_daemon_start(st);
}
int ui_run(RpcClient *rpc, Config *cfg, const char *startup_msg)
{
AppState st;
memset(&st, 0, sizeof(st));
st.rpc = rpc;
st.cfg = cfg;
st.running = 1;
st.sort_col = SORT_NAME;
st.focus = FOCUS_LIST;
torrent_list_init(&st.list);
setlocale(LC_ALL, "");
/* JSON numbers are always '.'-decimal regardless of locale (RFC 8259);
* cJSON's number parser uses strtod() under the active LC_NUMERIC, so a
* locale with a comma decimal point (e.g. sv_SE) silently truncates
* every "1.0"-style value and desyncs the parser. Keep LC_NUMERIC at
* "C" while leaving the rest of the locale (LC_CTYPE etc.) localized
* for correct UTF-8 rendering. */
setlocale(LC_NUMERIC, "C");
#ifdef NCURSES_VERSION
/* ncurses defaults to a 1s ESCDELAY to disambiguate a lone Esc from the
* start of a function-key sequence, which makes Esc feel unresponsive. */
set_escdelay(25);
#endif
initscr();
/* ncurses can size stdscr from stale $COLUMNS/$LINES env vars instead
* of the terminal's actual dimensions (common after a resize the shell
* never got to re-export, or in some multiplexer/SSH setups) - centered
* popups then get positioned for a screen that doesn't match reality
* and end up partly or fully off-screen. Force a re-sync from the
* kernel's own idea of the window size right after initscr(). */
{
struct winsize ws;
if (ioctl(STDOUT_FILENO, TIOCGWINSZ, &ws) == 0 && ws.ws_row > 0 && ws.ws_col > 0)
resizeterm(ws.ws_row, ws.ws_col);
}
cbreak();
noecho();
keypad(stdscr, TRUE);
curs_set(0);
ui_init_colors();
int pending_key = ERR;
if (cfg->show_splash)
pending_key = ui_splash_show();
int poll_tick_ms = 200; /* how often we wake up to check the poll interval / redraw */
timeout(poll_tick_ms);
if (ui_refresh_list(&st) != 0) {
ui_offer_reconnect_help(&st);
} else {
if (startup_msg)
ui_set_status(&st, "%s", startup_msg);
else
ui_set_status(&st, "Connected to %s:%d", cfg->host, cfg->port);
ui_settings_reconcile_now(&st);
}
/* Only redraw when something could actually have changed (a key was
* handled, or a periodic RPC refresh landed) - not unconditionally on
* every ~200ms poll tick. Redrawing (and re-sending a full screen's
* worth of escape sequences) when nothing changed is wasted work, and
* on slower links/terminals the constant churn can show up as visible
* flicker on modal popups the user is just sitting and looking at. */
int need_render = 1;
while (st.running) {
if (need_render) {
ui_rebuild_order(&st);
switch (st.view) {
case VIEW_LIST:
ui_list_render(&st);
break;
case VIEW_DETAIL:
ui_detail_render(&st);
break;
case VIEW_SETTINGS:
ui_settings_render(&st);
break;
case VIEW_HELP:
ui_help_render(&st);
break;
}
need_render = 0;
}
/* Whatever key dismissed the splash is a real command the user
* meant for the app (e.g. 'g' for settings) - act on it instead of
* silently swallowing it, before falling back to reading new input. */
int ch;
if (pending_key != ERR) {
ch = pending_key;
pending_key = ERR;
} else {
ch = getch();
}
if (ch == ERR) {
if (elapsed_ms(&st.last_poll) >= cfg->poll_interval_ms || st.need_poll_now) {
st.need_poll_now = 0;
ui_refresh_list(&st);
if (st.view == VIEW_DETAIL && st.detail_loaded) {
char err[256];
torrent_detail_free(&st.detail);
st.detail_loaded =
torrent_get_detail(st.rpc, st.detail_id, &st.detail, err, sizeof(err)) == 0;
}
need_render = 1;
}
continue;
}
need_render = 1;
switch (st.view) {
case VIEW_LIST:
ui_list_handle_key(&st, ch);
break;
case VIEW_DETAIL:
ui_detail_handle_key(&st, ch);
break;
case VIEW_SETTINGS:
ui_settings_handle_key(&st, ch);
break;
case VIEW_HELP:
ui_help_handle_key(&st, ch);
break;
}
}
if (st.detail_loaded)
torrent_detail_free(&st.detail);
free(st.order);
torrent_list_free(&st.list);
endwin();
return 0;
}