Initial commit: haul — desktop SFTP client
A two-pane local/remote file browser over SSH, split out of dial so file transfer lives in its own tool. The two share ~/.ssh/config as their base. - internal/sshconf: ~/.ssh/config parsing, for the saved-host list. - internal/sshx: dialling and auth — agent, keys, password, host-key verification, ProxyJump. - internal/vfs: the FS interface both sides are driven through, implemented over the local disk and over SFTP. - internal/xfer: recursive copy engine with progress reporting, cancellation, conflict policy, and resume of partial transfers. - internal/ui: the Fyne GUI — connect window (saved hosts plus quick connect) and the two-pane browser session. Build with `make build`; the Makefile locates the Go toolchain at ~/.local/go, which is deliberately kept off PATH. Cross-compiling needs a per-target C toolchain because Fyne is cgo, so `make dist` builds the native binary only — the alternatives are documented in the Makefile. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,388 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"fyne.io/fyne/v2"
|
||||
"fyne.io/fyne/v2/container"
|
||||
"fyne.io/fyne/v2/dialog"
|
||||
"fyne.io/fyne/v2/theme"
|
||||
"fyne.io/fyne/v2/widget"
|
||||
"github.com/pkg/sftp"
|
||||
"golang.org/x/crypto/ssh"
|
||||
|
||||
"gitea.apointless.space/bsncubed/haul/internal/sshconf"
|
||||
"gitea.apointless.space/bsncubed/haul/internal/sshx"
|
||||
)
|
||||
|
||||
// connectWindow is haul's front door: the hosts already in ~/.ssh/config on one
|
||||
// tab, an ad-hoc target on the other. It stays alive behind each session so
|
||||
// closing a session returns here rather than quitting.
|
||||
type connectWindow struct {
|
||||
app fyne.App
|
||||
win fyne.Window
|
||||
|
||||
cfgPath string
|
||||
hosts []sshconf.Host // everything parsed from the config
|
||||
shown []sshconf.Host // what the filter currently leaves visible
|
||||
list *widget.List
|
||||
selected int
|
||||
|
||||
qHost *widget.Entry
|
||||
qPort *widget.Entry
|
||||
qUser *widget.Entry
|
||||
qPass *widget.Entry
|
||||
qKey *widget.Entry
|
||||
|
||||
tabs *container.AppTabs
|
||||
connectBtn *widget.Button
|
||||
status *widget.Label
|
||||
loadErr string // config problem found before the status label existed
|
||||
}
|
||||
|
||||
func newConnectWindow(app fyne.App) *connectWindow {
|
||||
c := &connectWindow{
|
||||
app: app,
|
||||
win: app.NewWindow("haul"),
|
||||
selected: -1,
|
||||
}
|
||||
|
||||
c.loadHosts()
|
||||
savedTab := container.NewTabItemWithIcon("Saved hosts", theme.ComputerIcon(), c.buildSavedTab())
|
||||
quickTab := container.NewTabItemWithIcon("Quick connect", theme.SearchIcon(), c.buildQuickTab())
|
||||
c.tabs = container.NewAppTabs(savedTab, quickTab)
|
||||
|
||||
c.status = widget.NewLabel(c.loadErr)
|
||||
c.status.Wrapping = fyne.TextWrapWord
|
||||
c.connectBtn = widget.NewButtonWithIcon("Connect", theme.ConfirmIcon(), c.connect)
|
||||
c.connectBtn.Importance = widget.HighImportance
|
||||
|
||||
bottom := container.NewBorder(widget.NewSeparator(), nil, nil, c.connectBtn, c.status)
|
||||
c.win.SetContent(container.NewBorder(nil, bottom, nil, nil, c.tabs))
|
||||
c.win.Resize(fyne.NewSize(620, 520))
|
||||
return c
|
||||
}
|
||||
|
||||
// --- saved hosts ---
|
||||
|
||||
func (c *connectWindow) loadHosts() {
|
||||
path, err := sshconf.DefaultConfigPath()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
c.cfgPath = path
|
||||
hosts, err := sshconf.Parse(path)
|
||||
if err != nil {
|
||||
// A malformed config shouldn't stop haul launching — quick connect still
|
||||
// works — so the problem is held here and shown once the window is built.
|
||||
c.loadErr = "Could not read " + path + ": " + err.Error()
|
||||
return
|
||||
}
|
||||
c.hosts = hosts
|
||||
c.shown = hosts
|
||||
}
|
||||
|
||||
func (c *connectWindow) buildSavedTab() fyne.CanvasObject {
|
||||
search := widget.NewEntry()
|
||||
search.SetPlaceHolder("Filter hosts…")
|
||||
search.OnChanged = c.filter
|
||||
|
||||
c.list = widget.NewList(
|
||||
func() int { return len(c.shown) },
|
||||
func() fyne.CanvasObject {
|
||||
return container.NewVBox(
|
||||
widget.NewLabelWithStyle("", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
|
||||
widget.NewLabel(""),
|
||||
)
|
||||
},
|
||||
func(id widget.ListItemID, o fyne.CanvasObject) {
|
||||
box, ok := o.(*fyne.Container)
|
||||
if !ok || id < 0 || id >= len(c.shown) {
|
||||
return
|
||||
}
|
||||
h := c.shown[id]
|
||||
alias := box.Objects[0].(*widget.Label)
|
||||
detail := box.Objects[1].(*widget.Label)
|
||||
alias.SetText(h.Alias)
|
||||
detail.SetText(describeHost(h))
|
||||
},
|
||||
)
|
||||
c.list.OnSelected = func(id widget.ListItemID) { c.selected = id }
|
||||
|
||||
// The "no hosts" note is only added when it has something to say — an empty
|
||||
// label still occupies a full row, which reads as a rendering glitch.
|
||||
top := fyne.CanvasObject(search)
|
||||
if len(c.hosts) == 0 {
|
||||
note := widget.NewLabel("No hosts found in " + c.cfgPath + " — use Quick connect.")
|
||||
note.Wrapping = fyne.TextWrapWord
|
||||
top = container.NewVBox(search, note)
|
||||
}
|
||||
|
||||
return container.NewBorder(top, nil, nil, nil, c.list)
|
||||
}
|
||||
|
||||
// describeHost is the one-line summary under each alias in the list.
|
||||
func describeHost(h sshconf.Host) string {
|
||||
target := h.HostName
|
||||
if target == "" {
|
||||
target = h.Alias
|
||||
}
|
||||
if h.User != "" {
|
||||
target = h.User + "@" + target
|
||||
}
|
||||
if h.Port != "" && h.Port != "22" {
|
||||
target += ":" + h.Port
|
||||
}
|
||||
if h.ProxyJump != "" {
|
||||
target += " (via " + h.ProxyJump + ")"
|
||||
}
|
||||
if len(h.Tags) > 0 {
|
||||
target += " [" + strings.Join(h.Tags, ", ") + "]"
|
||||
}
|
||||
return target
|
||||
}
|
||||
|
||||
func (c *connectWindow) filter(q string) {
|
||||
q = strings.ToLower(strings.TrimSpace(q))
|
||||
if q == "" {
|
||||
c.shown = c.hosts
|
||||
} else {
|
||||
c.shown = nil
|
||||
for _, h := range c.hosts {
|
||||
if strings.Contains(strings.ToLower(h.Alias), q) ||
|
||||
strings.Contains(strings.ToLower(h.HostName), q) ||
|
||||
strings.Contains(strings.ToLower(strings.Join(h.Tags, " ")), q) {
|
||||
c.shown = append(c.shown, h)
|
||||
}
|
||||
}
|
||||
}
|
||||
c.selected = -1
|
||||
c.list.UnselectAll()
|
||||
c.list.Refresh()
|
||||
}
|
||||
|
||||
// --- quick connect ---
|
||||
|
||||
func (c *connectWindow) buildQuickTab() fyne.CanvasObject {
|
||||
c.qHost = widget.NewEntry()
|
||||
c.qHost.SetPlaceHolder("hostname or IP")
|
||||
c.qPort = widget.NewEntry()
|
||||
c.qPort.SetText("22")
|
||||
c.qUser = widget.NewEntry()
|
||||
c.qUser.SetText(currentUser())
|
||||
c.qPass = widget.NewPasswordEntry()
|
||||
c.qPass.SetPlaceHolder("leave blank to use ssh-agent / key")
|
||||
c.qKey = widget.NewEntry()
|
||||
c.qKey.SetPlaceHolder("optional private key file")
|
||||
|
||||
browse := widget.NewButtonWithIcon("", theme.FolderOpenIcon(), func() {
|
||||
dialog.ShowFileOpen(func(r fyne.URIReadCloser, err error) {
|
||||
if err != nil || r == nil {
|
||||
return
|
||||
}
|
||||
defer r.Close()
|
||||
c.qKey.SetText(r.URI().Path())
|
||||
}, c.win)
|
||||
})
|
||||
|
||||
form := widget.NewForm(
|
||||
widget.NewFormItem("Host", c.qHost),
|
||||
widget.NewFormItem("Port", c.qPort),
|
||||
widget.NewFormItem("User", c.qUser),
|
||||
widget.NewFormItem("Password", c.qPass),
|
||||
widget.NewFormItem("Key file", container.NewBorder(nil, nil, nil, browse, c.qKey)),
|
||||
)
|
||||
// Enter anywhere in the form connects.
|
||||
c.qHost.OnSubmitted = func(string) { c.connect() }
|
||||
c.qPass.OnSubmitted = func(string) { c.connect() }
|
||||
|
||||
note := widget.NewLabel("Quick connect doesn't touch ~/.ssh/config. An unrecognised host key is " +
|
||||
"shown for confirmation before it's recorded in known_hosts.")
|
||||
note.Wrapping = fyne.TextWrapWord
|
||||
|
||||
return container.NewVBox(form, widget.NewSeparator(), note)
|
||||
}
|
||||
|
||||
func currentUser() string {
|
||||
for _, k := range []string{"USER", "USERNAME", "LOGNAME"} {
|
||||
if v := os.Getenv(k); v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// --- connecting ---
|
||||
|
||||
func (c *connectWindow) connect() {
|
||||
if c.tabs.SelectedIndex() == 1 {
|
||||
c.connectQuick()
|
||||
return
|
||||
}
|
||||
c.connectSaved()
|
||||
}
|
||||
|
||||
func (c *connectWindow) connectSaved() {
|
||||
if c.selected < 0 || c.selected >= len(c.shown) {
|
||||
c.setStatus("Select a host first.")
|
||||
return
|
||||
}
|
||||
alias := c.shown[c.selected].Alias
|
||||
c.start(alias, "", func(opts sshx.Options) (*ssh.Client, error) {
|
||||
return sshx.Connect(alias, opts)
|
||||
})
|
||||
}
|
||||
|
||||
func (c *connectWindow) connectQuick() {
|
||||
host := strings.TrimSpace(c.qHost.Text)
|
||||
if host == "" {
|
||||
c.setStatus("Enter a hostname.")
|
||||
return
|
||||
}
|
||||
// "user@host:port" typed into the host box is understood, since that's how
|
||||
// people paste a target.
|
||||
user := strings.TrimSpace(c.qUser.Text)
|
||||
port := strings.TrimSpace(c.qPort.Text)
|
||||
if u, rest, ok := strings.Cut(host, "@"); ok {
|
||||
user, host = u, rest
|
||||
}
|
||||
if h, p, ok := strings.Cut(host, ":"); ok {
|
||||
host, port = h, p
|
||||
}
|
||||
if port == "" {
|
||||
port = "22"
|
||||
}
|
||||
|
||||
p := sshx.Params{
|
||||
Alias: host,
|
||||
HostName: host,
|
||||
User: user,
|
||||
Port: port,
|
||||
}
|
||||
if key := strings.TrimSpace(c.qKey.Text); key != "" {
|
||||
p.IdentityFiles = []string{key}
|
||||
p.IdentitiesOnly = true
|
||||
}
|
||||
label := host
|
||||
if user != "" {
|
||||
label = user + "@" + host
|
||||
}
|
||||
c.start(label, c.qPass.Text, func(opts sshx.Options) (*ssh.Client, error) {
|
||||
return sshx.ConnectParams(p, opts)
|
||||
})
|
||||
}
|
||||
|
||||
// start runs a connection attempt off the UI thread and opens a session window
|
||||
// on success. dial is whichever sshx entry point the chosen tab implies.
|
||||
func (c *connectWindow) start(name, password string, dial func(sshx.Options) (*ssh.Client, error)) {
|
||||
c.connectBtn.Disable()
|
||||
c.setStatus("Connecting to " + name + "…")
|
||||
|
||||
go func() {
|
||||
var diagMu sync.Mutex
|
||||
var diag []string
|
||||
|
||||
opts := c.sshOptions(password, func(format string, args ...any) {
|
||||
diagMu.Lock()
|
||||
defer diagMu.Unlock()
|
||||
diag = append(diag, fmt.Sprintf(format, args...))
|
||||
})
|
||||
|
||||
client, err := dial(opts)
|
||||
if err != nil {
|
||||
diagMu.Lock()
|
||||
detail := strings.Join(diag, "\n · ")
|
||||
diagMu.Unlock()
|
||||
fyne.Do(func() {
|
||||
c.connectBtn.Enable()
|
||||
c.setStatus("")
|
||||
c.showConnectError(err, detail)
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
sf, err := sftp.NewClient(client)
|
||||
if err != nil {
|
||||
client.Close()
|
||||
fyne.Do(func() {
|
||||
c.connectBtn.Enable()
|
||||
c.setStatus("")
|
||||
dialog.ShowError(fmt.Errorf("opening SFTP subsystem: %w", err), c.win)
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
fyne.Do(func() {
|
||||
c.connectBtn.Enable()
|
||||
c.setStatus("")
|
||||
c.win.Hide()
|
||||
newSession(c.app, name, client, sf, func() { c.win.Show() })
|
||||
})
|
||||
}()
|
||||
}
|
||||
|
||||
// sshOptions wires sshx's blocking callbacks to dialogs on the UI thread. Each
|
||||
// callback is invoked from the connecting goroutine and waits for an answer.
|
||||
func (c *connectWindow) sshOptions(initialPassword string, logf func(string, ...any)) sshx.Options {
|
||||
var mu sync.Mutex
|
||||
cached := initialPassword
|
||||
|
||||
return sshx.Options{
|
||||
Passphrase: func(keyfile string) ([]byte, error) {
|
||||
reply := make(chan []byte, 1)
|
||||
fyne.Do(func() {
|
||||
askSecret(c.win, "Key passphrase", "Enter the passphrase for "+keyfile, reply)
|
||||
})
|
||||
return <-reply, nil
|
||||
},
|
||||
Password: func(user, host string) ([]byte, error) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
// A password typed on the quick-connect form is reused for the
|
||||
// keyboard-interactive round rather than prompting twice.
|
||||
if cached != "" {
|
||||
return []byte(cached), nil
|
||||
}
|
||||
reply := make(chan []byte, 1)
|
||||
target := host
|
||||
if user != "" {
|
||||
target = user + "@" + host
|
||||
}
|
||||
fyne.Do(func() {
|
||||
askSecret(c.win, "Password", "Password for "+target, reply)
|
||||
})
|
||||
b := <-reply
|
||||
cached = string(b)
|
||||
return b, nil
|
||||
},
|
||||
AcceptHostKey: func(host string, key ssh.PublicKey) (bool, error) {
|
||||
reply := make(chan bool, 1)
|
||||
fyne.Do(func() { askHostKey(c.win, host, key, reply) })
|
||||
return <-reply, nil
|
||||
},
|
||||
Logf: logf,
|
||||
}
|
||||
}
|
||||
|
||||
// showConnectError presents the failure with the auth diagnostics sshx
|
||||
// collected, which is usually what actually explains it.
|
||||
func (c *connectWindow) showConnectError(err error, detail string) {
|
||||
msg := err.Error()
|
||||
if detail != "" {
|
||||
msg += "\n\nAuth diagnostics:\n · " + detail
|
||||
}
|
||||
label := widget.NewLabel(msg)
|
||||
label.Wrapping = fyne.TextWrapWord
|
||||
d := dialog.NewCustom("Connection failed", "Close", container.NewVScroll(label), c.win)
|
||||
d.Resize(fyne.NewSize(560, 320))
|
||||
d.Show()
|
||||
}
|
||||
|
||||
func (c *connectWindow) setStatus(s string) { c.status.SetText(s) }
|
||||
|
||||
func (c *connectWindow) showAndRun() {
|
||||
c.win.ShowAndRun()
|
||||
}
|
||||
Reference in New Issue
Block a user