foxygit / TransTUI Log in
commit a2c07f1d2c30ed86f7e2910005e0d3412c436d7f
Author:     MrJensK <jens.se@icloud.com>
AuthorDate: Fri Jul 31 14:31:18 2026 +0200
Commit:     MrJensK <jens.se@icloud.com>
CommitDate: Fri Jul 31 14:31:18 2026 +0200

    updates
---
 docs/Architecture.md  |  19 +++++++
 docs/Configuration.md |  20 +++++++
 docs/Interface.md     |  27 ++++++++--
 src/config.c          |  50 ++++++++++++++++-
 src/config.h          |  24 +++++++++
 src/ui/ui.c           |   6 ++-
 src/ui/ui.h           |  12 +++++
 src/ui/ui_dialogs.c   | 100 ++++++++++++++++++++++++----------
 src/ui/ui_settings.c  | 147 ++++++++++++++++++++++++++++++++++++++++++++++++--
 9 files changed, 362 insertions(+), 43 deletions(-)

diff --git a/docs/Architecture.md b/docs/Architecture.md
index b2c9202..f462783 100644
--- a/docs/Architecture.md
+++ b/docs/Architecture.md
@@ -86,6 +86,25 @@ 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
diff --git a/docs/Configuration.md b/docs/Configuration.md
index 38c4f34..feac948 100644
--- a/docs/Configuration.md
+++ b/docs/Configuration.md
@@ -27,6 +27,26 @@ The file is written automatically when you change connection or
 splash-screen settings in the app (`g`, see [Interface](Interface.md#settings-g)) -
 you rarely need to edit it by hand.

+### Session settings cache (`cache_*` keys)
+
+Whenever you change a daemon-side session setting - download folder, speed
+limits, alt-speed schedule, ratio limit - `transtui` also remembers that
+value in `config.ini` (as `session_cache_set` and a set of `cache_*` keys).
+This exists because `transmission-daemon` only writes its own
+`settings.json` to disk on a clean shutdown, not immediately when a
+setting is changed over RPC - if the daemon is later killed uncleanly
+(crash, power loss, `kill -9`) before a clean stop happens, it reloads its
+old `settings.json` on the next start and silently forgets the change.
+
+`transtui` checks for this every time it connects (at startup, and each
+time you open settings): if the daemon's current value doesn't match the
+cache, it pushes the cached value back and shows a status message like
+"Restored 1 setting(s) the daemon had reverted". This intentionally wins
+over changes made through any other RPC client (Transmission's own web UI,
+`transmission-remote`, ...) since `transtui` was last used - if you manage
+the daemon from multiple tools, keep that in mind. These keys are
+maintained automatically; there's normally no reason to edit them by hand.
+
 ## Command-line flags

 ```
diff --git a/docs/Interface.md b/docs/Interface.md
index f2da522..ab300bb 100644
--- a/docs/Interface.md
+++ b/docs/Interface.md
@@ -79,9 +79,11 @@ wrong host/port. Contains:
   command-line flags (`-H`/`-P`/`-u`/`-p`), which are still read at startup
   and override what's in `config.ini`.
 - **Splash screen** (On/Off).
-- **Download folder** and speed limits/ratio limit/alt-speed schedule -
-  read/written directly against the daemon via `session-get`/`session-set`,
-  not saved locally.
+- **Download folder** (choose to browse or type a path, same picker as
+  described below) and speed limits/ratio limit/alt-speed schedule -
+  read/written directly against the daemon via `session-get`/`session-set`.
+  Also cached locally so an unclean daemon restart can't silently lose the
+  change - see [Configuration](Configuration.md#session-settings-cache-cache_-keys).

 `↑`/`↓` navigates the fields, `Enter`/`space` changes a value or toggles
 on/off, `Esc`/`q` closes. If the daemon isn't connected it's shown in the
@@ -110,5 +112,20 @@ showing only subfolders and `.torrent` files to keep it uncluttered:
 | `Backspace` / `←` | go up one folder (also listed as `..`) |
 | `Esc` / `q` | cancel |

-After picking a file (or typing a source), you're asked for a download
-folder (blank = the daemon's default).
+After picking a file (or typing a source), you're asked whether to use the
+daemon's default download folder, or choose one - and if choosing, whether
+to browse or type a path. The folder picker works like the file picker
+above, but only lists subfolders (no `.torrent` filter) and `space`
+selects whichever folder is currently open, since there's no file to press
+`Enter` on to finish:
+
+| Key | Function |
+|---|---|
+| `↑`/`k`, `↓`/`j` | move selection |
+| `Enter` | open the selected folder |
+| `space` | select the folder currently shown at the top of the box |
+| `Backspace` / `←` | go up one folder (also listed as `..`) |
+| `Esc` / `q` | cancel |
+
+The same folder picker is used for the **Download folder** field in
+settings (`g`).
diff --git a/src/config.c b/src/config.c
index 680e1cf..3561812 100644
--- a/src/config.c
+++ b/src/config.c
@@ -105,6 +105,34 @@ static void parse_file(Config *cfg, FILE *f)
             cfg->poll_interval_ms = atoi(val);
         else if (strcmp(key, "show_splash") == 0)
             cfg->show_splash = (strcmp(val, "0") != 0 && strcasecmp(val, "false") != 0);
+        else if (strcmp(key, "session_cache_set") == 0)
+            cfg->session_cache_set = (strcmp(val, "0") != 0 && strcasecmp(val, "false") != 0);
+        else if (strcmp(key, "cache_download_dir") == 0)
+            snprintf(cfg->cache_download_dir, sizeof(cfg->cache_download_dir), "%s", val);
+        else if (strcmp(key, "cache_speed_down") == 0)
+            cfg->cache_speed_down = atol(val);
+        else if (strcmp(key, "cache_speed_down_enabled") == 0)
+            cfg->cache_speed_down_enabled = (strcmp(val, "0") != 0 && strcasecmp(val, "false") != 0);
+        else if (strcmp(key, "cache_speed_up") == 0)
+            cfg->cache_speed_up = atol(val);
+        else if (strcmp(key, "cache_speed_up_enabled") == 0)
+            cfg->cache_speed_up_enabled = (strcmp(val, "0") != 0 && strcasecmp(val, "false") != 0);
+        else if (strcmp(key, "cache_alt_enabled") == 0)
+            cfg->cache_alt_enabled = (strcmp(val, "0") != 0 && strcasecmp(val, "false") != 0);
+        else if (strcmp(key, "cache_alt_down") == 0)
+            cfg->cache_alt_down = atol(val);
+        else if (strcmp(key, "cache_alt_up") == 0)
+            cfg->cache_alt_up = atol(val);
+        else if (strcmp(key, "cache_alt_time_enabled") == 0)
+            cfg->cache_alt_time_enabled = (strcmp(val, "0") != 0 && strcasecmp(val, "false") != 0);
+        else if (strcmp(key, "cache_alt_time_begin") == 0)
+            cfg->cache_alt_time_begin = atoi(val);
+        else if (strcmp(key, "cache_alt_time_end") == 0)
+            cfg->cache_alt_time_end = atoi(val);
+        else if (strcmp(key, "cache_seed_ratio_limit") == 0)
+            cfg->cache_seed_ratio_limit = atof(val);
+        else if (strcmp(key, "cache_seed_ratio_enabled") == 0)
+            cfg->cache_seed_ratio_enabled = (strcmp(val, "0") != 0 && strcasecmp(val, "false") != 0);
     }
 }

@@ -153,9 +181,27 @@ int config_save(const Config *cfg, char *err, size_t errlen)
             "username = %s\n"
             "password = %s\n"
             "poll_interval_ms = %d\n"
-            "show_splash = %d\n",
+            "show_splash = %d\n"
+            "session_cache_set = %d\n"
+            "cache_download_dir = %s\n"
+            "cache_speed_down = %ld\n"
+            "cache_speed_down_enabled = %d\n"
+            "cache_speed_up = %ld\n"
+            "cache_speed_up_enabled = %d\n"
+            "cache_alt_enabled = %d\n"
+            "cache_alt_down = %ld\n"
+            "cache_alt_up = %ld\n"
+            "cache_alt_time_enabled = %d\n"
+            "cache_alt_time_begin = %d\n"
+            "cache_alt_time_end = %d\n"
+            "cache_seed_ratio_limit = %.2f\n"
+            "cache_seed_ratio_enabled = %d\n",
             cfg->host, cfg->port, cfg->username, cfg->password, cfg->poll_interval_ms,
-            cfg->show_splash);
+            cfg->show_splash, cfg->session_cache_set, cfg->cache_download_dir,
+            cfg->cache_speed_down, cfg->cache_speed_down_enabled, cfg->cache_speed_up,
+            cfg->cache_speed_up_enabled, cfg->cache_alt_enabled, cfg->cache_alt_down,
+            cfg->cache_alt_up, cfg->cache_alt_time_enabled, cfg->cache_alt_time_begin,
+            cfg->cache_alt_time_end, cfg->cache_seed_ratio_limit, cfg->cache_seed_ratio_enabled);
     fclose(f);
     return 0;
 }
diff --git a/src/config.h b/src/config.h
index dd9b7f9..02cb227 100644
--- a/src/config.h
+++ b/src/config.h
@@ -11,6 +11,30 @@ typedef struct {
     int poll_interval_ms;
     int show_splash;
     char path[1024]; /* resolved config file path, for messages */
+
+    /* Cache of the session settings (download dir, speed limits, ...) the
+     * user last explicitly set via the in-app settings screen.
+     * transmission-daemon only writes its own settings.json to disk on a
+     * clean shutdown, not immediately when changed over RPC - if it's later
+     * killed uncleanly (crash, power loss, kill -9) before that, the change
+     * is lost and the daemon reverts to whatever it last saved. transtui
+     * re-applies this cache whenever the daemon's current session doesn't
+     * match it (see ui_settings.c), so an intentional change survives an
+     * unclean daemon restart even though the daemon itself forgot it. */
+    int session_cache_set; /* 0 until a session field has been changed at least once */
+    char cache_download_dir[512];
+    long cache_speed_down;
+    int cache_speed_down_enabled;
+    long cache_speed_up;
+    int cache_speed_up_enabled;
+    int cache_alt_enabled;
+    long cache_alt_down;
+    long cache_alt_up;
+    int cache_alt_time_enabled;
+    int cache_alt_time_begin;
+    int cache_alt_time_end;
+    double cache_seed_ratio_limit;
+    int cache_seed_ratio_enabled;
 } Config;

 /* Loads ~/.config/transtui/config.ini (or $XDG_CONFIG_HOME/transtui/config.ini).
diff --git a/src/ui/ui.c b/src/ui/ui.c
index fd2bb98..655d933 100644
--- a/src/ui/ui.c
+++ b/src/ui/ui.c
@@ -413,10 +413,12 @@ int ui_run(RpcClient *rpc, Config *cfg)
     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)
+    if (ui_refresh_list(&st) != 0) {
         ui_offer_reconnect_help(&st);
-    else
+    } 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
diff --git a/src/ui/ui.h b/src/ui/ui.h
index 129c348..97f85c0 100644
--- a/src/ui/ui.h
+++ b/src/ui/ui.h
@@ -144,6 +144,12 @@ void ui_draw_footer(const KeyHint *hints, size_t n, int nrows);
 int ui_confirm(const char *title, const char *msg);
 int ui_prompt(const char *title, const char *initial, char *out, size_t outsize);
 void ui_message(const char *title, const char *msg);
+/* Directory picker: navigate with arrows/Enter, `space` selects whichever
+ * folder is currently open, Backspace goes up, Esc cancels. start_hint (may
+ * be NULL/empty/nonexistent) is where browsing begins if it's a real
+ * directory; otherwise falls back to the last folder browsed to, then
+ * $HOME. Returns 1 with `out` filled on selection, 0 on cancel. */
+int ui_folder_browser(const char *start_hint, char *out, size_t outsize);

 void ui_dialog_add_torrent(AppState *st);
 void ui_dialog_remove(AppState *st, const int *ids, size_t n);
@@ -161,6 +167,12 @@ void ui_detail_handle_key(AppState *st, int ch);

 void ui_settings_render(AppState *st);
 void ui_settings_handle_key(AppState *st, int ch);
+/* Fetches the daemon's current session settings and, if they don't match
+ * what the user last explicitly set via the settings screen, pushes the
+ * remembered values back - see reconcile_cache() in ui_settings.c. Called
+ * once after the initial connection so an unclean daemon restart gets
+ * fixed up even if the user never reopens settings. */
+void ui_settings_reconcile_now(AppState *st);

 void ui_help_render(AppState *st);
 void ui_help_handle_key(AppState *st, int ch);
diff --git a/src/ui/ui_dialogs.c b/src/ui/ui_dialogs.c
index 5501603..b1e063f 100644
--- a/src/ui/ui_dialogs.c
+++ b/src/ui/ui_dialogs.c
@@ -50,14 +50,15 @@ int ui_confirm(const char *title, const char *msg)
     if (w > cols - 4)
         w = cols - 4;
     WINDOW *win = open_box(5, w, title);
+    keypad(win, TRUE);
     mvwprintw(win, 2, 2, "%.*s", w - 4, msg);
-    mvwprintw(win, 3, 2, "[y]es  [n]o");
+    mvwprintw(win, 3, 2, "[y]es/Enter  [n]o/Esc");
     wrefresh(win);

     int result = 0;
     for (;;) {
         int ch = wgetch(win);
-        if (ch == 'y' || ch == 'Y') {
+        if (ch == 'y' || ch == 'Y' || ch == '\n' || ch == '\r' || ch == KEY_ENTER) {
             result = 1;
             break;
         }
@@ -160,10 +161,12 @@ static int browse_entry_cmp(const void *a, const void *b)
     return strcasecmp(ea->name, eb->name);
 }

-/* Lists `path`'s entries into *out_entries (caller frees), showing only
- * subdirectories and *.torrent files - a plain directory can otherwise be
- * full of unrelated files that just make picking a torrent tedious. */
-static int load_dir(const char *path, BrowseEntry **out_entries, size_t *out_n)
+/* Lists `path`'s entries into *out_entries (caller frees). In file mode
+ * shows subdirectories and *.torrent files (a plain directory can
+ * otherwise be full of unrelated files that just make picking a torrent
+ * tedious); in dirs_only mode shows only subdirectories, since a folder
+ * picker has no use for regular files at all. */
+static int load_dir(const char *path, int dirs_only, BrowseEntry **out_entries, size_t *out_n)
 {
     DIR *d = opendir(path);
     if (!d)
@@ -187,7 +190,7 @@ static int load_dir(const char *path, BrowseEntry **out_entries, size_t *out_n)
         if (stat(full, &st) != 0)
             continue;
         int is_dir = S_ISDIR(st.st_mode);
-        if (!is_dir && !has_torrent_ext(de->d_name))
+        if (!is_dir && (dirs_only || !has_torrent_ext(de->d_name)))
             continue;
         if (n == cap) {
             cap *= 2;
@@ -212,28 +215,41 @@ static int load_dir(const char *path, BrowseEntry **out_entries, size_t *out_n)
     return 0;
 }

-/* Remembered across calls in this run so re-opening the browser picks up
- * where you left off instead of always starting back at $HOME. */
-static char g_browse_dir[1024];
-
-/* Simple two-pane-free directory browser: navigate with arrows, Enter
- * descends into a directory or selects a *.torrent file, Backspace goes up.
- * Returns 1 with `out` filled on selection, 0 on cancel. */
-static int ui_file_browser(char *out, size_t outsize)
+/* Remembered across calls in this run so re-opening a browser picks up
+ * where you left off instead of always starting back at $HOME - kept
+ * separate per mode since "last .torrent folder" and "last download
+ * folder" are usually different places. */
+static char g_browse_file_dir[1024];
+static char g_browse_folder_dir[1024];
+
+/* Shared directory-browsing engine behind ui_file_browser()/
+ * ui_folder_browser(). In file mode (dirs_only=0): Enter descends into a
+ * directory or selects a highlighted *.torrent file. In dirs_only mode:
+ * only directories are listed/enterable, and `space` selects whichever
+ * directory is currently open (there's no file to press Enter on to
+ * finish). Backspace goes up either way. Returns 1 with `out` filled on
+ * selection, 0 on cancel. */
+static int ui_browse(char *out, size_t outsize, int dirs_only, const char *title,
+                      const char *start_hint)
 {
-    if (!g_browse_dir[0]) {
+    char *remembered = dirs_only ? g_browse_folder_dir : g_browse_file_dir;
+    char path[1024];
+    struct stat hint_st;
+    if (start_hint && start_hint[0] && stat(start_hint, &hint_st) == 0 && S_ISDIR(hint_st.st_mode))
+        snprintf(path, sizeof(path), "%s", start_hint);
+    else if (remembered[0])
+        snprintf(path, sizeof(path), "%s", remembered);
+    else {
         const char *home = getenv("HOME");
-        snprintf(g_browse_dir, sizeof(g_browse_dir), "%s", (home && home[0]) ? home : "/");
+        snprintf(path, sizeof(path), "%s", (home && home[0]) ? home : "/");
     }
-    char path[1024];
-    snprintf(path, sizeof(path), "%s", g_browse_dir);

     int cursor = 0, top = 0, result = 0;

     for (;;) {
         BrowseEntry *entries = NULL;
         size_t n = 0;
-        if (load_dir(path, &entries, &n) != 0) {
+        if (load_dir(path, dirs_only, &entries, &n) != 0) {
             ui_message("Error", "Could not open directory");
             break;
         }
@@ -246,18 +262,21 @@ static int ui_file_browser(char *out, size_t outsize)
         getmaxyx(stdscr, rows, cols);
         int h = rows > 14 ? rows - 4 : rows;
         int w = cols > 48 ? cols - 8 : cols;
-        WINDOW *win = open_box(h, w, "Select .torrent file");
+        WINDOW *win = open_box(h, w, title);
         keypad(win, TRUE);
         int list_top = 2;
         int list_h = h - list_top - 2;
         if (list_h < 1)
             list_h = 1;

+        const char *hint = dirs_only ? "Enter=open  space=select this folder  Backspace=up  Esc=cancel"
+                                      : "Enter=open/select  Backspace=up  Esc=cancel";
+
         int reload = 0; /* 0=keep reading input, 1=dir changed, 2=done */
         while (!reload) {
             werase(win);
             ui_box(win, h, w);
-            ui_box_title(win, w, "Select .torrent file");
+            ui_box_title(win, w, title);
             mvwprintw(win, 1, 2, "%.*s", w - 4, path);

             if (cursor < top)
@@ -268,7 +287,8 @@ static int ui_file_browser(char *out, size_t outsize)
                 top = 0;

             if (n == 0)
-                mvwprintw(win, list_top, 2, "(no subfolders or .torrent files here)");
+                mvwprintw(win, list_top, 2, dirs_only ? "(no subfolders here)"
+                                                        : "(no subfolders or .torrent files here)");
             for (int row = 0; row < list_h; row++) {
                 int idx = top + row;
                 if ((size_t)idx >= n)
@@ -280,8 +300,7 @@ static int ui_file_browser(char *out, size_t outsize)
                 wattroff(win, attr);
             }
             wattron(win, COLOR_PAIR(CP_BORDER));
-            mvwprintw(win, h - 2, 2, "%.*s", w - 4,
-                      "Enter=open/select  Backspace=up  Esc=cancel");
+            mvwprintw(win, h - 2, 2, "%.*s", w - 4, hint);
             wattroff(win, COLOR_PAIR(CP_BORDER));
             wrefresh(win);

@@ -312,6 +331,14 @@ static int ui_file_browser(char *out, size_t outsize)
                     reload = 1;
                 }
                 break;
+            case ' ':
+                if (dirs_only) {
+                    snprintf(out, outsize, "%s", path);
+                    snprintf(remembered, sizeof(path), "%s", path);
+                    result = 1;
+                    reload = 2;
+                }
+                break;
             case '\n':
             case '\r':
             case KEY_ENTER:
@@ -337,7 +364,7 @@ static int ui_file_browser(char *out, size_t outsize)
                         reload = 1;
                     } else {
                         snprintf(out, outsize, "%s", candidate);
-                        snprintf(g_browse_dir, sizeof(g_browse_dir), "%s", path);
+                        snprintf(remembered, sizeof(path), "%s", path);
                         result = 1;
                         reload = 2;
                     }
@@ -345,7 +372,7 @@ static int ui_file_browser(char *out, size_t outsize)
                 break;
             case 27:
             case 'q':
-                snprintf(g_browse_dir, sizeof(g_browse_dir), "%s", path);
+                snprintf(remembered, sizeof(path), "%s", path);
                 result = 0;
                 reload = 2;
                 break;
@@ -361,6 +388,16 @@ static int ui_file_browser(char *out, size_t outsize)
     return result;
 }

+static int ui_file_browser(char *out, size_t outsize)
+{
+    return ui_browse(out, outsize, 0, "Select .torrent file", NULL);
+}
+
+int ui_folder_browser(const char *start_hint, char *out, size_t outsize)
+{
+    return ui_browse(out, outsize, 1, "Select download folder", start_hint);
+}
+
 void ui_dialog_add_torrent(AppState *st)
 {
     char source[1024] = "";
@@ -373,8 +410,13 @@ void ui_dialog_add_torrent(AppState *st)
     if (!have_source || source[0] == '\0')
         return;

-    char dir[512] = "";
-    ui_prompt("Download folder (blank = default)", "", dir, sizeof(dir));
+    char dir[1024] = "";
+    if (!ui_confirm("Download folder", "Use the default download folder?")) {
+        if (ui_confirm("Download folder", "Browse for a folder? (No = type path)"))
+            ui_folder_browser(NULL, dir, sizeof(dir));
+        else
+            ui_prompt("Download folder", "", dir, sizeof(dir));
+    }

     char err[256];
     if (torrent_add(st->rpc, source, dir[0] ? dir : NULL, err, sizeof(err)) != 0)
diff --git a/src/ui/ui_settings.c b/src/ui/ui_settings.c
index b92db60..b3a2985 100644
--- a/src/ui/ui_settings.c
+++ b/src/ui/ui_settings.c
@@ -122,6 +122,107 @@ static int parse_hhmm(const char *s)
     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;

@@ -133,6 +234,13 @@ static int field_row(int field)
     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
@@ -145,6 +253,8 @@ void ui_settings_render(AppState *st)
         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;
@@ -288,8 +398,14 @@ static void apply_field(AppState *st, int field)
         break;
     case F_DOWNLOAD_DIR: {
         char v[512];
-        snprintf(v, sizeof(v), "%s", g_sess.download_dir);
-        if (ui_prompt("Download folder", v, v, sizeof(v))) {
+        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 {
@@ -444,10 +560,31 @@ static void apply_field(AppState *st, int field)
         return;
     }

-    if (push_session(st, args, err, sizeof(err)) != 0)
+    if (push_session(st, args, err, sizeof(err)) != 0) {
         ui_set_status(st, "Error: %s", err);
-    else
-        ui_set_status(st, "Setting saved");
+        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)