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

411 lines
9.6 KiB
Go

//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
}