Developing Gextto¶
This guide is for contributors who build, test or extend Gextto. For installing and operating a daemon, use the repository README and user manual instead.
Contributor workflow¶
- Make a focused change and preserve existing user data and configuration.
- Format changed Go files, run the focused test, then run the full suite.
- Update the user-facing documentation and API reference with the behavior.
- Inspect
git diff --checkbefore committing.
Project layout¶
cmd/gexttod/ executable entry point
*.go daemon, database, web handlers and services
uiweb/v2/templates/ server-rendered HTML templates
uiweb/v2/static/ CSS and browser JavaScript
uiweb/end2end/ Playwright tests
internal/ shared packages and backend helpers
scripts/ build, update, packaging and service helpers
docs/ user, advanced-user and developer documentation
The root Go package contains the application logic. cmd/gexttod/main.go only
assembles and starts the executable. For a file-by-file map of that package,
the torrent backends and where to make common changes, see
ARCHITECTURE.md.
Requirements and build¶
Install Go 1.26.8 or newer (the version in go.mod). The build is pure Go
and has no C/C++ dependency: gx-torrent, the default engine, is a pure-Go
daemon built next to gexttod. The normal build embeds the web UI; it does not
need a separate frontend build.
make build # pure Go (default): gexttod + gx-torrent
go test ./... # pure-Go tests
The executable is written to bin/gexttod, with the gx-torrent daemon next to
it. Supported torrent backends are gx-torrent (default, pure Go) and the
qBittorrent-nox Web API adapter. See
ARCHITECTURE.md for the torrent engine layer.
Local development¶
Run without real downloads in an isolated data directory:
GEXTTO_DATA_DIR="$PWD/data-dev" \
GEXTTO_ACTIVE=0 \
GEXTTO_DRY_RUN=1 \
./bin/gexttod --dry-run
For a user service:
GEXTTO_DATA_DIR="$HOME/gextto-data" \
GEXTTO_LISTEN=127.0.0.1:5000 \
scripts/install-user-service.sh
After a source update, scripts/update.sh builds the daemon and restarts the
detected service.
Testing a local gx-core build¶
scripts/gx-core-test.sh runs a test gx-core (gx-torrent in standalone
mode) built from the checkout, next to the Gextto in production, so a change
to the engine or the daemon can be tried as soon as it compiles, without
waiting for a CI release:
scripts/gx-core-test.sh start # start it (rebuilds if the code changed)
scripts/gx-core-test.sh restart # stop + start (rebuilds if changed)
scripts/gx-core-test.sh status # pid, version, data directory
scripts/gx-core-test.sh log # follow its log
scripts/gx-core-test.sh stop
scripts/gx-core-test.sh start --build # force a rebuild even if nothing changed
scripts/gx-core-test.sh start -- -debug # extra daemon options after "--"
It keeps everything apart from Gextto: its own directory (the test work area,
~/.cache/gextto-tests/gx-core-test: binary, state, session database,
downloads, log), its own web page/API address
(0.0.0.0:8899, open on the whole LAN like the packaged standalone daemon) and
peer ports (51500-51510), no UPnP/NAT-PMP mapping. start and status print
the address another device on the network opens (the machine's LAN address); its
page stays open until a password is set in its own settings.json (standalone's
LAN bypass). The build goes to that directory's bin, not to bin/, and does not
bump gx-torrent.build_number: Gextto's managed gx-torrent is never replaced.
The binary carries the commit (-dirty when the engine or daemon sources have
uncommitted changes) and no release channel, so it never updates itself.
GX_TEST_DIR, GX_TEST_LISTEN (e.g. 127.0.0.1:8899 to keep it on this
machine only), GX_TEST_PEER_PORTS, GX_TEST_TOKEN and GX_TEST_BIN change the
defaults.
start and restart rebuild the test binary by themselves when the
gx-torrent sources changed since the last build: the script hashes them exactly
as scripts/build-daemon.sh does for bin/gx-torrent, so a copy left behind by
a merge is refreshed instead of showing the old page (--build forces a rebuild,
GX_TEST_BIN is never touched). When the instance was set up with a page
password, the script still recognises it: the API answers 401 until a session
exists, and the script reports that instead of calling the daemon dead.
The script can be called through a symlink (for example
ln -s ~/gextto/scripts/gx-core-test.sh ~/gx-core-test.sh): it resolves the
link and builds the checkout the link points to.
Web UI and API changes¶
The server-rendered UI is assembled in uiweb_v2.go, uiweb_sections.go,
uiweb_pages.go and uiweb/v2/templates. Static browser behavior belongs in
uiweb/v2/static/v2-core.js; styles belong in uiweb/v2/static/gextto-ui.css
and v2.css.
The official UI is served at /; the old /v2 prefix redirects to the root and
the removed legacy /ui route must not be reintroduced. Live shell metrics are
rendered by the /partial/chrome HTMX fragment, while the Downloads table uses its own
5-second polling fragment. Keep both polling fragments free of a recursive
load trigger when they replace themselves with outerHTML.
The shell references every stylesheet and script with ?v=<build>-<fingerprint>
and the assets are served immutable for a year. The fingerprint is a hash of
the embedded files (web_static.go), so a change to a CSS or JS file changes
the URL even when build_number does not: without it a release that touched
only a stylesheet would keep the same URL and every browser that had already
cached it would go on showing the old file. To ship a UI change, no cache
clearing is needed on the client — just rebuild and restart.
When adding an API endpoint:
- implement the handler with the shared
AppStatesignature; - register it in
web_router.go; - add authorization, validation and bounded input handling;
- document it in
docs/API.md; - add a focused Go test and, when it changes the UI, a Playwright test;
- keep the route table in
docs/API.mdaligned (TestAPIDocumentationMatchesRouterfor Gextto,TestAPIRoutesAreDocumentedfor the gx-torrent/api/v1and/api/v2tables).
Use the existing JSON helpers and keep destructive operations explicit and confirmable in the UI.
The v2 pages read and write through the JSON API in-process
(v2InternalJSON, using the router stored on AppState). A renamed route does
not break the build, so TestInternalJSONPathsAreRegisteredRoutes resolves
every literal method and path of those calls, and the Endpoint/GetPath/
PostPath/TestEndpoint/AddPath fields of the page specs, against the real
router.
Settings: a key in the settings index (uiweb_settings.go) is writable by
construction; settingWritableAPIOnly (web_settings.go) lists only the
keys with no row on the page. A key in the index gets a constant in
settings_keys_gen.go, generated from the index with make settings-keys
(scripts/gen-settings-keys.go): code reads settingTorrent… and a typo
cannot compile. TestSettingsKeyConstantsMatchTheIndex fails when a row is
added without regenerating the file, and TestSettingsLiteralsAreKnownKeys
scans the package for a key read or saved through a literal and fails on one
Gextto does not know (index, API-only list, writable prefix, or the explicit
legacy/internal/panel lists in settings_keys_test.go). Every write path (the JSON API, the settings page,
the setup wizard) validates the value through validateSettingValue
(backend_config_validation.go), so a rule added there applies everywhere.
The starting value the page shows (uiSettingDefaults,
uiweb_settings_defaults.go) must match what the backend uses when nothing is
saved: TestSettingDefaultsMatchTheBackend pins it against the typed
DefaultTransferSettings (derived in Config.applyTransferSettings) and against
the scalar getters, and TestDefaultTransferSettingsMatchLoader pins those
fallbacks against the struct. A new option with a backend default needs an entry
there. The /api/config view (ConfigView, web_handlers_core.go) repeats a
page default for the flat options it reports; TestConfigViewDefaultsMatchThePage
compares those to the page (the list keys and the nested transfer/paths blocks
are a different representation and are read through the config instead), so a
backend default must reach the view as well as the page.
Every route sits behind AuthMiddleware (auth.go). Access control is off by
default; when it is on, only /login, /logout, /static/, the favicon, the
manifest and the service worker are public, and local clients pass without a
login unless the operator disabled that. A new public route needs an explicit
entry in authPublicPath.
Interface texts and translations¶
Templates and Go code write interface texts in Italian, translated with the
bundled catalogs internal_translations.yml (English), _de, _fr, _es and
_pl, whose keys are the Italian strings. Every new text needs an entry in all
five catalogs (a test checks that they share the same keys). In a template every
text and translatable attribute goes through {{tr "…"}} while the page renders;
a sentence with values keeps them as printf verbs ({{tr "Apri %s su TMDB" .Name}}),
and a whole title attribute is written {{trTitle "…"}}, which also marks it as
translated for the client. TestTemplatesTranslateEveryText fails on any text
left outside tr. Texts built outside the templates use uiText(s, "…") or
uiTextf(s, "… %d …", n); markup Go assembles itself (table cells, row actions)
goes through v2TranslateFragment. The catalogs are
the only source of the texts, and the translation database mirrors them: at
startup, when a hash kept in the translation_seed table says they changed,
every row follows its catalog (a new key is inserted, a corrected value replaces
the stored one, a key that left the catalog is removed). There is no editor in
the interface, so a correction goes into the catalogs and reaches every
installation at the next start. The TUI has
its own six-language catalog (IT, EN, DE, FR, ES, PL) in internal/tui/i18n.go; daemon log lines are
English only.
The gx-torrent daemon translates its own pages the same way: the English
text is the key, cmd/gx-torrent/i18n.go holds the six columns, and the pages
in internal/webui/assets write every text and tooltip as {{tr "…"}} (with
printf verbs when a value sits inside the phrase). The pages are parsed once
per page and language, so nothing is translated after rendering and a value can
never be mistaken for a phrase; the labels a handler builds in Go go through
uiTr(lang, …). Two tests guard it: TestUIPageIsFullyTranslatable (static,
every asset) and TestDaemonPagesTranslateEveryLabel (the pages rendered in
German), plus TestDaemonPageKeepsDataUntranslated for the other direction.
Database changes¶
Database schema changes must be migration-safe for existing installations. Use the database migration helpers, parameterised SQL and tests covering both a fresh database and an older schema. Never put user data, local paths or secrets in fixtures committed to the repository.
Testing¶
Run these checks before a commit:
gofmt -w changed.go
make test # pure Go: check-ui + installer self-test + test-dht + Go tests (root package split across processes)
make test-serial # the same with a plain CGO_ENABLED=0 go test ./...
make test-engine # tests of the gx-core engine
make test-ui # UI end2end (Playwright): pages, mobile, a11y (needs Node + a browser)
CGO_ENABLED=1 go test -race ./... # race detector needs cgo (a C compiler)
node --check uiweb/v2/static/v2-core.js
git diff --check
make test is pure Go. The root package's tests run in several processes
(scripts/go-test-sharded.sh, round-robin by test name): a test must not
depend on another having run before it in the same process, and a slow test
that only waits (a timeout, a retry, a poll) should get its wait shortened
through a package variable the test sets, as the eD2k search timing does
(ed2kMinSearchWait, ed2kMaxSearchWait, ed2kSearchPoll, ed2kSearchRetry).
The pacing waits that every test should skip (retry back-offs, per-host gaps,
polls) are shortened once in main_test.go (TestMain) of the root package
and of cmd/gx-torrent; a test about one of those waits sets the variable
itself. make test-ui runs three groups (three daemons and three Chromium):
the mini PC it runs on does not take more.
The end-to-end suite is under uiweb/end2end (Playwright + Node). make test-ui
builds the daemon and gx-torrent in a temporary directory of the run (never in
bin/, which the service on the machine runs), starts it in dry-run on its own port, completes the first-run
wizard once in global-setup.ts and drives the real interface in a browser:
make test-ui # pages, mobile layout, i18n, axe-core accessibility
GEXTTO_TEST_UI_SERIAL=1 make test-ui # old single process
Locally scripts/test-ui.sh splits the specs into two independent groups — one
with the settings, i18n and the smaller read-only specs, one with the
accessibility and page specs — each with its own dry-run daemon, data directory
and ports, and runs them concurrently. CI keeps the serial process. The script
kills the test gx-torrent at the end; an interrupted run can leave one
listening until its 15-minute orphan timeout.
Screenshots¶
The images of the README and of the guided tour (docs/TOUR.en.md,
docs/TOUR.it.md) are generated, not taken by hand:
make screenshots # docs/screenshots/<lang>/<name>.png
scripts/screenshots.sh compiles gexttod and gx-torrent into
~/.cache/gextto-tests/screenshots (no change to bin/ or to the build numbers),
starts a real instance there — dry-run cannot add torrents — with automation
off, its own ports and a 2000 KiB/s cap, adds the legal torrents (Debian,
Ubuntu Server, the Blender open movies) and a small public-domain library, lets
them download for GEXTTO_SHOTS_SETTLE seconds (150) and runs
uiweb/end2end/screenshots.config.ts. English and Italian get every image, de,
fr, es and pl the dashboard (GEXTTO_SHOTS_LANGS,
GEXTTO_SHOTS_DASHBOARD_LANGS). The work directory is removed at the end;
GEXTTO_SHOTS_KEEP=1 keeps the instance running to look at it. Run it after a
visible UI change and commit the images with it: the downloads are real, a few
hundred MB on the line.
The suite also gives the test daemon its own gx-torrent: playwright.config.ts
sets GEXTTO_GXTORRENT_LISTEN=127.0.0.1:18893, which replaces both the managed
daemon's listen address and the URL Gextto uses to reach it. Without it, a
gx-torrent already answering on the default 8890 (a live installation) would be
adopted by the test instance, which then reloaded its IP filter and restarted
its session. Gextto now refuses a daemon that carries a fingerprint but belongs
to another data directory, so that mistake fails loudly instead of disturbing a
running installation.
It runs in CI in the ui-e2e job. tests/accessibility.spec.ts holds the
axe-core checks and the keyboard/reflow regressions: it should pass before
changing UI templates, styles or client-side interaction code.
Fuzzing¶
The network-facing parsers have Go fuzz targets. Run them before touching the
internal/gxcore codecs, the DHT packets, the .torrent/magnet/RSS/Torznab
parsing, the IP filter or the MSE/peer-wire handshake:
make fuzz-smoke # 30s per target (FUZZ_TIME default)
FUZZ_TIME=2m make fuzz-smoke # a serious pass
A crashing input is written under testdata/fuzz/<Target>/: fix the code and
commit the input as a regression seed. .github/workflows/fuzz.yml runs the same
check nightly (2m per target), on a push that touches an engine/parser path (30s)
and on demand from the Actions tab, uploading the failing inputs as an artifact.
Each target is retried once, so a spurious context deadline exceeded from the
Go fuzzer shutting down under load does not fail the run (a real crash repeats).
Temporary space (/tmp)¶
/tmp here is a tmpfs (RAM), so everything left there costs memory and, once
it fills up, breaks the tests: gextto's disk guard pauses downloads when the
target filesystem has less than 1 GiB free, and the tests that download then wait
for a torrent that never starts (a suite that takes 40 s turns into minutes of
timeouts). Keep it clean:
scripts/tmp-smart-clean.sh --dry-run # what would go
scripts/tmp-smart-clean.sh --verbose # what goes
scripts/install-tmp-cleaner.sh # daily systemd *user* timer (no sudo)
The cleaner is deliberately conservative: it only looks at the current user's
entries, only older than 24 h (build/test residue, go-build*, Test*,
playwright-*, ages faster, 6 h, with a 1 h floor), never anything a process
still holds open (a directory holding an open file, or a socket, is left alone),
and never the system/desktop plumbing (systemd-private-*, snap-private-tmp,
.X11-unix, tmux-*, ssh-*, …). The timer runs it daily with a 30 minute
jitter; the log is in the journal (journalctl --user -u tmp-clean).
Claude Code's scratch area (/tmp/claude-<uid>) is cleaned too, one session at
a time rather than as a whole: <project>/<session> goes once the session is
idle — its transcript ~/.claude/projects/<project>/<session>.jsonl and its
own content untouched for 12 h (--claude-idle HOURS) — and nothing in it is
open or is a process's working directory, so a running session keeps its
scratchpad. The loose files beside the sessions age like build residue (6 h).
Test and benchmark work area¶
Tests, measurements and screenshots never write into $HOME at its top level or
into the checkout: each run gets a folder under one root, and the folder is
removed when the cycle ends.
scripts/test-workdir.sh root # ~/.cache/gextto-tests
scripts/test-workdir.sh new cycle-42 # the folder of this cycle
scripts/test-workdir.sh clean # remove what no process is using
The root is $GEXTTO_TEST_ROOT, default
${XDG_CACHE_HOME:-$HOME/.cache}/gextto-tests; it is on a real disk because
/tmp is tmpfs (too small for torrent payloads). The tools already default
inside it: scripts/gx-core-test.sh (GX_TEST_DIR),
scripts/phase3-env.sh and make measure-merge3 (GX_PHASE3_DIR),
scripts/compare/run.py (GX_COMPARE_DIR), scripts/screenshots.sh
(GEXTTO_SHOTS_WORK), scripts/bench-all.sh (GX_BENCH_OUT). Logs of a single
command go in a mktemp -d scratch folder, deleted at the end.
Cleanup has a command for each thing: scripts/gx-core-test.sh clean stops the
test daemon and removes its directory, scripts/compare/run.py clean removes
the payloads, the containers and the network of a round (--images removes the
gxc-* images too), scripts/test-workdir.sh clean the throwaway folders (it
keeps a running instance and the long-lived gx-core test instance; --all
removes those too). The only files that stay are the raw benchmark results in
gextto-data/bench/, which the next run overwrites. The unattended comparison
is scripts/compare/nightly.sh: it waits for five quiet minutes, smoke-tests
the clients it does not trust yet, measures HEAD in a temporary worktree when
the checkout has uncommitted changes, and writes the tables. The anacrolix row
is driven by scripts/compare/anacrolix, a small HTTP front end over the
library. Docker's build cache is not
part of the work area: docker builder prune after a round that rebuilt the
images.
Build cache¶
The Go build cache (GOCACHE, normally ~/.cache/go-build) grows over time: a
few gigabytes are normal on a busy machine. scripts/refresh-build-cache.sh
keeps it in check:
when the cache passes a threshold (default 5 GiB; override with
GEXTTO_BUILD_CACHE_LIMIT_GB or --limit-gb N) it runs go clean -cache, then
go build ./... and make test, so the cache is left clean and warm rather
than cold. A fresh rebuild is usually far smaller than the accumulated one (for
example 4.3 GiB → 0.5 GiB in a real run). It deliberately does not run
make build, which would bump the committed build_number and dirty the
checkout.
scripts/refresh-build-cache.sh --dry-run # size and decision only
scripts/refresh-build-cache.sh --status # size, limit and the user timer
scripts/refresh-build-cache.sh --force # clean + rebuild + test regardless
scripts/refresh-build-cache.sh # clean + rebuild + test only if over the limit
scripts/refresh-build-cache.sh --install # weekly systemd user timer
make cache-refresh # same check, via Make
--install copies systemd/gextto-build-cache.{service,timer} into
~/.config/systemd/user, points ExecStart at the script in the checkout and
enables the timer (systemctl --user list-timers gextto-build-cache.timer);
--uninstall removes it. The timer runs weekly and does nothing when the cache
is still under the limit.
GitHub Actions cache¶
Distinct from the local Go cache, GitHub keeps a 10 GB budget of Actions
caches per repository: the setup-go and npm caches, plus the CodeQL overlay
base databases, which are one per commit and ~75 MB each — on this repository
they alone fill most of the budget. GitHub has no "keep the last N" policy: past
the budget the least recently used caches are evicted, which can throw away the
useful build caches before the overlay databases that caused the pressure.
.github/workflows/cache-prune.yml runs daily (and on demand from Actions →
Cache prune) and keeps only the newest caches of every kind (default 20).
scripts/prune-actions-caches.sh --dry-run # list what would be deleted
scripts/prune-actions-caches.sh --keep 20 # keep the newest 20 per kind
scripts/prune-actions-caches.sh --overlay-only # CodeQL base databases only
Concurrency and safety¶
The daemon mixes HTTP handlers and long-lived workers, so a few invariants are load-bearing:
- Goroutines started by HTTP handlers (manual cycle, rename-all) must be
tracked in a
WaitGroupor derive fromBackgroundContext()so they finish before their state is torn down. - Run
go test -race ./.... It catches the races above; do not merge a change that introduces a new race. - New endpoints must validate and bound every user input and restrict filesystem paths to the configured roots before use.
Packaging and releases¶
Build the daemon and standalone Linux payload with:
make build
scripts/package-linux.sh
The payload is pure Go: gexttod, the gx-torrent daemon, run.sh,
VERSION and a quick-start README. scripts/build-release.sh builds the
release in an Ubuntu 22.04 container (glibc 2.35 is the baseline the released
binaries must run against). It is the payload consumed by the official installer
and by the advanced self-update command.
The same release build also produces the standalone gx-torrent packages, published next to Gextto's:
| Script | Output | Contents |
|---|---|---|
scripts/package-gx-torrent.sh |
gx-torrent-linux-<arch>.tar.gz |
binary, run.sh, systemd unit, README, VERSION |
scripts/package-gx-torrent-windows.sh |
gx-torrent-windows-<arch>.zip (amd64, arm64) |
gx-torrent.exe (native Windows service), install-service.ps1, uninstall-service.ps1, README.txt |
aMule (EC) against a real core¶
The default suite uses the fake core in internal/amule/amuletest. To check
the EC client against a real amuled (for example a throw-away instance with
its own --config-dir and EC port):
GEXTTO_AMULE_EC=127.0.0.1:4712 GEXTTO_AMULE_PASSWORD=secret \
go test ./internal/amule/ -run Live -v
Windows support is guarded in CI (ci.yml): build-test cross-compiles the
engine and the daemon and runs go vet for Windows; windows-platform runs the
platform tests on windows-latest; windows-service (non-blocking) installs
the real service with scripts/gx-torrent-service-selftest.ps1, waits for
/api/v1/health and checks a clean stop. Platform-specific daemon code lives
in cmd/gx-torrent/*_linux.go, *_windows.go and *_other.go; on Windows a
library link is a junction, so always test for a link with isDirLink, never
with os.ModeSymlink.
Keep generated binaries, databases, logs and local configuration out of Git. Release checks and CI configuration are maintained outside the user manuals.