From 04725fef54fcf4e045759bc0a62b49fea1128cbc Mon Sep 17 00:00:00 2001 From: spiritlhl <103393591+spiritLHLS@users.noreply.github.com> Date: Thu, 16 Apr 2026 12:13:06 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0=E7=B1=BBGUI=E6=98=BE?= =?UTF-8?q?=E7=A4=BA=EF=BC=8C=E5=A2=9E=E5=BC=BA=E5=8F=82=E6=95=B0=E8=A7=A3?= =?UTF-8?q?=E6=9E=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- go.mod | 14 + go.sum | 31 ++ goecs.go | 2 +- internal/menu/menu.go | 92 ++--- internal/menu/tui.go | 762 ++++++++++++++++++++++++++++++++++++++ internal/params/params.go | 51 ++- 6 files changed, 896 insertions(+), 56 deletions(-) create mode 100644 internal/menu/tui.go diff --git a/go.mod b/go.mod index 78e69224..f2f3017c 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,8 @@ module github.com/oneclickvirt/ecs go 1.25.4 require ( + github.com/charmbracelet/bubbletea v1.3.10 + github.com/charmbracelet/lipgloss v1.1.0 github.com/imroc/req/v3 v3.54.0 github.com/oneclickvirt/UnlockTests v0.0.35-20260207053956 github.com/oneclickvirt/backtrace v0.0.8-20251109090457 @@ -25,8 +27,14 @@ require ( github.com/StackExchange/wmi v1.2.1 // indirect github.com/andybalholm/brotli v1.2.0 // indirect github.com/andybalholm/cascadia v1.3.2 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect + github.com/charmbracelet/x/ansi v0.10.1 // indirect + github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect + github.com/charmbracelet/x/term v0.2.1 // indirect github.com/cloudflare/circl v1.6.1 // indirect github.com/ebitengine/purego v0.8.4 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/fatih/color v1.18.0 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/go-ole/go-ole v1.3.0 // indirect @@ -48,15 +56,20 @@ require ( github.com/koron/go-ssdp v0.0.4 // indirect github.com/libp2p/go-nat v0.2.0 // indirect github.com/libp2p/go-netroute v0.2.1 // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect github.com/mattn/go-runewidth v0.0.16 // indirect github.com/miekg/dns v1.1.61 // indirect github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db // indirect github.com/mitchellh/go-homedir v1.1.0 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/termenv v0.16.0 // indirect github.com/nxtrace/NTrace-core v1.5.0 // indirect github.com/oneclickvirt/dd v0.0.2-20250808062818 // indirect github.com/oneclickvirt/fio v0.0.2-20250808045755 // indirect @@ -94,6 +107,7 @@ require ( github.com/tklauser/numcpus v0.9.0 // indirect github.com/tsosunchia/powclient v0.2.0 // indirect github.com/xjasonlyu/windivert-go v0.0.0-20201010013527-4239d0afa76f // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap v1.27.0 // indirect diff --git a/go.sum b/go.sum index 003dabfe..01217377 100644 --- a/go.sum +++ b/go.sum @@ -6,6 +6,20 @@ github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwTo github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= github.com/andybalholm/cascadia v1.3.2 h1:3Xi6Dw5lHF15JtdcmAHD3i1+T8plmv7BQ/nsViSLyss= github.com/andybalholm/cascadia v1.3.2/go.mod h1:7gtRlve5FxPPgIgX36uWBX58OdBsSS6lUvCFb+h7KvU= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= +github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ= +github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= +github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= github.com/chengxilo/virtualterm v1.0.4 h1:Z6IpERbRVlfB8WkOmtbHiDbBANU7cimRIof7mk9/PwM= github.com/chengxilo/virtualterm v1.0.4/go.mod h1:DyxxBZz/x1iqJjFxTFcr6/x+jSpqN0iwWCOK1q10rlY= github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0= @@ -16,6 +30,8 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/ebitengine/purego v0.8.4 h1:CF7LEKg5FFOsASUj0+QwaXf8Ht6TlFxg09+S9wz0omw= github.com/ebitengine/purego v0.8.4/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= @@ -75,12 +91,16 @@ github.com/libp2p/go-nat v0.2.0 h1:Tyz+bUFAYqGyJ/ppPPymMGbIgNRH+WqC5QrT5fKrrGk= github.com/libp2p/go-nat v0.2.0/go.mod h1:3MJr+GRpRkyT65EpVPBstXLvOlAPzUVlG6Pwg9ohLJk= github.com/libp2p/go-netroute v0.2.1 h1:V8kVrpD8GK0Riv15/7VN6RbUQ3URNZVosw7H2v9tksU= github.com/libp2p/go-netroute v0.2.1/go.mod h1:hraioZr0fhBjG0ZRXJJ6Zj2IVEVNx6tDTFQfSmcq7mQ= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4= github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/miekg/dns v1.1.61 h1:nLxbwF3XxhwVSm8g9Dghm9MHPaUZuqhPiGL+675ZmEs= @@ -94,6 +114,12 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/nxtrace/NTrace-core v1.5.0 h1:n+a/FObw/+CcqvhuSQiWcm1q+ODtfo7Wt3VmaIx504I= github.com/nxtrace/NTrace-core v1.5.0/go.mod h1:/jME48iJ7QaVTzsrTPQyTJ+yExhjeWjax2L6uBd4ckk= github.com/oneclickvirt/UnlockTests v0.0.35-20260207053956 h1:yccGrw/sYOHZMaFJghPVN3Xn6JyTOXsEQc9v0I92k3M= @@ -216,6 +242,8 @@ github.com/tsosunchia/powclient v0.2.0 h1:BDrI3O69CbzarbD+CnnY10Kuwn8xlmtQR0m5tB github.com/tsosunchia/powclient v0.2.0/go.mod h1:fkb7tTW+HMH3ZWZzQUgwvvFKMj/8Ys+C8Sm/uGQzDA0= github.com/xjasonlyu/windivert-go v0.0.0-20201010013527-4239d0afa76f h1:glX3VZCYwW1/OmFxOjazfCtBLxXB3YNZk9LF2lYx+Lw= github.com/xjasonlyu/windivert-go v0.0.0-20201010013527-4239d0afa76f/go.mod h1:gh//RKyt2Gesx3eOj3ulzrSQ60ySj2UA4qnOdrtarvg= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= @@ -238,6 +266,8 @@ golang.org/x/crypto v0.8.0/go.mod h1:mRqEX+O9/h5TFCrQhkgjo2yKi0yYA+9ecGkdQoHrywE golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw= golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= +golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8 h1:yixxcjnhBmY0nkL253HFVIm0JsFHwrHdT3Yh6szTnfY= +golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8/go.mod h1:jj3sYF3dwk5D+ghuXyeI3r5MFf+NT2An6/9dOA95KSI= golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= @@ -267,6 +297,7 @@ golang.org/x/sys v0.0.0-20201008064518-c1f3e3309c71/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= diff --git a/goecs.go b/goecs.go index adaca7f0..4405b2f6 100644 --- a/goecs.go +++ b/goecs.go @@ -27,7 +27,7 @@ import ( ) var ( - ecsVersion = "v0.1.117" // 融合怪版本号 + ecsVersion = "v0.1.118" // 融合怪版本号 configs = params.NewConfig(ecsVersion) // 全局配置实例 userSetFlags = make(map[string]bool) // 用于跟踪哪些参数是用户显式设置的 ) diff --git a/internal/menu/menu.go b/internal/menu/menu.go index c8379303..2663888e 100644 --- a/internal/menu/menu.go +++ b/internal/menu/menu.go @@ -21,7 +21,7 @@ func GetMenuChoice(language string) string { sigChan := make(chan os.Signal, 1) signal.Notify(sigChan, os.Interrupt, syscall.SIGINT, syscall.SIGTERM) defer signal.Stop(sigChan) - + go func() { select { case <-sigChan: @@ -35,7 +35,7 @@ func GetMenuChoice(language string) string { return } }() - + for { var input string if language == "zh" { @@ -46,7 +46,7 @@ func GetMenuChoice(language string) string { fmt.Scanln(&input) input = strings.TrimSpace(input) input = strings.TrimRight(input, "\n") - + re := regexp.MustCompile(`^\d+$`) if re.MatchString(input) { inChoice := input @@ -125,13 +125,13 @@ func PrintMenuOptions(preCheck utils.NetCheckResult, config *params.Config) { } fmt.Printf("使用统计: %s\n", statsInfo) } - fmt.Println("1. 融合怪完全体(能测全测)") - fmt.Println("2. 极简版(系统信息+CPU+内存+磁盘+测速节点5个)") - fmt.Println("3. 精简版(系统信息+CPU+内存+磁盘+跨国平台解锁+路由+测速节点5个)") - fmt.Println("4. 精简网络版(系统信息+CPU+内存+磁盘+回程+路由+测速节点5个)") - fmt.Println("5. 精简解锁版(系统信息+CPU+内存+磁盘IO+跨国平台解锁+测速节点5个)") - fmt.Println("6. 网络单项(IP质量检测+上游及三网回程+广州三网回程详细路由+全国延迟+TGDC+网站延迟+测速节点11个)") - fmt.Println("7. 解锁单项(跨国平台解锁)") + fmt.Println("1. 融合怪完全体(能测全测)") + fmt.Println("2. 极简版(系统信息+CPU+内存+磁盘+测速节点5个)") + fmt.Println("3. 精简版(系统信息+CPU+内存+磁盘+跨国平台解锁+路由+测速节点5个)") + fmt.Println("4. 精简网络版(系统信息+CPU+内存+磁盘+回程+路由+测速节点5个)") + fmt.Println("5. 精简解锁版(系统信息+CPU+内存+磁盘IO+跨国平台解锁+测速节点5个)") + fmt.Println("6. 网络单项(IP质量检测+上游及三网回程+广州三网回程详细路由+全国延迟+TGDC+网站延迟+测速节点11个)") + fmt.Println("7. 解锁单项(跨国平台解锁)") fmt.Println("8. 硬件单项(系统信息+CPU+dd磁盘测试+fio磁盘测试)") fmt.Println("9. IP质量检测(15个数据库的IP质量检测+邮件端口检测)") fmt.Println("10. 三网回程线路检测+三网回程详细路由(北京上海广州成都)+全国延迟+TGDC+网站延迟") @@ -145,20 +145,20 @@ func PrintMenuOptions(preCheck utils.NetCheckResult, config *params.Config) { } fmt.Printf("%s\n", statsInfo) } - fmt.Println("1. VPS Fusion Monster Test (Full Test)") - fmt.Println("2. Minimal Test Suite (System Info + CPU + Memory + Disk + 5 Speed Test Nodes)") - fmt.Println("3. Standard Test Suite (System Info + CPU + Memory + Disk + International Platform Unlock + Routing + 5 Speed Test Nodes)") - fmt.Println("4. Network-Focused Test Suite (System Info + CPU + Memory + Disk + Backtrace + Routing + 5 Speed Test Nodes)") - fmt.Println("5. Unlock-Focused Test Suite (System Info + CPU + Memory + Disk IO + International Platform Unlock + 5 Speed Test Nodes)") - fmt.Println("6. Network-Only Test (IP Quality Test + Upstream & 3-Network Backtrace + Guangzhou 3-Network Detailed Routing + National Latency + TGDC + Websites + 11 Speed Test Nodes)") - fmt.Println("7. Unlock-Only Test (International Platform Unlock)") - fmt.Println("8. Hardware-Only Test (System Info + CPU + Memory + dd Disk Test + fio Disk Test)") - fmt.Println("9. IP Quality Test (IP Test with 15 Databases + Email Port Test)") - fmt.Println("0. Exit Program") + fmt.Println("1. VPS Fusion Monster Test (Full Test)") + fmt.Println("2. Minimal Test Suite (System Info + CPU + Memory + Disk + 5 Speed Test Nodes)") + fmt.Println("3. Standard Test Suite (System Info + CPU + Memory + Disk + International Platform Unlock + Routing + 5 Speed Test Nodes)") + fmt.Println("4. Network-Focused Test Suite (System Info + CPU + Memory + Disk + Backtrace + Routing + 5 Speed Test Nodes)") + fmt.Println("5. Unlock-Focused Test Suite (System Info + CPU + Memory + Disk IO + International Platform Unlock + 5 Speed Test Nodes)") + fmt.Println("6. Network-Only Test (IP Quality Test + Upstream & 3-Network Backtrace + Guangzhou 3-Network Detailed Routing + National Latency + TGDC + Websites + 11 Speed Test Nodes)") + fmt.Println("7. Unlock-Only Test (International Platform Unlock)") + fmt.Println("8. Hardware-Only Test (System Info + CPU + Memory + dd Disk Test + fio Disk Test)") + fmt.Println("9. IP Quality Test (IP Test with 15 Databases + Email Port Test)") + fmt.Println("0. Exit Program") } } -// HandleMenuMode handles menu selection +// HandleMenuMode handles menu selection using the interactive TUI func HandleMenuMode(preCheck utils.NetCheckResult, config *params.Config) { savedParams := config.SaveUserSetParams() config.BasicStatus = false @@ -174,63 +174,47 @@ func HandleMenuMode(preCheck utils.NetCheckResult, config *params.Config) { config.TgdcTestStatus = false config.WebTestStatus = false config.AutoChangeDiskMethod = true - PrintMenuOptions(preCheck, config) -Loop: - for { - config.Choice = GetMenuChoice(config.Language) - switch config.Choice { + + result := RunTuiMenu(preCheck, config) + if result.quit { + os.Exit(0) + } + + // Update language if changed by TUI selection + config.Language = result.language + + if result.custom { + applyCustomResult(result, preCheck, config) + if config.SpeedTestStatus { + config.OnlyChinaTest = utils.CheckChina(config.EnableLogger, config.Language) + } + } else { + config.Choice = result.choice + switch result.choice { case "0": os.Exit(0) case "1": SetFullTestStatus(preCheck, config) config.OnlyChinaTest = utils.CheckChina(config.EnableLogger, config.Language) - break Loop case "2": SetMinimalTestStatus(preCheck, config) - break Loop case "3": SetStandardTestStatus(preCheck, config) - break Loop case "4": SetNetworkFocusedTestStatus(preCheck, config) - break Loop case "5": SetUnlockFocusedTestStatus(preCheck, config) - break Loop case "6": - if !preCheck.Connected { - fmt.Println("Can not test without network connection!") - return - } SetNetworkOnlyTestStatus(config) - break Loop case "7": - if !preCheck.Connected { - fmt.Println("Can not test without network connection!") - return - } SetUnlockOnlyTestStatus(config) - break Loop case "8": SetHardwareOnlyTestStatus(preCheck, config) - break Loop case "9": - if !preCheck.Connected { - fmt.Println("Can not test without network connection!") - return - } SetIPQualityTestStatus(config) - break Loop case "10": - if !preCheck.Connected { - fmt.Println("Can not test without network connection!") - return - } config.Nt3Location = "ALL" SetRouteTestStatus(config) - break Loop - default: - PrintInvalidChoice(config.Language) } } config.RestoreUserSetParams(savedParams) diff --git a/internal/menu/tui.go b/internal/menu/tui.go new file mode 100644 index 00000000..3ffa83ca --- /dev/null +++ b/internal/menu/tui.go @@ -0,0 +1,762 @@ +package menu + +import ( + "fmt" + "os" + "strings" + "sync" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/oneclickvirt/ecs/internal/params" + "github.com/oneclickvirt/ecs/utils" +) + +// --- Styles --- + +var ( + tTitleStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("39")) + tInfoStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("244")) + tWarnStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("214")) + tSelStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("120")).Bold(true) + tNormStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("252")) + tDimStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("241")) + tHelpStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("241")) + tSectStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("214")).Bold(true) + tChkOnStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("120")) + tChkOffStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240")) + tBtnStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("0")).Background(lipgloss.Color("120")).Bold(true).Padding(0, 2) + tBtnDimStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("252")).Background(lipgloss.Color("238")).Padding(0, 2) +) + +// --- Types --- + +type menuPhase int + +const ( + phaseLang menuPhase = iota + phaseMain + phaseCustom +) + +type mainMenuItem struct { + id string + zh string + en string + needNet bool +} + +type testToggle struct { + nameZh string + nameEn string + enabled bool + needNet bool +} + +type advSetting struct { + nameZh string + nameEn string + options []string + current int +} + +type tuiResult struct { + choice string + language string + quit bool + custom bool + toggles []testToggle + advanced []advSetting +} + +type tuiModel struct { + phase menuPhase + config *params.Config + preCheck utils.NetCheckResult + langPreset bool + + // Language selection + langCursor int + + // Main menu + mainCursor int + mainItems []mainMenuItem + + // Custom mode + customCursor int + toggles []testToggle + advanced []advSetting + customTotal int // toggles + advanced + 1 (confirm button) + + // Pre-loaded info + statsTotal int + statsDaily int + hasStats bool + cmpVersion int + newVersion string + + // Result + result tuiResult + + width int + height int +} + +// --- Default data --- + +func defaultMainItems() []mainMenuItem { + return []mainMenuItem{ + {id: "1", zh: "融合怪完全体(能测全测)", en: "Full Test (All Available Tests)", needNet: false}, + {id: "2", zh: "极简版(系统信息+CPU+内存+磁盘+测速节点5个)", en: "Minimal Suite (SysInfo+CPU+Mem+Disk+5 Speed Nodes)", needNet: false}, + {id: "3", zh: "精简版(系统信息+CPU+内存+磁盘+解锁+路由+测速节点5个)", en: "Standard Suite (SysInfo+CPU+Mem+Disk+Unlock+Route+5 Speed Nodes)", needNet: false}, + {id: "4", zh: "精简网络版(系统信息+CPU+内存+磁盘+回程+路由+测速节点5个)", en: "Network Suite (SysInfo+CPU+Mem+Disk+Backtrace+Route+5 Speed Nodes)", needNet: false}, + {id: "5", zh: "精简解锁版(系统信息+CPU+内存+磁盘IO+解锁+测速节点5个)", en: "Unlock Suite (SysInfo+CPU+Mem+DiskIO+Unlock+5 Speed Nodes)", needNet: false}, + {id: "6", zh: "网络单项(IP质量+回程+路由+延迟+TGDC+网站+测速节点11个)", en: "Network Only (IPQuality+Backtrace+Route+Latency+TGDC+Web+11 Speed Nodes)", needNet: true}, + {id: "7", zh: "解锁单项(跨国平台解锁)", en: "Unlock Only (International Platform Unlock)", needNet: true}, + {id: "8", zh: "硬件单项(系统信息+CPU+内存+dd磁盘+fio磁盘)", en: "Hardware Only (SysInfo+CPU+Mem+DD Disk+FIO Disk)", needNet: false}, + {id: "9", zh: "IP质量检测(15个数据库+邮件端口检测)", en: "IP Quality (15 Databases + Email Port Test)", needNet: true}, + {id: "10", zh: "三网回程线路+路由+延迟+TGDC+网站延迟", en: "3-Net Backtrace+Route+Latency+TGDC+Websites", needNet: true}, + {id: "custom", zh: ">>> 自定义测试(自由选择测试项和高级设置)", en: ">>> Custom Test (Choose Tests & Advanced Settings)", needNet: false}, + {id: "0", zh: "退出程序", en: "Exit Program", needNet: false}, + } +} + +func defaultTestToggles() []testToggle { + return []testToggle{ + {nameZh: "基础系统信息", nameEn: "Basic System Info", enabled: true, needNet: false}, + {nameZh: "CPU测试", nameEn: "CPU Test", enabled: true, needNet: false}, + {nameZh: "内存测试", nameEn: "Memory Test", enabled: true, needNet: false}, + {nameZh: "磁盘测试", nameEn: "Disk Test", enabled: true, needNet: false}, + {nameZh: "跨国平台解锁", nameEn: "Streaming Unlock", enabled: false, needNet: true}, + {nameZh: "IP质量检测", nameEn: "IP Quality Check", enabled: false, needNet: true}, + {nameZh: "邮件端口检测", nameEn: "Email Port Check", enabled: false, needNet: true}, + {nameZh: "回程路由", nameEn: "Backtrace Route", enabled: false, needNet: true}, + {nameZh: "NT3路由", nameEn: "NT3 Route", enabled: false, needNet: true}, + {nameZh: "测速", nameEn: "Speed Test", enabled: false, needNet: true}, + {nameZh: "Ping测试", nameEn: "Ping Test", enabled: false, needNet: true}, + {nameZh: "TGDC测试", nameEn: "TGDC Test", enabled: false, needNet: true}, + {nameZh: "网站延迟", nameEn: "Website Latency", enabled: false, needNet: true}, + } +} + +func defaultAdvSettings(config *params.Config) []advSetting { + adv := []advSetting{ + {nameZh: "CPU测试方法", nameEn: "CPU Method", options: []string{"sysbench", "geekbench", "winsat"}, current: 0}, + {nameZh: "CPU线程模式", nameEn: "CPU Thread", options: []string{"multi", "single"}, current: 0}, + {nameZh: "内存测试方法", nameEn: "Memory Method", options: []string{"stream", "sysbench", "dd", "winsat", "auto"}, current: 0}, + {nameZh: "磁盘测试方法", nameEn: "Disk Method", options: []string{"fio", "dd", "winsat"}, current: 0}, + {nameZh: "NT3测试位置", nameEn: "NT3 Location", options: []string{"GZ", "SH", "BJ", "CD", "ALL"}, current: 0}, + {nameZh: "NT3测试类型", nameEn: "NT3 Type", options: []string{"ipv4", "ipv6", "both"}, current: 0}, + {nameZh: "测速节点数/运营商", nameEn: "Speed Nodes/ISP", options: []string{"1", "2", "3", "4", "5", "6", "7", "8", "9", "10"}, current: 1}, + } + // Set current values from config + setAdvCurrent := func(idx int, val string) { + for j, opt := range adv[idx].options { + if opt == val { + adv[idx].current = j + return + } + } + } + setAdvCurrent(0, config.CpuTestMethod) + setAdvCurrent(1, config.CpuTestThreadMode) + setAdvCurrent(2, config.MemoryTestMethod) + setAdvCurrent(3, config.DiskTestMethod) + setAdvCurrent(4, config.Nt3Location) + setAdvCurrent(5, config.Nt3CheckType) + if config.SpNum >= 1 && config.SpNum <= 10 { + adv[6].current = config.SpNum - 1 + } + return adv +} + +func newTuiModel(preCheck utils.NetCheckResult, config *params.Config, langPreset bool, statsTotal, statsDaily int, hasStats bool, cmpVersion int, newVersion string) tuiModel { + toggles := defaultTestToggles() + advanced := defaultAdvSettings(config) + m := tuiModel{ + config: config, + preCheck: preCheck, + langPreset: langPreset, + mainItems: defaultMainItems(), + toggles: toggles, + advanced: advanced, + customTotal: len(toggles) + len(advanced) + 1, + statsTotal: statsTotal, + statsDaily: statsDaily, + hasStats: hasStats, + cmpVersion: cmpVersion, + newVersion: newVersion, + width: 80, + height: 24, + } + if langPreset { + m.phase = phaseMain + m.result.language = config.Language + } else { + m.phase = phaseLang + } + return m +} + +// --- Bubbletea Interface --- + +func (m tuiModel) Init() tea.Cmd { + return nil +} + +func (m tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + return m, nil + case tea.KeyMsg: + switch m.phase { + case phaseLang: + return m.updateLang(msg) + case phaseMain: + return m.updateMain(msg) + case phaseCustom: + return m.updateCustom(msg) + } + } + return m, nil +} + +func (m tuiModel) View() string { + switch m.phase { + case phaseLang: + return m.viewLang() + case phaseMain: + return m.viewMain() + case phaseCustom: + return m.viewCustom() + } + return "" +} + +// --- Language Selection --- + +func (m tuiModel) updateLang(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "up", "k": + if m.langCursor > 0 { + m.langCursor-- + } + case "down", "j": + if m.langCursor < 1 { + m.langCursor++ + } + case "1": + m.result.language = "zh" + m.phase = phaseMain + case "2": + m.result.language = "en" + m.phase = phaseMain + case "enter": + if m.langCursor == 0 { + m.result.language = "zh" + } else { + m.result.language = "en" + } + m.phase = phaseMain + case "q", "ctrl+c": + m.result.quit = true + return m, tea.Quit + } + return m, nil +} + +func (m tuiModel) viewLang() string { + var s strings.Builder + s.WriteString("\n") + s.WriteString(tTitleStyle.Render(" VPS融合怪测试 / VPS Fusion Monster Test")) + s.WriteString("\n\n") + s.WriteString(tInfoStyle.Render(" 请选择语言 / Please select language:")) + s.WriteString("\n\n") + + langs := []struct { + label string + desc string + }{ + {"1. 中文", "Chinese"}, + {"2. English", "英文"}, + } + + for i, l := range langs { + cursor := " " + style := tNormStyle + if m.langCursor == i { + cursor = " > " + style = tSelStyle + } + s.WriteString(fmt.Sprintf("%s%s %s\n", cursor, style.Render(l.label), tDimStyle.Render(l.desc))) + } + + s.WriteString("\n") + s.WriteString(tHelpStyle.Render(" ↑/↓ Navigate Enter Confirm 1/2 Quick-Select q Quit")) + s.WriteString("\n") + return s.String() +} + +// --- Main Menu --- + +func (m tuiModel) updateMain(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + key := msg.String() + switch key { + case "up", "k": + if m.mainCursor > 0 { + m.mainCursor-- + } + case "down", "j": + if m.mainCursor < len(m.mainItems)-1 { + m.mainCursor++ + } + case "home": + m.mainCursor = 0 + case "end": + m.mainCursor = len(m.mainItems) - 1 + case "enter": + item := m.mainItems[m.mainCursor] + if item.needNet && !m.preCheck.Connected { + return m, nil // can't select network-dependent items without connection + } + if item.id == "custom" { + m.phase = phaseCustom + m.customCursor = 0 + return m, nil + } + m.result.choice = item.id + return m, tea.Quit + case "q", "ctrl+c": + m.result.quit = true + return m, tea.Quit + default: + // Number quick-select + for i, item := range m.mainItems { + if key == item.id { + if item.needNet && !m.preCheck.Connected { + return m, nil + } + if item.id == "custom" { + m.phase = phaseCustom + m.customCursor = 0 + return m, nil + } + m.mainCursor = i + m.result.choice = item.id + return m, tea.Quit + } + } + } + return m, nil +} + +func (m tuiModel) viewMain() string { + lang := m.result.language + var s strings.Builder + s.WriteString("\n") + + // Title + if lang == "zh" { + s.WriteString(tTitleStyle.Render(fmt.Sprintf(" VPS融合怪 %s", m.config.EcsVersion))) + } else { + s.WriteString(tTitleStyle.Render(fmt.Sprintf(" VPS Fusion Monster %s", m.config.EcsVersion))) + } + s.WriteString("\n") + + // Version warning + if m.preCheck.Connected && m.cmpVersion == -1 { + if lang == "zh" { + s.WriteString(tWarnStyle.Render(fmt.Sprintf(" ! 检测到新版本 %s 如有必要请更新!", m.newVersion))) + } else { + s.WriteString(tWarnStyle.Render(fmt.Sprintf(" ! New version %s detected, update if necessary!", m.newVersion))) + } + s.WriteString("\n") + } + + // Stats + if m.preCheck.Connected && m.hasStats { + if lang == "zh" { + s.WriteString(tInfoStyle.Render(fmt.Sprintf(" 总使用量: %s | 今日使用: %s", + utils.FormatGoecsNumber(m.statsTotal), utils.FormatGoecsNumber(m.statsDaily)))) + } else { + s.WriteString(tInfoStyle.Render(fmt.Sprintf(" Total Usage: %s | Daily Usage: %s", + utils.FormatGoecsNumber(m.statsTotal), utils.FormatGoecsNumber(m.statsDaily)))) + } + s.WriteString("\n") + } + s.WriteString("\n") + + // Section header + if lang == "zh" { + s.WriteString(tSectStyle.Render(" 请选择测试方案:")) + } else { + s.WriteString(tSectStyle.Render(" Select Test Suite:")) + } + s.WriteString("\n\n") + + // Menu items + for i, item := range m.mainItems { + cursor := " " + style := tNormStyle + if m.mainCursor == i { + cursor = " > " + style = tSelStyle + } + + var label string + if lang == "zh" { + label = item.zh + } else { + label = item.en + } + + // Number prefix + prefix := "" + switch { + case item.id == "custom": + prefix = "" + case item.id == "0": + prefix = " 0. " + default: + prefix = fmt.Sprintf("%2s. ", item.id) + } + + // Disabled indicator for network-dependent items when no connection + suffix := "" + if item.needNet && !m.preCheck.Connected { + style = tDimStyle + if lang == "zh" { + suffix = " [需要网络]" + } else { + suffix = " [No Network]" + } + } + + s.WriteString(fmt.Sprintf("%s%s%s\n", cursor, style.Render(prefix+label), tDimStyle.Render(suffix))) + } + + s.WriteString("\n") + if lang == "zh" { + s.WriteString(tHelpStyle.Render(" ↑/↓/j/k 移动 Enter 确认 数字 快速选择 q 退出")) + } else { + s.WriteString(tHelpStyle.Render(" Up/Down/j/k Navigate Enter Confirm Number Quick-Select q Quit")) + } + s.WriteString("\n") + return s.String() +} + +// --- Custom Mode --- + +func (m tuiModel) updateCustom(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + key := msg.String() + switch key { + case "up", "k": + if m.customCursor > 0 { + m.customCursor-- + } + case "down", "j": + if m.customCursor < m.customTotal-1 { + m.customCursor++ + } + case "home": + m.customCursor = 0 + case "end": + m.customCursor = m.customTotal - 1 + case " ": + if m.customCursor < len(m.toggles) { + // Toggle test item + t := &m.toggles[m.customCursor] + if t.needNet && !m.preCheck.Connected { + break // can't enable without network + } + t.enabled = !t.enabled + } else if m.customCursor < len(m.toggles)+len(m.advanced) { + // Cycle advanced setting forward + idx := m.customCursor - len(m.toggles) + a := &m.advanced[idx] + a.current = (a.current + 1) % len(a.options) + } + case "right", "l": + if m.customCursor >= len(m.toggles) && m.customCursor < len(m.toggles)+len(m.advanced) { + idx := m.customCursor - len(m.toggles) + a := &m.advanced[idx] + a.current = (a.current + 1) % len(a.options) + } + case "left", "h": + if m.customCursor >= len(m.toggles) && m.customCursor < len(m.toggles)+len(m.advanced) { + idx := m.customCursor - len(m.toggles) + a := &m.advanced[idx] + a.current = (a.current - 1 + len(a.options)) % len(a.options) + } + case "a": + // Toggle all test items + allEnabled := true + for _, t := range m.toggles { + if !t.enabled && (!t.needNet || m.preCheck.Connected) { + allEnabled = false + break + } + } + for i := range m.toggles { + if m.toggles[i].needNet && !m.preCheck.Connected { + continue + } + m.toggles[i].enabled = !allEnabled + } + case "enter": + if m.customCursor == m.customTotal-1 { + // Confirm button + m.result.custom = true + m.result.choice = "custom" + m.result.toggles = m.toggles + m.result.advanced = m.advanced + return m, tea.Quit + } + // Toggle current item (same as space) + if m.customCursor < len(m.toggles) { + t := &m.toggles[m.customCursor] + if t.needNet && !m.preCheck.Connected { + break + } + t.enabled = !t.enabled + } else if m.customCursor < len(m.toggles)+len(m.advanced) { + idx := m.customCursor - len(m.toggles) + a := &m.advanced[idx] + a.current = (a.current + 1) % len(a.options) + } + case "esc": + m.phase = phaseMain + return m, nil + case "q", "ctrl+c": + m.result.quit = true + return m, tea.Quit + } + return m, nil +} + +func (m tuiModel) viewCustom() string { + lang := m.result.language + var s strings.Builder + s.WriteString("\n") + + if lang == "zh" { + s.WriteString(tTitleStyle.Render(" 自定义测试配置")) + } else { + s.WriteString(tTitleStyle.Render(" Custom Test Configuration")) + } + s.WriteString("\n\n") + + // Test toggles + if lang == "zh" { + s.WriteString(tSectStyle.Render(" 测试项目 (空格切换, a 全选/全不选):")) + } else { + s.WriteString(tSectStyle.Render(" Test Items (Space toggle, a Select all/none):")) + } + s.WriteString("\n\n") + + for i, t := range m.toggles { + cursor := " " + if m.customCursor == i { + cursor = " > " + } + + var check string + if t.enabled { + check = tChkOnStyle.Render("[x]") + } else { + check = tChkOffStyle.Render("[ ]") + } + + var name string + if lang == "zh" { + name = t.nameZh + } else { + name = t.nameEn + } + + style := tNormStyle + if m.customCursor == i { + style = tSelStyle + } + if t.needNet && !m.preCheck.Connected { + style = tDimStyle + } + + s.WriteString(fmt.Sprintf("%s%s %s\n", cursor, check, style.Render(name))) + } + + // Advanced settings + s.WriteString("\n") + if lang == "zh" { + s.WriteString(tSectStyle.Render(" 高级设置 (空格/←/→ 切换选项):")) + } else { + s.WriteString(tSectStyle.Render(" Advanced Settings (Space/Left/Right cycle):")) + } + s.WriteString("\n\n") + + for i, a := range m.advanced { + idx := len(m.toggles) + i + cursor := " " + if m.customCursor == idx { + cursor = " > " + } + + var name string + if lang == "zh" { + name = a.nameZh + } else { + name = a.nameEn + } + + style := tNormStyle + if m.customCursor == idx { + style = tSelStyle + } + + // Render current value with arrows + val := a.options[a.current] + var optStr string + if m.customCursor == idx { + optStr = tSelStyle.Render(fmt.Sprintf("< %s >", val)) + } else { + optStr = tDimStyle.Render(fmt.Sprintf("< %s >", val)) + } + + s.WriteString(fmt.Sprintf("%s%-22s %s\n", cursor, style.Render(name+":"), optStr)) + } + + // Confirm button + s.WriteString("\n") + confirmIdx := m.customTotal - 1 + if m.customCursor == confirmIdx { + if lang == "zh" { + s.WriteString(fmt.Sprintf(" %s\n", tBtnStyle.Render(">> 开始测试 <<"))) + } else { + s.WriteString(fmt.Sprintf(" %s\n", tBtnStyle.Render(">> Start Test <<"))) + } + } else { + if lang == "zh" { + s.WriteString(fmt.Sprintf(" %s\n", tBtnDimStyle.Render(">> 开始测试 <<"))) + } else { + s.WriteString(fmt.Sprintf(" %s\n", tBtnDimStyle.Render(">> Start Test <<"))) + } + } + + s.WriteString("\n") + if lang == "zh" { + s.WriteString(tHelpStyle.Render(" ↑/↓ 移动 空格/Enter 切换 ←/→ 调整 a 全选 Esc 返回 q 退出")) + } else { + s.WriteString(tHelpStyle.Render(" Up/Down Move Space/Enter Toggle Left/Right Adjust a All Esc Back q Quit")) + } + s.WriteString("\n") + return s.String() +} + +// --- Public Interface --- + +// RunTuiMenu runs the interactive TUI menu and returns the result +func RunTuiMenu(preCheck utils.NetCheckResult, config *params.Config) tuiResult { + // Pre-load stats + var statsTotal, statsDaily int + var hasStats bool + var cmpVersion int + var newVersion string + + if preCheck.Connected { + var wg sync.WaitGroup + var stats *utils.StatsResponse + var statsErr error + var githubInfo *utils.GitHubRelease + var githubErr error + wg.Add(2) + go func() { + defer wg.Done() + stats, statsErr = utils.GetGoescStats() + }() + go func() { + defer wg.Done() + githubInfo, githubErr = utils.GetLatestEcsRelease() + }() + wg.Wait() + if statsErr == nil { + statsTotal = stats.Total + statsDaily = stats.Daily + hasStats = true + } + if githubErr == nil { + cmpVersion = utils.CompareVersions(config.EcsVersion, githubInfo.TagName) + newVersion = githubInfo.TagName + } + } + + langPreset := config.UserSetFlags["lang"] || config.UserSetFlags["l"] + m := newTuiModel(preCheck, config, langPreset, statsTotal, statsDaily, hasStats, cmpVersion, newVersion) + + p := tea.NewProgram(m, tea.WithAltScreen()) + finalModel, err := p.Run() + if err != nil { + fmt.Printf("Error running menu: %v\n", err) + os.Exit(1) + } + return finalModel.(tuiModel).result +} + +// applyCustomResult applies custom mode selections to config +func applyCustomResult(result tuiResult, preCheck utils.NetCheckResult, config *params.Config) { + // Apply test toggles (order must match defaultTestToggles) + for i, t := range result.toggles { + enabled := t.enabled + if t.needNet && !preCheck.Connected { + enabled = false + } + switch i { + case 0: + config.BasicStatus = enabled + case 1: + config.CpuTestStatus = enabled + case 2: + config.MemoryTestStatus = enabled + case 3: + config.DiskTestStatus = enabled + case 4: + config.UtTestStatus = enabled + case 5: + config.SecurityTestStatus = enabled + case 6: + config.EmailTestStatus = enabled + case 7: + config.BacktraceStatus = enabled + case 8: + config.Nt3Status = enabled + case 9: + config.SpeedTestStatus = enabled + case 10: + config.PingTestStatus = enabled + case 11: + config.TgdcTestStatus = enabled + case 12: + config.WebTestStatus = enabled + } + } + + // Apply advanced settings (order must match defaultAdvSettings) + if len(result.advanced) >= 7 { + config.CpuTestMethod = result.advanced[0].options[result.advanced[0].current] + config.CpuTestThreadMode = result.advanced[1].options[result.advanced[1].current] + config.MemoryTestMethod = result.advanced[2].options[result.advanced[2].current] + config.DiskTestMethod = result.advanced[3].options[result.advanced[3].current] + config.Nt3Location = result.advanced[4].options[result.advanced[4].current] + config.Nt3CheckType = result.advanced[5].options[result.advanced[5].current] + spIdx := result.advanced[6].current + config.SpNum = spIdx + 1 + } + + // Set OnlyIpInfoCheck if no hardware tests are enabled + if !config.BasicStatus && !config.CpuTestStatus && !config.MemoryTestStatus && !config.DiskTestStatus { + config.OnlyIpInfoCheck = true + } + + config.AutoChangeDiskMethod = true +} diff --git a/internal/params/params.go b/internal/params/params.go index 165788b8..a2aa47f9 100644 --- a/internal/params/params.go +++ b/internal/params/params.go @@ -3,6 +3,7 @@ package params import ( "flag" "fmt" + "strings" ) // Config holds all configuration parameters @@ -64,7 +65,7 @@ func NewConfig(version string) *Config { MemoryTestMethod: "stream", DiskTestMethod: "fio", DiskTestPath: "", - DiskMultiCheck: false, + DiskMultiCheck: false, Nt3CheckType: "ipv4", SpNum: 2, Width: 82, @@ -92,8 +93,56 @@ func NewConfig(version string) *Config { } } +// normalizeBoolArgs preprocesses args so that bool flags written as +// "-flag true" or "-flag false" (space-separated) are converted to +// "-flag=true" / "-flag=false" that the standard flag package understands. +// This also strips any duplicate spaces that may appear between tokens when +// args have been assembled by shell scripts or other callers. +func normalizeBoolArgs(args []string) []string { + // All known boolean flag names (without leading dash). + boolFlags := map[string]bool{ + "h": true, "help": true, "v": true, "version": true, + "menu": true, "basic": true, "cpu": true, "memory": true, + "disk": true, "ut": true, "security": true, "email": true, + "backtrace": true, "nt3": true, "speed": true, "ping": true, + "tgdc": true, "web": true, "log": true, "upload": true, + "diskmc": true, + } + + out := make([]string, 0, len(args)) + i := 0 + for i < len(args) { + arg := args[i] + // Skip empty tokens that can appear from split on multiple spaces. + if arg == "" { + i++ + continue + } + + // Detect flag tokens: -flag or --flag (without embedded =). + if strings.HasPrefix(arg, "-") && !strings.Contains(arg, "=") { + name := strings.TrimLeft(arg, "-") + if boolFlags[name] { + // Peek at next token: if it is "true" or "false", merge. + if i+1 < len(args) { + next := strings.ToLower(strings.TrimSpace(args[i+1])) + if next == "true" || next == "false" { + out = append(out, arg+"="+next) + i += 2 + continue + } + } + } + } + out = append(out, arg) + i++ + } + return out +} + // ParseFlags parses command line flags func (c *Config) ParseFlags(args []string) { + args = normalizeBoolArgs(args) c.GoecsFlag.BoolVar(&c.Help, "h", false, "Show help information") c.GoecsFlag.BoolVar(&c.Help, "help", false, "Show help information") c.GoecsFlag.BoolVar(&c.ShowVersion, "v", false, "Display version information")