Files
2026-09-21 16:46:55 +08:00

693 lines
18 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//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")
)