//go:build kanholec_gui package gui import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "os" "path/filepath" "strconv" "sync" "time" "fyne.io/fyne/v2" "fyne.io/fyne/v2/app" "fyne.io/fyne/v2/container" "fyne.io/fyne/v2/dialog" "fyne.io/fyne/v2/layout" "fyne.io/fyne/v2/theme" "fyne.io/fyne/v2/widget" "kanhole/client" "kanhole/pkg/config" "kanhole/pkg/config/source" v1 "kanhole/pkg/config/v1" "kanhole/pkg/policy/security" "kanhole/pkg/util/log" "kanhole/pkg/util/version" ) type AppState int const ( StateSetup AppState = iota StateDashboard ) type App struct { fyneApp fyne.App window fyne.Window wizard *Wizard dashboard *Dashboard tray *TrayManager svcMgr *ServiceManager mainContainer *fyne.Container state AppState mu sync.Mutex running bool configData string clientService *client.Service cancelFunc context.CancelFunc } func New() *App { a := &App{} a.fyneApp = app.NewWithID("io.kanhole.kanholec") a.fyneApp.Settings().SetTheme(&kanholeTheme{}) a.window = a.fyneApp.NewWindow("kanholec") a.window.Resize(fyne.NewSize(900, 640)) a.window.SetMaster() a.svcMgr = NewServiceManager() a.tray = NewTrayManager(a) a.mainContainer = container.NewStack() a.window.SetContent(a.mainContainer) a.window.SetCloseIntercept(func() { if a.tray.IsSupported() { a.window.Hide() } else { a.Quit() } }) if a.hasSavedConfig() { a.showDashboard() } else { a.showWizard() } return a } func (a *App) hasSavedConfig() bool { prefs := a.fyneApp.Preferences() return prefs.StringWithFallback("server_addr", "") != "" } func (a *App) showWizard() { a.state = StateSetup a.wizard = NewWizard(a, a.onWizardComplete) a.mainContainer.Objects = []fyne.CanvasObject{a.wizard.Content()} a.mainContainer.Refresh() } func (a *App) showDashboard() { a.state = StateDashboard a.dashboard = NewDashboard(a) prefs := a.fyneApp.Preferences() addr := prefs.StringWithFallback("server_addr", "127.0.0.1") port := prefs.IntWithFallback("server_port", 7000) a.dashboard.SetServerInfo(addr, port) toolbar := a.buildToolbar() content := container.NewBorder( toolbar, nil, nil, nil, a.dashboard.Content(), ) a.mainContainer.Objects = []fyne.CanvasObject{content} a.mainContainer.Refresh() a.dashboard.AppendLog("Dashboard initialized") a.dashboard.AppendLog(fmt.Sprintf("Server: %s:%d", addr, port)) a.tray.Start() } func (a *App) buildToolbar() fyne.CanvasObject { logo := widget.NewLabelWithStyle("kanholec", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}) ver := widget.NewLabel("v" + version.Full()) ver.Importance = widget.LowImportance settingsBtn := widget.NewButtonWithIcon("", theme.SettingsIcon(), func() { a.showSettingsDialog() }) settingsBtn.Importance = widget.LowImportance serviceBtn := widget.NewButtonWithIcon("Service", nil, func() { a.showServiceDialog() }) resetBtn := widget.NewButtonWithIcon("Reset Setup", nil, func() { dialog.ShowConfirm("Reset Setup", "This will clear your configuration and restart the setup wizard. Continue?", func(ok bool) { if ok { a.clearSavedConfig() a.showWizard() } }, a.window) }) resetBtn.Importance = widget.LowImportance return container.NewVBox( container.NewHBox( logo, widget.NewLabel(""), ver, layout.NewSpacer(), serviceBtn, widget.NewLabel(""), settingsBtn, widget.NewLabel(""), resetBtn, ), widget.NewLabel(""), widget.NewSeparator(), widget.NewLabel(""), ) } func (a *App) onWizardComplete(result WizardResult) { prefs := a.fyneApp.Preferences() prefs.SetString("server_addr", result.ServerAddr) port, _ := strconv.Atoi(result.ServerPort) if port == 0 { port = 7000 } prefs.SetInt("server_port", port) prefs.SetString("auth_method", result.AuthMethod) prefs.SetString("auth_token", result.Token) prefs.SetString("auth_user", result.Username) prefs.SetString("auth_pass", result.Password) prefs.SetString("client_name", result.ClientName) a.showDashboard() a.dashboard.AppendLog("Setup completed. Fetching configuration...") go func() { a.fetchAndSaveConfig(result) time.Sleep(500 * time.Millisecond) a.startClient() }() } func (a *App) fetchAndSaveConfig(result WizardResult) { serverURL := fmt.Sprintf("http://%s:%s", result.ServerAddr, "7500") var configData []byte var err error a.dashboard.AppendLog(fmt.Sprintf("Connecting to %s...", serverURL)) if result.AuthMethod == "Token" && result.Token != "" { a.dashboard.AppendLog("Authenticating with token...") configData, err = a.authWithToken(serverURL, result.Token) } else if result.AuthMethod == "Username/Password" { a.dashboard.AppendLog("Authenticating with credentials...") configData, err = a.authWithCredentials(serverURL, result.Username, result.Password, result.ClientName) } if err != nil { a.dashboard.AppendLog(fmt.Sprintf("Auth error: %v", err)) a.dashboard.AppendLog("You can still start the client with manual configuration.") return } if configData != nil { a.configData = string(configData) a.saveConfigToFile(configData) a.dashboard.SetConfig(a.configData) a.dashboard.AppendLog("Configuration fetched successfully") a.dashboard.AppendLog("Configuration saved to disk") } } func (a *App) authWithToken(serverURL, token string) ([]byte, error) { apiURL := serverURL + "/admin/api/client/auth" body, _ := json.Marshal(map[string]string{"token": token}) ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() req, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, bytes.NewReader(body)) if err != nil { return nil, err } req.Header.Set("Content-Type", "application/json") resp, err := http.DefaultClient.Do(req) if err != nil { return nil, fmt.Errorf("connection failed: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { respBody, _ := io.ReadAll(resp.Body) return nil, fmt.Errorf("auth failed (HTTP %d): %s", resp.StatusCode, string(respBody)) } return io.ReadAll(resp.Body) } func (a *App) authWithCredentials(serverURL, username, password, clientName string) ([]byte, error) { apiURL := serverURL + "/admin/api/client/auth" body, _ := json.Marshal(map[string]string{ "username": username, "password": password, "client_name": clientName, }) ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() req, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, bytes.NewReader(body)) if err != nil { return nil, err } req.Header.Set("Content-Type", "application/json") resp, err := http.DefaultClient.Do(req) if err != nil { return nil, fmt.Errorf("connection failed: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { respBody, _ := io.ReadAll(resp.Body) return nil, fmt.Errorf("auth failed (HTTP %d): %s", resp.StatusCode, string(respBody)) } return io.ReadAll(resp.Body) } func (a *App) saveConfigToFile(data []byte) { configDir := a.getConfigDir() os.MkdirAll(configDir, 0755) configPath := filepath.Join(configDir, "kanholec.toml") os.WriteFile(configPath, data, 0644) } func (a *App) getConfigDir() string { if os.Getenv("ProgramData") != "" { return filepath.Join(os.Getenv("ProgramData"), "kanholec") } home, _ := os.UserHomeDir() return filepath.Join(home, ".kanholec") } func (a *App) loadSavedConfig() ([]byte, error) { configPath := filepath.Join(a.getConfigDir(), "kanholec.toml") return os.ReadFile(configPath) } func (a *App) startClient() { a.mu.Lock() defer a.mu.Unlock() if a.running { a.dashboard.AppendLog("Client is already running") return } a.dashboard.AppendLog("Starting client...") var cfgData []byte var err error if a.configData != "" { a.dashboard.AppendLog("Using fetched configuration") cfgData = []byte(a.configData) } else { a.dashboard.AppendLog("Loading saved configuration from disk...") cfgData, err = a.loadSavedConfig() if err != nil { a.dashboard.AppendLog(fmt.Sprintf("No configuration available: %v", err)) a.dashboard.AppendLog("Please complete setup wizard or add manual configuration") return } a.dashboard.AppendLog("Configuration loaded from disk") } var cfg v1.ClientConfig if err := config.LoadConfigure(cfgData, &cfg, false, "toml"); err != nil { a.dashboard.AppendLog(fmt.Sprintf("Invalid configuration: %v", err)) return } result := &config.ClientConfigLoadResult{ Common: &cfg.ClientCommonConfig, Proxies: make([]v1.ProxyConfigurer, 0), } for _, c := range cfg.Proxies { result.Proxies = append(result.Proxies, c.ProxyConfigurer) } a.dashboard.AppendLog(fmt.Sprintf("Loaded %d proxies from configuration", len(result.Proxies))) configSrc := source.NewConfigSource() if err := configSrc.ReplaceAll(result.Proxies, result.Visitors); err != nil { a.dashboard.AppendLog(fmt.Sprintf("Config source error: %v", err)) return } aggregator := source.NewAggregator(configSrc) unsafeFeatures := security.NewUnsafeFeatures(nil) log.InitLogger(cfg.Log.To, cfg.Log.Level, int(cfg.Log.MaxDays), cfg.Log.DisablePrintColor) ctx, cancel := context.WithCancel(context.Background()) a.cancelFunc = cancel svr, err := client.NewService(client.ServiceOptions{ Common: &cfg.ClientCommonConfig, ConfigSourceAggregator: aggregator, UnsafeFeatures: unsafeFeatures, }) if err != nil { cancel() a.dashboard.AppendLog(fmt.Sprintf("Service creation failed: %v", err)) return } a.clientService = svr a.running = true a.dashboard.AppendLog("Client service created, connecting...") go func() { a.dashboard.SetRunning(true) a.dashboard.AppendLog("Client started") if err := svr.Run(ctx); err != nil { a.dashboard.AppendLog(fmt.Sprintf("Client error: %v", err)) } a.mu.Lock() a.running = false a.mu.Unlock() a.dashboard.SetRunning(false) a.dashboard.AppendLog("Client stopped") }() } func (a *App) stopClient() { a.mu.Lock() defer a.mu.Unlock() if !a.running || a.cancelFunc == nil { return } a.cancelFunc() a.running = false a.dashboard.SetRunning(false) a.dashboard.AppendLog("Client stop requested") } func (a *App) reconnect() { a.stopClient() time.Sleep(500 * time.Millisecond) prefs := a.fyneApp.Preferences() result := WizardResult{ ServerAddr: prefs.StringWithFallback("server_addr", ""), ServerPort: strconv.Itoa(prefs.IntWithFallback("server_port", 7000)), AuthMethod: prefs.StringWithFallback("auth_method", "Token"), Token: prefs.StringWithFallback("auth_token", ""), Username: prefs.StringWithFallback("auth_user", ""), Password: prefs.StringWithFallback("auth_pass", ""), ClientName: prefs.StringWithFallback("client_name", ""), } a.fetchAndSaveConfig(result) a.startClient() } func (a *App) showSettingsDialog() { prefs := a.fyneApp.Preferences() addrEntry := widget.NewEntry() addrEntry.SetText(prefs.StringWithFallback("server_addr", "")) portEntry := widget.NewEntry() portEntry.SetText(strconv.Itoa(prefs.IntWithFallback("server_port", 7000))) tokenEntry := widget.NewEntry() tokenEntry.SetText(prefs.StringWithFallback("auth_token", "")) form := dialog.NewForm("Settings", "Save", "Cancel", []*widget.FormItem{ widget.NewFormItem("Server Address", addrEntry), widget.NewFormItem("Server Port", portEntry), widget.NewFormItem("Auth Token", tokenEntry), }, func(ok bool) { if !ok { return } prefs.SetString("server_addr", addrEntry.Text) port, _ := strconv.Atoi(portEntry.Text) if port > 0 { prefs.SetInt("server_port", port) } prefs.SetString("auth_token", tokenEntry.Text) a.dashboard.SetServerInfo(addrEntry.Text, port) a.dashboard.AppendLog("Settings saved") }, a.window, ) form.Resize(fyne.NewSize(450, 300)) form.Show() } func (a *App) showServiceDialog() { installed := a.svcMgr.IsInstalled() running := a.svcMgr.IsRunning() statusText := "Not installed" if installed { if running { statusText = "Installed and Running" } else { statusText = "Installed (Stopped)" } } statusLabel := widget.NewLabelWithStyle("Service: "+statusText, fyne.TextAlignLeading, fyne.TextStyle{Bold: true}) var buttons []fyne.CanvasObject if !installed { installBtn := widget.NewButton("Install Service", func() { configPath := a.svcMgr.GetConfigPath() if err := a.svcMgr.Install(configPath); err != nil { a.showError(fmt.Sprintf("Install failed: %v", err)) } else { a.dashboard.AppendLog("Windows service installed") statusLabel.SetText("Service: Installed (Stopped)") } }) installBtn.Importance = widget.HighImportance buttons = append(buttons, installBtn) } else { if !running { startBtn := widget.NewButton("Start Service", func() { if err := a.svcMgr.Start(); err != nil { a.showError(fmt.Sprintf("Start failed: %v", err)) } else { a.dashboard.AppendLog("Windows service started") statusLabel.SetText("Service: Installed and Running") } }) startBtn.Importance = widget.HighImportance buttons = append(buttons, startBtn) } else { stopBtn := widget.NewButton("Stop Service", func() { if err := a.svcMgr.Stop(); err != nil { a.showError(fmt.Sprintf("Stop failed: %v", err)) } else { a.dashboard.AppendLog("Windows service stopped") statusLabel.SetText("Service: Installed (Stopped)") } }) buttons = append(buttons, stopBtn) } uninstallBtn := widget.NewButton("Uninstall Service", func() { dialog.ShowConfirm("Uninstall Service", "Remove the kanholec Windows service?", func(ok bool) { if ok { if err := a.svcMgr.Uninstall(); err != nil { a.showError(fmt.Sprintf("Uninstall failed: %v", err)) } else { a.dashboard.AppendLog("Windows service uninstalled") statusLabel.SetText("Service: Not installed") } } }, a.window) }) uninstallBtn.Importance = widget.DangerImportance buttons = append(buttons, uninstallBtn) } content := container.NewVBox( statusLabel, widget.NewSeparator(), container.NewHBox(buttons...), ) d := dialog.NewCustom("Windows Service", "Close", content, a.window) d.Resize(fyne.NewSize(400, 200)) d.Show() } func (a *App) showError(msg string) { if a.window != nil { dialog.ShowError(fmt.Errorf("%s", msg), a.window) } if a.dashboard != nil { a.dashboard.AppendLog("Error: " + msg) } } func (a *App) clearSavedConfig() { prefs := a.fyneApp.Preferences() prefs.SetString("server_addr", "") prefs.SetInt("server_port", 0) prefs.SetString("auth_method", "") prefs.SetString("auth_token", "") prefs.SetString("auth_user", "") prefs.SetString("auth_pass", "") prefs.SetString("client_name", "") a.configData = "" } func (a *App) Quit() { a.stopClient() a.tray.Stop() a.fyneApp.Quit() } func (a *App) Run() { a.window.ShowAndRun() } func Run() { gui := New() log.Infof("starting kanholec GUI v%s", version.Full()) gui.Run() }