Files
akukanara f4a88f4b2c
golangci-lint / lint (push) Failing after 4s
feat(gui): redesign kanholec GUI with modern interface and WiX v7 installer
- 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)
2026-05-29 22:53:41 +07:00

582 lines
15 KiB
Go

//go:build kanholec_gui
package gui
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strconv"
"sync"
"time"
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/app"
"fyne.io/fyne/v2/container"
"fyne.io/fyne/v2/dialog"
"fyne.io/fyne/v2/layout"
"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
wizard *Wizard
dashboard *Dashboard
tray *TrayManager
svcMgr *ServiceManager
mainContainer *fyne.Container
state AppState
mu sync.Mutex
running bool
configData string
clientService *client.Service
cancelFunc context.CancelFunc
}
func New() *App {
a := &App{}
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.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) hasSavedConfig() bool {
prefs := a.fyneApp.Preferences()
return prefs.StringWithFallback("server_addr", "") != ""
}
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) showDashboard() {
a.state = StateDashboard
a.dashboard = NewDashboard(a)
prefs := a.fyneApp.Preferences()
addr := prefs.StringWithFallback("server_addr", "127.0.0.1")
port := prefs.IntWithFallback("server_port", 7000)
a.dashboard.SetServerInfo(addr, port)
toolbar := a.buildToolbar()
content := container.NewBorder(
toolbar,
nil, nil, nil,
a.dashboard.Content(),
)
a.mainContainer.Objects = []fyne.CanvasObject{content}
a.mainContainer.Refresh()
a.dashboard.AppendLog("Dashboard initialized")
a.dashboard.AppendLog(fmt.Sprintf("Server: %s:%d", addr, port))
a.tray.Start()
}
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
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 configData != nil {
a.configData = string(configData)
a.saveConfigToFile(configData)
a.dashboard.SetConfig(a.configData)
a.dashboard.AppendLog("Configuration fetched successfully")
a.dashboard.AppendLog("Configuration saved to disk")
}
}
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(cfgData, &cfg, false, "toml"); err != nil {
a.dashboard.AppendLog(fmt.Sprintf("Invalid configuration: %v", err))
return
}
result := &config.ClientConfigLoadResult{
Common: &cfg.ClientCommonConfig,
Proxies: make([]v1.ProxyConfigurer, 0),
}
for _, c := range cfg.Proxies {
result.Proxies = append(result.Proxies, c.ProxyConfigurer)
}
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) stopClient() {
a.mu.Lock()
defer a.mu.Unlock()
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() {
a.window.ShowAndRun()
}
func Run() {
gui := New()
log.Infof("starting kanholec GUI v%s", version.Full())
gui.Run()
}