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,144 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
"fyne.io/fyne/v2"
|
||||
"fyne.io/fyne/v2/container"
|
||||
"fyne.io/fyne/v2/dialog"
|
||||
"fyne.io/fyne/v2/layout"
|
||||
"fyne.io/fyne/v2/widget"
|
||||
"golang.org/x/crypto/ssh"
|
||||
|
||||
"gitea.apointless.space/bsncubed/haul/internal/sshx"
|
||||
"gitea.apointless.space/bsncubed/haul/internal/xfer"
|
||||
)
|
||||
|
||||
// The prompts in this file are all shaped the same way, because they are all
|
||||
// answers a background goroutine is blocked waiting for: the goroutine makes a
|
||||
// buffered channel, asks for the prompt to be shown on the UI thread, and reads
|
||||
// its answer. Every dismissal path — button, escape, window close — must send
|
||||
// exactly once, or the transfer goroutine hangs forever holding the connection.
|
||||
// sync.Once is what guarantees the "exactly".
|
||||
|
||||
func confirmDialog(title, msg string, win fyne.Window, cb func(bool)) {
|
||||
dialog.ShowConfirm(title, msg, cb, win)
|
||||
}
|
||||
|
||||
func newFormDialog(title, confirm string, items []*widget.FormItem, win fyne.Window, cb func(bool)) dialog.Dialog {
|
||||
d := dialog.NewForm(title, confirm, "Cancel", items, cb, win)
|
||||
d.Resize(fyne.NewSize(420, 0))
|
||||
return d
|
||||
}
|
||||
|
||||
// conflictAnswer is the user's decision about one pre-existing destination file.
|
||||
type conflictAnswer struct {
|
||||
choice xfer.Conflict
|
||||
all bool // apply this decision to every remaining conflict
|
||||
}
|
||||
|
||||
// askConflict shows the overwrite prompt. It must be called on the UI thread.
|
||||
func askConflict(win fyne.Window, name string, reply chan<- conflictAnswer) {
|
||||
var once sync.Once
|
||||
send := func(a conflictAnswer) { once.Do(func() { reply <- a }) }
|
||||
|
||||
all := widget.NewCheck("Apply to all remaining conflicts", nil)
|
||||
msg := widget.NewLabel(fmt.Sprintf("%s already exists at the destination.", name))
|
||||
msg.Wrapping = fyne.TextWrapWord
|
||||
|
||||
var d *dialog.CustomDialog
|
||||
button := func(label string, c xfer.Conflict) *widget.Button {
|
||||
return widget.NewButton(label, func() {
|
||||
send(conflictAnswer{choice: c, all: all.Checked})
|
||||
d.Hide()
|
||||
})
|
||||
}
|
||||
overwrite := button("Overwrite", xfer.Overwrite)
|
||||
overwrite.Importance = widget.HighImportance
|
||||
|
||||
content := container.NewVBox(
|
||||
msg,
|
||||
all,
|
||||
container.NewHBox(layout.NewSpacer(),
|
||||
button("Skip", xfer.Skip),
|
||||
button("Keep both", xfer.Rename),
|
||||
overwrite,
|
||||
),
|
||||
)
|
||||
d = dialog.NewCustomWithoutButtons("File exists", content, win)
|
||||
// Dismissing without choosing is treated as "skip this file": the safe
|
||||
// reading of "I didn't answer" is "don't destroy anything".
|
||||
d.SetOnClosed(func() { send(conflictAnswer{choice: xfer.Skip}) })
|
||||
d.Resize(fyne.NewSize(460, 0))
|
||||
d.Show()
|
||||
}
|
||||
|
||||
// askSecret prompts for a password or key passphrase. An empty result means the
|
||||
// user declined, which callers pass on to sshx as "skip this method".
|
||||
func askSecret(win fyne.Window, title, prompt string, reply chan<- []byte) {
|
||||
var once sync.Once
|
||||
send := func(b []byte) { once.Do(func() { reply <- b }) }
|
||||
|
||||
entry := widget.NewPasswordEntry()
|
||||
label := widget.NewLabel(prompt)
|
||||
label.Wrapping = fyne.TextWrapWord
|
||||
|
||||
var d *dialog.CustomDialog
|
||||
submit := func() {
|
||||
send([]byte(entry.Text))
|
||||
d.Hide()
|
||||
}
|
||||
entry.OnSubmitted = func(string) { submit() }
|
||||
|
||||
ok := widget.NewButton("OK", submit)
|
||||
ok.Importance = widget.HighImportance
|
||||
cancel := widget.NewButton("Cancel", func() {
|
||||
send(nil)
|
||||
d.Hide()
|
||||
})
|
||||
|
||||
content := container.NewVBox(
|
||||
label,
|
||||
entry,
|
||||
container.NewHBox(layout.NewSpacer(), cancel, ok),
|
||||
)
|
||||
d = dialog.NewCustomWithoutButtons(title, content, win)
|
||||
d.SetOnClosed(func() { send(nil) })
|
||||
d.Resize(fyne.NewSize(420, 0))
|
||||
d.Show()
|
||||
win.Canvas().Focus(entry)
|
||||
}
|
||||
|
||||
// askHostKey shows the unknown-host prompt, the GUI equivalent of ssh's
|
||||
// "authenticity of host ... can't be established". The fingerprint is shown in
|
||||
// the same SHA256 form ssh prints, so it can be compared by eye.
|
||||
func askHostKey(win fyne.Window, host string, key ssh.PublicKey, reply chan<- bool) {
|
||||
var once sync.Once
|
||||
send := func(b bool) { once.Do(func() { reply <- b }) }
|
||||
|
||||
msg := widget.NewLabel(fmt.Sprintf(
|
||||
"The authenticity of host %s can't be established.\n\n%s key fingerprint:", host, key.Type()))
|
||||
msg.Wrapping = fyne.TextWrapWord
|
||||
fp := widget.NewLabelWithStyle(sshx.FingerprintSHA256(key), fyne.TextAlignCenter, fyne.TextStyle{Monospace: true})
|
||||
warn := widget.NewLabel("Only continue if you recognise this fingerprint. It will be added to your known_hosts file.")
|
||||
warn.Wrapping = fyne.TextWrapWord
|
||||
|
||||
var d *dialog.CustomDialog
|
||||
accept := widget.NewButton("Connect and record key", func() {
|
||||
send(true)
|
||||
d.Hide()
|
||||
})
|
||||
accept.Importance = widget.HighImportance
|
||||
reject := widget.NewButton("Cancel", func() {
|
||||
send(false)
|
||||
d.Hide()
|
||||
})
|
||||
|
||||
content := container.NewVBox(msg, fp, warn,
|
||||
container.NewHBox(layout.NewSpacer(), reject, accept))
|
||||
d = dialog.NewCustomWithoutButtons("Unknown host key", content, win)
|
||||
d.SetOnClosed(func() { send(false) })
|
||||
d.Resize(fyne.NewSize(520, 0))
|
||||
d.Show()
|
||||
}
|
||||
Reference in New Issue
Block a user