//go:build kanholec_gui package gui import ( "fmt" "io" "net/http" "strings" "sync" "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/pkg/config" v1 "kanhole/pkg/config/v1" "kanhole/pkg/util/log" ) type App struct { fyneApp fyne.App window fyne.Window 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 mu sync.Mutex running bool configData string } func New() *App { a := &App{} a.fyneApp = app.New() a.window = a.fyneApp.NewWindow("kanholec GUI") a.window.Resize(fyne.NewSize(850, 620)) a.window.SetMaster() a.setupUI() return a } func (a *App) saveSettings() { p := a.fyneApp.Preferences() p.SetString("server_url", a.serverURL.Text) } 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) setupUI() { a.serverURL = widget.NewEntry() a.serverURL.SetText("http://localhost:7500") a.serverURL.PlaceHolder = "http://frps-server:7500" a.authToken = widget.NewEntry() a.authToken.PlaceHolder = "One-time token" 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, 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}), 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() { url := strings.TrimRight(a.serverURL.Text, "/") token := a.authToken.Text key := a.clientKey.Text a.saveSettings() if token == "" && key == "" { a.statusLabel.SetText("Error: provide token or client key") 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) 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") } } func (a *App) onStart() { if a.configData == "" { a.statusLabel.SetText("Error: no config loaded") return } 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) return } a.mu.Lock() a.running = true a.mu.Unlock() a.startBtn.Disable() a.stopBtn.Enable() a.statusLabel.SetText("Running") } func (a *App) onStop() { a.mu.Lock() a.running = false a.mu.Unlock() a.startBtn.Enable() a.stopBtn.Disable() a.statusLabel.SetText("Stopped") } func (a *App) Run() { a.window.ShowAndRun() } func Run() { gui := New() log.Infof("starting frpc GUI") gui.Run() }