Files
tsrun/internal/ui/app.go
T
dmgnr 75b657dfd4
Deploy tsrun / deploy (push) Successful in 5m43s
feat: add bubble tea interface
2026-07-23 02:54:57 +00:00

340 lines
7.3 KiB
Go

package ui
import (
"context"
"errors"
"fmt"
"net/netip"
"strings"
"github.com/charmbracelet/bubbles/spinner"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"tsrun/internal/embedded"
base "tsrun/internal/runtime"
"tsrun/internal/runtime/full"
"tsrun/internal/runtime/proxy"
"tsrun/internal/vault"
)
type screen int
const (
screenLogin screen = iota
screenConnecting
screenDashboard
screenError
)
type runtimeEventMsg struct{ event base.Event }
type runtimeDoneMsg struct{ err error }
type App struct {
useProxy bool
prefill string
screen screen
spinner spinner.Model
username textinput.Model
password textinput.Model
focus int
status string
errText string
event base.Event
ctx context.Context
cancel context.CancelFunc
eventCh chan base.Event
doneCh chan error
started bool
connected bool
quitting bool
}
func Run(useProxy bool, prefill string) error {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
sp := spinner.New()
sp.Spinner = spinner.Spinner{
Frames: []string{"◇", "◈", "◆", "◈"},
FPS: spinner.Line.FPS,
}
username := textinput.New()
username.Placeholder = "username"
username.Prompt = "Username: "
username.CharLimit = 64
username.SetValue(prefill)
password := textinput.New()
password.Placeholder = "password"
password.Prompt = "Password: "
password.CharLimit = 128
password.EchoMode = textinput.EchoPassword
password.EchoCharacter = '•'
if prefill == "" {
username.Focus()
} else {
password.Focus()
}
app := &App{
useProxy: useProxy,
prefill: prefill,
screen: screenLogin,
spinner: sp,
username: username,
password: password,
ctx: ctx,
cancel: cancel,
}
_, err := tea.NewProgram(app, tea.WithAltScreen()).Run()
if err != nil {
return err
}
if app.errText != "" {
return errors.New(app.errText)
}
return nil
}
func (a *App) Init() tea.Cmd { return nil }
func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch a.screen {
case screenLogin:
return a.updateLogin(msg)
case screenConnecting, screenDashboard:
if msg.String() == "q" || msg.String() == "ctrl+c" {
if !a.quitting {
a.quitting = true
a.status = "disconnecting"
a.screen = screenConnecting
a.cancel()
}
return a, nil
}
case screenError:
if msg.String() == "enter" || msg.String() == "q" || msg.String() == "esc" || msg.String() == "ctrl+c" {
return a, tea.Quit
}
}
case spinner.TickMsg:
if a.screen == screenConnecting {
var cmd tea.Cmd
a.spinner, cmd = a.spinner.Update(msg)
return a, cmd
}
case runtimeEventMsg:
a.status = msg.event.Message
a.event = msg.event
if msg.event.Kind == base.EventRunning {
a.connected = true
a.screen = screenDashboard
}
return a, a.waitForEventCmd()
case runtimeDoneMsg:
if msg.err != nil && !errors.Is(msg.err, context.Canceled) {
a.errText = msg.err.Error()
a.screen = screenError
return a, nil
}
if a.connected || a.quitting {
return a, tea.Quit
}
if msg.err != nil {
a.errText = msg.err.Error()
a.screen = screenError
return a, nil
}
return a, tea.Quit
}
return a, nil
}
func (a *App) updateLogin(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "ctrl+c", "esc":
return a, tea.Quit
case "tab", "shift+tab", "up", "down":
if a.focus == 0 {
a.focus = 1
a.username.Blur()
a.password.Focus()
} else {
a.focus = 0
a.password.Blur()
a.username.Focus()
}
return a, nil
case "enter":
if a.focus == 0 {
a.focus = 1
a.username.Blur()
a.password.Focus()
return a, nil
}
return a, a.startRuntime()
}
if a.focus == 0 {
var cmd tea.Cmd
a.username, cmd = a.username.Update(msg)
return a, cmd
}
var cmd tea.Cmd
a.password, cmd = a.password.Update(msg)
return a, cmd
}
func (a *App) startRuntime() tea.Cmd {
username := strings.TrimSpace(a.username.Value())
passwordBytes := []byte(a.password.Value())
a.screen = screenConnecting
a.status = "unlocking credentials"
payload, err := vault.Unlock(embedded.Blob, username, passwordBytes)
vault.Zero(passwordBytes)
if err != nil {
if errors.Is(err, vault.ErrAuthFailed) {
a.errText = "authentication failed"
} else {
a.errText = err.Error()
}
a.screen = screenError
return nil
}
a.eventCh = make(chan base.Event, 16)
a.doneCh = make(chan error, 1)
a.started = true
go func() {
report := base.Reporter(func(event base.Event) {
a.eventCh <- event
})
var runErr error
if a.useProxy {
runErr = proxy.RunWithReporter(a.ctx, payload, report)
} else {
runErr = full.RunWithReporter(a.ctx, payload, report)
}
a.doneCh <- runErr
close(a.eventCh)
close(a.doneCh)
}()
return tea.Batch(a.spinner.Tick, a.waitForEventCmd(), a.waitForDoneCmd())
}
func (a *App) waitForEventCmd() tea.Cmd {
if !a.started || a.eventCh == nil {
return nil
}
return func() tea.Msg {
event, ok := <-a.eventCh
if !ok {
return nil
}
return runtimeEventMsg{event: event}
}
}
func (a *App) waitForDoneCmd() tea.Cmd {
if !a.started || a.doneCh == nil {
return nil
}
return func() tea.Msg {
err, ok := <-a.doneCh
if !ok {
return nil
}
return runtimeDoneMsg{err: err}
}
}
func (a *App) View() string {
switch a.screen {
case screenLogin:
return pageStyle.Render(lipgloss.JoinVertical(lipgloss.Left,
titleStyle.Render("tsrun"),
subStyle.Render("Temporary Tailscale access"),
"",
a.username.View(),
a.password.View(),
"",
helpStyle.Render("Tab to switch, Enter to continue, Esc to quit"),
))
case screenConnecting:
return pageStyle.Render(lipgloss.JoinVertical(lipgloss.Left,
titleStyle.Render("tsrun"),
"",
fmt.Sprintf("%s %s", a.spinner.View(), a.status),
"",
helpStyle.Render("q to cancel"),
))
case screenDashboard:
lines := []string{
fmt.Sprintf("Mode: %s", a.event.Mode),
fmt.Sprintf("Status: %s", a.event.Message),
}
if a.event.Hostname != "" {
lines = append(lines, fmt.Sprintf("Hostname: %s", a.event.Hostname))
}
if validAddr(a.event.IPv4) {
lines = append(lines, fmt.Sprintf("IPv4: %s", a.event.IPv4))
}
if validAddr(a.event.IPv6) {
lines = append(lines, fmt.Sprintf("IPv6: %s", a.event.IPv6))
}
if a.event.ProxyAddr != "" {
lines = append(lines,
fmt.Sprintf("Proxy: %s", a.event.ProxyAddr),
fmt.Sprintf("Proxy user: %s", a.event.ProxyUser),
fmt.Sprintf("Proxy password: %s", a.event.ProxyPass),
)
}
return pageStyle.Render(lipgloss.JoinVertical(lipgloss.Left,
titleStyle.Render("tsrun"),
"",
strings.Join(lines, "\n"),
"",
helpStyle.Render("q to disconnect"),
))
case screenError:
return pageStyle.Render(lipgloss.JoinVertical(lipgloss.Left,
titleStyle.Render("tsrun"),
"",
errorStyle.Render(a.errText),
"",
helpStyle.Render("Enter or q to exit"),
))
default:
return ""
}
}
func validAddr(v string) bool {
if v == "" || v == "<nil>" {
return false
}
_, err := netip.ParseAddr(v)
return err == nil
}
var (
pageStyle = lipgloss.NewStyle().Padding(2, 4)
titleStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("12"))
subStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("8"))
helpStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("8"))
errorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("9")).Bold(true)
)