foxygit / TransTUI Log in
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