commits
tags
#include "ui.h"
#include "../third_party/cJSON.h"
#include <curses.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
typedef struct {
int loaded;
char download_dir[512];
long speed_down;
int speed_down_enabled;
long speed_up;
int speed_up_enabled;
int alt_enabled;
long alt_down;
long alt_up;
int alt_time_enabled;
int alt_time_begin;
int alt_time_end;
double seed_ratio_limit;
int seed_ratio_enabled;
} SessionInfo;
static SessionInfo g_sess;
static int g_cursor;
static int g_attempted; /* we tried session-get at least once since opening */
static int g_connected; /* whether that attempt (or the latest one) succeeded */
enum {
/* Daemon connection - these live in Config, not the RPC session, and
* are saved to config.ini (CLI flags still override them at startup). */
F_HOST,
F_PORT,
F_USERNAME,
F_PASSWORD,
F_SHOW_SPLASH,
/* Everything below is read/written via Transmission's session-get/set. */
F_DOWNLOAD_DIR,
F_SPEED_DOWN_ENABLED,
F_SPEED_DOWN,
F_SPEED_UP_ENABLED,
F_SPEED_UP,
F_ALT_ENABLED,
F_ALT_DOWN,
F_ALT_UP,
F_ALT_TIME_ENABLED,
F_ALT_TIME_BEGIN,
F_ALT_TIME_END,
F_SEED_RATIO_ENABLED,
F_SEED_RATIO,
F_COUNT,
};
#define F_DAEMON_COUNT (F_DOWNLOAD_DIR)
static long ji(const cJSON *o, const char *k, long def)
{
const cJSON *v = cJSON_GetObjectItemCaseSensitive(o, k);
return (v && cJSON_IsNumber(v)) ? (long)v->valuedouble : def;
}
static double jd(const cJSON *o, const char *k, double def)
{
const cJSON *v = cJSON_GetObjectItemCaseSensitive(o, k);
return (v && cJSON_IsNumber(v)) ? v->valuedouble : def;
}
static int jb(const cJSON *o, const char *k, int def)
{
const cJSON *v = cJSON_GetObjectItemCaseSensitive(o, k);
return v ? cJSON_IsTrue(v) : def;
}
static void js(const cJSON *o, const char *k, char *buf, size_t n)
{
const cJSON *v = cJSON_GetObjectItemCaseSensitive(o, k);
if (v && cJSON_IsString(v))
snprintf(buf, n, "%s", v->valuestring);
}
static int fetch_session(AppState *st, char *err, size_t errlen)
{
cJSON *args = NULL;
if (rpc_call(st->rpc, "session-get", NULL, &args, err, errlen) != 0)
return -1;
memset(&g_sess, 0, sizeof(g_sess));
js(args, "download-dir", g_sess.download_dir, sizeof(g_sess.download_dir));
g_sess.speed_down = ji(args, "speed-limit-down", 0);
g_sess.speed_down_enabled = jb(args, "speed-limit-down-enabled", 0);
g_sess.speed_up = ji(args, "speed-limit-up", 0);
g_sess.speed_up_enabled = jb(args, "speed-limit-up-enabled", 0);
g_sess.alt_enabled = jb(args, "alt-speed-enabled", 0);
g_sess.alt_down = ji(args, "alt-speed-down", 0);
g_sess.alt_up = ji(args, "alt-speed-up", 0);
g_sess.alt_time_enabled = jb(args, "alt-speed-time-enabled", 0);
g_sess.alt_time_begin = (int)ji(args, "alt-speed-time-begin", 0);
g_sess.alt_time_end = (int)ji(args, "alt-speed-time-end", 0);
g_sess.seed_ratio_limit = jd(args, "seedRatioLimit", 0.0);
g_sess.seed_ratio_enabled = jb(args, "seedRatioLimited", 0);
g_sess.loaded = 1;
cJSON_Delete(args);
return 0;
}
static int push_session(AppState *st, cJSON *args, char *err, size_t errlen)
{
return rpc_call(st->rpc, "session-set", args, NULL, err, errlen);
}
static void fmt_hhmm(int minutes, char *buf, size_t n)
{
snprintf(buf, n, "%02d:%02d", (minutes / 60) % 24, minutes % 60);
}
static int parse_hhmm(const char *s)
{
int h = 0, m = 0;
if (sscanf(s, "%d:%d", &h, &m) != 2)
return -1;
if (h < 0 || h > 23 || m < 0 || m > 59)
return -1;
return h * 60 + m;
}
/* If the daemon's current session settings don't match what the user last
* explicitly set via this screen (cfg->cache_*), push the cached values
* back - transmission-daemon only writes settings.json on a clean
* shutdown, so an unclean restart (crash, power loss, kill -9) silently
* reverts RPC-set session values to whatever was last saved. This keeps
* the user's intent durable across that. Only runs once cache_set has ever
* been populated (i.e. the user has changed a session field at least once
* via transtui), and intentionally wins over changes made by other RPC
* clients since transtui was last opened - that tradeoff is what the user
* asked for. */
static void reconcile_cache(AppState *st)
{
Config *cfg = st->cfg;
if (!cfg->session_cache_set)
return;
cJSON *args = cJSON_CreateObject();
int changed = 0;
if (strcmp(cfg->cache_download_dir, g_sess.download_dir) != 0) {
cJSON_AddStringToObject(args, "download-dir", cfg->cache_download_dir);
changed++;
}
if (cfg->cache_speed_down_enabled != g_sess.speed_down_enabled) {
cJSON_AddBoolToObject(args, "speed-limit-down-enabled", cfg->cache_speed_down_enabled);
changed++;
}
if (cfg->cache_speed_down != g_sess.speed_down) {
cJSON_AddNumberToObject(args, "speed-limit-down", (double)cfg->cache_speed_down);
changed++;
}
if (cfg->cache_speed_up_enabled != g_sess.speed_up_enabled) {
cJSON_AddBoolToObject(args, "speed-limit-up-enabled", cfg->cache_speed_up_enabled);
changed++;
}
if (cfg->cache_speed_up != g_sess.speed_up) {
cJSON_AddNumberToObject(args, "speed-limit-up", (double)cfg->cache_speed_up);
changed++;
}
if (cfg->cache_alt_enabled != g_sess.alt_enabled) {
cJSON_AddBoolToObject(args, "alt-speed-enabled", cfg->cache_alt_enabled);
changed++;
}
if (cfg->cache_alt_down != g_sess.alt_down) {
cJSON_AddNumberToObject(args, "alt-speed-down", (double)cfg->cache_alt_down);
changed++;
}
if (cfg->cache_alt_up != g_sess.alt_up) {
cJSON_AddNumberToObject(args, "alt-speed-up", (double)cfg->cache_alt_up);
changed++;
}
if (cfg->cache_alt_time_enabled != g_sess.alt_time_enabled) {
cJSON_AddBoolToObject(args, "alt-speed-time-enabled", cfg->cache_alt_time_enabled);
changed++;
}
if (cfg->cache_alt_time_begin != g_sess.alt_time_begin) {
cJSON_AddNumberToObject(args, "alt-speed-time-begin", cfg->cache_alt_time_begin);
changed++;
}
if (cfg->cache_alt_time_end != g_sess.alt_time_end) {
cJSON_AddNumberToObject(args, "alt-speed-time-end", cfg->cache_alt_time_end);
changed++;
}
if (cfg->cache_seed_ratio_enabled != g_sess.seed_ratio_enabled) {
cJSON_AddBoolToObject(args, "seedRatioLimited", cfg->cache_seed_ratio_enabled);
changed++;
}
double ratio_diff = cfg->cache_seed_ratio_limit - g_sess.seed_ratio_limit;
if (ratio_diff < 0)
ratio_diff = -ratio_diff;
if (ratio_diff > 0.001) {
cJSON_AddNumberToObject(args, "seedRatioLimit", cfg->cache_seed_ratio_limit);
changed++;
}
if (changed == 0) {
cJSON_Delete(args);
return;
}
char err[256];
if (push_session(st, args, err, sizeof(err)) == 0) {
snprintf(g_sess.download_dir, sizeof(g_sess.download_dir), "%s", cfg->cache_download_dir);
g_sess.speed_down = cfg->cache_speed_down;
g_sess.speed_down_enabled = cfg->cache_speed_down_enabled;
g_sess.speed_up = cfg->cache_speed_up;
g_sess.speed_up_enabled = cfg->cache_speed_up_enabled;
g_sess.alt_enabled = cfg->cache_alt_enabled;
g_sess.alt_down = cfg->cache_alt_down;
g_sess.alt_up = cfg->cache_alt_up;
g_sess.alt_time_enabled = cfg->cache_alt_time_enabled;
g_sess.alt_time_begin = cfg->cache_alt_time_begin;
g_sess.alt_time_end = cfg->cache_alt_time_end;
g_sess.seed_ratio_limit = cfg->cache_seed_ratio_limit;
g_sess.seed_ratio_enabled = cfg->cache_seed_ratio_enabled;
ui_set_status(st, "Restored %d setting(s) the daemon had reverted", changed);
}
/* On failure just leave the daemon's current values shown - don't block
* opening settings or nag about it every time. */
}
/* Popup geometry, shared between render and the row->y mapping used by input. */
static int g_popup_h, g_popup_w;
static int field_row(int field)
{
int row = field;
if (field >= F_DOWNLOAD_DIR)
row += 1; /* blank separator line between daemon and session fields */
return row;
}
void ui_settings_reconcile_now(AppState *st)
{
char err[256];
if (fetch_session(st, err, sizeof(err)) == 0)
reconcile_cache(st);
}
void ui_settings_render(AppState *st)
{
/* Always open the popup, even if the daemon is unreachable - that's
* often exactly when the user needs it, to fix the host/port. Only the
* session-derived fields (download dir, speed limits, ...) depend on a
* live connection; the daemon fields below always work. */
if (!g_attempted) {
char err[256];
g_connected = (fetch_session(st, err, sizeof(err)) == 0);
if (!g_connected) {
memset(&g_sess, 0, sizeof(g_sess));
ui_set_status(st, "Not connected: %s", err);
} else {
reconcile_cache(st);
}
g_attempted = 1;
g_cursor = 0;
}
/* Full-screen view rather than a popup layered over the list backdrop -
* matches ui_help_render()'s pattern. An overlapping popup+backdrop here
* turned out unreliable in practice (state changed correctly on 'g' but
* nothing visibly redrew until a later, unrelated screen update forced
* it), and a dedicated screen sidesteps that class of issue entirely. */
int rows, cols;
getmaxyx(stdscr, rows, cols);
erase();
g_popup_h = rows - 2;
g_popup_w = cols - 2;
if (g_popup_h < 4)
g_popup_h = rows > 4 ? 4 : rows;
if (g_popup_w < 10)
g_popup_w = cols > 10 ? 10 : cols;
WINDOW *win = derwin(stdscr, g_popup_h, g_popup_w, 1, 1);
ui_box(win, g_popup_h, g_popup_w);
ui_box_title(win, g_popup_w, g_connected ? "Settings" : "Settings - not connected");
char hhmm_begin[8], hhmm_end[8];
fmt_hhmm(g_sess.alt_time_begin, hhmm_begin, sizeof(hhmm_begin));
fmt_hhmm(g_sess.alt_time_end, hhmm_end, sizeof(hhmm_end));
char line[F_COUNT][640];
snprintf(line[F_HOST], sizeof(line[0]), "Host: %s", st->cfg->host);
snprintf(line[F_PORT], sizeof(line[0]), "Port: %d", st->cfg->port);
snprintf(line[F_USERNAME], sizeof(line[0]), "Username: %s",
st->cfg->username[0] ? st->cfg->username : "(none)");
snprintf(line[F_PASSWORD], sizeof(line[0]), "Password: %s",
st->cfg->password[0] ? "********" : "(none)");
snprintf(line[F_SHOW_SPLASH], sizeof(line[0]), "Splash screen: %s",
st->cfg->show_splash ? "On" : "Off");
snprintf(line[F_DOWNLOAD_DIR], sizeof(line[0]), "Download folder: %s",
g_connected ? g_sess.download_dir : "(not connected)");
snprintf(line[F_SPEED_DOWN_ENABLED], sizeof(line[0]), "Download limit: %s",
g_sess.speed_down_enabled ? "On" : "Off");
snprintf(line[F_SPEED_DOWN], sizeof(line[0]), " Limit (KB/s): %ld", g_sess.speed_down);
snprintf(line[F_SPEED_UP_ENABLED], sizeof(line[0]), "Upload limit: %s",
g_sess.speed_up_enabled ? "On" : "Off");
snprintf(line[F_SPEED_UP], sizeof(line[0]), " Limit (KB/s): %ld", g_sess.speed_up);
snprintf(line[F_ALT_ENABLED], sizeof(line[0]), "Alternative speed: %s",
g_sess.alt_enabled ? "On" : "Off");
snprintf(line[F_ALT_DOWN], sizeof(line[0]), " Down (KB/s): %ld", g_sess.alt_down);
snprintf(line[F_ALT_UP], sizeof(line[0]), " Up (KB/s): %ld", g_sess.alt_up);
snprintf(line[F_ALT_TIME_ENABLED], sizeof(line[0]), " Schedule: %s",
g_sess.alt_time_enabled ? "On" : "Off");
snprintf(line[F_ALT_TIME_BEGIN], sizeof(line[0]), " Start: %s", hhmm_begin);
snprintf(line[F_ALT_TIME_END], sizeof(line[0]), " End: %s", hhmm_end);
snprintf(line[F_SEED_RATIO_ENABLED], sizeof(line[0]), "Ratio limit: %s",
g_sess.seed_ratio_enabled ? "On" : "Off");
snprintf(line[F_SEED_RATIO], sizeof(line[0]), " Ratio: %.2f", g_sess.seed_ratio_limit);
int top = 2;
int field_w = g_popup_w - 4;
for (int i = 0; i < F_COUNT; i++) {
int y = top + field_row(i);
if (y >= g_popup_h - 2)
break;
int attr = (i == g_cursor) ? (COLOR_PAIR(CP_SELROW) | A_BOLD) : 0;
wattron(win, attr);
mvwprintw(win, y, 2, "%-*.*s", field_w, field_w, line[i]);
wattroff(win, attr);
}
wattron(win, COLOR_PAIR(CP_BORDER));
mvwprintw(win, g_popup_h - 2, 2, "%.*s", field_w,
g_connected
? "Esc/q=close Enter/space=change \xe2\x86\x91\xe2\x86\x93=navigate"
: "Esc/q=close Enter/space=change r=reconnect \xe2\x86\x91\xe2\x86\x93=navigate");
wattroff(win, COLOR_PAIR(CP_BORDER));
wnoutrefresh(win);
delwin(win);
doupdate();
}
static void apply_field(AppState *st, int field)
{
cJSON *args = cJSON_CreateObject();
char err[256];
int ok = 1;
int daemon_changed = 0;
int local_changed = 0; /* config.ini-only setting, no reconnect needed */
switch (field) {
case F_HOST: {
char v[256];
snprintf(v, sizeof(v), "%s", st->cfg->host);
if (ui_prompt("Host", v, v, sizeof(v)) && v[0]) {
snprintf(st->cfg->host, sizeof(st->cfg->host), "%s", v);
daemon_changed = 1;
}
ok = 0; /* not a session-set field */
break;
}
case F_PORT: {
char v[16];
snprintf(v, sizeof(v), "%d", st->cfg->port);
if (ui_prompt("Port", v, v, sizeof(v))) {
int p = atoi(v);
if (p > 0 && p <= 65535) {
st->cfg->port = p;
daemon_changed = 1;
} else {
ui_set_status(st, "Invalid port");
}
}
ok = 0;
break;
}
case F_USERNAME: {
char v[128];
snprintf(v, sizeof(v), "%s", st->cfg->username);
if (ui_prompt("Username", v, v, sizeof(v))) {
snprintf(st->cfg->username, sizeof(st->cfg->username), "%s", v);
daemon_changed = 1;
}
ok = 0;
break;
}
case F_PASSWORD: {
char v[128];
snprintf(v, sizeof(v), "%s", st->cfg->password);
if (ui_prompt("Password", v, v, sizeof(v))) {
snprintf(st->cfg->password, sizeof(st->cfg->password), "%s", v);
daemon_changed = 1;
}
ok = 0;
break;
}
case F_SHOW_SPLASH:
st->cfg->show_splash = !st->cfg->show_splash;
local_changed = 1;
ok = 0;
break;
case F_DOWNLOAD_DIR: {
char v[512];
int got;
if (ui_confirm("Download folder", "Browse for a folder? (No = type path)")) {
got = ui_folder_browser(g_sess.download_dir, v, sizeof(v));
} else {
snprintf(v, sizeof(v), "%s", g_sess.download_dir);
got = ui_prompt("Download folder", v, v, sizeof(v));
}
if (got) {
snprintf(g_sess.download_dir, sizeof(g_sess.download_dir), "%s", v);
cJSON_AddStringToObject(args, "download-dir", g_sess.download_dir);
} else {
ok = 0;
}
break;
}
case F_SPEED_DOWN_ENABLED:
g_sess.speed_down_enabled = !g_sess.speed_down_enabled;
cJSON_AddBoolToObject(args, "speed-limit-down-enabled", g_sess.speed_down_enabled);
break;
case F_SPEED_DOWN: {
char v[32];
snprintf(v, sizeof(v), "%ld", g_sess.speed_down);
if (ui_prompt("Download limit KB/s", v, v, sizeof(v))) {
g_sess.speed_down = strtol(v, NULL, 10);
cJSON_AddNumberToObject(args, "speed-limit-down", (double)g_sess.speed_down);
} else {
ok = 0;
}
break;
}
case F_SPEED_UP_ENABLED:
g_sess.speed_up_enabled = !g_sess.speed_up_enabled;
cJSON_AddBoolToObject(args, "speed-limit-up-enabled", g_sess.speed_up_enabled);
break;
case F_SPEED_UP: {
char v[32];
snprintf(v, sizeof(v), "%ld", g_sess.speed_up);
if (ui_prompt("Upload limit KB/s", v, v, sizeof(v))) {
g_sess.speed_up = strtol(v, NULL, 10);
cJSON_AddNumberToObject(args, "speed-limit-up", (double)g_sess.speed_up);
} else {
ok = 0;
}
break;
}
case F_ALT_ENABLED:
g_sess.alt_enabled = !g_sess.alt_enabled;
cJSON_AddBoolToObject(args, "alt-speed-enabled", g_sess.alt_enabled);
break;
case F_ALT_DOWN: {
char v[32];
snprintf(v, sizeof(v), "%ld", g_sess.alt_down);
if (ui_prompt("Alternative download speed KB/s", v, v, sizeof(v))) {
g_sess.alt_down = strtol(v, NULL, 10);
cJSON_AddNumberToObject(args, "alt-speed-down", (double)g_sess.alt_down);
} else {
ok = 0;
}
break;
}
case F_ALT_UP: {
char v[32];
snprintf(v, sizeof(v), "%ld", g_sess.alt_up);
if (ui_prompt("Alternative upload speed KB/s", v, v, sizeof(v))) {
g_sess.alt_up = strtol(v, NULL, 10);
cJSON_AddNumberToObject(args, "alt-speed-up", (double)g_sess.alt_up);
} else {
ok = 0;
}
break;
}
case F_ALT_TIME_ENABLED:
g_sess.alt_time_enabled = !g_sess.alt_time_enabled;
cJSON_AddBoolToObject(args, "alt-speed-time-enabled", g_sess.alt_time_enabled);
break;
case F_ALT_TIME_BEGIN: {
char v[8];
fmt_hhmm(g_sess.alt_time_begin, v, sizeof(v));
if (ui_prompt("Start time (HH:MM)", v, v, sizeof(v))) {
int mins = parse_hhmm(v);
if (mins < 0) {
ui_set_status(st, "Invalid time, use HH:MM");
ok = 0;
} else {
g_sess.alt_time_begin = mins;
cJSON_AddNumberToObject(args, "alt-speed-time-begin", mins);
}
} else {
ok = 0;
}
break;
}
case F_ALT_TIME_END: {
char v[8];
fmt_hhmm(g_sess.alt_time_end, v, sizeof(v));
if (ui_prompt("End time (HH:MM)", v, v, sizeof(v))) {
int mins = parse_hhmm(v);
if (mins < 0) {
ui_set_status(st, "Invalid time, use HH:MM");
ok = 0;
} else {
g_sess.alt_time_end = mins;
cJSON_AddNumberToObject(args, "alt-speed-time-end", mins);
}
} else {
ok = 0;
}
break;
}
case F_SEED_RATIO_ENABLED:
g_sess.seed_ratio_enabled = !g_sess.seed_ratio_enabled;
cJSON_AddBoolToObject(args, "seedRatioLimited", g_sess.seed_ratio_enabled);
break;
case F_SEED_RATIO: {
char v[32];
snprintf(v, sizeof(v), "%.2f", g_sess.seed_ratio_limit);
if (ui_prompt("Ratio limit", v, v, sizeof(v))) {
g_sess.seed_ratio_limit = strtod(v, NULL);
cJSON_AddNumberToObject(args, "seedRatioLimit", g_sess.seed_ratio_limit);
} else {
ok = 0;
}
break;
}
default:
ok = 0;
break;
}
if (daemon_changed) {
/* Live-apply to the RPC client and start a fresh CSRF handshake -
* the daemon we're now talking to may not know our old session id. */
snprintf(st->rpc->host, sizeof(st->rpc->host), "%s", st->cfg->host);
st->rpc->port = st->cfg->port;
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';
if (config_save(st->cfg, err, sizeof(err)) != 0)
ui_set_status(st, "Error saving: %s", err);
else
ui_set_status(st, "Connection settings saved");
g_attempted = 0; /* re-fetch session-get against the (possibly new) daemon */
st->need_poll_now = 1;
cJSON_Delete(args);
return;
}
if (local_changed) {
if (config_save(st->cfg, err, sizeof(err)) != 0)
ui_set_status(st, "Error saving: %s", err);
else
ui_set_status(st, "Setting saved");
cJSON_Delete(args);
return;
}
if (!ok || cJSON_GetArraySize(args) == 0) {
cJSON_Delete(args);
return;
}
if (push_session(st, args, err, sizeof(err)) != 0) {
ui_set_status(st, "Error: %s", err);
return;
}
/* Remember what was just set so it can be restored if the daemon later
* forgets it - see reconcile_cache() and Config's cache_* fields. */
Config *cfg = st->cfg;
cfg->session_cache_set = 1;
snprintf(cfg->cache_download_dir, sizeof(cfg->cache_download_dir), "%s", g_sess.download_dir);
cfg->cache_speed_down = g_sess.speed_down;
cfg->cache_speed_down_enabled = g_sess.speed_down_enabled;
cfg->cache_speed_up = g_sess.speed_up;
cfg->cache_speed_up_enabled = g_sess.speed_up_enabled;
cfg->cache_alt_enabled = g_sess.alt_enabled;
cfg->cache_alt_down = g_sess.alt_down;
cfg->cache_alt_up = g_sess.alt_up;
cfg->cache_alt_time_enabled = g_sess.alt_time_enabled;
cfg->cache_alt_time_begin = g_sess.alt_time_begin;
cfg->cache_alt_time_end = g_sess.alt_time_end;
cfg->cache_seed_ratio_limit = g_sess.seed_ratio_limit;
cfg->cache_seed_ratio_enabled = g_sess.seed_ratio_enabled;
config_save(cfg, err, sizeof(err)); /* best effort - a failure here just means no cache this time */
ui_set_status(st, "Setting saved");
}
void ui_settings_handle_key(AppState *st, int ch)
{
switch (ch) {
case 'q':
case 27:
g_attempted = 0;
st->view = VIEW_LIST;
break;
case 'r':
if (!g_connected) {
ui_offer_reconnect_help(st);
g_attempted = 0; /* retry session-get on next render either way */
}
break;
case KEY_UP:
case 'k':
if (g_cursor > 0)
g_cursor--;
break;
case KEY_DOWN:
case 'j':
if (g_cursor + 1 < F_COUNT)
g_cursor++;
break;
case '\n':
case '\r':
case KEY_ENTER:
case ' ':
apply_field(st, g_cursor);
break;
default:
break;
}
}