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": ""
}
}