Files
haul/README.md
T
bsncubed 9ddc152103 build: package haul.exe with an embedded icon
A plain `go build` links no icon resource, so Explorer and the Start menu
draw the generic executable glyph. Inno's SetupIconFile only skins
setup.exe, so the fix has to happen at build time: `fyne package` turns
cmd/haul/Icon.png into a .syso and links it in, and adds -H=windowsgui
itself.

Documents the installer build alongside it, since the two steps are only
useful together.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 08:17:26 +10:00

491 lines
20 KiB
Markdown

# 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 <alias>` 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.
### Packaging on macOS
`make build` produces a plain Unix executable, and that is all you need if you
launch haul from a shell. **Double-click it in Finder, though, and macOS opens a
Terminal window alongside the app.** That is not a bug in haul and no build flag
fixes it — it is what Finder does with any bare executable: having no better
idea what a Mach-O file is, it hands it to Terminal.app to run, and the terminal
stays on screen for as long as the process does. macOS has no equivalent of
Windows' `-H=windowsgui`.
The fix is to ship an application *bundle* — a `haul.app` directory carrying an
`Info.plist` — which Finder launches directly, with a Dock icon and no terminal:
```sh
make package-darwin # -> ./haul.app
```
Then drag `haul.app` into `/Applications`. Run it on a Mac: it compiles with the
host cgo toolchain, and it builds for the host architecture, so an Intel Mac
gives you an `amd64` bundle and Apple silicon an `arm64` one.
Under the hood that target runs [`fyne
package`](https://docs.fyne.io/started/packaging), which reads its metadata from
`cmd/haul/FyneApp.toml`, converts `cmd/haul/Icon.png` into the bundle's
`.icns` icon, and writes the `Info.plist`. Without `make`:
```sh
go run fyne.io/tools/cmd/fyne@v1.7.2 package --target darwin --src ./cmd/haul --release
```
Note that `fyne package` **rewrites `cmd/haul/FyneApp.toml` in place**, bumping
`Build` on every run and dropping any comments you add to it. That is by design;
commit the bump or discard it as you prefer.
The bundle is unsigned, so Gatekeeper blocks the first launch on any Mac that
did not build it. Right-click the app and choose **Open** (once — after that it
starts normally), or clear the quarantine flag:
```sh
xattr -dr com.apple.quarantine /Applications/haul.app
```
Signing and notarising properly needs a paid Apple Developer ID, after which
`fyne release --target darwin --certificate <name>` handles it.
### Packaging on Windows
The `go build` command [above](#building-on-windows) produces a working
`haul.exe`, but one with **no icon** — Explorer, the taskbar and the Start menu
all draw the generic executable glyph. A Windows binary carries its icon as a
linked resource, and the Go compiler has nothing to link unless you give it a
`.syso`. `fyne package` builds one:
```powershell
go run fyne.io/tools/cmd/fyne@v1.7.2 package --target windows --src .\cmd\haul --release
Move-Item .\cmd\haul\haul.exe .\bin\haul.exe
```
Or, from the MSYS2 shell with `make` installed, `make package-windows`.
That converts `cmd/haul/Icon.png` to an icon resource, links it in, and adds
`-H=windowsgui` itself — so unlike the manual build you don't pass any linker
flags. It writes the binary *beside the source* as `cmd\haul\haul.exe`, hence
the move: `Inno-compile.iss` expects it in `bin\`. As on macOS, `fyne package`
rewrites `cmd/haul/FyneApp.toml` in place and bumps `Build`.
#### The installer
`Inno-compile.iss` wraps that binary in a setup program. Install [Inno
Setup](https://jrsoftware.org/isdl.php) (`winget install JRSoftware.InnoSetup`),
then from the repo root:
```powershell
iscc Inno-compile.iss
```
That writes `Output\haul-0.1.0-setup-x64.exe`. Every `Source:` path in the
script is relative to the script itself, so it compiles from any clone — but it
packages whatever is in `bin\` at that moment, and does not build anything
itself. Run the packaging step above first.
It installs per-user into `%LOCALAPPDATA%\Programs\Haul`, so it needs no
administrator rights and prompts for no UAC elevation.
The wizard offers two install types. **Standard** installs `haul.exe` alone.
**Remote Desktop / virtual machine** adds every DLL from `bin\` as the optional
`mesa` component — the software OpenGL renderer described
[below](#running-over-remote-desktop-or-in-a-vm). That split is deliberate and
worth keeping: those DLLs override the system OpenGL for anything in the same
folder, so installing them by default would force software rendering on machines
with a perfectly good GPU. Populate `bin\` with the Mesa DLLs only when you
intend to build an installer that offers them.
Note that `cmd\haul\Icon.ico` is used by Inno for `setup.exe`'s own icon. The
icon inside `haul.exe` comes from `Icon.png` via `fyne package` — the two are
separate, and dropping the packaging step leaves you with a nicely-iconed
installer that installs an icon-less application.
### 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-<version>-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 package-darwin` | macOS `.app` bundle into `./haul.app` (run on a Mac) |
| `make package-windows` | `bin/haul.exe` with an embedded icon (run on Windows) |
| `make clean` | Remove `bin/`, `dist/`, `haul.app/` and `Output/` |
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 — and on macOS the
binary alone is not the artefact you want to hand out, see [Packaging on
macOS](#packaging-on-macos). 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.