commit e9c2a59ee53b277ad570a270237f1d95bfbe0faa Author: Hadi <112569860+anotherhadi@users.noreply.github.com> Date: Thu Aug 27 23:14:56 2026 +0200 init diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..3550a30 --- /dev/null +++ b/.envrc @@ -0,0 +1 @@ +use flake diff --git a/.github/.goreleaser.yaml b/.github/.goreleaser.yaml new file mode 100644 index 0000000..1209073 --- /dev/null +++ b/.github/.goreleaser.yaml @@ -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:" diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md new file mode 100644 index 0000000..7b8ae86 --- /dev/null +++ b/.github/CONTRIBUTING.md @@ -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. diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..b1c5749 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1 @@ +ko_fi: anotherhadi diff --git a/.github/assets/demo.gif b/.github/assets/demo.gif new file mode 100644 index 0000000..2922448 Binary files /dev/null and b/.github/assets/demo.gif differ diff --git a/.github/assets/demo.tape b/.github/assets/demo.tape new file mode 100644 index 0000000..241ce00 --- /dev/null +++ b/.github/assets/demo.tape @@ -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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..bf265c5 --- /dev/null +++ b/.github/workflows/release.yml @@ -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 }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..87e2f4b --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.claude/ +CLAUDE.md +result/ +.direnv/ +.pre-commit-config.yaml diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..7ce7478 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..df5bd27 --- /dev/null +++ b/README.md @@ -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) + + + + +- [Features](#features) +- [Installation](#installation) +- [Configuration](#configuration) + + + +demo + +> **⚠️ 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 + +
+Go install + +```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`). + +
+ +
+NUR (Nix/NixOS) + +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 ]; +``` + +
+ +## 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. + +--- + +
+ github | + gitlab (mirror) | + gitea (mirror) +
diff --git a/cmd/proton-vpn-tui/app.go b/cmd/proton-vpn-tui/app.go new file mode 100644 index 0000000..93a4ffa --- /dev/null +++ b/cmd/proton-vpn-tui/app.go @@ -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, + } +} diff --git a/cmd/proton-vpn-tui/main.go b/cmd/proton-vpn-tui/main.go new file mode 100644 index 0000000..de94d12 --- /dev/null +++ b/cmd/proton-vpn-tui/main.go @@ -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 + } +} diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..305e988 --- /dev/null +++ b/flake.lock @@ -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 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..895931b --- /dev/null +++ b/flake.nix @@ -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}; + }; + }); + }; +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..0c53509 --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..a2162f6 --- /dev/null +++ b/go.sum @@ -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= diff --git a/internal/backend/config.go b/internal/backend/config.go new file mode 100644 index 0000000..0d25444 --- /dev/null +++ b/internal/backend/config.go @@ -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) +} diff --git a/internal/backend/connect.go b/internal/backend/connect.go new file mode 100644 index 0000000..9a0a328 --- /dev/null +++ b/internal/backend/connect.go @@ -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) +} diff --git a/internal/backend/countries.go b/internal/backend/countries.go new file mode 100644 index 0000000..2a83b53 --- /dev/null +++ b/internal/backend/countries.go @@ -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 +} diff --git a/internal/backend/exec.go b/internal/backend/exec.go new file mode 100644 index 0000000..19c13ac --- /dev/null +++ b/internal/backend/exec.go @@ -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 +} diff --git a/internal/backend/flag.go b/internal/backend/flag.go new file mode 100644 index 0000000..29e28e4 --- /dev/null +++ b/internal/backend/flag.go @@ -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)) +} diff --git a/internal/backend/servers.go b/internal/backend/servers.go new file mode 100644 index 0000000..d56bb9c --- /dev/null +++ b/internal/backend/servers.go @@ -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 +} diff --git a/internal/backend/status.go b/internal/backend/status.go new file mode 100644 index 0000000..57744a1 --- /dev/null +++ b/internal/backend/status.go @@ -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 +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..83a7990 --- /dev/null +++ b/internal/config/config.go @@ -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 diff --git a/internal/config/default_config.yaml b/internal/config/default_config.yaml new file mode 100644 index 0000000..c6d8de0 --- /dev/null +++ b/internal/config/default_config.yaml @@ -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" diff --git a/internal/config/func_config.go b/internal/config/func_config.go new file mode 100644 index 0000000..79c6890 --- /dev/null +++ b/internal/config/func_config.go @@ -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 +} diff --git a/internal/config/func_keybindings.go b/internal/config/func_keybindings.go new file mode 100644 index 0000000..0a89471 --- /dev/null +++ b/internal/config/func_keybindings.go @@ -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) + } + } +} diff --git a/internal/config/keybindings.go b/internal/config/keybindings.go new file mode 100644 index 0000000..ea105e9 --- /dev/null +++ b/internal/config/keybindings.go @@ -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"` +} diff --git a/internal/icons/icons.go b/internal/icons/icons.go new file mode 100644 index 0000000..af3d2b0 --- /dev/null +++ b/internal/icons/icons.go @@ -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: "/", + } + } +} diff --git a/internal/ui/connect.go b/internal/ui/connect.go new file mode 100644 index 0000000..5afdc49 --- /dev/null +++ b/internal/ui/connect.go @@ -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) +} diff --git a/internal/ui/delegate.go b/internal/ui/delegate.go new file mode 100644 index 0000000..d596b6f --- /dev/null +++ b/internal/ui/delegate.go @@ -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 +} diff --git a/internal/ui/header.go b/internal/ui/header.go new file mode 100644 index 0000000..bcaa9e6 --- /dev/null +++ b/internal/ui/header.go @@ -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) +} diff --git a/internal/ui/model.go b/internal/ui/model.go new file mode 100644 index 0000000..efa72ca --- /dev/null +++ b/internal/ui/model.go @@ -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) +} diff --git a/internal/ui/mouse.go b/internal/ui/mouse.go new file mode 100644 index 0000000..89bca2a --- /dev/null +++ b/internal/ui/mouse.go @@ -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() +} diff --git a/internal/ui/notsignedin.go b/internal/ui/notsignedin.go new file mode 100644 index 0000000..c59acdc --- /dev/null +++ b/internal/ui/notsignedin.go @@ -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.") +} diff --git a/internal/ui/rows.go b/internal/ui/rows.go new file mode 100644 index 0000000..53f31e1 --- /dev/null +++ b/internal/ui/rows.go @@ -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 +} diff --git a/internal/ui/search.go b/internal/ui/search.go new file mode 100644 index 0000000..1b632c9 --- /dev/null +++ b/internal/ui/search.go @@ -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 +} diff --git a/internal/ui/settings/delegate.go b/internal/ui/settings/delegate.go new file mode 100644 index 0000000..7833363 --- /dev/null +++ b/internal/ui/settings/delegate.go @@ -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, " ") +} diff --git a/internal/ui/settings/model.go b/internal/ui/settings/model.go new file mode 100644 index 0000000..04b71ec --- /dev/null +++ b/internal/ui/settings/model.go @@ -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 ", + 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")) +} diff --git a/internal/ui/styles.go b/internal/ui/styles.go new file mode 100644 index 0000000..f2d7cd6 --- /dev/null +++ b/internal/ui/styles.go @@ -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) +) diff --git a/nix/shell.nix b/nix/shell.nix new file mode 100644 index 0000000..c93b672 --- /dev/null +++ b/nix/shell.nix @@ -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; + }