foxygit / TransTUI Log in
commits tags

/src/ui/ui.c · 17.59 KB

raw
#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;
}