foxygit / TransTUI Log in
commit f613d74512ea6c44e01a5c373a1d1efb530ad2b9
Author:     MrJensK <jens.se@icloud.com>
AuthorDate: Wed Aug 12 19:59:25 2026 +0200
Commit:     MrJensK <jens.se@icloud.com>
CommitDate: Wed Aug 12 19:59:25 2026 +0200

    Add permission fix for torrent download directories in daemon
---
 docs/Daemon-Setup-and-Troubleshooting.md | 51 +++++++++++++++++++++++++--
 docs/Interface.md                        | 10 +++---
 src/daemon.c                             | 30 ++++++++++++++++
 src/daemon.h                             | 15 ++++++++
 src/ui/ui.c                              | 59 ++++++++++++++++++++++++++++++++
 src/ui/ui.h                              |  4 +++
 6 files changed, 162 insertions(+), 7 deletions(-)

diff --git a/docs/Daemon-Setup-and-Troubleshooting.md b/docs/Daemon-Setup-and-Troubleshooting.md
index 3fc8457..0c27e59 100644
--- a/docs/Daemon-Setup-and-Troubleshooting.md
+++ b/docs/Daemon-Setup-and-Troubleshooting.md
@@ -83,10 +83,57 @@ sudo systemctl start transmission-daemon
 Transmission hashes the password in the file automatically the next time
 the daemon starts - that's expected, not a bug.

+## Case 3: A torrent errors with "permission denied" on its download directory
+
+Very common when the daemon was installed via `apt`: Debian's package runs
+`transmission-daemon` as its own system account (`debian-transmission`), not
+as you. If you point a torrent at a folder only your user owns - e.g. your
+own `~/Downloads`, which defaults to mode `0700` - the daemon can create the
+torrent but can't write any data into it, and the torrent sits in an error
+state with an `errorString` like:
+
+```
+Couldn't create '/home/you/Downloads/...': Permission denied
+```
+
+The first time `transtui` sees a torrent error containing "permission
+denied" (and the daemon is local, and *your* user genuinely can write to
+that directory - so this really is just an account mismatch, not a missing
+folder), it asks:
+
+> '\<torrent>' can't write to '\<dir>' (permission denied) - looks like the
+> daemon's service account can't get into a directory only your user owns.
+> Fix it now (requires sudo)?
+
+If you answer `y`, `transtui`:
+
+1. Looks up the directory's owning group and runs
+   `sudo usermod -aG <that group> debian-transmission`.
+2. `chmod`s the directory group-writable (no `sudo` needed - you already own
+   it).
+3. Restarts the daemon via `sudo systemctl restart transmission-daemon`,
+   since group membership only takes effect for processes started after the
+   change.
+4. Retries the torrent that errored.
+
+This is offered at most once per session (whether you accept or decline),
+so a torrent still failing for some other reason won't re-prompt on every
+poll.
+
+### Manually, if you'd rather do it yourself
+
+```sh
+sudo usermod -aG "$(stat -c '%G' ~/Downloads)" debian-transmission
+chmod g+rwx ~/Downloads
+sudo systemctl restart transmission-daemon
+```
+
 ## Triggering the help again later

-Both flows can be triggered again: open settings (`g`) and press `r` when
-the title shows "not connected".
+The connection flows (cases 1 and 2) can be triggered again: open settings
+(`g`) and press `r` when the title shows "not connected". The download
+directory fix (case 3) isn't tied to that key - just restart `transtui`,
+which resets the once-per-session offer.

 ## Other common errors

diff --git a/docs/Interface.md b/docs/Interface.md
index ab300bb..6117d44 100644
--- a/docs/Interface.md
+++ b/docs/Interface.md
@@ -28,13 +28,13 @@ until you press a key. Can be turned off permanently in settings
 ## List view

 ```
-╭─ Filter ─────╮╭─ All (2) ───────────────────────────────────────────────╮
-│ ❯ All        ││  Name            Size        %  Status       Down   Up  │
+╭─ Filter ─────╮╭─ All (2) ────────────────────────────────────────────────╮
+│ ❯ All        ││  Name            Size        %  Status       Down   Up   │
 │   Downloading││  Debian netinst  667 MiB  100%  Seeding      0 B/s  12K/s│
 │   Uploading  ││  Ubuntu ISO      3.7 GiB    75%  Downloading 5M/s   0 B/s│
-│   Paused     ││                                                         │
-│   Completed  ││                                                         │
-╰──────────────╯╰─────────────────────────────────────────────────────────╯
+│   Paused     ││                                                          │
+│   Completed  ││                                                          │
+╰──────────────╯╰──────────────────────────────────────────────────────────╯
   ↑↓  Navigate  Tab  Filter  Enter  Details  Space  Select  s/S/p  Start/Stop
   a  Add  d  Remove  l  Speed  o/O  Sort  /  Search  g  Settings
 ```
