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);