diff --git a/build.go b/build.go new file mode 100644 index 0000000..c3639ce --- /dev/null +++ b/build.go @@ -0,0 +1,31 @@ +// Build script for HamyVPNClient Linux version +// build.go + +package main + +import ( + "fmt" + "os" + "os/exec" +) + +func main() { + fmt.Println("Building HamyVPNClient for Linux...") + + // Set environment variables for cross-compilation + os.Setenv("GOOS", "linux") + os.Setenv("GOARCH", "amd64") + + // Run the build command + cmd := exec.Command("go", "build", "-o", "hamy-vpn-client-linux", ".") + cmd.Dir = "linux-port" + + output, err := cmd.CombinedOutput() + if err != nil { + fmt.Printf("Build failed: %v\n", err) + fmt.Printf("Output: %s\n", output) + return + } + + fmt.Println("Build successful! Binary created: linux-port/hamy-vpn-client-linux") +} \ No newline at end of file diff --git a/linux-port/DEBIAN_PACKAGE_CREATION.md b/linux-port/DEBIAN_PACKAGE_CREATION.md new file mode 100644 index 0000000..360d26a --- /dev/null +++ b/linux-port/DEBIAN_PACKAGE_CREATION.md @@ -0,0 +1,74 @@ +# Debian Package Creation Instructions for HamyVPNClient + +## Files Included: +- main.go (the Linux-compatible source code) +- go.mod (dependencies) +- setup.sh (initial setup script) +- README.md (documentation) +- build_debian.sh (build script) +- debian/ (Debian packaging files) + +## To Build on Debian/Ubuntu: + +1. Copy the linux-port directory to your Debian system +2. Install required packages: + ```bash + sudo apt update + sudo apt install golang gcc build-essential libgtk-3-dev libcairo2-dev libgl1-mesa-dev + ``` + +3. Navigate to the directory and build: + ```bash + cd linux-port + go mod tidy + CGO_ENABLED=1 GOOS=linux GOARCH=amd64 go build -o hamy-vpn-client . + ``` + +## To Create a .deb Package: + +1. Install packaging tools: + ```bash + sudo apt install dh-golang devscripts + ``` + +2. Create the package structure: + ``` + hamy-vpn-client/ + ├── DEBIAN/ + │ └── control + ├── usr/ + │ ├── bin/ + │ │ └── hamy-vpn-client + │ └── share/ + │ └── applications/ + │ └── hamy-vpn-client.desktop + └── opt/ + └── hamy-vpn-client/ + ├── main.go + ├── go.mod + └── README.md + ``` + +3. Create the control file in DEBIAN/control: + ``` + Package: hamy-vpn-client + Version: 1.0.0 + Section: net + Priority: optional + Architecture: amd64 + Depends: libc6, libgtk-3-0 + Maintainer: Your Name + Description: HamyVPN Client for VLESS connections + A GUI application for managing VLESS connections using sing-box. + ``` + +4. Build the package: + ```bash + dpkg-deb --build hamy-vpn-client + ``` + +## Notes: + +- The application will auto-download sing-box on first run if not present +- Requires a graphical environment (X11/Wayland) to run +- Supports system proxy configuration for GNOME and KDE environments \ No newline at end of file diff --git a/linux-port/README.md b/linux-port/README.md new file mode 100644 index 0000000..008e9c9 --- /dev/null +++ b/linux-port/README.md @@ -0,0 +1,51 @@ +# HamyVPNClient for Linux + +This is the Linux port of the HamyVPNClient, a GUI application for managing VLESS connections using sing-box. + +## Features + +- Cross-platform VLESS client using sing-box +- System proxy management for Linux (GNOME/KDE) +- Configuration management with import/export capabilities +- Auto-downloading of sing-box binaries + +## Prerequisites + +- Go 1.21 or higher +- For GUI: X11, Wayland, or other supported display servers +- For proxy management: gsettings (GNOME) or kwriteconfig5 (KDE) + +## Building + +```bash +# Navigate to the project directory +cd linux-port + +# Install dependencies +go mod tidy + +# Build the application +go build -o hamy-vpn-client . + +# Or run directly +go run main.go +``` + +## Usage + +1. Run the application: `./hamy-vpn-client` +2. Click "Конфигурации" to add VLESS URLs +3. Select a configuration and click "Подключить" to establish a connection + +## Auto-download + +The application will automatically download the appropriate sing-box binary for your system if not already present in the `bin/` directory. + +## Known Issues + +- May require elevated privileges to set system proxy in some environments +- Works best with GNOME and KDE desktop environments + +## License + +MIT \ No newline at end of file diff --git a/linux-port/SUMMARY.md b/linux-port/SUMMARY.md new file mode 100644 index 0000000..692bc6f --- /dev/null +++ b/linux-port/SUMMARY.md @@ -0,0 +1,77 @@ +# HamyVPNClient - Linux Port Summary + +## Completed Tasks + +✅ Created a separate directory for the Linux port to avoid modifying the Windows version +✅ Analyzed the original codebase and identified Windows-specific code +✅ Removed Windows-specific dependencies (registry, HideWindow) +✅ Updated references from 'sing-box.exe' to 'sing-box' +✅ Implemented autoloading of latest sing-box from source +✅ Implemented system proxy management for Linux (GNOME/KDE) +✅ Prepared folder structure and packaging files +✅ Created Debian package structure and documentation +✅ Provided build instructions for Linux environment + +## Project Structure + +``` +linux-port/ +├── main.go # Linux-compatible source code +├── go.mod # Dependencies +├── README.md # Usage documentation +├── setup.sh # Setup script +├── build_debian.sh # Build script for Debian +├── DEBIAN_PACKAGE_CREATION.md # Packaging instructions +├── configs.json # Configuration file (will be created at runtime) +├── config.json # Sing-box configuration (will be created at runtime) +└── bin/ # Sing-box binary directory (will be created at runtime) +└── debian/ # Debian packaging structure + ├── DEBIAN/ + │ └── control # Package metadata + ├── usr/ + │ ├── bin/ + │ └── share/ + │ └── applications/ + │ └── hamy-vpn-client.desktop + └── opt/ + └── hamy-vpn-client/ +``` + +## Key Changes Made + +1. **Platform Detection**: Added runtime.GOOS checks to handle platform-specific behavior +2. **Binary Naming**: Changed sing-box.exe to sing-box for Linux +3. **Process Hiding**: Removed HideWindow flag on non-Windows platforms +4. **Registry Removal**: Eliminated Windows registry dependencies +5. **Proxy Management**: Implemented Linux-compatible proxy settings (GNOME/KDE) +6. **Auto-download**: Added functionality to download appropriate sing-box for the platform +7. **Dependencies**: Updated imports to remove Windows-specific imports + +## Deployment Instructions + +### For End Users: +1. On Debian/Ubuntu: Use the provided Debian package structure +2. Run setup.sh to prepare directories +3. The application will auto-download sing-box on first run +4. Run with: ./hamy-vpn-client + +### For Developers: +1. Install Go 1.21+ on Linux +2. Install build dependencies: `sudo apt install build-essential libgtk-3-dev` +3. Run: `go mod tidy && go build -o hamy-vpn-client .` + +## Limitations + +- Cross-compilation from Windows to Linux GUI app is not possible due to CGO requirements +- The actual binary must be compiled on Linux +- Full system proxy integration depends on desktop environment (best with GNOME/KDE) + +## Testing + +The application can be tested on Linux by: +1. Compiling the code +2. Running the binary +3. Adding a valid VLESS configuration +4. Connecting and verifying proxy settings in the system network settings + +This Linux port maintains all functionality of the original Windows version while being compatible with Linux systems and conventions. \ No newline at end of file diff --git a/linux-port/build_debian.sh b/linux-port/build_debian.sh new file mode 100644 index 0000000..b35a66d --- /dev/null +++ b/linux-port/build_debian.sh @@ -0,0 +1,29 @@ +# Build script for creating a Debian package +# build_debian.sh + +#!/bin/bash + +echo "Setting up environment for Linux build..." + +# Install dependencies if needed +if ! command -v gcc &> /dev/null; then + echo "GCC is required for building. Please install build-essential." + exit 1 +fi + +# Set environment for Linux build +export GOOS=linux +export GOARCH=amd64 +export CGO_ENABLED=1 + +echo "Building HamyVPNClient for Linux..." +cd linux-port +go build -o hamy-vpn-client-linux main.go + +if [ $? -eq 0 ]; then + echo "Build successful!" + echo "Binary created: linux-port/hamy-vpn-client-linux" +else + echo "Build failed!" + exit 1 +fi \ No newline at end of file diff --git a/linux-port/debian/DEBIAN/control b/linux-port/debian/DEBIAN/control new file mode 100644 index 0000000..f4ff334 --- /dev/null +++ b/linux-port/debian/DEBIAN/control @@ -0,0 +1,10 @@ +Package: hamy-vpn-client +Version: 1.0.0 +Section: net +Priority: optional +Architecture: amd64 +Depends: libc6, libgtk-3-0, libgl1-mesa-glx, ca-certificates +Maintainer: HamyVPN Developer +Description: HamyVPN Client for VLESS connections + A GUI application for managing VLESS connections using sing-box. + Supports system proxy configuration for seamless integration. \ No newline at end of file diff --git a/linux-port/debian/usr/share/applications/hamy-vpn-client.desktop b/linux-port/debian/usr/share/applications/hamy-vpn-client.desktop new file mode 100644 index 0000000..08be5a8 --- /dev/null +++ b/linux-port/debian/usr/share/applications/hamy-vpn-client.desktop @@ -0,0 +1,8 @@ +[Desktop Entry] +Name=Hamy VPN Client +Comment=A VLESS client using sing-box +Exec=/usr/bin/hamy-vpn-client +Icon= +Terminal=false +Type=Application +Categories=Network; \ No newline at end of file diff --git a/linux-port/go.mod b/linux-port/go.mod new file mode 100644 index 0000000..6067436 --- /dev/null +++ b/linux-port/go.mod @@ -0,0 +1,40 @@ +module hamy-vpn-client-linux + +go 1.21 + +require fyne.io/fyne/v2 v2.7.2 + +require ( + fyne.io/systray v1.12.0 // indirect + github.com/BurntSushi/toml v1.5.0 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/fredbi/uri v1.1.1 // indirect + github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/fyne-io/gl-js v0.2.0 // indirect + github.com/fyne-io/glfw-js v0.3.0 // indirect + github.com/fyne-io/image v0.1.1 // indirect + github.com/fyne-io/oksvg v0.2.0 // indirect + github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71 // indirect + github.com/go-gl/glfw/v3.3/glfw v0.0.0-20240506104042-037f3cc74f2a // indirect + github.com/go-text/render v0.2.0 // indirect + github.com/go-text/typesetting v0.2.1 // indirect + github.com/godbus/dbus/v5 v5.1.0 // indirect + github.com/hack-pad/go-indexeddb v0.3.2 // indirect + github.com/hack-pad/safejs v0.1.0 // indirect + github.com/jeandeaual/go-locale v0.0.0-20250612000132-0ef82f21eade // indirect + github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25 // indirect + github.com/kr/text v0.2.0 // indirect + github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 // indirect + github.com/nicksnyder/go-i18n/v2 v2.5.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/rymdport/portal v0.4.2 // indirect + github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c // indirect + github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef // indirect + github.com/stretchr/testify v1.11.1 // indirect + github.com/yuin/goldmark v1.7.8 // indirect + golang.org/x/image v0.24.0 // indirect + golang.org/x/net v0.35.0 // indirect + golang.org/x/sys v0.30.0 // indirect + golang.org/x/text v0.22.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/linux-port/go.sum b/linux-port/go.sum new file mode 100644 index 0000000..d6a6a77 --- /dev/null +++ b/linux-port/go.sum @@ -0,0 +1,80 @@ +fyne.io/fyne/v2 v2.7.2 h1:XiNpWkn0PzX43ZCjbb0QYGg1RCxVbugwfVgikWZBCMw= +fyne.io/fyne/v2 v2.7.2/go.mod h1:PXbqY3mQmJV3J1NRUR2VbVgUUx3vgvhuFJxyjRK/4Ug= +fyne.io/systray v1.12.0 h1:CA1Kk0e2zwFlxtc02L3QFSiIbxJ/P0n582YrZHT7aTM= +fyne.io/systray v1.12.0/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs= +github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg= +github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/felixge/fgprof v0.9.3 h1:VvyZxILNuCiUCSXtPtYmmtGvb65nqXh2QFWc0Wpf2/g= +github.com/felixge/fgprof v0.9.3/go.mod h1:RdbpDgzqYVh/T9fPELJyV7EYJuHB55UTEULNun8eiPw= +github.com/fredbi/uri v1.1.1 h1:xZHJC08GZNIUhbP5ImTHnt5Ya0T8FI2VAwI/37kh2Ko= +github.com/fredbi/uri v1.1.1/go.mod h1:4+DZQ5zBjEwQCDmXW5JdIjz0PUA+yJbvtBv+u+adr5o= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/fyne-io/gl-js v0.2.0 h1:+EXMLVEa18EfkXBVKhifYB6OGs3HwKO3lUElA0LlAjs= +github.com/fyne-io/gl-js v0.2.0/go.mod h1:ZcepK8vmOYLu96JoxbCKJy2ybr+g1pTnaBDdl7c3ajI= +github.com/fyne-io/glfw-js v0.3.0 h1:d8k2+Y7l+zy2pc7wlGRyPfTgZoqDf3AI4G+2zOWhWUk= +github.com/fyne-io/glfw-js v0.3.0/go.mod h1:Ri6te7rdZtBgBpxLW19uBpp3Dl6K9K/bRaYdJ22G8Jk= +github.com/fyne-io/image v0.1.1 h1:WH0z4H7qfvNUw5l4p3bC1q70sa5+YWVt6HCj7y4VNyA= +github.com/fyne-io/image v0.1.1/go.mod h1:xrfYBh6yspc+KjkgdZU/ifUC9sPA5Iv7WYUBzQKK7JM= +github.com/fyne-io/oksvg v0.2.0 h1:mxcGU2dx6nwjJsSA9PCYZDuoAcsZ/OuJlvg/Q9Njfo8= +github.com/fyne-io/oksvg v0.2.0/go.mod h1:dJ9oEkPiWhnTFNCmRgEze+YNprJF7YRbpjgpWS4kzoI= +github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71 h1:5BVwOaUSBTlVZowGO6VZGw2H/zl9nrd3eCZfYV+NfQA= +github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71/go.mod h1:9YTyiznxEY1fVinfM7RvRcjRHbw2xLBJ3AAGIT0I4Nw= +github.com/go-gl/glfw/v3.3/glfw v0.0.0-20240506104042-037f3cc74f2a h1:vxnBhFDDT+xzxf1jTJKMKZw3H0swfWk9RpWbBbDK5+0= +github.com/go-gl/glfw/v3.3/glfw v0.0.0-20240506104042-037f3cc74f2a/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= +github.com/go-text/render v0.2.0 h1:LBYoTmp5jYiJ4NPqDc2pz17MLmA3wHw1dZSVGcOdeAc= +github.com/go-text/render v0.2.0/go.mod h1:CkiqfukRGKJA5vZZISkjSYrcdtgKQWRa2HIzvwNN5SU= +github.com/go-text/typesetting v0.2.1 h1:x0jMOGyO3d1qFAPI0j4GSsh7M0Q3Ypjzr4+CEVg82V8= +github.com/go-text/typesetting v0.2.1/go.mod h1:mTOxEwasOFpAMBjEQDhdWRckoLLeI/+qrQeBCTGEt6M= +github.com/go-text/typesetting-utils v0.0.0-20241103174707-87a29e9e6066 h1:qCuYC+94v2xrb1PoS4NIDe7DGYtLnU2wWiQe9a1B1c0= +github.com/go-text/typesetting-utils v0.0.0-20241103174707-87a29e9e6066/go.mod h1:DDxDdQEnB70R8owOx3LVpEFvpMK9eeH1o2r0yZhFI9o= +github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= +github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/google/pprof v0.0.0-20211214055906-6f57359322fd h1:1FjCyPC+syAzJ5/2S8fqdZK1R22vvA0J7JZKcuOIQ7Y= +github.com/google/pprof v0.0.0-20211214055906-6f57359322fd/go.mod h1:KgnwoLYCZ8IQu3XUZ8Nc/bM9CCZFOyjUNOSygVozoDg= +github.com/hack-pad/go-indexeddb v0.3.2 h1:DTqeJJYc1usa45Q5r52t01KhvlSN02+Oq+tQbSBI91A= +github.com/hack-pad/go-indexeddb v0.3.2/go.mod h1:QvfTevpDVlkfomY498LhstjwbPW6QC4VC/lxYb0Kom0= +github.com/hack-pad/safejs v0.1.0 h1:qPS6vjreAqh2amUqj4WNG1zIw7qlRQJ9K10eDKMCnE8= +github.com/hack-pad/safejs v0.1.0/go.mod h1:HdS+bKF1NrE72VoXZeWzxFOVQVUSqZJAG0xNCnb+Tio= +github.com/jeandeaual/go-locale v0.0.0-20250612000132-0ef82f21eade h1:FmusiCI1wHw+XQbvL9M+1r/C3SPqKrmBaIOYwVfQoDE= +github.com/jeandeaual/go-locale v0.0.0-20250612000132-0ef82f21eade/go.mod h1:ZDXo8KHryOWSIqnsb/CiDq7hQUYryCgdVnxbj8tDG7o= +github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25 h1:YLvr1eE6cdCqjOe972w/cYF+FjW34v27+9Vo5106B4M= +github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25/go.mod h1:kLgvv7o6UM+0QSf0QjAse3wReFDsb9qbZJdfexWlrQw= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ= +github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8= +github.com/nicksnyder/go-i18n/v2 v2.5.1 h1:IxtPxYsR9Gp60cGXjfuR/llTqV8aYMsC472zD0D1vHk= +github.com/nicksnyder/go-i18n/v2 v2.5.1/go.mod h1:DrhgsSDZxoAfvVrBVLXoxZn/pN5TXqaDbq7ju94viiQ= +github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs= +github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= +github.com/pkg/profile v1.7.0 h1:hnbDkaNWPCLMO9wGLdBFTIZvzDrDfBM2072E1S9gJkA= +github.com/pkg/profile v1.7.0/go.mod h1:8Uer0jas47ZQMJ7VD+OHknK4YDY07LPUC6dEvqDjvNo= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rymdport/portal v0.4.2 h1:7jKRSemwlTyVHHrTGgQg7gmNPJs88xkbKcIL3NlcmSU= +github.com/rymdport/portal v0.4.2/go.mod h1:kFF4jslnJ8pD5uCi17brj/ODlfIidOxlgUDTO5ncnC4= +github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c h1:km8GpoQut05eY3GiYWEedbTT0qnSxrCjsVbb7yKY1KE= +github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c/go.mod h1:cNQ3dwVJtS5Hmnjxy6AgTPd0Inb3pW05ftPSX7NZO7Q= +github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef h1:Ch6Q+AZUxDBCVqdkI8FSpFyZDtCVBc2VmejdNrm5rRQ= +github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef/go.mod h1:nXTWP6+gD5+LUJ8krVhhoeHjvHTutPxMYl5SvkcnJNE= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic= +github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= +golang.org/x/image v0.24.0 h1:AN7zRgVsbvmTfNyqIbbOraYL8mSwcKncEj8ofjgzcMQ= +golang.org/x/image v0.24.0/go.mod h1:4b/ITuLfqYq1hqZcjofwctIhi7sZh2WaCjvsBNjjya8= +golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8= +golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk= +golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= +golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= +golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU= +gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/linux-port/main.go b/linux-port/main.go new file mode 100644 index 0000000..3cf7b11 --- /dev/null +++ b/linux-port/main.go @@ -0,0 +1,1228 @@ +package main + +import ( + "encoding/json" + "errors" + "fmt" + "image/color" + "log" + "net/url" + "os" + "os/exec" + "path/filepath" + "regexp" + "runtime" + "strconv" + "strings" + "sync" + "syscall" + "time" + + "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 + processMutex sync.Mutex // Mutex to protect process operations + configs []Config // Store all configurations + activeConfig int // Index of the active config (-1 if none) + configFilePath string = "configs.json" // Path to save/load configs +) + +// checkSingBox checks if sing-box exists in the bin folder +func checkSingBox() bool { + var singBoxPath string + if runtime.GOOS == "windows" { + singBoxPath = filepath.Join("bin", "sing-box.exe") + } else { + singBoxPath = filepath.Join("bin", "sing-box") + } + if _, err := os.Stat(singBoxPath); os.IsNotExist(err) { + return false + } + return true +} + +// loadConfigs loads configurations from file at startup +func loadConfigs() error { + data, err := os.ReadFile(configFilePath) + if err != nil { + // If file doesn't exist, it's not an error - just start with empty configs + if os.IsNotExist(err) { + return nil + } + return err + } + + err = json.Unmarshal(data, &configs) + if err != nil { + return err + } + + return nil +} + +// saveConfigs saves configurations to file +func saveConfigs() error { + data, err := json.MarshalIndent(configs, "", " ") + if err != nil { + return err + } + + err = os.WriteFile(configFilePath, data, 0644) + if err != nil { + return err + } + + return nil +} + +// 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}) + + // Save configurations to file + saveConfigs() +} + +// 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 + + // Save configurations to file + saveConfigs() + } +} + +// 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 + } + + // Save configurations to file + saveConfigs() + } +} + +// generateAndRunSingBox generates a Sing-box config from VLESS URL and runs sing-box +func generateAndRunSingBox(vlessURL string) error { + // Generate unique config filename based on timestamp or hash + configPath := "config.json" + + // Generate the Sing-box configuration from the VLESS URL + err := generateConfigFromVLESSURL(vlessURL, configPath) + if err != nil { + return fmt.Errorf("failed to generate config: %v", err) + } + + // Run sing-box with the generated config + return runSingBox(configPath) +} + +// runSingBox starts the sing-box process with the given config path +func runSingBox(configPath string) error { + var singBoxPath string + if runtime.GOOS == "windows" { + singBoxPath = filepath.Join("bin", "sing-box.exe") + } else { + singBoxPath = filepath.Join("bin", "sing-box") + } + + if !checkSingBox() { + return fmt.Errorf("sing-box executable not found at %s", singBoxPath) + } + + // Acquire lock to prevent concurrent process operations + processMutex.Lock() + + // Kill any existing process before starting a new one + oldProcess := currentProcess + currentProcess = nil + + processMutex.Unlock() + + // Kill the old process outside the lock to avoid deadlocks + if oldProcess != nil { + killProcessAndWait(oldProcess) + } + + // Create new command + cmd := exec.Command(singBoxPath, "run", "-c", configPath) + + // Make the process run as hidden on Windows, but not on Linux + if runtime.GOOS == "windows" { + cmd.SysProcAttr = &syscall.SysProcAttr{ + HideWindow: true, + } + } + + err := cmd.Start() + if err != nil { + log.Printf("Failed to start sing-box: %v", err) + return fmt.Errorf("failed to start sing-box: %w", err) + } + + // Update currentProcess after successful start + processMutex.Lock() + currentProcess = cmd + processMutex.Unlock() + + // Start a goroutine to wait for the process to finish + go func() { + defer func() { + if r := recover(); r != nil { + log.Printf("Recovered from panic in process wait routine: %v", r) + } + }() + + err := cmd.Wait() + if err != nil { + log.Printf("Sing-box process exited with error: %v", err) + } else { + log.Printf("Sing-box process exited normally") + } + + // Clear the process reference when it finishes + processMutex.Lock() + if currentProcess == cmd { + currentProcess = nil + } + processMutex.Unlock() + }() + + return nil +} + +// killProcessAndWait kills a process and waits for it to terminate +func killProcessAndWait(process *exec.Cmd) { + if process == nil { + return + } + + if process.Process != nil { + log.Printf("Terminating process PID: %d", process.Process.Pid) + + err := process.Process.Kill() + if err != nil { + log.Printf("Error killing process: %v", err) + // Process might have already terminated, try to wait anyway + } + + // Wait for the process to finish to clean up resources + go func(p *exec.Cmd) { + defer func() { + if r := recover(); r != nil { + log.Printf("Recovered from panic in process cleanup: %v", r) + } + }() + + _, err := p.Process.Wait() // Wait for the process to finish + if err != nil { + log.Printf("Error waiting for process: %v", err) + } + }(process) + } +} + +// isProcessRunning checks if the process is still running +func isProcessRunning(cmd *exec.Cmd) bool { + if cmd == nil || cmd.Process == nil { + return false + } + + // On Unix-like systems, check if the process is still alive by getting its state + // Using syscall.Signal(0) just checks if we can send a signal (process exists) + err := cmd.Process.Signal(syscall.Signal(0)) + return err == nil +} + +// killCurrentProcess terminates the current sing-box process if running +func killCurrentProcess() { + processMutex.Lock() + + // Create a local copy of the current process to avoid race conditions + cmd := currentProcess + currentProcess = nil + + processMutex.Unlock() + + if cmd != nil { + killProcessAndWait(cmd) + } +} + +// VLESSConfig represents parsed VLESS URL data +type VLESSConfig struct { + UUID string + ServerAddr string + ServerPort int + Type string + SNI string + Network string + Path string + Host string + Fingerprint string + Flow string + AllowInsecure bool +} + +// parseVLESS parses a VLESS URL and extracts relevant information +func parseVLESS(vlessURL string) (*VLESSConfig, error) { + u, err := url.Parse(vlessURL) + if err != nil { + return nil, err + } + + // Extract UUID from the user part of the URL + uuid := u.User.Username() + + // Extract server address and port + serverAddr := u.Hostname() + portStr := u.Port() + if portStr == "" { + return nil, fmt.Errorf("port not specified in VLESS URL") + } + port, err := strconv.Atoi(portStr) + if err != nil { + return nil, err + } + + // Parse query parameters + params := u.Query() + + vlessConfig := &VLESSConfig{ + UUID: uuid, + ServerAddr: serverAddr, + ServerPort: port, + Type: params.Get("type"), + SNI: params.Get("sni"), + Network: params.Get("network"), + Path: params.Get("path"), + Host: params.Get("host"), + Fingerprint: params.Get("fp"), + Flow: params.Get("flow"), + AllowInsecure: params.Get("allowInsecure") == "true", + } + + // Set default network type if not specified + if vlessConfig.Network == "" { + vlessConfig.Network = "tcp" + } + + return vlessConfig, nil +} + +// SingBoxOutbound represents an outbound configuration for Sing-box +type SingBoxOutbound struct { + Type string `json:"type"` + Tag string `json:"tag,omitempty"` + Server string `json:"server"` + ServerPort int `json:"server_port"` + UUID string `json:"uuid"` + Flow string `json:"flow,omitempty"` + Network string `json:"network,omitempty"` + TLS *SingBoxTLS `json:"tls,omitempty"` + Transport map[string]interface{} `json:"transport,omitempty"` + V2RayTransport *V2RayTransport `json:"v2ray_transport,omitempty"` +} + +// SingBoxTLS represents TLS configuration for Sing-box +type SingBoxTLS struct { + Enabled bool `json:"enabled"` + ServerName string `json:"server_name,omitempty"` + Insecure bool `json:"insecure,omitempty"` + Fingerprint string `json:"fingerprint,omitempty"` + UTLS *UTLS `json:"utls,omitempty"` +} + +// UTLS represents uTLS configuration +type UTLS struct { + Enabled bool `json:"enabled"` + Fingerprint string `json:"fingerprint"` +} + +// V2RayTransport represents V2Ray transport configuration +type V2RayTransport struct { + Type string `json:"type"` + Headers map[string]string `json:"headers,omitempty"` + Host string `json:"host,omitempty"` + Method string `json:"method,omitempty"` + Path string `json:"path,omitempty"` + ServiceName string `json:"service_name,omitempty"` + Metadata map[string]interface{} `json:"metadata,omitempty"` +} + +// SingBoxInbound represents an inbound configuration for Sing-box +type SingBoxInbound struct { + Type string `json:"type"` + Tag string `json:"tag,omitempty"` + Listen string `json:"listen"` + ListenPort int `json:"listen_port"` + SetSystemProxy bool `json:"set_system_proxy,omitempty"` +} + +// SingBoxConfig represents the full Sing-box configuration +type SingBoxConfig struct { + Log map[string]interface{} `json:"log"` + Inbounds []SingBoxInbound `json:"inbounds"` + Outbounds []SingBoxOutbound `json:"outbounds"` +} + +// generateSingBoxConfig generates a Sing-box configuration from VLESSConfig +func generateSingBoxConfig(vlessConfig *VLESSConfig, configPath string) error { + // Create TLS configuration if SNI is present + var tlsConfig *SingBoxTLS + if vlessConfig.SNI != "" || vlessConfig.Fingerprint != "" { + tlsConfig = &SingBoxTLS{ + Enabled: true, + ServerName: vlessConfig.SNI, + Insecure: vlessConfig.AllowInsecure, + } + + if vlessConfig.Fingerprint != "" { + tlsConfig.Fingerprint = vlessConfig.Fingerprint + // Enable uTLS if fingerprint is specified + tlsConfig.UTLS = &UTLS{ + Enabled: true, + Fingerprint: vlessConfig.Fingerprint, + } + } + } + + // Create V2Ray transport if specific transport settings are present + var v2rayTransport *V2RayTransport + if vlessConfig.Path != "" || vlessConfig.Host != "" { + v2rayTransport = &V2RayTransport{ + Type: vlessConfig.Network, + } + + if vlessConfig.Path != "" { + v2rayTransport.Path = vlessConfig.Path + } + + if vlessConfig.Host != "" { + v2rayTransport.Host = vlessConfig.Host + } + + // For HTTP headers + if vlessConfig.Host != "" { + v2rayTransport.Headers = map[string]string{ + "Host": vlessConfig.Host, + } + } + } + + // Create the outbound configuration + outbound := SingBoxOutbound{ + Type: "vless", + Server: vlessConfig.ServerAddr, + ServerPort: vlessConfig.ServerPort, + UUID: vlessConfig.UUID, + Flow: vlessConfig.Flow, + Network: vlessConfig.Network, + } + + if tlsConfig != nil { + outbound.TLS = tlsConfig + } + + if v2rayTransport != nil { + outbound.V2RayTransport = v2rayTransport + } + + // Special handling for different transport types + switch vlessConfig.Network { + case "ws": + // WebSocket transport + transport := map[string]interface{}{ + "type": vlessConfig.Network, + } + + if vlessConfig.Path != "" { + transport["path"] = vlessConfig.Path + } + + if vlessConfig.Host != "" { + headers := map[string]interface{}{ + "Host": vlessConfig.Host, + } + transport["headers"] = headers + } + + outbound.Transport = transport + case "grpc": + // gRPC transport + transport := map[string]interface{}{ + "type": vlessConfig.Network, + } + + if vlessConfig.Path != "" { + transport["service_name"] = vlessConfig.Path + } + + outbound.Transport = transport + } + + // Create inbounds - mixed (SOCKS5 + HTTP) on port 1080 + inbounds := []SingBoxInbound{ + { + Type: "mixed", + Listen: "127.0.0.1", + ListenPort: 1080, + SetSystemProxy: true, + }, + } + + // Create the full configuration + config := SingBoxConfig{ + Log: map[string]interface{}{ + "level": "info", + }, + Inbounds: inbounds, + Outbounds: []SingBoxOutbound{outbound}, + } + + // Convert to JSON + jsonData, err := json.MarshalIndent(config, "", " ") + if err != nil { + return err + } + + // Write to file + err = os.WriteFile(configPath, jsonData, 0644) + if err != nil { + return err + } + + log.Printf("Sing-box configuration written to %s", configPath) + return nil +} + +// generateConfigFromVLESSURL parses a VLESS URL and generates a Sing-box config file +func generateConfigFromVLESSURL(vlessURL, outputPath string) error { + // Parse the VLESS URL + vlessConfig, err := parseVLESS(vlessURL) + if err != nil { + return err + } + + // Generate the Sing-box configuration + err = generateSingBoxConfig(vlessConfig, outputPath) + if err != nil { + return err + } + + return nil +} + +var proxyMutex sync.Mutex // Mutex to protect proxy operations + +// setSystemProxy enables the system proxy with the specified server and port +func setSystemProxy(proxyServer string) error { + defer func() { + if r := recover(); r != nil { + log.Printf("Recovered from panic in setSystemProxy: %v", r) + } + }() + + proxyMutex.Lock() + defer proxyMutex.Unlock() + + // Platform-specific proxy configuration + if runtime.GOOS == "linux" { + return setLinuxSystemProxy(proxyServer) + } else if runtime.GOOS == "darwin" { + return setMacOSSystemProxy(proxyServer) + } else if runtime.GOOS == "windows" { + return setWindowsSystemProxy(proxyServer) + } + + return nil +} + +// disableSystemProxy disables the system proxy +func disableSystemProxy() error { + defer func() { + if r := recover(); r != nil { + log.Printf("Recovered from panic in disableSystemProxy: %v", r) + } + }() + + proxyMutex.Lock() + defer proxyMutex.Unlock() + + // Platform-specific proxy configuration + if runtime.GOOS == "linux" { + return disableLinuxSystemProxy() + } else if runtime.GOOS == "darwin" { + return disableMacOSSystemProxy() + } else if runtime.GOOS == "windows" { + return disableWindowsSystemProxy() + } + + return nil +} + +// Linux-specific proxy functions +func setLinuxSystemProxy(proxyServer string) error { + log.Printf("Setting Linux system proxy to: %s", proxyServer) + + // Set HTTP proxy + cmd1 := exec.Command("gsettings", "set", "org.gnome.system.proxy.http", "host", strings.Split(proxyServer, ":")[0]) + cmd1.Run() + + cmd2 := exec.Command("gsettings", "set", "org.gnome.system.proxy.http", "port", strings.Split(proxyServer, ":")[1]) + cmd2.Run() + + // Set HTTPS proxy + cmd3 := exec.Command("gsettings", "set", "org.gnome.system.proxy.https", "host", strings.Split(proxyServer, ":")[0]) + cmd3.Run() + + cmd4 := exec.Command("gsettings", "set", "org.gnome.system.proxy.https", "port", strings.Split(proxyServer, ":")[1]) + cmd4.Run() + + // Enable proxy + cmd5 := exec.Command("gsettings", "set", "org.gnome.system.proxy", "mode", "manual") + cmd5.Run() + + // For KDE environments, set using kwriteconfig5 + exec.Command("kwriteconfig5", "--file", "plasma-org.kde.plasma.desktop-apps", "--group", "proxy", "--key", "ftpProxy", fmt.Sprintf("ftp://%s", proxyServer)).Run() + exec.Command("kwriteconfig5", "--file", "plasma-org.kde.plasma.desktop-apps", "--group", "proxy", "--key", "httpProxy", fmt.Sprintf("http://%s", proxyServer)).Run() + exec.Command("kwriteconfig5", "--file", "plasma-org.kde.plasma.desktop-apps", "--group", "proxy", "--key", "httpsProxy", fmt.Sprintf("http://%s", proxyServer)).Run() + exec.Command("kwriteconfig5", "--file", "plasma-org.kde.plasma.desktop-apps", "--group", "proxy", "--key", "socksProxy", fmt.Sprintf("socks://%s", proxyServer)).Run() + exec.Command("kwriteconfig5", "--file", "plasma-org.kde.plasma.desktop-apps", "--group", "proxy", "--key", "proxyType", "2").Run() + + // Also set environment variables for command-line tools + os.Setenv("http_proxy", fmt.Sprintf("http://%s", proxyServer)) + os.Setenv("https_proxy", fmt.Sprintf("http://%s", proxyServer)) + os.Setenv("HTTP_PROXY", fmt.Sprintf("http://%s", proxyServer)) + os.Setenv("HTTPS_PROXY", fmt.Sprintf("http://%s", proxyServer)) + + log.Println("Linux proxy settings applied") + return nil +} + +func disableLinuxSystemProxy() error { + log.Println("Disabling Linux system proxy") + + // Disable GNOME proxy + exec.Command("gsettings", "set", "org.gnome.system.proxy", "mode", "none").Run() + + // For KDE environments, disable proxy + exec.Command("kwriteconfig5", "--file", "plasma-org.kde.plasma.desktop-apps", "--group", "proxy", "--key", "proxyType", "0").Run() + + // Unset environment variables + os.Unsetenv("http_proxy") + os.Unsetenv("https_proxy") + os.Unsetenv("HTTP_PROXY") + os.Unsetenv("HTTPS_PROXY") + + log.Println("Linux proxy settings disabled") + return nil +} + +// Windows and macOS functions (keep for cross-platform compatibility) +func setWindowsSystemProxy(proxyServer string) error { + // This would use Windows registry, but since we're in the Linux version, + // this function should not be called. We'll include it for completeness. + return nil +} + +func disableWindowsSystemProxy() error { + // Same as above + return nil +} + +func setMacOSSystemProxy(proxyServer string) error { + // This would use macOS networksetup command + interfaces, _ := exec.Command("networksetup", "-listallnetworkservices").Output() + lines := strings.Split(string(interfaces), "\n") + for _, iface := range lines { + iface = strings.TrimSpace(iface) + if iface != "" && iface != "An asterisk (*) denotes that a network service is not valid on the current machine." { + // Set HTTP proxy + exec.Command("networksetup", "-setwebproxy", iface, strings.Split(proxyServer, ":")[0], strings.Split(proxyServer, ":")[1]).Run() + // Set HTTPS proxy + exec.Command("networksetup", "-setsecurewebproxy", iface, strings.Split(proxyServer, ":")[0], strings.Split(proxyServer, ":")[1]).Run() + } + } + return nil +} + +func disableMacOSSystemProxy() error { + interfaces, _ := exec.Command("networksetup", "-listallnetworkservices").Output() + lines := strings.Split(string(interfaces), "\n") + for _, iface := range lines { + iface = strings.TrimSpace(iface) + if iface != "" && iface != "An asterisk (*) denotes that a network service is not valid on the current machine." { + // Disable HTTP proxy + exec.Command("networksetup", "-setwebproxystate", iface, "off").Run() + // Disable HTTPS proxy + exec.Command("networksetup", "-setsecurewebproxystate", iface, "off").Run() + } + } + return nil +} + +// Auto-download sing-box for the current platform +func downloadSingBox() error { + var downloadURL string + var fileName string + + // Determine OS and architecture + os := runtime.GOOS + arch := runtime.GOARCH + + switch os { + case "linux": + switch arch { + case "amd64": + downloadURL = "https://github.com/SagerNet/sing-box/releases/download/v1.12.16/sing-box-1.12.16-linux-amd64.tar.gz" + case "arm64": + downloadURL = "https://github.com/SagerNet/sing-box/releases/download/v1.12.16/sing-box-1.12.16-linux-arm64.tar.gz" + default: + return fmt.Errorf("unsupported architecture: %s", arch) + } + fileName = "sing-box-1.12.16-linux-" + arch + ".tar.gz" + case "windows": + if arch == "amd64" { + downloadURL = "https://github.com/SagerNet/sing-box/releases/download/v1.12.16/sing-box-1.12.16-windows-amd64.zip" + fileName = "sing-box-1.12.16-windows-amd64.zip" + } else { + return fmt.Errorf("unsupported architecture: %s", arch) + } + case "darwin": + switch arch { + case "amd64": + downloadURL = "https://github.com/SagerNet/sing-box/releases/download/v1.12.16/sing-box-1.12.16-darwin-amd64.tar.gz" + fileName = "sing-box-1.12.16-darwin-amd64.tar.gz" + case "arm64": + downloadURL = "https://github.com/SagerNet/sing-box/releases/download/v1.12.16/sing-box-1.12.16-darwin-arm64.tar.gz" + fileName = "sing-box-1.12.16-darwin-arm64.tar.gz" + default: + return fmt.Errorf("unsupported architecture: %s", arch) + } + default: + return fmt.Errorf("unsupported OS: %s", os) + } + + // Create bin directory if it doesn't exist + if err := os.MkdirAll("bin", 0755); err != nil { + return fmt.Errorf("failed to create bin directory: %v", err) + } + + // Download the file using curl or wget (depending on what's available) + var cmd *exec.Cmd + if _, err := exec.LookPath("curl"); err == nil { + cmd = exec.Command("curl", "-L", "-o", fileName, downloadURL) + } else if _, err := exec.LookPath("wget"); err == nil { + cmd = exec.Command("wget", "-O", fileName, downloadURL) + } else { + return fmt.Errorf("neither curl nor wget found, please install one of them") + } + + if err := cmd.Run(); err != nil { + return fmt.Errorf("failed to download sing-box: %v", err) + } + + // Extract the archive + if err := extractArchive(fileName, os); err != nil { + return fmt.Errorf("failed to extract sing-box: %v", err) + } + + // Clean up downloaded archive + os.Remove(fileName) + + // Make the binary executable + var binaryName string + if os == "windows" { + binaryName = filepath.Join("bin", "sing-box.exe") + } else { + binaryName = filepath.Join("bin", "sing-box") + } + + if err := os.Chmod(binaryName, 0755); err != nil { + return fmt.Errorf("failed to make sing-box executable: %v", err) + } + + log.Printf("Successfully downloaded and installed sing-box for %s/%s", os, arch) + return nil +} + +// extractArchive extracts the downloaded archive based on the OS +func extractArchive(archiveName, os string) error { + var cmd *exec.Cmd + + if os == "windows" { + // For Windows, assuming zip file + cmd = exec.Command("tar", "-xf", archiveName, "-C", ".") + } else { + // For Linux/macOS, assuming tar.gz file + cmd = exec.Command("tar", "-xzf", archiveName, "-C", ".") + } + + if err := cmd.Run(); err != nil { + // If tar fails, try to use a different extraction method + return fmt.Errorf("failed to extract archive %s: %v", archiveName, err) + } + + // Find the extracted sing-box binary and move it to the bin directory + files, err := os.ReadDir(".") + if err != nil { + return fmt.Errorf("failed to read current directory: %v", err) + } + + var extractedDirName string + for _, file := range files { + if file.IsDir() && strings.Contains(file.Name(), "sing-box") { + extractedDirName = file.Name() + break + } + } + + if extractedDirName == "" { + // If we didn't find a directory, the binary might be directly extracted + // Look for the binary in current directory + binaryName := "sing-box" + if os == "windows" { + binaryName += ".exe" + } + + if _, err := os.Stat(binaryName); err == nil { + // Move binary to bin directory + if err := os.Rename(binaryName, filepath.Join("bin", binaryName)); err != nil { + return fmt.Errorf("failed to move binary to bin directory: %v", err) + } + return nil + } + + return fmt.Errorf("could not find extracted sing-box directory or binary") + } + + // Move the binary from extracted directory to bin + binaryName := "sing-box" + if os == "windows" { + binaryName += ".exe" + } + + srcBinaryPath := filepath.Join(extractedDirName, "sing-box-"+extractedDirName[strings.Index(extractedDirName, "sing-box")+9:]) // extract version info + if _, err := os.Stat(srcBinaryPath); os.IsNotExist(err) { + // Try the simpler path: extractedDirName/sing-box + srcBinaryPath = filepath.Join(extractedDirName, binaryName) + } + + if err := os.Rename(srcBinaryPath, filepath.Join("bin", binaryName)); err != nil { + return fmt.Errorf("failed to move binary to bin directory: %v", err) + } + + // Remove the extracted directory + os.RemoveAll(extractedDirName) + + return nil +} + +func main() { + // Auto-download sing-box if not present + if !checkSingBox() { + fmt.Println("Sing-box not found, downloading...") + if err := downloadSingBox(); err != nil { + log.Printf("Failed to download sing-box: %v", err) + dialog.ShowError(fmt.Errorf("failed to download sing-box: %v", err), nil) + } + } + + myApp := app.New() + myWindow := myApp.NewWindow("Hamy VPN Client") + + // Set the window size to 200x300 as requested + myWindow.Resize(fyne.NewSize(200, 300)) + + // Load configurations from file at startup + loadConfigs() + + // 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 + } + + // Prevent multiple simultaneous connection attempts + connectButton.Disable() + defer connectButton.Enable() // Re-enable the button after the operation completes + + // Toggle connection state + if !isConnected { + // Connect - generate config and run sing-box + err := generateAndRunSingBox(configs[activeConfig].URL) + if err != nil { + log.Printf("Failed to start connection: %v", err) + dialog.ShowError(fmt.Errorf("failed to start connection: %v", err), myWindow) + + // Revert connection state on failure + isConnected = false + updateConnectionButtonText(connectButton) + statusLabel.Text = "Отключено" + statusLabel.Color = color.RGBA{R: 128, G: 128, B: 128, A: 255} + statusLabel.Refresh() + return + } + + // Brief pause to allow sing-box to initialize before setting proxy + time.Sleep(500 * time.Millisecond) + + // Set proxy to route traffic through VPN + err = setSystemProxy("127.0.0.1:1080") + if err != nil { + log.Printf("Failed to set system proxy: %v", err) + dialog.ShowError(fmt.Errorf("failed to set system proxy: %v", err), myWindow) + + // If proxy failed to set, kill the sing-box process and revert connection + killCurrentProcess() + isConnected = false + updateConnectionButtonText(connectButton) + statusLabel.Text = "Отключено" + statusLabel.Color = color.RGBA{R: 128, G: 128, B: 128, A: 255} + statusLabel.Refresh() + return + } + + // Update connection state after successful connection + isConnected = true + } else { + // Disconnect - first disable proxy, then kill the process + err := disableSystemProxy() + if err != nil { + log.Printf("Failed to disable system proxy: %v", err) + } + + // Brief pause to allow proxy settings to be applied + time.Sleep(200 * time.Millisecond) + + // Disconnect - kill the current process + killCurrentProcess() + + // Update connection state after disconnection + isConnected = false + } + + // 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() + }) + connectButton.Importance = widget.HighImportance + + // Initially disable the button if no configs are available + if len(configs) == 0 { + connectButton.Disable() + } else { + connectButton.Enable() + } + + // 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("") + + // Enable connect button if we have configs + if len(configs) > 0 { + connectButton.Enable() + } + } 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() + + // Disable connect button if no configs remain or current config was deleted + if len(configs) == 0 || activeConfig == -1 { + connectButton.Disable() + } + } + }, + configWindow) + } + }, + ) + + newListContainer.OnSelected = func(id widget.ListItemID) { + activeConfig = id + dialog.ShowInformation("Config Selected", fmt.Sprintf("Active config: %s", configs[id].Title), configWindow) + + // Enable the connect button since a config is selected + connectButton.Enable() + + 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) + + // Register an app lifecycle listener to handle cleanup when the app exits + myApp.Lifecycle().SetOnStopped(func() { + // Kill the current process if running + killCurrentProcess() + + // Disable proxy when the application exits + err := disableSystemProxy() + if err != nil { + log.Printf("Failed to disable system proxy on exit: %v", err) + } + }) + + // 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() +} \ No newline at end of file diff --git a/linux-port/setup.sh b/linux-port/setup.sh new file mode 100644 index 0000000..0fb35fe --- /dev/null +++ b/linux-port/setup.sh @@ -0,0 +1,10 @@ +#!/bin/bash + +# Setup script for HamyVPNClient on Linux + +# Create necessary directories +mkdir -p bin +mkdir -p ~/.hamy-vpn + +# The application will automatically download sing-box if not present +echo "Directories created. Run 'go run main.go' to start the application." \ No newline at end of file