commit 7dbe67c8c2e9648975cfa2bdc911ba3b08a1ca52
Author: MrJensK <jens.se@icloud.com>
AuthorDate: Thu Jul 30 23:13:12 2026 +0200
Commit: MrJensK <jens.se@icloud.com>
CommitDate: Thu Jul 30 23:13:12 2026 +0200
New
---
.gitignore | 2 +
Makefile | 49 +
README.md | 49 +
desktop/transtui-add.desktop | 9 +
docs/Architecture.md | 113 ++
docs/Browser-Integration.md | 55 +
docs/Configuration.md | 62 +
docs/Daemon-Setup-and-Troubleshooting.md | 101 +
docs/Home.md | 42 +
docs/Installation.md | 51 +
docs/Interface.md | 114 ++
docs/Keybindings.md | 76 +
src/config.c | 209 ++
src/config.h | 33 +
src/daemon.c | 170 ++
src/daemon.h | 31 +
src/http.c | 302 +++
src/http.h | 25 +
src/main.c | 69 +
src/net.c | 56 +
src/net.h | 10 +
src/rpc.c | 91 +
src/rpc.h | 32 +
src/torrent.c | 348 ++++
src/torrent.h | 117 ++
src/ui/ui.c | 497 +++++
src/ui/ui.h | 168 ++
src/ui/ui_details.c | 290 +++
src/ui/ui_dialogs.c | 419 ++++
src/ui/ui_help.c | 68 +
src/ui/ui_list.c | 515 +++++
src/ui/ui_settings.c | 486 +++++
src/ui/ui_splash.c | 113 ++
src/util.c | 179 ++
src/util.h | 26 +
third_party/cJSON.c | 3206 ++++++++++++++++++++++++++++++
third_party/cJSON.h | 306 +++
37 files changed, 8489 insertions(+)
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..2e22939
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,2 @@
+*.o
+/transtui
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..3334c06
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,49 @@
+CC ?= cc
+PREFIX ?= /usr/local
+PKGCFG := $(shell pkg-config --exists ncursesw && echo yes)
+
+CFLAGS ?= -Os -Wall -Wextra -std=c11 -D_GNU_SOURCE
+CPPFLAGS := -Isrc -Ithird_party
+LDFLAGS ?=
+
+ifeq ($(PKGCFG),yes)
+ CFLAGS += $(shell pkg-config --cflags ncursesw)
+ LDLIBS += $(shell pkg-config --libs ncursesw)
+else
+ LDLIBS += -lncursesw
+endif
+
+SRCS := $(wildcard src/*.c) $(wildcard src/ui/*.c) third_party/cJSON.c
+OBJS := $(SRCS:.c=.o)
+BIN := transtui
+
+.PHONY: all clean release install install-desktop
+
+all: $(BIN)
+
+$(BIN): $(OBJS)
+ $(CC) $(CFLAGS) $(OBJS) -o $@ $(LDFLAGS) $(LDLIBS)
+
+%.o: %.c
+ $(CC) $(CFLAGS) $(CPPFLAGS) -c $< -o $@
+
+release: CFLAGS += -DNDEBUG
+release: clean $(BIN)
+ strip $(BIN)
+
+install: release
+ install -Dm755 $(BIN) $(DESTDIR)$(PREFIX)/bin/$(BIN)
+
+# Registers transtui as the handler for magnet: links and .torrent files, so
+# "Open with" in a browser/file manager routes straight to `transtui --add`
+# (see desktop/transtui-add.desktop). Requires transtui already on PATH
+# (run `make install` first) and the xdg-utils/desktop-file-utils tools.
+install-desktop:
+ install -Dm644 desktop/transtui-add.desktop \
+ $(DESTDIR)$(HOME)/.local/share/applications/transtui-add.desktop
+ -update-desktop-database $(DESTDIR)$(HOME)/.local/share/applications 2>/dev/null
+ -xdg-mime default transtui-add.desktop x-scheme-handler/magnet
+ -xdg-mime default transtui-add.desktop application/x-bittorrent
+
+clean:
+ rm -f $(OBJS) $(BIN)
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..ec486e4
--- /dev/null
+++ b/README.md
@@ -0,0 +1,49 @@
+# TransTUI
+
+A terminal client (ncurses) for the [Transmission](https://transmissionbt.com/)
+BitTorrent daemon, written in C. Talks directly to `transmission-daemon`'s
+JSON-RPC API - no `libcurl`, `jansson`, or other heavy dependency, just
+`libncursesw`.
+
+```
+╭─ 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 ││ │
+╰──────────────╯╰─────────────────────────────────────────────────────────╯
+ ↑↓ Navigate Tab Filter Enter Details Space Select s/S/p Start/Stop
+ a Add d Remove l Speed o/O Sort / Search g Settings
+```
+
+## Quick start
+
+```sh
+sudo apt install build-essential libncurses-dev pkg-config # build deps
+make && make install
+transtui
+```
+
+Connects to `127.0.0.1:9091` by default. No daemon running, or it requires
+a password you don't know? `transtui` detects that and helps you fix it
+interactively at startup - see the
+[wiki](docs/Daemon-Setup-and-Troubleshooting.md) for details.
+
+## Documentation
+
+Full documentation lives in the [wiki](docs/Home.md):
+
+- **[Installation](docs/Installation.md)** - dependencies, building, installing
+- **[Configuration](docs/Configuration.md)** - `config.ini`, command-line flags
+- **[Interface](docs/Interface.md)** - a tour through the views
+- **[Keybindings](docs/Keybindings.md)** - full reference
+- **[Daemon: setup & troubleshooting](docs/Daemon-Setup-and-Troubleshooting.md)**
+- **[Browser Integration](docs/Browser-Integration.md)** - add torrents
+ directly from magnet links/`.torrent` files
+- **[Architecture](docs/Architecture.md)** - how the code is structured and why
+
+## License
+
+`third_party/cJSON.c`/`.h` is [cJSON](https://github.com/DaveGamble/cJSON),
+MIT licensed.
diff --git a/desktop/transtui-add.desktop b/desktop/transtui-add.desktop
new file mode 100644
index 0000000..21ab32a
--- /dev/null
+++ b/desktop/transtui-add.desktop
@@ -0,0 +1,9 @@
+[Desktop Entry]
+Type=Application
+Name=TransTUI (add torrent)
+Comment=Adds a magnet link or .torrent file to Transmission via transtui
+Exec=transtui --add %u
+Terminal=false
+NoDisplay=true
+MimeType=x-scheme-handler/magnet;application/x-bittorrent;
+Categories=Network;FileTransfer;
diff --git a/docs/Architecture.md b/docs/Architecture.md
new file mode 100644
index 0000000..b2c9202
--- /dev/null
+++ b/docs/Architecture.md
@@ -0,0 +1,113 @@
+# Architecture
+
+## Module overview
+
+```
+src/
+├── main.c CLI parsing, switches between TUI mode and --add mode
+├── net.c/.h raw TCP connection (connect w/ timeout)
+├── http.c/.h minimal HTTP/1.1 client on top of net.h (POST, headers,
+ Content-Length and chunked response parsing)
+├── rpc.c/.h Transmission JSON-RPC layer: session-id/409 handling
+├── torrent.c/.h data model + parsing of torrent-get responses into structs
+├── config.c/.h config.ini reading/writing, CLI flags
+├── daemon.c/.h start local transmission-daemon, fix RPC auth (sudo)
+├── util.c/.h string formatting, base64, file I/O, small string utils
+└── ui/
+ ├── ui.c/.h shared types (AppState), main loop, colors, frames
+ ├── ui_list.c list view: sidebar, columns, sort/filter/search
+ ├── ui_details.c detail view: General/Files/Peers/Trackers
+ ├── ui_settings.c settings screen
+ ├── ui_dialogs.c confirmations/text prompts/messages
+ ├── ui_splash.c splash screen
+ └── ui_help.c help screen
+
+third_party/cJSON.c/.h vendored JSON parser (github.com/DaveGamble/cJSON)
+```
+
+## Design decisions
+
+### No `libcurl`/`jansson`/`json-c`
+
+Linking in `libcurl` would have pulled in openssl/zlib/nghttp2 and friends
+and made the binary considerably larger for a protocol that's fundamentally
+just "a POST with a JSON body". `http.c` implements exactly what
+Transmission's RPC spec requires: an HTTP/1.1 POST, reading the response
+with either `Content-Length` or `Transfer-Encoding: chunked`, and parsing
+the `X-Transmission-Session-Id` header. `cJSON` is vendored as a single
+`.c`/`.h` file instead of a system dependency. Result: the binary lands
+around 70-90 KB stripped, with only `libncursesw` as a runtime dependency.
+
+### Transmission's CSRF handshake
+
+Transmission's RPC requires an `X-Transmission-Session-Id` header; a call
+without one (or with the wrong session id) gets a `409 Conflict` back with
+the correct id in the response header. `rpc_call()` in `rpc.c` makes this
+transparent: on `409` the new session id is saved and the call is retried
+once automatically - the rest of the code never has to think about it.
+
+### Single-threaded event loop
+
+No background thread. The main loop (`ui_run()` in `ui.c`) sets
+`timeout(200)` on `getch()`; every time it times out (no keypress), it
+checks whether `poll_interval_ms` has elapsed since the last list refresh,
+and if so does a new RPC refresh. All RPC calls are synchronous - on a
+local network that's fast enough to feel instant, and it avoids the whole
+class of race conditions a threaded solution would have introduced.
+
+### Read on demand
+
+`torrent-get` for the full list only fetches summary fields (name, size,
+status, speeds, ...). Files/peers/trackers are only fetched when the
+detail view opens for a specific torrent, and are only kept in memory
+while that view is open - not for every torrent on every refresh.
+
+### Memory reuse in the list
+
+`TorrentList` in `torrent.c` only reallocates its array when it needs to
+grow, never shrinks, and never reallocates fresh on every refresh.
+Selection (`space` marking) in the UI is tied to the torrent `id`, not the
+array index, and is restored after every refresh since the daemon's
+response order isn't guaranteed stable.
+
+### Shared UI building blocks
+
+`ui_box()`/`ui_box_title()` (rounded frames) and `ui_draw_footer()`
+(key chips) in `ui.c` are used by every view for a consistent look. Column
+positions in the list view are computed once per render into a
+`ContentCols` struct and reused for both the header and the rows, so they
+can never drift out of sync with each other. Popup views (dialogs,
+confirmations, prompts) render with `open_box()`/`close_box()` in
+`ui_dialogs.c`, which call `touchwin(stdscr)` after closing so the next
+full-screen redraw doesn't skip cells it thinks already match. The
+settings view used to render as a popup layered over the list view as a
+backdrop, but that turned out unreliable on some real terminals (the view
+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.
+
+### Two pinned environment details
+
+- **`LC_NUMERIC` pinned to `"C"`.** `setlocale(LC_ALL, "")` (needed for
+ correct UTF-8 rendering in ncursesw) also changes the decimal separator
+ according to the system locale. cJSON's number parsing uses `strtod()`
+ under the active locale, so under e.g. a locale with a comma decimal
+ separator, `"1.0"` in a JSON response would be parsed incorrectly and
+ desync the whole parser. `LC_NUMERIC` is therefore reset to `"C"`
+ immediately after, while the rest of the locale (for UTF-8/character
+ width) is left as the system's own.
+- **`set_escdelay(25)`.** ncurses waits 1 second by default after a lone
+ `Esc` keypress to decide whether it's a standalone Esc or the start of a
+ function-key sequence. That makes Esc feel noticeably sluggish; the
+ value is lowered to 25 ms at startup.
+
+### `daemon.c`: never shells out a password
+
+When `transtui` rewrites `/etc/transmission-daemon/settings.json` (see
+[Daemon: setup & troubleshooting](Daemon-Setup-and-Troubleshooting.md)),
+the file is read/modified/written via cJSON to a temp file, and `sudo
+systemctl stop/start` plus `sudo cp` are then each run separately via
+`fork()`+`execvp()` with argv arrays - never through a shell string. The
+user-chosen password therefore never passes through a shell or a `sed`
+expression, so there's nothing to escape and no injection surface
+regardless of what characters it contains.
diff --git a/docs/Browser-Integration.md b/docs/Browser-Integration.md
new file mode 100644
index 0000000..947a02e
--- /dev/null
+++ b/docs/Browser-Integration.md
@@ -0,0 +1,55 @@
+# 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.
+
+## `--add` mode
+
+```sh
+transtui --add "magnet:?xt=urn:btih:..."
+transtui --add /path/to/file.torrent
+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).
+
+`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.
+
+## Registering as the default handler
+
+```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
+ `~/.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.
+
+## Troubleshooting
+
+- **Nothing happens when you click a magnet link**: check that `transtui`
+ 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
+ [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.
diff --git a/docs/Configuration.md b/docs/Configuration.md
new file mode 100644
index 0000000..38c4f34
--- /dev/null
+++ b/docs/Configuration.md
@@ -0,0 +1,62 @@
+# Configuration
+
+## Config file
+
+`transtui` reads (and creates, with defaults, if missing)
+`~/.config/transtui/config.ini` (or `$XDG_CONFIG_HOME/transtui/config.ini`
+if that environment variable is set):
+
+```ini
+host = 127.0.0.1
+port = 9091
+username =
+password =
+poll_interval_ms = 2000
+show_splash = 1
+```
+
+| Field | Meaning |
+|---|---|
+| `host` | Transmission daemon address |
+| `port` | RPC port (Transmission's default is 9091) |
+| `username` / `password` | RPC authentication, empty if the daemon doesn't require it |
+| `poll_interval_ms` | How often the list auto-refreshes (min. 250 ms) |
+| `show_splash` | `1`/`0` - show the splash screen at startup |
+
+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.
+
+## Command-line flags
+
+```
+transtui [-H host] [-P port] [-u user] [-p pass] [-i ms]
+transtui --add <magnet|url|file> [-H host] [-P port] [-u user] [-p pass]
+```
+
+| Flag | Meaning |
+|---|---|
+| `-H`, `--host <host>` | Override `host` for this run |
+| `-P`, `--port <port>` | Override `port` |
+| `-u`, `--user <name>` | Override `username` |
+| `-p`, `--pass <pass>` | Override `password` |
+| `-i`, `--interval <ms>` | Override `poll_interval_ms` |
+| `-A`, `--add <source>` | Add a torrent and exit immediately, see [Browser Integration](Browser-Integration.md) |
+| `-h`, `--help` | Show help |
+
+The flags do **not** overwrite `config.ini` - they only apply to that run.
+That makes them good for one-off runs, scripts, or pointing at a different
+daemon temporarily without changing your usual setup.
+
+## Connecting to a remote daemon
+
+```sh
+transtui -H 192.168.1.10 -P 9091 -u username -p password
+```
+
+Works against any Transmission daemon that exposes the RPC, local or over
+the network - `transtui` just needs to reach the port. The automatic "no
+daemon found" help (starting/configuring the daemon for you) only applies
+to `127.0.0.1`/`localhost` though, for obvious reasons: it can't `sudo`
+into another machine. See
+[Daemon: setup & troubleshooting](Daemon-Setup-and-Troubleshooting.md).
diff --git a/docs/Daemon-Setup-and-Troubleshooting.md b/docs/Daemon-Setup-and-Troubleshooting.md
new file mode 100644
index 0000000..3fc8457
--- /dev/null
+++ b/docs/Daemon-Setup-and-Troubleshooting.md
@@ -0,0 +1,101 @@
+# Daemon: setup & troubleshooting
+
+If `transtui` can't connect at startup, and `host` points at the local
+machine (`127.0.0.1`/`localhost`), it tries to help automatically - what
+happens depends on *why* the connection failed.
+
+## Case 1: No daemon responds at all
+
+You're asked:
+
+> No daemon is responding at 127.0.0.1:9091. Start transmission-daemon?
+
+If you answer `y`, `transmission-daemon` is launched (must be in `PATH`)
+and `transtui` tries to connect for a few seconds while it starts up.
+
+**If `transmission-daemon` isn't installed**, you instead get a clear error
+message with an install hint:
+
+```sh
+sudo apt install transmission-daemon # Debian/Ubuntu
+```
+
+(on other distributions a generic pointer to your package manager is shown,
+e.g. `dnf install transmission-daemon` on Fedora).
+
+## Case 2: The daemon responds but requires a password you don't know (HTTP 401)
+
+Very common if you installed via `apt`: Debian's package starts
+`transmission-daemon` as a **systemd service** right at install time, with
+RPC authentication enabled and a **randomly generated password** that's
+never shown in plaintext anywhere. There's no way to "read back" that
+password afterward.
+
+`transtui` detects this specifically (distinguishes "no daemon responding"
+from "daemon responds with 401") and asks:
+
+> The daemon at 127.0.0.1:9091 is responding but requires a password we
+> don't know. Fix it now (requires sudo)?
+
+If you answer `y` you get to choose:
+
+- **Set a custom password** - you enter a password, `transtui` sets
+ `rpc-username` to `transmission` and that password in the daemon's
+ config, and saves the same credentials in your own `config.ini` so the
+ next run connects automatically.
+- **No** (disable password protection entirely) -
+ `rpc-authentication-required` is set to `false`. Convenient if you run
+ everything locally on your own machine and don't care about access
+ control on the RPC port.
+
+How it's done technically: `transtui` reads
+`/etc/transmission-daemon/settings.json`, only changes the affected fields
+(via JSON parsing, no text manipulation), writes the result to a temp
+file, then runs `sudo systemctl stop transmission-daemon`,
+`sudo cp <tempfile> /etc/transmission-daemon/settings.json`, and
+`sudo systemctl start transmission-daemon` as three separate commands (no
+shell involved), so your password can never accidentally be interpreted as
+a shell command regardless of what characters it contains. The `sudo`
+prompt appears in the same terminal `transtui` is running in.
+
+### Manually, if you'd rather do it yourself
+
+```sh
+sudo systemctl stop transmission-daemon
+sudo nano /etc/transmission-daemon/settings.json
+```
+
+Change:
+
+```json
+"rpc-authentication-required": true,
+"rpc-username": "transmission",
+"rpc-password": "YourPassword",
+```
+
+(or set `rpc-authentication-required` to `false` to disable password
+protection entirely). Save, then restart:
+
+```sh
+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.
+
+## Triggering the help again later
+
+Both flows can be triggered again: open settings (`g`) and press `r` when
+the title shows "not connected".
+
+## Other common errors
+
+| Symptom | Likely cause |
+|---|---|
+| `Connection error: cannot connect to host:port: Connection refused` | No daemon is listening on the given host/port - see above for `127.0.0.1`, otherwise check that the remote daemon is running and the port is reachable (firewall?) |
+| `HTTP error 401 from server` (in the status line, without the auto-help appearing) | Host isn't `127.0.0.1`/`localhost` - can't be auto-fixed on a remote machine, set `-u`/`-p` or edit the settings manually with the right credentials |
+| `RPC error: ...` | The daemon responded but the RPC call failed for another reason - the text after the colon comes directly from Transmission's own error reporting |
+| `could not parse JSON response` | Unusual - check that `host`/`port` actually point at a Transmission daemon and not something else that happens to respond on that port |
+
+See also [Configuration](Configuration.md) for how host/port/user/pass are
+set, and [Architecture](Architecture.md) for protocol details.
diff --git a/docs/Home.md b/docs/Home.md
new file mode 100644
index 0000000..04812d9
--- /dev/null
+++ b/docs/Home.md
@@ -0,0 +1,42 @@
+# TransTUI wiki
+
+TransTUI is a terminal client (ncurses) for the [Transmission](https://transmissionbt.com/)
+BitTorrent daemon, written in C. It talks directly to `transmission-daemon`'s
+JSON-RPC API - no `libcurl`, `jansson`, or other heavy dependency required.
+
+```
+ ╭─ TransTUI ──────────────────────────────────────────╮
+ │ │
+ │ █████ ████ ███ █ █ ████ █████ █ █ █████ │
+ │ █ █ █ █ █ ██ █ █ █ █ █ █ │
+ │ █ ████ █████ █ █ █ ███ █ █ █ █ │
+ │ █ █ █ █ █ █ ██ █ █ █ █ █ │
+ │ █ █ █ █ █ █ █ ████ █ ███ █████ │
+ │ │
+ │ Transmission TUI Client │
+ ╰─────────────────────────────────────────────────────╯
+```
+
+## Contents
+
+- **[Installation](Installation.md)** - dependencies, building, installing
+- **[Configuration](Configuration.md)** - `config.ini`, command-line flags
+- **[Interface](Interface.md)** - tour: sidebar, list view, detail view,
+ settings screen, splash screen
+- **[Keybindings](Keybindings.md)** - full reference
+- **[Daemon: setup & troubleshooting](Daemon-Setup-and-Troubleshooting.md)**
+ - no daemon found, HTTP 401 authentication, common errors
+- **[Browser Integration](Browser-Integration.md)** - `--add` mode,
+ `.desktop` file for magnet links/`.torrent` files
+- **[Architecture](Architecture.md)** - how the code is structured and why
+
+## Quick start
+
+```sh
+make && make install
+transtui
+```
+
+See [Installation](Installation.md) for dependencies and
+[Daemon: setup & troubleshooting](Daemon-Setup-and-Troubleshooting.md) if
+`transtui` can't connect to a daemon.
diff --git a/docs/Installation.md b/docs/Installation.md
new file mode 100644
index 0000000..5e6edeb
--- /dev/null
+++ b/docs/Installation.md
@@ -0,0 +1,51 @@
+# Installation
+
+## Dependencies
+
+**Building:** `gcc`/`cc`, `make`, `pkg-config`, `libncursesw-dev` (or the
+equivalent dev package for your distro, e.g. `ncurses-devel` on Fedora).
+
+**Running:** only `libncursesw` (shared via the system, already installed if
+you could build). No `libcurl`, `jansson`, or `json-c` needed - the HTTP
+client (raw TCP socket, own HTTP/1.1 parser) and JSON handling
+([cJSON](https://github.com/DaveGamble/cJSON), vendored in `third_party/`)
+are built into the binary. See [Architecture](Architecture.md) for why.
+
+Debian/Ubuntu:
+
+```sh
+sudo apt install build-essential libncursesw6 libncurses-dev pkg-config
+```
+
+## Build
+
+```sh
+make # regular build (-Os -Wall -Wextra), binary: ./transtui
+make release # same, but stripped binary (smaller file size)
+make clean # removes build artifacts
+```
+
+The binary typically lands around 70-90 KB stripped, thanks to no heavy
+libraries being linked in.
+
+## Install
+
+```sh
+make install # copies to $PREFIX/bin (default /usr/local/bin)
+```
+
+Run as root or set `DESTDIR`/`PREFIX` for a different location, e.g.:
+
+```sh
+make install PREFIX=$HOME/.local
+```
+
+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)).
+
+## Next steps
+
+- [Configuration](Configuration.md) - connect to your `transmission-daemon`
+- [Daemon: setup & troubleshooting](Daemon-Setup-and-Troubleshooting.md) -
+ if you don't already have a daemon running
diff --git a/docs/Interface.md b/docs/Interface.md
new file mode 100644
index 0000000..f2da522
--- /dev/null
+++ b/docs/Interface.md
@@ -0,0 +1,114 @@
+# Interface
+
+The layout is inspired by [torrra](https://github.com/stabldev/torrra): a
+left sidebar with filter categories, a main panel with the torrent list, and
+an always-visible footer with keybindings. All panels and popups are drawn
+with rounded corners (`╭─╮`/`╰─╯`) in a consistent style.
+
+## Splash screen
+
+```
+ ╭─ TransTUI ──────────────────────────────────────────╮
+ │ │
+ │ █████ ████ ███ █ █ ████ █████ █ █ █████ │
+ │ █ █ █ █ █ ██ █ █ █ █ █ █ │
+ │ █ ████ █████ █ █ █ ███ █ █ █ █ │
+ │ █ █ █ █ █ █ ██ █ █ █ █ █ │
+ │ █ █ █ █ █ █ █ ████ █ ███ █████ │
+ │ │
+ │ Transmission TUI Client │
+ │ Press any key to continue… │
+ ╰─────────────────────────────────────────────────────╯
+```
+
+A blocky, white "16-bit"-style splash screen shows for up to 3 seconds or
+until you press a key. Can be turned off permanently in settings
+(`g` → *Splash screen: On/Off*), see [Configuration](Configuration.md).
+
+## List view
+
+```
+╭─ 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 ││ │
+╰──────────────╯╰─────────────────────────────────────────────────────────╯
+ ↑↓ Navigate Tab Filter Enter Details Space Select s/S/p Start/Stop
+ a Add d Remove l Speed o/O Sort / Search g Settings
+```
+
+- **The sidebar** (left) filters the list live by category: All,
+ Downloading, Uploading, Paused, Completed.
+- **The main panel** (right) shows torrents matching the filter, with
+ columns for size, progress, status, speeds, ETA, and ratio (the last two
+ are automatically hidden on narrow terminals to give the name more room).
+- **The footer** shows keybindings as chips, context-dependent on whether
+ the sidebar or the list has focus.
+- **The status line** (above the footer) shows total speeds, torrent count,
+ and sort column - or a temporary status message after an action (e.g.
+ "Started 1 torrent(s)").
+
+`Tab` switches keyboard focus between the sidebar and the list. With focus
+in the sidebar, `↑`/`↓` moves the cursor between categories and applies the
+filter immediately (live preview); `Enter`/`→`/`Tab` moves focus back to
+the list. Full key reference: [Keybindings](Keybindings.md).
+
+## Detail view
+
+Opened with `Enter` on a selected torrent. Four tabs (`Tab` or `1`-`4`):
+
+- **General** - hash, size, progress, speed, ratio, peers, speed limits,
+ folder, dates, any error message.
+- **Files** - every file in the torrent with size, progress, priority, and
+ wanted/skip status. `space` toggles wanted/skip, `+`/`-` changes priority
+ (low/normal/high) - changes are sent to the daemon immediately.
+- **Peers** - connected peers: address, client, progress, speeds, flags.
+- **Trackers** - tracker URLs per tier, latest announce result,
+ seeders/leechers.
+
+## Settings (`g`)
+
+Opens as its own full-screen view (same style as the help screen) - even if
+no daemon is connected, since otherwise it would be impossible to fix a
+wrong host/port. Contains:
+
+- **Connection** (Host, Port, Username, Password) - saved to `config.ini`
+ and applied immediately to the running session. Does *not* replace the
+ 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.
+
+`↑`/`↓` 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
+title, and `r` offers help fixing it - see
+[Daemon: setup & troubleshooting](Daemon-Setup-and-Troubleshooting.md).
+
+## Dialogs
+
+Confirmations (`[y]es`/`[n]o`), text prompts (e.g. when adding a torrent
+manually or editing a field), and message boxes are drawn as smaller,
+centered popup boxes in the same rounded style, and can stack (e.g. "also
+delete data on disk?" on top of "remove torrent?").
+
+## Add torrent (`a`)
+
+Asks first whether to browse for a local `.torrent` file or type a
+magnet link/URL/path by hand - magnet links and remote URLs obviously
+can't be browsed to. Choosing to browse opens a directory picker starting
+in `$HOME` (remembering the last folder you visited for next time),
+showing only subfolders and `.torrent` files to keep it uncluttered:
+
+| Key | Function |
+|---|---|
+| `↑`/`k`, `↓`/`j` | move selection |
+| `Enter` | open the selected folder, or select the highlighted `.torrent` file |
+| `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).
diff --git a/docs/Keybindings.md b/docs/Keybindings.md
new file mode 100644
index 0000000..287eaa2
--- /dev/null
+++ b/docs/Keybindings.md
@@ -0,0 +1,76 @@
+# Keybindings
+
+Full reference. See also the built-in help screen (`?` from the list view)
+and the footer at the bottom of every view, which shows a context-dependent
+subset.
+
+## List view - sidebar has focus
+
+| Key | Function |
+|---|---|
+| `↑`/`k`, `↓`/`j` | move the cursor between filter categories, applies immediately |
+| `Enter` / `→` / `Tab` | move focus to the list |
+| `g` | open settings |
+| `?` | help |
+| `q` | quit |
+
+## List view - list has focus
+
+| Key | Function |
+|---|---|
+| `Tab` / `←` | move focus to the sidebar |
+| `↑`/`k`, `↓`/`j` | move selection |
+| `PgUp`/`PgDn` | page up/down |
+| `Home`/`End` | jump to top/bottom of the list |
+| `Enter` | show details for the selected torrent |
+| `space` | select/deselect torrent (for batch actions on multiple at once) |
+| `s` | start |
+| `S` | stop |
+| `p` | toggle start/stop for the selected torrent |
+| `v` | verify (recheck) |
+| `d` / `Delete` | remove (asks whether to delete data on disk) |
+| `l` | set speed limit for selected/marked |
+| `a` | add torrent - choose to browse for a local `.torrent` file, or type a magnet link/URL/path |
+| `r` | force refresh |
+| `o` | next sort column |
+| `O` | reverse sort direction |
+| `/` | search by name |
+| `Esc` | clear search, otherwise deselect all selected |
+| `g` | open settings |
+| `?` | help |
+| `q` | quit |
+
+Batch actions (`s`/`S`/`p`/`v`/`d`/`l`) act on all `space`-selected
+torrents, or just the highlighted row if none are selected.
+
+## Detail view
+
+| Key | Function |
+|---|---|
+| `Tab` | next tab |
+| `Shift+Tab` | previous tab |
+| `1`-`4` | jump directly to a tab (General/Files/Peers/Trackers) |
+| `↑`/`k`, `↓`/`j` | move selection (Files/Peers/Trackers) |
+| `space` (Files) | toggle wanted/skip for the selected file |
+| `+` / `-` (Files) | raise/lower download priority for the selected file |
+| `Esc` / `q` / `Backspace` | back to the list |
+
+## Settings (opened with `g`)
+
+| Key | Function |
+|---|---|
+| `↑`/`k`, `↓`/`j` | navigate the fields |
+| `Enter` / `space` | change value (text field) or toggle on/off (boolean field) |
+| `r` | (only visible when not connected) try reconnecting / offer help - see [Daemon: setup & troubleshooting](Daemon-Setup-and-Troubleshooting.md) |
+| `Esc` / `q` | close |
+
+## Dialogs (confirmations, text prompts, messages)
+
+| Key | Function |
+|---|---|
+| `y` | yes (confirmations) |
+| `n` / `Esc` | no / cancel |
+| letters/digits | type text (in text prompts) |
+| `Backspace` | delete character |
+| `Enter` | confirm text |
+| any key | close message boxes |
diff --git a/src/config.c b/src/config.c
new file mode 100644
index 0000000..680e1cf
--- /dev/null
+++ b/src/config.c
@@ -0,0 +1,209 @@
+#include "config.h"
+
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <strings.h>
+#include <sys/stat.h>
+#include <unistd.h>
+
+static void set_defaults(Config *cfg)
+{
+ memset(cfg, 0, sizeof(*cfg));
+ snprintf(cfg->host, sizeof(cfg->host), "127.0.0.1");
+ cfg->port = 9091;
+ cfg->poll_interval_ms = 2000;
+ cfg->show_splash = 1;
+}
+
+static int resolve_path(char *out, size_t n)
+{
+ const char *xdg = getenv("XDG_CONFIG_HOME");
+ if (xdg && xdg[0]) {
+ snprintf(out, n, "%s/transtui/config.ini", xdg);
+ return 0;
+ }
+ const char *home = getenv("HOME");
+ if (!home || !home[0])
+ return -1;
+ snprintf(out, n, "%s/.config/transtui/config.ini", home);
+ return 0;
+}
+
+static void mkdir_parents(const char *path)
+{
+ char tmp[1024];
+ snprintf(tmp, sizeof(tmp), "%s", path);
+ for (char *p = tmp + 1; *p; p++) {
+ if (*p == '/') {
+ *p = '\0';
+ mkdir(tmp, 0755);
+ *p = '/';
+ }
+ }
+}
+
+static int write_default_file(const char *path)
+{
+ char dir[1024];
+ snprintf(dir, sizeof(dir), "%s", path);
+ char *slash = strrchr(dir, '/');
+ if (slash) {
+ *slash = '\0';
+ mkdir_parents(dir);
+ mkdir(dir, 0755);
+ }
+
+ FILE *f = fopen(path, "w");
+ if (!f)
+ return -1;
+ fprintf(f,
+ "# transtui config\n"
+ "host = 127.0.0.1\n"
+ "port = 9091\n"
+ "username =\n"
+ "password =\n"
+ "poll_interval_ms = 2000\n"
+ "show_splash = 1\n");
+ fclose(f);
+ return 0;
+}
+
+static char *trim(char *s)
+{
+ while (*s == ' ' || *s == '\t')
+ s++;
+ size_t n = strlen(s);
+ while (n > 0 && (s[n - 1] == ' ' || s[n - 1] == '\t' || s[n - 1] == '\r' || s[n - 1] == '\n'))
+ s[--n] = '\0';
+ return s;
+}
+
+static void parse_file(Config *cfg, FILE *f)
+{
+ char line[1024];
+ while (fgets(line, sizeof(line), f)) {
+ char *l = trim(line);
+ if (l[0] == '\0' || l[0] == '#' || l[0] == ';')
+ continue;
+ char *eq = strchr(l, '=');
+ if (!eq)
+ continue;
+ *eq = '\0';
+ char *key = trim(l);
+ char *val = trim(eq + 1);
+
+ if (strcmp(key, "host") == 0)
+ snprintf(cfg->host, sizeof(cfg->host), "%s", val);
+ else if (strcmp(key, "port") == 0)
+ cfg->port = atoi(val);
+ else if (strcmp(key, "username") == 0)
+ snprintf(cfg->username, sizeof(cfg->username), "%s", val);
+ else if (strcmp(key, "password") == 0)
+ snprintf(cfg->password, sizeof(cfg->password), "%s", val);
+ else if (strcmp(key, "poll_interval_ms") == 0)
+ cfg->poll_interval_ms = atoi(val);
+ else if (strcmp(key, "show_splash") == 0)
+ cfg->show_splash = (strcmp(val, "0") != 0 && strcasecmp(val, "false") != 0);
+ }
+}
+
+int config_load(Config *cfg, char *err, size_t errlen)
+{
+ set_defaults(cfg);
+
+ if (resolve_path(cfg->path, sizeof(cfg->path)) != 0) {
+ snprintf(err, errlen, "could not find HOME to resolve the config file");
+ return -1;
+ }
+
+ FILE *f = fopen(cfg->path, "r");
+ if (!f) {
+ if (write_default_file(cfg->path) != 0) {
+ snprintf(err, errlen, "could not create %s", cfg->path);
+ return -1;
+ }
+ return 0; /* defaults already set */
+ }
+
+ parse_file(cfg, f);
+ fclose(f);
+ if (cfg->port <= 0 || cfg->port > 65535)
+ cfg->port = 9091;
+ if (cfg->poll_interval_ms < 250)
+ cfg->poll_interval_ms = 250;
+ return 0;
+}
+
+int config_save(const Config *cfg, char *err, size_t errlen)
+{
+ if (!cfg->path[0]) {
+ snprintf(err, errlen, "no config path known");
+ return -1;
+ }
+ FILE *f = fopen(cfg->path, "w");
+ if (!f) {
+ snprintf(err, errlen, "could not write %s", cfg->path);
+ return -1;
+ }
+ fprintf(f,
+ "# transtui config\n"
+ "host = %s\n"
+ "port = %d\n"
+ "username = %s\n"
+ "password = %s\n"
+ "poll_interval_ms = %d\n"
+ "show_splash = %d\n",
+ cfg->host, cfg->port, cfg->username, cfg->password, cfg->poll_interval_ms,
+ cfg->show_splash);
+ fclose(f);
+ return 0;
+}
+
+static void usage(const char *prog)
+{
+ fprintf(stderr,
+ "Usage: %s [-H host] [-P port] [-u user] [-p pass] [-i ms]\n"
+ " %s --add <magnet|url|file> [-H host] [-P port] [-u user] [-p pass]\n"
+ " -H host Transmission daemon address (default 127.0.0.1)\n"
+ " -P port RPC port (default 9091)\n"
+ " -u user RPC username\n"
+ " -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"
+ " -h Show this help\n",
+ prog, prog);
+}
+
+void config_apply_args(Config *cfg, int argc, char **argv, char *add_source, size_t add_source_size)
+{
+ if (add_source_size)
+ add_source[0] = '\0';
+
+ for (int i = 1; i < argc; i++) {
+ const char *a = argv[i];
+ if ((strcmp(a, "-H") == 0 || strcmp(a, "--host") == 0) && i + 1 < argc) {
+ snprintf(cfg->host, sizeof(cfg->host), "%s", argv[++i]);
+ } else if ((strcmp(a, "-P") == 0 || strcmp(a, "--port") == 0) && i + 1 < argc) {
+ cfg->port = atoi(argv[++i]);
+ } else if ((strcmp(a, "-u") == 0 || strcmp(a, "--user") == 0) && i + 1 < argc) {
+ snprintf(cfg->username, sizeof(cfg->username), "%s", argv[++i]);
+ } else if ((strcmp(a, "-p") == 0 || strcmp(a, "--pass") == 0) && i + 1 < argc) {
+ snprintf(cfg->password, sizeof(cfg->password), "%s", argv[++i]);
+ } else if ((strcmp(a, "-i") == 0 || strcmp(a, "--interval") == 0) && i + 1 < argc) {
+ cfg->poll_interval_ms = atoi(argv[++i]);
+ } else if ((strcmp(a, "-A") == 0 || strcmp(a, "--add") == 0) && i + 1 < argc) {
+ snprintf(add_source, add_source_size, "%s", argv[++i]);
+ } else if (strcmp(a, "-h") == 0 || strcmp(a, "--help") == 0) {
+ usage(argv[0]);
+ exit(0);
+ } else {
+ fprintf(stderr, "Unknown argument: %s\n\n", a);
+ usage(argv[0]);
+ exit(1);
+ }
+ }
+}
diff --git a/src/config.h b/src/config.h
new file mode 100644
index 0000000..dd9b7f9
--- /dev/null
+++ b/src/config.h
@@ -0,0 +1,33 @@
+#ifndef TRANSTUI_CONFIG_H
+#define TRANSTUI_CONFIG_H
+
+#include <stddef.h>
+
+typedef struct {
+ char host[256];
+ int port;
+ char username[128];
+ char password[128];
+ int poll_interval_ms;
+ int show_splash;
+ char path[1024]; /* resolved config file path, for messages */
+} Config;
+
+/* Loads ~/.config/transtui/config.ini (or $XDG_CONFIG_HOME/transtui/config.ini).
+ * Creates the file with sane defaults if it doesn't exist yet.
+ * Returns 0 on success (cfg always ends up with usable defaults), -1 only if
+ * the config directory couldn't be created/read at all (err filled). */
+int config_load(Config *cfg, char *err, size_t errlen);
+
+/* Writes cfg back to cfg->path (as resolved by config_load). Used when the
+ * user edits daemon/download settings from the in-app settings screen. */
+int config_save(const Config *cfg, char *err, size_t errlen);
+
+/* Applies -H/-P/-u/-p/-i command-line overrides on top of an already-loaded
+ * config. Prints usage and exits on -h/--help or a bad argument. If -A/--add
+ * <source> is given, its value is copied into add_source (left empty
+ * otherwise) - the caller uses that to switch into non-interactive
+ * add-and-exit mode instead of launching the TUI. */
+void config_apply_args(Config *cfg, int argc, char **argv, char *add_source, size_t add_source_size);
+
+#endif
diff --git a/src/daemon.c b/src/daemon.c
new file mode 100644
index 0000000..6212ea3
--- /dev/null
+++ b/src/daemon.c
@@ -0,0 +1,170 @@
+#include "daemon.h"
+#include "../third_party/cJSON.h"
+#include "util.h"
+
+#include <errno.h>
+#include <fcntl.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <sys/wait.h>
+#include <unistd.h>
+
+int daemon_host_is_local(const char *host)
+{
+ return strcmp(host, "127.0.0.1") == 0 || strcmp(host, "localhost") == 0 ||
+ strcmp(host, "::1") == 0;
+}
+
+int daemon_start(char *err, size_t errlen)
+{
+ pid_t pid = fork();
+ if (pid < 0) {
+ snprintf(err, errlen, "fork failed: %s", strerror(errno));
+ return -1;
+ }
+
+ if (pid == 0) {
+ /* transmission-daemon daemonizes (double-forks) itself by default,
+ * so this child just execs it and lets it detach on its own. Give
+ * it a clean session and /dev/null fds so it doesn't inherit our
+ * controlling terminal or fight ncurses for it. */
+ setsid();
+ int devnull = open("/dev/null", O_RDWR);
+ if (devnull >= 0) {
+ dup2(devnull, STDIN_FILENO);
+ dup2(devnull, STDOUT_FILENO);
+ dup2(devnull, STDERR_FILENO);
+ if (devnull > STDERR_FILENO)
+ close(devnull);
+ }
+ execlp("transmission-daemon", "transmission-daemon", (char *)NULL);
+ _exit(127); /* only reached if execlp failed */
+ }
+
+ int status = 0;
+ if (waitpid(pid, &status, 0) < 0) {
+ snprintf(err, errlen, "waitpid failed: %s", strerror(errno));
+ return -1;
+ }
+ if (WIFEXITED(status) && WEXITSTATUS(status) == 127) {
+ if (access("/etc/debian_version", F_OK) == 0)
+ snprintf(err, errlen,
+ "transmission-daemon is not installed. Run: sudo apt install transmission-daemon");
+ else
+ snprintf(err, errlen, "transmission-daemon is not installed (install it via your package manager)");
+ return -1;
+ }
+ if (WIFEXITED(status) && WEXITSTATUS(status) != 0) {
+ snprintf(err, errlen, "transmission-daemon exited with code %d", WEXITSTATUS(status));
+ return -1;
+ }
+ if (WIFSIGNALED(status)) {
+ snprintf(err, errlen, "transmission-daemon was killed by signal %d", WTERMSIG(status));
+ return -1;
+ }
+ return 0;
+}
+
+/* Runs argv[0] with argv as its arguments (no shell involved - the caller's
+ * values, e.g. a user-chosen password, never pass through a shell so there's
+ * nothing to escape or inject). Inherits our stdin/stdout/stderr so sudo can
+ * prompt on the real terminal. */
+static int run_argv(char *const argv[], char *err, size_t errlen)
+{
+ pid_t pid = fork();
+ if (pid < 0) {
+ if (err)
+ snprintf(err, errlen, "fork failed: %s", strerror(errno));
+ return -1;
+ }
+ if (pid == 0) {
+ execvp(argv[0], argv);
+ _exit(127);
+ }
+ int status = 0;
+ if (waitpid(pid, &status, 0) < 0) {
+ if (err)
+ snprintf(err, errlen, "waitpid failed: %s", strerror(errno));
+ return -1;
+ }
+ if (!WIFEXITED(status) || WEXITSTATUS(status) != 0) {
+ if (err)
+ snprintf(err, errlen, "%s failed (cancelled at sudo?)", argv[0]);
+ return -1;
+ }
+ return 0;
+}
+
+#define TRANSMISSION_SETTINGS_PATH "/etc/transmission-daemon/settings.json"
+#define TRANSMISSION_SERVICE "transmission-daemon"
+
+int daemon_fix_auth(const char *username, const char *password, char *err, size_t errlen)
+{
+ size_t flen = 0;
+ unsigned char *data = read_file_all(TRANSMISSION_SETTINGS_PATH, &flen);
+ if (!data) {
+ snprintf(err, errlen, "cannot find %s - are you running Debian's transmission-daemon package?",
+ TRANSMISSION_SETTINGS_PATH);
+ return -1;
+ }
+ cJSON *root = cJSON_ParseWithLength((const char *)data, flen);
+ free(data);
+ if (!root) {
+ snprintf(err, errlen, "could not parse %s", TRANSMISSION_SETTINGS_PATH);
+ return -1;
+ }
+
+ if (password && password[0]) {
+ cJSON_ReplaceItemInObjectCaseSensitive(root, "rpc-username", cJSON_CreateString(username));
+ cJSON_ReplaceItemInObjectCaseSensitive(root, "rpc-password", cJSON_CreateString(password));
+ cJSON_ReplaceItemInObjectCaseSensitive(root, "rpc-authentication-required", cJSON_CreateBool(1));
+ } else {
+ cJSON_ReplaceItemInObjectCaseSensitive(root, "rpc-authentication-required", cJSON_CreateBool(0));
+ }
+
+ char *out = cJSON_Print(root);
+ cJSON_Delete(root);
+ if (!out) {
+ snprintf(err, errlen, "out of memory");
+ return -1;
+ }
+
+ char tmp_path[] = "/tmp/transtui-settings-XXXXXX";
+ int fd = mkstemp(tmp_path);
+ if (fd < 0) {
+ snprintf(err, errlen, "mkstemp failed: %s", strerror(errno));
+ cJSON_free(out);
+ return -1;
+ }
+ size_t outlen = strlen(out);
+ ssize_t wr = write(fd, out, outlen);
+ close(fd);
+ cJSON_free(out);
+ if (wr < 0 || (size_t)wr != outlen) {
+ snprintf(err, errlen, "could not write temp file");
+ unlink(tmp_path);
+ return -1;
+ }
+
+ char *stop_argv[] = {"sudo", "systemctl", "stop", TRANSMISSION_SERVICE, NULL};
+ char *cp_argv[] = {"sudo", "cp", tmp_path, TRANSMISSION_SETTINGS_PATH, NULL};
+ char *start_argv[] = {"sudo", "systemctl", "start", TRANSMISSION_SERVICE, NULL};
+
+ int rc = run_argv(stop_argv, err, errlen);
+ if (rc == 0)
+ rc = run_argv(cp_argv, err, errlen);
+
+ /* Always try to bring the service back up, even if the copy failed,
+ * so we don't leave it stopped - but don't let a start failure here
+ * clobber a more specific error from the steps above. */
+ char start_err[128];
+ int start_rc = run_argv(start_argv, start_err, sizeof(start_err));
+ if (rc == 0 && start_rc != 0) {
+ snprintf(err, errlen, "%s", start_err);
+ rc = -1;
+ }
+
+ unlink(tmp_path);
+ return rc;
+}
diff --git a/src/daemon.h b/src/daemon.h
new file mode 100644
index 0000000..f8c1460
--- /dev/null
+++ b/src/daemon.h
@@ -0,0 +1,31 @@
+#ifndef TRANSTUI_DAEMON_H
+#define TRANSTUI_DAEMON_H
+
+#include <stddef.h>
+
+/* True if host refers to this machine (127.0.0.1/localhost/::1) - the only
+ * case where offering to spawn transmission-daemon locally makes sense. */
+int daemon_host_is_local(const char *host);
+
+/* Launches `transmission-daemon` (must be on PATH) and lets it daemonize
+ * itself in the background as it normally does. Returns 0 if the command
+ * was found and its (short-lived, since it double-forks) launcher process
+ * exited cleanly, -1 on failure (err filled, e.g. "not found on PATH"). */
+int daemon_start(char *err, size_t errlen);
+
+/* Rewrites the system transmission-daemon's RPC credentials (Debian's
+ * packaged /etc/transmission-daemon/settings.json) and restarts the service
+ * via `sudo systemctl` - for when the daemon is already running but RPC
+ * auth is enabled with a password nobody knows (the Debian package
+ * generates a random one that's never shown in plaintext).
+ *
+ * If password is NULL or empty, disables RPC auth entirely instead of
+ * setting a username/password. sudo will prompt on the controlling
+ * terminal - the caller is responsible for leaving curses mode first
+ * (def_prog_mode()+endwin(), then reset_prog_mode() after).
+ *
+ * Returns 0 on success, -1 on failure (err filled - e.g. sudo was denied,
+ * 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);
+
+#endif
diff --git a/src/http.c b/src/http.c
new file mode 100644
index 0000000..c208c62
--- /dev/null
+++ b/src/http.c
@@ -0,0 +1,302 @@
+#include "http.h"
+#include "net.h"
+#include "util.h"
+
+#include <ctype.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <strings.h>
+#include <unistd.h>
+
+#define INITIAL_BUF 8192
+
+typedef struct {
+ char *data;
+ size_t len;
+ size_t cap;
+} Buf;
+
+static int buf_ensure(Buf *b, size_t extra)
+{
+ if (b->len + extra + 1 <= b->cap)
+ return 0;
+ size_t ncap = b->cap ? b->cap * 2 : INITIAL_BUF;
+ while (ncap < b->len + extra + 1)
+ ncap *= 2;
+ char *nd = realloc(b->data, ncap);
+ if (!nd)
+ return -1;
+ b->data = nd;
+ b->cap = ncap;
+ return 0;
+}
+
+static int buf_append(Buf *b, const char *s, size_t n)
+{
+ if (buf_ensure(b, n) != 0)
+ return -1;
+ memcpy(b->data + b->len, s, n);
+ b->len += n;
+ b->data[b->len] = '\0';
+ return 0;
+}
+
+/* Appends a NUL-terminated C string, sizing itself via strlen() so callers
+ * never have to hand-count literal lengths (a previous version did, and got
+ * it wrong, silently splicing NUL bytes into outgoing HTTP headers). */
+static int buf_append_str(Buf *b, const char *s)
+{
+ return buf_append(b, s, strlen(s));
+}
+
+/* Case-insensitive substring search (portable stand-in for strcasestr). */
+static const char *ci_find(const char *hay, size_t haylen, const char *needle)
+{
+ size_t nlen = strlen(needle);
+ if (nlen == 0 || nlen > haylen)
+ return NULL;
+ for (size_t i = 0; i + nlen <= haylen; i++) {
+ size_t j = 0;
+ for (; j < nlen; j++) {
+ if (tolower((unsigned char)hay[i + j]) != tolower((unsigned char)needle[j]))
+ break;
+ }
+ if (j == nlen)
+ return hay + i;
+ }
+ return NULL;
+}
+
+/* block is the raw response from the status line through the blank line
+ * that terminates the headers (block_len does NOT need to include the body). */
+static void extract_header(const char *block, size_t block_len, const char *name,
+ char *out, size_t outsize)
+{
+ out[0] = '\0';
+ char pat[64];
+ snprintf(pat, sizeof(pat), "\r\n%s:", name);
+ const char *p = ci_find(block, block_len, pat);
+ if (!p)
+ return;
+
+ const char *v = p + 2 + strlen(name) + 1; /* skip \r\n NAME: */
+ const char *block_end = block + block_len;
+ while (v < block_end && (*v == ' ' || *v == '\t'))
+ v++;
+ const char *end = ci_find(v, (size_t)(block_end - v), "\r\n");
+ if (!end)
+ end = block_end;
+ size_t vlen = (size_t)(end - v);
+ if (vlen >= outsize)
+ vlen = outsize - 1;
+ memcpy(out, v, vlen);
+ out[vlen] = '\0';
+}
+
+static long extract_content_length(const char *headers, size_t headers_len)
+{
+ char v[32];
+ extract_header(headers, headers_len, "Content-Length", v, sizeof(v));
+ if (v[0] == '\0')
+ return -1;
+ return atol(v);
+}
+
+static int is_chunked(const char *headers, size_t headers_len)
+{
+ char v[64];
+ extract_header(headers, headers_len, "Transfer-Encoding", v, sizeof(v));
+ return strcasecmp(v, "chunked") == 0;
+}
+
+/* Decodes an HTTP chunked body in-place; returns decoded length, or -1 on malformed input. */
+static long decode_chunked(const char *in, size_t inlen, char **out)
+{
+ Buf b = {0};
+ size_t pos = 0;
+ while (pos < inlen) {
+ const char *line_end = NULL;
+ for (size_t i = pos; i + 1 < inlen; i++) {
+ if (in[i] == '\r' && in[i + 1] == '\n') {
+ line_end = in + i;
+ break;
+ }
+ }
+ if (!line_end)
+ break;
+ long chunk_len = strtol(in + pos, NULL, 16);
+ pos = (size_t)(line_end - in) + 2;
+ if (chunk_len <= 0)
+ break;
+ if (pos + (size_t)chunk_len > inlen)
+ break;
+ if (buf_append(&b, in + pos, (size_t)chunk_len) != 0) {
+ free(b.data);
+ return -1;
+ }
+ pos += (size_t)chunk_len;
+ if (pos + 2 <= inlen && in[pos] == '\r' && in[pos + 1] == '\n')
+ pos += 2;
+ }
+ *out = b.data;
+ return (long)b.len;
+}
+
+int http_post_json(const char *host, int port, const char *path,
+ const char *user, const char *pass,
+ const char *session_id_in,
+ const char *body, size_t body_len,
+ HttpResponse *resp, char *err, size_t errlen)
+{
+ memset(resp, 0, sizeof(*resp));
+
+ Buf req = {0};
+ char line[1024];
+
+ int n = snprintf(line, sizeof(line), "POST %s HTTP/1.1\r\n", path);
+ buf_append(&req, line, (size_t)n);
+
+ n = snprintf(line, sizeof(line), "Host: %s:%d\r\n", host, port);
+ buf_append(&req, line, (size_t)n);
+
+ buf_append_str(&req, "User-Agent: transtui/1.0\r\n");
+ buf_append_str(&req, "Content-Type: application/json\r\n");
+ buf_append_str(&req, "Accept: application/json\r\n");
+ buf_append_str(&req, "Connection: close\r\n");
+
+ n = snprintf(line, sizeof(line), "Content-Length: %zu\r\n", body_len);
+ buf_append(&req, line, (size_t)n);
+
+ if (session_id_in && session_id_in[0]) {
+ n = snprintf(line, sizeof(line), "X-Transmission-Session-Id: %s\r\n", session_id_in);
+ buf_append(&req, line, (size_t)n);
+ }
+
+ if (user && user[0]) {
+ char cred[256];
+ n = snprintf(cred, sizeof(cred), "%s:%s", user, pass ? pass : "");
+ char b64[400];
+ long enc = base64_encode((unsigned char *)cred, (size_t)n, b64, sizeof(b64));
+ if (enc > 0) {
+ n = snprintf(line, sizeof(line), "Authorization: Basic %s\r\n", b64);
+ buf_append(&req, line, (size_t)n);
+ }
+ }
+
+ buf_append_str(&req, "\r\n");
+ if (body_len)
+ buf_append(&req, body, body_len);
+
+ int fd = net_connect(host, port, err, errlen);
+ if (fd < 0) {
+ free(req.data);
+ return -1;
+ }
+
+ size_t sent = 0;
+ while (sent < req.len) {
+ ssize_t w = write(fd, req.data + sent, req.len - sent);
+ if (w <= 0) {
+ snprintf(err, errlen, "failed to send HTTP request");
+ free(req.data);
+ close(fd);
+ return -1;
+ }
+ sent += (size_t)w;
+ }
+ free(req.data);
+
+ Buf resb = {0};
+ char chunk[4096];
+ long content_length = -1;
+ int chunked = 0;
+ size_t header_end = 0;
+ int have_headers = 0;
+
+ for (;;) {
+ ssize_t r = read(fd, chunk, sizeof(chunk));
+ if (r < 0) {
+ snprintf(err, errlen, "error reading response");
+ free(resb.data);
+ close(fd);
+ return -1;
+ }
+ if (r == 0)
+ break; /* server closed connection */
+ if (buf_append(&resb, chunk, (size_t)r) != 0) {
+ snprintf(err, errlen, "out of memory");
+ free(resb.data);
+ close(fd);
+ return -1;
+ }
+
+ if (!have_headers) {
+ char *sep = memmem(resb.data, resb.len, "\r\n\r\n", 4);
+ if (sep) {
+ have_headers = 1;
+ header_end = (size_t)(sep - resb.data) + 4;
+ content_length = extract_content_length(resb.data, header_end);
+ chunked = is_chunked(resb.data, header_end);
+ }
+ }
+ if (have_headers && !chunked && content_length >= 0) {
+ if (resb.len - header_end >= (size_t)content_length)
+ break;
+ }
+ }
+ close(fd);
+
+ if (!have_headers) {
+ snprintf(err, errlen, "incomplete HTTP response");
+ free(resb.data);
+ return -1;
+ }
+
+ /* Parse status line: "HTTP/1.1 200 OK" */
+ int status = 0;
+ sscanf(resb.data, "HTTP/%*d.%*d %d", &status);
+ resp->status = status;
+
+ extract_header(resb.data, header_end, "X-Transmission-Session-Id", resp->session_id, sizeof(resp->session_id));
+
+ const char *body_start = resb.data + header_end;
+ size_t avail = resb.len - header_end;
+
+ if (chunked) {
+ char *decoded = NULL;
+ long dlen = decode_chunked(body_start, avail, &decoded);
+ if (dlen < 0) {
+ snprintf(err, errlen, "could not decode chunked response");
+ free(resb.data);
+ return -1;
+ }
+ resp->body = decoded;
+ resp->body_len = (size_t)dlen;
+ free(resb.data);
+ } else {
+ size_t blen = avail;
+ if (content_length >= 0 && (size_t)content_length < blen)
+ blen = (size_t)content_length;
+ char *b = malloc(blen + 1);
+ if (!b) {
+ snprintf(err, errlen, "out of memory");
+ free(resb.data);
+ return -1;
+ }
+ memcpy(b, body_start, blen);
+ b[blen] = '\0';
+ resp->body = b;
+ resp->body_len = blen;
+ free(resb.data);
+ }
+
+ return 0;
+}
+
+void http_response_free(HttpResponse *resp)
+{
+ free(resp->body);
+ resp->body = NULL;
+ resp->body_len = 0;
+}
diff --git a/src/http.h b/src/http.h
new file mode 100644
index 0000000..41d97b3
--- /dev/null
+++ b/src/http.h
@@ -0,0 +1,25 @@
+#ifndef TRANSTUI_HTTP_H
+#define TRANSTUI_HTTP_H
+
+#include <stddef.h>
+
+typedef struct {
+ int status;
+ char *body;
+ size_t body_len;
+ char session_id[128]; /* set if response carried X-Transmission-Session-Id */
+} HttpResponse;
+
+/* Issues a single HTTP/1.1 POST request with a JSON body and reads the full
+ * response. resp is always initialized on return; check resp->status.
+ * Returns 0 if the request/response was exchanged (regardless of HTTP status),
+ * -1 on a network-level failure (err filled with a message). */
+int http_post_json(const char *host, int port, const char *path,
+ const char *user, const char *pass,
+ const char *session_id_in,
+ const char *body, size_t body_len,
+ HttpResponse *resp, char *err, size_t errlen);
+
+void http_response_free(HttpResponse *resp);
+
+#endif
diff --git a/src/main.c b/src/main.c
new file mode 100644
index 0000000..b60a3cd
--- /dev/null
+++ b/src/main.c
@@ -0,0 +1,69 @@
+#include <ctype.h>
+#include <stdio.h>
+#include <stdlib.h>
+
+#include "config.h"
+#include "rpc.h"
+#include "torrent.h"
+#include "util.h"
+#include "ui/ui.h"
+
+/* Browsers/file managers hand "Open with" targets a file:// URI (percent-
+ * encoded) rather than a plain path. Strip and decode that so torrent_add()
+ * gets a real filesystem path; magnet:/http(s):// sources pass through
+ * untouched since their own percent-encoding must stay intact. */
+static void resolve_add_source(const char *in, char *out, size_t outsize)
+{
+ if (!str_starts_with(in, "file://")) {
+ snprintf(out, outsize, "%s", in);
+ return;
+ }
+ const char *p = in + 7;
+ size_t o = 0;
+ for (size_t i = 0; p[i] && o + 1 < outsize; i++) {
+ if (p[i] == '%' && isxdigit((unsigned char)p[i + 1]) && isxdigit((unsigned char)p[i + 2])) {
+ char hex[3] = {p[i + 1], p[i + 2], '\0'};
+ out[o++] = (char)strtol(hex, NULL, 16);
+ i += 2;
+ } else {
+ out[o++] = p[i];
+ }
+ }
+ out[o] = '\0';
+}
+
+static int run_add_and_exit(RpcClient *rpc, const char *add_source)
+{
+ 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;
+}
+
+int main(int argc, char **argv)
+{
+ Config cfg;
+ char err[256];
+ if (config_load(&cfg, err, sizeof(err)) != 0) {
+ fprintf(stderr, "transtui: %s\n", err);
+ return 1;
+ }
+
+ char add_source[1024];
+ config_apply_args(&cfg, argc, argv, add_source, sizeof(add_source));
+
+ RpcClient rpc;
+ rpc_init(&rpc, cfg.host, cfg.port, cfg.username[0] ? cfg.username : NULL,
+ cfg.password[0] ? cfg.password : NULL);
+
+ if (add_source[0])
+ return run_add_and_exit(&rpc, add_source);
+
+ return ui_run(&rpc, &cfg);
+}
diff --git a/src/net.c b/src/net.c
new file mode 100644
index 0000000..20703cf
--- /dev/null
+++ b/src/net.c
@@ -0,0 +1,56 @@
+#include "net.h"
+
+#include <errno.h>
+#include <netdb.h>
+#include <stdio.h>
+#include <string.h>
+#include <sys/socket.h>
+#include <sys/time.h>
+#include <unistd.h>
+
+#define NET_TIMEOUT_SEC 10
+
+int net_connect(const char *host, int port, char *err, size_t errlen)
+{
+ char portstr[16];
+ snprintf(portstr, sizeof(portstr), "%d", port);
+
+ struct addrinfo hints;
+ memset(&hints, 0, sizeof(hints));
+ hints.ai_family = AF_UNSPEC;
+ hints.ai_socktype = SOCK_STREAM;
+
+ struct addrinfo *res = NULL;
+ int rc = getaddrinfo(host, portstr, &hints, &res);
+ if (rc != 0) {
+ snprintf(err, errlen, "cannot resolve %s: %s", host, gai_strerror(rc));
+ return -1;
+ }
+
+ int fd = -1;
+ for (struct addrinfo *ai = res; ai != NULL; ai = ai->ai_next) {
+ fd = socket(ai->ai_family, ai->ai_socktype, ai->ai_protocol);
+ if (fd < 0)
+ continue;
+
+ struct timeval tv;
+ tv.tv_sec = NET_TIMEOUT_SEC;
+ tv.tv_usec = 0;
+ setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv));
+ setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof(tv));
+
+ if (connect(fd, ai->ai_addr, ai->ai_addrlen) == 0) {
+ break;
+ }
+ close(fd);
+ fd = -1;
+ }
+ freeaddrinfo(res);
+
+ if (fd < 0) {
+ snprintf(err, errlen, "cannot connect to %s:%d: %s", host, port, strerror(errno));
+ return -1;
+ }
+
+ return fd;
+}
diff --git a/src/net.h b/src/net.h
new file mode 100644
index 0000000..0fafe3b
--- /dev/null
+++ b/src/net.h
@@ -0,0 +1,10 @@
+#ifndef TRANSTUI_NET_H
+#define TRANSTUI_NET_H
+
+#include <stddef.h>
+
+/* Opens a TCP connection to host:port with a connect+IO timeout.
+ * Returns a connected socket fd, or -1 on failure (err filled with a message). */
+int net_connect(const char *host, int port, char *err, size_t errlen);
+
+#endif
diff --git a/src/rpc.c b/src/rpc.c
new file mode 100644
index 0000000..1c12c52
--- /dev/null
+++ b/src/rpc.c
@@ -0,0 +1,91 @@
+#include "rpc.h"
+#include "http.h"
+
+#include <stdio.h>
+#include <string.h>
+
+void rpc_init(RpcClient *c, const char *host, int port, const char *user, const char *pass)
+{
+ memset(c, 0, sizeof(*c));
+ snprintf(c->host, sizeof(c->host), "%s", host);
+ c->port = port;
+ if (user)
+ snprintf(c->user, sizeof(c->user), "%s", user);
+ if (pass)
+ snprintf(c->pass, sizeof(c->pass), "%s", pass);
+}
+
+int rpc_call(RpcClient *c, const char *method, cJSON *arguments,
+ cJSON **out_args, char *err, size_t errlen)
+{
+ if (out_args)
+ *out_args = NULL;
+
+ cJSON *root = cJSON_CreateObject();
+ cJSON_AddStringToObject(root, "method", method);
+ if (arguments)
+ cJSON_AddItemToObject(root, "arguments", arguments);
+
+ char *body = cJSON_PrintUnformatted(root);
+ size_t body_len = strlen(body);
+
+ c->last_http_status = 0;
+ int rc = -1;
+ for (int attempt = 0; attempt < 2; attempt++) {
+ HttpResponse resp;
+ int hrc = http_post_json(c->host, c->port, "/transmission/rpc",
+ c->user[0] ? c->user : NULL, c->pass,
+ c->session_id[0] ? c->session_id : NULL,
+ body, body_len, &resp, err, errlen);
+ if (hrc != 0) {
+ rc = -1;
+ break;
+ }
+ c->last_http_status = resp.status;
+
+ if (resp.session_id[0])
+ snprintf(c->session_id, sizeof(c->session_id), "%s", resp.session_id);
+
+ if (resp.status == 409 && attempt == 0) {
+ /* CSRF handshake: retry once now that we have a session id. */
+ http_response_free(&resp);
+ continue;
+ }
+
+ if (resp.status != 200) {
+ snprintf(err, errlen, "HTTP error %d from server", resp.status);
+ http_response_free(&resp);
+ rc = -1;
+ break;
+ }
+
+ cJSON *respjson = cJSON_ParseWithLength(resp.body, resp.body_len);
+ http_response_free(&resp);
+ if (!respjson) {
+ snprintf(err, errlen, "could not parse JSON response");
+ rc = -1;
+ break;
+ }
+
+ cJSON *result = cJSON_GetObjectItemCaseSensitive(respjson, "result");
+ if (!cJSON_IsString(result) || strcmp(result->valuestring, "success") != 0) {
+ snprintf(err, errlen, "RPC error: %s",
+ cJSON_IsString(result) ? result->valuestring : "unknown error");
+ cJSON_Delete(respjson);
+ rc = -1;
+ break;
+ }
+
+ if (out_args) {
+ cJSON *args = cJSON_DetachItemFromObjectCaseSensitive(respjson, "arguments");
+ *out_args = args;
+ }
+ cJSON_Delete(respjson);
+ rc = 0;
+ break;
+ }
+
+ cJSON_free(body);
+ cJSON_Delete(root);
+ return rc;
+}
diff --git a/src/rpc.h b/src/rpc.h
new file mode 100644
index 0000000..a709bdb
--- /dev/null
+++ b/src/rpc.h
@@ -0,0 +1,32 @@
+#ifndef TRANSTUI_RPC_H
+#define TRANSTUI_RPC_H
+
+#include <stddef.h>
+#include "../third_party/cJSON.h"
+
+typedef struct RpcClient {
+ char host[256];
+ int port;
+ char user[128];
+ char pass[128];
+ char session_id[128];
+ int last_http_status; /* 0 if the last call never got an HTTP response at all */
+} RpcClient;
+
+void rpc_init(RpcClient *c, const char *host, int port, const char *user, const char *pass);
+
+/* Performs a single Transmission JSON-RPC call, transparently handling the
+ * X-Transmission-Session-Id CSRF handshake (one retry on HTTP 409).
+ *
+ * `arguments` ownership is transferred to this call (it will be freed
+ * internally) - pass NULL if the method takes no arguments.
+ *
+ * On success returns 0 and, if out_args is non-NULL, sets *out_args to the
+ * "arguments" object of the response (caller must cJSON_Delete it), or NULL
+ * if the response had none.
+ *
+ * On failure returns -1 and fills err with a human-readable message. */
+int rpc_call(RpcClient *c, const char *method, cJSON *arguments,
+ cJSON **out_args, char *err, size_t errlen);
+
+#endif
diff --git a/src/torrent.c b/src/torrent.c
new file mode 100644
index 0000000..1fcd0dc
--- /dev/null
+++ b/src/torrent.c
@@ -0,0 +1,348 @@
+#include "torrent.h"
+#include "util.h"
+
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+
+/* ---- small cJSON accessor helpers ---------------------------------- */
+
+static int64_t get_i64(const cJSON *obj, const char *key, int64_t def)
+{
+ const cJSON *v = cJSON_GetObjectItemCaseSensitive(obj, key);
+ if (!v || !cJSON_IsNumber(v))
+ return def;
+ return (int64_t)v->valuedouble;
+}
+
+static int get_int(const cJSON *obj, const char *key, int def)
+{
+ return (int)get_i64(obj, key, def);
+}
+
+static double get_dbl(const cJSON *obj, const char *key, double def)
+{
+ const cJSON *v = cJSON_GetObjectItemCaseSensitive(obj, key);
+ if (!v || !cJSON_IsNumber(v))
+ return def;
+ return v->valuedouble;
+}
+
+static void get_str(const cJSON *obj, const char *key, char *buf, size_t n)
+{
+ const cJSON *v = cJSON_GetObjectItemCaseSensitive(obj, key);
+ if (v && cJSON_IsString(v))
+ snprintf(buf, n, "%s", v->valuestring);
+ else
+ buf[0] = '\0';
+}
+
+static cJSON *field_array(const char **names, size_t n)
+{
+ cJSON *arr = cJSON_CreateArray();
+ for (size_t i = 0; i < n; i++)
+ cJSON_AddItemToArray(arr, cJSON_CreateString(names[i]));
+ return arr;
+}
+
+static cJSON *id_array(const int *ids, size_t n)
+{
+ cJSON *arr = cJSON_CreateArray();
+ for (size_t i = 0; i < n; i++)
+ cJSON_AddItemToArray(arr, cJSON_CreateNumber(ids[i]));
+ return arr;
+}
+
+static void parse_summary(const cJSON *t, Torrent *out)
+{
+ memset(out, 0, sizeof(*out));
+ out->id = get_int(t, "id", -1);
+ get_str(t, "hashString", out->hash, sizeof(out->hash));
+ get_str(t, "name", out->name, sizeof(out->name));
+ out->total_size = get_i64(t, "totalSize", 0);
+ out->left_until_done = get_i64(t, "leftUntilDone", 0);
+ out->percent_done = get_dbl(t, "percentDone", 0.0);
+ out->status = get_int(t, "status", TR_STATUS_STOPPED);
+ out->rate_download = get_i64(t, "rateDownload", 0);
+ out->rate_upload = get_i64(t, "rateUpload", 0);
+ out->eta = get_int(t, "eta", -1);
+ out->upload_ratio = get_dbl(t, "uploadRatio", 0.0);
+ out->error = get_int(t, "error", 0);
+ get_str(t, "errorString", out->error_string, sizeof(out->error_string));
+ get_str(t, "downloadDir", out->download_dir, sizeof(out->download_dir));
+ out->peers_connected = get_int(t, "peersConnected", 0);
+ out->peers_sending_to_us = get_int(t, "peersSendingToUs", 0);
+ out->peers_getting_from_us = get_int(t, "peersGettingFromUs", 0);
+}
+
+static const char *SUMMARY_FIELDS[] = {
+ "id", "name", "hashString", "totalSize", "leftUntilDone", "percentDone",
+ "status", "rateDownload", "rateUpload", "eta", "uploadRatio", "error",
+ "errorString", "downloadDir", "peersConnected", "peersSendingToUs",
+ "peersGettingFromUs",
+};
+
+void torrent_list_init(TorrentList *list)
+{
+ memset(list, 0, sizeof(*list));
+}
+
+void torrent_list_free(TorrentList *list)
+{
+ free(list->items);
+ memset(list, 0, sizeof(*list));
+}
+
+int torrent_list_refresh(RpcClient *rpc, TorrentList *list, char *err, size_t errlen)
+{
+ cJSON *args = cJSON_CreateObject();
+ cJSON_AddItemToObject(args, "fields",
+ field_array(SUMMARY_FIELDS, sizeof(SUMMARY_FIELDS) / sizeof(SUMMARY_FIELDS[0])));
+
+ cJSON *out = NULL;
+ if (rpc_call(rpc, "torrent-get", args, &out, err, errlen) != 0)
+ return -1;
+
+ cJSON *torrents = out ? cJSON_GetObjectItemCaseSensitive(out, "torrents") : NULL;
+ if (!torrents || !cJSON_IsArray(torrents)) {
+ snprintf(err, errlen, "unexpected response from torrent-get");
+ cJSON_Delete(out);
+ return -1;
+ }
+
+ /* Remember which torrent ids were selected so we can restore the flag
+ * after repopulating the array (list order can shift between polls). */
+ size_t old_selected_count = 0;
+ for (size_t i = 0; i < list->count; i++)
+ if (list->items[i].selected)
+ old_selected_count++;
+ int *old_selected_ids = NULL;
+ if (old_selected_count) {
+ old_selected_ids = malloc(old_selected_count * sizeof(int));
+ size_t j = 0;
+ for (size_t i = 0; i < list->count; i++)
+ if (list->items[i].selected)
+ old_selected_ids[j++] = list->items[i].id;
+ }
+
+ int count = cJSON_GetArraySize(torrents);
+ if ((size_t)count > list->capacity) {
+ size_t ncap = (size_t)count;
+ Torrent *ni = realloc(list->items, ncap * sizeof(Torrent));
+ if (!ni) {
+ snprintf(err, errlen, "out of memory");
+ free(old_selected_ids);
+ cJSON_Delete(out);
+ return -1;
+ }
+ list->items = ni;
+ list->capacity = ncap;
+ }
+
+ for (int i = 0; i < count; i++)
+ parse_summary(cJSON_GetArrayItem(torrents, i), &list->items[i]);
+ list->count = (size_t)count;
+
+ for (size_t s = 0; s < old_selected_count; s++) {
+ for (size_t i = 0; i < list->count; i++) {
+ if (list->items[i].id == old_selected_ids[s]) {
+ list->items[i].selected = 1;
+ break;
+ }
+ }
+ }
+ free(old_selected_ids);
+
+ cJSON_Delete(out);
+ return 0;
+}
+
+void torrent_detail_free(TorrentDetail *d)
+{
+ free(d->files);
+ free(d->peers);
+ free(d->trackers);
+ memset(d, 0, sizeof(*d));
+}
+
+int torrent_get_detail(RpcClient *rpc, int id, TorrentDetail *out, char *err, size_t errlen)
+{
+ static const char *fields[] = {
+ "id", "name", "hashString", "totalSize", "leftUntilDone", "percentDone",
+ "status", "rateDownload", "rateUpload", "eta", "uploadRatio", "error",
+ "errorString", "downloadDir", "peersConnected", "peersSendingToUs",
+ "peersGettingFromUs", "dateAdded", "dateCreated", "doneDate",
+ "downloadLimit", "downloadLimited", "uploadLimit", "uploadLimited",
+ "files", "fileStats", "peers", "trackerStats",
+ };
+
+ cJSON *args = cJSON_CreateObject();
+ int idarr[1] = {id};
+ cJSON_AddItemToObject(args, "ids", id_array(idarr, 1));
+ cJSON_AddItemToObject(args, "fields", field_array(fields, sizeof(fields) / sizeof(fields[0])));
+
+ cJSON *resp = NULL;
+ if (rpc_call(rpc, "torrent-get", args, &resp, err, errlen) != 0)
+ return -1;
+
+ cJSON *torrents = resp ? cJSON_GetObjectItemCaseSensitive(resp, "torrents") : NULL;
+ if (!torrents || !cJSON_IsArray(torrents) || cJSON_GetArraySize(torrents) < 1) {
+ snprintf(err, errlen, "torrent not found");
+ cJSON_Delete(resp);
+ return -1;
+ }
+ cJSON *t = cJSON_GetArrayItem(torrents, 0);
+
+ memset(out, 0, sizeof(*out));
+ parse_summary(t, &out->info);
+ out->date_added = (long)get_i64(t, "dateAdded", 0);
+ out->date_created = (long)get_i64(t, "dateCreated", 0);
+ out->done_date = (long)get_i64(t, "doneDate", 0);
+ out->download_limit = get_i64(t, "downloadLimit", 0);
+ out->download_limited = get_int(t, "downloadLimited", 0);
+ out->upload_limit = get_i64(t, "uploadLimit", 0);
+ out->upload_limited = get_int(t, "uploadLimited", 0);
+
+ cJSON *files = cJSON_GetObjectItemCaseSensitive(t, "files");
+ cJSON *filestats = cJSON_GetObjectItemCaseSensitive(t, "fileStats");
+ if (files && cJSON_IsArray(files)) {
+ int n = cJSON_GetArraySize(files);
+ out->files = calloc((size_t)n, sizeof(TorrentFile));
+ out->file_count = (size_t)n;
+ for (int i = 0; i < n; i++) {
+ cJSON *f = cJSON_GetArrayItem(files, i);
+ TorrentFile *tf = &out->files[i];
+ get_str(f, "name", tf->name, sizeof(tf->name));
+ tf->length = get_i64(f, "length", 0);
+ tf->bytes_completed = get_i64(f, "bytesCompleted", 0);
+ tf->wanted = 1;
+ tf->priority = 0;
+ if (filestats && cJSON_IsArray(filestats) && i < cJSON_GetArraySize(filestats)) {
+ cJSON *fs = cJSON_GetArrayItem(filestats, i);
+ tf->wanted = get_int(fs, "wanted", 1);
+ tf->priority = get_int(fs, "priority", 0);
+ }
+ }
+ }
+
+ cJSON *peers = cJSON_GetObjectItemCaseSensitive(t, "peers");
+ if (peers && cJSON_IsArray(peers)) {
+ int n = cJSON_GetArraySize(peers);
+ out->peers = calloc((size_t)n, sizeof(TorrentPeer));
+ out->peer_count = (size_t)n;
+ for (int i = 0; i < n; i++) {
+ cJSON *p = cJSON_GetArrayItem(peers, i);
+ TorrentPeer *tp = &out->peers[i];
+ get_str(p, "address", tp->address, sizeof(tp->address));
+ get_str(p, "clientName", tp->client_name, sizeof(tp->client_name));
+ get_str(p, "flagStr", tp->flags, sizeof(tp->flags));
+ tp->progress = get_dbl(p, "progress", 0.0);
+ tp->rate_to_client = get_i64(p, "rateToClient", 0);
+ tp->rate_to_peer = get_i64(p, "rateToPeer", 0);
+ }
+ }
+
+ cJSON *trackers = cJSON_GetObjectItemCaseSensitive(t, "trackerStats");
+ if (trackers && cJSON_IsArray(trackers)) {
+ int n = cJSON_GetArraySize(trackers);
+ out->trackers = calloc((size_t)n, sizeof(TorrentTracker));
+ out->tracker_count = (size_t)n;
+ for (int i = 0; i < n; i++) {
+ cJSON *tr = cJSON_GetArrayItem(trackers, i);
+ TorrentTracker *tt = &out->trackers[i];
+ get_str(tr, "announce", tt->announce, sizeof(tt->announce));
+ tt->tier = get_int(tr, "tier", 0);
+ get_str(tr, "lastAnnounceResult", tt->last_announce_result, sizeof(tt->last_announce_result));
+ tt->seeder_count = get_int(tr, "seederCount", -1);
+ tt->leecher_count = get_int(tr, "leecherCount", -1);
+ }
+ }
+
+ cJSON_Delete(resp);
+ return 0;
+}
+
+int torrent_action(RpcClient *rpc, const char *method, const int *ids, size_t n, char *err, size_t errlen)
+{
+ cJSON *args = cJSON_CreateObject();
+ cJSON_AddItemToObject(args, "ids", id_array(ids, n));
+ return rpc_call(rpc, method, args, NULL, err, errlen);
+}
+
+int torrent_remove(RpcClient *rpc, const int *ids, size_t n, int delete_data, char *err, size_t errlen)
+{
+ cJSON *args = cJSON_CreateObject();
+ cJSON_AddItemToObject(args, "ids", id_array(ids, n));
+ cJSON_AddBoolToObject(args, "delete-local-data", delete_data);
+ return rpc_call(rpc, "torrent-remove", args, NULL, err, errlen);
+}
+
+int torrent_add(RpcClient *rpc, const char *source, const char *download_dir, char *err, size_t errlen)
+{
+ cJSON *args = cJSON_CreateObject();
+
+ if (str_starts_with(source, "magnet:") || str_starts_with(source, "http://") ||
+ str_starts_with(source, "https://")) {
+ cJSON_AddStringToObject(args, "filename", source);
+ } else {
+ size_t flen = 0;
+ unsigned char *data = read_file_all(source, &flen);
+ if (!data) {
+ snprintf(err, errlen, "could not read file: %s", source);
+ cJSON_Delete(args);
+ return -1;
+ }
+ size_t b64size = base64_encoded_size(flen) + 1;
+ char *b64 = malloc(b64size);
+ if (!b64 || base64_encode(data, flen, b64, b64size) < 0) {
+ snprintf(err, errlen, "could not base64-encode the file");
+ free(data);
+ free(b64);
+ cJSON_Delete(args);
+ return -1;
+ }
+ free(data);
+ cJSON_AddStringToObject(args, "metainfo", b64);
+ free(b64);
+ }
+
+ if (download_dir && download_dir[0])
+ cJSON_AddStringToObject(args, "download-dir", download_dir);
+
+ return rpc_call(rpc, "torrent-add", args, NULL, err, errlen);
+}
+
+int torrent_set_speed_limit(RpcClient *rpc, const int *ids, size_t n,
+ int64_t down_kbps, int down_enabled,
+ int64_t up_kbps, int up_enabled,
+ char *err, size_t errlen)
+{
+ cJSON *args = cJSON_CreateObject();
+ cJSON_AddItemToObject(args, "ids", id_array(ids, n));
+ cJSON_AddNumberToObject(args, "downloadLimit", (double)down_kbps);
+ cJSON_AddBoolToObject(args, "downloadLimited", down_enabled);
+ cJSON_AddNumberToObject(args, "uploadLimit", (double)up_kbps);
+ cJSON_AddBoolToObject(args, "uploadLimited", up_enabled);
+ return rpc_call(rpc, "torrent-set", args, NULL, err, errlen);
+}
+
+int torrent_set_file_wanted(RpcClient *rpc, int id, const int *file_indices, size_t n, int wanted,
+ char *err, size_t errlen)
+{
+ cJSON *args = cJSON_CreateObject();
+ int idarr[1] = {id};
+ cJSON_AddItemToObject(args, "ids", id_array(idarr, 1));
+ cJSON_AddItemToObject(args, wanted ? "files-wanted" : "files-unwanted", id_array(file_indices, n));
+ return rpc_call(rpc, "torrent-set", args, NULL, err, errlen);
+}
+
+int torrent_set_file_priority(RpcClient *rpc, int id, const int *file_indices, size_t n, int priority,
+ char *err, size_t errlen)
+{
+ const char *key = priority > 0 ? "priority-high" : (priority < 0 ? "priority-low" : "priority-normal");
+ cJSON *args = cJSON_CreateObject();
+ int idarr[1] = {id};
+ cJSON_AddItemToObject(args, "ids", id_array(idarr, 1));
+ cJSON_AddItemToObject(args, key, id_array(file_indices, n));
+ return rpc_call(rpc, "torrent-set", args, NULL, err, errlen);
+}
diff --git a/src/torrent.h b/src/torrent.h
new file mode 100644
index 0000000..361fc4d
--- /dev/null
+++ b/src/torrent.h
@@ -0,0 +1,117 @@
+#ifndef TRANSTUI_TORRENT_H
+#define TRANSTUI_TORRENT_H
+
+#include <stddef.h>
+#include <stdint.h>
+#include "rpc.h"
+
+enum {
+ TR_STATUS_STOPPED = 0,
+ TR_STATUS_CHECK_WAIT = 1,
+ TR_STATUS_CHECK = 2,
+ TR_STATUS_DOWNLOAD_WAIT = 3,
+ TR_STATUS_DOWNLOAD = 4,
+ TR_STATUS_SEED_WAIT = 5,
+ TR_STATUS_SEED = 6,
+};
+
+typedef struct {
+ int id;
+ char hash[41];
+ char name[256];
+ int64_t total_size;
+ int64_t left_until_done;
+ double percent_done;
+ int status;
+ int64_t rate_download;
+ int64_t rate_upload;
+ int eta;
+ double upload_ratio;
+ int error;
+ char error_string[256];
+ char download_dir[512];
+ int peers_connected;
+ int peers_sending_to_us;
+ int peers_getting_from_us;
+ int selected; /* multi-select flag, UI-owned */
+} Torrent;
+
+typedef struct {
+ Torrent *items;
+ size_t count;
+ size_t capacity;
+} TorrentList;
+
+typedef struct {
+ char name[512];
+ int64_t length;
+ int64_t bytes_completed;
+ int wanted;
+ int priority; /* -1 low, 0 normal, 1 high */
+} TorrentFile;
+
+typedef struct {
+ char address[64];
+ char client_name[128];
+ double progress;
+ int64_t rate_to_client;
+ int64_t rate_to_peer;
+ char flags[16];
+} TorrentPeer;
+
+typedef struct {
+ char announce[256];
+ int tier;
+ char last_announce_result[256];
+ int seeder_count;
+ int leecher_count;
+} TorrentTracker;
+
+typedef struct {
+ Torrent info;
+ long date_added;
+ long date_created;
+ long done_date;
+ int64_t download_limit;
+ int download_limited;
+ int64_t upload_limit;
+ int upload_limited;
+
+ TorrentFile *files;
+ size_t file_count;
+ TorrentPeer *peers;
+ size_t peer_count;
+ TorrentTracker *trackers;
+ size_t tracker_count;
+} TorrentDetail;
+
+void torrent_list_init(TorrentList *list);
+void torrent_list_free(TorrentList *list);
+/* Fetches the summary field set for all torrents and refreshes `list` in
+ * place, reusing the existing allocation where possible. */
+int torrent_list_refresh(RpcClient *rpc, TorrentList *list, char *err, size_t errlen);
+
+void torrent_detail_free(TorrentDetail *d);
+int torrent_get_detail(RpcClient *rpc, int id, TorrentDetail *out, char *err, size_t errlen);
+
+/* method is one of: torrent-start, torrent-start-now, torrent-stop,
+ * torrent-verify, torrent-reannounce */
+int torrent_action(RpcClient *rpc, const char *method, const int *ids, size_t n, char *err, size_t errlen);
+int torrent_remove(RpcClient *rpc, const int *ids, size_t n, int delete_data, char *err, size_t errlen);
+
+/* source: a magnet: URI, an http(s):// URL to a .torrent, or a local file
+ * path (read and sent as base64 metainfo, so it works against remote daemons
+ * too). download_dir may be NULL to use the daemon's default. */
+int torrent_add(RpcClient *rpc, const char *source, const char *download_dir, char *err, size_t errlen);
+
+int torrent_set_speed_limit(RpcClient *rpc, const int *ids, size_t n,
+ int64_t down_kbps, int down_enabled,
+ int64_t up_kbps, int up_enabled,
+ char *err, size_t errlen);
+
+int torrent_set_file_wanted(RpcClient *rpc, int id, const int *file_indices, size_t n, int wanted,
+ char *err, size_t errlen);
+int torrent_set_file_priority(RpcClient *rpc, int id, const int *file_indices, size_t n, int priority,
+ char *err, size_t errlen);
+
+#endif
diff --git a/src/ui/ui.c b/src/ui/ui.c
new file mode 100644
index 0000000..fd2bb98
--- /dev/null
+++ b/src/ui/ui.c
@@ -0,0 +1,497 @@
+#include "ui.h"
+#include "../daemon.h"
+#include "../util.h"
+
+#include <curses.h>
+#include <locale.h>
+#include <stdarg.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <strings.h>
+#include <sys/ioctl.h>
+#include <time.h>
+#include <unistd.h>
+#include <wchar.h>
+
+void ui_init_colors(void)
+{
+ if (!has_colors())
+ return;
+ start_color();
+ use_default_colors();
+ init_pair(CP_DEFAULT, -1, -1);
+ init_pair(CP_HEADER, COLOR_BLACK, COLOR_CYAN);
+ init_pair(CP_SELROW, COLOR_BLACK, COLOR_WHITE);
+ init_pair(CP_SEEDING, COLOR_GREEN, -1);
+ init_pair(CP_DOWNLOADING, COLOR_CYAN, -1);
+ init_pair(CP_PAUSED, COLOR_YELLOW, -1);
+ init_pair(CP_ERROR, COLOR_RED, -1);
+ init_pair(CP_CHECK, COLOR_MAGENTA, -1);
+ init_pair(CP_BORDER, COLOR_CYAN, -1);
+ init_pair(CP_ACCENT, COLOR_CYAN, -1);
+ init_pair(CP_SPLASH_WHITE, COLOR_WHITE, -1);
+}
+
+/* ---- shared panel/box/footer drawing ---------------------------------- */
+
+void ui_box(WINDOW *win, int h, int w)
+{
+ if (h < 2 || w < 2)
+ return;
+ wattron(win, COLOR_PAIR(CP_BORDER));
+ mvwaddwstr(win, 0, 0, L"╭");
+ for (int x = 1; x < w - 1; x++)
+ mvwaddwstr(win, 0, x, L"─");
+ mvwaddwstr(win, 0, w - 1, L"╮");
+ for (int y = 1; y < h - 1; y++) {
+ mvwaddwstr(win, y, 0, L"│");
+ mvwaddwstr(win, y, w - 1, L"│");
+ }
+ mvwaddwstr(win, h - 1, 0, L"╰");
+ for (int x = 1; x < w - 1; x++)
+ mvwaddwstr(win, h - 1, x, L"─");
+ mvwaddwstr(win, h - 1, w - 1, L"╯");
+ wattroff(win, COLOR_PAIR(CP_BORDER));
+}
+
+void ui_box_title(WINDOW *win, int w, const char *title)
+{
+ int len = (int)strlen(title);
+ int x = 2;
+ if (x + len + 2 > w)
+ len = w - x - 2;
+ if (len <= 0)
+ return;
+ wattron(win, COLOR_PAIR(CP_ACCENT) | A_BOLD);
+ mvwprintw(win, 0, x, " %.*s ", len, title);
+ wattroff(win, COLOR_PAIR(CP_ACCENT) | A_BOLD);
+}
+
+void ui_draw_footer(const KeyHint *hints, size_t n, int nrows)
+{
+ if (nrows > 2)
+ nrows = 2;
+ int rows, cols;
+ getmaxyx(stdscr, rows, cols);
+
+ for (int r = 0; r < nrows; r++) {
+ move(rows - nrows + r, 0);
+ clrtoeol();
+ }
+
+ int row = 0, x = 1;
+ for (size_t i = 0; i < n && row < nrows; i++) {
+ int chip_w = (int)(strlen(hints[i].key) + strlen(hints[i].desc)) + 3;
+ if (x + chip_w >= cols) {
+ row++;
+ x = 1;
+ if (row >= nrows)
+ break;
+ }
+ attron(COLOR_PAIR(CP_HEADER) | A_BOLD);
+ mvprintw(rows - nrows + row, x, " %s ", hints[i].key);
+ attroff(COLOR_PAIR(CP_HEADER) | A_BOLD);
+ x += (int)strlen(hints[i].key) + 2;
+ mvprintw(rows - nrows + row, x, " %s ", hints[i].desc);
+ x += (int)strlen(hints[i].desc) + 2;
+ }
+}
+
+int ui_color_for_status(int status)
+{
+ switch (status) {
+ case TR_STATUS_SEED:
+ case TR_STATUS_SEED_WAIT:
+ return CP_SEEDING;
+ case TR_STATUS_DOWNLOAD:
+ case TR_STATUS_DOWNLOAD_WAIT:
+ return CP_DOWNLOADING;
+ case TR_STATUS_CHECK:
+ case TR_STATUS_CHECK_WAIT:
+ return CP_CHECK;
+ case TR_STATUS_STOPPED:
+ default:
+ return CP_PAUSED;
+ }
+}
+
+void ui_set_status(AppState *st, const char *fmt, ...)
+{
+ va_list ap;
+ va_start(ap, fmt);
+ vsnprintf(st->status_msg, sizeof(st->status_msg), fmt, ap);
+ va_end(ap);
+ st->status_msg_until = time(NULL) + 5;
+}
+
+int ui_refresh_list(AppState *st)
+{
+ char err[256];
+ /* Always stamp last_poll, even on failure - otherwise a disconnected
+ * daemon leaves it at its zero-initialized value forever, and the main
+ * loop's "elapsed >= poll_interval_ms" check is true on every ~200ms
+ * tick, hammering connect() in a tight retry loop instead of backing
+ * off to the configured poll interval. */
+ clock_gettime(CLOCK_MONOTONIC, &st->last_poll);
+ if (torrent_list_refresh(st->rpc, &st->list, err, sizeof(err)) != 0) {
+ ui_set_status(st, "Connection error: %s", err);
+ return -1;
+ }
+ return 0;
+}
+
+/* --- sorting/filtering ------------------------------------------------ */
+
+static AppState *g_sort_ctx;
+
+static int passes_filter(const Torrent *t, FilterMode f)
+{
+ switch (f) {
+ case FILTER_DOWNLOADING:
+ return t->status == TR_STATUS_DOWNLOAD || t->status == TR_STATUS_DOWNLOAD_WAIT;
+ case FILTER_UPLOADING:
+ return t->status == TR_STATUS_SEED || t->status == TR_STATUS_SEED_WAIT;
+ case FILTER_PAUSED:
+ return t->status == TR_STATUS_STOPPED;
+ case FILTER_COMPLETED:
+ return t->percent_done >= 1.0;
+ case FILTER_ALL:
+ default:
+ return 1;
+ }
+}
+
+static int cmp_order(const void *pa, const void *pb)
+{
+ const AppState *st = g_sort_ctx;
+ const Torrent *a = &st->list.items[*(const int *)pa];
+ const Torrent *b = &st->list.items[*(const int *)pb];
+ int r = 0;
+ switch (st->sort_col) {
+ case SORT_NAME:
+ r = strcasecmp(a->name, b->name);
+ break;
+ case SORT_SIZE:
+ r = (a->total_size > b->total_size) - (a->total_size < b->total_size);
+ break;
+ case SORT_PROGRESS:
+ r = (a->percent_done > b->percent_done) - (a->percent_done < b->percent_done);
+ break;
+ case SORT_STATUS:
+ r = a->status - b->status;
+ break;
+ case SORT_DOWN:
+ r = (a->rate_download > b->rate_download) - (a->rate_download < b->rate_download);
+ break;
+ case SORT_UP:
+ r = (a->rate_upload > b->rate_upload) - (a->rate_upload < b->rate_upload);
+ break;
+ case SORT_RATIO:
+ r = (a->upload_ratio > b->upload_ratio) - (a->upload_ratio < b->upload_ratio);
+ break;
+ case SORT_ETA:
+ r = a->eta - b->eta;
+ break;
+ default:
+ break;
+ }
+ if (r == 0)
+ r = strcasecmp(a->name, b->name);
+ return st->sort_desc ? -r : r;
+}
+
+void ui_rebuild_order(AppState *st)
+{
+ if (st->order_capacity < st->list.count) {
+ size_t ncap = st->list.count;
+ int *no = realloc(st->order, ncap * sizeof(int));
+ if (!no)
+ return;
+ st->order = no;
+ st->order_capacity = ncap;
+ }
+
+ size_t n = 0;
+ for (size_t i = 0; i < st->list.count; i++) {
+ const Torrent *t = &st->list.items[i];
+ if (!passes_filter(t, st->filter))
+ continue;
+ if (st->search[0] && !str_ci_contains(t->name, st->search))
+ continue;
+ st->order[n++] = (int)i;
+ }
+ st->order_count = n;
+
+ g_sort_ctx = st;
+ if (n > 1)
+ qsort(st->order, n, sizeof(int), cmp_order);
+
+ if (st->cursor >= (int)st->order_count)
+ st->cursor = st->order_count ? (int)st->order_count - 1 : 0;
+ if (st->cursor < 0)
+ st->cursor = 0;
+}
+
+void ui_open_detail(AppState *st, int torrent_id)
+{
+ st->detail_id = torrent_id;
+ st->detail_loaded = 0;
+ st->detail_tab = DETAIL_GENERAL;
+ st->detail_cursor = 0;
+ st->view = VIEW_DETAIL;
+}
+
+void ui_close_detail(AppState *st)
+{
+ if (st->detail_loaded)
+ torrent_detail_free(&st->detail);
+ st->detail_loaded = 0;
+ st->view = VIEW_LIST;
+}
+
+/* --- main loop ---------------------------------------------------------- */
+
+static long elapsed_ms(const struct timespec *since)
+{
+ struct timespec now;
+ clock_gettime(CLOCK_MONOTONIC, &now);
+ return (now.tv_sec - since->tv_sec) * 1000 + (now.tv_nsec - since->tv_nsec) / 1000000;
+}
+
+/* Called once at startup if the initial connection failed. If the daemon is
+ * supposed to be on this machine, offer to launch it and give it a few
+ * seconds to come up before continuing into the normal UI either way. */
+static void offer_daemon_start(AppState *st)
+{
+ if (!daemon_host_is_local(st->cfg->host))
+ return;
+
+ char msg[512];
+ snprintf(msg, sizeof(msg), "No daemon is responding at %s:%d. Start transmission-daemon?",
+ st->cfg->host, st->cfg->port);
+ if (!ui_confirm("No daemon found", msg))
+ return;
+
+ char err[256];
+ if (daemon_start(err, sizeof(err)) != 0) {
+ ui_message("Could not start daemon", err);
+ return;
+ }
+
+ ui_set_status(st, "Started transmission-daemon, connecting...");
+ ui_list_render(st);
+
+ for (int attempt = 0; attempt < 10; attempt++) {
+ usleep(400000);
+ if (ui_refresh_list(st) == 0)
+ return;
+ }
+ ui_set_status(st, "Started the daemon but could not connect yet - try again with 'r'");
+}
+
+/* Called once at startup if the daemon answered but rejected us with 401 -
+ * it's up and reachable, we just don't know its RPC password (Debian's
+ * package generates a random one that's never shown in plaintext). Offers
+ * to either disable RPC auth or set a known password, via `sudo`. */
+static void offer_fix_auth(AppState *st)
+{
+ if (!daemon_host_is_local(st->cfg->host))
+ return;
+
+ char msg[512];
+ snprintf(msg, sizeof(msg),
+ "The daemon at %s:%d is responding but requires a password we don't know. "
+ "Fix it now (requires sudo)?",
+ st->cfg->host, st->cfg->port);
+ if (!ui_confirm("Login required", msg))
+ return;
+
+ int disable = !ui_confirm("Authentication", "Set a custom password? (No = disable password protection entirely)");
+
+ char pass[128] = "";
+ if (!disable) {
+ if (!ui_prompt("New RPC password (username will be 'transmission')", "", pass, sizeof(pass)) ||
+ !pass[0])
+ return;
+ }
+
+ ui_set_status(st, "Configuring transmission-daemon - check the terminal for a sudo prompt...");
+ ui_list_render(st);
+
+ /* sudo needs to prompt on the real terminal, which curses currently has
+ * in raw/alternate-screen mode - step out of its way and back in. */
+ def_prog_mode();
+ endwin();
+ char err[256];
+ int rc = daemon_fix_auth("transmission", disable ? NULL : pass, err, sizeof(err));
+ reset_prog_mode();
+ refresh();
+
+ if (rc != 0) {
+ ui_message("Could not configure the daemon", err);
+ return;
+ }
+
+ if (!disable) {
+ snprintf(st->cfg->username, sizeof(st->cfg->username), "transmission");
+ snprintf(st->cfg->password, sizeof(st->cfg->password), "%s", pass);
+ } else {
+ st->cfg->username[0] = '\0';
+ st->cfg->password[0] = '\0';
+ }
+ snprintf(st->rpc->user, sizeof(st->rpc->user), "%s", st->cfg->username);
+ snprintf(st->rpc->pass, sizeof(st->rpc->pass), "%s", st->cfg->password);
+ st->rpc->session_id[0] = '\0';
+ config_save(st->cfg, err, sizeof(err)); /* best effort */
+
+ ui_set_status(st, "Done, connecting...");
+ ui_list_render(st);
+
+ for (int attempt = 0; attempt < 10; attempt++) {
+ usleep(400000);
+ if (ui_refresh_list(st) == 0)
+ return;
+ }
+ ui_set_status(st, "Could not connect yet - try again with 'r'");
+}
+
+void ui_offer_reconnect_help(AppState *st)
+{
+ if (st->rpc->last_http_status == 401)
+ offer_fix_auth(st);
+ else
+ offer_daemon_start(st);
+}
+
+int ui_run(RpcClient *rpc, Config *cfg)
+{
+ AppState st;
+ memset(&st, 0, sizeof(st));
+ st.rpc = rpc;
+ st.cfg = cfg;
+ st.running = 1;
+ st.sort_col = SORT_NAME;
+ st.focus = FOCUS_LIST;
+ torrent_list_init(&st.list);
+
+ setlocale(LC_ALL, "");
+ /* JSON numbers are always '.'-decimal regardless of locale (RFC 8259);
+ * cJSON's number parser uses strtod() under the active LC_NUMERIC, so a
+ * locale with a comma decimal point (e.g. sv_SE) silently truncates
+ * every "1.0"-style value and desyncs the parser. Keep LC_NUMERIC at
+ * "C" while leaving the rest of the locale (LC_CTYPE etc.) localized
+ * for correct UTF-8 rendering. */
+ setlocale(LC_NUMERIC, "C");
+#ifdef NCURSES_VERSION
+ /* ncurses defaults to a 1s ESCDELAY to disambiguate a lone Esc from the
+ * start of a function-key sequence, which makes Esc feel unresponsive. */
+ set_escdelay(25);
+#endif
+ initscr();
+ /* ncurses can size stdscr from stale $COLUMNS/$LINES env vars instead
+ * of the terminal's actual dimensions (common after a resize the shell
+ * never got to re-export, or in some multiplexer/SSH setups) - centered
+ * popups then get positioned for a screen that doesn't match reality
+ * and end up partly or fully off-screen. Force a re-sync from the
+ * kernel's own idea of the window size right after initscr(). */
+ {
+ struct winsize ws;
+ if (ioctl(STDOUT_FILENO, TIOCGWINSZ, &ws) == 0 && ws.ws_row > 0 && ws.ws_col > 0)
+ resizeterm(ws.ws_row, ws.ws_col);
+ }
+ cbreak();
+ noecho();
+ keypad(stdscr, TRUE);
+ curs_set(0);
+ ui_init_colors();
+
+ int pending_key = ERR;
+ if (cfg->show_splash)
+ pending_key = ui_splash_show();
+
+ 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)
+ ui_offer_reconnect_help(&st);
+ else
+ ui_set_status(&st, "Connected to %s:%d", cfg->host, cfg->port);
+
+ /* Only redraw when something could actually have changed (a key was
+ * handled, or a periodic RPC refresh landed) - not unconditionally on
+ * every ~200ms poll tick. Redrawing (and re-sending a full screen's
+ * worth of escape sequences) when nothing changed is wasted work, and
+ * on slower links/terminals the constant churn can show up as visible
+ * flicker on modal popups the user is just sitting and looking at. */
+ int need_render = 1;
+ while (st.running) {
+ if (need_render) {
+ ui_rebuild_order(&st);
+ switch (st.view) {
+ case VIEW_LIST:
+ ui_list_render(&st);
+ break;
+ case VIEW_DETAIL:
+ ui_detail_render(&st);
+ break;
+ case VIEW_SETTINGS:
+ ui_settings_render(&st);
+ break;
+ case VIEW_HELP:
+ ui_help_render(&st);
+ break;
+ }
+ need_render = 0;
+ }
+
+ /* Whatever key dismissed the splash is a real command the user
+ * meant for the app (e.g. 'g' for settings) - act on it instead of
+ * silently swallowing it, before falling back to reading new input. */
+ int ch;
+ if (pending_key != ERR) {
+ ch = pending_key;
+ pending_key = ERR;
+ } else {
+ ch = getch();
+ }
+ if (ch == ERR) {
+ if (elapsed_ms(&st.last_poll) >= cfg->poll_interval_ms || st.need_poll_now) {
+ st.need_poll_now = 0;
+ ui_refresh_list(&st);
+ if (st.view == VIEW_DETAIL && st.detail_loaded) {
+ char err[256];
+ torrent_detail_free(&st.detail);
+ st.detail_loaded =
+ torrent_get_detail(st.rpc, st.detail_id, &st.detail, err, sizeof(err)) == 0;
+ }
+ need_render = 1;
+ }
+ continue;
+ }
+
+ need_render = 1;
+ switch (st.view) {
+ case VIEW_LIST:
+ ui_list_handle_key(&st, ch);
+ break;
+ case VIEW_DETAIL:
+ ui_detail_handle_key(&st, ch);
+ break;
+ case VIEW_SETTINGS:
+ ui_settings_handle_key(&st, ch);
+ break;
+ case VIEW_HELP:
+ ui_help_handle_key(&st, ch);
+ break;
+ }
+ }
+
+ if (st.detail_loaded)
+ torrent_detail_free(&st.detail);
+ free(st.order);
+ torrent_list_free(&st.list);
+
+ endwin();
+ return 0;
+}
diff --git a/src/ui/ui.h b/src/ui/ui.h
new file mode 100644
index 0000000..129c348
--- /dev/null
+++ b/src/ui/ui.h
@@ -0,0 +1,168 @@
+#ifndef TRANSTUI_UI_H
+#define TRANSTUI_UI_H
+
+#include <curses.h>
+#include <time.h>
+#include "../config.h"
+#include "../rpc.h"
+#include "../torrent.h"
+
+typedef enum {
+ VIEW_LIST,
+ VIEW_DETAIL,
+ VIEW_SETTINGS,
+ VIEW_HELP,
+} ViewMode;
+
+/* Sidebar categories. Order here is the order shown in the left menu. */
+typedef enum {
+ FILTER_ALL,
+ FILTER_DOWNLOADING,
+ FILTER_UPLOADING,
+ FILTER_PAUSED,
+ FILTER_COMPLETED,
+ FILTER_COUNT,
+} FilterMode;
+
+typedef enum {
+ FOCUS_SIDEBAR,
+ FOCUS_LIST,
+} FocusZone;
+
+typedef enum {
+ SORT_NAME,
+ SORT_SIZE,
+ SORT_PROGRESS,
+ SORT_STATUS,
+ SORT_DOWN,
+ SORT_UP,
+ SORT_RATIO,
+ SORT_ETA,
+ SORT_COUNT,
+} SortColumn;
+
+typedef enum {
+ DETAIL_GENERAL,
+ DETAIL_FILES,
+ DETAIL_PEERS,
+ DETAIL_TRACKERS,
+ DETAIL_TAB_COUNT,
+} DetailTab;
+
+/* Color pairs, initialized once in ui_init_colors(). */
+enum {
+ CP_DEFAULT = 1,
+ CP_HEADER,
+ CP_SELROW,
+ CP_SEEDING,
+ CP_DOWNLOADING,
+ CP_PAUSED,
+ CP_ERROR,
+ CP_CHECK,
+ CP_BORDER,
+ CP_ACCENT,
+ CP_SPLASH_WHITE,
+};
+
+/* A single "key -> what it does" entry for the footer hint bar. */
+typedef struct {
+ const char *key;
+ const char *desc;
+} KeyHint;
+
+typedef struct {
+ RpcClient *rpc;
+ Config *cfg;
+ int running;
+
+ TorrentList list;
+ /* indices into list.items after filtering+sorting, rebuilt each frame */
+ int *order;
+ size_t order_count;
+ size_t order_capacity;
+
+ int cursor; /* index into order[] */
+ int top; /* first visible row, index into order[] */
+
+ SortColumn sort_col;
+ int sort_desc;
+ FilterMode filter;
+ char search[128];
+
+ FocusZone focus;
+ int sidebar_cursor;
+
+ ViewMode view;
+
+ int detail_id;
+ TorrentDetail detail;
+ int detail_loaded;
+ DetailTab detail_tab;
+ int detail_cursor;
+
+ char status_msg[256];
+ time_t status_msg_until;
+
+ struct timespec last_poll;
+ int need_poll_now;
+} AppState;
+
+int ui_run(RpcClient *rpc, Config *cfg);
+
+/* 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
+ * that key (so the caller can act on it immediately instead of discarding
+ * it), or ERR if the splash simply timed out. */
+int ui_splash_show(void);
+
+/* Offers to fix a broken *local* connection: "no daemon answering" (offer to
+ * spawn transmission-daemon) or "daemon answered with 401" (offer to fix its
+ * RPC auth via sudo), based on rpc->last_http_status. No-op for non-local
+ * hosts (nothing we can sudo into on someone else's machine). Used both at
+ * startup and from the settings popup's 'r' retry key. */
+void ui_offer_reconnect_help(AppState *st);
+
+/* shared helpers, used across ui_*.c */
+void ui_set_status(AppState *st, const char *fmt, ...);
+void ui_init_colors(void);
+int ui_color_for_status(int status);
+void ui_rebuild_order(AppState *st);
+int ui_refresh_list(AppState *st);
+void ui_open_detail(AppState *st, int torrent_id);
+void ui_close_detail(AppState *st);
+
+/* Draws a rounded ╭─╮ box border into win (h x w, starting at 0,0 in win's
+ * own coordinates). Shared look for panels and popups. */
+void ui_box(WINDOW *win, int h, int w);
+/* Draws a title left-aligned into a box's top border, e.g. ui_box_title(win, w, "Filter"). */
+void ui_box_title(WINDOW *win, int w, const char *title);
+/* Renders a wrapping row of "key description" chips at the bottom `nrows`
+ * lines of the screen (nrows capped to 2). Hints that don't fit are dropped. */
+void ui_draw_footer(const KeyHint *hints, size_t n, int nrows);
+
+/* modal dialogs (ui_dialogs.c) - block until answered, then redraw caller's view */
+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);
+
+void ui_dialog_add_torrent(AppState *st);
+void ui_dialog_remove(AppState *st, const int *ids, size_t n);
+void ui_dialog_speed_limit(AppState *st, const int *ids, size_t n);
+
+/* per-view render+input handlers */
+void ui_list_render(AppState *st);
+/* Same as ui_list_render() but skips doupdate(), for use as a backdrop
+ * under a popup that wants a single atomic flush (see ui_settings.c). */
+void ui_list_render_content(AppState *st);
+void ui_list_handle_key(AppState *st, int ch);
+
+void ui_detail_render(AppState *st);
+void ui_detail_handle_key(AppState *st, int ch);
+
+void ui_settings_render(AppState *st);
+void ui_settings_handle_key(AppState *st, int ch);
+
+void ui_help_render(AppState *st);
+void ui_help_handle_key(AppState *st, int ch);
+
+#endif
diff --git a/src/ui/ui_details.c b/src/ui/ui_details.c
new file mode 100644
index 0000000..fb69642
--- /dev/null
+++ b/src/ui/ui_details.c
@@ -0,0 +1,290 @@
+#include "ui.h"
+#include "../util.h"
+
+#include <curses.h>
+#include <string.h>
+
+#define FOOTER_H 2
+
+static const char *TAB_LABEL[DETAIL_TAB_COUNT] = {"General", "Files", "Peers", "Trackers"};
+
+static void ensure_loaded(AppState *st)
+{
+ if (st->detail_loaded)
+ return;
+ char err[256];
+ if (torrent_get_detail(st->rpc, st->detail_id, &st->detail, err, sizeof(err)) == 0) {
+ st->detail_loaded = 1;
+ } else {
+ ui_set_status(st, "Error: %s", err);
+ st->view = VIEW_LIST;
+ }
+}
+
+static void draw_title(AppState *st, int cols)
+{
+ attron(COLOR_PAIR(CP_HEADER) | A_BOLD);
+ mvhline(0, 0, ' ', cols);
+ mvprintw(0, 0, " %-*.*s", cols - 1, cols - 1,
+ st->detail_loaded ? st->detail.info.name : "");
+ attroff(COLOR_PAIR(CP_HEADER) | A_BOLD);
+}
+
+static void draw_general(WINDOW *win, AppState *st, int top)
+{
+ TorrentDetail *d = &st->detail;
+ char sizebuf[24], donebuf[24], downbuf[24], upbuf[24], addedbuf[32], createdbuf[32], donedatebuf[32];
+ fmt_size(d->info.total_size, sizebuf, sizeof(sizebuf));
+ fmt_size(d->info.total_size - d->info.left_until_done, donebuf, sizeof(donebuf));
+ fmt_speed(d->info.rate_download, downbuf, sizeof(downbuf));
+ fmt_speed(d->info.rate_upload, upbuf, sizeof(upbuf));
+ fmt_time(d->date_added, addedbuf, sizeof(addedbuf));
+ fmt_time(d->date_created, createdbuf, sizeof(createdbuf));
+ fmt_time(d->done_date, donedatebuf, sizeof(donedatebuf));
+
+ int y = top;
+ mvwprintw(win, y++, 2, "Hash: %s", d->info.hash);
+ mvwprintw(win, y++, 2, "Size: %s (%s done)", sizebuf, donebuf);
+ mvwprintw(win, y++, 2, "Progress: %.1f%%", d->info.percent_done * 100.0);
+ mvwprintw(win, y++, 2, "Speed: \xe2\x86\x93 %s \xe2\x86\x91 %s", downbuf, upbuf);
+ mvwprintw(win, y++, 2, "Ratio: %.2f", d->info.upload_ratio);
+ mvwprintw(win, y++, 2, "Peers: %d connected (%d sending, %d receiving)",
+ d->info.peers_connected, d->info.peers_sending_to_us, d->info.peers_getting_from_us);
+ mvwprintw(win, y++, 2, "Down limit: %s", d->download_limited ? "active" : "unlimited");
+ mvwprintw(win, y++, 2, "Up limit: %s", d->upload_limited ? "active" : "unlimited");
+ mvwprintw(win, y++, 2, "Folder: %s", d->info.download_dir);
+ mvwprintw(win, y++, 2, "Added: %s", addedbuf);
+ mvwprintw(win, y++, 2, "Created: %s", createdbuf);
+ mvwprintw(win, y++, 2, "Completed: %s", donedatebuf);
+ if (d->info.error)
+ mvwprintw(win, y++, 2, "Error: %s", d->info.error_string);
+}
+
+static void draw_files(WINDOW *win, AppState *st, int top, int rows, int cols)
+{
+ TorrentDetail *d = &st->detail;
+ if (d->file_count == 0) {
+ mvwprintw(win, top, 2, "(no file information)");
+ return;
+ }
+ if (st->detail_cursor >= (int)d->file_count)
+ st->detail_cursor = (int)d->file_count - 1;
+ if (st->detail_cursor < 0)
+ st->detail_cursor = 0;
+
+ int vis = rows;
+ int first = 0;
+ if (st->detail_cursor >= vis)
+ first = st->detail_cursor - vis + 1;
+
+ int name_w = cols - 36 > 10 ? cols - 36 : 10;
+ for (int row = 0; row < vis; row++) {
+ int i = first + row;
+ if (i >= (int)d->file_count)
+ break;
+ TorrentFile *f = &d->files[i];
+ char sizebuf[24];
+ fmt_size(f->length, sizebuf, sizeof(sizebuf));
+ double pct = f->length > 0 ? (100.0 * (double)f->bytes_completed / (double)f->length) : 100.0;
+ const char *prio = f->priority > 0 ? "High" : (f->priority < 0 ? "Low" : "Normal");
+
+ int attr = (i == st->detail_cursor) ? (COLOR_PAIR(CP_SELROW) | A_BOLD) : 0;
+ mvwhline(win, top + row, 2, ' ', getmaxx(win) - 4);
+ wattron(win, attr);
+ mvwprintw(win, top + row, 2, "%c %-*.*s %9s %5.1f%% %-7s %s",
+ f->wanted ? ' ' : '-', name_w, name_w, f->name, sizebuf, pct, prio,
+ f->wanted ? "" : "(skipped)");
+ wattroff(win, attr);
+ }
+}
+
+static void draw_peers(WINDOW *win, AppState *st, int top, int rows)
+{
+ TorrentDetail *d = &st->detail;
+ if (d->peer_count == 0) {
+ mvwprintw(win, top, 2, "(no connected peers)");
+ return;
+ }
+ for (int row = 0; row < rows; row++) {
+ size_t i = (size_t)row;
+ if (i >= d->peer_count)
+ break;
+ TorrentPeer *p = &d->peers[i];
+ char downbuf[24], upbuf[24];
+ fmt_speed(p->rate_to_client, downbuf, sizeof(downbuf));
+ fmt_speed(p->rate_to_peer, upbuf, sizeof(upbuf));
+ mvwprintw(win, top + row, 2, "%-22s %-20s %5.1f%% %10s %10s %-8s",
+ p->address, p->client_name, p->progress * 100.0, downbuf, upbuf, p->flags);
+ }
+}
+
+static void draw_trackers(WINDOW *win, AppState *st, int top, int rows, int cols)
+{
+ TorrentDetail *d = &st->detail;
+ if (d->tracker_count == 0) {
+ mvwprintw(win, top, 2, "(no trackers)");
+ return;
+ }
+ int url_w = cols - 22 > 10 ? cols - 22 : 10;
+ for (int row = 0; row < rows; row++) {
+ size_t i = (size_t)row;
+ if (i >= d->tracker_count)
+ break;
+ TorrentTracker *t = &d->trackers[i];
+ mvwprintw(win, top + row, 2, "[%d] %-*.*s S:%d L:%d", t->tier, url_w, url_w, t->announce,
+ t->seeder_count, t->leecher_count);
+ if (t->last_announce_result[0])
+ mvwprintw(win, top + row + 1, 6, "%.*s", cols - 7, t->last_announce_result);
+ }
+}
+
+static const KeyHint DETAIL_HINTS[] = {
+ {"Esc/q", "Back"}, {"Tab", "Next tab"}, {"1-4", "Select tab"},
+ {"Space", "Select/skip (Files)"}, {"+/-", "Priority (Files)"},
+};
+
+void ui_detail_render(AppState *st)
+{
+ ensure_loaded(st);
+ if (st->view != VIEW_DETAIL)
+ return;
+
+ int rows, cols;
+ getmaxyx(stdscr, rows, cols);
+ erase();
+ draw_title(st, cols);
+
+ int body_top = 1;
+ int body_h = rows - FOOTER_H - 1 - body_top;
+ if (body_h < 3)
+ body_h = 3;
+
+ WINDOW *win = derwin(stdscr, body_h, cols, body_top, 0);
+ ui_box(win, body_h, cols);
+
+ int x = 2;
+ for (int i = 0; i < DETAIL_TAB_COUNT; i++) {
+ int active = (i == (int)st->detail_tab);
+ wattron(win, active ? (COLOR_PAIR(CP_SELROW) | A_BOLD) : (COLOR_PAIR(CP_ACCENT)));
+ mvwprintw(win, 0, x, " %d:%s ", i + 1, TAB_LABEL[i]);
+ wattroff(win, active ? (COLOR_PAIR(CP_SELROW) | A_BOLD) : (COLOR_PAIR(CP_ACCENT)));
+ x += (int)strlen(TAB_LABEL[i]) + 5;
+ }
+
+ int content_top = 2;
+ int content_rows = body_h - content_top - 1;
+ if (content_rows < 1)
+ content_rows = 1;
+
+ switch (st->detail_tab) {
+ case DETAIL_GENERAL:
+ draw_general(win, st, content_top);
+ break;
+ case DETAIL_FILES:
+ draw_files(win, st, content_top, content_rows, cols);
+ break;
+ case DETAIL_PEERS:
+ draw_peers(win, st, content_top, content_rows);
+ break;
+ case DETAIL_TRACKERS:
+ draw_trackers(win, st, content_top, content_rows, cols);
+ break;
+ default:
+ break;
+ }
+
+ wnoutrefresh(win);
+ delwin(win);
+
+ ui_draw_footer(DETAIL_HINTS, sizeof(DETAIL_HINTS) / sizeof(DETAIL_HINTS[0]), FOOTER_H);
+ doupdate();
+}
+
+void ui_detail_handle_key(AppState *st, int ch)
+{
+ switch (ch) {
+ case 'q':
+ case 27:
+ case KEY_BACKSPACE:
+ case 127:
+ ui_close_detail(st);
+ return;
+ case '\t':
+ st->detail_tab = (st->detail_tab + 1) % DETAIL_TAB_COUNT;
+ st->detail_cursor = 0;
+ return;
+ case KEY_BTAB:
+ st->detail_tab = (st->detail_tab + DETAIL_TAB_COUNT - 1) % DETAIL_TAB_COUNT;
+ st->detail_cursor = 0;
+ return;
+ case '1':
+ st->detail_tab = DETAIL_GENERAL;
+ st->detail_cursor = 0;
+ return;
+ case '2':
+ st->detail_tab = DETAIL_FILES;
+ st->detail_cursor = 0;
+ return;
+ case '3':
+ st->detail_tab = DETAIL_PEERS;
+ st->detail_cursor = 0;
+ return;
+ case '4':
+ st->detail_tab = DETAIL_TRACKERS;
+ st->detail_cursor = 0;
+ return;
+ default:
+ break;
+ }
+
+ if (st->detail_tab == DETAIL_FILES && st->detail_loaded) {
+ TorrentDetail *d = &st->detail;
+ switch (ch) {
+ case KEY_UP:
+ case 'k':
+ if (st->detail_cursor > 0)
+ st->detail_cursor--;
+ break;
+ case KEY_DOWN:
+ case 'j':
+ if ((size_t)(st->detail_cursor + 1) < d->file_count)
+ st->detail_cursor++;
+ break;
+ case ' ': {
+ if ((size_t)st->detail_cursor >= d->file_count)
+ break;
+ TorrentFile *f = &d->files[st->detail_cursor];
+ int newwanted = !f->wanted;
+ char err[256];
+ if (torrent_set_file_wanted(st->rpc, st->detail_id, &st->detail_cursor, 1, newwanted,
+ err, sizeof(err)) == 0) {
+ f->wanted = newwanted;
+ } else {
+ ui_set_status(st, "Error: %s", err);
+ }
+ break;
+ }
+ case '+':
+ case '-': {
+ if ((size_t)st->detail_cursor >= d->file_count)
+ break;
+ TorrentFile *f = &d->files[st->detail_cursor];
+ int prio = f->priority + (ch == '+' ? 1 : -1);
+ if (prio > 1)
+ prio = 1;
+ if (prio < -1)
+ prio = -1;
+ char err[256];
+ if (torrent_set_file_priority(st->rpc, st->detail_id, &st->detail_cursor, 1, prio,
+ err, sizeof(err)) == 0) {
+ f->priority = prio;
+ } else {
+ ui_set_status(st, "Error: %s", err);
+ }
+ break;
+ }
+ default:
+ break;
+ }
+ }
+}
diff --git a/src/ui/ui_dialogs.c b/src/ui/ui_dialogs.c
new file mode 100644
index 0000000..5501603
--- /dev/null
+++ b/src/ui/ui_dialogs.c
@@ -0,0 +1,419 @@
+#include "ui.h"
+
+#include <ctype.h>
+#include <curses.h>
+#include <dirent.h>
+#include <stdlib.h>
+#include <string.h>
+#include <strings.h>
+#include <sys/stat.h>
+
+static WINDOW *open_box(int h, int w, const char *title)
+{
+ int rows, cols;
+ getmaxyx(stdscr, rows, cols);
+ /* Callers size w against cols already, but not h against rows - clamp
+ * here too so a fixed-height dialog can't get pushed off a short
+ * terminal the same way the settings popup could (see ui_settings.c). */
+ if (h > rows)
+ h = rows;
+ if (w > cols)
+ w = cols;
+ int y = (rows - h) / 2;
+ int x = (cols - w) / 2;
+ if (y < 0)
+ y = 0;
+ if (x < 0)
+ x = 0;
+ WINDOW *win = newwin(h, w, y, x);
+ werase(win);
+ ui_box(win, h, w);
+ if (title)
+ ui_box_title(win, w, title);
+ return win;
+}
+
+static void close_box(WINDOW *win)
+{
+ delwin(win);
+ touchwin(stdscr);
+}
+
+int ui_confirm(const char *title, const char *msg)
+{
+ int w = (int)strlen(msg) + 8;
+ if (w < 30)
+ w = 30;
+ int rows, cols;
+ getmaxyx(stdscr, rows, cols);
+ (void)rows;
+ if (w > cols - 4)
+ w = cols - 4;
+ WINDOW *win = open_box(5, w, title);
+ mvwprintw(win, 2, 2, "%.*s", w - 4, msg);
+ mvwprintw(win, 3, 2, "[y]es [n]o");
+ wrefresh(win);
+
+ int result = 0;
+ for (;;) {
+ int ch = wgetch(win);
+ if (ch == 'y' || ch == 'Y') {
+ result = 1;
+ break;
+ }
+ if (ch == 'n' || ch == 'N' || ch == 27) {
+ result = 0;
+ break;
+ }
+ }
+ close_box(win);
+ return result;
+}
+
+void ui_message(const char *title, const char *msg)
+{
+ int w = (int)strlen(msg) + 8;
+ if (w < 30)
+ w = 30;
+ int cols = getmaxx(stdscr);
+ if (w > cols - 4)
+ w = cols - 4;
+ WINDOW *win = open_box(5, w, title);
+ mvwprintw(win, 2, 2, "%.*s", w - 4, msg);
+ mvwprintw(win, 3, 2, "Press any key...");
+ wrefresh(win);
+ wgetch(win);
+ close_box(win);
+}
+
+int ui_prompt(const char *title, const char *initial, char *out, size_t outsize)
+{
+ int cols = getmaxx(stdscr);
+ int w = cols - 8;
+ if (w > 70)
+ w = 70;
+ if (w < 30)
+ w = 30;
+ WINDOW *win = open_box(4, w, title);
+ keypad(win, TRUE);
+
+ char buf[512];
+ snprintf(buf, sizeof(buf), "%s", initial ? initial : "");
+ size_t len = strlen(buf);
+ size_t cap = sizeof(buf) - 1;
+ int field_w = w - 4;
+
+ curs_set(1);
+ int result = 0;
+ for (;;) {
+ mvwhline(win, 2, 2, ' ', field_w);
+ size_t show_from = 0;
+ if ((int)len >= field_w)
+ show_from = len - field_w + 1;
+ mvwprintw(win, 2, 2, "%s", buf + show_from);
+ wmove(win, 2, 2 + (int)(len - show_from));
+ wrefresh(win);
+
+ int ch = wgetch(win);
+ if (ch == '\n' || ch == '\r' || ch == KEY_ENTER) {
+ result = 1;
+ break;
+ }
+ if (ch == 27) {
+ result = 0;
+ break;
+ }
+ if (ch == KEY_BACKSPACE || ch == 127 || ch == 8) {
+ if (len > 0)
+ buf[--len] = '\0';
+ continue;
+ }
+ if (ch >= 32 && ch < 127 && len < cap) {
+ buf[len++] = (char)ch;
+ buf[len] = '\0';
+ }
+ }
+ curs_set(0);
+ close_box(win);
+
+ if (result)
+ snprintf(out, outsize, "%s", buf);
+ return result;
+}
+
+typedef struct {
+ char name[256];
+ int is_dir;
+} BrowseEntry;
+
+static int has_torrent_ext(const char *name)
+{
+ size_t n = strlen(name);
+ return n > 8 && strcasecmp(name + n - 8, ".torrent") == 0;
+}
+
+static int browse_entry_cmp(const void *a, const void *b)
+{
+ const BrowseEntry *ea = a, *eb = b;
+ if (ea->is_dir != eb->is_dir)
+ return eb->is_dir - ea->is_dir; /* directories first */
+ 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)
+{
+ DIR *d = opendir(path);
+ if (!d)
+ return -1;
+
+ size_t cap = 64, n = 0;
+ BrowseEntry *entries = malloc(cap * sizeof(BrowseEntry));
+ if (strcmp(path, "/") != 0) {
+ snprintf(entries[n].name, sizeof(entries[n].name), "..");
+ entries[n].is_dir = 1;
+ n++;
+ }
+
+ struct dirent *de;
+ while ((de = readdir(d)) != NULL) {
+ if (de->d_name[0] == '.')
+ continue; /* skip "." and hidden files/dirs */
+ char full[1280];
+ snprintf(full, sizeof(full), "%s/%s", path, de->d_name);
+ struct stat st;
+ if (stat(full, &st) != 0)
+ continue;
+ int is_dir = S_ISDIR(st.st_mode);
+ if (!is_dir && !has_torrent_ext(de->d_name))
+ continue;
+ if (n == cap) {
+ cap *= 2;
+ BrowseEntry *ne = realloc(entries, cap * sizeof(BrowseEntry));
+ if (!ne)
+ break;
+ entries = ne;
+ }
+ snprintf(entries[n].name, sizeof(entries[n].name), "%s", de->d_name);
+ entries[n].is_dir = is_dir;
+ n++;
+ }
+ closedir(d);
+
+ /* Keep ".." pinned first, sort the rest. */
+ size_t sort_off = (n > 0 && strcmp(entries[0].name, "..") == 0) ? 1 : 0;
+ if (n > sort_off + 1)
+ qsort(entries + sort_off, n - sort_off, sizeof(BrowseEntry), browse_entry_cmp);
+
+ *out_entries = entries;
+ *out_n = 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)
+{
+ if (!g_browse_dir[0]) {
+ const char *home = getenv("HOME");
+ snprintf(g_browse_dir, sizeof(g_browse_dir), "%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) {
+ ui_message("Error", "Could not open directory");
+ break;
+ }
+ if (cursor >= (int)n)
+ cursor = n ? (int)n - 1 : 0;
+ if (cursor < 0)
+ cursor = 0;
+
+ int rows, cols;
+ 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");
+ keypad(win, TRUE);
+ int list_top = 2;
+ int list_h = h - list_top - 2;
+ if (list_h < 1)
+ list_h = 1;
+
+ 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");
+ mvwprintw(win, 1, 2, "%.*s", w - 4, path);
+
+ if (cursor < top)
+ top = cursor;
+ if (cursor >= top + list_h)
+ top = cursor - list_h + 1;
+ if (top < 0)
+ top = 0;
+
+ if (n == 0)
+ mvwprintw(win, list_top, 2, "(no subfolders or .torrent files here)");
+ for (int row = 0; row < list_h; row++) {
+ int idx = top + row;
+ if ((size_t)idx >= n)
+ break;
+ int attr = (idx == cursor) ? (COLOR_PAIR(CP_SELROW) | A_BOLD) : 0;
+ wattron(win, attr);
+ mvwprintw(win, list_top + row, 2, "%s%-*.*s",
+ entries[idx].is_dir ? "/ " : " ", w - 6, w - 6, entries[idx].name);
+ wattroff(win, attr);
+ }
+ wattron(win, COLOR_PAIR(CP_BORDER));
+ mvwprintw(win, h - 2, 2, "%.*s", w - 4,
+ "Enter=open/select Backspace=up Esc=cancel");
+ wattroff(win, COLOR_PAIR(CP_BORDER));
+ wrefresh(win);
+
+ int ch = wgetch(win);
+ switch (ch) {
+ case KEY_UP:
+ case 'k':
+ if (cursor > 0)
+ cursor--;
+ break;
+ case KEY_DOWN:
+ case 'j':
+ if ((size_t)(cursor + 1) < n)
+ cursor++;
+ break;
+ case KEY_BACKSPACE:
+ case 127:
+ case 8:
+ case KEY_LEFT:
+ if (strcmp(path, "/") != 0) {
+ char *slash = strrchr(path, '/');
+ if (slash == path)
+ path[1] = '\0';
+ else if (slash)
+ *slash = '\0';
+ cursor = 0;
+ top = 0;
+ reload = 1;
+ }
+ break;
+ case '\n':
+ case '\r':
+ case KEY_ENTER:
+ if (n > 0) {
+ char candidate[1024];
+ size_t plen = strlen(path);
+ int root = (plen > 0 && path[plen - 1] == '/');
+ if (strcmp(entries[cursor].name, "..") == 0) {
+ snprintf(candidate, sizeof(candidate), "%s", path);
+ char *slash = strrchr(candidate, '/');
+ if (slash == candidate)
+ candidate[1] = '\0';
+ else if (slash)
+ *slash = '\0';
+ } else {
+ snprintf(candidate, sizeof(candidate), root ? "%s%s" : "%s/%s", path,
+ entries[cursor].name);
+ }
+ if (entries[cursor].is_dir) {
+ snprintf(path, sizeof(path), "%s", candidate);
+ cursor = 0;
+ top = 0;
+ reload = 1;
+ } else {
+ snprintf(out, outsize, "%s", candidate);
+ snprintf(g_browse_dir, sizeof(g_browse_dir), "%s", path);
+ result = 1;
+ reload = 2;
+ }
+ }
+ break;
+ case 27:
+ case 'q':
+ snprintf(g_browse_dir, sizeof(g_browse_dir), "%s", path);
+ result = 0;
+ reload = 2;
+ break;
+ default:
+ break;
+ }
+ }
+ free(entries);
+ close_box(win);
+ if (reload == 2)
+ break;
+ }
+ return result;
+}
+
+void ui_dialog_add_torrent(AppState *st)
+{
+ char source[1024] = "";
+ int have_source = 0;
+ if (ui_confirm("Add torrent", "Browse for a local .torrent file? (No = type magnet/URL/path)")) {
+ have_source = ui_file_browser(source, sizeof(source));
+ } else {
+ have_source = ui_prompt("Add torrent (magnet/URL/file path)", "", source, sizeof(source));
+ }
+ if (!have_source || source[0] == '\0')
+ return;
+
+ char dir[512] = "";
+ ui_prompt("Download folder (blank = default)", "", dir, sizeof(dir));
+
+ char err[256];
+ if (torrent_add(st->rpc, source, dir[0] ? dir : NULL, err, sizeof(err)) != 0)
+ ui_message("Error", err);
+ else
+ ui_set_status(st, "Torrent added");
+}
+
+void ui_dialog_remove(AppState *st, const int *ids, size_t n)
+{
+ char msg[128];
+ snprintf(msg, sizeof(msg), "Remove %zu torrent(s) from the list?", n);
+ if (!ui_confirm("Remove", msg))
+ return;
+
+ int delete_data = ui_confirm("Remove", "Also delete the files on disk?");
+
+ char err[256];
+ if (torrent_remove(st->rpc, ids, n, delete_data, err, sizeof(err)) != 0)
+ ui_set_status(st, "Error: %s", err);
+ else
+ ui_set_status(st, "Removed %zu torrent(s)%s", n, delete_data ? " (incl. data)" : "");
+}
+
+void ui_dialog_speed_limit(AppState *st, const int *ids, size_t n)
+{
+ char down[32] = "0", up[32] = "0";
+ if (!ui_prompt("Download limit KB/s (0 = unlimited)", "0", down, sizeof(down)))
+ return;
+ if (!ui_prompt("Upload limit KB/s (0 = unlimited)", "0", up, sizeof(up)))
+ return;
+
+ long down_kbps = strtol(down, NULL, 10);
+ long up_kbps = strtol(up, NULL, 10);
+
+ char err[256];
+ if (torrent_set_speed_limit(st->rpc, ids, n, down_kbps, down_kbps > 0, up_kbps, up_kbps > 0,
+ err, sizeof(err)) != 0)
+ ui_set_status(st, "Error: %s", err);
+ else
+ ui_set_status(st, "Speed limit set for %zu torrent(s)", n);
+}
diff --git a/src/ui/ui_help.c b/src/ui/ui_help.c
new file mode 100644
index 0000000..131ee3e
--- /dev/null
+++ b/src/ui/ui_help.c
@@ -0,0 +1,68 @@
+#include "ui.h"
+
+#include <curses.h>
+
+static const char *HELP_LINES[] = {
+ "Sidebar (focus with Tab / \xe2\x86\x90):",
+ " \xe2\x86\x91/k \xe2\x86\x93/j select filter category (All/Downloading/Uploading/Paused/Completed)",
+ " Enter / \xe2\x86\x92 / Tab move focus to the list",
+ "",
+ "List view (focus with Tab / \xe2\x86\x92):",
+ " \xe2\x86\x91/k \xe2\x86\x93/j move selection",
+ " PgUp/PgDn page up/down",
+ " Tab / \xe2\x86\x90 move focus to the sidebar",
+ " Enter show details for the selected torrent",
+ " space select/deselect torrent (for batch actions)",
+ " s / S / p start / stop / toggle start-stop",
+ " v verify (recheck)",
+ " d / Delete remove (asks whether to delete data)",
+ " l set speed limit",
+ " a add torrent (magnet/URL/file)",
+ " r force refresh",
+ " o / O next sort column / reverse direction",
+ " / search by name",
+ " Esc clear search / deselect",
+ " g settings",
+ " q quit",
+ "",
+ "Detail view:",
+ " Tab / 1-4 switch tab (General/Files/Peers/Trackers)",
+ " space (Files) select/skip file",
+ " + / - (Files) raise/lower priority",
+ " Esc/q back to the list",
+ "",
+ "Settings (opened with g):",
+ " \xe2\x86\x91/k \xe2\x86\x93/j navigate fields",
+ " Enter/space change value / toggle on/off",
+ " Esc/q close",
+ "",
+ "Press any key to go back.",
+};
+
+void ui_help_render(AppState *st)
+{
+ (void)st;
+ int rows, cols;
+ getmaxyx(stdscr, rows, cols);
+ erase();
+
+ int h = rows - 2;
+ int w = cols - 2;
+ WINDOW *win = derwin(stdscr, h, w, 1, 1);
+ ui_box(win, h, w);
+ ui_box_title(win, w, "Help");
+
+ size_t n = sizeof(HELP_LINES) / sizeof(HELP_LINES[0]);
+ for (size_t i = 0; i < n && (int)i + 2 < h; i++)
+ mvwprintw(win, (int)i + 2, 2, "%.*s", w - 4, HELP_LINES[i]);
+
+ wnoutrefresh(win);
+ delwin(win);
+ doupdate();
+}
+
+void ui_help_handle_key(AppState *st, int ch)
+{
+ (void)ch;
+ st->view = VIEW_LIST;
+}
diff --git a/src/ui/ui_list.c b/src/ui/ui_list.c
new file mode 100644
index 0000000..b05aafd
--- /dev/null
+++ b/src/ui/ui_list.c
@@ -0,0 +1,515 @@
+#include "ui.h"
+#include "../util.h"
+
+#include <curses.h>
+#include <stdlib.h>
+#include <string.h>
+#include <time.h>
+
+#define SIDEBAR_W 16
+#define FOOTER_H 2
+
+static const char *STATUS_LABEL[] = {
+ "Stopped", "Queued", "Checking", "Queued",
+ "Downloading", "Queued", "Seeding",
+};
+
+typedef struct {
+ const char *label;
+ FilterMode filter;
+} SidebarItem;
+
+static const SidebarItem SIDEBAR_ITEMS[] = {
+ {"All", FILTER_ALL},
+ {"Downloading", FILTER_DOWNLOADING},
+ {"Uploading", FILTER_UPLOADING},
+ {"Paused", FILTER_PAUSED},
+ {"Completed", FILTER_COMPLETED},
+};
+#define SIDEBAR_ITEM_COUNT (int)(sizeof(SIDEBAR_ITEMS) / sizeof(SIDEBAR_ITEMS[0]))
+
+static const char *SORT_LABEL[SORT_COUNT] = {
+ "Name", "Size", "Progress", "Status", "Down", "Up", "Ratio", "ETA",
+};
+
+static void draw_title(AppState *st, int cols)
+{
+ attron(COLOR_PAIR(CP_HEADER) | A_BOLD);
+ mvhline(0, 0, ' ', cols);
+ mvprintw(0, 0, " TransTUI \xe2\x80\xa2 %s:%d \xe2\x80\xa2 %s", st->cfg->host, st->cfg->port,
+ st->status_msg[0] && time(NULL) < st->status_msg_until ? "..." : "Connected");
+ if (st->search[0]) {
+ int x = cols - (int)strlen(st->search) - 12;
+ if (x > 20)
+ mvprintw(0, x, "Search: %s", st->search);
+ }
+ attroff(COLOR_PAIR(CP_HEADER) | A_BOLD);
+}
+
+static void draw_sidebar(AppState *st, int y, int x, int h, int w)
+{
+ WINDOW *win = derwin(stdscr, h, w, y, x);
+ if (st->focus == FOCUS_SIDEBAR)
+ wattron(win, A_BOLD);
+ ui_box(win, h, w);
+ if (st->focus == FOCUS_SIDEBAR)
+ wattroff(win, A_BOLD);
+ ui_box_title(win, w, "Filter");
+
+ for (int i = 0; i < SIDEBAR_ITEM_COUNT && i + 2 < h; i++) {
+ int active = (st->filter == SIDEBAR_ITEMS[i].filter);
+ int hover = (st->focus == FOCUS_SIDEBAR && st->sidebar_cursor == i);
+ int attr = 0;
+ if (hover)
+ attr = COLOR_PAIR(CP_SELROW) | A_BOLD;
+ else if (active)
+ attr = COLOR_PAIR(CP_ACCENT) | A_BOLD;
+ wattron(win, attr);
+ mvwprintw(win, i + 2, 1, "%s%-*s", active ? "\xe2\x9d\xaf " : " ", w - 4, SIDEBAR_ITEMS[i].label);
+ wattroff(win, attr);
+ }
+ wnoutrefresh(win);
+ delwin(win);
+}
+
+/* Column x-offsets are computed once here and reused by both the header and
+ * the row renderer, so the two can never drift out of alignment. */
+typedef struct {
+ int mark, name, size, pct, status, down, up, eta, ratio;
+ int name_w;
+ int show_eta, show_ratio;
+} ContentCols;
+
+static ContentCols compute_cols(int content_w)
+{
+ ContentCols c;
+ memset(&c, 0, sizeof(c));
+ int fixed = 1 + 9 + 1 + 6 + 1 + 11 + 1 + 10 + 1 + 10;
+ c.show_eta = 1;
+ c.show_ratio = 1;
+ c.name_w = content_w - fixed - 2 * 7;
+ if (c.name_w < 10) {
+ c.show_ratio = 0;
+ c.name_w = content_w - fixed - 7;
+ }
+ if (c.name_w < 10) {
+ c.show_eta = 0;
+ c.name_w = content_w - fixed;
+ }
+ if (c.name_w < 6)
+ c.name_w = 6;
+
+ c.mark = 2;
+ c.name = c.mark + 1;
+ c.size = c.name + c.name_w + 1;
+ c.pct = c.size + 9 + 1;
+ c.status = c.pct + 6 + 1;
+ c.down = c.status + 11 + 1;
+ c.up = c.down + 10 + 1;
+ c.eta = c.up + 10 + 1;
+ c.ratio = c.eta + (c.show_eta ? 7 : 0);
+ return c;
+}
+
+static void draw_content_header(WINDOW *win, const ContentCols *c)
+{
+ wattron(win, A_BOLD);
+ mvwprintw(win, 1, c->name, "%-*.*s", c->name_w, c->name_w, "Name");
+ mvwprintw(win, 1, c->size, "%9s", "Size");
+ mvwprintw(win, 1, c->pct, "%6s", "%");
+ mvwprintw(win, 1, c->status, "%-11s", "Status");
+ mvwprintw(win, 1, c->down, "%10s", "Down");
+ mvwprintw(win, 1, c->up, "%10s", "Up");
+ if (c->show_eta)
+ mvwprintw(win, 1, c->eta, "%6s", "ETA");
+ if (c->show_ratio)
+ mvwprintw(win, 1, c->ratio, "%6s", "Ratio");
+ wattroff(win, A_BOLD);
+}
+
+static void draw_content_row(WINDOW *win, int y, const ContentCols *c, const Torrent *t, int is_cursor)
+{
+ char sizebuf[24], downbuf[24], upbuf[24], etabuf[16], ratiobuf[16];
+ fmt_size(t->total_size, sizebuf, sizeof(sizebuf));
+ fmt_speed(t->rate_download, downbuf, sizeof(downbuf));
+ fmt_speed(t->rate_upload, upbuf, sizeof(upbuf));
+ fmt_eta(t->eta, etabuf, sizeof(etabuf));
+ fmt_ratio(t->upload_ratio, ratiobuf, sizeof(ratiobuf));
+ const char *status = t->error ? "Error" : STATUS_LABEL[t->status];
+
+ int attr = is_cursor ? (COLOR_PAIR(CP_SELROW) | A_BOLD)
+ : COLOR_PAIR(t->error ? CP_ERROR : ui_color_for_status(t->status));
+
+ /* Blank just the interior (not wclrtoeol - that would also wipe the
+ * box's right border character on this row). */
+ mvwhline(win, y, 1, ' ', getmaxx(win) - 2);
+ wattron(win, attr);
+ mvwprintw(win, y, c->mark, "%c", t->selected ? '*' : ' ');
+ mvwprintw(win, y, c->name, "%-*.*s", c->name_w, c->name_w, t->name);
+ mvwprintw(win, y, c->size, "%9s", sizebuf);
+ mvwprintw(win, y, c->pct, "%5.1f%%", t->percent_done * 100.0);
+ mvwprintw(win, y, c->status, "%-11s", status);
+ mvwprintw(win, y, c->down, "%10s", downbuf);
+ mvwprintw(win, y, c->up, "%10s", upbuf);
+ if (c->show_eta)
+ mvwprintw(win, y, c->eta, "%6s", etabuf);
+ if (c->show_ratio)
+ mvwprintw(win, y, c->ratio, "%6s", ratiobuf);
+ wattroff(win, attr);
+}
+
+static void draw_content(AppState *st, int y, int x, int h, int w)
+{
+ WINDOW *win = derwin(stdscr, h, w, y, x);
+ if (st->focus == FOCUS_LIST)
+ wattron(win, A_BOLD);
+ ui_box(win, h, w);
+ if (st->focus == FOCUS_LIST)
+ wattroff(win, A_BOLD);
+
+ char title[64];
+ const char *filter_label = "All";
+ for (int i = 0; i < SIDEBAR_ITEM_COUNT; i++)
+ if (SIDEBAR_ITEMS[i].filter == st->filter)
+ filter_label = SIDEBAR_ITEMS[i].label;
+ snprintf(title, sizeof(title), "%s (%zu)", filter_label, st->order_count);
+ ui_box_title(win, w, title);
+
+ ContentCols cols = compute_cols(w - 4);
+ draw_content_header(win, &cols);
+
+ int list_top = 2;
+ int list_h = h - list_top - 1;
+ if (list_h < 0)
+ list_h = 0;
+
+ if (st->cursor < st->top)
+ st->top = st->cursor;
+ if (st->cursor >= st->top + list_h)
+ st->top = st->cursor - list_h + 1;
+ if (st->top < 0)
+ st->top = 0;
+
+ for (int row = 0; row < list_h; row++) {
+ int idx = st->top + row;
+ if ((size_t)idx >= st->order_count)
+ break;
+ const Torrent *t = &st->list.items[st->order[idx]];
+ int is_cursor = (idx == st->cursor) && st->focus == FOCUS_LIST;
+ draw_content_row(win, list_top + row, &cols, t, is_cursor);
+ }
+
+ wnoutrefresh(win);
+ delwin(win);
+}
+
+static void draw_status_line(AppState *st, int y, int cols)
+{
+ move(y, 0);
+ clrtoeol();
+ if (st->status_msg[0] && time(NULL) < st->status_msg_until) {
+ attron(COLOR_PAIR(CP_ACCENT));
+ mvprintw(y, 1, "%.*s", cols - 2, st->status_msg);
+ attroff(COLOR_PAIR(CP_ACCENT));
+ return;
+ }
+ int64_t down = 0, up = 0;
+ for (size_t i = 0; i < st->list.count; i++) {
+ down += st->list.items[i].rate_download;
+ up += st->list.items[i].rate_upload;
+ }
+ char downbuf[24], upbuf[24];
+ fmt_speed(down, downbuf, sizeof(downbuf));
+ fmt_speed(up, upbuf, sizeof(upbuf));
+ mvprintw(y, 1, "%zu torrents \xe2\x80\xa2 \xe2\x86\x93 %s \xe2\x80\xa2 \xe2\x86\x91 %s \xe2\x80\xa2 sort: %s",
+ st->list.count, downbuf, upbuf, SORT_LABEL[st->sort_col]);
+}
+
+static const KeyHint LIST_HINTS_SIDEBAR[] = {
+ {"\xe2\x86\x91\xe2\x86\x93", "Filter"}, {"Tab", "Switch focus"}, {"Enter", "Apply"},
+ {"g", "Settings"}, {"?", "Help"}, {"q", "Quit"},
+};
+static const KeyHint LIST_HINTS_LIST[] = {
+ {"\xe2\x86\x91\xe2\x86\x93", "Navigate"}, {"Tab", "Filter"}, {"Enter", "Details"},
+ {"Space", "Select"}, {"s/S/p", "Start/Stop"}, {"v", "Verify"},
+ {"a", "Add"}, {"d", "Remove"}, {"l", "Speed"}, {"o/O", "Sort"},
+ {"/", "Search"}, {"g", "Settings"}, {"?", "Help"}, {"q", "Quit"},
+};
+
+/* Draws the list view into curses' pending-update buffer without flushing
+ * it (no doupdate()). Used both by ui_list_render() below and by the
+ * settings popup, which needs to draw this as a backdrop and then layer its
+ * own box on top before a single atomic doupdate() - otherwise the list-only
+ * backdrop would flash on screen for one frame before the popup appears. */
+void ui_list_render_content(AppState *st)
+{
+ int rows, cols;
+ getmaxyx(stdscr, rows, cols);
+ erase();
+
+ draw_title(st, cols);
+
+ int body_top = 1;
+ int body_bottom = rows - FOOTER_H - 1 - 1;
+ int body_h = body_bottom - body_top + 1;
+ if (body_h < 3)
+ body_h = 3;
+
+ draw_sidebar(st, body_top, 0, body_h, SIDEBAR_W);
+ int content_x = SIDEBAR_W;
+ int content_w = cols - SIDEBAR_W;
+ if (content_w < 20)
+ content_w = 20;
+ draw_content(st, body_top, content_x, body_h, content_w);
+
+ draw_status_line(st, rows - FOOTER_H - 1, cols);
+
+ if (st->focus == FOCUS_SIDEBAR)
+ ui_draw_footer(LIST_HINTS_SIDEBAR, sizeof(LIST_HINTS_SIDEBAR) / sizeof(LIST_HINTS_SIDEBAR[0]), FOOTER_H);
+ else
+ ui_draw_footer(LIST_HINTS_LIST, sizeof(LIST_HINTS_LIST) / sizeof(LIST_HINTS_LIST[0]), FOOTER_H);
+}
+
+void ui_list_render(AppState *st)
+{
+ ui_list_render_content(st);
+ doupdate();
+}
+
+static int *collect_target_ids(AppState *st, size_t *out_n)
+{
+ size_t cnt = 0;
+ for (size_t i = 0; i < st->list.count; i++)
+ if (st->list.items[i].selected)
+ cnt++;
+
+ if (cnt == 0) {
+ if (st->order_count == 0) {
+ *out_n = 0;
+ return NULL;
+ }
+ int *buf = malloc(sizeof(int));
+ buf[0] = st->list.items[st->order[st->cursor]].id;
+ *out_n = 1;
+ return buf;
+ }
+
+ int *buf = malloc(cnt * sizeof(int));
+ size_t j = 0;
+ for (size_t i = 0; i < st->list.count; i++)
+ if (st->list.items[i].selected)
+ buf[j++] = st->list.items[i].id;
+ *out_n = cnt;
+ return buf;
+}
+
+static void do_action(AppState *st, const char *method, const char *verb)
+{
+ size_t n;
+ int *ids = collect_target_ids(st, &n);
+ if (!ids || n == 0) {
+ free(ids);
+ return;
+ }
+ char err[256];
+ if (torrent_action(st->rpc, method, ids, n, err, sizeof(err)) != 0)
+ ui_set_status(st, "Error: %s", err);
+ else
+ ui_set_status(st, "%s %zu torrent(s)", verb, n);
+ free(ids);
+ st->need_poll_now = 1;
+}
+
+static void do_search(AppState *st)
+{
+ char buf[128];
+ snprintf(buf, sizeof(buf), "%s", st->search);
+ if (ui_prompt("Search", buf, buf, sizeof(buf))) {
+ snprintf(st->search, sizeof(st->search), "%s", buf);
+ st->cursor = 0;
+ }
+}
+
+static void handle_sidebar_key(AppState *st, int ch)
+{
+ switch (ch) {
+ case KEY_UP:
+ case 'k':
+ if (st->sidebar_cursor > 0) {
+ st->sidebar_cursor--;
+ st->filter = SIDEBAR_ITEMS[st->sidebar_cursor].filter;
+ st->cursor = 0;
+ }
+ break;
+ case KEY_DOWN:
+ case 'j':
+ if (st->sidebar_cursor + 1 < SIDEBAR_ITEM_COUNT) {
+ st->sidebar_cursor++;
+ st->filter = SIDEBAR_ITEMS[st->sidebar_cursor].filter;
+ st->cursor = 0;
+ }
+ break;
+ case '\t':
+ case '\n':
+ case '\r':
+ case KEY_ENTER:
+ case KEY_RIGHT:
+ st->focus = FOCUS_LIST;
+ break;
+ case 'q':
+ case 'Q':
+ st->running = 0;
+ break;
+ case 'g':
+ st->view = VIEW_SETTINGS;
+ break;
+ case '?':
+ st->view = VIEW_HELP;
+ break;
+ default:
+ break;
+ }
+}
+
+static void handle_list_key(AppState *st, int ch)
+{
+ int page = 10;
+ {
+ int rows, cols;
+ getmaxyx(stdscr, rows, cols);
+ (void)cols;
+ page = rows - 8 > 1 ? rows - 8 : 1;
+ }
+
+ switch (ch) {
+ case 'q':
+ case 'Q':
+ st->running = 0;
+ break;
+ case '\t':
+ case KEY_LEFT:
+ st->focus = FOCUS_SIDEBAR;
+ st->sidebar_cursor = st->filter;
+ break;
+ case KEY_UP:
+ case 'k':
+ if (st->cursor > 0)
+ st->cursor--;
+ break;
+ case KEY_DOWN:
+ case 'j':
+ if ((size_t)(st->cursor + 1) < st->order_count)
+ st->cursor++;
+ break;
+ case KEY_PPAGE:
+ st->cursor -= page;
+ if (st->cursor < 0)
+ st->cursor = 0;
+ break;
+ case KEY_NPAGE:
+ st->cursor += page;
+ if ((size_t)st->cursor >= st->order_count)
+ st->cursor = st->order_count ? (int)st->order_count - 1 : 0;
+ break;
+ case KEY_HOME:
+ st->cursor = 0;
+ break;
+ case KEY_END:
+ st->cursor = st->order_count ? (int)st->order_count - 1 : 0;
+ break;
+ case ' ':
+ if ((size_t)st->cursor < st->order_count) {
+ Torrent *t = &st->list.items[st->order[st->cursor]];
+ t->selected = !t->selected;
+ if ((size_t)(st->cursor + 1) < st->order_count)
+ st->cursor++;
+ }
+ break;
+ case '\n':
+ case '\r':
+ case KEY_ENTER:
+ if ((size_t)st->cursor < st->order_count)
+ ui_open_detail(st, st->list.items[st->order[st->cursor]].id);
+ break;
+ case 's':
+ do_action(st, "torrent-start", "Started");
+ break;
+ case 'S':
+ do_action(st, "torrent-stop", "Stopped");
+ break;
+ case 'p': {
+ if ((size_t)st->cursor < st->order_count) {
+ const Torrent *t = &st->list.items[st->order[st->cursor]];
+ do_action(st, t->status == TR_STATUS_STOPPED ? "torrent-start" : "torrent-stop",
+ t->status == TR_STATUS_STOPPED ? "Started" : "Stopped");
+ }
+ break;
+ }
+ case 'v':
+ do_action(st, "torrent-verify", "Verifying");
+ break;
+ case 'd':
+ case KEY_DC: {
+ size_t n;
+ int *ids = collect_target_ids(st, &n);
+ if (ids && n) {
+ ui_dialog_remove(st, ids, n);
+ st->need_poll_now = 1;
+ }
+ free(ids);
+ break;
+ }
+ case 'l': {
+ size_t n;
+ int *ids = collect_target_ids(st, &n);
+ if (ids && n)
+ ui_dialog_speed_limit(st, ids, n);
+ free(ids);
+ break;
+ }
+ case 'a':
+ ui_dialog_add_torrent(st);
+ st->need_poll_now = 1;
+ break;
+ case 'r':
+ st->need_poll_now = 1;
+ break;
+ case 'o':
+ st->sort_col = (st->sort_col + 1) % SORT_COUNT;
+ ui_set_status(st, "Sort: %s", SORT_LABEL[st->sort_col]);
+ break;
+ case 'O':
+ st->sort_desc = !st->sort_desc;
+ ui_set_status(st, "Sort: %s (%s)", SORT_LABEL[st->sort_col],
+ st->sort_desc ? "descending" : "ascending");
+ break;
+ case '/':
+ do_search(st);
+ break;
+ case 27: /* Esc */
+ if (st->search[0]) {
+ st->search[0] = '\0';
+ st->cursor = 0;
+ } else {
+ for (size_t i = 0; i < st->list.count; i++)
+ st->list.items[i].selected = 0;
+ }
+ break;
+ case 'g':
+ st->view = VIEW_SETTINGS;
+ break;
+ case '?':
+ st->view = VIEW_HELP;
+ break;
+ default:
+ break;
+ }
+}
+
+void ui_list_handle_key(AppState *st, int ch)
+{
+ if (st->focus == FOCUS_SIDEBAR)
+ handle_sidebar_key(st, ch);
+ else
+ handle_list_key(st, ch);
+}
diff --git a/src/ui/ui_settings.c b/src/ui/ui_settings.c
new file mode 100644
index 0000000..b92db60
--- /dev/null
+++ b/src/ui/ui_settings.c
@@ -0,0 +1,486 @@
+#include "ui.h"
+#include "../third_party/cJSON.h"
+
+#include <curses.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+
+typedef struct {
+ int loaded;
+ char download_dir[512];
+ long speed_down;
+ int speed_down_enabled;
+ long speed_up;
+ int speed_up_enabled;
+ int alt_enabled;
+ long alt_down;
+ long alt_up;
+ int alt_time_enabled;
+ int alt_time_begin;
+ int alt_time_end;
+ double seed_ratio_limit;
+ int seed_ratio_enabled;
+} SessionInfo;
+
+static SessionInfo g_sess;
+static int g_cursor;
+static int g_attempted; /* we tried session-get at least once since opening */
+static int g_connected; /* whether that attempt (or the latest one) succeeded */
+
+enum {
+ /* Daemon connection - these live in Config, not the RPC session, and
+ * are saved to config.ini (CLI flags still override them at startup). */
+ F_HOST,
+ F_PORT,
+ F_USERNAME,
+ F_PASSWORD,
+ F_SHOW_SPLASH,
+ /* Everything below is read/written via Transmission's session-get/set. */
+ F_DOWNLOAD_DIR,
+ F_SPEED_DOWN_ENABLED,
+ F_SPEED_DOWN,
+ F_SPEED_UP_ENABLED,
+ F_SPEED_UP,
+ F_ALT_ENABLED,
+ F_ALT_DOWN,
+ F_ALT_UP,
+ F_ALT_TIME_ENABLED,
+ F_ALT_TIME_BEGIN,
+ F_ALT_TIME_END,
+ F_SEED_RATIO_ENABLED,
+ F_SEED_RATIO,
+ F_COUNT,
+};
+#define F_DAEMON_COUNT (F_DOWNLOAD_DIR)
+
+static long ji(const cJSON *o, const char *k, long def)
+{
+ const cJSON *v = cJSON_GetObjectItemCaseSensitive(o, k);
+ return (v && cJSON_IsNumber(v)) ? (long)v->valuedouble : def;
+}
+static double jd(const cJSON *o, const char *k, double def)
+{
+ const cJSON *v = cJSON_GetObjectItemCaseSensitive(o, k);
+ return (v && cJSON_IsNumber(v)) ? v->valuedouble : def;
+}
+static int jb(const cJSON *o, const char *k, int def)
+{
+ const cJSON *v = cJSON_GetObjectItemCaseSensitive(o, k);
+ return v ? cJSON_IsTrue(v) : def;
+}
+static void js(const cJSON *o, const char *k, char *buf, size_t n)
+{
+ const cJSON *v = cJSON_GetObjectItemCaseSensitive(o, k);
+ if (v && cJSON_IsString(v))
+ snprintf(buf, n, "%s", v->valuestring);
+}
+
+static int fetch_session(AppState *st, char *err, size_t errlen)
+{
+ cJSON *args = NULL;
+ if (rpc_call(st->rpc, "session-get", NULL, &args, err, errlen) != 0)
+ return -1;
+
+ memset(&g_sess, 0, sizeof(g_sess));
+ js(args, "download-dir", g_sess.download_dir, sizeof(g_sess.download_dir));
+ g_sess.speed_down = ji(args, "speed-limit-down", 0);
+ g_sess.speed_down_enabled = jb(args, "speed-limit-down-enabled", 0);
+ g_sess.speed_up = ji(args, "speed-limit-up", 0);
+ g_sess.speed_up_enabled = jb(args, "speed-limit-up-enabled", 0);
+ g_sess.alt_enabled = jb(args, "alt-speed-enabled", 0);
+ g_sess.alt_down = ji(args, "alt-speed-down", 0);
+ g_sess.alt_up = ji(args, "alt-speed-up", 0);
+ g_sess.alt_time_enabled = jb(args, "alt-speed-time-enabled", 0);
+ g_sess.alt_time_begin = (int)ji(args, "alt-speed-time-begin", 0);
+ g_sess.alt_time_end = (int)ji(args, "alt-speed-time-end", 0);
+ g_sess.seed_ratio_limit = jd(args, "seedRatioLimit", 0.0);
+ g_sess.seed_ratio_enabled = jb(args, "seedRatioLimited", 0);
+ g_sess.loaded = 1;
+
+ cJSON_Delete(args);
+ return 0;
+}
+
+static int push_session(AppState *st, cJSON *args, char *err, size_t errlen)
+{
+ return rpc_call(st->rpc, "session-set", args, NULL, err, errlen);
+}
+
+static void fmt_hhmm(int minutes, char *buf, size_t n)
+{
+ snprintf(buf, n, "%02d:%02d", (minutes / 60) % 24, minutes % 60);
+}
+
+static int parse_hhmm(const char *s)
+{
+ int h = 0, m = 0;
+ if (sscanf(s, "%d:%d", &h, &m) != 2)
+ return -1;
+ if (h < 0 || h > 23 || m < 0 || m > 59)
+ return -1;
+ return h * 60 + m;
+}
+
+/* Popup geometry, shared between render and the row->y mapping used by input. */
+static int g_popup_h, g_popup_w;
+
+static int field_row(int field)
+{
+ int row = field;
+ if (field >= F_DOWNLOAD_DIR)
+ row += 1; /* blank separator line between daemon and session fields */
+ return row;
+}
+
+void ui_settings_render(AppState *st)
+{
+ /* Always open the popup, even if the daemon is unreachable - that's
+ * often exactly when the user needs it, to fix the host/port. Only the
+ * session-derived fields (download dir, speed limits, ...) depend on a
+ * live connection; the daemon fields below always work. */
+ if (!g_attempted) {
+ char err[256];
+ g_connected = (fetch_session(st, err, sizeof(err)) == 0);
+ if (!g_connected) {
+ memset(&g_sess, 0, sizeof(g_sess));
+ ui_set_status(st, "Not connected: %s", err);
+ }
+ g_attempted = 1;
+ g_cursor = 0;
+ }
+
+ /* Full-screen view rather than a popup layered over the list backdrop -
+ * matches ui_help_render()'s pattern. An overlapping popup+backdrop here
+ * turned out unreliable in practice (state changed correctly on 'g' but
+ * nothing visibly redrew until a later, unrelated screen update forced
+ * it), and a dedicated screen sidesteps that class of issue entirely. */
+ int rows, cols;
+ getmaxyx(stdscr, rows, cols);
+ erase();
+
+ g_popup_h = rows - 2;
+ g_popup_w = cols - 2;
+ if (g_popup_h < 4)
+ g_popup_h = rows > 4 ? 4 : rows;
+ if (g_popup_w < 10)
+ g_popup_w = cols > 10 ? 10 : cols;
+
+ WINDOW *win = derwin(stdscr, g_popup_h, g_popup_w, 1, 1);
+ ui_box(win, g_popup_h, g_popup_w);
+ ui_box_title(win, g_popup_w, g_connected ? "Settings" : "Settings - not connected");
+
+ char hhmm_begin[8], hhmm_end[8];
+ fmt_hhmm(g_sess.alt_time_begin, hhmm_begin, sizeof(hhmm_begin));
+ fmt_hhmm(g_sess.alt_time_end, hhmm_end, sizeof(hhmm_end));
+
+ char line[F_COUNT][640];
+ snprintf(line[F_HOST], sizeof(line[0]), "Host: %s", st->cfg->host);
+ snprintf(line[F_PORT], sizeof(line[0]), "Port: %d", st->cfg->port);
+ snprintf(line[F_USERNAME], sizeof(line[0]), "Username: %s",
+ st->cfg->username[0] ? st->cfg->username : "(none)");
+ snprintf(line[F_PASSWORD], sizeof(line[0]), "Password: %s",
+ st->cfg->password[0] ? "********" : "(none)");
+ snprintf(line[F_SHOW_SPLASH], sizeof(line[0]), "Splash screen: %s",
+ st->cfg->show_splash ? "On" : "Off");
+ snprintf(line[F_DOWNLOAD_DIR], sizeof(line[0]), "Download folder: %s",
+ g_connected ? g_sess.download_dir : "(not connected)");
+ snprintf(line[F_SPEED_DOWN_ENABLED], sizeof(line[0]), "Download limit: %s",
+ g_sess.speed_down_enabled ? "On" : "Off");
+ snprintf(line[F_SPEED_DOWN], sizeof(line[0]), " Limit (KB/s): %ld", g_sess.speed_down);
+ snprintf(line[F_SPEED_UP_ENABLED], sizeof(line[0]), "Upload limit: %s",
+ g_sess.speed_up_enabled ? "On" : "Off");
+ snprintf(line[F_SPEED_UP], sizeof(line[0]), " Limit (KB/s): %ld", g_sess.speed_up);
+ snprintf(line[F_ALT_ENABLED], sizeof(line[0]), "Alternative speed: %s",
+ g_sess.alt_enabled ? "On" : "Off");
+ snprintf(line[F_ALT_DOWN], sizeof(line[0]), " Down (KB/s): %ld", g_sess.alt_down);
+ snprintf(line[F_ALT_UP], sizeof(line[0]), " Up (KB/s): %ld", g_sess.alt_up);
+ snprintf(line[F_ALT_TIME_ENABLED], sizeof(line[0]), " Schedule: %s",
+ g_sess.alt_time_enabled ? "On" : "Off");
+ snprintf(line[F_ALT_TIME_BEGIN], sizeof(line[0]), " Start: %s", hhmm_begin);
+ snprintf(line[F_ALT_TIME_END], sizeof(line[0]), " End: %s", hhmm_end);
+ snprintf(line[F_SEED_RATIO_ENABLED], sizeof(line[0]), "Ratio limit: %s",
+ g_sess.seed_ratio_enabled ? "On" : "Off");
+ snprintf(line[F_SEED_RATIO], sizeof(line[0]), " Ratio: %.2f", g_sess.seed_ratio_limit);
+
+ int top = 2;
+ int field_w = g_popup_w - 4;
+ for (int i = 0; i < F_COUNT; i++) {
+ int y = top + field_row(i);
+ if (y >= g_popup_h - 2)
+ break;
+ int attr = (i == g_cursor) ? (COLOR_PAIR(CP_SELROW) | A_BOLD) : 0;
+ wattron(win, attr);
+ mvwprintw(win, y, 2, "%-*.*s", field_w, field_w, line[i]);
+ wattroff(win, attr);
+ }
+
+ wattron(win, COLOR_PAIR(CP_BORDER));
+ mvwprintw(win, g_popup_h - 2, 2, "%.*s", field_w,
+ g_connected
+ ? "Esc/q=close Enter/space=change \xe2\x86\x91\xe2\x86\x93=navigate"
+ : "Esc/q=close Enter/space=change r=reconnect \xe2\x86\x91\xe2\x86\x93=navigate");
+ wattroff(win, COLOR_PAIR(CP_BORDER));
+
+ wnoutrefresh(win);
+ delwin(win);
+ doupdate();
+}
+
+static void apply_field(AppState *st, int field)
+{
+ cJSON *args = cJSON_CreateObject();
+ char err[256];
+ int ok = 1;
+ int daemon_changed = 0;
+ int local_changed = 0; /* config.ini-only setting, no reconnect needed */
+
+ switch (field) {
+ case F_HOST: {
+ char v[256];
+ snprintf(v, sizeof(v), "%s", st->cfg->host);
+ if (ui_prompt("Host", v, v, sizeof(v)) && v[0]) {
+ snprintf(st->cfg->host, sizeof(st->cfg->host), "%s", v);
+ daemon_changed = 1;
+ }
+ ok = 0; /* not a session-set field */
+ break;
+ }
+ case F_PORT: {
+ char v[16];
+ snprintf(v, sizeof(v), "%d", st->cfg->port);
+ if (ui_prompt("Port", v, v, sizeof(v))) {
+ int p = atoi(v);
+ if (p > 0 && p <= 65535) {
+ st->cfg->port = p;
+ daemon_changed = 1;
+ } else {
+ ui_set_status(st, "Invalid port");
+ }
+ }
+ ok = 0;
+ break;
+ }
+ case F_USERNAME: {
+ char v[128];
+ snprintf(v, sizeof(v), "%s", st->cfg->username);
+ if (ui_prompt("Username", v, v, sizeof(v))) {
+ snprintf(st->cfg->username, sizeof(st->cfg->username), "%s", v);
+ daemon_changed = 1;
+ }
+ ok = 0;
+ break;
+ }
+ case F_PASSWORD: {
+ char v[128];
+ snprintf(v, sizeof(v), "%s", st->cfg->password);
+ if (ui_prompt("Password", v, v, sizeof(v))) {
+ snprintf(st->cfg->password, sizeof(st->cfg->password), "%s", v);
+ daemon_changed = 1;
+ }
+ ok = 0;
+ break;
+ }
+ case F_SHOW_SPLASH:
+ st->cfg->show_splash = !st->cfg->show_splash;
+ local_changed = 1;
+ ok = 0;
+ 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))) {
+ snprintf(g_sess.download_dir, sizeof(g_sess.download_dir), "%s", v);
+ cJSON_AddStringToObject(args, "download-dir", g_sess.download_dir);
+ } else {
+ ok = 0;
+ }
+ break;
+ }
+ case F_SPEED_DOWN_ENABLED:
+ g_sess.speed_down_enabled = !g_sess.speed_down_enabled;
+ cJSON_AddBoolToObject(args, "speed-limit-down-enabled", g_sess.speed_down_enabled);
+ break;
+ case F_SPEED_DOWN: {
+ char v[32];
+ snprintf(v, sizeof(v), "%ld", g_sess.speed_down);
+ if (ui_prompt("Download limit KB/s", v, v, sizeof(v))) {
+ g_sess.speed_down = strtol(v, NULL, 10);
+ cJSON_AddNumberToObject(args, "speed-limit-down", (double)g_sess.speed_down);
+ } else {
+ ok = 0;
+ }
+ break;
+ }
+ case F_SPEED_UP_ENABLED:
+ g_sess.speed_up_enabled = !g_sess.speed_up_enabled;
+ cJSON_AddBoolToObject(args, "speed-limit-up-enabled", g_sess.speed_up_enabled);
+ break;
+ case F_SPEED_UP: {
+ char v[32];
+ snprintf(v, sizeof(v), "%ld", g_sess.speed_up);
+ if (ui_prompt("Upload limit KB/s", v, v, sizeof(v))) {
+ g_sess.speed_up = strtol(v, NULL, 10);
+ cJSON_AddNumberToObject(args, "speed-limit-up", (double)g_sess.speed_up);
+ } else {
+ ok = 0;
+ }
+ break;
+ }
+ case F_ALT_ENABLED:
+ g_sess.alt_enabled = !g_sess.alt_enabled;
+ cJSON_AddBoolToObject(args, "alt-speed-enabled", g_sess.alt_enabled);
+ break;
+ case F_ALT_DOWN: {
+ char v[32];
+ snprintf(v, sizeof(v), "%ld", g_sess.alt_down);
+ if (ui_prompt("Alternative download speed KB/s", v, v, sizeof(v))) {
+ g_sess.alt_down = strtol(v, NULL, 10);
+ cJSON_AddNumberToObject(args, "alt-speed-down", (double)g_sess.alt_down);
+ } else {
+ ok = 0;
+ }
+ break;
+ }
+ case F_ALT_UP: {
+ char v[32];
+ snprintf(v, sizeof(v), "%ld", g_sess.alt_up);
+ if (ui_prompt("Alternative upload speed KB/s", v, v, sizeof(v))) {
+ g_sess.alt_up = strtol(v, NULL, 10);
+ cJSON_AddNumberToObject(args, "alt-speed-up", (double)g_sess.alt_up);
+ } else {
+ ok = 0;
+ }
+ break;
+ }
+ case F_ALT_TIME_ENABLED:
+ g_sess.alt_time_enabled = !g_sess.alt_time_enabled;
+ cJSON_AddBoolToObject(args, "alt-speed-time-enabled", g_sess.alt_time_enabled);
+ break;
+ case F_ALT_TIME_BEGIN: {
+ char v[8];
+ fmt_hhmm(g_sess.alt_time_begin, v, sizeof(v));
+ if (ui_prompt("Start time (HH:MM)", v, v, sizeof(v))) {
+ int mins = parse_hhmm(v);
+ if (mins < 0) {
+ ui_set_status(st, "Invalid time, use HH:MM");
+ ok = 0;
+ } else {
+ g_sess.alt_time_begin = mins;
+ cJSON_AddNumberToObject(args, "alt-speed-time-begin", mins);
+ }
+ } else {
+ ok = 0;
+ }
+ break;
+ }
+ case F_ALT_TIME_END: {
+ char v[8];
+ fmt_hhmm(g_sess.alt_time_end, v, sizeof(v));
+ if (ui_prompt("End time (HH:MM)", v, v, sizeof(v))) {
+ int mins = parse_hhmm(v);
+ if (mins < 0) {
+ ui_set_status(st, "Invalid time, use HH:MM");
+ ok = 0;
+ } else {
+ g_sess.alt_time_end = mins;
+ cJSON_AddNumberToObject(args, "alt-speed-time-end", mins);
+ }
+ } else {
+ ok = 0;
+ }
+ break;
+ }
+ case F_SEED_RATIO_ENABLED:
+ g_sess.seed_ratio_enabled = !g_sess.seed_ratio_enabled;
+ cJSON_AddBoolToObject(args, "seedRatioLimited", g_sess.seed_ratio_enabled);
+ break;
+ case F_SEED_RATIO: {
+ char v[32];
+ snprintf(v, sizeof(v), "%.2f", g_sess.seed_ratio_limit);
+ if (ui_prompt("Ratio limit", v, v, sizeof(v))) {
+ g_sess.seed_ratio_limit = strtod(v, NULL);
+ cJSON_AddNumberToObject(args, "seedRatioLimit", g_sess.seed_ratio_limit);
+ } else {
+ ok = 0;
+ }
+ break;
+ }
+ default:
+ ok = 0;
+ break;
+ }
+
+ if (daemon_changed) {
+ /* Live-apply to the RPC client and start a fresh CSRF handshake -
+ * the daemon we're now talking to may not know our old session id. */
+ snprintf(st->rpc->host, sizeof(st->rpc->host), "%s", st->cfg->host);
+ st->rpc->port = st->cfg->port;
+ snprintf(st->rpc->user, sizeof(st->rpc->user), "%s", st->cfg->username);
+ snprintf(st->rpc->pass, sizeof(st->rpc->pass), "%s", st->cfg->password);
+ st->rpc->session_id[0] = '\0';
+
+ if (config_save(st->cfg, err, sizeof(err)) != 0)
+ ui_set_status(st, "Error saving: %s", err);
+ else
+ ui_set_status(st, "Connection settings saved");
+ g_attempted = 0; /* re-fetch session-get against the (possibly new) daemon */
+ st->need_poll_now = 1;
+ cJSON_Delete(args);
+ return;
+ }
+
+ if (local_changed) {
+ if (config_save(st->cfg, err, sizeof(err)) != 0)
+ ui_set_status(st, "Error saving: %s", err);
+ else
+ ui_set_status(st, "Setting saved");
+ cJSON_Delete(args);
+ return;
+ }
+
+ if (!ok || cJSON_GetArraySize(args) == 0) {
+ cJSON_Delete(args);
+ return;
+ }
+
+ if (push_session(st, args, err, sizeof(err)) != 0)
+ ui_set_status(st, "Error: %s", err);
+ else
+ ui_set_status(st, "Setting saved");
+}
+
+void ui_settings_handle_key(AppState *st, int ch)
+{
+ switch (ch) {
+ case 'q':
+ case 27:
+ g_attempted = 0;
+ st->view = VIEW_LIST;
+ break;
+ case 'r':
+ if (!g_connected) {
+ ui_offer_reconnect_help(st);
+ g_attempted = 0; /* retry session-get on next render either way */
+ }
+ break;
+ case KEY_UP:
+ case 'k':
+ if (g_cursor > 0)
+ g_cursor--;
+ break;
+ case KEY_DOWN:
+ case 'j':
+ if (g_cursor + 1 < F_COUNT)
+ g_cursor++;
+ break;
+ case '\n':
+ case '\r':
+ case KEY_ENTER:
+ case ' ':
+ apply_field(st, g_cursor);
+ break;
+ default:
+ break;
+ }
+}
diff --git a/src/ui/ui_splash.c b/src/ui/ui_splash.c
new file mode 100644
index 0000000..b5d9df0
--- /dev/null
+++ b/src/ui/ui_splash.c
@@ -0,0 +1,113 @@
+#include "ui.h"
+
+#include <curses.h>
+#include <string.h>
+#include <time.h>
+
+#define GLYPH_H 5
+#define GLYPH_W 5
+#define LETTER_GAP 1
+
+typedef const char *Glyph[GLYPH_H];
+
+/* Blocky 5x5 "16-bit" pixel font, just enough letters to spell TRANSTUI. */
+static const Glyph GLYPH_T = {"\xe2\x96\x88\xe2\x96\x88\xe2\x96\x88\xe2\x96\x88\xe2\x96\x88", " \xe2\x96\x88 ", " \xe2\x96\x88 ",
+ " \xe2\x96\x88 ", " \xe2\x96\x88 "};
+static const Glyph GLYPH_R = {"\xe2\x96\x88\xe2\x96\x88\xe2\x96\x88\xe2\x96\x88 ", "\xe2\x96\x88 \xe2\x96\x88",
+ "\xe2\x96\x88\xe2\x96\x88\xe2\x96\x88\xe2\x96\x88 ", "\xe2\x96\x88 \xe2\x96\x88 ", "\xe2\x96\x88 \xe2\x96\x88"};
+static const Glyph GLYPH_A = {" \xe2\x96\x88\xe2\x96\x88\xe2\x96\x88 ", "\xe2\x96\x88 \xe2\x96\x88",
+ "\xe2\x96\x88\xe2\x96\x88\xe2\x96\x88\xe2\x96\x88\xe2\x96\x88", "\xe2\x96\x88 \xe2\x96\x88", "\xe2\x96\x88 \xe2\x96\x88"};
+static const Glyph GLYPH_N = {"\xe2\x96\x88 \xe2\x96\x88", "\xe2\x96\x88\xe2\x96\x88 \xe2\x96\x88", "\xe2\x96\x88 \xe2\x96\x88 \xe2\x96\x88",
+ "\xe2\x96\x88 \xe2\x96\x88\xe2\x96\x88", "\xe2\x96\x88 \xe2\x96\x88"};
+static const Glyph GLYPH_S = {" \xe2\x96\x88\xe2\x96\x88\xe2\x96\x88\xe2\x96\x88", "\xe2\x96\x88 ", " \xe2\x96\x88\xe2\x96\x88\xe2\x96\x88 ",
+ " \xe2\x96\x88", "\xe2\x96\x88\xe2\x96\x88\xe2\x96\x88\xe2\x96\x88 "};
+static const Glyph GLYPH_U = {"\xe2\x96\x88 \xe2\x96\x88", "\xe2\x96\x88 \xe2\x96\x88", "\xe2\x96\x88 \xe2\x96\x88",
+ "\xe2\x96\x88 \xe2\x96\x88", " \xe2\x96\x88\xe2\x96\x88\xe2\x96\x88 "};
+static const Glyph GLYPH_I = {"\xe2\x96\x88\xe2\x96\x88\xe2\x96\x88\xe2\x96\x88\xe2\x96\x88", " \xe2\x96\x88 ", " \xe2\x96\x88 ",
+ " \xe2\x96\x88 ", "\xe2\x96\x88\xe2\x96\x88\xe2\x96\x88\xe2\x96\x88\xe2\x96\x88"};
+
+/* T R A N S T U I */
+static const Glyph *const WORD[] = {&GLYPH_T, &GLYPH_R, &GLYPH_A, &GLYPH_N,
+ &GLYPH_S, &GLYPH_T, &GLYPH_U, &GLYPH_I};
+#define WORD_LEN (int)(sizeof(WORD) / sizeof(WORD[0]))
+
+#define SPLASH_MS 3000
+
+/* Returns the key that dismissed the splash, or ERR if it timed out on its
+ * own - the caller feeds a real key straight into the normal key handler
+ * instead of silently swallowing it, so e.g. pressing 'g' to skip the
+ * splash also opens settings immediately rather than just landing on the
+ * plain list view. */
+int ui_splash_show(void)
+{
+ int rows, cols;
+ getmaxyx(stdscr, rows, cols);
+
+ int banner_w = WORD_LEN * GLYPH_W + (WORD_LEN - 1) * LETTER_GAP;
+ const char *subtitle = "Transmission TUI Client";
+ const char *hint = "Press any key to continue\xe2\x80\xa6";
+
+ int content_w = banner_w;
+ if ((int)strlen(subtitle) > content_w)
+ content_w = (int)strlen(subtitle);
+ int box_w = content_w + 8;
+ if (box_w > cols - 2)
+ box_w = cols - 2;
+ int box_h = GLYPH_H + 6;
+ if (box_h > rows - 2)
+ box_h = rows - 2;
+ if (box_w < 10 || box_h < 8)
+ return ERR; /* terminal too small to bother */
+
+ int box_y = (rows - box_h) / 2;
+ int box_x = (cols - box_w) / 2;
+
+ erase();
+ WINDOW *win = derwin(stdscr, box_h, box_w, box_y, box_x);
+ ui_box(win, box_h, box_w);
+ ui_box_title(win, box_w, "TransTUI");
+
+ int have_color = has_colors();
+ int banner_y = 2;
+ int banner_x = (box_w - banner_w) / 2;
+ if (banner_x < 1)
+ banner_x = 1;
+
+ int white = have_color ? (COLOR_PAIR(CP_SPLASH_WHITE) | A_BOLD) : A_BOLD;
+
+ wattron(win, white);
+ for (int li = 0; li < WORD_LEN; li++)
+ for (int r = 0; r < GLYPH_H; r++)
+ mvwaddstr(win, banner_y + r, banner_x + li * (GLYPH_W + LETTER_GAP), (*WORD[li])[r]);
+ wattroff(win, white);
+
+ int sub_y = banner_y + GLYPH_H + 1;
+ wattron(win, white);
+ mvwprintw(win, sub_y, (box_w - (int)strlen(subtitle)) / 2, "%s", subtitle);
+ wattroff(win, white);
+
+ int hint_w = (int)strlen(hint) - 2; /* the ellipsis is one 3-byte UTF-8 char, not 3 cells */
+ wattron(win, have_color ? COLOR_PAIR(CP_SPLASH_WHITE) : 0);
+ mvwprintw(win, box_h - 2, (box_w - hint_w) / 2, "%s", hint);
+ wattroff(win, have_color ? COLOR_PAIR(CP_SPLASH_WHITE) : 0);
+
+ wnoutrefresh(win);
+ doupdate();
+ delwin(win);
+
+ struct timespec t0;
+ clock_gettime(CLOCK_MONOTONIC, &t0);
+ timeout(50);
+ int ch = ERR;
+ for (;;) {
+ ch = getch();
+ if (ch != ERR)
+ break;
+ struct timespec now;
+ clock_gettime(CLOCK_MONOTONIC, &now);
+ long elapsed = (now.tv_sec - t0.tv_sec) * 1000 + (now.tv_nsec - t0.tv_nsec) / 1000000;
+ if (elapsed >= SPLASH_MS)
+ break;
+ }
+ return ch;
+}
diff --git a/src/util.c b/src/util.c
new file mode 100644
index 0000000..9824da7
--- /dev/null
+++ b/src/util.c
@@ -0,0 +1,179 @@
+#include "util.h"
+
+#include <ctype.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <time.h>
+
+void fmt_size(int64_t bytes, char *buf, size_t n)
+{
+ static const char *units[] = {"B", "KiB", "MiB", "GiB", "TiB", "PiB"};
+ double v = (double)bytes;
+ size_t u = 0;
+ while (v >= 1024.0 && u < 5) {
+ v /= 1024.0;
+ u++;
+ }
+ if (u == 0)
+ snprintf(buf, n, "%.0f %s", v, units[u]);
+ else
+ snprintf(buf, n, "%.2f %s", v, units[u]);
+}
+
+void fmt_speed(int64_t bytes_per_sec, char *buf, size_t n)
+{
+ char tmp[32];
+ fmt_size(bytes_per_sec, tmp, sizeof(tmp));
+ snprintf(buf, n, "%s/s", tmp);
+}
+
+void fmt_eta(int seconds, char *buf, size_t n)
+{
+ if (seconds < 0) {
+ snprintf(buf, n, "\xe2\x88\x9e"); /* infinity, unknown */
+ return;
+ }
+ if (seconds == 0) {
+ snprintf(buf, n, "klar");
+ return;
+ }
+ int days = seconds / 86400;
+ int hours = (seconds % 86400) / 3600;
+ int mins = (seconds % 3600) / 60;
+ int secs = seconds % 60;
+ if (days > 0)
+ snprintf(buf, n, "%dd %dh", days, hours);
+ else if (hours > 0)
+ snprintf(buf, n, "%dh %dm", hours, mins);
+ else if (mins > 0)
+ snprintf(buf, n, "%dm %ds", mins, secs);
+ else
+ snprintf(buf, n, "%ds", secs);
+}
+
+void fmt_ratio(double ratio, char *buf, size_t n)
+{
+ if (ratio < 0)
+ snprintf(buf, n, "\xe2\x88\x9e");
+ else
+ snprintf(buf, n, "%.2f", ratio);
+}
+
+void fmt_time(long unix_time, char *buf, size_t n)
+{
+ if (unix_time <= 0) {
+ snprintf(buf, n, "-");
+ return;
+ }
+ time_t t = (time_t)unix_time;
+ struct tm tmv;
+ localtime_r(&t, &tmv);
+ strftime(buf, n, "%Y-%m-%d %H:%M", &tmv);
+}
+
+static const char b64tab[] =
+ "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
+
+size_t base64_encoded_size(size_t len)
+{
+ return ((len + 2) / 3) * 4;
+}
+
+long base64_encode(const unsigned char *data, size_t len, char *out, size_t outsize)
+{
+ size_t needed = base64_encoded_size(len);
+ if (outsize < needed + 1)
+ return -1;
+
+ size_t i, o = 0;
+ for (i = 0; i + 2 < len; i += 3) {
+ uint32_t n = ((uint32_t)data[i] << 16) | ((uint32_t)data[i + 1] << 8) | data[i + 2];
+ out[o++] = b64tab[(n >> 18) & 0x3F];
+ out[o++] = b64tab[(n >> 12) & 0x3F];
+ out[o++] = b64tab[(n >> 6) & 0x3F];
+ out[o++] = b64tab[n & 0x3F];
+ }
+ size_t rem = len - i;
+ if (rem == 1) {
+ uint32_t n = (uint32_t)data[i] << 16;
+ out[o++] = b64tab[(n >> 18) & 0x3F];
+ out[o++] = b64tab[(n >> 12) & 0x3F];
+ out[o++] = '=';
+ out[o++] = '=';
+ } else if (rem == 2) {
+ uint32_t n = ((uint32_t)data[i] << 16) | ((uint32_t)data[i + 1] << 8);
+ out[o++] = b64tab[(n >> 18) & 0x3F];
+ out[o++] = b64tab[(n >> 12) & 0x3F];
+ out[o++] = b64tab[(n >> 6) & 0x3F];
+ out[o++] = '=';
+ }
+ out[o] = '\0';
+ return (long)o;
+}
+
+unsigned char *read_file_all(const char *path, size_t *out_len)
+{
+ FILE *f = fopen(path, "rb");
+ if (!f)
+ return NULL;
+ if (fseek(f, 0, SEEK_END) != 0) {
+ fclose(f);
+ return NULL;
+ }
+ long sz = ftell(f);
+ if (sz < 0) {
+ fclose(f);
+ return NULL;
+ }
+ rewind(f);
+ unsigned char *buf = malloc((size_t)sz > 0 ? (size_t)sz : 1);
+ if (!buf) {
+ fclose(f);
+ return NULL;
+ }
+ size_t rd = fread(buf, 1, (size_t)sz, f);
+ fclose(f);
+ if (rd != (size_t)sz) {
+ free(buf);
+ return NULL;
+ }
+ if (out_len)
+ *out_len = rd;
+ return buf;
+}
+
+int str_ends_with(const char *s, const char *suffix)
+{
+ size_t ls = strlen(s), lsuf = strlen(suffix);
+ if (lsuf > ls)
+ return 0;
+ return strcmp(s + (ls - lsuf), suffix) == 0;
+}
+
+int str_starts_with(const char *s, const char *prefix)
+{
+ return strncmp(s, prefix, strlen(prefix)) == 0;
+}
+
+int str_ci_contains(const char *haystack, const char *needle)
+{
+ size_t nlen = strlen(needle);
+ if (nlen == 0)
+ return 1;
+ size_t hlen = strlen(haystack);
+ if (nlen > hlen)
+ return 0;
+ for (size_t i = 0; i + nlen <= hlen; i++) {
+ size_t j = 0;
+ for (; j < nlen; j++) {
+ unsigned char a = (unsigned char)haystack[i + j];
+ unsigned char b = (unsigned char)needle[j];
+ if (tolower(a) != tolower(b))
+ break;
+ }
+ if (j == nlen)
+ return 1;
+ }
+ return 0;
+}
diff --git a/src/util.h b/src/util.h
new file mode 100644
index 0000000..255fba8
--- /dev/null
+++ b/src/util.h
@@ -0,0 +1,26 @@
+#ifndef TRANSTUI_UTIL_H
+#define TRANSTUI_UTIL_H
+
+#include <stddef.h>
+#include <stdint.h>
+
+void fmt_size(int64_t bytes, char *buf, size_t n);
+void fmt_speed(int64_t bytes_per_sec, char *buf, size_t n);
+void fmt_eta(int seconds, char *buf, size_t n);
+void fmt_ratio(double ratio, char *buf, size_t n);
+void fmt_time(long unix_time, char *buf, size_t n);
+
+/* Base64-encodes data into out (NUL-terminated). outsize must be at least
+ * base64_encoded_size(len)+1. Returns encoded length, or -1 if out is too small. */
+size_t base64_encoded_size(size_t len);
+long base64_encode(const unsigned char *data, size_t len, char *out, size_t outsize);
+
+/* Reads an entire file into a malloc'd buffer. Returns NULL on failure. */
+unsigned char *read_file_all(const char *path, size_t *out_len);
+
+int str_ends_with(const char *s, const char *suffix);
+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);
+
+#endif
diff --git a/third_party/cJSON.c b/third_party/cJSON.c
new file mode 100644
index 0000000..88c2d95
--- /dev/null
+++ b/third_party/cJSON.c
@@ -0,0 +1,3206 @@
+/*
+ Copyright (c) 2009-2017 Dave Gamble and cJSON contributors
+
+ Permission is hereby granted, free of charge, to any person obtaining a copy
+ of this software and associated documentation files (the "Software"), to deal
+ in the Software without restriction, including without limitation the rights
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ copies of the Software, and to permit persons to whom the Software is
+ furnished to do so, subject to the following conditions:
+
+ The above copyright notice and this permission notice shall be included in
+ all copies or substantial portions of the Software.
+
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ THE SOFTWARE.
+*/
+
+/* cJSON */
+/* JSON parser in C. */
+
+/* disable warnings about old C89 functions in MSVC */
+#if !defined(_CRT_SECURE_NO_DEPRECATE) && defined(_MSC_VER)
+#define _CRT_SECURE_NO_DEPRECATE
+#endif
+
+#ifdef __GNUC__
+#pragma GCC visibility push(default)
+#endif
+#if defined(_MSC_VER)
+#pragma warning (push)
+/* disable warning about single line comments in system headers */
+#pragma warning (disable : 4001)
+#endif
+
+#include <string.h>
+#include <stdio.h>
+#include <math.h>
+#include <stdlib.h>
+#include <limits.h>
+#include <ctype.h>
+#include <float.h>
+
+#ifdef ENABLE_LOCALES
+#include <locale.h>
+#endif
+
+#if defined(_MSC_VER)
+#pragma warning (pop)
+#endif
+#ifdef __GNUC__
+#pragma GCC visibility pop
+#endif
+
+#include "cJSON.h"
+
+/* define our own boolean type */
+#ifdef true
+#undef true
+#endif
+#define true ((cJSON_bool)1)
+
+#ifdef false
+#undef false
+#endif
+#define false ((cJSON_bool)0)
+
+/* define isnan and isinf for ANSI C, if in C99 or above, isnan and isinf has been defined in math.h */
+#ifndef isinf
+#define isinf(d) (isnan((d - d)) && !isnan(d))
+#endif
+#ifndef isnan
+#define isnan(d) (d != d)
+#endif
+
+#ifndef NAN
+#ifdef _WIN32
+#define NAN sqrt(-1.0)
+#else
+#define NAN 0.0/0.0
+#endif
+#endif
+
+typedef struct {
+ const unsigned char *json;
+ size_t position;
+} error;
+static error global_error = { NULL, 0 };
+
+CJSON_PUBLIC(const char *) cJSON_GetErrorPtr(void)
+{
+ return (const char*) (global_error.json + global_error.position);
+}
+
+CJSON_PUBLIC(char *) cJSON_GetStringValue(const cJSON * const item)
+{
+ if (!cJSON_IsString(item))
+ {
+ return NULL;
+ }
+
+ return item->valuestring;
+}
+
+CJSON_PUBLIC(double) cJSON_GetNumberValue(const cJSON * const item)
+{
+ if (!cJSON_IsNumber(item))
+ {
+ return (double) NAN;
+ }
+
+ return item->valuedouble;
+}
+
+/* This is a safeguard to prevent copy-pasters from using incompatible C and header files */
+#if (CJSON_VERSION_MAJOR != 1) || (CJSON_VERSION_MINOR != 7) || (CJSON_VERSION_PATCH != 19)
+ #error cJSON.h and cJSON.c have different versions. Make sure that both have the same.
+#endif
+
+CJSON_PUBLIC(const char*) cJSON_Version(void)
+{
+ static char version[15];
+ sprintf(version, "%i.%i.%i", CJSON_VERSION_MAJOR, CJSON_VERSION_MINOR, CJSON_VERSION_PATCH);
+
+ return version;
+}
+
+/* Case insensitive string comparison, doesn't consider two NULL pointers equal though */
+static int case_insensitive_strcmp(const unsigned char *string1, const unsigned char *string2)
+{
+ if ((string1 == NULL) || (string2 == NULL))
+ {
+ return 1;
+ }
+
+ if (string1 == string2)
+ {
+ return 0;
+ }
+
+ for(; tolower(*string1) == tolower(*string2); (void)string1++, string2++)
+ {
+ if (*string1 == '\0')
+ {
+ return 0;
+ }
+ }
+
+ return tolower(*string1) - tolower(*string2);
+}
+
+typedef struct internal_hooks
+{
+ void *(CJSON_CDECL *allocate)(size_t size);
+ void (CJSON_CDECL *deallocate)(void *pointer);
+ void *(CJSON_CDECL *reallocate)(void *pointer, size_t size);
+} internal_hooks;
+
+#if defined(_MSC_VER)
+/* work around MSVC error C2322: '...' address of dllimport '...' is not static */
+static void * CJSON_CDECL internal_malloc(size_t size)
+{
+ return malloc(size);
+}
+static void CJSON_CDECL internal_free(void *pointer)
+{
+ free(pointer);
+}
+static void * CJSON_CDECL internal_realloc(void *pointer, size_t size)
+{
+ return realloc(pointer, size);
+}
+#else
+#define internal_malloc malloc
+#define internal_free free
+#define internal_realloc realloc
+#endif
+
+/* strlen of character literals resolved at compile time */
+#define static_strlen(string_literal) (sizeof(string_literal) - sizeof(""))
+
+static internal_hooks global_hooks = { internal_malloc, internal_free, internal_realloc };
+
+static unsigned char* cJSON_strdup(const unsigned char* string, const internal_hooks * const hooks)
+{
+ size_t length = 0;
+ unsigned char *copy = NULL;
+
+ if (string == NULL)
+ {
+ return NULL;
+ }
+
+ length = strlen((const char*)string) + sizeof("");
+ copy = (unsigned char*)hooks->allocate(length);
+ if (copy == NULL)
+ {
+ return NULL;
+ }
+ memcpy(copy, string, length);
+
+ return copy;
+}
+
+CJSON_PUBLIC(void) cJSON_InitHooks(cJSON_Hooks* hooks)
+{
+ if (hooks == NULL)
+ {
+ /* Reset hooks */
+ global_hooks.allocate = malloc;
+ global_hooks.deallocate = free;
+ global_hooks.reallocate = realloc;
+ return;
+ }
+
+ global_hooks.allocate = malloc;
+ if (hooks->malloc_fn != NULL)
+ {
+ global_hooks.allocate = hooks->malloc_fn;
+ }
+
+ global_hooks.deallocate = free;
+ if (hooks->free_fn != NULL)
+ {
+ global_hooks.deallocate = hooks->free_fn;
+ }
+
+ /* use realloc only if both free and malloc are used */
+ global_hooks.reallocate = NULL;
+ if ((global_hooks.allocate == malloc) && (global_hooks.deallocate == free))
+ {
+ global_hooks.reallocate = realloc;
+ }
+}
+
+/* Internal constructor. */
+static cJSON *cJSON_New_Item(const internal_hooks * const hooks)
+{
+ cJSON* node = (cJSON*)hooks->allocate(sizeof(cJSON));
+ if (node)
+ {
+ memset(node, '\0', sizeof(cJSON));
+ }
+
+ return node;
+}
+
+/* Delete a cJSON structure. */
+CJSON_PUBLIC(void) cJSON_Delete(cJSON *item)
+{
+ cJSON *next = NULL;
+ while (item != NULL)
+ {
+ next = item->next;
+ if (!(item->type & cJSON_IsReference) && (item->child != NULL))
+ {
+ cJSON_Delete(item->child);
+ }
+ if (!(item->type & cJSON_IsReference) && (item->valuestring != NULL))
+ {
+ global_hooks.deallocate(item->valuestring);
+ item->valuestring = NULL;
+ }
+ if (!(item->type & cJSON_StringIsConst) && (item->string != NULL))
+ {
+ global_hooks.deallocate(item->string);
+ item->string = NULL;
+ }
+ global_hooks.deallocate(item);
+ item = next;
+ }
+}
+
+/* get the decimal point character of the current locale */
+static unsigned char get_decimal_point(void)
+{
+#ifdef ENABLE_LOCALES
+ struct lconv *lconv = localeconv();
+ return (unsigned char) lconv->decimal_point[0];
+#else
+ return '.';
+#endif
+}
+
+typedef struct
+{
+ const unsigned char *content;
+ size_t length;
+ size_t offset;
+ size_t depth; /* How deeply nested (in arrays/objects) is the input at the current offset. */
+ internal_hooks hooks;
+} parse_buffer;
+
+/* check if the given size is left to read in a given parse buffer (starting with 1) */
+#define can_read(buffer, size) ((buffer != NULL) && (((buffer)->offset + size) <= (buffer)->length))
+/* check if the buffer can be accessed at the given index (starting with 0) */
+#define can_access_at_index(buffer, index) ((buffer != NULL) && (((buffer)->offset + index) < (buffer)->length))
+#define cannot_access_at_index(buffer, index) (!can_access_at_index(buffer, index))
+/* get a pointer to the buffer at the position */
+#define buffer_at_offset(buffer) ((buffer)->content + (buffer)->offset)
+
+/* Parse the input text to generate a number, and populate the result into item. */
+static cJSON_bool parse_number(cJSON * const item, parse_buffer * const input_buffer)
+{
+ double number = 0;
+ unsigned char *after_end = NULL;
+ unsigned char *number_c_string;
+ unsigned char decimal_point = get_decimal_point();
+ size_t i = 0;
+ size_t number_string_length = 0;
+ cJSON_bool has_decimal_point = false;
+
+ if ((input_buffer == NULL) || (input_buffer->content == NULL))
+ {
+ return false;
+ }
+
+ /* copy the number into a temporary buffer and replace '.' with the decimal point
+ * of the current locale (for strtod)
+ * This also takes care of '\0' not necessarily being available for marking the end of the input */
+ for (i = 0; can_access_at_index(input_buffer, i); i++)
+ {
+ switch (buffer_at_offset(input_buffer)[i])
+ {
+ case '0':
+ case '1':
+ case '2':
+ case '3':
+ case '4':
+ case '5':
+ case '6':
+ case '7':
+ case '8':
+ case '9':
+ case '+':
+ case '-':
+ case 'e':
+ case 'E':
+ number_string_length++;
+ break;
+
+ case '.':
+ number_string_length++;
+ has_decimal_point = true;
+ break;
+
+ default:
+ goto loop_end;
+ }
+ }
+loop_end:
+ /* malloc for temporary buffer, add 1 for '\0' */
+ number_c_string = (unsigned char *) input_buffer->hooks.allocate(number_string_length + 1);
+ if (number_c_string == NULL)
+ {
+ return false; /* allocation failure */
+ }
+
+ memcpy(number_c_string, buffer_at_offset(input_buffer), number_string_length);
+ number_c_string[number_string_length] = '\0';
+
+ if (has_decimal_point)
+ {
+ for (i = 0; i < number_string_length; i++)
+ {
+ if (number_c_string[i] == '.')
+ {
+ /* replace '.' with the decimal point of the current locale (for strtod) */
+ number_c_string[i] = decimal_point;
+ }
+ }
+ }
+
+ number = strtod((const char*)number_c_string, (char**)&after_end);
+ if (number_c_string == after_end)
+ {
+ /* free the temporary buffer */
+ input_buffer->hooks.deallocate(number_c_string);
+ return false; /* parse_error */
+ }
+
+ item->valuedouble = number;
+
+ /* use saturation in case of overflow */
+ if (number >= INT_MAX)
+ {
+ item->valueint = INT_MAX;
+ }
+ else if (number <= (double)INT_MIN)
+ {
+ item->valueint = INT_MIN;
+ }
+ else
+ {
+ item->valueint = (int)number;
+ }
+
+ item->type = cJSON_Number;
+
+ input_buffer->offset += (size_t)(after_end - number_c_string);
+ /* free the temporary buffer */
+ input_buffer->hooks.deallocate(number_c_string);
+ return true;
+}
+
+/* don't ask me, but the original cJSON_SetNumberValue returns an integer or double */
+CJSON_PUBLIC(double) cJSON_SetNumberHelper(cJSON *object, double number)
+{
+ if (object == NULL)
+ {
+ return (double)NAN;
+ }
+
+ if (number >= INT_MAX)
+ {
+ object->valueint = INT_MAX;
+ }
+ else if (number <= (double)INT_MIN)
+ {
+ object->valueint = INT_MIN;
+ }
+ else
+ {
+ object->valueint = (int)number;
+ }
+
+ return object->valuedouble = number;
+}
+
+/* Note: when passing a NULL valuestring, cJSON_SetValuestring treats this as an error and return NULL */
+CJSON_PUBLIC(char*) cJSON_SetValuestring(cJSON *object, const char *valuestring)
+{
+ char *copy = NULL;
+ size_t v1_len;
+ size_t v2_len;
+ /* if object's type is not cJSON_String or is cJSON_IsReference, it should not set valuestring */
+ if ((object == NULL) || !(object->type & cJSON_String) || (object->type & cJSON_IsReference))
+ {
+ return NULL;
+ }
+ /* return NULL if the object is corrupted or valuestring is NULL */
+ if (object->valuestring == NULL || valuestring == NULL)
+ {
+ return NULL;
+ }
+
+ v1_len = strlen(valuestring);
+ v2_len = strlen(object->valuestring);
+
+ if (v1_len <= v2_len)
+ {
+ /* strcpy does not handle overlapping string: [X1, X2] [Y1, Y2] => X2 < Y1 or Y2 < X1 */
+ if (!( valuestring + v1_len < object->valuestring || object->valuestring + v2_len < valuestring ))
+ {
+ return NULL;
+ }
+ strcpy(object->valuestring, valuestring);
+ return object->valuestring;
+ }
+ copy = (char*) cJSON_strdup((const unsigned char*)valuestring, &global_hooks);
+ if (copy == NULL)
+ {
+ return NULL;
+ }
+ if (object->valuestring != NULL)
+ {
+ cJSON_free(object->valuestring);
+ }
+ object->valuestring = copy;
+
+ return copy;
+}
+
+typedef struct
+{
+ unsigned char *buffer;
+ size_t length;
+ size_t offset;
+ size_t depth; /* current nesting depth (for formatted printing) */
+ cJSON_bool noalloc;
+ cJSON_bool format; /* is this print a formatted print */
+ internal_hooks hooks;
+} printbuffer;
+
+/* realloc printbuffer if necessary to have at least "needed" bytes more */
+static unsigned char* ensure(printbuffer * const p, size_t needed)
+{
+ unsigned char *newbuffer = NULL;
+ size_t newsize = 0;
+
+ if ((p == NULL) || (p->buffer == NULL))
+ {
+ return NULL;
+ }
+
+ if ((p->length > 0) && (p->offset >= p->length))
+ {
+ /* make sure that offset is valid */
+ return NULL;
+ }
+
+ if (needed > INT_MAX)
+ {
+ /* sizes bigger than INT_MAX are currently not supported */
+ return NULL;
+ }
+
+ needed += p->offset + 1;
+ if (needed <= p->length)
+ {
+ return p->buffer + p->offset;
+ }
+
+ if (p->noalloc) {
+ return NULL;
+ }
+
+ /* calculate new buffer size */
+ if (needed > (INT_MAX / 2))
+ {
+ /* overflow of int, use INT_MAX if possible */
+ if (needed <= INT_MAX)
+ {
+ newsize = INT_MAX;
+ }
+ else
+ {
+ return NULL;
+ }
+ }
+ else
+ {
+ newsize = needed * 2;
+ }
+
+ if (p->hooks.reallocate != NULL)
+ {
+ /* reallocate with realloc if available */
+ newbuffer = (unsigned char*)p->hooks.reallocate(p->buffer, newsize);
+ if (newbuffer == NULL)
+ {
+ p->hooks.deallocate(p->buffer);
+ p->length = 0;
+ p->buffer = NULL;
+
+ return NULL;
+ }
+ }
+ else
+ {
+ /* otherwise reallocate manually */
+ newbuffer = (unsigned char*)p->hooks.allocate(newsize);
+ if (!newbuffer)
+ {
+ p->hooks.deallocate(p->buffer);
+ p->length = 0;
+ p->buffer = NULL;
+
+ return NULL;
+ }
+
+ memcpy(newbuffer, p->buffer, p->offset + 1);
+ p->hooks.deallocate(p->buffer);
+ }
+ p->length = newsize;
+ p->buffer = newbuffer;
+
+ return newbuffer + p->offset;
+}
+
+/* calculate the new length of the string in a printbuffer and update the offset */
+static void update_offset(printbuffer * const buffer)
+{
+ const unsigned char *buffer_pointer = NULL;
+ if ((buffer == NULL) || (buffer->buffer == NULL))
+ {
+ return;
+ }
+ buffer_pointer = buffer->buffer + buffer->offset;
+
+ buffer->offset += strlen((const char*)buffer_pointer);
+}
+
+/* securely comparison of floating-point variables */
+static cJSON_bool compare_double(double a, double b)
+{
+ double maxVal = fabs(a) > fabs(b) ? fabs(a) : fabs(b);
+ return (fabs(a - b) <= maxVal * DBL_EPSILON);
+}
+
+/* Render the number nicely from the given item into a string. */
+static cJSON_bool print_number(const cJSON * const item, printbuffer * const output_buffer)
+{
+ unsigned char *output_pointer = NULL;
+ double d = item->valuedouble;
+ int length = 0;
+ size_t i = 0;
+ unsigned char number_buffer[26] = {0}; /* temporary buffer to print the number into */
+ unsigned char decimal_point = get_decimal_point();
+ double test = 0.0;
+
+ if (output_buffer == NULL)
+ {
+ return false;
+ }
+
+ /* This checks for NaN and Infinity */
+ if (isnan(d) || isinf(d))
+ {
+ length = sprintf((char*)number_buffer, "null");
+ }
+ else if(d == (double)item->valueint)
+ {
+ length = sprintf((char*)number_buffer, "%d", item->valueint);
+ }
+ else
+ {
+ /* Try 15 decimal places of precision to avoid nonsignificant nonzero digits */
+ length = sprintf((char*)number_buffer, "%1.15g", d);
+
+ /* Check whether the original double can be recovered */
+ if ((sscanf((char*)number_buffer, "%lg", &test) != 1) || !compare_double((double)test, d))
+ {
+ /* If not, print with 17 decimal places of precision */
+ length = sprintf((char*)number_buffer, "%1.17g", d);
+ }
+ }
+
+ /* sprintf failed or buffer overrun occurred */
+ if ((length < 0) || (length > (int)(sizeof(number_buffer) - 1)))
+ {
+ return false;
+ }
+
+ /* reserve appropriate space in the output */
+ output_pointer = ensure(output_buffer, (size_t)length + sizeof(""));
+ if (output_pointer == NULL)
+ {
+ return false;
+ }
+
+ /* copy the printed number to the output and replace locale
+ * dependent decimal point with '.' */
+ for (i = 0; i < ((size_t)length); i++)
+ {
+ if (number_buffer[i] == decimal_point)
+ {
+ output_pointer[i] = '.';
+ continue;
+ }
+
+ output_pointer[i] = number_buffer[i];
+ }
+ output_pointer[i] = '\0';
+
+ output_buffer->offset += (size_t)length;
+
+ return true;
+}
+
+/* parse 4 digit hexadecimal number */
+static unsigned parse_hex4(const unsigned char * const input)
+{
+ unsigned int h = 0;
+ size_t i = 0;
+
+ for (i = 0; i < 4; i++)
+ {
+ /* parse digit */
+ if ((input[i] >= '0') && (input[i] <= '9'))
+ {
+ h += (unsigned int) input[i] - '0';
+ }
+ else if ((input[i] >= 'A') && (input[i] <= 'F'))
+ {
+ h += (unsigned int) 10 + input[i] - 'A';
+ }
+ else if ((input[i] >= 'a') && (input[i] <= 'f'))
+ {
+ h += (unsigned int) 10 + input[i] - 'a';
+ }
+ else /* invalid */
+ {
+ return 0;
+ }
+
+ if (i < 3)
+ {
+ /* shift left to make place for the next nibble */
+ h = h << 4;
+ }
+ }
+
+ return h;
+}
+
+/* converts a UTF-16 literal to UTF-8
+ * A literal can be one or two sequences of the form \uXXXX */
+static unsigned char utf16_literal_to_utf8(const unsigned char * const input_pointer, const unsigned char * const input_end, unsigned char **output_pointer)
+{
+ long unsigned int codepoint = 0;
+ unsigned int first_code = 0;
+ const unsigned char *first_sequence = input_pointer;
+ unsigned char utf8_length = 0;
+ unsigned char utf8_position = 0;
+ unsigned char sequence_length = 0;
+ unsigned char first_byte_mark = 0;
+
+ if ((input_end - first_sequence) < 6)
+ {
+ /* input ends unexpectedly */
+ goto fail;
+ }
+
+ /* get the first utf16 sequence */
+ first_code = parse_hex4(first_sequence + 2);
+
+ /* check that the code is valid */
+ if (((first_code >= 0xDC00) && (first_code <= 0xDFFF)))
+ {
+ goto fail;
+ }
+
+ /* UTF16 surrogate pair */
+ if ((first_code >= 0xD800) && (first_code <= 0xDBFF))
+ {
+ const unsigned char *second_sequence = first_sequence + 6;
+ unsigned int second_code = 0;
+ sequence_length = 12; /* \uXXXX\uXXXX */
+
+ if ((input_end - second_sequence) < 6)
+ {
+ /* input ends unexpectedly */
+ goto fail;
+ }
+
+ if ((second_sequence[0] != '\\') || (second_sequence[1] != 'u'))
+ {
+ /* missing second half of the surrogate pair */
+ goto fail;
+ }
+
+ /* get the second utf16 sequence */
+ second_code = parse_hex4(second_sequence + 2);
+ /* check that the code is valid */
+ if ((second_code < 0xDC00) || (second_code > 0xDFFF))
+ {
+ /* invalid second half of the surrogate pair */
+ goto fail;
+ }
+
+
+ /* calculate the unicode codepoint from the surrogate pair */
+ codepoint = 0x10000 + (((first_code & 0x3FF) << 10) | (second_code & 0x3FF));
+ }
+ else
+ {
+ sequence_length = 6; /* \uXXXX */
+ codepoint = first_code;
+ }
+
+ /* encode as UTF-8
+ * takes at maximum 4 bytes to encode:
+ * 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx */
+ if (codepoint < 0x80)
+ {
+ /* normal ascii, encoding 0xxxxxxx */
+ utf8_length = 1;
+ }
+ else if (codepoint < 0x800)
+ {
+ /* two bytes, encoding 110xxxxx 10xxxxxx */
+ utf8_length = 2;
+ first_byte_mark = 0xC0; /* 11000000 */
+ }
+ else if (codepoint < 0x10000)
+ {
+ /* three bytes, encoding 1110xxxx 10xxxxxx 10xxxxxx */
+ utf8_length = 3;
+ first_byte_mark = 0xE0; /* 11100000 */
+ }
+ else if (codepoint <= 0x10FFFF)
+ {
+ /* four bytes, encoding 1110xxxx 10xxxxxx 10xxxxxx 10xxxxxx */
+ utf8_length = 4;
+ first_byte_mark = 0xF0; /* 11110000 */
+ }
+ else
+ {
+ /* invalid unicode codepoint */
+ goto fail;
+ }
+
+ /* encode as utf8 */
+ for (utf8_position = (unsigned char)(utf8_length - 1); utf8_position > 0; utf8_position--)
+ {
+ /* 10xxxxxx */
+ (*output_pointer)[utf8_position] = (unsigned char)((codepoint | 0x80) & 0xBF);
+ codepoint >>= 6;
+ }
+ /* encode first byte */
+ if (utf8_length > 1)
+ {
+ (*output_pointer)[0] = (unsigned char)((codepoint | first_byte_mark) & 0xFF);
+ }
+ else
+ {
+ (*output_pointer)[0] = (unsigned char)(codepoint & 0x7F);
+ }
+
+ *output_pointer += utf8_length;
+
+ return sequence_length;
+
+fail:
+ return 0;
+}
+
+/* Parse the input text into an unescaped cinput, and populate item. */
+static cJSON_bool parse_string(cJSON * const item, parse_buffer * const input_buffer)
+{
+ const unsigned char *input_pointer = buffer_at_offset(input_buffer) + 1;
+ const unsigned char *input_end = buffer_at_offset(input_buffer) + 1;
+ unsigned char *output_pointer = NULL;
+ unsigned char *output = NULL;
+
+ /* not a string */
+ if (buffer_at_offset(input_buffer)[0] != '\"')
+ {
+ goto fail;
+ }
+
+ {
+ /* calculate approximate size of the output (overestimate) */
+ size_t allocation_length = 0;
+ size_t skipped_bytes = 0;
+ while (((size_t)(input_end - input_buffer->content) < input_buffer->length) && (*input_end != '\"'))
+ {
+ /* is escape sequence */
+ if (input_end[0] == '\\')
+ {
+ if ((size_t)(input_end + 1 - input_buffer->content) >= input_buffer->length)
+ {
+ /* prevent buffer overflow when last input character is a backslash */
+ goto fail;
+ }
+ skipped_bytes++;
+ input_end++;
+ }
+ input_end++;
+ }
+ if (((size_t)(input_end - input_buffer->content) >= input_buffer->length) || (*input_end != '\"'))
+ {
+ goto fail; /* string ended unexpectedly */
+ }
+
+ /* This is at most how much we need for the output */
+ allocation_length = (size_t) (input_end - buffer_at_offset(input_buffer)) - skipped_bytes;
+ output = (unsigned char*)input_buffer->hooks.allocate(allocation_length + sizeof(""));
+ if (output == NULL)
+ {
+ goto fail; /* allocation failure */
+ }
+ }
+
+ output_pointer = output;
+ /* loop through the string literal */
+ while (input_pointer < input_end)
+ {
+ if (*input_pointer != '\\')
+ {
+ *output_pointer++ = *input_pointer++;
+ }
+ /* escape sequence */
+ else
+ {
+ unsigned char sequence_length = 2;
+ if ((input_end - input_pointer) < 1)
+ {
+ goto fail;
+ }
+
+ switch (input_pointer[1])
+ {
+ case 'b':
+ *output_pointer++ = '\b';
+ break;
+ case 'f':
+ *output_pointer++ = '\f';
+ break;
+ case 'n':
+ *output_pointer++ = '\n';
+ break;
+ case 'r':
+ *output_pointer++ = '\r';
+ break;
+ case 't':
+ *output_pointer++ = '\t';
+ break;
+ case '\"':
+ case '\\':
+ case '/':
+ *output_pointer++ = input_pointer[1];
+ break;
+
+ /* UTF-16 literal */
+ case 'u':
+ sequence_length = utf16_literal_to_utf8(input_pointer, input_end, &output_pointer);
+ if (sequence_length == 0)
+ {
+ /* failed to convert UTF16-literal to UTF-8 */
+ goto fail;
+ }
+ break;
+
+ default:
+ goto fail;
+ }
+ input_pointer += sequence_length;
+ }
+ }
+
+ /* zero terminate the output */
+ *output_pointer = '\0';
+
+ item->type = cJSON_String;
+ item->valuestring = (char*)output;
+
+ input_buffer->offset = (size_t) (input_end - input_buffer->content);
+ input_buffer->offset++;
+
+ return true;
+
+fail:
+ if (output != NULL)
+ {
+ input_buffer->hooks.deallocate(output);
+ output = NULL;
+ }
+
+ if (input_pointer != NULL)
+ {
+ input_buffer->offset = (size_t)(input_pointer - input_buffer->content);
+ }
+
+ return false;
+}
+
+/* Render the cstring provided to an escaped version that can be printed. */
+static cJSON_bool print_string_ptr(const unsigned char * const input, printbuffer * const output_buffer)
+{
+ const unsigned char *input_pointer = NULL;
+ unsigned char *output = NULL;
+ unsigned char *output_pointer = NULL;
+ size_t output_length = 0;
+ /* numbers of additional characters needed for escaping */
+ size_t escape_characters = 0;
+
+ if (output_buffer == NULL)
+ {
+ return false;
+ }
+
+ /* empty string */
+ if (input == NULL)
+ {
+ output = ensure(output_buffer, sizeof("\"\""));
+ if (output == NULL)
+ {
+ return false;
+ }
+ strcpy((char*)output, "\"\"");
+
+ return true;
+ }
+
+ /* set "flag" to 1 if something needs to be escaped */
+ for (input_pointer = input; *input_pointer; input_pointer++)
+ {
+ switch (*input_pointer)
+ {
+ case '\"':
+ case '\\':
+ case '\b':
+ case '\f':
+ case '\n':
+ case '\r':
+ case '\t':
+ /* one character escape sequence */
+ escape_characters++;
+ break;
+ default:
+ if (*input_pointer < 32)
+ {
+ /* UTF-16 escape sequence uXXXX */
+ escape_characters += 5;
+ }
+ break;
+ }
+ }
+ output_length = (size_t)(input_pointer - input) + escape_characters;
+
+ output = ensure(output_buffer, output_length + sizeof("\"\""));
+ if (output == NULL)
+ {
+ return false;
+ }
+
+ /* no characters have to be escaped */
+ if (escape_characters == 0)
+ {
+ output[0] = '\"';
+ memcpy(output + 1, input, output_length);
+ output[output_length + 1] = '\"';
+ output[output_length + 2] = '\0';
+
+ return true;
+ }
+
+ output[0] = '\"';
+ output_pointer = output + 1;
+ /* copy the string */
+ for (input_pointer = input; *input_pointer != '\0'; (void)input_pointer++, output_pointer++)
+ {
+ if ((*input_pointer > 31) && (*input_pointer != '\"') && (*input_pointer != '\\'))
+ {
+ /* normal character, copy */
+ *output_pointer = *input_pointer;
+ }
+ else
+ {
+ /* character needs to be escaped */
+ *output_pointer++ = '\\';
+ switch (*input_pointer)
+ {
+ case '\\':
+ *output_pointer = '\\';
+ break;
+ case '\"':
+ *output_pointer = '\"';
+ break;
+ case '\b':
+ *output_pointer = 'b';
+ break;
+ case '\f':
+ *output_pointer = 'f';
+ break;
+ case '\n':
+ *output_pointer = 'n';
+ break;
+ case '\r':
+ *output_pointer = 'r';
+ break;
+ case '\t':
+ *output_pointer = 't';
+ break;
+ default:
+ /* escape and print as unicode codepoint */
+ sprintf((char*)output_pointer, "u%04x", *input_pointer);
+ output_pointer += 4;
+ break;
+ }
+ }
+ }
+ output[output_length + 1] = '\"';
+ output[output_length + 2] = '\0';
+
+ return true;
+}
+
+/* Invoke print_string_ptr (which is useful) on an item. */
+static cJSON_bool print_string(const cJSON * const item, printbuffer * const p)
+{
+ return print_string_ptr((unsigned char*)item->valuestring, p);
+}
+
+/* Predeclare these prototypes. */
+static cJSON_bool parse_value(cJSON * const item, parse_buffer * const input_buffer);
+static cJSON_bool print_value(const cJSON * const item, printbuffer * const output_buffer);
+static cJSON_bool parse_array(cJSON * const item, parse_buffer * const input_buffer);
+static cJSON_bool print_array(const cJSON * const item, printbuffer * const output_buffer);
+static cJSON_bool parse_object(cJSON * const item, parse_buffer * const input_buffer);
+static cJSON_bool print_object(const cJSON * const item, printbuffer * const output_buffer);
+
+/* Utility to jump whitespace and cr/lf */
+static parse_buffer *buffer_skip_whitespace(parse_buffer * const buffer)
+{
+ if ((buffer == NULL) || (buffer->content == NULL))
+ {
+ return NULL;
+ }
+
+ if (cannot_access_at_index(buffer, 0))
+ {
+ return buffer;
+ }
+
+ while (can_access_at_index(buffer, 0) && (buffer_at_offset(buffer)[0] <= 32))
+ {
+ buffer->offset++;
+ }
+
+ if (buffer->offset == buffer->length)
+ {
+ buffer->offset--;
+ }
+
+ return buffer;
+}
+
+/* skip the UTF-8 BOM (byte order mark) if it is at the beginning of a buffer */
+static parse_buffer *skip_utf8_bom(parse_buffer * const buffer)
+{
+ if ((buffer == NULL) || (buffer->content == NULL) || (buffer->offset != 0))
+ {
+ return NULL;
+ }
+
+ if (can_access_at_index(buffer, 4) && (strncmp((const char*)buffer_at_offset(buffer), "\xEF\xBB\xBF", 3) == 0))
+ {
+ buffer->offset += 3;
+ }
+
+ return buffer;
+}
+
+CJSON_PUBLIC(cJSON *) cJSON_ParseWithOpts(const char *value, const char **return_parse_end, cJSON_bool require_null_terminated)
+{
+ size_t buffer_length;
+
+ if (NULL == value)
+ {
+ return NULL;
+ }
+
+ /* Adding null character size due to require_null_terminated. */
+ buffer_length = strlen(value) + sizeof("");
+
+ return cJSON_ParseWithLengthOpts(value, buffer_length, return_parse_end, require_null_terminated);
+}
+
+/* Parse an object - create a new root, and populate. */
+CJSON_PUBLIC(cJSON *) cJSON_ParseWithLengthOpts(const char *value, size_t buffer_length, const char **return_parse_end, cJSON_bool require_null_terminated)
+{
+ parse_buffer buffer = { 0, 0, 0, 0, { 0, 0, 0 } };
+ cJSON *item = NULL;
+
+ /* reset error position */
+ global_error.json = NULL;
+ global_error.position = 0;
+
+ if (value == NULL || 0 == buffer_length)
+ {
+ goto fail;
+ }
+
+ buffer.content = (const unsigned char*)value;
+ buffer.length = buffer_length;
+ buffer.offset = 0;
+ buffer.hooks = global_hooks;
+
+ item = cJSON_New_Item(&global_hooks);
+ if (item == NULL) /* memory fail */
+ {
+ goto fail;
+ }
+
+ if (!parse_value(item, buffer_skip_whitespace(skip_utf8_bom(&buffer))))
+ {
+ /* parse failure. ep is set. */
+ goto fail;
+ }
+
+ /* if we require null-terminated JSON without appended garbage, skip and then check for a null terminator */
+ if (require_null_terminated)
+ {
+ buffer_skip_whitespace(&buffer);
+ if ((buffer.offset >= buffer.length) || buffer_at_offset(&buffer)[0] != '\0')
+ {
+ goto fail;
+ }
+ }
+ if (return_parse_end)
+ {
+ *return_parse_end = (const char*)buffer_at_offset(&buffer);
+ }
+
+ return item;
+
+fail:
+ if (item != NULL)
+ {
+ cJSON_Delete(item);
+ }
+
+ if (value != NULL)
+ {
+ error local_error;
+ local_error.json = (const unsigned char*)value;
+ local_error.position = 0;
+
+ if (buffer.offset < buffer.length)
+ {
+ local_error.position = buffer.offset;
+ }
+ else if (buffer.length > 0)
+ {
+ local_error.position = buffer.length - 1;
+ }
+
+ if (return_parse_end != NULL)
+ {
+ *return_parse_end = (const char*)local_error.json + local_error.position;
+ }
+
+ global_error = local_error;
+ }
+
+ return NULL;
+}
+
+/* Default options for cJSON_Parse */
+CJSON_PUBLIC(cJSON *) cJSON_Parse(const char *value)
+{
+ return cJSON_ParseWithOpts(value, 0, 0);
+}
+
+CJSON_PUBLIC(cJSON *) cJSON_ParseWithLength(const char *value, size_t buffer_length)
+{
+ return cJSON_ParseWithLengthOpts(value, buffer_length, 0, 0);
+}
+
+#define cjson_min(a, b) (((a) < (b)) ? (a) : (b))
+
+static unsigned char *print(const cJSON * const item, cJSON_bool format, const internal_hooks * const hooks)
+{
+ static const size_t default_buffer_size = 256;
+ printbuffer buffer[1];
+ unsigned char *printed = NULL;
+
+ memset(buffer, 0, sizeof(buffer));
+
+ /* create buffer */
+ buffer->buffer = (unsigned char*) hooks->allocate(default_buffer_size);
+ buffer->length = default_buffer_size;
+ buffer->format = format;
+ buffer->hooks = *hooks;
+ if (buffer->buffer == NULL)
+ {
+ goto fail;
+ }
+
+ /* print the value */
+ if (!print_value(item, buffer))
+ {
+ goto fail;
+ }
+ update_offset(buffer);
+
+ /* check if reallocate is available */
+ if (hooks->reallocate != NULL)
+ {
+ printed = (unsigned char*) hooks->reallocate(buffer->buffer, buffer->offset + 1);
+ if (printed == NULL) {
+ goto fail;
+ }
+ buffer->buffer = NULL;
+ }
+ else /* otherwise copy the JSON over to a new buffer */
+ {
+ printed = (unsigned char*) hooks->allocate(buffer->offset + 1);
+ if (printed == NULL)
+ {
+ goto fail;
+ }
+ memcpy(printed, buffer->buffer, cjson_min(buffer->length, buffer->offset + 1));
+ printed[buffer->offset] = '\0'; /* just to be sure */
+
+ /* free the buffer */
+ hooks->deallocate(buffer->buffer);
+ buffer->buffer = NULL;
+ }
+
+ return printed;
+
+fail:
+ if (buffer->buffer != NULL)
+ {
+ hooks->deallocate(buffer->buffer);
+ buffer->buffer = NULL;
+ }
+
+ if (printed != NULL)
+ {
+ hooks->deallocate(printed);
+ printed = NULL;
+ }
+
+ return NULL;
+}
+
+/* Render a cJSON item/entity/structure to text. */
+CJSON_PUBLIC(char *) cJSON_Print(const cJSON *item)
+{
+ return (char*)print(item, true, &global_hooks);
+}
+
+CJSON_PUBLIC(char *) cJSON_PrintUnformatted(const cJSON *item)
+{
+ return (char*)print(item, false, &global_hooks);
+}
+
+CJSON_PUBLIC(char *) cJSON_PrintBuffered(const cJSON *item, int prebuffer, cJSON_bool fmt)
+{
+ printbuffer p = { 0, 0, 0, 0, 0, 0, { 0, 0, 0 } };
+
+ if (prebuffer < 0)
+ {
+ return NULL;
+ }
+
+ p.buffer = (unsigned char*)global_hooks.allocate((size_t)prebuffer);
+ if (!p.buffer)
+ {
+ return NULL;
+ }
+
+ p.length = (size_t)prebuffer;
+ p.offset = 0;
+ p.noalloc = false;
+ p.format = fmt;
+ p.hooks = global_hooks;
+
+ if (!print_value(item, &p))
+ {
+ global_hooks.deallocate(p.buffer);
+ p.buffer = NULL;
+ return NULL;
+ }
+
+ return (char*)p.buffer;
+}
+
+CJSON_PUBLIC(cJSON_bool) cJSON_PrintPreallocated(cJSON *item, char *buffer, const int length, const cJSON_bool format)
+{
+ printbuffer p = { 0, 0, 0, 0, 0, 0, { 0, 0, 0 } };
+
+ if ((length < 0) || (buffer == NULL))
+ {
+ return false;
+ }
+
+ p.buffer = (unsigned char*)buffer;
+ p.length = (size_t)length;
+ p.offset = 0;
+ p.noalloc = true;
+ p.format = format;
+ p.hooks = global_hooks;
+
+ return print_value(item, &p);
+}
+
+/* Parser core - when encountering text, process appropriately. */
+static cJSON_bool parse_value(cJSON * const item, parse_buffer * const input_buffer)
+{
+ if ((input_buffer == NULL) || (input_buffer->content == NULL))
+ {
+ return false; /* no input */
+ }
+
+ /* parse the different types of values */
+ /* null */
+ if (can_read(input_buffer, 4) && (strncmp((const char*)buffer_at_offset(input_buffer), "null", 4) == 0))
+ {
+ item->type = cJSON_NULL;
+ input_buffer->offset += 4;
+ return true;
+ }
+ /* false */
+ if (can_read(input_buffer, 5) && (strncmp((const char*)buffer_at_offset(input_buffer), "false", 5) == 0))
+ {
+ item->type = cJSON_False;
+ input_buffer->offset += 5;
+ return true;
+ }
+ /* true */
+ if (can_read(input_buffer, 4) && (strncmp((const char*)buffer_at_offset(input_buffer), "true", 4) == 0))
+ {
+ item->type = cJSON_True;
+ item->valueint = 1;
+ input_buffer->offset += 4;
+ return true;
+ }
+ /* string */
+ if (can_access_at_index(input_buffer, 0) && (buffer_at_offset(input_buffer)[0] == '\"'))
+ {
+ return parse_string(item, input_buffer);
+ }
+ /* number */
+ if (can_access_at_index(input_buffer, 0) && ((buffer_at_offset(input_buffer)[0] == '-') || ((buffer_at_offset(input_buffer)[0] >= '0') && (buffer_at_offset(input_buffer)[0] <= '9'))))
+ {
+ return parse_number(item, input_buffer);
+ }
+ /* array */
+ if (can_access_at_index(input_buffer, 0) && (buffer_at_offset(input_buffer)[0] == '['))
+ {
+ return parse_array(item, input_buffer);
+ }
+ /* object */
+ if (can_access_at_index(input_buffer, 0) && (buffer_at_offset(input_buffer)[0] == '{'))
+ {
+ return parse_object(item, input_buffer);
+ }
+
+ return false;
+}
+
+/* Render a value to text. */
+static cJSON_bool print_value(const cJSON * const item, printbuffer * const output_buffer)
+{
+ unsigned char *output = NULL;
+
+ if ((item == NULL) || (output_buffer == NULL))
+ {
+ return false;
+ }
+
+ switch ((item->type) & 0xFF)
+ {
+ case cJSON_NULL:
+ output = ensure(output_buffer, 5);
+ if (output == NULL)
+ {
+ return false;
+ }
+ strcpy((char*)output, "null");
+ return true;
+
+ case cJSON_False:
+ output = ensure(output_buffer, 6);
+ if (output == NULL)
+ {
+ return false;
+ }
+ strcpy((char*)output, "false");
+ return true;
+
+ case cJSON_True:
+ output = ensure(output_buffer, 5);
+ if (output == NULL)
+ {
+ return false;
+ }
+ strcpy((char*)output, "true");
+ return true;
+
+ case cJSON_Number:
+ return print_number(item, output_buffer);
+
+ case cJSON_Raw:
+ {
+ size_t raw_length = 0;
+ if (item->valuestring == NULL)
+ {
+ return false;
+ }
+
+ raw_length = strlen(item->valuestring) + sizeof("");
+ output = ensure(output_buffer, raw_length);
+ if (output == NULL)
+ {
+ return false;
+ }
+ memcpy(output, item->valuestring, raw_length);
+ return true;
+ }
+
+ case cJSON_String:
+ return print_string(item, output_buffer);
+
+ case cJSON_Array:
+ return print_array(item, output_buffer);
+
+ case cJSON_Object:
+ return print_object(item, output_buffer);
+
+ default:
+ return false;
+ }
+}
+
+/* Build an array from input text. */
+static cJSON_bool parse_array(cJSON * const item, parse_buffer * const input_buffer)
+{
+ cJSON *head = NULL; /* head of the linked list */
+ cJSON *current_item = NULL;
+
+ if (input_buffer->depth >= CJSON_NESTING_LIMIT)
+ {
+ return false; /* to deeply nested */
+ }
+ input_buffer->depth++;
+
+ if (buffer_at_offset(input_buffer)[0] != '[')
+ {
+ /* not an array */
+ goto fail;
+ }
+
+ input_buffer->offset++;
+ buffer_skip_whitespace(input_buffer);
+ if (can_access_at_index(input_buffer, 0) && (buffer_at_offset(input_buffer)[0] == ']'))
+ {
+ /* empty array */
+ goto success;
+ }
+
+ /* check if we skipped to the end of the buffer */
+ if (cannot_access_at_index(input_buffer, 0))
+ {
+ input_buffer->offset--;
+ goto fail;
+ }
+
+ /* step back to character in front of the first element */
+ input_buffer->offset--;
+ /* loop through the comma separated array elements */
+ do
+ {
+ /* allocate next item */
+ cJSON *new_item = cJSON_New_Item(&(input_buffer->hooks));
+ if (new_item == NULL)
+ {
+ goto fail; /* allocation failure */
+ }
+
+ /* attach next item to list */
+ if (head == NULL)
+ {
+ /* start the linked list */
+ current_item = head = new_item;
+ }
+ else
+ {
+ /* add to the end and advance */
+ current_item->next = new_item;
+ new_item->prev = current_item;
+ current_item = new_item;
+ }
+
+ /* parse next value */
+ input_buffer->offset++;
+ buffer_skip_whitespace(input_buffer);
+ if (!parse_value(current_item, input_buffer))
+ {
+ goto fail; /* failed to parse value */
+ }
+ buffer_skip_whitespace(input_buffer);
+ }
+ while (can_access_at_index(input_buffer, 0) && (buffer_at_offset(input_buffer)[0] == ','));
+
+ if (cannot_access_at_index(input_buffer, 0) || buffer_at_offset(input_buffer)[0] != ']')
+ {
+ goto fail; /* expected end of array */
+ }
+
+success:
+ input_buffer->depth--;
+
+ if (head != NULL) {
+ head->prev = current_item;
+ }
+
+ item->type = cJSON_Array;
+ item->child = head;
+
+ input_buffer->offset++;
+
+ return true;
+
+fail:
+ if (head != NULL)
+ {
+ cJSON_Delete(head);
+ }
+
+ return false;
+}
+
+/* Render an array to text */
+static cJSON_bool print_array(const cJSON * const item, printbuffer * const output_buffer)
+{
+ unsigned char *output_pointer = NULL;
+ size_t length = 0;
+ cJSON *current_element = item->child;
+
+ if (output_buffer == NULL)
+ {
+ return false;
+ }
+
+ if (output_buffer->depth >= CJSON_NESTING_LIMIT)
+ {
+ return false; /* nesting is too deep */
+ }
+
+ /* Compose the output array. */
+ /* opening square bracket */
+ output_pointer = ensure(output_buffer, 1);
+ if (output_pointer == NULL)
+ {
+ return false;
+ }
+
+ *output_pointer = '[';
+ output_buffer->offset++;
+ output_buffer->depth++;
+
+ while (current_element != NULL)
+ {
+ if (!print_value(current_element, output_buffer))
+ {
+ return false;
+ }
+ update_offset(output_buffer);
+ if (current_element->next)
+ {
+ length = (size_t) (output_buffer->format ? 2 : 1);
+ output_pointer = ensure(output_buffer, length + 1);
+ if (output_pointer == NULL)
+ {
+ return false;
+ }
+ *output_pointer++ = ',';
+ if(output_buffer->format)
+ {
+ *output_pointer++ = ' ';
+ }
+ *output_pointer = '\0';
+ output_buffer->offset += length;
+ }
+ current_element = current_element->next;
+ }
+
+ output_pointer = ensure(output_buffer, 2);
+ if (output_pointer == NULL)
+ {
+ return false;
+ }
+ *output_pointer++ = ']';
+ *output_pointer = '\0';
+ output_buffer->depth--;
+
+ return true;
+}
+
+/* Build an object from the text. */
+static cJSON_bool parse_object(cJSON * const item, parse_buffer * const input_buffer)
+{
+ cJSON *head = NULL; /* linked list head */
+ cJSON *current_item = NULL;
+
+ if (input_buffer->depth >= CJSON_NESTING_LIMIT)
+ {
+ return false; /* to deeply nested */
+ }
+ input_buffer->depth++;
+
+ if (cannot_access_at_index(input_buffer, 0) || (buffer_at_offset(input_buffer)[0] != '{'))
+ {
+ goto fail; /* not an object */
+ }
+
+ input_buffer->offset++;
+ buffer_skip_whitespace(input_buffer);
+ if (can_access_at_index(input_buffer, 0) && (buffer_at_offset(input_buffer)[0] == '}'))
+ {
+ goto success; /* empty object */
+ }
+
+ /* check if we skipped to the end of the buffer */
+ if (cannot_access_at_index(input_buffer, 0))
+ {
+ input_buffer->offset--;
+ goto fail;
+ }
+
+ /* step back to character in front of the first element */
+ input_buffer->offset--;
+ /* loop through the comma separated array elements */
+ do
+ {
+ /* allocate next item */
+ cJSON *new_item = cJSON_New_Item(&(input_buffer->hooks));
+ if (new_item == NULL)
+ {
+ goto fail; /* allocation failure */
+ }
+
+ /* attach next item to list */
+ if (head == NULL)
+ {
+ /* start the linked list */
+ current_item = head = new_item;
+ }
+ else
+ {
+ /* add to the end and advance */
+ current_item->next = new_item;
+ new_item->prev = current_item;
+ current_item = new_item;
+ }
+
+ if (cannot_access_at_index(input_buffer, 1))
+ {
+ goto fail; /* nothing comes after the comma */
+ }
+
+ /* parse the name of the child */
+ input_buffer->offset++;
+ buffer_skip_whitespace(input_buffer);
+ if (!parse_string(current_item, input_buffer))
+ {
+ goto fail; /* failed to parse name */
+ }
+ buffer_skip_whitespace(input_buffer);
+
+ /* swap valuestring and string, because we parsed the name */
+ current_item->string = current_item->valuestring;
+ current_item->valuestring = NULL;
+
+ if (cannot_access_at_index(input_buffer, 0) || (buffer_at_offset(input_buffer)[0] != ':'))
+ {
+ goto fail; /* invalid object */
+ }
+
+ /* parse the value */
+ input_buffer->offset++;
+ buffer_skip_whitespace(input_buffer);
+ if (!parse_value(current_item, input_buffer))
+ {
+ goto fail; /* failed to parse value */
+ }
+ buffer_skip_whitespace(input_buffer);
+ }
+ while (can_access_at_index(input_buffer, 0) && (buffer_at_offset(input_buffer)[0] == ','));
+
+ if (cannot_access_at_index(input_buffer, 0) || (buffer_at_offset(input_buffer)[0] != '}'))
+ {
+ goto fail; /* expected end of object */
+ }
+
+success:
+ input_buffer->depth--;
+
+ if (head != NULL) {
+ head->prev = current_item;
+ }
+
+ item->type = cJSON_Object;
+ item->child = head;
+
+ input_buffer->offset++;
+ return true;
+
+fail:
+ if (head != NULL)
+ {
+ cJSON_Delete(head);
+ }
+
+ return false;
+}
+
+/* Render an object to text. */
+static cJSON_bool print_object(const cJSON * const item, printbuffer * const output_buffer)
+{
+ unsigned char *output_pointer = NULL;
+ size_t length = 0;
+ cJSON *current_item = item->child;
+
+ if (output_buffer == NULL)
+ {
+ return false;
+ }
+
+ if (output_buffer->depth >= CJSON_NESTING_LIMIT)
+ {
+ return false; /* nesting is too deep */
+ }
+
+ /* Compose the output: */
+ length = (size_t) (output_buffer->format ? 2 : 1); /* fmt: {\n */
+ output_pointer = ensure(output_buffer, length + 1);
+ if (output_pointer == NULL)
+ {
+ return false;
+ }
+
+ *output_pointer++ = '{';
+ output_buffer->depth++;
+ if (output_buffer->format)
+ {
+ *output_pointer++ = '\n';
+ }
+ output_buffer->offset += length;
+
+ while (current_item)
+ {
+ if (output_buffer->format)
+ {
+ size_t i;
+ output_pointer = ensure(output_buffer, output_buffer->depth);
+ if (output_pointer == NULL)
+ {
+ return false;
+ }
+ for (i = 0; i < output_buffer->depth; i++)
+ {
+ *output_pointer++ = '\t';
+ }
+ output_buffer->offset += output_buffer->depth;
+ }
+
+ /* print key */
+ if (!print_string_ptr((unsigned char*)current_item->string, output_buffer))
+ {
+ return false;
+ }
+ update_offset(output_buffer);
+
+ length = (size_t) (output_buffer->format ? 2 : 1);
+ output_pointer = ensure(output_buffer, length);
+ if (output_pointer == NULL)
+ {
+ return false;
+ }
+ *output_pointer++ = ':';
+ if (output_buffer->format)
+ {
+ *output_pointer++ = '\t';
+ }
+ output_buffer->offset += length;
+
+ /* print value */
+ if (!print_value(current_item, output_buffer))
+ {
+ return false;
+ }
+ update_offset(output_buffer);
+
+ /* print comma if not last */
+ length = ((size_t)(output_buffer->format ? 1 : 0) + (size_t)(current_item->next ? 1 : 0));
+ output_pointer = ensure(output_buffer, length + 1);
+ if (output_pointer == NULL)
+ {
+ return false;
+ }
+ if (current_item->next)
+ {
+ *output_pointer++ = ',';
+ }
+
+ if (output_buffer->format)
+ {
+ *output_pointer++ = '\n';
+ }
+ *output_pointer = '\0';
+ output_buffer->offset += length;
+
+ current_item = current_item->next;
+ }
+
+ output_pointer = ensure(output_buffer, output_buffer->format ? (output_buffer->depth + 1) : 2);
+ if (output_pointer == NULL)
+ {
+ return false;
+ }
+ if (output_buffer->format)
+ {
+ size_t i;
+ for (i = 0; i < (output_buffer->depth - 1); i++)
+ {
+ *output_pointer++ = '\t';
+ }
+ }
+ *output_pointer++ = '}';
+ *output_pointer = '\0';
+ output_buffer->depth--;
+
+ return true;
+}
+
+/* Get Array size/item / object item. */
+CJSON_PUBLIC(int) cJSON_GetArraySize(const cJSON *array)
+{
+ cJSON *child = NULL;
+ size_t size = 0;
+
+ if (array == NULL)
+ {
+ return 0;
+ }
+
+ child = array->child;
+
+ while(child != NULL)
+ {
+ size++;
+ child = child->next;
+ }
+
+ /* FIXME: Can overflow here. Cannot be fixed without breaking the API */
+
+ return (int)size;
+}
+
+static cJSON* get_array_item(const cJSON *array, size_t index)
+{
+ cJSON *current_child = NULL;
+
+ if (array == NULL)
+ {
+ return NULL;
+ }
+
+ current_child = array->child;
+ while ((current_child != NULL) && (index > 0))
+ {
+ index--;
+ current_child = current_child->next;
+ }
+
+ return current_child;
+}
+
+CJSON_PUBLIC(cJSON *) cJSON_GetArrayItem(const cJSON *array, int index)
+{
+ if (index < 0)
+ {
+ return NULL;
+ }
+
+ return get_array_item(array, (size_t)index);
+}
+
+static cJSON *get_object_item(const cJSON * const object, const char * const name, const cJSON_bool case_sensitive)
+{
+ cJSON *current_element = NULL;
+
+ if ((object == NULL) || (name == NULL))
+ {
+ return NULL;
+ }
+
+ current_element = object->child;
+ if (case_sensitive)
+ {
+ while ((current_element != NULL) && (current_element->string != NULL) && (strcmp(name, current_element->string) != 0))
+ {
+ current_element = current_element->next;
+ }
+ }
+ else
+ {
+ while ((current_element != NULL) && (case_insensitive_strcmp((const unsigned char*)name, (const unsigned char*)(current_element->string)) != 0))
+ {
+ current_element = current_element->next;
+ }
+ }
+
+ if ((current_element == NULL) || (current_element->string == NULL)) {
+ return NULL;
+ }
+
+ return current_element;
+}
+
+CJSON_PUBLIC(cJSON *) cJSON_GetObjectItem(const cJSON * const object, const char * const string)
+{
+ return get_object_item(object, string, false);
+}
+
+CJSON_PUBLIC(cJSON *) cJSON_GetObjectItemCaseSensitive(const cJSON * const object, const char * const string)
+{
+ return get_object_item(object, string, true);
+}
+
+CJSON_PUBLIC(cJSON_bool) cJSON_HasObjectItem(const cJSON *object, const char *string)
+{
+ return cJSON_GetObjectItem(object, string) ? 1 : 0;
+}
+
+/* Utility for array list handling. */
+static void suffix_object(cJSON *prev, cJSON *item)
+{
+ prev->next = item;
+ item->prev = prev;
+}
+
+/* Utility for handling references. */
+static cJSON *create_reference(const cJSON *item, const internal_hooks * const hooks)
+{
+ cJSON *reference = NULL;
+ if (item == NULL)
+ {
+ return NULL;
+ }
+
+ reference = cJSON_New_Item(hooks);
+ if (reference == NULL)
+ {
+ return NULL;
+ }
+
+ memcpy(reference, item, sizeof(cJSON));
+ reference->string = NULL;
+ reference->type |= cJSON_IsReference;
+ reference->next = reference->prev = NULL;
+ return reference;
+}
+
+static cJSON_bool add_item_to_array(cJSON *array, cJSON *item)
+{
+ cJSON *child = NULL;
+
+ if ((item == NULL) || (array == NULL) || (array == item))
+ {
+ return false;
+ }
+
+ child = array->child;
+ /*
+ * To find the last item in array quickly, we use prev in array
+ */
+ if (child == NULL)
+ {
+ /* list is empty, start new one */
+ array->child = item;
+ item->prev = item;
+ item->next = NULL;
+ }
+ else
+ {
+ /* append to the end */
+ if (child->prev)
+ {
+ suffix_object(child->prev, item);
+ array->child->prev = item;
+ }
+ }
+
+ return true;
+}
+
+/* Add item to array/object. */
+CJSON_PUBLIC(cJSON_bool) cJSON_AddItemToArray(cJSON *array, cJSON *item)
+{
+ return add_item_to_array(array, item);
+}
+
+#if defined(__clang__) || (defined(__GNUC__) && ((__GNUC__ > 4) || ((__GNUC__ == 4) && (__GNUC_MINOR__ > 5))))
+ #pragma GCC diagnostic push
+#endif
+#ifdef __GNUC__
+#pragma GCC diagnostic ignored "-Wcast-qual"
+#endif
+/* helper function to cast away const */
+static void* cast_away_const(const void* string)
+{
+ return (void*)string;
+}
+#if defined(__clang__) || (defined(__GNUC__) && ((__GNUC__ > 4) || ((__GNUC__ == 4) && (__GNUC_MINOR__ > 5))))
+ #pragma GCC diagnostic pop
+#endif
+
+
+static cJSON_bool add_item_to_object(cJSON * const object, const char * const string, cJSON * const item, const internal_hooks * const hooks, const cJSON_bool constant_key)
+{
+ char *new_key = NULL;
+ int new_type = cJSON_Invalid;
+
+ if ((object == NULL) || (string == NULL) || (item == NULL) || (object == item))
+ {
+ return false;
+ }
+
+ if (constant_key)
+ {
+ new_key = (char*)cast_away_const(string);
+ new_type = item->type | cJSON_StringIsConst;
+ }
+ else
+ {
+ new_key = (char*)cJSON_strdup((const unsigned char*)string, hooks);
+ if (new_key == NULL)
+ {
+ return false;
+ }
+
+ new_type = item->type & ~cJSON_StringIsConst;
+ }
+
+ if (!(item->type & cJSON_StringIsConst) && (item->string != NULL))
+ {
+ hooks->deallocate(item->string);
+ }
+
+ item->string = new_key;
+ item->type = new_type;
+
+ return add_item_to_array(object, item);
+}
+
+CJSON_PUBLIC(cJSON_bool) cJSON_AddItemToObject(cJSON *object, const char *string, cJSON *item)
+{
+ return add_item_to_object(object, string, item, &global_hooks, false);
+}
+
+/* Add an item to an object with constant string as key */
+CJSON_PUBLIC(cJSON_bool) cJSON_AddItemToObjectCS(cJSON *object, const char *string, cJSON *item)
+{
+ return add_item_to_object(object, string, item, &global_hooks, true);
+}
+
+CJSON_PUBLIC(cJSON_bool) cJSON_AddItemReferenceToArray(cJSON *array, cJSON *item)
+{
+ if (array == NULL)
+ {
+ return false;
+ }
+
+ return add_item_to_array(array, create_reference(item, &global_hooks));
+}
+
+CJSON_PUBLIC(cJSON_bool) cJSON_AddItemReferenceToObject(cJSON *object, const char *string, cJSON *item)
+{
+ if ((object == NULL) || (string == NULL))
+ {
+ return false;
+ }
+
+ return add_item_to_object(object, string, create_reference(item, &global_hooks), &global_hooks, false);
+}
+
+CJSON_PUBLIC(cJSON*) cJSON_AddNullToObject(cJSON * const object, const char * const name)
+{
+ cJSON *null = cJSON_CreateNull();
+ if (add_item_to_object(object, name, null, &global_hooks, false))
+ {
+ return null;
+ }
+
+ cJSON_Delete(null);
+ return NULL;
+}
+
+CJSON_PUBLIC(cJSON*) cJSON_AddTrueToObject(cJSON * const object, const char * const name)
+{
+ cJSON *true_item = cJSON_CreateTrue();
+ if (add_item_to_object(object, name, true_item, &global_hooks, false))
+ {
+ return true_item;
+ }
+
+ cJSON_Delete(true_item);
+ return NULL;
+}
+
+CJSON_PUBLIC(cJSON*) cJSON_AddFalseToObject(cJSON * const object, const char * const name)
+{
+ cJSON *false_item = cJSON_CreateFalse();
+ if (add_item_to_object(object, name, false_item, &global_hooks, false))
+ {
+ return false_item;
+ }
+
+ cJSON_Delete(false_item);
+ return NULL;
+}
+
+CJSON_PUBLIC(cJSON*) cJSON_AddBoolToObject(cJSON * const object, const char * const name, const cJSON_bool boolean)
+{
+ cJSON *bool_item = cJSON_CreateBool(boolean);
+ if (add_item_to_object(object, name, bool_item, &global_hooks, false))
+ {
+ return bool_item;
+ }
+
+ cJSON_Delete(bool_item);
+ return NULL;
+}
+
+CJSON_PUBLIC(cJSON*) cJSON_AddNumberToObject(cJSON * const object, const char * const name, const double number)
+{
+ cJSON *number_item = cJSON_CreateNumber(number);
+ if (add_item_to_object(object, name, number_item, &global_hooks, false))
+ {
+ return number_item;
+ }
+
+ cJSON_Delete(number_item);
+ return NULL;
+}
+
+CJSON_PUBLIC(cJSON*) cJSON_AddStringToObject(cJSON * const object, const char * const name, const char * const string)
+{
+ cJSON *string_item = cJSON_CreateString(string);
+ if (add_item_to_object(object, name, string_item, &global_hooks, false))
+ {
+ return string_item;
+ }
+
+ cJSON_Delete(string_item);
+ return NULL;
+}
+
+CJSON_PUBLIC(cJSON*) cJSON_AddRawToObject(cJSON * const object, const char * const name, const char * const raw)
+{
+ cJSON *raw_item = cJSON_CreateRaw(raw);
+ if (add_item_to_object(object, name, raw_item, &global_hooks, false))
+ {
+ return raw_item;
+ }
+
+ cJSON_Delete(raw_item);
+ return NULL;
+}
+
+CJSON_PUBLIC(cJSON*) cJSON_AddObjectToObject(cJSON * const object, const char * const name)
+{
+ cJSON *object_item = cJSON_CreateObject();
+ if (add_item_to_object(object, name, object_item, &global_hooks, false))
+ {
+ return object_item;
+ }
+
+ cJSON_Delete(object_item);
+ return NULL;
+}
+
+CJSON_PUBLIC(cJSON*) cJSON_AddArrayToObject(cJSON * const object, const char * const name)
+{
+ cJSON *array = cJSON_CreateArray();
+ if (add_item_to_object(object, name, array, &global_hooks, false))
+ {
+ return array;
+ }
+
+ cJSON_Delete(array);
+ return NULL;
+}
+
+CJSON_PUBLIC(cJSON *) cJSON_DetachItemViaPointer(cJSON *parent, cJSON * const item)
+{
+ if ((parent == NULL) || (item == NULL) || (item != parent->child && item->prev == NULL))
+ {
+ return NULL;
+ }
+
+ if (item != parent->child)
+ {
+ /* not the first element */
+ item->prev->next = item->next;
+ }
+ if (item->next != NULL)
+ {
+ /* not the last element */
+ item->next->prev = item->prev;
+ }
+
+ if (item == parent->child)
+ {
+ /* first element */
+ parent->child = item->next;
+ }
+ else if (item->next == NULL)
+ {
+ /* last element */
+ parent->child->prev = item->prev;
+ }
+
+ /* make sure the detached item doesn't point anywhere anymore */
+ item->prev = NULL;
+ item->next = NULL;
+
+ return item;
+}
+
+CJSON_PUBLIC(cJSON *) cJSON_DetachItemFromArray(cJSON *array, int which)
+{
+ if (which < 0)
+ {
+ return NULL;
+ }
+
+ return cJSON_DetachItemViaPointer(array, get_array_item(array, (size_t)which));
+}
+
+CJSON_PUBLIC(void) cJSON_DeleteItemFromArray(cJSON *array, int which)
+{
+ cJSON_Delete(cJSON_DetachItemFromArray(array, which));
+}
+
+CJSON_PUBLIC(cJSON *) cJSON_DetachItemFromObject(cJSON *object, const char *string)
+{
+ cJSON *to_detach = cJSON_GetObjectItem(object, string);
+
+ return cJSON_DetachItemViaPointer(object, to_detach);
+}
+
+CJSON_PUBLIC(cJSON *) cJSON_DetachItemFromObjectCaseSensitive(cJSON *object, const char *string)
+{
+ cJSON *to_detach = cJSON_GetObjectItemCaseSensitive(object, string);
+
+ return cJSON_DetachItemViaPointer(object, to_detach);
+}
+
+CJSON_PUBLIC(void) cJSON_DeleteItemFromObject(cJSON *object, const char *string)
+{
+ cJSON_Delete(cJSON_DetachItemFromObject(object, string));
+}
+
+CJSON_PUBLIC(void) cJSON_DeleteItemFromObjectCaseSensitive(cJSON *object, const char *string)
+{
+ cJSON_Delete(cJSON_DetachItemFromObjectCaseSensitive(object, string));
+}
+
+/* Replace array/object items with new ones. */
+CJSON_PUBLIC(cJSON_bool) cJSON_InsertItemInArray(cJSON *array, int which, cJSON *newitem)
+{
+ cJSON *after_inserted = NULL;
+
+ if (which < 0 || newitem == NULL)
+ {
+ return false;
+ }
+
+ after_inserted = get_array_item(array, (size_t)which);
+ if (after_inserted == NULL)
+ {
+ return add_item_to_array(array, newitem);
+ }
+
+ if (after_inserted != array->child && after_inserted->prev == NULL) {
+ /* return false if after_inserted is a corrupted array item */
+ return false;
+ }
+
+ newitem->next = after_inserted;
+ newitem->prev = after_inserted->prev;
+ after_inserted->prev = newitem;
+ if (after_inserted == array->child)
+ {
+ array->child = newitem;
+ }
+ else
+ {
+ newitem->prev->next = newitem;
+ }
+ return true;
+}
+
+CJSON_PUBLIC(cJSON_bool) cJSON_ReplaceItemViaPointer(cJSON * const parent, cJSON * const item, cJSON * replacement)
+{
+ if ((parent == NULL) || (parent->child == NULL) || (replacement == NULL) || (item == NULL))
+ {
+ return false;
+ }
+
+ if (replacement == item)
+ {
+ return true;
+ }
+
+ replacement->next = item->next;
+ replacement->prev = item->prev;
+
+ if (replacement->next != NULL)
+ {
+ replacement->next->prev = replacement;
+ }
+ if (parent->child == item)
+ {
+ if (parent->child->prev == parent->child)
+ {
+ replacement->prev = replacement;
+ }
+ parent->child = replacement;
+ }
+ else
+ { /*
+ * To find the last item in array quickly, we use prev in array.
+ * We can't modify the last item's next pointer where this item was the parent's child
+ */
+ if (replacement->prev != NULL)
+ {
+ replacement->prev->next = replacement;
+ }
+ if (replacement->next == NULL)
+ {
+ parent->child->prev = replacement;
+ }
+ }
+
+ item->next = NULL;
+ item->prev = NULL;
+ cJSON_Delete(item);
+
+ return true;
+}
+
+CJSON_PUBLIC(cJSON_bool) cJSON_ReplaceItemInArray(cJSON *array, int which, cJSON *newitem)
+{
+ if (which < 0)
+ {
+ return false;
+ }
+
+ return cJSON_ReplaceItemViaPointer(array, get_array_item(array, (size_t)which), newitem);
+}
+
+static cJSON_bool replace_item_in_object(cJSON *object, const char *string, cJSON *replacement, cJSON_bool case_sensitive)
+{
+ if ((replacement == NULL) || (string == NULL))
+ {
+ return false;
+ }
+
+ /* replace the name in the replacement */
+ if (!(replacement->type & cJSON_StringIsConst) && (replacement->string != NULL))
+ {
+ cJSON_free(replacement->string);
+ }
+ replacement->string = (char*)cJSON_strdup((const unsigned char*)string, &global_hooks);
+ if (replacement->string == NULL)
+ {
+ return false;
+ }
+
+ replacement->type &= ~cJSON_StringIsConst;
+
+ return cJSON_ReplaceItemViaPointer(object, get_object_item(object, string, case_sensitive), replacement);
+}
+
+CJSON_PUBLIC(cJSON_bool) cJSON_ReplaceItemInObject(cJSON *object, const char *string, cJSON *newitem)
+{
+ return replace_item_in_object(object, string, newitem, false);
+}
+
+CJSON_PUBLIC(cJSON_bool) cJSON_ReplaceItemInObjectCaseSensitive(cJSON *object, const char *string, cJSON *newitem)
+{
+ return replace_item_in_object(object, string, newitem, true);
+}
+
+/* Create basic types: */
+CJSON_PUBLIC(cJSON *) cJSON_CreateNull(void)
+{
+ cJSON *item = cJSON_New_Item(&global_hooks);
+ if(item)
+ {
+ item->type = cJSON_NULL;
+ }
+
+ return item;
+}
+
+CJSON_PUBLIC(cJSON *) cJSON_CreateTrue(void)
+{
+ cJSON *item = cJSON_New_Item(&global_hooks);
+ if(item)
+ {
+ item->type = cJSON_True;
+ }
+
+ return item;
+}
+
+CJSON_PUBLIC(cJSON *) cJSON_CreateFalse(void)
+{
+ cJSON *item = cJSON_New_Item(&global_hooks);
+ if(item)
+ {
+ item->type = cJSON_False;
+ }
+
+ return item;
+}
+
+CJSON_PUBLIC(cJSON *) cJSON_CreateBool(cJSON_bool boolean)
+{
+ cJSON *item = cJSON_New_Item(&global_hooks);
+ if(item)
+ {
+ item->type = boolean ? cJSON_True : cJSON_False;
+ }
+
+ return item;
+}
+
+CJSON_PUBLIC(cJSON *) cJSON_CreateNumber(double num)
+{
+ cJSON *item = cJSON_New_Item(&global_hooks);
+ if(item)
+ {
+ item->type = cJSON_Number;
+ item->valuedouble = num;
+
+ /* use saturation in case of overflow */
+ if (num >= INT_MAX)
+ {
+ item->valueint = INT_MAX;
+ }
+ else if (num <= (double)INT_MIN)
+ {
+ item->valueint = INT_MIN;
+ }
+ else
+ {
+ item->valueint = (int)num;
+ }
+ }
+
+ return item;
+}
+
+CJSON_PUBLIC(cJSON *) cJSON_CreateString(const char *string)
+{
+ cJSON *item = cJSON_New_Item(&global_hooks);
+ if(item)
+ {
+ item->type = cJSON_String;
+ item->valuestring = (char*)cJSON_strdup((const unsigned char*)string, &global_hooks);
+ if(!item->valuestring)
+ {
+ cJSON_Delete(item);
+ return NULL;
+ }
+ }
+
+ return item;
+}
+
+CJSON_PUBLIC(cJSON *) cJSON_CreateStringReference(const char *string)
+{
+ cJSON *item = cJSON_New_Item(&global_hooks);
+ if (item != NULL)
+ {
+ item->type = cJSON_String | cJSON_IsReference;
+ item->valuestring = (char*)cast_away_const(string);
+ }
+
+ return item;
+}
+
+CJSON_PUBLIC(cJSON *) cJSON_CreateObjectReference(const cJSON *child)
+{
+ cJSON *item = cJSON_New_Item(&global_hooks);
+ if (item != NULL) {
+ item->type = cJSON_Object | cJSON_IsReference;
+ item->child = (cJSON*)cast_away_const(child);
+ }
+
+ return item;
+}
+
+CJSON_PUBLIC(cJSON *) cJSON_CreateArrayReference(const cJSON *child) {
+ cJSON *item = cJSON_New_Item(&global_hooks);
+ if (item != NULL) {
+ item->type = cJSON_Array | cJSON_IsReference;
+ item->child = (cJSON*)cast_away_const(child);
+ }
+
+ return item;
+}
+
+CJSON_PUBLIC(cJSON *) cJSON_CreateRaw(const char *raw)
+{
+ cJSON *item = cJSON_New_Item(&global_hooks);
+ if(item)
+ {
+ item->type = cJSON_Raw;
+ item->valuestring = (char*)cJSON_strdup((const unsigned char*)raw, &global_hooks);
+ if(!item->valuestring)
+ {
+ cJSON_Delete(item);
+ return NULL;
+ }
+ }
+
+ return item;
+}
+
+CJSON_PUBLIC(cJSON *) cJSON_CreateArray(void)
+{
+ cJSON *item = cJSON_New_Item(&global_hooks);
+ if(item)
+ {
+ item->type=cJSON_Array;
+ }
+
+ return item;
+}
+
+CJSON_PUBLIC(cJSON *) cJSON_CreateObject(void)
+{
+ cJSON *item = cJSON_New_Item(&global_hooks);
+ if (item)
+ {
+ item->type = cJSON_Object;
+ }
+
+ return item;
+}
+
+/* Create Arrays: */
+CJSON_PUBLIC(cJSON *) cJSON_CreateIntArray(const int *numbers, int count)
+{
+ size_t i = 0;
+ cJSON *n = NULL;
+ cJSON *p = NULL;
+ cJSON *a = NULL;
+
+ if ((count < 0) || (numbers == NULL))
+ {
+ return NULL;
+ }
+
+ a = cJSON_CreateArray();
+
+ for(i = 0; a && (i < (size_t)count); i++)
+ {
+ n = cJSON_CreateNumber(numbers[i]);
+ if (!n)
+ {
+ cJSON_Delete(a);
+ return NULL;
+ }
+ if(!i)
+ {
+ a->child = n;
+ }
+ else
+ {
+ suffix_object(p, n);
+ }
+ p = n;
+ }
+
+ if (a && a->child) {
+ a->child->prev = n;
+ }
+
+ return a;
+}
+
+CJSON_PUBLIC(cJSON *) cJSON_CreateFloatArray(const float *numbers, int count)
+{
+ size_t i = 0;
+ cJSON *n = NULL;
+ cJSON *p = NULL;
+ cJSON *a = NULL;
+
+ if ((count < 0) || (numbers == NULL))
+ {
+ return NULL;
+ }
+
+ a = cJSON_CreateArray();
+
+ for(i = 0; a && (i < (size_t)count); i++)
+ {
+ n = cJSON_CreateNumber((double)numbers[i]);
+ if(!n)
+ {
+ cJSON_Delete(a);
+ return NULL;
+ }
+ if(!i)
+ {
+ a->child = n;
+ }
+ else
+ {
+ suffix_object(p, n);
+ }
+ p = n;
+ }
+
+ if (a && a->child) {
+ a->child->prev = n;
+ }
+
+ return a;
+}
+
+CJSON_PUBLIC(cJSON *) cJSON_CreateDoubleArray(const double *numbers, int count)
+{
+ size_t i = 0;
+ cJSON *n = NULL;
+ cJSON *p = NULL;
+ cJSON *a = NULL;
+
+ if ((count < 0) || (numbers == NULL))
+ {
+ return NULL;
+ }
+
+ a = cJSON_CreateArray();
+
+ for(i = 0; a && (i < (size_t)count); i++)
+ {
+ n = cJSON_CreateNumber(numbers[i]);
+ if(!n)
+ {
+ cJSON_Delete(a);
+ return NULL;
+ }
+ if(!i)
+ {
+ a->child = n;
+ }
+ else
+ {
+ suffix_object(p, n);
+ }
+ p = n;
+ }
+
+ if (a && a->child) {
+ a->child->prev = n;
+ }
+
+ return a;
+}
+
+CJSON_PUBLIC(cJSON *) cJSON_CreateStringArray(const char *const *strings, int count)
+{
+ size_t i = 0;
+ cJSON *n = NULL;
+ cJSON *p = NULL;
+ cJSON *a = NULL;
+
+ if ((count < 0) || (strings == NULL))
+ {
+ return NULL;
+ }
+
+ a = cJSON_CreateArray();
+
+ for (i = 0; a && (i < (size_t)count); i++)
+ {
+ n = cJSON_CreateString(strings[i]);
+ if(!n)
+ {
+ cJSON_Delete(a);
+ return NULL;
+ }
+ if(!i)
+ {
+ a->child = n;
+ }
+ else
+ {
+ suffix_object(p,n);
+ }
+ p = n;
+ }
+
+ if (a && a->child) {
+ a->child->prev = n;
+ }
+
+ return a;
+}
+
+/* Duplication */
+cJSON * cJSON_Duplicate_rec(const cJSON *item, size_t depth, cJSON_bool recurse);
+
+CJSON_PUBLIC(cJSON *) cJSON_Duplicate(const cJSON *item, cJSON_bool recurse)
+{
+ return cJSON_Duplicate_rec(item, 0, recurse );
+}
+
+cJSON * cJSON_Duplicate_rec(const cJSON *item, size_t depth, cJSON_bool recurse)
+{
+ cJSON *newitem = NULL;
+ cJSON *child = NULL;
+ cJSON *next = NULL;
+ cJSON *newchild = NULL;
+
+ /* Bail on bad ptr */
+ if (!item)
+ {
+ goto fail;
+ }
+ /* Create new item */
+ newitem = cJSON_New_Item(&global_hooks);
+ if (!newitem)
+ {
+ goto fail;
+ }
+ /* Copy over all vars */
+ newitem->type = item->type & (~cJSON_IsReference);
+ newitem->valueint = item->valueint;
+ newitem->valuedouble = item->valuedouble;
+ if (item->valuestring)
+ {
+ newitem->valuestring = (char*)cJSON_strdup((unsigned char*)item->valuestring, &global_hooks);
+ if (!newitem->valuestring)
+ {
+ goto fail;
+ }
+ }
+ if (item->string)
+ {
+ newitem->string = (item->type&cJSON_StringIsConst) ? item->string : (char*)cJSON_strdup((unsigned char*)item->string, &global_hooks);
+ if (!newitem->string)
+ {
+ goto fail;
+ }
+ }
+ /* If non-recursive, then we're done! */
+ if (!recurse)
+ {
+ return newitem;
+ }
+ /* Walk the ->next chain for the child. */
+ child = item->child;
+ while (child != NULL)
+ {
+ if(depth >= CJSON_CIRCULAR_LIMIT) {
+ goto fail;
+ }
+ newchild = cJSON_Duplicate_rec(child, depth + 1, true); /* Duplicate (with recurse) each item in the ->next chain */
+ if (!newchild)
+ {
+ goto fail;
+ }
+ if (next != NULL)
+ {
+ /* If newitem->child already set, then crosswire ->prev and ->next and move on */
+ next->next = newchild;
+ newchild->prev = next;
+ next = newchild;
+ }
+ else
+ {
+ /* Set newitem->child and move to it */
+ newitem->child = newchild;
+ next = newchild;
+ }
+ child = child->next;
+ }
+ if (newitem && newitem->child)
+ {
+ newitem->child->prev = newchild;
+ }
+
+ return newitem;
+
+fail:
+ if (newitem != NULL)
+ {
+ cJSON_Delete(newitem);
+ }
+
+ return NULL;
+}
+
+static void skip_oneline_comment(char **input)
+{
+ *input += static_strlen("//");
+
+ for (; (*input)[0] != '\0'; ++(*input))
+ {
+ if ((*input)[0] == '\n') {
+ *input += static_strlen("\n");
+ return;
+ }
+ }
+}
+
+static void skip_multiline_comment(char **input)
+{
+ *input += static_strlen("/*");
+
+ for (; (*input)[0] != '\0'; ++(*input))
+ {
+ if (((*input)[0] == '*') && ((*input)[1] == '/'))
+ {
+ *input += static_strlen("*/");
+ return;
+ }
+ }
+}
+
+static void minify_string(char **input, char **output) {
+ (*output)[0] = (*input)[0];
+ *input += static_strlen("\"");
+ *output += static_strlen("\"");
+
+
+ for (; (*input)[0] != '\0'; (void)++(*input), ++(*output)) {
+ (*output)[0] = (*input)[0];
+
+ if ((*input)[0] == '\"') {
+ (*output)[0] = '\"';
+ *input += static_strlen("\"");
+ *output += static_strlen("\"");
+ return;
+ } else if (((*input)[0] == '\\') && ((*input)[1] == '\"')) {
+ (*output)[1] = (*input)[1];
+ *input += static_strlen("\"");
+ *output += static_strlen("\"");
+ }
+ }
+}
+
+CJSON_PUBLIC(void) cJSON_Minify(char *json)
+{
+ char *into = json;
+
+ if (json == NULL)
+ {
+ return;
+ }
+
+ while (json[0] != '\0')
+ {
+ switch (json[0])
+ {
+ case ' ':
+ case '\t':
+ case '\r':
+ case '\n':
+ json++;
+ break;
+
+ case '/':
+ if (json[1] == '/')
+ {
+ skip_oneline_comment(&json);
+ }
+ else if (json[1] == '*')
+ {
+ skip_multiline_comment(&json);
+ } else {
+ json++;
+ }
+ break;
+
+ case '\"':
+ minify_string(&json, (char**)&into);
+ break;
+
+ default:
+ into[0] = json[0];
+ json++;
+ into++;
+ }
+ }
+
+ /* and null-terminate. */
+ *into = '\0';
+}
+
+CJSON_PUBLIC(cJSON_bool) cJSON_IsInvalid(const cJSON * const item)
+{
+ if (item == NULL)
+ {
+ return false;
+ }
+
+ return (item->type & 0xFF) == cJSON_Invalid;
+}
+
+CJSON_PUBLIC(cJSON_bool) cJSON_IsFalse(const cJSON * const item)
+{
+ if (item == NULL)
+ {
+ return false;
+ }
+
+ return (item->type & 0xFF) == cJSON_False;
+}
+
+CJSON_PUBLIC(cJSON_bool) cJSON_IsTrue(const cJSON * const item)
+{
+ if (item == NULL)
+ {
+ return false;
+ }
+
+ return (item->type & 0xff) == cJSON_True;
+}
+
+
+CJSON_PUBLIC(cJSON_bool) cJSON_IsBool(const cJSON * const item)
+{
+ if (item == NULL)
+ {
+ return false;
+ }
+
+ return (item->type & (cJSON_True | cJSON_False)) != 0;
+}
+CJSON_PUBLIC(cJSON_bool) cJSON_IsNull(const cJSON * const item)
+{
+ if (item == NULL)
+ {
+ return false;
+ }
+
+ return (item->type & 0xFF) == cJSON_NULL;
+}
+
+CJSON_PUBLIC(cJSON_bool) cJSON_IsNumber(const cJSON * const item)
+{
+ if (item == NULL)
+ {
+ return false;
+ }
+
+ return (item->type & 0xFF) == cJSON_Number;
+}
+
+CJSON_PUBLIC(cJSON_bool) cJSON_IsString(const cJSON * const item)
+{
+ if (item == NULL)
+ {
+ return false;
+ }
+
+ return (item->type & 0xFF) == cJSON_String;
+}
+
+CJSON_PUBLIC(cJSON_bool) cJSON_IsArray(const cJSON * const item)
+{
+ if (item == NULL)
+ {
+ return false;
+ }
+
+ return (item->type & 0xFF) == cJSON_Array;
+}
+
+CJSON_PUBLIC(cJSON_bool) cJSON_IsObject(const cJSON * const item)
+{
+ if (item == NULL)
+ {
+ return false;
+ }
+
+ return (item->type & 0xFF) == cJSON_Object;
+}
+
+CJSON_PUBLIC(cJSON_bool) cJSON_IsRaw(const cJSON * const item)
+{
+ if (item == NULL)
+ {
+ return false;
+ }
+
+ return (item->type & 0xFF) == cJSON_Raw;
+}
+
+CJSON_PUBLIC(cJSON_bool) cJSON_Compare(const cJSON * const a, const cJSON * const b, const cJSON_bool case_sensitive)
+{
+ if ((a == NULL) || (b == NULL) || ((a->type & 0xFF) != (b->type & 0xFF)))
+ {
+ return false;
+ }
+
+ /* check if type is valid */
+ switch (a->type & 0xFF)
+ {
+ case cJSON_False:
+ case cJSON_True:
+ case cJSON_NULL:
+ case cJSON_Number:
+ case cJSON_String:
+ case cJSON_Raw:
+ case cJSON_Array:
+ case cJSON_Object:
+ break;
+
+ default:
+ return false;
+ }
+
+ /* identical objects are equal */
+ if (a == b)
+ {
+ return true;
+ }
+
+ switch (a->type & 0xFF)
+ {
+ /* in these cases and equal type is enough */
+ case cJSON_False:
+ case cJSON_True:
+ case cJSON_NULL:
+ return true;
+
+ case cJSON_Number:
+ if (compare_double(a->valuedouble, b->valuedouble))
+ {
+ return true;
+ }
+ return false;
+
+ case cJSON_String:
+ case cJSON_Raw:
+ if ((a->valuestring == NULL) || (b->valuestring == NULL))
+ {
+ return false;
+ }
+ if (strcmp(a->valuestring, b->valuestring) == 0)
+ {
+ return true;
+ }
+
+ return false;
+
+ case cJSON_Array:
+ {
+ cJSON *a_element = a->child;
+ cJSON *b_element = b->child;
+
+ for (; (a_element != NULL) && (b_element != NULL);)
+ {
+ if (!cJSON_Compare(a_element, b_element, case_sensitive))
+ {
+ return false;
+ }
+
+ a_element = a_element->next;
+ b_element = b_element->next;
+ }
+
+ /* one of the arrays is longer than the other */
+ if (a_element != b_element) {
+ return false;
+ }
+
+ return true;
+ }
+
+ case cJSON_Object:
+ {
+ cJSON *a_element = NULL;
+ cJSON *b_element = NULL;
+ cJSON_ArrayForEach(a_element, a)
+ {
+ /* TODO This has O(n^2) runtime, which is horrible! */
+ b_element = get_object_item(b, a_element->string, case_sensitive);
+ if (b_element == NULL)
+ {
+ return false;
+ }
+
+ if (!cJSON_Compare(a_element, b_element, case_sensitive))
+ {
+ return false;
+ }
+ }
+
+ /* doing this twice, once on a and b to prevent true comparison if a subset of b
+ * TODO: Do this the proper way, this is just a fix for now */
+ cJSON_ArrayForEach(b_element, b)
+ {
+ a_element = get_object_item(a, b_element->string, case_sensitive);
+ if (a_element == NULL)
+ {
+ return false;
+ }
+
+ if (!cJSON_Compare(b_element, a_element, case_sensitive))
+ {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ default:
+ return false;
+ }
+}
+
+CJSON_PUBLIC(void *) cJSON_malloc(size_t size)
+{
+ return global_hooks.allocate(size);
+}
+
+CJSON_PUBLIC(void) cJSON_free(void *object)
+{
+ global_hooks.deallocate(object);
+ object = NULL;
+}
diff --git a/third_party/cJSON.h b/third_party/cJSON.h
new file mode 100644
index 0000000..cab5feb
--- /dev/null
+++ b/third_party/cJSON.h
@@ -0,0 +1,306 @@
+/*
+ Copyright (c) 2009-2017 Dave Gamble and cJSON contributors
+
+ Permission is hereby granted, free of charge, to any person obtaining a copy
+ of this software and associated documentation files (the "Software"), to deal
+ in the Software without restriction, including without limitation the rights
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ copies of the Software, and to permit persons to whom the Software is
+ furnished to do so, subject to the following conditions:
+
+ The above copyright notice and this permission notice shall be included in
+ all copies or substantial portions of the Software.
+
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ THE SOFTWARE.
+*/
+
+#ifndef cJSON__h
+#define cJSON__h
+
+#ifdef __cplusplus
+extern "C"
+{
+#endif
+
+#if !defined(__WINDOWS__) && (defined(WIN32) || defined(WIN64) || defined(_MSC_VER) || defined(_WIN32))
+#define __WINDOWS__
+#endif
+
+#ifdef __WINDOWS__
+
+/* When compiling for windows, we specify a specific calling convention to avoid issues where we are being called from a project with a different default calling convention. For windows you have 3 define options:
+
+CJSON_HIDE_SYMBOLS - Define this in the case where you don't want to ever dllexport symbols
+CJSON_EXPORT_SYMBOLS - Define this on library build when you want to dllexport symbols (default)
+CJSON_IMPORT_SYMBOLS - Define this if you want to dllimport symbol
+
+For *nix builds that support visibility attribute, you can define similar behavior by
+
+setting default visibility to hidden by adding
+-fvisibility=hidden (for gcc)
+or
+-xldscope=hidden (for sun cc)
+to CFLAGS
+
+then using the CJSON_API_VISIBILITY flag to "export" the same symbols the way CJSON_EXPORT_SYMBOLS does
+
+*/
+
+#define CJSON_CDECL __cdecl
+#define CJSON_STDCALL __stdcall
+
+/* export symbols by default, this is necessary for copy pasting the C and header file */
+#if !defined(CJSON_HIDE_SYMBOLS) && !defined(CJSON_IMPORT_SYMBOLS) && !defined(CJSON_EXPORT_SYMBOLS)
+#define CJSON_EXPORT_SYMBOLS
+#endif
+
+#if defined(CJSON_HIDE_SYMBOLS)
+#define CJSON_PUBLIC(type) type CJSON_STDCALL
+#elif defined(CJSON_EXPORT_SYMBOLS)
+#define CJSON_PUBLIC(type) __declspec(dllexport) type CJSON_STDCALL
+#elif defined(CJSON_IMPORT_SYMBOLS)
+#define CJSON_PUBLIC(type) __declspec(dllimport) type CJSON_STDCALL
+#endif
+#else /* !__WINDOWS__ */
+#define CJSON_CDECL
+#define CJSON_STDCALL
+
+#if (defined(__GNUC__) || defined(__SUNPRO_CC) || defined (__SUNPRO_C)) && defined(CJSON_API_VISIBILITY)
+#define CJSON_PUBLIC(type) __attribute__((visibility("default"))) type
+#else
+#define CJSON_PUBLIC(type) type
+#endif
+#endif
+
+/* project version */
+#define CJSON_VERSION_MAJOR 1
+#define CJSON_VERSION_MINOR 7
+#define CJSON_VERSION_PATCH 19
+
+#include <stddef.h>
+
+/* cJSON Types: */
+#define cJSON_Invalid (0)
+#define cJSON_False (1 << 0)
+#define cJSON_True (1 << 1)
+#define cJSON_NULL (1 << 2)
+#define cJSON_Number (1 << 3)
+#define cJSON_String (1 << 4)
+#define cJSON_Array (1 << 5)
+#define cJSON_Object (1 << 6)
+#define cJSON_Raw (1 << 7) /* raw json */
+
+#define cJSON_IsReference 256
+#define cJSON_StringIsConst 512
+
+/* The cJSON structure: */
+typedef struct cJSON
+{
+ /* next/prev allow you to walk array/object chains. Alternatively, use GetArraySize/GetArrayItem/GetObjectItem */
+ struct cJSON *next;
+ struct cJSON *prev;
+ /* An array or object item will have a child pointer pointing to a chain of the items in the array/object. */
+ struct cJSON *child;
+
+ /* The type of the item, as above. */
+ int type;
+
+ /* The item's string, if type==cJSON_String and type == cJSON_Raw */
+ char *valuestring;
+ /* writing to valueint is DEPRECATED, use cJSON_SetNumberValue instead */
+ int valueint;
+ /* The item's number, if type==cJSON_Number */
+ double valuedouble;
+
+ /* The item's name string, if this item is the child of, or is in the list of subitems of an object. */
+ char *string;
+} cJSON;
+
+typedef struct cJSON_Hooks
+{
+ /* malloc/free are CDECL on Windows regardless of the default calling convention of the compiler, so ensure the hooks allow passing those functions directly. */
+ void *(CJSON_CDECL *malloc_fn)(size_t sz);
+ void (CJSON_CDECL *free_fn)(void *ptr);
+} cJSON_Hooks;
+
+typedef int cJSON_bool;
+
+/* Limits how deeply nested arrays/objects can be before cJSON rejects to parse them.
+ * This is to prevent stack overflows. */
+#ifndef CJSON_NESTING_LIMIT
+#define CJSON_NESTING_LIMIT 1000
+#endif
+
+/* Limits the length of circular references can be before cJSON rejects to parse them.
+ * This is to prevent stack overflows. */
+#ifndef CJSON_CIRCULAR_LIMIT
+#define CJSON_CIRCULAR_LIMIT 10000
+#endif
+
+/* returns the version of cJSON as a string */
+CJSON_PUBLIC(const char*) cJSON_Version(void);
+
+/* Supply malloc, realloc and free functions to cJSON */
+CJSON_PUBLIC(void) cJSON_InitHooks(cJSON_Hooks* hooks);
+
+/* Memory Management: the caller is always responsible to free the results from all variants of cJSON_Parse (with cJSON_Delete) and cJSON_Print (with stdlib free, cJSON_Hooks.free_fn, or cJSON_free as appropriate). The exception is cJSON_PrintPreallocated, where the caller has full responsibility of the buffer. */
+/* Supply a block of JSON, and this returns a cJSON object you can interrogate. */
+CJSON_PUBLIC(cJSON *) cJSON_Parse(const char *value);
+CJSON_PUBLIC(cJSON *) cJSON_ParseWithLength(const char *value, size_t buffer_length);
+/* ParseWithOpts allows you to require (and check) that the JSON is null terminated, and to retrieve the pointer to the final byte parsed. */
+/* If you supply a ptr in return_parse_end and parsing fails, then return_parse_end will contain a pointer to the error so will match cJSON_GetErrorPtr(). */
+CJSON_PUBLIC(cJSON *) cJSON_ParseWithOpts(const char *value, const char **return_parse_end, cJSON_bool require_null_terminated);
+CJSON_PUBLIC(cJSON *) cJSON_ParseWithLengthOpts(const char *value, size_t buffer_length, const char **return_parse_end, cJSON_bool require_null_terminated);
+
+/* Render a cJSON entity to text for transfer/storage. */
+CJSON_PUBLIC(char *) cJSON_Print(const cJSON *item);
+/* Render a cJSON entity to text for transfer/storage without any formatting. */
+CJSON_PUBLIC(char *) cJSON_PrintUnformatted(const cJSON *item);
+/* Render a cJSON entity to text using a buffered strategy. prebuffer is a guess at the final size. guessing well reduces reallocation. fmt=0 gives unformatted, =1 gives formatted */
+CJSON_PUBLIC(char *) cJSON_PrintBuffered(const cJSON *item, int prebuffer, cJSON_bool fmt);
+/* Render a cJSON entity to text using a buffer already allocated in memory with given length. Returns 1 on success and 0 on failure. */
+/* NOTE: cJSON is not always 100% accurate in estimating how much memory it will use, so to be safe allocate 5 bytes more than you actually need */
+CJSON_PUBLIC(cJSON_bool) cJSON_PrintPreallocated(cJSON *item, char *buffer, const int length, const cJSON_bool format);
+/* Delete a cJSON entity and all subentities. */
+CJSON_PUBLIC(void) cJSON_Delete(cJSON *item);
+
+/* Returns the number of items in an array (or object). */
+CJSON_PUBLIC(int) cJSON_GetArraySize(const cJSON *array);
+/* Retrieve item number "index" from array "array". Returns NULL if unsuccessful. */
+CJSON_PUBLIC(cJSON *) cJSON_GetArrayItem(const cJSON *array, int index);
+/* Get item "string" from object. Case insensitive. */
+CJSON_PUBLIC(cJSON *) cJSON_GetObjectItem(const cJSON * const object, const char * const string);
+CJSON_PUBLIC(cJSON *) cJSON_GetObjectItemCaseSensitive(const cJSON * const object, const char * const string);
+CJSON_PUBLIC(cJSON_bool) cJSON_HasObjectItem(const cJSON *object, const char *string);
+/* For analysing failed parses. This returns a pointer to the parse error. You'll probably need to look a few chars back to make sense of it. Defined when cJSON_Parse() returns 0. 0 when cJSON_Parse() succeeds. */
+CJSON_PUBLIC(const char *) cJSON_GetErrorPtr(void);
+
+/* Check item type and return its value */
+CJSON_PUBLIC(char *) cJSON_GetStringValue(const cJSON * const item);
+CJSON_PUBLIC(double) cJSON_GetNumberValue(const cJSON * const item);
+
+/* These functions check the type of an item */
+CJSON_PUBLIC(cJSON_bool) cJSON_IsInvalid(const cJSON * const item);
+CJSON_PUBLIC(cJSON_bool) cJSON_IsFalse(const cJSON * const item);
+CJSON_PUBLIC(cJSON_bool) cJSON_IsTrue(const cJSON * const item);
+CJSON_PUBLIC(cJSON_bool) cJSON_IsBool(const cJSON * const item);
+CJSON_PUBLIC(cJSON_bool) cJSON_IsNull(const cJSON * const item);
+CJSON_PUBLIC(cJSON_bool) cJSON_IsNumber(const cJSON * const item);
+CJSON_PUBLIC(cJSON_bool) cJSON_IsString(const cJSON * const item);
+CJSON_PUBLIC(cJSON_bool) cJSON_IsArray(const cJSON * const item);
+CJSON_PUBLIC(cJSON_bool) cJSON_IsObject(const cJSON * const item);
+CJSON_PUBLIC(cJSON_bool) cJSON_IsRaw(const cJSON * const item);
+
+/* These calls create a cJSON item of the appropriate type. */
+CJSON_PUBLIC(cJSON *) cJSON_CreateNull(void);
+CJSON_PUBLIC(cJSON *) cJSON_CreateTrue(void);
+CJSON_PUBLIC(cJSON *) cJSON_CreateFalse(void);
+CJSON_PUBLIC(cJSON *) cJSON_CreateBool(cJSON_bool boolean);
+CJSON_PUBLIC(cJSON *) cJSON_CreateNumber(double num);
+CJSON_PUBLIC(cJSON *) cJSON_CreateString(const char *string);
+/* raw json */
+CJSON_PUBLIC(cJSON *) cJSON_CreateRaw(const char *raw);
+CJSON_PUBLIC(cJSON *) cJSON_CreateArray(void);
+CJSON_PUBLIC(cJSON *) cJSON_CreateObject(void);
+
+/* Create a string where valuestring references a string so
+ * it will not be freed by cJSON_Delete */
+CJSON_PUBLIC(cJSON *) cJSON_CreateStringReference(const char *string);
+/* Create an object/array that only references it's elements so
+ * they will not be freed by cJSON_Delete */
+CJSON_PUBLIC(cJSON *) cJSON_CreateObjectReference(const cJSON *child);
+CJSON_PUBLIC(cJSON *) cJSON_CreateArrayReference(const cJSON *child);
+
+/* These utilities create an Array of count items.
+ * The parameter count cannot be greater than the number of elements in the number array, otherwise array access will be out of bounds.*/
+CJSON_PUBLIC(cJSON *) cJSON_CreateIntArray(const int *numbers, int count);
+CJSON_PUBLIC(cJSON *) cJSON_CreateFloatArray(const float *numbers, int count);
+CJSON_PUBLIC(cJSON *) cJSON_CreateDoubleArray(const double *numbers, int count);
+CJSON_PUBLIC(cJSON *) cJSON_CreateStringArray(const char *const *strings, int count);
+
+/* Append item to the specified array/object. */
+CJSON_PUBLIC(cJSON_bool) cJSON_AddItemToArray(cJSON *array, cJSON *item);
+CJSON_PUBLIC(cJSON_bool) cJSON_AddItemToObject(cJSON *object, const char *string, cJSON *item);
+/* Use this when string is definitely const (i.e. a literal, or as good as), and will definitely survive the cJSON object.
+ * WARNING: When this function was used, make sure to always check that (item->type & cJSON_StringIsConst) is zero before
+ * writing to `item->string` */
+CJSON_PUBLIC(cJSON_bool) cJSON_AddItemToObjectCS(cJSON *object, const char *string, cJSON *item);
+/* Append reference to item to the specified array/object. Use this when you want to add an existing cJSON to a new cJSON, but don't want to corrupt your existing cJSON. */
+CJSON_PUBLIC(cJSON_bool) cJSON_AddItemReferenceToArray(cJSON *array, cJSON *item);
+CJSON_PUBLIC(cJSON_bool) cJSON_AddItemReferenceToObject(cJSON *object, const char *string, cJSON *item);
+
+/* Remove/Detach items from Arrays/Objects. */
+CJSON_PUBLIC(cJSON *) cJSON_DetachItemViaPointer(cJSON *parent, cJSON * const item);
+CJSON_PUBLIC(cJSON *) cJSON_DetachItemFromArray(cJSON *array, int which);
+CJSON_PUBLIC(void) cJSON_DeleteItemFromArray(cJSON *array, int which);
+CJSON_PUBLIC(cJSON *) cJSON_DetachItemFromObject(cJSON *object, const char *string);
+CJSON_PUBLIC(cJSON *) cJSON_DetachItemFromObjectCaseSensitive(cJSON *object, const char *string);
+CJSON_PUBLIC(void) cJSON_DeleteItemFromObject(cJSON *object, const char *string);
+CJSON_PUBLIC(void) cJSON_DeleteItemFromObjectCaseSensitive(cJSON *object, const char *string);
+
+/* Update array items. */
+CJSON_PUBLIC(cJSON_bool) cJSON_InsertItemInArray(cJSON *array, int which, cJSON *newitem); /* Shifts pre-existing items to the right. */
+CJSON_PUBLIC(cJSON_bool) cJSON_ReplaceItemViaPointer(cJSON * const parent, cJSON * const item, cJSON * replacement);
+CJSON_PUBLIC(cJSON_bool) cJSON_ReplaceItemInArray(cJSON *array, int which, cJSON *newitem);
+CJSON_PUBLIC(cJSON_bool) cJSON_ReplaceItemInObject(cJSON *object,const char *string,cJSON *newitem);
+CJSON_PUBLIC(cJSON_bool) cJSON_ReplaceItemInObjectCaseSensitive(cJSON *object,const char *string,cJSON *newitem);
+
+/* Duplicate a cJSON item */
+CJSON_PUBLIC(cJSON *) cJSON_Duplicate(const cJSON *item, cJSON_bool recurse);
+/* Duplicate will create a new, identical cJSON item to the one you pass, in new memory that will
+ * need to be released. With recurse!=0, it will duplicate any children connected to the item.
+ * The item->next and ->prev pointers are always zero on return from Duplicate. */
+/* Recursively compare two cJSON items for equality. If either a or b is NULL or invalid, they will be considered unequal.
+ * case_sensitive determines if object keys are treated case sensitive (1) or case insensitive (0) */
+CJSON_PUBLIC(cJSON_bool) cJSON_Compare(const cJSON * const a, const cJSON * const b, const cJSON_bool case_sensitive);
+
+/* Minify a strings, remove blank characters(such as ' ', '\t', '\r', '\n') from strings.
+ * The input pointer json cannot point to a read-only address area, such as a string constant,
+ * but should point to a readable and writable address area. */
+CJSON_PUBLIC(void) cJSON_Minify(char *json);
+
+/* Helper functions for creating and adding items to an object at the same time.
+ * They return the added item or NULL on failure. */
+CJSON_PUBLIC(cJSON*) cJSON_AddNullToObject(cJSON * const object, const char * const name);
+CJSON_PUBLIC(cJSON*) cJSON_AddTrueToObject(cJSON * const object, const char * const name);
+CJSON_PUBLIC(cJSON*) cJSON_AddFalseToObject(cJSON * const object, const char * const name);
+CJSON_PUBLIC(cJSON*) cJSON_AddBoolToObject(cJSON * const object, const char * const name, const cJSON_bool boolean);
+CJSON_PUBLIC(cJSON*) cJSON_AddNumberToObject(cJSON * const object, const char * const name, const double number);
+CJSON_PUBLIC(cJSON*) cJSON_AddStringToObject(cJSON * const object, const char * const name, const char * const string);
+CJSON_PUBLIC(cJSON*) cJSON_AddRawToObject(cJSON * const object, const char * const name, const char * const raw);
+CJSON_PUBLIC(cJSON*) cJSON_AddObjectToObject(cJSON * const object, const char * const name);
+CJSON_PUBLIC(cJSON*) cJSON_AddArrayToObject(cJSON * const object, const char * const name);
+
+/* When assigning an integer value, it needs to be propagated to valuedouble too. */
+#define cJSON_SetIntValue(object, number) ((object) ? (object)->valueint = (object)->valuedouble = (number) : (number))
+/* helper for the cJSON_SetNumberValue macro */
+CJSON_PUBLIC(double) cJSON_SetNumberHelper(cJSON *object, double number);
+#define cJSON_SetNumberValue(object, number) ((object != NULL) ? cJSON_SetNumberHelper(object, (double)number) : (number))
+/* Change the valuestring of a cJSON_String object, only takes effect when type of object is cJSON_String */
+CJSON_PUBLIC(char*) cJSON_SetValuestring(cJSON *object, const char *valuestring);
+
+/* If the object is not a boolean type this does nothing and returns cJSON_Invalid else it returns the new type*/
+#define cJSON_SetBoolValue(object, boolValue) ( \
+ (object != NULL && ((object)->type & (cJSON_False|cJSON_True))) ? \
+ (object)->type=((object)->type &(~(cJSON_False|cJSON_True)))|((boolValue)?cJSON_True:cJSON_False) : \
+ cJSON_Invalid\
+)
+
+/* Macro for iterating over an array or object */
+#define cJSON_ArrayForEach(element, array) for(element = (array != NULL) ? (array)->child : NULL; element != NULL; element = element->next)
+
+/* malloc/free objects using the malloc/free functions that have been set with cJSON_InitHooks */
+CJSON_PUBLIC(void *) cJSON_malloc(size_t size);
+CJSON_PUBLIC(void) cJSON_free(void *object);
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif