package main import ( "errors" "fmt" "image/color" "net/url" "os" "os/exec" "path/filepath" "regexp" "strings" "syscall" "fyne.io/fyne/v2/app" "fyne.io/fyne/v2/canvas" "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" "fyne.io/fyne/v2" ) // Config represents a VPN configuration type Config struct { Title string URL string } var ( currentProcess *exec.Cmd configs []Config // Store all configurations activeConfig int // Index of the active config (-1 if none) ) // checkSingBox checks if sing-box.exe exists in the bin folder func checkSingBox() bool { singBoxPath := filepath.Join("bin", "sing-box.exe") if _, err := os.Stat(singBoxPath); os.IsNotExist(err) { return false } return true } // isValidVLESS checks if the given URL is a valid VLESS URL func isValidVLESS(vlessURL string) bool { // Basic validation for VLESS URL format if !strings.HasPrefix(vlessURL, "vless://") { return false } u, err := url.Parse(vlessURL) if err != nil { return false } // Check if it's a valid VLESS link matched, _ := regexp.MatchString(`^vless://[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}@.*`, vlessURL) if !matched { return false } // Additional validation checks can be added here return u.Scheme == "vless" && u.Host != "" } // addConfig adds a new configuration to the list func addConfig(title, url string) { configs = append(configs, Config{Title: title, URL: url}) } // updateConfig updates an existing configuration func updateConfig(index int, title, url string) { if index >= 0 && index < len(configs) { configs[index].Title = title configs[index].URL = url } } // removeConfig removes a configuration by index func removeConfig(index int) { if index >= 0 && index < len(configs) { configs = append(configs[:index], configs[index+1:]...) if activeConfig >= index { activeConfig-- } if activeConfig >= len(configs) { activeConfig = -1 } } } // runSingBox starts the sing-box process with the given config path func runSingBox(configPath string) error { singBoxPath := filepath.Join("bin", "sing-box.exe") if !checkSingBox() { return nil } cmd := exec.Command(singBoxPath, "run", "-c", configPath) // Make the process run as hidden cmd.SysProcAttr = &syscall.SysProcAttr{ HideWindow: true, } err := cmd.Start() if err != nil { return err } currentProcess = cmd return nil } // killCurrentProcess terminates the current sing-box process if running func killCurrentProcess() { if currentProcess != nil && currentProcess.Process != nil { currentProcess.Process.Kill() currentProcess = nil } } func main() { myApp := app.New() myWindow := myApp.NewWindow("Hamy VPN Client") // Set the window to not resizable and fixed size myWindow.SetFixedSize(true) // Set the window size to 200x300 as requested myWindow.Resize(fyne.NewSize(200, 300)) // Initialize active config to -1 (no config selected) activeConfig = -1 // Connection state - true for connected, false for disconnected var isConnected bool = false // Update connection button text based on connection state updateConnectionButtonText := func(button *widget.Button) { if isConnected { button.SetText("Отключить") } else { button.SetText("Подключить") } } // Create program title with proper padding titleText := canvas.NewText("Hamy VPN", nil) titleText.TextStyle.Bold = true titleText.TextSize = 16 titleText.Alignment = fyne.TextAlignLeading // Create settings button (gear icon) settingsButton := widget.NewButtonWithIcon("", theme.SettingsIcon(), func() { // Show settings dialog in a separate fixed-size window settingsWindow := myApp.NewWindow("Настройки") settingsWindow.Resize(fyne.NewSize(400, 300)) settingsWindow.SetFixedSize(true) // Add content to settings window (for now just a placeholder) settingsLabel := widget.NewLabel("Настройки приложения") settingsContent := container.NewVBox( settingsLabel, widget.NewLabel("Добавьте элементы настроек здесь"), ) settingsWindow.SetContent(settingsContent) settingsWindow.Show() }) // Create HBox for title and settings button with proper padding titleContainer := container.NewBorder(nil, nil, titleText, settingsButton, layout.NewSpacer()) // Status indicator (bold, smaller padding) statusLabel := canvas.NewText("Отключено", color.RGBA{R: 128, G: 128, B: 128, A: 255}) // Gray color statusLabel.Alignment = fyne.TextAlignCenter statusLabel.TextSize = 14 // Small font statusLabel.TextStyle.Bold = true // Make it bold // Connect/Disconnect button (with dynamic text) var connectButton *widget.Button connectButton = widget.NewButton("Подключить", func() { if activeConfig == -1 || len(configs) == 0 { dialog.ShowError(errors.New("Выберите конфигурацию"), myWindow) return } // Toggle connection state isConnected = !isConnected // Update button text updateConnectionButtonText(connectButton) // Update status text if isConnected { statusLabel.Text = "Подключено" statusLabel.Color = color.RGBA{R: 0, G: 128, B: 0, A: 255} // Green color for connected } else { statusLabel.Text = "Отключено" statusLabel.Color = color.RGBA{R: 128, G: 128, B: 128, A: 255} // Gray color for disconnected } statusLabel.Refresh() // This functionality will be implemented later dialog.ShowInformation("Info", fmt.Sprintf("Connect/Disconnect functionality will be implemented later using: %s", configs[activeConfig].Title), myWindow) }) connectButton.Importance = widget.HighImportance if len(configs) == 0 { connectButton.Disable() // Disable initially until a config is selected } else { connectButton.Enable() // Enable if we have configs } // Configurations button - opens a separate window configsButton := widget.NewButton("Конфигурации", func() { // Create a separate fixed-size window for configurations configWindow := myApp.NewWindow("Конфигурации") configWindow.Resize(fyne.NewSize(400, 300)) configWindow.SetFixedSize(true) // Declare variables before use var listContainer *widget.List var scrollContainer *container.Scroll var refreshList func() // Entry for importing new configuration - fixed to expand properly importEntry := widget.NewEntry() importEntry.PlaceHolder = "vless://..." // Container for import field and button - use HBox to allow proper expansion importButton := widget.NewButtonWithIcon("", theme.ContentAddIcon(), func() { url := importEntry.Text if url != "" && isValidVLESS(url) { title := fmt.Sprintf("Конфиг %d", len(configs)+1) addConfig(title, url) dialog.ShowInformation("Успешно", "Конфиг добавлен", configWindow) // Refresh the list refreshList() importEntry.SetText("") } else if url != "" { dialog.ShowError(errors.New("Невалидная ссылка"), configWindow) } }) // Fixed the order of operations to avoid nil reference refreshList = func() { // Create the list first newListContainer := widget.NewList( func() int { return len(configs) }, func() fyne.CanvasObject { return container.NewBorder(nil, nil, widget.NewLabel("Template"), container.NewHBox( widget.NewButtonWithIcon("", theme.DocumentCreateIcon(), func() {}), widget.NewButtonWithIcon("", theme.DeleteIcon(), func() {}), ), ) }, func(id widget.ListItemID, obj fyne.CanvasObject) { containerObj := obj.(*fyne.Container) // For a container.NewBorder(nil, nil, left, right, nil), the structure is: // Objects[0] is the left element (label) // Objects[1] is the right element (buttons container) titleLabel := containerObj.Objects[0].(*widget.Label) buttonsContainer := containerObj.Objects[1].(*fyne.Container) editButton := buttonsContainer.Objects[0].(*widget.Button) deleteButton := buttonsContainer.Objects[1].(*widget.Button) // Update visual indication for active config if id == activeConfig { titleLabel.SetText(fmt.Sprintf("● %s", configs[id].Title)) } else { titleLabel.SetText(fmt.Sprintf("○ %s", configs[id].Title)) } // Set up edit button editButton.OnTapped = func() { showEditDialog(configWindow, id) } // Set up delete button deleteButton.OnTapped = func() { dialog.ShowConfirm("Удалить конфигурацию", fmt.Sprintf("Вы уверены, что хотите удалить конфигурацию '%s'?", configs[id].Title), func(confirmed bool) { if confirmed { removeConfig(id) dialog.ShowInformation("Удалено", "Конфигурация удалена", configWindow) // Refresh the list refreshList() } }, configWindow) } }, ) newListContainer.OnSelected = func(id widget.ListItemID) { activeConfig = id dialog.ShowInformation("Config Selected", fmt.Sprintf("Active config: %s", configs[id].Title), configWindow) configWindow.Close() } // Assign to the variable before using it in the scroll container listContainer = newListContainer // Create scroll container only once if scrollContainer == nil { scrollContainer = container.NewScroll(listContainer) } else { scrollContainer.Content = listContainer scrollContainer.Refresh() } } // Call refreshList to initialize the list first refreshList() // Container for import field and button - use HBox to allow proper expansion // Using container.NewBorder to make the entry expand properly importContainer := container.NewBorder(nil, nil, nil, importButton, importEntry) // Main content for the config window using a border container to properly size the scroll area configContent := container.NewBorder(importContainer, nil, nil, nil, scrollContainer) configWindow.SetContent(configContent) configWindow.Show() }) // Main content container with vertical layout contentContainer := container.NewVBox( titleContainer, layout.NewSpacer(), container.NewCenter(statusLabel), layout.NewSpacer(), container.NewCenter(connectButton), layout.NewSpacer(), container.NewCenter(configsButton), layout.NewSpacer(), ) // Create a top-level container mainContainer := container.NewStack(contentContainer) // Set the content and show the window myWindow.SetContent(mainContainer) myWindow.ShowAndRun() } // Function to show the edit dialog func showEditDialog(parentWindow fyne.Window, configIndex int) { // Create entry fields for title and URL with existing values titleEntry := widget.NewEntry() titleEntry.SetText(configs[configIndex].Title) titleEntry.PlaceHolder = "Название конфигурации" urlEntry := widget.NewEntry() urlEntry.SetText(configs[configIndex].URL) urlEntry.PlaceHolder = "vless://..." // Create a custom dialog editDialog := dialog.NewForm("Редактировать конфигурацию", "Сохранить", "Отмена", []*widget.FormItem{ widget.NewFormItem("Заголовок", titleEntry), widget.NewFormItem("Ссылка", urlEntry), }, func(ok bool) { if ok { title := titleEntry.Text url := urlEntry.Text if title == "" { title = fmt.Sprintf("Конфиг %d", configIndex+1) // Default title } if isValidVLESS(url) { updateConfig(configIndex, title, url) dialog.ShowInformation("Успешно", "Конфиг обновлен", parentWindow) } else { dialog.ShowError(errors.New("Невалидная ссылка"), parentWindow) } } }, parentWindow, ) editDialog.Show() }