diff --git a/internal/gui/advanced.go b/internal/gui/advanced.go new file mode 100644 index 0000000..23e5e12 --- /dev/null +++ b/internal/gui/advanced.go @@ -0,0 +1,235 @@ +package gui + +import ( + "fmt" + "sort" + "strings" + "time" + + "gioui.org/layout" + "gioui.org/unit" + "gioui.org/widget/material" + "vpn-client/internal/config" + "vpn-client/internal/vless" + "vpn-client/internal/wireguard" +) + +func (u *ui) addWireGuardManual() { + name := strings.TrimSpace(u.wgNameEditor.Text()) + configText := strings.TrimSpace(u.wgConfigEditor.Text()) + if name == "" || configText == "" { + u.message = "WireGuard name and config text are required." + u.messageColor = dangerColor + return + } + if err := wireguard.AddConfig(name, configText); err != nil { + u.message = err.Error() + u.messageColor = dangerColor + return + } + u.wgNameEditor.SetText("") + u.wgConfigEditor.SetText("") + u.message = "WireGuard config added." + u.messageColor = successColor + u.refresh() +} + +func (u *ui) addWireGuardFromFile() { + name := strings.TrimSpace(u.wgFileNameEditor.Text()) + filePath := strings.TrimSpace(u.wgFilePathEditor.Text()) + if name == "" || filePath == "" { + u.message = "WireGuard name and file path are required." + u.messageColor = dangerColor + return + } + if err := wireguard.AddConfigFromFile(name, filePath); err != nil { + u.message = err.Error() + u.messageColor = dangerColor + return + } + u.wgFileNameEditor.SetText("") + u.wgFilePathEditor.SetText("") + u.message = "WireGuard config imported from file." + u.messageColor = successColor + u.refresh() +} + +func (u *ui) deleteWireGuardConfig(name string) { + if err := wireguard.DeleteConfig(name); err != nil { + u.message = err.Error() + u.messageColor = dangerColor + return + } + u.message = "WireGuard config removed." + u.messageColor = successColor + u.refresh() +} + +func (u *ui) subscriptionConfigs(name string) []config.VLESSConfig { + items := make([]config.VLESSConfig, 0) + for _, cfg := range u.configs.VLESS { + if cfg.Subscription == name { + items = append(items, cfg) + } + } + return items +} + +func (u *ui) subscriptionSummary(name string) string { + items := u.subscriptionConfigs(name) + if len(items) == 0 { + return "No imported configs yet." + } + preview := strings.TrimPrefix(items[0].Name, fmt.Sprintf("[%s] ", name)) + if len(items) == 1 { + return fmt.Sprintf("1 imported config: %s", trimForPreview(preview, 42)) + } + return fmt.Sprintf("%d imported configs. First: %s", len(items), trimForPreview(preview, 36)) +} + +func (u *ui) testSubscriptionConfigs(name string) error { + items := u.subscriptionConfigs(name) + if len(items) == 0 { + return fmt.Errorf("no imported configs found for %s", name) + } + + type result struct { + name string + ping float64 + ok bool + } + results := make([]result, 0, len(items)) + for _, cfg := range items { + ok, ping, _ := vless.PingServer(cfg.URL, 3*time.Second) + results = append(results, result{ + name: strings.TrimPrefix(cfg.Name, fmt.Sprintf("[%s] ", name)), + ping: ping, + ok: ok, + }) + } + + successful := results[:0] + for _, r := range results { + if r.ok { + successful = append(successful, r) + } + } + if len(successful) == 0 { + return fmt.Errorf("all %d configs from %s are unreachable", len(results), name) + } + + sort.Slice(successful, func(i, j int) bool { return successful[i].ping < successful[j].ping }) + best := successful[0] + u.message = fmt.Sprintf("%s: %d/%d reachable, best %s at %.0f ms.", name, len(successful), len(results), trimForPreview(best.name, 34), best.ping) + u.messageColor = successColor + return nil +} + +func (u *ui) layoutWireGuard(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.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 manually") + lbl.Color = textColor + return lbl.Layout(gtx) + }), + layout.Rigid(spacerH(10)), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { return u.layoutEditor(gtx, &u.wgNameEditor, "Config name") }), + layout.Rigid(spacerH(10)), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return u.layoutEditor(gtx, &u.wgConfigEditor, "WireGuard config text") + }), + layout.Rigid(spacerH(12)), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return u.layoutButton(gtx, &u.addWGManualBtn, "Save manual config", accentColor) + }), + ) + }) + }) + }), + layout.Rigid(spacerW(12)), + 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 layout.Flex{Axis: layout.Vertical}.Layout( + gtx, + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + lbl := material.H5(u.theme, "Import from file") + lbl.Color = textColor + return lbl.Layout(gtx) + }), + layout.Rigid(spacerH(10)), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return u.layoutEditor(gtx, &u.wgFileNameEditor, "Config name") + }), + layout.Rigid(spacerH(10)), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return u.layoutEditor(gtx, &u.wgFilePathEditor, "Path to .conf file") + }), + layout.Rigid(spacerH(12)), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return u.layoutButton(gtx, &u.addWGFileBtn, "Import file", accentSoftColor) + }), + ) + }) + }) + }), + ) + }), + 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 { + if len(u.configs.WireGuard) == 0 { + lbl := material.Body1(u.theme, "No WireGuard configs yet.") + lbl.Color = mutedColor + return lbl.Layout(gtx) + } + return u.wgList.Layout(gtx, len(u.configs.WireGuard), func(gtx layout.Context, i int) layout.Dimensions { + cfg := u.configs.WireGuard[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, trimForPreview(cfg.Config, 110)) + meta.Color = mutedColor + return meta.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.wgConnectBtns[i], "Connect", accentColor) + }), + layout.Rigid(spacerW(8)), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return u.layoutButton(gtx, &u.wgDeleteBtns[i], "Delete", dangerColor) + }), + ) + }), + ) + }) + }) + }) + }) + }) + }) + }), + ) +} diff --git a/internal/gui/app.go b/internal/gui/app.go index 9f48bac..942e66b 100644 --- a/internal/gui/app.go +++ b/internal/gui/app.go @@ -24,6 +24,7 @@ import ( "vpn-client/internal/subscription" "vpn-client/internal/vless" "vpn-client/internal/vpn" + "vpn-client/internal/wireguard" ) type tabID string @@ -31,6 +32,7 @@ type tabID string const ( tabDashboard tabID = "dashboard" tabVLESS tabID = "vless" + tabWireGuard tabID = "wireguard" tabSubscriptions tabID = "subscriptions" ) @@ -66,22 +68,29 @@ type ui struct { tabButtons map[tabID]*widget.Clickable - refreshBtn widget.Clickable - disconnectBtn widget.Clickable - openLogsBtn widget.Clickable - addConfigBtn widget.Clickable - addSubBtn widget.Clickable - testURLBtn widget.Clickable + refreshBtn widget.Clickable + disconnectBtn widget.Clickable + openLogsBtn widget.Clickable + addConfigBtn widget.Clickable + addSubBtn widget.Clickable + testURLBtn widget.Clickable + addWGManualBtn widget.Clickable + addWGFileBtn widget.Clickable vlessConnectBtns []widget.Clickable vlessDeleteBtns []widget.Clickable vlessPingBtns []widget.Clickable + wgConnectBtns []widget.Clickable + wgDeleteBtns []widget.Clickable + subUpdateBtns []widget.Clickable subDeleteBtns []widget.Clickable + subTestBtns []widget.Clickable contentList layout.List vlessList layout.List + wgList layout.List subList layout.List configNameEditor widget.Editor @@ -89,6 +98,10 @@ type ui struct { subNameEditor widget.Editor subURLEditor widget.Editor testURLEditor widget.Editor + wgNameEditor widget.Editor + wgConfigEditor widget.Editor + wgFileNameEditor widget.Editor + wgFilePathEditor widget.Editor } func Run(window *app.Window) error { @@ -126,10 +139,12 @@ func newUI(window *app.Window) *ui { tabButtons: map[tabID]*widget.Clickable{ tabDashboard: new(widget.Clickable), tabVLESS: new(widget.Clickable), + tabWireGuard: new(widget.Clickable), tabSubscriptions: new(widget.Clickable), }, contentList: layout.List{Axis: layout.Vertical}, vlessList: layout.List{Axis: layout.Vertical}, + wgList: layout.List{Axis: layout.Vertical}, subList: layout.List{Axis: layout.Vertical}, } @@ -138,12 +153,19 @@ func newUI(window *app.Window) *ui { model.subNameEditor.SingleLine = true model.subURLEditor.SingleLine = true model.testURLEditor.SingleLine = true + model.wgNameEditor.SingleLine = true + model.wgFileNameEditor.SingleLine = true + model.wgFilePathEditor.SingleLine = true model.configNameEditor.Submit = true model.configURLEditor.Submit = true model.subNameEditor.Submit = true model.subURLEditor.Submit = true model.testURLEditor.Submit = true + model.wgNameEditor.Submit = true + model.wgConfigEditor.Submit = true + model.wgFileNameEditor.Submit = true + model.wgFilePathEditor.Submit = true model.configURLEditor.SetText("vless://") model.testURLEditor.SetText("vless://") @@ -190,6 +212,12 @@ func (u *ui) handleClicks(gtx layout.Context) { for u.testURLBtn.Clicked(gtx) { u.testURL() } + for u.addWGManualBtn.Clicked(gtx) { + u.addWireGuardManual() + } + for u.addWGFileBtn.Clicked(gtx) { + u.addWireGuardFromFile() + } for i := range u.vlessConnectBtns { for u.vlessConnectBtns[i].Clicked(gtx) { @@ -223,6 +251,23 @@ func (u *ui) handleClicks(gtx layout.Context) { } } + for i := range u.wgConnectBtns { + for u.wgConnectBtns[i].Clicked(gtx) { + if i < len(u.configs.WireGuard) { + name := u.configs.WireGuard[i].Name + u.runAction("Connecting WireGuard "+name+"...", func() error { + return wireguard.Connect(name, config.LogsDir) + }) + } + } + for u.wgDeleteBtns[i].Clicked(gtx) { + if i < len(u.configs.WireGuard) { + name := u.configs.WireGuard[i].Name + u.deleteWireGuardConfig(name) + } + } + } + for i := range u.subUpdateBtns { for u.subUpdateBtns[i].Clicked(gtx) { if i < len(u.subs.Subscriptions) { @@ -238,6 +283,14 @@ func (u *ui) handleClicks(gtx layout.Context) { u.deleteSubscription(name) } } + for u.subTestBtns[i].Clicked(gtx) { + if i < len(u.subs.Subscriptions) { + name := u.subs.Subscriptions[i].Name + u.runAction("Testing configs from "+name+"...", func() error { + return u.testSubscriptionConfigs(name) + }) + } + } } } @@ -294,8 +347,11 @@ 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.wgConnectBtns = ensureClickables(u.wgConnectBtns, len(u.configs.WireGuard)) + u.wgDeleteBtns = ensureClickables(u.wgDeleteBtns, len(u.configs.WireGuard)) u.subUpdateBtns = ensureClickables(u.subUpdateBtns, len(u.subs.Subscriptions)) u.subDeleteBtns = ensureClickables(u.subDeleteBtns, len(u.subs.Subscriptions)) + u.subTestBtns = ensureClickables(u.subTestBtns, len(u.subs.Subscriptions)) } func ensureClickables(list []widget.Clickable, count int) []widget.Clickable { @@ -629,6 +685,8 @@ func (u *ui) layoutBody(gtx layout.Context) layout.Dimensions { switch u.tab { case tabVLESS: return u.layoutVLESS(gtx) + case tabWireGuard: + return u.layoutWireGuard(gtx) case tabSubscriptions: return u.layoutSubscriptions(gtx) default: @@ -649,6 +707,10 @@ func (u *ui) layoutTabs(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, tabWireGuard, "WireGuard") + }), + layout.Rigid(spacerW(10)), layout.Rigid(func(gtx layout.Context) layout.Dimensions { return u.layoutTab(gtx, tabSubscriptions, "Subscriptions") }),