This commit is contained in:
Hadi
2026-08-27 23:14:56 +02:00
commit e9c2a59ee5
41 changed files with 3680 additions and 0 deletions
+229
View File
@@ -0,0 +1,229 @@
package backend
import (
"context"
"encoding/json"
"errors"
"os"
"path/filepath"
"strings"
"unicode"
)
type Option struct {
ID string
Description string
}
type Setting struct {
Description string
Options []Option
Value string
}
type Config map[string]Setting
func LoadConfig() (c Config, err error) {
c = make(Config)
ctx := context.Background()
res, err := Run(ctx, "config", "list")
if err != nil {
return
}
inOptions := false
for line := range strings.SplitSeq(res.Stdout, "\n") {
fields := strings.Fields(line)
if len(fields) < 2 {
inOptions = false
continue
}
if strings.HasPrefix(fields[0], "---") {
inOptions = true
continue
}
if !inOptions {
continue
}
c[fields[0]] = Setting{Value: strings.Join(fields[1:], " ")}
}
for setting := range c {
desc, options, e := getInfo(setting)
if e != nil {
err = e
return
}
entry := c[setting]
entry.Description = desc
entry.Options = options
c[setting] = entry
}
if err = c.SaveCache(); err != nil {
return
}
return
}
func LoadConfigCache() (c Config, err error) {
path, err := configCachePath()
if err != nil {
return
}
data, err := os.ReadFile(path)
if err != nil {
return
}
err = json.Unmarshal(data, &c)
return
}
func (c Config) SaveCache() error {
path, err := configCachePath()
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return err
}
data, err := json.Marshal(c)
if err != nil {
return err
}
return os.WriteFile(path, data, 0o600)
}
func configCachePath() (string, error) {
cacheHome, err := xdgCacheHome()
if err != nil {
return "", err
}
return filepath.Join(cacheHome, "proton-vpn-tui", "config.json"), nil
}
func (c Config) CycleSettingUp(setting string) (err error) {
entry, ok := c[setting]
if !ok || len(entry.Options) == 0 {
return errors.New("setting unknown")
}
idx := -1
for i, opt := range entry.Options {
if opt.ID == entry.Value {
idx = i
break
}
}
next := idx + 1
if next >= len(entry.Options) {
next = 0
}
return c.setSetting(setting, entry.Options[next].ID)
}
func (c Config) CycleSettingDown(setting string) (err error) {
entry, ok := c[setting]
if !ok || len(entry.Options) == 0 {
return errors.New("setting unknown")
}
idx := -1
for i, opt := range entry.Options {
if opt.ID == entry.Value {
idx = i
break
}
}
prev := idx - 1
if prev < 0 {
prev = len(entry.Options) - 1
}
return c.setSetting(setting, entry.Options[prev].ID)
}
func (c Config) setSetting(setting string, value string) error {
ctx := context.Background()
_, err := Run(ctx, "config", "set", setting, value)
if err != nil {
return err
}
entry := c[setting]
entry.Value = value
c[setting] = entry
return c.SaveCache()
}
func getInfo(setting string) (description string, options []Option, err error) {
ctx := context.Background()
res, err := Run(ctx, "config", "set", setting, "--help")
if err != nil {
return
}
inUsage := false
inDescription := false
inOptions := false
descDone := false
var descLines []string
for line := range strings.SplitSeq(res.Stdout, "\n") {
line = strings.TrimSpace(stripControl(line))
if strings.HasPrefix(line, "Usage:") {
inUsage = true
continue
}
if line == "Values:" {
inOptions = true
continue
}
if line == "" {
if inUsage {
inUsage = false
inDescription = !descDone
} else if len(descLines) > 0 {
descDone = true
inDescription = false
}
inOptions = false
continue
}
if inUsage {
continue
}
if inOptions {
fields := strings.Fields(line)
if len(fields) < 2 {
continue
}
options = append(options, Option{ID: fields[0], Description: strings.Join(fields[1:], " ")})
continue
}
if inDescription {
descLines = append(descLines, line)
}
}
description = strings.Join(descLines, " ")
return
}
func stripControl(s string) string {
return strings.Map(func(r rune) rune {
if unicode.IsControl(r) {
return -1
}
return r
}, s)
}
+74
View File
@@ -0,0 +1,74 @@
package backend
import (
"context"
"fmt"
"regexp"
"strings"
)
func connectArgs(country, city string, p2p, secureCore, tor bool) []string {
args := []string{"connect"}
if country != "" {
args = append(args, "--country", country)
}
if city != "" {
args = append(args, "--city", city)
}
if p2p {
args = append(args, "--p2p")
}
if secureCore {
args = append(args, "--securecore")
}
if tor {
args = append(args, "--tor")
}
return args
}
func ConnectFastest(country, city string, p2p, secureCore, tor bool) (err error) {
_, err = Run(context.Background(), connectArgs(country, city, p2p, secureCore, tor)...)
return
}
func ConnectRandom(country, city string, p2p, secureCore, tor bool) (err error) {
args := append(connectArgs(country, city, p2p, secureCore, tor), "--random")
_, err = Run(context.Background(), args...)
return
}
func ConnectId(id string) (err error) {
if !isValidServerId(id) {
return fmt.Errorf("protonvpn: invalid server id %q", id)
}
_, err = Run(context.Background(), "connect", id)
return
}
func ConnectAny() (err error) {
_, err = Run(context.Background(), "connect")
return
}
func Disconnect() (err error) {
res, err := Run(context.Background(), "disconnect")
if err != nil && res != nil && strings.HasPrefix(res.Stdout, "Disconnected") {
// The official CLI sometimes reports a successful disconnect and
// then, in a separate step (e.g. cleanup/telemetry), fails and
// exits non-zero with the generic error banner. The VPN is
// already disconnected at that point, so treat it as success.
return nil
}
return err
}
var serverIdPattern = regexp.MustCompile(`^[A-Z]{2}(-[A-Z0-9]{2,4})?#[0-9]+(-TOR)?$`)
func isValidServerId(id string) bool {
return serverIdPattern.MatchString(id)
}
func IsServerID(s string) bool {
return isValidServerId(s)
}
+108
View File
@@ -0,0 +1,108 @@
package backend
import "strings"
var countryNames = map[string]string{
"AD": "Andorra", "AE": "United Arab Emirates", "AF": "Afghanistan",
"AG": "Antigua and Barbuda", "AI": "Anguilla", "AL": "Albania", "AM": "Armenia",
"AO": "Angola", "AQ": "Antarctica", "AR": "Argentina", "AS": "American Samoa",
"AT": "Austria", "AU": "Australia", "AW": "Aruba", "AX": "Åland Islands",
"AZ": "Azerbaijan", "BA": "Bosnia and Herzegovina", "BB": "Barbados",
"BD": "Bangladesh", "BE": "Belgium", "BF": "Burkina Faso", "BG": "Bulgaria",
"BH": "Bahrain", "BI": "Burundi", "BJ": "Benin", "BL": "Saint Barthélemy",
"BM": "Bermuda", "BN": "Brunei", "BO": "Bolivia", "BQ": "Caribbean Netherlands",
"BR": "Brazil", "BS": "Bahamas", "BT": "Bhutan", "BV": "Bouvet Island",
"BW": "Botswana", "BY": "Belarus", "BZ": "Belize", "CA": "Canada",
"CC": "Cocos Islands", "CD": "DR Congo", "CF": "Central African Republic",
"CG": "Congo", "CH": "Switzerland", "CI": "Ivory Coast", "CK": "Cook Islands",
"CL": "Chile", "CM": "Cameroon", "CN": "China", "CO": "Colombia",
"CR": "Costa Rica", "CU": "Cuba", "CV": "Cabo Verde", "CW": "Curaçao",
"CX": "Christmas Island", "CY": "Cyprus", "CZ": "Czech Republic",
"DE": "Germany", "DJ": "Djibouti", "DK": "Denmark", "DM": "Dominica",
"DO": "Dominican Republic", "DZ": "Algeria", "EC": "Ecuador", "EE": "Estonia",
"EG": "Egypt", "EH": "Western Sahara", "ER": "Eritrea", "ES": "Spain",
"ET": "Ethiopia", "FI": "Finland", "FJ": "Fiji", "FK": "Falkland Islands",
"FM": "Micronesia", "FO": "Faroe Islands", "FR": "France", "GA": "Gabon",
"GB": "United Kingdom", "GD": "Grenada", "GE": "Georgia",
"GF": "French Guiana", "GG": "Guernsey", "GH": "Ghana", "GI": "Gibraltar",
"GL": "Greenland", "GM": "Gambia", "GN": "Guinea", "GP": "Guadeloupe",
"GQ": "Equatorial Guinea", "GR": "Greece",
"GS": "South Georgia and the South Sandwich Islands", "GT": "Guatemala",
"GU": "Guam", "GW": "Guinea-Bissau", "GY": "Guyana", "HK": "Hong Kong",
"HM": "Heard Island and McDonald Islands", "HN": "Honduras", "HR": "Croatia",
"HT": "Haiti", "HU": "Hungary", "ID": "Indonesia", "IE": "Ireland",
"IL": "Israel", "IM": "Isle of Man", "IN": "India",
"IO": "British Indian Ocean Territory", "IQ": "Iraq", "IR": "Iran",
"IS": "Iceland", "IT": "Italy", "JE": "Jersey", "JM": "Jamaica",
"JO": "Jordan", "JP": "Japan", "KE": "Kenya", "KG": "Kyrgyzstan",
"KH": "Cambodia", "KI": "Kiribati", "KM": "Comoros",
"KN": "Saint Kitts and Nevis", "KP": "North Korea", "KR": "South Korea",
"KW": "Kuwait", "KY": "Cayman Islands", "KZ": "Kazakhstan", "LA": "Laos",
"LB": "Lebanon", "LC": "Saint Lucia", "LI": "Liechtenstein",
"LK": "Sri Lanka", "LR": "Liberia", "LS": "Lesotho", "LT": "Lithuania",
"LU": "Luxembourg", "LV": "Latvia", "LY": "Libya", "MA": "Morocco",
"MC": "Monaco", "MD": "Moldova", "ME": "Montenegro", "MF": "Saint Martin",
"MG": "Madagascar", "MH": "Marshall Islands", "MK": "North Macedonia",
"ML": "Mali", "MM": "Myanmar", "MN": "Mongolia", "MO": "Macao",
"MP": "Northern Mariana Islands", "MQ": "Martinique", "MR": "Mauritania",
"MS": "Montserrat", "MT": "Malta", "MU": "Mauritius", "MV": "Maldives",
"MW": "Malawi", "MX": "Mexico", "MY": "Malaysia", "MZ": "Mozambique",
"NA": "Namibia", "NC": "New Caledonia", "NE": "Niger",
"NF": "Norfolk Island", "NG": "Nigeria", "NI": "Nicaragua",
"NL": "Netherlands", "NO": "Norway", "NP": "Nepal", "NR": "Nauru",
"NU": "Niue", "NZ": "New Zealand", "OM": "Oman", "PA": "Panama",
"PE": "Peru", "PF": "French Polynesia", "PG": "Papua New Guinea",
"PH": "Philippines", "PK": "Pakistan", "PL": "Poland",
"PM": "Saint Pierre and Miquelon", "PN": "Pitcairn Islands",
"PR": "Puerto Rico", "PS": "Palestine", "PT": "Portugal", "PW": "Palau",
"PY": "Paraguay", "QA": "Qatar", "RE": "Réunion", "RO": "Romania",
"RS": "Serbia", "RU": "Russia", "RW": "Rwanda", "SA": "Saudi Arabia",
"SB": "Solomon Islands", "SC": "Seychelles", "SD": "Sudan", "SE": "Sweden",
"SG": "Singapore", "SH": "Saint Helena", "SI": "Slovenia",
"SJ": "Svalbard and Jan Mayen", "SK": "Slovakia", "SL": "Sierra Leone",
"SM": "San Marino", "SN": "Senegal", "SO": "Somalia", "SR": "Suriname",
"SS": "South Sudan", "ST": "São Tomé and Príncipe", "SV": "El Salvador",
"SX": "Sint Maarten", "SY": "Syria", "SZ": "Eswatini",
"TC": "Turks and Caicos Islands", "TD": "Chad",
"TF": "French Southern Territories", "TG": "Togo", "TH": "Thailand",
"TJ": "Tajikistan", "TK": "Tokelau", "TL": "Timor-Leste",
"TM": "Turkmenistan", "TN": "Tunisia", "TO": "Tonga", "TR": "Turkey",
"TT": "Trinidad and Tobago", "TV": "Tuvalu", "TW": "Taiwan",
"TZ": "Tanzania", "UA": "Ukraine", "UG": "Uganda",
"UM": "United States Minor Outlying Islands", "US": "United States",
"UY": "Uruguay", "UZ": "Uzbekistan", "VA": "Vatican City",
"VC": "Saint Vincent and the Grenadines", "VE": "Venezuela",
"VG": "British Virgin Islands", "VI": "United States Virgin Islands",
"VN": "Vietnam", "VU": "Vanuatu", "WF": "Wallis and Futuna",
"WS": "Samoa", "YE": "Yemen", "YT": "Mayotte", "ZA": "South Africa",
"ZM": "Zambia", "ZW": "Zimbabwe",
}
func CountryName(countryCode string) string {
code := strings.ToUpper(countryCode)
if code == "UK" {
code = "GB"
}
if name, ok := countryNames[code]; ok {
return name
}
return countryCode
}
func ResolveCountryCode(s string) (string, bool) {
code := strings.ToUpper(strings.TrimSpace(s))
if code == "UK" {
code = "GB"
}
if _, ok := countryNames[code]; ok {
return code, true
}
name := strings.ToLower(strings.TrimSpace(s))
for c, n := range countryNames {
if strings.ToLower(n) == name {
return c, true
}
}
return "", false
}
+109
View File
@@ -0,0 +1,109 @@
package backend
import (
"bytes"
"context"
"errors"
"fmt"
"os/exec"
"strings"
"time"
)
const DefaultTimeout = 15 * time.Second
var BinaryName = "protonvpn"
var ErrTimeout = errors.New("protonvpn: command timeout")
var ErrNotFound = errors.New("protonvpn: binary not found in PATH")
type Result struct {
Stdout string
Stderr string
}
type CommandError struct {
Result *Result
Err error
}
const unexpectedErrorBanner = "An unexpected error occurred. Please try again."
func (e *CommandError) Error() string {
if strings.HasPrefix(e.Result.Stderr, unexpectedErrorBanner) ||
strings.HasPrefix(e.Result.Stdout, unexpectedErrorBanner) {
return unexpectedErrorBanner
}
if e.Result.Stderr != "" {
return fmt.Sprintf("%v (stderr: %q)", e.Err, e.Result.Stderr)
}
if e.Result.Stdout != "" {
return e.Result.Stdout
}
return e.Err.Error()
}
func (e *CommandError) Unwrap() error {
return e.Err
}
func IsAuthRequired(err error) bool {
var cmdErr *CommandError
if !errors.As(err, &cmdErr) {
return false
}
return strings.Contains(cmdErr.Result.Stderr, "protonvpn signin") ||
strings.Contains(cmdErr.Result.Stdout, "protonvpn signin")
}
func IsFreePlanRestricted(err error) bool {
var cmdErr *CommandError
if !errors.As(err, &cmdErr) {
return false
}
return strings.Contains(cmdErr.Result.Stderr, "not available on the free plan") ||
strings.Contains(cmdErr.Result.Stdout, "not available on the free plan")
}
func Available() error {
if _, err := exec.LookPath(BinaryName); err != nil {
return ErrNotFound
}
return nil
}
func Run(ctx context.Context, args ...string) (*Result, error) {
if ctx == nil {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(context.Background(), DefaultTimeout)
defer cancel()
}
cmd := exec.CommandContext(ctx, BinaryName, args...)
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
runErr := cmd.Run()
res := &Result{
Stdout: strings.TrimSpace(stdout.String()),
Stderr: strings.TrimSpace(stderr.String()),
}
if ctx.Err() == context.DeadlineExceeded {
return res, &CommandError{Result: res, Err: ErrTimeout}
}
if runErr != nil {
return res, &CommandError{Result: res, Err: runErr}
}
if strings.HasPrefix(res.Stdout, "Error:") {
return res, &CommandError{Result: res, Err: errors.New(res.Stdout)}
}
return res, nil
}
+34
View File
@@ -0,0 +1,34 @@
package backend
import "strings"
func countryCodeFromServerId(id string) string {
if i := strings.IndexAny(id, "-#"); i != -1 {
return id[:i]
}
return id
}
func FlagEmoji(countryCode string) string {
code := strings.ToUpper(countryCode)
if code == "UK" {
code = "GB"
}
if len(code) != 2 {
return "🏳️"
}
var flag strings.Builder
for _, r := range code {
if r < 'A' || r > 'Z' {
return "🏳️"
}
flag.WriteRune(r + 127397)
}
return flag.String()
}
func FlagEmojiFromServerId(serverId string) string {
return FlagEmoji(countryCodeFromServerId(serverId))
}
+113
View File
@@ -0,0 +1,113 @@
package backend
import (
"context"
"encoding/json"
"os"
"path/filepath"
)
const (
featureSecureCore = 1 << iota
featureTor
featureP2P
featureStreaming
featureIPv6
)
type LogicalServer struct {
Name string
EntryCountry string
ExitCountry string
City *string
Tier int
SecureCore bool
Tor bool
P2P bool
Streaming bool
IPv6 bool
Score float64
ID string
Status int
Load int
}
func (s LogicalServer) IsFree() bool {
return s.Tier == 0
}
func (s *LogicalServer) UnmarshalJSON(data []byte) error {
var raw struct {
Name string
EntryCountry string
ExitCountry string
City *string
Tier int
Features int
Score float64
ID string
Status int
Load int
}
if err := json.Unmarshal(data, &raw); err != nil {
return err
}
*s = LogicalServer{
Name: raw.Name,
EntryCountry: raw.EntryCountry,
ExitCountry: raw.ExitCountry,
City: raw.City,
Tier: raw.Tier,
SecureCore: raw.Features&featureSecureCore != 0,
Tor: raw.Features&featureTor != 0,
P2P: raw.Features&featureP2P != 0,
Streaming: raw.Features&featureStreaming != 0,
IPv6: raw.Features&featureIPv6 != 0,
Score: raw.Score,
ID: raw.ID,
Status: raw.Status,
Load: raw.Load,
}
return nil
}
func RefreshServerList() error {
_, err := Run(context.Background(), "cities", "list", "France")
return err
}
func ParseCache() ([]LogicalServer, error) {
cacheHome, err := xdgCacheHome()
if err != nil {
return nil, err
}
path := filepath.Join(cacheHome, "Proton", "VPN", "serverlist.json")
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var cache struct {
LogicalServers []LogicalServer
}
if err := json.Unmarshal(data, &cache); err != nil {
return nil, err
}
return cache.LogicalServers, nil
}
func xdgCacheHome() (string, error) {
if dir := os.Getenv("XDG_CACHE_HOME"); dir != "" {
return dir, nil
}
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
return filepath.Join(home, ".cache"), nil
}
+103
View File
@@ -0,0 +1,103 @@
package backend
import (
"bytes"
"context"
"errors"
"fmt"
"os/exec"
"strconv"
"strings"
)
const protonInterface = "proton0"
var ErrNmcliNotFound = errors.New("nmcli: binary not found in PATH")
type ConnectionStatus struct {
Connected bool
Server string
Location string
Load int
Protocol string
}
func Status() (ConnectionStatus, error) {
connected, err := isConnectedViaNetworkManager()
if err != nil {
return ConnectionStatus{}, err
}
if !connected {
return ConnectionStatus{Connected: false}, nil
}
res, err := Run(context.Background(), "status")
if err != nil {
return ConnectionStatus{}, err
}
status := parseStatus(res.Stdout)
status.Connected = true
return status, nil
}
func IsConnected() (bool, error) {
return isConnectedViaNetworkManager()
}
func isConnectedViaNetworkManager() (bool, error) {
if _, err := exec.LookPath("nmcli"); err != nil {
return false, ErrNmcliNotFound
}
ctx, cancel := context.WithTimeout(context.Background(), DefaultTimeout)
defer cancel()
cmd := exec.CommandContext(ctx, "nmcli", "-t", "-f", "DEVICE", "connection", "show", "--active")
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
return false, fmt.Errorf("nmcli: %w (stderr: %q)", err, strings.TrimSpace(stderr.String()))
}
for device := range strings.SplitSeq(strings.TrimSpace(stdout.String()), "\n") {
if device == protonInterface {
return true, nil
}
}
return false, nil
}
func parseStatus(output string) ConnectionStatus {
var status ConnectionStatus
for line := range strings.SplitSeq(output, "\n") {
key, value, found := strings.Cut(strings.TrimSpace(line), ": ")
if !found {
continue
}
switch key {
case "Status":
status.Connected = value == "Connected"
case "Server":
if server, location, ok := strings.Cut(value, " in "); ok {
status.Server = server
status.Location = location
} else {
status.Server = value
}
case "Load":
if load, err := strconv.Atoi(strings.TrimSuffix(value, "%")); err == nil {
status.Load = load
}
case "Protocol":
status.Protocol = value
}
}
return status
}
+31
View File
@@ -0,0 +1,31 @@
package config
import (
_ "embed"
)
//go:embed default_config.yaml
var defaultConfig []byte
type Config struct {
Version string `mapstructure:"-"`
App struct {
ShowFlags bool `mapstructure:"show_flags"`
FocusSearchOnLaunch bool `mapstructure:"focus_search_on_launch"`
Default struct {
Tor bool `mapstructure:"tor"`
P2P bool `mapstructure:"p2p"`
SecureCore bool `mapstructure:"securecore"`
Free bool `mapstructure:"free"`
Prefer string `mapstructure:"prefer"`
} `mapstructure:"default"`
Favorites []string `mapstructure:"favorites"`
} `mapstructure:"app"`
Keybindings Keybindings `mapstructure:"keybindings"`
}
var Global *Config
+43
View File
@@ -0,0 +1,43 @@
app:
show_flags: true
focus_search_on_launch: false
default:
tor: false # Enable Tor over VPN
p2p: false # Only show P2P-optimized servers
securecore: false # Use an entry node and an exit node
free: false # Show only free servers
prefer: "fastest" # 'fastest' or 'random'
# Country code (e.g. "CH"), country name (e.g. "Switzerland"), or a
# specific server ID (e.g. "CH#20"). Case-insensitive.
favorites: []
keybindings:
global:
quit: "q,ctrl+c"
escape: "esc,ctrl+c"
help: "?"
up: "up,k"
down: "down,j"
left: "left,h"
right: "right,l"
goto_top: "g"
goto_bottom: "G,end"
home:
open_settings: "s"
connect: "enter,c"
disconnect: "d"
goto_favorite: "f"
search: "/,ctrl+f"
refresh: "r"
toggle_tor: "t"
toggle_securecore: "S"
toggle_p2p: "p"
toggle_free: "F"
toggle_prefer: "P"
settings:
toggle_setting: "space"
previous_option: "left,h"
next_option: "right,l"
+78
View File
@@ -0,0 +1,78 @@
package config
import (
_ "embed"
"errors"
"fmt"
"maps"
"os"
"path/filepath"
"reflect"
"github.com/go-viper/mapstructure/v2"
"github.com/spf13/viper"
"gopkg.in/yaml.v3"
)
func DefaultPath() string {
dir, err := os.UserConfigDir()
if err != nil {
dir = "."
}
return filepath.Join(dir, "proton-vpn-tui", "config.yaml")
}
func Load(path string) error {
var defaults map[string]any
if err := yaml.Unmarshal(defaultConfig, &defaults); err != nil {
return fmt.Errorf("default config: %w", err)
}
for k, v := range flatten("", defaults) {
viper.SetDefault(k, v)
}
viper.SetConfigType("yaml")
viper.SetConfigFile(path)
if err := viper.ReadInConfig(); err != nil {
if !errors.Is(err, os.ErrNotExist) {
return err
}
}
Global = &Config{}
hook := viper.DecodeHook(mapstructure.ComposeDecodeHookFunc(
stringToKeyBindingHook,
mapstructure.StringToTimeDurationHookFunc(),
))
if err := viper.Unmarshal(Global, hook); err != nil {
return err
}
fillHelp(reflect.ValueOf(&Global.Keybindings))
return nil
}
func WriteDefaultConfig(path string) error {
if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
return fmt.Errorf("create config dir: %w", err)
}
if err := os.WriteFile(path, defaultConfig, 0o600); err != nil {
return fmt.Errorf("write config: %w", err)
}
return nil
}
func flatten(prefix string, m map[string]any) map[string]any {
out := make(map[string]any)
for k, v := range m {
key := k
if prefix != "" {
key = prefix + "." + k
}
if nested, ok := v.(map[string]any); ok {
maps.Copy(out, flatten(key, nested))
} else {
out[key] = v
}
}
return out
}
+113
View File
@@ -0,0 +1,113 @@
package config
import (
"fmt"
"reflect"
"slices"
"strings"
"charm.land/bubbles/v2/key"
)
func ShortHelp(keymap any) []key.Binding {
return bindingsWithHelp(keymap, "short")
}
func pageFullHelp(keymap any) []key.Binding {
return bindingsWithHelp(keymap, "short", "full")
}
func bindingsWithHelp(keymap any, levels ...string) []key.Binding {
v := reflect.ValueOf(keymap)
if v.Kind() == reflect.Pointer {
v = v.Elem()
}
t := v.Type()
var out []key.Binding
for i := range v.NumField() {
field := t.Field(i)
if field.Type != bindingType {
continue
}
if slices.Contains(levels, field.Tag.Get("help")) {
out = append(out, v.Field(i).Interface().(key.Binding))
}
}
return out
}
type HelpKeyMap struct {
pages []any
}
func Help(pages ...any) HelpKeyMap {
return HelpKeyMap{pages: pages}
}
func (h HelpKeyMap) ShortHelp() []key.Binding {
var out []key.Binding
for _, p := range h.pages {
out = append(out, ShortHelp(p)...)
}
return out
}
func (h HelpKeyMap) FullHelp() [][]key.Binding {
cols := make([][]key.Binding, 0, len(h.pages))
for _, p := range h.pages {
if col := pageFullHelp(p); len(col) > 0 {
cols = append(cols, col)
}
}
return cols
}
var bindingType = reflect.TypeFor[key.Binding]()
var validHelpLevels = map[string]bool{"none": true, "short": true, "full": true}
func stringToKeyBindingHook(from reflect.Type, to reflect.Type, data any) (any, error) {
if to != bindingType || from.Kind() != reflect.String {
return data, nil
}
var keys []string
for part := range strings.SplitSeq(data.(string), ",") {
if k := strings.TrimSpace(part); k != "" {
keys = append(keys, k)
}
}
return key.NewBinding(key.WithKeys(keys...)), nil
}
func fillHelp(v reflect.Value) {
if v.Kind() == reflect.Pointer {
v = v.Elem()
}
if v.Kind() != reflect.Struct {
return
}
t := v.Type()
for i := range t.NumField() {
field := t.Field(i)
fv := v.Field(i)
if field.Type == bindingType {
b := fv.Addr().Interface().(*key.Binding)
b.SetHelp(strings.Join(b.Keys(), ","), field.Tag.Get("desc"))
if level := field.Tag.Get("help"); !validHelpLevels[level] {
panic(fmt.Sprintf(
"config: %s.%s has invalid `help` tag %q, want \"none\", \"short\" or \"full\"",
t.Name(), field.Name, level,
))
}
continue
}
if field.Type.Kind() == reflect.Struct {
fillHelp(fv)
}
}
}
+44
View File
@@ -0,0 +1,44 @@
package config
import "charm.land/bubbles/v2/key"
type GlobalKeys struct {
Quit key.Binding `mapstructure:"quit" desc:"quit" help:"short"`
Escape key.Binding `mapstructure:"escape" desc:"close" help:"none"`
Help key.Binding `mapstructure:"help" desc:"help" help:"none"`
Up key.Binding `mapstructure:"up" desc:"up" help:"full"`
Down key.Binding `mapstructure:"down" desc:"down" help:"full"`
Left key.Binding `mapstructure:"left" desc:"left" help:"full"`
Right key.Binding `mapstructure:"right" desc:"right" help:"full"`
GotoTop key.Binding `mapstructure:"goto_top" desc:"go to top" help:"full"`
GotoBottom key.Binding `mapstructure:"goto_bottom" desc:"go to bottom" help:"full"`
}
type HomeKeys struct {
OpenSettings key.Binding `mapstructure:"open_settings" desc:"settings" help:"full"`
Connect key.Binding `mapstructure:"connect" desc:"connect" help:"short"`
Disconnect key.Binding `mapstructure:"disconnect" desc:"disconnect" help:"short"`
GotoFavorite key.Binding `mapstructure:"goto_favorite" desc:"go to favorites" help:"full"`
Search key.Binding `mapstructure:"search" desc:"search" help:"short"`
Refresh key.Binding `mapstructure:"refresh" desc:"refresh" help:"full"`
ToggleTor key.Binding `mapstructure:"toggle_tor" desc:"toggle tor" help:"full"`
ToggleSecureCore key.Binding `mapstructure:"toggle_securecore" desc:"toggle secure core" help:"full"`
ToggleP2P key.Binding `mapstructure:"toggle_p2p" desc:"toggle p2p" help:"full"`
ToggleFree key.Binding `mapstructure:"toggle_free" desc:"toggle free" help:"full"`
TogglePrefer key.Binding `mapstructure:"toggle_prefer" desc:"fastest/random" help:"full"`
}
type SettingsKeys struct {
PreviousOption key.Binding `mapstructure:"previous_option" desc:"decrease" help:"full"`
NextOption key.Binding `mapstructure:"next_option" desc:"increase" help:"full"`
ToggleSetting key.Binding `mapstructure:"toggle_setting" desc:"toggle" help:"short"`
}
type Keybindings struct {
Global GlobalKeys `mapstructure:"global"`
Home HomeKeys `mapstructure:"home"`
Settings SettingsKeys `mapstructure:"settings"`
}
+87
View File
@@ -0,0 +1,87 @@
package icons
import (
"github.com/anotherhadi/ilovetui/style"
)
type Icons struct {
Settings string
Vpn string
SecureCore string
Tor string
P2p string
Free string
Fastest string
Random string
Connected string
Disconnected string
Connecting string
Loading string
Country string
City string
Server string
Expanded string
Collapsed string
Favorite string
Search string
Load string
PillLeft string
PillRight string
}
var I *Icons
func Init() {
if style.S.NerdFonts {
I = &Icons{
Settings: "",
Vpn: "",
SecureCore: "",
Tor: "",
P2p: "",
Free: "",
Fastest: "",
Random: "",
Connected: "",
Disconnected: "",
Connecting: "",
Loading: "",
Country: "",
City: "",
Server: "",
Expanded: "",
Collapsed: "",
Favorite: "",
Search: "",
Load: "",
PillLeft: "",
PillRight: "",
}
} else {
I = &Icons{
Connected: "●",
Disconnected: "○",
Connecting: "…",
Loading: "…",
Expanded: "v",
Collapsed: ">",
Favorite: "*",
Search: "/",
}
}
}
+144
View File
@@ -0,0 +1,144 @@
package app
import (
"time"
tea "charm.land/bubbletea/v2"
"github.com/anotherhadi/ilovetui/notification"
"github.com/anotherhadi/proton-vpn-tui/internal/backend"
)
type (
tickMsg time.Time
serversMsg []backend.LogicalServer
serversRefreshedMsg []backend.LogicalServer
serversErrMsg struct{ err error }
statusMsg backend.ConnectionStatus
statusErrMsg struct{ err error }
serversCacheErrMsg struct{ err error }
quickStatusMsg bool
quickStatusErrMsg struct{ err error }
connectResultMsg struct {
err error
target string
disconnect bool
freeFallback bool
}
)
func tickCmd() tea.Cmd {
return tea.Tick(5*time.Second, func(t time.Time) tea.Msg { return tickMsg(t) })
}
func fetchServersCache() tea.Msg {
servers, err := backend.ParseCache()
if err != nil {
return serversCacheErrMsg{err: err}
}
return serversMsg(servers)
}
func refreshServers() tea.Msg {
if err := backend.RefreshServerList(); err != nil {
return serversErrMsg{err: err}
}
servers, err := parseCacheAfterRefresh()
if err != nil {
return serversErrMsg{err: err}
}
return serversMsg(servers)
}
func refreshServersManual() tea.Msg {
if err := backend.RefreshServerList(); err != nil {
return serversErrMsg{err: err}
}
servers, err := parseCacheAfterRefresh()
if err != nil {
return serversErrMsg{err: err}
}
return serversRefreshedMsg(servers)
}
func parseCacheAfterRefresh() ([]backend.LogicalServer, error) {
servers, err := backend.ParseCache()
if err == nil {
return servers, nil
}
time.Sleep(300 * time.Millisecond)
return backend.ParseCache()
}
func fetchStatus() tea.Msg {
status, err := backend.Status()
if err != nil {
return statusErrMsg{err: err}
}
return statusMsg(status)
}
func fetchQuickStatus() tea.Msg {
connected, err := backend.IsConnected()
if err != nil {
return quickStatusErrMsg{err: err}
}
return quickStatusMsg(connected)
}
func connectGroup(country, city, prefer string, f filters) tea.Cmd {
return func() tea.Msg {
target := backend.CountryName(country)
if city != "" {
target = city + ", " + target
}
err, fallback := connectWithFreeFallback(func() error {
if prefer == "random" {
return backend.ConnectRandom(country, city, f.P2P, f.SecureCore, f.Tor)
}
return backend.ConnectFastest(country, city, f.P2P, f.SecureCore, f.Tor)
})
if fallback {
target = "an available free server"
}
return connectResultMsg{err: err, target: target, freeFallback: fallback}
}
}
func connectServer(s backend.LogicalServer) tea.Cmd {
return func() tea.Msg {
target := s.Name
err, fallback := connectWithFreeFallback(func() error { return backend.ConnectId(s.Name) })
if fallback {
target = "an available free server"
}
return connectResultMsg{err: err, target: target, freeFallback: fallback}
}
}
func connectWithFreeFallback(attempt func() error) (err error, fallback bool) {
err = attempt()
if !backend.IsFreePlanRestricted(err) {
return err, false
}
return backend.ConnectAny(), true
}
func disconnectCmd() tea.Cmd {
return func() tea.Msg {
err := backend.Disconnect()
return connectResultMsg{err: err, disconnect: true}
}
}
func errorToast(err error) tea.Cmd {
return notification.Show("Error", err.Error(), notification.Error)
}
+180
View File
@@ -0,0 +1,180 @@
package app
import (
"fmt"
"image/color"
"io"
"strings"
"charm.land/bubbles/v2/list"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/charmbracelet/x/ansi"
zone "github.com/lrstanley/bubblezone/v2"
"github.com/anotherhadi/ilovetui/style"
"github.com/anotherhadi/proton-vpn-tui/internal/backend"
"github.com/anotherhadi/proton-vpn-tui/internal/config"
"github.com/anotherhadi/proton-vpn-tui/internal/icons"
)
const (
infoColWidth = 14
loadColWidth = 8
minNameWidth = 6
boxOverhead = 4
serversColumnMinWidth = 45
)
type rowDelegate struct {
showInfo bool
}
func (d rowDelegate) Height() int { return 3 }
func (d rowDelegate) Spacing() int { return 1 }
func (d rowDelegate) Update(_ tea.Msg, _ *list.Model) tea.Cmd { return nil }
func (d rowDelegate) Render(w io.Writer, m list.Model, index int, item list.Item) {
r, ok := item.(row)
if !ok {
return
}
selected := index == m.Index()
indent := strings.Repeat(" ", int(r.kind))
expandIcon := " "
if r.kind == rowCountry || r.kind == rowCity {
if r.expanded {
expandIcon = icons.I.Expanded
} else {
expandIcon = icons.I.Collapsed
}
}
label, info, load := d.columns(r)
nameStyle := lipgloss.NewStyle().Foreground(style.S.Text)
metaStyle := lipgloss.NewStyle().Foreground(style.S.Muted)
if selected {
nameStyle = nameStyle.Foreground(style.S.Primary).Bold(true)
metaStyle = metaStyle.Foreground(style.S.Primary)
}
prefixWidth := lipgloss.Width(indent) + lipgloss.Width(expandIcon) + 1
infoW, gaps := infoColumnLayout(d.showInfo)
nameWidth := max(m.Width()-boxOverhead-prefixWidth-infoW-loadColWidth-gaps, minNameWidth)
nameCol := nameStyle.Render(fixedWidth(label, nameWidth))
loadCol := metaStyle.Render(fixedWidth(load, loadColWidth))
borderType := style.S.BorderType
if !selected && r.kind != rowCountry {
borderType = lipgloss.HiddenBorder()
}
boxStyle := lipgloss.NewStyle().
Border(borderType).
BorderForeground(rowBorderColor(r, selected)).
Padding(0, 1)
line := indent + expandIcon + " " + nameCol
if d.showInfo {
line += " " + metaStyle.Render(fixedWidth(info, infoColWidth))
}
line += " " + loadCol
rendered := boxStyle.Render(clampToWidth(line, m.Width()-boxOverhead))
fmt.Fprint(w, zone.Mark(rowZoneID(index), rendered))
}
func infoColumnLayout(showInfo bool) (width, gaps int) {
if !showInfo {
return 0, 1
}
return infoColWidth, 2
}
func fixedWidth(s string, width int) string {
if width <= 0 {
return ""
}
return lipgloss.NewStyle().Width(width).MaxWidth(width).Inline(true).
Render(ansi.Truncate(s, width, "…"))
}
func rowBorderColor(r row, selected bool) color.Color {
if selected {
return style.S.Primary
}
if r.kind == rowCountry {
return style.S.Subtle
}
return style.S.Background
}
func (d rowDelegate) columns(r row) (name, info, load string) {
switch r.kind {
case rowCountry:
countryName := backend.CountryName(r.country)
mark := ""
switch {
case config.Global.App.ShowFlags:
mark = backend.FlagEmoji(r.country) + " "
case icons.I.Country != "":
mark = icons.I.Country + " "
}
fav := ""
if r.favorite {
fav = icons.I.Favorite + " "
}
name = fav + mark + countryName
info = fmt.Sprintf("%d servers", r.serverCount)
load = fmt.Sprintf("%s %d%%", icons.I.Load, r.avgLoad)
case rowCity:
cityName := r.city
if cityName == "" {
cityName = "Other"
}
cityIcon := ""
if icons.I.City != "" {
cityIcon = icons.I.City + " "
}
fav := ""
if r.favorite {
fav = icons.I.Favorite + " "
}
name = fav + cityIcon + cityName
info = fmt.Sprintf("%d servers", r.serverCount)
load = fmt.Sprintf("%s %d%%", icons.I.Load, r.avgLoad)
default:
serverIcon := ""
if icons.I.Server != "" {
serverIcon = icons.I.Server + " "
}
fav := ""
if r.favorite {
fav = icons.I.Favorite + " "
}
name = fav + serverIcon + r.server.Name
var badges []string
if r.server.SecureCore {
badges = append(badges, icons.I.SecureCore)
}
if r.server.Tor {
badges = append(badges, icons.I.Tor)
}
if r.server.P2P {
badges = append(badges, icons.I.P2p)
}
info = strings.Join(badges, " ")
load = fmt.Sprintf("%s %d%%", icons.I.Load, r.server.Load)
}
return
}
+121
View File
@@ -0,0 +1,121 @@
package app
import (
"fmt"
"strings"
"charm.land/lipgloss/v2"
"github.com/charmbracelet/x/ansi"
zone "github.com/lrstanley/bubblezone/v2"
"github.com/anotherhadi/ilovetui/style"
"github.com/anotherhadi/proton-vpn-tui/internal/icons"
)
func (m Model) renderHeader() string {
title := headerStyle.Render("ProtonVPN")
if style.S.NerdFonts {
title = icons.I.Vpn + title
}
lines := []string{
title,
"",
m.renderStatusLine(),
m.renderModesLine(),
}
if searchLine := m.renderSearchLine(); searchLine != "" {
lines = append(lines, searchLine)
}
lines = append(lines, "", m.renderColumnHeader())
for i, l := range lines {
lines[i] = clampToWidth(l, m.width)
}
return strings.Join(lines, "\n")
}
func (m Model) renderColumnHeader() string {
indent, expandIcon := "", " "
prefixWidth := lipgloss.Width(indent) + lipgloss.Width(expandIcon) + 1
infoW, gaps := infoColumnLayout(m.showServersColumn())
nameWidth := max(m.width-boxOverhead-prefixWidth-infoW-loadColWidth-gaps, minNameWidth)
hdrStyle := lipgloss.NewStyle().Foreground(style.S.Subtle).Bold(true)
line := " " + indent + expandIcon + " " + hdrStyle.Render(fixedWidth("Name", nameWidth))
if m.showServersColumn() {
line += " " + hdrStyle.Render(fixedWidth("Servers", infoColWidth))
}
return line + " " + hdrStyle.Render(fixedWidth("Load", loadColWidth))
}
func oneLine(s string) string {
return strings.Join(strings.Fields(s), " ")
}
func clampToWidth(s string, width int) string {
if width <= 0 {
return s
}
return ansi.Truncate(s, width, "…")
}
func (m Model) renderStatusLine() string {
label := infoLabelStyle.Render("Status")
if m.connecting {
return label + primaryStyle.Render(icons.I.Connecting+" connecting…")
}
if m.disconnecting {
return label + mutedStyle.Render(icons.I.Loading+" loading…")
}
if m.statusErr != nil {
return label + warnStyle.Render(oneLine(m.statusErr.Error()))
}
if !m.statusLoaded {
if m.quickConnected {
return label + mutedStyle.Render(icons.I.Connected+" connected, "+icons.I.Loading+" loading…")
}
return label + mutedStyle.Render(icons.I.Loading+" loading…")
}
if !m.status.Connected {
return label + mutedStyle.Render(icons.I.Disconnected+" disconnected")
}
loc := m.status.Server
if m.status.Location != "" {
loc = fmt.Sprintf("%s (%s)", m.status.Server, m.status.Location)
}
text := fmt.Sprintf("%s connected: %s", icons.I.Connected, loc)
if m.status.Load > 0 {
text += fmt.Sprintf(" %d%% load", m.status.Load)
}
return label + connectedStyle.Render(text)
}
func (m Model) renderSearchLine() string {
if !m.searching && strings.TrimSpace(m.searchInput.Value()) == "" {
return ""
}
return infoLabelStyle.Render("Search") + m.searchInput.View()
}
func (m Model) renderModesLine() string {
label := infoLabelStyle.Render("Mode")
parts := []string{
zone.Mark(zoneBadgeFastest, modeBadge(icons.I.Fastest+" fastest", m.prefer == "fastest")),
zone.Mark(zoneBadgeRandom, modeBadge(icons.I.Random+" random", m.prefer == "random")),
zone.Mark(zoneBadgeSecureCore, modeBadge(icons.I.SecureCore+" secure core", m.filters.SecureCore)),
zone.Mark(zoneBadgeTor, modeBadge(icons.I.Tor+" tor", m.filters.Tor)),
zone.Mark(zoneBadgeP2P, modeBadge(icons.I.P2p+" p2p", m.filters.P2P)),
zone.Mark(zoneBadgeFree, modeBadge(icons.I.Free+" free", m.filters.Free)),
}
return label + strings.Join(parts, mutedStyle.Render(" "))
}
func modeBadge(text string, active bool) string {
if active {
return primaryStyle.Bold(true).Render(text)
}
return mutedStyle.Render(text)
}
+495
View File
@@ -0,0 +1,495 @@
package app
import (
"fmt"
"strings"
"charm.land/bubbles/v2/key"
"charm.land/bubbles/v2/list"
"charm.land/bubbles/v2/textinput"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/anotherhadi/ilovetui/app"
"github.com/anotherhadi/ilovetui/bubbles"
"github.com/anotherhadi/ilovetui/drawer"
"github.com/anotherhadi/ilovetui/helpbar"
"github.com/anotherhadi/ilovetui/modal"
"github.com/anotherhadi/ilovetui/notification"
"github.com/anotherhadi/proton-vpn-tui/internal/backend"
"github.com/anotherhadi/proton-vpn-tui/internal/config"
"github.com/anotherhadi/proton-vpn-tui/internal/icons"
"github.com/anotherhadi/proton-vpn-tui/internal/ui/settings"
)
type filters struct {
Tor bool
SecureCore bool
P2P bool
Free bool
}
type Model struct {
list list.Model
help helpbar.Model
width, height int
servers []backend.LogicalServer
tree []*countryGroup
serversLoaded bool
expandedCountries map[string]bool
expandedCities map[string]bool
filters filters
prefer string
status backend.ConnectionStatus
statusErr error
statusLoaded bool
quickConnected bool
connecting bool
disconnecting bool
searching bool
searchInput textinput.Model
Overlay bool
}
func New() Model {
l := bubbles.NewList(nil, 0, 0)
l.SetDelegate(rowDelegate{})
l.SetShowHelp(false)
l.SetFilteringEnabled(false)
l.SetShowStatusBar(false)
l.SetShowTitle(false)
l.DisableQuitKeybindings()
gk := config.Global.Keybindings.Global
l.KeyMap.CursorUp = gk.Up
l.KeyMap.CursorDown = gk.Down
l.KeyMap.GoToStart = gk.GotoTop
l.KeyMap.GoToEnd = gk.GotoBottom
h := helpbar.New(helpbar.WithToggle(gk.Help))
si := bubbles.NewTextInput()
si.Prompt = icons.I.Search + " "
si.Placeholder = "country, city, or server id…"
focusSearch := config.Global.App.FocusSearchOnLaunch
if focusSearch {
si.Focus()
}
def := config.Global.App.Default
return Model{
list: l,
help: h,
searchInput: si,
searching: focusSearch,
expandedCountries: map[string]bool{},
expandedCities: map[string]bool{},
filters: filters{Tor: def.Tor, SecureCore: def.SecureCore, P2P: def.P2P, Free: def.Free},
prefer: def.Prefer,
}
}
func (m Model) Init() tea.Cmd {
return tea.Batch(
fetchServersCache, refreshServers,
fetchQuickStatus, fetchStatus,
tickCmd(),
)
}
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
m, cmd := m.update(msg)
m.resizeList()
return m, cmd
}
func (m Model) update(msg tea.Msg) (Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width, m.height = msg.Width, msg.Height
m.help.SetWidth(msg.Width)
m.searchInput.SetWidth(max(msg.Width-14, 10))
return m, nil
case tickMsg:
return m, tea.Batch(fetchStatus, tickCmd())
case serversMsg:
m.servers = msg
m.serversLoaded = true
m.tree = buildTree(m.servers, m.filters)
cmd := m.refreshList()
return m, cmd
case serversRefreshedMsg:
m.servers = msg
m.serversLoaded = true
m.tree = buildTree(m.servers, m.filters)
cmd := m.refreshList()
return m, tea.Batch(cmd, notification.Show("ProtonVPN", "Server list refreshed from cache", notification.Success))
case serversErrMsg:
m.serversLoaded = true
if backend.IsAuthRequired(msg.err) {
return m, modal.Show("Not signed in", notSignedInModal{})
}
return m, errorToast(msg.err)
case serversCacheErrMsg:
return m, nil
case quickStatusMsg:
if !m.statusLoaded {
m.quickConnected = bool(msg)
}
return m, nil
case quickStatusErrMsg:
return m, nil
case statusMsg:
m.status = backend.ConnectionStatus(msg)
m.statusErr = nil
m.statusLoaded = true
return m, nil
case statusErrMsg:
m.statusErr = msg.err
m.status = backend.ConnectionStatus{}
m.statusLoaded = true
return m, nil
case connectResultMsg:
m.connecting = false
m.disconnecting = false
if msg.err != nil {
return m, tea.Batch(errorToast(msg.err), fetchStatus)
}
title, text := "ProtonVPN", fmt.Sprintf("Connected to %s", msg.target)
switch {
case msg.disconnect:
text = "Disconnected"
case msg.freeFallback:
title = "Free plan"
text = "Free accounts can't pick a server or location. Connected you to an available free server instead."
}
return m, tea.Batch(fetchStatus, notification.Show(title, text, notification.Success))
case tea.KeyPressMsg:
if m.Overlay {
return m, nil
}
if m.searching {
return m.updateSearch(msg)
}
return m.updateList(msg)
case tea.MouseWheelMsg:
if m.Overlay {
return m, nil
}
switch msg.Button {
case tea.MouseWheelUp:
m.list.CursorUp()
case tea.MouseWheelDown:
m.list.CursorDown()
}
return m, nil
case tea.MouseClickMsg:
if m.Overlay {
return m, nil
}
return m.updateMouseClick(msg)
}
if m.Overlay {
return m, nil
}
var cmd tea.Cmd
m.list, cmd = m.list.Update(msg)
return m, cmd
}
func (m Model) updateList(msg tea.KeyPressMsg) (Model, tea.Cmd) {
gk := config.Global.Keybindings.Global
hk := config.Global.Keybindings.Home
switch {
case key.Matches(msg, gk.Quit):
return m, app.Quit()
case key.Matches(msg, hk.OpenSettings):
title := "Settings"
if icons.I.Settings != "" {
title = icons.I.Settings + " " + title
}
return m, drawer.Show(title, settings.New(m.width, m.drawerContentHeight()), drawer.WithSide(drawer.Right))
case key.Matches(msg, hk.Search):
m.searching = true
return m, m.searchInput.Focus()
case key.Matches(msg, gk.Escape):
if strings.TrimSpace(m.searchInput.Value()) == "" {
return m, nil
}
m.searchInput.Reset()
cmd := m.refreshList()
return m, cmd
case key.Matches(msg, hk.Refresh):
return m, refreshServersManual
case key.Matches(msg, hk.Disconnect):
m.disconnecting = true
return m, disconnectCmd()
case key.Matches(msg, hk.ToggleTor):
m.filters.Tor = !m.filters.Tor
m.tree = buildTree(m.servers, m.filters)
cmd := m.refreshList()
return m, cmd
case key.Matches(msg, hk.ToggleSecureCore):
m.filters.SecureCore = !m.filters.SecureCore
m.tree = buildTree(m.servers, m.filters)
cmd := m.refreshList()
return m, cmd
case key.Matches(msg, hk.ToggleP2P):
m.filters.P2P = !m.filters.P2P
m.tree = buildTree(m.servers, m.filters)
cmd := m.refreshList()
return m, cmd
case key.Matches(msg, hk.ToggleFree):
m.filters.Free = !m.filters.Free
m.tree = buildTree(m.servers, m.filters)
cmd := m.refreshList()
return m, cmd
case key.Matches(msg, hk.TogglePrefer):
if m.prefer == "random" {
m.prefer = "fastest"
} else {
m.prefer = "random"
}
return m, nil
case key.Matches(msg, hk.GotoFavorite):
m.gotoFavorite()
return m, nil
case key.Matches(msg, gk.Right):
return m.expandSelected()
case key.Matches(msg, gk.Left):
return m.collapseSelected()
case key.Matches(msg, hk.Connect):
return m.connectSelected()
}
var cmd tea.Cmd
m.list, cmd = m.list.Update(msg)
return m, cmd
}
func (m Model) selectedRow() (row, bool) {
if item := m.list.SelectedItem(); item != nil {
return item.(row), true
}
return row{}, false
}
func (m Model) expandSelected() (Model, tea.Cmd) {
r, ok := m.selectedRow()
if !ok {
return m, nil
}
switch r.kind {
case rowCountry:
m.expandedCountries[r.country] = true
case rowCity:
m.expandedCities[cityKey(r.country, r.city)] = true
default:
return m, nil
}
cmd := m.refreshList()
return m, cmd
}
func (m Model) collapseSelected() (Model, tea.Cmd) {
r, ok := m.selectedRow()
if !ok {
return m, nil
}
switch r.kind {
case rowCountry:
m.expandedCountries[r.country] = false
case rowCity, rowServer:
m.expandedCities[cityKey(r.country, r.city)] = false
}
cmd := m.refreshList()
return m, cmd
}
func (m *Model) gotoFavorite() {
for i, item := range m.list.Items() {
if r, ok := item.(row); ok && r.kind == rowCountry && r.favorite {
m.list.Select(i)
return
}
}
}
func (m Model) connectSelected() (Model, tea.Cmd) {
r, ok := m.selectedRow()
if !ok {
return m, nil
}
m.connecting = true
if r.kind == rowServer {
return m, connectServer(r.server)
}
return m, connectGroup(r.country, r.city, m.prefer, m.filters)
}
func (m Model) favoritesSet() (countries, servers map[string]bool) {
countries = map[string]bool{}
servers = map[string]bool{}
for _, raw := range config.Global.App.Favorites {
f := strings.TrimSpace(raw)
if f == "" {
continue
}
if id := strings.ToUpper(f); backend.IsServerID(id) {
servers[id] = true
continue
}
if code, ok := backend.ResolveCountryCode(f); ok {
countries[code] = true
}
}
return
}
func (m *Model) refreshList() tea.Cmd {
q := strings.TrimSpace(m.searchInput.Value())
favCountries, favServers := m.favoritesSet()
var rows []row
if q != "" {
rows = searchRows(m.tree, q, m.expandedCountries, m.expandedCities, favCountries, favServers)
} else {
rows = flattenRows(m.tree, m.expandedCountries, m.expandedCities, favCountries, favServers)
}
return m.setListItems(rows)
}
func (m *Model) setListItems(rows []row) tea.Cmd {
items := make([]list.Item, len(rows))
for i, r := range rows {
items[i] = r
}
cmd := m.list.SetItems(items)
m.resizeList()
if idx := m.list.Index(); len(items) > 0 && (idx < 0 || idx >= len(items)) {
m.list.Select(len(items) - 1)
}
return cmd
}
func (m *Model) resizeList() {
m.list.SetDelegate(rowDelegate{showInfo: m.showServersColumn()})
m.list.SetSize(m.width, m.listHeight())
}
func (m Model) showServersColumn() bool {
return m.width > serversColumnMinWidth
}
func (m Model) listHeight() int {
headerH := lipgloss.Height(m.renderHeader())
helpH := m.help.Height(m.helpBindings()...)
return m.height - headerH - helpH
}
func (m Model) View() string {
header := m.renderHeader()
listView := strings.TrimRight(m.list.View(), "\n")
if !m.serversLoaded && len(m.list.Items()) == 0 {
listView = m.loadingView()
}
return strings.Join([]string{header, listView}, "\n")
}
func (m Model) loadingView() string {
text := mutedStyle.Render(icons.I.Loading + " loading…")
return lipgloss.Place(m.width, max(m.listHeight(), 1), lipgloss.Center, lipgloss.Center, text)
}
func (m Model) WindowTitle() string {
const base = "ProtonVPN TUI"
switch {
case m.statusLoaded && m.status.Connected:
return base + " - Connected"
case m.statusLoaded:
return base + " - Disconnected"
case m.quickConnected:
return base + " - Connected"
default:
return base
}
}
func (m Model) HelpView() string {
return strings.TrimRight(m.help.View(m.helpBindings()...), "\n")
}
func (m *Model) ToggleHelp() {
m.help.ShowAll = !m.help.ShowAll
m.resizeList()
}
func (m Model) settingsHelpHeight() int {
pages := config.Help(config.Global.Keybindings.Global, config.Global.Keybindings.Settings)
return m.help.Height(m.bindingsFor(pages)...)
}
func (m Model) drawerContentHeight() int {
return m.height - m.settingsHelpHeight()
}
func (m Model) DrawerWindowSizeMsg() tea.WindowSizeMsg {
return tea.WindowSizeMsg{Width: m.width, Height: m.drawerContentHeight()}
}
func (m Model) bindingsFor(pages config.HelpKeyMap) []key.Binding {
if !m.help.ShowAll {
return pages.ShortHelp()
}
var all []key.Binding
for _, col := range pages.FullHelp() {
all = append(all, col...)
}
return all
}
func (m Model) helpBindings() []key.Binding {
pages := config.Help(config.Global.Keybindings.Global, config.Global.Keybindings.Home)
if m.Overlay {
pages = config.Help(config.Global.Keybindings.Global, config.Global.Keybindings.Settings)
}
return m.bindingsFor(pages)
}
+88
View File
@@ -0,0 +1,88 @@
package app
import (
"fmt"
tea "charm.land/bubbletea/v2"
zone "github.com/lrstanley/bubblezone/v2"
)
const (
zoneBadgeFastest = "badge-fastest"
zoneBadgeRandom = "badge-random"
zoneBadgeSecureCore = "badge-securecore"
zoneBadgeTor = "badge-tor"
zoneBadgeP2P = "badge-p2p"
zoneBadgeFree = "badge-free"
)
func rowZoneID(index int) string {
return fmt.Sprintf("row-%d", index)
}
func (m Model) updateMouseClick(msg tea.MouseClickMsg) (Model, tea.Cmd) {
if msg.Button != tea.MouseLeft {
return m, nil
}
switch {
case zone.Get(zoneBadgeFastest).InBounds(msg):
m.prefer = "fastest"
return m, nil
case zone.Get(zoneBadgeRandom).InBounds(msg):
m.prefer = "random"
return m, nil
case zone.Get(zoneBadgeSecureCore).InBounds(msg):
m.filters.SecureCore = !m.filters.SecureCore
m.tree = buildTree(m.servers, m.filters)
return m, m.refreshList()
case zone.Get(zoneBadgeTor).InBounds(msg):
m.filters.Tor = !m.filters.Tor
m.tree = buildTree(m.servers, m.filters)
return m, m.refreshList()
case zone.Get(zoneBadgeP2P).InBounds(msg):
m.filters.P2P = !m.filters.P2P
m.tree = buildTree(m.servers, m.filters)
return m, m.refreshList()
case zone.Get(zoneBadgeFree).InBounds(msg):
m.filters.Free = !m.filters.Free
m.tree = buildTree(m.servers, m.filters)
return m, m.refreshList()
}
return m.clickRow(msg)
}
func (m Model) clickRow(msg tea.MouseClickMsg) (Model, tea.Cmd) {
items := m.list.Items()
for i := range items {
if !zone.Get(rowZoneID(i)).InBounds(msg) {
continue
}
if i != m.list.Index() {
m.list.Select(i)
return m, nil
}
return m.activateSelected()
}
return m, nil
}
func (m Model) activateSelected() (Model, tea.Cmd) {
r, ok := m.selectedRow()
if !ok {
return m, nil
}
if r.kind == rowServer {
return m.connectSelected()
}
if r.expanded {
return m.collapseSelected()
}
return m.expandSelected()
}
+22
View File
@@ -0,0 +1,22 @@
package app
import (
tea "charm.land/bubbletea/v2"
"github.com/anotherhadi/ilovetui/app"
)
type notSignedInModal struct{}
func (notSignedInModal) Init() tea.Cmd { return nil }
func (notSignedInModal) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if _, ok := msg.(tea.KeyPressMsg); ok {
return notSignedInModal{}, app.Quit()
}
return notSignedInModal{}, nil
}
func (notSignedInModal) View() tea.View {
return tea.NewView("You're not signed in.\n\nRun `protonvpn signin` to sign in,\nthen restart proton-vpn-tui.\n\nPress any key to quit.")
}
+318
View File
@@ -0,0 +1,318 @@
package app
import (
"sort"
"strings"
"github.com/anotherhadi/proton-vpn-tui/internal/backend"
)
type rowKind int
const (
rowCountry rowKind = iota
rowCity
rowServer
)
type row struct {
kind rowKind
country string
city string
server backend.LogicalServer
expanded bool
favorite bool
serverCount int
avgLoad int
}
func (r row) FilterValue() string {
switch r.kind {
case rowServer:
return r.server.Name + " " + r.city + " " + backend.CountryName(r.country)
case rowCity:
return r.city + " " + backend.CountryName(r.country)
default:
return r.country + " " + backend.CountryName(r.country)
}
}
func cityKey(country, city string) string { return country + "\x00" + city }
type countryGroup struct {
code string
cities map[string][]backend.LogicalServer
cityOrder []string
servers []backend.LogicalServer
}
func buildTree(servers []backend.LogicalServer, f filters) []*countryGroup {
groups := make(map[string]*countryGroup)
var order []string
for _, s := range servers {
if s.Tor != f.Tor || s.SecureCore != f.SecureCore {
continue
}
if f.P2P && !s.P2P {
continue
}
if f.Free && !s.IsFree() {
continue
}
code := s.EntryCountry
if s.SecureCore {
code = s.ExitCountry
}
g, ok := groups[code]
if !ok {
g = &countryGroup{code: code, cities: make(map[string][]backend.LogicalServer)}
groups[code] = g
order = append(order, code)
}
city := ""
if s.City != nil {
city = *s.City
}
if _, ok := g.cities[city]; !ok {
g.cityOrder = append(g.cityOrder, city)
}
g.cities[city] = append(g.cities[city], s)
g.servers = append(g.servers, s)
}
result := make([]*countryGroup, 0, len(order))
for _, code := range order {
result = append(result, groups[code])
}
sort.Slice(result, func(i, j int) bool {
return backend.CountryName(result[i].code) < backend.CountryName(result[j].code)
})
for _, g := range result {
sort.Slice(g.cityOrder, func(i, j int) bool {
a, b := g.cityOrder[i], g.cityOrder[j]
if a == "" {
return false
}
if b == "" {
return true
}
return a < b
})
for _, servers := range g.cities {
sort.Slice(servers, func(i, j int) bool { return servers[i].Name < servers[j].Name })
}
}
return result
}
func avgLoad(servers []backend.LogicalServer) int {
if len(servers) == 0 {
return 0
}
total := 0
for _, s := range servers {
total += s.Load
}
return total / len(servers)
}
func serverIsFavorite(s backend.LogicalServer, favServers map[string]bool) bool {
return favServers[strings.ToUpper(s.Name)]
}
func serversHaveFavorite(servers []backend.LogicalServer, favServers map[string]bool) bool {
for _, s := range servers {
if serverIsFavorite(s, favServers) {
return true
}
}
return false
}
func countryIsFavorite(g *countryGroup, favCountries, favServers map[string]bool) bool {
return favCountries[g.code] || serversHaveFavorite(g.servers, favServers)
}
func orderWithFavoritesFirst(tree []*countryGroup, favCountries, favServers map[string]bool) []*countryGroup {
ordered := append([]*countryGroup(nil), tree...)
sort.SliceStable(ordered, func(i, j int) bool {
return countryIsFavorite(ordered[i], favCountries, favServers) &&
!countryIsFavorite(ordered[j], favCountries, favServers)
})
return ordered
}
func orderCitiesWithFavoritesFirst(g *countryGroup, favServers map[string]bool) []string {
cities := append([]string(nil), g.cityOrder...)
sort.SliceStable(cities, func(i, j int) bool {
return serversHaveFavorite(g.cities[cities[i]], favServers) &&
!serversHaveFavorite(g.cities[cities[j]], favServers)
})
return cities
}
func orderServersWithFavoritesFirst(servers []backend.LogicalServer, favServers map[string]bool) []backend.LogicalServer {
ordered := append([]backend.LogicalServer(nil), servers...)
sort.SliceStable(ordered, func(i, j int) bool {
return serverIsFavorite(ordered[i], favServers) && !serverIsFavorite(ordered[j], favServers)
})
return ordered
}
func flattenRows(tree []*countryGroup, expandedCountries, expandedCities, favCountries, favServers map[string]bool) []row {
ordered := orderWithFavoritesFirst(tree, favCountries, favServers)
var rows []row
for _, g := range ordered {
rows = append(rows, row{
kind: rowCountry,
country: g.code,
expanded: expandedCountries[g.code],
favorite: countryIsFavorite(g, favCountries, favServers),
serverCount: len(g.servers),
avgLoad: avgLoad(g.servers),
})
if !expandedCountries[g.code] {
continue
}
for _, city := range orderCitiesWithFavoritesFirst(g, favServers) {
servers := orderServersWithFavoritesFirst(g.cities[city], favServers)
key := cityKey(g.code, city)
rows = append(rows, row{
kind: rowCity,
country: g.code,
city: city,
expanded: expandedCities[key],
favorite: serversHaveFavorite(servers, favServers),
serverCount: len(servers),
avgLoad: avgLoad(servers),
})
if !expandedCities[key] {
continue
}
for _, s := range servers {
rows = append(rows, row{
kind: rowServer,
country: g.code,
city: city,
server: s,
favorite: serverIsFavorite(s, favServers),
})
}
}
}
return rows
}
type cityMatch struct {
city string
servers []backend.LogicalServer
}
func searchRows(tree []*countryGroup, query string, expandedCountries, expandedCities, favCountries, favServers map[string]bool) []row {
q := strings.ToLower(strings.TrimSpace(query))
if q == "" {
return nil
}
ordered := orderWithFavoritesFirst(tree, favCountries, favServers)
matchCities := len([]rune(q)) > 3
var rows []row
for _, g := range ordered {
countryMatch := strings.Contains(strings.ToLower(backend.CountryName(g.code)), q) ||
strings.Contains(strings.ToLower(g.code), q)
var matches []cityMatch
if matchCities {
for _, city := range g.cityOrder {
if servers := matchingCityServers(city, g.cities[city], q); len(servers) > 0 {
matches = append(matches, cityMatch{city: city, servers: servers})
}
}
}
if !countryMatch && len(matches) == 0 {
continue
}
expanded := len(matches) > 0
if v, ok := expandedCountries[g.code]; ok {
expanded = v
}
rows = append(rows, row{
kind: rowCountry,
country: g.code,
expanded: expanded,
favorite: countryIsFavorite(g, favCountries, favServers),
serverCount: len(g.servers),
avgLoad: avgLoad(g.servers),
})
if !expanded {
continue
}
if len(matches) == 0 {
for _, city := range g.cityOrder {
matches = append(matches, cityMatch{city: city, servers: g.cities[city]})
}
}
sort.SliceStable(matches, func(i, j int) bool {
return serversHaveFavorite(matches[i].servers, favServers) &&
!serversHaveFavorite(matches[j].servers, favServers)
})
for _, cm := range matches {
cityExpanded := true
if v, ok := expandedCities[cityKey(g.code, cm.city)]; ok {
cityExpanded = v
}
servers := orderServersWithFavoritesFirst(cm.servers, favServers)
rows = append(rows, row{
kind: rowCity,
country: g.code,
city: cm.city,
expanded: cityExpanded,
favorite: serversHaveFavorite(servers, favServers),
serverCount: len(servers),
avgLoad: avgLoad(servers),
})
if !cityExpanded {
continue
}
for _, s := range servers {
rows = append(rows, row{
kind: rowServer,
country: g.code,
city: cm.city,
server: s,
favorite: serverIsFavorite(s, favServers),
})
}
}
}
return rows
}
func matchingCityServers(city string, servers []backend.LogicalServer, q string) []backend.LogicalServer {
label := city
if label == "" {
label = "other"
}
if strings.Contains(strings.ToLower(label), q) {
return servers
}
var matched []backend.LogicalServer
for _, s := range servers {
if strings.Contains(strings.ToLower(s.Name), q) {
matched = append(matched, s)
}
}
return matched
}
+66
View File
@@ -0,0 +1,66 @@
package app
import (
"strings"
"charm.land/bubbles/v2/key"
tea "charm.land/bubbletea/v2"
"github.com/anotherhadi/proton-vpn-tui/internal/backend"
"github.com/anotherhadi/proton-vpn-tui/internal/config"
)
var searchEnterKey = key.NewBinding(key.WithKeys("enter"))
func (m Model) updateSearch(msg tea.KeyPressMsg) (Model, tea.Cmd) {
gk := config.Global.Keybindings.Global
switch {
case key.Matches(msg, gk.Escape):
return m.exitSearch(true)
case key.Matches(msg, searchEnterKey):
return m.submitSearch()
}
prev := m.searchInput.Value()
var cmd tea.Cmd
m.searchInput, cmd = m.searchInput.Update(msg)
if m.searchInput.Value() != prev {
m.expandedCountries = map[string]bool{}
m.expandedCities = map[string]bool{}
}
return m, tea.Batch(cmd, m.refreshList())
}
func (m Model) submitSearch() (Model, tea.Cmd) {
q := strings.TrimSpace(m.searchInput.Value())
if q == "" {
return m.exitSearch(true)
}
m.searching = false
m.searchInput.Blur()
if id := strings.ToUpper(q); backend.IsServerID(id) {
m.selectServerByID(id)
}
return m, nil
}
func (m *Model) selectServerByID(id string) {
for i, it := range m.list.Items() {
if r, ok := it.(row); ok && r.kind == rowServer && strings.EqualFold(r.server.Name, id) {
m.list.Select(i)
return
}
}
}
func (m Model) exitSearch(clear bool) (Model, tea.Cmd) {
m.searching = false
m.searchInput.Blur()
if clear {
m.searchInput.Reset()
}
cmd := m.refreshList()
return m, cmd
}
+149
View File
@@ -0,0 +1,149 @@
package settings
import (
"fmt"
"image/color"
"io"
"strings"
"charm.land/bubbles/v2/list"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/charmbracelet/x/ansi"
"github.com/anotherhadi/ilovetui/style"
"github.com/anotherhadi/proton-vpn-tui/internal/backend"
"github.com/anotherhadi/proton-vpn-tui/internal/icons"
)
func clampToWidth(s string, width int) string {
if width <= 0 {
return s
}
return ansi.Truncate(s, width, "…")
}
type delegate struct{}
func (d delegate) Height() int { return 2 }
func (d delegate) Spacing() int { return 1 }
func (d delegate) Update(_ tea.Msg, _ *list.Model) tea.Cmd { return nil }
func (d delegate) Render(w io.Writer, m list.Model, index int, listItem list.Item) {
it, ok := listItem.(item)
if !ok {
return
}
selected := index == m.Index()
var titleStyle, descStyle, valueStyle lipgloss.Style
if selected {
titleStyle = lipgloss.NewStyle().
Border(lipgloss.NormalBorder(), false, false, false, true).
BorderForeground(style.S.Primary).
Foreground(style.S.Primary).
Bold(true).
PaddingLeft(1)
descStyle = lipgloss.NewStyle().
Border(lipgloss.NormalBorder(), false, false, false, true).
BorderForeground(style.S.Primary).
Foreground(style.S.Muted).
PaddingLeft(1)
valueStyle = lipgloss.NewStyle().Foreground(style.S.Success).Bold(true)
} else {
titleStyle = lipgloss.NewStyle().Foreground(style.S.Text).PaddingLeft(2)
descStyle = lipgloss.NewStyle().Foreground(style.S.Muted).PaddingLeft(2)
valueStyle = lipgloss.NewStyle().Foreground(style.S.Success)
}
value := renderCycleValue(valueStyle.Render(humanizeKebab(it.setting.Value)))
if isOnOffSetting(it.setting) {
value = renderToggle(it.setting.Value == "on")
}
width := m.Width()
title := titleStyle.Render(humanizeKebab(it.key))
line1 := padLine(justifyBetween(title, value, width), width)
line2 := padLine(descStyle.Render(it.setting.Description), width)
fmt.Fprintf(w, "%s\n%s", line1, line2)
}
func justifyBetween(left, right string, width int) string {
gap := width - lipgloss.Width(left) - lipgloss.Width(right)
if gap < 1 {
gap = 1
}
return left + strings.Repeat(" ", gap) + right
}
func padLine(s string, width int) string {
s = clampToWidth(s, width)
if pad := width - lipgloss.Width(s); pad > 0 {
s += strings.Repeat(" ", pad)
}
return s
}
func isOnOffSetting(s backend.Setting) bool {
if len(s.Options) != 2 {
return false
}
var hasOn, hasOff bool
for _, o := range s.Options {
hasOn = hasOn || o.ID == "on"
hasOff = hasOff || o.ID == "off"
}
return hasOn && hasOff
}
func renderCycleValue(value string) string {
arrow := lipgloss.NewStyle().Foreground(style.S.Muted)
return arrow.Render("< ") + value + arrow.Render(" >")
}
func renderToggle(on bool) string {
if on {
return renderBadge(" ●", style.S.Success)
}
return renderBadge("● ", style.S.Subtle)
}
func renderBadge(label string, fill color.Color) string {
body := lipgloss.NewStyle().
Background(fill).
Foreground(style.S.Background).
Bold(true).
Render(label)
if icons.I.PillLeft == "" && icons.I.PillRight == "" {
return body
}
cap := lipgloss.NewStyle().Foreground(fill).Background(style.S.Background)
return cap.Render(icons.I.PillLeft) + body + cap.Render(icons.I.PillRight)
}
var settingWordOverrides = map[string]string{
"nat": "NAT",
"dns": "DNS",
"vpn": "VPN",
"ip": "IP",
"ipv6": "IPv6",
"netshield": "NetShield",
}
func humanizeKebab(s string) string {
words := strings.Split(s, "-")
for i, w := range words {
if override, ok := settingWordOverrides[strings.ToLower(w)]; ok {
words[i] = override
continue
}
if w == "" {
continue
}
words[i] = strings.ToUpper(w[:1]) + w[1:]
}
return strings.Join(words, " ")
}
+186
View File
@@ -0,0 +1,186 @@
package settings
import (
"sort"
"strings"
"charm.land/bubbles/v2/key"
"charm.land/bubbles/v2/list"
tea "charm.land/bubbletea/v2"
"github.com/anotherhadi/ilovetui/bubbles"
"github.com/anotherhadi/ilovetui/drawer"
"github.com/anotherhadi/ilovetui/notification"
"github.com/anotherhadi/proton-vpn-tui/internal/backend"
"github.com/anotherhadi/proton-vpn-tui/internal/config"
)
type item struct {
key string
setting backend.Setting
}
func (i item) FilterValue() string { return i.key }
type (
loadedMsg backend.Config
errMsg struct{ err error }
)
type Model struct {
list list.Model
cfg backend.Config
width int
height int
}
func New(width, height int) tea.Model {
l := bubbles.NewList(nil, 0, 0)
l.SetDelegate(delegate{})
l.SetShowHelp(false)
l.SetShowStatusBar(false)
l.SetShowTitle(false)
l.DisableQuitKeybindings()
l.SetFilteringEnabled(false)
gk := config.Global.Keybindings.Global
l.KeyMap.CursorUp = gk.Up
l.KeyMap.CursorDown = gk.Down
l.KeyMap.GoToStart = gk.GotoTop
l.KeyMap.GoToEnd = gk.GotoBottom
m := &Model{list: l}
m.applySize(width, height)
return m
}
const maxDrawerWidth = 80
func (m *Model) applySize(width, height int) {
m.width, m.height = width, height
boxWidth := min(int(float64(width)*0.8), maxDrawerWidth)
inner := boxWidth - 4
if floor := min(24, max(width-4, 1)); inner < floor {
inner = floor
}
listH := height - 3
if listH < 3 {
listH = 3
}
m.list.SetSize(inner, listH)
}
func (m *Model) rebuildItems() tea.Cmd {
keys := make([]string, 0, len(m.cfg))
for k := range m.cfg {
keys = append(keys, k)
}
sort.Strings(keys)
items := make([]list.Item, len(keys))
for i, k := range keys {
items[i] = item{key: k, setting: m.cfg[k]}
}
return m.list.SetItems(items)
}
func manualSetupNotice(it item) (tea.Cmd, bool) {
if it.key != "custom-dns" || it.setting.Value != "off" {
return nil, false
}
return notification.Show(
"Settings",
"Custom DNS needs a server address, set it via the CLI: "+
"protonvpn config set custom-dns on --dns <ip[,ip...]>",
notification.Info,
), true
}
func (m Model) selectedSetting() (item, bool) {
if it := m.list.SelectedItem(); it != nil {
return it.(item), true
}
return item{}, false
}
func (m *Model) Init() tea.Cmd {
return tea.Batch(loadCache, loadFresh)
}
func loadCache() tea.Msg {
cfg, err := backend.LoadConfigCache()
if err != nil {
return errMsg{err: err}
}
return loadedMsg(cfg)
}
func loadFresh() tea.Msg {
cfg, err := backend.LoadConfig()
if err != nil {
return errMsg{err: err}
}
return loadedMsg(cfg)
}
func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.applySize(msg.Width, msg.Height)
return m, nil
case loadedMsg:
m.cfg = backend.Config(msg)
cmd := m.rebuildItems()
return m, cmd
case errMsg:
return m, notification.Show("Settings", msg.err.Error(), notification.Error)
case tea.KeyPressMsg:
gk := config.Global.Keybindings.Global
sk := config.Global.Keybindings.Settings
switch {
case key.Matches(msg, gk.Escape), key.Matches(msg, gk.Quit):
return m, drawer.Close()
case key.Matches(msg, sk.NextOption), key.Matches(msg, sk.ToggleSetting):
if it, ok := m.selectedSetting(); ok {
if cmd, handled := manualSetupNotice(it); handled {
return m, cmd
}
if err := m.cfg.CycleSettingUp(it.key); err != nil {
return m, notification.Show("Settings", err.Error(), notification.Error)
}
cmd := m.rebuildItems()
return m, cmd
}
return m, nil
case key.Matches(msg, sk.PreviousOption):
if it, ok := m.selectedSetting(); ok {
if cmd, handled := manualSetupNotice(it); handled {
return m, cmd
}
if err := m.cfg.CycleSettingDown(it.key); err != nil {
return m, notification.Show("Settings", err.Error(), notification.Error)
}
cmd := m.rebuildItems()
return m, cmd
}
return m, nil
}
}
var cmd tea.Cmd
m.list, cmd = m.list.Update(msg)
return m, cmd
}
func (m *Model) View() tea.View {
listView := strings.TrimRight(m.list.View(), "\n")
return tea.NewView(strings.Join([]string{"", listView}, "\n"))
}
+24
View File
@@ -0,0 +1,24 @@
package app
import (
"charm.land/lipgloss/v2"
"github.com/anotherhadi/ilovetui/style"
)
var (
headerStyle = lipgloss.NewStyle().
Bold(true).
Foreground(style.S.Primary).
PaddingLeft(1)
mutedStyle = lipgloss.NewStyle().Foreground(style.S.Muted)
warnStyle = lipgloss.NewStyle().Foreground(style.S.Warning)
connectedStyle = lipgloss.NewStyle().Foreground(style.S.Success)
primaryStyle = lipgloss.NewStyle().Foreground(style.S.Primary)
infoLabelStyle = lipgloss.NewStyle().
Foreground(style.S.Muted).
PaddingLeft(1).
Width(10)
)