This commit is contained in:
2026-04-10 01:46:57 +09:00
commit ddd6ecccda
15 changed files with 1370 additions and 0 deletions

36
internal/pages/home.go Normal file
View File

@@ -0,0 +1,36 @@
package pages
type HomePageMsg struct{}
type SettingsPageMsg struct{}
type SelectServerPageMsg struct{}
type VPNStatusMsg struct {
Connected bool
Err error
}
type VPNToggleMsg struct {
Connected bool
Err error
}
type MenuItem struct {
Label string
Key string
}
func HomeMenuItems(connected, hasServers bool) []MenuItem {
var items []MenuItem
if hasServers {
if connected {
items = append(items, MenuItem{Label: "Turn Off", Key: "off"})
} else {
items = append(items, MenuItem{Label: "Turn On", Key: "on"})
}
items = append(items, MenuItem{Label: "Select Server", Key: "select-server"})
}
items = append(items, MenuItem{Label: "Settings", Key: "settings"})
return items
}

View File

@@ -0,0 +1,10 @@
package pages
import "tailscale-vpn/internal/services"
type ServerSelectedMsg struct {
Server services.Server
Err error
}
type ServerSelectionCanceledMsg struct{}

View File

@@ -0,0 +1,21 @@
package pages
import "tailscale-vpn/internal/services"
type ServerListLoadedMsg struct {
Servers []services.Server
Err error
}
type ServerEditMsg struct {
Mode string
Server services.Server
}
type ServerSaveMsg struct {
Err error
}
type ServerDeleteMsg struct {
Err error
}

130
internal/services/config.go Normal file
View File

@@ -0,0 +1,130 @@
package services
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"slices"
)
type Server struct {
ID string `json:"id"`
Name string `json:"name"`
Host string `json:"host"`
}
type Config struct {
Servers []Server `json:"servers"`
SelectedServerID string `json:"selectedServerId"`
}
type ConfigService struct {
path string
config Config
}
func NewConfigService() (*ConfigService, error) {
configDir, err := os.UserConfigDir()
if err != nil {
return nil, fmt.Errorf("get config dir: %w", err)
}
dir := filepath.Join(configDir, "tailscale-vpn")
if err := os.MkdirAll(dir, 0o700); err != nil {
return nil, fmt.Errorf("create config dir: %w", err)
}
path := filepath.Join(dir, "settings.json")
if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) {
if err := os.WriteFile(path, []byte(`{"servers":[]}`), 0o600); err != nil {
return nil, fmt.Errorf("create config file: %w", err)
}
}
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read config file: %w", err)
}
var cfg Config
if err := json.Unmarshal(data, &cfg); err != nil {
return nil, fmt.Errorf("parse config: %w", err)
}
return &ConfigService{path: path, config: cfg}, nil
}
func (s *ConfigService) GetServers() []Server {
return s.config.Servers
}
func (s *ConfigService) GetServer(id string) (Server, bool) {
for _, srv := range s.config.Servers {
if srv.ID == id {
return srv, true
}
}
return Server{}, false
}
func (s *ConfigService) AddServer(name, host string) error {
srv := Server{
ID: host,
Name: name,
Host: host,
}
s.config.Servers = append(s.config.Servers, srv)
return s.Save()
}
func (s *ConfigService) RemoveServer(id string) error {
s.config.Servers = slices.DeleteFunc(s.config.Servers, func(srv Server) bool {
return srv.ID == id
})
return s.Save()
}
func (s *ConfigService) GetSelectedServer() (Server, bool) {
for _, srv := range s.config.Servers {
if srv.ID == s.config.SelectedServerID {
return srv, true
}
}
return Server{}, false
}
func (s *ConfigService) SetSelectedServer(id string) error {
for _, srv := range s.config.Servers {
if srv.ID == id {
s.config.SelectedServerID = id
return s.Save()
}
}
return fmt.Errorf("server not found")
}
func (s *ConfigService) UpdateServer(id, name, host string) error {
for i, srv := range s.config.Servers {
if srv.ID == id {
s.config.Servers[i].Name = name
s.config.Servers[i].Host = host
s.config.Servers[i].ID = host
return s.Save()
}
}
return fmt.Errorf("server not found")
}
func (s *ConfigService) Save() error {
data, err := json.MarshalIndent(s.config, "", " ")
if err != nil {
return fmt.Errorf("marshal config: %w", err)
}
if err := os.WriteFile(s.path, data, 0o600); err != nil {
return fmt.Errorf("write config: %w", err)
}
return nil
}

