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
+12 -6
View File
@@ -47,13 +47,19 @@ kanholec-windows:
kanholec-windows-arm64: kanholec-windows-arm64:
env CGO_ENABLED=0 GOOS=windows GOARCH=arm64 go build -trimpath -ldflags "$(LDFLAGS)" -tags "kanholec$(NOWEB_TAG)" -o bin/kanholec-windows-arm64.exe ./cmd/kanholec env CGO_ENABLED=0 GOOS=windows GOARCH=arm64 go build -trimpath -ldflags "$(LDFLAGS)" -tags "kanholec$(NOWEB_TAG)" -o bin/kanholec-windows-arm64.exe ./cmd/kanholec
kanholec-windows-msi: kanholec-windows kanholec-windows-gui:
wixl -o bin/kanholec-$(KANHOLE_VERSION).msi packaging/windows/kanholec.wxs 2>/dev/null \ env CGO_ENABLED=1 GOOS=windows GOARCH=amd64 go build -trimpath -ldflags "$(LDFLAGS)" -tags "kanholec,kanholec_gui$(NOWEB_TAG)" -o bin/kanholec-windows-amd64.exe ./cmd/kanholec
|| { echo "wixl failed. Try: apt install msitools"; \
echo "Or on Windows: candle kanholec.wxs -o kanholec.wixobj && light kanholec.wixobj -o kanholec.msi"; \
exit 1; }
.PHONY: kanholec-windows kanholec-windows-arm64 kanholec-windows-msi kanholec-windows-gui-arm64:
env CGO_ENABLED=1 GOOS=windows GOARCH=arm64 go build -trimpath -ldflags "$(LDFLAGS)" -tags "kanholec,kanholec_gui$(NOWEB_TAG)" -o bin/kanholec-windows-arm64.exe ./cmd/kanholec
kanholec-windows-msi: kanholec-windows-gui
powershell -ExecutionPolicy Bypass -File packaging/windows/build-msi.ps1 -Arch amd64 -SkipBuild
kanholec-windows-msi-arm64: kanholec-windows-gui-arm64
powershell -ExecutionPolicy Bypass -File packaging/windows/build-msi.ps1 -Arch arm64 -SkipBuild
.PHONY: kanholec-windows kanholec-windows-arm64 kanholec-windows-msi kanholec-windows-msi-arm64 kanholec-windows-gui kanholec-windows-gui-arm64
test: gotest test: gotest
+488 -161
View File
@@ -3,11 +3,17 @@
package gui package gui
import ( import (
"bytes"
"context"
"encoding/json"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
"strings" "os"
"path/filepath"
"strconv"
"sync" "sync"
"time"
"fyne.io/fyne/v2" "fyne.io/fyne/v2"
"fyne.io/fyne/v2/app" "fyne.io/fyne/v2/app"
@@ -17,230 +23,551 @@ import (
"fyne.io/fyne/v2/theme" "fyne.io/fyne/v2/theme"
"fyne.io/fyne/v2/widget" "fyne.io/fyne/v2/widget"
"kanhole/client"
"kanhole/pkg/config" "kanhole/pkg/config"
"kanhole/pkg/config/source"
v1 "kanhole/pkg/config/v1" v1 "kanhole/pkg/config/v1"
"kanhole/pkg/policy/security"
"kanhole/pkg/util/log" "kanhole/pkg/util/log"
"kanhole/pkg/util/version"
)
type AppState int
const (
StateSetup AppState = iota
StateDashboard
) )
type App struct { type App struct {
fyneApp fyne.App fyneApp fyne.App
window fyne.Window window fyne.Window
wizard *Wizard
dashboard *Dashboard
tray *TrayManager
svcMgr *ServiceManager
serverURL *widget.Entry mainContainer *fyne.Container
authToken *widget.Entry state AppState
clientKey *widget.Entry
statusLabel *widget.Label
configView *widget.Entry
proxyList *widget.List
startBtn *widget.Button
stopBtn *widget.Button
connectBtn *widget.Button
mu sync.Mutex mu sync.Mutex
running bool running bool
configData string configData string
clientService *client.Service
cancelFunc context.CancelFunc
} }
func New() *App { func New() *App {
a := &App{} a := &App{}
a.fyneApp = app.New() a.fyneApp = app.NewWithID("io.kanhole.kanholec")
a.window = a.fyneApp.NewWindow("kanholec GUI") a.fyneApp.Settings().SetTheme(&kanholeTheme{})
a.window.Resize(fyne.NewSize(850, 620))
a.window = a.fyneApp.NewWindow("kanholec")
a.window.Resize(fyne.NewSize(900, 640))
a.window.SetMaster() 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 return a
} }
func (a *App) saveSettings() { func (a *App) hasSavedConfig() bool {
p := a.fyneApp.Preferences() prefs := a.fyneApp.Preferences()
p.SetString("server_url", a.serverURL.Text) return prefs.StringWithFallback("server_addr", "") != ""
} }
func (a *App) loadSettings() { func (a *App) showWizard() {
p := a.fyneApp.Preferences() a.state = StateSetup
if url := p.StringWithFallback("server_url", "http://localhost:7500"); url != "" { a.wizard = NewWizard(a, a.onWizardComplete)
a.serverURL.SetText(url) 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
func (a *App) setupUI() { return container.NewVBox(
a.serverURL = widget.NewEntry() container.NewHBox(
a.serverURL.SetText("http://localhost:7500") logo,
a.serverURL.PlaceHolder = "http://frps-server:7500" widget.NewLabel(""),
ver,
a.authToken = widget.NewEntry() layout.NewSpacer(),
a.authToken.PlaceHolder = "One-time token" serviceBtn,
widget.NewLabel(""),
a.clientKey = widget.NewEntry()
a.clientKey.PlaceHolder = "Client key (from server)"
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, settingsBtn,
layout.NewSpacer(), widget.NewLabel(""),
) resetBtn,
// 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,
), ),
widget.NewLabel(""),
widget.NewSeparator(),
widget.NewLabel(""),
) )
// Config tab
configTab := container.NewBorder(
widget.NewLabelWithStyle("Configuration (read-only)", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
nil, nil, nil,
a.configView,
)
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.window.SetContent(tabs)
a.loadSettings()
} }
func (a *App) onConnect() { func (a *App) onWizardComplete(result WizardResult) {
url := strings.TrimRight(a.serverURL.Text, "/") prefs := a.fyneApp.Preferences()
token := a.authToken.Text prefs.SetString("server_addr", result.ServerAddr)
key := a.clientKey.Text
a.saveSettings()
if token == "" && key == "" { port, _ := strconv.Atoi(result.ServerPort)
a.statusLabel.SetText("Error: provide token or client key") 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 return
} }
if token != "" { if configData != nil {
apiURL := url + "/admin/api/client/auth" a.configData = string(configData)
body := fmt.Sprintf(`{"token":"%s"}`, token) a.saveConfigToFile(configData)
resp, err := http.Post(apiURL, "application/json", strings.NewReader(body)) 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 { if err != nil {
a.statusLabel.SetText(fmt.Sprintf("Error: %v", err)) return nil, err
return }
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() defer resp.Body.Close()
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
respBody, _ := io.ReadAll(resp.Body) respBody, _ := io.ReadAll(resp.Body)
a.statusLabel.SetText(fmt.Sprintf("Auth failed (%d): %s", resp.StatusCode, string(respBody))) return nil, fmt.Errorf("auth failed (HTTP %d): %s", resp.StatusCode, string(respBody))
return
} }
configData, _ := io.ReadAll(resp.Body) return io.ReadAll(resp.Body)
a.configData = string(configData) }
a.configView.SetText(a.configData)
a.statusLabel.SetText("Connected (token auth)") func (a *App) authWithCredentials(serverURL, username, password, clientName string) ([]byte, error) {
a.startBtn.Enable() apiURL := serverURL + "/admin/api/client/auth"
a.connectBtn.SetText("Reconnect") body, _ := json.Marshal(map[string]string{
} else if key != "" { "username": username,
apiURL := fmt.Sprintf("%s/admin/api/frpc/proxy-config/%s", url, key) "password": password,
resp, err := http.Get(apiURL) "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 { if err != nil {
a.statusLabel.SetText(fmt.Sprintf("Error: %v", err)) return nil, err
return }
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() defer resp.Body.Close()
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
a.statusLabel.SetText(fmt.Sprintf("Config fetch failed (%d)", resp.StatusCode)) respBody, _ := io.ReadAll(resp.Body)
return return nil, fmt.Errorf("auth failed (HTTP %d): %s", resp.StatusCode, string(respBody))
} }
configData, _ := io.ReadAll(resp.Body) return 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")
}
} }
func (a *App) onStart() { func (a *App) saveConfigToFile(data []byte) {
if a.configData == "" { configDir := a.getConfigDir()
a.statusLabel.SetText("Error: no config loaded") 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 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 var cfg v1.ClientConfig
if err := config.LoadConfigure([]byte(a.configData), &cfg, false, "toml"); err != nil { if err := config.LoadConfigure(cfgData, &cfg, false, "toml"); err != nil {
dialog.ShowError(fmt.Errorf("invalid config: %w", err), a.window) a.dashboard.AppendLog(fmt.Sprintf("Invalid configuration: %v", err))
return return
} }
a.mu.Lock() 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.running = true
a.mu.Unlock()
a.startBtn.Disable() a.dashboard.AppendLog("Client service created, connecting...")
a.stopBtn.Enable()
a.statusLabel.SetText("Running")
}
func (a *App) onStop() { 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.mu.Lock()
a.running = false a.running = false
a.mu.Unlock() a.mu.Unlock()
a.dashboard.SetRunning(false)
a.dashboard.AppendLog("Client stopped")
}()
}
a.startBtn.Enable() func (a *App) stopClient() {
a.stopBtn.Disable() a.mu.Lock()
a.statusLabel.SetText("Stopped") 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() { func (a *App) Run() {
@@ -249,6 +576,6 @@ func (a *App) Run() {
func Run() { func Run() {
gui := New() gui := New()
log.Infof("starting frpc GUI") log.Infof("starting kanholec GUI v%s", version.Full())
gui.Run() gui.Run()
} }
+320
View File
@@ -0,0 +1,320 @@
//go:build kanholec_gui
package gui
import (
"fmt"
"image/color"
"sync"
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/canvas"
"fyne.io/fyne/v2/container"
"fyne.io/fyne/v2/layout"
"fyne.io/fyne/v2/widget"
)
type Dashboard struct {
app *App
statusDot *canvas.Circle
statusLabel *widget.Label
statusDetail *widget.Label
proxyList *widget.List
proxyData []ProxyInfo
proxyDataMu sync.Mutex
logView *widget.Entry
logLines []string
logMu sync.Mutex
startBtn *widget.Button
stopBtn *widget.Button
reconnectBtn *widget.Button
serverInfo *widget.Label
configView *widget.Entry
content *fyne.Container
}
type ProxyInfo struct {
Name string
Type string
Status string
LocalAddr string
RemoteAddr string
Error string
}
func NewDashboard(app *App) *Dashboard {
d := &Dashboard{
app: app,
}
d.buildUI()
return d
}
func (d *Dashboard) buildUI() {
d.statusDot = canvas.NewCircle(colorPlaceholder)
d.statusDot.Resize(fyne.NewSize(16, 16))
d.statusLabel = widget.NewLabelWithStyle("Disconnected", fyne.TextAlignLeading, fyne.TextStyle{Bold: true})
d.statusDetail = widget.NewLabel("Not connected to server")
d.statusDetail.Importance = widget.LowImportance
d.serverInfo = widget.NewLabel("Server: --")
d.serverInfo.Importance = widget.LowImportance
d.configView = widget.NewMultiLineEntry()
d.configView.SetMinRowsVisible(10)
d.configView.Disable()
d.configView.SetText("No configuration loaded yet...")
d.logView = widget.NewMultiLineEntry()
d.logView.SetMinRowsVisible(15)
d.logView.Disable()
d.logView.Wrapping = fyne.TextWrapWord
d.logView.SetText("Waiting for logs...\n")
d.proxyData = []ProxyInfo{}
d.proxyList = widget.NewList(
func() int {
d.proxyDataMu.Lock()
defer d.proxyDataMu.Unlock()
return len(d.proxyData)
},
func() fyne.CanvasObject {
name := widget.NewLabelWithStyle("proxy-name", fyne.TextAlignLeading, fyne.TextStyle{Bold: true})
typ := widget.NewLabel("tcp")
typ.Importance = widget.LowImportance
status := widget.NewLabel("unknown")
local := widget.NewLabel("127.0.0.1:8080")
remote := widget.NewLabel("0.0.0.0:9090")
errLabel := widget.NewLabel("")
errLabel.Importance = widget.DangerImportance
return container.NewVBox(
container.NewHBox(name, layout.NewSpacer(), status),
widget.NewLabel(""),
container.NewHBox(typ, widget.NewSeparator(), local, widget.NewSeparator(), remote),
errLabel,
)
},
func(id widget.ListItemID, o fyne.CanvasObject) {
d.proxyDataMu.Lock()
defer d.proxyDataMu.Unlock()
if id >= len(d.proxyData) {
return
}
p := d.proxyData[id]
vbox := o.(*fyne.Container)
row1 := vbox.Objects[0].(*fyne.Container)
nameLabel := row1.Objects[0].(*widget.Label)
nameLabel.SetText(p.Name)
statusLabel := row1.Objects[2].(*widget.Label)
statusLabel.SetText(p.Status)
statusLabel.Importance = widget.MediumImportance
row2 := vbox.Objects[2].(*fyne.Container)
typLabel := row2.Objects[0].(*widget.Label)
typLabel.SetText(p.Type)
localLabel := row2.Objects[2].(*widget.Label)
localLabel.SetText(p.LocalAddr)
remoteLabel := row2.Objects[4].(*widget.Label)
remoteLabel.SetText(p.RemoteAddr)
errLabel := vbox.Objects[3].(*widget.Label)
errLabel.SetText(p.Error)
},
)
d.startBtn = widget.NewButtonWithIcon("Start", nil, d.onStart)
d.startBtn.Importance = widget.HighImportance
d.stopBtn = widget.NewButtonWithIcon("Stop", nil, d.onStop)
d.stopBtn.Importance = widget.DangerImportance
d.stopBtn.Disable()
d.reconnectBtn = widget.NewButtonWithIcon("Reconnect", nil, d.onReconnect)
statusCard := container.NewVBox(
container.NewHBox(
d.statusDot,
widget.NewLabel(""),
d.statusLabel,
layout.NewSpacer(),
d.serverInfo,
),
widget.NewLabel(""),
d.statusDetail,
)
controls := container.NewHBox(
d.startBtn,
widget.NewLabel(""),
d.stopBtn,
layout.NewSpacer(),
d.reconnectBtn,
)
proxiesTab := container.NewBorder(
container.NewVBox(
container.NewHBox(
widget.NewLabelWithStyle("Proxies", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
layout.NewSpacer(),
widget.NewButton("Refresh", func() { d.refreshProxies() }),
),
widget.NewLabel(""),
),
nil, nil, nil,
container.NewStack(
d.proxyList,
container.NewVBox(
layout.NewSpacer(),
container.NewHBox(
layout.NewSpacer(),
widget.NewLabel("No proxies configured yet. Start the client to connect."),
layout.NewSpacer(),
),
layout.NewSpacer(),
),
),
)
logTab := container.NewBorder(
container.NewVBox(
container.NewHBox(
widget.NewLabelWithStyle("Logs", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
layout.NewSpacer(),
widget.NewButton("Clear", func() { d.clearLogs() }),
),
widget.NewLabel(""),
),
nil, nil, nil,
d.logView,
)
configTab := container.NewBorder(
container.NewVBox(
widget.NewLabelWithStyle("Active Configuration", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
widget.NewLabel(""),
),
nil, nil, nil,
d.configView,
)
tabs := container.NewAppTabs(
container.NewTabItem("Proxies", proxiesTab),
container.NewTabItem("Logs", logTab),
container.NewTabItem("Config", configTab),
)
tabs.SelectIndex(0)
d.content = container.NewBorder(
container.NewVBox(
statusCard,
widget.NewLabel(""),
widget.NewSeparator(),
widget.NewLabel(""),
controls,
widget.NewLabel(""),
widget.NewSeparator(),
widget.NewLabel(""),
),
nil, nil, nil,
tabs,
)
}
func (d *Dashboard) Content() *fyne.Container {
return d.content
}
func (d *Dashboard) SetStatus(status string, detail string) {
d.statusLabel.SetText(status)
d.statusDetail.SetText(detail)
c := StatusColor(status)
d.statusDot.FillColor = c
d.statusDot.Refresh()
}
func (d *Dashboard) SetServerInfo(addr string, port int) {
d.serverInfo.SetText(fmt.Sprintf("Server: %s:%d", addr, port))
}
func (d *Dashboard) SetConfig(data string) {
d.configView.SetText(data)
}
func (d *Dashboard) AppendLog(line string) {
d.logMu.Lock()
d.logLines = append(d.logLines, line)
if len(d.logLines) > 500 {
d.logLines = d.logLines[len(d.logLines)-500:]
}
d.logMu.Unlock()
text := ""
d.logMu.Lock()
for _, l := range d.logLines {
text += l + "\n"
}
d.logMu.Unlock()
d.logView.SetText(text)
}
func (d *Dashboard) clearLogs() {
d.logMu.Lock()
d.logLines = nil
d.logMu.Unlock()
d.logView.SetText("")
}
func (d *Dashboard) SetProxies(proxies []ProxyInfo) {
d.proxyDataMu.Lock()
d.proxyData = proxies
d.proxyDataMu.Unlock()
d.proxyList.Refresh()
if len(proxies) > 0 {
d.AppendLog(fmt.Sprintf("Loaded %d proxies", len(proxies)))
}
}
func (d *Dashboard) refreshProxies() {
if d.app != nil && d.app.clientService != nil {
d.AppendLog("Refreshing proxy status...")
}
}
func (d *Dashboard) onStart() {
d.app.startClient()
}
func (d *Dashboard) onStop() {
d.app.stopClient()
}
func (d *Dashboard) onReconnect() {
d.app.reconnect()
}
func (d *Dashboard) SetRunning(running bool) {
if running {
d.startBtn.Disable()
d.stopBtn.Enable()
d.SetStatus("running", "Client is connected and running")
d.statusDot.FillColor = color.NRGBA{R: 34, G: 197, B: 94, A: 255}
} else {
d.startBtn.Enable()
d.stopBtn.Disable()
d.SetStatus("stopped", "Client is not running")
d.statusDot.FillColor = color.NRGBA{R: 239, G: 68, B: 68, A: 255}
}
d.statusDot.Refresh()
if d.app != nil && d.app.tray != nil {
d.app.tray.SetRunning(running)
}
}
+141
View File
@@ -0,0 +1,141 @@
//go:build kanholec_gui
package gui
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
)
type ServiceManager struct {
binaryPath string
serviceName string
}
func NewServiceManager() *ServiceManager {
exe, _ := os.Executable()
return &ServiceManager{
binaryPath: exe,
serviceName: "kanholec",
}
}
func (s *ServiceManager) IsInstalled() bool {
if runtime.GOOS != "windows" {
return false
}
cmd := exec.Command("sc", "query", s.serviceName)
err := cmd.Run()
return err == nil
}
func (s *ServiceManager) Install(configPath string) error {
if runtime.GOOS != "windows" {
return fmt.Errorf("service installation is only supported on Windows")
}
if s.IsInstalled() {
return fmt.Errorf("service %q is already installed", s.serviceName)
}
args := []string{
"create", s.serviceName,
"binPath=", fmt.Sprintf(`"%s" -c "%s"`, s.binaryPath, configPath),
"DisplayName=", "kanholec - kanhole Client",
"start=", "auto",
"obj=", "LocalSystem",
}
cmd := exec.Command("sc", args...)
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("failed to install service: %s: %w", string(out), err)
}
descArgs := []string{"description", s.serviceName, "kanhole reverse proxy client service"}
cmd = exec.Command("sc", descArgs...)
cmd.Run()
return nil
}
func (s *ServiceManager) Uninstall() error {
if runtime.GOOS != "windows" {
return fmt.Errorf("service management is only supported on Windows")
}
if !s.IsInstalled() {
return fmt.Errorf("service %q is not installed", s.serviceName)
}
s.Stop()
cmd := exec.Command("sc", "delete", s.serviceName)
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("failed to uninstall service: %s: %w", string(out), err)
}
return nil
}
func (s *ServiceManager) Start() error {
if runtime.GOOS != "windows" {
return fmt.Errorf("service management is only supported on Windows")
}
cmd := exec.Command("sc", "start", s.serviceName)
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("failed to start service: %s: %w", string(out), err)
}
return nil
}
func (s *ServiceManager) Stop() error {
if runtime.GOOS != "windows" {
return fmt.Errorf("service management is only supported on Windows")
}
cmd := exec.Command("sc", "stop", s.serviceName)
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("failed to stop service: %s: %w", string(out), err)
}
return nil
}
func (s *ServiceManager) IsRunning() bool {
if runtime.GOOS != "windows" {
return false
}
cmd := exec.Command("sc", "query", s.serviceName)
out, err := cmd.CombinedOutput()
if err != nil {
return false
}
return contains(string(out), "RUNNING")
}
func (s *ServiceManager) GetConfigPath() string {
programData := os.Getenv("ProgramData")
if programData == "" {
programData = `C:\ProgramData`
}
return filepath.Join(programData, "kanholec", "kanholec.toml")
}
func contains(s, substr string) bool {
return len(s) >= len(substr) && searchString(s, substr)
}
func searchString(s, substr string) bool {
for i := 0; i <= len(s)-len(substr); i++ {
if s[i:i+len(substr)] == substr {
return true
}
}
return false
}
+124
View File
@@ -0,0 +1,124 @@
//go:build kanholec_gui
package gui
import (
"image/color"
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/theme"
)
var (
colorPrimary = color.NRGBA{R: 99, G: 102, B: 241, A: 255}
colorPrimaryDark = color.NRGBA{R: 79, G: 70, B: 229, A: 255}
colorSecondary = color.NRGBA{R: 236, G: 72, B: 153, A: 255}
colorBackground = color.NRGBA{R: 17, G: 24, B: 39, A: 255}
colorSurface = color.NRGBA{R: 31, G: 41, B: 55, A: 255}
colorForeground = color.NRGBA{R: 243, G: 244, B: 246, A: 255}
colorButton = color.NRGBA{R: 55, G: 65, B: 81, A: 255}
colorHover = color.NRGBA{R: 75, G: 85, B: 99, A: 255}
colorDisabled = color.NRGBA{R: 107, G: 114, B: 128, A: 255}
colorInputBorder = color.NRGBA{R: 75, G: 85, B: 99, A: 255}
colorPlaceholder = color.NRGBA{R: 156, G: 163, B: 175, A: 255}
colorSuccess = color.NRGBA{R: 34, G: 197, B: 94, A: 255}
colorWarning = color.NRGBA{R: 251, G: 146, B: 60, A: 255}
colorError = color.NRGBA{R: 239, G: 68, B: 68, A: 255}
colorSeparator = color.NRGBA{R: 55, G: 65, B: 81, A: 255}
colorAccent = color.NRGBA{R: 139, G: 92, B: 246, A: 255}
)
type kanholeTheme struct{}
var _ fyne.Theme = (*kanholeTheme)(nil)
func (t *kanholeTheme) Color(name fyne.ThemeColorName, variant fyne.ThemeVariant) color.Color {
switch name {
case theme.ColorNameBackground:
return colorBackground
case theme.ColorNameForeground, theme.ColorNameForegroundOnPrimary:
return colorForeground
case theme.ColorNamePrimary:
return colorPrimary
case theme.ColorNameButton:
return colorButton
case theme.ColorNameHover:
return colorHover
case theme.ColorNameDisabled:
return colorDisabled
case theme.ColorNameDisabledButton:
return color.NRGBA{R: 45, G: 55, B: 72, A: 255}
case theme.ColorNameInputBorder:
return colorInputBorder
case theme.ColorNamePlaceHolder:
return colorPlaceholder
case theme.ColorNameScrollBar:
return color.NRGBA{R: 107, G: 114, B: 128, A: 180}
case theme.ColorNameShadow:
return color.NRGBA{R: 0, G: 0, B: 0, A: 100}
case theme.ColorNameSeparator:
return colorSeparator
case theme.ColorNameFocus:
return color.NRGBA{R: 99, G: 102, B: 241, A: 140}
case theme.ColorNameSelection:
return color.NRGBA{R: 99, G: 102, B: 241, A: 80}
case theme.ColorNameInputBackground:
return colorSurface
case theme.ColorNameHeaderBackground:
return color.NRGBA{R: 17, G: 24, B: 39, A: 255}
default:
return theme.DefaultTheme().Color(name, theme.VariantDark)
}
}
func (t *kanholeTheme) Font(style fyne.TextStyle) fyne.Resource {
return theme.DefaultTheme().Font(style)
}
func (t *kanholeTheme) Icon(name fyne.ThemeIconName) fyne.Resource {
return theme.DefaultTheme().Icon(name)
}
func (t *kanholeTheme) Size(name fyne.ThemeSizeName) float32 {
switch name {
case theme.SizeNamePadding:
return 10
case theme.SizeNameInnerPadding:
return 8
case theme.SizeNameLineSpacing:
return 32
case theme.SizeNameScrollBar:
return 10
case theme.SizeNameScrollBarSmall:
return 5
case theme.SizeNameText:
return 14
case theme.SizeNameHeadingText:
return 28
case theme.SizeNameSubHeadingText:
return 18
case theme.SizeNameCaptionText:
return 12
case theme.SizeNameInputBorder:
return 2
case theme.SizeNameInputRadius:
return 10
case theme.SizeNameSelectionRadius:
return 8
default:
return theme.DefaultTheme().Size(name)
}
}
func StatusColor(s string) color.Color {
switch s {
case "running", "connected", "ok", "active":
return colorSuccess
case "warning", "reconnecting":
return colorWarning
case "error", "stopped", "failed", "disconnected":
return colorError
default:
return colorPlaceholder
}
}
+133
View File
@@ -0,0 +1,133 @@
//go:build kanholec_gui
package gui
import (
"fyne.io/fyne/v2"
"fyne.io/systray"
)
type TrayManager struct {
app *App
running bool
mShow *systray.MenuItem
mStatus *systray.MenuItem
mStart *systray.MenuItem
mStop *systray.MenuItem
mQuit *systray.MenuItem
}
func NewTrayManager(app *App) *TrayManager {
return &TrayManager{app: app}
}
func (t *TrayManager) Start() {
go t.run()
}
func (t *TrayManager) run() {
defer func() {
recover()
}()
systray.Run(t.onReady, t.onExit)
}
func (t *TrayManager) onReady() {
t.running = true
systray.SetIcon(trayIconData())
systray.SetTitle("kanholec")
systray.SetTooltip("kanholec - kanhole Client")
t.mShow = systray.AddMenuItem("Show Dashboard", "Open kanholec GUI")
systray.AddSeparator()
t.mStatus = systray.AddMenuItem("Status: Disconnected", "")
t.mStatus.Disable()
systray.AddSeparator()
t.mStart = systray.AddMenuItem("Start Client", "Start the kanholec client")
t.mStop = systray.AddMenuItem("Stop Client", "Stop the kanholec client")
t.mStop.Disable()
systray.AddSeparator()
t.mQuit = systray.AddMenuItem("Quit", "Exit kanholec")
go func() {
for {
select {
case <-t.mShow.ClickedCh:
if t.app.window != nil {
t.app.window.Show()
t.app.window.RequestFocus()
}
case <-t.mStart.ClickedCh:
t.app.startClient()
t.mStart.Disable()
t.mStop.Enable()
t.mStatus.SetTitle("Status: Running")
case <-t.mStop.ClickedCh:
t.app.stopClient()
t.mStart.Enable()
t.mStop.Disable()
t.mStatus.SetTitle("Status: Stopped")
case <-t.mQuit.ClickedCh:
t.app.Quit()
systray.Quit()
return
}
}
}()
}
func (t *TrayManager) onExit() {
t.running = false
}
func (t *TrayManager) SetStatus(status string) {
if !t.running || t.mStatus == nil {
return
}
t.mStatus.SetTitle("Status: " + status)
}
func (t *TrayManager) SetRunning(running bool) {
if !t.running {
return
}
if running {
t.mStart.Disable()
t.mStop.Enable()
t.mStatus.SetTitle("Status: Running")
} else {
t.mStart.Enable()
t.mStop.Disable()
t.mStatus.SetTitle("Status: Stopped")
}
}
func (t *TrayManager) Stop() {
if t.running {
systray.Quit()
}
}
func (t *TrayManager) IsSupported() bool {
return t.running
}
func trayIconResource() *fyne.StaticResource {
return &fyne.StaticResource{
StaticName: "kanholec.ico",
StaticContent: trayIconData(),
}
}
func trayIconData() []byte {
return []byte{
0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x10, 0x10, 0x00, 0x00, 0x01, 0x00,
0x20, 0x00, 0x68, 0x04, 0x00, 0x00, 0x16, 0x00, 0x00, 0x00, 0x28, 0x00,
0x00, 0x00, 0x10, 0x00, 0x00, 0x00, 0x20, 0x00, 0x00, 0x00, 0x01, 0x00,
0x20, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x04, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
}
}
+410
View File
@@ -0,0 +1,410 @@
//go:build kanholec_gui
package gui
import (
"fmt"
"image/color"
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/canvas"
"fyne.io/fyne/v2/container"
"fyne.io/fyne/v2/layout"
"fyne.io/fyne/v2/widget"
)
type wizardStep int
const (
stepWelcome wizardStep = iota
stepServer
stepAuth
stepFinish
)
type Wizard struct {
app *App
currentStep wizardStep
container *fyne.Container
stepIndicator *fyne.Container
serverAddr *widget.Entry
serverPort *widget.Entry
authMethod *widget.Select
authToken *widget.Entry
authUser *widget.Entry
authPass *widget.Entry
clientName *widget.Entry
onComplete func(cfg WizardResult)
}
type WizardResult struct {
ServerAddr string
ServerPort string
AuthMethod string
Token string
Username string
Password string
ClientName string
}
func NewWizard(app *App, onComplete func(WizardResult)) *Wizard {
w := &Wizard{
app: app,
onComplete: onComplete,
}
w.buildUI()
return w
}
func (w *Wizard) buildUI() {
w.serverAddr = widget.NewEntry()
w.serverAddr.PlaceHolder = "127.0.0.1"
w.serverAddr.SetText("127.0.0.1")
w.serverPort = widget.NewEntry()
w.serverPort.PlaceHolder = "7000"
w.serverPort.SetText("7000")
w.authMethod = widget.NewSelect([]string{"Token", "Username/Password"}, nil)
w.authMethod.SetSelected("Token")
w.authToken = widget.NewEntry()
w.authToken.PlaceHolder = "One-time provisioning token"
w.authUser = widget.NewEntry()
w.authUser.PlaceHolder = "admin"
w.authPass = widget.NewPasswordEntry()
w.authPass.PlaceHolder = "Password"
w.clientName = widget.NewEntry()
w.clientName.PlaceHolder = "my-client"
w.authMethod.OnChanged = func(s string) {
if s == "Token" {
w.authToken.Show()
w.authUser.Hide()
w.authPass.Hide()
w.clientName.Hide()
} else {
w.authToken.Hide()
w.authUser.Show()
w.authPass.Show()
w.clientName.Show()
}
}
w.container = container.NewStack()
w.buildStepIndicator()
w.showStep(stepWelcome)
}
func (w *Wizard) buildStepIndicator() {
labels := []string{"Welcome", "Server", "Auth", "Finish"}
items := []fyne.CanvasObject{}
for i, label := range labels {
isActive := i == int(w.currentStep)
isCompleted := i < int(w.currentStep)
var dotColor color.Color
if isActive {
dotColor = colorPrimary
} else if isCompleted {
dotColor = colorSuccess
} else {
dotColor = color.NRGBA{R: 75, G: 85, B: 99, A: 255}
}
dot := canvas.NewCircle(dotColor)
dot.Resize(fyne.NewSize(16, 16))
lbl := widget.NewLabel(label)
lbl.Alignment = fyne.TextAlignCenter
if isActive {
lbl.TextStyle = fyne.TextStyle{Bold: true}
lbl.Importance = widget.HighImportance
} else if isCompleted {
lbl.Importance = widget.MediumImportance
} else {
lbl.Importance = widget.LowImportance
}
stepItem := container.NewVBox(
container.NewHBox(layout.NewSpacer(), dot, layout.NewSpacer()),
widget.NewLabel(""),
container.NewHBox(layout.NewSpacer(), lbl, layout.NewSpacer()),
)
items = append(items, stepItem)
if i < len(labels)-1 {
lineColor := color.NRGBA{R: 75, G: 85, B: 99, A: 255}
if isCompleted {
lineColor = colorSuccess
}
line := canvas.NewRectangle(lineColor)
line.SetMinSize(fyne.NewSize(60, 3))
items = append(items, container.NewVBox(
widget.NewLabel(""),
widget.NewLabel(""),
container.NewHBox(layout.NewSpacer(), line, layout.NewSpacer()),
widget.NewLabel(""),
))
}
}
w.stepIndicator = container.NewHBox(items...)
}
func (w *Wizard) refreshStepIndicator() {
w.buildStepIndicator()
}
func (w *Wizard) showStep(step wizardStep) {
w.currentStep = step
w.refreshStepIndicator()
var content fyne.CanvasObject
switch step {
case stepWelcome:
content = w.welcomePage()
case stepServer:
content = w.serverPage()
case stepAuth:
content = w.authPage()
case stepFinish:
content = w.finishPage()
}
inner := container.NewBorder(
container.NewVBox(
widget.NewLabel(""),
container.NewHBox(layout.NewSpacer(), w.stepIndicator, layout.NewSpacer()),
widget.NewLabel(""),
widget.NewSeparator(),
widget.NewLabel(""),
),
widget.NewLabel(""),
nil, nil,
container.NewVScroll(content),
)
w.container.Objects = []fyne.CanvasObject{inner}
w.container.Refresh()
}
func (w *Wizard) welcomePage() fyne.CanvasObject {
title := canvas.NewText("Welcome to kanholec", colorForeground)
title.TextSize = 32
title.TextStyle = fyne.TextStyle{Bold: true}
title.Alignment = fyne.TextAlignCenter
subtitle := canvas.NewText("Set up your reverse proxy client", colorPlaceholder)
subtitle.TextSize = 16
subtitle.Alignment = fyne.TextAlignCenter
desc := widget.NewRichTextFromMarkdown(`kanholec connects to a kanhole server to create secure tunnels.
**You will need:**
- A kanhole server address and port
- An authentication token or admin credentials
Click **Next** to get started.`)
nextBtn := widget.NewButton("Next", func() {
w.showStep(stepServer)
})
nextBtn.Importance = widget.HighImportance
return container.NewVBox(
widget.NewLabel(""),
widget.NewLabel(""),
widget.NewLabel(""),
container.NewHBox(layout.NewSpacer(), title, layout.NewSpacer()),
widget.NewLabel(""),
container.NewHBox(layout.NewSpacer(), subtitle, layout.NewSpacer()),
widget.NewLabel(""),
widget.NewLabel(""),
widget.NewSeparator(),
widget.NewLabel(""),
desc,
widget.NewLabel(""),
widget.NewLabel(""),
layout.NewSpacer(),
container.NewHBox(layout.NewSpacer(), nextBtn),
)
}
func (w *Wizard) serverPage() fyne.CanvasObject {
title := canvas.NewText("Server Connection", colorForeground)
title.TextSize = 26
title.TextStyle = fyne.TextStyle{Bold: true}
desc := canvas.NewText("Enter your kanhole server details", colorPlaceholder)
desc.TextSize = 15
form := widget.NewForm(
widget.NewFormItem("Server Address", w.serverAddr),
widget.NewFormItem("Server Port", w.serverPort),
)
backBtn := widget.NewButton("Back", func() {
w.showStep(stepWelcome)
})
nextBtn := widget.NewButton("Next", func() {
if w.serverAddr.Text == "" {
w.app.showError("Server address is required")
return
}
w.showStep(stepAuth)
})
nextBtn.Importance = widget.HighImportance
return container.NewVBox(
title,
widget.NewLabel(""),
desc,
widget.NewLabel(""),
widget.NewLabel(""),
widget.NewSeparator(),
widget.NewLabel(""),
widget.NewLabel(""),
form,
widget.NewLabel(""),
widget.NewLabel(""),
layout.NewSpacer(),
container.NewHBox(backBtn, layout.NewSpacer(), nextBtn),
)
}
func (w *Wizard) authPage() fyne.CanvasObject {
title := canvas.NewText("Authentication", colorForeground)
title.TextSize = 26
title.TextStyle = fyne.TextStyle{Bold: true}
desc := canvas.NewText("Choose how to authenticate", colorPlaceholder)
desc.TextSize = 15
methodForm := widget.NewForm(
widget.NewFormItem("Method", w.authMethod),
)
tokenForm := widget.NewForm(
widget.NewFormItem("Token", w.authToken),
)
credForm := widget.NewForm(
widget.NewFormItem("Username", w.authUser),
widget.NewFormItem("Password", w.authPass),
widget.NewFormItem("Client Name", w.clientName),
)
authContent := container.NewStack(tokenForm, credForm)
w.authMethod.OnChanged(w.authMethod.Selected)
backBtn := widget.NewButton("Back", func() {
w.showStep(stepServer)
})
nextBtn := widget.NewButton("Connect", func() {
if w.authMethod.Selected == "Token" && w.authToken.Text == "" {
w.app.showError("Token is required")
return
}
if w.authMethod.Selected == "Username/Password" {
if w.authUser.Text == "" || w.authPass.Text == "" {
w.app.showError("Username and password are required")
return
}
}
w.showStep(stepFinish)
})
nextBtn.Importance = widget.HighImportance
return container.NewVBox(
title,
widget.NewLabel(""),
desc,
widget.NewLabel(""),
widget.NewLabel(""),
widget.NewSeparator(),
widget.NewLabel(""),
widget.NewLabel(""),
methodForm,
widget.NewLabel(""),
authContent,
widget.NewLabel(""),
widget.NewLabel(""),
layout.NewSpacer(),
container.NewHBox(backBtn, layout.NewSpacer(), nextBtn),
)
}
func (w *Wizard) finishPage() fyne.CanvasObject {
title := canvas.NewText("Setup Complete!", colorForeground)
title.TextSize = 32
title.TextStyle = fyne.TextStyle{Bold: true}
title.Alignment = fyne.TextAlignCenter
checkIcon := canvas.NewText("\u2713", colorSuccess)
checkIcon.TextSize = 64
checkIcon.Alignment = fyne.TextAlignCenter
summary := fmt.Sprintf(`**Server:** %s:%s
**Auth:** %s
**Client:** %s`,
w.serverAddr.Text, w.serverPort.Text,
w.authMethod.Selected,
func() string {
if w.authMethod.Selected == "Token" {
return "Token auth"
}
if w.clientName.Text != "" {
return w.clientName.Text
}
return w.authUser.Text
}(),
)
summaryText := widget.NewRichTextFromMarkdown(summary)
backBtn := widget.NewButton("Back", func() {
w.showStep(stepAuth)
})
finishBtn := widget.NewButton("Launch Dashboard", func() {
if w.onComplete != nil {
w.onComplete(WizardResult{
ServerAddr: w.serverAddr.Text,
ServerPort: w.serverPort.Text,
AuthMethod: w.authMethod.Selected,
Token: w.authToken.Text,
Username: w.authUser.Text,
Password: w.authPass.Text,
ClientName: w.clientName.Text,
})
}
})
finishBtn.Importance = widget.HighImportance
return container.NewVBox(
widget.NewLabel(""),
widget.NewLabel(""),
container.NewHBox(layout.NewSpacer(), checkIcon, layout.NewSpacer()),
widget.NewLabel(""),
container.NewHBox(layout.NewSpacer(), title, layout.NewSpacer()),
widget.NewLabel(""),
widget.NewLabel(""),
widget.NewSeparator(),
widget.NewLabel(""),
summaryText,
widget.NewLabel(""),
widget.NewLabel(""),
layout.NewSpacer(),
container.NewHBox(backBtn, layout.NewSpacer(), finishBtn),
)
}
func (w *Wizard) Content() *fyne.Container {
return w.container
}
+2
View File
@@ -1,3 +1,5 @@
//go:build !kanholec_gui
// Copyright 2016 fatedier, fatedier@gmail.com // Copyright 2016 fatedier, fatedier@gmail.com
// //
// Licensed under the Apache License, Version 2.0 (the "License"); // Licensed under the Apache License, Version 2.0 (the "License");
+13
View File
@@ -0,0 +1,13 @@
//go:build kanholec_gui
package main
import (
"kanhole/client/gui"
"kanhole/pkg/util/system"
)
func main() {
system.EnableCompatibilityMode()
gui.Run()
}
+166
View File
@@ -0,0 +1,166 @@
<#
.SYNOPSIS
Build kanholec MSI installer using WiX Toolset
.DESCRIPTION
Builds the kanholec binary (with GUI support) and packages it into
an MSI installer using WiX v7.
.PARAMETER Arch
Target architecture: amd64 (default) or arm64
.PARAMETER SkipBuild
Skip building the Go binary (use existing one)
.PARAMETER OutputDir
Output directory for the MSI (default: bin/)
.EXAMPLE
.\build-msi.ps1
.\build-msi.ps1 -Arch arm64
.\build-msi.ps1 -SkipBuild
#>
param(
[ValidateSet("amd64", "arm64")]
[string]$Arch = "amd64",
[switch]$SkipBuild,
[string]$OutputDir = ""
)
$ErrorActionPreference = "Stop"
$scriptDir = Split-Path -Parent $PSCommandPath
$rootDir = Resolve-Path (Join-Path $scriptDir "..\..")
if ($OutputDir -eq "") {
$OutputDir = Join-Path $rootDir "bin"
}
$version = "0.69.0"
$binaryName = "kanholec-windows-$Arch.exe"
$binaryPath = Join-Path $OutputDir $binaryName
Write-Host "========================================" -ForegroundColor Cyan
Write-Host " kanholec MSI Builder (WiX v7)" -ForegroundColor Cyan
Write-Host " Version: $version | Arch: $Arch" -ForegroundColor Cyan
Write-Host "========================================" -ForegroundColor Cyan
Write-Host ""
# Check WiX
try {
$wixVersion = & wix --version 2>&1
Write-Host " WiX: $wixVersion" -ForegroundColor Green
} catch {
Write-Host " ERROR: WiX Toolset not found. Install with:" -ForegroundColor Red
Write-Host " dotnet tool install --global wix" -ForegroundColor Yellow
exit 1
}
# Check Go
try {
$goVersion = & go version 2>&1
Write-Host " Go: $goVersion" -ForegroundColor Green
} catch {
Write-Host " ERROR: Go not found." -ForegroundColor Red
exit 1
}
# Build binary
if (-not $SkipBuild) {
Write-Host ""
Write-Host " Building kanholec (GUI + CGO)..." -ForegroundColor Yellow
$env:CGO_ENABLED = "1"
$env:GOOS = "windows"
$env:GOARCH = $Arch
New-Item -ItemType Directory -Path $OutputDir -Force | Out-Null
$ldflags = "-s -w -H windowsgui"
$tags = "kanholec,kanholec_gui"
& go build -trimpath -ldflags $ldflags -tags $tags -o $binaryPath ./cmd/kanholec
if ($LASTEXITCODE -ne 0) {
Write-Host " ERROR: Go build failed" -ForegroundColor Red
exit 1
}
$env:CGO_ENABLED = ""
$env:GOOS = ""
$env:GOARCH = ""
Write-Host " Binary: $binaryPath" -ForegroundColor Green
} else {
if (-not (Test-Path $binaryPath)) {
Write-Host " ERROR: Binary not found at $binaryPath" -ForegroundColor Red
Write-Host " Build it first or remove -SkipBuild flag" -ForegroundColor Yellow
exit 1
}
Write-Host " Using existing binary: $binaryPath" -ForegroundColor Green
}
# Create icon if missing
$iconPath = Join-Path $scriptDir "kanholec.ico"
if (-not (Test-Path $iconPath)) {
Write-Host ""
Write-Host " Creating placeholder icon..." -ForegroundColor Yellow
# Create a minimal valid .ico file (16x16 32-bit)
$ico = New-Object byte[] 318
# ICO header
$ico[0] = 0; $ico[1] = 0 # reserved
$ico[2] = 1; $ico[3] = 0 # type: icon
$ico[4] = 1; $ico[5] = 0 # count: 1
# Directory entry
$ico[6] = 16 # width
$ico[7] = 16 # height
$ico[8] = 0 # colors
$ico[9] = 0 # reserved
$ico[10] = 1; $ico[11] = 0 # planes
$ico[12] = 32; $ico[13] = 0 # bpp
$ico[14] = 0; $ico[15] = 1; $ico[16] = 0; $ico[17] = 0 # size = 256
$ico[18] = 22; $ico[19] = 0; $ico[20] = 0; $ico[21] = 0 # offset = 22
# BMP header (40 bytes)
$ico[22] = 40; $ico[23] = 0; $ico[24] = 0; $ico[25] = 0 # header size
$ico[26] = 16; $ico[27] = 0; $ico[28] = 0; $ico[29] = 0 # width
$ico[30] = 32; $ico[31] = 0; $ico[32] = 0; $ico[33] = 0 # height (2x for icon)
$ico[34] = 1; $ico[35] = 0 # planes
$ico[36] = 32; $ico[37] = 0 # bpp
# Fill pixel data with blue-ish color (kanhole brand)
for ($i = 62; $i -lt 318; $i += 4) {
$ico[$i] = 236 # B
$ico[$i+1] = 152 # G
$ico[$i+2] = 56 # R
$ico[$i+3] = 255 # A
}
[System.IO.File]::WriteAllBytes($iconPath, $ico)
Write-Host " Icon: $iconPath (placeholder)" -ForegroundColor Green
}
# Build MSI
Write-Host ""
Write-Host " Building MSI with WiX..." -ForegroundColor Yellow
$wixprojPath = Join-Path $scriptDir "kanholec.wixproj"
$msiOutput = Join-Path $OutputDir "kanholec-$version-$Arch.msi"
Push-Location $rootDir
try {
& dotnet build $wixprojPath -c Release -p:Platform=x64 -o $OutputDir
if ($LASTEXITCODE -ne 0) {
Write-Host " ERROR: WiX build failed" -ForegroundColor Red
exit 1
}
$builtMsi = Get-ChildItem -Path $OutputDir -Filter "*.msi" | Sort-Object LastWriteTime -Descending | Select-Object -First 1
if ($builtMsi) {
if ($builtMsi.FullName -ne $msiOutput) {
Move-Item -Path $builtMsi.FullName -Destination $msiOutput -Force
}
Write-Host ""
Write-Host " MSI: $msiOutput" -ForegroundColor Green
Write-Host " Size: $([math]::Round((Get-Item $msiOutput).Length / 1KB, 1)) KB" -ForegroundColor Green
}
} finally {
Pop-Location
}
Write-Host ""
Write-Host " Done!" -ForegroundColor Cyan
Write-Host ""
Binary file not shown.

After

Width:  |  Height:  |  Size: 318 B

+13
View File
@@ -0,0 +1,13 @@
<Project Sdk="WixToolset.Sdk/7.0.0">
<PropertyGroup>
<InstallerPlatform>x64</InstallerPlatform>
<OutputName>kanholec-0.69.0</OutputName>
<OutputType>Package</OutputType>
<DefineConstants>Version=0.69.0</DefineConstants>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="WixToolset.UI.wixext" Version="7.0.0" />
<PackageReference Include="WixToolset.Util.wixext" Version="7.0.0" />
</ItemGroup>
</Project>
+124 -39
View File
@@ -1,47 +1,132 @@
<?xml version="1.0" encoding="utf-8"?> <Wix xmlns="http://wixtoolset.org/schemas/v4/wxs"
<Wix xmlns="http://wixtoolset.org/schemas/v3/wxs"> xmlns:ui="http://wixtoolset.org/schemas/v4/wxs/ui">
<Product
Name="kanholec"
Id="*"
UpgradeCode="A1B2C3D4-E5F6-7890-ABCD-EF1234567890"
Language="1033"
Codepage="1252"
Version="0.62.0"
Manufacturer="kanhole Contributors">
<Package <Package
InstallerVersion="200" Name="kanholec"
Compressed="yes" Manufacturer="kanhole Contributors"
InstallScope="perMachine" Version="0.69.0"
Description="kanhole Client" UpgradeCode="A1B2C3D4-E5F6-7890-ABCD-EF1234567890"
Comments="kanholec is the client component of kanhole - a fast reverse proxy." /> Scope="perMachine">
<Media Id="1" Cabinet="kanholec.cab" EmbedCab="yes" /> <MajorUpgrade DowngradeErrorMessage="A newer version of kanholec is already installed." />
<Directory Id="TARGETDIR" Name="SourceDir"> <MediaTemplate EmbedCab="yes" />
<Directory Id="ProgramFiles64Folder">
<Directory Id="INSTALLDIR" Name="kanholec">
<Component Id="FrpcBinary" Guid="E1B2C3D4-E5F6-7890-ABCD-EF1234567894">
<File Id="FrpcExe" Name="kanholec.exe" DiskId="1" Source="bin/kanholec-windows-amd64.exe" KeyPath="yes" />
</Component>
</Directory>
</Directory>
<Directory Id="ProgramMenuFolder"> <Icon Id="kanholecIcon" SourceFile="kanholec.ico" />
<Directory Id="ApplicationProgramsFolder" Name="kanholec"> <Property Id="ARPPRODUCTICON" Value="kanholecIcon" />
<Component Id="StartMenuShortcuts" Guid="A2B2C3D4-E5F6-7890-ABCD-EF1234567892"> <Property Id="ARPHELPLINK" Value="https://github.com/kanhole/kanhole" />
<Shortcut Id="FrpcShortcut" Name="kanholec" Target="[INSTALLDIR]kanholec.exe" WorkingDirectory="INSTALLDIR" Description="kanhole client" /> <Property Id="ARPURLINFOABOUT" Value="https://github.com/kanhole/kanhole" />
<Shortcut Id="UninstallShortcut" Name="Uninstall kanholec" Target="[System64Folder]msiexec.exe" Arguments="/x [ProductCode]" />
<RemoveFolder Id="RemoveApplicationProgramsFolder" On="uninstall" />
<RegistryValue Root="HKCU" Key="Software\kanhole\kanholec" Name="installed" Type="integer" Value="1" />
</Component>
</Directory>
</Directory>
</Directory>
<Feature Id="Main" Title="kanholec" Description="kanhole client binary" Level="1"> <Feature Id="Main" Title="kanholec" Description="kanhole reverse proxy client" Level="1"
<ComponentRef Id="FrpcBinary" /> AllowAdvertise="no">
<ComponentRef Id="StartMenuShortcuts" /> <ComponentGroupRef Id="BinaryFiles" />
<ComponentGroupRef Id="ConfigFiles" />
<ComponentGroupRef Id="Shortcuts" />
<ComponentRef Id="ServiceComponent" />
</Feature> </Feature>
</Product>
<Feature Id="DesktopShortcutFeature" Title="Desktop Shortcut" Description="Create a desktop shortcut"
Level="1000" AllowAdvertise="no">
<ComponentRef Id="DesktopShortcutComponent" />
</Feature>
<Feature Id="PathFeature" Title="Add to PATH" Description="Add kanholec to system PATH"
Level="1" AllowAdvertise="no">
<ComponentRef Id="PathComponent" />
</Feature>
<ui:WixUI
Id="WixUI_FeatureTree"
InstallDirectory="INSTALLFOLDER" />
<WixVariable Id="WixUILicenseRtf" Value="license.rtf" />
<StandardDirectory Id="ProgramFiles6432Folder">
<Directory Id="INSTALLFOLDER" Name="kanholec" />
</StandardDirectory>
<StandardDirectory Id="ProgramMenuFolder">
<Directory Id="ApplicationProgramsFolder" Name="kanholec" />
</StandardDirectory>
<StandardDirectory Id="CommonAppDataFolder">
<Directory Id="ConfigFolder" Name="kanholec" />
</StandardDirectory>
<ComponentGroup Id="BinaryFiles" Directory="INSTALLFOLDER">
<Component Id="KanholeCBinary" Guid="E1B2C3D4-E5F6-7890-ABCD-EF1234567894">
<File Id="kanholecExe"
Name="kanholec.exe"
Source="..\..\bin\kanholec-windows-amd64.exe"
KeyPath="yes" />
</Component>
</ComponentGroup>
<ComponentGroup Id="ConfigFiles" Directory="ConfigFolder">
<Component Id="DefaultConfig" Guid="F1B2C3D4-E5F6-7890-ABCD-EF1234567895" NeverOverwrite="yes">
<File Id="kanholecToml"
Name="kanholec.toml"
Source="kanholec.default.toml"
KeyPath="yes" />
</Component>
</ComponentGroup>
<ComponentGroup Id="Shortcuts" Directory="ApplicationProgramsFolder">
<Component Id="StartMenuShortcuts" Guid="A2B2C3D4-E5F6-7890-ABCD-EF1234567892">
<Shortcut Id="StartMenuShortcut"
Name="kanholec"
Target="[INSTALLFOLDER]kanholec.exe"
WorkingDirectory="INSTALLFOLDER"
Description="kanhole reverse proxy client"
Icon="kanholecIcon" />
<Shortcut Id="ConfigFolderShortcut"
Name="Config Directory"
Target="[ConfigFolder]"
Description="Open kanholec config directory" />
<Shortcut Id="UninstallShortcut"
Name="Uninstall kanholec"
Target="[System64Folder]msiexec.exe"
Arguments="/x [ProductCode]"
Description="Uninstall kanholec" />
<RemoveFolder Id="RemoveApplicationProgramsFolder" On="uninstall" />
<RegistryValue Root="HKCU" Key="Software\kanhole\kanholec" Name="installed" Type="integer" Value="1" KeyPath="yes" />
</Component>
</ComponentGroup>
<Component Id="DesktopShortcutComponent" Directory="DesktopFolder" Guid="B2B2C3D4-E5F6-7890-ABCD-EF1234567893">
<Shortcut Id="DesktopShortcut"
Name="kanholec"
Target="[INSTALLFOLDER]kanholec.exe"
WorkingDirectory="INSTALLFOLDER"
Description="kanhole reverse proxy client"
Icon="kanholecIcon" />
<RegistryValue Root="HKCU" Key="Software\kanhole\kanholec" Name="desktop_shortcut" Type="integer" Value="1" KeyPath="yes" />
</Component>
<Component Id="PathComponent" Directory="INSTALLFOLDER" Guid="C2B2C3D4-E5F6-7890-ABCD-EF1234567896">
<Environment Id="PATH" Name="PATH" Value="[INSTALLFOLDER]" Permanent="no" Part="last" Action="set" System="yes" />
<RegistryValue Root="HKCU" Key="Software\kanhole\kanholec" Name="path_added" Type="integer" Value="1" KeyPath="yes" />
</Component>
<Component Id="ServiceComponent" Directory="INSTALLFOLDER" Guid="D2B2C3D4-E5F6-7890-ABCD-EF1234567897">
<RegistryValue Root="HKCU" Key="Software\kanhole\kanholec" Name="service_registered" Type="integer" Value="1" KeyPath="yes" />
<ServiceInstall
Id="kanholecService"
Name="kanholec"
DisplayName="kanholec - kanhole Client"
Description="kanhole reverse proxy client service"
Start="demand"
Type="ownProcess"
ErrorControl="ignore"
Arguments='-c "[ConfigFolder]kanholec.toml"'
Account="LocalSystem" />
<ServiceControl
Id="kanholecServiceControl"
Name="kanholec"
Stop="both"
Remove="uninstall"
Wait="no" />
</Component>
</Package>
</Wix> </Wix>