6b52e83bdc
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>
389 lines
10 KiB
Go
389 lines
10 KiB
Go
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()
|
|
}
|