diff --git a/src/daemon.c b/src/daemon.c
index 6212ea3..7a2b98b 100644
--- a/src/daemon.c
+++ b/src/daemon.c
@@ -4,9 +4,11 @@

 #include <errno.h>
 #include <fcntl.h>
+#include <grp.h>
 #include <stdio.h>
 #include <stdlib.h>
 #include <string.h>
+#include <sys/stat.h>
 #include <sys/wait.h>
 #include <unistd.h>

@@ -168,3 +170,31 @@ int daemon_fix_auth(const char *username, const char *password, char *err, size_
     unlink(tmp_path);
     return rc;
 }
+
+#define TRANSMISSION_DAEMON_USER "debian-transmission"
+
+int daemon_fix_download_dir_perms(const char *dir, char *err, size_t errlen)
+{
+    struct stat dst;
+    if (stat(dir, &dst) != 0) {
+        snprintf(err, errlen, "cannot stat %s: %s", dir, strerror(errno));
+        return -1;
+    }
+    struct group *gr = getgrgid(dst.st_gid);
+    if (!gr) {
+        snprintf(err, errlen, "cannot resolve group for %s", dir);
+        return -1;
+    }
+
+    char *usermod_argv[] = {"sudo", "usermod", "-aG", gr->gr_name, TRANSMISSION_DAEMON_USER, NULL};
+    if (run_argv(usermod_argv, err, errlen) != 0)
+        return -1;
+
+    if (chmod(dir, (dst.st_mode & 07777) | S_IRGRP | S_IWGRP | S_IXGRP) != 0) {
+        snprintf(err, errlen, "chmod %s failed: %s", dir, strerror(errno));
+        return -1;
+    }
+
+    char *restart_argv[] = {"sudo", "systemctl", "restart", TRANSMISSION_SERVICE, NULL};
+    return run_argv(restart_argv, err, errlen);
+}
diff --git a/src/daemon.h b/src/daemon.h
index f8c1460..e39dc71 100644
--- a/src/daemon.h
+++ b/src/daemon.h
@@ -28,4 +28,19 @@ int daemon_start(char *err, size_t errlen);
  * the settings file wasn't found, or the service failed to restart). */
 int daemon_fix_auth(const char *username, const char *password, char *err, size_t errlen);

+/* For when the daemon's errorString on a torrent points at a permission
+ * problem writing to `dir`: adds the system daemon's service account
+ * (Debian's package runs it as `debian-transmission`) to `dir`'s owning
+ * group, chmods the directory group-writable, and restarts the service via
+ * `sudo systemctl` so the new group membership takes effect (group changes
+ * don't apply to an already-running process). `dir` must be a directory the
+ * *invoking* user already owns/can write - this only bridges the gap to the
+ * separate daemon account, it can't grant access nobody has.
+ *
+ * sudo will prompt on the controlling terminal - same caller responsibility
+ * as daemon_fix_auth() re: leaving/re-entering curses mode.
+ *
+ * Returns 0 on success, -1 on failure (err filled). */
+int daemon_fix_download_dir_perms(const char *dir, char *err, size_t errlen);
+
 #endif
diff --git a/src/ui/ui.c b/src/ui/ui.c
index 655d933..ee755f7 100644
--- a/src/ui/ui.c
+++ b/src/ui/ui.c
@@ -125,6 +125,8 @@ void ui_set_status(AppState *st, const char *fmt, ...)
     st->status_msg_until = time(NULL) + 5;
 }

+static void check_download_dir_perms(AppState *st);
+
 int ui_refresh_list(AppState *st)
 {
     char err[256];
@@ -138,6 +140,7 @@ int ui_refresh_list(AppState *st)
         ui_set_status(st, "Connection error: %s", err);
         return -1;
     }
+    check_download_dir_perms(st);
     return 0;
 }

@@ -356,6 +359,62 @@ static void offer_fix_auth(AppState *st)
     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)
diff --git a/src/ui/ui.h b/src/ui/ui.h
index 97f85c0..d8379c4 100644
--- a/src/ui/ui.h
+++ b/src/ui/ui.h
@@ -105,6 +105,10 @@ typedef struct {

     struct timespec last_poll;
     int need_poll_now;
+
+    /* set once we've offered (accepted or not) to fix a torrent's download
+     * directory permissions this session - see check_download_dir_perms() */
+    int perm_fix_offered;
 } AppState;

 int ui_run(RpcClient *rpc, Config *cfg);