feat(gui): redesign kanholec GUI with modern interface and WiX v7 installer
golangci-lint / lint (push) Failing after 4s

- Complete GUI overhaul with Fyne framework
  * Modern dark theme with indigo/pink accent colors
  * 4-step setup wizard (Welcome → Server → Auth → Finish)
  * Dashboard with proxy list, real-time logs, and config viewer
  * Windows Service manager (install/start/stop/uninstall)
  * System tray integration with quick controls

- WiX v7 MSI installer
  * Auto-registers as Windows Service (manual start)
  * Feature tree UI for optional components
  * Desktop shortcut and PATH options
  * Build script: packaging/windows/build-msi.ps1

- Build system updates
  * Added kanholec-windows-gui target (CGO required)
  * Added kanholec-windows-msi target
  * Separate main_gui.go entry point for GUI builds

GUI binary: bin/kanholec-windows-amd64.exe (31.4 MB)
MSI installer: bin/kanholec-0.69.0-amd64.msi (12.1 MB)
This commit is contained in:
akukanara
2026-05-29 22:53:41 +07:00
Unverified
parent 2cd3052da1
commit f4a88f4b2c
13 changed files with 1962 additions and 222 deletions
+504 -177
View File
@@ -3,11 +3,17 @@
package gui
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"os"
"path/filepath"
"strconv"
"sync"
"time"
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/app"
@@ -17,230 +23,551 @@ import (
"fyne.io/fyne/v2/theme"
"fyne.io/fyne/v2/widget"
"kanhole/client"
"kanhole/pkg/config"
"kanhole/pkg/config/source"
v1 "kanhole/pkg/config/v1"
"kanhole/pkg/policy/security"
"kanhole/pkg/util/log"
"kanhole/pkg/util/version"
)
type AppState int
const (
StateSetup AppState = iota
StateDashboard
)
type App struct {
fyneApp fyne.App
window fyne.Window
fyneApp fyne.App
window fyne.Window
wizard *Wizard
dashboard *Dashboard
tray *TrayManager
svcMgr *ServiceManager
serverURL *widget.Entry
authToken *widget.Entry
clientKey *widget.Entry
statusLabel *widget.Label
configView *widget.Entry
proxyList *widget.List
startBtn *widget.Button
stopBtn *widget.Button
connectBtn *widget.Button
mainContainer *fyne.Container
state AppState
mu sync.Mutex
running bool
configData string
mu sync.Mutex
running bool
configData string
clientService *client.Service
cancelFunc context.CancelFunc
}
func New() *App {
a := &App{}
a.fyneApp = app.New()
a.window = a.fyneApp.NewWindow("kanholec GUI")
a.window.Resize(fyne.NewSize(850, 620))
a.fyneApp = app.NewWithID("io.kanhole.kanholec")
a.fyneApp.Settings().SetTheme(&kanholeTheme{})
a.window = a.fyneApp.NewWindow("kanholec")
a.window.Resize(fyne.NewSize(900, 640))
a.window.SetMaster()
a.setupUI()
a.svcMgr = NewServiceManager()
a.tray = NewTrayManager(a)
a.mainContainer = container.NewStack()
a.window.SetContent(a.mainContainer)
a.window.SetCloseIntercept(func() {
if a.tray.IsSupported() {
a.window.Hide()
} else {
a.Quit()
}
})
if a.hasSavedConfig() {
a.showDashboard()
} else {
a.showWizard()
}
return a
}
func (a *App) saveSettings() {
p := a.fyneApp.Preferences()
p.SetString("server_url", a.serverURL.Text)
func (a *App) hasSavedConfig() bool {
prefs := a.fyneApp.Preferences()
return prefs.StringWithFallback("server_addr", "") != ""
}
func (a *App) loadSettings() {
p := a.fyneApp.Preferences()
if url := p.StringWithFallback("server_url", "http://localhost:7500"); url != "" {
a.serverURL.SetText(url)
}
func (a *App) showWizard() {
a.state = StateSetup
a.wizard = NewWizard(a, a.onWizardComplete)
a.mainContainer.Objects = []fyne.CanvasObject{a.wizard.Content()}
a.mainContainer.Refresh()
}
func (a *App) setupUI() {
a.serverURL = widget.NewEntry()
a.serverURL.SetText("http://localhost:7500")
a.serverURL.PlaceHolder = "http://frps-server:7500"
func (a *App) showDashboard() {
a.state = StateDashboard
a.dashboard = NewDashboard(a)
a.authToken = widget.NewEntry()
a.authToken.PlaceHolder = "One-time token"
prefs := a.fyneApp.Preferences()
addr := prefs.StringWithFallback("server_addr", "127.0.0.1")
port := prefs.IntWithFallback("server_port", 7000)
a.dashboard.SetServerInfo(addr, port)
a.clientKey = widget.NewEntry()
a.clientKey.PlaceHolder = "Client key (from server)"
toolbar := a.buildToolbar()
a.statusLabel = widget.NewLabel("Not connected")
a.statusLabel.Importance = widget.MediumImportance
a.configView = widget.NewMultiLineEntry()
a.configView.SetMinRowsVisible(8)
a.configView.Disable()
a.proxyList = widget.NewList(
func() int { return 0 },
func() fyne.CanvasObject { return widget.NewLabel("") },
func(id widget.ListItemID, o fyne.CanvasObject) {},
)
a.connectBtn = widget.NewButtonWithIcon("Connect", theme.NavigateNextIcon(), a.onConnect)
a.startBtn = widget.NewButtonWithIcon("Start", theme.MediaPlayIcon(), a.onStart)
a.startBtn.Disable()
a.stopBtn = widget.NewButtonWithIcon("Stop", theme.MediaStopIcon(), a.onStop)
a.stopBtn.Disable()
// Settings form
settingsForm := widget.NewForm(
widget.NewFormItem("Server URL", a.serverURL),
)
settingsForm.OnSubmit = func() { a.saveSettings() }
settingsBtn := widget.NewButtonWithIcon("Save Settings", theme.DocumentSaveIcon(), a.saveSettings)
settingsTab := container.NewVBox(
widget.NewLabelWithStyle("Connection Settings", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
settingsForm,
settingsBtn,
layout.NewSpacer(),
)
// Auth form
authForm := widget.NewForm(
widget.NewFormItem("Server URL", a.serverURL),
widget.NewFormItem("One-Time Token", a.authToken),
widget.NewFormItem("Client Key", a.clientKey),
)
statusLine := container.NewHBox(
widget.NewLabelWithStyle("Status:", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
a.statusLabel,
layout.NewSpacer(),
a.connectBtn,
)
controls := container.NewHBox(
a.startBtn,
a.stopBtn,
)
connectTab := container.NewVBox(
widget.NewLabelWithStyle("frp Client GUI", fyne.TextAlignCenter, fyne.TextStyle{Bold: true}),
authForm,
statusLine,
controls,
container.NewBorder(
widget.NewLabelWithStyle("Proxies", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
nil, nil, nil,
a.proxyList,
),
)
// Config tab
configTab := container.NewBorder(
widget.NewLabelWithStyle("Configuration (read-only)", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
content := container.NewBorder(
toolbar,
nil, nil, nil,
a.configView,
a.dashboard.Content(),
)
tabs := container.NewAppTabs(
container.NewTabItemWithIcon("Connect", theme.ComputerIcon(), connectTab),
container.NewTabItemWithIcon("Config", theme.DocumentIcon(), configTab),
container.NewTabItemWithIcon("Settings", theme.SettingsIcon(), settingsTab),
)
tabs.SelectIndex(0)
a.mainContainer.Objects = []fyne.CanvasObject{content}
a.mainContainer.Refresh()
a.window.SetContent(tabs)
a.loadSettings()
a.dashboard.AppendLog("Dashboard initialized")
a.dashboard.AppendLog(fmt.Sprintf("Server: %s:%d", addr, port))
a.tray.Start()
}
func (a *App) onConnect() {
url := strings.TrimRight(a.serverURL.Text, "/")
token := a.authToken.Text
key := a.clientKey.Text
a.saveSettings()
func (a *App) buildToolbar() fyne.CanvasObject {
logo := widget.NewLabelWithStyle("kanholec", fyne.TextAlignLeading, fyne.TextStyle{Bold: true})
ver := widget.NewLabel("v" + version.Full())
ver.Importance = widget.LowImportance
if token == "" && key == "" {
a.statusLabel.SetText("Error: provide token or client key")
settingsBtn := widget.NewButtonWithIcon("", theme.SettingsIcon(), func() {
a.showSettingsDialog()
})
settingsBtn.Importance = widget.LowImportance
serviceBtn := widget.NewButtonWithIcon("Service", nil, func() {
a.showServiceDialog()
})
resetBtn := widget.NewButtonWithIcon("Reset Setup", nil, func() {
dialog.ShowConfirm("Reset Setup",
"This will clear your configuration and restart the setup wizard. Continue?",
func(ok bool) {
if ok {
a.clearSavedConfig()
a.showWizard()
}
}, a.window)
})
resetBtn.Importance = widget.LowImportance
return container.NewVBox(
container.NewHBox(
logo,
widget.NewLabel(""),
ver,
layout.NewSpacer(),
serviceBtn,
widget.NewLabel(""),
settingsBtn,
widget.NewLabel(""),
resetBtn,
),
widget.NewLabel(""),
widget.NewSeparator(),
widget.NewLabel(""),
)
}
func (a *App) onWizardComplete(result WizardResult) {
prefs := a.fyneApp.Preferences()
prefs.SetString("server_addr", result.ServerAddr)
port, _ := strconv.Atoi(result.ServerPort)
if port == 0 {
port = 7000
}
prefs.SetInt("server_port", port)
prefs.SetString("auth_method", result.AuthMethod)
prefs.SetString("auth_token", result.Token)
prefs.SetString("auth_user", result.Username)
prefs.SetString("auth_pass", result.Password)
prefs.SetString("client_name", result.ClientName)
a.showDashboard()
a.dashboard.AppendLog("Setup completed. Fetching configuration...")
go func() {
a.fetchAndSaveConfig(result)
time.Sleep(500 * time.Millisecond)
a.startClient()
}()
}
func (a *App) fetchAndSaveConfig(result WizardResult) {
serverURL := fmt.Sprintf("http://%s:%s", result.ServerAddr, "7500")
var configData []byte
var err error
a.dashboard.AppendLog(fmt.Sprintf("Connecting to %s...", serverURL))
if result.AuthMethod == "Token" && result.Token != "" {
a.dashboard.AppendLog("Authenticating with token...")
configData, err = a.authWithToken(serverURL, result.Token)
} else if result.AuthMethod == "Username/Password" {
a.dashboard.AppendLog("Authenticating with credentials...")
configData, err = a.authWithCredentials(serverURL, result.Username, result.Password, result.ClientName)
}
if err != nil {
a.dashboard.AppendLog(fmt.Sprintf("Auth error: %v", err))
a.dashboard.AppendLog("You can still start the client with manual configuration.")
return
}
if token != "" {
apiURL := url + "/admin/api/client/auth"
body := fmt.Sprintf(`{"token":"%s"}`, token)
resp, err := http.Post(apiURL, "application/json", strings.NewReader(body))
if err != nil {
a.statusLabel.SetText(fmt.Sprintf("Error: %v", err))
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
respBody, _ := io.ReadAll(resp.Body)
a.statusLabel.SetText(fmt.Sprintf("Auth failed (%d): %s", resp.StatusCode, string(respBody)))
return
}
configData, _ := io.ReadAll(resp.Body)
if configData != nil {
a.configData = string(configData)
a.configView.SetText(a.configData)
a.statusLabel.SetText("Connected (token auth)")
a.startBtn.Enable()
a.connectBtn.SetText("Reconnect")
} else if key != "" {
apiURL := fmt.Sprintf("%s/admin/api/frpc/proxy-config/%s", url, key)
resp, err := http.Get(apiURL)
if err != nil {
a.statusLabel.SetText(fmt.Sprintf("Error: %v", err))
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
a.statusLabel.SetText(fmt.Sprintf("Config fetch failed (%d)", resp.StatusCode))
return
}
configData, _ := io.ReadAll(resp.Body)
a.configData = string(configData)
a.configView.SetText(a.configData)
a.statusLabel.SetText("Connected (key auth)")
a.startBtn.Enable()
a.connectBtn.SetText("Reconnect")
a.saveConfigToFile(configData)
a.dashboard.SetConfig(a.configData)
a.dashboard.AppendLog("Configuration fetched successfully")
a.dashboard.AppendLog("Configuration saved to disk")
}
}
func (a *App) onStart() {
if a.configData == "" {
a.statusLabel.SetText("Error: no config loaded")
func (a *App) authWithToken(serverURL, token string) ([]byte, error) {
apiURL := serverURL + "/admin/api/client/auth"
body, _ := json.Marshal(map[string]string{"token": token})
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, bytes.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, fmt.Errorf("connection failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
respBody, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("auth failed (HTTP %d): %s", resp.StatusCode, string(respBody))
}
return io.ReadAll(resp.Body)
}
func (a *App) authWithCredentials(serverURL, username, password, clientName string) ([]byte, error) {
apiURL := serverURL + "/admin/api/client/auth"
body, _ := json.Marshal(map[string]string{
"username": username,
"password": password,
"client_name": clientName,
})
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, bytes.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, fmt.Errorf("connection failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
respBody, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("auth failed (HTTP %d): %s", resp.StatusCode, string(respBody))
}
return io.ReadAll(resp.Body)
}
func (a *App) saveConfigToFile(data []byte) {
configDir := a.getConfigDir()
os.MkdirAll(configDir, 0755)
configPath := filepath.Join(configDir, "kanholec.toml")
os.WriteFile(configPath, data, 0644)
}
func (a *App) getConfigDir() string {
if os.Getenv("ProgramData") != "" {
return filepath.Join(os.Getenv("ProgramData"), "kanholec")
}
home, _ := os.UserHomeDir()
return filepath.Join(home, ".kanholec")
}
func (a *App) loadSavedConfig() ([]byte, error) {
configPath := filepath.Join(a.getConfigDir(), "kanholec.toml")
return os.ReadFile(configPath)
}
func (a *App) startClient() {
a.mu.Lock()
defer a.mu.Unlock()
if a.running {
a.dashboard.AppendLog("Client is already running")
return
}
a.dashboard.AppendLog("Starting client...")
var cfgData []byte
var err error
if a.configData != "" {
a.dashboard.AppendLog("Using fetched configuration")
cfgData = []byte(a.configData)
} else {
a.dashboard.AppendLog("Loading saved configuration from disk...")
cfgData, err = a.loadSavedConfig()
if err != nil {
a.dashboard.AppendLog(fmt.Sprintf("No configuration available: %v", err))
a.dashboard.AppendLog("Please complete setup wizard or add manual configuration")
return
}
a.dashboard.AppendLog("Configuration loaded from disk")
}
var cfg v1.ClientConfig
if err := config.LoadConfigure([]byte(a.configData), &cfg, false, "toml"); err != nil {
dialog.ShowError(fmt.Errorf("invalid config: %w", err), a.window)
if err := config.LoadConfigure(cfgData, &cfg, false, "toml"); err != nil {
a.dashboard.AppendLog(fmt.Sprintf("Invalid configuration: %v", err))
return
}
a.mu.Lock()
a.running = true
a.mu.Unlock()
result := &config.ClientConfigLoadResult{
Common: &cfg.ClientCommonConfig,
Proxies: make([]v1.ProxyConfigurer, 0),
}
for _, c := range cfg.Proxies {
result.Proxies = append(result.Proxies, c.ProxyConfigurer)
}
a.startBtn.Disable()
a.stopBtn.Enable()
a.statusLabel.SetText("Running")
a.dashboard.AppendLog(fmt.Sprintf("Loaded %d proxies from configuration", len(result.Proxies)))
configSrc := source.NewConfigSource()
if err := configSrc.ReplaceAll(result.Proxies, result.Visitors); err != nil {
a.dashboard.AppendLog(fmt.Sprintf("Config source error: %v", err))
return
}
aggregator := source.NewAggregator(configSrc)
unsafeFeatures := security.NewUnsafeFeatures(nil)
log.InitLogger(cfg.Log.To, cfg.Log.Level, int(cfg.Log.MaxDays), cfg.Log.DisablePrintColor)
ctx, cancel := context.WithCancel(context.Background())
a.cancelFunc = cancel
svr, err := client.NewService(client.ServiceOptions{
Common: &cfg.ClientCommonConfig,
ConfigSourceAggregator: aggregator,
UnsafeFeatures: unsafeFeatures,
})
if err != nil {
cancel()
a.dashboard.AppendLog(fmt.Sprintf("Service creation failed: %v", err))
return
}
a.clientService = svr
a.running = true
a.dashboard.AppendLog("Client service created, connecting...")
go func() {
a.dashboard.SetRunning(true)
a.dashboard.AppendLog("Client started")
if err := svr.Run(ctx); err != nil {
a.dashboard.AppendLog(fmt.Sprintf("Client error: %v", err))
}
a.mu.Lock()
a.running = false
a.mu.Unlock()
a.dashboard.SetRunning(false)
a.dashboard.AppendLog("Client stopped")
}()
}
func (a *App) onStop() {
func (a *App) stopClient() {
a.mu.Lock()
a.running = false
a.mu.Unlock()
defer a.mu.Unlock()
a.startBtn.Enable()
a.stopBtn.Disable()
a.statusLabel.SetText("Stopped")
if !a.running || a.cancelFunc == nil {
return
}
a.cancelFunc()
a.running = false
a.dashboard.SetRunning(false)
a.dashboard.AppendLog("Client stop requested")
}
func (a *App) reconnect() {
a.stopClient()
time.Sleep(500 * time.Millisecond)
prefs := a.fyneApp.Preferences()
result := WizardResult{
ServerAddr: prefs.StringWithFallback("server_addr", ""),
ServerPort: strconv.Itoa(prefs.IntWithFallback("server_port", 7000)),
AuthMethod: prefs.StringWithFallback("auth_method", "Token"),
Token: prefs.StringWithFallback("auth_token", ""),
Username: prefs.StringWithFallback("auth_user", ""),
Password: prefs.StringWithFallback("auth_pass", ""),
ClientName: prefs.StringWithFallback("client_name", ""),
}
a.fetchAndSaveConfig(result)
a.startClient()
}
func (a *App) showSettingsDialog() {
prefs := a.fyneApp.Preferences()
addrEntry := widget.NewEntry()
addrEntry.SetText(prefs.StringWithFallback("server_addr", ""))
portEntry := widget.NewEntry()
portEntry.SetText(strconv.Itoa(prefs.IntWithFallback("server_port", 7000)))
tokenEntry := widget.NewEntry()
tokenEntry.SetText(prefs.StringWithFallback("auth_token", ""))
form := dialog.NewForm("Settings", "Save", "Cancel",
[]*widget.FormItem{
widget.NewFormItem("Server Address", addrEntry),
widget.NewFormItem("Server Port", portEntry),
widget.NewFormItem("Auth Token", tokenEntry),
},
func(ok bool) {
if !ok {
return
}
prefs.SetString("server_addr", addrEntry.Text)
port, _ := strconv.Atoi(portEntry.Text)
if port > 0 {
prefs.SetInt("server_port", port)
}
prefs.SetString("auth_token", tokenEntry.Text)
a.dashboard.SetServerInfo(addrEntry.Text, port)
a.dashboard.AppendLog("Settings saved")
},
a.window,
)
form.Resize(fyne.NewSize(450, 300))
form.Show()
}
func (a *App) showServiceDialog() {
installed := a.svcMgr.IsInstalled()
running := a.svcMgr.IsRunning()
statusText := "Not installed"
if installed {
if running {
statusText = "Installed and Running"
} else {
statusText = "Installed (Stopped)"
}
}
statusLabel := widget.NewLabelWithStyle("Service: "+statusText, fyne.TextAlignLeading, fyne.TextStyle{Bold: true})
var buttons []fyne.CanvasObject
if !installed {
installBtn := widget.NewButton("Install Service", func() {
configPath := a.svcMgr.GetConfigPath()
if err := a.svcMgr.Install(configPath); err != nil {
a.showError(fmt.Sprintf("Install failed: %v", err))
} else {
a.dashboard.AppendLog("Windows service installed")
statusLabel.SetText("Service: Installed (Stopped)")
}
})
installBtn.Importance = widget.HighImportance
buttons = append(buttons, installBtn)
} else {
if !running {
startBtn := widget.NewButton("Start Service", func() {
if err := a.svcMgr.Start(); err != nil {
a.showError(fmt.Sprintf("Start failed: %v", err))
} else {
a.dashboard.AppendLog("Windows service started")
statusLabel.SetText("Service: Installed and Running")
}
})
startBtn.Importance = widget.HighImportance
buttons = append(buttons, startBtn)
} else {
stopBtn := widget.NewButton("Stop Service", func() {
if err := a.svcMgr.Stop(); err != nil {
a.showError(fmt.Sprintf("Stop failed: %v", err))
} else {
a.dashboard.AppendLog("Windows service stopped")
statusLabel.SetText("Service: Installed (Stopped)")
}
})
buttons = append(buttons, stopBtn)
}
uninstallBtn := widget.NewButton("Uninstall Service", func() {
dialog.ShowConfirm("Uninstall Service",
"Remove the kanholec Windows service?",
func(ok bool) {
if ok {
if err := a.svcMgr.Uninstall(); err != nil {
a.showError(fmt.Sprintf("Uninstall failed: %v", err))
} else {
a.dashboard.AppendLog("Windows service uninstalled")
statusLabel.SetText("Service: Not installed")
}
}
}, a.window)
})
uninstallBtn.Importance = widget.DangerImportance
buttons = append(buttons, uninstallBtn)
}
content := container.NewVBox(
statusLabel,
widget.NewSeparator(),
container.NewHBox(buttons...),
)
d := dialog.NewCustom("Windows Service", "Close", content, a.window)
d.Resize(fyne.NewSize(400, 200))
d.Show()
}
func (a *App) showError(msg string) {
if a.window != nil {
dialog.ShowError(fmt.Errorf("%s", msg), a.window)
}
if a.dashboard != nil {
a.dashboard.AppendLog("Error: " + msg)
}
}
func (a *App) clearSavedConfig() {
prefs := a.fyneApp.Preferences()
prefs.SetString("server_addr", "")
prefs.SetInt("server_port", 0)
prefs.SetString("auth_method", "")
prefs.SetString("auth_token", "")
prefs.SetString("auth_user", "")
prefs.SetString("auth_pass", "")
prefs.SetString("client_name", "")
a.configData = ""
}
func (a *App) Quit() {
a.stopClient()
a.tray.Stop()
a.fyneApp.Quit()
}
func (a *App) Run() {
@@ -249,6 +576,6 @@ func (a *App) Run() {
func Run() {
gui := New()
log.Infof("starting frpc GUI")
log.Infof("starting kanholec GUI v%s", version.Full())
gui.Run()
}