Replace Python launcher with native Go UI

This commit is contained in:
Storm Dragon
2026-09-03 11:43:21 -04:00
parent 0d4daeb45a
commit afd4148e9a
14 changed files with 2428 additions and 961 deletions
+234
View File
@@ -0,0 +1,234 @@
package main
import (
"bytes"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
)
func openssl_path() (string, error) {
path, err := exec.LookPath("openssl")
if err != nil {
return "", fmt.Errorf("required command not found: openssl")
}
return path, nil
}
func openssl_subject_name(commonName string) (string, error) {
if strings.ContainsAny(commonName, "\x00\r\n") {
return "", fmt.Errorf("certificate name cannot contain control characters")
}
escaped := strings.NewReplacer(`\`, `\\`, `/`, `\/`).Replace(commonName)
return "/CN=" + escaped, nil
}
func install_private_bytes(path string, contents []byte) error {
return write_private_file(path, func(writer io.Writer) error {
_, err := writer.Write(contents)
return err
})
}
func (app *App) generate_certificate() error {
openssl, err := openssl_path()
if err != nil {
return app.ui.message(err.Error())
}
if _, err := os.Stat(app.paths.CertFile); err == nil {
replace, err := app.ui.confirm("A certificate already exists. Replace it? This may affect your registered identity on servers.")
if err != nil || !replace {
return err
}
} else if !os.IsNotExist(err) {
return app.ui.message("Could not inspect the certificate: " + err.Error())
}
commonName, cancelled, err := app.ui.input("Enter a name for your certificate, such as your username:", "barnard", false)
if err != nil || cancelled {
return err
}
commonName = strings.TrimSpace(commonName)
if commonName == "" {
commonName = "barnard"
}
subject, err := openssl_subject_name(commonName)
if err != nil {
return app.ui.message(err.Error())
}
keyFile, err := os.CreateTemp(app.paths.ConfigDir, ".certificate-key-")
if err != nil {
return app.ui.message("Failed to create a certificate: " + err.Error())
}
keyPath := keyFile.Name()
keyFile.Close()
defer os.Remove(keyPath)
certificateFile, err := os.CreateTemp(app.paths.ConfigDir, ".certificate-public-")
if err != nil {
return app.ui.message("Failed to create a certificate: " + err.Error())
}
certificatePath := certificateFile.Name()
certificateFile.Close()
defer os.Remove(certificatePath)
command := exec.Command(openssl, "req", "-x509", "-newkey", "rsa:2048", "-keyout", keyPath, "-out", certificatePath, "-days", "3650", "-nodes", "-subj", subject)
if output, err := command.CombinedOutput(); err != nil {
app.log_line("OpenSSL certificate generation failed: " + strings.TrimSpace(string(output)))
return app.ui.message("Failed to generate certificate.")
}
privateKey, err := os.ReadFile(keyPath)
if err != nil {
return app.ui.message("Failed to read generated private key: " + err.Error())
}
certificate, err := os.ReadFile(certificatePath)
if err != nil {
return app.ui.message("Failed to read generated certificate: " + err.Error())
}
combined := append(append(append([]byte(nil), privateKey...), '\n'), certificate...)
if err := install_private_bytes(app.paths.CertFile, combined); err != nil {
return app.ui.message("Failed to install generated certificate: " + err.Error())
}
app.log_line("Generated certificate " + app.paths.CertFile)
return app.ui.message("Certificate generated successfully.")
}
func (app *App) view_certificate() error {
openssl, err := openssl_path()
if err != nil {
return app.ui.message(err.Error())
}
if _, err := os.Stat(app.paths.CertFile); os.IsNotExist(err) {
return app.ui.message("No certificate found: " + app.paths.CertFile)
} else if err != nil {
return app.ui.message("Could not inspect the certificate: " + err.Error())
}
command := exec.Command(openssl, "x509", "-in", app.paths.CertFile, "-noout", "-subject", "-dates", "-fingerprint")
output, err := command.CombinedOutput()
if err != nil || strings.TrimSpace(string(output)) == "" {
return app.ui.message("Could not read certificate information.")
}
return app.ui.message(strings.TrimSpace(string(output)))
}
func validate_certificate_pair(openssl, path string) error {
certificateCheck := exec.Command(openssl, "x509", "-in", path, "-noout")
if err := certificateCheck.Run(); err != nil {
return fmt.Errorf("the file does not contain a valid PEM certificate")
}
keyCheck := exec.Command(openssl, "pkey", "-in", path, "-check", "-noout")
if err := keyCheck.Run(); err != nil {
return fmt.Errorf("the file does not contain a valid private key")
}
certificatePublic, err := exec.Command(openssl, "x509", "-in", path, "-pubkey", "-noout").Output()
if err != nil {
return fmt.Errorf("could not read the certificate public key")
}
keyPublic, err := exec.Command(openssl, "pkey", "-in", path, "-pubout").Output()
if err != nil {
return fmt.Errorf("could not read the private key public key")
}
if !bytes.Equal(bytes.TrimSpace(certificatePublic), bytes.TrimSpace(keyPublic)) {
return fmt.Errorf("the certificate and private key do not match")
}
return nil
}
func (app *App) import_certificate() error {
openssl, err := openssl_path()
if err != nil {
return app.ui.message(err.Error())
}
rawPath, cancelled, err := app.ui.input("Enter the full path to a PEM file containing both the certificate and private key:", "", false)
if err != nil || cancelled {
return err
}
rawPath = strings.TrimSpace(rawPath)
if rawPath == "" {
return nil
}
path, err := expand_user_path(rawPath)
if err != nil {
return app.ui.message("Could not resolve certificate path: " + err.Error())
}
info, err := os.Stat(path)
if os.IsNotExist(err) {
return app.ui.message("File not found: " + path)
}
if err != nil {
return app.ui.message("Could not inspect certificate file: " + err.Error())
}
if !info.Mode().IsRegular() {
return app.ui.message("Certificate path is not a regular file: " + path)
}
contents, err := os.ReadFile(path)
if err != nil {
return app.ui.message("Failed to read certificate: " + err.Error())
}
validationFile, err := os.CreateTemp(app.paths.ConfigDir, ".certificate-import-")
if err != nil {
return app.ui.message("Failed to prepare certificate validation: " + err.Error())
}
validationPath := validationFile.Name()
defer os.Remove(validationPath)
if err := validationFile.Chmod(0600); err != nil {
validationFile.Close()
return app.ui.message("Failed to protect certificate validation file: " + err.Error())
}
if _, err := validationFile.Write(contents); err != nil {
validationFile.Close()
return app.ui.message("Failed to prepare certificate validation: " + err.Error())
}
if err := validationFile.Close(); err != nil {
return app.ui.message("Failed to prepare certificate validation: " + err.Error())
}
if err := validate_certificate_pair(openssl, validationPath); err != nil {
return app.ui.message(err.Error() + ".")
}
if _, err := os.Stat(app.paths.CertFile); err == nil {
samePath := false
sourcePath, sourceErr := filepath.EvalSymlinks(path)
destinationPath, destinationErr := filepath.EvalSymlinks(app.paths.CertFile)
if sourceErr == nil && destinationErr == nil {
samePath = sourcePath == destinationPath
}
if samePath {
return app.ui.message("That certificate is already the active Barnard certificate.")
}
replace, err := app.ui.confirm("A certificate already exists. Replace it?")
if err != nil || !replace {
return err
}
} else if !os.IsNotExist(err) {
return app.ui.message("Could not inspect the existing certificate: " + err.Error())
}
if err := install_private_bytes(app.paths.CertFile, contents); err != nil {
return app.ui.message("Failed to import certificate: " + err.Error())
}
app.log_line("Imported certificate " + app.paths.CertFile)
return app.ui.message("Certificate imported successfully.")
}
func (app *App) manage_certificate() error {
options := []string{"Generate", "View", "Import", "Go Back"}
for {
selection, cancelled, err := app.ui.menu(options)
if err != nil || cancelled || selection == len(options)-1 {
return err
}
switch options[selection] {
case "Generate":
err = app.generate_certificate()
case "View":
err = app.view_certificate()
case "Import":
err = app.import_certificate()
}
if err != nil {
return err
}
}
}
+55
View File
@@ -0,0 +1,55 @@
package main
import (
"os"
"os/exec"
"path/filepath"
"testing"
)
func TestOpenSSLSubjectNameEscapesSlashAndBackslash(t *testing.T) {
got, err := openssl_subject_name(`Example/User\Name`)
if err != nil {
t.Fatal(err)
}
want := `/CN=Example\/User\\Name`
if got != want {
t.Fatalf("subject = %q; want %q", got, want)
}
}
func TestOpenSSLSubjectNameRejectsLineBreak(t *testing.T) {
if _, err := openssl_subject_name("Example\nUser"); err == nil {
t.Fatal("certificate subject accepted a line break")
}
}
func TestValidateCertificatePairAcceptsMatchingPEM(t *testing.T) {
openssl, err := exec.LookPath("openssl")
if err != nil {
t.Skip("openssl is not installed")
}
directory := t.TempDir()
keyPath := filepath.Join(directory, "key.pem")
certificatePath := filepath.Join(directory, "certificate.pem")
combinedPath := filepath.Join(directory, "combined.pem")
command := exec.Command(openssl, "req", "-x509", "-newkey", "rsa:2048", "-keyout", keyPath, "-out", certificatePath, "-days", "1", "-nodes", "-subj", "/CN=Test")
if output, err := command.CombinedOutput(); err != nil {
t.Fatalf("generate test certificate: %v: %s", err, output)
}
key, err := os.ReadFile(keyPath)
if err != nil {
t.Fatal(err)
}
certificate, err := os.ReadFile(certificatePath)
if err != nil {
t.Fatal(err)
}
combined := append(append(append([]byte(nil), key...), '\n'), certificate...)
if err := os.WriteFile(combinedPath, combined, 0600); err != nil {
t.Fatal(err)
}
if err := validate_certificate_pair(openssl, combinedPath); err != nil {
t.Fatal(err)
}
}
+156
View File
@@ -0,0 +1,156 @@
package main
import (
"errors"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"syscall"
"time"
)
func initialize_paths(paths Paths) error {
if err := os.MkdirAll(paths.ConfigDir, 0700); err != nil {
return fmt.Errorf("create configuration directory: %w", err)
}
if err := os.MkdirAll(paths.CacheDir, 0700); err != nil {
return fmt.Errorf("create cache directory: %w", err)
}
logHandle, err := os.OpenFile(paths.LogFile, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0600)
if err != nil {
return fmt.Errorf("initialize launcher log: %w", err)
}
if err := logHandle.Chmod(0600); err != nil {
logHandle.Close()
return fmt.Errorf("protect launcher log: %w", err)
}
if err := logHandle.Close(); err != nil {
return fmt.Errorf("initialize launcher log: %w", err)
}
return nil
}
func write_private_file(path string, writeContent func(io.Writer) error) error {
directory := filepath.Dir(path)
if err := os.MkdirAll(directory, 0700); err != nil {
return err
}
temporary, err := os.CreateTemp(directory, filepath.Base(path)+".tmp-")
if err != nil {
return err
}
temporaryPath := temporary.Name()
defer os.Remove(temporaryPath)
if err := temporary.Chmod(0600); err != nil {
temporary.Close()
return err
}
if err := writeContent(temporary); err != nil {
temporary.Close()
return err
}
if err := temporary.Sync(); err != nil {
temporary.Close()
return err
}
if err := temporary.Close(); err != nil {
return err
}
if err := os.Rename(temporaryPath, path); err != nil {
return err
}
directoryHandle, err := os.Open(directory)
if err == nil {
_ = directoryHandle.Sync()
_ = directoryHandle.Close()
}
return nil
}
func write_password_file(configDir, password string) (string, error) {
if strings.ContainsAny(password, "\r\n") {
return "", fmt.Errorf("password cannot contain line breaks")
}
if err := os.MkdirAll(configDir, 0700); err != nil {
return "", err
}
file, err := os.CreateTemp(configDir, ".password-")
if err != nil {
return "", err
}
path := file.Name()
failed := true
defer func() {
if failed {
_ = os.Remove(path)
}
}()
if err := file.Chmod(0600); err != nil {
file.Close()
return "", err
}
if _, err := io.WriteString(file, password); err != nil {
file.Close()
return "", err
}
if err := file.Sync(); err != nil {
file.Close()
return "", err
}
if err := file.Close(); err != nil {
return "", err
}
failed = false
return path, nil
}
func expand_user_path(path string) (string, error) {
if path != "~" && !strings.HasPrefix(path, "~/") {
return path, nil
}
homeDir, err := os.UserHomeDir()
if err != nil {
return "", err
}
if path == "~" {
return homeDir, nil
}
return filepath.Join(homeDir, strings.TrimPrefix(path, "~/")), nil
}
func run_external(ui *TerminalUI, command *exec.Cmd) error {
if err := command.Start(); err != nil {
return err
}
done := make(chan error, 1)
go func() {
done <- command.Wait()
}()
select {
case err := <-done:
if receivedSignal := ui.take_termination(); receivedSignal != nil {
return &terminalSignalError{signal: receivedSignal}
}
var exitError *exec.ExitError
if errors.As(err, &exitError) {
if status, ok := exitError.Sys().(syscall.WaitStatus); ok && status.Signaled() {
return &terminalSignalError{signal: status.Signal()}
}
}
return err
case receivedSignal := <-ui.termination:
_ = command.Process.Signal(receivedSignal)
timer := time.NewTimer(3 * time.Second)
defer timer.Stop()
select {
case <-done:
case <-timer.C:
_ = command.Process.Kill()
<-done
}
return &terminalSignalError{signal: receivedSignal}
}
}
+98
View File
@@ -0,0 +1,98 @@
package main
import (
"errors"
"os"
"os/exec"
"path/filepath"
"syscall"
"testing"
"time"
)
func TestInitializePathsCreatesProtectedEmptyLog(t *testing.T) {
root := t.TempDir()
paths := Paths{
ConfigDir: filepath.Join(root, "config"),
CacheDir: filepath.Join(root, "cache"),
LogFile: filepath.Join(root, "cache", "barnard-ui.log"),
}
if err := initialize_paths(paths); err != nil {
t.Fatal(err)
}
info, err := os.Stat(paths.LogFile)
if err != nil {
t.Fatal(err)
}
if info.Mode().Perm() != 0600 || info.Size() != 0 {
t.Fatalf("log mode and size = %o, %d; want 600, 0", info.Mode().Perm(), info.Size())
}
}
func TestRunExternalForwardsTerminationSignal(t *testing.T) {
sleep, err := exec.LookPath("sleep")
if err != nil {
t.Skip("sleep is not installed")
}
ui := &TerminalUI{termination: make(chan os.Signal, 1)}
ui.termination <- syscall.SIGTERM
started := time.Now()
err = run_external(ui, exec.Command(sleep, "30"))
var signalErr *terminalSignalError
if !errors.As(err, &signalErr) || signalErr.signal != syscall.SIGTERM {
t.Fatalf("run_external error = %v; want SIGTERM error", err)
}
if time.Since(started) > 5*time.Second {
t.Fatal("run_external did not promptly terminate the child")
}
}
func TestRunExternalReportsChildSignal(t *testing.T) {
shell, err := exec.LookPath("sh")
if err != nil {
t.Skip("sh is not installed")
}
ui := &TerminalUI{termination: make(chan os.Signal, 1)}
err = run_external(ui, exec.Command(shell, "-c", "kill -TERM $$"))
var signalErr *terminalSignalError
if !errors.As(err, &signalErr) || signalErr.signal != syscall.SIGTERM {
t.Fatalf("run_external error = %v; want child SIGTERM error", err)
}
}
func TestWritePasswordFilePreservesPasswordAndMode(t *testing.T) {
path, err := write_password_file(t.TempDir(), " secret value ")
if err != nil {
t.Fatal(err)
}
defer os.Remove(path)
contents, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if string(contents) != " secret value " {
t.Fatalf("password contents = %q", string(contents))
}
info, err := os.Stat(path)
if err != nil {
t.Fatal(err)
}
if info.Mode().Perm() != 0600 {
t.Fatalf("password file mode = %o; want 600", info.Mode().Perm())
}
}
func TestExpandUserPath(t *testing.T) {
home, err := os.UserHomeDir()
if err != nil {
t.Fatal(err)
}
got, err := expand_user_path("~/certificate.pem")
if err != nil {
t.Fatal(err)
}
want := filepath.Join(home, "certificate.pem")
if got != want {
t.Fatalf("expanded path = %q; want %q", got, want)
}
}
+275
View File
@@ -0,0 +1,275 @@
package main
import (
"archive/tar"
"bufio"
"compress/gzip"
"errors"
"fmt"
"io"
"io/fs"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"time"
)
var safeFilenamePattern = regexp.MustCompile(`[^A-Za-z0-9_.-]`)
func (app *App) log_line(line string) {
handle, err := os.OpenFile(app.paths.LogFile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0600)
if err != nil {
return
}
defer handle.Close()
_, _ = fmt.Fprintln(handle, line)
}
func load_logging_pref(path string) bool {
file, err := os.Open(path)
if err != nil {
return false
}
defer file.Close()
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
key, value, found := strings.Cut(line, "=")
if !found || !strings.EqualFold(strings.TrimSpace(key), "saveSessionLogs") {
continue
}
switch strings.ToLower(strings.TrimSpace(value)) {
case "1", "true", "yes", "on":
return true
default:
return false
}
}
return false
}
func save_logging_pref(path string, enabled bool) error {
return write_private_file(path, func(writer io.Writer) error {
value := 0
if enabled {
value = 1
}
_, err := fmt.Fprintf(writer, "saveSessionLogs=%d\n", value)
return err
})
}
func sanitize_filename(value string) string {
return safeFilenamePattern.ReplaceAllString(value, "_")
}
func prepare_session_log(logDir, serverName string) (string, error) {
if err := os.MkdirAll(logDir, 0700); err != nil {
return "", err
}
path := filepath.Join(logDir, fmt.Sprintf("%s-%s.log", sanitize_filename(serverName), time.Now().Format("2006-01-02")))
handle, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0600)
if err != nil {
return "", err
}
if err := handle.Chmod(0600); err != nil {
handle.Close()
return "", err
}
if err := handle.Close(); err != nil {
return "", err
}
return path, nil
}
func log_files(logDir string) ([]string, error) {
entries, err := os.ReadDir(logDir)
if err != nil {
return nil, err
}
paths := []string{}
for _, entry := range entries {
if entry.Type().IsRegular() && strings.HasSuffix(entry.Name(), ".log") {
paths = append(paths, filepath.Join(logDir, entry.Name()))
}
}
return paths, nil
}
func create_log_archive(logDir, cacheDir string) (string, error) {
paths, err := log_files(logDir)
if err != nil {
return "", err
}
if len(paths) == 0 {
return "", fs.ErrNotExist
}
if err := os.MkdirAll(cacheDir, 0700); err != nil {
return "", err
}
archiveFile, err := os.CreateTemp(cacheDir, "barnard-logs-*.tar.gz")
if err != nil {
return "", err
}
archivePath := archiveFile.Name()
failed := true
defer func() {
if failed {
_ = os.Remove(archivePath)
}
}()
if err := archiveFile.Chmod(0600); err != nil {
archiveFile.Close()
return "", err
}
gzipWriter := gzip.NewWriter(archiveFile)
tarWriter := tar.NewWriter(gzipWriter)
for _, path := range paths {
info, err := os.Stat(path)
if err != nil {
tarWriter.Close()
gzipWriter.Close()
archiveFile.Close()
return "", err
}
header, err := tar.FileInfoHeader(info, "")
if err != nil {
tarWriter.Close()
gzipWriter.Close()
archiveFile.Close()
return "", err
}
header.Name = filepath.Base(path)
header.Mode = 0600
if err := tarWriter.WriteHeader(header); err != nil {
tarWriter.Close()
gzipWriter.Close()
archiveFile.Close()
return "", err
}
file, err := os.Open(path)
if err != nil {
tarWriter.Close()
gzipWriter.Close()
archiveFile.Close()
return "", err
}
_, copyErr := io.Copy(tarWriter, file)
closeErr := file.Close()
if copyErr != nil {
tarWriter.Close()
gzipWriter.Close()
archiveFile.Close()
return "", copyErr
}
if closeErr != nil {
tarWriter.Close()
gzipWriter.Close()
archiveFile.Close()
return "", closeErr
}
}
if err := tarWriter.Close(); err != nil {
gzipWriter.Close()
archiveFile.Close()
return "", err
}
if err := gzipWriter.Close(); err != nil {
archiveFile.Close()
return "", err
}
if err := archiveFile.Sync(); err != nil {
archiveFile.Close()
return "", err
}
if err := archiveFile.Close(); err != nil {
return "", err
}
failed = false
return archivePath, nil
}
func (app *App) send_logs() error {
wormhole, err := exec.LookPath("wormhole")
if err != nil {
return app.ui.message("Required command not found: wormhole")
}
archivePath, err := create_log_archive(app.paths.LogDir, app.paths.CacheDir)
if errors.Is(err, fs.ErrNotExist) || errors.Is(err, os.ErrNotExist) {
return app.ui.message("No logs to send. Logs are saved to: " + app.paths.LogDir)
}
if err != nil {
return app.ui.message("Could not create log archive: " + err.Error())
}
defer os.Remove(archivePath)
if err := app.ui.message("Wormhole will display the transfer code in the normal terminal. Press Ctrl+C there to cancel the transfer."); err != nil {
return err
}
command := exec.Command(wormhole, "send", archivePath)
command.Stdin = os.Stdin
command.Stdout = os.Stdout
command.Stderr = os.Stderr
app.ui.close()
commandErr := run_external(app.ui, command)
var signalErr *terminalSignalError
if errors.As(commandErr, &signalErr) {
return signalErr
}
if err := app.ui.open(); err != nil {
return err
}
if receivedSignal := app.ui.take_termination(); receivedSignal != nil {
return &terminalSignalError{signal: receivedSignal}
}
if commandErr == nil {
app.log_line("Sent log archive with wormhole")
return app.ui.message("Logs sent successfully.")
}
return app.ui.message("Log transfer did not complete successfully: " + commandErr.Error())
}
func (app *App) toggle_session_logging() error {
question := "Session logging is currently disabled. Enable saving logs to the logs directory?"
if app.saveSessionLogs {
question = "Session logging is currently enabled. Disable it?"
}
confirmed, err := app.ui.confirm(question)
if err != nil || !confirmed {
return err
}
newValue := !app.saveSessionLogs
if err := save_logging_pref(app.paths.LogPrefsFile, newValue); err != nil {
return app.ui.message("Could not save logging preference: " + err.Error())
}
app.saveSessionLogs = newValue
return nil
}
func (app *App) manage_logs() error {
for {
toggleLabel := "Enable logs"
if app.saveSessionLogs {
toggleLabel = "Disable logs"
}
options := []string{toggleLabel, "Send logs with wormhole", "Go Back"}
selection, cancelled, err := app.ui.menu(options)
if err != nil || cancelled || selection == len(options)-1 {
return err
}
switch selection {
case 0:
err = app.toggle_session_logging()
case 1:
err = app.send_logs()
}
if err != nil {
return err
}
}
}
+82
View File
@@ -0,0 +1,82 @@
package main
import (
"archive/tar"
"compress/gzip"
"io"
"os"
"path/filepath"
"reflect"
"testing"
)
func TestLoggingPreferenceRoundTrip(t *testing.T) {
path := filepath.Join(t.TempDir(), "config", "logging.conf")
if load_logging_pref(path) {
t.Fatal("missing logging preference must default to disabled")
}
if err := save_logging_pref(path, true); err != nil {
t.Fatal(err)
}
if !load_logging_pref(path) {
t.Fatal("saved logging preference was not loaded")
}
info, err := os.Stat(path)
if err != nil {
t.Fatal(err)
}
if info.Mode().Perm() != 0600 {
t.Fatalf("logging preference mode = %o; want 600", info.Mode().Perm())
}
}
func TestCreateLogArchiveIncludesOnlyRegularLogFiles(t *testing.T) {
root := t.TempDir()
logDir := filepath.Join(root, "logs")
cacheDir := filepath.Join(root, "cache")
if err := os.MkdirAll(logDir, 0700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(logDir, "first.log"), []byte("first\n"), 0600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(logDir, "ignore.txt"), []byte("ignore\n"), 0600); err != nil {
t.Fatal(err)
}
archivePath, err := create_log_archive(logDir, cacheDir)
if err != nil {
t.Fatal(err)
}
defer os.Remove(archivePath)
file, err := os.Open(archivePath)
if err != nil {
t.Fatal(err)
}
defer file.Close()
gzipReader, err := gzip.NewReader(file)
if err != nil {
t.Fatal(err)
}
defer gzipReader.Close()
tarReader := tar.NewReader(gzipReader)
names := []string{}
contents := ""
for {
header, err := tarReader.Next()
if err == io.EOF {
break
}
if err != nil {
t.Fatal(err)
}
names = append(names, header.Name)
data, err := io.ReadAll(tarReader)
if err != nil {
t.Fatal(err)
}
contents += string(data)
}
if !reflect.DeepEqual(names, []string{"first.log"}) || contents != "first\n" {
t.Fatalf("archive contains names %v and data %q", names, contents)
}
}
+677
View File
@@ -0,0 +1,677 @@
package main
import (
"bufio"
"errors"
"flag"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
"syscall"
)
const (
defaultPort = 64738
programName = "barnard-ui"
)
var (
bracketedAddressPattern = regexp.MustCompile(`^\[([^\]]+)\](?::([0-9]+))?$`)
hostPortPattern = regexp.MustCompile(`^(.+):([0-9]+)$`)
)
type Server struct {
Name string
Address string
Port int
Password string
Insecure bool
}
type ConfigError struct {
Line int
Content string
Problem string
}
func (e *ConfigError) Error() string {
return fmt.Sprintf("line %d: %s: %q", e.Line, e.Problem, e.Content)
}
type Paths struct {
ConfigDir string
CacheDir string
ServerFile string
CertFile string
BarnardTOML string
LogFile string
LogDir string
LogPrefsFile string
}
func default_paths(configDirOverride string) (Paths, error) {
homeDir, err := os.UserHomeDir()
if err != nil {
return Paths{}, err
}
configDir := filepath.Join(homeDir, ".config", "barnard")
if configDirOverride != "" {
configDir, err = filepath.Abs(configDirOverride)
if err != nil {
return Paths{}, err
}
}
cacheDir, err := os.UserCacheDir()
if err != nil {
return Paths{}, err
}
return Paths{
ConfigDir: configDir,
CacheDir: cacheDir,
ServerFile: filepath.Join(configDir, "servers.conf"),
CertFile: filepath.Join(configDir, "barnard.pem"),
BarnardTOML: filepath.Join(homeDir, ".barnard.toml"),
LogFile: filepath.Join(cacheDir, "barnard-ui.log"),
LogDir: filepath.Join(homeDir, "barnard-logs"),
LogPrefsFile: filepath.Join(configDir, "logging.conf"),
}, nil
}
type App struct {
ui *TerminalUI
paths Paths
servers map[string]Server
saveSessionLogs bool
}
func parse_port(raw string) (int, bool) {
port, err := strconv.Atoi(raw)
return port, err == nil && port >= 1 && port <= 65535
}
func parse_host_port(raw string) (string, int, bool) {
raw = strings.TrimSpace(raw)
if raw == "" || strings.ContainsAny(raw, "\r\n") {
return "", 0, false
}
if matches := bracketedAddressPattern.FindStringSubmatch(raw); matches != nil {
address := strings.TrimSpace(matches[1])
if address == "" {
return "", 0, false
}
if matches[2] == "" {
return address, defaultPort, true
}
port, valid := parse_port(matches[2])
if !valid {
return "", 0, false
}
return address, port, true
}
if matches := hostPortPattern.FindStringSubmatch(raw); matches != nil {
address := strings.TrimSpace(matches[1])
port, valid := parse_port(matches[2])
if address == "" || strings.Contains(address, ":") || !valid {
return "", 0, false
}
return address, port, true
}
if strings.Contains(raw, ":") {
return "", 0, false
}
return raw, defaultPort, true
}
func parse_server_input(raw string) (string, int, string, bool) {
raw = strings.TrimSpace(raw)
if raw == "" {
return "", 0, "", false
}
password := ""
hostPort := raw
if before, after, found := strings.Cut(raw, "@"); found {
password = before
hostPort = after
}
if strings.ContainsAny(password, "\r\n") {
return "", 0, "", false
}
address, port, valid := parse_host_port(hostPort)
return address, port, password, valid
}
func normalize_insecure(raw string) bool {
switch strings.ToLower(strings.TrimSpace(raw)) {
case "1", "true", "yes", "on":
return true
default:
return false
}
}
func error_content(raw string) string {
key, _, found := strings.Cut(raw, "=")
if found {
return strings.TrimSpace(key) + " = <redacted>"
}
return "<redacted>"
}
func load_servers(path string) (map[string]Server, []string, error) {
servers := make(map[string]Server)
file, err := os.Open(path)
if errors.Is(err, os.ErrNotExist) {
return servers, nil, nil
}
if err != nil {
return nil, nil, err
}
defer file.Close()
var current *Server
currentLine := 0
warnings := []string{}
finishServer := func(line int) error {
if current == nil {
return nil
}
if current.Name == "" {
return &ConfigError{Line: line, Content: "[server]", Problem: "server entry is missing a name"}
}
if current.Address == "" {
return &ConfigError{Line: line, Content: "[server]", Problem: "server entry is missing an address"}
}
if _, exists := servers[current.Name]; exists {
warnings = append(warnings, fmt.Sprintf("Duplicate server name %q near line %d; keeping the last entry.", current.Name, line))
}
servers[current.Name] = *current
return nil
}
scanner := bufio.NewScanner(file)
for lineNumber := 1; scanner.Scan(); lineNumber++ {
raw := scanner.Text()
line := strings.TrimSpace(raw)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") {
if err := finishServer(currentLine); err != nil {
return nil, nil, err
}
if !strings.EqualFold(strings.TrimSpace(line[1:len(line)-1]), "server") {
return nil, nil, &ConfigError{Line: lineNumber, Content: raw, Problem: "unexpected section"}
}
current = &Server{Port: defaultPort}
currentLine = lineNumber
continue
}
if current == nil {
return nil, nil, &ConfigError{Line: lineNumber, Content: error_content(raw), Problem: "key outside of a [server] section"}
}
key, value, found := strings.Cut(raw, "=")
if !found {
return nil, nil, &ConfigError{Line: lineNumber, Content: error_content(raw), Problem: "expected key=value"}
}
key = strings.ToLower(strings.TrimSpace(key))
if key == "password" {
if strings.HasPrefix(value, " ") || strings.HasPrefix(value, "\t") {
value = value[1:]
}
} else {
value = strings.TrimSpace(value)
}
switch key {
case "name":
current.Name = value
case "address", "host":
current.Address = value
case "port":
port, valid := parse_port(value)
if !valid {
return nil, nil, &ConfigError{Line: lineNumber, Content: error_content(raw), Problem: "invalid port"}
}
current.Port = port
case "password":
current.Password = value
case "insecure":
current.Insecure = normalize_insecure(value)
default:
return nil, nil, &ConfigError{Line: lineNumber, Content: error_content(raw), Problem: fmt.Sprintf("unknown key %q", key)}
}
}
if err := scanner.Err(); err != nil {
return nil, nil, err
}
if err := finishServer(currentLine); err != nil {
return nil, nil, err
}
return servers, warnings, nil
}
func write_server_list(writer io.Writer, servers map[string]Server) error {
names := make([]string, 0, len(servers))
for name := range servers {
names = append(names, name)
}
sort.Strings(names)
if _, err := fmt.Fprintln(writer, "# barnard-ui server list"); err != nil {
return err
}
if _, err := fmt.Fprint(writer, "# Passwords are stored only when provided; this file is written with mode 0600.\n\n"); err != nil {
return err
}
for _, name := range names {
server := servers[name]
if strings.ContainsAny(server.Name+server.Address+server.Password, "\r\n") {
return fmt.Errorf("server %q contains a line break", name)
}
if _, err := fmt.Fprintf(writer, "[server]\nname = %s\naddress = %s\nport = %d\npassword = %s\ninsecure = %t\n\n",
server.Name, server.Address, server.Port, server.Password, server.Insecure); err != nil {
return err
}
}
return nil
}
func copy_file(source, destination string) error {
input, err := os.Open(source)
if err != nil {
return err
}
defer input.Close()
output, err := os.OpenFile(destination, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0600)
if err != nil {
return err
}
if err := output.Chmod(0600); err != nil {
output.Close()
return err
}
_, copyErr := io.Copy(output, input)
closeErr := output.Close()
if copyErr != nil {
return copyErr
}
return closeErr
}
func save_servers(path string, servers map[string]Server) error {
directory := filepath.Dir(path)
if err := os.MkdirAll(directory, 0700); err != nil {
return err
}
temporary, err := os.CreateTemp(directory, filepath.Base(path)+".tmp-")
if err != nil {
return err
}
temporaryName := temporary.Name()
defer os.Remove(temporaryName)
if err := temporary.Chmod(0600); err != nil {
temporary.Close()
return err
}
if err := write_server_list(temporary, servers); err != nil {
temporary.Close()
return err
}
if err := temporary.Sync(); err != nil {
temporary.Close()
return err
}
if err := temporary.Close(); err != nil {
return err
}
if _, err := os.Stat(path); err == nil {
if err := copy_file(path, path+".bak"); err != nil {
return fmt.Errorf("create backup: %w", err)
}
} else if !errors.Is(err, os.ErrNotExist) {
return err
}
if err := os.Rename(temporaryName, path); err != nil {
return err
}
directoryHandle, err := os.Open(directory)
if err == nil {
_ = directoryHandle.Sync()
_ = directoryHandle.Close()
}
return nil
}
func sorted_server_names(servers map[string]Server) []string {
names := make([]string, 0, len(servers))
for name := range servers {
names = append(names, name)
}
sort.Strings(names)
return names
}
func clone_servers(servers map[string]Server) map[string]Server {
cloned := make(map[string]Server, len(servers))
for name, server := range servers {
cloned[name] = server
}
return cloned
}
func (app *App) add_server() error {
name, cancelled, err := app.ui.input("Enter a name for the new server:", "", false)
if err != nil || cancelled {
return err
}
name = strings.TrimSpace(name)
if name == "" || strings.ContainsAny(name, "\r\n") {
return app.ui.message("Server name cannot be empty or contain line breaks.")
}
rawAddress, cancelled, err := app.ui.input("Enter the server address. Add :port when it is not 64738.", "", false)
if err != nil || cancelled {
return err
}
address, port, shorthandPassword, valid := parse_server_input(rawAddress)
if !valid {
return app.ui.message("Invalid server address or port.")
}
password, cancelled, err := app.ui.input("Enter the server password, or leave it blank:", "", true)
if err != nil || cancelled {
return err
}
if strings.ContainsAny(password, "\r\n") {
return app.ui.message("Server password cannot contain line breaks.")
}
if password == "" {
password = shorthandPassword
}
insecure, err := app.ui.confirm("Skip server certificate verification for this server?")
if err != nil {
return err
}
if _, exists := app.servers[name]; exists {
overwrite, err := app.ui.confirm("A server named " + name + " already exists. Overwrite it?")
if err != nil || !overwrite {
return err
}
}
updatedServers := clone_servers(app.servers)
updatedServers[name] = Server{Name: name, Address: address, Port: port, Password: password, Insecure: insecure}
if err := save_servers(app.paths.ServerFile, updatedServers); err != nil {
return app.ui.message("Could not save server list: " + err.Error())
}
app.servers = updatedServers
app.log_line(fmt.Sprintf("Added server %s %s:%d", name, address, port))
return app.ui.message("Added server " + name)
}
func (app *App) remove_server() error {
if len(app.servers) == 0 {
return app.ui.message("No saved servers to remove.")
}
names := sorted_server_names(app.servers)
selection, cancelled, err := app.ui.menu(append(names, "Go Back"))
if err != nil || cancelled || selection == len(names) {
return err
}
name := names[selection]
confirmed, err := app.ui.confirm("Remove server " + name + "?")
if err != nil || !confirmed {
return err
}
updatedServers := clone_servers(app.servers)
delete(updatedServers, name)
if err := save_servers(app.paths.ServerFile, updatedServers); err != nil {
return app.ui.message("Could not save server list: " + err.Error())
}
app.servers = updatedServers
app.log_line("Removed server " + name)
return app.ui.message("Removed server " + name)
}
func config_has_value(path, wantedKey string) bool {
file, err := os.Open(path)
if err != nil {
return false
}
defer file.Close()
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
key, value, found := strings.Cut(line, "=")
if !found || !strings.EqualFold(strings.TrimSpace(key), wantedKey) {
continue
}
value = strings.TrimSpace(value)
return value != "" && value != `""` && value != "''"
}
return false
}
func format_address(address string, port int) string {
if strings.Contains(address, ":") {
return fmt.Sprintf("[%s]:%d", address, port)
}
return fmt.Sprintf("%s:%d", address, port)
}
func default_username() string {
user := os.Getenv("USER")
host, _ := os.Hostname()
switch {
case user != "" && host != "":
return user + "-" + host
case user != "":
return user
case host != "":
return host
default:
return "barnard"
}
}
func connection_args(server Server, paths Paths) ([]string, error) {
args := []string{"-server", format_address(server.Address, server.Port)}
if server.Insecure {
args = append(args, "-insecure")
}
if !config_has_value(paths.BarnardTOML, "username") {
args = append(args, "-username", default_username())
}
if _, err := os.Stat(paths.CertFile); err == nil && !config_has_value(paths.BarnardTOML, "certificate") {
args = append(args, "-certificate", paths.CertFile)
}
args = append(args, "--fifo", filepath.Join(paths.ConfigDir, "cmd"), "--buffers", "16")
return args, nil
}
func (app *App) connect() error {
if len(app.servers) == 0 {
return app.ui.message("No saved servers. Add a server first.")
}
names := sorted_server_names(app.servers)
selection, cancelled, err := app.ui.menu(append(names, "Go Back"))
if err != nil || cancelled || selection == len(names) {
return err
}
name := names[selection]
server := app.servers[name]
barnardPath, err := exec.LookPath("barnard")
if err != nil {
return app.ui.message("Required command not found: barnard")
}
args, err := connection_args(server, app.paths)
if err != nil {
return err
}
passwordFile := ""
if server.Password != "" {
passwordFile, err = write_password_file(app.paths.ConfigDir, server.Password)
if err != nil {
return app.ui.message("Could not prepare the server password: " + err.Error())
}
defer os.Remove(passwordFile)
args = append(args, "-password-file", passwordFile)
}
sessionLogFile := ""
if app.saveSessionLogs {
sessionLogFile, err = prepare_session_log(app.paths.LogDir, name)
if err != nil {
if messageErr := app.ui.message("Could not create session log: " + err.Error()); messageErr != nil {
return messageErr
}
} else {
args = append(args, "-log", "debug", "-logfile", sessionLogFile)
}
}
command := exec.Command(barnardPath, args...)
command.Stdin = os.Stdin
logHandle, err := os.OpenFile(app.paths.LogFile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0600)
if err != nil {
return app.ui.message("Could not open launcher log: " + err.Error())
}
defer logHandle.Close()
outputWriters := []io.Writer{os.Stdout, logHandle}
if sessionLogFile != "" {
sessionOutput, err := os.OpenFile(sessionLogFile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0600)
if err != nil {
return app.ui.message("Could not open session log: " + err.Error())
}
defer sessionOutput.Close()
outputWriters = append(outputWriters, sessionOutput)
}
commandOutput := io.MultiWriter(outputWriters...)
command.Stdout = commandOutput
command.Stderr = commandOutput
app.ui.close()
commandErr := run_external(app.ui, command)
var signalErr *terminalSignalError
if errors.As(commandErr, &signalErr) {
return signalErr
}
if err := app.ui.open(); err != nil {
return err
}
if receivedSignal := app.ui.take_termination(); receivedSignal != nil {
return &terminalSignalError{signal: receivedSignal}
}
if commandErr != nil {
return app.ui.message("Barnard exited with an error: " + commandErr.Error() + ". See log: " + app.paths.LogFile)
}
return nil
}
func (app *App) run() error {
for {
options := main_menu_options()
selection, cancelled, err := app.ui.menu(options)
if err != nil {
return err
}
if cancelled || selection == len(options)-1 {
return nil
}
switch options[selection] {
case "Connect":
err = app.connect()
case "Add server":
err = app.add_server()
case "Remove server":
err = app.remove_server()
case "Manage Certificate":
err = app.manage_certificate()
case "Logs":
err = app.manage_logs()
case "About barnard-ui":
err = app.ui.message("barnard-ui is the native Go interface for managing Barnard servers, certificates, and logs. It does not require Python or GNU Dialog.")
}
if err != nil {
return err
}
}
}
func main_menu_options() []string {
return []string{
"Connect",
"Add server",
"Remove server",
"Manage Certificate",
"Logs",
"About barnard-ui",
"Exit",
}
}
func run(arguments []string) error {
flags := flag.NewFlagSet(programName, flag.ContinueOnError)
configDir := flags.String("config-dir", "", "directory containing servers.conf and barnard.pem")
if err := flags.Parse(arguments); err != nil {
return err
}
if flags.NArg() != 0 {
return fmt.Errorf("unexpected arguments: %s", strings.Join(flags.Args(), " "))
}
paths, err := default_paths(*configDir)
if err != nil {
return err
}
if err := initialize_paths(paths); err != nil {
return err
}
servers, warnings, err := load_servers(paths.ServerFile)
if err != nil {
return fmt.Errorf("load %s: %w", paths.ServerFile, err)
}
ui, err := new_terminal_ui()
if err != nil {
return err
}
defer ui.shutdown()
app := &App{
ui: ui,
paths: paths,
servers: servers,
saveSessionLogs: load_logging_pref(paths.LogPrefsFile),
}
for _, warning := range warnings {
if err := app.ui.message(warning); err != nil {
return err
}
}
return app.run()
}
func main() {
if err := run(os.Args[1:]); err != nil {
if errors.Is(err, flag.ErrHelp) {
return
}
var signalErr *terminalSignalError
if errors.As(err, &signalErr) {
if receivedSignal, ok := signalErr.signal.(syscall.Signal); ok {
os.Exit(128 + int(receivedSignal))
}
}
fmt.Fprintln(os.Stderr, programName+":", err)
os.Exit(1)
}
}
+212
View File
@@ -0,0 +1,212 @@
package main
import (
"os"
"path/filepath"
"slices"
"strings"
"testing"
)
func TestParseHostPort(t *testing.T) {
tests := []struct {
name string
input string
wantAddress string
wantPort int
wantValid bool
}{
{name: "hostname", input: "example.com", wantAddress: "example.com", wantPort: 64738, wantValid: true},
{name: "hostname and port", input: "example.com:64739", wantAddress: "example.com", wantPort: 64739, wantValid: true},
{name: "bracketed IPv6", input: "[2001:db8::1]:64740", wantAddress: "2001:db8::1", wantPort: 64740, wantValid: true},
{name: "bracketed IPv6 default port", input: "[2001:db8::1]", wantAddress: "2001:db8::1", wantPort: 64738, wantValid: true},
{name: "unbracketed IPv6", input: "2001:db8::1", wantValid: false},
{name: "invalid port", input: "example.com:70000", wantValid: false},
{name: "non-numeric port", input: "example.com:abc", wantValid: false},
{name: "empty", input: "", wantValid: false},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
address, port, valid := parse_host_port(test.input)
if valid != test.wantValid || address != test.wantAddress || port != test.wantPort {
t.Fatalf("parse_host_port(%q) = %q, %d, %t; want %q, %d, %t",
test.input, address, port, valid, test.wantAddress, test.wantPort, test.wantValid)
}
})
}
}
func TestParseServerInputPasswordShorthand(t *testing.T) {
address, port, password, valid := parse_server_input("secret@example.com:64739")
if !valid || address != "example.com" || port != 64739 || password != "secret" {
t.Fatalf("unexpected parse result: %q, %d, %q, %t", address, port, password, valid)
}
}
func TestSaveAndLoadServersRoundTrip(t *testing.T) {
path := filepath.Join(t.TempDir(), "barnard", "servers.conf")
want := map[string]Server{
"Example": {
Name: "Example",
Address: "example.com",
Port: 64739,
Password: " secret value ",
Insecure: true,
},
}
if err := save_servers(path, want); err != nil {
t.Fatal(err)
}
info, err := os.Stat(path)
if err != nil {
t.Fatal(err)
}
if info.Mode().Perm() != 0600 {
t.Fatalf("server file mode = %o; want 600", info.Mode().Perm())
}
got, warnings, err := load_servers(path)
if err != nil {
t.Fatal(err)
}
if len(warnings) != 0 {
t.Fatalf("unexpected warnings: %v", warnings)
}
if got["Example"] != want["Example"] {
t.Fatalf("loaded server = %#v; want %#v", got["Example"], want["Example"])
}
}
func TestLoadServersStartsEmptyWhenFileIsMissing(t *testing.T) {
path := filepath.Join(t.TempDir(), "servers.conf")
servers, warnings, err := load_servers(path)
if err != nil {
t.Fatal(err)
}
if len(servers) != 0 {
t.Fatalf("new configuration contains %d default servers; want none", len(servers))
}
if len(warnings) != 0 {
t.Fatalf("unexpected warnings: %v", warnings)
}
}
func TestSaveServersCreatesBackup(t *testing.T) {
path := filepath.Join(t.TempDir(), "servers.conf")
if err := os.WriteFile(path, []byte("old contents\n"), 0600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path+".bak", []byte("older contents\n"), 0644); err != nil {
t.Fatal(err)
}
if err := save_servers(path, map[string]Server{}); err != nil {
t.Fatal(err)
}
backup, err := os.ReadFile(path + ".bak")
if err != nil {
t.Fatal(err)
}
if string(backup) != "old contents\n" {
t.Fatalf("backup = %q; want old contents", backup)
}
info, err := os.Stat(path + ".bak")
if err != nil {
t.Fatal(err)
}
if info.Mode().Perm() != 0600 {
t.Fatalf("backup mode = %o; want 600", info.Mode().Perm())
}
}
func TestLoadServersRejectsUnknownKeys(t *testing.T) {
path := filepath.Join(t.TempDir(), "servers.conf")
contents := "[server]\nname = Example\naddress = example.com\nmystery = value\n"
if err := os.WriteFile(path, []byte(contents), 0600); err != nil {
t.Fatal(err)
}
_, _, err := load_servers(path)
if err == nil || !strings.Contains(err.Error(), "unknown key") {
t.Fatalf("load_servers error = %v; want unknown key", err)
}
if strings.Contains(err.Error(), "value") {
t.Fatalf("load_servers exposed configuration value: %v", err)
}
}
func TestLoadServersRedactsMalformedLines(t *testing.T) {
path := filepath.Join(t.TempDir(), "servers.conf")
secret := "private-password-without-an-equals-sign"
contents := "[server]\nname = Example\naddress = example.com\n" + secret + "\n"
if err := os.WriteFile(path, []byte(contents), 0600); err != nil {
t.Fatal(err)
}
_, _, err := load_servers(path)
if err == nil {
t.Fatal("load_servers accepted a malformed line")
}
if strings.Contains(err.Error(), secret) {
t.Fatalf("load_servers exposed malformed configuration content: %v", err)
}
}
func TestConfigHasValue(t *testing.T) {
path := filepath.Join(t.TempDir(), ".barnard.toml")
contents := "# username = ignored\nUsername = \"Example User\"\nCertificate = \"\"\n"
if err := os.WriteFile(path, []byte(contents), 0600); err != nil {
t.Fatal(err)
}
if !config_has_value(path, "username") {
t.Fatal("expected username to be present")
}
if config_has_value(path, "certificate") {
t.Fatal("empty certificate should not count as present")
}
}
func TestFormatAddress(t *testing.T) {
if got := format_address("example.com", 64738); got != "example.com:64738" {
t.Fatalf("format_address hostname = %q", got)
}
if got := format_address("2001:db8::1", 64738); got != "[2001:db8::1]:64738" {
t.Fatalf("format_address IPv6 = %q", got)
}
}
func TestDefaultPathsAcceptsIsolatedConfigDirectory(t *testing.T) {
configDir := filepath.Join(t.TempDir(), "isolated")
paths, err := default_paths(configDir)
if err != nil {
t.Fatal(err)
}
if paths.ConfigDir != configDir {
t.Fatalf("config directory = %q; want %q", paths.ConfigDir, configDir)
}
if paths.ServerFile != filepath.Join(configDir, "servers.conf") {
t.Fatalf("server file = %q", paths.ServerFile)
}
}
func TestConnectionArgsNeverExposePassword(t *testing.T) {
server := Server{Name: "Private", Address: "example.com", Port: 64738, Password: "do not expose"}
args, err := connection_args(server, Paths{})
if err != nil {
t.Fatal(err)
}
if strings.Contains(strings.Join(args, " "), server.Password) {
t.Fatalf("connection arguments expose password: %v", args)
}
}
func TestProgramIdentityAndAboutMenuUseBarnardUI(t *testing.T) {
if programName != "barnard-ui" {
t.Fatalf("programName = %q", programName)
}
options := main_menu_options()
if !slices.Contains(options, "About barnard-ui") {
t.Fatalf("main menu lacks About barnard-ui: %v", options)
}
for _, option := range options {
if strings.Contains(option, "go-ui") {
t.Fatalf("legacy go-ui name remains in main menu: %q", option)
}
}
}
+470
View File
@@ -0,0 +1,470 @@
package main
import (
"errors"
"os"
"os/signal"
"strings"
"sync/atomic"
"syscall"
"unicode"
"github.com/mattn/go-runewidth"
"github.com/nsf/termbox-go"
)
type TerminalUI struct {
initialized atomic.Bool
signals chan os.Signal
termination chan os.Signal
stopSignals chan struct{}
}
var errTerminalInterrupted = errors.New("terminal interface interrupted")
type terminalSignalError struct {
signal os.Signal
}
func (err *terminalSignalError) Error() string {
return "interrupted by " + err.signal.String()
}
func new_terminal_ui() (*TerminalUI, error) {
ui := &TerminalUI{
signals: make(chan os.Signal, 1),
termination: make(chan os.Signal, 1),
stopSignals: make(chan struct{}),
}
if err := ui.open(); err != nil {
return nil, err
}
signal.Notify(ui.signals, os.Interrupt, syscall.SIGTERM, syscall.SIGHUP)
go func() {
for {
select {
case receivedSignal := <-ui.signals:
select {
case ui.termination <- receivedSignal:
default:
}
if ui.initialized.Load() {
termbox.Interrupt()
}
case <-ui.stopSignals:
return
}
}
}()
return ui, nil
}
func (ui *TerminalUI) open() error {
if ui.initialized.Load() {
return nil
}
if err := termbox.Init(); err != nil {
return err
}
termbox.SetInputMode(termbox.InputEsc)
ui.initialized.Store(true)
return nil
}
func (ui *TerminalUI) close() {
if ui.initialized.Swap(false) {
termbox.Close()
}
}
func (ui *TerminalUI) shutdown() {
signal.Stop(ui.signals)
close(ui.stopSignals)
ui.close()
}
func (ui *TerminalUI) take_termination() os.Signal {
select {
case receivedSignal := <-ui.termination:
return receivedSignal
default:
return nil
}
}
func safe_text_rune(value rune) rune {
if unicode.IsControl(value) || unicode.Is(unicode.Bidi_Control, value) {
return ' '
}
return value
}
func draw_text(x, y int, text string, foreground termbox.Attribute) {
width, height := termbox.Size()
if y < 0 || y >= height {
return
}
for _, character := range text {
if x >= width {
break
}
if x >= 0 {
character = safe_text_rune(character)
termbox.SetCell(x, y, character, foreground, termbox.ColorDefault)
}
characterWidth := runewidth.RuneWidth(character)
if characterWidth < 1 {
characterWidth = 1
}
x += characterWidth
}
}
func wrap_lines(text string, width int) []string {
if width < 1 {
width = 1
}
result := []string{}
for _, paragraph := range strings.Split(text, "\n") {
words := strings.Fields(paragraph)
if len(words) == 0 {
result = append(result, "")
continue
}
line := ""
for _, word := range words {
for runewidth.StringWidth(word) > width {
if line != "" {
result = append(result, line)
line = ""
}
prefix := runewidth.Truncate(word, width, "")
if prefix == "" {
prefix = string([]rune(word)[0])
}
result = append(result, prefix)
word = strings.TrimPrefix(word, prefix)
}
candidate := word
if line != "" {
candidate = line + " " + word
}
if runewidth.StringWidth(candidate) > width {
result = append(result, line)
line = word
} else {
line = candidate
}
}
result = append(result, line)
}
return result
}
func begin_screen(instructions string) (int, int) {
termbox.Clear(termbox.ColorDefault, termbox.ColorDefault)
width, height := termbox.Size()
termbox.HideCursor()
draw_text(0, 0, instructions, termbox.ColorDefault|termbox.AttrBold)
return width, height
}
func set_cursor(x, y, width, height int) {
if width < 1 || height < 1 {
termbox.HideCursor()
return
}
if x < 0 {
x = 0
} else if x >= width {
x = width - 1
}
if y < 0 {
y = 0
} else if y >= height {
y = height - 1
}
termbox.SetCursor(x, y)
}
// choice_label deliberately leaves selection out of the terminal contents.
// Moving only the hardware cursor lets terminal screen readers announce the
// newly visited item without also announcing the item that lost selection.
func choice_label(label string, _ bool) string {
return label
}
func (ui *TerminalUI) poll_key() (termbox.Event, error) {
for {
event := termbox.PollEvent()
switch event.Type {
case termbox.EventKey, termbox.EventResize:
return event, nil
case termbox.EventInterrupt:
select {
case receivedSignal := <-ui.termination:
return event, &terminalSignalError{signal: receivedSignal}
default:
return event, errTerminalInterrupted
}
case termbox.EventError:
return event, event.Err
}
}
}
func (ui *TerminalUI) message(message string) error {
for {
width, height := begin_screen("Press Enter or Escape to continue.")
lines := wrap_lines(message, width)
for index, line := range lines {
draw_text(0, index+2, line, termbox.ColorDefault)
}
cursorY := 2
if height <= cursorY {
cursorY = height - 1
}
set_cursor(0, cursorY, width, height)
if err := termbox.Flush(); err != nil {
return err
}
event, err := ui.poll_key()
if err != nil {
return err
}
if event.Type == termbox.EventResize {
continue
}
if event.Key == termbox.KeyEnter || event.Key == termbox.KeyEsc || event.Key == termbox.KeyCtrlC {
return nil
}
}
}
func (ui *TerminalUI) confirm(question string) (bool, error) {
selectedYes := true
for {
width, height := begin_screen("Use the arrow keys or Tab to choose. Press Enter to confirm or Escape for no.")
for index, line := range wrap_lines(question, width) {
draw_text(0, index+2, line, termbox.ColorDefault)
}
optionY := height - 3
if optionY < 0 {
optionY = 0
}
noY := optionY + 1
if noY >= height {
noY = height - 1
}
draw_text(0, optionY, choice_label("Yes", selectedYes), termbox.ColorDefault)
draw_text(0, noY, choice_label("No", !selectedYes), termbox.ColorDefault)
cursorY := noY
if selectedYes {
cursorY = optionY
}
set_cursor(0, cursorY, width, height)
if err := termbox.Flush(); err != nil {
return false, err
}
event, err := ui.poll_key()
if err != nil {
return false, err
}
if event.Type == termbox.EventResize {
continue
}
switch event.Key {
case termbox.KeyArrowLeft, termbox.KeyArrowRight, termbox.KeyArrowUp, termbox.KeyArrowDown, termbox.KeyTab:
selectedYes = !selectedYes
case termbox.KeyEnter:
return selectedYes, nil
case termbox.KeyEsc, termbox.KeyCtrlC:
return false, nil
}
}
}
func visible_input(value []rune, position int, password bool, width int) ([]rune, int) {
display := append([]rune(nil), value...)
if password {
for index := range display {
display[index] = '*'
}
}
if width < 1 {
return nil, 0
}
start := position
usedColumns := 0
for start > 0 {
characterWidth := runewidth.RuneWidth(display[start-1])
if characterWidth < 1 {
characterWidth = 1
}
if usedColumns+characterWidth >= width {
break
}
usedColumns += characterWidth
start--
}
end := start
visibleColumns := 0
for end < len(display) {
characterWidth := runewidth.RuneWidth(display[end])
if characterWidth < 1 {
characterWidth = 1
}
if visibleColumns+characterWidth > width {
break
}
visibleColumns += characterWidth
end++
}
return display[start:end], runewidth.StringWidth(string(display[start:position]))
}
func (ui *TerminalUI) input(instructions, initial string, password bool) (string, bool, error) {
value := []rune(initial)
position := len(value)
for {
width, height := begin_screen("Type text and press Enter. Press Escape to cancel.")
for index, line := range wrap_lines(instructions, width) {
draw_text(0, index+2, line, termbox.ColorDefault)
}
inputY := height - 2
if inputY < 0 {
inputY = 0
}
display, cursorX := visible_input(value, position, password, width)
draw_text(0, inputY, string(display), termbox.ColorDefault)
set_cursor(cursorX, inputY, width, height)
if err := termbox.Flush(); err != nil {
return "", false, err
}
event, err := ui.poll_key()
if err != nil {
return "", false, err
}
if event.Type == termbox.EventResize {
continue
}
if event.Ch != 0 {
value = append(value, 0)
copy(value[position+1:], value[position:])
value[position] = event.Ch
position++
continue
}
switch event.Key {
case termbox.KeyEnter:
return string(value), false, nil
case termbox.KeyEsc, termbox.KeyCtrlC:
return "", true, nil
case termbox.KeyArrowLeft:
if position > 0 {
position--
}
case termbox.KeyArrowRight:
if position < len(value) {
position++
}
case termbox.KeyHome:
position = 0
case termbox.KeyEnd:
position = len(value)
case termbox.KeyBackspace, termbox.KeyBackspace2:
if position > 0 {
value = append(value[:position-1], value[position:]...)
position--
}
case termbox.KeyDelete:
if position < len(value) {
value = append(value[:position], value[position+1:]...)
}
}
}
}
func (ui *TerminalUI) menu(options []string) (int, bool, error) {
if len(options) == 0 {
return 0, true, nil
}
selected := 0
for {
width, height := begin_screen("Use Up and Down arrows, then press Enter. Press Escape to go back.")
draw_text(0, 2, "Please select one", termbox.ColorDefault|termbox.AttrBold)
firstRow := 4
if firstRow >= height {
firstRow = height - 1
if firstRow < 0 {
firstRow = 0
}
}
availableRows := height - firstRow
if availableRows < 1 {
availableRows = 1
}
start := 0
if selected >= availableRows {
start = selected - availableRows + 1
}
end := start + availableRows
if end > len(options) {
end = len(options)
}
for index := start; index < end; index++ {
draw_text(0, firstRow+index-start, choice_label(options[index], index == selected), termbox.ColorDefault)
}
set_cursor(0, firstRow+selected-start, width, height)
if err := termbox.Flush(); err != nil {
return 0, false, err
}
event, err := ui.poll_key()
if err != nil {
return 0, false, err
}
if event.Type == termbox.EventResize {
continue
}
if event.Ch != 0 {
wanted := unicode.ToLower(event.Ch)
for offset := 1; offset <= len(options); offset++ {
candidate := (selected + offset) % len(options)
label := []rune(options[candidate])
if len(label) > 0 && unicode.ToLower(label[0]) == wanted {
selected = candidate
break
}
}
continue
}
switch event.Key {
case termbox.KeyArrowUp:
if selected > 0 {
selected--
}
case termbox.KeyArrowDown, termbox.KeyTab:
if selected < len(options)-1 {
selected++
}
case termbox.KeyHome:
selected = 0
case termbox.KeyEnd:
selected = len(options) - 1
case termbox.KeyPgup:
selected -= availableRows
if selected < 0 {
selected = 0
}
case termbox.KeyPgdn:
selected += availableRows
if selected >= len(options) {
selected = len(options) - 1
}
case termbox.KeyEnter:
return selected, false, nil
case termbox.KeyEsc, termbox.KeyCtrlC:
return 0, true, nil
}
}
}
+51
View File
@@ -0,0 +1,51 @@
package main
import (
"reflect"
"testing"
)
func TestWrapLinesPreservesParagraphsAndBoundsWidth(t *testing.T) {
got := wrap_lines("one two three\n\nfour", 7)
want := []string{"one two", "three", "", "four"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("wrap_lines = %#v; want %#v", got, want)
}
}
func TestWrapLinesMakesProgressWhenRuneIsWiderThanScreen(t *testing.T) {
got := wrap_lines("界a", 1)
want := []string{"界", "a"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("wrap_lines = %#v; want %#v", got, want)
}
}
func TestVisibleInputMasksPasswords(t *testing.T) {
got, cursor := visible_input([]rune("secret"), 6, true, 20)
if string(got) != "******" || cursor != 6 {
t.Fatalf("visible_input = %q at %d; want masked text at 6", string(got), cursor)
}
}
func TestVisibleInputKeepsEditingCursorOnScreen(t *testing.T) {
got, cursor := visible_input([]rune("abcdefghij"), 5, false, 4)
if string(got) != "cdef" || cursor != 3 {
t.Fatalf("visible_input = %q at %d; want cdef at 3", string(got), cursor)
}
}
func TestVisibleInputUsesTerminalColumnWidths(t *testing.T) {
got, cursor := visible_input([]rune("a界b"), 2, false, 3)
if string(got) != "界b" || cursor != 2 {
t.Fatalf("visible_input = %q at %d; want 界b at 2", string(got), cursor)
}
}
func TestChoiceLabelDoesNotChangeWithSelection(t *testing.T) {
selected := choice_label("Connect", true)
unselected := choice_label("Connect", false)
if selected != "Connect" || unselected != "Connect" {
t.Fatalf("choice labels = %q and %q; selection must be represented only by the cursor", selected, unselected)
}
}