diff --git a/README.md b/README.md index 64b76b2..9fc6455 100644 --- a/README.md +++ b/README.md @@ -1,18 +1,44 @@ # GOST-PLUS -A cross-platform GUI client for [GOST.PLUS](https://gost.plus) built with [gioui](https://gioui.org). +A cross-platform GUI client for GOST.PLUS built with [gioui](https://gioui.org). + +## Features + +### File Tunnel + +Expose local files to the public network. + + + +### HTTP Tunnel + +Expose local HTTP service to the public network. + + + +### TCP Tunnel + +Expose local TCP service to the public network. + + + +### UDP Tunnel + +Expose local UDP service to the public network. + + ## Screenshot ### Desktop - - - - + + + + ### Mobile - - - \ No newline at end of file + + + \ No newline at end of file diff --git a/img/add-android.png b/assets/add-android.png similarity index 100% rename from img/add-android.png rename to assets/add-android.png diff --git a/img/add.png b/assets/add.png similarity index 100% rename from img/add.png rename to assets/add.png diff --git a/img/edit-android.png b/assets/edit-android.png similarity index 100% rename from img/edit-android.png rename to assets/edit-android.png diff --git a/img/edit.png b/assets/edit.png similarity index 100% rename from img/edit.png rename to assets/edit.png diff --git a/assets/file-tunnel.gif b/assets/file-tunnel.gif new file mode 100644 index 0000000..3cb7854 Binary files /dev/null and b/assets/file-tunnel.gif differ diff --git a/assets/http-tunnel.gif b/assets/http-tunnel.gif new file mode 100644 index 0000000..738e9cd Binary files /dev/null and b/assets/http-tunnel.gif differ diff --git a/img/list-android.png b/assets/list-android.png similarity index 100% rename from img/list-android.png rename to assets/list-android.png diff --git a/img/list.png b/assets/list.png similarity index 100% rename from img/list.png rename to assets/list.png diff --git a/img/menu.png b/assets/menu.png similarity index 100% rename from img/menu.png rename to assets/menu.png diff --git a/assets/tcp-tunnel.gif b/assets/tcp-tunnel.gif new file mode 100644 index 0000000..7e71854 Binary files /dev/null and b/assets/tcp-tunnel.gif differ diff --git a/assets/udp-tunnel.gif b/assets/udp-tunnel.gif new file mode 100644 index 0000000..e5d62f3 Binary files /dev/null and b/assets/udp-tunnel.gif differ diff --git a/config/config.go b/config/config.go index c72a66b..99407fe 100644 --- a/config/config.go +++ b/config/config.go @@ -101,6 +101,8 @@ type Tunnel struct { Username string `yaml:",omitempty"` Password string `yaml:",omitempty"` EnableTLS bool `yaml:"enableTLS,omitempty"` + Keepalive bool `yaml:",omitempty"` + TTL int `yaml:"ttl,omitempty"` Favorite bool Closed bool diff --git a/go.mod b/go.mod index 8aa5de1..5b1dbde 100644 --- a/go.mod +++ b/go.mod @@ -6,7 +6,7 @@ require ( gioui.org v0.4.1 gioui.org/x v0.4.0 github.com/go-gost/core v0.0.0-20231119081403-abc73f2ca2b7 - github.com/go-gost/x v0.0.0-20231130113937-b1390dda1cc8 + github.com/go-gost/x v0.0.0-20231216062858-f847fa533e98 github.com/google/uuid v1.4.0 github.com/spf13/viper v1.18.1 golang.org/x/exp/shiny v0.0.0-20231206192017-f3f8817b8deb diff --git a/go.sum b/go.sum index 4db3a8c..a773229 100644 --- a/go.sum +++ b/go.sum @@ -35,6 +35,8 @@ github.com/go-gost/tls-dissector v0.0.2-0.20220408131628-aac992c27451 h1:xj8gUZG github.com/go-gost/tls-dissector v0.0.2-0.20220408131628-aac992c27451/go.mod h1:/9QfdewqmHdaE362Hv5nDaSWLx3pCmtD870d6GaquXs= github.com/go-gost/x v0.0.0-20231130113937-b1390dda1cc8 h1:aM2tAfTg4+oBiB161GrYbRBhUHQM1CYNiDNYsqoVAlE= github.com/go-gost/x v0.0.0-20231130113937-b1390dda1cc8/go.mod h1:YaeMQsu+I8Q3bxPFeo5MbnmLsAdbGUxxG3A4mvUt2YE= +github.com/go-gost/x v0.0.0-20231216062858-f847fa533e98 h1:dFjwqp6SUJhDlFOBeBZXn5b2DqRjdbyBt+6w40+6cR8= +github.com/go-gost/x v0.0.0-20231216062858-f847fa533e98/go.mod h1:YaeMQsu+I8Q3bxPFeo5MbnmLsAdbGUxxG3A4mvUt2YE= github.com/go-redis/redis/v8 v8.11.5 h1:AcZZR7igkdvfVmQTPnu9WE37LRrO/YrBH5zWyjDC0oI= github.com/go-redis/redis/v8 v8.11.5/go.mod h1:gREzHqY1hg6oD9ngVRbLStwAWKhA0FEgq8Jd4h5lpwo= github.com/go-text/typesetting v0.0.0-20230803102845-24e03d8b5372 h1:FQivqchis6bE2/9uF70M2gmmLpe82esEm2QadL0TEJo= diff --git a/tunnel/entrypoint/entrypoint.go b/tunnel/entrypoint/entrypoint.go index 12e106e..b3d4342 100644 --- a/tunnel/entrypoint/entrypoint.go +++ b/tunnel/entrypoint/entrypoint.go @@ -97,32 +97,34 @@ func Delete(id string) { } func LoadConfig() { - for _, ep := range config.Global().EntryPoints { - if ep == nil { + for _, cfg := range config.Global().EntryPoints { + if cfg == nil { continue } - s := createEntryPoint(ep.Type, tunnel.Options{ - ID: ep.ID, - Name: ep.Name, - Endpoint: ep.Endpoint, - Hostname: ep.Hostname, - Username: ep.Username, - Password: ep.Password, - EnableTLS: ep.EnableTLS, + ep := createEntryPoint(cfg.Type, tunnel.Options{ + ID: cfg.ID, + Name: cfg.Name, + Endpoint: cfg.Endpoint, + Hostname: cfg.Hostname, + Username: cfg.Username, + Password: cfg.Password, + EnableTLS: cfg.EnableTLS, + Keepalive: cfg.Keepalive, + TTL: cfg.TTL, }) - if s == nil { + if ep == nil { continue } - if ep.Closed { - s.Close() + if cfg.Closed { + ep.Close() } else { - s.Run() + ep.Run() } - s.Favorite(ep.Favorite) - Add(s) + ep.Favorite(cfg.Favorite) + Add(ep) } } @@ -174,8 +176,8 @@ func createEntryPoint(st string, opts tunnel.Options) EntryPoint { switch st { case TCPEntryPoint: return NewTCPEntryPoint(options...) - // case UDPEntryPoint: - // return NewU(options...) + case UDPEntryPoint: + return NewUDPEntryPoint(options...) default: return nil } diff --git a/tunnel/entrypoint/udp.go b/tunnel/entrypoint/udp.go index 5543291..f5e9faa 100644 --- a/tunnel/entrypoint/udp.go +++ b/tunnel/entrypoint/udp.go @@ -6,6 +6,7 @@ import ( "fmt" "sync" "sync/atomic" + "time" "github.com/go-gost/core/chain" "github.com/go-gost/core/handler" @@ -17,7 +18,7 @@ import ( chain_parser "github.com/go-gost/x/config/parsing/chain" "github.com/go-gost/x/handler/forward/local" "github.com/go-gost/x/hop" - "github.com/go-gost/x/listener/tcp" + "github.com/go-gost/x/listener/udp" mdx "github.com/go-gost/x/metadata" xservice "github.com/go-gost/x/service" "github.com/google/uuid" @@ -108,6 +109,10 @@ func (s *udpEntryPoint) init() error { }, Listener: &config.ListenerConfig{ Type: "udp", + Metadata: map[string]any{ + "keepalive": s.opts.Keepalive, + "ttl": time.Duration(s.opts.TTL) * time.Second, + }, }, Forwarder: &config.ForwarderConfig{ Nodes: []*config.ForwardNodeConfig{ @@ -153,7 +158,7 @@ func (s *udpEntryPoint) Run() (err error) { } cfg := s.config.Services[0] - ln := tcp.NewListener( + ln := udp.NewListener( listener.AddrOption(cfg.Addr), listener.LoggerOption(log.WithFields(map[string]any{"kind": "listener", "listener": "udp"})), ) diff --git a/tunnel/tunnel.go b/tunnel/tunnel.go index ee9fe8c..cf3e785 100644 --- a/tunnel/tunnel.go +++ b/tunnel/tunnel.go @@ -36,6 +36,8 @@ type Options struct { Username string Password string EnableTLS bool + Keepalive bool + TTL int } type Option func(opts *Options) @@ -82,6 +84,18 @@ func EnableTLSOption(b bool) Option { } } +func KeepaliveOption(b bool) Option { + return func(opts *Options) { + opts.Keepalive = b + } +} + +func TTLOption(ttl int) Option { + return func(opts *Options) { + opts.TTL = ttl + } +} + type Tunnel interface { ID() string Type() string diff --git a/ui/icons/icons.go b/ui/icons/icons.go index f31da71..3eae400 100644 --- a/ui/icons/icons.go +++ b/ui/icons/icons.go @@ -32,20 +32,22 @@ func init() { var ( IconApp *widget.Image - IconHome = mustIcon(icons.ActionHome) - IconFavorite = mustIcon(icons.ActionFavorite) - IconAdd = mustIcon(icons.ContentAdd) - IconSettings = mustIcon(icons.ActionSettings) - IconDone = mustIcon(icons.ActionDone) - IconTunnelState = mustIcon(icons.ToggleRadioButtonChecked) - IconForward = mustIcon(icons.NavigationChevronRight) - IconEdit = mustIcon(icons.EditorModeEdit) - IconDelete = mustIcon(icons.ActionDelete) - IconStart = mustIcon(icons.AVPlayArrow) - IconStop = mustIcon(icons.AVStop) - IconBack = mustIcon(icons.NavigationArrowBack) - IconClose = mustIcon(icons.ContentClear) - IconCopy = mustIcon(icons.ContentContentCopy) + IconHome = mustIcon(icons.ActionHome) + IconFavorite = mustIcon(icons.ActionFavorite) + IconAdd = mustIcon(icons.ContentAdd) + IconSettings = mustIcon(icons.ActionSettings) + IconDone = mustIcon(icons.ActionDone) + IconTunnelState = mustIcon(icons.ToggleRadioButtonChecked) + IconForward = mustIcon(icons.NavigationChevronRight) + IconEdit = mustIcon(icons.EditorModeEdit) + IconDelete = mustIcon(icons.ActionDelete) + IconStart = mustIcon(icons.AVPlayArrow) + IconStop = mustIcon(icons.AVStop) + IconBack = mustIcon(icons.NavigationArrowBack) + IconClose = mustIcon(icons.ContentClear) + IconCopy = mustIcon(icons.ContentContentCopy) + IconVisibility = mustIcon(icons.ActionVisibility) + IconVisibilityOff = mustIcon(icons.ActionVisibilityOff) ) func mustIcon(data []byte) *widget.Icon { diff --git a/ui/page/entrypoint.go b/ui/page/entrypoint.go index 62fb27f..4f428b4 100644 --- a/ui/page/entrypoint.go +++ b/ui/page/entrypoint.go @@ -117,6 +117,8 @@ func (p *entryPointPage) Layout(gtx C, th *material.Theme) D { switch s.Type() { case entrypoint.TCPEntryPoint: p.router.SwitchTo(Route{Path: PageEditTCPEntryPoint, ID: s.ID()}) + case entrypoint.UDPEntryPoint: + p.router.SwitchTo(Route{Path: PageEditUDPEntryPoint, ID: s.ID()}) } op.InvalidateOp{}.Add(gtx.Ops) } diff --git a/ui/page/entrypoint_tcp.go b/ui/page/entrypoint_tcp.go index a8484dc..a287de3 100644 --- a/ui/page/entrypoint_tcp.go +++ b/ui/page/entrypoint_tcp.go @@ -55,9 +55,9 @@ func NewTCPEntryPointAddPage(r *Router) Page { } func (p *tcpEntryPointAddPage) Init(opts ...PageOption) { - p.name.SetText("") - p.tunnelID.SetText("") - p.addr.SetText("") + p.name.Clear() + p.tunnelID.Clear() + p.addr.Clear() p.router.bar.SetActions( []component.AppBarAction{ @@ -67,6 +67,9 @@ func (p *tcpEntryPointAddPage) Init(opts ...PageOption) { Tag: &p.wgDone, }, Layout: func(gtx C, bg, fg color.NRGBA) D { + if !p.isValid() { + gtx = gtx.Disabled() + } if p.wgDone.Clicked(gtx) { defer p.router.SwitchTo(Route{Path: PageEntryPoint}) p.createEntryPoint() @@ -81,6 +84,14 @@ func (p *tcpEntryPointAddPage) Init(opts ...PageOption) { p.router.bar.NavigationIcon = icons.IconClose } +func (p *tcpEntryPointAddPage) isValid() bool { + if p.tunnelID.Text() == "" || p.tunnelID.IsErrored() || + p.addr.Text() == "" || p.addr.IsErrored() { + return false + } + return true +} + func (p *tcpEntryPointAddPage) Layout(gtx C, th *material.Theme) D { return p.list.Layout(gtx, 1, func(gtx C, _ int) D { return layout.Center.Layout(gtx, func(gtx C) D { @@ -99,7 +110,7 @@ func (p *tcpEntryPointAddPage) layout(gtx C, th *material.Theme) D { return layout.Flex{ Axis: layout.Vertical, }.Layout(gtx, - layout.Rigid(material.Body1(th, "Create an entrypoint to connect to the specified TCP tunnel").Layout), + layout.Rigid(material.Body1(th, "Create an entrypoint to the specified TCP tunnel").Layout), layout.Rigid(layout.Spacer{Height: 10}.Layout), layout.Rigid(func(gtx C) D { return material.Body1(th, "Entrypoint name").Layout(gtx) @@ -113,13 +124,17 @@ func (p *tcpEntryPointAddPage) layout(gtx C, th *material.Theme) D { }), layout.Rigid(func(gtx C) D { if err := func() error { - tid := strings.TrimSpace(p.tunnelID.Text()) + tid := strings.ToLower(strings.TrimSpace(p.tunnelID.Text())) if tid == "" { return nil } if _, err := uuid.Parse(tid); err != nil { return fmt.Errorf("invalid tunnel ID, should be a valid UUID") } + + if ep := entrypoint.Get(tid); ep != nil { + return fmt.Errorf("the entrypoint for this tunnel exists") + } return nil }(); err != nil { p.tunnelID.SetError(err.Error()) @@ -155,16 +170,15 @@ func (p *tcpEntryPointAddPage) layout(gtx C, th *material.Theme) D { } func (p *tcpEntryPointAddPage) createEntryPoint() error { - tun := entrypoint.NewTCPEntryPoint( + ep := entrypoint.NewTCPEntryPoint( tunnel.NameOption(strings.TrimSpace(p.name.Text())), tunnel.IDOption(strings.ToLower(strings.TrimSpace(p.tunnelID.Text()))), tunnel.EndpointOption(strings.TrimSpace(p.addr.Text())), ) - entrypoint.Add(tun) + entrypoint.Add(ep) - if err := tun.Run(); err != nil { - tun.Close() + if err := ep.Run(); err != nil { return err } @@ -264,7 +278,12 @@ func (p *tcpEntryPointEditPage) Init(opts ...PageOption) { s := entrypoint.Get(p.id) if p.wgState.Clicked(gtx) && s != nil { if s.IsClosed() { - s = p.createEntryPoint() + opts := s.Options() + s = p.createEntryPoint( + tunnel.NameOption(opts.Name), + tunnel.IDOption(opts.ID), + tunnel.EndpointOption(opts.Endpoint), + ) } else { s.Close() } @@ -298,6 +317,10 @@ func (p *tcpEntryPointEditPage) Init(opts ...PageOption) { Tag: &p.wgDone, }, Layout: func(gtx C, bg, fg color.NRGBA) D { + if !p.isValid() { + gtx = gtx.Disabled() + } + if p.wgDone.Clicked(gtx) { defer p.router.SwitchTo(Route{Path: PageEntryPoint}) @@ -316,20 +339,31 @@ func (p *tcpEntryPointEditPage) Init(opts ...PageOption) { p.router.bar.NavigationIcon = icons.IconClose } -func (p *tcpEntryPointEditPage) createEntryPoint() entrypoint.EntryPoint { - s := entrypoint.NewTCPEntryPoint( - tunnel.IDOption(p.id), - tunnel.NameOption(strings.TrimSpace(p.name.Text())), - tunnel.IDOption(strings.ToLower(strings.TrimSpace(p.tunnelID.Text()))), - tunnel.EndpointOption(strings.TrimSpace(p.addr.Text())), - ) +func (p *tcpEntryPointEditPage) isValid() bool { + if p.tunnelID.Text() == "" || p.tunnelID.IsErrored() || + p.addr.Text() == "" || p.addr.IsErrored() { + return false + } + return true +} - if err := s.Run(); err != nil { +func (p *tcpEntryPointEditPage) createEntryPoint(opts ...tunnel.Option) entrypoint.EntryPoint { + if opts == nil { + opts = []tunnel.Option{ + tunnel.NameOption(strings.TrimSpace(p.name.Text())), + tunnel.IDOption(p.id), + tunnel.EndpointOption(strings.TrimSpace(p.addr.Text())), + } + } + ep := entrypoint.NewTCPEntryPoint(opts...) + + entrypoint.Set(ep) + + if err := ep.Run(); err != nil { log.Println(err) } - entrypoint.Set(s) - return s + return ep } func (p *tcpEntryPointEditPage) Layout(gtx C, th *material.Theme) D { @@ -350,7 +384,6 @@ func (p *tcpEntryPointEditPage) layout(gtx C, th *material.Theme) D { return layout.Flex{ Axis: layout.Vertical, }.Layout(gtx, - layout.Rigid(layout.Spacer{Height: 10}.Layout), layout.Rigid(func(gtx C) D { return material.Body1(th, "Entrypoint name").Layout(gtx) }), diff --git a/ui/page/entrypoint_udp.go b/ui/page/entrypoint_udp.go new file mode 100644 index 0000000..323819f --- /dev/null +++ b/ui/page/entrypoint_udp.go @@ -0,0 +1,535 @@ +package page + +import ( + "fmt" + "image/color" + "log" + "net" + "strconv" + "strings" + "unicode" + + "gioui.org/layout" + "gioui.org/widget" + "gioui.org/widget/material" + "gioui.org/x/component" + "github.com/go-gost/gost-plus/tunnel" + "github.com/go-gost/gost-plus/tunnel/entrypoint" + "github.com/go-gost/gost-plus/ui/icons" + "github.com/google/uuid" + "golang.org/x/exp/shiny/materialdesign/colornames" +) + +type udpEntryPointAddPage struct { + router *Router + + list layout.List + wgDone widget.Clickable + + name component.TextField + tunnelID component.TextField + addr component.TextField + + bKeepalive widget.Bool + ttl component.TextField +} + +func NewUDPEntryPointAddPage(r *Router) Page { + return &udpEntryPointAddPage{ + router: r, + list: layout.List{ + Axis: layout.Vertical, + Alignment: layout.Middle, + }, + name: component.TextField{ + Editor: widget.Editor{ + SingleLine: true, + }, + }, + tunnelID: component.TextField{ + Editor: widget.Editor{ + SingleLine: true, + }, + }, + addr: component.TextField{ + Editor: widget.Editor{ + SingleLine: true, + }, + }, + ttl: component.TextField{ + Editor: widget.Editor{ + SingleLine: true, + }, + }, + } +} + +func (p *udpEntryPointAddPage) Init(opts ...PageOption) { + p.name.SetText("") + p.tunnelID.SetText("") + p.addr.SetText("") + p.bKeepalive.Value = false + p.ttl.SetText("") + + p.router.bar.SetActions( + []component.AppBarAction{ + { + OverflowAction: component.OverflowAction{ + Name: "Create", + Tag: &p.wgDone, + }, + Layout: func(gtx C, bg, fg color.NRGBA) D { + if !p.isValid() { + gtx = gtx.Disabled() + } + if p.wgDone.Clicked(gtx) { + defer p.router.SwitchTo(Route{Path: PageEntryPoint}) + p.createEntryPoint() + entrypoint.SaveConfig() + } + return component.SimpleIconButton(bg, fg, &p.wgDone, icons.IconDone).Layout(gtx) + }, + }, + }, nil) + + p.router.bar.Title = "UDP" + p.router.bar.NavigationIcon = icons.IconClose +} + +func (p *udpEntryPointAddPage) isValid() bool { + if p.tunnelID.Text() == "" || p.tunnelID.IsErrored() || + p.addr.Text() == "" || p.addr.IsErrored() { + return false + } + return true +} + +func (p *udpEntryPointAddPage) Layout(gtx C, th *material.Theme) D { + return p.list.Layout(gtx, 1, func(gtx C, _ int) D { + return layout.Center.Layout(gtx, func(gtx C) D { + return layout.UniformInset(10).Layout(gtx, func(gtx C) D { + return component.Surface(th).Layout(gtx, func(gtx C) D { + return layout.UniformInset(10).Layout(gtx, func(gtx C) D { + return p.layout(gtx, th) + }) + }) + }) + }) + }) +} + +func (p *udpEntryPointAddPage) layout(gtx C, th *material.Theme) D { + return layout.Flex{ + Axis: layout.Vertical, + }.Layout(gtx, + layout.Rigid(material.Body1(th, "Create an entrypoint to the specified UDP tunnel").Layout), + layout.Rigid(layout.Spacer{Height: 10}.Layout), + layout.Rigid(func(gtx C) D { + return material.Body1(th, "Entrypoint name").Layout(gtx) + }), + layout.Rigid(func(gtx C) D { + return p.name.Layout(gtx, th, "Name") + }), + layout.Rigid(layout.Spacer{Height: 10}.Layout), + layout.Rigid(func(gtx C) D { + return material.Body1(th, "Tunnel ID").Layout(gtx) + }), + layout.Rigid(func(gtx C) D { + if err := func() error { + tid := strings.ToLower(strings.TrimSpace(p.tunnelID.Text())) + if tid == "" { + return nil + } + if _, err := uuid.Parse(tid); err != nil { + return fmt.Errorf("invalid tunnel ID, should be a valid UUID") + } + if ep := entrypoint.Get(tid); ep != nil { + return fmt.Errorf("the entrypoint for this tunnel exists") + } + return nil + }(); err != nil { + p.tunnelID.SetError(err.Error()) + } else { + p.tunnelID.ClearError() + } + + return p.tunnelID.Layout(gtx, th, "ID") + }), + layout.Rigid(layout.Spacer{Height: 10}.Layout), + layout.Rigid(func(gtx C) D { + return material.Body1(th, "Entrypoint address").Layout(gtx) + }), + layout.Rigid(func(gtx C) D { + if err := func() error { + addr := strings.TrimSpace(p.addr.Text()) + if addr == "" { + return nil + } + if _, err := net.ResolveUDPAddr("udp", addr); err != nil { + return fmt.Errorf("invalid address format, should be [IP]:PORT or [HOST]:PORT") + } + return nil + }(); err != nil { + p.addr.SetError(err.Error()) + } else { + p.addr.ClearError() + } + + return p.addr.Layout(gtx, th, "Address") + }), + layout.Rigid(layout.Spacer{Height: 10}.Layout), + layout.Rigid(func(gtx C) D { + return layout.Inset{Top: 10, Bottom: 10}.Layout(gtx, func(gtx C) D { + return layout.Flex{ + Spacing: layout.SpaceBetween, + }.Layout(gtx, + layout.Flexed(1, material.Body1(th, "Enable keepalive").Layout), + layout.Rigid(material.Switch(th, &p.bKeepalive, "keepalive").Layout), + ) + }) + }), + layout.Rigid(func(gtx C) D { + if !p.bKeepalive.Value { + p.ttl.Clear() + return layout.Dimensions{} + } + p.ttl.Suffix = func(gtx C) D { + return material.Label(th, th.TextSize, "s").Layout(gtx) + } + + if err := func() string { + for _, r := range p.ttl.Text() { + if !unicode.IsDigit(r) { + return "Must contain only digits" + } + } + return "" + }(); err != "" { + p.ttl.SetError(err) + } else { + p.ttl.ClearError() + } + return p.ttl.Layout(gtx, th, "TTL") + }), + ) +} + +func (p *udpEntryPointAddPage) createEntryPoint() error { + ttl, _ := strconv.Atoi(strings.TrimSpace(p.ttl.Text())) + ep := entrypoint.NewUDPEntryPoint( + tunnel.NameOption(strings.TrimSpace(p.name.Text())), + tunnel.IDOption(strings.ToLower(strings.TrimSpace(p.tunnelID.Text()))), + tunnel.EndpointOption(strings.TrimSpace(p.addr.Text())), + tunnel.KeepaliveOption(p.bKeepalive.Value), + tunnel.TTLOption(ttl), + ) + + entrypoint.Add(ep) + + if err := ep.Run(); err != nil { + return err + } + + return nil +} + +type udpEntryPointEditPage struct { + router *Router + + id string + + list layout.List + wgFavorite widget.Clickable + wgState widget.Clickable + wgDelete widget.Clickable + wgDone widget.Clickable + + name component.TextField + tunnelID component.TextField + addr component.TextField + + bKeepalive widget.Bool + ttl component.TextField +} + +func NewUDPEntryPointEditPage(r *Router) Page { + return &udpEntryPointEditPage{ + router: r, + list: layout.List{ + Axis: layout.Vertical, + Alignment: layout.Middle, + }, + name: component.TextField{ + Editor: widget.Editor{ + SingleLine: true, + }, + }, + tunnelID: component.TextField{ + Editor: widget.Editor{ + SingleLine: true, + ReadOnly: true, + }, + }, + addr: component.TextField{ + Editor: widget.Editor{ + SingleLine: true, + }, + }, + ttl: component.TextField{ + Editor: widget.Editor{ + SingleLine: true, + }, + }, + } +} + +func (p *udpEntryPointEditPage) Init(opts ...PageOption) { + var options PageOptions + for _, opt := range opts { + opt(&options) + } + + p.id = options.ID + + s := entrypoint.Get(p.id) + if s != nil { + sopts := s.Options() + p.name.SetText(sopts.Name) + p.tunnelID.SetText(sopts.ID) + p.addr.SetText(sopts.Endpoint) + p.bKeepalive.Value = sopts.Keepalive + p.ttl.SetText(strconv.Itoa(sopts.TTL)) + } + + actions := []component.AppBarAction{ + { + OverflowAction: component.OverflowAction{ + Name: "Favorite", + Tag: &p.wgFavorite, + }, + Layout: func(gtx C, bg, fg color.NRGBA) D { + s := entrypoint.Get(p.id) + if s == nil { + return D{} + } + + if p.wgFavorite.Clicked(gtx) { + s.Favorite(!s.IsFavorite()) + entrypoint.SaveConfig() + } + + btn := component.SimpleIconButton(bg, fg, &p.wgFavorite, icons.IconFavorite) + if s.IsFavorite() { + btn.Color = color.NRGBA(colornames.Red500) + } else { + btn.Color = fg + } + return btn.Layout(gtx) + }, + }, + { + OverflowAction: component.OverflowAction{ + Name: "Start/Stop", + Tag: &p.wgState, + }, + Layout: func(gtx C, bg, fg color.NRGBA) D { + s := entrypoint.Get(p.id) + if p.wgState.Clicked(gtx) && s != nil { + if s.IsClosed() { + opts := s.Options() + s = p.createEntryPoint( + tunnel.NameOption(opts.Name), + tunnel.IDOption(opts.ID), + tunnel.EndpointOption(opts.Endpoint), + tunnel.KeepaliveOption(opts.Keepalive), + tunnel.TTLOption(opts.TTL), + ) + } else { + s.Close() + } + entrypoint.SaveConfig() + } + + if s != nil && !s.IsClosed() { + return component.SimpleIconButton(bg, fg, &p.wgState, icons.IconStop).Layout(gtx) + } else { + return component.SimpleIconButton(bg, fg, &p.wgState, icons.IconStart).Layout(gtx) + } + }, + }, + { + OverflowAction: component.OverflowAction{ + Name: "Delete", + Tag: &p.wgDelete, + }, + Layout: func(gtx C, bg, fg color.NRGBA) D { + if p.wgDelete.Clicked(gtx) { + entrypoint.Delete(p.id) + entrypoint.SaveConfig() + p.router.SwitchTo(Route{Path: PageEntryPoint}) + } + return component.SimpleIconButton(bg, fg, &p.wgDelete, icons.IconDelete).Layout(gtx) + }, + }, + { + OverflowAction: component.OverflowAction{ + Name: "Save", + Tag: &p.wgDone, + }, + Layout: func(gtx C, bg, fg color.NRGBA) D { + if !p.isValid() { + gtx = gtx.Disabled() + } + + if p.wgDone.Clicked(gtx) { + defer p.router.SwitchTo(Route{Path: PageEntryPoint}) + + if s := entrypoint.Get(p.id); s != nil { + s.Close() + p.createEntryPoint() + entrypoint.SaveConfig() + } + } + return component.SimpleIconButton(bg, fg, &p.wgDone, icons.IconDone).Layout(gtx) + }, + }, + } + p.router.bar.SetActions(actions, nil) + p.router.bar.Title = "UDP" + p.router.bar.NavigationIcon = icons.IconClose +} + +func (p *udpEntryPointEditPage) isValid() bool { + if p.tunnelID.Text() == "" || p.tunnelID.IsErrored() || + p.addr.Text() == "" || p.addr.IsErrored() { + return false + } + return true +} + +func (p *udpEntryPointEditPage) createEntryPoint(opts ...tunnel.Option) entrypoint.EntryPoint { + if opts == nil { + ttl, _ := strconv.Atoi(strings.TrimSpace(p.ttl.Text())) + opts = []tunnel.Option{ + tunnel.NameOption(strings.TrimSpace(p.name.Text())), + tunnel.IDOption(p.id), + tunnel.EndpointOption(strings.TrimSpace(p.addr.Text())), + tunnel.KeepaliveOption(p.bKeepalive.Value), + tunnel.TTLOption(ttl), + } + } + ep := entrypoint.NewUDPEntryPoint(opts...) + + entrypoint.Set(ep) + + if err := ep.Run(); err != nil { + log.Println(err) + } + + return ep +} + +func (p *udpEntryPointEditPage) Layout(gtx C, th *material.Theme) D { + return p.list.Layout(gtx, 1, func(gtx C, _ int) D { + return layout.Center.Layout(gtx, func(gtx C) D { + return layout.UniformInset(10).Layout(gtx, func(gtx C) D { + return component.Surface(th).Layout(gtx, func(gtx C) D { + return layout.UniformInset(10).Layout(gtx, func(gtx C) D { + return p.layout(gtx, th) + }) + }) + }) + }) + }) +} + +func (p *udpEntryPointEditPage) layout(gtx C, th *material.Theme) D { + return layout.Flex{ + Axis: layout.Vertical, + }.Layout(gtx, + layout.Rigid(func(gtx C) D { + return material.Body1(th, "Entrypoint name").Layout(gtx) + }), + layout.Rigid(func(gtx C) D { + return p.name.Layout(gtx, th, "Name") + }), + layout.Rigid(layout.Spacer{Height: 10}.Layout), + layout.Rigid(func(gtx C) D { + return material.Body1(th, "Tunnel ID").Layout(gtx) + }), + layout.Rigid(func(gtx C) D { + if err := func() error { + tid := strings.TrimSpace(p.tunnelID.Text()) + if tid == "" { + return nil + } + if _, err := uuid.Parse(tid); err != nil { + return fmt.Errorf("invalid tunnel ID, should be a valid UUID") + } + return nil + }(); err != nil { + p.tunnelID.SetError(err.Error()) + } else { + p.tunnelID.ClearError() + } + + return p.tunnelID.Layout(gtx, th, "ID") + }), + layout.Rigid(layout.Spacer{Height: 10}.Layout), + layout.Rigid(func(gtx C) D { + return material.Body1(th, "Entrypoint address").Layout(gtx) + }), + layout.Rigid(func(gtx C) D { + if err := func() error { + addr := strings.TrimSpace(p.addr.Text()) + if addr == "" { + return nil + } + if _, err := net.ResolveTCPAddr("tcp", addr); err != nil { + return fmt.Errorf("invalid address format, should be [IP]:PORT or [HOST]:PORT") + } + return nil + }(); err != nil { + p.addr.SetError(err.Error()) + } else { + p.addr.ClearError() + } + + return p.addr.Layout(gtx, th, "Address") + }), + layout.Rigid(layout.Spacer{Height: 10}.Layout), + layout.Rigid(func(gtx C) D { + return layout.Inset{Top: 10, Bottom: 10}.Layout(gtx, func(gtx C) D { + return layout.Flex{ + Spacing: layout.SpaceBetween, + }.Layout(gtx, + layout.Flexed(1, material.Body1(th, "Enable keepalive").Layout), + layout.Rigid(material.Switch(th, &p.bKeepalive, "keepalive").Layout), + ) + }) + }), + layout.Rigid(func(gtx C) D { + if !p.bKeepalive.Value { + p.ttl.Clear() + return layout.Dimensions{} + } + p.ttl.Suffix = func(gtx C) D { + return material.Label(th, th.TextSize, "s").Layout(gtx) + } + + if err := func() string { + for _, r := range p.ttl.Text() { + if !unicode.IsDigit(r) { + return "Must contain only digits" + } + } + return "" + }(); err != "" { + p.ttl.SetError(err) + } else { + p.ttl.ClearError() + } + return p.ttl.Layout(gtx, th, "TTL") + }), + ) +} diff --git a/ui/page/file.go b/ui/page/file.go index 340ccea..9f9137f 100644 --- a/ui/page/file.go +++ b/ui/page/file.go @@ -27,6 +27,9 @@ type fileAddPage struct { cbBasicAuth widget.Bool username component.TextField password component.TextField + + wgPassword widget.Clickable + passwordVisible bool } func NewFileAddPage(r *Router) Page { @@ -60,11 +63,12 @@ func NewFileAddPage(r *Router) Page { } func (p *fileAddPage) Init(opts ...PageOption) { - p.name.SetText("") - p.path.SetText("") + p.name.Clear() + p.path.Clear() p.cbBasicAuth.Value = false - p.username.SetText("") - p.password.SetText("") + p.username.Clear() + p.password.Clear() + p.passwordVisible = false p.router.bar.SetActions( []component.AppBarAction{ @@ -158,16 +162,36 @@ func (p *fileAddPage) layout(gtx C, th *material.Theme) D { }), layout.Rigid(func(gtx C) D { if !p.cbBasicAuth.Value { - p.username.SetText("") + p.username.Clear() return layout.Dimensions{} } return p.username.Layout(gtx, th, "Username") }), layout.Rigid(func(gtx C) D { if !p.cbBasicAuth.Value { - p.password.SetText("") + p.password.Clear() return layout.Dimensions{} } + + if p.wgPassword.Clicked(gtx) { + p.passwordVisible = !p.passwordVisible + } + + if p.passwordVisible { + p.password.Suffix = func(gtx layout.Context) layout.Dimensions { + return p.wgPassword.Layout(gtx, func(gtx layout.Context) layout.Dimensions { + return icons.IconVisibility.Layout(gtx, color.NRGBA(colornames.Grey500)) + }) + } + p.password.Mask = 0 + } else { + p.password.Suffix = func(gtx layout.Context) layout.Dimensions { + return p.wgPassword.Layout(gtx, func(gtx layout.Context) layout.Dimensions { + return icons.IconVisibilityOff.Layout(gtx, color.NRGBA(colornames.Grey500)) + }) + } + p.password.Mask = '*' + } return p.password.Layout(gtx, th, "Password") }), ) @@ -215,6 +239,9 @@ type fileEditPage struct { cbBasicAuth widget.Bool username component.TextField password component.TextField + + wgPassword widget.Clickable + passwordVisible bool } func NewFileEditPage(r *Router) Page { @@ -248,6 +275,8 @@ func NewFileEditPage(r *Router) Page { } func (p *fileEditPage) Init(opts ...PageOption) { + p.passwordVisible = false + var options PageOptions for _, opt := range opts { opt(&options) @@ -305,7 +334,14 @@ func (p *fileEditPage) Init(opts ...PageOption) { if p.wgState.Clicked(gtx) { if s.IsClosed() { - s = p.createTunnel() + opts := s.Options() + s = p.createTunnel( + tunnel.NameOption(opts.Name), + tunnel.IDOption(opts.ID), + tunnel.EndpointOption(opts.Endpoint), + tunnel.UsernameOption(opts.Username), + tunnel.PasswordOption(opts.Password), + ) } else { s.Close() } @@ -357,19 +393,22 @@ func (p *fileEditPage) Init(opts ...PageOption) { p.router.bar.NavigationIcon = icons.IconClose } -func (p *fileEditPage) createTunnel() tunnel.Tunnel { - var username, password string - if p.cbBasicAuth.Value { - username = strings.TrimSpace(p.username.Text()) - password = strings.TrimSpace(p.password.Text()) +func (p *fileEditPage) createTunnel(opts ...tunnel.Option) tunnel.Tunnel { + if opts == nil { + var username, password string + if p.cbBasicAuth.Value { + username = strings.TrimSpace(p.username.Text()) + password = strings.TrimSpace(p.password.Text()) + } + opts = []tunnel.Option{ + tunnel.NameOption(strings.TrimSpace(p.name.Text())), + tunnel.IDOption(p.id), + tunnel.EndpointOption(strings.TrimSpace(p.path.Text())), + tunnel.UsernameOption(username), + tunnel.PasswordOption(password), + } } - tun := tunnel.NewFileTunnel( - tunnel.IDOption(p.id), - tunnel.NameOption(strings.TrimSpace(p.name.Text())), - tunnel.EndpointOption(strings.TrimSpace(p.path.Text())), - tunnel.UsernameOption(username), - tunnel.PasswordOption(password), - ) + tun := tunnel.NewFileTunnel(opts...) tunnel.Set(tun) @@ -455,16 +494,37 @@ func (p *fileEditPage) layout(gtx C, th *material.Theme) D { }), layout.Rigid(func(gtx C) D { if !p.cbBasicAuth.Value { - p.username.SetText("") + p.username.Clear() return layout.Dimensions{} } return p.username.Layout(gtx, th, "Username") }), layout.Rigid(func(gtx C) D { if !p.cbBasicAuth.Value { - p.password.SetText("") + p.password.Clear() return layout.Dimensions{} } + + if p.wgPassword.Clicked(gtx) { + p.passwordVisible = !p.passwordVisible + } + + if p.passwordVisible { + p.password.Suffix = func(gtx layout.Context) layout.Dimensions { + return p.wgPassword.Layout(gtx, func(gtx layout.Context) layout.Dimensions { + return icons.IconVisibility.Layout(gtx, color.NRGBA(colornames.Grey500)) + }) + } + p.password.Mask = 0 + } else { + p.password.Suffix = func(gtx layout.Context) layout.Dimensions { + return p.wgPassword.Layout(gtx, func(gtx layout.Context) layout.Dimensions { + return icons.IconVisibilityOff.Layout(gtx, color.NRGBA(colornames.Grey500)) + }) + } + p.password.Mask = '*' + } + return p.password.Layout(gtx, th, "Password") }), ) diff --git a/ui/page/http.go b/ui/page/http.go index 7d95aa8..545679d 100644 --- a/ui/page/http.go +++ b/ui/page/http.go @@ -33,6 +33,9 @@ type httpAddPage struct { password component.TextField bTLS widget.Bool + + wgPassword widget.Clickable + passwordVisible bool } func NewHTTPAddPage(r *Router) Page { @@ -71,13 +74,14 @@ func NewHTTPAddPage(r *Router) Page { } func (p *httpAddPage) Init(opts ...PageOption) { - p.name.SetText("") - p.addr.SetText("") - p.hostname.SetText("") + p.name.Clear() + p.addr.Clear() + p.hostname.Clear() p.bBasicAuth.Value = false - p.username.SetText("") - p.password.SetText("") + p.username.Clear() + p.password.Clear() p.bTLS.Value = false + p.passwordVisible = false p.router.bar.SetActions( []component.AppBarAction{ @@ -186,6 +190,27 @@ func (p *httpAddPage) layout(gtx C, th *material.Theme) D { p.password.SetText("") return layout.Dimensions{} } + + if p.wgPassword.Clicked(gtx) { + p.passwordVisible = !p.passwordVisible + } + + if p.passwordVisible { + p.password.Suffix = func(gtx layout.Context) layout.Dimensions { + return p.wgPassword.Layout(gtx, func(gtx layout.Context) layout.Dimensions { + return icons.IconVisibility.Layout(gtx, color.NRGBA(colornames.Grey500)) + }) + } + p.password.Mask = 0 + } else { + p.password.Suffix = func(gtx layout.Context) layout.Dimensions { + return p.wgPassword.Layout(gtx, func(gtx layout.Context) layout.Dimensions { + return icons.IconVisibilityOff.Layout(gtx, color.NRGBA(colornames.Grey500)) + }) + } + p.password.Mask = '*' + } + return p.password.Layout(gtx, th, "Password") }), layout.Rigid(layout.Spacer{Height: 10}.Layout), @@ -254,6 +279,9 @@ type httpEditPage struct { wgID widget.Clickable wgEntrypoint widget.Clickable + + wgPassword widget.Clickable + passwordVisible bool } func NewHTTPEditPage(r *Router) Page { @@ -292,6 +320,8 @@ func NewHTTPEditPage(r *Router) Page { } func (p *httpEditPage) Init(opts ...PageOption) { + p.passwordVisible = false + var options PageOptions for _, opt := range opts { opt(&options) @@ -350,7 +380,16 @@ func (p *httpEditPage) Init(opts ...PageOption) { s := tunnel.Get(p.id) if p.wgState.Clicked(gtx) && s != nil { if s.IsClosed() { - s = p.createTunnel() + opts := s.Options() + s = p.createTunnel( + tunnel.NameOption(opts.Name), + tunnel.IDOption(opts.ID), + tunnel.EndpointOption(opts.Endpoint), + tunnel.HostnameOption(opts.Hostname), + tunnel.UsernameOption(opts.Username), + tunnel.PasswordOption(opts.Password), + tunnel.EnableTLSOption(opts.EnableTLS), + ) } else { s.Close() } @@ -402,25 +441,29 @@ func (p *httpEditPage) Init(opts ...PageOption) { p.router.bar.NavigationIcon = icons.IconClose } -func (p *httpEditPage) createTunnel() tunnel.Tunnel { - var username, password string - if p.cbBasicAuth.Value { - username = strings.TrimSpace(p.username.Text()) - password = strings.TrimSpace(p.password.Text()) - } - var hostname string - if p.bHost.Value { - hostname = strings.TrimSpace(p.hostname.Text()) +func (p *httpEditPage) createTunnel(opts ...tunnel.Option) tunnel.Tunnel { + if opts == nil { + var username, password string + if p.cbBasicAuth.Value { + username = strings.TrimSpace(p.username.Text()) + password = strings.TrimSpace(p.password.Text()) + } + var hostname string + if p.bHost.Value { + hostname = strings.TrimSpace(p.hostname.Text()) + } + + opts = []tunnel.Option{ + tunnel.NameOption(strings.TrimSpace(p.name.Text())), + tunnel.IDOption(p.id), + tunnel.EndpointOption(strings.TrimSpace(p.addr.Text())), + tunnel.UsernameOption(username), + tunnel.PasswordOption(password), + tunnel.HostnameOption(hostname), + tunnel.EnableTLSOption(p.bTLS.Value), + } } - tun := tunnel.NewHTTPTunnel( - tunnel.IDOption(p.id), - tunnel.NameOption(strings.TrimSpace(p.name.Text())), - tunnel.EndpointOption(strings.TrimSpace(p.addr.Text())), - tunnel.UsernameOption(username), - tunnel.PasswordOption(password), - tunnel.HostnameOption(hostname), - tunnel.EnableTLSOption(p.bTLS.Value), - ) + tun := tunnel.NewHTTPTunnel(opts...) tunnel.Set(tun) @@ -522,6 +565,27 @@ func (p *httpEditPage) layout(gtx C, th *material.Theme) D { p.password.SetText("") return layout.Dimensions{} } + + if p.wgPassword.Clicked(gtx) { + p.passwordVisible = !p.passwordVisible + } + + if p.passwordVisible { + p.password.Suffix = func(gtx layout.Context) layout.Dimensions { + return p.wgPassword.Layout(gtx, func(gtx layout.Context) layout.Dimensions { + return icons.IconVisibility.Layout(gtx, color.NRGBA(colornames.Grey500)) + }) + } + p.password.Mask = 0 + } else { + p.password.Suffix = func(gtx layout.Context) layout.Dimensions { + return p.wgPassword.Layout(gtx, func(gtx layout.Context) layout.Dimensions { + return icons.IconVisibilityOff.Layout(gtx, color.NRGBA(colornames.Grey500)) + }) + } + p.password.Mask = '*' + } + return p.password.Layout(gtx, th, "Password") }), layout.Rigid(func(gtx C) D { diff --git a/ui/page/menu.go b/ui/page/menu.go index d593c7e..dc07af2 100644 --- a/ui/page/menu.go +++ b/ui/page/menu.go @@ -66,6 +66,10 @@ func (p *menuPage) Layout(gtx C, th *material.Theme) D { p.router.SwitchTo(Route{Path: PageNewTCPEntryPoint}) return true } + if p.wgEntryPointUDP.Clicked(gtx) { + p.router.SwitchTo(Route{Path: PageNewUDPEntryPoint}) + return true + } return false }(); clicked { @@ -138,7 +142,18 @@ func (p *menuPage) Layout(gtx C, th *material.Theme) D { return component.Surface(th).Layout(gtx, func(gtx C) D { return p.wgEntryPointTCP.Layout(gtx, func(gtx C) D { return layout.UniformInset(10).Layout(gtx, func(gtx C) D { - return p.layoutCard(gtx, th, "TCP", "Create an entrypoint to connect to the specified TCP tunnel") + return p.layoutCard(gtx, th, "TCP", "Create an entrypoint to the specified TCP tunnel") + }) + }) + }) + }) + }), + layout.Rigid(func(gtx C) D { + return layout.Inset{Top: 5, Bottom: 5}.Layout(gtx, func(gtx C) D { + return component.Surface(th).Layout(gtx, func(gtx C) D { + return p.wgEntryPointUDP.Layout(gtx, func(gtx C) D { + return layout.UniformInset(10).Layout(gtx, func(gtx C) D { + return p.layoutCard(gtx, th, "UDP", "Create an entrypoint to the specified UDP tunnel") }) }) }) diff --git a/ui/page/page.go b/ui/page/page.go index d955be2..7fc4705 100644 --- a/ui/page/page.go +++ b/ui/page/page.go @@ -28,6 +28,8 @@ const ( PageEntryPoint = "/entrypoint" PageNewTCPEntryPoint = "/entrypoint/tcp/create" PageEditTCPEntryPoint = "/entrypoint/tcp/edit" + PageNewUDPEntryPoint = "/entrypoint/udp/create" + PageEditUDPEntryPoint = "/entrypoint/udp/edit" PageAbout = "/about" ) diff --git a/ui/page/router.go b/ui/page/router.go index 431a497..9cc4a97 100644 --- a/ui/page/router.go +++ b/ui/page/router.go @@ -44,8 +44,10 @@ func NewRouter() *Router { r.Register(PageEditUDP, NewUDPEditPage(r)) r.Register(PageEntryPoint, NewEntryPointPage(r)) - r.Register(PageEditTCPEntryPoint, NewTCPEntryPointEditPage(r)) r.Register(PageNewTCPEntryPoint, NewTCPEntryPointAddPage(r)) + r.Register(PageEditTCPEntryPoint, NewTCPEntryPointEditPage(r)) + r.Register(PageNewUDPEntryPoint, NewUDPEntryPointAddPage(r)) + r.Register(PageEditUDPEntryPoint, NewUDPEntryPointEditPage(r)) r.Register(PageAbout, NewAboutPage(r)) @@ -75,8 +77,11 @@ func (r *Router) Layout(gtx layout.Context, th *material.Theme) layout.Dimension for _, event := range r.bar.Events(gtx) { switch event := event.(type) { case component.AppBarNavigationClicked: + path := r.current.Path // log.Printf("navigation clicked: %+v", event) - if r.current.Path == PageTunnel { + if path == PageTunnel || + path == PageNewTCPEntryPoint || path == PageEditTCPEntryPoint || + path == PageNewUDPEntryPoint || path == PageEditUDPEntryPoint { r.SwitchTo(Route{Path: PageEntryPoint}) } else { r.SwitchTo(Route{Path: PageTunnel}) diff --git a/ui/page/tcp.go b/ui/page/tcp.go index 35036b6..6cffa0a 100644 --- a/ui/page/tcp.go +++ b/ui/page/tcp.go @@ -47,8 +47,8 @@ func NewTCPAddPage(r *Router) Page { } func (p *tcpAddPage) Init(opts ...PageOption) { - p.name.SetText("") - p.addr.SetText("") + p.name.Clear() + p.addr.Clear() p.router.bar.SetActions( []component.AppBarAction{ @@ -226,7 +226,12 @@ func (p *tcpEditPage) Init(opts ...PageOption) { s := tunnel.Get(p.id) if p.wgState.Clicked(gtx) && s != nil { if s.IsClosed() { - s = p.createTunnel() + opts := s.Options() + s = p.createTunnel( + tunnel.NameOption(opts.Name), + tunnel.IDOption(opts.ID), + tunnel.EndpointOption(opts.Endpoint), + ) } else { s.Close() } @@ -278,12 +283,15 @@ func (p *tcpEditPage) Init(opts ...PageOption) { p.router.bar.NavigationIcon = icons.IconClose } -func (p *tcpEditPage) createTunnel() tunnel.Tunnel { - tun := tunnel.NewTCPTunnel( - tunnel.IDOption(p.id), - tunnel.NameOption(strings.TrimSpace(p.name.Text())), - tunnel.EndpointOption(strings.TrimSpace(p.addr.Text())), - ) +func (p *tcpEditPage) createTunnel(opts ...tunnel.Option) tunnel.Tunnel { + if opts == nil { + opts = []tunnel.Option{ + tunnel.IDOption(p.id), + tunnel.NameOption(strings.TrimSpace(p.name.Text())), + tunnel.EndpointOption(strings.TrimSpace(p.addr.Text())), + } + } + tun := tunnel.NewTCPTunnel(opts...) tunnel.Set(tun) diff --git a/ui/page/udp.go b/ui/page/udp.go index d2ab7e9..5c4eb4f 100644 --- a/ui/page/udp.go +++ b/ui/page/udp.go @@ -47,8 +47,8 @@ func NewUDPAddPage(r *Router) Page { } func (p *udpAddPage) Init(opts ...PageOption) { - p.name.SetText("") - p.addr.SetText("") + p.name.Clear() + p.addr.Clear() p.router.bar.SetActions( []component.AppBarAction{ @@ -226,7 +226,12 @@ func (p *udpEditPage) Init(opts ...PageOption) { s := tunnel.Get(p.id) if p.wgState.Clicked(gtx) && s != nil { if s.IsClosed() { - s = p.createTunnel() + opts := s.Options() + s = p.createTunnel( + tunnel.NameOption(opts.Name), + tunnel.IDOption(opts.ID), + tunnel.EndpointOption(opts.Endpoint), + ) } else { s.Close() } @@ -279,12 +284,15 @@ func (p *udpEditPage) Init(opts ...PageOption) { p.router.bar.NavigationIcon = icons.IconClose } -func (p *udpEditPage) createTunnel() tunnel.Tunnel { - tun := tunnel.NewUDPTunnel( - tunnel.IDOption(p.id), - tunnel.NameOption(strings.TrimSpace(p.name.Text())), - tunnel.EndpointOption(strings.TrimSpace(p.addr.Text())), - ) +func (p *udpEditPage) createTunnel(opts ...tunnel.Option) tunnel.Tunnel { + if opts == nil { + opts = []tunnel.Option{ + tunnel.IDOption(p.id), + tunnel.NameOption(strings.TrimSpace(p.name.Text())), + tunnel.EndpointOption(strings.TrimSpace(p.addr.Text())), + } + } + tun := tunnel.NewUDPTunnel(opts...) tunnel.Set(tun)