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
+1
View File
@@ -0,0 +1 @@
use flake
+34
View File
@@ -0,0 +1,34 @@
version: 2
before:
hooks:
- go mod tidy
builds:
- binary: proton-vpn-tui
main: ./cmd/proton-vpn-tui
env:
- CGO_ENABLED=0
goos:
- linux
goarch:
- amd64
- arm64
ldflags:
- -s -w -X main.version={{.Version}}
archives:
- formats:
- tar.gz
name_template: "{{ .ProjectName }}_{{ .Os }}_{{ .Arch }}"
checksum:
name_template: checksums.txt
changelog:
sort: asc
filters:
exclude:
- "^docs:"
- "^test:"
- "^ci:"
+10
View File
@@ -0,0 +1,10 @@
# Contributing
Everybody is invited and welcome to contribute. There is a lot to do... Check the issues!
The process is straight-forward.
- Read [How to get faster PR reviews](https://github.com/kubernetes/community/blob/master/contributors/guide/pull-requests.md#best-practices-for-faster-reviews) by Kubernetes (but skip step 0 and 1)
- Fork this git repository
- Write your changes (bug fixes, new features, ...).
- Create a Pull Request against the main branch.
+1
View File
@@ -0,0 +1 @@
ko_fi: anotherhadi
Binary file not shown.

After

Width:  |  Height:  |  Size: 577 KiB

+29
View File
@@ -0,0 +1,29 @@
Output ./.github/assets/demo.gif
Require proton-vpn-tui
Set Shell "zsh"
Set FontSize 32
Set Width 1600
Set Height 1900
Type "proton-vpn-tui"
Sleep 800ms
Enter
Sleep 3s
Down@800ms 3
Sleep 2s
Ctrl+F
Type "france"
Sleep 1s
Enter
Sleep 2s
Right
Sleep 1s
Down
Sleep 1s
Left
Sleep 1s
Down@900ms 2
Enter
Sleep 5s
+28
View File
@@ -0,0 +1,28 @@
name: Release
on:
push:
tags:
- "v*"
permissions:
contents: write
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
- uses: goreleaser/goreleaser-action@v6
with:
version: "~> v2"
args: release --clean --config .github/.goreleaser.yaml
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+5
View File
@@ -0,0 +1,5 @@
.claude/
CLAUDE.md
result/
.direnv/
.pre-commit-config.yaml
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Hadi
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+78
View File
@@ -0,0 +1,78 @@
```
____ _ __ ______ _ _ _____ _ _ ___
| _ \ _ __ ___ | |_ ___ _ _\ \ / / _ \| \ | | |_ _| | | |_ _|
| |_) | '__/ _ \| __/ _ \| '_ \ \ / /| |_) | \| | | | | | | || |
| __/| | | (_) | || (_) | | | \ V / | __/| |\ | | | | |_| || |
|_| |_| \___/ \__\___/|_| |_|\_/ |_| |_| \_| |_| \___/|___|
```
# ProtonVPN TUI
> A minimal, TUI and keyboard friendly wrapper for proton-vpn-cli.
![GitHub Stars](https://www.shieldcn.dev/github/stars/anotherhadi/proton-vpn-tui.svg?variant=outline&theme=violet)
![Release](https://www.shieldcn.dev/github/release/anotherhadi/proton-vpn-tui.svg?variant=outline&theme=violet)
![CI](https://www.shieldcn.dev/github/ci/anotherhadi/proton-vpn-tui.svg?variant=outline&theme=violet)
[![Ko-fi](https://www.shieldcn.dev/badge/Ko--fi-sponsor-FF5E5B.svg?logo=kofi&variant=secondary&theme=violet)](https://ko-fi.com/anotherhadi)
<!-- START doctoc generated TOC please keep comment here to allow auto update -->
<!-- DON'T EDIT THIS SECTION, INSTEAD RE-RUN doctoc TO UPDATE -->
- [Features](#features)
- [Installation](#installation)
- [Configuration](#configuration)
<!-- END doctoc generated TOC please keep comment here to allow auto update -->
<img alt="demo" src="./.github/assets/demo.gif" width="700" />
> **⚠️ DISCLAIMER**
> This project is **NOT affiliated with**, endorsed by, or connected to Proton AG or ProtonVPN in any way. It is an unofficial, community-driven tool.
## Features
- **Server selection**: Connect to any ProtonVPN server
- **Filters**: Filter servers by Secure Core, P2P, Tor, and more
- **Free account compatible**: Works with free ProtonVPN accounts
- **Mouse support**: Navigate and interact using your mouse
- **Settings editor**: Edit settings such as Kill Switch, NetShield, and more
## Installation
<details>
<summary>Go install</summary>
```sh
go install github.com/anotherhadi/proton-vpn-tui/cmd/proton-vpn-tui@latest
```
Requires Go 1.22+. The binary will be placed in `$GOPATH/bin` (or `~/go/bin`).
</details>
<details>
<summary>NUR (Nix/NixOS)</summary>
Available via [NUR](https://github.com/nix-community/NUR), under the `anotherhadi` repo:
```nix
# configuration.nix / home.nix
environment.systemPackages = [ nur.repos.anotherhadi.proton-vpn-tui ];
```
</details>
## Configuration
Proton-VPN-TUI is fully configured via a YAML file at `~/.config/proton-vpn-tui/config.yaml`.
Check the default configuration with all the options [here](./internal/config/default_config.yaml)
Colors and styles can be customized using [ilovetui](https://github.com/anotherhadi/ilovetui), which applies theme changes across all compatible TUI applications at once.
---
<div align="center">
<a href="https://github.com/anotherhadi/proton-vpn-tui">github</a> |
<a href="https://gitlab.com/anotherhadi_mirror/proton-vpn-tui">gitlab (mirror)</a> |
<a href="https://git.hadi.icu/anotherhadi/proton-vpn-tui">gitea (mirror)</a>
</div>
+98
View File
@@ -0,0 +1,98 @@
package main
import (
"strings"
"charm.land/bubbles/v2/key"
tea "charm.land/bubbletea/v2"
ilovetuiapp "github.com/anotherhadi/ilovetui/app"
"github.com/anotherhadi/ilovetui/drawer"
"github.com/anotherhadi/ilovetui/minsize"
"github.com/anotherhadi/ilovetui/modal"
"github.com/anotherhadi/ilovetui/notification"
zone "github.com/lrstanley/bubblezone/v2"
"github.com/anotherhadi/proton-vpn-tui/internal/config"
"github.com/anotherhadi/proton-vpn-tui/internal/ui"
)
const (
minWidth = 30
minHeight = 16
)
type appModel struct {
core app.Model
drawers drawer.Model
modals modal.Model
notif notification.Model
minSize minsize.Model
width, height int
}
func newApp() appModel {
return appModel{
core: app.New(),
drawers: drawer.New(drawer.WithMaxWidth(100000)),
modals: modal.New(),
notif: notification.New(),
minSize: minsize.New(minWidth, minHeight),
}
}
func (a appModel) Init() tea.Cmd {
return tea.Batch(a.core.Init(), a.drawers.Init(), a.modals.Init(), a.notif.Init())
}
func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if _, ok := msg.(ilovetuiapp.QuitMsg); ok {
return a, tea.Quit
}
if sizeMsg, ok := msg.(tea.WindowSizeMsg); ok {
a.width, a.height = sizeMsg.Width, sizeMsg.Height
}
var drawerCmd, modalCmd tea.Cmd
a.drawers, drawerCmd = a.drawers.Update(msg)
a.modals, modalCmd = a.modals.Update(msg)
a.core.Overlay = a.drawers.Open() || a.modals.Open()
helpToggled := false
if keyMsg, ok := msg.(tea.KeyPressMsg); ok && key.Matches(keyMsg, config.Global.Keybindings.Global.Help) {
a.core.ToggleHelp()
helpToggled = true
}
var coreCmd, notifCmd tea.Cmd
a.core, coreCmd = a.core.Update(msg)
a.notif, notifCmd = a.notif.Update(msg)
if _, isResize := msg.(tea.WindowSizeMsg); a.drawers.Open() && (isResize || helpToggled) {
var cmd tea.Cmd
a.drawers, cmd = a.drawers.Update(a.core.DrawerWindowSizeMsg())
drawerCmd = tea.Batch(drawerCmd, cmd)
}
return a, tea.Batch(coreCmd, drawerCmd, modalCmd, notifCmd)
}
func (a appModel) View() tea.View {
bg := a.core.View()
bg = zone.Scan(bg)
bg = a.drawers.Render(bg)
bg = a.modals.Render(bg)
bg = a.notif.Render(bg)
bg = strings.Join([]string{bg, a.core.HelpView()}, "\n")
if !a.minSize.Fits(a.width, a.height) {
bg = a.minSize.View(a.width, a.height)
}
return tea.View{
Content: bg,
AltScreen: true,
WindowTitle: a.core.WindowTitle(),
MouseMode: tea.MouseModeCellMotion,
}
}
+93
View File
@@ -0,0 +1,93 @@
package main
import (
"fmt"
"os"
"path/filepath"
"runtime/debug"
tea "charm.land/bubbletea/v2"
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"
"github.com/spf13/cobra"
)
var version = "dev"
func main() {
var (
flagConfig string
flagAddDefaultConfig bool
)
rootCmd := &cobra.Command{
Use: "proton-vpn-tui",
Short: "A minimal, TUI and keyboard friendly wrapper for proton-vpn-cli.",
Version: version,
SilenceUsage: true,
SilenceErrors: true,
RunE: func(cmd *cobra.Command, args []string) error {
if flagAddDefaultConfig {
home, _ := os.UserHomeDir()
cfgPath := filepath.Join(home, ".config", "proton-vpn-tui", "config.yaml")
if flagConfig != "" {
cfgPath = flagConfig
}
if err := config.WriteDefaultConfig(cfgPath); err != nil {
return fmt.Errorf("add-default-config: %w", err)
}
fmt.Printf("default config written to %s\n", cfgPath)
return nil
}
home, _ := os.UserHomeDir()
cfgPath := filepath.Join(home, ".config", "proton-vpn-tui", "config.yaml")
if flagConfig != "" {
cfgPath = flagConfig
}
if err := config.Load(cfgPath); err != nil {
return fmt.Errorf("config: %w", err)
}
config.Global.Version = version
style.Init()
icons.Init()
zone.NewGlobal()
if err := backend.Available(); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
p := tea.NewProgram(newApp())
if _, err := p.Run(); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
return nil
},
}
rootCmd.Flags().StringVarP(&flagConfig, "config", "c", "", "path to config file")
rootCmd.Flags().BoolVar(&flagAddDefaultConfig, "add-default-config", false, "copy the default config file to the config path and exit")
if err := rootCmd.Execute(); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
func init() {
if version != "dev" {
return
}
if info, ok := debug.ReadBuildInfo(); ok && info.Main.Version != "" && info.Main.Version != "(devel)" {
version = info.Main.Version
}
}
Generated
+65
View File
@@ -0,0 +1,65 @@
{
"nodes": {
"flake-compat": {
"flake": false,
"locked": {
"lastModified": 1767039857,
"narHash": "sha256-vNpUSpF5Nuw8xvDLj2KCwwksIbjua2LZCqhV1LNRDns=",
"owner": "NixOS",
"repo": "flake-compat",
"rev": "5edf11c44bc78a0d334f6334cdaf7d60d732daab",
"type": "github"
},
"original": {
"owner": "NixOS",
"repo": "flake-compat",
"type": "github"
}
},
"git-hooks": {
"inputs": {
"flake-compat": "flake-compat",
"nixpkgs": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1787424939,
"narHash": "sha256-O2tBn84NNuHrnqNVxx/XqsXwfYvS1YwBh+7CBnbCYsk=",
"owner": "cachix",
"repo": "git-hooks.nix",
"rev": "809414f0cdadf82cf11b06c2b29ba9b3168b3297",
"type": "github"
},
"original": {
"owner": "cachix",
"repo": "git-hooks.nix",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1787360063,
"narHash": "sha256-dt4WdcvsA8/RCe+VZZwqU0X+XMM3wBbGCWA0/sFWzGo=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "2c423e03bbafcff28bfadc6781a4a8257f205cb5",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"git-hooks": "git-hooks",
"nixpkgs": "nixpkgs"
}
}
},
"root": "root",
"version": 7
}
+30
View File
@@ -0,0 +1,30 @@
{
description = "ProtonVPN TUI: A minimal, TUI and keyboard friendly wrapper for proton-vpn-cli.";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
git-hooks = {
url = "github:cachix/git-hooks.nix";
inputs.nixpkgs.follows = "nixpkgs";
};
};
outputs = {
self,
nixpkgs,
git-hooks,
}: let
supportedSystems = ["x86_64-linux" "aarch64-linux"];
forAllSystems = f:
nixpkgs.lib.genAttrs supportedSystems
(system: f system (import nixpkgs {inherit system;}));
in {
devShells = forAllSystems (system: pkgs: {
default = import ./nix/shell.nix {
inherit pkgs;
gitHooksLib = git-hooks.lib.${system};
};
});
};
}
+58
View File
@@ -0,0 +1,58 @@
module github.com/anotherhadi/proton-vpn-tui
go 1.26.5
require (
charm.land/bubbles/v2 v2.1.0
charm.land/bubbletea/v2 v2.0.2
charm.land/lipgloss/v2 v2.0.3
github.com/anotherhadi/ilovetui v0.2.3
github.com/charmbracelet/x/ansi v0.11.7
github.com/go-viper/mapstructure/v2 v2.4.0
github.com/lrstanley/bubblezone/v2 v2.0.0
github.com/spf13/cobra v1.10.2
github.com/spf13/viper v1.21.0
gopkg.in/yaml.v3 v3.0.1
)
require (
charm.land/glamour/v2 v2.0.0 // indirect
github.com/alecthomas/chroma/v2 v2.14.0 // indirect
github.com/atotto/clipboard v0.1.4 // indirect
github.com/aymerick/douceur v0.2.0 // indirect
github.com/charmbracelet/colorprofile v0.4.3 // indirect
github.com/charmbracelet/harmonica v0.2.0 // indirect
github.com/charmbracelet/ultraviolet v0.0.0-20260223171050-89c142e4aa73 // indirect
github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // 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/dlclark/regexp2 v1.11.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/gorilla/css v1.0.1 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/lucasb-eyer/go-colorful v1.4.0 // indirect
github.com/mattn/go-runewidth v0.0.23 // indirect
github.com/microcosm-cc/bluemonday v1.0.27 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/sagikazarmark/locafero v0.11.0 // indirect
github.com/sahilm/fuzzy v0.1.1 // indirect
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
github.com/spf13/afero v1.15.0 // indirect
github.com/spf13/cast v1.10.0 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
github.com/yuin/goldmark v1.7.8 // indirect
github.com/yuin/goldmark-emoji v1.0.5 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/net v0.39.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.43.0 // indirect
golang.org/x/text v0.28.0 // indirect
)
+137
View File
@@ -0,0 +1,137 @@
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/glamour/v2 v2.0.0 h1:IDBoqLEy7Hdpb9VOXN+khLP/XSxtJy1VsHuW/yF87+U=
charm.land/glamour/v2 v2.0.0/go.mod h1:kjq9WB0s8vuUYZNYey2jp4Lgd9f4cKdzAw88FZtpj/w=
charm.land/lipgloss/v2 v2.0.3 h1:yM2zJ4Cf5Y51b7RHIwioil4ApI/aypFXXVHSwlM6RzU=
charm.land/lipgloss/v2 v2.0.3/go.mod h1:7myLU9iG/3xluAWzpY/fSxYYHCgoKTie7laxk6ATwXA=
github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
github.com/alecthomas/assert/v2 v2.7.0 h1:QtqSACNS3tF7oasA8CU6A6sXZSBDqnm7RfpLl9bZqbE=
github.com/alecthomas/assert/v2 v2.7.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
github.com/alecthomas/chroma/v2 v2.14.0 h1:R3+wzpnUArGcQz7fCETQBzO5n9IMNi13iIs46aU4V9E=
github.com/alecthomas/chroma/v2 v2.14.0/go.mod h1:QolEbTfmUHIMVpBqxeDnNBj2uoeI4EbYP4i6n68SG4I=
github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc=
github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
github.com/anotherhadi/ilovetui v0.2.3 h1:0xTHMMyQqN0FtQ4u9Uix6xl5YzcVugLqC62aLV4AoXU=
github.com/anotherhadi/ilovetui v0.2.3/go.mod h1:gAn69amLM3z0x8PV4obyf5bJntL/PTZusEuhu/xdFIY=
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o=
github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w=
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q=
github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q=
github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ=
github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao=
github.com/charmbracelet/ultraviolet v0.0.0-20260223171050-89c142e4aa73 h1:Af/L28Xh+pddhouT/6lJ7IAIYfu5tWJOB0iqt+mXsYM=
github.com/charmbracelet/ultraviolet v0.0.0-20260223171050-89c142e4aa73/go.mod h1:E6/0abq9uG2SnM8IbLB9Y5SW09uIgfaFETk8aRzgXUQ=
github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI=
github.com/charmbracelet/x/ansi v0.11.7/go.mod h1:9qGpnAVYz+8ACONkZBUWPtL7lulP9No6p1epAihUZwQ=
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA=
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I=
github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf h1:rLG0Yb6MQSDKdB52aGX55JT1oi0P0Kuaj7wi1bLUpnI=
github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf/go.mod h1:B3UgsnsBZS/eX42BlaNiJkD1pPOUa+oF1IYC6Yd2CEU=
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/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/lrstanley/bubblezone/v2 v2.0.0 h1:pMb9fHKs0slJF6OrzQ2hEgWusqyl9VU/S0UZ5hyh7ZA=
github.com/lrstanley/bubblezone/v2 v2.0.0/go.mod h1:yV/QTjcm4Zu5cqvGvdHi7xVUfnB36w/SafOuDp57dgY=
github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4=
github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw=
github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc=
github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik=
github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA=
github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw=
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U=
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
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=
github.com/yuin/goldmark v1.7.1/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic=
github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
github.com/yuin/goldmark-emoji v1.0.5 h1:EMVWyCGPlXJfUXBXpuMu+ii3TIaxbVBnEX9uaDC4cIk=
github.com/yuin/goldmark-emoji v1.0.5/go.mod h1:tTkZEbwu5wkPmgTcitqddVxY9osFZiavD+r4AzQrh1U=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY=
golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E=
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.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+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)
)
+33
View File
@@ -0,0 +1,33 @@
{
pkgs,
gitHooksLib,
}: let
hooks = gitHooksLib.run {
src = ../.;
hooks = {
gofmt.enable = true;
govet.enable = true;
doctoc = {
enable = true;
name = "doctoc";
entry = "doctoc --notitle README.md";
language = "system";
files = "(README\\.md|cmd/)";
pass_filenames = false;
};
};
};
in
pkgs.mkShell {
packages = with pkgs;
[
go
gosec
govulncheck
doctoc
]
++ hooks.enabledPackages;
shellHook = hooks.shellHook;
}