7 Commits
Author SHA1 Message Date
ginuerzh 42846b8e7b add noto fonts 2024-06-03 22:23:28 +08:00
ginuerzh 64838e272f fix auth for http tunnel 2024-04-23 19:07:42 +08:00
ginuerzh 4cb9abe5dd added service stats 2024-04-22 23:14:36 +08:00
ginuerzh 6360ba3875 add i18n 2024-03-22 14:51:24 +08:00
ginuerzh 02e071bbbc add README.md 2024-03-16 18:21:48 +08:00
ginuerzh ee37621f97 add theme 2024-03-16 18:19:00 +08:00
ginuerzh c2987df06d add bg for list item 2023-12-16 23:25:18 +08:00
79 changed files with 6622 additions and 3884 deletions
+2 -1
View File
@@ -21,8 +21,9 @@
go.work
bin/
build/
*.syso
*.apk
*.idsig
gost-plus
gost.plus
+26 -15
View File
@@ -1,46 +1,57 @@
# 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)
GOOS=linux GOARCH=amd64 CGO_ENABLED=1 go build --ldflags="-s -w" -v -x -a -o $(BINDIR)/$(NAME)-$(VERSION)-$@ $(GOFILES)
linux-arm64:
GOOS=linux GOARCH=arm64 CGO_ENABLED=1 go build --ldflags="-s -w" -v -x -a -o $(BINDIR)/$(NAME)-$(VERSION)-$@ $(GOFILES)
darwin-amd64:
GOOS=darwin GOARCH=amd64 $(GOBUILD) -o $(BINDIR)/$(NAME)-$@ $(GOFILES)
GOOS=darwin GOARCH=amd64 $(GOBUILD) -o $(BINDIR)/$(NAME)-$(VERSION)-$@ $(GOFILES)
darwin-arm64:
GOOS=darwin GOARCH=arm64 $(GOBUILD) -o $(BINDIR)/$(NAME)-$@ $(GOFILES)
GOOS=darwin GOARCH=arm64 $(GOBUILD) -o $(BINDIR)/$(NAME)-$(VERSION)-$@ $(GOFILES)
# https://github.com/tc-hib/go-winres
windows-amd64: winres
go-winres make
GOOS=windows GOARCH=amd64 CGO_ENABLED=0 go build -ldflags="-s -w -H windowsgui" -o $(BINDIR)/$(NAME)-$@.exe $(GOFILES)
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)-$(VERSION)-$@.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)-$(VERSION)-$@.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 22 -version $(VERSION).8 -name GOST+ -signkey build/sign.keystore -signpass android -appid gost.plus -o $(BINDIR)/$(NAME)-$(VERSION).aab .
gogio -x -work -target android -minsdk 22 -version $(VERSION).8 -name GOST+ -signkey build/sign.keystore -signpass android -appid gost.plus -o $(BINDIR)/$(NAME)-$(VERSION).apk .
gz_releases=$(addsuffix .gz, $(PLATFORM_LIST))
zip_releases=$(addsuffix .zip, $(WINDOWS_ARCH_LIST))
$(gz_releases): %.gz : %
chmod +x $(BINDIR)/$(NAME)-$(basename $@)
gzip -f -S -$(VERSION).gz $(BINDIR)/$(NAME)-$(basename $@)
chmod +x $(BINDIR)/$(NAME)-$(VERSION)-$(basename $@)
gzip -f -S .gz $(BINDIR)/$(NAME)-$(VERSION)-$(basename $@)
$(zip_releases): %.zip : %
zip -m -j $(BINDIR)/$(NAME)-$(basename $@)-$(VERSION).zip $(BINDIR)/$(NAME)-$(basename $@).exe
zip -m -j $(BINDIR)/$(NAME)-$(VERSION)-$(basename $@).zip $(BINDIR)/$(NAME)-$(VERSION)-$(basename $@).exe
releases: $(gz_releases) $(zip_releases)
releases: $(gz_releases) $(zip_releases) android
clean:
rm $(BINDIR)/*
rm *.syso -f
rm $(BINDIR)/* -rf
+1 -1
View File
@@ -1,6 +1,6 @@
# GOST-PLUS
A cross-platform GUI client for GOST.PLUS built with [gioui](https://gioui.org).
A cross-platform GUI client for [GOST.PLUS](https://gost.plus) built with [gioui](https://gioui.org).
## Features
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 137 KiB

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 157 KiB

After

Width:  |  Height:  |  Size: 82 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 148 KiB

After

Width:  |  Height:  |  Size: 116 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 778 KiB

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 391 KiB

After

Width:  |  Height:  |  Size: 144 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 74 KiB

After

Width:  |  Height:  |  Size: 50 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 751 KiB

After

Width:  |  Height:  |  Size: 1.1 MiB

+51 -38
View File
@@ -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,20 @@ type Tunnel struct {
Keepalive bool `yaml:",omitempty"`
TTL int `yaml:"ttl,omitempty"`
Favorite bool
Closed bool
Stats ServiceStats
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
@@ -137,3 +138,15 @@ func (c *Config) Write() error {
return os.WriteFile(filepath.Join(configDir, configFile), buf.Bytes(), 0644)
}
type ServiceStats struct {
Time time.Time
TotalConns uint64
RequestRate float64
CurrentConns uint64
TotalErrs uint64
InputBytes uint64
InputRateBytes uint64
OutputBytes uint64
OutputRateBytes uint64
}
+24 -22
View File
@@ -1,15 +1,17 @@
module github.com/go-gost/gost-plus
module github.com/go-gost/gost.plus
go 1.21.1
go 1.22
toolchain go1.22.2
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.6.0
gioui.org/x v0.6.1
github.com/go-gost/core v0.0.0-20240309124515-fea73cf68235
github.com/go-gost/x v0.0.0-20240131151842-25dcf536c6f5
github.com/google/uuid v1.6.0
golang.org/x/exp/shiny v0.0.0-20240103183307-be819d1f06fc
gopkg.in/yaml.v3 v3.0.1
)
require (
@@ -20,13 +22,13 @@ require (
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/plugin v0.0.0-20240103125338-9c84e29cb81a // indirect
github.com/go-gost/relay v0.5.0 // 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/go-text/typesetting v0.1.1 // indirect
github.com/gobwas/glob v0.2.3 // indirect
github.com/golang/protobuf v1.5.3 // indirect
github.com/golang/protobuf v1.5.4 // 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
@@ -36,7 +38,7 @@ require (
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/client_model v0.5.0 // 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
@@ -47,19 +49,19 @@ require (
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/spf13/viper v1.18.2 // 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-20240314144324-c7f7c6466f7f // indirect
golang.org/x/image v0.14.0 // indirect
golang.org/x/net v0.24.0 // indirect
golang.org/x/sys v0.19.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
google.golang.org/genproto/googleapis/rpc v0.0.0-20240415180920-8c6c420018be // indirect
google.golang.org/grpc v1.63.2 // indirect
google.golang.org/protobuf v1.33.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
)
+42 -80
View File
@@ -1,14 +1,14 @@
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.6.0 h1:ZSXO/AbpFZJ2L9NU69uFQfDI3BKIH+YEJElrn0B+aZI=
gioui.org v0.6.0/go.mod h1:eUvGo6FAzA7jUqeSu5a+M1W03yc9r1nanIBS8A5+Nng=
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=
gioui.org/x v0.6.1 h1:cHxT3CA07YYm6yNv6FrHRUqbNH2KId8vli3uIM8BF5A=
gioui.org/x v0.6.1/go.mod h1:18BNyA2swIgDWV3/Nf7cQh9TaCTQQGbdLUg+pmURS3o=
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=
@@ -23,37 +23,33 @@ github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHk
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/core v0.0.0-20240309124515-fea73cf68235 h1:2iju5ndwCnazCaOcacxJaVFf3J/yb0L0ebXYh2J4rik=
github.com/go-gost/core v0.0.0-20240309124515-fea73cf68235/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/plugin v0.0.0-20240103125338-9c84e29cb81a h1:ME7P1Brcg4C640DSPqlvQr7JuvvQfJ8QpmS3yCFlK3A=
github.com/go-gost/plugin v0.0.0-20240103125338-9c84e29cb81a/go.mod h1:qXr2Zm9Ex2ATqnWuNUzVZqySPMnuIihvblYZt4MlZLw=
github.com/go-gost/relay v0.5.0 h1:JG1tgy/KWiVXS0ukuVXvbM0kbYuJTWxYpJ5JwzsCf/c=
github.com/go-gost/relay v0.5.0/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-gost/x v0.0.0-20240131151842-25dcf536c6f5 h1:IiZLdqGMx0lGVbDBy/N9LPu10qSlxm939EBvZ77qJNI=
github.com/go-gost/x v0.0.0-20240131151842-25dcf536c6f5/go.mod h1:FDqjiiPbCqJLU/wY+q2IZCBVcYnfTJTw+SJLrspLQms=
github.com/go-redis/redis/v8 v8.11.5 h1:AcZZR7igkdvfVmQTPnu9WE37LRrO/YrBH5zWyjDC0oI=
github.com/go-redis/redis/v8 v8.11.5/go.mod h1:gREzHqY1hg6oD9ngVRbLStwAWKhA0FEgq8Jd4h5lpwo=
github.com/go-text/typesetting v0.0.0-20230803102845-24e03d8b5372 h1:FQivqchis6bE2/9uF70M2gmmLpe82esEm2QadL0TEJo=
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/go-text/typesetting v0.1.1 h1:bGAesCuo85nXnEN5LmFMVGAGpGkCPtHrZLi//qD7EJo=
github.com/go-text/typesetting v0.1.1/go.mod h1:d22AnmeKq/on0HNv73UFriMKc4Ez6EqZAofLhAzpSzI=
github.com/go-text/typesetting-utils v0.0.0-20231211103740-d9332ae51f04 h1:zBx+p/W2aQYtNuyZNcTfinWvXBQwYtDfme051PR/lAY=
github.com/go-text/typesetting-utils v0.0.0-20231211103740-d9332ae51f04/go.mod h1:DDxDdQEnB70R8owOx3LVpEFvpMK9eeH1o2r0yZhFI9o=
github.com/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/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.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=
@@ -85,8 +81,8 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI
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/client_model v0.5.0 h1:VQw1hfvPvk3Uv6Qf29VrPF32JB6rtbgI6cYPYQjL0Qw=
github.com/prometheus/client_model v0.5.0/go.mod h1:dTiFglRmd66nLR9Pv9f0mZi7B7fk5Pm3gvsjB5tr+kI=
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=
@@ -109,8 +105,8 @@ 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/spf13/viper v1.18.2 h1:LUXCnvUvSM6FXAsj6nnfc8Q2tp1dIgUfY9Kc8GsSOiQ=
github.com/spf13/viper v1.18.2/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=
@@ -126,62 +122,28 @@ 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/exp v0.0.0-20240314144324-c7f7c6466f7f h1:3CW0unweImhOzd5FmYuRsD4Y4oQFKZIjAnKbjV4WIrw=
golang.org/x/exp v0.0.0-20240314144324-c7f7c6466f7f/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/net v0.24.0 h1:1PcaxkF854Fu3+lvBIx5SYn9wRlBzzcnHZSiaFFAb0w=
golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8=
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/sys v0.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o=
golang.org/x/sys v0.19.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=
google.golang.org/genproto/googleapis/rpc v0.0.0-20240415180920-8c6c420018be h1:LG9vZxsWGOmUKieR8wPAUR3u3MpnYFQZROPIMaXh7/A=
google.golang.org/genproto/googleapis/rpc v0.0.0-20240415180920-8c6c420018be/go.mod h1:WtryC6hu0hhx87FDGxWCDptyssuo68sk10vYjF+T9fY=
google.golang.org/grpc v1.63.2 h1:MUeiw1B2maTVZthpU5xvASfTh3LDbxHd6IJ6QQVU+xM=
google.golang.org/grpc v1.63.2/go.mod h1:WAX/8DgncnokcFUldAxq7GeB5DXHDbMF+lLvDomNkRA=
google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI=
google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
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=
+69 -22
View File
@@ -1,51 +1,98 @@
package main
import (
"log"
"context"
"fmt"
"log/slog"
_ "net"
"os"
"time"
"gioui.org/app"
_ "gioui.org/app/permission/storage"
"gioui.org/io/system"
"gioui.org/layout"
"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/core/logger"
"github.com/go-gost/gost.plus/config"
"github.com/go-gost/gost.plus/runner"
"github.com/go-gost/gost.plus/runner/task"
"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/ui/page"
"github.com/go-gost/gost.plus/ui/theme"
"github.com/go-gost/gost.plus/ui/widget"
_ "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.MinSize(800, 600),
)
err := run(w)
if err != nil {
log.Fatal(err)
if err := run(); err != nil {
logger.Default().Fatal(err)
}
os.Exit(0)
}()
app.Main()
}
func run(w *app.Window) error {
func run() error {
ui := ui.NewUI()
go handleEvent(ui)
w := ui.Window()
var ops op.Ops
for {
switch e := w.NextEvent().(type) {
case system.DestroyEvent:
switch e := w.Event().(type) {
case app.DestroyEvent:
tunnel.SaveConfig()
entrypoint.SaveConfig()
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)
}
}
}
func handleEvent(ui *ui.UI) {
for {
select {
case e := <-ui.Router().Event():
switch e.ID {
case page.EventThemeChanged:
slog.Debug("theme changed", "event", e.ID)
ui.Window().Option(app.StatusColor(theme.Current().Material.Bg))
}
case e := <-runner.Event():
switch e.TaskID {
case runner.TaskUpdateStats:
ui.Window().Invalidate()
default:
if e.Err != nil {
slog.Error(fmt.Sprintf("task: %s", e.Err), "task", e.TaskID)
ui.Router().Notify(widget.Message{
Type: widget.Error,
Content: e.Err.Error(),
})
}
}
}
}
}
func Init() {
config.Init()
tunnel.LoadConfig()
entrypoint.LoadConfig()
runner.Exec(context.Background(), task.UpdateStats(),
runner.WithAync(true),
runner.WithInterval(time.Second),
runner.WithCancel(true),
)
}
+193
View File
@@ -0,0 +1,193 @@
package runner
import (
"context"
"sync"
"time"
"github.com/go-gost/core/logger"
)
var (
runner = NewRunner()
)
type TaskEvent struct {
TaskID TaskID
Err error
}
type taskState struct {
task Task
cancel context.CancelFunc
}
type Options struct {
Async bool
Interval time.Duration
Cancel bool
}
type Option func(opts *Options)
func WithAync(aync bool) Option {
return func(opts *Options) {
opts.Async = aync
}
}
func WithInterval(interval time.Duration) Option {
return func(opts *Options) {
opts.Interval = interval
}
}
func WithCancel(cancel bool) Option {
return func(opts *Options) {
opts.Cancel = cancel
}
}
func Event() <-chan *TaskEvent {
return runner.Event()
}
func Exec(ctx context.Context, task Task, opts ...Option) error {
return runner.Exec(ctx, task, opts...)
}
func Cancel(id TaskID) {
runner.Cancel(id)
}
type Runner struct {
events chan *TaskEvent
states map[TaskID]taskState
mu sync.RWMutex
}
func NewRunner() *Runner {
return &Runner{
events: make(chan *TaskEvent, 16),
states: make(map[TaskID]taskState),
}
}
func (r *Runner) Event() <-chan *TaskEvent {
return r.events
}
func (r *Runner) Exec(ctx context.Context, task Task, opts ...Option) error {
if task == nil || task.ID() == "" {
return nil
}
var options Options
for _, opt := range opts {
opt(&options)
}
if options.Cancel {
r.Cancel(task.ID())
}
ctx, cancel := context.WithCancel(ctx)
r.setState(taskState{
task: task,
cancel: cancel,
})
log := logger.Default().WithFields(map[string]any{
"kind": "runner",
"async": options.Async,
})
log.Debugf("task %s started", task.ID())
if !options.Async {
t := time.Now()
err := task.Run(ctx)
log.WithFields(map[string]any{
"duration": time.Since(t),
}).Debugf("task %s done: %v", task.ID(), err)
select {
case r.events <- &TaskEvent{
TaskID: task.ID(),
Err: err,
}:
default:
}
return err
}
go func() {
defer cancel()
t := time.Now()
defer func() {
log.WithFields(map[string]any{
"duration": time.Since(t),
}).Debugf("task %s done", task.ID())
}()
run := func() {
select {
case r.events <- &TaskEvent{
TaskID: task.ID(),
Err: task.Run(ctx),
}:
default:
}
}
run()
interval := options.Interval
if interval <= 0 {
return
}
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
run()
case <-ctx.Done():
return
}
}
}()
return nil
}
func (r *Runner) Cancel(id TaskID) {
r.delState(id)
}
func (r *Runner) setState(state taskState) {
if state.task == nil {
return
}
r.mu.Lock()
defer r.mu.Unlock()
r.states[state.task.ID()] = state
}
func (r *Runner) delState(id TaskID) {
r.mu.Lock()
defer r.mu.Unlock()
state := r.states[id]
if state.cancel != nil {
state.cancel()
}
delete(r.states, id)
}
+16
View File
@@ -0,0 +1,16 @@
package runner
import (
"context"
)
type TaskID string
const (
TaskUpdateStats TaskID = "service.stats.update"
)
type Task interface {
ID() TaskID
Run(ctx context.Context) error
}
+136
View File
@@ -0,0 +1,136 @@
package task
import (
"context"
"time"
"github.com/go-gost/gost.plus/config"
"github.com/go-gost/gost.plus/runner"
"github.com/go-gost/gost.plus/tunnel"
"github.com/go-gost/gost.plus/tunnel/entrypoint"
stats_pkg "github.com/go-gost/x/stats"
)
type updateStatsTask struct{}
func UpdateStats() runner.Task {
return &updateStatsTask{}
}
func (t *updateStatsTask) ID() runner.TaskID {
return runner.TaskUpdateStats
}
func (t *updateStatsTask) Run(context.Context) error {
t.updateTunnel()
t.updateEntrypoint()
return nil
}
func (t *updateStatsTask) updateTunnel() error {
for i := 0; i < tunnel.Count(); i++ {
tun := tunnel.GetIndex(i)
if tun == nil {
continue
}
status := tun.Status()
if status == nil {
continue
}
oldStats := tun.Stats()
d := time.Since(oldStats.Time)
if d <= 0 {
continue
}
stats := config.ServiceStats{}
if s := status.Stats(); s != nil {
stats.CurrentConns = s.Get(stats_pkg.KindCurrentConns)
stats.InputBytes = s.Get(stats_pkg.KindInputBytes)
stats.OutputBytes = s.Get(stats_pkg.KindOutputBytes)
stats.TotalConns = s.Get(stats_pkg.KindTotalConns)
stats.TotalErrs = s.Get(stats_pkg.KindTotalErrs)
stats.Time = time.Now()
}
inputRateBytes := int64(stats.InputBytes) - int64(oldStats.InputBytes)
if inputRateBytes < 0 {
inputRateBytes = 0
}
stats.InputRateBytes = uint64(float64(inputRateBytes) / d.Seconds())
outputRateBytes := int64(stats.OutputBytes) - int64(oldStats.OutputBytes)
if outputRateBytes < 0 {
outputRateBytes = 0
}
stats.OutputRateBytes = uint64(float64(outputRateBytes) / d.Seconds())
reqRate := int64(stats.TotalConns) - int64(oldStats.TotalConns)
if reqRate < 0 {
reqRate = 0
}
stats.RequestRate = float64(reqRate) / d.Seconds()
tun.SetStats(stats)
}
return tunnel.SaveConfig()
}
func (t *updateStatsTask) updateEntrypoint() error {
for i := 0; i < entrypoint.Count(); i++ {
ep := entrypoint.GetIndex(i)
if ep == nil {
continue
}
status := ep.Status()
if status == nil {
continue
}
oldStats := ep.Stats()
d := time.Since(oldStats.Time)
if d <= 0 {
continue
}
stats := config.ServiceStats{}
if s := status.Stats(); s != nil {
stats.CurrentConns = s.Get(stats_pkg.KindCurrentConns)
stats.InputBytes = s.Get(stats_pkg.KindInputBytes)
stats.OutputBytes = s.Get(stats_pkg.KindOutputBytes)
stats.TotalConns = s.Get(stats_pkg.KindTotalConns)
stats.TotalErrs = s.Get(stats_pkg.KindTotalErrs)
stats.Time = time.Now()
}
inputRateBytes := int64(stats.InputBytes) - int64(oldStats.InputBytes)
if inputRateBytes < 0 {
inputRateBytes = 0
}
stats.InputRateBytes = uint64(float64(inputRateBytes) / d.Seconds())
outputRateBytes := int64(stats.OutputBytes) - int64(oldStats.OutputBytes)
if outputRateBytes < 0 {
outputRateBytes = 0
}
stats.OutputRateBytes = uint64(float64(outputRateBytes) / d.Seconds())
reqRate := int64(stats.TotalConns) - int64(oldStats.TotalConns)
if reqRate < 0 {
reqRate = 0
}
stats.RequestRate = float64(reqRate) / d.Seconds()
ep.SetStats(stats)
}
return entrypoint.SaveConfig()
}
+15 -7
View File
@@ -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
}
@@ -112,6 +112,8 @@ func LoadConfig() {
EnableTLS: cfg.EnableTLS,
Keepalive: cfg.Keepalive,
TTL: cfg.TTL,
CreatedAt: cfg.CreatedAt,
Stats: cfg.Stats,
})
if ep == nil {
continue
@@ -129,7 +131,7 @@ func LoadConfig() {
}
func SaveConfig() error {
cfg := config.Global()
cfg := config.Get()
cfg.EntryPoints = nil
for i := 0; i < Count(); i++ {
@@ -151,6 +153,8 @@ func SaveConfig() error {
EnableTLS: opts.EnableTLS,
Favorite: ep.IsFavorite(),
Closed: ep.IsClosed(),
CreatedAt: opts.CreatedAt,
Stats: ep.Stats(),
})
}
@@ -163,7 +167,7 @@ func SaveConfig() error {
return nil
}
func createEntryPoint(st string, opts tunnel.Options) EntryPoint {
func createEntryPoint(st string, opts tunnel.Options) (ep EntryPoint) {
options := []tunnel.Option{
tunnel.IDOption(opts.ID),
tunnel.NameOption(opts.Name),
@@ -172,13 +176,17 @@ func createEntryPoint(st string, opts tunnel.Options) EntryPoint {
tunnel.UsernameOption(opts.Username),
tunnel.PasswordOption(opts.Password),
tunnel.EnableTLSOption(opts.EnableTLS),
tunnel.CreatedAtOption(opts.CreatedAt),
}
switch st {
case TCPEntryPoint:
return NewTCPEntryPoint(options...)
ep = NewTCPEntryPoint(options...)
case UDPEntryPoint:
return NewUDPEntryPoint(options...)
ep = NewUDPEntryPoint(options...)
default:
return nil
}
ep.SetStats(opts.Stats)
return
}
+34 -2
View File
@@ -6,13 +6,15 @@ import (
"fmt"
"sync"
"sync/atomic"
"time"
"github.com/go-gost/core/chain"
"github.com/go-gost/core/handler"
"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"
cfg "github.com/go-gost/gost.plus/config"
"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"
@@ -20,6 +22,7 @@ import (
"github.com/go-gost/x/listener/tcp"
mdx "github.com/go-gost/x/metadata"
xservice "github.com/go-gost/x/service"
"github.com/go-gost/x/stats"
"github.com/google/uuid"
)
@@ -29,6 +32,7 @@ type tcpEntryPoint struct {
config *config.Config
forward service.Service
favorite atomic.Bool
stats cfg.ServiceStats
cclose chan struct{}
@@ -56,6 +60,9 @@ func NewTCPEntryPoint(opts ...tunnel.Option) EntryPoint {
if options.Name == "" {
options.Name = endpoint
}
if options.CreatedAt.IsZero() {
options.CreatedAt = time.Now()
}
s := &tcpEntryPoint{
endpoint: endpoint,
@@ -152,10 +159,13 @@ func (s *tcpEntryPoint) Run() (err error) {
return
}
stats := &stats.Stats{}
cfg := s.config.Services[0]
ln := tcp.NewListener(
listener.AddrOption(cfg.Addr),
listener.LoggerOption(log.WithFields(map[string]any{"kind": "listener", "listener": "tcp"})),
listener.StatsOption(stats),
)
if err = ln.Init(mdx.NewMetadata(cfg.Listener.Metadata)); err != nil {
return
@@ -179,7 +189,10 @@ func (s *tcpEntryPoint) Run() (err error) {
hop.LoggerOption(log.WithFields(map[string]any{"kind": "hop"})),
))
}
s.forward = xservice.NewService(s.opts.Name, ln, h, xservice.LoggerOption(log))
s.forward = xservice.NewService(s.opts.Name, ln, h,
xservice.LoggerOption(log),
xservice.StatsOption(stats),
)
}
go func() {
@@ -189,6 +202,25 @@ 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) Stats() cfg.ServiceStats {
s.mu.RLock()
defer s.mu.RUnlock()
return s.stats
}
func (s *tcpEntryPoint) SetStats(stats cfg.ServiceStats) {
s.mu.Lock()
defer s.mu.Unlock()
s.stats = stats
}
func (s *tcpEntryPoint) Close() error {
defer func() {
select {
+33 -2
View File
@@ -13,7 +13,8 @@ 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"
cfg "github.com/go-gost/gost.plus/config"
"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"
@@ -21,6 +22,7 @@ import (
"github.com/go-gost/x/listener/udp"
mdx "github.com/go-gost/x/metadata"
xservice "github.com/go-gost/x/service"
"github.com/go-gost/x/stats"
"github.com/google/uuid"
)
@@ -30,6 +32,7 @@ type udpEntryPoint struct {
config *config.Config
forward service.Service
favorite atomic.Bool
stats cfg.ServiceStats
cclose chan struct{}
@@ -57,6 +60,9 @@ func NewUDPEntryPoint(opts ...tunnel.Option) EntryPoint {
if options.Name == "" {
options.Name = endpoint
}
if options.CreatedAt.IsZero() {
options.CreatedAt = time.Now()
}
s := &udpEntryPoint{
endpoint: endpoint,
@@ -157,10 +163,13 @@ func (s *udpEntryPoint) Run() (err error) {
return
}
stats := &stats.Stats{}
cfg := s.config.Services[0]
ln := udp.NewListener(
listener.AddrOption(cfg.Addr),
listener.LoggerOption(log.WithFields(map[string]any{"kind": "listener", "listener": "udp"})),
listener.StatsOption(stats),
)
if err = ln.Init(mdx.NewMetadata(cfg.Listener.Metadata)); err != nil {
return
@@ -184,7 +193,10 @@ func (s *udpEntryPoint) Run() (err error) {
hop.LoggerOption(log.WithFields(map[string]any{"kind": "hop"})),
))
}
s.forward = xservice.NewService(s.opts.Name, ln, h, xservice.LoggerOption(log))
s.forward = xservice.NewService(s.opts.Name, ln, h,
xservice.LoggerOption(log),
xservice.StatsOption(stats),
)
}
go func() {
@@ -194,6 +206,25 @@ 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) Stats() cfg.ServiceStats {
s.mu.RLock()
defer s.mu.RUnlock()
return s.stats
}
func (s *udpEntryPoint) SetStats(stats cfg.ServiceStats) {
s.mu.Lock()
defer s.mu.Unlock()
s.stats = stats
}
func (s *udpEntryPoint) Close() error {
defer func() {
select {
+40 -2
View File
@@ -7,6 +7,7 @@ import (
"os"
"sync"
"sync/atomic"
"time"
"github.com/go-gost/core/auth"
"github.com/go-gost/core/chain"
@@ -14,6 +15,7 @@ import (
"github.com/go-gost/core/listener"
"github.com/go-gost/core/logger"
"github.com/go-gost/core/service"
cfg "github.com/go-gost/gost.plus/config"
xauth "github.com/go-gost/x/auth"
"github.com/go-gost/x/config"
chain_parser "github.com/go-gost/x/config/parsing/chain"
@@ -24,6 +26,7 @@ import (
"github.com/go-gost/x/listener/tcp"
mdx "github.com/go-gost/x/metadata"
xservice "github.com/go-gost/x/service"
"github.com/go-gost/x/stats"
"github.com/google/uuid"
)
@@ -34,6 +37,7 @@ type fileTunnel struct {
file service.Service
forward service.Service
favorite atomic.Bool
stats cfg.ServiceStats
cclose chan struct{}
@@ -46,7 +50,6 @@ func NewFileTunnel(opts ...Option) Tunnel {
for _, opt := range opts {
opt(&options)
}
if options.ID == "" {
options.ID = uuid.NewString()
}
@@ -61,6 +64,9 @@ func NewFileTunnel(opts ...Option) Tunnel {
if options.Name == "" {
options.Name = endpoint
}
if options.CreatedAt.IsZero() {
options.CreatedAt = time.Now()
}
s := &fileTunnel{
endpoint: endpoint,
@@ -192,11 +198,21 @@ func (s *fileTunnel) Run() (err error) {
return
}
pStats := &stats.Stats{}
{
pStats.Add(stats.KindCurrentConns, int64(s.stats.CurrentConns))
pStats.Add(stats.KindInputBytes, int64(s.stats.InputBytes))
pStats.Add(stats.KindOutputBytes, int64(s.stats.OutputBytes))
pStats.Add(stats.KindTotalConns, int64(s.stats.TotalConns))
pStats.Add(stats.KindTotalErrs, int64(s.stats.TotalErrs))
}
cfg := s.config.Services[1]
ln := rtcp.NewListener(
listener.AddrOption(cfg.Addr),
listener.ChainOption(ch),
listener.LoggerOption(log.WithFields(map[string]any{"kind": "listener", "listener": "rtcp"})),
listener.StatsOption(pStats),
)
if err = ln.Init(mdx.NewMetadata(cfg.Listener.Metadata)); err != nil {
return
@@ -214,7 +230,10 @@ func (s *fileTunnel) Run() (err error) {
hop.LoggerOption(log.WithFields(map[string]any{"kind": "hop"})),
))
}
s.forward = xservice.NewService(s.opts.Name, ln, h, xservice.LoggerOption(log))
s.forward = xservice.NewService(s.opts.Name, ln, h,
xservice.LoggerOption(log),
xservice.StatsOption(pStats),
)
log.Infof("service listen on %s", s.file.Addr())
}
@@ -227,6 +246,25 @@ 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) Stats() cfg.ServiceStats {
s.mu.RLock()
defer s.mu.RUnlock()
return s.stats
}
func (s *fileTunnel) SetStats(stats cfg.ServiceStats) {
s.mu.Lock()
defer s.mu.Unlock()
s.stats = stats
}
func (s *fileTunnel) Close() error {
defer func() {
select {
+42 -11
View File
@@ -6,12 +6,14 @@ import (
"fmt"
"sync"
"sync/atomic"
"time"
"github.com/go-gost/core/chain"
"github.com/go-gost/core/handler"
"github.com/go-gost/core/listener"
"github.com/go-gost/core/logger"
"github.com/go-gost/core/service"
cfg "github.com/go-gost/gost.plus/config"
xauth "github.com/go-gost/x/auth"
"github.com/go-gost/x/config"
chain_parser "github.com/go-gost/x/config/parsing/chain"
@@ -20,6 +22,7 @@ import (
"github.com/go-gost/x/listener/rtcp"
mdx "github.com/go-gost/x/metadata"
xservice "github.com/go-gost/x/service"
"github.com/go-gost/x/stats"
"github.com/google/uuid"
)
@@ -29,6 +32,7 @@ type httpTunnel struct {
config *config.Config
forward service.Service
favorite atomic.Bool
stats cfg.ServiceStats
cclose chan struct{}
@@ -56,6 +60,9 @@ func NewHTTPTunnel(opts ...Option) Tunnel {
if options.Name == "" {
options.Name = endpoint
}
if options.CreatedAt.IsZero() {
options.CreatedAt = time.Now()
}
s := &httpTunnel{
endpoint: endpoint,
@@ -102,17 +109,16 @@ func (s *httpTunnel) init() error {
node := &config.ForwardNodeConfig{
Name: s.opts.Name,
Addr: s.opts.Endpoint,
HTTP: &config.HTTPNodeConfig{},
}
if s.opts.Username != "" {
node.Auth = &config.AuthConfig{
node.HTTP.Auth = &config.AuthConfig{
Username: s.opts.Username,
Password: s.opts.Password,
}
}
if s.opts.Hostname != "" {
node.HTTP = &config.HTTPNodeConfig{
Host: s.opts.Hostname,
}
node.HTTP.Host = s.opts.Hostname
}
if s.opts.EnableTLS {
node.TLS = &config.TLSNodeConfig{}
@@ -169,11 +175,14 @@ func (s *httpTunnel) Run() (err error) {
return
}
stats := &stats.Stats{}
cfg := s.config.Services[0]
ln := rtcp.NewListener(
listener.AddrOption(cfg.Addr),
listener.ChainOption(ch),
listener.LoggerOption(log.WithFields(map[string]any{"kind": "listener", "listener": "rtcp"})),
listener.StatsOption(stats),
)
if err = ln.Init(mdx.NewMetadata(cfg.Listener.Metadata)); err != nil {
return
@@ -188,15 +197,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{
@@ -209,7 +218,10 @@ func (s *httpTunnel) Run() (err error) {
hop.LoggerOption(log.WithFields(map[string]any{"kind": "hop"})),
))
}
s.forward = xservice.NewService(s.opts.Name, ln, h, xservice.LoggerOption(log))
s.forward = xservice.NewService(s.opts.Name, ln, h,
xservice.LoggerOption(log),
xservice.StatsOption(stats),
)
}
go func() {
@@ -219,6 +231,25 @@ 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) Stats() cfg.ServiceStats {
s.mu.RLock()
defer s.mu.RUnlock()
return s.stats
}
func (s *httpTunnel) SetStats(stats cfg.ServiceStats) {
s.mu.Lock()
defer s.mu.Unlock()
s.stats = stats
}
func (s *httpTunnel) Close() error {
defer func() {
select {
+33 -1
View File
@@ -6,12 +6,14 @@ import (
"fmt"
"sync"
"sync/atomic"
"time"
"github.com/go-gost/core/chain"
"github.com/go-gost/core/handler"
"github.com/go-gost/core/listener"
"github.com/go-gost/core/logger"
"github.com/go-gost/core/service"
cfg "github.com/go-gost/gost.plus/config"
"github.com/go-gost/x/config"
chain_parser "github.com/go-gost/x/config/parsing/chain"
"github.com/go-gost/x/handler/forward/remote"
@@ -19,6 +21,7 @@ import (
"github.com/go-gost/x/listener/rtcp"
mdx "github.com/go-gost/x/metadata"
xservice "github.com/go-gost/x/service"
"github.com/go-gost/x/stats"
"github.com/google/uuid"
)
@@ -28,6 +31,7 @@ type tcpTunnel struct {
config *config.Config
forward service.Service
favorite atomic.Bool
stats cfg.ServiceStats
cclose chan struct{}
@@ -55,6 +59,9 @@ func NewTCPTunnel(opts ...Option) Tunnel {
if options.Name == "" {
options.Name = endpoint
}
if options.CreatedAt.IsZero() {
options.CreatedAt = time.Now()
}
s := &tcpTunnel{
endpoint: endpoint,
@@ -151,11 +158,14 @@ func (s *tcpTunnel) Run() (err error) {
return
}
stats := &stats.Stats{}
cfg := s.config.Services[0]
ln := rtcp.NewListener(
listener.AddrOption(cfg.Addr),
listener.ChainOption(ch),
listener.LoggerOption(log.WithFields(map[string]any{"kind": "listener", "listener": "rtcp"})),
listener.StatsOption(stats),
)
if err = ln.Init(mdx.NewMetadata(cfg.Listener.Metadata)); err != nil {
return
@@ -175,7 +185,10 @@ func (s *tcpTunnel) Run() (err error) {
hop.LoggerOption(log.WithFields(map[string]any{"kind": "hop"})),
))
}
s.forward = xservice.NewService(s.opts.Name, ln, h, xservice.LoggerOption(log))
s.forward = xservice.NewService(s.opts.Name, ln, h,
xservice.LoggerOption(log),
xservice.StatsOption(stats),
)
}
go func() {
@@ -185,6 +198,25 @@ 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) Stats() cfg.ServiceStats {
s.mu.RLock()
defer s.mu.RUnlock()
return s.stats
}
func (s *tcpTunnel) SetStats(stats cfg.ServiceStats) {
s.mu.Lock()
defer s.mu.Unlock()
s.stats = stats
}
func (s *tcpTunnel) Close() error {
defer func() {
select {
+52 -26
View File
@@ -3,12 +3,14 @@ package tunnel
import (
"errors"
"sync"
"time"
"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 (
@@ -38,6 +40,8 @@ type Options struct {
EnableTLS bool
Keepalive bool
TTL int
CreatedAt time.Time
Stats config.ServiceStats
}
type Option func(opts *Options)
@@ -96,6 +100,16 @@ func TTLOption(ttl int) Option {
}
}
func CreatedAtOption(createdAt time.Time) Option {
return func(opts *Options) {
opts.CreatedAt = createdAt
}
}
type ServiceStatus interface {
Status() *xservice.Status
}
type Tunnel interface {
ID() string
Type() string
@@ -104,6 +118,9 @@ type Tunnel interface {
Entrypoint() string
Options() Options
Run() error
Status() *xservice.Status
Stats() config.ServiceStats
SetStats(stats config.ServiceStats)
Favorite(b bool)
IsFavorite() bool
Close() error
@@ -215,37 +232,39 @@ 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,
CreatedAt: cfg.CreatedAt,
Stats: cfg.Stats,
})
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++ {
@@ -267,6 +286,8 @@ func SaveConfig() error {
EnableTLS: opts.EnableTLS,
Favorite: tun.IsFavorite(),
Closed: tun.IsClosed(),
CreatedAt: opts.CreatedAt,
Stats: tun.Stats(),
})
}
@@ -279,7 +300,7 @@ func SaveConfig() error {
return nil
}
func createTunnel(st string, opts Options) Tunnel {
func createTunnel(st string, opts Options) (t Tunnel) {
options := []Option{
IDOption(opts.ID),
NameOption(opts.Name),
@@ -288,17 +309,22 @@ func createTunnel(st string, opts Options) Tunnel {
UsernameOption(opts.Username),
PasswordOption(opts.Password),
EnableTLSOption(opts.EnableTLS),
CreatedAtOption(opts.CreatedAt),
}
switch st {
case FileTunnel:
return NewFileTunnel(options...)
t = NewFileTunnel(options...)
case HTTPTunnel:
return NewHTTPTunnel(options...)
t = NewHTTPTunnel(options...)
case TCPTunnel:
return NewTCPTunnel(options...)
t = NewTCPTunnel(options...)
case UDPTunnel:
return NewUDPTunnel(options...)
t = NewUDPTunnel(options...)
default:
return nil
}
t.SetStats(opts.Stats)
return
}
+33 -1
View File
@@ -6,12 +6,14 @@ import (
"fmt"
"sync"
"sync/atomic"
"time"
"github.com/go-gost/core/chain"
"github.com/go-gost/core/handler"
"github.com/go-gost/core/listener"
"github.com/go-gost/core/logger"
"github.com/go-gost/core/service"
cfg "github.com/go-gost/gost.plus/config"
"github.com/go-gost/x/config"
chain_parser "github.com/go-gost/x/config/parsing/chain"
"github.com/go-gost/x/handler/forward/remote"
@@ -19,6 +21,7 @@ import (
"github.com/go-gost/x/listener/rudp"
mdx "github.com/go-gost/x/metadata"
xservice "github.com/go-gost/x/service"
"github.com/go-gost/x/stats"
"github.com/google/uuid"
)
@@ -28,6 +31,7 @@ type udpTunnel struct {
config *config.Config
forward service.Service
favorite atomic.Bool
stats cfg.ServiceStats
cclose chan struct{}
@@ -55,6 +59,9 @@ func NewUDPTunnel(opts ...Option) Tunnel {
if options.Name == "" {
options.Name = endpoint
}
if options.CreatedAt.IsZero() {
options.CreatedAt = time.Now()
}
s := &udpTunnel{
endpoint: endpoint,
@@ -151,11 +158,14 @@ func (s *udpTunnel) Run() (err error) {
return
}
stats := &stats.Stats{}
cfg := s.config.Services[0]
ln := rudp.NewListener(
listener.AddrOption(cfg.Addr),
listener.ChainOption(ch),
listener.LoggerOption(log.WithFields(map[string]any{"kind": "listener", "listener": "rudp"})),
listener.StatsOption(stats),
)
if err = ln.Init(mdx.NewMetadata(cfg.Listener.Metadata)); err != nil {
return
@@ -175,7 +185,10 @@ func (s *udpTunnel) Run() (err error) {
hop.LoggerOption(log.WithFields(map[string]any{"kind": "hop"})),
))
}
s.forward = xservice.NewService(s.opts.Name, ln, h, xservice.LoggerOption(log))
s.forward = xservice.NewService(s.opts.Name, ln, h,
xservice.LoggerOption(log),
xservice.StatsOption(stats),
)
}
go func() {
@@ -185,6 +198,25 @@ 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) Stats() cfg.ServiceStats {
s.mu.RLock()
defer s.mu.RUnlock()
return s.stats
}
func (s *udpTunnel) SetStats(stats cfg.ServiceStats) {
s.mu.Lock()
defer s.mu.Unlock()
s.stats = stats
}
func (s *udpTunnel) Close() error {
defer func() {
select {
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+16 -7
View File
@@ -8,19 +8,28 @@ import (
"gioui.org/font/opentype"
)
//go:embed NotoSansSC-Regular.ttf
var fontNotoRegular []byte
var (
//go:embed NotoSansSC-Bold.ttf
var fontNotoBold []byte
//go:embed NotoSans-Regular.ttf
notoSansRegular []byte
//go:embed NotoSans-SemiBold.ttf
notoSansSemiBold []byte
//go:embed NotoSansSC-Regular.ttf
notoSansSCRegular []byte
//go:embed NotoSansSC-SemiBold.ttf
notoSansSCSemiBold []byte
)
var (
collection []font.FontFace
)
func init() {
register(fontNotoRegular)
register(fontNotoBold)
register(notoSansRegular)
register(notoSansSemiBold)
register(notoSansSCRegular)
register(notoSansSCSemiBold)
}
func Collection() []font.FontFace {
@@ -32,5 +41,5 @@ func register(ttf []byte) {
if err != nil {
panic(fmt.Errorf("failed to parse font: %v", err))
}
collection = append(collection, faces[0])
collection = append(collection, faces...)
}
+41
View File
@@ -0,0 +1,41 @@
package i18n
var en_US = map[Key]string{
Tunnel: "Tunnel",
Entrypoint: "Entrypoint",
Type: "Type",
Name: "Name",
Address: "Address",
Endpoint: "Endpoint",
BasicAuth: "Basic auth",
Username: "Username",
Password: "Password",
DirectoryPath: "Directory path",
CustomHostname: "Custom hostname (rewrite HTTP Host header)",
Hostname: "Hostname",
EnableTLS: "Enalbe TLS",
TunnelID: "Tunnel ID",
Keepalive: "Keepalive",
FileTunnelDesc: "Expose local files to public network",
HTTPTunnelDesc: "Expose local HTTP service to public network",
TCPTunnelDesc: "Expose local TCP service to public network",
UDPTunnelDesc: "Expose local UDP service to public network",
TCPEntrypointDesc: "Create an entrypoint to the specified TCP tunnel",
UDPEntrypointDesc: "Create an entrypoint to the specified UDP tunnel",
Settings: "Settings",
Language: "Language",
Theme: "Theme",
Light: "Light",
Dark: "Dark",
OK: "OK",
Cancel: "Cancel",
DeleteTunnel: "Delete tunnel?",
DeleteEntrypoint: "Delete entrypoint?",
ErrInvalidTunnelID: "invalid tunnel ID, should be a valid UUID",
ErrInvalidAddr: "invalid address format, should be [IP]:PORT or [HOST]:PORT",
ErrDigitOnly: "Must contain only digits",
ErrDirectory: "is not a directory",
English: "English",
Chinese: "Chinese",
}
+108
View File
@@ -0,0 +1,108 @@
package i18n
import "sync"
type Lang struct {
Name Key
Value string
content map[Key]string
}
var langs = []Lang{
{
Name: English,
Value: "en_US",
content: en_US,
},
{
Name: Chinese,
Value: "zh_CN",
content: zh_CN,
},
}
func Langs() []Lang {
return langs
}
var (
currentLang Lang = langs[0]
mux sync.RWMutex
)
func Current() Lang {
mux.RLock()
defer mux.RUnlock()
return currentLang
}
func Set(lang string) {
mux.Lock()
defer mux.Unlock()
for i := range langs {
if langs[i].Value == lang {
currentLang = langs[i]
return
}
}
currentLang = langs[0]
}
const (
Tunnel Key = "tunnel"
Entrypoint Key = "entrypoint"
Type Key = "type"
Name Key = "name"
Address Key = "address"
Endpoint Key = "endpoint"
BasicAuth Key = "basicAuth"
Username Key = "username"
Password Key = "password"
DirectoryPath Key = "dirPath"
CustomHostname Key = "customHostname"
Hostname Key = "hostname"
EnableTLS Key = "enableTLS"
TunnelID Key = "tunnelID"
Keepalive Key = "keepalive"
FileTunnelDesc Key = "fileTunnelDesc"
HTTPTunnelDesc Key = "httpTunnelDesc"
TCPTunnelDesc Key = "tcpTunnelDesc"
UDPTunnelDesc Key = "udpTunnelDesc"
TCPEntrypointDesc Key = "tcpEntrypointDesc"
UDPEntrypointDesc Key = "udpEntrypointDesc"
Settings Key = "settings"
Language Key = "language"
Theme Key = "theme"
Light Key = "light"
Dark Key = "dark"
OK Key = "ok"
Cancel Key = "cancel"
DeleteTunnel Key = "deleteTunnel"
DeleteEntrypoint Key = "deleteEntrypoint"
ErrInvalidTunnelID Key = "errInvalidTunnelID"
ErrInvalidAddr Key = "errInvalidAddr"
ErrDigitOnly Key = "errDigitOnly"
ErrDirectory Key = "errDir"
English Key = "english"
Chinese Key = "chinese"
)
type Key string
func (k Key) Value() string {
return get(k)
}
func get(key Key) string {
mux.RLock()
defer mux.RUnlock()
if v := currentLang.content[key]; v != "" {
return v
}
return langs[0].content[key]
}
+41
View File
@@ -0,0 +1,41 @@
package i18n
var zh_CN = map[Key]string{
Tunnel: "隧道",
Entrypoint: "入口点",
Type: "类型",
Name: "名称",
Address: "地址",
Endpoint: "端点",
BasicAuth: "基本认证",
Username: "用户名",
Password: "密码",
DirectoryPath: "文件目录路径",
CustomHostname: "自定义主机名(重写HTTP Host头)",
Hostname: "主机名",
EnableTLS: "开启TLS",
TunnelID: "隧道ID",
Keepalive: "保持连接",
FileTunnelDesc: "将本地文件系统暴露到公网",
HTTPTunnelDesc: "将本地的一个HTTP服务暴露到公网",
TCPTunnelDesc: "将本地的一个TCP服务暴露到公网",
UDPTunnelDesc: "将本地的一个UDP服务暴露到公网",
TCPEntrypointDesc: "创建一个指定TCP隧道的入口点",
UDPEntrypointDesc: "创建一个指定UDP隧道的入口点",
Settings: "设置",
Language: "语言",
Theme: "主题",
Light: "浅色",
Dark: "深色",
OK: "确认",
Cancel: "取消",
DeleteTunnel: "删除隧道?",
DeleteEntrypoint: "删除入口点?",
ErrInvalidTunnelID: "无效的隧道ID, 仅支持合法的UUID格式,例如:6bcb409c-dd0f-4ce7-9869-651c52c09d1c",
ErrInvalidAddr: "无效的地址格式,仅支持[IP]:PORT或[HOST]:PORT",
ErrDigitOnly: "仅能输入数字",
ErrDirectory: "不是一个目录",
English: "英语",
Chinese: "中文",
}
+30 -17
View File
@@ -25,29 +25,42 @@ 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)
IconInfo = mustIcon(icons.ActionInfo)
IconAlert = mustIcon(icons.AlertErrorOutline)
)
func mustIcon(data []byte) *widget.Icon {
-57
View File
@@ -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)
}),
)
})
})
})
}
-179
View File
@@ -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 state.editor.Layout(gtx, 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))
}),
)
}
+141
View File
@@ -0,0 +1,141 @@
package entrypoint
import (
"image/color"
"gioui.org/layout"
"gioui.org/widget"
"gioui.org/widget/material"
"github.com/go-gost/gost.plus/ui/i18n"
"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 i18n.Key
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: i18n.TCPEntrypointDesc,
path: page.PageEntrypointTCP,
},
{
name: "UDP",
desc: i18n.UDPEntrypointDesc,
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, i18n.Entrypoint.Value())
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.UniformInset(16).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.Value()).Layout),
)
}),
layout.Rigid(layout.Spacer{Width: 8}.Layout),
layout.Rigid(func(gtx C) D {
return icons.IconForward.Layout(gtx, color.NRGBA(colornames.Grey500))
}),
)
})
})
})
})
}),
)
}
+408
View File
@@ -0,0 +1,408 @@
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/i18n"
"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
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,
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: i18n.DeleteEntrypoint,
},
}
}
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.tunnelID.Clear()
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.router.HideModal(gtx)
}
p.router.ShowModal(gtx, func(gtx page.C, th *material.Theme) page.D {
return p.delDialog.Layout(gtx, th)
})
}
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{
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.UniformInset(16).Layout(gtx, func(gtx C) D {
return layout.Flex{
Axis: layout.Vertical,
}.Layout(gtx,
layout.Rigid(func(gtx C) D {
return material.Body1(th, i18n.TunnelID.Value()).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(i18n.ErrInvalidTunnelID.Value())
}
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, i18n.Name.Value()).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, i18n.Entrypoint.Value()).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(i18n.ErrInvalidAddr.Value())
}
return nil
}(); err != nil {
p.entrypoint.SetError(err.Error())
} else {
p.entrypoint.ClearError()
}
return p.entrypoint.Layout(gtx, th, i18n.Address.Value())
}),
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()
p.router.Notify(ui_widget.Message{
Type: ui_widget.Error,
Content: err.Error(),
})
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()
p.router.Notify(ui_widget.Message{
Type: ui_widget.Error,
Content: err.Error(),
})
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),
tunnel.CreatedAtOption(opts.CreatedAt),
)
} else {
ep.Close()
}
entrypoint.SaveConfig()
}
func (p *tcpPage) delete() {
entrypoint.Delete(p.id)
entrypoint.SaveConfig()
}
+471
View File
@@ -0,0 +1,471 @@
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/i18n"
"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
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,
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: i18n.DeleteEntrypoint,
},
}
}
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.tunnelID.Clear()
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.router.HideModal(gtx)
}
p.router.ShowModal(gtx, func(gtx page.C, th *material.Theme) page.D {
return p.delDialog.Layout(gtx, th)
})
}
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{
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.UniformInset(16).Layout(gtx, func(gtx C) D {
return layout.Flex{
Axis: layout.Vertical,
}.Layout(gtx,
layout.Rigid(func(gtx C) D {
return material.Body1(th, i18n.TunnelID.Value()).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(i18n.ErrInvalidTunnelID.Value())
}
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, i18n.Name.Value()).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, i18n.Entrypoint.Value()).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(i18n.ErrInvalidAddr.Value())
}
return nil
}(); err != nil {
p.entrypoint.SetError(err.Error())
} else {
p.entrypoint.ClearError()
}
return p.entrypoint.Layout(gtx, th, i18n.Address.Value())
}),
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, i18n.Keepalive.Value()).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 i18n.ErrDigitOnly.Value()
}
}
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()
p.router.Notify(ui_widget.Message{
Type: ui_widget.Error,
Content: err.Error(),
})
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()
p.router.Notify(ui_widget.Message{
Type: ui_widget.Error,
Content: err.Error(),
})
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),
tunnel.CreatedAtOption(opts.CreatedAt),
)
} else {
ep.Close()
}
entrypoint.SaveConfig()
}
func (p *udpPage) delete() {
entrypoint.Delete(p.id)
entrypoint.SaveConfig()
}
-438
View File
@@ -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")
}),
)
}
-535
View File
@@ -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")
}),
)
}
+11
View File
@@ -0,0 +1,11 @@
package page
type EventID string
const (
EventThemeChanged EventID = "event.theme.changed"
)
type Event struct {
ID EventID
}
-531
View File
@@ -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")
}),
)
}
+216
View File
@@ -0,0 +1,216 @@
package list
import (
"fmt"
"image/color"
"strconv"
"strings"
"time"
"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/i18n"
"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(),
})
}
stats := t.Stats()
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.UniformInset(16).Layout(gtx, func(gtx C) D {
return layout.Flex{
Axis: layout.Vertical,
}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return layout.Flex{
Alignment: layout.Middle,
Spacing: layout.SpaceBetween,
}.Layout(gtx,
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
label := material.Body1(th, t.Name())
label.Font.Weight = font.SemiBold
return label.Layout(gtx)
}),
layout.Rigid(layout.Spacer{Width: 4}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
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))
}),
)
}),
layout.Rigid(layout.Spacer{Height: 4}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return layout.Flex{
Alignment: layout.Middle,
Spacing: layout.SpaceBetween,
}.Layout(gtx,
layout.Flexed(1, material.Body2(th, fmt.Sprintf("%s: %s", i18n.Type.Value(), strings.ToUpper(t.Type()))).Layout),
layout.Rigid(layout.Spacer{Width: 4}.Layout),
layout.Rigid(func(gtx C) D {
if createdAt := t.Options().CreatedAt; !createdAt.IsZero() {
v, unit := formatDuration(time.Since(createdAt))
return material.Body2(th, fmt.Sprintf("%d%s", v, unit)).Layout(gtx)
}
return material.Body2(th, "N/A").Layout(gtx)
}),
)
}),
layout.Rigid(layout.Spacer{Height: 4}.Layout),
layout.Rigid(material.Body2(th, fmt.Sprintf("%s: %s", i18n.Endpoint.Value(), t.Endpoint())).Layout),
layout.Rigid(layout.Spacer{Height: 4}.Layout),
layout.Rigid(func(gtx C) D {
return layout.Flex{
Alignment: layout.Middle,
Spacing: layout.SpaceBetween,
}.Layout(gtx,
layout.Rigid(func(gtx C) D {
return icons.IconActionCode.Layout(gtx, th.Fg)
}),
layout.Rigid(layout.Spacer{Width: 4}.Layout),
layout.Flexed(1, func(gtx C) D {
current, unitCurrent := format(int64(stats.CurrentConns), 1000)
current = float64(int64(current*10)) / 10
total, unitTotal := format(int64(stats.TotalConns), 1000)
total = float64(int64(total*10)) / 10
return material.Body2(th, fmt.Sprintf("%s%s / %s%s",
strconv.FormatFloat(current, 'f', -1, 64), strings.ToLower(unitCurrent),
strconv.FormatFloat(total, 'f', -1, 64), strings.ToLower(unitTotal))).Layout(gtx)
}),
layout.Rigid(func(gtx C) D {
rate := stats.RequestRate
rate = float64(int64(rate*100)) / 100
return material.Body2(th, fmt.Sprintf("%s R/s", strconv.FormatFloat(rate, 'f', -1, 64))).Layout(gtx)
}),
)
}),
layout.Rigid(layout.Spacer{Height: 4}.Layout),
layout.Rigid(func(gtx C) D {
return layout.Flex{
Alignment: layout.Middle,
Spacing: layout.SpaceBetween,
}.Layout(gtx,
layout.Rigid(func(gtx C) D {
return icons.IconNavExpandLess.Layout(gtx, th.Fg)
}),
layout.Rigid(layout.Spacer{Width: 4}.Layout),
layout.Flexed(1, func(gtx C) D {
v, unit := format(int64(stats.OutputBytes), 1024)
v = float64(int64(v*100)) / 100
return material.Body2(th, fmt.Sprintf("%s %sB", strconv.FormatFloat(v, 'f', -1, 64), unit)).Layout(gtx)
}),
layout.Rigid(func(gtx C) D {
v, unit := format(int64(stats.OutputRateBytes), 1024)
v = float64(int64(v*100)) / 100
return material.Body2(th, fmt.Sprintf("%s %sB/s", strconv.FormatFloat(v, 'f', -1, 64), unit)).Layout(gtx)
}),
)
}),
layout.Rigid(layout.Spacer{Height: 4}.Layout),
layout.Rigid(func(gtx C) D {
return layout.Flex{
Alignment: layout.Middle,
Spacing: layout.SpaceBetween,
}.Layout(gtx,
layout.Rigid(func(gtx C) D {
return icons.IconNavExpandMore.Layout(gtx, th.Fg)
}),
layout.Rigid(layout.Spacer{Width: 4}.Layout),
layout.Flexed(1, func(gtx C) D {
v, unit := format(int64(stats.InputBytes), 1024)
v = float64(int64(v*100)) / 100
return material.Body2(th, fmt.Sprintf("%s %sB", strconv.FormatFloat(v, 'f', -1, 64), unit)).Layout(gtx)
}),
layout.Rigid(func(gtx C) D {
v, unit := format(int64(stats.InputRateBytes), 1024)
v = float64(int64(v*100)) / 100
return material.Body2(th, fmt.Sprintf("%s %sB/s", strconv.FormatFloat(v, 'f', -1, 64), unit)).Layout(gtx)
}),
)
}),
)
})
})
})
})
}
+19
View File
@@ -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)
}
+264
View File
@@ -0,0 +1,264 @@
package list
import (
"fmt"
"image/color"
"math"
"strconv"
"strings"
"time"
"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/i18n"
"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(),
})
}
stats := t.Stats()
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.UniformInset(16).Layout(gtx, func(gtx C) D {
return layout.Flex{
Axis: layout.Vertical,
}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return layout.Flex{
Alignment: layout.Middle,
Spacing: layout.SpaceBetween,
}.Layout(gtx,
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
label := material.Body1(th, t.Name())
label.Font.Weight = font.SemiBold
return label.Layout(gtx)
}),
layout.Rigid(layout.Spacer{Width: 4}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
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))
}),
)
}),
layout.Rigid(layout.Spacer{Height: 4}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return layout.Flex{
Alignment: layout.Middle,
Spacing: layout.SpaceBetween,
}.Layout(gtx,
layout.Flexed(1, material.Body2(th, fmt.Sprintf("%s: %s", i18n.Type.Value(), strings.ToUpper(t.Type()))).Layout),
layout.Rigid(layout.Spacer{Width: 4}.Layout),
layout.Rigid(func(gtx C) D {
if createdAt := t.Options().CreatedAt; !createdAt.IsZero() {
v, unit := formatDuration(time.Since(createdAt))
return material.Body2(th, fmt.Sprintf("%d%s", v, unit)).Layout(gtx)
}
return material.Body2(th, "N/A").Layout(gtx)
}),
)
}),
layout.Rigid(layout.Spacer{Height: 4}.Layout),
layout.Rigid(material.Body2(th, fmt.Sprintf("%s: %s", i18n.Endpoint.Value(), t.Endpoint())).Layout),
layout.Rigid(layout.Spacer{Height: 4}.Layout),
layout.Rigid(func(gtx C) D {
return layout.Flex{
Alignment: layout.Middle,
Spacing: layout.SpaceBetween,
}.Layout(gtx,
layout.Rigid(func(gtx C) D {
return icons.IconActionCode.Layout(gtx, th.Fg)
}),
layout.Rigid(layout.Spacer{Width: 4}.Layout),
layout.Flexed(1, func(gtx C) D {
current, unitCurrent := format(int64(stats.CurrentConns), 1000)
current = float64(int64(current*10)) / 10
total, unitTotal := format(int64(stats.TotalConns), 1000)
total = float64(int64(total*10)) / 10
return material.Body2(th, fmt.Sprintf("%s%s / %s%s",
strconv.FormatFloat(current, 'f', -1, 64), strings.ToLower(unitCurrent),
strconv.FormatFloat(total, 'f', -1, 64), strings.ToLower(unitTotal))).Layout(gtx)
}),
layout.Rigid(func(gtx C) D {
rate := stats.RequestRate
rate = float64(int64(rate*100)) / 100
return material.Body2(th, fmt.Sprintf("%s R/s", strconv.FormatFloat(rate, 'f', -1, 64))).Layout(gtx)
}),
)
}),
layout.Rigid(layout.Spacer{Height: 4}.Layout),
layout.Rigid(func(gtx C) D {
return layout.Flex{
Alignment: layout.Middle,
Spacing: layout.SpaceBetween,
}.Layout(gtx,
layout.Rigid(func(gtx C) D {
return icons.IconNavExpandLess.Layout(gtx, th.Fg)
}),
layout.Rigid(layout.Spacer{Width: 4}.Layout),
layout.Flexed(1, func(gtx C) D {
v, unit := format(int64(stats.OutputBytes), 1024)
v = float64(int64(v*100)) / 100
return material.Body2(th, fmt.Sprintf("%s %sB", strconv.FormatFloat(v, 'f', -1, 64), unit)).Layout(gtx)
}),
layout.Rigid(func(gtx C) D {
v, unit := format(int64(stats.OutputRateBytes), 1024)
v = float64(int64(v*100)) / 100
return material.Body2(th, fmt.Sprintf("%s %sB/s", strconv.FormatFloat(v, 'f', -1, 64), unit)).Layout(gtx)
}),
)
}),
layout.Rigid(layout.Spacer{Height: 4}.Layout),
layout.Rigid(func(gtx C) D {
return layout.Flex{
Alignment: layout.Middle,
Spacing: layout.SpaceBetween,
}.Layout(gtx,
layout.Rigid(func(gtx C) D {
return icons.IconNavExpandMore.Layout(gtx, th.Fg)
}),
layout.Rigid(layout.Spacer{Width: 4}.Layout),
layout.Flexed(1, func(gtx C) D {
v, unit := format(int64(stats.InputBytes), 1024)
v = float64(int64(v*100)) / 100
return material.Body2(th, fmt.Sprintf("%s %sB", strconv.FormatFloat(v, 'f', -1, 64), unit)).Layout(gtx)
}),
layout.Rigid(func(gtx C) D {
v, unit := format(int64(stats.InputRateBytes), 1024)
v = float64(int64(v*100)) / 100
return material.Body2(th, fmt.Sprintf("%s %sB/s", strconv.FormatFloat(v, 'f', -1, 64), unit)).Layout(gtx)
}),
)
}),
)
})
})
})
})
}
var (
units = []string{"", "K", "M", "G", "T", "P", "E"}
)
func format(n int64, scale int64) (v float64, unit string) {
var remain float64
for i := range units {
unit = units[i]
r := n % scale
if n = n / scale; n == 0 {
v = float64(r) + remain/math.Pow(float64(scale), float64(i))
return
}
remain += float64(r) * math.Pow(float64(scale), float64(i))
}
return
}
var (
dunits = []string{"s", "m", "h"}
)
func formatDuration(d time.Duration) (v int64, unit string) {
if d.Hours() >= 24 {
v = int64(d.Hours() / 24)
unit = "d"
return
}
var scale int64 = 60
n := int64(d.Seconds())
for i := range dunits {
v = n % scale
unit = dunits[i]
if n = n / scale; n == 0 {
return
}
}
return
}
+169
View File
@@ -0,0 +1,169 @@
package home
import (
"image/color"
"sync/atomic"
"gioui.org/layout"
"gioui.org/widget"
"gioui.org/widget/material"
"github.com/go-gost/gost.plus/ui/i18n"
"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,
pages: []navPage{
{
list: list.Tunnel(r),
path: page.PageTunnel,
},
{
list: list.Entrypoint(r),
path: page.PageEntrypoint,
},
},
}
}
func (p *homePage) Init(opts ...page.PageOption) {
p.nav = ui_widget.NewNav(
ui_widget.NewNavButton(i18n.Tunnel),
ui_widget.NewNavButton(i18n.Entrypoint),
)
}
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.Flexed(1, 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)
})
}),
)
}
+83
View File
@@ -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)
})
})
})
}),
)
}
-602
View File
@@ -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),
)
})
}),
)
}
-195
View File
@@ -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 p.wgFile.Layout(gtx, 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 p.wgHTTP.Layout(gtx, 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 p.wgTCP.Layout(gtx, 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 p.wgUDP.Layout(gtx, 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 p.wgEntryPointTCP.Layout(gtx, func(gtx C) D {
return layout.UniformInset(10).Layout(gtx, func(gtx C) D {
return p.layoutCard(gtx, th, "TCP", "Create an entrypoint to the specified TCP tunnel")
})
})
})
})
}),
layout.Rigid(func(gtx C) D {
return layout.Inset{Top: 5, Bottom: 5}.Layout(gtx, func(gtx C) D {
return component.Surface(th).Layout(gtx, func(gtx C) D {
return p.wgEntryPointUDP.Layout(gtx, func(gtx C) D {
return layout.UniformInset(10).Layout(gtx, func(gtx C) D {
return p.layoutCard(gtx, th, "UDP", "Create an entrypoint to the specified UDP tunnel")
})
})
})
})
}),
)
})
})
})
}
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))
}),
)
}
+20 -104
View File
@@ -1,52 +1,38 @@
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 {
type C = layout.Context
type D = layout.Dimensions
type T = material.Theme
func WithPageID(id string) PageOption {
return func(opts *PageOptions) {
opts.ID = id
}
@@ -54,75 +40,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
}
+186 -70
View File
@@ -1,109 +1,225 @@
package page
import (
"time"
"gioui.org/app"
"gioui.org/io/event"
"gioui.org/io/key"
"gioui.org/layout"
"gioui.org/op/clip"
"gioui.org/op/paint"
"gioui.org/unit"
"gioui.org/widget"
"gioui.org/widget/material"
"gioui.org/x/component"
"github.com/go-gost/core/logger"
"github.com/go-gost/gost.plus/ui/theme"
ui_widget "github.com/go-gost/gost.plus/ui/widget"
)
type C = layout.Context
type D = layout.Dimensions
const (
MaxWidth = 800
)
type Route struct {
Path string
Path PagePath
ID string
}
type Router struct {
modal *component.ModalLayer
bar *component.AppBar
pages map[string]Page
w *app.Window
pages map[PagePath]Page
stack routeStack
current Route
*material.Theme
modal *component.ModalLayer
notification *ui_widget.Notification
events chan Event
}
func NewRouter() *Router {
modal := component.NewModal()
bar := component.NewAppBar(modal)
func NewRouter(w *app.Window, th *T) *Router {
r := &Router{
modal: modal,
bar: bar,
pages: make(map[string]Page),
w: w,
pages: make(map[PagePath]Page),
Theme: th,
modal: component.NewModal(),
notification: ui_widget.NewNotification(3*time.Second, func() {
w.Invalidate()
}),
events: make(chan Event, 16),
}
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))
logger.Default().WithFields(map[string]any{
"kind": "router",
"route.id": route.ID,
}).Debugf("go to %s", route.Path)
}
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})
func (r *Router) Back() {
r.stack.Pop()
route := r.stack.Peek()
page := r.pages[route.Path]
if page == nil {
return
}
r.current = route
logger.Default().WithFields(map[string]any{
"kind": "router",
"route.id": route.ID,
}).Debugf("back to %s", route.Path)
}
func (r *Router) Layout(gtx C) D {
if r.stack.Peek().Path != PageHome {
event.Op(gtx.Ops, r.w)
for {
ev, ok := gtx.Event(
key.Filter{Name: key.NameBack},
key.Filter{Name: key.NameEscape},
)
if !ok {
break
}
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})
switch ev := ev.(type) {
case key.Event:
if ev.State == key.Press {
r.Back()
}
}
}
}
defer r.modal.Layout(gtx, th)
r.Theme.Palette = theme.Current().Material
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)
}),
defer r.modal.Layout(gtx, r.Theme)
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(MaxWidth)
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 layout.Stack{
Alignment: layout.N,
}.Layout(gtx,
layout.Expanded(page.Layout),
layout.Stacked(func(gtx layout.Context) layout.Dimensions {
return layout.Inset{
Top: 16,
Bottom: 16,
Right: gtx.Metric.PxToDp(gtx.Constraints.Max.X) / 5,
Left: gtx.Metric.PxToDp(gtx.Constraints.Max.X) / 5,
}.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
return r.notification.Layout(gtx, r.Theme)
})
}),
)
})
},
)
}
func (r *Router) ShowModal(gtx layout.Context, w func(gtx C, th *material.Theme) D) {
r.modal.Widget = func(gtx C, th *material.Theme, anim *component.VisibilityAnimation) D {
if gtx.Constraints.Max.X > gtx.Dp(MaxWidth) {
gtx.Constraints.Max.X = gtx.Dp(MaxWidth)
}
gtx.Constraints.Max.X = gtx.Constraints.Max.X * 3 / 4
var clk widget.Clickable
return clk.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
return w(gtx, th)
})
}
r.modal.Appear(gtx.Now)
}
func (r *Router) HideModal(gtx C) {
r.modal.Disappear(gtx.Now)
}
func (r *Router) Notify(message ui_widget.Message) {
r.notification.Show(message)
}
func (r *Router) Emit(event Event) {
select {
case r.events <- event:
default:
}
}
func (r *Router) Event() <-chan Event {
return r.events
}
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]
}
+286
View File
@@ -0,0 +1,286 @@
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/i18n"
"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
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,
menu: ui_widget.Menu{
List: layout.List{
Axis: layout.Vertical,
},
},
list: widget.List{
List: layout.List{
Axis: layout.Vertical,
},
},
lang: ui_widget.Selector{Title: i18n.Language},
theme: ui_widget.Selector{Title: i18n.Theme},
}
}
func (p *settingsPage) Init(opts ...page.PageOption) {
settings := config.Get().Settings
if settings == nil {
settings = &config.Settings{}
}
if settings.Lang == "" {
settings.Lang = i18n.Current().Value
}
if settings.Theme == "" {
settings.Theme = theme.Light
}
p.lang.Clear()
p.lang.Select(ui_widget.SelectorItem{
Name: i18n.Current().Name,
Value: i18n.Current().Value,
})
p.theme.Clear()
if settings.Theme == theme.Light {
p.theme.Select(ui_widget.SelectorItem{
Name: i18n.Light,
Value: settings.Theme,
})
} else {
p.theme.Select(ui_widget.SelectorItem{
Name: i18n.Dark,
Value: settings.Theme,
})
}
}
func (p *settingsPage) Layout(gtx layout.Context) layout.Dimensions {
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 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, i18n.Settings.Value())
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 layout.Flex{
Axis: layout.Vertical,
Alignment: layout.Middle,
}.Layout(gtx,
layout.Rigid(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 {
gtx.Constraints.Max.X = gtx.Dp(60)
return icons.IconApp.Layout(gtx)
})
}),
layout.Rigid(layout.Spacer{Height: 16}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
label := material.Body1(th, "GOST+")
label.Font.Weight = font.SemiBold
return layout.Center.Layout(gtx, label.Layout)
}),
layout.Rigid(layout.Spacer{Height: 8}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return layout.Center.Layout(gtx, material.Body1(th, version.Version).Layout)
}),
)
}),
layout.Rigid(layout.Spacer{Height: 32}.Layout),
layout.Rigid(func(gtx layout.Context) 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.UniformInset(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 {
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) {
var items []ui_widget.MenuItem
for _, lang := range i18n.Langs() {
items = append(items, ui_widget.MenuItem{
Key: lang.Name,
Value: lang.Value,
})
}
var found bool
for i := range items {
if found = p.lang.Any(items[i].Key.Value()); found {
items[i].Selected = found
break
}
}
if !found {
items[0].Selected = true
}
p.menu.Title = i18n.Language
p.menu.Items = items
p.menu.Selected = func(index int) {
p.lang.Clear()
p.lang.Select(ui_widget.SelectorItem{
Name: p.menu.Items[index].Key,
Value: p.menu.Items[index].Value,
})
p.router.HideModal(gtx)
cfg := config.Get()
if cfg.Settings == nil {
cfg.Settings = &config.Settings{}
}
cfg.Settings.Lang = p.lang.Item().Value
config.Set(cfg)
cfg.Write()
i18n.Set(cfg.Settings.Lang)
}
p.router.ShowModal(gtx, func(gtx page.C, th *material.Theme) page.D {
return p.menu.Layout(gtx, th)
})
}
func (p *settingsPage) showThemeMenu(gtx layout.Context) {
items := []ui_widget.MenuItem{
{Key: i18n.Light, Value: theme.Light},
{Key: i18n.Dark, Value: theme.Dark},
}
var found bool
for i := range items {
if found = p.theme.Any(items[i].Key.Value()); found {
items[i].Selected = found
break
}
}
if !found {
items[0].Selected = true
}
p.menu.Title = i18n.Theme
p.menu.Items = items
p.menu.Selected = func(index int) {
p.theme.Clear()
p.theme.Select(ui_widget.SelectorItem{
Name: p.menu.Items[index].Key,
Value: p.menu.Items[index].Value,
})
p.router.HideModal(gtx)
cfg := config.Get()
if cfg.Settings == nil {
cfg.Settings = &config.Settings{}
}
cfg.Settings.Theme = p.theme.Item().Value
config.Set(cfg)
cfg.Write()
switch cfg.Settings.Theme {
case theme.Dark:
theme.UseDark()
default:
theme.UseLight()
}
p.router.Emit(page.Event{ID: page.EventThemeChanged})
}
p.router.ShowModal(gtx, func(gtx page.C, th *material.Theme) page.D {
return p.menu.Layout(gtx, th)
})
}
-357
View File
@@ -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")
}),
)
}
-184
View File
@@ -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 state.editor.Layout(gtx, 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))
}),
)
}
+570
View File
@@ -0,0 +1,570 @@
package file
import (
"bytes"
"fmt"
"image/color"
"io"
"os"
"strings"
"time"
"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/i18n"
"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
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
lastCopyID time.Time
lastCopyEP time.Time
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,
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: i18n.DeleteTunnel,
},
}
}
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.router.HideModal(gtx)
}
p.router.ShowModal(gtx, func(gtx layout.Context, th *material.Theme) layout.Dimensions {
return p.delDialog.Layout(gtx, th)
})
}
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{
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.UniformInset(16).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
if p.wgID.Clicked(gtx) {
p.lastCopyEP = time.Time{}
p.lastCopyID = time.Now()
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 {
if time.Since(p.lastCopyID) < 3*time.Second {
return icons.IconDone.Layout(gtx, color.NRGBA(colornames.Green500))
}
return icons.IconCopy.Layout(gtx, color.NRGBA(colornames.Blue500))
}),
)
})
})
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if tun == nil {
return layout.Dimensions{}
}
gtx.Source = src
if p.wgEntrypoint.Clicked(gtx) {
p.lastCopyID = time.Time{}
p.lastCopyEP = time.Now()
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 {
if time.Since(p.lastCopyEP) < 3*time.Second {
return icons.IconDone.Layout(gtx, color.NRGBA(colornames.Green500))
}
return icons.IconCopy.Layout(gtx, color.NRGBA(colornames.Blue500))
}),
)
})
})
}),
layout.Rigid(layout.Spacer{Height: 8}.Layout),
layout.Rigid(func(gtx C) D {
return material.Body1(th, i18n.Name.Value()).Layout(gtx)
}),
layout.Rigid(func(gtx C) D {
return p.name.Layout(gtx, th, "")
}),
layout.Rigid(layout.Spacer{Height: 16}.Layout),
layout.Rigid(material.Body1(th, i18n.Endpoint.Value()).Layout),
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 %s", dir, i18n.ErrDirectory.Value())
}
return nil
}(); err != nil {
p.endpoint.SetError(err.Error())
} else {
p.endpoint.ClearError()
}
return p.endpoint.Layout(gtx, th, i18n.DirectoryPath.Value())
}),
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, i18n.BasicAuth.Value()).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, i18n.Username.Value())
}),
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, i18n.Password.Value())
})
}),
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()
p.router.Notify(ui_widget.Message{
Type: ui_widget.Error,
Content: err.Error(),
})
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()
p.router.Notify(ui_widget.Message{
Type: ui_widget.Error,
Content: err.Error(),
})
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),
tunnel.CreatedAtOption(opts.CreatedAt),
)
} else {
tun.Close()
}
tunnel.SaveConfig()
}
func (p *filePage) delete() {
tunnel.Delete(p.id)
tunnel.SaveConfig()
}
+636
View File
@@ -0,0 +1,636 @@
package http
import (
"bytes"
"fmt"
"image/color"
"io"
"net"
"strings"
"time"
"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/i18n"
"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
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
lastCopyID time.Time
lastCopyEP time.Time
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,
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: i18n.DeleteTunnel,
},
}
}
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.router.HideModal(gtx)
}
p.router.ShowModal(gtx, func(gtx page.C, th *material.Theme) page.D {
return p.delDialog.Layout(gtx, th)
})
}
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{
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.UniformInset(16).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
if p.wgID.Clicked(gtx) {
p.lastCopyEP = time.Time{}
p.lastCopyID = time.Now()
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 {
if time.Since(p.lastCopyID) < 3*time.Second {
return icons.IconDone.Layout(gtx, color.NRGBA(colornames.Green500))
}
return icons.IconCopy.Layout(gtx, color.NRGBA(colornames.Blue500))
}),
)
})
})
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if tun == nil {
return layout.Dimensions{}
}
gtx.Source = src
if p.wgEntrypoint.Clicked(gtx) {
p.lastCopyID = time.Time{}
p.lastCopyEP = time.Now()
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 {
if time.Since(p.lastCopyEP) < 3*time.Second {
return icons.IconDone.Layout(gtx, color.NRGBA(colornames.Green500))
}
return icons.IconCopy.Layout(gtx, color.NRGBA(colornames.Blue500))
}),
)
})
})
}),
layout.Rigid(layout.Spacer{Height: 8}.Layout),
layout.Rigid(func(gtx C) D {
return material.Body1(th, i18n.Name.Value()).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, i18n.Endpoint.Value()).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(i18n.ErrInvalidAddr.Value())
}
return nil
}(); err != nil {
p.endpoint.SetError(err.Error())
} else {
p.endpoint.ClearError()
}
return p.endpoint.Layout(gtx, th, i18n.Address.Value())
}),
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, i18n.BasicAuth.Value()).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, i18n.Username.Value())
}),
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, i18n.Password.Value())
})
}),
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, i18n.CustomHostname.Value()).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, i18n.Hostname.Value())
})
}),
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, i18n.EnableTLS.Value()).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()
p.router.Notify(ui_widget.Message{
Type: ui_widget.Error,
Content: err.Error(),
})
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()
p.router.Notify(ui_widget.Message{
Type: ui_widget.Error,
Content: err.Error(),
})
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),
tunnel.CreatedAtOption(opts.CreatedAt),
)
} else {
tun.Close()
}
tunnel.SaveConfig()
}
func (p *httpPage) delete() {
tunnel.Delete(p.id)
tunnel.SaveConfig()
}
+151
View File
@@ -0,0 +1,151 @@
package tunnel
import (
"image/color"
"gioui.org/layout"
"gioui.org/widget"
"gioui.org/widget/material"
"github.com/go-gost/gost.plus/ui/i18n"
"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 i18n.Key
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: i18n.FileTunnelDesc,
path: page.PageTunnelFile,
},
{
name: "HTTP",
desc: i18n.HTTPTunnelDesc,
path: page.PageTunnelHTTP,
},
{
name: "TCP",
desc: i18n.TCPTunnelDesc,
path: page.PageTunnelTCP,
},
{
name: "UDP",
desc: i18n.UDPTunnelDesc,
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, i18n.Tunnel.Value())
// 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.UniformInset(16).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.Value()).Layout),
)
}),
layout.Rigid(layout.Spacer{Width: 8}.Layout),
layout.Rigid(func(gtx C) D {
return icons.IconForward.Layout(gtx, color.NRGBA(colornames.Grey500))
}),
)
})
})
})
})
}),
)
}
+464
View File
@@ -0,0 +1,464 @@
package tcp
import (
"bytes"
"fmt"
"image/color"
"io"
"net"
"strings"
"time"
"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/i18n"
"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
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
lastCopyID time.Time
lastCopyEP time.Time
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,
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: i18n.DeleteTunnel,
},
}
}
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.router.HideModal(gtx)
}
p.router.ShowModal(gtx, func(gtx page.C, th *material.Theme) page.D {
return p.delDialog.Layout(gtx, th)
})
}
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{
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.UniformInset(16).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
if p.wgID.Clicked(gtx) {
p.lastCopyEP = time.Time{}
p.lastCopyID = time.Now()
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 {
if time.Since(p.lastCopyID) < 3*time.Second {
return icons.IconDone.Layout(gtx, color.NRGBA(colornames.Green500))
}
return icons.IconCopy.Layout(gtx, color.NRGBA(colornames.Blue500))
}),
)
})
})
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if tun == nil {
return layout.Dimensions{}
}
gtx.Source = src
if p.wgEntrypoint.Clicked(gtx) {
p.lastCopyID = time.Time{}
p.lastCopyEP = time.Now()
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 {
if time.Since(p.lastCopyEP) < 3*time.Second {
return icons.IconDone.Layout(gtx, color.NRGBA(colornames.Green500))
}
return icons.IconCopy.Layout(gtx, color.NRGBA(colornames.Blue500))
}),
)
})
})
}),
layout.Rigid(layout.Spacer{Height: 8}.Layout),
layout.Rigid(func(gtx C) D {
return material.Body1(th, i18n.Name.Value()).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, i18n.Endpoint.Value()).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(i18n.ErrInvalidAddr.Value())
}
return nil
}(); err != nil {
p.endpoint.SetError(err.Error())
} else {
p.endpoint.ClearError()
}
return p.endpoint.Layout(gtx, th, i18n.Address.Value())
}),
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()
p.router.Notify(ui_widget.Message{
Type: ui_widget.Error,
Content: err.Error(),
})
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()
p.router.Notify(ui_widget.Message{
Type: ui_widget.Error,
Content: err.Error(),
})
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),
tunnel.CreatedAtOption(opts.CreatedAt),
)
} else {
tun.Close()
}
tunnel.SaveConfig()
}
func (p *tcpPage) delete() {
tunnel.Delete(p.id)
tunnel.SaveConfig()
}
+464
View File
@@ -0,0 +1,464 @@
package udp
import (
"bytes"
"fmt"
"image/color"
"io"
"net"
"strings"
"time"
"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/i18n"
"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
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
lastCopyID time.Time
lastCopyEP time.Time
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,
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: i18n.DeleteTunnel,
},
}
}
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.router.HideModal(gtx)
}
p.router.ShowModal(gtx, func(gtx page.C, th *material.Theme) page.D {
return p.delDialog.Layout(gtx, th)
})
}
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{
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.UniformInset(16).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
if p.wgID.Clicked(gtx) {
p.lastCopyEP = time.Time{}
p.lastCopyID = time.Now()
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 {
if time.Since(p.lastCopyID) < 3*time.Second {
return icons.IconDone.Layout(gtx, color.NRGBA(colornames.Green500))
}
return icons.IconCopy.Layout(gtx, color.NRGBA(colornames.Blue500))
}),
)
})
})
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if tun == nil {
return layout.Dimensions{}
}
gtx.Source = src
if p.wgEntrypoint.Clicked(gtx) {
p.lastCopyID = time.Time{}
p.lastCopyEP = time.Now()
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 {
if time.Since(p.lastCopyEP) < 3*time.Second {
return icons.IconDone.Layout(gtx, color.NRGBA(colornames.Green500))
}
return icons.IconCopy.Layout(gtx, color.NRGBA(colornames.Blue500))
}),
)
})
})
}),
layout.Rigid(layout.Spacer{Height: 8}.Layout),
layout.Rigid(func(gtx C) D {
return material.Body1(th, i18n.Name.Value()).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, i18n.Endpoint.Value()).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(i18n.ErrInvalidAddr.Value())
}
return nil
}(); err != nil {
p.endpoint.SetError(err.Error())
} else {
p.endpoint.ClearError()
}
return p.endpoint.Layout(gtx, th, i18n.Address.Value())
}),
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()
p.router.Notify(ui_widget.Message{
Type: ui_widget.Error,
Content: err.Error(),
})
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()
p.router.Notify(ui_widget.Message{
Type: ui_widget.Error,
Content: err.Error(),
})
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),
tunnel.CreatedAtOption(opts.CreatedAt),
)
} else {
tun.Close()
}
tunnel.SaveConfig()
}
func (p *udpPage) delete() {
tunnel.Delete(p.id)
tunnel.SaveConfig()
}
-358
View File
@@ -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")
}),
)
}
+95
View File
@@ -0,0 +1,95 @@
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
ItemBg color.NRGBA
NotificationBg 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.BlueGrey50),
NavButtonContrastBg: color.NRGBA(colornames.BlueGrey100),
ItemBg: color.NRGBA(colornames.Grey300),
NotificationBg: color.NRGBA(colornames.Grey200),
},
}
dark = Theme{
Palette: Palette{
Material: material.Palette{
Fg: color.NRGBA(colornames.White),
Bg: color.NRGBA(colornames.Grey900),
ContrastBg: color.NRGBA(colornames.Green500),
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),
ItemBg: color.NRGBA(colornames.Grey600),
NotificationBg: color.NRGBA(colornames.Grey700),
},
}
)
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
}
+63 -11
View File
@@ -1,36 +1,88 @@
package ui
import (
"gioui.org/app"
"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/fonts"
"github.com/go-gost/gost.plus/ui/i18n"
"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
w *app.Window
router *page.Router
}
func NewUI() *UI {
if settings := config.Get().Settings; settings != nil {
switch settings.Theme {
case theme.Dark:
theme.UseDark()
default:
theme.UseLight()
}
i18n.Set(settings.Lang)
}
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)
th.Palette = theme.Current().Material
ui := &UI{
th: th,
router: page.NewRouter(),
w := &app.Window{}
w.Option(
app.Title("GOST"),
app.MinSize(800, 600),
app.StatusColor(th.Bg),
)
router := page.NewRouter(w, 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{
w: w,
router: router,
}
return ui
}
func (ui *UI) Layout(gtx C) D {
return ui.router.Layout(gtx, ui.th)
return ui.router.Layout(gtx)
}
func (ui *UI) Window() *app.Window {
return ui.w
}
func (ui *UI) Router() *page.Router {
return ui.router
}
+276
View File
@@ -0,0 +1,276 @@
package widget
import (
"gioui.org/layout"
"gioui.org/widget"
"gioui.org/widget/material"
"gioui.org/x/component"
"github.com/go-gost/gost.plus/ui/i18n"
)
type Dialog struct {
Title i18n.Key
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 {
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.Value()).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: 18,
Button: &p.btnCancel,
}.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, i18n.Cancel.Value())
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: 18,
Button: &p.btnOK,
}.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, i18n.OK.Value())
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)
})
})
}),
)
}),
)
})
})
})
})
}
+129
View File
@@ -0,0 +1,129 @@
package widget
import (
"gioui.org/layout"
"gioui.org/widget"
"gioui.org/widget/material"
"gioui.org/x/component"
"github.com/go-gost/gost.plus/ui/i18n"
"github.com/go-gost/gost.plus/ui/icons"
)
type Menu struct {
List layout.List
Items []MenuItem
Title i18n.Key
Selected func(index int)
btnAdd widget.Clickable
ShowAdd bool
Multiple bool
}
func (p *Menu) Layout(gtx layout.Context, th *material.Theme) layout.Dimensions {
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.Value()).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 i18n.Key
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: 12,
Bottom: 12,
Left: 24,
Right: 24,
}.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.Value()).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{}
}),
)
})
})
}
+92
View File
@@ -0,0 +1,92 @@
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/i18n"
"github.com/go-gost/gost.plus/ui/theme"
)
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
} else {
btn.background = theme.Current().NavButtonBg
}
return layout.UniformInset(8).Layout(gtx, func(gtx layout.Context) layout.Dimensions {
return btn.Layout(gtx, th)
})
})
}
type NavButton struct {
btn widget.Clickable
cornerRadius unit.Dp
background color.NRGBA
text i18n.Key
}
func NewNavButton(text i18n.Key) *NavButton {
return &NavButton{
cornerRadius: 20,
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: theme.Current().NavButtonContrastBg,
CornerRadius: btn.cornerRadius,
}.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 {
label := material.Body1(th, btn.text.Value())
return label.Layout(gtx)
})
})
})
}
+122
View File
@@ -0,0 +1,122 @@
package widget
import (
"image/color"
"sync"
"sync/atomic"
"time"
"gioui.org/layout"
"gioui.org/widget/material"
"gioui.org/x/component"
"github.com/go-gost/gost.plus/ui/icons"
"github.com/go-gost/gost.plus/ui/theme"
"golang.org/x/exp/shiny/materialdesign/colornames"
)
const (
Success string = "success"
Info string = "info"
Warn string = "warn"
Error string = "error"
)
type Message struct {
Type string
Content string
}
type Notification struct {
show atomic.Bool
messages chan Message
current Message
duration time.Duration
callback func()
mu sync.RWMutex
}
func NewNotification(d time.Duration, callback func()) *Notification {
p := &Notification{
messages: make(chan Message, 16),
duration: d,
callback: callback,
}
go p.run()
return p
}
func (p *Notification) Layout(gtx C, th *T) D {
if !p.show.Load() {
return D{}
}
p.mu.RLock()
message := p.current
p.mu.RUnlock()
return component.SurfaceStyle{
Theme: th,
ShadowStyle: component.ShadowStyle{
CornerRadius: 8,
},
Fill: theme.Current().NotificationBg,
}.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,
}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
switch message.Type {
case Success:
return icons.IconInfo.Layout(gtx, color.NRGBA(colornames.Green500))
case Warn:
return icons.IconInfo.Layout(gtx, color.NRGBA(colornames.Orange500))
case Error:
return icons.IconAlert.Layout(gtx, color.NRGBA(colornames.Red500))
case Info:
fallthrough
default:
return icons.IconInfo.Layout(gtx, color.NRGBA(colornames.Blue500))
}
}),
layout.Rigid(layout.Spacer{Width: 8}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return material.Body2(th, message.Content).Layout(gtx)
}),
)
})
})
}
func (p *Notification) Show(message Message) {
select {
case p.messages <- message:
default:
}
}
func (p *Notification) run() {
for m := range p.messages {
p.mu.Lock()
p.current = m
p.mu.Unlock()
p.show.Store(true)
if p.callback != nil {
p.callback()
}
<-time.After(p.duration)
p.show.Store(false)
if p.callback != nil {
p.callback()
}
}
}
+144
View File
@@ -0,0 +1,144 @@
package widget
import (
"gioui.org/layout"
"gioui.org/widget"
"gioui.org/widget/material"
"gioui.org/x/component"
"gioui.org/x/outlay"
"github.com/go-gost/gost.plus/ui/i18n"
"github.com/go-gost/gost.plus/ui/icons"
"github.com/go-gost/gost.plus/ui/theme"
)
type SelectorItem struct {
Name i18n.Key
Value string
}
type Selector struct {
Title i18n.Key
items []SelectorItem
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: 4,
Bottom: 4,
}.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
return layout.Flex{
Alignment: layout.Middle,
}.Layout(gtx,
layout.Rigid(material.Body1(th, p.Title.Value()).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 {
var values []string
for _, item := range p.items {
if item.Value == "" {
continue
}
value := item.Name.Value()
if value == "" {
value = item.Value
}
values = append(values, value)
}
return outlay.FlowWrap{
Alignment: layout.Middle,
}.Layout(gtx, len(values), func(gtx layout.Context, i int) layout.Dimensions {
return layout.UniformInset(4).Layout(gtx, func(gtx layout.Context) layout.Dimensions {
return component.SurfaceStyle{
Theme: th,
ShadowStyle: component.ShadowStyle{CornerRadius: 14},
Fill: theme.Current().ItemBg,
}.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
return layout.Inset{
Top: 4,
Bottom: 4,
Left: 10,
Right: 10,
}.Layout(gtx, material.Body2(th, values[i]).Layout)
})
})
})
})
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if len(p.items) > 0 {
return layout.Dimensions{}
}
return layout.Inset{
Top: 4,
Bottom: 5,
}.Layout(gtx, 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 ...SelectorItem) {
for _, item := range items {
if item.Name == "" {
continue
}
p.items = append(p.items, item)
}
}
func (p *Selector) Unselect(name string) {
for i := range p.items {
if p.items[i].Name.Value() == name {
p.items = append(p.items[:i], p.items[i+1:]...)
return
}
}
}
func (p *Selector) Any(names ...string) bool {
for _, name := range names {
if p.contains(name) {
return true
}
}
return false
}
func (p *Selector) Item() SelectorItem {
if len(p.items) == 0 {
return SelectorItem{}
}
return p.items[0]
}
func (p *Selector) Items() []SelectorItem {
return p.items
}
func (p *Selector) contains(name string) bool {
if name == "" && len(p.items) == 0 {
return true
}
for i := range p.items {
if p.items[i].Name.Value() == name {
return true
}
}
return false
}
func (p *Selector) Clear() {
p.items = nil
}
+41
View File
@@ -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
}
+14
View File
@@ -0,0 +1,14 @@
package widget
import (
"gioui.org/layout"
"gioui.org/widget/material"
)
type C = layout.Context
type D = layout.Dimensions
type T = material.Theme
type Widget interface {
Layout(gtx C, th *T) D
}
+1 -1
View File
@@ -1,3 +1,3 @@
package version
var Version = "0.2.0"
var Version = "0.3.2"
+1
View File
@@ -0,0 +1 @@
package winres
+7 -7
View File
@@ -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": ""
}
}