Add Gio desktop GUI shell
This commit is contained in:
21
cmd/cli/main.go
Normal file
21
cmd/cli/main.go
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"vpn-client/internal/cli"
|
||||||
|
"vpn-client/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
if err := config.Init(); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "config init error: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := cli.Run(); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "cli error: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
14
go.mod
14
go.mod
@@ -2,10 +2,20 @@ module vpn-client
|
|||||||
|
|
||||||
go 1.21
|
go 1.21
|
||||||
|
|
||||||
require github.com/fatih/color v1.16.0
|
require (
|
||||||
|
gioui.org v0.7.1
|
||||||
|
github.com/fatih/color v1.16.0
|
||||||
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
gioui.org/cpu v0.0.0-20210817075930-8d6a761490d2 // indirect
|
||||||
|
gioui.org/shader v1.0.8 // indirect
|
||||||
|
github.com/go-text/typesetting v0.1.1 // indirect
|
||||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
golang.org/x/sys v0.16.0 // indirect
|
golang.org/x/exp v0.0.0-20240707233637-46b078467d37 // indirect
|
||||||
|
golang.org/x/exp/shiny v0.0.0-20240707233637-46b078467d37 // indirect
|
||||||
|
golang.org/x/image v0.18.0 // indirect
|
||||||
|
golang.org/x/sys v0.22.0 // indirect
|
||||||
|
golang.org/x/text v0.16.0 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
25
go.sum
25
go.sum
@@ -1,11 +1,32 @@
|
|||||||
|
eliasnaur.com/font v0.0.0-20230308162249-dd43949cb42d h1:ARo7NCVvN2NdhLlJE9xAbKweuI9L6UgfTbYb0YwPacY=
|
||||||
|
eliasnaur.com/font v0.0.0-20230308162249-dd43949cb42d/go.mod h1:OYVuxibdk9OSLX8vAqydtRPP87PyTFcT9uH3MlEGBQA=
|
||||||
|
gioui.org v0.7.1 h1:l7OVj47n1z8acaszQ6Wlu+Rxme+HqF3q8b+Fs68+x3w=
|
||||||
|
gioui.org v0.7.1/go.mod h1:5Kw/q7R1BWc5MKStuTNvhCgSrRqbfHc9Dzfjs4IGgZo=
|
||||||
|
gioui.org/cpu v0.0.0-20210808092351-bfe733dd3334/go.mod h1:A8M0Cn5o+vY5LTMlnRoK3O5kG+rH0kWfJjeKd9QpBmQ=
|
||||||
|
gioui.org/cpu v0.0.0-20210817075930-8d6a761490d2 h1:AGDDxsJE1RpcXTAxPG2B4jrwVUJGFDjINIPi1jtO6pc=
|
||||||
|
gioui.org/cpu v0.0.0-20210817075930-8d6a761490d2/go.mod h1:A8M0Cn5o+vY5LTMlnRoK3O5kG+rH0kWfJjeKd9QpBmQ=
|
||||||
|
gioui.org/shader v1.0.8 h1:6ks0o/A+b0ne7RzEqRZK5f4Gboz2CfG+mVliciy6+qA=
|
||||||
|
gioui.org/shader v1.0.8/go.mod h1:mWdiME581d/kV7/iEhLmUgUK5iZ09XR5XpduXzbePVM=
|
||||||
github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM=
|
github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM=
|
||||||
github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE=
|
github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE=
|
||||||
|
github.com/go-text/typesetting v0.1.1 h1:bGAesCuo85nXnEN5LmFMVGAGpGkCPtHrZLi//qD7EJo=
|
||||||
|
github.com/go-text/typesetting v0.1.1/go.mod h1:d22AnmeKq/on0HNv73UFriMKc4Ez6EqZAofLhAzpSzI=
|
||||||
|
github.com/go-text/typesetting-utils v0.0.0-20231211103740-d9332ae51f04 h1:zBx+p/W2aQYtNuyZNcTfinWvXBQwYtDfme051PR/lAY=
|
||||||
|
github.com/go-text/typesetting-utils v0.0.0-20231211103740-d9332ae51f04/go.mod h1:DDxDdQEnB70R8owOx3LVpEFvpMK9eeH1o2r0yZhFI9o=
|
||||||
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||||
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
|
golang.org/x/exp v0.0.0-20240707233637-46b078467d37 h1:uLDX+AfeFCct3a2C7uIWBKMJIR3CJMhcgfrUAqjRK6w=
|
||||||
|
golang.org/x/exp v0.0.0-20240707233637-46b078467d37/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY=
|
||||||
|
golang.org/x/exp/shiny v0.0.0-20240707233637-46b078467d37 h1:SOSg7+sueresE4IbmmGM60GmlIys+zNX63d6/J4CMtU=
|
||||||
|
golang.org/x/exp/shiny v0.0.0-20240707233637-46b078467d37/go.mod h1:3F+MieQB7dRYLTmnncoFbb1crS5lfQoTfDgQy6K4N0o=
|
||||||
|
golang.org/x/image v0.18.0 h1:jGzIakQa/ZXI1I0Fxvaa9W7yP25TqT6cHIHn+6CqvSQ=
|
||||||
|
golang.org/x/image v0.18.0/go.mod h1:4yyo5vMFQjVjUcVk4jEQcU9MGy/rulF5WvUILseCM2E=
|
||||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU=
|
golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI=
|
||||||
golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
|
golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4=
|
||||||
|
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
|
||||||
|
|||||||
979
internal/gui/app.go
Normal file
979
internal/gui/app.go
Normal file
@@ -0,0 +1,979 @@
|
|||||||
|
package gui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"image"
|
||||||
|
"image/color"
|
||||||
|
"net/url"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gioui.org/app"
|
||||||
|
"gioui.org/layout"
|
||||||
|
"gioui.org/op"
|
||||||
|
"gioui.org/op/clip"
|
||||||
|
"gioui.org/op/paint"
|
||||||
|
"gioui.org/text"
|
||||||
|
"gioui.org/unit"
|
||||||
|
"gioui.org/widget"
|
||||||
|
"gioui.org/widget/material"
|
||||||
|
"vpn-client/internal/config"
|
||||||
|
"vpn-client/internal/subscription"
|
||||||
|
"vpn-client/internal/vless"
|
||||||
|
"vpn-client/internal/vpn"
|
||||||
|
)
|
||||||
|
|
||||||
|
type tabID string
|
||||||
|
|
||||||
|
const (
|
||||||
|
tabDashboard tabID = "dashboard"
|
||||||
|
tabVLESS tabID = "vless"
|
||||||
|
tabSubscriptions tabID = "subscriptions"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
bgColor = rgb(0x0A, 0x0F, 0x1F)
|
||||||
|
panelColor = rgb(0x12, 0x19, 0x2E)
|
||||||
|
panelAltColor = rgb(0x16, 0x22, 0x3B)
|
||||||
|
accentColor = rgb(0x58, 0xE1, 0xC1)
|
||||||
|
accentSoftColor = rgb(0x5E, 0x9D, 0xFF)
|
||||||
|
textColor = rgb(0xF4, 0xF7, 0xFB)
|
||||||
|
mutedColor = rgb(0x9C, 0xA9, 0xC2)
|
||||||
|
dangerColor = rgb(0xFF, 0x7C, 0x7C)
|
||||||
|
warningColor = rgb(0xFF, 0xC8, 0x57)
|
||||||
|
successColor = rgb(0x6E, 0xF0, 0xAE)
|
||||||
|
borderColor = rgb(0x24, 0x31, 0x4D)
|
||||||
|
)
|
||||||
|
|
||||||
|
type ui struct {
|
||||||
|
window *app.Window
|
||||||
|
theme *material.Theme
|
||||||
|
|
||||||
|
tab tabID
|
||||||
|
|
||||||
|
statusState *config.ConnectionState
|
||||||
|
configs *config.Configs
|
||||||
|
subs *config.Subscriptions
|
||||||
|
|
||||||
|
message string
|
||||||
|
messageColor color.NRGBA
|
||||||
|
busy bool
|
||||||
|
|
||||||
|
async chan func()
|
||||||
|
|
||||||
|
tabButtons map[tabID]*widget.Clickable
|
||||||
|
|
||||||
|
refreshBtn widget.Clickable
|
||||||
|
disconnectBtn widget.Clickable
|
||||||
|
openLogsBtn widget.Clickable
|
||||||
|
addConfigBtn widget.Clickable
|
||||||
|
addSubBtn widget.Clickable
|
||||||
|
testURLBtn widget.Clickable
|
||||||
|
|
||||||
|
vlessConnectBtns []widget.Clickable
|
||||||
|
vlessDeleteBtns []widget.Clickable
|
||||||
|
vlessPingBtns []widget.Clickable
|
||||||
|
|
||||||
|
subUpdateBtns []widget.Clickable
|
||||||
|
subDeleteBtns []widget.Clickable
|
||||||
|
|
||||||
|
contentList layout.List
|
||||||
|
vlessList layout.List
|
||||||
|
subList layout.List
|
||||||
|
|
||||||
|
configNameEditor widget.Editor
|
||||||
|
configURLEditor widget.Editor
|
||||||
|
subNameEditor widget.Editor
|
||||||
|
subURLEditor widget.Editor
|
||||||
|
testURLEditor widget.Editor
|
||||||
|
}
|
||||||
|
|
||||||
|
func Run(window *app.Window) error {
|
||||||
|
model := newUI(window)
|
||||||
|
|
||||||
|
var ops op.Ops
|
||||||
|
for {
|
||||||
|
switch evt := window.Event().(type) {
|
||||||
|
case app.DestroyEvent:
|
||||||
|
return evt.Err
|
||||||
|
case app.FrameEvent:
|
||||||
|
model.applyAsync()
|
||||||
|
gtx := app.NewContext(&ops, evt)
|
||||||
|
model.handleClicks(gtx)
|
||||||
|
model.layout(gtx)
|
||||||
|
evt.Frame(gtx.Ops)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newUI(window *app.Window) *ui {
|
||||||
|
th := material.NewTheme()
|
||||||
|
th.Palette = material.Palette{
|
||||||
|
Bg: bgColor,
|
||||||
|
Fg: textColor,
|
||||||
|
ContrastBg: accentSoftColor,
|
||||||
|
ContrastFg: textColor,
|
||||||
|
}
|
||||||
|
|
||||||
|
model := &ui{
|
||||||
|
window: window,
|
||||||
|
theme: th,
|
||||||
|
tab: tabDashboard,
|
||||||
|
async: make(chan func(), 128),
|
||||||
|
tabButtons: map[tabID]*widget.Clickable{
|
||||||
|
tabDashboard: new(widget.Clickable),
|
||||||
|
tabVLESS: new(widget.Clickable),
|
||||||
|
tabSubscriptions: new(widget.Clickable),
|
||||||
|
},
|
||||||
|
contentList: layout.List{Axis: layout.Vertical},
|
||||||
|
vlessList: layout.List{Axis: layout.Vertical},
|
||||||
|
subList: layout.List{Axis: layout.Vertical},
|
||||||
|
}
|
||||||
|
|
||||||
|
model.configNameEditor.SingleLine = true
|
||||||
|
model.configURLEditor.SingleLine = true
|
||||||
|
model.subNameEditor.SingleLine = true
|
||||||
|
model.subURLEditor.SingleLine = true
|
||||||
|
model.testURLEditor.SingleLine = true
|
||||||
|
|
||||||
|
model.configNameEditor.Submit = true
|
||||||
|
model.configURLEditor.Submit = true
|
||||||
|
model.subNameEditor.Submit = true
|
||||||
|
model.subURLEditor.Submit = true
|
||||||
|
model.testURLEditor.Submit = true
|
||||||
|
|
||||||
|
model.configURLEditor.SetText("vless://")
|
||||||
|
model.testURLEditor.SetText("vless://")
|
||||||
|
model.refresh()
|
||||||
|
|
||||||
|
return model
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *ui) applyAsync() {
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case fn := <-u.async:
|
||||||
|
fn()
|
||||||
|
default:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *ui) handleClicks(gtx layout.Context) {
|
||||||
|
for id, btn := range u.tabButtons {
|
||||||
|
for btn.Clicked(gtx) {
|
||||||
|
u.tab = id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for u.refreshBtn.Clicked(gtx) {
|
||||||
|
u.refresh()
|
||||||
|
}
|
||||||
|
for u.disconnectBtn.Clicked(gtx) {
|
||||||
|
u.runAction("Disconnecting...", func() error {
|
||||||
|
return vpn.Disconnect(config.LogsDir)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
for u.openLogsBtn.Clicked(gtx) {
|
||||||
|
u.openLogs()
|
||||||
|
}
|
||||||
|
for u.addConfigBtn.Clicked(gtx) {
|
||||||
|
u.addVLESSConfig()
|
||||||
|
}
|
||||||
|
for u.addSubBtn.Clicked(gtx) {
|
||||||
|
u.addSubscription()
|
||||||
|
}
|
||||||
|
for u.testURLBtn.Clicked(gtx) {
|
||||||
|
u.testURL()
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := range u.vlessConnectBtns {
|
||||||
|
for u.vlessConnectBtns[i].Clicked(gtx) {
|
||||||
|
if i < len(u.configs.VLESS) {
|
||||||
|
name := u.configs.VLESS[i].Name
|
||||||
|
u.runAction("Connecting to "+name+"...", func() error {
|
||||||
|
return vless.Connect(name, config.LogsDir, config.XrayDir)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for u.vlessDeleteBtns[i].Clicked(gtx) {
|
||||||
|
if i < len(u.configs.VLESS) {
|
||||||
|
name := u.configs.VLESS[i].Name
|
||||||
|
u.deleteVLESSConfig(name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for u.vlessPingBtns[i].Clicked(gtx) {
|
||||||
|
if i < len(u.configs.VLESS) {
|
||||||
|
cfg := u.configs.VLESS[i]
|
||||||
|
u.runAction("Testing "+cfg.Name+"...", func() error {
|
||||||
|
ok, latency, err := vless.PingServer(cfg.URL, 4*time.Second)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("server is unreachable")
|
||||||
|
}
|
||||||
|
return fmt.Errorf("latency %.0f ms", latency)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := range u.subUpdateBtns {
|
||||||
|
for u.subUpdateBtns[i].Clicked(gtx) {
|
||||||
|
if i < len(u.subs.Subscriptions) {
|
||||||
|
name := u.subs.Subscriptions[i].Name
|
||||||
|
u.runAction("Updating "+name+"...", func() error {
|
||||||
|
return subscription.UpdateSubscription(name, config.LogsDir)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for u.subDeleteBtns[i].Clicked(gtx) {
|
||||||
|
if i < len(u.subs.Subscriptions) {
|
||||||
|
name := u.subs.Subscriptions[i].Name
|
||||||
|
u.deleteSubscription(name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *ui) runAction(progress string, fn func() error) {
|
||||||
|
if u.busy {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
u.busy = true
|
||||||
|
u.message = progress
|
||||||
|
u.messageColor = warningColor
|
||||||
|
u.window.Invalidate()
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
err := fn()
|
||||||
|
u.async <- func() {
|
||||||
|
u.busy = false
|
||||||
|
if err != nil {
|
||||||
|
if strings.HasPrefix(err.Error(), "latency ") {
|
||||||
|
u.message = err.Error()
|
||||||
|
u.messageColor = successColor
|
||||||
|
} else {
|
||||||
|
u.message = err.Error()
|
||||||
|
u.messageColor = dangerColor
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
u.message = "Action completed."
|
||||||
|
u.messageColor = successColor
|
||||||
|
}
|
||||||
|
u.refresh()
|
||||||
|
u.window.Invalidate()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *ui) refresh() {
|
||||||
|
state, _ := vpn.GetStatus()
|
||||||
|
cfgs, err := config.LoadConfigs()
|
||||||
|
if err != nil || cfgs == nil {
|
||||||
|
cfgs = &config.Configs{}
|
||||||
|
}
|
||||||
|
subs, err := config.LoadSubscriptions()
|
||||||
|
if err != nil || subs == nil {
|
||||||
|
subs = &config.Subscriptions{}
|
||||||
|
}
|
||||||
|
|
||||||
|
u.statusState = state
|
||||||
|
u.configs = cfgs
|
||||||
|
u.subs = subs
|
||||||
|
u.ensureButtons()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *ui) ensureButtons() {
|
||||||
|
u.vlessConnectBtns = ensureClickables(u.vlessConnectBtns, len(u.configs.VLESS))
|
||||||
|
u.vlessDeleteBtns = ensureClickables(u.vlessDeleteBtns, len(u.configs.VLESS))
|
||||||
|
u.vlessPingBtns = ensureClickables(u.vlessPingBtns, len(u.configs.VLESS))
|
||||||
|
u.subUpdateBtns = ensureClickables(u.subUpdateBtns, len(u.subs.Subscriptions))
|
||||||
|
u.subDeleteBtns = ensureClickables(u.subDeleteBtns, len(u.subs.Subscriptions))
|
||||||
|
}
|
||||||
|
|
||||||
|
func ensureClickables(list []widget.Clickable, count int) []widget.Clickable {
|
||||||
|
if len(list) >= count {
|
||||||
|
return list[:count]
|
||||||
|
}
|
||||||
|
return append(list, make([]widget.Clickable, count-len(list))...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *ui) addVLESSConfig() {
|
||||||
|
name := strings.TrimSpace(u.configNameEditor.Text())
|
||||||
|
rawURL := strings.TrimSpace(u.configURLEditor.Text())
|
||||||
|
if name == "" || rawURL == "" {
|
||||||
|
u.message = "Name and VLESS URL are required."
|
||||||
|
u.messageColor = dangerColor
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := url.Parse(rawURL); err != nil {
|
||||||
|
u.message = "The VLESS URL is not valid."
|
||||||
|
u.messageColor = dangerColor
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
cfgs, err := config.LoadConfigs()
|
||||||
|
if err != nil {
|
||||||
|
u.message = err.Error()
|
||||||
|
u.messageColor = dangerColor
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, item := range cfgs.VLESS {
|
||||||
|
if item.Name == name {
|
||||||
|
u.message = "A config with this name already exists."
|
||||||
|
u.messageColor = dangerColor
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cfgs.VLESS = append(cfgs.VLESS, config.VLESSConfig{
|
||||||
|
Name: name,
|
||||||
|
URL: rawURL,
|
||||||
|
Protocol: "VLESS",
|
||||||
|
})
|
||||||
|
if err := config.SaveConfigs(cfgs); err != nil {
|
||||||
|
u.message = err.Error()
|
||||||
|
u.messageColor = dangerColor
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
u.configNameEditor.SetText("")
|
||||||
|
u.configURLEditor.SetText("vless://")
|
||||||
|
u.message = "VLESS config added."
|
||||||
|
u.messageColor = successColor
|
||||||
|
u.refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *ui) deleteVLESSConfig(name string) {
|
||||||
|
cfgs, err := config.LoadConfigs()
|
||||||
|
if err != nil {
|
||||||
|
u.message = err.Error()
|
||||||
|
u.messageColor = dangerColor
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
filtered := cfgs.VLESS[:0]
|
||||||
|
found := false
|
||||||
|
for _, item := range cfgs.VLESS {
|
||||||
|
if item.Name == name {
|
||||||
|
found = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
filtered = append(filtered, item)
|
||||||
|
}
|
||||||
|
cfgs.VLESS = filtered
|
||||||
|
if !found {
|
||||||
|
u.message = "Config was already removed."
|
||||||
|
u.messageColor = warningColor
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := config.SaveConfigs(cfgs); err != nil {
|
||||||
|
u.message = err.Error()
|
||||||
|
u.messageColor = dangerColor
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
u.message = "Config removed."
|
||||||
|
u.messageColor = successColor
|
||||||
|
u.refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *ui) addSubscription() {
|
||||||
|
name := strings.TrimSpace(u.subNameEditor.Text())
|
||||||
|
rawURL := strings.TrimSpace(u.subURLEditor.Text())
|
||||||
|
if name == "" || rawURL == "" {
|
||||||
|
u.message = "Subscription name and URL are required."
|
||||||
|
u.messageColor = dangerColor
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := url.Parse(rawURL); err != nil {
|
||||||
|
u.message = "The subscription URL is not valid."
|
||||||
|
u.messageColor = dangerColor
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
subs, err := config.LoadSubscriptions()
|
||||||
|
if err != nil {
|
||||||
|
u.message = err.Error()
|
||||||
|
u.messageColor = dangerColor
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, item := range subs.Subscriptions {
|
||||||
|
if item.Name == name {
|
||||||
|
u.message = "A subscription with this name already exists."
|
||||||
|
u.messageColor = dangerColor
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
subs.Subscriptions = append(subs.Subscriptions, config.Subscription{
|
||||||
|
Name: name,
|
||||||
|
URL: rawURL,
|
||||||
|
})
|
||||||
|
if err := config.SaveSubscriptions(subs); err != nil {
|
||||||
|
u.message = err.Error()
|
||||||
|
u.messageColor = dangerColor
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
u.subNameEditor.SetText("")
|
||||||
|
u.subURLEditor.SetText("")
|
||||||
|
u.message = "Subscription added."
|
||||||
|
u.messageColor = successColor
|
||||||
|
u.refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *ui) deleteSubscription(name string) {
|
||||||
|
subs, err := config.LoadSubscriptions()
|
||||||
|
if err != nil {
|
||||||
|
u.message = err.Error()
|
||||||
|
u.messageColor = dangerColor
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
filtered := subs.Subscriptions[:0]
|
||||||
|
found := false
|
||||||
|
for _, item := range subs.Subscriptions {
|
||||||
|
if item.Name == name {
|
||||||
|
found = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
filtered = append(filtered, item)
|
||||||
|
}
|
||||||
|
subs.Subscriptions = filtered
|
||||||
|
if !found {
|
||||||
|
u.message = "Subscription was already removed."
|
||||||
|
u.messageColor = warningColor
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := config.SaveSubscriptions(subs); err != nil {
|
||||||
|
u.message = err.Error()
|
||||||
|
u.messageColor = dangerColor
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
u.message = "Subscription removed."
|
||||||
|
u.messageColor = successColor
|
||||||
|
u.refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *ui) testURL() {
|
||||||
|
rawURL := strings.TrimSpace(u.testURLEditor.Text())
|
||||||
|
if rawURL == "" {
|
||||||
|
u.message = "Paste a VLESS URL to test."
|
||||||
|
u.messageColor = dangerColor
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
u.runAction("Testing custom URL...", func() error {
|
||||||
|
ok, latency, err := vless.PingServer(rawURL, 4*time.Second)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("server is unreachable")
|
||||||
|
}
|
||||||
|
return fmt.Errorf("latency %.0f ms", latency)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *ui) openLogs() {
|
||||||
|
path := filepath.Clean(config.LogsDir)
|
||||||
|
var cmd *exec.Cmd
|
||||||
|
switch runtime.GOOS {
|
||||||
|
case "windows":
|
||||||
|
cmd = exec.Command("explorer", path)
|
||||||
|
case "darwin":
|
||||||
|
cmd = exec.Command("open", path)
|
||||||
|
default:
|
||||||
|
cmd = exec.Command("xdg-open", path)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := cmd.Start(); err != nil {
|
||||||
|
u.message = "Could not open logs folder."
|
||||||
|
u.messageColor = dangerColor
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
u.message = "Logs folder opened."
|
||||||
|
u.messageColor = successColor
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *ui) layout(gtx layout.Context) layout.Dimensions {
|
||||||
|
fill(gtx, bgColor)
|
||||||
|
|
||||||
|
return layout.UniformInset(unit.Dp(24)).Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return u.contentList.Layout(gtx, 1, func(gtx layout.Context, _ int) layout.Dimensions {
|
||||||
|
return layout.Flex{Axis: layout.Vertical}.Layout(
|
||||||
|
gtx,
|
||||||
|
layout.Rigid(u.layoutHeader),
|
||||||
|
layout.Rigid(spacerH(18)),
|
||||||
|
layout.Rigid(u.layoutStatusBanner),
|
||||||
|
layout.Rigid(spacerH(18)),
|
||||||
|
layout.Flexed(1, u.layoutBody),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *ui) layoutHeader(gtx layout.Context) layout.Dimensions {
|
||||||
|
return layout.Flex{Spacing: layout.SpaceBetween, Alignment: layout.Middle}.Layout(
|
||||||
|
gtx,
|
||||||
|
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return layout.Flex{Axis: layout.Vertical}.Layout(
|
||||||
|
gtx,
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
lbl := material.Label(u.theme, unit.Sp(15), "GIO DESKTOP")
|
||||||
|
lbl.Color = accentColor
|
||||||
|
return lbl.Layout(gtx)
|
||||||
|
}),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
lbl := material.H3(u.theme, "Go VPN Client")
|
||||||
|
lbl.Color = textColor
|
||||||
|
return lbl.Layout(gtx)
|
||||||
|
}),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
lbl := material.Body1(u.theme, "A modern control surface for VLESS, subscriptions, and live connection state.")
|
||||||
|
lbl.Color = mutedColor
|
||||||
|
return lbl.Layout(gtx)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return layout.Flex{Alignment: layout.Middle}.Layout(
|
||||||
|
gtx,
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return u.layoutButton(gtx, &u.refreshBtn, "Refresh", accentSoftColor)
|
||||||
|
}),
|
||||||
|
layout.Rigid(spacerW(10)),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return u.layoutButton(gtx, &u.openLogsBtn, "Logs", panelAltColor)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *ui) layoutStatusBanner(gtx layout.Context) layout.Dimensions {
|
||||||
|
return u.card(gtx, panelColor, func(gtx layout.Context) layout.Dimensions {
|
||||||
|
inset := layout.UniformInset(unit.Dp(18))
|
||||||
|
return inset.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||||
|
connected := u.statusState != nil && u.statusState.Connected
|
||||||
|
statusText := "Offline"
|
||||||
|
statusColor := warningColor
|
||||||
|
details := "No active tunnel."
|
||||||
|
|
||||||
|
if connected {
|
||||||
|
statusText = "Connected"
|
||||||
|
statusColor = successColor
|
||||||
|
details = fmt.Sprintf("%s via %s", u.statusState.ConfigName, strings.ToUpper(u.statusState.ConfigType))
|
||||||
|
}
|
||||||
|
|
||||||
|
return layout.Flex{Spacing: layout.SpaceBetween, Alignment: layout.Middle}.Layout(
|
||||||
|
gtx,
|
||||||
|
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return layout.Flex{Axis: layout.Vertical}.Layout(
|
||||||
|
gtx,
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
lbl := material.Body1(u.theme, "Connection status")
|
||||||
|
lbl.Color = mutedColor
|
||||||
|
return lbl.Layout(gtx)
|
||||||
|
}),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
lbl := material.H4(u.theme, statusText)
|
||||||
|
lbl.Color = statusColor
|
||||||
|
return lbl.Layout(gtx)
|
||||||
|
}),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
lbl := material.Body1(u.theme, details)
|
||||||
|
lbl.Color = textColor
|
||||||
|
return lbl.Layout(gtx)
|
||||||
|
}),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
msg := u.message
|
||||||
|
if msg == "" {
|
||||||
|
msg = "Ready."
|
||||||
|
}
|
||||||
|
lbl := material.Body2(u.theme, msg)
|
||||||
|
lbl.Color = u.messageColor
|
||||||
|
if lbl.Color == (color.NRGBA{}) {
|
||||||
|
lbl.Color = mutedColor
|
||||||
|
}
|
||||||
|
return lbl.Layout(gtx)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return u.layoutButton(gtx, &u.disconnectBtn, "Disconnect", dangerColor)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *ui) layoutBody(gtx layout.Context) layout.Dimensions {
|
||||||
|
return layout.Flex{Axis: layout.Vertical}.Layout(
|
||||||
|
gtx,
|
||||||
|
layout.Rigid(u.layoutTabs),
|
||||||
|
layout.Rigid(spacerH(16)),
|
||||||
|
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
|
||||||
|
switch u.tab {
|
||||||
|
case tabVLESS:
|
||||||
|
return u.layoutVLESS(gtx)
|
||||||
|
case tabSubscriptions:
|
||||||
|
return u.layoutSubscriptions(gtx)
|
||||||
|
default:
|
||||||
|
return u.layoutDashboard(gtx)
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *ui) layoutTabs(gtx layout.Context) layout.Dimensions {
|
||||||
|
return layout.Flex{}.Layout(
|
||||||
|
gtx,
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return u.layoutTab(gtx, tabDashboard, "Overview")
|
||||||
|
}),
|
||||||
|
layout.Rigid(spacerW(10)),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return u.layoutTab(gtx, tabVLESS, "VLESS")
|
||||||
|
}),
|
||||||
|
layout.Rigid(spacerW(10)),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return u.layoutTab(gtx, tabSubscriptions, "Subscriptions")
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *ui) layoutTab(gtx layout.Context, id tabID, label string) layout.Dimensions {
|
||||||
|
bg := panelAltColor
|
||||||
|
fg := mutedColor
|
||||||
|
if u.tab == id {
|
||||||
|
bg = accentSoftColor
|
||||||
|
fg = textColor
|
||||||
|
}
|
||||||
|
return u.buttonLike(gtx, u.tabButtons[id], label, bg, fg, unit.Dp(16), unit.Dp(10))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *ui) layoutDashboard(gtx layout.Context) layout.Dimensions {
|
||||||
|
return layout.Flex{Axis: layout.Vertical}.Layout(
|
||||||
|
gtx,
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return layout.Flex{Spacing: layout.SpaceBetween}.Layout(
|
||||||
|
gtx,
|
||||||
|
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return u.metricCard(gtx, "VLESS configs", fmt.Sprintf("%d", len(u.configs.VLESS)), accentColor)
|
||||||
|
}),
|
||||||
|
layout.Rigid(spacerW(12)),
|
||||||
|
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return u.metricCard(gtx, "Subscriptions", fmt.Sprintf("%d", len(u.subs.Subscriptions)), accentSoftColor)
|
||||||
|
}),
|
||||||
|
layout.Rigid(spacerW(12)),
|
||||||
|
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
|
||||||
|
state := "Offline"
|
||||||
|
if u.statusState != nil && u.statusState.Connected {
|
||||||
|
state = strings.ToUpper(u.statusState.ConfigType)
|
||||||
|
}
|
||||||
|
return u.metricCard(gtx, "Active mode", state, successColor)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
layout.Rigid(spacerH(16)),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return u.card(gtx, panelColor, func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return layout.UniformInset(unit.Dp(18)).Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return layout.Flex{Axis: layout.Vertical}.Layout(
|
||||||
|
gtx,
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
lbl := material.H5(u.theme, "Quick latency probe")
|
||||||
|
lbl.Color = textColor
|
||||||
|
return lbl.Layout(gtx)
|
||||||
|
}),
|
||||||
|
layout.Rigid(spacerH(10)),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return u.layoutEditor(gtx, &u.testURLEditor, "Paste a VLESS URL")
|
||||||
|
}),
|
||||||
|
layout.Rigid(spacerH(12)),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return u.layoutButton(gtx, &u.testURLBtn, "Test URL", accentColor)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *ui) layoutVLESS(gtx layout.Context) layout.Dimensions {
|
||||||
|
return layout.Flex{Axis: layout.Vertical}.Layout(
|
||||||
|
gtx,
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return u.card(gtx, panelColor, func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return layout.UniformInset(unit.Dp(18)).Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return layout.Flex{Axis: layout.Vertical}.Layout(
|
||||||
|
gtx,
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
lbl := material.H5(u.theme, "Add VLESS config")
|
||||||
|
lbl.Color = textColor
|
||||||
|
return lbl.Layout(gtx)
|
||||||
|
}),
|
||||||
|
layout.Rigid(spacerH(10)),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return u.layoutEditor(gtx, &u.configNameEditor, "Display name")
|
||||||
|
}),
|
||||||
|
layout.Rigid(spacerH(10)),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return u.layoutEditor(gtx, &u.configURLEditor, "VLESS URL")
|
||||||
|
}),
|
||||||
|
layout.Rigid(spacerH(12)),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return u.layoutButton(gtx, &u.addConfigBtn, "Save config", accentColor)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
layout.Rigid(spacerH(16)),
|
||||||
|
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return u.card(gtx, panelColor, func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return layout.UniformInset(unit.Dp(18)).Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return u.vlessList.Layout(gtx, len(u.configs.VLESS), func(gtx layout.Context, i int) layout.Dimensions {
|
||||||
|
cfg := u.configs.VLESS[i]
|
||||||
|
return layout.Inset{Bottom: unit.Dp(12)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return u.card(gtx, panelAltColor, func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return layout.UniformInset(unit.Dp(16)).Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return layout.Flex{Axis: layout.Vertical}.Layout(
|
||||||
|
gtx,
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
lbl := material.H6(u.theme, cfg.Name)
|
||||||
|
lbl.Color = textColor
|
||||||
|
return lbl.Layout(gtx)
|
||||||
|
}),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
meta := material.Body2(u.theme, cfg.Protocol)
|
||||||
|
meta.Color = accentColor
|
||||||
|
return meta.Layout(gtx)
|
||||||
|
}),
|
||||||
|
layout.Rigid(spacerH(6)),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
value := material.Body2(u.theme, trimForPreview(cfg.URL, 96))
|
||||||
|
value.Color = mutedColor
|
||||||
|
return value.Layout(gtx)
|
||||||
|
}),
|
||||||
|
layout.Rigid(spacerH(12)),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return layout.Flex{}.Layout(
|
||||||
|
gtx,
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return u.layoutButton(gtx, &u.vlessConnectBtns[i], "Connect", accentColor)
|
||||||
|
}),
|
||||||
|
layout.Rigid(spacerW(8)),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return u.layoutButton(gtx, &u.vlessPingBtns[i], "Ping", accentSoftColor)
|
||||||
|
}),
|
||||||
|
layout.Rigid(spacerW(8)),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return u.layoutButton(gtx, &u.vlessDeleteBtns[i], "Delete", dangerColor)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *ui) layoutSubscriptions(gtx layout.Context) layout.Dimensions {
|
||||||
|
return layout.Flex{Axis: layout.Vertical}.Layout(
|
||||||
|
gtx,
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return u.card(gtx, panelColor, func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return layout.UniformInset(unit.Dp(18)).Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return layout.Flex{Axis: layout.Vertical}.Layout(
|
||||||
|
gtx,
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
lbl := material.H5(u.theme, "Add subscription")
|
||||||
|
lbl.Color = textColor
|
||||||
|
return lbl.Layout(gtx)
|
||||||
|
}),
|
||||||
|
layout.Rigid(spacerH(10)),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return u.layoutEditor(gtx, &u.subNameEditor, "Subscription name")
|
||||||
|
}),
|
||||||
|
layout.Rigid(spacerH(10)),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return u.layoutEditor(gtx, &u.subURLEditor, "Subscription URL")
|
||||||
|
}),
|
||||||
|
layout.Rigid(spacerH(12)),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return u.layoutButton(gtx, &u.addSubBtn, "Save subscription", accentColor)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
layout.Rigid(spacerH(16)),
|
||||||
|
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return u.card(gtx, panelColor, func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return layout.UniformInset(unit.Dp(18)).Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return u.subList.Layout(gtx, len(u.subs.Subscriptions), func(gtx layout.Context, i int) layout.Dimensions {
|
||||||
|
sub := u.subs.Subscriptions[i]
|
||||||
|
return layout.Inset{Bottom: unit.Dp(12)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return u.card(gtx, panelAltColor, func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return layout.UniformInset(unit.Dp(16)).Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return layout.Flex{Axis: layout.Vertical}.Layout(
|
||||||
|
gtx,
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
lbl := material.H6(u.theme, sub.Name)
|
||||||
|
lbl.Color = textColor
|
||||||
|
return lbl.Layout(gtx)
|
||||||
|
}),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
value := material.Body2(u.theme, trimForPreview(sub.URL, 92))
|
||||||
|
value.Color = mutedColor
|
||||||
|
return value.Layout(gtx)
|
||||||
|
}),
|
||||||
|
layout.Rigid(spacerH(12)),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return layout.Flex{}.Layout(
|
||||||
|
gtx,
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return u.layoutButton(gtx, &u.subUpdateBtns[i], "Update configs", accentColor)
|
||||||
|
}),
|
||||||
|
layout.Rigid(spacerW(8)),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return u.layoutButton(gtx, &u.subDeleteBtns[i], "Delete", dangerColor)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *ui) metricCard(gtx layout.Context, label, value string, glow color.NRGBA) layout.Dimensions {
|
||||||
|
return u.card(gtx, panelColor, func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return layout.UniformInset(unit.Dp(18)).Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return layout.Flex{Axis: layout.Vertical}.Layout(
|
||||||
|
gtx,
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
lbl := material.Body2(u.theme, strings.ToUpper(label))
|
||||||
|
lbl.Color = mutedColor
|
||||||
|
return lbl.Layout(gtx)
|
||||||
|
}),
|
||||||
|
layout.Rigid(spacerH(8)),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
lbl := material.H4(u.theme, value)
|
||||||
|
lbl.Color = glow
|
||||||
|
return lbl.Layout(gtx)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *ui) layoutEditor(gtx layout.Context, ed *widget.Editor, hint string) layout.Dimensions {
|
||||||
|
return u.card(gtx, panelAltColor, func(gtx layout.Context) layout.Dimensions {
|
||||||
|
border := clip.RRect{
|
||||||
|
Rect: image.Rectangle{Max: gtx.Constraints.Min},
|
||||||
|
SE: 14, SW: 14, NW: 14, NE: 14,
|
||||||
|
}
|
||||||
|
_ = border
|
||||||
|
return layout.UniformInset(unit.Dp(14)).Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return material.Editor(u.theme, ed, hint).Layout(gtx)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *ui) layoutButton(gtx layout.Context, btn *widget.Clickable, label string, bg color.NRGBA) layout.Dimensions {
|
||||||
|
return u.buttonLike(gtx, btn, label, bg, textColor, unit.Dp(14), unit.Dp(10))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *ui) buttonLike(gtx layout.Context, btn *widget.Clickable, label string, bg, fg color.NRGBA, hPad, vPad unit.Dp) layout.Dimensions {
|
||||||
|
return btn.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||||
|
min := image.Pt(gtx.Dp(unit.Dp(96)), gtx.Dp(unit.Dp(40)))
|
||||||
|
if gtx.Constraints.Min.X < min.X {
|
||||||
|
gtx.Constraints.Min.X = min.X
|
||||||
|
}
|
||||||
|
if gtx.Constraints.Min.Y < min.Y {
|
||||||
|
gtx.Constraints.Min.Y = min.Y
|
||||||
|
}
|
||||||
|
|
||||||
|
rr := gtx.Dp(unit.Dp(14))
|
||||||
|
defer clip.RRect{Rect: image.Rectangle{Max: gtx.Constraints.Min}, NW: rr, NE: rr, SW: rr, SE: rr}.Push(gtx.Ops).Pop()
|
||||||
|
paint.Fill(gtx.Ops, bg)
|
||||||
|
return layout.Inset{
|
||||||
|
Top: vPad,
|
||||||
|
Bottom: vPad,
|
||||||
|
Left: hPad,
|
||||||
|
Right: hPad,
|
||||||
|
}.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||||
|
lbl := material.Label(u.theme, unit.Sp(14), label)
|
||||||
|
lbl.Color = fg
|
||||||
|
lbl.Alignment = text.Middle
|
||||||
|
return lbl.Layout(gtx)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *ui) card(gtx layout.Context, bg color.NRGBA, content layout.Widget) layout.Dimensions {
|
||||||
|
return layout.Stack{}.Layout(
|
||||||
|
gtx,
|
||||||
|
layout.Expanded(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
rr := gtx.Dp(unit.Dp(20))
|
||||||
|
defer clip.RRect{Rect: image.Rectangle{Max: gtx.Constraints.Max}, NW: rr, NE: rr, SW: rr, SE: rr}.Push(gtx.Ops).Pop()
|
||||||
|
paint.Fill(gtx.Ops, bg)
|
||||||
|
return layout.Dimensions{Size: gtx.Constraints.Max}
|
||||||
|
}),
|
||||||
|
layout.Stacked(content),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func fill(gtx layout.Context, c color.NRGBA) {
|
||||||
|
defer clip.Rect{Max: gtx.Constraints.Max}.Push(gtx.Ops).Pop()
|
||||||
|
paint.Fill(gtx.Ops, c)
|
||||||
|
}
|
||||||
|
|
||||||
|
func spacerH(v unit.Dp) layout.Widget {
|
||||||
|
return func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return layout.Spacer{Height: v}.Layout(gtx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func spacerW(v unit.Dp) layout.Widget {
|
||||||
|
return func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return layout.Spacer{Width: v}.Layout(gtx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func trimForPreview(value string, limit int) string {
|
||||||
|
if len(value) <= limit {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
return value[:limit-3] + "..."
|
||||||
|
}
|
||||||
|
|
||||||
|
func rgb(r, g, b uint8) color.NRGBA {
|
||||||
|
return color.NRGBA{R: r, G: g, B: b, A: 255}
|
||||||
|
}
|
||||||
33
main.go
33
main.go
@@ -1,23 +1,32 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
"vpn-client/internal/cli"
|
"gioui.org/app"
|
||||||
"vpn-client/internal/config"
|
"vpn-client/internal/config"
|
||||||
|
"vpn-client/internal/gui"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
// Инициализация конфигурации
|
go func() {
|
||||||
if err := config.Init(); err != nil {
|
if err := config.Init(); err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "Ошибка инициализации: %v\n", err)
|
log.Printf("config init failed: %v", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Запуск CLI
|
window := new(app.Window)
|
||||||
if err := cli.Run(); err != nil {
|
window.Option(
|
||||||
fmt.Fprintf(os.Stderr, "Ошибка: %v\n", err)
|
app.Title("Go VPN Client"),
|
||||||
os.Exit(1)
|
)
|
||||||
}
|
|
||||||
|
if err := gui.Run(window); err != nil {
|
||||||
|
log.Printf("gui failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
os.Exit(0)
|
||||||
|
}()
|
||||||
|
|
||||||
|
app.Main()
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user