diff --git a/.gitignore b/.gitignore index 5363177..833a9fd 100644 --- a/.gitignore +++ b/.gitignore @@ -21,8 +21,9 @@ go.work bin/ +build/ *.syso *.apk *.idsig -gost-plus \ No newline at end of file +gost.plus \ No newline at end of file diff --git a/Makefile b/Makefile index 5103011..ad93339 100644 --- a/Makefile +++ b/Makefile @@ -1,19 +1,24 @@ # https://gioui.org/doc/install -NAME=gost-plus +NAME=gost.plus BINDIR=bin VERSION=$(shell cat version/version.go | grep 'Version =' | sed 's/.*\"\(.*\)\".*/\1/g') GOBUILD=CGO_ENABLED=0 go build --ldflags="-s -w" -v -x -a GOFILES=*.go PLATFORM_LIST = \ - linux-amd64 + linux-amd64 \ + linux-arm64 WINDOWS_ARCH_LIST = \ - windows-amd64 + windows-amd64 \ + windows-arm64 linux-amd64: GOOS=linux GOARCH=amd64 CGO_ENABLED=1 go build --ldflags="-s -w" -v -x -a -o $(BINDIR)/$(NAME)-$@ $(GOFILES) + +linux-arm64: + GOOS=linux GOARCH=arm64 CGO_ENABLED=1 go build --ldflags="-s -w" -v -x -a -o $(BINDIR)/$(NAME)-$@ $(GOFILES) darwin-amd64: GOOS=darwin GOARCH=amd64 $(GOBUILD) -o $(BINDIR)/$(NAME)-$@ $(GOFILES) @@ -22,13 +27,17 @@ darwin-arm64: GOOS=darwin GOARCH=arm64 $(GOBUILD) -o $(BINDIR)/$(NAME)-$@ $(GOFILES) # https://github.com/tc-hib/go-winres -windows-amd64: winres - go-winres make +windows-amd64: + GOOS=windows GOARCH=amd64 go-winres make --in winres/winres.json --out winres/rsrc GOOS=windows GOARCH=amd64 CGO_ENABLED=0 go build -ldflags="-s -w -H windowsgui" -o $(BINDIR)/$(NAME)-$@.exe $(GOFILES) +windows-arm64: + GOOS=windows GOARCH=arm64 go-winres make --in winres/winres.json --out winres/rsrc + GOOS=windows GOARCH=arm64 CGO_ENABLED=0 go build -ldflags="-s -w -H windowsgui" -o $(BINDIR)/$(NAME)-$@.exe $(GOFILES) + # go install gioui.org/cmd/gogio@latest android: - gogio -x -work -target android -minsdk 22 -version 1 -appid gost.plus -o $(BINDIR)/$(NAME)-$(VERSION).apk . + gogio -x -work -target android -minsdk 33 -version $(VERSION).2 -signkey build/sign.keystore -signpass android -appid gost.plus -o $(BINDIR)/$(NAME)-$(VERSION).aab . gz_releases=$(addsuffix .gz, $(PLATFORM_LIST)) zip_releases=$(addsuffix .zip, $(WINDOWS_ARCH_LIST)) @@ -43,4 +52,5 @@ $(zip_releases): %.zip : % releases: $(gz_releases) $(zip_releases) clean: - rm $(BINDIR)/* + rm *.syso -f + rm $(BINDIR)/* -rf diff --git a/README.md b/README.md deleted file mode 100644 index 95d61ec..0000000 --- a/README.md +++ /dev/null @@ -1,44 +0,0 @@ -# GOST-PLUS - -A cross-platform GUI client for [GOST.PLUS](https://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 diff --git a/assets/add-android.png b/assets/add-android.png deleted file mode 100644 index f83ef44..0000000 Binary files a/assets/add-android.png and /dev/null differ diff --git a/assets/add.png b/assets/add.png deleted file mode 100644 index f82a663..0000000 Binary files a/assets/add.png and /dev/null differ diff --git a/assets/edit-android.png b/assets/edit-android.png deleted file mode 100644 index bdd580f..0000000 Binary files a/assets/edit-android.png and /dev/null differ diff --git a/assets/edit.png b/assets/edit.png deleted file mode 100644 index a05aab2..0000000 Binary files a/assets/edit.png and /dev/null differ diff --git a/assets/file-tunnel.gif b/assets/file-tunnel.gif deleted file mode 100644 index 3cb7854..0000000 Binary files a/assets/file-tunnel.gif and /dev/null differ diff --git a/assets/http-tunnel.gif b/assets/http-tunnel.gif deleted file mode 100644 index 738e9cd..0000000 Binary files a/assets/http-tunnel.gif and /dev/null differ diff --git a/assets/list-android.png b/assets/list-android.png deleted file mode 100644 index 080c8a1..0000000 Binary files a/assets/list-android.png and /dev/null differ diff --git a/assets/list.png b/assets/list.png deleted file mode 100644 index a08f5f4..0000000 Binary files a/assets/list.png and /dev/null differ diff --git a/assets/menu.png b/assets/menu.png deleted file mode 100644 index 5214f70..0000000 Binary files a/assets/menu.png and /dev/null differ diff --git a/assets/tcp-tunnel.gif b/assets/tcp-tunnel.gif deleted file mode 100644 index 7e71854..0000000 Binary files a/assets/tcp-tunnel.gif and /dev/null differ diff --git a/assets/udp-tunnel.gif b/assets/udp-tunnel.gif deleted file mode 100644 index e5d62f3..0000000 Binary files a/assets/udp-tunnel.gif and /dev/null differ diff --git a/config/config.go b/config/config.go index 99407fe..5b6df6d 100644 --- a/config/config.go +++ b/config/config.go @@ -2,14 +2,15 @@ package config import ( "bytes" - "log" + "fmt" + "log/slog" "os" "path/filepath" - "sync" + "sync/atomic" + "time" "gioui.org/app" "github.com/go-gost/core/logger" - "github.com/go-gost/x/config" xconfig "github.com/go-gost/x/config" logger_parser "github.com/go-gost/x/config/parsing/logger" "gopkg.in/yaml.v3" @@ -23,12 +24,16 @@ var ( configDir string ) +func init() { + config.Store(&Config{}) +} + func Init() { - log.SetFlags(log.Lshortfile | log.LstdFlags | log.Lmicroseconds) + slog.SetDefault(slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{AddSource: true}))) dir, err := app.DataDir() if err != nil { - log.Println(err) + slog.Error(fmt.Sprintf("appDir: %v", err)) } if dir == "" { dir, _ = os.Getwd() @@ -36,55 +41,49 @@ func Init() { configDir = filepath.Join(dir, "gost.plus") os.MkdirAll(configDir, 0755) - log.Println("config dir:", configDir) + slog.Info(fmt.Sprintf("appDir: %s", configDir)) - if err := global.Load(); err != nil { - log.Println(err) + cfg := Get() + if err := cfg.load(); err != nil { + slog.Error(fmt.Sprintf("load config: %v", err)) if _, ok := err.(*os.PathError); ok { - global.Write() + cfg.Write() } } - if global.Log == nil { - logDir := filepath.Join(configDir, "logs") - os.MkdirAll(logDir, 0755) - log.Println("log dir:", logDir) + Set(cfg) - global.Log = &xconfig.LogConfig{ - Output: filepath.Join(logDir, "gost-plus.log"), + initLog() +} + +func initLog() { + cfg := Get().Log + if cfg == nil { + cfg = &xconfig.LogConfig{ + Output: "stdout", Level: string(logger.InfoLevel), Format: string(logger.JSONFormat), - Rotation: &xconfig.LogRotationConfig{ - MaxSize: 10, - MaxAge: 7, - MaxBackups: 10, - LocalTime: true, - Compress: true, - }, } } - logger.SetDefault(logger_parser.ParseLogger(&xconfig.LoggerConfig{Log: global.Log})) + logger.SetDefault(logger_parser.ParseLogger(&xconfig.LoggerConfig{Log: cfg})) } var ( - global = &Config{} - globalMux sync.RWMutex + config atomic.Value ) -func Global() *Config { - globalMux.RLock() - defer globalMux.RUnlock() - +func Get() *Config { + c := config.Load().(*Config) cfg := &Config{} - *cfg = *global + *cfg = *c return cfg } func Set(c *Config) { - globalMux.Lock() - defer globalMux.Unlock() - - global = c + if c == nil { + c = &Config{} + } + config.Store(c) } type Settings struct { @@ -104,18 +103,19 @@ type Tunnel struct { Keepalive bool `yaml:",omitempty"` TTL int `yaml:"ttl,omitempty"` - Favorite bool - Closed bool + Favorite bool + Closed bool + CreatedAt time.Time } type Config struct { Settings *Settings Tunnels []*Tunnel EntryPoints []*Tunnel - Log *config.LogConfig + Log *xconfig.LogConfig } -func (c *Config) Load() error { +func (c *Config) load() error { f, err := os.Open(filepath.Join(configDir, configFile)) if err != nil { return err diff --git a/go.mod b/go.mod index 5b1dbde..f35567d 100644 --- a/go.mod +++ b/go.mod @@ -1,65 +1,21 @@ -module github.com/go-gost/gost-plus +module github.com/go-gost/gost.plus -go 1.21.1 +go 1.22.0 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-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 + gioui.org v0.5.0 + gioui.org/x v0.5.0 + golang.org/x/exp/shiny v0.0.0-20240103183307-be819d1f06fc + gopkg.in/natefinch/lumberjack.v2 v2.2.1 + gopkg.in/yaml.v3 v3.0.1 ) require ( gioui.org/cpu v0.0.0-20210817075930-8d6a761490d2 // indirect gioui.org/shader v1.0.8 // indirect - github.com/beorn7/perks v1.0.1 // indirect - github.com/cespare/xxhash/v2 v2.2.0 // indirect - github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect - github.com/fsnotify/fsnotify v1.7.0 // indirect - github.com/go-gost/gosocks5 v0.4.0 // indirect - github.com/go-gost/plugin v0.0.0-20231119084331-d49a1cb23b3b // indirect - github.com/go-gost/relay v0.4.1-0.20230916134211-828f314ddfe7 // indirect - github.com/go-gost/tls-dissector v0.0.2-0.20220408131628-aac992c27451 // indirect - github.com/go-redis/redis/v8 v8.11.5 // indirect github.com/go-text/typesetting v0.0.0-20230803102845-24e03d8b5372 // indirect - github.com/gobwas/glob v0.2.3 // indirect - github.com/golang/protobuf v1.5.3 // indirect - github.com/gorilla/websocket v1.5.1 // indirect - github.com/hashicorp/hcl v1.0.0 // indirect - github.com/magiconair/properties v1.8.7 // indirect - github.com/matttproud/golang_protobuf_extensions v1.0.4 // indirect - github.com/mitchellh/mapstructure v1.5.0 // indirect - github.com/patrickmn/go-cache v2.1.0+incompatible // indirect - github.com/pelletier/go-toml/v2 v2.1.1 // indirect - github.com/pires/go-proxyproto v0.7.0 // indirect - github.com/prometheus/client_golang v1.17.0 // indirect - github.com/prometheus/client_model v0.4.1-0.20230718164431-9a2bf3000d16 // indirect - github.com/prometheus/common v0.44.0 // indirect - github.com/prometheus/procfs v0.11.1 // indirect - github.com/rs/xid v1.3.0 // indirect - github.com/sagikazarmark/locafero v0.4.0 // indirect - github.com/sagikazarmark/slog-shim v0.1.0 // indirect - github.com/sirupsen/logrus v1.8.1 // indirect - github.com/sourcegraph/conc v0.3.0 // indirect - github.com/spf13/afero v1.11.0 // indirect - github.com/spf13/cast v1.6.0 // indirect - github.com/spf13/pflag v1.0.5 // indirect - github.com/subosito/gotenv v1.6.0 // indirect - github.com/xtaci/smux v1.5.24 // indirect - github.com/yl2chen/cidranger v1.0.2 // indirect - go.uber.org/multierr v1.11.0 // indirect - golang.org/x/exp v0.0.0-20231206192017-f3f8817b8deb // indirect - golang.org/x/image v0.7.0 // indirect - golang.org/x/net v0.19.0 // indirect - golang.org/x/sys v0.15.0 // indirect + golang.org/x/exp v0.0.0-20240222234643-814bf88cf225 // indirect + golang.org/x/image v0.14.0 // indirect + golang.org/x/sys v0.18.0 // indirect golang.org/x/text v0.14.0 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20231120223509-83a465c0220f // indirect - google.golang.org/grpc v1.59.0 // indirect - google.golang.org/protobuf v1.31.0 // indirect - gopkg.in/ini.v1 v1.67.0 // indirect - gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index a773229..34f5c26 100644 --- a/go.sum +++ b/go.sum @@ -1,199 +1,31 @@ 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.4.1 h1:sCTw5Fexg0xg9CxYmbrkKtHXcobf0JMbl5XpF2TC/zc= -gioui.org v0.4.1/go.mod h1:2atiYR4upH71/6ehnh6XsUELa7JZOrOHHNMDxGBZF0Q= +gioui.org v0.5.0 h1:07g7/LY1MFuTncfO4A5DIKMMsQV6PkPHyx0MhDqgmYY= +gioui.org v0.5.0/go.mod h1:2atiYR4upH71/6ehnh6XsUELa7JZOrOHHNMDxGBZF0Q= 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= -gioui.org/x v0.4.0 h1:H6DofC86KoG51wgzeeA4ujZDDfcIa8vbL+jD9SpF/D8= -gioui.org/x v0.4.0/go.mod h1:YAoFl2lbeARk4LopDXHK1N7fBQJupPYDSm9maf6tFlM= -github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= -github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= -github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= -github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= -github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= -github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= -github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= -github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= -github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= -github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= -github.com/go-gost/core v0.0.0-20231119081403-abc73f2ca2b7 h1:fxVUlZANqPApygO7lT8bYySyajiCFA62bDiNorral1w= -github.com/go-gost/core v0.0.0-20231119081403-abc73f2ca2b7/go.mod h1:ndkgWVYRLwupVaFFWv8ML1Nr8tD3xhHK245PLpUDg4E= -github.com/go-gost/gosocks5 v0.4.0 h1:EIrOEkpJez4gwHrMa33frA+hHXJyevjp47thpMQsJzI= -github.com/go-gost/gosocks5 v0.4.0/go.mod h1:1G6I7HP7VFVxveGkoK8mnprnJqSqJjdcASKsdUn4Pp4= -github.com/go-gost/plugin v0.0.0-20231119084331-d49a1cb23b3b h1:ZmnYutflq+KOZK+Px5RDckorDSxTYlkT4aQbjTC8/C4= -github.com/go-gost/plugin v0.0.0-20231119084331-d49a1cb23b3b/go.mod h1:qXr2Zm9Ex2ATqnWuNUzVZqySPMnuIihvblYZt4MlZLw= -github.com/go-gost/relay v0.4.1-0.20230916134211-828f314ddfe7 h1:qAG1OyjvdA5h221CfFSS3J359V3d2E7dJWyP29QoDSI= -github.com/go-gost/relay v0.4.1-0.20230916134211-828f314ddfe7/go.mod h1:lcX+23LCQ3khIeASBo+tJ/WbwXFO32/N5YN6ucuYTG8= -github.com/go-gost/tls-dissector v0.0.2-0.20220408131628-aac992c27451 h1:xj8gUZGYO3nb5+6Bjw9+tsFkA9sYynrOvDvvC4uDV2I= -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= +gioui.org/x v0.5.0 h1:NVKTn5AZuYhkAnF7MYcy1dIes36+U1N4gUTsgBhfr4A= +gioui.org/x v0.5.0/go.mod h1:X4UBhvanAN+8S16L3K6jDMrVo7Dii7NptgBpOLBD7E4= github.com/go-text/typesetting v0.0.0-20230803102845-24e03d8b5372 h1:FQivqchis6bE2/9uF70M2gmmLpe82esEm2QadL0TEJo= github.com/go-text/typesetting v0.0.0-20230803102845-24e03d8b5372/go.mod h1:evDBbvNR/KaVFZ2ZlDSOWWXIUKq0wCOEtzLxRM8SG3k= github.com/go-text/typesetting-utils v0.0.0-20230616150549-2a7df14b6a22 h1:LBQTFxP2MfsyEDqSKmUBZaDuDHN1vpqDyOZjcqS7MYI= github.com/go-text/typesetting-utils v0.0.0-20230616150549-2a7df14b6a22/go.mod h1:DDxDdQEnB70R8owOx3LVpEFvpMK9eeH1o2r0yZhFI9o= -github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y= -github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8= -github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= -github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= -github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= -github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= -github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= -github.com/google/uuid v1.4.0 h1:MtMxsa51/r9yyhkyLsVeVt0B+BGQZzpQiTQ4eHZ8bc4= -github.com/google/uuid v1.4.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY= -github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY= -github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= -github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= -github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= -github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= -github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= -github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= -github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= -github.com/matttproud/golang_protobuf_extensions v1.0.4 h1:mmDVorXM7PCGKw94cs5zkfA9PSy5pEvNWRP0ET0TIVo= -github.com/matttproud/golang_protobuf_extensions v1.0.4/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4= -github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= -github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= -github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE= -github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= -github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE= -github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU= -github.com/onsi/gomega v1.18.1 h1:M1GfJqGRrBrrGGsbxzV5dqM2U2ApXefZCQpkukxYRLE= -github.com/onsi/gomega v1.18.1/go.mod h1:0q+aL8jAiMXy9hbwj2mr5GziHiwhAIQpFmmtT5hitRs= -github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc= -github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ= -github.com/pelletier/go-toml/v2 v2.1.1 h1:LWAJwfNvjQZCFIDKWYQaM62NcYeYViCmWIwmOStowAI= -github.com/pelletier/go-toml/v2 v2.1.1/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc= -github.com/pires/go-proxyproto v0.7.0 h1:IukmRewDQFWC7kfnb66CSomk2q/seBuilHBYFwyq0Hs= -github.com/pires/go-proxyproto v0.7.0/go.mod h1:Vz/1JPY/OACxWGQNIRY2BeyDmpoaWmEP40O9LbuiFR4= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= -github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/prometheus/client_golang v1.17.0 h1:rl2sfwZMtSthVU752MqfjQozy7blglC+1SOtjMAMh+Q= -github.com/prometheus/client_golang v1.17.0/go.mod h1:VeL+gMmOAxkS2IqfCq0ZmHSL+LjWfWDUmp1mBz9JgUY= -github.com/prometheus/client_model v0.4.1-0.20230718164431-9a2bf3000d16 h1:v7DLqVdK4VrYkVD5diGdl4sxJurKJEMnODWRJlxV9oM= -github.com/prometheus/client_model v0.4.1-0.20230718164431-9a2bf3000d16/go.mod h1:oMQmHW1/JoDwqLtg57MGgP/Fb1CJEYF2imWWhWtMkYU= -github.com/prometheus/common v0.44.0 h1:+5BrQJwiBB9xsMygAB3TNvpQKOwlkc25LbISbrdOOfY= -github.com/prometheus/common v0.44.0/go.mod h1:ofAIvZbQ1e/nugmZGz4/qCb9Ap1VoSTIO7x0VV9VvuY= -github.com/prometheus/procfs v0.11.1 h1:xRC8Iq1yyca5ypa9n1EZnWZkt7dwcoRPQwX/5gwaUuI= -github.com/prometheus/procfs v0.11.1/go.mod h1:eesXgaPo1q7lBpVMoMy0ZOFTth9hBn4W/y0/p/ScXhY= -github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= -github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= -github.com/rs/xid v1.3.0 h1:6NjYksEUlhurdVehpc7S7dk6DAmcKv8V9gG0FsVN2U4= -github.com/rs/xid v1.3.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= -github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ= -github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4= -github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE= -github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ= -github.com/sirupsen/logrus v1.8.1 h1:dJKuHgqk1NNQlqoA6BTlM1Wf9DOH3NBjQyu0h9+AZZE= -github.com/sirupsen/logrus v1.8.1/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= -github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= -github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= -github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= -github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY= -github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0= -github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= -github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= -github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/spf13/viper v1.18.1 h1:rmuU42rScKWlhhJDyXZRKJQHXFX02chSVW1IvkPGiVM= -github.com/spf13/viper v1.18.1/go.mod h1:EKmWIqdnk5lOcmR72yw6hS+8OPYcwD0jteitLMVB+yk= -github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= -github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= -github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= -github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= -github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= -github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= -github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= -github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= -github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= -github.com/xtaci/smux v1.5.24 h1:77emW9dtnOxxOQ5ltR+8BbsX1kzcOxQ5gB+aaV9hXOY= -github.com/xtaci/smux v1.5.24/go.mod h1:OMlQbT5vcgl2gb49mFkYo6SMf+zP3rcjcwQz7ZU7IGY= -github.com/yl2chen/cidranger v1.0.2 h1:lbOWZVCG1tCRX4u24kuM1Tb4nHqWkDxwLdoS+SevawU= -github.com/yl2chen/cidranger v1.0.2/go.mod h1:9U1yz7WPYDwf0vpNWFaeRh0bjwz5RVgRy/9UEQfHl0g= -github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= -go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= -go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= -golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/exp v0.0.0-20231206192017-f3f8817b8deb h1:c0vyKkb6yr3KR7jEfJaOSv4lG7xPkbN6r52aJz1d8a8= -golang.org/x/exp v0.0.0-20231206192017-f3f8817b8deb/go.mod h1:iRJReGqOEeBhDZGkGbynYwcHlctCvnjTYIamk7uXpHI= -golang.org/x/exp/shiny v0.0.0-20231206192017-f3f8817b8deb h1:t3SA1mKG2eyhChDjOL0n0VLKZQmqU4J8UlCoQ0+nqSk= -golang.org/x/exp/shiny v0.0.0-20231206192017-f3f8817b8deb/go.mod h1:UH99kUObWAZkDnWqppdQe5ZhPYESUw8I0zVV1uWBR+0= -golang.org/x/image v0.7.0 h1:gzS29xtG1J5ybQlv0PuyfE3nmc6R4qB73m6LUUmvFuw= -golang.org/x/image v0.7.0/go.mod h1:nd/q4ef1AKKYl/4kft7g+6UyGbdiqWqTP1ZAbRoV7Rg= -golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= -golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= -golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= -golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= -golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= -golang.org/x/net v0.19.0 h1:zTwKpTd2XuCqf8huc7Fo2iSy+4RHPd10s4KzeTnVr1c= -golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U= -golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc= -golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= -golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= -golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= -golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= -golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/exp v0.0.0-20240222234643-814bf88cf225 h1:LfspQV/FYTatPTr/3HzIcmiUFH7PGP+OQ6mgDYo3yuQ= +golang.org/x/exp v0.0.0-20240222234643-814bf88cf225/go.mod h1:CxmFvTBINI24O/j8iY7H1xHzx2i4OsyguNBmN/uPtqc= +golang.org/x/exp/shiny v0.0.0-20240103183307-be819d1f06fc h1:OG+uKOKt/BW+ydf/M7gym7ONo8U+dyIlLazys3du298= +golang.org/x/exp/shiny v0.0.0-20240103183307-be819d1f06fc/go.mod h1:3F+MieQB7dRYLTmnncoFbb1crS5lfQoTfDgQy6K4N0o= +golang.org/x/image v0.14.0 h1:tNgSxAFe3jC4uYqvZdTr84SZoM1KfwdC9SKIFrLjFn4= +golang.org/x/image v0.14.0/go.mod h1:HUYqC05R2ZcZ3ejNQsIHQDQiwWM4JBqmm6MKANTp4LE= +golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4= +golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= -golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= -golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= -golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -google.golang.org/genproto/googleapis/rpc v0.0.0-20231120223509-83a465c0220f h1:ultW7fxlIvee4HYrtnaRPon9HpEgFk5zYpmfMgtKB5I= -google.golang.org/genproto/googleapis/rpc v0.0.0-20231120223509-83a465c0220f/go.mod h1:L9KNLi232K1/xB6f7AlSX692koaRnKaWSR0stBki0Yc= -google.golang.org/grpc v1.59.0 h1:Z5Iec2pjwb+LEOqzpB2MR12/eKFhDPhuqW91O+4bwUk= -google.golang.org/grpc v1.59.0/go.mod h1:aUPDwccQo6OTjy7Hct4AfBPD1GptF4fyUjIkQ9YtF98= -google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= -google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= -google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8= -google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= -gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= -gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= -gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= -gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= -gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= -gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= -gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= -gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/main.go b/main.go index 253da97..81f8deb 100644 --- a/main.go +++ b/main.go @@ -7,23 +7,21 @@ import ( "gioui.org/app" _ "gioui.org/app/permission/storage" - "gioui.org/io/system" - "gioui.org/layout" + "gioui.org/io/key" "gioui.org/op" - "github.com/go-gost/gost-plus/config" - "github.com/go-gost/gost-plus/tunnel" - "github.com/go-gost/gost-plus/tunnel/entrypoint" - "github.com/go-gost/gost-plus/ui" + "github.com/go-gost/gost.plus/config" + "github.com/go-gost/gost.plus/tunnel" + "github.com/go-gost/gost.plus/tunnel/entrypoint" + "github.com/go-gost/gost.plus/ui" + _ "github.com/go-gost/gost.plus/winres" ) func main() { - config.Init() - tunnel.LoadConfig() - entrypoint.LoadConfig() + Init() go func() { w := app.NewWindow( - app.Title("GOST.PLUS"), + app.Title("GOST+"), app.MinSize(800, 600), ) err := run(w) @@ -40,12 +38,22 @@ func run(w *app.Window) error { var ops op.Ops for { switch e := w.NextEvent().(type) { - case system.DestroyEvent: + case app.DestroyEvent: return e.Err - case system.FrameEvent: - gtx := layout.NewContext(&ops, e) + case app.FrameEvent: + gtx := app.NewContext(&ops, e) ui.Layout(gtx) e.Frame(gtx.Ops) + case key.Event: + if e.Name == key.NameBack { + return nil + } } } } + +func Init() { + config.Init() + tunnel.LoadConfig() + entrypoint.LoadConfig() +} diff --git a/tunnel/entrypoint/entrypoint.go b/tunnel/entrypoint/entrypoint.go index b3d4342..032d033 100644 --- a/tunnel/entrypoint/entrypoint.go +++ b/tunnel/entrypoint/entrypoint.go @@ -5,8 +5,8 @@ import ( "sync" "github.com/go-gost/core/logger" - "github.com/go-gost/gost-plus/config" - "github.com/go-gost/gost-plus/tunnel" + "github.com/go-gost/gost.plus/config" + "github.com/go-gost/gost.plus/tunnel" ) const ( @@ -97,7 +97,7 @@ func Delete(id string) { } func LoadConfig() { - for _, cfg := range config.Global().EntryPoints { + for _, cfg := range config.Get().EntryPoints { if cfg == nil { continue } @@ -129,7 +129,7 @@ func LoadConfig() { } func SaveConfig() error { - cfg := config.Global() + cfg := config.Get() cfg.EntryPoints = nil for i := 0; i < Count(); i++ { diff --git a/tunnel/entrypoint/tcp.go b/tunnel/entrypoint/tcp.go index a15573b..f6ea1aa 100644 --- a/tunnel/entrypoint/tcp.go +++ b/tunnel/entrypoint/tcp.go @@ -12,7 +12,7 @@ import ( "github.com/go-gost/core/listener" "github.com/go-gost/core/logger" "github.com/go-gost/core/service" - "github.com/go-gost/gost-plus/tunnel" + "github.com/go-gost/gost.plus/tunnel" "github.com/go-gost/x/config" chain_parser "github.com/go-gost/x/config/parsing/chain" "github.com/go-gost/x/handler/forward/local" @@ -189,6 +189,13 @@ func (s *tcpEntryPoint) Run() (err error) { return nil } +func (s *tcpEntryPoint) Status() *xservice.Status { + if ss, _ := s.forward.(tunnel.ServiceStatus); ss != nil { + return ss.Status() + } + return nil +} + func (s *tcpEntryPoint) Close() error { defer func() { select { diff --git a/tunnel/entrypoint/udp.go b/tunnel/entrypoint/udp.go index f5e9faa..7a7e869 100644 --- a/tunnel/entrypoint/udp.go +++ b/tunnel/entrypoint/udp.go @@ -13,7 +13,7 @@ import ( "github.com/go-gost/core/listener" "github.com/go-gost/core/logger" "github.com/go-gost/core/service" - "github.com/go-gost/gost-plus/tunnel" + "github.com/go-gost/gost.plus/tunnel" "github.com/go-gost/x/config" chain_parser "github.com/go-gost/x/config/parsing/chain" "github.com/go-gost/x/handler/forward/local" @@ -194,6 +194,13 @@ func (s *udpEntryPoint) Run() (err error) { return nil } +func (s *udpEntryPoint) Status() *xservice.Status { + if ss, _ := s.forward.(tunnel.ServiceStatus); ss != nil { + return ss.Status() + } + return nil +} + func (s *udpEntryPoint) Close() error { defer func() { select { diff --git a/tunnel/file.go b/tunnel/file.go index 5c8df92..62dff62 100644 --- a/tunnel/file.go +++ b/tunnel/file.go @@ -227,6 +227,13 @@ func (s *fileTunnel) Run() (err error) { return nil } +func (s *fileTunnel) Status() *xservice.Status { + if ss, _ := s.forward.(ServiceStatus); ss != nil { + return ss.Status() + } + return nil +} + func (s *fileTunnel) Close() error { defer func() { select { diff --git a/tunnel/http.go b/tunnel/http.go index 8223b4e..91f130e 100644 --- a/tunnel/http.go +++ b/tunnel/http.go @@ -188,15 +188,15 @@ func (s *httpTunnel) Run() (err error) { node := cfg.Forwarder.Nodes[0] var nodeOpts []chain.NodeOption - if node.Auth != nil { - auther := xauth.NewAuthenticator(xauth.AuthsOption(map[string]string{node.Auth.Username: node.Auth.Password})) - nodeOpts = append(nodeOpts, chain.AutherNodeOption(auther)) - } if node.HTTP != nil { - nodeOpts = append(nodeOpts, chain.HTTPNodeOption(&chain.HTTPNodeSettings{ + httpNodeSettings := &chain.HTTPNodeSettings{ Host: node.HTTP.Host, Header: node.HTTP.Header, - })) + } + if node.HTTP.Auth != nil { + httpNodeSettings.Auther = xauth.NewAuthenticator(xauth.AuthsOption(map[string]string{node.HTTP.Auth.Username: node.HTTP.Auth.Password})) + } + nodeOpts = append(nodeOpts, chain.HTTPNodeOption(httpNodeSettings)) } if node.TLS != nil { nodeOpts = append(nodeOpts, chain.TLSNodeOption(&chain.TLSNodeSettings{ @@ -219,6 +219,13 @@ func (s *httpTunnel) Run() (err error) { return nil } +func (s *httpTunnel) Status() *xservice.Status { + if ss, _ := s.forward.(ServiceStatus); ss != nil { + return ss.Status() + } + return nil +} + func (s *httpTunnel) Close() error { defer func() { select { diff --git a/tunnel/tcp.go b/tunnel/tcp.go index 8f6680b..a9ec5bc 100644 --- a/tunnel/tcp.go +++ b/tunnel/tcp.go @@ -185,6 +185,13 @@ func (s *tcpTunnel) Run() (err error) { return nil } +func (s *tcpTunnel) Status() *xservice.Status { + if ss, _ := s.forward.(ServiceStatus); ss != nil { + return ss.Status() + } + return nil +} + func (s *tcpTunnel) Close() error { defer func() { select { diff --git a/tunnel/tunnel.go b/tunnel/tunnel.go index cf3e785..3a0e547 100644 --- a/tunnel/tunnel.go +++ b/tunnel/tunnel.go @@ -5,10 +5,11 @@ import ( "sync" "github.com/go-gost/core/logger" - "github.com/go-gost/gost-plus/config" + "github.com/go-gost/gost.plus/config" xconfig "github.com/go-gost/x/config" _ "github.com/go-gost/x/connector/tunnel" _ "github.com/go-gost/x/dialer/ws" + xservice "github.com/go-gost/x/service" ) const ( @@ -96,6 +97,10 @@ func TTLOption(ttl int) Option { } } +type ServiceStatus interface { + Status() *xservice.Status +} + type Tunnel interface { ID() string Type() string @@ -104,6 +109,7 @@ type Tunnel interface { Entrypoint() string Options() Options Run() error + Status() *xservice.Status Favorite(b bool) IsFavorite() bool Close() error @@ -215,37 +221,37 @@ func ChainConfig(id string, name string) *xconfig.ChainConfig { } func LoadConfig() { - for _, tun := range config.Global().Tunnels { + for _, cfg := range config.Get().Tunnels { + if cfg == nil { + continue + } + + tun := createTunnel(cfg.Type, Options{ + ID: cfg.ID, + Name: cfg.Name, + Endpoint: cfg.Endpoint, + Hostname: cfg.Hostname, + Username: cfg.Username, + Password: cfg.Password, + EnableTLS: cfg.EnableTLS, + }) if tun == nil { continue } - s := createTunnel(tun.Type, Options{ - ID: tun.ID, - Name: tun.Name, - Endpoint: tun.Endpoint, - Hostname: tun.Hostname, - Username: tun.Username, - Password: tun.Password, - EnableTLS: tun.EnableTLS, - }) - if s == nil { - continue - } - - if tun.Closed { - s.Close() + if cfg.Closed { + tun.Close() } else { - s.Run() + tun.Run() } - s.Favorite(tun.Favorite) - Add(s) + tun.Favorite(cfg.Favorite) + Add(tun) } } func SaveConfig() error { - cfg := config.Global() + cfg := config.Get() cfg.Tunnels = nil for i := 0; i < Count(); i++ { diff --git a/tunnel/udp.go b/tunnel/udp.go index 9d7516f..0208cba 100644 --- a/tunnel/udp.go +++ b/tunnel/udp.go @@ -185,6 +185,13 @@ func (s *udpTunnel) Run() (err error) { return nil } +func (s *udpTunnel) Status() *xservice.Status { + if ss, _ := s.forward.(ServiceStatus); ss != nil { + return ss.Status() + } + return nil +} + func (s *udpTunnel) Close() error { defer func() { select { diff --git a/ui/fonts/NotoSansSC-Bold.ttf b/ui/fonts/NotoSansSC-Bold.ttf deleted file mode 100644 index b9010df..0000000 Binary files a/ui/fonts/NotoSansSC-Bold.ttf and /dev/null differ diff --git a/ui/fonts/NotoSansSC-Regular.ttf b/ui/fonts/NotoSansSC-Regular.ttf deleted file mode 100644 index 4d4cadb..0000000 Binary files a/ui/fonts/NotoSansSC-Regular.ttf and /dev/null differ diff --git a/ui/fonts/font.go b/ui/fonts/font.go deleted file mode 100644 index 40f154c..0000000 --- a/ui/fonts/font.go +++ /dev/null @@ -1,36 +0,0 @@ -package fonts - -import ( - _ "embed" - "fmt" - - "gioui.org/font" - "gioui.org/font/opentype" -) - -//go:embed NotoSansSC-Regular.ttf -var fontNotoRegular []byte - -//go:embed NotoSansSC-Bold.ttf -var fontNotoBold []byte - -var ( - collection []font.FontFace -) - -func init() { - register(fontNotoRegular) - register(fontNotoBold) -} - -func Collection() []font.FontFace { - return collection -} - -func register(ttf []byte) { - faces, err := opentype.ParseCollection(ttf) - if err != nil { - panic(fmt.Errorf("failed to parse font: %v", err)) - } - collection = append(collection, faces[0]) -} diff --git a/ui/icons/icons.go b/ui/icons/icons.go index 3eae400..ed77d7f 100644 --- a/ui/icons/icons.go +++ b/ui/icons/icons.go @@ -25,29 +25,40 @@ func init() { IconApp = &widget.Image{ Src: paint.NewImageOp(img), - Fit: widget.Unscaled, + Fit: widget.ScaleDown, } } 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) - IconVisibility = mustIcon(icons.ActionVisibility) - IconVisibilityOff = mustIcon(icons.ActionVisibilityOff) + IconHome = mustIcon(icons.ActionHome) + IconFavorite = mustIcon(icons.ActionFavorite) + IconAdd = mustIcon(icons.ContentAdd) + IconRemove = mustIcon(icons.ContentRemove) + IconSettings = mustIcon(icons.ActionSettings) + IconDone = mustIcon(icons.ActionDone) + IconRadioButtonUnchecked = mustIcon(icons.ToggleRadioButtonUnchecked) + IconRadioButtonChecked = mustIcon(icons.ToggleRadioButtonChecked) + IconForward = mustIcon(icons.NavigationChevronRight) + IconEdit = mustIcon(icons.EditorModeEdit) + IconDelete = mustIcon(icons.ActionDelete) + IconDeleteForever = mustIcon(icons.ActionDeleteForever) + 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) + IconNavArrowForward = mustIcon(icons.NavigationArrowForward) + IconCircle = mustIcon(icons.ImageLens) + IconNavRight = mustIcon(icons.NavigationChevronRight) + IconNavExpandLess = mustIcon(icons.NavigationExpandLess) + IconNavExpandMore = mustIcon(icons.NavigationExpandMore) + IconActionCode = mustIcon(icons.ActionCode) + IconActionUpdate = mustIcon(icons.ActionUpdate) + IconActionHourGlassEmpty = mustIcon(icons.ActionHourglassEmpty) ) func mustIcon(data []byte) *widget.Icon { diff --git a/ui/page/about.go b/ui/page/about.go deleted file mode 100644 index 15ca48d..0000000 --- a/ui/page/about.go +++ /dev/null @@ -1,57 +0,0 @@ -package page - -import ( - "gioui.org/font" - "gioui.org/layout" - "gioui.org/widget/material" - "github.com/go-gost/gost-plus/ui/icons" - "github.com/go-gost/gost-plus/version" -) - -type aboutPage struct { - router *Router - - list layout.List -} - -func NewAboutPage(r *Router) Page { - return &aboutPage{ - router: r, - } -} - -func (p *aboutPage) Init(opts ...PageOption) { - p.router.bar.SetActions(nil, nil) - p.router.bar.Title = "About" - p.router.bar.NavigationIcon = icons.IconBack -} - -func (p *aboutPage) Layout(gtx C, th *material.Theme) D { - return layout.Center.Layout(gtx, func(gtx C) D { - return p.list.Layout(gtx, 1, func(gtx C, _ int) D { - return layout.UniformInset(10).Layout(gtx, func(gtx C) D { - return layout.Flex{ - Axis: layout.Vertical, - Alignment: layout.Middle, - }.Layout(gtx, - layout.Rigid(func(gtx C) D { - if icons.IconApp == nil { - return D{} - } - return icons.IconApp.Layout(gtx) - }), - layout.Rigid(layout.Spacer{Height: 10}.Layout), - layout.Rigid(func(gtx C) D { - label := material.H6(th, "GOST.PLUS") - label.Font.Weight = font.Bold - return label.Layout(gtx) - }), - layout.Rigid(layout.Spacer{Height: 10}.Layout), - layout.Rigid(func(gtx C) D { - return material.Body1(th, version.Version).Layout(gtx) - }), - ) - }) - }) - }) -} diff --git a/ui/page/entrypoint.go b/ui/page/entrypoint.go deleted file mode 100644 index cfa5f56..0000000 --- a/ui/page/entrypoint.go +++ /dev/null @@ -1,179 +0,0 @@ -package page - -import ( - "fmt" - "image/color" - "strings" - "sync/atomic" - - "gioui.org/font" - "gioui.org/layout" - "gioui.org/op" - "gioui.org/widget" - "gioui.org/widget/material" - "gioui.org/x/component" - "github.com/go-gost/gost-plus/tunnel/entrypoint" - "github.com/go-gost/gost-plus/ui/icons" - "golang.org/x/exp/shiny/materialdesign/colornames" -) - -type entryPointState struct { - editor widget.Clickable -} - -type entryPointPage struct { - router *Router - - list layout.List - - wgFavorite widget.Clickable - wgAdd widget.Clickable - - entryPoints map[int]*entryPointState - favorite atomic.Bool -} - -func NewEntryPointPage(r *Router) Page { - return &entryPointPage{ - router: r, - list: layout.List{ - Axis: layout.Vertical, - Alignment: layout.Middle, - }, - entryPoints: make(map[int]*entryPointState), - } -} - -func (p *entryPointPage) Init(opts ...PageOption) { - p.router.bar.SetActions( - []component.AppBarAction{ - { - OverflowAction: component.OverflowAction{ - Name: "Favorite", - Tag: &p.wgFavorite, - }, - Layout: func(gtx C, bg, fg color.NRGBA) D { - if p.wgFavorite.Clicked(gtx) { - p.favorite.Store(!p.favorite.Load()) - } - - btn := component.SimpleIconButton(bg, fg, &p.wgFavorite, icons.IconFavorite) - if p.favorite.Load() { - btn.Color = color.NRGBA(colornames.Red500) - } else { - btn.Color = fg - } - return btn.Layout(gtx) - }, - }, - { - OverflowAction: component.OverflowAction{ - Name: "Add", - Tag: &p.wgAdd, - }, - Layout: func(gtx C, bg, fg color.NRGBA) D { - if p.wgAdd.Clicked(gtx) { - p.router.SwitchTo(Route{Path: PageMenu}) - } - return component.SimpleIconButton(bg, fg, &p.wgAdd, icons.IconAdd).Layout(gtx) - }, - }, - }, - []component.OverflowAction{ - { - Name: "About", - Tag: OverflowActionAbout, - }, - }, - ) - - p.router.bar.Title = "EntryPoints" - p.router.bar.NavigationIcon = icons.IconHome -} - -func (p *entryPointPage) Layout(gtx C, th *material.Theme) D { - favorite := p.favorite.Load() - // gtx.Constraints.Min.X = gtx.Constraints.Max.X - return p.list.Layout(gtx, entrypoint.Count(), func(gtx C, index int) D { - s := entrypoint.GetIndex(index) - if s == nil { - delete(p.entryPoints, index) - return D{} - } - - if p.entryPoints[index] == nil { - p.entryPoints[index] = &entryPointState{} - } - - if favorite && !s.IsFavorite() { - return 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 { - state := p.entryPoints[index] - if state.editor.Clicked(gtx) { - 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) - } - return material.Clickable(gtx, &state.editor, func(gtx C) D { - return layout.UniformInset(10).Layout(gtx, func(gtx C) D { - return p.layout(gtx, th, s) - }) - }) - }) - }) - }) - }) -} - -func (p *entryPointPage) layout(gtx C, th *material.Theme, ep entrypoint.EntryPoint) D { - return layout.Flex{ - Alignment: layout.Middle, - Spacing: layout.SpaceBetween, - }.Layout(gtx, - layout.Flexed(1, func(gtx C) D { - return layout.Flex{Axis: layout.Vertical}.Layout(gtx, - layout.Rigid(func(gtx C) D { - label := material.Body1(th, ep.ID()) - label.Font.Weight = font.Bold - return label.Layout(gtx) - }), - layout.Rigid(layout.Spacer{Height: 5}.Layout), - layout.Rigid(material.Body2(th, fmt.Sprintf("Type: %s", strings.ToUpper(ep.Type()))).Layout), - layout.Rigid(layout.Spacer{Height: 5}.Layout), - layout.Rigid(material.Body2(th, fmt.Sprintf("Name: %s", ep.Name())).Layout), - layout.Rigid(layout.Spacer{Height: 5}.Layout), - layout.Rigid(material.Body2(th, fmt.Sprintf("Endpoint: %s", ep.Endpoint())).Layout), - layout.Rigid(layout.Spacer{Height: 5}.Layout), - layout.Rigid(material.Body2(th, fmt.Sprintf("Entrypoint: %s", ep.Entrypoint())).Layout), - layout.Rigid(layout.Spacer{Height: 5}.Layout), - layout.Rigid(func(gtx C) D { - if err := ep.Err(); !ep.IsClosed() && err != nil { - label := material.Body2(th, err.Error()) - label.Color = color.NRGBA(colornames.Red500) - return label.Layout(gtx) - } - return D{} - }), - ) - }), - layout.Rigid(layout.Spacer{Width: 10}.Layout), - layout.Rigid(func(gtx C) D { - c := colornames.Green500 - if ep.Err() != nil { - c = colornames.Red500 - } - if ep.IsClosed() { - c = colornames.Grey500 - } - return icons.IconTunnelState.Layout(gtx, color.NRGBA(c)) - }), - ) -} diff --git a/ui/page/entrypoint/page.go b/ui/page/entrypoint/page.go new file mode 100644 index 0000000..ea83df8 --- /dev/null +++ b/ui/page/entrypoint/page.go @@ -0,0 +1,144 @@ +package entrypoint + +import ( + "image/color" + + "gioui.org/layout" + "gioui.org/widget" + "gioui.org/widget/material" + "github.com/go-gost/gost.plus/ui/icons" + "github.com/go-gost/gost.plus/ui/page" + "github.com/go-gost/gost.plus/ui/theme" + "golang.org/x/exp/shiny/materialdesign/colornames" +) + +type C = layout.Context +type D = layout.Dimensions + +type navButton struct { + btn widget.Clickable + name string + desc string + path page.PagePath +} + +type entrypointPage struct { + router *page.Router + list widget.List + + navs []navButton + + btnBack widget.Clickable +} + +func NewPage(r *page.Router) page.Page { + return &entrypointPage{ + router: r, + list: widget.List{ + List: layout.List{ + Axis: layout.Vertical, + }, + }, + navs: []navButton{ + { + name: "TCP", + desc: "Create an entrypoint to the specified TCP tunnel", + path: page.PageEntrypointTCP, + }, + { + name: "UDP", + desc: "Create an entrypoint to the specified UDP tunnel", + path: page.PageEntrypointUDP, + }, + }, + } +} + +func (p *entrypointPage) Init(opts ...page.PageOption) {} + +func (p *entrypointPage) Layout(gtx C) D { + if p.btnBack.Clicked(gtx) { + p.router.Back() + } + + th := p.router.Theme + + return layout.Flex{ + Axis: layout.Vertical, + }.Layout(gtx, + // header + layout.Rigid(func(gtx C) D { + return layout.Inset{ + Top: 8, + Bottom: 8, + Left: 8, + Right: 8, + }.Layout(gtx, func(gtx C) D { + return layout.Flex{ + Alignment: layout.Middle, + }.Layout(gtx, + layout.Rigid(func(gtx C) D { + btn := material.IconButton(th, &p.btnBack, icons.IconBack, "Back") + btn.Color = th.Fg + btn.Background = th.Bg + return btn.Layout(gtx) + }), + layout.Rigid(layout.Spacer{Width: 8}.Layout), + layout.Rigid(func(gtx C) D { + title := material.H6(th, "Entrypoint") + return title.Layout(gtx) + }), + layout.Rigid(layout.Spacer{Width: 8}.Layout), + ) + }) + }), + layout.Flexed(1, func(gtx C) D { + return p.list.Layout(gtx, len(p.navs), func(gtx C, index int) D { + if p.navs[index].btn.Clicked(gtx) { + p.router.Goto(page.Route{ + Path: p.navs[index].path, + }) + } + + return layout.Inset{ + Top: 8, + Bottom: 8, + Left: 8, + Right: 8, + }.Layout(gtx, func(gtx layout.Context) layout.Dimensions { + return material.ButtonLayoutStyle{ + Background: theme.Current().ListBg, + CornerRadius: 12, + Button: &p.navs[index].btn, + }.Layout(gtx, func(gtx C) D { + return layout.Inset{ + Top: 8, + Bottom: 8, + Left: 8, + Right: 8, + }.Layout(gtx, func(gtx C) D { + return layout.Flex{ + Alignment: layout.Middle, + Spacing: layout.SpaceBetween, + }.Layout(gtx, + layout.Flexed(1, func(gtx C) D { + return layout.Flex{ + Axis: layout.Vertical, + }.Layout(gtx, + layout.Rigid(material.Body1(th, p.navs[index].name).Layout), + layout.Rigid(layout.Spacer{Height: 8}.Layout), + layout.Rigid(material.Body2(th, p.navs[index].desc).Layout), + ) + }), + layout.Rigid(layout.Spacer{Width: 8}.Layout), + layout.Rigid(func(gtx C) D { + return icons.IconForward.Layout(gtx, color.NRGBA(colornames.Grey500)) + }), + ) + }) + }) + }) + }) + }), + ) +} diff --git a/ui/page/entrypoint/tcp/page.go b/ui/page/entrypoint/tcp/page.go new file mode 100644 index 0000000..a13271c --- /dev/null +++ b/ui/page/entrypoint/tcp/page.go @@ -0,0 +1,407 @@ +package tcp + +import ( + "fmt" + "image/color" + "net" + "strings" + + "gioui.org/layout" + "gioui.org/widget" + "gioui.org/widget/material" + "gioui.org/x/component" + "github.com/go-gost/core/logger" + "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/go-gost/gost.plus/ui/page" + "github.com/go-gost/gost.plus/ui/theme" + ui_widget "github.com/go-gost/gost.plus/ui/widget" + "github.com/google/uuid" + "golang.org/x/exp/shiny/materialdesign/colornames" +) + +type C = layout.Context +type D = layout.Dimensions + +type tcpPage struct { + router *page.Router + modal *component.ModalLayer + + btnBack widget.Clickable + btnState widget.Clickable + btnDelete widget.Clickable + btnEdit widget.Clickable + btnSave widget.Clickable + btnFavorite widget.Clickable + + list layout.List + + tunnelID component.TextField + name component.TextField + entrypoint component.TextField + + id string + edit bool + + delDialog ui_widget.Dialog +} + +func NewPage(r *page.Router) page.Page { + return &tcpPage{ + router: r, + modal: component.NewModal(), + list: layout.List{ + // NOTE: the list must be vertical + Axis: layout.Vertical, + }, + tunnelID: component.TextField{ + Editor: widget.Editor{ + SingleLine: true, + }, + }, + name: component.TextField{ + Editor: widget.Editor{ + SingleLine: true, + }, + }, + entrypoint: component.TextField{ + Editor: widget.Editor{ + SingleLine: true, + }, + }, + delDialog: ui_widget.Dialog{ + Title: "Delete entrypoint?", + }, + } +} + +func (p *tcpPage) Init(opts ...page.PageOption) { + var options page.PageOptions + for _, opt := range opts { + opt(&options) + } + + p.id = options.ID + + if p.id != "" { + p.edit = false + } else { + p.edit = true + } + + p.name.Clear() + p.entrypoint.Clear() + + s := entrypoint.Get(p.id) + if s != nil { + sopts := s.Options() + p.tunnelID.SetText(sopts.ID) + p.name.SetText(sopts.Name) + p.entrypoint.SetText(sopts.Endpoint) + } +} + +func (p *tcpPage) Layout(gtx C) D { + if p.btnBack.Clicked(gtx) { + p.router.Back() + } + if p.btnEdit.Clicked(gtx) { + p.edit = true + } + + if p.btnSave.Clicked(gtx) { + if p.id == "" { + p.create() + } else { + p.update() + } + p.router.Back() + } + + if p.btnDelete.Clicked(gtx) { + p.delDialog.Clicked = func(ok bool) { + if ok { + p.delete() + p.router.Back() + } + p.modal.Disappear(gtx.Now) + } + p.modal.Widget = func(gtx layout.Context, th *material.Theme, anim *component.VisibilityAnimation) layout.Dimensions { + return p.delDialog.Layout(gtx, th) + } + p.modal.Appear(gtx.Now) + } + + th := p.router.Theme + + defer p.modal.Layout(gtx, th) + + return layout.Flex{ + Axis: layout.Vertical, + }.Layout(gtx, + // header + layout.Rigid(func(gtx C) D { + return layout.Inset{ + Top: 8, + Bottom: 8, + Left: 8, + Right: 8, + }.Layout(gtx, func(gtx C) D { + return layout.Flex{ + Spacing: layout.SpaceBetween, + Alignment: layout.Middle, + }.Layout(gtx, + layout.Rigid(func(gtx C) D { + btn := material.IconButton(th, &p.btnBack, icons.IconBack, "Back") + btn.Color = th.Fg + btn.Background = th.Bg + return btn.Layout(gtx) + }), + layout.Rigid(layout.Spacer{Width: 8}.Layout), + layout.Flexed(1, func(gtx C) D { + title := material.H6(th, "TCP") + return title.Layout(gtx) + }), + layout.Rigid(layout.Spacer{Width: 8}.Layout), + layout.Rigid(func(gtx C) D { + ep := entrypoint.Get(p.id) + if ep == nil { + return D{} + } + + if p.btnFavorite.Clicked(gtx) { + ep.Favorite(!ep.IsFavorite()) + entrypoint.SaveConfig() + } + + btn := material.IconButton(th, &p.btnFavorite, icons.IconFavorite, "Favorite") + + if ep.IsFavorite() { + btn.Color = color.NRGBA(colornames.Red500) + } else { + btn.Color = th.Fg + } + btn.Background = th.Bg + + return btn.Layout(gtx) + }), + layout.Rigid(layout.Spacer{Width: 8}.Layout), + layout.Rigid(func(gtx C) D { + ep := entrypoint.Get(p.id) + if ep == nil { + return D{} + } + + if p.btnState.Clicked(gtx) { + p.onoff() + } + + if !ep.IsClosed() { + btn := material.IconButton(th, &p.btnState, icons.IconStop, "Stop") + + btn.Color = th.Fg + btn.Background = th.Bg + return btn.Layout(gtx) + } + + btn := material.IconButton(th, &p.btnState, icons.IconStart, "Start") + + btn.Color = th.Fg + btn.Background = th.Bg + return btn.Layout(gtx) + + }), + layout.Rigid(layout.Spacer{Width: 8}.Layout), + layout.Rigid(func(gtx C) D { + if p.id == "" { + return D{} + } + btn := material.IconButton(th, &p.btnDelete, icons.IconDelete, "Delete") + + btn.Color = th.Fg + btn.Background = th.Bg + return btn.Layout(gtx) + }), + layout.Rigid(layout.Spacer{Width: 8}.Layout), + layout.Rigid(func(gtx C) D { + if p.edit { + btn := material.IconButton(th, &p.btnSave, icons.IconDone, "Done") + btn.Color = th.Fg + btn.Background = th.Bg + return btn.Layout(gtx) + } else { + btn := material.IconButton(th, &p.btnEdit, icons.IconEdit, "Edit") + btn.Color = th.Fg + btn.Background = th.Bg + return btn.Layout(gtx) + } + }), + ) + }) + }), + layout.Flexed(1, func(gtx C) D { + return p.list.Layout(gtx, 1, func(gtx C, _ int) D { + return layout.Inset{ + Top: 8, + Bottom: 8, + Left: 8, + Right: 8, + }.Layout(gtx, func(gtx layout.Context) layout.Dimensions { + return p.layout(gtx, th) + }) + }) + }), + ) +} + +func (p *tcpPage) layout(gtx C, th *material.Theme) D { + if !p.edit { + gtx = gtx.Disabled() + } + + return component.SurfaceStyle{ + Theme: th, + ShadowStyle: component.ShadowStyle{ + CornerRadius: 12, + }, + Fill: theme.Current().ContentSurfaceBg, + }.Layout(gtx, func(gtx C) D { + return layout.Inset{ + Top: 8, + Bottom: 8, + Left: 8, + Right: 8, + }.Layout(gtx, func(gtx C) D { + return layout.Flex{ + Axis: layout.Vertical, + }.Layout(gtx, + 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() + } + + if p.id != "" { + gtx = gtx.Disabled() + } + return p.tunnelID.Layout(gtx, th, "") + }), + layout.Rigid(layout.Spacer{Height: 16}.Layout), + + layout.Rigid(func(gtx C) D { + return material.Body1(th, "Name").Layout(gtx) + }), + layout.Rigid(func(gtx C) D { + return p.name.Layout(gtx, th, "") + }), + layout.Rigid(layout.Spacer{Height: 16}.Layout), + + layout.Rigid(func(gtx C) D { + return material.Body1(th, "Entrypoint").Layout(gtx) + }), + layout.Rigid(func(gtx C) D { + if err := func() error { + addr := strings.TrimSpace(p.entrypoint.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.entrypoint.SetError(err.Error()) + } else { + p.entrypoint.ClearError() + } + + return p.entrypoint.Layout(gtx, th, "Address") + }), + layout.Rigid(layout.Spacer{Height: 8}.Layout), + ) + }) + }) +} + +func (p *tcpPage) create() error { + defer entrypoint.SaveConfig() + + ep := entrypoint.NewTCPEntryPoint( + tunnel.IDOption(strings.ToLower(strings.TrimSpace(p.tunnelID.Text()))), + tunnel.NameOption(strings.TrimSpace(p.name.Text())), + tunnel.EndpointOption(strings.TrimSpace(p.entrypoint.Text())), + ) + + entrypoint.Add(ep) + + if err := ep.Run(); err != nil { + ep.Close() + return err + } + + return nil +} + +func (p *tcpPage) update(opts ...tunnel.Option) tunnel.Tunnel { + defer entrypoint.SaveConfig() + + if t := entrypoint.Get(p.id); t != nil { + t.Close() + } + + if opts == nil { + opts = []tunnel.Option{ + tunnel.IDOption(strings.ToLower(strings.TrimSpace(p.tunnelID.Text()))), + tunnel.NameOption(strings.TrimSpace(p.name.Text())), + tunnel.EndpointOption(strings.TrimSpace(p.entrypoint.Text())), + } + } + ep := entrypoint.NewTCPEntryPoint(opts...) + + entrypoint.Set(ep) + + if err := ep.Run(); err != nil { + ep.Close() + logger.Default().Error(err) + } + + return ep +} + +func (p *tcpPage) onoff() { + ep := entrypoint.Get(p.id) + if ep == nil { + return + } + + if ep.IsClosed() { + opts := ep.Options() + p.update( + tunnel.IDOption(opts.ID), + tunnel.NameOption(opts.Name), + tunnel.EndpointOption(opts.Endpoint), + ) + } else { + ep.Close() + } + entrypoint.SaveConfig() +} + +func (p *tcpPage) delete() { + entrypoint.Delete(p.id) + entrypoint.SaveConfig() +} diff --git a/ui/page/entrypoint/udp/page.go b/ui/page/entrypoint/udp/page.go new file mode 100644 index 0000000..e701289 --- /dev/null +++ b/ui/page/entrypoint/udp/page.go @@ -0,0 +1,470 @@ +package udp + +import ( + "fmt" + "image/color" + "net" + "strconv" + "strings" + "unicode" + + "gioui.org/layout" + "gioui.org/widget" + "gioui.org/widget/material" + "gioui.org/x/component" + "github.com/go-gost/core/logger" + "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/go-gost/gost.plus/ui/page" + "github.com/go-gost/gost.plus/ui/theme" + ui_widget "github.com/go-gost/gost.plus/ui/widget" + "github.com/google/uuid" + "golang.org/x/exp/shiny/materialdesign/colornames" +) + +type C = layout.Context +type D = layout.Dimensions + +type udpPage struct { + router *page.Router + modal *component.ModalLayer + + btnBack widget.Clickable + btnState widget.Clickable + btnDelete widget.Clickable + btnEdit widget.Clickable + btnSave widget.Clickable + btnFavorite widget.Clickable + + list layout.List + + tunnelID component.TextField + name component.TextField + entrypoint component.TextField + + keepalive widget.Bool + ttl component.TextField + + id string + edit bool + + delDialog ui_widget.Dialog +} + +func NewPage(r *page.Router) page.Page { + return &udpPage{ + router: r, + modal: component.NewModal(), + list: layout.List{ + // NOTE: the list must be vertical + Axis: layout.Vertical, + }, + tunnelID: component.TextField{ + Editor: widget.Editor{ + SingleLine: true, + }, + }, + name: component.TextField{ + Editor: widget.Editor{ + SingleLine: true, + }, + }, + entrypoint: component.TextField{ + Editor: widget.Editor{ + SingleLine: true, + }, + }, + ttl: component.TextField{ + Editor: widget.Editor{ + SingleLine: true, + }, + Suffix: func(gtx C) D { + return material.Label(r.Theme, r.Theme.TextSize, "s").Layout(gtx) + }, + }, + delDialog: ui_widget.Dialog{ + Title: "Delete entrypoint?", + }, + } +} + +func (p *udpPage) Init(opts ...page.PageOption) { + var options page.PageOptions + for _, opt := range opts { + opt(&options) + } + + p.id = options.ID + + if p.id != "" { + p.edit = false + } else { + p.edit = true + } + + p.name.Clear() + p.entrypoint.Clear() + + p.keepalive.Value = false + p.ttl.Clear() + + s := entrypoint.Get(p.id) + if s != nil { + sopts := s.Options() + p.tunnelID.SetText(sopts.ID) + p.name.SetText(sopts.Name) + p.entrypoint.SetText(sopts.Endpoint) + p.keepalive.Value = sopts.Keepalive + p.ttl.SetText(strconv.Itoa(sopts.TTL)) + } +} + +func (p *udpPage) Layout(gtx C) D { + if p.btnBack.Clicked(gtx) { + p.router.Back() + } + if p.btnEdit.Clicked(gtx) { + p.edit = true + } + + if p.btnSave.Clicked(gtx) { + if p.id == "" { + p.create() + } else { + p.update() + } + p.router.Back() + } + + if p.btnDelete.Clicked(gtx) { + p.delDialog.Clicked = func(ok bool) { + if ok { + p.delete() + p.router.Back() + } + p.modal.Disappear(gtx.Now) + } + p.modal.Widget = func(gtx layout.Context, th *material.Theme, anim *component.VisibilityAnimation) layout.Dimensions { + return p.delDialog.Layout(gtx, th) + } + p.modal.Appear(gtx.Now) + } + + th := p.router.Theme + + defer p.modal.Layout(gtx, th) + + return layout.Flex{ + Axis: layout.Vertical, + }.Layout(gtx, + // header + layout.Rigid(func(gtx C) D { + return layout.Inset{ + Top: 8, + Bottom: 8, + Left: 8, + Right: 8, + }.Layout(gtx, func(gtx C) D { + return layout.Flex{ + Spacing: layout.SpaceBetween, + Alignment: layout.Middle, + }.Layout(gtx, + layout.Rigid(func(gtx C) D { + btn := material.IconButton(th, &p.btnBack, icons.IconBack, "Back") + btn.Color = th.Fg + btn.Background = th.Bg + return btn.Layout(gtx) + }), + layout.Rigid(layout.Spacer{Width: 8}.Layout), + layout.Flexed(1, func(gtx C) D { + title := material.H6(th, "UDP") + return title.Layout(gtx) + }), + layout.Rigid(layout.Spacer{Width: 8}.Layout), + layout.Rigid(func(gtx C) D { + ep := entrypoint.Get(p.id) + if ep == nil { + return D{} + } + + if p.btnFavorite.Clicked(gtx) { + ep.Favorite(!ep.IsFavorite()) + entrypoint.SaveConfig() + } + + btn := material.IconButton(th, &p.btnFavorite, icons.IconFavorite, "Favorite") + + if ep.IsFavorite() { + btn.Color = color.NRGBA(colornames.Red500) + } else { + btn.Color = th.Fg + } + btn.Background = th.Bg + + return btn.Layout(gtx) + }), + layout.Rigid(layout.Spacer{Width: 8}.Layout), + layout.Rigid(func(gtx C) D { + ep := entrypoint.Get(p.id) + if ep == nil { + return D{} + } + + if p.btnState.Clicked(gtx) { + p.onoff() + } + + if !ep.IsClosed() { + btn := material.IconButton(th, &p.btnState, icons.IconStop, "Stop") + + btn.Color = th.Fg + btn.Background = th.Bg + return btn.Layout(gtx) + } + + btn := material.IconButton(th, &p.btnState, icons.IconStart, "Start") + + btn.Color = th.Fg + btn.Background = th.Bg + return btn.Layout(gtx) + + }), + layout.Rigid(layout.Spacer{Width: 8}.Layout), + layout.Rigid(func(gtx C) D { + if p.id == "" { + return D{} + } + btn := material.IconButton(th, &p.btnDelete, icons.IconDelete, "Delete") + + btn.Color = th.Fg + btn.Background = th.Bg + return btn.Layout(gtx) + }), + layout.Rigid(layout.Spacer{Width: 8}.Layout), + layout.Rigid(func(gtx C) D { + if p.edit { + btn := material.IconButton(th, &p.btnSave, icons.IconDone, "Done") + btn.Color = th.Fg + btn.Background = th.Bg + return btn.Layout(gtx) + } else { + btn := material.IconButton(th, &p.btnEdit, icons.IconEdit, "Edit") + btn.Color = th.Fg + btn.Background = th.Bg + return btn.Layout(gtx) + } + }), + ) + }) + }), + layout.Flexed(1, func(gtx C) D { + return p.list.Layout(gtx, 1, func(gtx C, _ int) D { + return layout.Inset{ + Top: 8, + Bottom: 8, + Left: 8, + Right: 8, + }.Layout(gtx, func(gtx layout.Context) layout.Dimensions { + return p.layout(gtx, th) + }) + }) + }), + ) +} + +func (p *udpPage) layout(gtx C, th *material.Theme) D { + if !p.edit { + gtx = gtx.Disabled() + } + + return component.SurfaceStyle{ + Theme: th, + ShadowStyle: component.ShadowStyle{ + CornerRadius: 12, + }, + Fill: theme.Current().ContentSurfaceBg, + }.Layout(gtx, func(gtx C) D { + return layout.Inset{ + Top: 8, + Bottom: 8, + Left: 8, + Right: 8, + }.Layout(gtx, func(gtx C) D { + return layout.Flex{ + Axis: layout.Vertical, + }.Layout(gtx, + 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() + } + + if p.id != "" { + gtx = gtx.Disabled() + } + return p.tunnelID.Layout(gtx, th, "") + }), + layout.Rigid(layout.Spacer{Height: 16}.Layout), + + layout.Rigid(func(gtx C) D { + return material.Body1(th, "Name").Layout(gtx) + }), + layout.Rigid(func(gtx C) D { + return p.name.Layout(gtx, th, "") + }), + layout.Rigid(layout.Spacer{Height: 16}.Layout), + + layout.Rigid(func(gtx C) D { + return material.Body1(th, "Entrypoint").Layout(gtx) + }), + layout.Rigid(func(gtx C) D { + if err := func() error { + addr := strings.TrimSpace(p.entrypoint.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.entrypoint.SetError(err.Error()) + } else { + p.entrypoint.ClearError() + } + + return p.entrypoint.Layout(gtx, th, "Address") + }), + layout.Rigid(layout.Spacer{Height: 8}.Layout), + + layout.Rigid(func(gtx C) D { + return layout.Inset{ + Top: 8, + Bottom: 8, + }.Layout(gtx, func(gtx C) D { + return layout.Flex{ + Spacing: layout.SpaceBetween, + }.Layout(gtx, + layout.Flexed(1, material.Body1(th, "Keepalive").Layout), + layout.Rigid(material.Switch(th, &p.keepalive, "keepalive").Layout), + ) + }) + }), + layout.Rigid(func(gtx C) D { + if !p.keepalive.Value { + p.ttl.Clear() + return layout.Dimensions{} + } + + 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") + }), + layout.Rigid(layout.Spacer{Height: 8}.Layout), + ) + }) + }) +} + +func (p *udpPage) create() error { + defer entrypoint.SaveConfig() + + ttl, _ := strconv.Atoi(strings.TrimSpace(p.ttl.Text())) + + ep := entrypoint.NewUDPEntryPoint( + tunnel.IDOption(strings.ToLower(strings.TrimSpace(p.tunnelID.Text()))), + tunnel.NameOption(strings.TrimSpace(p.name.Text())), + tunnel.EndpointOption(strings.TrimSpace(p.entrypoint.Text())), + tunnel.KeepaliveOption(p.keepalive.Value), + tunnel.TTLOption(ttl), + ) + + entrypoint.Add(ep) + + if err := ep.Run(); err != nil { + ep.Close() + return err + } + + return nil +} + +func (p *udpPage) update(opts ...tunnel.Option) tunnel.Tunnel { + defer entrypoint.SaveConfig() + + if t := entrypoint.Get(p.id); t != nil { + t.Close() + } + + if opts == nil { + ttl, _ := strconv.Atoi(strings.TrimSpace(p.ttl.Text())) + + opts = []tunnel.Option{ + tunnel.IDOption(strings.ToLower(strings.TrimSpace(p.tunnelID.Text()))), + tunnel.NameOption(strings.TrimSpace(p.name.Text())), + tunnel.EndpointOption(strings.TrimSpace(p.entrypoint.Text())), + tunnel.KeepaliveOption(p.keepalive.Value), + tunnel.TTLOption(ttl), + } + } + ep := entrypoint.NewUDPEntryPoint(opts...) + + entrypoint.Set(ep) + + if err := ep.Run(); err != nil { + ep.Close() + logger.Default().Error(err) + } + + return ep +} + +func (p *udpPage) onoff() { + ep := entrypoint.Get(p.id) + if ep == nil { + return + } + + if ep.IsClosed() { + opts := ep.Options() + p.update( + tunnel.IDOption(opts.ID), + tunnel.NameOption(opts.Name), + tunnel.EndpointOption(opts.Endpoint), + tunnel.KeepaliveOption(opts.Keepalive), + tunnel.TTLOption(opts.TTL), + ) + } else { + ep.Close() + } + entrypoint.SaveConfig() +} + +func (p *udpPage) delete() { + entrypoint.Delete(p.id) + entrypoint.SaveConfig() +} diff --git a/ui/page/entrypoint_tcp.go b/ui/page/entrypoint_tcp.go deleted file mode 100644 index a287de3..0000000 --- a/ui/page/entrypoint_tcp.go +++ /dev/null @@ -1,438 +0,0 @@ -package page - -import ( - "fmt" - "image/color" - "log" - "net" - "strings" - - "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 tcpEntryPointAddPage struct { - router *Router - - list layout.List - wgDone widget.Clickable - - name component.TextField - tunnelID component.TextField - addr component.TextField -} - -func NewTCPEntryPointAddPage(r *Router) Page { - return &tcpEntryPointAddPage{ - 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, - }, - }, - } -} - -func (p *tcpEntryPointAddPage) Init(opts ...PageOption) { - p.name.Clear() - p.tunnelID.Clear() - p.addr.Clear() - - 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 = "TCP" - 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 { - 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 *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 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) - }), - 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.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") - }), - ) -} - -func (p *tcpEntryPointAddPage) createEntryPoint() error { - 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(ep) - - if err := ep.Run(); err != nil { - return err - } - - return nil -} - -type tcpEntryPointEditPage 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 -} - -func NewTCPEntryPointEditPage(r *Router) Page { - return &tcpEntryPointEditPage{ - 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, - }, - }, - } -} - -func (p *tcpEntryPointEditPage) 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) - } - - 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), - ) - } 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 = "TCP" - p.router.bar.NavigationIcon = icons.IconClose -} - -func (p *tcpEntryPointEditPage) isValid() bool { - if p.tunnelID.Text() == "" || p.tunnelID.IsErrored() || - p.addr.Text() == "" || p.addr.IsErrored() { - return false - } - return true -} - -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) - } - - return ep -} - -func (p *tcpEntryPointEditPage) 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 *tcpEntryPointEditPage) 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") - }), - ) -} diff --git a/ui/page/entrypoint_udp.go b/ui/page/entrypoint_udp.go deleted file mode 100644 index 323819f..0000000 --- a/ui/page/entrypoint_udp.go +++ /dev/null @@ -1,535 +0,0 @@ -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 deleted file mode 100644 index 9f9137f..0000000 --- a/ui/page/file.go +++ /dev/null @@ -1,531 +0,0 @@ -package page - -import ( - "fmt" - "image/color" - "log" - "os" - "strings" - - "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/ui/icons" - "golang.org/x/exp/shiny/materialdesign/colornames" -) - -type fileAddPage struct { - router *Router - - list layout.List - wgDone widget.Clickable - - name component.TextField - path component.TextField - cbBasicAuth widget.Bool - username component.TextField - password component.TextField - - wgPassword widget.Clickable - passwordVisible bool -} - -func NewFileAddPage(r *Router) Page { - return &fileAddPage{ - router: r, - list: layout.List{ - Axis: layout.Vertical, - Alignment: layout.Middle, - }, - name: component.TextField{ - Editor: widget.Editor{ - SingleLine: true, - }, - }, - path: component.TextField{ - Editor: widget.Editor{ - SingleLine: true, - }, - }, - username: component.TextField{ - Editor: widget.Editor{ - SingleLine: true, - }, - }, - password: component.TextField{ - Editor: widget.Editor{ - SingleLine: true, - }, - }, - } -} - -func (p *fileAddPage) Init(opts ...PageOption) { - p.name.Clear() - p.path.Clear() - p.cbBasicAuth.Value = false - p.username.Clear() - p.password.Clear() - p.passwordVisible = false - - 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.wgDone.Clicked(gtx) { - defer p.router.SwitchTo(Route{Path: PageTunnel}) - p.createTunnel() - tunnel.SaveConfig() - } - return component.SimpleIconButton(bg, fg, &p.wgDone, icons.IconDone).Layout(gtx) - }, - }, - }, nil) - - p.router.bar.Title = "File" - p.router.bar.NavigationIcon = icons.IconClose -} - -func (p *fileAddPage) 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 *fileAddPage) layout(gtx C, th *material.Theme) D { - return layout.Flex{ - Axis: layout.Vertical, - }.Layout(gtx, - layout.Rigid(material.Body1(th, "Expose local files to public network.").Layout), - layout.Rigid(layout.Spacer{Height: 15}.Layout), - layout.Rigid(func(gtx C) D { - return material.Body1(th, "Service 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, "Root directory, default to the current working directory").Layout(gtx) - }), - layout.Rigid(func(gtx C) D { - if err := func() error { - dir := strings.TrimSpace(p.path.Text()) - if dir == "" { - return nil - } - f, err := os.Open(dir) - if err != nil { - return err - } - defer f.Close() - fs, err := f.Stat() - if err != nil { - return err - } - if !fs.IsDir() { - return fmt.Errorf("%s is not a directory", dir) - } - return nil - }(); err != nil { - p.path.SetError(err.Error()) - } else { - p.path.ClearError() - } - - return p.path.Layout(gtx, th, "Path") - }), - 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, "Use basic auth").Layout), - layout.Rigid(material.Switch(th, &p.cbBasicAuth, "use basic auth").Layout), - ) - }) - }), - layout.Rigid(func(gtx C) D { - if !p.cbBasicAuth.Value { - 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.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") - }), - ) -} - -func (p *fileAddPage) createTunnel() error { - var username, password string - if p.cbBasicAuth.Value { - username = strings.TrimSpace(p.username.Text()) - password = strings.TrimSpace(p.password.Text()) - } - tun := tunnel.NewFileTunnel( - tunnel.NameOption(strings.TrimSpace(p.name.Text())), - tunnel.EndpointOption(strings.TrimSpace(p.path.Text())), - tunnel.UsernameOption(username), - tunnel.PasswordOption(password), - ) - - tunnel.Add(tun) - - if err := tun.Run(); err != nil { - tun.Close() - return err - } - - return nil -} - -type fileEditPage struct { - router *Router - - id string - - list layout.List - wgFavorite widget.Clickable - wgState widget.Clickable - wgDelete widget.Clickable - wgDone widget.Clickable - - wgID widget.Clickable - wgEntrypoint widget.Clickable - - name component.TextField - path component.TextField - cbBasicAuth widget.Bool - username component.TextField - password component.TextField - - wgPassword widget.Clickable - passwordVisible bool -} - -func NewFileEditPage(r *Router) Page { - return &fileEditPage{ - router: r, - list: layout.List{ - Axis: layout.Vertical, - Alignment: layout.Middle, - }, - name: component.TextField{ - Editor: widget.Editor{ - SingleLine: true, - }, - }, - path: component.TextField{ - Editor: widget.Editor{ - SingleLine: true, - }, - }, - username: component.TextField{ - Editor: widget.Editor{ - SingleLine: true, - }, - }, - password: component.TextField{ - Editor: widget.Editor{ - SingleLine: true, - }, - }, - } -} - -func (p *fileEditPage) Init(opts ...PageOption) { - p.passwordVisible = false - - var options PageOptions - for _, opt := range opts { - opt(&options) - } - - p.id = options.ID - s := tunnel.Get(p.id) - if s != nil { - sopts := s.Options() - p.name.SetText(sopts.Name) - p.path.SetText(sopts.Endpoint) - if sopts.Username != "" { - p.cbBasicAuth.Value = true - p.username.SetText(sopts.Username) - p.password.SetText(sopts.Password) - } - } - - actions := []component.AppBarAction{ - { - OverflowAction: component.OverflowAction{ - Name: "Favorite", - Tag: &p.wgFavorite, - }, - Layout: func(gtx C, bg, fg color.NRGBA) D { - s := tunnel.Get(p.id) - if s == nil { - return D{} - } - - if p.wgFavorite.Clicked(gtx) { - s.Favorite(!s.IsFavorite()) - tunnel.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 := tunnel.Get(p.id) - if s == nil { - return D{} - } - - if p.wgState.Clicked(gtx) { - if s.IsClosed() { - 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() - } - tunnel.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) { - tunnel.Delete(p.id) - tunnel.SaveConfig() - p.router.SwitchTo(Route{Path: PageTunnel}) - } - 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.wgDone.Clicked(gtx) { - defer p.router.SwitchTo(Route{Path: PageTunnel}) - - if s := tunnel.Get(p.id); s != nil { - s.Close() - p.createTunnel() - tunnel.SaveConfig() - } - } - return component.SimpleIconButton(bg, fg, &p.wgDone, icons.IconDone).Layout(gtx) - }, - }, - } - p.router.bar.SetActions(actions, nil) - p.router.bar.Title = "File" - p.router.bar.NavigationIcon = icons.IconClose -} - -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(opts...) - - tunnel.Set(tun) - - if err := tun.Run(); err != nil { - tun.Close() - log.Println(err) - } - - return tun -} - -func (p *fileEditPage) 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 *fileEditPage) layout(gtx C, th *material.Theme) D { - return layout.Flex{ - Axis: layout.Vertical, - }.Layout(gtx, - layout.Rigid(func(gtx C) D { - return layoutHeader(gtx, th, tunnel.Get(p.id), &p.wgID, &p.wgEntrypoint) - }), - layout.Rigid(func(gtx C) D { - div := component.Divider(th) - return div.Layout(gtx) - }), - layout.Rigid(layout.Spacer{Height: 10}.Layout), - layout.Rigid(func(gtx C) D { - return material.Body1(th, "Tunnel 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, "Root directory, default to the current working directory").Layout(gtx) - }), - layout.Rigid(func(gtx C) D { - if err := func() error { - dir := strings.TrimSpace(p.path.Text()) - if dir == "" { - return nil - } - f, err := os.Open(dir) - if err != nil { - return err - } - defer f.Close() - fs, err := f.Stat() - if err != nil { - return err - } - if !fs.IsDir() { - return fmt.Errorf("%s is not a directory", dir) - } - return nil - }(); err != nil { - p.path.SetError(err.Error()) - } else { - p.path.ClearError() - } - - return p.path.Layout(gtx, th, "Path") - }), - layout.Rigid(layout.Spacer{Height: 10}.Layout), - layout.Rigid(func(gtx C) D { - return layout.Flex{ - Spacing: layout.SpaceBetween, - }.Layout(gtx, - layout.Flexed(1, material.Body1(th, "Use basic auth").Layout), - layout.Rigid(material.Switch(th, &p.cbBasicAuth, "use basic auth").Layout), - ) - }), - layout.Rigid(func(gtx C) D { - if !p.cbBasicAuth.Value { - 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.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/home/list/entrypoint.go b/ui/page/home/list/entrypoint.go new file mode 100644 index 0000000..929288c --- /dev/null +++ b/ui/page/home/list/entrypoint.go @@ -0,0 +1,134 @@ +package list + +import ( + "fmt" + "image/color" + "strings" + + "gioui.org/font" + "gioui.org/layout" + "gioui.org/widget" + "gioui.org/widget/material" + "github.com/go-gost/gost.plus/tunnel/entrypoint" + "github.com/go-gost/gost.plus/ui/icons" + "github.com/go-gost/gost.plus/ui/page" + "github.com/go-gost/gost.plus/ui/theme" + "golang.org/x/exp/shiny/materialdesign/colornames" +) + +type entrypointState struct { + btn widget.Clickable +} + +type entrypointList struct { + router *page.Router + list layout.List + states []entrypointState + filter Filter +} + +func (l *entrypointList) Filter(f Filter) { + l.filter = f +} + +func Entrypoint(r *page.Router) List { + return &entrypointList{ + router: r, + list: layout.List{ + Axis: layout.Vertical, + }, + states: make([]entrypointState, 16), + } +} + +func (l *entrypointList) Layout(gtx C, th *material.Theme) D { + tn := entrypoint.Count() + if tn > len(l.states) { + states := l.states + l.states = make([]entrypointState, tn) + copy(l.states, states) + } + + return l.list.Layout(gtx, tn, func(gtx C, index int) D { + t := entrypoint.GetIndex(index) + if t == nil { + return D{} + } + + if l.filter.Favorite && !t.IsFavorite() { + return D{} + } + + if l.states[index].btn.Clicked(gtx) { + var path page.PagePath + switch t.Type() { + case entrypoint.TCPEntryPoint: + path = page.PageEntrypointTCP + case entrypoint.UDPEntryPoint: + path = page.PageEntrypointUDP + } + l.router.Goto(page.Route{ + Path: path, + ID: t.ID(), + }) + } + + return layout.Inset{ + Top: 8, + Bottom: 8, + Left: 8, + Right: 8, + }.Layout(gtx, func(gtx C) D { + return material.ButtonLayoutStyle{ + Background: theme.Current().ListBg, + CornerRadius: 12, + Button: &l.states[index].btn, + }.Layout(gtx, func(gtx C) D { + return layout.Inset{ + Top: 8, + Bottom: 8, + Left: 8, + Right: 8, + }.Layout(gtx, func(gtx C) D { + return layout.Flex{ + Alignment: layout.Middle, + Spacing: layout.SpaceBetween, + }.Layout(gtx, + layout.Flexed(1, func(gtx C) D { + return layout.Flex{ + Axis: layout.Vertical, + }.Layout(gtx, + layout.Rigid(func(gtx C) D { + label := material.Body1(th, t.ID()) + label.Font.Weight = font.SemiBold + return label.Layout(gtx) + }), + layout.Rigid(layout.Spacer{Height: 8}.Layout), + layout.Rigid(material.Body2(th, fmt.Sprintf("Type: %s", strings.ToUpper(t.Type()))).Layout), + layout.Rigid(layout.Spacer{Height: 8}.Layout), + layout.Rigid(material.Body2(th, fmt.Sprintf("Name: %s", t.Name())).Layout), + layout.Rigid(layout.Spacer{Height: 8}.Layout), + layout.Rigid(material.Body2(th, fmt.Sprintf("Endpoint: %s", t.Endpoint())).Layout), + layout.Rigid(layout.Spacer{Height: 8}.Layout), + layout.Rigid(material.Body2(th, fmt.Sprintf("Entrypoint: %s", t.Entrypoint())).Layout), + ) + }), + layout.Rigid(layout.Spacer{Width: 8}.Layout), + layout.Rigid(func(gtx C) D { + gtx.Constraints.Min.X = gtx.Dp(12) + + c := colornames.GreenA700 + if t.Err() != nil { + c = colornames.Red600 + } + if t.IsClosed() { + c = colornames.Grey600 + } + return icons.IconCircle.Layout(gtx, color.NRGBA(c)) + }), + ) + }) + }) + }) + }) +} diff --git a/ui/page/home/list/list.go b/ui/page/home/list/list.go new file mode 100644 index 0000000..91af81f --- /dev/null +++ b/ui/page/home/list/list.go @@ -0,0 +1,19 @@ +package list + +import ( + "gioui.org/layout" + "gioui.org/widget/material" +) + +type C = layout.Context +type D = layout.Dimensions + +type Filter struct { + Favorite bool + Name string +} + +type List interface { + Layout(gtx C, th *material.Theme) D + Filter(f Filter) +} diff --git a/ui/page/home/list/tunnel.go b/ui/page/home/list/tunnel.go new file mode 100644 index 0000000..edd5c01 --- /dev/null +++ b/ui/page/home/list/tunnel.go @@ -0,0 +1,138 @@ +package list + +import ( + "fmt" + "image/color" + "strings" + + "gioui.org/font" + "gioui.org/layout" + "gioui.org/widget" + "gioui.org/widget/material" + "github.com/go-gost/gost.plus/tunnel" + "github.com/go-gost/gost.plus/ui/icons" + "github.com/go-gost/gost.plus/ui/page" + "github.com/go-gost/gost.plus/ui/theme" + "golang.org/x/exp/shiny/materialdesign/colornames" +) + +type tunnelState struct { + btn widget.Clickable +} + +type tunnelList struct { + router *page.Router + list layout.List + states []tunnelState + filter Filter +} + +func Tunnel(r *page.Router) List { + return &tunnelList{ + router: r, + list: layout.List{ + Axis: layout.Vertical, + }, + states: make([]tunnelState, 16), + } +} + +func (l *tunnelList) Filter(f Filter) { + l.filter = f +} + +func (l *tunnelList) Layout(gtx C, th *material.Theme) D { + tn := tunnel.Count() + if tn > len(l.states) { + states := l.states + l.states = make([]tunnelState, tn) + copy(l.states, states) + } + + return l.list.Layout(gtx, tn, func(gtx C, index int) D { + t := tunnel.GetIndex(index) + if t == nil { + return D{} + } + + if l.filter.Favorite && !t.IsFavorite() { + return D{} + } + + if l.states[index].btn.Clicked(gtx) { + var path page.PagePath + switch t.Type() { + case tunnel.FileTunnel: + path = page.PageTunnelFile + case tunnel.HTTPTunnel: + path = page.PageTunnelHTTP + case tunnel.TCPTunnel: + path = page.PageTunnelTCP + case tunnel.UDPTunnel: + path = page.PageTunnelUDP + } + l.router.Goto(page.Route{ + Path: path, + ID: t.ID(), + }) + } + + return layout.Inset{ + Top: 8, + Bottom: 8, + Left: 8, + Right: 8, + }.Layout(gtx, func(gtx C) D { + return material.ButtonLayoutStyle{ + Background: theme.Current().ListBg, + CornerRadius: 12, + Button: &l.states[index].btn, + }.Layout(gtx, func(gtx C) D { + return layout.Inset{ + Top: 8, + Bottom: 8, + Left: 8, + Right: 8, + }.Layout(gtx, func(gtx C) D { + return layout.Flex{ + Alignment: layout.Middle, + Spacing: layout.SpaceBetween, + }.Layout(gtx, + layout.Flexed(1, func(gtx C) D { + return layout.Flex{ + Axis: layout.Vertical, + }.Layout(gtx, + layout.Rigid(func(gtx C) D { + label := material.Body1(th, t.ID()) + label.Font.Weight = font.SemiBold + return label.Layout(gtx) + }), + layout.Rigid(layout.Spacer{Height: 8}.Layout), + layout.Rigid(material.Body2(th, fmt.Sprintf("Type: %s", strings.ToUpper(t.Type()))).Layout), + layout.Rigid(layout.Spacer{Height: 8}.Layout), + layout.Rigid(material.Body2(th, fmt.Sprintf("Name: %s", t.Name())).Layout), + layout.Rigid(layout.Spacer{Height: 8}.Layout), + layout.Rigid(material.Body2(th, fmt.Sprintf("Endpoint: %s", t.Endpoint())).Layout), + layout.Rigid(layout.Spacer{Height: 8}.Layout), + layout.Rigid(material.Body2(th, fmt.Sprintf("Entrypoint: %s", t.Entrypoint())).Layout), + ) + }), + layout.Rigid(layout.Spacer{Width: 8}.Layout), + layout.Rigid(func(gtx C) D { + gtx.Constraints.Min.X = gtx.Dp(12) + + c := colornames.GreenA700 + if t.Err() != nil { + c = colornames.Red600 + } + if t.IsClosed() { + c = colornames.Grey600 + } + return icons.IconCircle.Layout(gtx, color.NRGBA(c)) + }), + ) + }) + }) + }) + }) +} diff --git a/ui/page/home/page.go b/ui/page/home/page.go new file mode 100644 index 0000000..9463562 --- /dev/null +++ b/ui/page/home/page.go @@ -0,0 +1,175 @@ +package home + +import ( + "image/color" + "sync/atomic" + + "gioui.org/font" + "gioui.org/layout" + "gioui.org/widget" + "gioui.org/widget/material" + "github.com/go-gost/gost.plus/ui/icons" + "github.com/go-gost/gost.plus/ui/page" + "github.com/go-gost/gost.plus/ui/page/home/list" + ui_widget "github.com/go-gost/gost.plus/ui/widget" + "golang.org/x/exp/shiny/materialdesign/colornames" +) + +type C = layout.Context +type D = layout.Dimensions + +type navPage struct { + list list.List + path page.PagePath +} + +type homePage struct { + router *page.Router + + btnFavorite widget.Clickable + favorite atomic.Bool + + nav *ui_widget.Nav + pages []navPage + btnAdd widget.Clickable + btnSettings widget.Clickable +} + +func NewPage(r *page.Router) page.Page { + return &homePage{ + router: r, + nav: ui_widget.NewNav( + ui_widget.NewNavButton("Tunnel"), + ui_widget.NewNavButton("Entrypoint"), + ), + pages: []navPage{ + { + list: list.Tunnel(r), + path: page.PageTunnel, + }, + { + list: list.Entrypoint(r), + path: page.PageEntrypoint, + }, + }, + } +} + +func (p *homePage) Init(opts ...page.PageOption) {} + +func (p *homePage) Layout(gtx C) D { + if p.btnAdd.Clicked(gtx) { + p.router.Goto(page.Route{Path: p.pages[p.nav.Current()].path}) + } + + th := p.router.Theme + + return layout.Stack{ + Alignment: layout.SE, + }.Layout(gtx, + layout.Expanded(func(gtx C) D { + gtx.Constraints.Min.X = gtx.Constraints.Max.X + return layout.Flex{ + Axis: layout.Vertical, + }.Layout(gtx, + // header + layout.Rigid(func(gtx C) D { + return layout.Inset{ + Top: 8, + Bottom: 8, + Left: 8, + Right: 8, + }.Layout(gtx, func(gtx C) D { + return layout.Flex{ + Spacing: layout.SpaceBetween, + Alignment: layout.Middle, + }.Layout(gtx, + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + gtx.Constraints.Max.X = gtx.Dp(50) + return icons.IconApp.Layout(gtx) + }), + layout.Rigid(layout.Spacer{Width: 8}.Layout), + layout.Flexed(1, func(gtx C) D { + label := material.H6(th, "GOST+") + label.Font.Weight = font.SemiBold + return label.Layout(gtx) + }), + layout.Rigid(layout.Spacer{Width: 8}.Layout), + + layout.Rigid(func(gtx C) D { + if p.btnFavorite.Clicked(gtx) { + p.favorite.Store(!p.favorite.Load()) + } + + btn := material.IconButton(th, &p.btnFavorite, icons.IconFavorite, "Favorite") + + if p.favorite.Load() { + btn.Color = color.NRGBA(colornames.Red500) + } else { + btn.Color = th.Fg + } + btn.Background = th.Bg + + return btn.Layout(gtx) + }), + layout.Rigid(layout.Spacer{Width: 8}.Layout), + layout.Rigid(func(gtx C) D { + if p.btnSettings.Clicked(gtx) { + p.router.Goto(page.Route{ + Path: page.PageSettings, + }) + } + + btn := material.IconButton(th, &p.btnSettings, icons.IconSettings, "Settings") + btn.Color = th.Fg + btn.Background = th.Bg + return btn.Layout(gtx) + }), + ) + }) + }), + // nav + layout.Rigid(func(gtx C) D { + return layout.Center.Layout(gtx, func(gtx layout.Context) layout.Dimensions { + return layout.Inset{ + Top: 4, + Bottom: 4, + }.Layout(gtx, func(gtx C) D { + return p.nav.Layout(gtx, th) + }) + }) + }), + // list + layout.Flexed(1, func(gtx C) D { + current := p.nav.Current() + if current < 0 || current >= len(p.pages) { + current = 0 + } + pg := p.pages[current] + if pg.list == nil { + return D{ + Size: gtx.Constraints.Max, + } + } + pg.list.Filter(list.Filter{ + Favorite: p.favorite.Load(), + }) + return pg.list.Layout(gtx, th) + }), + ) + }), + layout.Stacked(func(gtx C) D { + return layout.Inset{ + Top: 16, + Bottom: 16, + Left: 16, + Right: 16, + }.Layout(gtx, func(gtx C) D { + btn := material.IconButton(th, &p.btnAdd, icons.IconAdd, "Add") + btn.Inset = layout.UniformInset(16) + + return btn.Layout(gtx) + }) + }), + ) +} diff --git a/ui/page/home/widget.go b/ui/page/home/widget.go new file mode 100644 index 0000000..649bfe8 --- /dev/null +++ b/ui/page/home/widget.go @@ -0,0 +1,83 @@ +package home + +import ( + "fmt" + "image/color" + + "gioui.org/layout" + "gioui.org/widget" + "gioui.org/widget/material" + "gioui.org/x/component" + "github.com/go-gost/gost.plus/ui/icons" + "golang.org/x/exp/shiny/materialdesign/colornames" +) + +type Widget interface { + Layout(gtx C, th *material.Theme) D +} + +type serviceWidget struct { + list layout.List + nav widget.Clickable +} + +func (p *serviceWidget) Layout(gtx C, th *material.Theme) D { + return layout.Flex{ + Axis: layout.Vertical, + }.Layout(gtx, + layout.Rigid(func(gtx C) D { + return layout.Inset{ + Left: 10, + Right: 10, + }.Layout(gtx, func(gtx C) D { + return layout.Flex{ + Spacing: layout.SpaceBetween, + Alignment: layout.Middle, + }.Layout(gtx, + layout.Rigid(func(gtx C) D { + return material.H6(th, "Services").Layout(gtx) + }), + layout.Flexed(1, layout.Spacer{}.Layout), + layout.Rigid(func(gtx C) D { + btn := material.IconButtonStyle{ + Color: color.NRGBA(colornames.Grey800), + Icon: icons.IconNavArrowForward, + Button: &p.nav, + Size: 24, + Inset: layout.UniformInset(5), + Description: "forward", + } + return btn.Layout(gtx) + + }), + ) + }) + }), + layout.Rigid(layout.Spacer{Height: 10}.Layout), + layout.Rigid(func(gtx C) D { + return p.list.Layout(gtx, 10, func(gtx C, index int) D { + return layout.Inset{ + Top: 5, + Bottom: 5, + Right: 10, + Left: 10, + }.Layout(gtx, func(gtx C) D { + return component.SurfaceStyle{ + Theme: th, + ShadowStyle: component.ShadowStyle{ + CornerRadius: 15, + }, + Fill: color.NRGBA(colornames.BlueGrey50), + }.Layout(gtx, func(gtx C) D { + return layout.Inset{ + Top: 10, + Bottom: 10, + Left: 10, + Right: 10, + }.Layout(gtx, material.H4(th, fmt.Sprintf("Service-%d\n\n", index)).Layout) + }) + }) + }) + }), + ) +} diff --git a/ui/page/http.go b/ui/page/http.go deleted file mode 100644 index 545679d..0000000 --- a/ui/page/http.go +++ /dev/null @@ -1,602 +0,0 @@ -package page - -import ( - "fmt" - "image/color" - "log" - "net" - "strings" - - "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/ui/icons" - "golang.org/x/exp/shiny/materialdesign/colornames" -) - -type httpAddPage struct { - router *Router - - list layout.List - wgDone widget.Clickable - - name component.TextField - addr component.TextField - - bHost widget.Bool - hostname component.TextField - - bBasicAuth widget.Bool - username component.TextField - password component.TextField - - bTLS widget.Bool - - wgPassword widget.Clickable - passwordVisible bool -} - -func NewHTTPAddPage(r *Router) Page { - return &httpAddPage{ - router: r, - list: layout.List{ - Axis: layout.Vertical, - Alignment: layout.Middle, - }, - name: component.TextField{ - Editor: widget.Editor{ - SingleLine: true, - }, - }, - addr: component.TextField{ - Editor: widget.Editor{ - SingleLine: true, - }, - }, - hostname: component.TextField{ - Editor: widget.Editor{ - SingleLine: true, - }, - }, - username: component.TextField{ - Editor: widget.Editor{ - SingleLine: true, - }, - }, - password: component.TextField{ - Editor: widget.Editor{ - SingleLine: true, - }, - }, - } -} - -func (p *httpAddPage) Init(opts ...PageOption) { - p.name.Clear() - p.addr.Clear() - p.hostname.Clear() - p.bBasicAuth.Value = false - p.username.Clear() - p.password.Clear() - p.bTLS.Value = false - p.passwordVisible = false - - 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.wgDone.Clicked(gtx) { - defer p.router.SwitchTo(Route{Path: PageTunnel}) - p.createTunnel() - tunnel.SaveConfig() - } - return component.SimpleIconButton(bg, fg, &p.wgDone, icons.IconDone).Layout(gtx) - }, - }, - }, nil) - - p.router.bar.Title = "HTTP" - p.router.bar.NavigationIcon = icons.IconClose -} - -func (p *httpAddPage) 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 *httpAddPage) layout(gtx C, th *material.Theme) D { - return layout.Flex{ - Axis: layout.Vertical, - }.Layout(gtx, - layout.Rigid(material.Body1(th, "Expose local http tunnel to public network.").Layout), - layout.Rigid(layout.Spacer{Height: 10}.Layout), - layout.Rigid(func(gtx C) D { - return material.Body1(th, "Tunnel 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, "Endpoint 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.SplitHostPort(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.Flex{ - Spacing: layout.SpaceBetween, - }.Layout(gtx, - layout.Flexed(1, material.Body1(th, "Use custom hostname (rewrite HTTP Host header)").Layout), - layout.Rigid(material.Switch(th, &p.bHost, "custom hostname").Layout), - ) - }), - layout.Rigid(func(gtx C) D { - if !p.bHost.Value { - p.hostname.SetText("") - return layout.Dimensions{} - } - return p.hostname.Layout(gtx, th, "Hostname") - }), - layout.Rigid(layout.Spacer{Height: 10}.Layout), - layout.Rigid(func(gtx C) D { - return layout.Flex{ - Spacing: layout.SpaceBetween, - }.Layout(gtx, - layout.Flexed(1, material.Body1(th, "Use basic auth").Layout), - layout.Rigid(material.Switch(th, &p.bBasicAuth, "use basic auth").Layout), - ) - }), - layout.Rigid(func(gtx C) D { - if !p.bBasicAuth.Value { - p.username.SetText("") - return layout.Dimensions{} - } - return p.username.Layout(gtx, th, "Username") - }), - layout.Rigid(func(gtx C) D { - if !p.bBasicAuth.Value { - 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), - layout.Rigid(func(gtx C) D { - return layout.Flex{ - Spacing: layout.SpaceBetween, - }.Layout(gtx, - layout.Flexed(1, material.Body1(th, "Enable TLS").Layout), - layout.Rigid(material.Switch(th, &p.bTLS, "enable TLS").Layout), - ) - }), - ) -} - -func (p *httpAddPage) createTunnel() error { - var username, password string - if p.bBasicAuth.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()) - } - tun := tunnel.NewHTTPTunnel( - 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), - ) - - tunnel.Add(tun) - - if err := tun.Run(); err != nil { - tun.Close() - return err - } - - return nil -} - -type httpEditPage struct { - router *Router - - id string - - list layout.List - wgFavorite widget.Clickable - wgState widget.Clickable - wgDelete widget.Clickable - wgDone widget.Clickable - - name component.TextField - addr component.TextField - - bHost widget.Bool - hostname component.TextField - - cbBasicAuth widget.Bool - username component.TextField - password component.TextField - - bTLS widget.Bool - - wgID widget.Clickable - wgEntrypoint widget.Clickable - - wgPassword widget.Clickable - passwordVisible bool -} - -func NewHTTPEditPage(r *Router) Page { - return &httpEditPage{ - router: r, - list: layout.List{ - Axis: layout.Vertical, - Alignment: layout.Middle, - }, - name: component.TextField{ - Editor: widget.Editor{ - SingleLine: true, - }, - }, - addr: component.TextField{ - Editor: widget.Editor{ - SingleLine: true, - }, - }, - hostname: component.TextField{ - Editor: widget.Editor{ - SingleLine: true, - }, - }, - username: component.TextField{ - Editor: widget.Editor{ - SingleLine: true, - }, - }, - password: component.TextField{ - Editor: widget.Editor{ - SingleLine: true, - }, - }, - } -} - -func (p *httpEditPage) Init(opts ...PageOption) { - p.passwordVisible = false - - var options PageOptions - for _, opt := range opts { - opt(&options) - } - - p.id = options.ID - s := tunnel.Get(p.id) - if s != nil { - sopts := s.Options() - p.name.SetText(sopts.Name) - p.addr.SetText(sopts.Endpoint) - if sopts.Hostname != "" { - p.bHost.Value = true - p.hostname.SetText(sopts.Hostname) - } - if sopts.Username != "" { - p.cbBasicAuth.Value = true - p.username.SetText(sopts.Username) - p.password.SetText(sopts.Password) - } - p.bTLS.Value = sopts.EnableTLS - } - - actions := []component.AppBarAction{ - { - OverflowAction: component.OverflowAction{ - Name: "Favorite", - Tag: &p.wgFavorite, - }, - Layout: func(gtx C, bg, fg color.NRGBA) D { - s := tunnel.Get(p.id) - if s == nil { - return D{} - } - - if p.wgFavorite.Clicked(gtx) { - s.Favorite(!s.IsFavorite()) - tunnel.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 := tunnel.Get(p.id) - if p.wgState.Clicked(gtx) && s != nil { - if s.IsClosed() { - 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() - } - tunnel.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) { - tunnel.Delete(p.id) - tunnel.SaveConfig() - p.router.SwitchTo(Route{Path: PageTunnel}) - } - 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.wgDone.Clicked(gtx) { - defer p.router.SwitchTo(Route{Path: PageTunnel}) - - if s := tunnel.Get(p.id); s != nil { - s.Close() - p.createTunnel() - tunnel.SaveConfig() - } - } - return component.SimpleIconButton(bg, fg, &p.wgDone, icons.IconDone).Layout(gtx) - }, - }, - } - p.router.bar.SetActions(actions, nil) - p.router.bar.Title = "HTTP" - p.router.bar.NavigationIcon = icons.IconClose -} - -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(opts...) - - tunnel.Set(tun) - - if err := tun.Run(); err != nil { - tun.Close() - log.Println(err) - } - - return tun -} - -func (p *httpEditPage) 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 *httpEditPage) layout(gtx C, th *material.Theme) D { - return layout.Flex{ - Axis: layout.Vertical, - }.Layout(gtx, - layout.Rigid(func(gtx C) D { - return layoutHeader(gtx, th, tunnel.Get(p.id), &p.wgID, &p.wgEntrypoint) - }), - layout.Rigid(layout.Spacer{Height: 10}.Layout), - layout.Rigid(func(gtx C) D { - return material.Body1(th, "Tunnel 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, "Endpoint 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, "Use custom hostname (rewrite HTTP Host header)").Layout), - layout.Rigid(material.Switch(th, &p.bHost, "custom hostname").Layout), - ) - }) - }), - layout.Rigid(func(gtx C) D { - if !p.bHost.Value { - p.hostname.SetText("") - return layout.Dimensions{} - } - return p.hostname.Layout(gtx, th, "Hostname") - }), - 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, "Use basic auth").Layout), - layout.Rigid(material.Switch(th, &p.cbBasicAuth, "use basic auth").Layout), - ) - }) - }), - layout.Rigid(func(gtx C) D { - if !p.cbBasicAuth.Value { - p.username.SetText("") - return layout.Dimensions{} - } - return p.username.Layout(gtx, th, "Username") - }), - layout.Rigid(func(gtx C) D { - if !p.cbBasicAuth.Value { - 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 { - 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 TLS").Layout), - layout.Rigid(material.Switch(th, &p.bTLS, "enable TLS").Layout), - ) - }) - }), - ) -} diff --git a/ui/page/menu.go b/ui/page/menu.go deleted file mode 100644 index 2638b0a..0000000 --- a/ui/page/menu.go +++ /dev/null @@ -1,195 +0,0 @@ -package page - -import ( - "image/color" - - "gioui.org/font" - "gioui.org/layout" - "gioui.org/op" - "gioui.org/widget" - "gioui.org/widget/material" - "gioui.org/x/component" - "github.com/go-gost/gost-plus/ui/icons" - "golang.org/x/exp/shiny/materialdesign/colornames" -) - -type menuPage struct { - router *Router - - list widget.List - - wgFile widget.Clickable - wgHTTP widget.Clickable - wgTCP widget.Clickable - wgUDP widget.Clickable - - wgEntryPointTCP widget.Clickable - wgEntryPointUDP widget.Clickable -} - -func NewMenuPage(r *Router) Page { - return &menuPage{ - router: r, - list: widget.List{ - List: layout.List{ - Axis: layout.Vertical, - }, - }, - } -} - -func (p *menuPage) Init(opts ...PageOption) { - p.router.bar.SetActions(nil, nil) - p.router.bar.Title = "Add" - p.router.bar.NavigationIcon = icons.IconBack -} - -func (p *menuPage) Layout(gtx C, th *material.Theme) D { - if clicked := func() bool { - if p.wgFile.Clicked(gtx) { - p.router.SwitchTo(Route{Path: PageNewFile}) - return true - } - if p.wgHTTP.Clicked(gtx) { - p.router.SwitchTo(Route{Path: PageNewHTTP}) - return true - } - if p.wgTCP.Clicked(gtx) { - p.router.SwitchTo(Route{Path: PageNewTCP}) - return true - } - if p.wgUDP.Clicked(gtx) { - p.router.SwitchTo(Route{Path: PageNewUDP}) - return true - } - if p.wgEntryPointTCP.Clicked(gtx) { - p.router.SwitchTo(Route{Path: PageNewTCPEntryPoint}) - return true - } - if p.wgEntryPointUDP.Clicked(gtx) { - p.router.SwitchTo(Route{Path: PageNewUDPEntryPoint}) - return true - } - - return false - }(); clicked { - op.InvalidateOp{}.Add(gtx.Ops) - } - - return p.list.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 layout.Flex{ - Axis: layout.Vertical, - }.Layout(gtx, - layout.Rigid(func(gtx C) D { - label := material.H6(th, "Tunnels") - label.Font.Weight = font.Bold - return layout.Inset{Top: 5, Bottom: 5}.Layout(gtx, label.Layout) - }), - 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 material.Clickable(gtx, &p.wgFile, func(gtx C) D { - return layout.UniformInset(10).Layout(gtx, func(gtx C) D { - return p.layoutCard(gtx, th, "File", "Expose local files to public network") - }) - }) - }) - }) - }), - 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 material.Clickable(gtx, &p.wgHTTP, func(gtx C) D { - return layout.UniformInset(10).Layout(gtx, func(gtx C) D { - return p.layoutCard(gtx, th, "HTTP", "Expose local HTTP service to public network") - }) - }) - }) - }) - }), - 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 material.Clickable(gtx, &p.wgTCP, func(gtx C) D { - return layout.UniformInset(10).Layout(gtx, func(gtx C) D { - return p.layoutCard(gtx, th, "TCP", "Expose local TCP service to public network") - }) - }) - }) - }) - }), - 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 material.Clickable(gtx, &p.wgUDP, func(gtx C) D { - return layout.UniformInset(10).Layout(gtx, func(gtx C) D { - return p.layoutCard(gtx, th, "UDP", "Expose local UDP service to public network") - }) - }) - }) - }) - }), - layout.Rigid(layout.Spacer{Height: 10}.Layout), - layout.Rigid(func(gtx C) D { - label := material.H6(th, "EntryPoints") - label.Font.Weight = font.Bold - return layout.Inset{Top: 5, Bottom: 5}.Layout(gtx, label.Layout) - }), - 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 material.Clickable(gtx, &p.wgEntryPointTCP, func(gtx C) D { - return layout.UniformInset(10).Layout(gtx, func(gtx C) D { - 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 material.Clickable(gtx, &p.wgEntryPointUDP, 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") - }) - }) - }) - }) - }), - ) - }) - }) - }) -} - -func (p *menuPage) layoutCard(gtx C, th *material.Theme, name, desc string) D { - return layout.Flex{ - Axis: layout.Horizontal, - Spacing: layout.SpaceBetween, - Alignment: layout.Middle, - }.Layout(gtx, - layout.Flexed(1, func(gtx C) D { - return layout.Flex{ - Axis: layout.Vertical, - }.Layout(gtx, - layout.Rigid(func(gtx C) D { - title := material.Body1(th, name) - title.Font.Weight = font.Bold - return title.Layout(gtx) - }), - layout.Rigid(layout.Spacer{Height: 5}.Layout), - layout.Rigid(func(gtx C) D { - title := material.Body1(th, desc) - return title.Layout(gtx) - }), - ) - }), - layout.Rigid(layout.Spacer{Width: 5}.Layout), - layout.Rigid(func(gtx C) D { - return icons.IconForward.Layout(gtx, color.NRGBA(colornames.Grey500)) - }), - ) -} diff --git a/ui/page/page.go b/ui/page/page.go index 7fc4705..f2a8bb2 100644 --- a/ui/page/page.go +++ b/ui/page/page.go @@ -1,52 +1,33 @@ package page import ( - "image/color" - - "gioui.org/font" - "gioui.org/io/clipboard" "gioui.org/layout" - "gioui.org/widget" - "gioui.org/widget/material" - "github.com/go-gost/gost-plus/tunnel" - "github.com/go-gost/gost-plus/ui/icons" - "golang.org/x/exp/shiny/materialdesign/colornames" ) -const ( - PageMenu = "/menu" - PageTunnel = "/tunnel" - PageNewFile = "/tunnel/file/create" - PageNewHTTP = "/tunnel/http/create" - PageNewTCP = "/tunnel/tcp/create" - PageNewUDP = "/tunnel/udp/create" - PageEditFile = "/tunnel/file/edit" - PageEditHTTP = "/tunnel/http/edit" - PageEditTCP = "/tunnel/tcp/edit" - PageEditUDP = "/tunnel/udp/edit" - - PageEntryPoint = "/entrypoint" - PageNewTCPEntryPoint = "/entrypoint/tcp/create" - PageEditTCPEntryPoint = "/entrypoint/tcp/edit" - PageNewUDPEntryPoint = "/entrypoint/udp/create" - PageEditUDPEntryPoint = "/entrypoint/udp/edit" - - PageAbout = "/about" -) - -type OverflowAction string +type PagePath string const ( - OverflowActionAbout OverflowAction = "about" + PageHome PagePath = "/" + PageSettings PagePath = "/settings" + + PageTunnel PagePath = "/tunnel" + PageTunnelFile PagePath = "/tunnel/file" + PageTunnelHTTP PagePath = "/tunnel/http" + PageTunnelTCP PagePath = "/tunnel/tcp" + PageTunnelUDP PagePath = "/tunnel/udp" + + PageEntrypoint PagePath = "/entrypoint" + PageEntrypointTCP PagePath = "/entrypoint/tcp" + PageEntrypointUDP PagePath = "/entrypoint/udp" ) type PageOptions struct { ID string } -type PageOption func(*PageOptions) +type PageOption func(opts *PageOptions) -func IDPageOption(id string) PageOption { +func WithPageID(id string) PageOption { return func(opts *PageOptions) { opts.ID = id } @@ -54,75 +35,5 @@ func IDPageOption(id string) PageOption { type Page interface { Init(opts ...PageOption) - Layout(gtx layout.Context, th *material.Theme) layout.Dimensions -} - -func layoutHeader(gtx C, th *material.Theme, tun tunnel.Tunnel, wgID, wgEntrypoint *widget.Clickable) D { - return layout.Flex{ - Axis: layout.Vertical, - }.Layout(gtx, - layout.Rigid(func(gtx C) D { - if tun == nil { - return D{} - } - - copied := false - if wgID.Clicked(gtx) { - copied = true - clipboard.WriteOp{ - Text: tun.ID(), - }.Add(gtx.Ops) - } - - return wgID.Layout(gtx, func(gtx C) D { - return layout.Flex{}.Layout(gtx, - layout.Rigid(func(gtx C) D { - label := material.Body1(th, tun.ID()) - label.Font.Weight = font.Bold - return label.Layout(gtx) - }), - layout.Rigid(layout.Spacer{Width: 5}.Layout), - layout.Rigid(func(gtx C) D { - c := color.NRGBA(colornames.Blue500) - if copied { - c = color.NRGBA(colornames.Green500) - } - return icons.IconCopy.Layout(gtx, c) - }), - ) - }) - }), - layout.Rigid(func(gtx C) D { - if tun == nil { - return D{} - } - - copied := false - if wgEntrypoint.Clicked(gtx) { - copied = true - clipboard.WriteOp{ - Text: tun.Entrypoint(), - }.Add(gtx.Ops) - } - - return wgEntrypoint.Layout(gtx, func(gtx C) D { - return layout.Inset{Top: 5, Bottom: 5}.Layout(gtx, func(gtx C) D { - return layout.Flex{}.Layout(gtx, - layout.Rigid(func(gtx C) D { - label := material.Body1(th, tun.Entrypoint()) - return label.Layout(gtx) - }), - layout.Rigid(layout.Spacer{Width: 5}.Layout), - layout.Rigid(func(gtx C) D { - c := color.NRGBA(colornames.Blue500) - if copied { - c = color.NRGBA(colornames.Green500) - } - return icons.IconCopy.Layout(gtx, c) - }), - ) - }) - }) - }), - ) + Layout(gtx layout.Context) layout.Dimensions } diff --git a/ui/page/router.go b/ui/page/router.go index 9cc4a97..5f887ed 100644 --- a/ui/page/router.go +++ b/ui/page/router.go @@ -1,109 +1,134 @@ package page import ( + "fmt" + "log/slog" + "gioui.org/layout" + "gioui.org/op/clip" + "gioui.org/op/paint" + "gioui.org/unit" "gioui.org/widget/material" - "gioui.org/x/component" + "github.com/go-gost/gost.plus/ui/theme" ) type C = layout.Context type D = layout.Dimensions type Route struct { - Path string + Path PagePath ID string } type Router struct { - modal *component.ModalLayer - bar *component.AppBar - - pages map[string]Page + pages map[PagePath]Page + stack routeStack current Route + *material.Theme } -func NewRouter() *Router { - modal := component.NewModal() - bar := component.NewAppBar(modal) - +func NewRouter(th *material.Theme) *Router { r := &Router{ - modal: modal, - bar: bar, - pages: make(map[string]Page), + pages: make(map[PagePath]Page), + Theme: th, } - r.Register(PageTunnel, NewTunnelPage(r)) - r.Register(PageMenu, NewMenuPage(r)) - r.Register(PageNewFile, NewFileAddPage(r)) - r.Register(PageEditFile, NewFileEditPage(r)) - r.Register(PageNewHTTP, NewHTTPAddPage(r)) - r.Register(PageEditHTTP, NewHTTPEditPage(r)) - r.Register(PageNewTCP, NewTCPAddPage(r)) - r.Register(PageEditTCP, NewTCPEditPage(r)) - r.Register(PageNewUDP, NewUDPAddPage(r)) - r.Register(PageEditUDP, NewUDPEditPage(r)) - - r.Register(PageEntryPoint, NewEntryPointPage(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)) - - r.SwitchTo(Route{Path: PageTunnel}) - return r } -func (r *Router) Register(path string, page Page) { - if page != nil { - r.pages[path] = page - } +func (r *Router) Register(path PagePath, page Page) { + r.pages[path] = page } -func (r *Router) SwitchTo(route Route) { - p := r.pages[route.Path] - if p == nil { +func (r *Router) Goto(route Route) { + page := r.pages[route.Path] + if page == nil { return } - p.Init(IDPageOption(route.ID)) - r.current = route + r.stack.Push(route) + + page.Init(WithPageID(route.ID)) + slog.Debug(fmt.Sprintf("go to %s", route.Path), "kind", "router", "route.id", route.ID) } -func (r *Router) Layout(gtx layout.Context, th *material.Theme) layout.Dimensions { - 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 path == PageTunnel || - path == PageNewTCPEntryPoint || path == PageEditTCPEntryPoint || - path == PageNewUDPEntryPoint || path == PageEditUDPEntryPoint { - r.SwitchTo(Route{Path: PageEntryPoint}) - } else { - r.SwitchTo(Route{Path: PageTunnel}) - } - case component.AppBarContextMenuDismissed: - // log.Printf("Context menu dismissed: %+v", event) - r.SwitchTo(Route{Path: PageTunnel}) - case component.AppBarOverflowActionClicked: - if event.Tag == OverflowActionAbout { - r.SwitchTo(Route{Path: PageAbout}) - } - } +func (r *Router) Back() { + r.stack.Pop() + route := r.stack.Peek() + + page := r.pages[route.Path] + if page == nil { + return } + r.current = route - defer r.modal.Layout(gtx, th) + page.Init(WithPageID(route.ID)) + slog.Debug(fmt.Sprintf("back to %s", route.Path), "kind", "router", "route.id", route.ID) +} - return layout.Flex{Axis: layout.Vertical}.Layout(gtx, - layout.Rigid(func(gtx C) D { - return r.bar.Layout(gtx, th, "Menu", "Actions") - }), - layout.Flexed(1, func(gtx C) D { - return r.pages[r.current.Path].Layout(gtx, th) - }), +func (r *Router) Layout(gtx C) D { + r.Theme.Palette = theme.Current().Material + + return layout.Background{}.Layout(gtx, + func(gtx C) D { + defer clip.Rect{ + Max: gtx.Constraints.Max, + }.Op().Push(gtx.Ops).Pop() + + paint.ColorOp{ + Color: r.Theme.Bg, + }.Add(gtx.Ops) + paint.PaintOp{}.Add(gtx.Ops) + + return layout.Dimensions{ + Size: gtx.Constraints.Max, + } + }, + func(gtx C) D { + page := r.pages[r.current.Path] + if page == nil { + page = r.pages[PageHome] + } + + inset := layout.Inset{} + width := unit.Dp(800) + if x := gtx.Metric.PxToDp(gtx.Constraints.Max.X); x > width { + inset.Left = (x - width) / 2 + inset.Right = inset.Left + } + + return inset.Layout(gtx, func(gtx layout.Context) layout.Dimensions { + return page.Layout(gtx) + }) + }, ) } + +type routeStack struct { + routes []Route +} + +func (p *routeStack) Push(route Route) { + p.routes = append(p.routes, route) +} + +func (p *routeStack) Pop() (route Route) { + if len(p.routes) == 0 { + return + } + + n := len(p.routes) - 1 + route = p.routes[n] + p.routes = p.routes[:n] + + return +} + +func (p *routeStack) Peek() (route Route) { + if len(p.routes) == 0 { + return + } + + return p.routes[len(p.routes)-1] +} diff --git a/ui/page/settings/page.go b/ui/page/settings/page.go new file mode 100644 index 0000000..5529ee9 --- /dev/null +++ b/ui/page/settings/page.go @@ -0,0 +1,257 @@ +package settings + +import ( + "gioui.org/font" + "gioui.org/layout" + "gioui.org/widget" + "gioui.org/widget/material" + "gioui.org/x/component" + "github.com/go-gost/gost.plus/config" + "github.com/go-gost/gost.plus/ui/icons" + "github.com/go-gost/gost.plus/ui/page" + "github.com/go-gost/gost.plus/ui/theme" + ui_widget "github.com/go-gost/gost.plus/ui/widget" + "github.com/go-gost/gost.plus/version" +) + +type settingsPage struct { + router *page.Router + modal *component.ModalLayer + menu ui_widget.Menu + list widget.List + + btnBack widget.Clickable + + lang ui_widget.Selector + theme ui_widget.Selector +} + +func NewPage(r *page.Router) page.Page { + return &settingsPage{ + router: r, + modal: component.NewModal(), + menu: ui_widget.Menu{ + List: layout.List{ + Axis: layout.Vertical, + }, + }, + list: widget.List{ + List: layout.List{ + Axis: layout.Vertical, + }, + }, + lang: ui_widget.Selector{Title: "Language"}, + theme: ui_widget.Selector{Title: "Theme"}, + } +} + +func (p *settingsPage) Init(opts ...page.PageOption) { + settings := config.Get().Settings + if settings == nil { + settings = &config.Settings{} + } + if settings.Lang == "" { + settings.Lang = "en_US" + } + if settings.Theme == "" { + settings.Theme = theme.Light + } + + p.lang.Clear() + p.lang.Select(settings.Lang) + + p.theme.Clear() + p.theme.Select(settings.Theme) +} + +func (p *settingsPage) Layout(gtx layout.Context) layout.Dimensions { + if p.btnBack.Clicked(gtx) { + p.router.Back() + } + + th := p.router.Theme + + defer p.modal.Layout(gtx, th) + + return layout.Flex{ + Axis: layout.Vertical, + }.Layout(gtx, + // header + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return layout.Inset{ + Top: 8, + Bottom: 8, + Left: 8, + Right: 8, + }.Layout(gtx, func(gtx layout.Context) layout.Dimensions { + return layout.Flex{ + Alignment: layout.Middle, + }.Layout(gtx, + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + btn := material.IconButton(th, &p.btnBack, icons.IconBack, "Back") + btn.Color = th.Fg + btn.Background = th.Bg + return btn.Layout(gtx) + }), + layout.Rigid(layout.Spacer{Width: 8}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + title := material.H6(th, "Settings") + return title.Layout(gtx) + }), + layout.Rigid(layout.Spacer{Width: 8}.Layout), + ) + }) + }), + layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { + return p.list.Layout(gtx, 1, func(gtx layout.Context, _ int) layout.Dimensions { + return layout.Inset{ + Top: 8, + Bottom: 8, + Left: 8, + Right: 8, + }.Layout(gtx, func(gtx layout.Context) layout.Dimensions { + return p.layout(gtx, th) + }) + }) + }), + ) +} + +func (p *settingsPage) layout(gtx layout.Context, th *material.Theme) layout.Dimensions { + return component.SurfaceStyle{ + Theme: th, + ShadowStyle: component.ShadowStyle{ + CornerRadius: 12, + }, + Fill: theme.Current().ContentSurfaceBg, + }.Layout(gtx, func(gtx layout.Context) layout.Dimensions { + return layout.Inset{ + Top: 8, + Bottom: 8, + Left: 8, + Right: 8, + }.Layout(gtx, func(gtx layout.Context) layout.Dimensions { + return layout.Flex{ + Axis: layout.Vertical, + Alignment: layout.Middle, + }.Layout(gtx, + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return layout.Center.Layout(gtx, func(gtx layout.Context) layout.Dimensions { + return icons.IconApp.Layout(gtx) + }) + }), + layout.Rigid(layout.Spacer{Height: 8}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + label := material.H6(th, "GOST+") + label.Font.Weight = font.Bold + return label.Layout(gtx) + }), + layout.Rigid(layout.Spacer{Height: 8}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return material.Body1(th, version.Version).Layout(gtx) + }), + layout.Rigid(layout.Spacer{Height: 16}.Layout), + + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + if p.lang.Clicked(gtx) { + p.showLangMenu(gtx) + } + return p.lang.Layout(gtx, th) + }), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + if p.theme.Clicked(gtx) { + p.showThemeMenu(gtx) + } + return p.theme.Layout(gtx, th) + }), + ) + }) + }) +} + +func (p *settingsPage) showLangMenu(gtx layout.Context) { + items := []ui_widget.MenuItem{ + {Key: "en_US", Value: "en_US"}, + {Key: "zh_CN", Value: "zh_CN"}, + } + + var found bool + for i := range items { + if found = p.lang.Any(items[i].Value); found { + items[i].Selected = found + break + } + } + if !found { + items[0].Selected = true + } + + p.menu.Title = "Language" + p.menu.Items = items + p.menu.Selected = func(index int) { + p.lang.Clear() + p.lang.Select(p.menu.Items[index].Value) + p.modal.Disappear(gtx.Now) + + cfg := config.Get() + if cfg.Settings == nil { + cfg.Settings = &config.Settings{} + } + cfg.Settings.Lang = p.lang.Value() + + config.Set(cfg) + cfg.Write() + } + + p.modal.Widget = func(gtx layout.Context, th *material.Theme, anim *component.VisibilityAnimation) layout.Dimensions { + return p.menu.Layout(gtx, th) + } + p.modal.Appear(gtx.Now) +} + +func (p *settingsPage) showThemeMenu(gtx layout.Context) { + items := []ui_widget.MenuItem{ + {Key: "light", Value: theme.Light}, + {Key: "dark", Value: theme.Dark}, + } + + var found bool + for i := range items { + if found = p.theme.Any(items[i].Value); found { + items[i].Selected = found + break + } + } + if !found { + items[0].Selected = true + } + + p.menu.Title = "Theme" + p.menu.Items = items + p.menu.Selected = func(index int) { + p.theme.Clear() + p.theme.Select(p.menu.Items[index].Value) + p.modal.Disappear(gtx.Now) + + cfg := config.Get() + if cfg.Settings == nil { + cfg.Settings = &config.Settings{} + } + cfg.Settings.Theme = p.theme.Value() + + config.Set(cfg) + cfg.Write() + + switch cfg.Settings.Theme { + case theme.Dark: + theme.UseDark() + default: + theme.UseLight() + } + } + + p.modal.Widget = func(gtx layout.Context, th *material.Theme, anim *component.VisibilityAnimation) layout.Dimensions { + return p.menu.Layout(gtx, th) + } + p.modal.Appear(gtx.Now) +} diff --git a/ui/page/tcp.go b/ui/page/tcp.go deleted file mode 100644 index 6cffa0a..0000000 --- a/ui/page/tcp.go +++ /dev/null @@ -1,357 +0,0 @@ -package page - -import ( - "fmt" - "image/color" - "log" - "net" - "strings" - - "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/ui/icons" - "golang.org/x/exp/shiny/materialdesign/colornames" -) - -type tcpAddPage struct { - router *Router - - list layout.List - wgDone widget.Clickable - - name component.TextField - addr component.TextField -} - -func NewTCPAddPage(r *Router) Page { - return &tcpAddPage{ - router: r, - list: layout.List{ - Axis: layout.Vertical, - Alignment: layout.Middle, - }, - name: component.TextField{ - Editor: widget.Editor{ - SingleLine: true, - }, - }, - addr: component.TextField{ - Editor: widget.Editor{ - SingleLine: true, - }, - }, - } -} - -func (p *tcpAddPage) Init(opts ...PageOption) { - p.name.Clear() - p.addr.Clear() - - 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.wgDone.Clicked(gtx) { - defer p.router.SwitchTo(Route{Path: PageTunnel}) - p.createTunnel() - tunnel.SaveConfig() - } - return component.SimpleIconButton(bg, fg, &p.wgDone, icons.IconDone).Layout(gtx) - }, - }, - }, nil) - - p.router.bar.Title = "TCP" - p.router.bar.NavigationIcon = icons.IconClose -} - -func (p *tcpAddPage) 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 *tcpAddPage) layout(gtx C, th *material.Theme) D { - return layout.Flex{ - Axis: layout.Vertical, - }.Layout(gtx, - layout.Rigid(material.Body1(th, "Expose local TCP tunnel to public network.").Layout), - layout.Rigid(layout.Spacer{Height: 10}.Layout), - layout.Rigid(func(gtx C) D { - return material.Body1(th, "Tunnel 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, "Endpoint 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") - }), - ) -} - -func (p *tcpAddPage) createTunnel() error { - tun := tunnel.NewTCPTunnel( - tunnel.NameOption(strings.TrimSpace(p.name.Text())), - tunnel.EndpointOption(strings.TrimSpace(p.addr.Text())), - ) - - tunnel.Add(tun) - - if err := tun.Run(); err != nil { - tun.Close() - return err - } - - return nil -} - -type tcpEditPage struct { - router *Router - - id string - - list layout.List - wgFavorite widget.Clickable - wgState widget.Clickable - wgDelete widget.Clickable - wgDone widget.Clickable - - name component.TextField - addr component.TextField - - wgID widget.Clickable - wgEntrypoint widget.Clickable -} - -func NewTCPEditPage(r *Router) Page { - return &tcpEditPage{ - router: r, - list: layout.List{ - Axis: layout.Vertical, - Alignment: layout.Middle, - }, - name: component.TextField{ - Editor: widget.Editor{ - SingleLine: true, - }, - }, - addr: component.TextField{ - Editor: widget.Editor{ - SingleLine: true, - }, - }, - } -} - -func (p *tcpEditPage) Init(opts ...PageOption) { - var options PageOptions - for _, opt := range opts { - opt(&options) - } - - p.id = options.ID - s := tunnel.Get(p.id) - if s != nil { - sopts := s.Options() - p.name.SetText(sopts.Name) - p.addr.SetText(sopts.Endpoint) - } - - actions := []component.AppBarAction{ - { - OverflowAction: component.OverflowAction{ - Name: "Favorite", - Tag: &p.wgFavorite, - }, - Layout: func(gtx C, bg, fg color.NRGBA) D { - s := tunnel.Get(p.id) - if s == nil { - return D{} - } - - if p.wgFavorite.Clicked(gtx) { - s.Favorite(!s.IsFavorite()) - tunnel.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 := tunnel.Get(p.id) - if p.wgState.Clicked(gtx) && s != nil { - if s.IsClosed() { - opts := s.Options() - s = p.createTunnel( - tunnel.NameOption(opts.Name), - tunnel.IDOption(opts.ID), - tunnel.EndpointOption(opts.Endpoint), - ) - } else { - s.Close() - } - tunnel.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) { - tunnel.Delete(p.id) - tunnel.SaveConfig() - p.router.SwitchTo(Route{Path: PageTunnel}) - } - 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.wgDone.Clicked(gtx) { - defer p.router.SwitchTo(Route{Path: PageTunnel}) - - if s := tunnel.Get(p.id); s != nil { - s.Close() - p.createTunnel() - tunnel.SaveConfig() - } - } - return component.SimpleIconButton(bg, fg, &p.wgDone, icons.IconDone).Layout(gtx) - }, - }, - } - p.router.bar.SetActions(actions, nil) - p.router.bar.Title = "TCP" - p.router.bar.NavigationIcon = icons.IconClose -} - -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) - - if err := tun.Run(); err != nil { - tun.Close() - log.Println(err) - } - - return tun -} - -func (p *tcpEditPage) 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 *tcpEditPage) layout(gtx C, th *material.Theme) D { - return layout.Flex{ - Axis: layout.Vertical, - }.Layout(gtx, - layout.Rigid(func(gtx C) D { - return layoutHeader(gtx, th, tunnel.Get(p.id), &p.wgID, &p.wgEntrypoint) - }), - layout.Rigid(layout.Spacer{Height: 10}.Layout), - layout.Rigid(func(gtx C) D { - return material.Body1(th, "Tunnel 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, "Endpoint 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.SplitHostPort(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") - }), - ) -} diff --git a/ui/page/tunnel.go b/ui/page/tunnel.go deleted file mode 100644 index 3be16c0..0000000 --- a/ui/page/tunnel.go +++ /dev/null @@ -1,184 +0,0 @@ -package page - -import ( - "fmt" - "image/color" - "strings" - "sync/atomic" - - "gioui.org/font" - "gioui.org/layout" - "gioui.org/op" - "gioui.org/widget" - "gioui.org/widget/material" - "gioui.org/x/component" - "github.com/go-gost/gost-plus/tunnel" - "github.com/go-gost/gost-plus/ui/icons" - "golang.org/x/exp/shiny/materialdesign/colornames" -) - -type tunnelState struct { - editor widget.Clickable -} - -type tunnelPage struct { - router *Router - - list layout.List - - wgFavorite widget.Clickable - wgAdd widget.Clickable - - tunnels map[int]*tunnelState - favorite atomic.Bool -} - -func NewTunnelPage(r *Router) Page { - return &tunnelPage{ - router: r, - list: layout.List{ - Axis: layout.Vertical, - Alignment: layout.Middle, - }, - tunnels: make(map[int]*tunnelState), - } -} - -func (p *tunnelPage) Init(opts ...PageOption) { - p.router.bar.SetActions( - []component.AppBarAction{ - { - OverflowAction: component.OverflowAction{ - Name: "Favorite", - Tag: &p.wgFavorite, - }, - Layout: func(gtx C, bg, fg color.NRGBA) D { - if p.wgFavorite.Clicked(gtx) { - p.favorite.Store(!p.favorite.Load()) - } - - btn := component.SimpleIconButton(bg, fg, &p.wgFavorite, icons.IconFavorite) - if p.favorite.Load() { - btn.Color = color.NRGBA(colornames.Red500) - } else { - btn.Color = fg - } - return btn.Layout(gtx) - }, - }, - { - OverflowAction: component.OverflowAction{ - Name: "Add", - Tag: &p.wgAdd, - }, - Layout: func(gtx C, bg, fg color.NRGBA) D { - if p.wgAdd.Clicked(gtx) { - p.router.SwitchTo(Route{Path: PageMenu}) - } - return component.SimpleIconButton(bg, fg, &p.wgAdd, icons.IconAdd).Layout(gtx) - }, - }, - }, - []component.OverflowAction{ - { - Name: "About", - Tag: OverflowActionAbout, - }, - }, - ) - - p.router.bar.Title = "Tunnels" - p.router.bar.NavigationIcon = icons.IconHome -} - -func (p *tunnelPage) Layout(gtx C, th *material.Theme) D { - favorite := p.favorite.Load() - // gtx.Constraints.Min.X = gtx.Constraints.Max.X - return p.list.Layout(gtx, tunnel.Count(), func(gtx C, index int) D { - s := tunnel.GetIndex(index) - if s == nil { - delete(p.tunnels, index) - return D{} - } - - if p.tunnels[index] == nil { - p.tunnels[index] = &tunnelState{} - } - - if favorite && !s.IsFavorite() { - return D{} - } - - return layout.Center.Layout(gtx, func(gtx C) D { - return layout.UniformInset(10).Layout(gtx, func(gtx C) D { - surface := component.Surface(th) - return surface.Layout(gtx, func(gtx C) D { - state := p.tunnels[index] - if state.editor.Clicked(gtx) { - switch s.Type() { - case tunnel.FileTunnel: - p.router.SwitchTo(Route{Path: PageEditFile, ID: s.ID()}) - case tunnel.HTTPTunnel: - p.router.SwitchTo(Route{Path: PageEditHTTP, ID: s.ID()}) - case tunnel.TCPTunnel: - p.router.SwitchTo(Route{Path: PageEditTCP, ID: s.ID()}) - case tunnel.UDPTunnel: - p.router.SwitchTo(Route{Path: PageEditUDP, ID: s.ID()}) - } - op.InvalidateOp{}.Add(gtx.Ops) - } - return material.Clickable(gtx, &state.editor, func(gtx C) D { - return layout.UniformInset(10).Layout(gtx, func(gtx C) D { - return p.layout(gtx, th, s) - }) - }) - }) - }) - }) - }) -} - -func (p *tunnelPage) layout(gtx C, th *material.Theme, s tunnel.Tunnel) D { - return layout.Flex{ - Alignment: layout.Middle, - Spacing: layout.SpaceBetween, - }.Layout(gtx, - layout.Flexed(1, func(gtx C) D { - return layout.Flex{Axis: layout.Vertical}.Layout(gtx, - layout.Rigid(func(gtx C) D { - label := material.Body1(th, s.ID()) - label.Font.Weight = font.Bold - return label.Layout(gtx) - }), - layout.Rigid(layout.Spacer{Height: 5}.Layout), - layout.Rigid(material.Body2(th, fmt.Sprintf("Type: %s", strings.ToUpper(s.Type()))).Layout), - layout.Rigid(layout.Spacer{Height: 5}.Layout), - layout.Rigid(material.Body2(th, fmt.Sprintf("Name: %s", s.Name())).Layout), - layout.Rigid(layout.Spacer{Height: 5}.Layout), - layout.Rigid(material.Body2(th, fmt.Sprintf("Endpoint: %s", s.Endpoint())).Layout), - layout.Rigid(layout.Spacer{Height: 5}.Layout), - layout.Rigid(material.Body2(th, fmt.Sprintf("Entrypoint: %s", s.Entrypoint())).Layout), - layout.Rigid(layout.Spacer{Height: 5}.Layout), - layout.Rigid(func(gtx C) D { - if err := s.Err(); !s.IsClosed() && err != nil { - label := material.Body2(th, err.Error()) - label.Color = color.NRGBA(colornames.Red500) - return label.Layout(gtx) - } - return D{} - }), - ) - }), - layout.Rigid(layout.Spacer{Width: 10}.Layout), - layout.Rigid(func(gtx C) D { - c := colornames.Green500 - if s.Err() != nil { - c = colornames.Red500 - } - if s.IsClosed() { - c = colornames.Grey500 - } - return icons.IconTunnelState.Layout(gtx, color.NRGBA(c)) - }), - ) -} diff --git a/ui/page/tunnel/file/page.go b/ui/page/tunnel/file/page.go new file mode 100644 index 0000000..cc0d7a7 --- /dev/null +++ b/ui/page/tunnel/file/page.go @@ -0,0 +1,570 @@ +package file + +import ( + "bytes" + "fmt" + "image/color" + "io" + "os" + "strings" + + "gioui.org/font" + "gioui.org/io/clipboard" + "gioui.org/layout" + "gioui.org/widget" + "gioui.org/widget/material" + "gioui.org/x/component" + "github.com/go-gost/core/logger" + "github.com/go-gost/gost.plus/tunnel" + "github.com/go-gost/gost.plus/ui/icons" + "github.com/go-gost/gost.plus/ui/page" + "github.com/go-gost/gost.plus/ui/theme" + ui_widget "github.com/go-gost/gost.plus/ui/widget" + "golang.org/x/exp/shiny/materialdesign/colornames" +) + +type C = layout.Context +type D = layout.Dimensions + +type filePage struct { + router *page.Router + modal *component.ModalLayer + + btnBack widget.Clickable + btnState widget.Clickable + btnDelete widget.Clickable + btnEdit widget.Clickable + btnSave widget.Clickable + btnFavorite widget.Clickable + + list layout.List + + wgID widget.Clickable + wgEntrypoint widget.Clickable + + name component.TextField + endpoint component.TextField + + basicAuth widget.Bool + username component.TextField + password component.TextField + + btnPasswordVisible widget.Clickable + passwordVisible bool + + id string + edit bool + + delDialog ui_widget.Dialog +} + +func NewPage(r *page.Router) page.Page { + return &filePage{ + router: r, + modal: component.NewModal(), + list: layout.List{ + // NOTE: the list must be vertical + Axis: layout.Vertical, + }, + name: component.TextField{ + Editor: widget.Editor{ + SingleLine: true, + }, + }, + endpoint: component.TextField{ + Editor: widget.Editor{ + SingleLine: true, + }, + }, + username: component.TextField{ + Editor: widget.Editor{ + SingleLine: true, + }, + }, + password: component.TextField{ + Editor: widget.Editor{ + SingleLine: true, + }, + }, + delDialog: ui_widget.Dialog{ + Title: "Delete tunnel?", + }, + } +} + +func (p *filePage) Init(opts ...page.PageOption) { + var options page.PageOptions + for _, opt := range opts { + opt(&options) + } + p.id = options.ID + + if p.id != "" { + p.edit = false + } else { + p.edit = true + } + + p.name.Clear() + p.endpoint.Clear() + p.basicAuth.Value = false + p.username.Clear() + p.password.Clear() + p.passwordVisible = false + + s := tunnel.Get(p.id) + if s != nil { + sopts := s.Options() + p.name.SetText(sopts.Name) + p.endpoint.SetText(sopts.Endpoint) + if sopts.Username != "" { + p.basicAuth.Value = true + p.username.SetText(sopts.Username) + p.password.SetText(sopts.Password) + } + } +} + +func (p *filePage) Layout(gtx C) D { + if p.btnBack.Clicked(gtx) { + p.router.Back() + } + if p.btnEdit.Clicked(gtx) { + p.edit = true + } + + if p.btnSave.Clicked(gtx) { + if p.id == "" { + p.create() + } else { + p.update() + } + p.router.Back() + } + + if p.btnDelete.Clicked(gtx) { + p.delDialog.Clicked = func(ok bool) { + if ok { + p.delete() + p.router.Back() + } + p.modal.Disappear(gtx.Now) + } + p.modal.Widget = func(gtx layout.Context, th *material.Theme, anim *component.VisibilityAnimation) layout.Dimensions { + return p.delDialog.Layout(gtx, th) + } + p.modal.Appear(gtx.Now) + } + + th := p.router.Theme + + defer p.modal.Layout(gtx, th) + + return layout.Flex{ + Axis: layout.Vertical, + }.Layout(gtx, + // header + layout.Rigid(func(gtx C) D { + return layout.Inset{ + Top: 8, + Bottom: 8, + Left: 8, + Right: 8, + }.Layout(gtx, func(gtx C) D { + return layout.Flex{ + Spacing: layout.SpaceBetween, + Alignment: layout.Middle, + }.Layout(gtx, + layout.Rigid(func(gtx C) D { + btn := material.IconButton(th, &p.btnBack, icons.IconBack, "Back") + btn.Color = th.Fg + btn.Background = th.Bg + return btn.Layout(gtx) + }), + layout.Rigid(layout.Spacer{Width: 8}.Layout), + layout.Flexed(1, func(gtx C) D { + title := material.H6(th, "File") + return title.Layout(gtx) + }), + layout.Rigid(layout.Spacer{Width: 8}.Layout), + layout.Rigid(func(gtx C) D { + tun := tunnel.Get(p.id) + if tun == nil { + return D{} + } + + if p.btnFavorite.Clicked(gtx) { + tun.Favorite(!tun.IsFavorite()) + tunnel.SaveConfig() + } + + btn := material.IconButton(th, &p.btnFavorite, icons.IconFavorite, "Favorite") + + if tun.IsFavorite() { + btn.Color = color.NRGBA(colornames.Red500) + } else { + btn.Color = th.Fg + } + btn.Background = th.Bg + + return btn.Layout(gtx) + }), + layout.Rigid(layout.Spacer{Width: 8}.Layout), + layout.Rigid(func(gtx C) D { + tun := tunnel.Get(p.id) + if tun == nil { + return D{} + } + + if p.btnState.Clicked(gtx) { + p.onoff() + } + + if !tun.IsClosed() { + btn := material.IconButton(th, &p.btnState, icons.IconStop, "Stop") + + btn.Color = th.Fg + btn.Background = th.Bg + return btn.Layout(gtx) + } + + btn := material.IconButton(th, &p.btnState, icons.IconStart, "Start") + + btn.Color = th.Fg + btn.Background = th.Bg + return btn.Layout(gtx) + + }), + layout.Rigid(layout.Spacer{Width: 8}.Layout), + layout.Rigid(func(gtx C) D { + if p.id == "" { + return D{} + } + btn := material.IconButton(th, &p.btnDelete, icons.IconDelete, "Delete") + + btn.Color = th.Fg + btn.Background = th.Bg + return btn.Layout(gtx) + }), + layout.Rigid(layout.Spacer{Width: 8}.Layout), + layout.Rigid(func(gtx C) D { + if p.edit { + btn := material.IconButton(th, &p.btnSave, icons.IconDone, "Done") + btn.Color = th.Fg + btn.Background = th.Bg + return btn.Layout(gtx) + } else { + btn := material.IconButton(th, &p.btnEdit, icons.IconEdit, "Edit") + btn.Color = th.Fg + btn.Background = th.Bg + return btn.Layout(gtx) + } + }), + ) + }) + }), + layout.Flexed(1, func(gtx C) D { + return p.list.Layout(gtx, 1, func(gtx C, _ int) D { + return layout.Inset{ + Top: 8, + Bottom: 8, + Left: 8, + Right: 8, + }.Layout(gtx, func(gtx layout.Context) layout.Dimensions { + return p.layout(gtx, th) + }) + }) + }), + ) +} + +func (p *filePage) layout(gtx C, th *material.Theme) D { + src := gtx.Source + + if !p.edit { + gtx = gtx.Disabled() + } + + tun := tunnel.Get(p.id) + + return component.SurfaceStyle{ + Theme: th, + ShadowStyle: component.ShadowStyle{ + CornerRadius: 12, + }, + Fill: theme.Current().ContentSurfaceBg, + }.Layout(gtx, func(gtx C) D { + return layout.Inset{ + Top: 8, + Bottom: 8, + Left: 8, + Right: 8, + }.Layout(gtx, func(gtx C) D { + return layout.Flex{ + Axis: layout.Vertical, + }.Layout(gtx, + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + if tun == nil { + return layout.Dimensions{} + } + + gtx.Source = src + + copied := false + if p.wgID.Clicked(gtx) { + copied = true + gtx.Execute(clipboard.WriteCmd{ + Data: io.NopCloser(bytes.NewBufferString(tun.ID())), + }) + } + + return layout.Inset{ + Top: 8, + Bottom: 8, + }.Layout(gtx, func(gtx layout.Context) layout.Dimensions { + return p.wgID.Layout(gtx, func(gtx layout.Context) layout.Dimensions { + return layout.Flex{ + Alignment: layout.Middle, + }.Layout(gtx, + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + label := material.Body1(th, tun.ID()) + label.Font.Weight = font.SemiBold + return label.Layout(gtx) + }), + layout.Rigid(layout.Spacer{Width: 8}.Layout), + layout.Rigid(func(gtx C) D { + c := color.NRGBA(colornames.Blue500) + if copied { + c = color.NRGBA(colornames.Green500) + } + return icons.IconCopy.Layout(gtx, c) + }), + ) + }) + }) + }), + + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + if tun == nil { + return layout.Dimensions{} + } + + gtx.Source = src + + copied := false + if p.wgEntrypoint.Clicked(gtx) { + copied = true + gtx.Execute(clipboard.WriteCmd{ + Data: io.NopCloser(bytes.NewBufferString(tun.Entrypoint())), + }) + } + + return layout.Inset{ + Top: 8, + Bottom: 8, + }.Layout(gtx, func(gtx layout.Context) layout.Dimensions { + return p.wgEntrypoint.Layout(gtx, func(gtx layout.Context) layout.Dimensions { + return layout.Flex{ + Alignment: layout.Middle, + }.Layout(gtx, + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + label := material.Body1(th, tun.Entrypoint()) + label.Font.Weight = font.SemiBold + return label.Layout(gtx) + }), + layout.Rigid(layout.Spacer{Width: 8}.Layout), + layout.Rigid(func(gtx C) D { + c := color.NRGBA(colornames.Blue500) + if copied { + c = color.NRGBA(colornames.Green500) + } + return icons.IconCopy.Layout(gtx, c) + }), + ) + }) + }) + }), + layout.Rigid(layout.Spacer{Height: 8}.Layout), + + layout.Rigid(func(gtx C) D { + return material.Body1(th, "Name").Layout(gtx) + }), + layout.Rigid(func(gtx C) D { + return p.name.Layout(gtx, th, "") + }), + layout.Rigid(layout.Spacer{Height: 16}.Layout), + layout.Rigid(func(gtx C) D { + return material.Body1(th, "Endpoint").Layout(gtx) + }), + layout.Rigid(func(gtx C) D { + if err := func() error { + dir := strings.TrimSpace(p.endpoint.Text()) + if dir == "" { + return nil + } + f, err := os.Open(dir) + if err != nil { + return err + } + defer f.Close() + fs, err := f.Stat() + if err != nil { + return err + } + if !fs.IsDir() { + return fmt.Errorf("%s is not a directory", dir) + } + return nil + }(); err != nil { + p.endpoint.SetError(err.Error()) + } else { + p.endpoint.ClearError() + } + + return p.endpoint.Layout(gtx, th, "Directory path") + }), + layout.Rigid(layout.Spacer{Height: 16}.Layout), + + layout.Rigid(func(gtx C) D { + return layout.Flex{ + Spacing: layout.SpaceBetween, + }.Layout(gtx, + layout.Flexed(1, material.Body1(th, "Basic auth").Layout), + layout.Rigid(material.Switch(th, &p.basicAuth, "basic auth").Layout), + ) + }), + layout.Rigid(layout.Spacer{Height: 8}.Layout), + + layout.Rigid(func(gtx C) D { + if !p.basicAuth.Value { + p.username.Clear() + return D{} + } + return p.username.Layout(gtx, th, "Username") + }), + layout.Rigid(func(gtx C) D { + if !p.basicAuth.Value { + p.password.Clear() + return D{} + } + + { + gtx := gtx + gtx.Source = src + + if p.btnPasswordVisible.Clicked(gtx) { + p.passwordVisible = !p.passwordVisible + } + + if p.passwordVisible { + p.password.Suffix = func(gtx C) D { + return p.btnPasswordVisible.Layout(gtx, func(gtx C) D { + return icons.IconVisibility.Layout(gtx, color.NRGBA(colornames.Grey500)) + }) + } + p.password.Mask = 0 + } else { + p.password.Suffix = func(gtx C) D { + return p.btnPasswordVisible.Layout(gtx, func(gtx C) D { + return icons.IconVisibilityOff.Layout(gtx, color.NRGBA(colornames.Grey500)) + }) + } + p.password.Mask = '*' + } + } + + return layout.Inset{ + Bottom: 8, + }.Layout(gtx, func(gtx layout.Context) layout.Dimensions { + return p.password.Layout(gtx, th, "Password") + }) + }), + layout.Rigid(layout.Spacer{Height: 8}.Layout), + ) + }) + }) +} + +func (p *filePage) create() error { + defer tunnel.SaveConfig() + + var username, password string + if p.basicAuth.Value { + username = strings.TrimSpace(p.username.Text()) + password = strings.TrimSpace(p.password.Text()) + } + tun := tunnel.NewFileTunnel( + tunnel.NameOption(strings.TrimSpace(p.name.Text())), + tunnel.EndpointOption(strings.TrimSpace(p.endpoint.Text())), + tunnel.UsernameOption(username), + tunnel.PasswordOption(password), + ) + + tunnel.Add(tun) + + if err := tun.Run(); err != nil { + tun.Close() + return err + } + + return nil +} + +func (p *filePage) update(opts ...tunnel.Option) tunnel.Tunnel { + defer tunnel.SaveConfig() + + if t := tunnel.Get(p.id); t != nil { + t.Close() + } + + if opts == nil { + var username, password string + if p.basicAuth.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.endpoint.Text())), + tunnel.UsernameOption(username), + tunnel.PasswordOption(password), + } + } + tun := tunnel.NewFileTunnel(opts...) + + tunnel.Set(tun) + + if err := tun.Run(); err != nil { + tun.Close() + logger.Default().Error(err) + } + + return tun +} + +func (p *filePage) onoff() { + tun := tunnel.Get(p.id) + if tun == nil { + return + } + + if tun.IsClosed() { + opts := tun.Options() + p.update( + tunnel.NameOption(opts.Name), + tunnel.IDOption(opts.ID), + tunnel.EndpointOption(opts.Endpoint), + tunnel.UsernameOption(opts.Username), + tunnel.PasswordOption(opts.Password), + ) + } else { + tun.Close() + } + tunnel.SaveConfig() +} + +func (p *filePage) delete() { + tunnel.Delete(p.id) + tunnel.SaveConfig() +} diff --git a/ui/page/tunnel/http/page.go b/ui/page/tunnel/http/page.go new file mode 100644 index 0000000..3d7e7bb --- /dev/null +++ b/ui/page/tunnel/http/page.go @@ -0,0 +1,635 @@ +package http + +import ( + "bytes" + "fmt" + "image/color" + "io" + "net" + "strings" + + "gioui.org/font" + "gioui.org/io/clipboard" + "gioui.org/layout" + "gioui.org/widget" + "gioui.org/widget/material" + "gioui.org/x/component" + "github.com/go-gost/core/logger" + "github.com/go-gost/gost.plus/tunnel" + "github.com/go-gost/gost.plus/ui/icons" + "github.com/go-gost/gost.plus/ui/page" + "github.com/go-gost/gost.plus/ui/theme" + ui_widget "github.com/go-gost/gost.plus/ui/widget" + "golang.org/x/exp/shiny/materialdesign/colornames" +) + +type C = layout.Context +type D = layout.Dimensions + +type httpPage struct { + router *page.Router + modal *component.ModalLayer + + btnBack widget.Clickable + btnState widget.Clickable + btnDelete widget.Clickable + btnEdit widget.Clickable + btnSave widget.Clickable + btnFavorite widget.Clickable + + list layout.List + + wgID widget.Clickable + wgEntrypoint widget.Clickable + + name component.TextField + endpoint component.TextField + + rewriteHost widget.Bool + hostname component.TextField + + basicAuth widget.Bool + username component.TextField + password component.TextField + + enableTLS widget.Bool + + btnPasswordVisible widget.Clickable + passwordVisible bool + + id string + edit bool + + delDialog ui_widget.Dialog +} + +func NewPage(r *page.Router) page.Page { + return &httpPage{ + router: r, + modal: component.NewModal(), + list: layout.List{ + // NOTE: the list must be vertical + Axis: layout.Vertical, + }, + name: component.TextField{ + Editor: widget.Editor{ + SingleLine: true, + }, + }, + endpoint: component.TextField{ + Editor: widget.Editor{ + SingleLine: true, + }, + }, + hostname: component.TextField{ + Editor: widget.Editor{ + SingleLine: true, + }, + }, + username: component.TextField{ + Editor: widget.Editor{ + SingleLine: true, + }, + }, + password: component.TextField{ + Editor: widget.Editor{ + SingleLine: true, + }, + }, + delDialog: ui_widget.Dialog{ + Title: "Delete tunnel?", + }, + } +} + +func (p *httpPage) Init(opts ...page.PageOption) { + var options page.PageOptions + for _, opt := range opts { + opt(&options) + } + p.id = options.ID + + if p.id != "" { + p.edit = false + } else { + p.edit = true + } + + p.name.Clear() + p.endpoint.Clear() + + p.rewriteHost.Value = false + p.hostname.Clear() + + p.basicAuth.Value = false + p.username.Clear() + p.password.Clear() + p.passwordVisible = false + + s := tunnel.Get(p.id) + if s != nil { + sopts := s.Options() + p.name.SetText(sopts.Name) + p.endpoint.SetText(sopts.Endpoint) + if sopts.Hostname != "" { + p.rewriteHost.Value = true + p.hostname.SetText(sopts.Hostname) + } + if sopts.Username != "" { + p.basicAuth.Value = true + p.username.SetText(sopts.Username) + p.password.SetText(sopts.Password) + } + p.enableTLS.Value = sopts.EnableTLS + } +} + +func (p *httpPage) Layout(gtx C) D { + if p.btnBack.Clicked(gtx) { + p.router.Back() + } + if p.btnEdit.Clicked(gtx) { + p.edit = true + } + + if p.btnSave.Clicked(gtx) { + if p.id == "" { + p.create() + } else { + p.update() + } + p.router.Back() + } + + if p.btnDelete.Clicked(gtx) { + p.delDialog.Clicked = func(ok bool) { + if ok { + p.delete() + p.router.Back() + } + p.modal.Disappear(gtx.Now) + } + p.modal.Widget = func(gtx layout.Context, th *material.Theme, anim *component.VisibilityAnimation) layout.Dimensions { + return p.delDialog.Layout(gtx, th) + } + p.modal.Appear(gtx.Now) + } + + th := p.router.Theme + + defer p.modal.Layout(gtx, th) + + return layout.Flex{ + Axis: layout.Vertical, + }.Layout(gtx, + // header + layout.Rigid(func(gtx C) D { + return layout.Inset{ + Top: 8, + Bottom: 8, + Left: 8, + Right: 8, + }.Layout(gtx, func(gtx C) D { + return layout.Flex{ + Spacing: layout.SpaceBetween, + Alignment: layout.Middle, + }.Layout(gtx, + layout.Rigid(func(gtx C) D { + btn := material.IconButton(th, &p.btnBack, icons.IconBack, "Back") + btn.Color = th.Fg + btn.Background = th.Bg + return btn.Layout(gtx) + }), + layout.Rigid(layout.Spacer{Width: 8}.Layout), + layout.Flexed(1, func(gtx C) D { + title := material.H6(th, "HTTP") + return title.Layout(gtx) + }), + layout.Rigid(layout.Spacer{Width: 8}.Layout), + layout.Rigid(func(gtx C) D { + tun := tunnel.Get(p.id) + if tun == nil { + return D{} + } + + if p.btnFavorite.Clicked(gtx) { + tun.Favorite(!tun.IsFavorite()) + tunnel.SaveConfig() + } + + btn := material.IconButton(th, &p.btnFavorite, icons.IconFavorite, "Favorite") + + if tun.IsFavorite() { + btn.Color = color.NRGBA(colornames.Red500) + } else { + btn.Color = th.Fg + } + btn.Background = th.Bg + + return btn.Layout(gtx) + }), + layout.Rigid(layout.Spacer{Width: 8}.Layout), + layout.Rigid(func(gtx C) D { + tun := tunnel.Get(p.id) + if tun == nil { + return D{} + } + + if p.btnState.Clicked(gtx) { + p.onoff() + } + + if !tun.IsClosed() { + btn := material.IconButton(th, &p.btnState, icons.IconStop, "Stop") + + btn.Color = th.Fg + btn.Background = th.Bg + return btn.Layout(gtx) + } + + btn := material.IconButton(th, &p.btnState, icons.IconStart, "Start") + + btn.Color = th.Fg + btn.Background = th.Bg + return btn.Layout(gtx) + + }), + layout.Rigid(layout.Spacer{Width: 8}.Layout), + layout.Rigid(func(gtx C) D { + if p.id == "" { + return D{} + } + btn := material.IconButton(th, &p.btnDelete, icons.IconDelete, "Delete") + + btn.Color = th.Fg + btn.Background = th.Bg + return btn.Layout(gtx) + }), + layout.Rigid(layout.Spacer{Width: 8}.Layout), + layout.Rigid(func(gtx C) D { + if p.edit { + btn := material.IconButton(th, &p.btnSave, icons.IconDone, "Done") + btn.Color = th.Fg + btn.Background = th.Bg + return btn.Layout(gtx) + } else { + btn := material.IconButton(th, &p.btnEdit, icons.IconEdit, "Edit") + btn.Color = th.Fg + btn.Background = th.Bg + return btn.Layout(gtx) + } + }), + ) + }) + }), + layout.Flexed(1, func(gtx C) D { + return p.list.Layout(gtx, 1, func(gtx C, _ int) D { + return layout.Inset{ + Top: 8, + Bottom: 8, + Left: 8, + Right: 8, + }.Layout(gtx, func(gtx layout.Context) layout.Dimensions { + return p.layout(gtx, th) + }) + }) + }), + ) +} + +func (p *httpPage) layout(gtx C, th *material.Theme) D { + src := gtx.Source + + if !p.edit { + gtx = gtx.Disabled() + } + + tun := tunnel.Get(p.id) + + return component.SurfaceStyle{ + Theme: th, + ShadowStyle: component.ShadowStyle{ + CornerRadius: 12, + }, + Fill: theme.Current().ContentSurfaceBg, + }.Layout(gtx, func(gtx C) D { + return layout.Inset{ + Top: 8, + Bottom: 8, + Left: 8, + Right: 8, + }.Layout(gtx, func(gtx C) D { + return layout.Flex{ + Axis: layout.Vertical, + }.Layout(gtx, + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + if tun == nil { + return layout.Dimensions{} + } + + gtx.Source = src + + copied := false + if p.wgID.Clicked(gtx) { + copied = true + gtx.Execute(clipboard.WriteCmd{ + Data: io.NopCloser(bytes.NewBufferString(tun.ID())), + }) + } + + return layout.Inset{ + Top: 8, + Bottom: 8, + }.Layout(gtx, func(gtx layout.Context) layout.Dimensions { + return p.wgID.Layout(gtx, func(gtx layout.Context) layout.Dimensions { + return layout.Flex{ + Alignment: layout.Middle, + }.Layout(gtx, + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + label := material.Body1(th, tun.ID()) + label.Font.Weight = font.SemiBold + return label.Layout(gtx) + }), + layout.Rigid(layout.Spacer{Width: 8}.Layout), + layout.Rigid(func(gtx C) D { + c := color.NRGBA(colornames.Blue500) + if copied { + c = color.NRGBA(colornames.Green500) + } + return icons.IconCopy.Layout(gtx, c) + }), + ) + }) + }) + }), + + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + if tun == nil { + return layout.Dimensions{} + } + + gtx.Source = src + + copied := false + if p.wgEntrypoint.Clicked(gtx) { + copied = true + gtx.Execute(clipboard.WriteCmd{ + Data: io.NopCloser(bytes.NewBufferString(tun.Entrypoint())), + }) + } + + return layout.Inset{ + Top: 8, + Bottom: 8, + }.Layout(gtx, func(gtx layout.Context) layout.Dimensions { + return p.wgEntrypoint.Layout(gtx, func(gtx layout.Context) layout.Dimensions { + return layout.Flex{ + Alignment: layout.Middle, + }.Layout(gtx, + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + label := material.Body1(th, tun.Entrypoint()) + label.Font.Weight = font.SemiBold + return label.Layout(gtx) + }), + layout.Rigid(layout.Spacer{Width: 8}.Layout), + layout.Rigid(func(gtx C) D { + c := color.NRGBA(colornames.Blue500) + if copied { + c = color.NRGBA(colornames.Green500) + } + return icons.IconCopy.Layout(gtx, c) + }), + ) + }) + }) + }), + layout.Rigid(layout.Spacer{Height: 8}.Layout), + + layout.Rigid(func(gtx C) D { + return material.Body1(th, "Name").Layout(gtx) + }), + layout.Rigid(func(gtx C) D { + return p.name.Layout(gtx, th, "") + }), + layout.Rigid(layout.Spacer{Height: 16}.Layout), + layout.Rigid(func(gtx C) D { + return material.Body1(th, "Endpoint").Layout(gtx) + }), + layout.Rigid(func(gtx C) D { + if err := func() error { + addr := strings.TrimSpace(p.endpoint.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.endpoint.SetError(err.Error()) + } else { + p.endpoint.ClearError() + } + + return p.endpoint.Layout(gtx, th, "Address") + }), + layout.Rigid(layout.Spacer{Height: 8}.Layout), + + layout.Rigid(func(gtx C) D { + return layout.Inset{ + Top: 8, + Bottom: 8, + }.Layout(gtx, func(gtx layout.Context) layout.Dimensions { + return layout.Flex{ + Spacing: layout.SpaceBetween, + }.Layout(gtx, + layout.Flexed(1, material.Body1(th, "Basic auth").Layout), + layout.Rigid(material.Switch(th, &p.basicAuth, "Basic auth").Layout), + ) + }) + }), + + layout.Rigid(func(gtx C) D { + if !p.basicAuth.Value { + p.username.Clear() + return D{} + } + return p.username.Layout(gtx, th, "Username") + }), + layout.Rigid(func(gtx C) D { + if !p.basicAuth.Value { + p.password.Clear() + return D{} + } + + { + gtx := gtx + gtx.Source = src + + if p.btnPasswordVisible.Clicked(gtx) { + p.passwordVisible = !p.passwordVisible + } + + if p.passwordVisible { + p.password.Suffix = func(gtx C) D { + return p.btnPasswordVisible.Layout(gtx, func(gtx C) D { + return icons.IconVisibility.Layout(gtx, color.NRGBA(colornames.Grey500)) + }) + } + p.password.Mask = 0 + } else { + p.password.Suffix = func(gtx C) D { + return p.btnPasswordVisible.Layout(gtx, func(gtx C) D { + return icons.IconVisibilityOff.Layout(gtx, color.NRGBA(colornames.Grey500)) + }) + } + p.password.Mask = '*' + } + } + + return layout.Inset{ + Bottom: 8, + }.Layout(gtx, func(gtx layout.Context) layout.Dimensions { + return p.password.Layout(gtx, th, "Password") + }) + }), + + layout.Rigid(func(gtx C) D { + return layout.Inset{ + Top: 8, + Bottom: 8, + }.Layout(gtx, func(gtx layout.Context) layout.Dimensions { + return layout.Flex{ + Spacing: layout.SpaceBetween, + }.Layout(gtx, + layout.Flexed(1, material.Body1(th, "Custom hostname (rewrite HTTP Host header)").Layout), + layout.Rigid(material.Switch(th, &p.rewriteHost, "Hostname").Layout), + ) + }) + }), + layout.Rigid(func(gtx C) D { + if !p.rewriteHost.Value { + p.hostname.SetText("") + return layout.Dimensions{} + } + + return layout.Inset{ + Bottom: 8, + }.Layout(gtx, func(gtx layout.Context) layout.Dimensions { + return p.hostname.Layout(gtx, th, "Hostname") + }) + }), + + layout.Rigid(func(gtx C) D { + return layout.Inset{Top: 8, Bottom: 8}.Layout(gtx, func(gtx C) D { + return layout.Flex{ + Spacing: layout.SpaceBetween, + }.Layout(gtx, + layout.Flexed(1, material.Body1(th, "Enable TLS").Layout), + layout.Rigid(material.Switch(th, &p.enableTLS, "enable TLS").Layout), + ) + }) + }), + layout.Rigid(layout.Spacer{Height: 8}.Layout), + ) + }) + }) +} + +func (p *httpPage) create() error { + defer tunnel.SaveConfig() + + var username, password string + if p.basicAuth.Value { + username = strings.TrimSpace(p.username.Text()) + password = strings.TrimSpace(p.password.Text()) + } + var hostname string + if p.rewriteHost.Value { + hostname = strings.TrimSpace(p.hostname.Text()) + } + tun := tunnel.NewHTTPTunnel( + tunnel.NameOption(strings.TrimSpace(p.name.Text())), + tunnel.EndpointOption(strings.TrimSpace(p.endpoint.Text())), + tunnel.UsernameOption(username), + tunnel.PasswordOption(password), + tunnel.HostnameOption(hostname), + tunnel.EnableTLSOption(p.enableTLS.Value), + ) + + tunnel.Add(tun) + + if err := tun.Run(); err != nil { + tun.Close() + return err + } + + return nil +} + +func (p *httpPage) update(opts ...tunnel.Option) tunnel.Tunnel { + defer tunnel.SaveConfig() + + if t := tunnel.Get(p.id); t != nil { + t.Close() + } + + if opts == nil { + var username, password string + if p.basicAuth.Value { + username = strings.TrimSpace(p.username.Text()) + password = strings.TrimSpace(p.password.Text()) + } + var hostname string + if p.rewriteHost.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.endpoint.Text())), + tunnel.UsernameOption(username), + tunnel.PasswordOption(password), + tunnel.HostnameOption(hostname), + tunnel.EnableTLSOption(p.enableTLS.Value), + } + } + tun := tunnel.NewHTTPTunnel(opts...) + + tunnel.Set(tun) + + if err := tun.Run(); err != nil { + tun.Close() + logger.Default().Error(err) + } + + return tun +} + +func (p *httpPage) onoff() { + tun := tunnel.Get(p.id) + if tun == nil { + return + } + + if tun.IsClosed() { + opts := tun.Options() + p.update( + tunnel.NameOption(opts.Name), + tunnel.IDOption(opts.ID), + tunnel.EndpointOption(opts.Endpoint), + tunnel.UsernameOption(opts.Username), + tunnel.PasswordOption(opts.Password), + tunnel.HostnameOption(opts.Hostname), + tunnel.EnableTLSOption(opts.EnableTLS), + ) + } else { + tun.Close() + } + tunnel.SaveConfig() +} + +func (p *httpPage) delete() { + tunnel.Delete(p.id) + tunnel.SaveConfig() +} diff --git a/ui/page/tunnel/page.go b/ui/page/tunnel/page.go new file mode 100644 index 0000000..96bd510 --- /dev/null +++ b/ui/page/tunnel/page.go @@ -0,0 +1,155 @@ +package tunnel + +import ( + "image/color" + + "gioui.org/layout" + "gioui.org/widget" + "gioui.org/widget/material" + "github.com/go-gost/gost.plus/ui/icons" + "github.com/go-gost/gost.plus/ui/page" + "github.com/go-gost/gost.plus/ui/theme" + "golang.org/x/exp/shiny/materialdesign/colornames" +) + +type C = layout.Context +type D = layout.Dimensions + +type navButton struct { + btn widget.Clickable + name string + desc string + path page.PagePath +} + +type tunnelPage struct { + router *page.Router + list widget.List + + navs []navButton + + btnBack widget.Clickable +} + +func NewPage(r *page.Router) page.Page { + return &tunnelPage{ + router: r, + list: widget.List{ + List: layout.List{ + Axis: layout.Vertical, + }, + }, + navs: []navButton{ + { + name: "File", + desc: "Expose local files to public network", + path: page.PageTunnelFile, + }, + { + name: "HTTP", + desc: "Expose local HTTP service to public network", + path: page.PageTunnelHTTP, + }, + { + name: "TCP", + desc: "Expose local TCP service to public network", + path: page.PageTunnelTCP, + }, + { + name: "UDP", + desc: "Expose local UDP service to public network", + path: page.PageTunnelUDP, + }, + }, + } +} + +func (p *tunnelPage) Init(opts ...page.PageOption) {} + +func (p *tunnelPage) Layout(gtx C) D { + if p.btnBack.Clicked(gtx) { + p.router.Back() + } + + th := p.router.Theme + + return layout.Flex{ + Axis: layout.Vertical, + }.Layout(gtx, + // header + layout.Rigid(func(gtx C) D { + return layout.Inset{ + Top: 8, + Bottom: 8, + Left: 8, + Right: 8, + }.Layout(gtx, func(gtx C) D { + return layout.Flex{ + Alignment: layout.Middle, + }.Layout(gtx, + layout.Rigid(func(gtx C) D { + btn := material.IconButton(th, &p.btnBack, icons.IconBack, "Back") + btn.Color = th.Fg + btn.Background = th.Bg + return btn.Layout(gtx) + }), + layout.Rigid(layout.Spacer{Width: 8}.Layout), + layout.Rigid(func(gtx C) D { + title := material.H6(th, "Tunnel") + // title.Font.Weight = font.Bold + return title.Layout(gtx) + }), + layout.Rigid(layout.Spacer{Width: 8}.Layout), + ) + }) + }), + layout.Flexed(1, func(gtx C) D { + return p.list.Layout(gtx, len(p.navs), func(gtx C, index int) D { + if p.navs[index].btn.Clicked(gtx) { + p.router.Goto(page.Route{ + Path: p.navs[index].path, + }) + } + + return layout.Inset{ + Top: 8, + Bottom: 8, + Left: 8, + Right: 8, + }.Layout(gtx, func(gtx layout.Context) layout.Dimensions { + return material.ButtonLayoutStyle{ + Background: theme.Current().ListBg, + CornerRadius: 12, + Button: &p.navs[index].btn, + }.Layout(gtx, func(gtx C) D { + return layout.Inset{ + Top: 8, + Bottom: 8, + Left: 8, + Right: 8, + }.Layout(gtx, func(gtx C) D { + return layout.Flex{ + Alignment: layout.Middle, + Spacing: layout.SpaceBetween, + }.Layout(gtx, + layout.Flexed(1, func(gtx C) D { + return layout.Flex{ + Axis: layout.Vertical, + }.Layout(gtx, + layout.Rigid(material.Body1(th, p.navs[index].name).Layout), + layout.Rigid(layout.Spacer{Height: 8}.Layout), + layout.Rigid(material.Body2(th, p.navs[index].desc).Layout), + ) + }), + layout.Rigid(layout.Spacer{Width: 8}.Layout), + layout.Rigid(func(gtx C) D { + return icons.IconForward.Layout(gtx, color.NRGBA(colornames.Grey500)) + }), + ) + }) + }) + }) + }) + }), + ) +} diff --git a/ui/page/tunnel/tcp/page.go b/ui/page/tunnel/tcp/page.go new file mode 100644 index 0000000..636a637 --- /dev/null +++ b/ui/page/tunnel/tcp/page.go @@ -0,0 +1,463 @@ +package tcp + +import ( + "bytes" + "fmt" + "image/color" + "io" + "net" + "strings" + + "gioui.org/font" + "gioui.org/io/clipboard" + "gioui.org/layout" + "gioui.org/widget" + "gioui.org/widget/material" + "gioui.org/x/component" + "github.com/go-gost/core/logger" + "github.com/go-gost/gost.plus/tunnel" + "github.com/go-gost/gost.plus/ui/icons" + "github.com/go-gost/gost.plus/ui/page" + "github.com/go-gost/gost.plus/ui/theme" + ui_widget "github.com/go-gost/gost.plus/ui/widget" + "golang.org/x/exp/shiny/materialdesign/colornames" +) + +type C = layout.Context +type D = layout.Dimensions + +type tcpPage struct { + router *page.Router + modal *component.ModalLayer + + btnBack widget.Clickable + btnState widget.Clickable + btnDelete widget.Clickable + btnEdit widget.Clickable + btnSave widget.Clickable + btnFavorite widget.Clickable + + list layout.List + + wgID widget.Clickable + wgEntrypoint widget.Clickable + + name component.TextField + endpoint component.TextField + + id string + edit bool + + delDialog ui_widget.Dialog +} + +func NewPage(r *page.Router) page.Page { + return &tcpPage{ + router: r, + modal: component.NewModal(), + list: layout.List{ + // NOTE: the list must be vertical + Axis: layout.Vertical, + }, + name: component.TextField{ + Editor: widget.Editor{ + SingleLine: true, + }, + }, + endpoint: component.TextField{ + Editor: widget.Editor{ + SingleLine: true, + }, + }, + delDialog: ui_widget.Dialog{ + Title: "Delete tunnel?", + }, + } +} + +func (p *tcpPage) Init(opts ...page.PageOption) { + var options page.PageOptions + for _, opt := range opts { + opt(&options) + } + p.id = options.ID + + if p.id != "" { + p.edit = false + } else { + p.edit = true + } + + p.name.Clear() + p.endpoint.Clear() + + s := tunnel.Get(p.id) + if s != nil { + sopts := s.Options() + p.name.SetText(sopts.Name) + p.endpoint.SetText(sopts.Endpoint) + } +} + +func (p *tcpPage) Layout(gtx C) D { + if p.btnBack.Clicked(gtx) { + p.router.Back() + } + if p.btnEdit.Clicked(gtx) { + p.edit = true + } + + if p.btnSave.Clicked(gtx) { + if p.id == "" { + p.create() + } else { + p.update() + } + p.router.Back() + } + + if p.btnDelete.Clicked(gtx) { + p.delDialog.Clicked = func(ok bool) { + if ok { + p.delete() + p.router.Back() + } + p.modal.Disappear(gtx.Now) + } + p.modal.Widget = func(gtx layout.Context, th *material.Theme, anim *component.VisibilityAnimation) layout.Dimensions { + return p.delDialog.Layout(gtx, th) + } + p.modal.Appear(gtx.Now) + } + + th := p.router.Theme + + defer p.modal.Layout(gtx, th) + + return layout.Flex{ + Axis: layout.Vertical, + }.Layout(gtx, + // header + layout.Rigid(func(gtx C) D { + return layout.Inset{ + Top: 8, + Bottom: 8, + Left: 8, + Right: 8, + }.Layout(gtx, func(gtx C) D { + return layout.Flex{ + Spacing: layout.SpaceBetween, + Alignment: layout.Middle, + }.Layout(gtx, + layout.Rigid(func(gtx C) D { + btn := material.IconButton(th, &p.btnBack, icons.IconBack, "Back") + btn.Color = th.Fg + btn.Background = th.Bg + return btn.Layout(gtx) + }), + layout.Rigid(layout.Spacer{Width: 8}.Layout), + layout.Flexed(1, func(gtx C) D { + title := material.H6(th, "TCP") + return title.Layout(gtx) + }), + layout.Rigid(layout.Spacer{Width: 8}.Layout), + layout.Rigid(func(gtx C) D { + tun := tunnel.Get(p.id) + if tun == nil { + return D{} + } + + if p.btnFavorite.Clicked(gtx) { + tun.Favorite(!tun.IsFavorite()) + tunnel.SaveConfig() + } + + btn := material.IconButton(th, &p.btnFavorite, icons.IconFavorite, "Favorite") + + if tun.IsFavorite() { + btn.Color = color.NRGBA(colornames.Red500) + } else { + btn.Color = th.Fg + } + btn.Background = th.Bg + + return btn.Layout(gtx) + }), + layout.Rigid(layout.Spacer{Width: 8}.Layout), + layout.Rigid(func(gtx C) D { + tun := tunnel.Get(p.id) + if tun == nil { + return D{} + } + + if p.btnState.Clicked(gtx) { + p.onoff() + } + + if !tun.IsClosed() { + btn := material.IconButton(th, &p.btnState, icons.IconStop, "Stop") + + btn.Color = th.Fg + btn.Background = th.Bg + return btn.Layout(gtx) + } + + btn := material.IconButton(th, &p.btnState, icons.IconStart, "Start") + + btn.Color = th.Fg + btn.Background = th.Bg + return btn.Layout(gtx) + + }), + layout.Rigid(layout.Spacer{Width: 8}.Layout), + layout.Rigid(func(gtx C) D { + if p.id == "" { + return D{} + } + btn := material.IconButton(th, &p.btnDelete, icons.IconDelete, "Delete") + + btn.Color = th.Fg + btn.Background = th.Bg + return btn.Layout(gtx) + }), + layout.Rigid(layout.Spacer{Width: 8}.Layout), + layout.Rigid(func(gtx C) D { + if p.edit { + btn := material.IconButton(th, &p.btnSave, icons.IconDone, "Done") + btn.Color = th.Fg + btn.Background = th.Bg + return btn.Layout(gtx) + } else { + btn := material.IconButton(th, &p.btnEdit, icons.IconEdit, "Edit") + btn.Color = th.Fg + btn.Background = th.Bg + return btn.Layout(gtx) + } + }), + ) + }) + }), + layout.Flexed(1, func(gtx C) D { + return p.list.Layout(gtx, 1, func(gtx C, _ int) D { + return layout.Inset{ + Top: 8, + Bottom: 8, + Left: 8, + Right: 8, + }.Layout(gtx, func(gtx layout.Context) layout.Dimensions { + return p.layout(gtx, th) + }) + }) + }), + ) +} + +func (p *tcpPage) layout(gtx C, th *material.Theme) D { + src := gtx.Source + + if !p.edit { + gtx = gtx.Disabled() + } + + tun := tunnel.Get(p.id) + + return component.SurfaceStyle{ + Theme: th, + ShadowStyle: component.ShadowStyle{ + CornerRadius: 12, + }, + Fill: theme.Current().ContentSurfaceBg, + }.Layout(gtx, func(gtx C) D { + return layout.Inset{ + Top: 8, + Bottom: 8, + Left: 8, + Right: 8, + }.Layout(gtx, func(gtx C) D { + return layout.Flex{ + Axis: layout.Vertical, + }.Layout(gtx, + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + if tun == nil { + return layout.Dimensions{} + } + + gtx.Source = src + + copied := false + if p.wgID.Clicked(gtx) { + copied = true + gtx.Execute(clipboard.WriteCmd{ + Data: io.NopCloser(bytes.NewBufferString(tun.ID())), + }) + } + + return layout.Inset{ + Top: 8, + Bottom: 8, + }.Layout(gtx, func(gtx layout.Context) layout.Dimensions { + return p.wgID.Layout(gtx, func(gtx layout.Context) layout.Dimensions { + return layout.Flex{ + Alignment: layout.Middle, + }.Layout(gtx, + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + label := material.Body1(th, tun.ID()) + label.Font.Weight = font.SemiBold + return label.Layout(gtx) + }), + layout.Rigid(layout.Spacer{Width: 8}.Layout), + layout.Rigid(func(gtx C) D { + c := color.NRGBA(colornames.Blue500) + if copied { + c = color.NRGBA(colornames.Green500) + } + return icons.IconCopy.Layout(gtx, c) + }), + ) + }) + }) + }), + + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + if tun == nil { + return layout.Dimensions{} + } + + gtx.Source = src + + copied := false + if p.wgEntrypoint.Clicked(gtx) { + copied = true + gtx.Execute(clipboard.WriteCmd{ + Data: io.NopCloser(bytes.NewBufferString(tun.Entrypoint())), + }) + } + + return layout.Inset{ + Top: 8, + Bottom: 8, + }.Layout(gtx, func(gtx layout.Context) layout.Dimensions { + return p.wgEntrypoint.Layout(gtx, func(gtx layout.Context) layout.Dimensions { + return layout.Flex{ + Alignment: layout.Middle, + }.Layout(gtx, + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + label := material.Body1(th, tun.Entrypoint()) + label.Font.Weight = font.SemiBold + return label.Layout(gtx) + }), + layout.Rigid(layout.Spacer{Width: 8}.Layout), + layout.Rigid(func(gtx C) D { + c := color.NRGBA(colornames.Blue500) + if copied { + c = color.NRGBA(colornames.Green500) + } + return icons.IconCopy.Layout(gtx, c) + }), + ) + }) + }) + }), + layout.Rigid(layout.Spacer{Height: 8}.Layout), + + layout.Rigid(func(gtx C) D { + return material.Body1(th, "Name").Layout(gtx) + }), + layout.Rigid(func(gtx C) D { + return p.name.Layout(gtx, th, "") + }), + layout.Rigid(layout.Spacer{Height: 16}.Layout), + layout.Rigid(func(gtx C) D { + return material.Body1(th, "Endpoint").Layout(gtx) + }), + layout.Rigid(func(gtx C) D { + if err := func() error { + addr := strings.TrimSpace(p.endpoint.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.endpoint.SetError(err.Error()) + } else { + p.endpoint.ClearError() + } + + return p.endpoint.Layout(gtx, th, "Address") + }), + layout.Rigid(layout.Spacer{Height: 8}.Layout), + ) + }) + }) +} + +func (p *tcpPage) create() error { + defer tunnel.SaveConfig() + + tun := tunnel.NewTCPTunnel( + tunnel.NameOption(strings.TrimSpace(p.name.Text())), + tunnel.EndpointOption(strings.TrimSpace(p.endpoint.Text())), + ) + + tunnel.Add(tun) + + if err := tun.Run(); err != nil { + tun.Close() + return err + } + + return nil +} + +func (p *tcpPage) update(opts ...tunnel.Option) tunnel.Tunnel { + defer tunnel.SaveConfig() + + if t := tunnel.Get(p.id); t != nil { + t.Close() + } + + if opts == nil { + opts = []tunnel.Option{ + tunnel.NameOption(strings.TrimSpace(p.name.Text())), + tunnel.IDOption(p.id), + tunnel.EndpointOption(strings.TrimSpace(p.endpoint.Text())), + } + } + tun := tunnel.NewTCPTunnel(opts...) + + tunnel.Set(tun) + + if err := tun.Run(); err != nil { + tun.Close() + logger.Default().Error(err) + } + + return tun +} + +func (p *tcpPage) onoff() { + tun := tunnel.Get(p.id) + if tun == nil { + return + } + + if tun.IsClosed() { + opts := tun.Options() + p.update( + tunnel.NameOption(opts.Name), + tunnel.IDOption(opts.ID), + tunnel.EndpointOption(opts.Endpoint), + ) + } else { + tun.Close() + } + tunnel.SaveConfig() +} + +func (p *tcpPage) delete() { + tunnel.Delete(p.id) + tunnel.SaveConfig() +} diff --git a/ui/page/tunnel/udp/page.go b/ui/page/tunnel/udp/page.go new file mode 100644 index 0000000..a011ca7 --- /dev/null +++ b/ui/page/tunnel/udp/page.go @@ -0,0 +1,463 @@ +package udp + +import ( + "bytes" + "fmt" + "image/color" + "io" + "net" + "strings" + + "gioui.org/font" + "gioui.org/io/clipboard" + "gioui.org/layout" + "gioui.org/widget" + "gioui.org/widget/material" + "gioui.org/x/component" + "github.com/go-gost/core/logger" + "github.com/go-gost/gost.plus/tunnel" + "github.com/go-gost/gost.plus/ui/icons" + "github.com/go-gost/gost.plus/ui/page" + "github.com/go-gost/gost.plus/ui/theme" + ui_widget "github.com/go-gost/gost.plus/ui/widget" + "golang.org/x/exp/shiny/materialdesign/colornames" +) + +type C = layout.Context +type D = layout.Dimensions + +type udpPage struct { + router *page.Router + modal *component.ModalLayer + + btnBack widget.Clickable + btnState widget.Clickable + btnDelete widget.Clickable + btnEdit widget.Clickable + btnSave widget.Clickable + btnFavorite widget.Clickable + + list layout.List + + wgID widget.Clickable + wgEntrypoint widget.Clickable + + name component.TextField + endpoint component.TextField + + id string + edit bool + + delDialog ui_widget.Dialog +} + +func NewPage(r *page.Router) page.Page { + return &udpPage{ + router: r, + modal: component.NewModal(), + list: layout.List{ + // NOTE: the list must be vertical + Axis: layout.Vertical, + }, + name: component.TextField{ + Editor: widget.Editor{ + SingleLine: true, + }, + }, + endpoint: component.TextField{ + Editor: widget.Editor{ + SingleLine: true, + }, + }, + delDialog: ui_widget.Dialog{ + Title: "Delete tunnel?", + }, + } +} + +func (p *udpPage) Init(opts ...page.PageOption) { + var options page.PageOptions + for _, opt := range opts { + opt(&options) + } + p.id = options.ID + + if p.id != "" { + p.edit = false + } else { + p.edit = true + } + + p.name.Clear() + p.endpoint.Clear() + + s := tunnel.Get(p.id) + if s != nil { + sopts := s.Options() + p.name.SetText(sopts.Name) + p.endpoint.SetText(sopts.Endpoint) + } +} + +func (p *udpPage) Layout(gtx C) D { + if p.btnBack.Clicked(gtx) { + p.router.Back() + } + if p.btnEdit.Clicked(gtx) { + p.edit = true + } + + if p.btnSave.Clicked(gtx) { + if p.id == "" { + p.create() + } else { + p.update() + } + p.router.Back() + } + + if p.btnDelete.Clicked(gtx) { + p.delDialog.Clicked = func(ok bool) { + if ok { + p.delete() + p.router.Back() + } + p.modal.Disappear(gtx.Now) + } + p.modal.Widget = func(gtx layout.Context, th *material.Theme, anim *component.VisibilityAnimation) layout.Dimensions { + return p.delDialog.Layout(gtx, th) + } + p.modal.Appear(gtx.Now) + } + + th := p.router.Theme + + defer p.modal.Layout(gtx, th) + + return layout.Flex{ + Axis: layout.Vertical, + }.Layout(gtx, + // header + layout.Rigid(func(gtx C) D { + return layout.Inset{ + Top: 8, + Bottom: 8, + Left: 8, + Right: 8, + }.Layout(gtx, func(gtx C) D { + return layout.Flex{ + Spacing: layout.SpaceBetween, + Alignment: layout.Middle, + }.Layout(gtx, + layout.Rigid(func(gtx C) D { + btn := material.IconButton(th, &p.btnBack, icons.IconBack, "Back") + btn.Color = th.Fg + btn.Background = th.Bg + return btn.Layout(gtx) + }), + layout.Rigid(layout.Spacer{Width: 8}.Layout), + layout.Flexed(1, func(gtx C) D { + title := material.H6(th, "UDP") + return title.Layout(gtx) + }), + layout.Rigid(layout.Spacer{Width: 8}.Layout), + layout.Rigid(func(gtx C) D { + tun := tunnel.Get(p.id) + if tun == nil { + return D{} + } + + if p.btnFavorite.Clicked(gtx) { + tun.Favorite(!tun.IsFavorite()) + tunnel.SaveConfig() + } + + btn := material.IconButton(th, &p.btnFavorite, icons.IconFavorite, "Favorite") + + if tun.IsFavorite() { + btn.Color = color.NRGBA(colornames.Red500) + } else { + btn.Color = th.Fg + } + btn.Background = th.Bg + + return btn.Layout(gtx) + }), + layout.Rigid(layout.Spacer{Width: 8}.Layout), + layout.Rigid(func(gtx C) D { + tun := tunnel.Get(p.id) + if tun == nil { + return D{} + } + + if p.btnState.Clicked(gtx) { + p.onoff() + } + + if !tun.IsClosed() { + btn := material.IconButton(th, &p.btnState, icons.IconStop, "Stop") + + btn.Color = th.Fg + btn.Background = th.Bg + return btn.Layout(gtx) + } + + btn := material.IconButton(th, &p.btnState, icons.IconStart, "Start") + + btn.Color = th.Fg + btn.Background = th.Bg + return btn.Layout(gtx) + + }), + layout.Rigid(layout.Spacer{Width: 8}.Layout), + layout.Rigid(func(gtx C) D { + if p.id == "" { + return D{} + } + btn := material.IconButton(th, &p.btnDelete, icons.IconDelete, "Delete") + + btn.Color = th.Fg + btn.Background = th.Bg + return btn.Layout(gtx) + }), + layout.Rigid(layout.Spacer{Width: 8}.Layout), + layout.Rigid(func(gtx C) D { + if p.edit { + btn := material.IconButton(th, &p.btnSave, icons.IconDone, "Done") + btn.Color = th.Fg + btn.Background = th.Bg + return btn.Layout(gtx) + } else { + btn := material.IconButton(th, &p.btnEdit, icons.IconEdit, "Edit") + btn.Color = th.Fg + btn.Background = th.Bg + return btn.Layout(gtx) + } + }), + ) + }) + }), + layout.Flexed(1, func(gtx C) D { + return p.list.Layout(gtx, 1, func(gtx C, _ int) D { + return layout.Inset{ + Top: 8, + Bottom: 8, + Left: 8, + Right: 8, + }.Layout(gtx, func(gtx layout.Context) layout.Dimensions { + return p.layout(gtx, th) + }) + }) + }), + ) +} + +func (p *udpPage) layout(gtx C, th *material.Theme) D { + src := gtx.Source + + if !p.edit { + gtx = gtx.Disabled() + } + + tun := tunnel.Get(p.id) + + return component.SurfaceStyle{ + Theme: th, + ShadowStyle: component.ShadowStyle{ + CornerRadius: 12, + }, + Fill: theme.Current().ContentSurfaceBg, + }.Layout(gtx, func(gtx C) D { + return layout.Inset{ + Top: 8, + Bottom: 8, + Left: 8, + Right: 8, + }.Layout(gtx, func(gtx C) D { + return layout.Flex{ + Axis: layout.Vertical, + }.Layout(gtx, + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + if tun == nil { + return layout.Dimensions{} + } + + gtx.Source = src + + copied := false + if p.wgID.Clicked(gtx) { + copied = true + gtx.Execute(clipboard.WriteCmd{ + Data: io.NopCloser(bytes.NewBufferString(tun.ID())), + }) + } + + return layout.Inset{ + Top: 8, + Bottom: 8, + }.Layout(gtx, func(gtx layout.Context) layout.Dimensions { + return p.wgID.Layout(gtx, func(gtx layout.Context) layout.Dimensions { + return layout.Flex{ + Alignment: layout.Middle, + }.Layout(gtx, + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + label := material.Body1(th, tun.ID()) + label.Font.Weight = font.SemiBold + return label.Layout(gtx) + }), + layout.Rigid(layout.Spacer{Width: 8}.Layout), + layout.Rigid(func(gtx C) D { + c := color.NRGBA(colornames.Blue500) + if copied { + c = color.NRGBA(colornames.Green500) + } + return icons.IconCopy.Layout(gtx, c) + }), + ) + }) + }) + }), + + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + if tun == nil { + return layout.Dimensions{} + } + + gtx.Source = src + + copied := false + if p.wgEntrypoint.Clicked(gtx) { + copied = true + gtx.Execute(clipboard.WriteCmd{ + Data: io.NopCloser(bytes.NewBufferString(tun.Entrypoint())), + }) + } + + return layout.Inset{ + Top: 8, + Bottom: 8, + }.Layout(gtx, func(gtx layout.Context) layout.Dimensions { + return p.wgEntrypoint.Layout(gtx, func(gtx layout.Context) layout.Dimensions { + return layout.Flex{ + Alignment: layout.Middle, + }.Layout(gtx, + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + label := material.Body1(th, tun.Entrypoint()) + label.Font.Weight = font.SemiBold + return label.Layout(gtx) + }), + layout.Rigid(layout.Spacer{Width: 8}.Layout), + layout.Rigid(func(gtx C) D { + c := color.NRGBA(colornames.Blue500) + if copied { + c = color.NRGBA(colornames.Green500) + } + return icons.IconCopy.Layout(gtx, c) + }), + ) + }) + }) + }), + layout.Rigid(layout.Spacer{Height: 8}.Layout), + + layout.Rigid(func(gtx C) D { + return material.Body1(th, "Name").Layout(gtx) + }), + layout.Rigid(func(gtx C) D { + return p.name.Layout(gtx, th, "") + }), + layout.Rigid(layout.Spacer{Height: 16}.Layout), + layout.Rigid(func(gtx C) D { + return material.Body1(th, "Endpoint").Layout(gtx) + }), + layout.Rigid(func(gtx C) D { + if err := func() error { + addr := strings.TrimSpace(p.endpoint.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.endpoint.SetError(err.Error()) + } else { + p.endpoint.ClearError() + } + + return p.endpoint.Layout(gtx, th, "Address") + }), + layout.Rigid(layout.Spacer{Height: 8}.Layout), + ) + }) + }) +} + +func (p *udpPage) create() error { + defer tunnel.SaveConfig() + + tun := tunnel.NewUDPTunnel( + tunnel.NameOption(strings.TrimSpace(p.name.Text())), + tunnel.EndpointOption(strings.TrimSpace(p.endpoint.Text())), + ) + + tunnel.Add(tun) + + if err := tun.Run(); err != nil { + tun.Close() + return err + } + + return nil +} + +func (p *udpPage) update(opts ...tunnel.Option) tunnel.Tunnel { + defer tunnel.SaveConfig() + + if t := tunnel.Get(p.id); t != nil { + t.Close() + } + + if opts == nil { + opts = []tunnel.Option{ + tunnel.NameOption(strings.TrimSpace(p.name.Text())), + tunnel.IDOption(p.id), + tunnel.EndpointOption(strings.TrimSpace(p.endpoint.Text())), + } + } + tun := tunnel.NewUDPTunnel(opts...) + + tunnel.Set(tun) + + if err := tun.Run(); err != nil { + tun.Close() + logger.Default().Error(err) + } + + return tun +} + +func (p *udpPage) onoff() { + tun := tunnel.Get(p.id) + if tun == nil { + return + } + + if tun.IsClosed() { + opts := tun.Options() + p.update( + tunnel.NameOption(opts.Name), + tunnel.IDOption(opts.ID), + tunnel.EndpointOption(opts.Endpoint), + ) + } else { + tun.Close() + } + tunnel.SaveConfig() +} + +func (p *udpPage) delete() { + tunnel.Delete(p.id) + tunnel.SaveConfig() +} diff --git a/ui/page/udp.go b/ui/page/udp.go deleted file mode 100644 index 5c4eb4f..0000000 --- a/ui/page/udp.go +++ /dev/null @@ -1,358 +0,0 @@ -package page - -import ( - "fmt" - "image/color" - "log" - "net" - "strings" - - "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/ui/icons" - "golang.org/x/exp/shiny/materialdesign/colornames" -) - -type udpAddPage struct { - router *Router - - list layout.List - wgDone widget.Clickable - - name component.TextField - addr component.TextField -} - -func NewUDPAddPage(r *Router) Page { - return &udpAddPage{ - router: r, - list: layout.List{ - Axis: layout.Vertical, - Alignment: layout.Middle, - }, - name: component.TextField{ - Editor: widget.Editor{ - SingleLine: true, - }, - }, - addr: component.TextField{ - Editor: widget.Editor{ - SingleLine: true, - }, - }, - } -} - -func (p *udpAddPage) Init(opts ...PageOption) { - p.name.Clear() - p.addr.Clear() - - 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.wgDone.Clicked(gtx) { - defer p.router.SwitchTo(Route{Path: PageTunnel}) - p.createTunnel() - tunnel.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 *udpAddPage) 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 *udpAddPage) layout(gtx C, th *material.Theme) D { - return layout.Flex{ - Axis: layout.Vertical, - }.Layout(gtx, - layout.Rigid(material.Body1(th, "Expose local UDP tunnel to public network.").Layout), - layout.Rigid(layout.Spacer{Height: 10}.Layout), - layout.Rigid(func(gtx C) D { - return material.Body1(th, "Tunnel 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, "Endpoint 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") - }), - ) -} - -func (p *udpAddPage) createTunnel() error { - tun := tunnel.NewUDPTunnel( - tunnel.NameOption(strings.TrimSpace(p.name.Text())), - tunnel.EndpointOption(strings.TrimSpace(p.addr.Text())), - ) - - tunnel.Add(tun) - - if err := tun.Run(); err != nil { - tun.Close() - return err - } - - return nil -} - -type udpEditPage struct { - router *Router - - id string - - list layout.List - wgFavorite widget.Clickable - wgState widget.Clickable - wgDelete widget.Clickable - wgDone widget.Clickable - - name component.TextField - addr component.TextField - - wgID widget.Clickable - wgEntrypoint widget.Clickable -} - -func NewUDPEditPage(r *Router) Page { - return &udpEditPage{ - router: r, - list: layout.List{ - Axis: layout.Vertical, - Alignment: layout.Middle, - }, - name: component.TextField{ - Editor: widget.Editor{ - SingleLine: true, - }, - }, - addr: component.TextField{ - Editor: widget.Editor{ - SingleLine: true, - }, - }, - } -} - -func (p *udpEditPage) Init(opts ...PageOption) { - var options PageOptions - for _, opt := range opts { - opt(&options) - } - - p.id = options.ID - s := tunnel.Get(p.id) - if s != nil { - sopts := s.Options() - p.name.SetText(sopts.Name) - p.addr.SetText(sopts.Endpoint) - } - - actions := []component.AppBarAction{ - { - OverflowAction: component.OverflowAction{ - Name: "Favorite", - Tag: &p.wgFavorite, - }, - Layout: func(gtx C, bg, fg color.NRGBA) D { - s := tunnel.Get(p.id) - if s == nil { - return D{} - } - - if p.wgFavorite.Clicked(gtx) { - s.Favorite(!s.IsFavorite()) - tunnel.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 := tunnel.Get(p.id) - if p.wgState.Clicked(gtx) && s != nil { - if s.IsClosed() { - opts := s.Options() - s = p.createTunnel( - tunnel.NameOption(opts.Name), - tunnel.IDOption(opts.ID), - tunnel.EndpointOption(opts.Endpoint), - ) - } else { - s.Close() - } - tunnel.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) { - tunnel.Delete(p.id) - tunnel.SaveConfig() - p.router.SwitchTo(Route{Path: PageTunnel}) - } - 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.wgDone.Clicked(gtx) { - defer p.router.SwitchTo(Route{Path: PageTunnel}) - - p.createTunnel() - if s := tunnel.Get(p.id); s != nil { - s.Close() - p.createTunnel() - tunnel.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 *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) - - if err := tun.Run(); err != nil { - tun.Close() - log.Println(err) - } - - return tun -} - -func (p *udpEditPage) 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 *udpEditPage) layout(gtx C, th *material.Theme) D { - return layout.Flex{ - Axis: layout.Vertical, - }.Layout(gtx, - layout.Rigid(func(gtx C) D { - return layoutHeader(gtx, th, tunnel.Get(p.id), &p.wgID, &p.wgEntrypoint) - }), - layout.Rigid(layout.Spacer{Height: 10}.Layout), - layout.Rigid(func(gtx C) D { - return material.Body1(th, "Tunnel 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, "Endpoint 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.SplitHostPort(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") - }), - ) -} diff --git a/ui/theme/theme.go b/ui/theme/theme.go new file mode 100644 index 0000000..6b5d3fe --- /dev/null +++ b/ui/theme/theme.go @@ -0,0 +1,89 @@ +package theme + +import ( + "image/color" + "sync" + + "gioui.org/widget/material" + "golang.org/x/exp/shiny/materialdesign/colornames" +) + +const ( + Light string = "light" + Dark string = "dark" +) + +type Palette struct { + Material material.Palette + ContentSurfaceBg color.NRGBA + ListBg color.NRGBA + NavButtonBg color.NRGBA + NavButtonContrastBg color.NRGBA +} + +type Theme struct { + Palette +} + +var ( + light = Theme{ + Palette: Palette{ + Material: material.Palette{ + Fg: color.NRGBA(colornames.Black), + Bg: color.NRGBA(colornames.White), + ContrastBg: color.NRGBA{ + R: 0x3f, + G: 0x51, + B: 0xb5, + A: 0xff, + }, + ContrastFg: color.NRGBA(colornames.White), + }, + ContentSurfaceBg: color.NRGBA(colornames.Grey50), + ListBg: color.NRGBA(colornames.BlueGrey50), + NavButtonBg: color.NRGBA(colornames.White), + NavButtonContrastBg: color.NRGBA(colornames.BlueGrey100), + }, + } + + dark = Theme{ + Palette: Palette{ + Material: material.Palette{ + Fg: color.NRGBA(colornames.White), + Bg: color.NRGBA(colornames.Grey900), + ContrastBg: color.NRGBA(colornames.LightBlueA400), + ContrastFg: color.NRGBA(colornames.White), + }, + ContentSurfaceBg: color.NRGBA(colornames.Grey800), + ListBg: color.NRGBA(colornames.Grey700), + NavButtonBg: color.NRGBA(colornames.Grey800), + NavButtonContrastBg: color.NRGBA(colornames.Grey600), + }, + } +) + +var ( + theme Theme = light + mux sync.RWMutex +) + +func Current() Theme { + mux.RLock() + defer mux.RUnlock() + + return theme +} + +func UseLight() { + mux.Lock() + defer mux.Unlock() + + theme = light +} + +func UseDark() { + mux.Lock() + defer mux.Unlock() + + theme = dark +} diff --git a/ui/ui.go b/ui/ui.go index e901c13..406715e 100644 --- a/ui/ui.go +++ b/ui/ui.go @@ -1,36 +1,67 @@ package ui import ( + "gioui.org/font/gofont" "gioui.org/layout" "gioui.org/text" "gioui.org/widget/material" - "github.com/go-gost/gost-plus/ui/fonts" - "github.com/go-gost/gost-plus/ui/page" + "github.com/go-gost/gost.plus/config" + "github.com/go-gost/gost.plus/ui/page" + "github.com/go-gost/gost.plus/ui/page/entrypoint" + tcp_ep "github.com/go-gost/gost.plus/ui/page/entrypoint/tcp" + udp_ep "github.com/go-gost/gost.plus/ui/page/entrypoint/udp" + "github.com/go-gost/gost.plus/ui/page/home" + "github.com/go-gost/gost.plus/ui/page/settings" + "github.com/go-gost/gost.plus/ui/page/tunnel" + "github.com/go-gost/gost.plus/ui/page/tunnel/file" + "github.com/go-gost/gost.plus/ui/page/tunnel/http" + "github.com/go-gost/gost.plus/ui/page/tunnel/tcp" + "github.com/go-gost/gost.plus/ui/page/tunnel/udp" + "github.com/go-gost/gost.plus/ui/theme" ) type C = layout.Context type D = layout.Dimensions type UI struct { - th *material.Theme router *page.Router } func NewUI() *UI { - th := material.NewTheme() - // th.Shaper = text.NewShaper(text.WithCollection(gofont.Collection())) - th.Shaper = text.NewShaper(text.WithCollection(fonts.Collection())) - // th.Bg = color.NRGBA(colornames.Brown800) - // th.Fg = color.NRGBA(colornames.Grey50) - - ui := &UI{ - th: th, - router: page.NewRouter(), + if settings := config.Get().Settings; settings != nil { + switch settings.Theme { + case theme.Dark: + theme.UseDark() + default: + theme.UseLight() + } } - return ui + th := material.NewTheme() + th.Shaper = text.NewShaper(text.WithCollection(gofont.Collection())) + th.Palette = theme.Current().Material + + router := page.NewRouter(th) + router.Register(page.PageHome, home.NewPage(router)) + router.Register(page.PageTunnel, tunnel.NewPage(router)) + router.Register(page.PageTunnelFile, file.NewPage(router)) + router.Register(page.PageTunnelHTTP, http.NewPage(router)) + router.Register(page.PageTunnelTCP, tcp.NewPage(router)) + router.Register(page.PageTunnelUDP, udp.NewPage(router)) + router.Register(page.PageEntrypoint, entrypoint.NewPage(router)) + router.Register(page.PageEntrypointTCP, tcp_ep.NewPage(router)) + router.Register(page.PageEntrypointUDP, udp_ep.NewPage(router)) + router.Register(page.PageSettings, settings.NewPage(router)) + + router.Goto(page.Route{ + Path: page.PageHome, + }) + + return &UI{ + router: router, + } } func (ui *UI) Layout(gtx C) D { - return ui.router.Layout(gtx, ui.th) + return ui.router.Layout(gtx) } diff --git a/ui/widget/dialog.go b/ui/widget/dialog.go new file mode 100644 index 0000000..5b9166e --- /dev/null +++ b/ui/widget/dialog.go @@ -0,0 +1,280 @@ +package widget + +import ( + "gioui.org/layout" + "gioui.org/widget" + "gioui.org/widget/material" + "gioui.org/x/component" +) + +type Dialog struct { + Title string + Body string + Widget func(gtx layout.Context, th *material.Theme) layout.Dimensions + Clicked func(ok bool) + btnCancel widget.Clickable + btnOK widget.Clickable +} + +func (p *Dialog) Layout(gtx layout.Context, th *material.Theme) layout.Dimensions { + if gtx.Constraints.Max.X > gtx.Dp(800) { + gtx.Constraints.Max.X = gtx.Dp(800) + } + gtx.Constraints.Max.X = gtx.Constraints.Max.X * 2 / 3 + + return layout.Center.Layout(gtx, func(gtx layout.Context) layout.Dimensions { + return layout.Inset{ + Top: 16, + Bottom: 16, + }.Layout(gtx, func(gtx layout.Context) layout.Dimensions { + return component.SurfaceStyle{ + Theme: th, + ShadowStyle: component.ShadowStyle{ + CornerRadius: 28, + }, + }.Layout(gtx, func(gtx layout.Context) layout.Dimensions { + return layout.Inset{ + Top: 16, + Bottom: 16, + Left: 24, + Right: 24, + }.Layout(gtx, func(gtx layout.Context) layout.Dimensions { + return layout.Flex{ + Axis: layout.Vertical, + }.Layout(gtx, + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + if p.Title == "" { + return layout.Dimensions{} + } + return layout.Inset{ + Top: 8, + Bottom: 8, + }.Layout(gtx, func(gtx layout.Context) layout.Dimensions { + return material.H6(th, p.Title).Layout(gtx) + }) + }), + + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + if p.Body == "" { + return layout.Dimensions{} + } + return layout.Inset{ + Top: 8, + Bottom: 8, + }.Layout(gtx, func(gtx layout.Context) layout.Dimensions { + return material.Body1(th, p.Body).Layout(gtx) + }) + }), + + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + if p.Widget == nil { + return layout.Dimensions{} + } + return layout.Inset{ + Top: 8, + Bottom: 8, + }.Layout(gtx, func(gtx layout.Context) layout.Dimensions { + return p.Widget(gtx, th) + }) + }), + + layout.Rigid(layout.Spacer{Height: 8}.Layout), + + layout.Rigid(func(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.Spacer{Width: 8}.Layout(gtx) + }), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + if p.btnCancel.Clicked(gtx) && p.Clicked != nil { + p.Clicked(false) + } + + return material.ButtonLayoutStyle{ + Background: th.Bg, + CornerRadius: 20, + Button: &p.btnCancel, + }.Layout(gtx, func(gtx layout.Context) layout.Dimensions { + return layout.Inset{ + Top: 8, + Bottom: 8, + Left: 24, + Right: 24, + }.Layout(gtx, func(gtx layout.Context) layout.Dimensions { + label := material.Body1(th, "Cancel") + label.Color = th.Fg + return label.Layout(gtx) + }) + + }) + }), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return layout.Spacer{Width: 8}.Layout(gtx) + }), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + if p.btnOK.Clicked(gtx) && p.Clicked != nil { + p.Clicked(true) + } + + return material.ButtonLayoutStyle{ + Background: th.Bg, + CornerRadius: 20, + Button: &p.btnOK, + }.Layout(gtx, func(gtx layout.Context) layout.Dimensions { + return layout.Inset{ + Top: 8, + Bottom: 8, + Left: 24, + Right: 24, + }.Layout(gtx, func(gtx layout.Context) layout.Dimensions { + label := material.Body1(th, "OK") + label.Color = th.Fg + return label.Layout(gtx) + }) + + }) + }), + ) + }), + ) + }) + }) + }) + }) +} + +type InputDialog struct { + Surface component.SurfaceStyle + Title string + Body string + Hint string + Input component.TextField + Clicked func(ok bool) + btnCancel widget.Clickable + btnOK widget.Clickable +} + +func (p *InputDialog) Layout(gtx layout.Context, th *material.Theme) layout.Dimensions { + if gtx.Constraints.Max.X > gtx.Dp(800) { + gtx.Constraints.Max.X = gtx.Dp(800) + } + gtx.Constraints.Max.X = gtx.Constraints.Max.X * 2 / 3 + + return layout.Center.Layout(gtx, func(gtx layout.Context) layout.Dimensions { + return layout.Inset{ + Top: 16, + Bottom: 16, + }.Layout(gtx, func(gtx layout.Context) layout.Dimensions { + return p.Surface.Layout(gtx, func(gtx layout.Context) layout.Dimensions { + return layout.Inset{ + Top: 16, + Bottom: 16, + Left: 24, + Right: 24, + }.Layout(gtx, func(gtx layout.Context) layout.Dimensions { + return layout.Flex{ + Axis: layout.Vertical, + }.Layout(gtx, + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + if p.Title == "" { + return layout.Dimensions{} + } + return layout.Inset{ + Top: 8, + Bottom: 8, + }.Layout(gtx, func(gtx layout.Context) layout.Dimensions { + return material.H6(th, p.Title).Layout(gtx) + }) + }), + + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + if p.Body == "" { + return layout.Dimensions{} + } + + return layout.Inset{ + Top: 8, + Bottom: 8, + }.Layout(gtx, func(gtx layout.Context) layout.Dimensions { + return material.Body1(th, p.Body).Layout(gtx) + }) + }), + + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return layout.Inset{ + Top: 8, + Bottom: 8, + }.Layout(gtx, func(gtx layout.Context) layout.Dimensions { + return p.Input.Layout(gtx, th, p.Hint) + }) + }), + layout.Rigid(layout.Spacer{Height: 8}.Layout), + + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return layout.Flex{ + Spacing: layout.SpaceBetween, + Alignment: layout.End, + }.Layout(gtx, + layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { + return layout.Spacer{Width: 0}.Layout(gtx) + }), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + if p.btnCancel.Clicked(gtx) && p.Clicked != nil { + p.Clicked(false) + } + + return material.ButtonLayoutStyle{ + Background: th.Bg, + CornerRadius: 20, + Button: &p.btnCancel, + }.Layout(gtx, func(gtx layout.Context) layout.Dimensions { + return layout.Inset{ + Top: 8, + Bottom: 8, + Left: 24, + Right: 24, + }.Layout(gtx, func(gtx layout.Context) layout.Dimensions { + label := material.Body1(th, "Cancel") + label.Color = th.Fg + return label.Layout(gtx) + }) + + }) + }), + layout.Rigid(layout.Spacer{Width: 8}.Layout), + + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + if p.btnOK.Clicked(gtx) && p.Clicked != nil { + p.Clicked(true) + } + + return material.ButtonLayoutStyle{ + Background: th.Bg, + CornerRadius: 20, + Button: &p.btnOK, + }.Layout(gtx, func(gtx layout.Context) layout.Dimensions { + return layout.Inset{ + Top: 8, + Bottom: 8, + Left: 24, + Right: 24, + }.Layout(gtx, func(gtx layout.Context) layout.Dimensions { + label := material.Body1(th, "OK") + label.Color = th.Fg + return label.Layout(gtx) + }) + + }) + }), + ) + }), + ) + }) + + }) + }) + }) +} diff --git a/ui/widget/menu.go b/ui/widget/menu.go new file mode 100644 index 0000000..78cd00b --- /dev/null +++ b/ui/widget/menu.go @@ -0,0 +1,132 @@ +package widget + +import ( + "gioui.org/layout" + "gioui.org/widget" + "gioui.org/widget/material" + "gioui.org/x/component" + "github.com/go-gost/gost.plus/ui/icons" +) + +type Menu struct { + List layout.List + Items []MenuItem + Title string + Selected func(index int) + btnAdd widget.Clickable + ShowAdd bool + Multiple bool +} + +func (p *Menu) Layout(gtx layout.Context, th *material.Theme) layout.Dimensions { + if gtx.Constraints.Max.X > gtx.Dp(800) { + gtx.Constraints.Max.X = gtx.Dp(800) + } + gtx.Constraints.Max.X = gtx.Constraints.Max.X * 2 / 3 + return layout.Center.Layout(gtx, func(gtx layout.Context) layout.Dimensions { + return layout.Inset{ + Top: 16, + Bottom: 16, + }.Layout(gtx, func(gtx layout.Context) layout.Dimensions { + return component.SurfaceStyle{ + Theme: th, + ShadowStyle: component.ShadowStyle{ + CornerRadius: 28, + }, + }.Layout(gtx, func(gtx layout.Context) layout.Dimensions { + return layout.Inset{ + Top: 16, + Bottom: 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 { + return layout.Inset{ + Top: 8, + Bottom: 8, + Left: 24, + Right: 24, + }.Layout(gtx, func(gtx layout.Context) layout.Dimensions { + return layout.Flex{ + Alignment: layout.Middle, + }.Layout(gtx, + layout.Flexed(1, material.H6(th, p.Title).Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + if !p.ShowAdd { + return layout.Dimensions{} + } + btn := material.IconButton(th, &p.btnAdd, icons.IconAdd, "Add") + btn.Background = th.Bg + btn.Color = th.Fg + btn.Inset = layout.UniformInset(0) + return btn.Layout(gtx) + }), + ) + }) + }), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return layout.Inset{ + Top: 8, + Bottom: 8, + }.Layout(gtx, func(gtx layout.Context) layout.Dimensions { + return p.List.Layout(gtx, len(p.Items), func(gtx layout.Context, index int) layout.Dimensions { + if p.Items[index].state.Clicked(gtx) { + if p.Multiple { + p.Items[index].Selected = !p.Items[index].Selected + } else { + for i := range p.Items { + p.Items[i].Selected = false + } + p.Items[index].Selected = true + } + if p.Selected != nil { + p.Selected(index) + } + } + return p.Items[index].Layout(gtx, th) + }) + }) + }), + ) + }) + }) + }) + }) +} + +type MenuItem struct { + state widget.Clickable + Key string + Value string + Selected bool +} + +func (p *MenuItem) Layout(gtx layout.Context, th *material.Theme) layout.Dimensions { + return material.ButtonLayoutStyle{ + Background: th.Bg, + Button: &p.state, + }.Layout(gtx, func(gtx layout.Context) layout.Dimensions { + return layout.Inset{ + Top: 8, + Bottom: 8, + Left: 16, + Right: 16, + }.Layout(gtx, func(gtx layout.Context) layout.Dimensions { + return layout.Flex{ + Spacing: layout.SpaceBetween, + Alignment: layout.Middle, + }.Layout(gtx, + layout.Flexed(1, material.Body2(th, p.Key).Layout), + layout.Rigid(layout.Spacer{Width: 8}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + if p.Selected { + gtx.Constraints.Min.X = gtx.Dp(16) + return icons.IconDone.Layout(gtx, th.Fg) + } + return layout.Dimensions{} + }), + ) + }) + }) +} diff --git a/ui/widget/metadata.go b/ui/widget/metadata.go new file mode 100644 index 0000000..8709d86 --- /dev/null +++ b/ui/widget/metadata.go @@ -0,0 +1,208 @@ +package widget + +import ( + "strings" + + "gioui.org/layout" + "gioui.org/widget" + "gioui.org/widget/material" + "gioui.org/x/component" + "github.com/go-gost/gost.plus/ui/icons" +) + +type MetadataDialog struct { + Surface component.SurfaceStyle + List material.ListStyle + Clicked func(ok bool) + btnAdd widget.Clickable + btnCancel widget.Clickable + btnOK widget.Clickable + metadata []*kv +} + +func (p *MetadataDialog) Layout(gtx layout.Context, th *material.Theme) layout.Dimensions { + if p.btnAdd.Clicked(gtx) { + p.metadata = append(p.metadata, &kv{}) + } + + if gtx.Constraints.Max.X > gtx.Dp(800) { + gtx.Constraints.Max.X = gtx.Dp(800) + } + gtx.Constraints.Max.X = gtx.Constraints.Max.X * 2 / 3 + return layout.Center.Layout(gtx, func(gtx layout.Context) layout.Dimensions { + return layout.Inset{ + Top: 16, + Bottom: 16, + }.Layout(gtx, func(gtx layout.Context) layout.Dimensions { + return p.Surface.Layout(gtx, func(gtx layout.Context) layout.Dimensions { + return layout.Inset{ + Top: 16, + Bottom: 16, + Left: 24, + Right: 24, + }.Layout(gtx, func(gtx layout.Context) layout.Dimensions { + return layout.Flex{ + Axis: layout.Vertical, + }.Layout(gtx, + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return layout.Inset{ + Top: 8, + Bottom: 8, + }.Layout(gtx, func(gtx layout.Context) layout.Dimensions { + return layout.Flex{ + Alignment: layout.Middle, + }.Layout(gtx, + layout.Flexed(1, material.H6(th, "Metadata").Layout), + layout.Rigid(layout.Spacer{Width: 8}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + btn := material.IconButton(th, &p.btnAdd, icons.IconAdd, "Add") + btn.Background = th.Bg + btn.Color = th.Fg + return btn.Layout(gtx) + }), + ) + }) + }), + + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + for i := range p.metadata { + if p.metadata[i].remove.Clicked(gtx) { + p.metadata = append(p.metadata[:i], p.metadata[i+1:]...) + break + } + } + + gtx.Constraints.Max.Y -= gtx.Dp(80) + return layout.Inset{ + Top: 8, + Bottom: 8, + }.Layout(gtx, func(gtx layout.Context) layout.Dimensions { + return p.List.Layout(gtx, len(p.metadata), func(gtx layout.Context, index int) layout.Dimensions { + return p.metadata[index].Layout(gtx, th) + }) + }) + }), + + layout.Rigid(layout.Spacer{Height: 8}.Layout), + + layout.Rigid(func(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.Spacer{Width: 8}.Layout(gtx) + }), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + if p.btnCancel.Clicked(gtx) && p.Clicked != nil { + p.Clicked(false) + } + + return material.ButtonLayoutStyle{ + Background: th.Bg, + CornerRadius: 20, + Button: &p.btnCancel, + }.Layout(gtx, func(gtx layout.Context) layout.Dimensions { + return layout.Inset{ + Top: 8, + Bottom: 8, + Left: 24, + Right: 24, + }.Layout(gtx, func(gtx layout.Context) layout.Dimensions { + label := material.Body1(th, "Cancel") + label.Color = th.Fg + return label.Layout(gtx) + }) + + }) + }), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return layout.Spacer{Width: 8}.Layout(gtx) + }), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + if p.btnOK.Clicked(gtx) && p.Clicked != nil { + p.Clicked(true) + } + + return material.ButtonLayoutStyle{ + Background: th.Bg, + CornerRadius: 20, + Button: &p.btnOK, + }.Layout(gtx, func(gtx layout.Context) layout.Dimensions { + return layout.Inset{ + Top: 8, + Bottom: 8, + Left: 24, + Right: 24, + }.Layout(gtx, func(gtx layout.Context) layout.Dimensions { + label := material.Body1(th, "OK") + label.Color = th.Fg + return label.Layout(gtx) + }) + + }) + }), + ) + }), + ) + }) + }) + }) + }) +} + +func (p *MetadataDialog) Add(k, v string) { + kv := &kv{} + kv.k.SetText(k) + kv.v.SetText(v) + p.metadata = append(p.metadata, kv) +} + +func (p *MetadataDialog) Clear() { + p.metadata = nil +} + +func (p *MetadataDialog) Metadata() []*kv { + return p.metadata +} + +type kv struct { + k component.TextField + v component.TextField + remove widget.Clickable +} + +func (p *kv) Get() (string, string) { + return strings.TrimSpace(p.k.Text()), strings.TrimSpace(p.v.Text()) +} + +func (p *kv) Set(k, v string) { + p.k.SetText(k) + p.v.SetText(v) +} + +func (p *kv) Layout(gtx layout.Context, th *material.Theme) layout.Dimensions { + return layout.Inset{ + Top: 4, + Bottom: 4, + }.Layout(gtx, func(gtx layout.Context) layout.Dimensions { + return layout.Flex{ + Alignment: layout.End, + }.Layout(gtx, + layout.Flexed(0.4, func(gtx layout.Context) layout.Dimensions { + return p.k.Layout(gtx, th, "Key") + }), + layout.Rigid(layout.Spacer{Width: 8}.Layout), + layout.Flexed(0.4, func(gtx layout.Context) layout.Dimensions { + return p.v.Layout(gtx, th, "Value") + }), + layout.Rigid(layout.Spacer{Width: 8}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + btn := material.IconButton(th, &p.remove, icons.IconDelete, "Remove") + btn.Color = th.Fg + btn.Background = th.Bg + return btn.Layout(gtx) + }), + ) + }) +} diff --git a/ui/widget/nav.go b/ui/widget/nav.go new file mode 100644 index 0000000..912e6e4 --- /dev/null +++ b/ui/widget/nav.go @@ -0,0 +1,104 @@ +package widget + +import ( + "image/color" + + "gioui.org/layout" + "gioui.org/unit" + "gioui.org/widget" + "gioui.org/widget/material" + "github.com/go-gost/gost.plus/ui/theme" + "golang.org/x/exp/shiny/materialdesign/colornames" +) + +type Nav struct { + list layout.List + btns []*NavButton + current int +} + +func NewNav(btns ...*NavButton) *Nav { + return &Nav{ + btns: btns, + } +} + +func (p *Nav) Current() int { + return p.current +} + +func (p *Nav) SetCurrent(n int) { + p.current = n +} + +func (p *Nav) Layout(gtx layout.Context, th *material.Theme) layout.Dimensions { + for i, btn := range p.btns { + if btn.btn.Clicked(gtx) { + p.current = i + break + } + } + + return p.list.Layout(gtx, len(p.btns), func(gtx layout.Context, index int) layout.Dimensions { + btn := p.btns[index] + + if p.current == index { + btn.background = theme.Current().NavButtonContrastBg + btn.borderWidth = 0 + } else { + btn.background = theme.Current().NavButtonBg + btn.borderWidth = 1 + } + + return layout.Inset{ + Top: 8, + Bottom: 8, + Left: 12, + Right: 12, + }.Layout(gtx, func(gtx layout.Context) layout.Dimensions { + return btn.Layout(gtx, th) + }) + }) +} + +type NavButton struct { + btn widget.Clickable + cornerRadius unit.Dp + borderWidth unit.Dp + borderColor color.NRGBA + background color.NRGBA + text string +} + +func NewNavButton(text string) *NavButton { + return &NavButton{ + cornerRadius: 18, + borderWidth: 1, + borderColor: color.NRGBA(colornames.Grey200), + text: text, + } +} + +func (btn *NavButton) Layout(gtx layout.Context, th *material.Theme) layout.Dimensions { + return material.ButtonLayoutStyle{ + Background: btn.background, + CornerRadius: btn.cornerRadius, + Button: &btn.btn, + }.Layout(gtx, func(gtx layout.Context) layout.Dimensions { + return widget.Border{ + Color: btn.borderColor, + Width: btn.borderWidth, + CornerRadius: btn.cornerRadius, + }.Layout(gtx, func(gtx layout.Context) layout.Dimensions { + return layout.Inset{ + Top: 8, + Bottom: 8, + Left: 20, + Right: 20, + }.Layout(gtx, func(gtx layout.Context) layout.Dimensions { + label := material.Body1(th, btn.text) + return label.Layout(gtx) + }) + }) + }) +} diff --git a/ui/widget/selector.go b/ui/widget/selector.go new file mode 100644 index 0000000..a83fde7 --- /dev/null +++ b/ui/widget/selector.go @@ -0,0 +1,99 @@ +package widget + +import ( + "strings" + + "gioui.org/layout" + "gioui.org/widget" + "gioui.org/widget/material" + "github.com/go-gost/gost.plus/ui/icons" +) + +type Selector struct { + Title string + items []string + clickable widget.Clickable +} + +func (p *Selector) Layout(gtx layout.Context, th *material.Theme) layout.Dimensions { + return material.Clickable(gtx, &p.clickable, func(gtx layout.Context) layout.Dimensions { + return layout.Inset{ + Top: 8, + Bottom: 8, + }.Layout(gtx, func(gtx layout.Context) layout.Dimensions { + return layout.Flex{ + Alignment: layout.Middle, + }.Layout(gtx, + layout.Rigid(material.Body1(th, p.Title).Layout), + layout.Rigid(layout.Spacer{Width: 8}.Layout), + layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { + return layout.E.Layout(gtx, func(gtx layout.Context) layout.Dimensions { + return material.Body2(th, strings.Join(p.items, ",")).Layout(gtx) + }) + }), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return icons.IconNavRight.Layout(gtx, th.Fg) + }), + ) + }) + }) +} + +func (p *Selector) Clicked(gtx layout.Context) bool { + return p.clickable.Clicked(gtx) +} + +func (p *Selector) Select(items ...string) { + for _, item := range items { + if item == "" { + continue + } + p.items = append(p.items, item) + } +} + +func (p *Selector) Unselect(item string) { + for i := range p.items { + if p.items[i] == item { + p.items = append(p.items[:i], p.items[i+1:]...) + return + } + } +} + +func (p *Selector) Any(items ...string) bool { + for _, item := range items { + if p.contains(item) { + return true + } + } + return false +} + +func (p *Selector) Value() string { + if len(p.items) == 0 { + return "" + } + return p.items[0] +} + +func (p *Selector) Values() []string { + return p.items +} + +func (p *Selector) contains(item string) bool { + if item == "" && len(p.items) == 0 { + return true + } + + for i := range p.items { + if p.items[i] == item { + return true + } + } + return false +} + +func (p *Selector) Clear() { + p.items = nil +} diff --git a/ui/widget/switcher.go b/ui/widget/switcher.go new file mode 100644 index 0000000..cdec49d --- /dev/null +++ b/ui/widget/switcher.go @@ -0,0 +1,41 @@ +package widget + +import ( + "gioui.org/layout" + "gioui.org/widget" + "gioui.org/widget/material" +) + +type Switcher struct { + clickable widget.Clickable + b widget.Bool + Title string +} + +func (p *Switcher) Layout(gtx layout.Context, th *material.Theme) layout.Dimensions { + if p.clickable.Clicked(gtx) { + p.b.Value = !p.b.Value + } + + return material.Clickable(gtx, &p.clickable, func(gtx layout.Context) layout.Dimensions { + return layout.Inset{ + Top: 8, + Bottom: 8, + }.Layout(gtx, func(gtx layout.Context) layout.Dimensions { + return layout.Flex{ + Alignment: layout.Middle, + }.Layout(gtx, + layout.Flexed(1, material.Body1(th, p.Title).Layout), + layout.Rigid(material.Switch(th, &p.b, "").Layout), + ) + }) + }) +} + +func (p *Switcher) Value() bool { + return p.b.Value +} + +func (p *Switcher) SetValue(b bool) { + p.b.Value = b +} diff --git a/version/version.go b/version/version.go index a33bb34..3494801 100644 --- a/version/version.go +++ b/version/version.go @@ -1,3 +1,3 @@ package version -var Version = "0.2.0" +var Version = "0.3.0" diff --git a/winres/winres.go b/winres/winres.go new file mode 100644 index 0000000..475afc3 --- /dev/null +++ b/winres/winres.go @@ -0,0 +1 @@ +package winres diff --git a/winres/winres.json b/winres/winres.json index e6e0a99..b35e1ea 100644 --- a/winres/winres.json +++ b/winres/winres.json @@ -13,7 +13,7 @@ "name": "", "version": "" }, - "description": "Simple GUI client for GOST.PLUS", + "description": "GUI client for GOST.PLUS", "minimum-os": "win7", "execution-level": "as invoker", "ui-access": false, @@ -35,22 +35,22 @@ "#1": { "0000": { "fixed": { - "file_version": "0.2.0.0", - "product_version": "0.2.0.0" + "file_version": "0.3.0.0", + "product_version": "0.3.0.0" }, "info": { "0409": { "Comments": "", "CompanyName": "GOST.PLUS", - "FileDescription": "A simple GUI client for GOST.PLUS", - "FileVersion": "0.2.0", + "FileDescription": "GUI client for GOST.PLUS", + "FileVersion": "0.3.0", "InternalName": "gost.plus", "LegalCopyright": "Copyright © 2024 GOST.PLUS", "LegalTrademarks": "GOST.PLUS", - "OriginalFilename": "gost-plus", + "OriginalFilename": "gost.plus", "PrivateBuild": "", "ProductName": "GOST.PLUS", - "ProductVersion": "0.2.0", + "ProductVersion": "0.3.0", "SpecialBuild": "" } }