//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") )