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() }