View File

@@ -0,0 +1,11 @@
package services
type ServicesStore struct {
VPN *VPNService
Config *ConfigService
}
func NewServicesStore() (*ServicesStore, error) {
cfg, _ := NewConfigService()
return &ServicesStore{VPN: NewVPNService(cfg), Config: cfg}, nil
}

View File

@@ -0,0 +1,66 @@
package services
import (
"bytes"
"context"
"log/slog"
"os/exec"
"strings"
)
type VPNService struct{
Config *ConfigService
}
func NewVPNService(cfg *ConfigService) *VPNService {
return &VPNService{
Config: cfg,
}
}
func (v *VPNService) CheckStatus(ctx context.Context) (bool, error) {
cmd := exec.CommandContext(ctx, "tailscale", "exit-node", "list")
var out bytes.Buffer
cmd.Stdout = &out
err := cmd.Run()
if err != nil {
slog.ErrorContext(ctx, "failed to check VPN status", "error", err)
return false, err
}
output := out.String()
return strings.Contains(output, "selected"), nil
}
func (v *VPNService) TurnOn(ctx context.Context) error {
selectedServer, _ := v.Config.GetSelectedServer()
cmd := exec.CommandContext(ctx, "sudo", "tailscale", "set", "--exit-node="+selectedServer.Host)
if err := cmd.Run(); err != nil {
slog.ErrorContext(ctx, "failed to turn on VPN", "error", err)
return err
}
return nil
}
func (v *VPNService) TurnOff(ctx context.Context) error {
cmd := exec.CommandContext(ctx, "sudo", "tailscale", "set", "--exit-node=")
if err := cmd.Run(); err != nil {
slog.ErrorContext(ctx, "failed to turn off VPN", "error", err)
return err
}
return nil
}
func (v *VPNService) ExitNodeList(ctx context.Context) (string, error) {
cmd := exec.CommandContext(ctx, "tailscale", "exit-node", "list")
var out bytes.Buffer
cmd.Stdout = &out
if err := cmd.Run(); err != nil {
slog.ErrorContext(ctx, "failed to get exit node list", "error", err)
return "", err
}
return out.String(), nil
}

159
internal/styles/styles.go Normal file
View File

@@ -0,0 +1,159 @@
package styles
import lipgloss "charm.land/lipgloss/v2"
var (
// Card / box
CardStyle = lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color("62")).
Width(52)
CardInnerStyle = lipgloss.NewStyle().
Padding(1, 3)
CardTitleStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("86")).
Bold(true).
MarginBottom(1)
CardSubtitleStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("245")).
MarginBottom(1)
// VPN Status
VPNStatusConnectedStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("86")).
Bold(true).
MarginBottom(1)
VPNStatusDisconnectedStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("240")).
MarginBottom(1)
VPNStatusLoadingStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("245")).
Italic(true).
MarginBottom(1)
VPNErrorStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("203")).
MarginBottom(1)
// Buttons
buttonBase = lipgloss.NewStyle().
Padding(0, 2).
MarginTop(1).
Width(44)
ButtonActiveStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("15")).
Background(lipgloss.Color("75")).
Bold(true).
Padding(0, 2).
MarginTop(1).
Width(44)
ButtonInactiveStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("240")).
Padding(0, 2).
MarginTop(1).
Width(44)
ButtonLoadingStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("238")).
Padding(0, 2).
MarginTop(1).
Width(44)
// Footer
FooterStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("240")).
BorderTop(true).
BorderStyle(lipgloss.NormalBorder()).
BorderForeground(lipgloss.Color("237")).
Padding(0, 1).
Width(50)
FooterKeyStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("86")).Bold(true)
FooterSepStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("237"))
FooterDescStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("243"))
// Settings Page
ServerListStyle = lipgloss.NewStyle().
PaddingTop(1)
ServerItemActiveStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("86")).
Bold(true)
ServerItemInactiveStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("245"))
ServerSelectedStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("86")).
Bold(true).
MarginBottom(1)
ServerNotSelectedStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("245")).
Italic(true).
MarginBottom(1)
EmptyListStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("245")).
Italic(true).
PaddingTop(1)
FormTitleStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("86")).
Bold(true).
MarginBottom(1)
FormLabelStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("245")).
Width(6)
FormInputActiveStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("15")).
Background(lipgloss.Color("236")).
Width(30)
FormInputInactiveStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("240")).
Width(30)
FormButtonActiveStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("232")).
Background(lipgloss.Color("75")).
Bold(true).
Padding(0, 2).
Width(14)
FormButtonInactiveStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("240")).
Padding(0, 2).
Width(14)
ConfirmTitleStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("203")).
Bold(true).
MarginBottom(1)
ConfirmStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("240"))
SelectListStyle = lipgloss.NewStyle().
PaddingTop(1)
SelectItemActiveStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("86")).
Bold(true)
SelectItemInactiveStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("245"))
SelectedMarkerStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("42")).
Bold(true)
)

