commit c4e49af577b408d5fa690b3ba81496b81dd4f6b2
Author: MrJensK <jens.se@icloud.com>
AuthorDate: Fri Aug 14 17:47:05 2026 +0200
Commit: MrJensK <jens.se@icloud.com>
CommitDate: Fri Aug 14 17:47:05 2026 +0200
Enhance torrent handling: open UI on add, implement single instance lock, and update documentation
---
Makefile | 7 +++++++
desktop/transtui-add.desktop | 4 ++--
docs/Browser-Integration.md | 47 +++++++++++++++++++++++++++++++-------------
docs/Installation.md | 7 +++++++
src/config.c | 6 +++---
src/main.c | 35 ++++++++++++++++++++++++---------
src/ui/ui.c | 7 +++++--
src/ui/ui.h | 6 +++++-
src/util.c | 26 ++++++++++++++++++++++++
src/util.h | 9 +++++++++
10 files changed, 123 insertions(+), 31 deletions(-)
diff --git a/Makefile b/Makefile
index 3334c06..8b2352f 100644
--- a/Makefile
+++ b/Makefile
@@ -33,6 +33,13 @@ release: clean $(BIN)
install: release
install -Dm755 $(BIN) $(DESTDIR)$(PREFIX)/bin/$(BIN)
+ifeq ($(shell id -u),0)
+ @echo "Running as root - skipping desktop/magnet-link registration"
+ @echo "(it installs into \$$HOME for the invoking user). Run"
+ @echo "'make install-desktop' as your normal user to enable it."
+else
+ $(MAKE) install-desktop
+endif
# Registers transtui as the handler for magnet: links and .torrent files, so
# "Open with" in a browser/file manager routes straight to `transtui --add`
diff --git a/desktop/transtui-add.desktop b/desktop/transtui-add.desktop
index 21ab32a..80d8386 100644
--- a/desktop/transtui-add.desktop
+++ b/desktop/transtui-add.desktop
@@ -1,9 +1,9 @@
[Desktop Entry]
Type=Application
Name=TransTUI (add torrent)
-Comment=Adds a magnet link or .torrent file to Transmission via transtui
+Comment=Adds a magnet link or .torrent file to Transmission and opens transtui
Exec=transtui --add %u
-Terminal=false
+Terminal=true
NoDisplay=true
MimeType=x-scheme-handler/magnet;application/x-bittorrent;
Categories=Network;FileTransfer;
diff --git a/docs/Browser-Integration.md b/docs/Browser-Integration.md
index 947a02e..3290301 100644
--- a/docs/Browser-Integration.md
+++ b/docs/Browser-Integration.md
@@ -1,8 +1,8 @@
# Browser Integration
-`transtui` can run headless to just add a torrent and exit - handy for
-registering it as the target when you click a magnet link or open a
-downloaded `.torrent` file.
+`transtui` can add a torrent and immediately open its normal TUI so you see
+it land in the list - handy for registering it as the target when you click
+a magnet link or open a downloaded `.torrent` file.
## `--add` mode
@@ -14,33 +14,51 @@ transtui --add "file:///path/to/file.torrent" # file:// URIs are supported too
Adds the torrent to the daemon (using the usual host/port/user/pass
configuration from `config.ini`, optionally overridden with
-`-H`/`-P`/`-u`/`-p`, see [Configuration](Configuration.md)) and exits
-immediately - no ncurses screen opens at all. Prints a result to
-stdout/stderr and sets the exit code (`0` = success, `1` = error).
+`-H`/`-P`/`-u`/`-p`, see [Configuration](Configuration.md)), then opens the
+regular ncurses UI with the result ("Torrent added: ..." or "Could not add
+torrent: ...") shown in the status bar.
`file://` URIs are decoded (percent-decoding, e.g. `%20` → space) before
the file is read, since desktop environments often send a `file://` URI
rather than a plain path when invoking an "open with" handler.
+Only one transtui instance runs at a time (enforced via a per-user lock
+file). Clicking a second magnet link while transtui is already open adds
+the torrent to the daemon and exits immediately instead of opening a second
+UI - the running instance picks it up on its next poll. Launching plain
+`transtui` (no `--add`) while one is already running just prints
+`transtui: already running` and exits.
+
## Registering as the default handler
+`make install` runs this automatically as long as it's not run as root:
+
+```sh
+make install PREFIX=$HOME/.local # binary + desktop/magnet registration, no sudo needed
+```
+
+`sudo make install` (system-wide binary install) skips it, since it would
+otherwise register the magnet handler under root's `$HOME` instead of yours.
+Run it yourself afterward, as your normal user:
+
```sh
-make install # transtui must be in PATH
make install-desktop # installs desktop/transtui-add.desktop and runs xdg-mime
```
`make install-desktop`:
1. Installs `desktop/transtui-add.desktop`
- (`Exec=transtui --add %u`, `Terminal=false`) to
+ (`Exec=transtui --add %u`, `Terminal=true`) to
`~/.local/share/applications/`.
2. Runs `update-desktop-database`.
3. Runs `xdg-mime default transtui-add.desktop x-scheme-handler/magnet`
and the same for `application/x-bittorrent`.
After that: clicking a magnet link in the browser, or "Open with" on a
-downloaded `.torrent` file in the file manager, adds it to the daemon
-immediately - no terminal or TUI pops up.
+downloaded `.torrent` file in the file manager, opens a terminal window
+running transtui - `Terminal=true` is required here since ncurses needs a
+real TTY to draw itself, which a desktop launcher doesn't provide on its
+own.
## Troubleshooting
@@ -48,8 +66,9 @@ immediately - no terminal or TUI pops up.
is on `PATH` (`which transtui`) and that
`xdg-mime query default x-scheme-handler/magnet` points at
`transtui-add.desktop`.
-- **"could not add torrent"**: the same connection problems as in the
- regular TUI mode can happen here too (wrong host/port/password) - see
+- **"Could not add torrent" in the status bar**: the same connection
+ problems as in the regular TUI mode can happen here too (wrong host/port/
+ password) - see
[Daemon: setup & troubleshooting](Daemon-Setup-and-Troubleshooting.md).
- Since `--add` isn't interactive, no help dialogs show up here; run
- `transtui` (without `--add`) once to resolve the connection first.
+ If the daemon can't be reached at all, transtui falls back to its normal
+ reconnect-help screen instead of showing the add result.
diff --git a/docs/Installation.md b/docs/Installation.md
index 5e6edeb..e33b63a 100644
--- a/docs/Installation.md
+++ b/docs/Installation.md
@@ -40,6 +40,13 @@ Run as root or set `DESTDIR`/`PREFIX` for a different location, e.g.:
make install PREFIX=$HOME/.local
```
+When not run as root, `make install` also registers `transtui` as the
+handler for magnet links and `.torrent` files (see
+[Browser Integration](Browser-Integration.md)), so clicking a magnet link in
+the browser opens `transtui --add` automatically. `sudo make install` skips
+this step - run `make install-desktop` afterward as your normal user
+instead.
+
After installing, make sure `$PREFIX/bin` is in your `PATH` if you're using
a non-standard path - otherwise `transtui --add ...` from the browser won't
find the binary (see [Browser Integration](Browser-Integration.md)).
diff --git a/src/config.c b/src/config.c
index 3561812..b15406a 100644
--- a/src/config.c
+++ b/src/config.c
@@ -217,9 +217,9 @@ static void usage(const char *prog)
" -p pass RPC password\n"
" -i ms Poll interval in milliseconds (default 2000)\n"
" -A, --add <source>\n"
- " Add a torrent (magnet link, URL, or .torrent file) and exit\n"
- " immediately without opening the UI - for registering transtui\n"
- " as the browser handler for magnet links/.torrent files.\n"
+ " Add a torrent (magnet link, URL, or .torrent file), then open\n"
+ " the UI - for registering transtui as the browser handler for\n"
+ " magnet links/.torrent files.\n"
" -h Show this help\n",
prog, prog);
}
diff --git a/src/main.c b/src/main.c
index b60a3cd..38896d5 100644
--- a/src/main.c
+++ b/src/main.c
@@ -32,18 +32,20 @@ static void resolve_add_source(const char *in, char *out, size_t outsize)
out[o] = '\0';
}
-static int run_add_and_exit(RpcClient *rpc, const char *add_source)
+/* Adds a torrent handed in via -A/--add (e.g. from a browser's magnet-link
+ * handler) before the TUI starts, so the user sees it land in the list
+ * instead of a silent background add. msg is filled with the result either
+ * way, for display in the TUI's status bar. */
+static void add_torrent_startup(RpcClient *rpc, const char *add_source, char *msg, size_t msg_size)
{
char source[1024];
resolve_add_source(add_source, source, sizeof(source));
char err[256];
- if (torrent_add(rpc, source, NULL, err, sizeof(err)) != 0) {
- fprintf(stderr, "transtui: could not add torrent: %s\n", err);
- return 1;
- }
- printf("Torrent added: %s\n", source);
- return 0;
+ if (torrent_add(rpc, source, NULL, err, sizeof(err)) != 0)
+ snprintf(msg, msg_size, "Could not add torrent: %s", err);
+ else
+ snprintf(msg, msg_size, "Torrent added: %s", source);
}
int main(int argc, char **argv)
@@ -62,8 +64,23 @@ int main(int argc, char **argv)
rpc_init(&rpc, cfg.host, cfg.port, cfg.username[0] ? cfg.username : NULL,
cfg.password[0] ? cfg.password : NULL);
+ char msg[sizeof(add_source) + 32];
+ msg[0] = '\0';
if (add_source[0])
- return run_add_and_exit(&rpc, add_source);
+ add_torrent_startup(&rpc, add_source, msg, sizeof(msg));
+
+ /* Only one TUI at a time: if another instance already holds the lock,
+ * a --add here has still reached the daemon above and will show up next
+ * time that instance polls, so just report the result and exit instead
+ * of opening a second ncurses screen. */
+ if (single_instance_lock() == 0) {
+ if (add_source[0]) {
+ printf("%s\n", msg);
+ return 0;
+ }
+ fprintf(stderr, "transtui: already running\n");
+ return 1;
+ }
- return ui_run(&rpc, &cfg);
+ return ui_run(&rpc, &cfg, msg[0] ? msg : NULL);
}
diff --git a/src/ui/ui.c b/src/ui/ui.c
index ee755f7..416e552 100644
--- a/src/ui/ui.c
+++ b/src/ui/ui.c
@@ -423,7 +423,7 @@ void ui_offer_reconnect_help(AppState *st)
offer_daemon_start(st);
}
-int ui_run(RpcClient *rpc, Config *cfg)
+int ui_run(RpcClient *rpc, Config *cfg, const char *startup_msg)
{
AppState st;
memset(&st, 0, sizeof(st));
@@ -475,7 +475,10 @@ int ui_run(RpcClient *rpc, Config *cfg)
if (ui_refresh_list(&st) != 0) {
ui_offer_reconnect_help(&st);
} else {
- ui_set_status(&st, "Connected to %s:%d", cfg->host, cfg->port);
+ 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);
}
diff --git a/src/ui/ui.h b/src/ui/ui.h
index d8379c4..4ff3228 100644
--- a/src/ui/ui.h
+++ b/src/ui/ui.h
@@ -111,7 +111,11 @@ typedef struct {
int perm_fix_offered;
} AppState;
-int ui_run(RpcClient *rpc, Config *cfg);
+/* startup_msg, if non-NULL, is shown in the status bar in place of the
+ * default "Connected to host:port" message once the initial list load
+ * succeeds - used to report the result of a --add done before the TUI
+ * started. */
+int ui_run(RpcClient *rpc, Config *cfg, const char *startup_msg);
/* Shows the startup splash (block-letter logo) and returns once it's been on
* screen for a bit or the user presses a key, whichever is first. Returns
diff --git a/src/util.c b/src/util.c
index 9824da7..02e7f22 100644
--- a/src/util.c
+++ b/src/util.c
@@ -1,10 +1,13 @@
#include "util.h"
#include <ctype.h>
+#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
+#include <sys/file.h>
#include <time.h>
+#include <unistd.h>
void fmt_size(int64_t bytes, char *buf, size_t n)
{
@@ -156,6 +159,29 @@ int str_starts_with(const char *s, const char *prefix)
return strncmp(s, prefix, strlen(prefix)) == 0;
}
+int single_instance_lock(void)
+{
+ char path[512];
+ const char *rundir = getenv("XDG_RUNTIME_DIR");
+ if (rundir && rundir[0])
+ snprintf(path, sizeof(path), "%s/transtui.lock", rundir);
+ else
+ snprintf(path, sizeof(path), "/tmp/transtui-%d.lock", (int)getuid());
+
+ int fd = open(path, O_CREAT | O_RDWR, 0600);
+ if (fd < 0)
+ return -1;
+
+ if (flock(fd, LOCK_EX | LOCK_NB) != 0) {
+ close(fd);
+ return 0;
+ }
+ /* fd is deliberately leaked - it must stay open for the process's
+ * lifetime for the lock to hold, and is closed by the kernel on exit
+ * (normal or crash) either way. */
+ return 1;
+}
+
int str_ci_contains(const char *haystack, const char *needle)
{
size_t nlen = strlen(needle);
diff --git a/src/util.h b/src/util.h
index 255fba8..cb41504 100644
--- a/src/util.h
+++ b/src/util.h
@@ -23,4 +23,13 @@ int str_starts_with(const char *s, const char *prefix);
/* Case-insensitive substring test. Empty needle always matches. */
int str_ci_contains(const char *haystack, const char *needle);
+/* Tries to become the single running transtui instance for this user, via
+ * an flock() on a per-user lock file (released automatically by the kernel
+ * on exit or crash, so a killed process can never leave a stale lock
+ * around). Returns 1 if this process holds the lock (no other instance is
+ * running), 0 if another instance already holds it, or -1 if the lock file
+ * itself couldn't be opened (treat as 1 - fail open rather than block the
+ * user out over an unrelated filesystem error). */
+int single_instance_lock(void);
+
#endif