This commit is contained in:
Yangxt
2026-09-21 16:46:55 +08:00
parent 01842c59c6
commit ad2b0a7d3e
58 changed files with 6906 additions and 3 deletions
+13
View File
@@ -0,0 +1,13 @@
//go:build !windows
package gui
import (
"fmt"
"os"
)
func Run(string) int {
fmt.Fprintln(os.Stderr, "GUI is only available on Windows; use --console to listen")
return 1
}
+692
View File
@@ -0,0 +1,692 @@
//go:build windows
package gui
import (
"bytes"
"context"
_ "embed"
"fmt"
"image/png"
"os"
"path/filepath"
"runtime"
"strings"
"sync"
"time"
"unsafe"
"github.com/lxn/walk"
. "github.com/lxn/walk/declarative"
"github.com/lxn/win"
"golang.org/x/sys/windows"
"mysql-tunnel/internal/app"
"mysql-tunnel/internal/config"
"mysql-tunnel/internal/logger"
)
//go:embed logo.png
var logoPNG []byte
func init() {
runtime.LockOSThread()
}
func Run(configFlag string) int {
hideConsoleIfStandalone()
path := strings.TrimSpace(configFlag)
if path == "" {
path = "config.yaml"
}
if resolved, err := config.ResolveConfigPath(path); err == nil {
path = resolved
} else if path == "config.yaml" {
path = config.DefaultSavePath()
} else if abs, err := filepath.Abs(path); err == nil {
path = abs
}
if err := runWindow(config.LoadOrEmpty(path)); err != nil {
fmt.Fprintln(os.Stderr, err)
return 1
}
return 0
}
type ui struct {
mw *walk.MainWindow
ni *walk.NotifyIcon
listenHost *walk.LineEdit
listenPort *walk.NumberEdit
localUser *walk.LineEdit
localPass *walk.LineEdit
tunnelURL *walk.LineEdit
encodeB64 *walk.CheckBox
tlsSkip *walk.CheckBox
remoteHost *walk.LineEdit
remotePort *walk.NumberEdit
remoteUser *walk.LineEdit
remotePass *walk.LineEdit
remoteDB *walk.LineEdit
charset *walk.LineEdit
timeout *walk.NumberEdit
maxConn *walk.NumberEdit
logLevel *walk.ComboBox
logSQL *walk.CheckBox
logFile *walk.LineEdit
status *walk.TextEdit
saveBtn *walk.PushButton
testBtn *walk.PushButton
startBtn *walk.PushButton
stopBtn *walk.PushButton
pathLabel *walk.Label
hint *walk.StatusBarItem
logoView *walk.ImageView
appIcon *walk.Icon
appBmp *walk.Bitmap
cfgPath string
mu sync.Mutex
running bool
busy bool
cancel context.CancelFunc
log *logger.Logger
exiting bool
hidOnce bool
}
func runWindow(cfg *config.Config) error {
u := &ui{cfgPath: cfg.Path}
if err := u.build(cfg); err != nil {
return err
}
defer u.dispose()
u.mw.Run()
return nil
}
func (u *ui) dispose() {
u.stopServer()
if u.ni != nil {
_ = u.ni.Dispose()
}
}
func (u *ui) build(cfg *config.Config) error {
logFile := relativeLogFile(cfg)
levels := []string{"debug", "info", "warn", "error"}
levelIdx := 1
for i, s := range levels {
if s == cfg.LogLevel {
levelIdx = i
break
}
}
if err := (MainWindow{
AssignTo: &u.mw,
Title: "MySQL Tunnel",
MinSize: Size{Width: 780, Height: 560},
Size: Size{Width: 840, Height: 620},
Layout: VBox{Margins: Margins{Left: 12, Top: 10, Right: 12, Bottom: 8}, Spacing: 8},
StatusBarItems: []StatusBarItem{
{AssignTo: &u.hint, Text: "就绪", Width: 800},
},
Children: []Widget{
Composite{
Layout: HBox{Spacing: 10, MarginsZero: true},
Children: []Widget{
ImageView{
AssignTo: &u.logoView,
MinSize: Size{Width: 40, Height: 40},
MaxSize: Size{Width: 40, Height: 40},
Mode: ImageViewModeZoom,
},
Composite{
Layout: VBox{MarginsZero: true, Spacing: 2},
Children: []Widget{
Label{Text: "MySQL Tunnel", Font: Font{Family: "Segoe UI", PointSize: 13, Bold: true}},
Label{AssignTo: &u.pathLabel, Text: cfg.Path},
},
},
HSpacer{},
},
},
Composite{
Layout: HBox{Spacing: 10, MarginsZero: true},
Children: []Widget{
Composite{
StretchFactor: 1,
MinSize: Size{Width: 380},
Layout: VBox{MarginsZero: true, Spacing: 8},
Children: []Widget{
GroupBox{
Title: "本机监听(影刀连这里)",
Layout: Grid{Columns: 2, Spacing: 6},
Children: []Widget{
fieldLabel("地址"),
LineEdit{AssignTo: &u.listenHost, Text: cfg.ListenHost},
fieldLabel("端口"),
NumberEdit{AssignTo: &u.listenPort, Value: float64(cfg.ListenPort), MinValue: 1, MaxValue: 65535, Decimals: 0, MinSize: Size{Width: 90}},
fieldLabel("本机用户"),
LineEdit{AssignTo: &u.localUser, Text: cfg.LocalUsername},
fieldLabel("本机密码"),
LineEdit{AssignTo: &u.localPass, Text: cfg.LocalPassword, PasswordMode: true},
},
},
GroupBox{
Title: "HTTP 隧道",
StretchFactor: 1,
Layout: Grid{Columns: 2, Spacing: 6},
Children: []Widget{
fieldLabel("URL"),
LineEdit{AssignTo: &u.tunnelURL, Text: cfg.TunnelURL},
CheckBox{AssignTo: &u.encodeB64, Text: "SQL Base64(WAF)", Checked: cfg.EncodeBase64, ColumnSpan: 2},
CheckBox{AssignTo: &u.tlsSkip, Text: "跳过 TLS 证书校验(仅排障)", Checked: cfg.TLSInsecureSkipVerify, ColumnSpan: 2},
},
},
},
},
Composite{
StretchFactor: 1,
MinSize: Size{Width: 380},
Layout: VBox{MarginsZero: true, Spacing: 8},
Children: []Widget{
GroupBox{
Title: "远程 MySQL(PHP 主机访问)",
Layout: Grid{Columns: 2, Spacing: 6},
Children: []Widget{
fieldLabel("主机"),
LineEdit{AssignTo: &u.remoteHost, Text: cfg.RemoteHost},
fieldLabel("端口"),
NumberEdit{AssignTo: &u.remotePort, Value: float64(nonzero(cfg.RemotePort, 3306)), MinValue: 1, MaxValue: 65535, Decimals: 0, MinSize: Size{Width: 90}},
fieldLabel("用户名"),
LineEdit{AssignTo: &u.remoteUser, Text: cfg.RemoteUsername},
fieldLabel("密码"),
LineEdit{AssignTo: &u.remotePass, Text: cfg.RemotePassword, PasswordMode: true},
fieldLabel("默认库"),
LineEdit{AssignTo: &u.remoteDB, Text: cfg.RemoteDatabase},
fieldLabel("字符集"),
LineEdit{AssignTo: &u.charset, Text: orDefault(cfg.RemoteCharset, "utf8mb4")},
},
},
GroupBox{
Title: "选项",
StretchFactor: 1,
Layout: Grid{Columns: 2, Spacing: 6},
Children: []Widget{
fieldLabel("超时(秒)"),
NumberEdit{AssignTo: &u.timeout, Value: float64(timeoutSec(cfg)), MinValue: 1, MaxValue: 600, Decimals: 0, MinSize: Size{Width: 90}},
fieldLabel("最大连接"),
NumberEdit{AssignTo: &u.maxConn, Value: float64(nonzero(cfg.MaxConnections, 5)), MinValue: 1, MaxValue: 256, Decimals: 0, MinSize: Size{Width: 90}},
fieldLabel("日志级别"),
ComboBox{AssignTo: &u.logLevel, Model: levels, CurrentIndex: levelIdx, Editable: false},
fieldLabel("日志文件"),
LineEdit{AssignTo: &u.logFile, Text: logFile},
CheckBox{AssignTo: &u.logSQL, Text: "记录 SQL(仍会脱敏)", Checked: cfg.LogSQL, ColumnSpan: 2},
},
},
},
},
},
},
Composite{
Layout: HBox{Spacing: 8},
Children: []Widget{
PushButton{AssignTo: &u.saveBtn, Text: "保存", MinSize: Size{Width: 88}},
PushButton{AssignTo: &u.testBtn, Text: "测试连接", MinSize: Size{Width: 88}},
PushButton{AssignTo: &u.startBtn, Text: "启动", MinSize: Size{Width: 88}},
PushButton{AssignTo: &u.stopBtn, Text: "停止", Enabled: false, MinSize: Size{Width: 88}},
HSpacer{},
},
},
GroupBox{
Title: "运行日志",
StretchFactor: 1,
MinSize: Size{Height: 140},
Layout: VBox{MarginsZero: true},
Children: []Widget{
TextEdit{AssignTo: &u.status, ReadOnly: true, VScroll: true, MaxLength: 64 * 1024},
},
},
},
}).Create(); err != nil {
return err
}
if u.saveBtn != nil {
u.saveBtn.Clicked().Attach(u.onSave)
}
if u.testBtn != nil {
u.testBtn.Clicked().Attach(u.onTest)
}
if u.startBtn != nil {
u.startBtn.Clicked().Attach(u.onStart)
}
if u.stopBtn != nil {
u.stopBtn.Clicked().Attach(u.onStop)
u.stopBtn.SetEnabled(false)
}
u.appendStatus("填写后点「保存」,再「测试连接」。通过后点「启动」,影刀连 本机地址:端口。")
u.appendStatus("最小化或关闭窗口会藏到系统托盘(监听可继续)。托盘左键打开,右键退出。")
for _, w := range cfg.Warnings {
u.appendStatus("注意: " + w)
}
if err := u.applyBranding(); err != nil {
return err
}
ni, err := walk.NewNotifyIcon(u.mw)
if err != nil {
return err
}
u.ni = ni
if u.appIcon != nil {
_ = ni.SetIcon(u.appIcon)
_ = u.mw.SetIcon(u.appIcon)
}
_ = ni.SetToolTip("MySQL Tunnel")
_ = ni.SetVisible(true)
ni.MouseDown().Attach(func(x, y int, button walk.MouseButton) {
if button == walk.LeftButton {
u.showWindow()
}
})
showAct := walk.NewAction()
_ = showAct.SetText("打开窗口")
showAct.Triggered().Attach(u.showWindow)
exitAct := walk.NewAction()
_ = exitAct.SetText("退出")
exitAct.Triggered().Attach(u.onExit)
if menu := ni.ContextMenu(); menu != nil {
_ = menu.Actions().Add(showAct)
_ = menu.Actions().Add(exitAct)
}
// Minimize → tray (not taskbar). Close also → tray unless exiting.
u.mw.SizeChanged().Attach(func() {
hwnd := win.HWND(u.mw.Handle())
if win.IsIconic(hwnd) {
u.hideToTray()
}
})
u.mw.Closing().Attach(func(canceled *bool, reason walk.CloseReason) {
u.mu.Lock()
exiting := u.exiting
u.mu.Unlock()
if exiting {
return
}
*canceled = true
u.hideToTray()
})
return nil
}
func (u *ui) hideToTray() {
if u.mw == nil {
return
}
u.mw.Hide()
hwnd := win.HWND(u.mw.Handle())
win.ShowWindow(hwnd, win.SW_HIDE)
if u.ni != nil && !u.hidOnce {
u.hidOnce = true
_ = u.ni.ShowInfo("MySQL Tunnel", "已放到系统托盘。左键打开窗口,右键退出。")
}
}
func (u *ui) showWindow() {
if u.mw == nil {
return
}
u.mw.Show()
hwnd := win.HWND(u.mw.Handle())
win.ShowWindow(hwnd, win.SW_RESTORE)
win.SetForegroundWindow(hwnd)
}
func (u *ui) onExit() {
u.mu.Lock()
u.exiting = true
u.mu.Unlock()
u.stopServer()
if u.ni != nil {
_ = u.ni.SetVisible(false)
}
u.mw.Close()
}
func (u *ui) collect() (*config.Config, error) {
c := config.Defaults()
c.Path = u.cfgPath
c.ListenHost = strings.TrimSpace(lineText(u.listenHost))
c.ListenPort = numValue(u.listenPort, config.DefaultListenPort)
c.LocalUsername = strings.TrimSpace(lineText(u.localUser))
c.LocalPassword = lineText(u.localPass)
c.TunnelURL = strings.TrimSpace(lineText(u.tunnelURL))
c.EncodeBase64 = boxChecked(u.encodeB64)
c.TLSInsecureSkipVerify = boxChecked(u.tlsSkip)
c.RemoteHost = strings.TrimSpace(lineText(u.remoteHost))
c.RemotePort = numValue(u.remotePort, 3306)
c.RemoteUsername = strings.TrimSpace(lineText(u.remoteUser))
c.RemotePassword = lineText(u.remotePass)
c.RemoteDatabase = strings.TrimSpace(lineText(u.remoteDB))
c.RemoteCharset = strings.TrimSpace(lineText(u.charset))
c.RequestTimeout = time.Duration(numValue(u.timeout, config.DefaultTimeoutSec)) * time.Second
c.MaxConnections = numValue(u.maxConn, config.DefaultMaxConnections)
if u.logLevel != nil {
c.LogLevel = strings.ToLower(strings.TrimSpace(u.logLevel.Text()))
}
if c.LogLevel == "" {
c.LogLevel = config.DefaultLogLevel
}
c.LogSQL = boxChecked(u.logSQL)
c.LogFile = strings.TrimSpace(lineText(u.logFile))
if c.LogFile == "" {
c.LogFile = config.DefaultLogFile
}
if err := c.Validate(); err != nil {
return &c, err
}
c.ResolvePaths()
return &c, nil
}
func (u *ui) onSave() {
c, err := u.collect()
if err != nil {
u.appendStatus("保存失败: " + err.Error())
walk.MsgBox(u.mw, "MySQL Tunnel", err.Error(), walk.MsgBoxIconError)
return
}
if err := config.Save(u.cfgPath, c); err != nil {
u.appendStatus("保存失败: " + err.Error())
walk.MsgBox(u.mw, "MySQL Tunnel", err.Error(), walk.MsgBoxIconError)
return
}
u.appendStatus("已保存 " + u.cfgPath)
for _, w := range c.Warnings {
u.appendStatus("注意: " + w)
}
}
func (u *ui) onTest() {
c, err := u.collect()
if err != nil {
u.appendStatus("无法测试: " + err.Error())
walk.MsgBox(u.mw, "MySQL Tunnel", "无法测试:\n"+err.Error(), walk.MsgBoxIconError)
return
}
u.setBusy(true)
u.setHint("正在测试隧道…")
u.appendStatus("正在测试 " + config.RedactedTunnelURL(c.TunnelURL) + " …")
go func() {
defer func() {
if rec := recover(); rec != nil {
u.mw.Synchronize(func() {
msg := fmt.Sprintf("测试异常: %v", rec)
u.appendStatus(msg)
u.setHint("测试异常")
walk.MsgBox(u.mw, "测试结果", msg, walk.MsgBoxIconError)
u.setBusy(false)
})
}
}()
log, _ := logger.New(c.LogFile, c.LogLevel, c.LogSQL)
r := app.Test(context.Background(), c, log)
if log != nil {
_ = log.Close()
}
u.mw.Synchronize(func() {
u.appendStatus(strings.ReplaceAll(r.Text(), "\n", "\r\n"))
icon := walk.MsgBoxIconError
hint := "测试未通过"
if r.OK() {
icon = walk.MsgBoxIconInformation
hint = "测试通过"
u.appendStatus("测试通过,可以启动监听。")
} else {
u.appendStatus("测试未通过。")
}
u.setHint(hint)
walk.MsgBox(u.mw, "测试结果", strings.ReplaceAll(r.Text(), "\r\n", "\n"), icon)
u.setBusy(false)
})
}()
}
func (u *ui) onStart() {
u.mu.Lock()
if u.running {
u.mu.Unlock()
return
}
u.mu.Unlock()
c, err := u.collect()
if err != nil {
u.appendStatus("无法启动: " + err.Error())
walk.MsgBox(u.mw, "MySQL Tunnel", err.Error(), walk.MsgBoxIconError)
return
}
log, err := logger.New(c.LogFile, c.LogLevel, c.LogSQL)
if err != nil {
u.appendStatus("日志: " + err.Error())
return
}
ctx, cancel := context.WithCancel(context.Background())
u.mu.Lock()
u.cancel = cancel
u.log = log
u.running = true
u.mu.Unlock()
u.refreshButtons()
u.appendStatus(fmt.Sprintf("正在监听 %s:%d 影刀用户=%s", c.ListenHost, c.ListenPort, c.LocalUsername))
u.appendStatus("隧道: " + config.RedactedTunnelURL(c.TunnelURL))
if u.ni != nil {
_ = u.ni.SetToolTip(fmt.Sprintf("MySQL Tunnel · %s:%d", c.ListenHost, c.ListenPort))
}
go func() {
err := app.Serve(ctx, c, log)
u.mw.Synchronize(func() {
u.mu.Lock()
u.running = false
u.cancel = nil
if u.log != nil {
_ = u.log.Close()
u.log = nil
}
u.mu.Unlock()
if err != nil && ctx.Err() == nil {
u.appendStatus("监听结束: " + logger.RedactError(err))
} else {
u.appendStatus("监听已停止")
}
if u.ni != nil {
_ = u.ni.SetToolTip("MySQL Tunnel")
}
u.refreshButtons()
})
}()
}
func (u *ui) onStop() {
u.stopServer()
}
func (u *ui) stopServer() {
u.mu.Lock()
cancel := u.cancel
u.mu.Unlock()
if cancel != nil {
cancel()
}
}
func (u *ui) setBusy(b bool) {
u.mu.Lock()
u.busy = b
u.mu.Unlock()
u.refreshButtons()
}
func (u *ui) refreshButtons() {
u.mu.Lock()
running, busy := u.running, u.busy
u.mu.Unlock()
if u.saveBtn != nil {
u.saveBtn.SetEnabled(!busy)
}
if u.testBtn != nil {
u.testBtn.SetEnabled(!busy)
if busy {
_ = u.testBtn.SetText("测试中…")
} else {
_ = u.testBtn.SetText("测试连接")
}
}
if u.startBtn != nil {
u.startBtn.SetEnabled(!running && !busy)
}
if u.stopBtn != nil {
u.stopBtn.SetEnabled(running)
}
}
func (u *ui) setHint(s string) {
if u.hint == nil {
return
}
_ = u.hint.SetText(s)
}
func (u *ui) appendStatus(line string) {
line = strings.ReplaceAll(strings.TrimRight(line, "\r\n"), "\n", "\r\n")
if u.status != nil {
prev := u.status.Text()
if prev != "" {
prev += "\r\n"
}
text := prev + time.Now().Format("15:04:05") + " " + line
_ = u.status.SetText(text)
end := len([]rune(text))
u.status.SetTextSelection(end, end)
}
first := line
if i := strings.Index(first, "\r\n"); i >= 0 {
first = first[:i]
}
if len([]rune(first)) > 80 {
first = string([]rune(first)[:80]) + "…"
}
u.setHint(first)
}
func relativeLogFile(cfg *config.Config) string {
if cfg == nil || cfg.LogFile == "" {
return config.DefaultLogFile
}
if !filepath.IsAbs(cfg.LogFile) {
return filepath.ToSlash(cfg.LogFile)
}
rel, err := filepath.Rel(filepath.Dir(cfg.Path), cfg.LogFile)
if err != nil {
return cfg.LogFile
}
return filepath.ToSlash(rel)
}
func lineText(e *walk.LineEdit) string {
if e == nil {
return ""
}
return e.Text()
}
func numValue(e *walk.NumberEdit, def int) int {
if e == nil {
return def
}
v := int(e.Value())
if v == 0 {
return def
}
return v
}
func boxChecked(b *walk.CheckBox) bool {
return b != nil && b.Checked()
}
func nonzero(v, def int) int {
if v == 0 {
return def
}
return v
}
func orDefault(s, def string) string {
if strings.TrimSpace(s) == "" {
return def
}
return s
}
func fieldLabel(text string) Label {
return Label{Text: text, Alignment: AlignHFarVCenter, MinSize: Size{Width: 72}}
}
func (u *ui) applyBranding() error {
if len(logoPNG) == 0 {
return nil
}
img, err := png.Decode(bytes.NewReader(logoPNG))
if err != nil {
return nil
}
if ico, err := walk.NewIconFromImage(img); err == nil {
u.appIcon = ico
if u.mw != nil {
_ = u.mw.SetIcon(ico)
}
}
if bmp, err := walk.NewBitmapFromImage(img); err == nil {
u.appBmp = bmp
if u.logoView != nil {
_ = u.logoView.SetImage(bmp)
}
}
return nil
}
func timeoutSec(cfg *config.Config) int {
if cfg == nil || cfg.RequestTimeout <= 0 {
return config.DefaultTimeoutSec
}
return int(cfg.RequestTimeout.Seconds())
}
func hideConsoleIfStandalone() {
var pids [16]uint32
n, _, _ := procGetConsoleProcessList.Call(uintptr(unsafe.Pointer(&pids[0])), uintptr(len(pids)))
if n == 0 || n > 1 {
return
}
hwnd, _, _ := procGetConsoleWindow.Call()
if hwnd == 0 {
return
}
procShowWindow.Call(hwnd, 0)
}
var (
user32 = windows.NewLazySystemDLL("user32.dll")
kernel32 = windows.NewLazySystemDLL("kernel32.dll")
procShowWindow = user32.NewProc("ShowWindow")
procGetConsoleWindow = kernel32.NewProc("GetConsoleWindow")
procGetConsoleProcessList = kernel32.NewProc("GetConsoleProcessList")
)
Binary file not shown.

After

Width:  |  Height:  |  Size: 182 KiB