100
internal/tui/commands.go Normal file
View File

@@ -0,0 +1,100 @@
package tui
import (
"context"
"fmt"
"tailscale-vpn/internal/pages"
"tailscale-vpn/internal/services"
tea "charm.land/bubbletea/v2"
)
func checkVPNStatusCmd(store *services.ServicesStore) tea.Cmd {
return func() tea.Msg {
ctx := context.Background()
connected, err := store.VPN.CheckStatus(ctx)
return pages.VPNStatusMsg{
Connected: connected,
Err: err,
}
}
}
func turnOnVPNCmd(store *services.ServicesStore) tea.Cmd {
return func() tea.Msg {
ctx := context.Background()
if err := store.VPN.TurnOn(ctx); err != nil {
return pages.VPNToggleMsg{
Connected: false,
Err: err,
}
}
connected, _ := store.VPN.CheckStatus(ctx)
return pages.VPNToggleMsg{
Connected: connected,
Err: nil,
}
}
}
func turnOffVPNCmd(store *services.ServicesStore) tea.Cmd {
return func() tea.Msg {
ctx := context.Background()
if err := store.VPN.TurnOff(ctx); err != nil {
return pages.VPNToggleMsg{
Connected: true,
Err: err,
}
}
connected, _ := store.VPN.CheckStatus(ctx)
return pages.VPNToggleMsg{
Connected: connected,
Err: nil,
}
}
}
func loadServersCmd(store *services.ServicesStore) tea.Cmd {
return func() tea.Msg {
servers := store.Config.GetServers()
return pages.ServerListLoadedMsg{
Servers: servers,
Err: nil,
}
}
}
func addServerCmd(store *services.ServicesStore, name, host string) tea.Cmd {
return func() tea.Msg {
err := store.Config.AddServer(name, host)
return pages.ServerSaveMsg{Err: err}
}
}
func updateServerCmd(store *services.ServicesStore, id, name, host string) tea.Cmd {
return func() tea.Msg {
err := store.Config.UpdateServer(id, name, host)
return pages.ServerSaveMsg{Err: err}
}
}
func deleteServerCmd(store *services.ServicesStore, id string) tea.Cmd {
return func() tea.Msg {
err := store.Config.RemoveServer(id)
return pages.ServerDeleteMsg{Err: err}
}
}
func selectServerCmd(store *services.ServicesStore, serverID string) tea.Cmd {
return func() tea.Msg {
err := store.Config.SetSelectedServer(serverID)
if err != nil {
return pages.ServerSelectedMsg{Err: err}
}
server, ok := store.Config.GetSelectedServer()
if !ok {
return pages.ServerSelectedMsg{Err: fmt.Errorf("failed to get selected server")}
}
return pages.ServerSelectedMsg{Server: server, Err: nil}
}
}

15
internal/tui/init.go Normal file
View File

