Files
haul/README.md
T
bsncubed a8a60f5860 build: package haul as a macOS .app bundle
Double-clicking the bare binary in Finder opens a Terminal window alongside
the app: Finder has no better idea what to do with a Mach-O executable than
hand it to Terminal.app, and the terminal stays up as long as the process
does. macOS has no counterpart to -H=windowsgui, so the only fix is to ship
an application bundle, which Finder launches directly.

Add `make package-darwin`, which runs a pinned `fyne package --target
darwin` to produce ./haul.app. Its metadata comes from cmd/haul/FyneApp.toml
— the app ID there must stay in step with the one app.NewWithID uses, or
macOS treats the bundle and the running app as separate applications. The
icon is a placeholder; `fyne package` will not run without one.

Note that `fyne package` rewrites FyneApp.toml in place on every run, so the
file is left in that tool's canonical formatting rather than a commented
version it would only strip again.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 22:30:22 +10:00

17 KiB

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, 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 (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
An SFTP-capable server Transfers use the SFTP subsystem — see 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:

# 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. 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:

# 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.

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

make build          # -> bin/haul

That's the whole thing. Run it with ./bin/haul, or install it onto your PATH:

make install        # -> $GOBIN, or $GOPATH/bin (usually ~/go/bin)

Make sure that directory is on PATH:

echo 'export PATH="$(go env GOPATH)/bin:$PATH"' >> ~/.bashrc   # or ~/.zshrc

Or just copy bin/haul wherever you like:

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:

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

winget install GoLang.Go

or the .msi from 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 is the usual source:

winget install MSYS2.MSYS2

Then open the "MSYS2 UCRT64" shell from the Start menu and install GCC:

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:

[Environment]::SetEnvironmentVariable("Path",
  [Environment]::GetEnvironmentVariable("Path","User") + ";C:\msys64\ucrt64\bin",
  "User")

Open a new terminal and confirm both are visible:

go version
gcc --version

3. Build. Windows has no make, so invoke the compiler directly from PowerShell in the repo root:

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:

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, 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:

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:

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.

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. 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 clean Remove bin/, dist/ and haul.app/

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

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. The practical route to the others is fyne-cross, which does each build inside a Docker image carrying the right toolchain:

go install github.com/fyne-io/fyne-cross@latest
fyne-cross windows -arch=amd64

Usage

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.