diff --git a/appicon.png b/appicon.png index 05aa1af..8ca49c8 100644 Binary files a/appicon.png and b/appicon.png differ diff --git a/config/config.go b/config/config.go index 5b6df6d..8fdd51a 100644 --- a/config/config.go +++ b/config/config.go @@ -103,6 +103,7 @@ type Tunnel struct { Keepalive bool `yaml:",omitempty"` TTL int `yaml:"ttl,omitempty"` + Stats ServiceStats Favorite bool Closed bool CreatedAt time.Time @@ -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 +} diff --git a/go.mod b/go.mod index f35567d..b4ec20c 100644 --- a/go.mod +++ b/go.mod @@ -3,19 +3,63 @@ module github.com/go-gost/gost.plus go 1.22.0 require ( - gioui.org v0.5.0 - gioui.org/x v0.5.0 + 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/natefinch/lumberjack.v2 v2.2.1 gopkg.in/yaml.v3 v3.0.1 ) require ( gioui.org/cpu v0.0.0-20210817075930-8d6a761490d2 // indirect gioui.org/shader v1.0.8 // indirect - github.com/go-text/typesetting v0.0.0-20230803102845-24e03d8b5372 // indirect + github.com/beorn7/perks v1.0.1 // indirect + github.com/cespare/xxhash/v2 v2.2.0 // indirect + github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect + github.com/fsnotify/fsnotify v1.7.0 // indirect + github.com/go-gost/gosocks5 v0.4.0 // indirect + github.com/go-gost/plugin v0.0.0-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.1.1 // indirect + github.com/gobwas/glob v0.2.3 // indirect + github.com/golang/protobuf v1.5.3 // indirect + github.com/gorilla/websocket v1.5.1 // indirect + github.com/hashicorp/hcl v1.0.0 // indirect + github.com/magiconair/properties v1.8.7 // indirect + github.com/matttproud/golang_protobuf_extensions v1.0.4 // indirect + github.com/mitchellh/mapstructure v1.5.0 // indirect + github.com/patrickmn/go-cache v2.1.0+incompatible // indirect + github.com/pelletier/go-toml/v2 v2.1.1 // indirect + github.com/pires/go-proxyproto v0.7.0 // indirect + github.com/prometheus/client_golang v1.17.0 // indirect + github.com/prometheus/client_model v0.4.1-0.20230718164431-9a2bf3000d16 // indirect + github.com/prometheus/common v0.44.0 // indirect + github.com/prometheus/procfs v0.11.1 // indirect + github.com/rs/xid v1.3.0 // indirect + github.com/sagikazarmark/locafero v0.4.0 // indirect + github.com/sagikazarmark/slog-shim v0.1.0 // indirect + github.com/sirupsen/logrus v1.8.1 // indirect + github.com/sourcegraph/conc v0.3.0 // indirect + github.com/spf13/afero v1.11.0 // indirect + github.com/spf13/cast v1.6.0 // indirect + github.com/spf13/pflag v1.0.5 // indirect + github.com/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-20240222234643-814bf88cf225 // indirect golang.org/x/image v0.14.0 // indirect + golang.org/x/net v0.19.0 // indirect golang.org/x/sys v0.18.0 // indirect golang.org/x/text v0.14.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20231120223509-83a465c0220f // indirect + google.golang.org/grpc v1.59.0 // indirect + google.golang.org/protobuf v1.31.0 // indirect + gopkg.in/ini.v1 v1.67.0 // indirect + gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect ) diff --git a/go.sum b/go.sum index 34f5c26..7145d15 100644 --- a/go.sum +++ b/go.sum @@ -1,31 +1,166 @@ 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.5.0 h1:07g7/LY1MFuTncfO4A5DIKMMsQV6PkPHyx0MhDqgmYY= -gioui.org v0.5.0/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.5.0 h1:NVKTn5AZuYhkAnF7MYcy1dIes36+U1N4gUTsgBhfr4A= -gioui.org/x v0.5.0/go.mod h1:X4UBhvanAN+8S16L3K6jDMrVo7Dii7NptgBpOLBD7E4= -github.com/go-text/typesetting v0.0.0-20230803102845-24e03d8b5372 h1:FQivqchis6bE2/9uF70M2gmmLpe82esEm2QadL0TEJo= -github.com/go-text/typesetting v0.0.0-20230803102845-24e03d8b5372/go.mod h1:evDBbvNR/KaVFZ2ZlDSOWWXIUKq0wCOEtzLxRM8SG3k= -github.com/go-text/typesetting-utils v0.0.0-20230616150549-2a7df14b6a22 h1:LBQTFxP2MfsyEDqSKmUBZaDuDHN1vpqDyOZjcqS7MYI= -github.com/go-text/typesetting-utils v0.0.0-20230616150549-2a7df14b6a22/go.mod h1:DDxDdQEnB70R8owOx3LVpEFvpMK9eeH1o2r0yZhFI9o= +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= +github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= +github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= +github.com/go-gost/core v0.0.0-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-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-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.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.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= +github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= +github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/matttproud/golang_protobuf_extensions v1.0.4 h1:mmDVorXM7PCGKw94cs5zkfA9PSy5pEvNWRP0ET0TIVo= +github.com/matttproud/golang_protobuf_extensions v1.0.4/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE= +github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= +github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE= +github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU= +github.com/onsi/gomega v1.18.1 h1:M1GfJqGRrBrrGGsbxzV5dqM2U2ApXefZCQpkukxYRLE= +github.com/onsi/gomega v1.18.1/go.mod h1:0q+aL8jAiMXy9hbwj2mr5GziHiwhAIQpFmmtT5hitRs= +github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc= +github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ= +github.com/pelletier/go-toml/v2 v2.1.1 h1:LWAJwfNvjQZCFIDKWYQaM62NcYeYViCmWIwmOStowAI= +github.com/pelletier/go-toml/v2 v2.1.1/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc= +github.com/pires/go-proxyproto v0.7.0 h1:IukmRewDQFWC7kfnb66CSomk2q/seBuilHBYFwyq0Hs= +github.com/pires/go-proxyproto v0.7.0/go.mod h1:Vz/1JPY/OACxWGQNIRY2BeyDmpoaWmEP40O9LbuiFR4= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v1.17.0 h1:rl2sfwZMtSthVU752MqfjQozy7blglC+1SOtjMAMh+Q= +github.com/prometheus/client_golang v1.17.0/go.mod h1:VeL+gMmOAxkS2IqfCq0ZmHSL+LjWfWDUmp1mBz9JgUY= +github.com/prometheus/client_model v0.4.1-0.20230718164431-9a2bf3000d16 h1:v7DLqVdK4VrYkVD5diGdl4sxJurKJEMnODWRJlxV9oM= +github.com/prometheus/client_model v0.4.1-0.20230718164431-9a2bf3000d16/go.mod h1:oMQmHW1/JoDwqLtg57MGgP/Fb1CJEYF2imWWhWtMkYU= +github.com/prometheus/common v0.44.0 h1:+5BrQJwiBB9xsMygAB3TNvpQKOwlkc25LbISbrdOOfY= +github.com/prometheus/common v0.44.0/go.mod h1:ofAIvZbQ1e/nugmZGz4/qCb9Ap1VoSTIO7x0VV9VvuY= +github.com/prometheus/procfs v0.11.1 h1:xRC8Iq1yyca5ypa9n1EZnWZkt7dwcoRPQwX/5gwaUuI= +github.com/prometheus/procfs v0.11.1/go.mod h1:eesXgaPo1q7lBpVMoMy0ZOFTth9hBn4W/y0/p/ScXhY= +github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= +github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= +github.com/rs/xid v1.3.0 h1:6NjYksEUlhurdVehpc7S7dk6DAmcKv8V9gG0FsVN2U4= +github.com/rs/xid v1.3.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= +github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ= +github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4= +github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE= +github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ= +github.com/sirupsen/logrus v1.8.1 h1:dJKuHgqk1NNQlqoA6BTlM1Wf9DOH3NBjQyu0h9+AZZE= +github.com/sirupsen/logrus v1.8.1/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= +github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= +github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= +github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= +github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY= +github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0= +github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.18.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= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= +github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +github.com/xtaci/smux v1.5.24 h1:77emW9dtnOxxOQ5ltR+8BbsX1kzcOxQ5gB+aaV9hXOY= +github.com/xtaci/smux v1.5.24/go.mod h1:OMlQbT5vcgl2gb49mFkYo6SMf+zP3rcjcwQz7ZU7IGY= +github.com/yl2chen/cidranger v1.0.2 h1:lbOWZVCG1tCRX4u24kuM1Tb4nHqWkDxwLdoS+SevawU= +github.com/yl2chen/cidranger v1.0.2/go.mod h1:9U1yz7WPYDwf0vpNWFaeRh0bjwz5RVgRy/9UEQfHl0g= +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/exp v0.0.0-20240222234643-814bf88cf225 h1:LfspQV/FYTatPTr/3HzIcmiUFH7PGP+OQ6mgDYo3yuQ= golang.org/x/exp v0.0.0-20240222234643-814bf88cf225/go.mod h1:CxmFvTBINI24O/j8iY7H1xHzx2i4OsyguNBmN/uPtqc= golang.org/x/exp/shiny v0.0.0-20240103183307-be819d1f06fc h1:OG+uKOKt/BW+ydf/M7gym7ONo8U+dyIlLazys3du298= golang.org/x/exp/shiny v0.0.0-20240103183307-be819d1f06fc/go.mod h1:3F+MieQB7dRYLTmnncoFbb1crS5lfQoTfDgQy6K4N0o= golang.org/x/image v0.14.0 h1:tNgSxAFe3jC4uYqvZdTr84SZoM1KfwdC9SKIFrLjFn4= golang.org/x/image v0.14.0/go.mod h1:HUYqC05R2ZcZ3ejNQsIHQDQiwWM4JBqmm6MKANTp4LE= +golang.org/x/net v0.19.0 h1:zTwKpTd2XuCqf8huc7Fo2iSy+4RHPd10s4KzeTnVr1c= +golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U= +golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4= golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/genproto/googleapis/rpc v0.0.0-20231120223509-83a465c0220f h1:ultW7fxlIvee4HYrtnaRPon9HpEgFk5zYpmfMgtKB5I= +google.golang.org/genproto/googleapis/rpc v0.0.0-20231120223509-83a465c0220f/go.mod h1:L9KNLi232K1/xB6f7AlSX692koaRnKaWSR0stBki0Yc= +google.golang.org/grpc v1.59.0 h1:Z5Iec2pjwb+LEOqzpB2MR12/eKFhDPhuqW91O+4bwUk= +google.golang.org/grpc v1.59.0/go.mod h1:aUPDwccQo6OTjy7Hct4AfBPD1GptF4fyUjIkQ9YtF98= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8= +google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= +gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/main.go b/main.go index b108158..c0601c5 100644 --- a/main.go +++ b/main.go @@ -1,15 +1,18 @@ package main import ( + "context" _ "net" "os" + "time" "gioui.org/app" _ "gioui.org/app/permission/storage" - "gioui.org/io/key" "gioui.org/op" "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" @@ -20,11 +23,10 @@ func main() { Init() go func() { - w := app.NewWindow( - app.Title("GOST+"), - app.MinSize(800, 600), - ) - err := run(w) + var w app.Window + w.Option(app.Title("GOST+")) + w.Option(app.MinSize(800, 600)) + err := run(&w) if err != nil { logger.Default().Fatal(err) } @@ -34,20 +36,26 @@ func main() { } func run(w *app.Window) error { + go func() { + for e := range runner.Event() { + if e.TaskID == runner.TaskUpdateStats { + w.Invalidate() + } + } + }() + ui := ui.NewUI() var ops op.Ops for { - switch e := w.NextEvent().(type) { + switch e := w.Event().(type) { case app.DestroyEvent: + tunnel.SaveConfig() + entrypoint.SaveConfig() return e.Err case app.FrameEvent: gtx := app.NewContext(&ops, e) ui.Layout(gtx) e.Frame(gtx.Ops) - case key.Event: - if e.Name == key.NameBack { - return nil - } } } } @@ -56,4 +64,10 @@ func Init() { config.Init() tunnel.LoadConfig() entrypoint.LoadConfig() + + runner.Exec(context.Background(), task.UpdateStats(), + runner.WithAync(true), + runner.WithInterval(time.Second), + runner.WithCancel(true), + ) } diff --git a/runner/runner.go b/runner/runner.go new file mode 100644 index 0000000..6dbb8c9 --- /dev/null +++ b/runner/runner.go @@ -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) +} diff --git a/runner/task.go b/runner/task.go new file mode 100644 index 0000000..7b4f26a --- /dev/null +++ b/runner/task.go @@ -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 +} diff --git a/runner/task/stats.go b/runner/task/stats.go new file mode 100644 index 0000000..036cb7d --- /dev/null +++ b/runner/task/stats.go @@ -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() +} diff --git a/tunnel/entrypoint/entrypoint.go b/tunnel/entrypoint/entrypoint.go index 032d033..770f814 100644 --- a/tunnel/entrypoint/entrypoint.go +++ b/tunnel/entrypoint/entrypoint.go @@ -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 @@ -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 } diff --git a/tunnel/entrypoint/tcp.go b/tunnel/entrypoint/tcp.go index f6ea1aa..b4dfb43 100644 --- a/tunnel/entrypoint/tcp.go +++ b/tunnel/entrypoint/tcp.go @@ -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/gost.plus/tunnel" "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/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() { @@ -196,6 +209,18 @@ func (s *tcpEntryPoint) Status() *xservice.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 { diff --git a/tunnel/entrypoint/udp.go b/tunnel/entrypoint/udp.go index 7a7e869..f845a5c 100644 --- a/tunnel/entrypoint/udp.go +++ b/tunnel/entrypoint/udp.go @@ -13,6 +13,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" "github.com/go-gost/gost.plus/tunnel" "github.com/go-gost/x/config" chain_parser "github.com/go-gost/x/config/parsing/chain" @@ -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() { @@ -201,6 +213,18 @@ func (s *udpEntryPoint) Status() *xservice.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 { diff --git a/tunnel/file.go b/tunnel/file.go index 62dff62..d6d8a7b 100644 --- a/tunnel/file.go +++ b/tunnel/file.go @@ -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()) } @@ -234,6 +253,18 @@ func (s *fileTunnel) Status() *xservice.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 { diff --git a/tunnel/http.go b/tunnel/http.go index 91f130e..10bda46 100644 --- a/tunnel/http.go +++ b/tunnel/http.go @@ -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, @@ -169,11 +176,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 @@ -209,7 +219,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() { @@ -226,6 +239,18 @@ func (s *httpTunnel) Status() *xservice.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 { diff --git a/tunnel/tcp.go b/tunnel/tcp.go index a9ec5bc..7f5062f 100644 --- a/tunnel/tcp.go +++ b/tunnel/tcp.go @@ -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() { @@ -192,6 +205,18 @@ func (s *tcpTunnel) Status() *xservice.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 { diff --git a/tunnel/tunnel.go b/tunnel/tunnel.go index 3a0e547..7de5daa 100644 --- a/tunnel/tunnel.go +++ b/tunnel/tunnel.go @@ -3,6 +3,7 @@ package tunnel import ( "errors" "sync" + "time" "github.com/go-gost/core/logger" "github.com/go-gost/gost.plus/config" @@ -39,6 +40,8 @@ type Options struct { EnableTLS bool Keepalive bool TTL int + CreatedAt time.Time + Stats config.ServiceStats } type Option func(opts *Options) @@ -97,6 +100,12 @@ func TTLOption(ttl int) Option { } } +func CreatedAtOption(createdAt time.Time) Option { + return func(opts *Options) { + opts.CreatedAt = createdAt + } +} + type ServiceStatus interface { Status() *xservice.Status } @@ -110,6 +119,8 @@ type Tunnel interface { Options() Options Run() error Status() *xservice.Status + Stats() config.ServiceStats + SetStats(stats config.ServiceStats) Favorite(b bool) IsFavorite() bool Close() error @@ -234,6 +245,8 @@ func LoadConfig() { Username: cfg.Username, Password: cfg.Password, EnableTLS: cfg.EnableTLS, + CreatedAt: cfg.CreatedAt, + Stats: cfg.Stats, }) if tun == nil { continue @@ -273,6 +286,8 @@ func SaveConfig() error { EnableTLS: opts.EnableTLS, Favorite: tun.IsFavorite(), Closed: tun.IsClosed(), + CreatedAt: opts.CreatedAt, + Stats: tun.Stats(), }) } @@ -285,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), @@ -294,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 } diff --git a/tunnel/udp.go b/tunnel/udp.go index 0208cba..ea713c9 100644 --- a/tunnel/udp.go +++ b/tunnel/udp.go @@ -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() { @@ -192,6 +205,18 @@ func (s *udpTunnel) Status() *xservice.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 { diff --git a/ui/fonts/NotoSansSC-Bold.ttf b/ui/fonts/NotoSansSC-Bold.ttf new file mode 100644 index 0000000..b9010df Binary files /dev/null and b/ui/fonts/NotoSansSC-Bold.ttf differ diff --git a/ui/fonts/NotoSansSC-Regular.ttf b/ui/fonts/NotoSansSC-Regular.ttf new file mode 100644 index 0000000..4d4cadb Binary files /dev/null and b/ui/fonts/NotoSansSC-Regular.ttf differ diff --git a/ui/fonts/font.go b/ui/fonts/font.go new file mode 100644 index 0000000..40f154c --- /dev/null +++ b/ui/fonts/font.go @@ -0,0 +1,36 @@ +package fonts + +import ( + _ "embed" + "fmt" + + "gioui.org/font" + "gioui.org/font/opentype" +) + +//go:embed NotoSansSC-Regular.ttf +var fontNotoRegular []byte + +//go:embed NotoSansSC-Bold.ttf +var fontNotoBold []byte + +var ( + collection []font.FontFace +) + +func init() { + register(fontNotoRegular) + register(fontNotoBold) +} + +func Collection() []font.FontFace { + return collection +} + +func register(ttf []byte) { + faces, err := opentype.ParseCollection(ttf) + if err != nil { + panic(fmt.Errorf("failed to parse font: %v", err)) + } + collection = append(collection, faces[0]) +} diff --git a/ui/i18n/lang.go b/ui/i18n/lang.go index 3edd07f..b305240 100644 --- a/ui/i18n/lang.go +++ b/ui/i18n/lang.go @@ -93,10 +93,10 @@ const ( type Key string func (k Key) Value() string { - return Get(k) + return get(k) } -func Get(key Key) string { +func get(key Key) string { mux.RLock() defer mux.RUnlock() diff --git a/ui/icons/icons.go b/ui/icons/icons.go index ed77d7f..fbdd45a 100644 --- a/ui/icons/icons.go +++ b/ui/icons/icons.go @@ -59,6 +59,7 @@ var ( IconActionCode = mustIcon(icons.ActionCode) IconActionUpdate = mustIcon(icons.ActionUpdate) IconActionHourGlassEmpty = mustIcon(icons.ActionHourglassEmpty) + IconInfo = mustIcon(icons.ActionInfo) ) func mustIcon(data []byte) *widget.Icon { diff --git a/ui/page/entrypoint/page.go b/ui/page/entrypoint/page.go index 848ef78..d304359 100644 --- a/ui/page/entrypoint/page.go +++ b/ui/page/entrypoint/page.go @@ -87,7 +87,7 @@ func (p *entrypointPage) Layout(gtx C) D { }), layout.Rigid(layout.Spacer{Width: 8}.Layout), layout.Rigid(func(gtx C) D { - title := material.H6(th, i18n.Get(i18n.Entrypoint)) + title := material.H6(th, i18n.Entrypoint.Value()) return title.Layout(gtx) }), layout.Rigid(layout.Spacer{Width: 8}.Layout), diff --git a/ui/page/entrypoint/tcp/page.go b/ui/page/entrypoint/tcp/page.go index 27e0c1d..e644d97 100644 --- a/ui/page/entrypoint/tcp/page.go +++ b/ui/page/entrypoint/tcp/page.go @@ -27,7 +27,6 @@ type D = layout.Dimensions type tcpPage struct { router *page.Router - modal *component.ModalLayer btnBack widget.Clickable btnState widget.Clickable @@ -51,7 +50,6 @@ type tcpPage struct { func NewPage(r *page.Router) page.Page { return &tcpPage{ router: r, - modal: component.NewModal(), list: layout.List{ // NOTE: the list must be vertical Axis: layout.Vertical, @@ -72,7 +70,7 @@ func NewPage(r *page.Router) page.Page { }, }, delDialog: ui_widget.Dialog{ - Title: i18n.Get(i18n.DeleteEntrypoint), + Title: i18n.DeleteEntrypoint, }, } } @@ -91,6 +89,7 @@ func (p *tcpPage) Init(opts ...page.PageOption) { p.edit = true } + p.tunnelID.Clear() p.name.Clear() p.entrypoint.Clear() @@ -126,18 +125,15 @@ func (p *tcpPage) Layout(gtx C) D { p.delete() p.router.Back() } - p.modal.Disappear(gtx.Now) + p.router.HideModal(gtx) } - p.modal.Widget = func(gtx layout.Context, th *material.Theme, anim *component.VisibilityAnimation) layout.Dimensions { + p.router.ShowModal(gtx, func(gtx page.C, th *material.Theme) page.D { return p.delDialog.Layout(gtx, th) - } - p.modal.Appear(gtx.Now) + }) } th := p.router.Theme - defer p.modal.Layout(gtx, th) - return layout.Flex{ Axis: layout.Vertical, }.Layout(gtx, @@ -273,7 +269,7 @@ func (p *tcpPage) layout(gtx C, th *material.Theme) D { Axis: layout.Vertical, }.Layout(gtx, layout.Rigid(func(gtx C) D { - return material.Body1(th, i18n.Get(i18n.TunnelID)).Layout(gtx) + return material.Body1(th, i18n.TunnelID.Value()).Layout(gtx) }), layout.Rigid(func(gtx C) D { if err := func() error { @@ -282,7 +278,7 @@ func (p *tcpPage) layout(gtx C, th *material.Theme) D { return nil } if _, err := uuid.Parse(tid); err != nil { - return fmt.Errorf(i18n.Get(i18n.ErrInvalidTunnelID)) + return fmt.Errorf(i18n.ErrInvalidTunnelID.Value()) } return nil }(); err != nil { @@ -299,7 +295,7 @@ func (p *tcpPage) layout(gtx C, th *material.Theme) D { layout.Rigid(layout.Spacer{Height: 16}.Layout), layout.Rigid(func(gtx C) D { - return material.Body1(th, i18n.Get(i18n.Name)).Layout(gtx) + return material.Body1(th, i18n.Name.Value()).Layout(gtx) }), layout.Rigid(func(gtx C) D { return p.name.Layout(gtx, th, "") @@ -307,7 +303,7 @@ func (p *tcpPage) layout(gtx C, th *material.Theme) D { layout.Rigid(layout.Spacer{Height: 16}.Layout), layout.Rigid(func(gtx C) D { - return material.Body1(th, i18n.Get(i18n.Entrypoint)).Layout(gtx) + return material.Body1(th, i18n.Entrypoint.Value()).Layout(gtx) }), layout.Rigid(func(gtx C) D { if err := func() error { @@ -316,7 +312,7 @@ func (p *tcpPage) layout(gtx C, th *material.Theme) D { return nil } if _, err := net.ResolveTCPAddr("tcp", addr); err != nil { - return fmt.Errorf(i18n.Get(i18n.ErrInvalidAddr)) + return fmt.Errorf(i18n.ErrInvalidAddr.Value()) } return nil }(); err != nil { @@ -325,7 +321,7 @@ func (p *tcpPage) layout(gtx C, th *material.Theme) D { p.entrypoint.ClearError() } - return p.entrypoint.Layout(gtx, th, i18n.Get(i18n.Address)) + return p.entrypoint.Layout(gtx, th, i18n.Address.Value()) }), layout.Rigid(layout.Spacer{Height: 8}.Layout), ) @@ -390,6 +386,7 @@ func (p *tcpPage) onoff() { tunnel.IDOption(opts.ID), tunnel.NameOption(opts.Name), tunnel.EndpointOption(opts.Endpoint), + tunnel.CreatedAtOption(opts.CreatedAt), ) } else { ep.Close() diff --git a/ui/page/entrypoint/udp/page.go b/ui/page/entrypoint/udp/page.go index 891a4d6..eda76f7 100644 --- a/ui/page/entrypoint/udp/page.go +++ b/ui/page/entrypoint/udp/page.go @@ -29,7 +29,6 @@ type D = layout.Dimensions type udpPage struct { router *page.Router - modal *component.ModalLayer btnBack widget.Clickable btnState widget.Clickable @@ -56,7 +55,6 @@ type udpPage struct { func NewPage(r *page.Router) page.Page { return &udpPage{ router: r, - modal: component.NewModal(), list: layout.List{ // NOTE: the list must be vertical Axis: layout.Vertical, @@ -85,7 +83,7 @@ func NewPage(r *page.Router) page.Page { }, }, delDialog: ui_widget.Dialog{ - Title: i18n.Get(i18n.DeleteEntrypoint), + Title: i18n.DeleteEntrypoint, }, } } @@ -104,6 +102,7 @@ func (p *udpPage) Init(opts ...page.PageOption) { p.edit = true } + p.tunnelID.Clear() p.name.Clear() p.entrypoint.Clear() @@ -144,18 +143,15 @@ func (p *udpPage) Layout(gtx C) D { p.delete() p.router.Back() } - p.modal.Disappear(gtx.Now) + p.router.HideModal(gtx) } - p.modal.Widget = func(gtx layout.Context, th *material.Theme, anim *component.VisibilityAnimation) layout.Dimensions { + p.router.ShowModal(gtx, func(gtx page.C, th *material.Theme) page.D { return p.delDialog.Layout(gtx, th) - } - p.modal.Appear(gtx.Now) + }) } th := p.router.Theme - defer p.modal.Layout(gtx, th) - return layout.Flex{ Axis: layout.Vertical, }.Layout(gtx, @@ -291,7 +287,7 @@ func (p *udpPage) layout(gtx C, th *material.Theme) D { Axis: layout.Vertical, }.Layout(gtx, layout.Rigid(func(gtx C) D { - return material.Body1(th, i18n.Get(i18n.TunnelID)).Layout(gtx) + return material.Body1(th, i18n.TunnelID.Value()).Layout(gtx) }), layout.Rigid(func(gtx C) D { if err := func() error { @@ -300,7 +296,7 @@ func (p *udpPage) layout(gtx C, th *material.Theme) D { return nil } if _, err := uuid.Parse(tid); err != nil { - return fmt.Errorf(i18n.Get(i18n.ErrInvalidTunnelID)) + return fmt.Errorf(i18n.ErrInvalidTunnelID.Value()) } return nil }(); err != nil { @@ -317,7 +313,7 @@ func (p *udpPage) layout(gtx C, th *material.Theme) D { layout.Rigid(layout.Spacer{Height: 16}.Layout), layout.Rigid(func(gtx C) D { - return material.Body1(th, i18n.Get(i18n.Name)).Layout(gtx) + return material.Body1(th, i18n.Name.Value()).Layout(gtx) }), layout.Rigid(func(gtx C) D { return p.name.Layout(gtx, th, "") @@ -325,7 +321,7 @@ func (p *udpPage) layout(gtx C, th *material.Theme) D { layout.Rigid(layout.Spacer{Height: 16}.Layout), layout.Rigid(func(gtx C) D { - return material.Body1(th, i18n.Get(i18n.Entrypoint)).Layout(gtx) + return material.Body1(th, i18n.Entrypoint.Value()).Layout(gtx) }), layout.Rigid(func(gtx C) D { if err := func() error { @@ -334,7 +330,7 @@ func (p *udpPage) layout(gtx C, th *material.Theme) D { return nil } if _, err := net.ResolveUDPAddr("udp", addr); err != nil { - return fmt.Errorf(i18n.Get(i18n.ErrInvalidAddr)) + return fmt.Errorf(i18n.ErrInvalidAddr.Value()) } return nil }(); err != nil { @@ -343,7 +339,7 @@ func (p *udpPage) layout(gtx C, th *material.Theme) D { p.entrypoint.ClearError() } - return p.entrypoint.Layout(gtx, th, i18n.Get(i18n.Address)) + return p.entrypoint.Layout(gtx, th, i18n.Address.Value()) }), layout.Rigid(layout.Spacer{Height: 8}.Layout), @@ -355,7 +351,7 @@ func (p *udpPage) layout(gtx C, th *material.Theme) D { return layout.Flex{ Spacing: layout.SpaceBetween, }.Layout(gtx, - layout.Flexed(1, material.Body1(th, i18n.Get(i18n.Keepalive)).Layout), + layout.Flexed(1, material.Body1(th, i18n.Keepalive.Value()).Layout), layout.Rigid(material.Switch(th, &p.keepalive, "keepalive").Layout), ) }) @@ -369,7 +365,7 @@ func (p *udpPage) layout(gtx C, th *material.Theme) D { if err := func() string { for _, r := range p.ttl.Text() { if !unicode.IsDigit(r) { - return i18n.Get(i18n.ErrDigitOnly) + return i18n.ErrDigitOnly.Value() } } return "" @@ -453,6 +449,7 @@ func (p *udpPage) onoff() { tunnel.EndpointOption(opts.Endpoint), tunnel.KeepaliveOption(opts.Keepalive), tunnel.TTLOption(opts.TTL), + tunnel.CreatedAtOption(opts.CreatedAt), ) } else { ep.Close() diff --git a/ui/page/home/list/entrypoint.go b/ui/page/home/list/entrypoint.go index b28684c..a841565 100644 --- a/ui/page/home/list/entrypoint.go +++ b/ui/page/home/list/entrypoint.go @@ -3,7 +3,9 @@ package list import ( "fmt" "image/color" + "strconv" "strings" + "time" "gioui.org/font" "gioui.org/layout" @@ -74,6 +76,8 @@ func (l *entrypointList) Layout(gtx C, th *material.Theme) D { }) } + stats := t.Stats() + return layout.Inset{ Top: 8, Bottom: 8, @@ -87,40 +91,122 @@ func (l *entrypointList) Layout(gtx C, th *material.Theme) D { }.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, + Axis: layout.Vertical, }.Layout(gtx, - layout.Flexed(1, func(gtx C) D { + layout.Rigid(func(gtx layout.Context) layout.Dimensions { return layout.Flex{ - Axis: layout.Vertical, + Alignment: layout.Middle, + Spacing: layout.SpaceBetween, }.Layout(gtx, - layout.Rigid(func(gtx C) D { - label := material.Body1(th, t.ID()) + 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{Height: 8}.Layout), - layout.Rigid(material.Body2(th, fmt.Sprintf("%s: %s", i18n.Get(i18n.Type), strings.ToUpper(t.Type()))).Layout), - layout.Rigid(layout.Spacer{Height: 8}.Layout), - layout.Rigid(material.Body2(th, fmt.Sprintf("%s: %s", i18n.Get(i18n.Name), t.Name())).Layout), - layout.Rigid(layout.Spacer{Height: 8}.Layout), - layout.Rigid(material.Body2(th, fmt.Sprintf("%s: %s", i18n.Get(i18n.Endpoint), t.Endpoint())).Layout), - layout.Rigid(layout.Spacer{Height: 8}.Layout), - layout.Rigid(material.Body2(th, fmt.Sprintf("%s: %s", i18n.Get(i18n.Entrypoint), t.Entrypoint())).Layout), + 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{Width: 8}.Layout), + 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 { - gtx.Constraints.Min.X = gtx.Dp(12) + 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 - c := colornames.GreenA700 - if t.Err() != nil { - c = colornames.Red600 - } - if t.IsClosed() { - c = colornames.Grey600 - } - return icons.IconCircle.Layout(gtx, color.NRGBA(c)) + 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) + }), + ) }), ) }) diff --git a/ui/page/home/list/tunnel.go b/ui/page/home/list/tunnel.go index da3b6ff..6971505 100644 --- a/ui/page/home/list/tunnel.go +++ b/ui/page/home/list/tunnel.go @@ -3,7 +3,10 @@ package list import ( "fmt" "image/color" + "math" + "strconv" "strings" + "time" "gioui.org/font" "gioui.org/layout" @@ -78,6 +81,8 @@ func (l *tunnelList) Layout(gtx C, th *material.Theme) D { }) } + stats := t.Stats() + return layout.Inset{ Top: 8, Bottom: 8, @@ -91,40 +96,122 @@ func (l *tunnelList) Layout(gtx C, th *material.Theme) D { }.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, + Axis: layout.Vertical, }.Layout(gtx, - layout.Flexed(1, func(gtx C) D { + layout.Rigid(func(gtx layout.Context) layout.Dimensions { return layout.Flex{ - Axis: layout.Vertical, + Alignment: layout.Middle, + Spacing: layout.SpaceBetween, }.Layout(gtx, - layout.Rigid(func(gtx C) D { - label := material.Body1(th, t.ID()) + 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{Height: 8}.Layout), - layout.Rigid(material.Body2(th, fmt.Sprintf("%s: %s", i18n.Get(i18n.Type), strings.ToUpper(t.Type()))).Layout), - layout.Rigid(layout.Spacer{Height: 8}.Layout), - layout.Rigid(material.Body2(th, fmt.Sprintf("%s: %s", i18n.Get(i18n.Name), t.Name())).Layout), - layout.Rigid(layout.Spacer{Height: 8}.Layout), - layout.Rigid(material.Body2(th, fmt.Sprintf("%s: %s", i18n.Get(i18n.Endpoint), t.Endpoint())).Layout), - layout.Rigid(layout.Spacer{Height: 8}.Layout), - layout.Rigid(material.Body2(th, fmt.Sprintf("%s: %s", i18n.Get(i18n.Entrypoint), t.Entrypoint())).Layout), + 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{Width: 8}.Layout), + 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 { - gtx.Constraints.Min.X = gtx.Dp(12) + 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 - c := colornames.GreenA700 - if t.Err() != nil { - c = colornames.Red600 - } - if t.IsClosed() { - c = colornames.Grey600 - } - return icons.IconCircle.Layout(gtx, color.NRGBA(c)) + 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) + }), + ) }), ) }) @@ -132,3 +219,46 @@ func (l *tunnelList) Layout(gtx C, th *material.Theme) D { }) }) } + +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 +} diff --git a/ui/page/router.go b/ui/page/router.go index abe4e8b..36cd505 100644 --- a/ui/page/router.go +++ b/ui/page/router.go @@ -5,11 +5,17 @@ import ( "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" ) +const ( + MaxWidth = 800 +) + type C = layout.Context type D = layout.Dimensions @@ -23,12 +29,14 @@ type Router struct { stack routeStack current Route *material.Theme + modal *component.ModalLayer } func NewRouter(th *material.Theme) *Router { r := &Router{ pages: make(map[PagePath]Page), Theme: th, + modal: component.NewModal(), } return r @@ -74,6 +82,8 @@ func (r *Router) Back() { func (r *Router) Layout(gtx C) D { r.Theme.Palette = theme.Current().Material + defer r.modal.Layout(gtx, r.Theme) + return layout.Background{}.Layout(gtx, func(gtx C) D { defer clip.Rect{ @@ -96,7 +106,7 @@ func (r *Router) Layout(gtx C) D { } inset := layout.Inset{} - width := unit.Dp(800) + width := unit.Dp(MaxWidth) if x := gtx.Metric.PxToDp(gtx.Constraints.Max.X); x > width { inset.Left = (x - width) / 2 inset.Right = inset.Left @@ -109,6 +119,25 @@ func (r *Router) Layout(gtx C) D { ) } +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 * 2 / 3 + + 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) +} + type routeStack struct { routes []Route } diff --git a/ui/page/settings/page.go b/ui/page/settings/page.go index db2d4cb..811f406 100644 --- a/ui/page/settings/page.go +++ b/ui/page/settings/page.go @@ -17,7 +17,6 @@ import ( type settingsPage struct { router *page.Router - modal *component.ModalLayer menu ui_widget.Menu list widget.List @@ -30,7 +29,6 @@ type settingsPage struct { func NewPage(r *page.Router) page.Page { return &settingsPage{ router: r, - modal: component.NewModal(), menu: ui_widget.Menu{ List: layout.List{ Axis: layout.Vertical, @@ -85,8 +83,6 @@ func (p *settingsPage) Layout(gtx layout.Context) layout.Dimensions { th := p.router.Theme - defer p.modal.Layout(gtx, th) - return layout.Flex{ Axis: layout.Vertical, }.Layout(gtx, @@ -109,7 +105,7 @@ func (p *settingsPage) Layout(gtx layout.Context) layout.Dimensions { }), layout.Rigid(layout.Spacer{Width: 8}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { - title := material.H6(th, i18n.Get(i18n.Settings)) + title := material.H6(th, i18n.Settings.Value()) return title.Layout(gtx) }), layout.Rigid(layout.Spacer{Width: 8}.Layout), @@ -198,7 +194,7 @@ func (p *settingsPage) showLangMenu(gtx layout.Context) { items[0].Selected = true } - p.menu.Title = i18n.Get(i18n.Language) + p.menu.Title = i18n.Language p.menu.Items = items p.menu.Selected = func(index int) { p.lang.Clear() @@ -206,7 +202,7 @@ func (p *settingsPage) showLangMenu(gtx layout.Context) { Name: p.menu.Items[index].Key, Value: p.menu.Items[index].Value, }) - p.modal.Disappear(gtx.Now) + p.router.HideModal(gtx) cfg := config.Get() if cfg.Settings == nil { @@ -220,10 +216,9 @@ func (p *settingsPage) showLangMenu(gtx layout.Context) { i18n.Set(cfg.Settings.Lang) } - p.modal.Widget = func(gtx layout.Context, th *material.Theme, anim *component.VisibilityAnimation) layout.Dimensions { + p.router.ShowModal(gtx, func(gtx page.C, th *material.Theme) page.D { return p.menu.Layout(gtx, th) - } - p.modal.Appear(gtx.Now) + }) } func (p *settingsPage) showThemeMenu(gtx layout.Context) { @@ -243,7 +238,7 @@ func (p *settingsPage) showThemeMenu(gtx layout.Context) { items[0].Selected = true } - p.menu.Title = i18n.Get(i18n.Theme) + p.menu.Title = i18n.Theme p.menu.Items = items p.menu.Selected = func(index int) { p.theme.Clear() @@ -251,7 +246,7 @@ func (p *settingsPage) showThemeMenu(gtx layout.Context) { Name: p.menu.Items[index].Key, Value: p.menu.Items[index].Value, }) - p.modal.Disappear(gtx.Now) + p.router.HideModal(gtx) cfg := config.Get() if cfg.Settings == nil { @@ -270,8 +265,7 @@ func (p *settingsPage) showThemeMenu(gtx layout.Context) { } } - p.modal.Widget = func(gtx layout.Context, th *material.Theme, anim *component.VisibilityAnimation) layout.Dimensions { + p.router.ShowModal(gtx, func(gtx page.C, th *material.Theme) page.D { return p.menu.Layout(gtx, th) - } - p.modal.Appear(gtx.Now) + }) } diff --git a/ui/page/tunnel/file/page.go b/ui/page/tunnel/file/page.go index 3a25651..4ffe4d7 100644 --- a/ui/page/tunnel/file/page.go +++ b/ui/page/tunnel/file/page.go @@ -7,6 +7,7 @@ import ( "io" "os" "strings" + "time" "gioui.org/font" "gioui.org/io/clipboard" @@ -29,7 +30,6 @@ type D = layout.Dimensions type filePage struct { router *page.Router - modal *component.ModalLayer btnBack widget.Clickable btnState widget.Clickable @@ -42,6 +42,8 @@ type filePage struct { wgID widget.Clickable wgEntrypoint widget.Clickable + lastCopyID time.Time + lastCopyEP time.Time name component.TextField endpoint component.TextField @@ -62,7 +64,6 @@ type filePage struct { func NewPage(r *page.Router) page.Page { return &filePage{ router: r, - modal: component.NewModal(), list: layout.List{ // NOTE: the list must be vertical Axis: layout.Vertical, @@ -88,7 +89,7 @@ func NewPage(r *page.Router) page.Page { }, }, delDialog: ui_widget.Dialog{ - Title: i18n.Get(i18n.DeleteTunnel), + Title: i18n.DeleteTunnel, }, } } @@ -149,18 +150,15 @@ func (p *filePage) Layout(gtx C) D { p.delete() p.router.Back() } - p.modal.Disappear(gtx.Now) + p.router.HideModal(gtx) } - p.modal.Widget = func(gtx layout.Context, th *material.Theme, anim *component.VisibilityAnimation) layout.Dimensions { + p.router.ShowModal(gtx, func(gtx layout.Context, th *material.Theme) layout.Dimensions { return p.delDialog.Layout(gtx, th) - } - p.modal.Appear(gtx.Now) + }) } th := p.router.Theme - defer p.modal.Layout(gtx, th) - return layout.Flex{ Axis: layout.Vertical, }.Layout(gtx, @@ -306,9 +304,9 @@ func (p *filePage) layout(gtx C, th *material.Theme) D { gtx.Source = src - copied := false if p.wgID.Clicked(gtx) { - copied = true + p.lastCopyEP = time.Time{} + p.lastCopyID = time.Now() gtx.Execute(clipboard.WriteCmd{ Data: io.NopCloser(bytes.NewBufferString(tun.ID())), }) @@ -329,11 +327,10 @@ func (p *filePage) layout(gtx C, th *material.Theme) D { }), layout.Rigid(layout.Spacer{Width: 8}.Layout), layout.Rigid(func(gtx C) D { - c := color.NRGBA(colornames.Blue500) - if copied { - c = color.NRGBA(colornames.Green500) + if time.Since(p.lastCopyID) < 3*time.Second { + return icons.IconDone.Layout(gtx, color.NRGBA(colornames.Green500)) } - return icons.IconCopy.Layout(gtx, c) + return icons.IconCopy.Layout(gtx, color.NRGBA(colornames.Blue500)) }), ) }) @@ -347,9 +344,9 @@ func (p *filePage) layout(gtx C, th *material.Theme) D { gtx.Source = src - copied := false if p.wgEntrypoint.Clicked(gtx) { - copied = true + p.lastCopyID = time.Time{} + p.lastCopyEP = time.Now() gtx.Execute(clipboard.WriteCmd{ Data: io.NopCloser(bytes.NewBufferString(tun.Entrypoint())), }) @@ -370,11 +367,10 @@ func (p *filePage) layout(gtx C, th *material.Theme) D { }), layout.Rigid(layout.Spacer{Width: 8}.Layout), layout.Rigid(func(gtx C) D { - c := color.NRGBA(colornames.Blue500) - if copied { - c = color.NRGBA(colornames.Green500) + if time.Since(p.lastCopyEP) < 3*time.Second { + return icons.IconDone.Layout(gtx, color.NRGBA(colornames.Green500)) } - return icons.IconCopy.Layout(gtx, c) + return icons.IconCopy.Layout(gtx, color.NRGBA(colornames.Blue500)) }), ) }) @@ -383,15 +379,14 @@ func (p *filePage) layout(gtx C, th *material.Theme) D { layout.Rigid(layout.Spacer{Height: 8}.Layout), layout.Rigid(func(gtx C) D { - return material.Body1(th, i18n.Get(i18n.Name)).Layout(gtx) + 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.Get(i18n.Endpoint)).Layout(gtx) - }), + + 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()) @@ -408,7 +403,7 @@ func (p *filePage) layout(gtx C, th *material.Theme) D { return err } if !fs.IsDir() { - return fmt.Errorf("%s %s", dir, i18n.Get(i18n.ErrDirectory)) + return fmt.Errorf("%s %s", dir, i18n.ErrDirectory.Value()) } return nil }(); err != nil { @@ -417,7 +412,7 @@ func (p *filePage) layout(gtx C, th *material.Theme) D { p.endpoint.ClearError() } - return p.endpoint.Layout(gtx, th, i18n.Get(i18n.DirectoryPath)) + return p.endpoint.Layout(gtx, th, i18n.DirectoryPath.Value()) }), layout.Rigid(layout.Spacer{Height: 16}.Layout), @@ -425,7 +420,7 @@ func (p *filePage) layout(gtx C, th *material.Theme) D { return layout.Flex{ Spacing: layout.SpaceBetween, }.Layout(gtx, - layout.Flexed(1, material.Body1(th, i18n.Get(i18n.BasicAuth)).Layout), + layout.Flexed(1, material.Body1(th, i18n.BasicAuth.Value()).Layout), layout.Rigid(material.Switch(th, &p.basicAuth, "basic auth").Layout), ) }), @@ -436,7 +431,7 @@ func (p *filePage) layout(gtx C, th *material.Theme) D { p.username.Clear() return D{} } - return p.username.Layout(gtx, th, i18n.Get(i18n.Username)) + return p.username.Layout(gtx, th, i18n.Username.Value()) }), layout.Rigid(func(gtx C) D { if !p.basicAuth.Value { @@ -472,7 +467,7 @@ func (p *filePage) layout(gtx C, th *material.Theme) D { return layout.Inset{ Bottom: 8, }.Layout(gtx, func(gtx layout.Context) layout.Dimensions { - return p.password.Layout(gtx, th, i18n.Get(i18n.Password)) + return p.password.Layout(gtx, th, i18n.Password.Value()) }) }), layout.Rigid(layout.Spacer{Height: 8}.Layout), @@ -553,6 +548,7 @@ func (p *filePage) onoff() { tunnel.EndpointOption(opts.Endpoint), tunnel.UsernameOption(opts.Username), tunnel.PasswordOption(opts.Password), + tunnel.CreatedAtOption(opts.CreatedAt), ) } else { tun.Close() diff --git a/ui/page/tunnel/http/page.go b/ui/page/tunnel/http/page.go index 2b41703..42432a8 100644 --- a/ui/page/tunnel/http/page.go +++ b/ui/page/tunnel/http/page.go @@ -7,6 +7,7 @@ import ( "io" "net" "strings" + "time" "gioui.org/font" "gioui.org/io/clipboard" @@ -29,7 +30,6 @@ type D = layout.Dimensions type httpPage struct { router *page.Router - modal *component.ModalLayer btnBack widget.Clickable btnState widget.Clickable @@ -42,6 +42,8 @@ type httpPage struct { wgID widget.Clickable wgEntrypoint widget.Clickable + lastCopyID time.Time + lastCopyEP time.Time name component.TextField endpoint component.TextField @@ -67,7 +69,6 @@ type httpPage struct { func NewPage(r *page.Router) page.Page { return &httpPage{ router: r, - modal: component.NewModal(), list: layout.List{ // NOTE: the list must be vertical Axis: layout.Vertical, @@ -98,7 +99,7 @@ func NewPage(r *page.Router) page.Page { }, }, delDialog: ui_widget.Dialog{ - Title: i18n.Get(i18n.DeleteTunnel), + Title: i18n.DeleteTunnel, }, } } @@ -168,18 +169,15 @@ func (p *httpPage) Layout(gtx C) D { p.delete() p.router.Back() } - p.modal.Disappear(gtx.Now) + p.router.HideModal(gtx) } - p.modal.Widget = func(gtx layout.Context, th *material.Theme, anim *component.VisibilityAnimation) layout.Dimensions { + p.router.ShowModal(gtx, func(gtx page.C, th *material.Theme) page.D { return p.delDialog.Layout(gtx, th) - } - p.modal.Appear(gtx.Now) + }) } th := p.router.Theme - defer p.modal.Layout(gtx, th) - return layout.Flex{ Axis: layout.Vertical, }.Layout(gtx, @@ -325,9 +323,9 @@ func (p *httpPage) layout(gtx C, th *material.Theme) D { gtx.Source = src - copied := false if p.wgID.Clicked(gtx) { - copied = true + p.lastCopyEP = time.Time{} + p.lastCopyID = time.Now() gtx.Execute(clipboard.WriteCmd{ Data: io.NopCloser(bytes.NewBufferString(tun.ID())), }) @@ -348,11 +346,10 @@ func (p *httpPage) layout(gtx C, th *material.Theme) D { }), layout.Rigid(layout.Spacer{Width: 8}.Layout), layout.Rigid(func(gtx C) D { - c := color.NRGBA(colornames.Blue500) - if copied { - c = color.NRGBA(colornames.Green500) + if time.Since(p.lastCopyID) < 3*time.Second { + return icons.IconDone.Layout(gtx, color.NRGBA(colornames.Green500)) } - return icons.IconCopy.Layout(gtx, c) + return icons.IconCopy.Layout(gtx, color.NRGBA(colornames.Blue500)) }), ) }) @@ -366,9 +363,9 @@ func (p *httpPage) layout(gtx C, th *material.Theme) D { gtx.Source = src - copied := false if p.wgEntrypoint.Clicked(gtx) { - copied = true + p.lastCopyID = time.Time{} + p.lastCopyEP = time.Now() gtx.Execute(clipboard.WriteCmd{ Data: io.NopCloser(bytes.NewBufferString(tun.Entrypoint())), }) @@ -389,11 +386,10 @@ func (p *httpPage) layout(gtx C, th *material.Theme) D { }), layout.Rigid(layout.Spacer{Width: 8}.Layout), layout.Rigid(func(gtx C) D { - c := color.NRGBA(colornames.Blue500) - if copied { - c = color.NRGBA(colornames.Green500) + if time.Since(p.lastCopyEP) < 3*time.Second { + return icons.IconDone.Layout(gtx, color.NRGBA(colornames.Green500)) } - return icons.IconCopy.Layout(gtx, c) + return icons.IconCopy.Layout(gtx, color.NRGBA(colornames.Blue500)) }), ) }) @@ -402,14 +398,14 @@ func (p *httpPage) layout(gtx C, th *material.Theme) D { layout.Rigid(layout.Spacer{Height: 8}.Layout), layout.Rigid(func(gtx C) D { - return material.Body1(th, i18n.Get(i18n.Name)).Layout(gtx) + 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.Get(i18n.Endpoint)).Layout(gtx) + return material.Body1(th, i18n.Endpoint.Value()).Layout(gtx) }), layout.Rigid(func(gtx C) D { if err := func() error { @@ -418,7 +414,7 @@ func (p *httpPage) layout(gtx C, th *material.Theme) D { return nil } if _, err := net.ResolveTCPAddr("tcp", addr); err != nil { - return fmt.Errorf(i18n.Get(i18n.ErrInvalidAddr)) + return fmt.Errorf(i18n.ErrInvalidAddr.Value()) } return nil }(); err != nil { @@ -427,7 +423,7 @@ func (p *httpPage) layout(gtx C, th *material.Theme) D { p.endpoint.ClearError() } - return p.endpoint.Layout(gtx, th, i18n.Get(i18n.Address)) + return p.endpoint.Layout(gtx, th, i18n.Address.Value()) }), layout.Rigid(layout.Spacer{Height: 8}.Layout), @@ -439,7 +435,7 @@ func (p *httpPage) layout(gtx C, th *material.Theme) D { return layout.Flex{ Spacing: layout.SpaceBetween, }.Layout(gtx, - layout.Flexed(1, material.Body1(th, i18n.Get(i18n.BasicAuth)).Layout), + layout.Flexed(1, material.Body1(th, i18n.BasicAuth.Value()).Layout), layout.Rigid(material.Switch(th, &p.basicAuth, "Basic auth").Layout), ) }) @@ -450,7 +446,7 @@ func (p *httpPage) layout(gtx C, th *material.Theme) D { p.username.Clear() return D{} } - return p.username.Layout(gtx, th, i18n.Get(i18n.Username)) + return p.username.Layout(gtx, th, i18n.Username.Value()) }), layout.Rigid(func(gtx C) D { if !p.basicAuth.Value { @@ -486,7 +482,7 @@ func (p *httpPage) layout(gtx C, th *material.Theme) D { return layout.Inset{ Bottom: 8, }.Layout(gtx, func(gtx layout.Context) layout.Dimensions { - return p.password.Layout(gtx, th, i18n.Get(i18n.Password)) + return p.password.Layout(gtx, th, i18n.Password.Value()) }) }), @@ -498,7 +494,7 @@ func (p *httpPage) layout(gtx C, th *material.Theme) D { return layout.Flex{ Spacing: layout.SpaceBetween, }.Layout(gtx, - layout.Flexed(1, material.Body1(th, i18n.Get(i18n.CustomHostname)).Layout), + layout.Flexed(1, material.Body1(th, i18n.CustomHostname.Value()).Layout), layout.Rigid(material.Switch(th, &p.rewriteHost, "Hostname").Layout), ) }) @@ -512,7 +508,7 @@ func (p *httpPage) layout(gtx C, th *material.Theme) D { return layout.Inset{ Bottom: 8, }.Layout(gtx, func(gtx layout.Context) layout.Dimensions { - return p.hostname.Layout(gtx, th, i18n.Get(i18n.Hostname)) + return p.hostname.Layout(gtx, th, i18n.Hostname.Value()) }) }), @@ -521,7 +517,7 @@ func (p *httpPage) layout(gtx C, th *material.Theme) D { return layout.Flex{ Spacing: layout.SpaceBetween, }.Layout(gtx, - layout.Flexed(1, material.Body1(th, i18n.Get(i18n.EnableTLS)).Layout), + layout.Flexed(1, material.Body1(th, i18n.EnableTLS.Value()).Layout), layout.Rigid(material.Switch(th, &p.enableTLS, "enable TLS").Layout), ) }) @@ -618,6 +614,7 @@ func (p *httpPage) onoff() { tunnel.PasswordOption(opts.Password), tunnel.HostnameOption(opts.Hostname), tunnel.EnableTLSOption(opts.EnableTLS), + tunnel.CreatedAtOption(opts.CreatedAt), ) } else { tun.Close() diff --git a/ui/page/tunnel/page.go b/ui/page/tunnel/page.go index a97ed94..c80ab8e 100644 --- a/ui/page/tunnel/page.go +++ b/ui/page/tunnel/page.go @@ -96,7 +96,7 @@ func (p *tunnelPage) Layout(gtx C) D { }), layout.Rigid(layout.Spacer{Width: 8}.Layout), layout.Rigid(func(gtx C) D { - title := material.H6(th, i18n.Get(i18n.Tunnel)) + title := material.H6(th, i18n.Tunnel.Value()) // title.Font.Weight = font.Bold return title.Layout(gtx) }), diff --git a/ui/page/tunnel/tcp/page.go b/ui/page/tunnel/tcp/page.go index c0a24c1..fc0d30b 100644 --- a/ui/page/tunnel/tcp/page.go +++ b/ui/page/tunnel/tcp/page.go @@ -7,6 +7,7 @@ import ( "io" "net" "strings" + "time" "gioui.org/font" "gioui.org/io/clipboard" @@ -29,7 +30,6 @@ type D = layout.Dimensions type tcpPage struct { router *page.Router - modal *component.ModalLayer btnBack widget.Clickable btnState widget.Clickable @@ -42,6 +42,8 @@ type tcpPage struct { wgID widget.Clickable wgEntrypoint widget.Clickable + lastCopyID time.Time + lastCopyEP time.Time name component.TextField endpoint component.TextField @@ -55,7 +57,6 @@ type tcpPage struct { func NewPage(r *page.Router) page.Page { return &tcpPage{ router: r, - modal: component.NewModal(), list: layout.List{ // NOTE: the list must be vertical Axis: layout.Vertical, @@ -71,7 +72,7 @@ func NewPage(r *page.Router) page.Page { }, }, delDialog: ui_widget.Dialog{ - Title: i18n.Get(i18n.DeleteTunnel), + Title: i18n.DeleteTunnel, }, } } @@ -123,18 +124,15 @@ func (p *tcpPage) Layout(gtx C) D { p.delete() p.router.Back() } - p.modal.Disappear(gtx.Now) + p.router.HideModal(gtx) } - p.modal.Widget = func(gtx layout.Context, th *material.Theme, anim *component.VisibilityAnimation) layout.Dimensions { + p.router.ShowModal(gtx, func(gtx page.C, th *material.Theme) page.D { return p.delDialog.Layout(gtx, th) - } - p.modal.Appear(gtx.Now) + }) } th := p.router.Theme - defer p.modal.Layout(gtx, th) - return layout.Flex{ Axis: layout.Vertical, }.Layout(gtx, @@ -280,9 +278,9 @@ func (p *tcpPage) layout(gtx C, th *material.Theme) D { gtx.Source = src - copied := false if p.wgID.Clicked(gtx) { - copied = true + p.lastCopyEP = time.Time{} + p.lastCopyID = time.Now() gtx.Execute(clipboard.WriteCmd{ Data: io.NopCloser(bytes.NewBufferString(tun.ID())), }) @@ -303,11 +301,10 @@ func (p *tcpPage) layout(gtx C, th *material.Theme) D { }), layout.Rigid(layout.Spacer{Width: 8}.Layout), layout.Rigid(func(gtx C) D { - c := color.NRGBA(colornames.Blue500) - if copied { - c = color.NRGBA(colornames.Green500) + if time.Since(p.lastCopyID) < 3*time.Second { + return icons.IconDone.Layout(gtx, color.NRGBA(colornames.Green500)) } - return icons.IconCopy.Layout(gtx, c) + return icons.IconCopy.Layout(gtx, color.NRGBA(colornames.Blue500)) }), ) }) @@ -321,9 +318,9 @@ func (p *tcpPage) layout(gtx C, th *material.Theme) D { gtx.Source = src - copied := false if p.wgEntrypoint.Clicked(gtx) { - copied = true + p.lastCopyID = time.Time{} + p.lastCopyEP = time.Now() gtx.Execute(clipboard.WriteCmd{ Data: io.NopCloser(bytes.NewBufferString(tun.Entrypoint())), }) @@ -344,11 +341,10 @@ func (p *tcpPage) layout(gtx C, th *material.Theme) D { }), layout.Rigid(layout.Spacer{Width: 8}.Layout), layout.Rigid(func(gtx C) D { - c := color.NRGBA(colornames.Blue500) - if copied { - c = color.NRGBA(colornames.Green500) + if time.Since(p.lastCopyEP) < 3*time.Second { + return icons.IconDone.Layout(gtx, color.NRGBA(colornames.Green500)) } - return icons.IconCopy.Layout(gtx, c) + return icons.IconCopy.Layout(gtx, color.NRGBA(colornames.Blue500)) }), ) }) @@ -357,14 +353,14 @@ func (p *tcpPage) layout(gtx C, th *material.Theme) D { layout.Rigid(layout.Spacer{Height: 8}.Layout), layout.Rigid(func(gtx C) D { - return material.Body1(th, i18n.Get(i18n.Name)).Layout(gtx) + 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.Get(i18n.Endpoint)).Layout(gtx) + return material.Body1(th, i18n.Endpoint.Value()).Layout(gtx) }), layout.Rigid(func(gtx C) D { if err := func() error { @@ -373,7 +369,7 @@ func (p *tcpPage) layout(gtx C, th *material.Theme) D { return nil } if _, err := net.ResolveTCPAddr("tcp", addr); err != nil { - return fmt.Errorf(i18n.Get(i18n.ErrInvalidAddr)) + return fmt.Errorf(i18n.ErrInvalidAddr.Value()) } return nil }(); err != nil { @@ -382,7 +378,7 @@ func (p *tcpPage) layout(gtx C, th *material.Theme) D { p.endpoint.ClearError() } - return p.endpoint.Layout(gtx, th, i18n.Get(i18n.Address)) + return p.endpoint.Layout(gtx, th, i18n.Address.Value()) }), layout.Rigid(layout.Spacer{Height: 8}.Layout), ) @@ -446,6 +442,7 @@ func (p *tcpPage) onoff() { tunnel.NameOption(opts.Name), tunnel.IDOption(opts.ID), tunnel.EndpointOption(opts.Endpoint), + tunnel.CreatedAtOption(opts.CreatedAt), ) } else { tun.Close() diff --git a/ui/page/tunnel/udp/page.go b/ui/page/tunnel/udp/page.go index b6cfd81..1b4bf9b 100644 --- a/ui/page/tunnel/udp/page.go +++ b/ui/page/tunnel/udp/page.go @@ -7,6 +7,7 @@ import ( "io" "net" "strings" + "time" "gioui.org/font" "gioui.org/io/clipboard" @@ -29,7 +30,6 @@ type D = layout.Dimensions type udpPage struct { router *page.Router - modal *component.ModalLayer btnBack widget.Clickable btnState widget.Clickable @@ -42,6 +42,8 @@ type udpPage struct { wgID widget.Clickable wgEntrypoint widget.Clickable + lastCopyID time.Time + lastCopyEP time.Time name component.TextField endpoint component.TextField @@ -55,7 +57,6 @@ type udpPage struct { func NewPage(r *page.Router) page.Page { return &udpPage{ router: r, - modal: component.NewModal(), list: layout.List{ // NOTE: the list must be vertical Axis: layout.Vertical, @@ -71,7 +72,7 @@ func NewPage(r *page.Router) page.Page { }, }, delDialog: ui_widget.Dialog{ - Title: i18n.Get(i18n.DeleteTunnel), + Title: i18n.DeleteTunnel, }, } } @@ -123,18 +124,15 @@ func (p *udpPage) Layout(gtx C) D { p.delete() p.router.Back() } - p.modal.Disappear(gtx.Now) + p.router.HideModal(gtx) } - p.modal.Widget = func(gtx layout.Context, th *material.Theme, anim *component.VisibilityAnimation) layout.Dimensions { + p.router.ShowModal(gtx, func(gtx page.C, th *material.Theme) page.D { return p.delDialog.Layout(gtx, th) - } - p.modal.Appear(gtx.Now) + }) } th := p.router.Theme - defer p.modal.Layout(gtx, th) - return layout.Flex{ Axis: layout.Vertical, }.Layout(gtx, @@ -280,9 +278,9 @@ func (p *udpPage) layout(gtx C, th *material.Theme) D { gtx.Source = src - copied := false if p.wgID.Clicked(gtx) { - copied = true + p.lastCopyEP = time.Time{} + p.lastCopyID = time.Now() gtx.Execute(clipboard.WriteCmd{ Data: io.NopCloser(bytes.NewBufferString(tun.ID())), }) @@ -303,11 +301,10 @@ func (p *udpPage) layout(gtx C, th *material.Theme) D { }), layout.Rigid(layout.Spacer{Width: 8}.Layout), layout.Rigid(func(gtx C) D { - c := color.NRGBA(colornames.Blue500) - if copied { - c = color.NRGBA(colornames.Green500) + if time.Since(p.lastCopyID) < 3*time.Second { + return icons.IconDone.Layout(gtx, color.NRGBA(colornames.Green500)) } - return icons.IconCopy.Layout(gtx, c) + return icons.IconCopy.Layout(gtx, color.NRGBA(colornames.Blue500)) }), ) }) @@ -321,9 +318,9 @@ func (p *udpPage) layout(gtx C, th *material.Theme) D { gtx.Source = src - copied := false if p.wgEntrypoint.Clicked(gtx) { - copied = true + p.lastCopyID = time.Time{} + p.lastCopyEP = time.Now() gtx.Execute(clipboard.WriteCmd{ Data: io.NopCloser(bytes.NewBufferString(tun.Entrypoint())), }) @@ -344,11 +341,10 @@ func (p *udpPage) layout(gtx C, th *material.Theme) D { }), layout.Rigid(layout.Spacer{Width: 8}.Layout), layout.Rigid(func(gtx C) D { - c := color.NRGBA(colornames.Blue500) - if copied { - c = color.NRGBA(colornames.Green500) + if time.Since(p.lastCopyEP) < 3*time.Second { + return icons.IconDone.Layout(gtx, color.NRGBA(colornames.Green500)) } - return icons.IconCopy.Layout(gtx, c) + return icons.IconCopy.Layout(gtx, color.NRGBA(colornames.Blue500)) }), ) }) @@ -357,14 +353,14 @@ func (p *udpPage) layout(gtx C, th *material.Theme) D { layout.Rigid(layout.Spacer{Height: 8}.Layout), layout.Rigid(func(gtx C) D { - return material.Body1(th, i18n.Get(i18n.Name)).Layout(gtx) + 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.Get(i18n.Endpoint)).Layout(gtx) + return material.Body1(th, i18n.Endpoint.Value()).Layout(gtx) }), layout.Rigid(func(gtx C) D { if err := func() error { @@ -373,7 +369,7 @@ func (p *udpPage) layout(gtx C, th *material.Theme) D { return nil } if _, err := net.ResolveUDPAddr("udp", addr); err != nil { - return fmt.Errorf(i18n.Get(i18n.ErrInvalidAddr)) + return fmt.Errorf(i18n.ErrInvalidAddr.Value()) } return nil }(); err != nil { @@ -382,7 +378,7 @@ func (p *udpPage) layout(gtx C, th *material.Theme) D { p.endpoint.ClearError() } - return p.endpoint.Layout(gtx, th, i18n.Get(i18n.Address)) + return p.endpoint.Layout(gtx, th, i18n.Address.Value()) }), layout.Rigid(layout.Spacer{Height: 8}.Layout), ) @@ -446,6 +442,7 @@ func (p *udpPage) onoff() { tunnel.NameOption(opts.Name), tunnel.IDOption(opts.ID), tunnel.EndpointOption(opts.Endpoint), + tunnel.CreatedAtOption(opts.CreatedAt), ) } else { tun.Close() diff --git a/ui/theme/theme.go b/ui/theme/theme.go index 6b5d3fe..38f7fdc 100644 --- a/ui/theme/theme.go +++ b/ui/theme/theme.go @@ -51,7 +51,7 @@ var ( Material: material.Palette{ Fg: color.NRGBA(colornames.White), Bg: color.NRGBA(colornames.Grey900), - ContrastBg: color.NRGBA(colornames.LightBlueA400), + ContrastBg: color.NRGBA(colornames.Green500), ContrastFg: color.NRGBA(colornames.White), }, ContentSurfaceBg: color.NRGBA(colornames.Grey800), diff --git a/ui/ui.go b/ui/ui.go index 603c3e1..913dcd9 100644 --- a/ui/ui.go +++ b/ui/ui.go @@ -1,11 +1,11 @@ package ui import ( - "gioui.org/font/gofont" "gioui.org/layout" "gioui.org/text" "gioui.org/widget/material" "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" @@ -40,7 +40,8 @@ func NewUI() *UI { } th := material.NewTheme() - th.Shaper = text.NewShaper(text.WithCollection(gofont.Collection())) + // th.Shaper = text.NewShaper(text.WithCollection(gofont.Collection())) + th.Shaper = text.NewShaper(text.WithCollection(fonts.Collection())) th.Palette = theme.Current().Material router := page.NewRouter(th) diff --git a/ui/widget/dialog.go b/ui/widget/dialog.go index d288317..e8bf185 100644 --- a/ui/widget/dialog.go +++ b/ui/widget/dialog.go @@ -9,7 +9,7 @@ import ( ) type Dialog struct { - Title string + Title i18n.Key Body string Widget func(gtx layout.Context, th *material.Theme) layout.Dimensions Clicked func(ok bool) @@ -18,132 +18,124 @@ type Dialog struct { } func (p *Dialog) Layout(gtx layout.Context, th *material.Theme) layout.Dimensions { - var cl widget.Clickable - return cl.Layout(gtx, func(gtx layout.Context) 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, + 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 component.SurfaceStyle{ - Theme: th, - ShadowStyle: component.ShadowStyle{ - CornerRadius: 28, - }, + return layout.Inset{ + Top: 16, + Bottom: 16, + Left: 24, + Right: 24, }.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) - }) - }), + 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.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(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(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) - } + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return layout.Flex{ + Spacing: layout.SpaceBetween, + Alignment: layout.Middle, + }.Layout(gtx, + layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { + return layout.Spacer{Width: 8}.Layout(gtx) + }), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + if p.btnCancel.Clicked(gtx) && p.Clicked != nil { + p.Clicked(false) + } - return material.ButtonLayoutStyle{ - Background: th.Bg, - CornerRadius: 20, - Button: &p.btnCancel, + 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 { - return layout.Inset{ - Top: 8, - Bottom: 8, - Left: 24, - Right: 24, - }.Layout(gtx, func(gtx layout.Context) layout.Dimensions { - label := material.Body1(th, i18n.Get(i18n.Cancel)) - label.Color = th.Fg - return label.Layout(gtx) - }) - + 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: 20, - Button: &p.btnOK, + }) + }), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return layout.Spacer{Width: 8}.Layout(gtx) + }), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + if p.btnOK.Clicked(gtx) && p.Clicked != nil { + p.Clicked(true) + } + + return material.ButtonLayoutStyle{ + Background: th.Bg, + CornerRadius: 20, + Button: &p.btnOK, + }.Layout(gtx, func(gtx layout.Context) layout.Dimensions { + return layout.Inset{ + Top: 8, + Bottom: 8, + Left: 24, + Right: 24, }.Layout(gtx, func(gtx layout.Context) layout.Dimensions { - return layout.Inset{ - Top: 8, - Bottom: 8, - Left: 24, - Right: 24, - }.Layout(gtx, func(gtx layout.Context) layout.Dimensions { - label := material.Body1(th, i18n.Get(i18n.OK)) - label.Color = th.Fg - return label.Layout(gtx) - }) - + label := material.Body1(th, i18n.OK.Value()) + label.Color = th.Fg + return label.Layout(gtx) }) - }), - ) - }), - ) - }) + + }) + }), + ) + }), + ) }) }) }) diff --git a/ui/widget/menu.go b/ui/widget/menu.go index 5c31a00..890a286 100644 --- a/ui/widget/menu.go +++ b/ui/widget/menu.go @@ -12,7 +12,7 @@ import ( type Menu struct { List layout.List Items []MenuItem - Title string + Title i18n.Key Selected func(index int) btnAdd widget.Clickable ShowAdd bool @@ -20,79 +20,72 @@ type Menu struct { } func (p *Menu) Layout(gtx layout.Context, th *material.Theme) layout.Dimensions { - var cl widget.Clickable - return cl.Layout(gtx, func(gtx layout.Context) 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, + 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 component.SurfaceStyle{ - Theme: th, - ShadowStyle: component.ShadowStyle{ - CornerRadius: 28, - }, + return layout.Inset{ + Top: 16, + Bottom: 16, }.Layout(gtx, func(gtx layout.Context) layout.Dimensions { - return layout.Inset{ - Top: 16, - Bottom: 16, - }.Layout(gtx, func(gtx layout.Context) layout.Dimensions { - return layout.Flex{ - Axis: layout.Vertical, - }.Layout(gtx, - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - return layout.Inset{ - Top: 8, - Bottom: 8, - Left: 24, - Right: 24, - }.Layout(gtx, func(gtx layout.Context) layout.Dimensions { - return layout.Flex{ - Alignment: layout.Middle, - }.Layout(gtx, - layout.Flexed(1, material.H6(th, p.Title).Layout), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - if !p.ShowAdd { - return layout.Dimensions{} - } - btn := material.IconButton(th, &p.btnAdd, icons.IconAdd, "Add") - btn.Background = th.Bg - btn.Color = th.Fg - btn.Inset = layout.UniformInset(0) - return btn.Layout(gtx) - }), - ) - }) - }), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - return layout.Inset{ - Top: 8, - Bottom: 8, - }.Layout(gtx, func(gtx layout.Context) layout.Dimensions { - return p.List.Layout(gtx, len(p.Items), func(gtx layout.Context, index int) layout.Dimensions { - if p.Items[index].state.Clicked(gtx) { - if p.Multiple { - p.Items[index].Selected = !p.Items[index].Selected - } else { - for i := range p.Items { - p.Items[i].Selected = false - } - p.Items[index].Selected = true - } - if p.Selected != nil { - p.Selected(index) - } + return 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{} } - return p.Items[index].Layout(gtx, th) - }) + 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) }) - }), - ) - }) + }) + }), + ) }) }) }) diff --git a/ui/widget/metadata.go b/ui/widget/metadata.go deleted file mode 100644 index 33d2e81..0000000 --- a/ui/widget/metadata.go +++ /dev/null @@ -1,212 +0,0 @@ -package widget - -import ( - "strings" - - "gioui.org/layout" - "gioui.org/widget" - "gioui.org/widget/material" - "gioui.org/x/component" - "github.com/go-gost/gost.plus/ui/icons" -) - -type MetadataDialog struct { - Surface component.SurfaceStyle - List material.ListStyle - Clicked func(ok bool) - btnAdd widget.Clickable - btnCancel widget.Clickable - btnOK widget.Clickable - metadata []*kv -} - -func (p *MetadataDialog) Layout(gtx layout.Context, th *material.Theme) layout.Dimensions { - if p.btnAdd.Clicked(gtx) { - p.metadata = append(p.metadata, &kv{}) - } - - var cl widget.Clickable - return cl.Layout(gtx, func(gtx layout.Context) 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 { - return layout.Inset{ - Top: 8, - Bottom: 8, - }.Layout(gtx, func(gtx layout.Context) layout.Dimensions { - return layout.Flex{ - Alignment: layout.Middle, - }.Layout(gtx, - layout.Flexed(1, material.H6(th, "Metadata").Layout), - layout.Rigid(layout.Spacer{Width: 8}.Layout), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - btn := material.IconButton(th, &p.btnAdd, icons.IconAdd, "Add") - btn.Background = th.Bg - btn.Color = th.Fg - return btn.Layout(gtx) - }), - ) - }) - }), - - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - for i := range p.metadata { - if p.metadata[i].remove.Clicked(gtx) { - p.metadata = append(p.metadata[:i], p.metadata[i+1:]...) - break - } - } - - gtx.Constraints.Max.Y -= gtx.Dp(80) - return layout.Inset{ - Top: 8, - Bottom: 8, - }.Layout(gtx, func(gtx layout.Context) layout.Dimensions { - return p.List.Layout(gtx, len(p.metadata), func(gtx layout.Context, index int) layout.Dimensions { - return p.metadata[index].Layout(gtx, th) - }) - }) - }), - - layout.Rigid(layout.Spacer{Height: 8}.Layout), - - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - return layout.Flex{ - Spacing: layout.SpaceBetween, - Alignment: layout.Middle, - }.Layout(gtx, - layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { - return layout.Spacer{Width: 8}.Layout(gtx) - }), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - if p.btnCancel.Clicked(gtx) && p.Clicked != nil { - p.Clicked(false) - } - - return material.ButtonLayoutStyle{ - Background: th.Bg, - CornerRadius: 20, - Button: &p.btnCancel, - }.Layout(gtx, func(gtx layout.Context) layout.Dimensions { - return layout.Inset{ - Top: 8, - Bottom: 8, - Left: 24, - Right: 24, - }.Layout(gtx, func(gtx layout.Context) layout.Dimensions { - label := material.Body1(th, "Cancel") - label.Color = th.Fg - return label.Layout(gtx) - }) - - }) - }), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - return layout.Spacer{Width: 8}.Layout(gtx) - }), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - if p.btnOK.Clicked(gtx) && p.Clicked != nil { - p.Clicked(true) - } - - return material.ButtonLayoutStyle{ - Background: th.Bg, - CornerRadius: 20, - Button: &p.btnOK, - }.Layout(gtx, func(gtx layout.Context) layout.Dimensions { - return layout.Inset{ - Top: 8, - Bottom: 8, - Left: 24, - Right: 24, - }.Layout(gtx, func(gtx layout.Context) layout.Dimensions { - label := material.Body1(th, "OK") - label.Color = th.Fg - return label.Layout(gtx) - }) - - }) - }), - ) - }), - ) - }) - }) - }) - }) - }) - -} - -func (p *MetadataDialog) Add(k, v string) { - kv := &kv{} - kv.k.SetText(k) - kv.v.SetText(v) - p.metadata = append(p.metadata, kv) -} - -func (p *MetadataDialog) Clear() { - p.metadata = nil -} - -func (p *MetadataDialog) Metadata() []*kv { - return p.metadata -} - -type kv struct { - k component.TextField - v component.TextField - remove widget.Clickable -} - -func (p *kv) Get() (string, string) { - return strings.TrimSpace(p.k.Text()), strings.TrimSpace(p.v.Text()) -} - -func (p *kv) Set(k, v string) { - p.k.SetText(k) - p.v.SetText(v) -} - -func (p *kv) Layout(gtx layout.Context, th *material.Theme) layout.Dimensions { - return layout.Inset{ - Top: 4, - Bottom: 4, - }.Layout(gtx, func(gtx layout.Context) layout.Dimensions { - return layout.Flex{ - Alignment: layout.End, - }.Layout(gtx, - layout.Flexed(0.4, func(gtx layout.Context) layout.Dimensions { - return p.k.Layout(gtx, th, "Key") - }), - layout.Rigid(layout.Spacer{Width: 8}.Layout), - layout.Flexed(0.4, func(gtx layout.Context) layout.Dimensions { - return p.v.Layout(gtx, th, "Value") - }), - layout.Rigid(layout.Spacer{Width: 8}.Layout), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - btn := material.IconButton(th, &p.remove, icons.IconDelete, "Remove") - btn.Color = th.Fg - btn.Background = th.Bg - return btn.Layout(gtx) - }), - ) - }) -}