@@ -0,0 +1,15 @@
package tui
import (
"tailscale-vpn/internal/pages"
tea "charm.land/bubbletea/v2"
)
func (m TUIInterface) Init() tea.Cmd {
return tea.Batch(
func() tea.Msg { return pages.HomePageMsg{} },
checkVPNStatusCmd(m.Services),
loadServersCmd(m.Services),
)
}

56
internal/tui/tui.go Normal file
View File

@@ -0,0 +1,56 @@
package tui
import (
"tailscale-vpn/internal/pages"
"tailscale-vpn/internal/services"
)
type page int
const (
pageHome page = iota
pageSettings
pageSelectServer
)
type TUIInterface struct {
Services *services.ServicesStore
Page page
MenuItems []pages.MenuItem
Selected int
Quitting bool
WindowWidth int
WindowHeight int
VPNConnected bool
VPNLoading bool
VPNToggleLoading bool
VPNError error
HasServers bool
SelectedServer services.Server
HasSelectedServer bool
SettingsServers []services.Server
SettingsSelected int
SettingsEditMode string
SettingsEditingServer services.Server
SettingsFormName string
SettingsFormHost string
SettingsFormField int
SettingsConfirmDelete bool
SelectServerServers []services.Server
SelectServerSelected int
}
func NewTUIInterface(store *services.ServicesStore) TUIInterface {
servers := store.Config.GetServers()
selectedServer, hasSelected := store.Config.GetSelectedServer()
return TUIInterface{
Services: store,
Page: pageHome,
MenuItems: pages.HomeMenuItems(false, len(servers) > 0),
VPNConnected: false,
VPNLoading: true,
HasServers: len(servers) > 0,
SelectedServer: selectedServer,
HasSelectedServer: hasSelected,
}
}

344
internal/tui/update.go Normal file
View File

