# haul A desktop SFTP client — a two-pane local/remote file browser over SSH, in the spirit of WinSCP but quite a bit simpler. `haul` reads your `~/.ssh/config` for its host list, or connects ad-hoc to anything that isn't in it. - **Native desktop GUI**, not a terminal app — built on [Fyne](https://fyne.io), so it's a single compiled binary with no runtime. - **Two panes**: local disk on the left, the remote host on the right, with the same navigation, marking, and file operations on both sides. - **Your existing SSH setup just works** — saved hosts come from `~/.ssh/config` (`Include` directives followed), and connecting resolves the effective config through `ssh -G`, so `ProxyJump` chains, `IdentityFile`, `User`, and `Port` behave exactly as the real `ssh` client would. - **Auth the usual ways**: `ssh-agent`, identity files (with passphrase prompt), password, and keyboard-interactive. - **Host keys are verified**, not blindly accepted. An unknown key is shown with its SHA256 fingerprint for confirmation before it's recorded in `known_hosts`. - **Recursive transfers** in both directions with aggregate progress, a stop button, per-file conflict prompts (overwrite / skip / rename, with "apply to all"), and resume of partial files. - **Never blocks on the network** — every SFTP call runs off the UI thread, so listing a slow directory doesn't freeze the window. - **High-contrast dark theme** with tightened padding, so more rows fit on screen. `haul` was split out of [`dial`](https://gitea.apointless.space/bsncubed/dial) (an interactive SSH host picker) so that file transfer lives in its own tool. The two share `~/.ssh/config` as their common base and are otherwise independent. ## Requirements To **run** haul: | Needs | Why | | ---------------------- | ------------------------------------------------------- | | The system `ssh` client | Saved-host connections call `ssh -G ` to resolve the effective config, so ssh_config semantics are never re-implemented | | A graphical desktop | haul is a GUI application; it needs a display server | | **OpenGL 2.1 or newer** | Fyne renders through OpenGL. Remote Desktop sessions, VMs without 3D acceleration, and machines running Microsoft Basic Display Adapter don't provide it — see [Remote Desktop and VMs](#running-over-remote-desktop-or-in-a-vm) | | An SFTP-capable server | Transfers use the SFTP subsystem — see [Scope](#scope) | ## Building haul is written in Go and uses cgo (Fyne renders through OpenGL), so a C compiler and the platform's graphics/windowing headers are needed as well as the Go toolchain. ### Prerequisites There are **two** prerequisites — the Go toolchain, and a C toolchain for cgo. Both are needed on every platform, macOS included. **1. Go 1.24 or newer.** Verify with `go version`; if that says `command not found`, install it: ```sh # macOS brew install go # Debian / Ubuntu sudo apt install golang-go # Fedora sudo dnf install golang # Arch sudo pacman -S go ``` Or download it from [go.dev/dl](https://go.dev/dl/). If your distro's package is older than 1.24, use the tarball from there instead. **2. A C toolchain and the platform's graphics headers**, because Fyne renders through OpenGL: ```sh # macOS — the Xcode command line tools xcode-select --install # Debian / Ubuntu sudo apt install gcc libgl1-mesa-dev xorg-dev # Fedora sudo dnf install gcc libX11-devel libXcursor-devel libXrandr-devel \ libXinerama-devel mesa-libGL-devel libXi-devel libXxf86vm-devel # Arch sudo pacman -S gcc libx11 libxcursor libxrandr libxinerama mesa libxi ``` Windows needs a MinGW-w64 toolchain and has no `make` — it gets [its own section below](#building-on-windows). On macOS the command line tools cover the C side entirely — there is no Homebrew formula to add for graphics. That makes it easy to run `xcode-select --install`, see "already installed", and assume you're ready when Go itself is still missing. Both steps are required. ### Build it ```sh make build # -> bin/haul ``` That's the whole thing. Run it with `./bin/haul`, or install it onto your `PATH`: ```sh make install # -> $GOBIN, or $GOPATH/bin (usually ~/go/bin) ``` Make sure that directory is on `PATH`: ```sh echo 'export PATH="$(go env GOPATH)/bin:$PATH"' >> ~/.bashrc # or ~/.zshrc ``` Or just copy `bin/haul` wherever you like: ```sh install -D -m 0755 bin/haul ~/.local/bin/haul ``` **Expect the first build to take a few minutes.** Compiling Fyne's cgo dependencies from scratch is the slow part; afterwards Go's build cache makes rebuilds close to instant. Without `make`, the equivalent is: ```sh go build -trimpath -ldflags "-s -w" -o bin/haul ./cmd/haul ``` ### Building on Windows Windows differs from the instructions above in three ways: cgo needs a MinGW-w64 compiler, there is no `make`, and a GUI build needs an extra linker flag to suppress the console window. All three are covered here. **1. Install Go.** Either ```powershell winget install GoLang.Go ``` or the `.msi` from [go.dev/dl](https://go.dev/dl/), which puts Go on `PATH` for you. Open a **new** terminal afterwards, then check with `go version`. **2. Install a C compiler.** Go's cgo needs a GCC-style compiler — **MSVC/`cl.exe` will not work.** [MSYS2](https://www.msys2.org/) is the usual source: ```powershell winget install MSYS2.MSYS2 ``` Then open the **"MSYS2 UCRT64"** shell from the Start menu and install GCC: ```sh pacman -S mingw-w64-ucrt-x86_64-gcc ``` Add its `bin` directory to your Windows `PATH` so the normal Go toolchain can find it — in PowerShell, as your user: ```powershell [Environment]::SetEnvironmentVariable("Path", [Environment]::GetEnvironmentVariable("Path","User") + ";C:\msys64\ucrt64\bin", "User") ``` Open a new terminal and confirm both are visible: ```powershell go version gcc --version ``` **3. Build.** Windows has no `make`, so invoke the compiler directly from PowerShell in the repo root: ```powershell go build -trimpath -ldflags "-s -w -H=windowsgui" -o bin\haul.exe .\cmd\haul ``` Then run `.\bin\haul.exe`. Two details in that command matter: - **`-H=windowsgui`** marks the binary as a GUI application. Without it Windows opens a console window behind haul every time it launches. The Makefile does not pass this, because it targets Unix — so on Windows prefer the command above even if you do have `make`. - **`-o bin\haul.exe`** names the output explicitly. Windows needs the `.exe` extension to execute a file. If you would rather use the Makefile, install `make` inside the MSYS2 shell (`pacman -S make`) and build from there — but you will still want to add `-H=windowsgui` yourself, for example `make build LDFLAGS="-s -w -H=windowsgui"`. Unlike Linux, Windows needs no graphics development *packages* to build: OpenGL ships with the operating system. Getting a usable OpenGL at **run** time is a separate matter — see below. ### Running over Remote Desktop (or in a VM) A build that compiles perfectly will still fail to start in an RDP session with: ``` Fyne error: window creation error Cause: APIUnavailable: WGL: The driver does not appear to support OpenGL ``` This is not a haul fault — any OpenGL application fails the same way. Fyne needs OpenGL 2.1+, and Remote Desktop's display driver doesn't provide it. The same applies to Hyper-V and to VMs without 3D acceleration, and to any machine using Microsoft Basic Display Adapter. The fix is Mesa's software OpenGL renderer (llvmpipe), deployed just for haul: 1. Download a `mesa3d--release-msvc.7z` from [mesa-dist-win releases](https://github.com/pal1000/mesa-dist-win/releases). It's a `.7z`, so you'll need 7-Zip (`winget install 7zip.7zip`) to extract it. 2. Open the extracted **`x64`** folder (not `x86`). 3. Copy **every DLL in it** — around 19 files — into the same folder as `haul.exe`. ``` bin\ haul.exe opengl32.dll libgallium_wgl.dll ...and the rest of x64\ ``` Verified working in an RDP session on 2026-07-30. Three things worth knowing: - **Copy all of the DLLs, not just `opengl32.dll`.** In current Mesa builds that file is a thin front-end that loads `libgallium_wgl.dll` and others alongside it. Taking it on its own produces a DLL that can't load its dependencies, and haul fails exactly as if you'd done nothing. - **A local `opengl32.dll` always wins over the system one**, because Windows searches the executable's own directory first. So these DLLs force software rendering *unconditionally* — including on a machine with a perfectly good GPU. If you use haul both locally and over RDP, keep two copies: one folder with the Mesa DLLs, one without. - **Don't run `systemwidedeploy.cmd`** if you see it in the archive. That installs Mesa for every OpenGL application on the machine; copying files beside `haul.exe` keeps the change contained to haul. Software rendering is slower than a GPU, but haul draws two tables and some text — it's entirely adequate. The headless Linux target does the same thing via `LIBGL_ALWAYS_SOFTWARE=1` in `make run-xvfb`. ### Make targets | Target | Does | | ----------------- | -------------------------------------------------------- | | `make build` | Native binary into `./bin/haul` | | `make install` | Build and install into `$GOBIN` / `$GOPATH/bin` | | `make test` | Run the test suite | | `make vet` | `go vet ./...` | | `make check` | Typecheck every package without needing the cgo toolchain | | `make run` | Build, then run (needs a `DISPLAY`) | | `make run-xvfb` | Build, then run against a virtual X display | | `make dist` | Release binary into `./dist` | | `make clean` | Remove `bin/` and `dist/` | The Makefile picks up a Go toolchain at `~/.local/go/bin/go` if one is there, falling back to whatever `go` is on `PATH` — so it works on machines where Go is deliberately kept off `PATH`. Override it explicitly with `make GO=/path/to/go build`. `make check` is the useful one when the C dependencies aren't installed: it builds for `GOOS=js GOARCH=wasm`, which typechecks the entire tree including all Fyne API usage without any cgo toolchain. It proves nothing about layout or runtime behaviour, but it catches every compile error. `make run-xvfb` is for headless machines — it starts an `Xvfb` virtual display, runs haul against it with software GL, and tears the display down again when you quit (including on Ctrl-C). Requires `xvfb` installed. ### Cross-compiling **This does not work the way it does for `dial`.** dial is pure Go and cross-compiles to five platforms with nothing but `GOOS`/`GOARCH`. haul is cgo, so `GOOS=windows go build` will fail — each target needs a matching C toolchain: | Target | Needs | | --------------- | -------------------------------------------------- | | `linux/arm64` | `gcc-aarch64-linux-gnu` | | `windows/amd64` | `gcc-mingw-w64` | | `darwin/*` | a Mac, or [osxcross](https://github.com/tpoechtrager/osxcross) | For that reason `make dist` builds only the native binary. The practical route to the others is [`fyne-cross`](https://github.com/fyne-io/fyne-cross), which does each build inside a Docker image carrying the right toolchain: ```sh go install github.com/fyne-io/fyne-cross@latest fyne-cross windows -arch=amd64 ``` ## Usage ```sh haul ``` ### Connecting The connect window has two tabs: - **Saved** — every `Host` block from `~/.ssh/config`, with a search box to filter them. Connecting resolves the alias through `ssh -G`, so it honours everything your config says about that host, `ProxyJump` chains included. - **Quick connect** — host, port, user, and optionally a password or a private key file, for anything not in your config. It doesn't touch `~/.ssh/config`. If the server's host key isn't in `known_hosts`, haul shows the SHA256 fingerprint and asks before recording it. Passphrase-protected keys and password prompts appear as dialogs during the connection attempt. ### Browsing Each pane has a path bar (type a path and press Enter to jump straight there), up / home / refresh buttons, and New folder, Rename, and Delete actions. In the file table: | Click | Action | | ---------------- | --------------------------------- | | the left column | mark / unmark a file for transfer | | a directory name | descend into it | | `..` | go up one level | | anywhere else | move the highlight | Marks apply to the directory you made them in and are cleared when you navigate away, since they refer to names in the directory you left. ### Transferring **Upload →** copies from the local pane to the remote one, **← Download** the other way. Either takes whatever is marked in the source pane, falling back to the highlighted row if nothing is marked, and writes into the *other* pane's current directory. Directories are copied recursively. While a transfer runs, the progress bar shows aggregate bytes and file counts, and **Stop** cancels it. Completed files stay put and the interrupted file is left on disk — running the same transfer again resumes it from where it stopped, for as long as the session stays open. If a destination file already existed beforehand, haul asks whether to overwrite, skip, or write under a non-colliding name, with an "apply to all" option so you're asked once rather than per file. Closing the window or disconnecting mid-transfer asks for confirmation first. ### Keyboard shortcuts | Key | Action | | ----------- | ------------------------------------------------------ | | `Ctrl+R` | reload the active pane | | `Ctrl+Up` | go up a directory in the active pane | | `Ctrl+T` | transfer the active pane's selection to the other side | `Ctrl+T` uploads or downloads depending on which pane you last touched, so one key covers both directions. ## Scope haul speaks **SFTP only**. There is no SCP or TFTP support: SCP is a copy-stream protocol with no directory listing, stat, rename, or delete, and TFTP has neither listing nor authentication — neither can back a file browser without falling back to parsing shell command output. Servers whose sshd has no `sftp-server` subsystem are therefore out of reach. Resume works within a session, because the engine tracks which destination files it created; restarting haul starts those files over. `~/.ssh/config` is treated as **read-only** — haul never writes to it. The only file it writes outside a transfer is `known_hosts`, when you confirm a new host key.