mirror of
https://github.com/0x1d/rcond.git
synced 2025-12-14 18:25:21 +01:00
422 lines
10 KiB
Go
422 lines
10 KiB
Go
package ui
|
|
|
|
import (
|
|
"fmt"
|
|
"image/color"
|
|
"log"
|
|
"strings"
|
|
|
|
"github.com/0x1d/rcond/pkg/config"
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
"github.com/charmbracelet/huh"
|
|
"github.com/charmbracelet/lipgloss"
|
|
"github.com/skip2/go-qrcode"
|
|
)
|
|
|
|
const maxWidth = 120
|
|
|
|
type UI struct {
|
|
appConfig *config.Config
|
|
program *tea.Program
|
|
}
|
|
|
|
type UIConfig struct {
|
|
Enabled bool `yaml:"enabled" envconfig:"UI_ENABLED"`
|
|
}
|
|
|
|
var (
|
|
red = lipgloss.AdaptiveColor{Light: "#FE5F86", Dark: "#FE5F86"}
|
|
indigo = lipgloss.AdaptiveColor{Light: "#5A56E0", Dark: "#7571F9"}
|
|
green = lipgloss.AdaptiveColor{Light: "#02BA84", Dark: "#02BF87"}
|
|
)
|
|
|
|
type Styles struct {
|
|
Base,
|
|
HeaderText,
|
|
Status,
|
|
StatusHeader,
|
|
Highlight,
|
|
ErrorHeaderText,
|
|
Help lipgloss.Style
|
|
}
|
|
|
|
func NewStyles(lg *lipgloss.Renderer) *Styles {
|
|
s := Styles{}
|
|
s.Base = lg.NewStyle().
|
|
Padding(1, 4, 0, 1)
|
|
s.HeaderText = lg.NewStyle().
|
|
Foreground(indigo).
|
|
Bold(true).
|
|
Padding(0, 1, 0, 2)
|
|
s.Status = lg.NewStyle().
|
|
Border(lipgloss.RoundedBorder()).
|
|
BorderForeground(indigo).
|
|
PaddingLeft(1).
|
|
MarginTop(1)
|
|
s.StatusHeader = lg.NewStyle().
|
|
Foreground(green).
|
|
Bold(true)
|
|
s.Highlight = lg.NewStyle().
|
|
Foreground(lipgloss.Color("212"))
|
|
s.ErrorHeaderText = s.HeaderText.
|
|
Foreground(red)
|
|
s.Help = lg.NewStyle().
|
|
Foreground(lipgloss.Color("240"))
|
|
return &s
|
|
}
|
|
|
|
type state int
|
|
|
|
const (
|
|
statusNormal state = iota
|
|
stateModeSelection
|
|
stateDone
|
|
)
|
|
|
|
type Model struct {
|
|
state state
|
|
lg *lipgloss.Renderer
|
|
styles *Styles
|
|
form *huh.Form
|
|
width int
|
|
appConfig *config.Config
|
|
}
|
|
|
|
func NewModel(appConfig *config.Config) Model {
|
|
m := Model{width: maxWidth, state: stateModeSelection, appConfig: appConfig}
|
|
m.lg = lipgloss.DefaultRenderer()
|
|
m.styles = NewStyles(m.lg)
|
|
|
|
m.form = m.createModeForm()
|
|
|
|
return m
|
|
}
|
|
|
|
func (m Model) createModeForm() *huh.Form {
|
|
return huh.NewForm(
|
|
huh.NewGroup(
|
|
huh.NewSelect[string]().
|
|
Key("mode").
|
|
Options(huh.NewOptions("AP", "STA")...).
|
|
Title("Parameters").
|
|
Description("What do you want to configure?"),
|
|
),
|
|
).
|
|
WithWidth(45).
|
|
WithShowHelp(false).
|
|
WithShowErrors(false)
|
|
}
|
|
|
|
func min(x, y int) int {
|
|
if x > y {
|
|
return y
|
|
}
|
|
return x
|
|
}
|
|
|
|
func (m Model) Init() tea.Cmd {
|
|
return m.form.Init()
|
|
}
|
|
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
switch msg := msg.(type) {
|
|
case tea.WindowSizeMsg:
|
|
m.width = min(msg.Width, maxWidth) - m.styles.Base.GetHorizontalFrameSize()
|
|
case tea.KeyMsg:
|
|
switch msg.String() {
|
|
case "ctrl+c":
|
|
return m, tea.Interrupt
|
|
case "esc", "q":
|
|
return m, tea.Quit
|
|
}
|
|
}
|
|
|
|
var cmds []tea.Cmd
|
|
|
|
// Process the form
|
|
form, cmd := m.form.Update(msg)
|
|
if f, ok := form.(*huh.Form); ok {
|
|
m.form = f
|
|
cmds = append(cmds, cmd)
|
|
}
|
|
|
|
// Handle mode selection
|
|
if m.state == stateModeSelection && m.form.State == huh.StateCompleted {
|
|
mode := m.form.GetString("mode")
|
|
if mode == "AP" {
|
|
m.form = m.createAPForm()
|
|
m.state = statusNormal
|
|
return m, m.form.Init()
|
|
} else if mode == "STA" {
|
|
m.form = m.createSTAForm()
|
|
m.state = statusNormal
|
|
return m, m.form.Init()
|
|
}
|
|
}
|
|
|
|
// After form completion, show mode selection again
|
|
if m.state == statusNormal && m.form.State == huh.StateCompleted {
|
|
m.appConfig.Wifi.AP.SSID = m.form.GetString("ssid")
|
|
m.appConfig.Wifi.AP.Password = m.form.GetString("password")
|
|
m.appConfig.Wifi.AP.Encryption = m.form.GetString("encryption")
|
|
|
|
m.form = m.createModeForm()
|
|
m.state = stateModeSelection
|
|
return m, m.form.Init()
|
|
}
|
|
|
|
return m, tea.Batch(cmds...)
|
|
}
|
|
|
|
func (m Model) createAPForm() *huh.Form {
|
|
if m.appConfig == nil {
|
|
log.Fatal("appConfig is nil")
|
|
return nil // or handle the error appropriately
|
|
}
|
|
return huh.NewForm(
|
|
huh.NewGroup(
|
|
huh.NewInput().
|
|
Key("ssid").
|
|
Placeholder("Access Point SSID").
|
|
Title("SSID").
|
|
Description("The name of your access point").
|
|
Value(&m.appConfig.Wifi.AP.SSID),
|
|
|
|
huh.NewInput().
|
|
Key("password").
|
|
Placeholder("Access Point Password").
|
|
Title("Password").
|
|
EchoMode(huh.EchoModePassword).
|
|
Description("The password for your access point").
|
|
Validate(func(v string) error {
|
|
if len(v) < 8 {
|
|
return fmt.Errorf("password must be at least 8 characters long")
|
|
}
|
|
return nil
|
|
}).
|
|
Value(&m.appConfig.Wifi.AP.Password),
|
|
|
|
huh.NewSelect[string]().
|
|
Key("encryption").
|
|
Options(huh.NewOptions("WPA2", "WPA3", "WEP")...).
|
|
Title("Encryption").
|
|
Description("The encryption method for your access point").
|
|
Value(&m.appConfig.Wifi.AP.Encryption),
|
|
|
|
huh.NewConfirm().
|
|
Key("save").
|
|
Title("Apply configuration").
|
|
Validate(func(v bool) error {
|
|
if !v {
|
|
m.state = stateModeSelection
|
|
return nil
|
|
}
|
|
return nil
|
|
}).
|
|
Affirmative("Yep").
|
|
Negative("Nope"),
|
|
),
|
|
).
|
|
WithWidth(45).
|
|
WithShowHelp(false).
|
|
WithShowErrors(false)
|
|
}
|
|
|
|
func (m Model) createSTAForm() *huh.Form {
|
|
return huh.NewForm(
|
|
huh.NewGroup(
|
|
huh.NewInput().
|
|
Key("ssid").
|
|
Placeholder("Station SSID").
|
|
Title("SSID").
|
|
Description("The name of the station").
|
|
Value(&m.appConfig.Wifi.STA.SSID),
|
|
|
|
huh.NewInput().
|
|
Key("password").
|
|
Placeholder("Station Password").
|
|
Title("Password").
|
|
EchoMode(huh.EchoModePassword).
|
|
Description("The password for the station").
|
|
Validate(func(v string) error {
|
|
if len(v) < 8 {
|
|
return fmt.Errorf("password must be at least 8 characters long")
|
|
}
|
|
return nil
|
|
}).
|
|
Value(&m.appConfig.Wifi.STA.Password),
|
|
huh.NewConfirm().
|
|
Key("done").
|
|
Title("All done?").
|
|
Validate(func(v bool) error {
|
|
if !v {
|
|
return fmt.Errorf("Welp, finish up then")
|
|
}
|
|
return nil
|
|
}).
|
|
Affirmative("Yep").
|
|
Negative("Wait, no"),
|
|
),
|
|
).
|
|
WithWidth(45).
|
|
WithShowHelp(false).
|
|
WithShowErrors(false)
|
|
}
|
|
|
|
func (m Model) View() string {
|
|
s := m.styles
|
|
|
|
switch m.state {
|
|
case stateModeSelection:
|
|
v := strings.TrimSuffix(m.form.View(), "\n\n")
|
|
form := m.lg.NewStyle().Margin(1, 0).Render(v)
|
|
header := m.appBoundaryView("Configuration")
|
|
footer := m.appBoundaryView(m.form.Help().ShortHelpView(m.form.KeyBinds()))
|
|
return s.Base.Render(header + "\n" + form + "\n\n" + footer)
|
|
case statusNormal:
|
|
switch m.form.State {
|
|
case huh.StateCompleted:
|
|
ssid := m.appConfig.Wifi.STA.SSID
|
|
password := m.appConfig.Wifi.STA.Password
|
|
encryption := m.appConfig.Wifi.STA.Encryption
|
|
qrCode, err := qrcode.New("WIFI:S:"+ssid+";T:"+encryption+";P:"+password+";;", qrcode.Medium)
|
|
if err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
qrCode.DisableBorder = true
|
|
qrCodeImage := qrCode.ToSmallString(false)
|
|
|
|
var b strings.Builder
|
|
fmt.Fprintf(&b, "Congratulations, you've configured your access point!\n\n")
|
|
fmt.Fprintf(&b, "SSID: %s\nEncryption: %s\n\nPlease proceed to connect to your access point.\n\n", ssid, encryption)
|
|
|
|
// Center the QR code
|
|
centeredQRCode := lipgloss.NewStyle().Render(qrCodeImage)
|
|
b.WriteString(centeredQRCode)
|
|
|
|
return s.Status.Margin(0, 1).Padding(2, 2).Width(48).Render(b.String()) + "\n\n"
|
|
default:
|
|
var ssid string
|
|
var newSsid, newPassword, newEncryption string
|
|
var encryption string
|
|
var password string
|
|
var qrCodeImage string
|
|
ssid = m.appConfig.Wifi.STA.SSID
|
|
password = m.appConfig.Wifi.STA.Password
|
|
encryption = m.appConfig.Wifi.STA.Encryption
|
|
|
|
newSsid = m.form.GetString("ssid")
|
|
if ssid != newSsid && newSsid != "" {
|
|
ssid = newSsid
|
|
}
|
|
newPassword = m.form.GetString("password")
|
|
if password != newPassword && newPassword != "" {
|
|
password = newPassword
|
|
}
|
|
newEncryption = m.form.GetString("encryption")
|
|
if encryption != newEncryption && newEncryption != "" {
|
|
encryption = newEncryption
|
|
}
|
|
|
|
if ssid != "" && password != "" && encryption != "" {
|
|
qrCode, err := qrcode.New("WIFI:S:"+ssid+";T:"+encryption+";P:"+password+";;", qrcode.Medium)
|
|
qrCode.BackgroundColor = color.White
|
|
qrCode.DisableBorder = false
|
|
if err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
qrCodeImage = qrCode.ToSmallString(false)
|
|
}
|
|
|
|
// Form (left side)
|
|
v := strings.TrimSuffix(m.form.View(), "\n\n")
|
|
form := m.lg.NewStyle().Margin(1, 0).Render(v)
|
|
|
|
// Status (right side)
|
|
var status string
|
|
{
|
|
var (
|
|
buildInfo = "(None)"
|
|
)
|
|
|
|
if ssid != "" {
|
|
buildInfo = "SSID: " + ssid
|
|
}
|
|
if password != "" {
|
|
buildInfo += "\n" + "Password: " + strings.Repeat("*", len(password))
|
|
}
|
|
if encryption != "" {
|
|
buildInfo += "\n" + "Encryption: " + encryption
|
|
}
|
|
|
|
if qrCodeImage != "" {
|
|
buildInfo += "\n\n" + qrCodeImage
|
|
}
|
|
|
|
const statusWidth = 50
|
|
statusMarginLeft := m.width - statusWidth - lipgloss.Width(form) - s.Status.GetMarginRight()
|
|
status = s.Status.
|
|
Height(lipgloss.Height(form)).
|
|
Width(statusWidth).
|
|
MarginLeft(statusMarginLeft).
|
|
Render(s.StatusHeader.Render("Current Configuration") + "\n" +
|
|
buildInfo)
|
|
}
|
|
|
|
errors := m.form.Errors()
|
|
header := m.appBoundaryView("Node Configuration")
|
|
if len(errors) > 0 {
|
|
header = m.appErrorBoundaryView(m.errorView())
|
|
}
|
|
body := lipgloss.JoinHorizontal(lipgloss.Left, form, status)
|
|
|
|
footer := m.appBoundaryView(m.form.Help().ShortHelpView(m.form.KeyBinds()))
|
|
if len(errors) > 0 {
|
|
footer = m.appErrorBoundaryView("")
|
|
}
|
|
|
|
return s.Base.Render(header + "\n" + body + "\n\n" + footer)
|
|
}
|
|
}
|
|
|
|
return ""
|
|
}
|
|
|
|
func (m Model) errorView() string {
|
|
var s string
|
|
for _, err := range m.form.Errors() {
|
|
s += err.Error()
|
|
}
|
|
return s
|
|
}
|
|
|
|
func (m Model) appBoundaryView(text string) string {
|
|
return lipgloss.PlaceHorizontal(
|
|
m.width,
|
|
lipgloss.Left,
|
|
m.styles.HeaderText.Render(text),
|
|
lipgloss.WithWhitespaceChars("/"),
|
|
lipgloss.WithWhitespaceForeground(indigo),
|
|
)
|
|
}
|
|
|
|
func (m Model) appErrorBoundaryView(text string) string {
|
|
return lipgloss.PlaceHorizontal(
|
|
m.width,
|
|
lipgloss.Left,
|
|
m.styles.ErrorHeaderText.Render(text),
|
|
lipgloss.WithWhitespaceChars("/"),
|
|
lipgloss.WithWhitespaceForeground(red),
|
|
)
|
|
}
|
|
|
|
func NewUI(appConfig *config.Config) *UI {
|
|
// Initialize our program
|
|
ui := &UI{appConfig: appConfig}
|
|
fmt.Printf("SSID: %s", appConfig.Wifi.AP.SSID)
|
|
ui.program = tea.NewProgram(NewModel(appConfig), tea.WithAltScreen())
|
|
if _, err := ui.program.Run(); err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
return ui
|
|
}
|