@@ -0,0 +1,344 @@
package tui
import (
"errors"
"tailscale-vpn/internal/pages"
"tailscale-vpn/internal/services"
tea "charm.land/bubbletea/v2"
)
func (m TUIInterface) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case pages.HomePageMsg:
m.Page = pageHome
m.MenuItems = pages.HomeMenuItems(m.VPNConnected, m.HasServers)
return m, nil
case pages.SettingsPageMsg:
m.Page = pageSettings
m.SettingsEditMode = ""
m.SettingsConfirmDelete = false
m.SettingsSelected = 0
return m, loadServersCmd(m.Services)
case pages.SelectServerPageMsg:
m.Page = pageSelectServer
m.SelectServerServers = m.SettingsServers
m.SelectServerSelected = 0
for i, srv := range m.SelectServerServers {
if m.HasSelectedServer && srv.ID == m.SelectedServer.ID {
m.SelectServerSelected = i
break
}
}
return m, nil
case pages.ServerSelectedMsg:
m.SelectedServer = msg.Server
m.HasSelectedServer = msg.Server.ID != ""
if msg.Err != nil {
m.VPNError = msg.Err
}
return m, func() tea.Msg { return pages.HomePageMsg{} }
case pages.ServerSelectionCanceledMsg:
return m, func() tea.Msg { return pages.HomePageMsg{} }
case pages.ServerListLoadedMsg:
m.SettingsServers = msg.Servers
m.HasServers = len(msg.Servers) > 0
if msg.Err != nil {
m.VPNError = msg.Err
}
return m, nil
case pages.ServerEditMsg:
m.SettingsEditMode = msg.Mode
m.SettingsConfirmDelete = false
if msg.Mode == "edit" {
m.SettingsEditingServer = msg.Server
m.SettingsFormName = msg.Server.Name
m.SettingsFormHost = msg.Server.Host
} else {
m.SettingsEditingServer = services.Server{}
m.SettingsFormName = ""
m.SettingsFormHost = ""
}
m.SettingsFormField = 0
return m, nil
case pages.ServerSaveMsg:
m.SettingsEditMode = ""
if msg.Err != nil {
m.VPNError = msg.Err
}
return m, loadServersCmd(m.Services)
case pages.ServerDeleteMsg:
m.SettingsConfirmDelete = false
if msg.Err != nil {
m.VPNError = msg.Err
} else {
if m.SettingsSelected >= len(m.SettingsServers) {
m.SettingsSelected = max(len(m.SettingsServers)-1, 0)
}
}
return m, loadServersCmd(m.Services)
case tea.WindowSizeMsg:
m.WindowWidth = msg.Width
m.WindowHeight = msg.Height
return m, nil
case tea.KeyPressMsg:
if m.Page == pageHome {
return m.updateHomePage(msg)
}
if m.Page == pageSettings {
return m.updateSettingsPage(msg)
}
if m.Page == pageSelectServer {
return m.updateSelectServerPage(msg)
}
case tea.PasteMsg:
if m.Page == pageSettings && m.SettingsEditMode != "" {
if m.SettingsFormField == 0 {
m.SettingsFormName += msg.Content
} else if m.SettingsFormField == 1 {
m.SettingsFormHost += msg.Content
}
return m, nil
}
case pages.VPNStatusMsg:
m.VPNConnected = msg.Connected
m.VPNLoading = false
if msg.Err != nil {
m.VPNError = msg.Err
} else {
m.VPNError = nil
}
m.MenuItems = pages.HomeMenuItems(m.VPNConnected, m.HasServers)
return m, nil
case pages.VPNToggleMsg:
m.VPNConnected = msg.Connected
m.VPNToggleLoading = false
if msg.Err != nil {
m.VPNError = msg.Err
} else {
m.VPNError = nil
}
m.MenuItems = pages.HomeMenuItems(m.VPNConnected, m.HasServers)
return m, nil
}
return m, nil
}
func (m TUIInterface) updateHomePage(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
if m.VPNLoading || m.VPNToggleLoading {
if msg.String() == "ctrl+c" {
m.Quitting = true
return m, tea.Quit
}
return m, nil
}
switch msg.String() {
case "up", "k":
if m.Selected > 0 {
m.Selected--
}
return m, nil
case "down", "j":
if m.Selected < len(m.MenuItems)-1 {
m.Selected++
}
return m, nil
case "enter":
if len(m.MenuItems) > 0 {
switch m.MenuItems[m.Selected].Key {
case "select-server":
return m, func() tea.Msg { return pages.SelectServerPageMsg{} }
case "settings":
return m, func() tea.Msg { return pages.SettingsPageMsg{} }
case "on":
m.VPNToggleLoading = true
m.VPNError = nil
return m, turnOnVPNCmd(m.Services)
case "off":
m.VPNToggleLoading = true
m.VPNError = nil
return m, turnOffVPNCmd(m.Services)
}
}
return m, nil
case "r":
m.VPNLoading = true
m.VPNError = nil
return m, checkVPNStatusCmd(m.Services)
case "ctrl+c", "esc":
m.Quitting = true
return m, tea.Quit
}
return m, nil
}
func (m TUIInterface) updateSelectServerPage(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "up", "k":
if m.SelectServerSelected > 0 {
m.SelectServerSelected--
}
return m, nil
case "down", "j":
if m.SelectServerSelected < len(m.SelectServerServers)-1 {
m.SelectServerSelected++
}
return m, nil
case "enter":
if m.SelectServerSelected >= 0 && m.SelectServerSelected < len(m.SelectServerServers) {
return m, selectServerCmd(m.Services, m.SelectServerServers[m.SelectServerSelected].ID)
}
return m, nil
case "esc", "ctrl+c":
return m, func() tea.Msg { return pages.ServerSelectionCanceledMsg{} }
}
return m, nil
}
func (m TUIInterface) updateSettingsPage(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
if m.SettingsEditMode != "" {
switch msg.String() {
case "esc":
m.SettingsEditMode = ""
return m, nil
case "enter":
if m.SettingsFormField == 2 {
if m.SettingsFormName == "" || m.SettingsFormHost == "" {
m.VPNError = errors.New("name and host are required")
return m, nil
}
if m.SettingsEditMode == "add" {
return m, addServerCmd(m.Services, m.SettingsFormName, m.SettingsFormHost)
}
return m, updateServerCmd(m.Services, m.SettingsEditingServer.ID, m.SettingsFormName, m.SettingsFormHost)
} else if m.SettingsFormField == 3 {
m.SettingsEditMode = ""
return m, nil
} else {
m.SettingsFormField = (m.SettingsFormField + 1) % 4
return m, nil
}
case "up", "k":
m.SettingsFormField = (m.SettingsFormField - 1 + 4) % 4
return m, nil
case "down", "j":
m.SettingsFormField = (m.SettingsFormField + 1) % 4
return m, nil
case "tab":
m.SettingsFormField = (m.SettingsFormField + 1) % 4
return m, nil
case "backtab":
m.SettingsFormField = (m.SettingsFormField - 1 + 4) % 4
return m, nil
case "backspace":
if m.SettingsFormField == 0 && len(m.SettingsFormName) > 0 {
m.SettingsFormName = m.SettingsFormName[:len(m.SettingsFormName)-1]
} else if m.SettingsFormField == 1 && len(m.SettingsFormHost) > 0 {
m.SettingsFormHost = m.SettingsFormHost[:len(m.SettingsFormHost)-1]
}
return m, nil
case "ctrl+c":
m.Quitting = true
return m, tea.Quit
default:
if m.SettingsFormField == 0 || m.SettingsFormField == 1 {
if msg.Text != "" {
if m.SettingsFormField == 0 {
m.SettingsFormName += msg.Text
} else {
m.SettingsFormHost += msg.Text
}
}
}
return m, nil
}
}
switch msg.String() {
case "esc":
if m.SettingsConfirmDelete {
m.SettingsConfirmDelete = false
return m, nil
}
return m, func() tea.Msg { return pages.HomePageMsg{} }
case "up", "k":
if m.SettingsConfirmDelete {
return m, nil
}
if m.SettingsSelected > 0 {
m.SettingsSelected--
}
return m, nil
case "down", "j":
if m.SettingsConfirmDelete {
return m, nil
}
if m.SettingsSelected < len(m.SettingsServers)-1 {
m.SettingsSelected++
}
return m, nil
case "a":
if m.SettingsConfirmDelete {
return m, nil
}
return m, func() tea.Msg { return pages.ServerEditMsg{Mode: "add"} }
case "enter":
if m.SettingsConfirmDelete {
if m.SettingsSelected >= 0 && m.SettingsSelected < len(m.SettingsServers) {
return m, deleteServerCmd(m.Services, m.SettingsServers[m.SettingsSelected].ID)
}
m.SettingsConfirmDelete = false
return m, nil
}
if m.SettingsSelected >= 0 && m.SettingsSelected < len(m.SettingsServers) {
return m, func() tea.Msg {
return pages.ServerEditMsg{
Mode: "edit",
Server: m.SettingsServers[m.SettingsSelected],
}
}
}
return m, nil
case "d":
if m.SettingsConfirmDelete {
return m, nil
}
if len(m.SettingsServers) > 0 {
m.SettingsConfirmDelete = true
}
return m, nil
case "ctrl+c":
m.Quitting = true
return m, tea.Quit
}
return m, nil
}

