From 27d3511bbe970e00cb6d0fd2067e123c7157bff7 Mon Sep 17 00:00:00 2001 From: administrator Date: Mon, 6 Apr 2026 08:30:49 +0700 Subject: [PATCH] Add Gio desktop GUI shell --- cmd/cli/main.go | 21 + go.mod | 14 +- go.sum | 25 +- internal/gui/app.go | 979 ++++++++++++++++++++++++++++++++++++++++++++ main.go | 33 +- 5 files changed, 1056 insertions(+), 16 deletions(-) create mode 100644 cmd/cli/main.go create mode 100644 internal/gui/app.go diff --git a/cmd/cli/main.go b/cmd/cli/main.go new file mode 100644 index 0000000..3cd07c1 --- /dev/null +++ b/cmd/cli/main.go @@ -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) + } +} diff --git a/go.mod b/go.mod index 766b48c..ee544b2 100644 --- a/go.mod +++ b/go.mod @@ -2,10 +2,20 @@ module vpn-client 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 ( + 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-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 ) diff --git a/go.sum b/go.sum index c9fe800..13f58ee 100644 --- a/go.sum +++ b/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/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/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.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU= -golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI= +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= diff --git a/internal/gui/app.go b/internal/gui/app.go new file mode 100644 index 0000000..9f48bac --- /dev/null +++ b/internal/gui/app.go @@ -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} +} diff --git a/main.go b/main.go index 028bb54..a922c68 100644 --- a/main.go +++ b/main.go @@ -1,23 +1,32 @@ package main import ( - "fmt" + "log" "os" - "vpn-client/internal/cli" + "gioui.org/app" "vpn-client/internal/config" + "vpn-client/internal/gui" ) func main() { - // Инициализация конфигурации - if err := config.Init(); err != nil { - fmt.Fprintf(os.Stderr, "Ошибка инициализации: %v\n", err) - os.Exit(1) - } + go func() { + if err := config.Init(); err != nil { + log.Printf("config init failed: %v", err) + os.Exit(1) + } - // Запуск CLI - if err := cli.Run(); err != nil { - fmt.Fprintf(os.Stderr, "Ошибка: %v\n", err) - os.Exit(1) - } + window := new(app.Window) + window.Option( + app.Title("Go VPN Client"), + ) + + if err := gui.Run(window); err != nil { + log.Printf("gui failed: %v", err) + } + + os.Exit(0) + }() + + app.Main() }