diff --git a/Makefile b/Makefile
index 20425d24..d1dbf233 100644
--- a/Makefile
+++ b/Makefile
@@ -47,13 +47,19 @@ kanholec-windows:
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
-kanholec-windows-msi: kanholec-windows
- wixl -o bin/kanholec-$(KANHOLE_VERSION).msi packaging/windows/kanholec.wxs 2>/dev/null \
- || { 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; }
+kanholec-windows-gui:
+ 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
-.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
diff --git a/client/gui/app.go b/client/gui/app.go
index 7a56e86a..73628fad 100644
--- a/client/gui/app.go
+++ b/client/gui/app.go
@@ -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()
}
diff --git a/client/gui/dashboard.go b/client/gui/dashboard.go
new file mode 100644
index 00000000..21492417
--- /dev/null
+++ b/client/gui/dashboard.go
@@ -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)
+ }
+}
diff --git a/client/gui/service.go b/client/gui/service.go
new file mode 100644
index 00000000..f320b9c9
--- /dev/null
+++ b/client/gui/service.go
@@ -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
+}
diff --git a/client/gui/theme.go b/client/gui/theme.go
new file mode 100644
index 00000000..f95f15a8
--- /dev/null
+++ b/client/gui/theme.go
@@ -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
+ }
+}
diff --git a/client/gui/tray.go b/client/gui/tray.go
new file mode 100644
index 00000000..a318fadf
--- /dev/null
+++ b/client/gui/tray.go
@@ -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,
+ }
+}
diff --git a/client/gui/wizard.go b/client/gui/wizard.go
new file mode 100644
index 00000000..b44e80c9
--- /dev/null
+++ b/client/gui/wizard.go
@@ -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
+}
diff --git a/cmd/kanholec/main.go b/cmd/kanholec/main.go
index 4a125fb0..4961e255 100644
--- a/cmd/kanholec/main.go
+++ b/cmd/kanholec/main.go
@@ -1,3 +1,5 @@
+//go:build !kanholec_gui
+
// Copyright 2016 fatedier, fatedier@gmail.com
//
// Licensed under the Apache License, Version 2.0 (the "License");
diff --git a/cmd/kanholec/main_gui.go b/cmd/kanholec/main_gui.go
new file mode 100644
index 00000000..0848e613
--- /dev/null
+++ b/cmd/kanholec/main_gui.go
@@ -0,0 +1,13 @@
+//go:build kanholec_gui
+
+package main
+
+import (
+ "kanhole/client/gui"
+ "kanhole/pkg/util/system"
+)
+
+func main() {
+ system.EnableCompatibilityMode()
+ gui.Run()
+}
diff --git a/packaging/windows/build-msi.ps1 b/packaging/windows/build-msi.ps1
new file mode 100644
index 00000000..6cdac17f
--- /dev/null
+++ b/packaging/windows/build-msi.ps1
@@ -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 ""
diff --git a/packaging/windows/kanholec.ico b/packaging/windows/kanholec.ico
new file mode 100644
index 00000000..3670679a
Binary files /dev/null and b/packaging/windows/kanholec.ico differ
diff --git a/packaging/windows/kanholec.wixproj b/packaging/windows/kanholec.wixproj
new file mode 100644
index 00000000..ad311ddf
--- /dev/null
+++ b/packaging/windows/kanholec.wixproj
@@ -0,0 +1,13 @@
+
+
+ x64
+ kanholec-0.69.0
+ Package
+ Version=0.69.0
+
+
+
+
+
+
+
diff --git a/packaging/windows/kanholec.wxs b/packaging/windows/kanholec.wxs
index 33214bb6..a3f16d47 100644
--- a/packaging/windows/kanholec.wxs
+++ b/packaging/windows/kanholec.wxs
@@ -1,47 +1,132 @@
-
-
-
+
+
+ Scope="perMachine">
-
+
-
+
-
-
-
-
-
-
-
-
+
+
+
+
-
-
-
-
-
-
+
+
+
+
+
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+