mirror of
https://github.com/anotherhadi/proton-vpn-tui.git
synced 2026-10-05 11:18:27 +02:00
init
This commit is contained in:
@@ -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:"
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ko_fi: anotherhadi
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 577 KiB |
@@ -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
|
||||||
@@ -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 }}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
.claude/
|
||||||
|
CLAUDE.md
|
||||||
|
result/
|
||||||
|
.direnv/
|
||||||
|
.pre-commit-config.yaml
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
```
|
||||||
|
____ _ __ ______ _ _ _____ _ _ ___
|
||||||
|
| _ \ _ __ ___ | |_ ___ _ _\ \ / / _ \| \ | | |_ _| | | |_ _|
|
||||||
|
| |_) | '__/ _ \| __/ _ \| '_ \ \ / /| |_) | \| | | | | | | || |
|
||||||
|
| __/| | | (_) | || (_) | | | \ V / | __/| |\ | | | | |_| || |
|
||||||
|
|_| |_| \___/ \__\___/|_| |_|\_/ |_| |_| \_| |_| \___/|___|
|
||||||
|
```
|
||||||
|
|
||||||
|
# ProtonVPN TUI
|
||||||
|
|
||||||
|
> A minimal, TUI and keyboard friendly wrapper for proton-vpn-cli.
|
||||||
|
|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|
[](https://ko-fi.com/anotherhadi)
|
||||||
|
|
||||||
|
<!-- START doctoc generated TOC please keep comment here to allow auto update -->
|
||||||
|
<!-- DON'T EDIT THIS SECTION, INSTEAD RE-RUN doctoc TO UPDATE -->
|
||||||
|
|
||||||
|
- [Features](#features)
|
||||||
|
- [Installation](#installation)
|
||||||
|
- [Configuration](#configuration)
|
||||||
|
|
||||||
|
<!-- END doctoc generated TOC please keep comment here to allow auto update -->
|
||||||
|
|
||||||
|
<img alt="demo" src="./.github/assets/demo.gif" width="700" />
|
||||||
|
|
||||||
|
> **⚠️ DISCLAIMER**
|
||||||
|
> This project is **NOT affiliated with**, endorsed by, or connected to Proton AG or ProtonVPN in any way. It is an unofficial, community-driven tool.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Server selection**: Connect to any ProtonVPN server
|
||||||
|
- **Filters**: Filter servers by Secure Core, P2P, Tor, and more
|
||||||
|
- **Free account compatible**: Works with free ProtonVPN accounts
|
||||||
|
- **Mouse support**: Navigate and interact using your mouse
|
||||||
|
- **Settings editor**: Edit settings such as Kill Switch, NetShield, and more
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Go install</summary>
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go install github.com/anotherhadi/proton-vpn-tui/cmd/proton-vpn-tui@latest
|
||||||
|
```
|
||||||
|
|
||||||
|
Requires Go 1.22+. The binary will be placed in `$GOPATH/bin` (or `~/go/bin`).
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>NUR (Nix/NixOS)</summary>
|
||||||
|
|
||||||
|
Available via [NUR](https://github.com/nix-community/NUR), under the `anotherhadi` repo:
|
||||||
|
|
||||||
|
```nix
|
||||||
|
# configuration.nix / home.nix
|
||||||
|
environment.systemPackages = [ nur.repos.anotherhadi.proton-vpn-tui ];
|
||||||
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Proton-VPN-TUI is fully configured via a YAML file at `~/.config/proton-vpn-tui/config.yaml`.
|
||||||
|
Check the default configuration with all the options [here](./internal/config/default_config.yaml)
|
||||||
|
|
||||||
|
Colors and styles can be customized using [ilovetui](https://github.com/anotherhadi/ilovetui), which applies theme changes across all compatible TUI applications at once.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
<a href="https://github.com/anotherhadi/proton-vpn-tui">github</a> |
|
||||||
|
<a href="https://gitlab.com/anotherhadi_mirror/proton-vpn-tui">gitlab (mirror)</a> |
|
||||||
|
<a href="https://git.hadi.icu/anotherhadi/proton-vpn-tui">gitea (mirror)</a>
|
||||||
|
</div>
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime/debug"
|
||||||
|
|
||||||
|
tea "charm.land/bubbletea/v2"
|
||||||
|
zone "github.com/lrstanley/bubblezone/v2"
|
||||||
|
|
||||||
|
"github.com/anotherhadi/ilovetui/style"
|
||||||
|
"github.com/anotherhadi/proton-vpn-tui/internal/backend"
|
||||||
|
"github.com/anotherhadi/proton-vpn-tui/internal/config"
|
||||||
|
"github.com/anotherhadi/proton-vpn-tui/internal/icons"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
var version = "dev"
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
var (
|
||||||
|
flagConfig string
|
||||||
|
flagAddDefaultConfig bool
|
||||||
|
)
|
||||||
|
|
||||||
|
rootCmd := &cobra.Command{
|
||||||
|
Use: "proton-vpn-tui",
|
||||||
|
Short: "A minimal, TUI and keyboard friendly wrapper for proton-vpn-cli.",
|
||||||
|
Version: version,
|
||||||
|
SilenceUsage: true,
|
||||||
|
SilenceErrors: true,
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
if flagAddDefaultConfig {
|
||||||
|
home, _ := os.UserHomeDir()
|
||||||
|
cfgPath := filepath.Join(home, ".config", "proton-vpn-tui", "config.yaml")
|
||||||
|
if flagConfig != "" {
|
||||||
|
cfgPath = flagConfig
|
||||||
|
}
|
||||||
|
if err := config.WriteDefaultConfig(cfgPath); err != nil {
|
||||||
|
return fmt.Errorf("add-default-config: %w", err)
|
||||||
|
}
|
||||||
|
fmt.Printf("default config written to %s\n", cfgPath)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
home, _ := os.UserHomeDir()
|
||||||
|
cfgPath := filepath.Join(home, ".config", "proton-vpn-tui", "config.yaml")
|
||||||
|
if flagConfig != "" {
|
||||||
|
cfgPath = flagConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := config.Load(cfgPath); err != nil {
|
||||||
|
return fmt.Errorf("config: %w", err)
|
||||||
|
}
|
||||||
|
config.Global.Version = version
|
||||||
|
|
||||||
|
style.Init()
|
||||||
|
icons.Init()
|
||||||
|
zone.NewGlobal()
|
||||||
|
|
||||||
|
if err := backend.Available(); err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
p := tea.NewProgram(newApp())
|
||||||
|
if _, err := p.Run(); err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
rootCmd.Flags().StringVarP(&flagConfig, "config", "c", "", "path to config file")
|
||||||
|
rootCmd.Flags().BoolVar(&flagAddDefaultConfig, "add-default-config", false, "copy the default config file to the config path and exit")
|
||||||
|
|
||||||
|
if err := rootCmd.Execute(); err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
if version != "dev" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if info, ok := debug.ReadBuildInfo(); ok && info.Main.Version != "" && info.Main.Version != "(devel)" {
|
||||||
|
version = info.Main.Version
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+65
@@ -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
|
||||||
|
}
|
||||||
@@ -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};
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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=
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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"
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"`
|
||||||
|
}
|
||||||
@@ -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: "/",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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.")
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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, " ")
|
||||||
|
}
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
package settings
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"charm.land/bubbles/v2/key"
|
||||||
|
"charm.land/bubbles/v2/list"
|
||||||
|
tea "charm.land/bubbletea/v2"
|
||||||
|
|
||||||
|
"github.com/anotherhadi/ilovetui/bubbles"
|
||||||
|
"github.com/anotherhadi/ilovetui/drawer"
|
||||||
|
"github.com/anotherhadi/ilovetui/notification"
|
||||||
|
|
||||||
|
"github.com/anotherhadi/proton-vpn-tui/internal/backend"
|
||||||
|
"github.com/anotherhadi/proton-vpn-tui/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
type item struct {
|
||||||
|
key string
|
||||||
|
setting backend.Setting
|
||||||
|
}
|
||||||
|
|
||||||
|
func (i item) FilterValue() string { return i.key }
|
||||||
|
|
||||||
|
type (
|
||||||
|
loadedMsg backend.Config
|
||||||
|
errMsg struct{ err error }
|
||||||
|
)
|
||||||
|
|
||||||
|
type Model struct {
|
||||||
|
list list.Model
|
||||||
|
cfg backend.Config
|
||||||
|
width int
|
||||||
|
height int
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(width, height int) tea.Model {
|
||||||
|
l := bubbles.NewList(nil, 0, 0)
|
||||||
|
l.SetDelegate(delegate{})
|
||||||
|
l.SetShowHelp(false)
|
||||||
|
l.SetShowStatusBar(false)
|
||||||
|
l.SetShowTitle(false)
|
||||||
|
l.DisableQuitKeybindings()
|
||||||
|
l.SetFilteringEnabled(false)
|
||||||
|
|
||||||
|
gk := config.Global.Keybindings.Global
|
||||||
|
l.KeyMap.CursorUp = gk.Up
|
||||||
|
l.KeyMap.CursorDown = gk.Down
|
||||||
|
l.KeyMap.GoToStart = gk.GotoTop
|
||||||
|
l.KeyMap.GoToEnd = gk.GotoBottom
|
||||||
|
|
||||||
|
m := &Model{list: l}
|
||||||
|
m.applySize(width, height)
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxDrawerWidth = 80
|
||||||
|
|
||||||
|
func (m *Model) applySize(width, height int) {
|
||||||
|
m.width, m.height = width, height
|
||||||
|
|
||||||
|
boxWidth := min(int(float64(width)*0.8), maxDrawerWidth)
|
||||||
|
inner := boxWidth - 4
|
||||||
|
if floor := min(24, max(width-4, 1)); inner < floor {
|
||||||
|
inner = floor
|
||||||
|
}
|
||||||
|
|
||||||
|
listH := height - 3
|
||||||
|
if listH < 3 {
|
||||||
|
listH = 3
|
||||||
|
}
|
||||||
|
m.list.SetSize(inner, listH)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) rebuildItems() tea.Cmd {
|
||||||
|
keys := make([]string, 0, len(m.cfg))
|
||||||
|
for k := range m.cfg {
|
||||||
|
keys = append(keys, k)
|
||||||
|
}
|
||||||
|
sort.Strings(keys)
|
||||||
|
|
||||||
|
items := make([]list.Item, len(keys))
|
||||||
|
for i, k := range keys {
|
||||||
|
items[i] = item{key: k, setting: m.cfg[k]}
|
||||||
|
}
|
||||||
|
return m.list.SetItems(items)
|
||||||
|
}
|
||||||
|
|
||||||
|
func manualSetupNotice(it item) (tea.Cmd, bool) {
|
||||||
|
if it.key != "custom-dns" || it.setting.Value != "off" {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
return notification.Show(
|
||||||
|
"Settings",
|
||||||
|
"Custom DNS needs a server address, set it via the CLI: "+
|
||||||
|
"protonvpn config set custom-dns on --dns <ip[,ip...]>",
|
||||||
|
notification.Info,
|
||||||
|
), true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) selectedSetting() (item, bool) {
|
||||||
|
if it := m.list.SelectedItem(); it != nil {
|
||||||
|
return it.(item), true
|
||||||
|
}
|
||||||
|
return item{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) Init() tea.Cmd {
|
||||||
|
return tea.Batch(loadCache, loadFresh)
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadCache() tea.Msg {
|
||||||
|
cfg, err := backend.LoadConfigCache()
|
||||||
|
if err != nil {
|
||||||
|
return errMsg{err: err}
|
||||||
|
}
|
||||||
|
return loadedMsg(cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadFresh() tea.Msg {
|
||||||
|
cfg, err := backend.LoadConfig()
|
||||||
|
if err != nil {
|
||||||
|
return errMsg{err: err}
|
||||||
|
}
|
||||||
|
return loadedMsg(cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
|
switch msg := msg.(type) {
|
||||||
|
case tea.WindowSizeMsg:
|
||||||
|
m.applySize(msg.Width, msg.Height)
|
||||||
|
return m, nil
|
||||||
|
|
||||||
|
case loadedMsg:
|
||||||
|
m.cfg = backend.Config(msg)
|
||||||
|
cmd := m.rebuildItems()
|
||||||
|
return m, cmd
|
||||||
|
|
||||||
|
case errMsg:
|
||||||
|
return m, notification.Show("Settings", msg.err.Error(), notification.Error)
|
||||||
|
|
||||||
|
case tea.KeyPressMsg:
|
||||||
|
gk := config.Global.Keybindings.Global
|
||||||
|
sk := config.Global.Keybindings.Settings
|
||||||
|
switch {
|
||||||
|
case key.Matches(msg, gk.Escape), key.Matches(msg, gk.Quit):
|
||||||
|
return m, drawer.Close()
|
||||||
|
|
||||||
|
case key.Matches(msg, sk.NextOption), key.Matches(msg, sk.ToggleSetting):
|
||||||
|
if it, ok := m.selectedSetting(); ok {
|
||||||
|
if cmd, handled := manualSetupNotice(it); handled {
|
||||||
|
return m, cmd
|
||||||
|
}
|
||||||
|
if err := m.cfg.CycleSettingUp(it.key); err != nil {
|
||||||
|
return m, notification.Show("Settings", err.Error(), notification.Error)
|
||||||
|
}
|
||||||
|
cmd := m.rebuildItems()
|
||||||
|
return m, cmd
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
|
||||||
|
case key.Matches(msg, sk.PreviousOption):
|
||||||
|
if it, ok := m.selectedSetting(); ok {
|
||||||
|
if cmd, handled := manualSetupNotice(it); handled {
|
||||||
|
return m, cmd
|
||||||
|
}
|
||||||
|
if err := m.cfg.CycleSettingDown(it.key); err != nil {
|
||||||
|
return m, notification.Show("Settings", err.Error(), notification.Error)
|
||||||
|
}
|
||||||
|
cmd := m.rebuildItems()
|
||||||
|
return m, cmd
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var cmd tea.Cmd
|
||||||
|
m.list, cmd = m.list.Update(msg)
|
||||||
|
return m, cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) View() tea.View {
|
||||||
|
listView := strings.TrimRight(m.list.View(), "\n")
|
||||||
|
return tea.NewView(strings.Join([]string{"", listView}, "\n"))
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
)
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user