init
This commit is contained in:
24
go.mod
Normal file
24
go.mod
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
module tailscale-vpn
|
||||||
|
|
||||||
|
go 1.26.1
|
||||||
|
|
||||||
|
require (
|
||||||
|
charm.land/bubbles/v2 v2.1.0 // indirect
|
||||||
|
charm.land/bubbletea/v2 v2.0.2 // indirect
|
||||||
|
charm.land/lipgloss/v2 v2.0.2 // indirect
|
||||||
|
github.com/charmbracelet/colorprofile v0.4.2 // indirect
|
||||||
|
github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 // indirect
|
||||||
|
github.com/charmbracelet/x/ansi v0.11.6 // indirect
|
||||||
|
github.com/charmbracelet/x/term v0.2.2 // indirect
|
||||||
|
github.com/charmbracelet/x/termios v0.1.1 // indirect
|
||||||
|
github.com/charmbracelet/x/windows v0.2.2 // indirect
|
||||||
|
github.com/clipperhouse/displaywidth v0.11.0 // indirect
|
||||||
|
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
|
||||||
|
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
|
||||||
|
github.com/mattn/go-runewidth v0.0.21 // indirect
|
||||||
|
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||||
|
github.com/rivo/uniseg v0.4.7 // indirect
|
||||||
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||||
|
golang.org/x/sync v0.19.0 // indirect
|
||||||
|
golang.org/x/sys v0.42.0 // indirect
|
||||||
|
)
|
||||||
36
go.sum
Normal file
36
go.sum
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
charm.land/bubbles/v2 v2.1.0 h1:YSnNh5cPYlYjPxRrzs5VEn3vwhtEn3jVGRBT3M7/I0g=
|
||||||
|
charm.land/bubbles/v2 v2.1.0/go.mod h1:l97h4hym2hvWBVfmJDtrEHHCtkIKeTEb3TTJ4ZOB3wY=
|
||||||
|
charm.land/bubbletea/v2 v2.0.2 h1:4CRtRnuZOdFDTWSff9r8QFt/9+z6Emubz3aDMnf/dx0=
|
||||||
|
charm.land/bubbletea/v2 v2.0.2/go.mod h1:3LRff2U4WIYXy7MTxfbAQ+AdfM3D8Xuvz2wbsOD9OHQ=
|
||||||
|
charm.land/lipgloss/v2 v2.0.2 h1:xFolbF8JdpNkM2cEPTfXEcW1p6NRzOWTSamRfYEw8cs=
|
||||||
|
charm.land/lipgloss/v2 v2.0.2/go.mod h1:KjPle2Qd3YmvP1KL5OMHiHysGcNwq6u83MUjYkFvEkM=
|
||||||
|
github.com/charmbracelet/colorprofile v0.4.2 h1:BdSNuMjRbotnxHSfxy+PCSa4xAmz7szw70ktAtWRYrY=
|
||||||
|
github.com/charmbracelet/colorprofile v0.4.2/go.mod h1:0rTi81QpwDElInthtrQ6Ni7cG0sDtwAd4C4le060fT8=
|
||||||
|
github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 h1:eyFRbAmexyt43hVfeyBofiGSEmJ7krjLOYt/9CF5NKA=
|
||||||
|
github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8/go.mod h1:SQpCTRNBtzJkwku5ye4S3HEuthAlGy2n9VXZnWkEW98=
|
||||||
|
github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
|
||||||
|
github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
|
||||||
|
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
|
||||||
|
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
|
||||||
|
github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
|
||||||
|
github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=
|
||||||
|
github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM=
|
||||||
|
github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k=
|
||||||
|
github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8=
|
||||||
|
github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
|
||||||
|
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
|
||||||
|
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
|
||||||
|
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
|
||||||
|
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||||
|
github.com/mattn/go-runewidth v0.0.21 h1:jJKAZiQH+2mIinzCJIaIG9Be1+0NR+5sz/lYEEjdM8w=
|
||||||
|
github.com/mattn/go-runewidth v0.0.21/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
||||||
|
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
|
||||||
|
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
||||||
|
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||||
|
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||||
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||||
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||||
|
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||||
|
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||||
|
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||||
|
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
36
internal/pages/home.go
Normal file
36
internal/pages/home.go
Normal 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
|
||||||
|
}
|
||||||
10
internal/pages/select_server.go
Normal file
10
internal/pages/select_server.go
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
package pages
|
||||||
|
|
||||||
|
import "tailscale-vpn/internal/services"
|
||||||
|
|
||||||
|
type ServerSelectedMsg struct {
|
||||||
|
Server services.Server
|
||||||
|
Err error
|
||||||
|
}
|
||||||
|
|
||||||
|
type ServerSelectionCanceledMsg struct{}
|
||||||
21
internal/pages/settings.go
Normal file
21
internal/pages/settings.go
Normal 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
130
internal/services/config.go
Normal 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
|
||||||
|
}
|
||||||
11
internal/services/services_store.go
Normal file
11
internal/services/services_store.go
Normal 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
|
||||||
|
}
|
||||||
66
internal/services/vpn_service.go
Normal file
66
internal/services/vpn_service.go
Normal 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
159
internal/styles/styles.go
Normal 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
100
internal/tui/commands.go
Normal 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
15
internal/tui/init.go
Normal 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
56
internal/tui/tui.go
Normal 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
344
internal/tui/update.go
Normal 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
324
internal/tui/view.go
Normal 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...))
|
||||||
|
}
|
||||||
38
main.go
Normal file
38
main.go
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
|
||||||
|
"tailscale-vpn/internal/services"
|
||||||
|
"tailscale-vpn/internal/tui"
|
||||||
|
|
||||||
|
tea "charm.land/bubbletea/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
func authenticateSudo() error {
|
||||||
|
cmd := exec.Command("sudo", "-v")
|
||||||
|
cmd.Stdin = os.Stdin
|
||||||
|
cmd.Stdout = os.Stdout
|
||||||
|
cmd.Stderr = os.Stderr
|
||||||
|
return cmd.Run()
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
// Authenticate sudo before starting TUI
|
||||||
|
fmt.Println("Authenticating sudo...")
|
||||||
|
if err := authenticateSudo(); err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, "Failed to authenticate sudo:", err)
|
||||||
|
fmt.Fprintln(os.Stderr, "Sudo is required to manage the Tailscale exit node.")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
srvStore, _ := services.NewServicesStore()
|
||||||
|
m := tui.NewTUIInterface(srvStore)
|
||||||
|
p := tea.NewProgram(m)
|
||||||
|
if _, err := p.Run(); err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user