324
internal/tui/view.go Normal file
View File

@@ -0,0 +1,324 @@
package tui
import (
"tailscale-vpn/internal/styles"
tea "charm.land/bubbletea/v2"
lipgloss "charm.land/lipgloss/v2"
)
func footerHint(key, desc string) string {
return styles.FooterKeyStyle.Render(key) +
" " +
styles.FooterDescStyle.Render(desc)
}
func footerSep() string {
return styles.FooterSepStyle.Render(" · ")
}
func (m TUIInterface) subtitle() string {
switch m.Page {
case pageHome:
return "Secure vpn"
case pageSettings:
return "Settings"
case pageSelectServer:
return "Select Server"
default:
return "Secure vpn"
}
}
func (m TUIInterface) View() tea.View {
if m.Quitting {
return tea.NewView("")
}
w := m.WindowWidth
h := m.WindowHeight
if w == 0 {
w = 80
}
if h == 0 {
h = 24
}
var body string
switch m.Page {
case pageHome:
body = m.viewHomePage()
case pageSettings:
body = m.viewSettingsPage()
case pageSelectServer:
body = m.viewSelectServerPage()
default:
body = m.viewHomePage()
}
header := lipgloss.JoinVertical(lipgloss.Left,
styles.CardTitleStyle.Render("✦ tailscale vpn"),
styles.CardSubtitleStyle.Render(m.subtitle()),
)
topContent := styles.CardInnerStyle.Render(
lipgloss.JoinVertical(lipgloss.Left, header, body),
)
var footerStr string
switch m.Page {
case pageHome:
footerStr = footerHint("↑↓", "navigate") +
footerSep() +
footerHint("enter", "select") +
footerSep() +
footerHint("r", "refresh") +
footerSep() +
footerHint("esc", "quit")
case pageSettings:
if m.SettingsEditMode == "" {
if m.SettingsConfirmDelete {
footerStr = footerHint("enter", "confirm") +
footerSep() +
footerHint("esc", "cancel")
} else {
footerStr = footerHint("↑↓", "navigate") +
footerSep() +
footerHint("enter", "select") +
footerSep() +
footerHint("a", "add") +
footerSep() +
footerHint("d", "delete") +
footerSep() +
footerHint("esc", "back")
}
} else {
footerStr = footerHint("↑↓", "navigate") +
footerSep() +
footerHint("enter", "select") +
footerSep() +
footerHint("esc", "cancel")
}
case pageSelectServer:
footerStr = footerHint("↑↓", "navigate") +
footerSep() +
footerHint("enter", "select") +
footerSep() +
footerHint("esc", "cancel")
default:
footerStr = footerHint("↑↓", "navigate") +
footerSep() +
footerHint("enter", "select") +
footerSep() +
footerHint("esc", "quit")
}
footer := styles.FooterStyle.Render(footerStr)
card := styles.CardStyle.Render(
lipgloss.JoinVertical(lipgloss.Left, topContent, footer),
)
cardHeight := lipgloss.Height(card)
topPad := max((h-cardHeight)/2, 0)
centeredCard := lipgloss.NewStyle().
Width(w).
Align(lipgloss.Center).
PaddingTop(topPad).
Render(card)
v := tea.NewView(centeredCard)
v.AltScreen = true
return v
}
func (m TUIInterface) viewHomePage() string {
var status string
if m.VPNLoading {
status = styles.VPNStatusLoadingStyle.Render("Checking...")
} else if m.VPNConnected {
status = styles.VPNStatusConnectedStyle.Render("● VPN Connected")
} else {
status = styles.VPNStatusDisconnectedStyle.Render("○ VPN Disconnected")
}
var serverStatus string
if m.HasSelectedServer {
serverStatus = styles.ServerSelectedStyle.Render("Server: " + m.SelectedServer.Name)
} else if m.HasServers {
serverStatus = styles.ServerNotSelectedStyle.Render("No server selected")
}
var menu string
if !m.VPNLoading && !m.VPNToggleLoading {
menu = m.renderMenu()
} else {
menu = styles.ButtonLoadingStyle.Render("...")
}
var errorStr string
if m.VPNError != nil {
errorStr = styles.VPNErrorStyle.Render("✗ " + m.VPNError.Error())
}
content := lipgloss.JoinVertical(lipgloss.Left, status, serverStatus, menu, errorStr)
return content
}
func (m TUIInterface) renderMenu() string {
if len(m.MenuItems) == 0 {
return ""
}
var rows []string
for i, item := range m.MenuItems {
if i == m.Selected {
prefix := "▸ "
rows = append(rows, styles.ButtonActiveStyle.Render(prefix+item.Label))
} else {
prefix := " "
rows = append(rows, styles.ButtonInactiveStyle.Render(prefix+item.Label))
}
}
return lipgloss.JoinVertical(lipgloss.Left, rows...)
}
func (m TUIInterface) viewSettingsPage() string {
if m.SettingsEditMode != "" {
return m.renderSettingsForm()
}
if m.SettingsConfirmDelete {
return m.renderSettingsDeleteConfirm()
}
return m.renderSettingsList()
}
func (m TUIInterface) renderSettingsList() string {
if len(m.SettingsServers) == 0 {
return styles.EmptyListStyle.Render("No servers configured")
}
var rows []string
for i, srv := range m.SettingsServers {
if i == m.SettingsSelected {
name := styles.ServerItemActiveStyle.Render(srv.Name)
host := styles.ServerItemActiveStyle.Render(srv.Host)
rows = append(rows, lipgloss.JoinHorizontal(lipgloss.Top, "▸ ", name, styles.ServerItemInactiveStyle.Render(" · "), host))
} else {
name := styles.ServerItemInactiveStyle.Render(srv.Name)
host := styles.ServerItemInactiveStyle.Render(srv.Host)
rows = append(rows, lipgloss.JoinHorizontal(lipgloss.Top, " ", name, styles.ServerItemInactiveStyle.Render(" · "), host))
}
}
return styles.ServerListStyle.Render(lipgloss.JoinVertical(lipgloss.Left, rows...))
}
func (m TUIInterface) renderSettingsForm() string {
title := styles.FormTitleStyle.Render(
func() string {
if m.SettingsEditMode == "add" {
return "Add Server"
}
return "Edit Server"
}(),
)
nameLabel := styles.FormLabelStyle.Render("Name:")
nameInput := func() string {
if m.SettingsFormField == 0 {
return styles.FormInputActiveStyle.Render(m.SettingsFormName + "_")
}
return styles.FormInputInactiveStyle.Render(m.SettingsFormName)
}()
hostLabel := styles.FormLabelStyle.Render("Host:")
hostInput := func() string {
if m.SettingsFormField == 1 {
return styles.FormInputActiveStyle.Render(m.SettingsFormHost + "_")
}
return styles.FormInputInactiveStyle.Render(m.SettingsFormHost)
}()
saveBtn := func() string {
if m.SettingsFormField == 2 {
return styles.FormButtonActiveStyle.Render("Save")
}
return styles.FormButtonInactiveStyle.Render("Save")
}()
cancelBtn := func() string {
if m.SettingsFormField == 3 {
return styles.FormButtonActiveStyle.Render("Cancel")
}
return styles.FormButtonInactiveStyle.Render("Cancel")
}()
buttons := lipgloss.JoinHorizontal(lipgloss.Left, saveBtn, " ", cancelBtn)
content := lipgloss.JoinVertical(
lipgloss.Left,
title,
"",
lipgloss.JoinHorizontal(lipgloss.Top, nameLabel, " ", nameInput),
lipgloss.JoinHorizontal(lipgloss.Top, hostLabel, " ", hostInput),
"",
buttons,
)
return styles.CardInnerStyle.Render(content)
}
func (m TUIInterface) renderSettingsDeleteConfirm() string {
if m.SettingsSelected < 0 || m.SettingsSelected >= len(m.SettingsServers) {
return ""
}
srv := m.SettingsServers[m.SettingsSelected]
title := styles.ConfirmTitleStyle.Render("Delete Server?")
message := styles.ConfirmStyle.Render("Delete \"" + srv.Name + "\"?")
content := lipgloss.JoinVertical(
lipgloss.Left,
title,
"",
message,
)
return styles.CardInnerStyle.Render(content)
}
func (m TUIInterface) viewSelectServerPage() string {
return m.renderSelectServerList()
}
func (m TUIInterface) renderSelectServerList() string {
if len(m.SelectServerServers) == 0 {
return styles.EmptyListStyle.Render("No servers available")
}
var rows []string
for i, srv := range m.SelectServerServers {
isSelected := m.HasSelectedServer && srv.ID == m.SelectedServer.ID
if i == m.SelectServerSelected {
prefix := "▸ "
row := prefix + srv.Name
if isSelected {
row += " " + styles.SelectedMarkerStyle.Render("✓")
}
rows = append(rows, styles.SelectItemActiveStyle.Render(row))
} else {
prefix := " "
row := prefix + srv.Name
if isSelected {
row += " " + styles.SelectedMarkerStyle.Render("✓")
}
rows = append(rows, styles.SelectItemInactiveStyle.Render(row))
}
}
return styles.SelectListStyle.Render(lipgloss.JoinVertical(lipgloss.Left, rows...))
}