diff --git a/.github/workflows/android-ci.yml b/.github/workflows/android-ci.yml index 4ac7a32..b460e33 100644 --- a/.github/workflows/android-ci.yml +++ b/.github/workflows/android-ci.yml @@ -40,7 +40,7 @@ jobs: uses: actions/upload-artifact@v4 with: name: goose-relay-android-debug-universal-apk - path: android/app/build/outputs/apk/debug/app-universal-debug.apk + path: android/app/build/outputs/apk/debug/GooseRelayVPN.apk - name: Upload Go AAR artifact uses: actions/upload-artifact@v4 diff --git a/README.md b/README.md index d4aa3fe..c112ed1 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,9 @@ # GooseRelayVPN Android Client 🌐 **[فارسی](README_FA.md)** +[![Version](https://img.shields.io/badge/version-1.7.1-brightgreen)]() +[![Go Version](https://img.shields.io/badge/go-1.25.0-00ADD8)]() +[![Kotlin](https://img.shields.io/badge/kotlin-2.1.0-7F52FF)]() Android client for GooseRelayVPN that runs the GooseRelay core through a Go mobile bridge and provides a complete Android UI for VPN lifecycle, profiles, logs, and settings. @@ -11,109 +14,494 @@ Android client for GooseRelayVPN that runs the GooseRelay core through a Go mobi This app creates a local SOCKS5 endpoint on Android and tunnels TCP traffic through the GooseRelay architecture: -1. Local app/browser traffic -> SOCKS5 -2. GooseRelay encrypted framing (AES key from your profile) +1. Local app/browser traffic → SOCKS5 +2. GooseRelay encrypted framing (AES-256-GCM key from your profile) 3. HTTPS path through Google-facing endpoints (Apps Script flow) 4. Your VPS exit server handles outbound target connections The app wraps this flow in Android `VpnService` so selected/full traffic can be routed through the tunnel. +## App Screenshots + + + + + + + + + + + + + + +
HomeProfilesSettingsLogs
Home ScreenProfiles ManagerSettings ScreenLogs
+ + + + + + + + + + + + +
Profile ImportProfile SharingSettings Sharing
Import ProfileShare ProfileShare Settings
+ ## Key Features -- Android VPN integration (`VpnService` + tun2socks) -- Profile-based configuration -- JSON import/export for profile config -- Real-time Home status + telemetry cards -- Logs tab for Android/core diagnostics -- Split tunneling and internet sharing controls +### VPN & Tunneling +- 🔐 Android VPN integration (`VpnService` + tun2socks) +- 🔌 SOCKS5 proxy endpoint (configurable host:port) +- 🛡️ AES-256-GCM encryption with per-profile keys +- 📍 Domain fronting through Google infrastructure +- 🎯 Split tunneling with package selection + +### Profile Management (v1.7.1+) +- 📋 **Remote profile subscriptions** - Sync profiles from remote HTTPS URLs +- 🔗 **goose-relay:// protocol** - Share profiles via Base64-encoded URI scheme +- 📤 **JSON import/export** - Full profile backup and restore +- 📋 **Clipboard import/export** - Quick copy-paste profile sharing +- 🔄 **Remote updates** - One-click refresh from remote source +- ⚙️ **Auto-save** - Changes saved automatically to local database + +### Configuration +- **Multiple deployment IDs** - Support for multiple Google Apps Script deployments +- **Custom SOCKS settings** - Host, port, and optional auth (username/password) +- **SNI list** - Customizable Server Name Indication hostnames +- **Coalescing** - Fine-tune tunnel packet coalescing behavior +- **Idle slots** - Control idle slot distribution per bucket + +### User Interface +- 🏠 **Home tab** - Real-time connection status with telemetry cards +- 📊 **Live logs** - Core and app-level diagnostic logs +- ⚙️ **Global settings** - Internet sharing, DNS, connection mode +- 🌍 **Settings tab** - Profile-specific configuration +- 🎨 Material Design 3 with dark mode support + +### System Integration +- 📱 **Internet sharing** - Share VPN via SOCKS/HTTP proxy (LAN) +- 🔒 **Split tunneling** - Include/exclude specific apps from VPN +- 🌐 **Fake DNS** - Local DNS interception and resolution +- 🔐 **Allow LAN** - Route local network traffic outside VPN +- 🔋 **Low overhead** - Efficient Go-based core ## Configuration Model (Profile) -Profile fields are aligned with GooseRelay client config: +Profile fields are aligned with GooseRelay client config. Create profiles manually or import JSON: ```json { + "name": "My VPN Profile", "debug_timing": false, "socks_host": "127.0.0.1", "socks_port": 1080, + "socks_user": "", + "socks_pass": "", "google_host": "216.239.38.120", - "sni": ["www.google.com", "mail.google.com", "accounts.google.com"], + "sni": [ + "www.google.com", + "mail.google.com", + "accounts.google.com" + ], "script_keys": [ "REPLACE_WITH_DEPLOYMENT_ID", - "OPTIONAL_SECOND_DEPLOYMENT_ID" + "OPTIONAL_SECOND_DEPLOYMENT_ID|account@example.com" ], - "tunnel_key": "REPLACE_WITH_OUTPUT_OF_scripts_gen-key.sh" + "tunnel_key": "REPLACE_WITH_OUTPUT_OF_scripts_gen-key.sh", + "coalesce_step_ms": 0, + "idle_slots_per_bucket": 2 } ``` -Notes: -- `script_keys` in UI: one key per line. -- `tunnel_key`: must match server-side key. -- Import/export in app uses JSON. +### Profile Fields + +| Field | Type | Description | +|-------|------|-------------| +| `name` | string | Profile display name | +| `debug_timing` | boolean | Enable timing debug logs | +| `socks_host` | string | SOCKS5 server host (usually `127.0.0.1`) | +| `socks_port` | integer | SOCKS5 server port (1-65535) | +| `socks_user` | string | SOCKS5 username (optional, paired with password) | +| `socks_pass` | string | SOCKS5 password (optional, paired with username) | +| `google_host` | string | Google IP for domain fronting | +| `sni` | array | SNI hostnames for TLS | +| `script_keys` | array/string | Google Apps Script deployment ID(s). Format: `ID` or `ID\|account` | +| `tunnel_key` | string | Tunnel encryption key (server-side match required) | +| `coalesce_step_ms` | integer | Packet coalescing timeout (milliseconds) | +| `idle_slots_per_bucket` | integer | Idle slots per bucket (1-3) | + +### Profile Import/Export Methods (v1.7.1+) + +#### 1. Manual Profile +- Tap the `+` button → Select "Add Manual" +- Fill in fields directly in the form + +#### 2. JSON File +- Tap the `+` button → Select "From JSON" +- Choose a `.json` file from your device +- Profile fields are auto-populated + +#### 3. Clipboard (Recommended) — **NEW** +- Copy JSON or `goose-relay://` protocol to clipboard +- Tap the `+` button → Select "From Clipboard" +- Profile is created instantly + +#### 4. Profile Sharing (goose-relay:// Protocol) — **NEW** +- Open a profile → Tap **Share** icon +- Select "to Clipboard" to copy `goose-relay://` URI +- Share via messaging, QR code, or link +- Recipients paste into clipboard and tap "From Clipboard" + +### Remote Profile Subscriptions (v1.7.1+) — **NEW** + +#### Setting Up Remote Sync +1. **Edit Profile** and add Remote URL: `https://your-server.com/profiles/my-profile.json` +2. Profile will fetch and auto-update from this URL on each refresh +3. **Tap Refresh** (↻) icon in Profiles toolbar to manually sync +4. Changes save locally automatically + +#### Remote URL Format +``` +https://example.com/path/to/profile.json +``` + +Supported endpoints: +- Any HTTPS server hosting profile JSON +- Optional HTTP Basic Auth: `https://user:pass@example.com/profile.json` +- Self-signed certs: Certificate validation required (add to system trust store) + +#### Auto-Update Behavior +- Profiles with remote URLs fetch on **manual refresh** only (not automatic) +- All fields except `remoteUrl` are updated from remote +- Local changes are preserved if remote URL is not set +- On update failure, error message displayed in snackbar + +## Default Split Tunneling Apps + +By default, these apps bypass the VPN (included in split tunnel list): + +- Instagram (`com.instagram.android`) +- Telegram (`org.telegram.messenger`) +- WhatsApp (`com.whatsapp`) +- YouTube (`com.google.android.youtube`) +- Chrome (`com.android.chrome`) +- Gmail (`com.google.android.gm`) +- Google Play Store (`com.android.vending`) +- Google Search (`com.google.android.googlequicksearchbox`) +- Twitter/X (`com.twitter.android`) +- OpenAI ChatGPT (`com.openai.chatgpt`) + +Modify in **Global Settings** → **Split Tunneling** → Edit package list. + +## Global Settings + +### Connection Mode +- **VPN**: Full VPN routing (default) +- **SOCKS**: Direct SOCKS proxy (app-by-app) + +### Tunneling +- **Split Tunneling**: Enable per-app VPN selection (default: enabled) +- **Mode**: Include selected apps or exclude selected apps +- **Allow LAN**: Route local network traffic outside VPN +- **Fake DNS**: Intercept DNS queries locally (default: enabled) + +### Internet Sharing +- **Share VPN via Network**: Enable/disable +- **SOCKS Port**: Proxy port for LAN devices (default: 8090) +- **HTTP Port**: HTTP proxy port (default: 8091) +- **Auth**: Optional username/password for proxies + +### Advanced +- **Custom DNS Servers**: Comma-separated DNS IP addresses +- **Auto-save**: Changes persist automatically (default: enabled in v1.7.1+) + +### Clipboard Export/Import (v1.7.1+) — **NEW** +- Tap **⋮** (More) menu in Settings toolbar +- **Export to Clipboard** — Copy all settings as JSON +- **Import from Clipboard** — Paste previously exported settings ## Upstream Setup Flow (Required) You still need upstream infrastructure prepared first: -1. Prepare VPS and run `goose-server` -2. Deploy `apps_script/Code.gs` and obtain deployment ID(s) -3. Generate key via `scripts/gen-key.sh` -4. Put deployment ID(s) and key into Android profile -5. Connect in app and configure app/browser proxy as needed +1. **Prepare VPS** + - Deploy VPS with public IP + - Run `goose-server` binary + - Note the VPS IP/domain + +2. **Deploy Google Apps Script** + - Copy `apps_script/Code.gs` from upstream repo + - Create Google Apps Script project + - Paste code and deploy as web app + - Note the **Deployment ID** + +3. **Generate Encryption Key** + - Run `scripts/gen-key.sh` + - Output is your `tunnel_key` -For full infra details, use upstream docs: +4. **Add Profile to App** + - Create new profile in app + - Set `script_keys` to deployment ID(s) + - Set `tunnel_key` to generated key + - Set `google_host` (usually 216.239.38.120) + +5. **Connect & Verify** + - Tap **Connect** on Home + - Check **Logs** for any errors + - Verify traffic flows through VPN + +For complete infrastructure details, see upstream docs: - https://github.com/kianmhz/GooseRelayVPN -## Android Build (Local) +## Android Build (Local Development) -Requirements: -- Android Studio -- JDK 17 -- Go 1.22+ -- Android SDK / NDK +### Requirements +- Android Studio 2024.1+ +- JDK 17+ +- Go 1.25.0+ (was 1.22, upgraded in v1.7.1) +- Android SDK API 36+ +- Android NDK (latest) -Build AAR (Go mobile bridge): +### Build Go Mobile Bridge ```bash -bash android/build_go_mobile.sh +cd android +bash ../build_go_mobile.sh ``` -Build debug APK: +This creates AAR library with GooseRelay core compiled for all architectures. + +### Build Debug APK ```bash cd android ./gradlew :app:assembleDebug ``` -## Release / CI +Output: `app/build/outputs/apk/debug/GooseRelayVPN.apk` + +### Build Release APK + +Requires signing configuration in `local.properties`: + +```properties +ANDROID_KEYSTORE_PATH=/path/to/keystore.jks +ANDROID_KEYSTORE_PASSWORD=keystore_password +ANDROID_KEY_ALIAS=key_alias +ANDROID_KEY_PASSWORD=key_password +``` + +Then: + +```bash +cd android +./gradlew :app:bundleRelease +``` + +Output: `app/build/outputs/bundle/release/app-release.aab` + +## Release & CI/CD + +### GitHub Workflows + +**`.github/workflows/android-ci.yml`** +- Runs on every commit +- Builds debug APK +- Runs unit tests +- Checks code quality + +**`.github/workflows/release.yml`** +- Triggered on version tag (v*.*.*) +- Builds signed release APK/AAB +- Creates GitHub Release with artifacts +- Requires signing secrets + +**`.github/workflows/release-manual.yml`** +- Manual trigger for releases +- Same as release.yml but on-demand + +### Secrets Required + +Configure these in GitHub repository settings: -Workflows in this repo: -- `.github/workflows/android-ci.yml` -- `.github/workflows/release-manual.yml` -- `.github/workflows/release.yml` +- `ANDROID_KEYSTORE_BASE64` — Base64-encoded keystore file +- `ANDROID_KEYSTORE_PASSWORD` — Keystore password +- `ANDROID_KEY_ALIAS` — Key alias in keystore +- `ANDROID_KEY_PASSWORD` — Key password -Manual signed release requires secrets: -- `ANDROID_KEYSTORE_BASE64` -- `ANDROID_KEYSTORE_PASSWORD` -- `ANDROID_KEY_ALIAS` -- `ANDROID_KEY_PASSWORD` +### Build Artifacts + +Release produces: +- `GooseRelayVPN.apk` — Universal APK (all architectures) +- `GooseRelayVPN-arm64-v8a.apk` — ARM64 only +- `GooseRelayVPN-armeabi-v7a.apk` — ARMv7 only +- `GooseRelayVPN-x86_64.apk` — x86_64 only +- `app-release.aab` — Google Play App Bundle ## Troubleshooting -- If reconnect fails with SOCKS port busy: - - Disconnect, wait a few seconds, reconnect. - - Ensure no other app uses the same SOCKS port. -- If connection stays in preparing state: - - Verify `script_keys` and `tunnel_key`. - - Check Logs tab for core errors. -- If no traffic flows: - - Verify VPN permission granted. - - Verify split tunnel app selection. +### Connection Issues + +**SOCKS Port Busy** +- Disconnect VPN, wait 3 seconds, reconnect +- Check no other app uses port 1080 (or configured port) +- Kill app via settings and restart + +**Connection Stays "Preparing"** +- Verify `script_keys` (deployment ID) is correct +- Verify `tunnel_key` matches server-side key +- Check **Logs** tab for core error messages +- Ensure VPS is running and reachable + +**No Traffic Flows** +- Verify VPN permission is granted +- Check split tunneling app selection +- Ensure profile is selected and connected +- Review **Logs** for network errors + +### Performance Issues + +**High Battery/Data Usage** +- Disable debug logging (set `debug_timing: false`) +- Check split tunnel configuration +- Monitor active connections in **Logs** + +**Slow Connections** +- Try different `google_host` values +- Adjust `coalesce_step_ms` (higher = more batch, more latency) +- Test on different networks (WiFi vs mobile) + +### Build Issues + +**Gradle Error: "Failed to install gomobile"** +- Check Go version: `go version` (need 1.25.0+) +- Delete `$GOPATH/pkg/mod/golang.org/x/mobile` and retry + +**NDK Not Found** +- Install via Android Studio: Settings → SDK Manager → SDK Tools +- Set `ANDROID_NDK` environment variable + +## Architecture Overview + +``` +┌─────────────────────────────────────────────┐ +│ Android UI Layer (Compose) │ +│ ┌──────────────────────────────────────┐ │ +│ │ Home │ Profiles │ Settings │ Logs │ │ +│ └──────────────────────────────────────┘ │ +└─────────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────────┐ +│ ViewModels & Repository Layer │ +│ ┌──────────────────────────────────────┐ │ +│ │ Room Database │ DataStore │ Hilt DI │ │ +│ └──────────────────────────────────────┘ │ +└─────────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────────┐ +│ VPN Service & Go Core │ +│ ┌──────────────────────────────────────┐ │ +│ │ tun2socks │ GooseRelay Core (Go) │ │ +│ └──────────────────────────────────────┘ │ +└─────────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────────┐ +│ Network Layer │ +│ ┌──────────────────────────────────────┐ │ +│ │ SOCKS5 → Google Apps Script → VPS │ │ +│ └──────────────────────────────────────┘ │ +└─────────────────────────────────────────────┘ +``` + +## Version History + +### v1.7.1 (Current) — July 2026 +- ✅ **Remote profile subscriptions** with manual refresh +- ✅ **goose-relay:// protocol** for URL-based profile sharing +- ✅ **Clipboard import/export** for profiles +- ✅ **Global settings clipboard** export/import +- ✅ **Auto-save** for profile and global settings changes +- ✅ **Dropdown menus** for profile management (+ button, share) +- ✅ **Default split tunneling apps** (Instagram, Telegram, WhatsApp, YouTube, Chrome, Gmail, Play Store, etc.) +- ✅ Updated to **Gradle 8.13**, **Android Gradle Plugin 8.13.0** +- ✅ Updated to **Go 1.25.0** with latest dependencies +- ✅ JVM memory increased (Xmx4096m) +- ✅ Fixed SOCKS field defaults in JSON export +- ✅ Improved error handling for imports + +### v1.0.0+ — Earlier Releases +- VPN service with tun2socks +- Profile management +- Live logs and diagnostics +- Split tunneling +- Internet sharing + +## Tech Stack + +### Android +- **Language**: Kotlin 2.1.0 +- **Framework**: Jetpack Compose +- **Architecture**: MVVM with Clean Architecture +- **Database**: Room ORM +- **DI**: Hilt +- **VPN**: Android VpnService API, tun2socks +- **Build**: Gradle 8.13, Android Gradle Plugin 8.13.0 + +### Go Core +- **Version**: 1.25.0 (upgraded from 1.23.1) +- **Crypto**: AES-256-GCM (golang.org/x/crypto) +- **Networking**: golang.org/x/net 0.52.0, tun2socks +- **Compression**: klauspost/compress (Brotli, Gzip) +- **Logging**: Uber zap + +### Key Dependencies + +**Go:** +- `github.com/things-go/go-socks5` — SOCKS5 server +- `github.com/xjasonlyu/tun2socks/v2` — TUN device handler +- `golang.org/x/net v0.52.0` — Networking utilities +- `golang.org/x/mobile v0.0.0-20260312152759-81488f6aeb60` — Go mobile binding +- `golang.org/x/crypto v0.49.0` — Cryptography + +**Android:** +- Jetpack Compose, Navigation, Hilt, Room +- Datastore Proto, Gson, Coil (images) + +See `go.mod`, `go.sum`, and `android/build.gradle.kts` for complete lists. ## Credits -1. Main project: - - https://github.com/kianmhz/GooseRelayVPN +1. **Main GooseRelay Project** + - https://github.com/Kianmhz/GooseRelayVPN -2. Project that inspired the main project idea: +2. **Inspiration** - https://github.com/masterking32/MasterHttpRelayVPN + +3. **Community** + - Go, Kotlin, Jetpack Compose teams + - tun2socks, SOCKS5, and networking library authors + +## License + +See [LICENSE](LICENSE) file for details. + +## Support & Contributing + +- **Issues**: Report bugs on [GitHub Issues](https://github.com/ArashAfkandeh/GooseRelayVPN-AndroidClient/issues) +- **Discussions**: Join [GitHub Discussions](https://github.com/ArashAfkandeh/GooseRelayVPN-AndroidClient/discussions) +- **Contributing**: PRs welcome! +- **Upstream**: Questions about core protocol → [GooseRelayVPN](https://github.com/kianmhz/GooseRelayVPN) + +## Disclaimer + +This tool is for educational and authorized use only. Users are responsible for: +- Compliance with local laws and regulations +- Proper authorization from network administrators +- Ethical use of VPN technology +- Respecting terms of service of infrastructure providers + +Developers are not liable for misuse or damages. diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 78fc104..115fdac 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -11,7 +11,7 @@ android { compileSdk = 36 val appVersionName = System.getenv("ANDROID_VERSION_NAME") ?.takeIf { it.isNotBlank() } - ?: "1.0.0" + ?: "1.7.1" val appVersionCode = System.getenv("ANDROID_VERSION_CODE") ?.toIntOrNull() ?: 1 @@ -67,6 +67,18 @@ android { } } + applicationVariants.all { + outputs.all { + val output = this as com.android.build.gradle.internal.api.ApkVariantOutputImpl + val abi = output.getFilter(com.android.build.OutputFile.ABI) + output.outputFileName = when (abi) { + null -> "GooseRelayVPN.apk" + "universal" -> "GooseRelayVPN.apk" + else -> "GooseRelayVPN-$abi.apk" + } + } + } + compileOptions { sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 diff --git a/android/app/src/main/java/com/gooserelay/gooserelayvpn/data/local/ProfileEntity.kt b/android/app/src/main/java/com/gooserelay/gooserelayvpn/data/local/ProfileEntity.kt index 9a03686..cf1c121 100644 --- a/android/app/src/main/java/com/gooserelay/gooserelayvpn/data/local/ProfileEntity.kt +++ b/android/app/src/main/java/com/gooserelay/gooserelayvpn/data/local/ProfileEntity.kt @@ -18,6 +18,7 @@ data class ProfileEntity( val tunnelKey: String = "", val coalesceStepMs: Int = 0, val idleSlotsPerBucket: Int = 2, + val remoteUrl: String? = null, val isSelected: Boolean = false, val createdAt: Long = System.currentTimeMillis() ) diff --git a/android/app/src/main/java/com/gooserelay/gooserelayvpn/ui/profiles/ProfilesScreen.kt b/android/app/src/main/java/com/gooserelay/gooserelayvpn/ui/profiles/ProfilesScreen.kt index a8a2fe6..6d28fcc 100644 --- a/android/app/src/main/java/com/gooserelay/gooserelayvpn/ui/profiles/ProfilesScreen.kt +++ b/android/app/src/main/java/com/gooserelay/gooserelayvpn/ui/profiles/ProfilesScreen.kt @@ -2,10 +2,10 @@ package com.gooserelay.gooserelayvpn.ui.profiles import android.content.Context import android.net.Uri +import android.util.Base64 import android.util.Log import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts -import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -20,19 +20,23 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.verticalScroll -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.CheckCircle import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.PersonAdd +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material.icons.filled.Share +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults +import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExposedDropdownMenuBox @@ -40,6 +44,7 @@ import androidx.compose.material3.ExposedDropdownMenuDefaults import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.MenuAnchorType import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold @@ -55,9 +60,10 @@ import androidx.compose.runtime.setValue import androidx.compose.runtime.collectAsState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import com.google.gson.Gson @@ -67,7 +73,6 @@ import com.gooserelay.gooserelayvpn.ui.components.mdv.controls.MdvBackTopAppBar import com.gooserelay.gooserelayvpn.ui.theme.ConnectedGreen import com.gooserelay.gooserelayvpn.ui.theme.MdvColor import com.gooserelay.gooserelayvpn.ui.theme.MdvSpace -import com.gooserelay.gooserelayvpn.ui.profiles.ProfilesViewModel import com.gooserelay.gooserelayvpn.util.ConfigGenerator data class ScriptKeyEntry( @@ -76,33 +81,26 @@ data class ScriptKeyEntry( ) fun parseScriptKeysText(text: String): List { - Log.d("ProfilesScreen", "parseScriptKeysText input: '$text'") - val result = text.lines() - .map { it.trim() } - .filter { it.isNotEmpty() } + if (text.isBlank()) return emptyList() + Log.d("ProfilesScreen", "Parsing script keys text: '$text'") + return text.split("\n") + .filter { it.isNotBlank() } .map { line -> if (line.contains("|")) { val parts = line.split("|") - val entry = ScriptKeyEntry( - id = parts.getOrElse(0) { "" }.trim(), - account = parts.getOrElse(1) { "" }.trim() - ) - Log.d("ProfilesScreen", "Parsed pipe: id='${entry.id}', account='${entry.account}'") - entry + val id = parts[0].trim() + val account = parts.getOrElse(1) { "" }.trim() + Log.d("ProfilesScreen", " Parsed pipe: id='$id', account='$account'") + ScriptKeyEntry(id, account) } else { - Log.d("ProfilesScreen", "Parsed no-pipe: id='${line.trim()}'") - ScriptKeyEntry(id = line.trim(), account = "") + val id = line.trim() + Log.d("ProfilesScreen", " Parsed single: id='$id'") + ScriptKeyEntry(id, "") } } - Log.d("ProfilesScreen", "parseScriptKeysText result: $result") - return result } fun scriptKeysToText(entries: List): String { - Log.d("ProfilesScreen", "scriptKeysToText input: ${entries.size} entries") - entries.forEachIndexed { i, entry -> - Log.d("ProfilesScreen", " [$i] id='${entry.id}', account='${entry.account}'") - } val result = entries .filter { it.id.isNotBlank() } .joinToString("\n") { entry -> @@ -112,16 +110,135 @@ fun scriptKeysToText(entries: List): String { return result } +fun parseProfileFromJson(raw: String, defaultName: String? = null): ProfileEntity? { + return try { + val root = Gson().fromJson(raw, JsonObject::class.java) + + // Check if it has at least one identifying part + if (!root.has("script_keys") && !root.has("tunnel_key")) return null + + val name = root.get("name")?.asString ?: defaultName ?: "Imported" + val debugTiming = root.get("debug_timing")?.asBoolean ?: false + val socksHost = root.get("socks_host")?.asString ?: "127.0.0.1" + val socksPort = root.get("socks_port")?.asInt ?: 1080 + val socksUser = root.get("socks_user")?.asString ?: "" + val socksPass = root.get("socks_pass")?.asString ?: "" + val googleHost = root.get("google_host")?.asString ?: "216.239.38.120" + val sniJson = when { + root.get("sni")?.isJsonArray == true -> Gson().toJson(root.getAsJsonArray("sni").mapNotNull { it.asString }) + root.get("sni")?.isJsonPrimitive == true -> Gson().toJson(listOf(root.get("sni").asString)) + else -> "[\"www.google.com\", \"mail.google.com\", \"accounts.google.com\"]" + } + val scriptKeysText = when { + root.get("script_keys")?.isJsonArray == true -> { + root.getAsJsonArray("script_keys").mapNotNull { element -> + when { + element.isJsonObject -> { + val obj = element.asJsonObject + val id = obj.get("id")?.asString?.trim() + val account = obj.get("account")?.asString?.trim() + if (id.isNullOrBlank()) null + else if (account.isNullOrBlank()) id + else "$id|$account" + } + element.isJsonPrimitive -> element.asString.trim() + else -> null + } + }.filter { it.isNotBlank() }.joinToString("\n") + } + root.get("script_keys")?.isJsonPrimitive == true -> root.get("script_keys").asString.trim() + else -> "" + } + val coalesceStepMs = root.get("coalesce_step_ms")?.asInt ?: 0 + val idleSlotsPerBucket = root.get("idle_slots_per_bucket")?.asInt?.coerceIn(1, 3) ?: 2 + val tunnelKey = root.get("tunnel_key")?.asString ?: "" + + ProfileEntity( + name = name, + debugTiming = debugTiming, + socksHost = socksHost, + socksPort = socksPort, + socksUser = socksUser, + socksPass = socksPass, + googleHost = googleHost, + sniJson = sniJson, + scriptKeysText = scriptKeysText, + tunnelKey = tunnelKey, + coalesceStepMs = coalesceStepMs, + idleSlotsPerBucket = idleSlotsPerBucket, + remoteUrl = null + ) + } catch (_: Exception) { + null + } +} + +fun parseGooseRelayProtocol(raw: String): ProfileEntity? { + val trimmed = raw.trim() + if (!trimmed.startsWith("goose-relay://")) return null + return try { + val base64Content = trimmed.substring("goose-relay://".length) + val decodedBytes = Base64.decode(base64Content, Base64.DEFAULT) + val decodedString = String(decodedBytes) + parseProfileFromJson(decodedString) + } catch (_: Exception) { + null + } +} + +private fun getFileNameFromUri(context: Context, uri: Uri): String? { + return context.contentResolver.query(uri, null, null, null, null)?.use { cursor -> + val nameIndex = cursor.getColumnIndex(android.provider.OpenableColumns.DISPLAY_NAME) + if (cursor.moveToFirst()) { + val full = cursor.getString(nameIndex) + full.substringBeforeLast(".") + } else null + } +} + @Composable fun ProfilesScreen( onBack: () -> Unit ) { val viewModel: ProfilesViewModel = hiltViewModel() val profiles by viewModel.profiles.collectAsState() + val isUpdating by viewModel.isUpdating.collectAsState() + val updateMessage by viewModel.updateMessage.collectAsState() val context = LocalContext.current + val snackbarHostState = remember { SnackbarHostState() } var showEditor by remember { mutableStateOf(false) } var editing by remember { mutableStateOf(null) } var profilePendingDelete by remember { mutableStateOf(null) } + var showErrorDialog by remember { mutableStateOf(null) } + var menuExpanded by remember { mutableStateOf(false) } + var profileToExport by remember { mutableStateOf(null) } + + val clipboardManager = LocalClipboardManager.current + + LaunchedEffect(updateMessage) { + updateMessage?.let { + snackbarHostState.showSnackbar(it) + viewModel.clearUpdateMessage() + } + } + + val exportLauncher = rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("application/json")) { uri -> + if (uri == null || profileToExport == null) return@rememberLauncherForActivityResult + writeTextToUri(context, uri, ConfigGenerator.exportProfileJson(profileToExport!!)) + profileToExport = null + } + + val importLauncher = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri -> + if (uri == null) return@rememberLauncherForActivityResult + val raw = readTextFromUri(context, uri) + val fileName = getFileNameFromUri(context, uri) + val profile = viewModel.parseProfileFromJson(raw, fileName) + if (profile != null) { + viewModel.addProfile(profile) + } else { + showErrorDialog = "Import failed: invalid JSON format." + } + } Scaffold( topBar = { @@ -129,12 +246,70 @@ fun ProfilesScreen( title = "Profiles", onBack = onBack, actions = { - IconButton(onClick = { editing = null; showEditor = true }) { - Icon(Icons.Filled.Add, contentDescription = "Add") + if (isUpdating) { + CircularProgressIndicator( + modifier = Modifier.size(24.dp).padding(4.dp), + strokeWidth = 2.dp, + color = MdvColor.Primary + ) + } else { + IconButton(onClick = { viewModel.updateRemoteProfile(context) }) { + Icon(Icons.Filled.Refresh, contentDescription = "Update Remote") + } + } + Box { + IconButton(onClick = { menuExpanded = true }) { + Icon(Icons.Filled.Add, contentDescription = "Add") + } + DropdownMenu( + expanded = menuExpanded, + onDismissRequest = { menuExpanded = false } + ) { + DropdownMenuItem( + text = { Text("From Clipboard") }, + onClick = { + menuExpanded = false + val text = clipboardManager.getText()?.text?.trim() + if (!text.isNullOrBlank()) { + when { + text.startsWith("http://") || text.startsWith("https://") -> { + viewModel.importProfileFromUrl(text, context) + } + else -> { + val profile = parseGooseRelayProtocol(text) ?: parseProfileFromJson(text) + if (profile != null) { + viewModel.addProfile(profile) + } else { + showErrorDialog = "Import failed: invalid JSON, URL, or goose-relay:// format." + } + } + } + } else { + showErrorDialog = "Clipboard is empty" + } + } + ) + DropdownMenuItem( + text = { Text("From JSON File") }, + onClick = { + menuExpanded = false + importLauncher.launch(arrayOf("application/json", "text/plain", "*/*")) + } + ) + DropdownMenuItem( + text = { Text("Add Manual") }, + onClick = { + menuExpanded = false + editing = null + showEditor = true + } + ) + } } } ) - } + }, + snackbarHost = { SnackbarHost(snackbarHostState) } ) { padding -> if (profiles.isEmpty()) { Box( @@ -148,9 +323,14 @@ fun ProfilesScreen( tint = MdvColor.OnSurfaceVariant ) Spacer(modifier = Modifier.height(MdvSpace.S3)) - Text("No profiles yet") - Spacer(modifier = Modifier.height(MdvSpace.S2)) - Button(onClick = { editing = null; showEditor = true }) { Text("Create Profile") } + Text("No profiles yet", style = MaterialTheme.typography.titleMedium) + Spacer(modifier = Modifier.height(MdvSpace.S1)) + Text( + "Tap the + button above to add a profile\nor import from clipboard/file", + style = MaterialTheme.typography.bodySmall, + color = MdvColor.OnSurfaceVariant, + textAlign = androidx.compose.ui.text.style.TextAlign.Center + ) } } } else { @@ -194,6 +374,41 @@ fun ProfilesScreen( IconButton(onClick = { editing = profile; showEditor = true }) { Icon(Icons.Filled.Edit, contentDescription = "Edit") } + + var shareMenuExpanded by remember { mutableStateOf(false) } + Box { + IconButton(onClick = { shareMenuExpanded = true }) { + Icon(Icons.Filled.Share, contentDescription = "Share") + } + DropdownMenu( + expanded = shareMenuExpanded, + onDismissRequest = { shareMenuExpanded = false } + ) { + DropdownMenuItem( + text = { Text("to Clipboard") }, + onClick = { + shareMenuExpanded = false + val exportObj = JsonObject() + exportObj.addProperty("name", profile.name) + exportObj.addProperty("script_keys", profile.scriptKeysText) + exportObj.addProperty("tunnel_key", profile.tunnelKey) + val json = Gson().toJson(exportObj) + val base64 = Base64.encodeToString(json.toByteArray(), Base64.NO_WRAP) + val protocol = "goose-relay://$base64" + clipboardManager.setText(AnnotatedString(protocol)) + } + ) + DropdownMenuItem( + text = { Text("to JSON") }, + onClick = { + shareMenuExpanded = false + profileToExport = profile + exportLauncher.launch("goose_profile_${profile.name}.json") + } + ) + } + } + IconButton(onClick = { profilePendingDelete = profile }) { Icon(Icons.Filled.Delete, contentDescription = "Delete") } @@ -204,9 +419,22 @@ fun ProfilesScreen( } } + val dialogError = showErrorDialog + if (dialogError != null) { + AlertDialog( + onDismissRequest = { showErrorDialog = null }, + confirmButton = { + TextButton(onClick = { showErrorDialog = null }) { + Text("OK") + } + }, + title = { Text("Notification") }, + text = { Text(dialogError) } + ) + } + if (showEditor) { ProfileEditorDialog( - context = context, profile = editing, onSave = { if (editing == null) viewModel.addProfile(it) else viewModel.updateProfile(it) @@ -251,7 +479,6 @@ fun ProfilesScreen( @OptIn(ExperimentalMaterial3Api::class) @Composable private fun ProfileEditorDialog( - context: Context, profile: ProfileEntity?, onSave: (ProfileEntity) -> Unit, onDismiss: () -> Unit @@ -264,107 +491,13 @@ private fun ProfileEditorDialog( var socksPass by remember { mutableStateOf(profile?.socksPass ?: "") } var googleHost by remember { mutableStateOf(profile?.googleHost ?: "216.239.38.120") } var sniCsv by remember { mutableStateOf(profile?.sniJson?.replace("[", "")?.replace("]", "")?.replace("\"", "") ?: "www.google.com, mail.google.com, accounts.google.com") } - var scriptKeysText by remember { mutableStateOf(profile?.scriptKeysText ?: "") } var scriptKeyEntries by remember { mutableStateOf(parseScriptKeysText(profile?.scriptKeysText ?: "").ifEmpty { listOf(ScriptKeyEntry()) }) } var tunnelKey by remember { mutableStateOf(profile?.tunnelKey ?: "") } var coalesceStepMs by remember { mutableStateOf((profile?.coalesceStepMs ?: 0).toString()) } var idleSlotsPerBucket by remember { mutableStateOf((profile?.idleSlotsPerBucket ?: 2).toString()) } + var remoteUrl by remember { mutableStateOf(profile?.remoteUrl ?: "") } var showErrorDialog by remember { mutableStateOf(null) } - Log.d("ProfilesScreen", "=== DIALOG INIT === profile=${profile?.name ?: "NEW"}") - Log.d("ProfilesScreen", "Loaded scriptKeysText: '${profile?.scriptKeysText ?: "(empty)"}'") - Log.d("ProfilesScreen", "Parsed scriptKeyEntries (${scriptKeyEntries.size}): $scriptKeyEntries") - - LaunchedEffect(scriptKeyEntries) { - Log.d("ProfilesScreen", "*** scriptKeyEntries CHANGED (${scriptKeyEntries.size} items) ***") - scriptKeyEntries.forEachIndexed { i, entry -> - Log.d("ProfilesScreen", " [$i] id='${entry.id}', account='${entry.account}'") - } - } - - val importLauncher = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri -> - if (uri == null) return@rememberLauncherForActivityResult - runCatching { - val raw = readTextFromUri(context, uri) - val root = Gson().fromJson(raw, JsonObject::class.java) - name = root.get("name")?.asString ?: name - debugTiming = root.get("debug_timing")?.asBoolean ?: debugTiming - socksHost = root.get("socks_host")?.asString ?: socksHost - socksPort = root.get("socks_port")?.asInt?.toString() ?: socksPort - socksUser = root.get("socks_user")?.asString ?: socksUser - socksPass = root.get("socks_pass")?.asString ?: socksPass - if ((socksUser.isBlank()) != (socksPass.isBlank())) { - showErrorDialog = "Import rejected: socks_user and socks_pass must both be set or both be empty (SOCKS5 auth requires both values)." - return@runCatching - } - showErrorDialog = null - googleHost = root.get("google_host")?.asString ?: googleHost - sniCsv = when { - root.get("sni")?.isJsonArray == true -> root.getAsJsonArray("sni")?.mapNotNull { it.asString }?.joinToString(", ") ?: "" - root.get("sni")?.isJsonPrimitive == true -> root.get("sni")?.asString ?: "" - else -> "" - } - val keys = when { - root.get("script_keys")?.isJsonArray == true -> { - Log.d("ProfilesScreen", "=== IMPORTING SCRIPT KEYS FROM JSON ===") - root.getAsJsonArray("script_keys")?.mapNotNull { element -> - when { - element.isJsonObject -> { - val obj = element.asJsonObject - val id = obj.get("id")?.asString?.trim() - val account = obj.get("account")?.asString?.trim() - Log.d("ProfilesScreen", "Import JSON obj: id='$id', account='$account'") - if (id.isNullOrBlank()) null - else if (account.isNullOrBlank()) { - Log.d("ProfilesScreen", " -> No account, saving as: '$id'") - id - } - else { - val result = "$id|$account" - Log.d("ProfilesScreen", " -> With account, saving as: '$result'") - result - } - } - element.isJsonPrimitive -> element.asString.trim() - else -> null - } - }?.filter { it.isNotBlank() }?.joinToString("\n") ?: "" - } - else -> "" - } - Log.d("ProfilesScreen", "Final imported keys text: '$keys'") - scriptKeysText = keys - Log.d("ProfilesScreen", "About to parse imported keys with parseScriptKeysText()") - scriptKeyEntries = parseScriptKeysText(keys) - Log.d("ProfilesScreen", "After parsing, scriptKeyEntries has ${scriptKeyEntries.size} items") - coalesceStepMs = (root.get("coalesce_step_ms")?.asInt ?: 0).toString() - idleSlotsPerBucket = (root.get("idle_slots_per_bucket")?.asInt?.coerceIn(1, 3) ?: 2).toString() - tunnelKey = root.get("tunnel_key")?.asString ?: tunnelKey - } - } - val exportLauncher = rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("application/json")) { uri -> - if (uri == null) return@rememberLauncherForActivityResult - val draft = ProfileEntity( - id = profile?.id ?: 0, - name = name, - debugTiming = debugTiming, - socksHost = socksHost, - socksPort = socksPort.toIntOrNull()?.coerceIn(1, 65535) ?: 1080, - socksUser = socksUser, - socksPass = socksPass, - googleHost = googleHost, - sniJson = if (sniCsv.isBlank()) "[]" else sniCsv.split(",").map { it.trim() }.filter { it.isNotBlank() } - .joinToString(prefix = "[\"", postfix = "\"]", separator = "\",\""), - scriptKeysText = scriptKeysToText(scriptKeyEntries), - tunnelKey = tunnelKey, - coalesceStepMs = coalesceStepMs.toIntOrNull() ?: 0, - idleSlotsPerBucket = idleSlotsPerBucket.toIntOrNull()?.coerceIn(1, 3) ?: 2, - isSelected = profile?.isSelected ?: false, - createdAt = profile?.createdAt ?: System.currentTimeMillis() - ) - writeTextToUri(context, uri, ConfigGenerator.exportProfileJson(draft)) - } - AlertDialog( onDismissRequest = onDismiss, confirmButton = { @@ -381,13 +514,8 @@ private fun ProfileEditorDialog( } val sniJson = sniCsv.split(",").map { it.trim() }.filter { it.isNotBlank() } .joinToString(prefix = "[\"", postfix = "\"]", separator = "\",\"") - Log.d("ProfilesScreen", "=== SAVING PROFILE ===") - Log.d("ProfilesScreen", "scriptKeyEntries state (${scriptKeyEntries.size} entries):") - scriptKeyEntries.forEachIndexed { i, entry -> - Log.d("ProfilesScreen", " [$i] id='${entry.id}', account='${entry.account}'") - } + val scriptKeysForSave = scriptKeysToText(scriptKeyEntries) - Log.d("ProfilesScreen", "Final scriptKeysText to save: '$scriptKeysForSave'") onSave( ProfileEntity( id = profile?.id ?: 0, @@ -403,6 +531,7 @@ private fun ProfileEditorDialog( tunnelKey = tunnelKey, coalesceStepMs = coalesceStepMs.toIntOrNull() ?: 0, idleSlotsPerBucket = idleSlotsPerBucket.toIntOrNull()?.coerceIn(1, 3) ?: 2, + remoteUrl = remoteUrl.ifBlank { null }, isSelected = profile?.isSelected ?: false, createdAt = profile?.createdAt ?: System.currentTimeMillis() ) @@ -416,10 +545,13 @@ private fun ProfileEditorDialog( modifier = Modifier.verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(8.dp) ) { - Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) { - OutlinedButton(onClick = { importLauncher.launch(arrayOf("application/json", "text/plain", "*/*")) }, modifier = Modifier.weight(1f)) { Text("Import JSON") } - OutlinedButton(onClick = { exportLauncher.launch("goose_profile.json") }, modifier = Modifier.weight(1f)) { Text("Export JSON") } - } + OutlinedTextField( + value = remoteUrl, + onValueChange = { remoteUrl = it }, + label = { Text("Remote URL (Subscription)") }, + modifier = Modifier.fillMaxWidth(), + supportingText = { Text("If set, this profile will update from this URL") } + ) OutlinedTextField(value = name, onValueChange = { name = it }, label = { Text("Profile Name") }) Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { Text("Debug Timing") @@ -436,7 +568,7 @@ private fun ProfileEditorDialog( readOnly = true, label = { Text("socks_host") }, trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = socksHostExpanded) }, - modifier = Modifier.menuAnchor() + modifier = Modifier.menuAnchor(type = MenuAnchorType.PrimaryNotEditable) ) ExposedDropdownMenu( expanded = socksHostExpanded, diff --git a/android/app/src/main/java/com/gooserelay/gooserelayvpn/ui/profiles/ProfilesViewModel.kt b/android/app/src/main/java/com/gooserelay/gooserelayvpn/ui/profiles/ProfilesViewModel.kt index 29df0dd..7e57289 100644 --- a/android/app/src/main/java/com/gooserelay/gooserelayvpn/ui/profiles/ProfilesViewModel.kt +++ b/android/app/src/main/java/com/gooserelay/gooserelayvpn/ui/profiles/ProfilesViewModel.kt @@ -1,14 +1,25 @@ package com.gooserelay.gooserelayvpn.ui.profiles +import android.content.Context import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.google.gson.Gson +import com.google.gson.JsonObject +import com.gooserelay.gooserelayvpn.R import com.gooserelay.gooserelayvpn.data.local.ProfileEntity import com.gooserelay.gooserelayvpn.data.repository.ProfileRepository import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.net.HttpURLConnection +import java.net.URL import javax.inject.Inject @HiltViewModel @@ -20,6 +31,12 @@ class ProfilesViewModel @Inject constructor( profileRepository.getAllProfiles() .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) + private val _isUpdating = MutableStateFlow(false) + val isUpdating: StateFlow = _isUpdating.asStateFlow() + + private val _updateMessage = MutableStateFlow(null) + val updateMessage: StateFlow = _updateMessage.asStateFlow() + fun addProfile(profile: ProfileEntity) { viewModelScope.launch { val id = profileRepository.insertProfile(profile) @@ -47,4 +64,152 @@ class ProfilesViewModel @Inject constructor( profileRepository.setSelectedProfile(id) } } + + fun updateRemoteProfile(context: Context) { + viewModelScope.launch { + val selectedProfile = profileRepository.getSelectedProfileFlow().first() + if (selectedProfile == null) { + _updateMessage.value = context.getString(R.string.profiles_no_selection_update) + return@launch + } + + val targetUrl = selectedProfile.remoteUrl + if (targetUrl.isNullOrBlank()) { + _updateMessage.value = context.getString(R.string.profiles_no_remote_url) + return@launch + } + + _isUpdating.value = true + try { + val json = withContext(Dispatchers.IO) { + val url = URL(targetUrl) + val connection = url.openConnection() as HttpURLConnection + connection.requestMethod = "GET" + connection.connectTimeout = 10000 + connection.readTimeout = 10000 + connection.inputStream.bufferedReader().use { it.readText() } + } + + val remoteProfile = parseProfileFromJson(json, remoteUrl = selectedProfile.remoteUrl) + if (remoteProfile != null) { + val updated = selectedProfile.copy( + name = if (selectedProfile.remoteUrl != null) remoteProfile.name else selectedProfile.name, + debugTiming = remoteProfile.debugTiming, + socksHost = remoteProfile.socksHost, + socksPort = remoteProfile.socksPort, + socksUser = remoteProfile.socksUser, + socksPass = remoteProfile.socksPass, + googleHost = remoteProfile.googleHost, + sniJson = remoteProfile.sniJson, + scriptKeysText = remoteProfile.scriptKeysText, + tunnelKey = remoteProfile.tunnelKey, + coalesceStepMs = remoteProfile.coalesceStepMs, + idleSlotsPerBucket = remoteProfile.idleSlotsPerBucket + ) + profileRepository.updateProfile(updated) + _updateMessage.value = context.getString(R.string.profiles_update_success) + } else { + _updateMessage.value = context.getString(R.string.profiles_update_error, "Invalid JSON format") + } + } catch (e: Exception) { + _updateMessage.value = context.getString(R.string.profiles_update_error, e.localizedMessage ?: "Unknown error") + } finally { + _isUpdating.value = false + } + } + } + + fun importProfileFromUrl(url: String, context: Context) { + viewModelScope.launch { + _isUpdating.value = true + try { + val json = withContext(Dispatchers.IO) { + val connection = URL(url).openConnection() as HttpURLConnection + connection.requestMethod = "GET" + connection.connectTimeout = 10000 + connection.readTimeout = 10000 + connection.inputStream.bufferedReader().use { it.readText() } + } + + val profile = parseProfileFromJson(json, remoteUrl = url) + if (profile != null) { + addProfile(profile) + _updateMessage.value = context.getString(R.string.profiles_update_success) + } else { + _updateMessage.value = "Import failed: Invalid JSON format" + } + } catch (e: Exception) { + _updateMessage.value = "Import failed: ${e.localizedMessage}" + } finally { + _isUpdating.value = false + } + } + } + + fun clearUpdateMessage() { + _updateMessage.value = null + } + + fun parseProfileFromJson(raw: String, defaultName: String? = null, remoteUrl: String? = null): ProfileEntity? { + return try { + val root = Gson().fromJson(raw, JsonObject::class.java) + + // Check if it has at least one identifying part + if (!root.has("script_keys") && !root.has("tunnel_key")) return null + + val name = root.get("name")?.asString ?: defaultName ?: "Imported" + val debugTiming = root.get("debug_timing")?.asBoolean ?: false + val socksHost = root.get("socks_host")?.asString ?: "127.0.0.1" + val socksPort = root.get("socks_port")?.asInt ?: 1080 + val socksUser = root.get("socks_user")?.asString ?: "" + val socksPass = root.get("socks_pass")?.asString ?: "" + val googleHost = root.get("google_host")?.asString ?: "216.239.38.120" + val sniJson = when { + root.get("sni")?.isJsonArray == true -> Gson().toJson(root.getAsJsonArray("sni").mapNotNull { it.asString }) + root.get("sni")?.isJsonPrimitive == true -> Gson().toJson(listOf(root.get("sni").asString)) + else -> "[\"www.google.com\", \"mail.google.com\", \"accounts.google.com\"]" + } + val scriptKeysText = when { + root.get("script_keys")?.isJsonArray == true -> { + root.getAsJsonArray("script_keys").mapNotNull { element -> + when { + element.isJsonObject -> { + val obj = element.asJsonObject + val id = obj.get("id")?.asString?.trim() + val account = obj.get("account")?.asString?.trim() + if (id.isNullOrBlank()) null + else if (account.isNullOrBlank()) id + else "$id|$account" + } + element.isJsonPrimitive -> element.asString.trim() + else -> null + } + }.filter { it.isNotBlank() }.joinToString("\n") + } + root.get("script_keys")?.isJsonPrimitive == true -> root.get("script_keys").asString.trim() + else -> "" + } + val coalesceStepMs = root.get("coalesce_step_ms")?.asInt ?: 0 + val idleSlotsPerBucket = root.get("idle_slots_per_bucket")?.asInt?.coerceIn(1, 3) ?: 2 + val tunnelKey = root.get("tunnel_key")?.asString ?: "" + + ProfileEntity( + name = name, + debugTiming = debugTiming, + socksHost = socksHost, + socksPort = socksPort, + socksUser = socksUser, + socksPass = socksPass, + googleHost = googleHost, + sniJson = sniJson, + scriptKeysText = scriptKeysText, + tunnelKey = tunnelKey, + coalesceStepMs = coalesceStepMs, + idleSlotsPerBucket = idleSlotsPerBucket, + remoteUrl = remoteUrl + ) + } catch (_: Exception) { + null + } + } } diff --git a/android/app/src/main/java/com/gooserelay/gooserelayvpn/ui/settings/GlobalSettingsScreen.kt b/android/app/src/main/java/com/gooserelay/gooserelayvpn/ui/settings/GlobalSettingsScreen.kt index b704b2e..4b07a09 100644 --- a/android/app/src/main/java/com/gooserelay/gooserelayvpn/ui/settings/GlobalSettingsScreen.kt +++ b/android/app/src/main/java/com/gooserelay/gooserelayvpn/ui/settings/GlobalSettingsScreen.kt @@ -23,7 +23,7 @@ import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ArrowDropDown -import androidx.compose.material.icons.filled.Save +import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material3.Button import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults @@ -57,7 +57,9 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.PasswordVisualTransformation @@ -67,7 +69,6 @@ import androidx.core.graphics.drawable.toBitmap import androidx.lifecycle.viewmodel.compose.viewModel import com.gooserelay.gooserelayvpn.R import com.gooserelay.gooserelayvpn.ui.components.mdv.controls.MdvFilterChip -import com.gooserelay.gooserelayvpn.ui.components.mdv.controls.MdvPrimaryActionButton import com.gooserelay.gooserelayvpn.ui.components.mdv.controls.MdvTopAppBar import com.gooserelay.gooserelayvpn.ui.theme.MdvColor import com.gooserelay.gooserelayvpn.ui.theme.MdvSpace @@ -110,27 +111,15 @@ fun GlobalSettingsScreen(vm: GlobalSettingsViewModel = viewModel()) { val splitPackagesCount by remember(draft.splitPackagesCsv) { derivedStateOf { parseCsv(draft.splitPackagesCsv).size } } - fun saveGlobalSettings() { - if (socksPortMissing || httpPortMissing) { - scope.launch { - snackbarHostState.showSnackbar(context.getString(R.string.global_settings_ports_required_msg)) - } - return - } - if (socksPortValue !in 1025..65535 || httpPortValue !in 1025..65535) { - scope.launch { - snackbarHostState.showSnackbar(context.getString(R.string.global_settings_ports_range_msg)) - } - return + val clipboardManager = LocalClipboardManager.current + val gson = remember { com.google.gson.Gson() } + var menuExpanded by remember { mutableStateOf(false) } + + LaunchedEffect(draft) { + val normalized = normalize(draft) + if (normalized != current) { + vm.save(normalized) } - val safeSocksPort = socksPortValue ?: return - val safeHttpPort = httpPortValue ?: return - draft = draft.copy( - internetSharingSocksPort = safeSocksPort, - internetSharingHttpPort = safeHttpPort - ) - vm.save(normalize(draft)) - scope.launch { snackbarHostState.showSnackbar(context.getString(R.string.global_settings_saved_msg)) } } Scaffold( @@ -139,13 +128,42 @@ fun GlobalSettingsScreen(vm: GlobalSettingsViewModel = viewModel()) { MdvTopAppBar( title = stringResource(R.string.settings_title), actions = { - IconButton( - onClick = ::saveGlobalSettings - ) { - Icon( - imageVector = Icons.Filled.Save, - contentDescription = stringResource(R.string.action_save) - ) + Box { + IconButton(onClick = { menuExpanded = true }) { + Icon(imageVector = Icons.Filled.MoreVert, contentDescription = "More") + } + DropdownMenu( + expanded = menuExpanded, + onDismissRequest = { menuExpanded = false } + ) { + DropdownMenuItem( + text = { Text("Export to Clipboard") }, + onClick = { + menuExpanded = false + val json = gson.toJson(draft) + clipboardManager.setText(AnnotatedString(json)) + scope.launch { snackbarHostState.showSnackbar("Settings copied to clipboard") } + } + ) + DropdownMenuItem( + text = { Text("Import from Clipboard") }, + onClick = { + menuExpanded = false + val text = clipboardManager.getText()?.text + if (!text.isNullOrBlank()) { + try { + val imported = gson.fromJson(text, GlobalSettings::class.java) + draft = imported + scope.launch { snackbarHostState.showSnackbar("Settings imported from clipboard") } + } catch (e: Exception) { + scope.launch { snackbarHostState.showSnackbar("Failed to parse settings JSON") } + } + } else { + scope.launch { snackbarHostState.showSnackbar("Clipboard is empty") } + } + } + ) + } } } ) @@ -402,13 +420,6 @@ fun GlobalSettingsScreen(vm: GlobalSettingsViewModel = viewModel()) { } } } - item { - MdvPrimaryActionButton( - text = stringResource(R.string.global_settings_save_button), - onClick = ::saveGlobalSettings, - modifier = Modifier.fillMaxWidth() - ) - } } } } diff --git a/android/app/src/main/java/com/gooserelay/gooserelayvpn/ui/settings/SettingsScreen.kt b/android/app/src/main/java/com/gooserelay/gooserelayvpn/ui/settings/SettingsScreen.kt index bbc874d..7388067 100644 --- a/android/app/src/main/java/com/gooserelay/gooserelayvpn/ui/settings/SettingsScreen.kt +++ b/android/app/src/main/java/com/gooserelay/gooserelayvpn/ui/settings/SettingsScreen.kt @@ -2,10 +2,12 @@ package com.gooserelay.gooserelayvpn.ui.settings import android.content.Context import android.net.Uri +import android.util.Base64 import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding @@ -25,6 +27,7 @@ import androidx.compose.runtime.setValue import androidx.compose.runtime.collectAsState import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import com.gooserelay.gooserelayvpn.ui.components.mdv.controls.MdvBackTopAppBar @@ -56,17 +59,30 @@ fun SettingsScreen( var scriptKeys by remember(profile.id) { mutableStateOf(profile.scriptKeysText) } var tunnelKey by remember(profile.id) { mutableStateOf(profile.tunnelKey) } - val importLauncher = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri -> - if (uri == null) return@rememberLauncherForActivityResult - val json = readTextFromUri(context, uri) - val updated = viewModel.importJsonToProfile(profile, json) + val clipboardManager = LocalClipboardManager.current + + fun applyImportedProfile(json: String) { + val raw = if (json.trim().startsWith("goose-relay://")) { + try { + val base64Content = json.trim().substring("goose-relay://".length) + val decodedBytes = Base64.decode(base64Content, Base64.DEFAULT) + String(decodedBytes) + } catch (_: Exception) { + validationMessage = "Import failed: invalid goose-relay:// format." + return + } + } else { + json + } + + val updated = viewModel.importJsonToProfile(profile, raw) if (updated == null) { validationMessage = "Import failed: invalid JSON format." - return@rememberLauncherForActivityResult + return } if ((updated.socksUser.isBlank()) != (updated.socksPass.isBlank())) { validationMessage = "Import rejected: socks_user and socks_pass must both be set or both be empty (SOCKS5 auth requires both values)." - return@rememberLauncherForActivityResult + return } debugTiming = updated.debugTiming socksHost = updated.socksHost @@ -79,6 +95,11 @@ fun SettingsScreen( tunnelKey = updated.tunnelKey } + val importLauncher = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri -> + if (uri == null) return@rememberLauncherForActivityResult + applyImportedProfile(readTextFromUri(context, uri)) + } + val exportLauncher = rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("application/json")) { uri -> if (uri == null) return@rememberLauncherForActivityResult val updated = profile.copy( @@ -105,6 +126,25 @@ fun SettingsScreen( validationMessage = null } } + + LaunchedEffect(debugTiming, socksHost, socksPort, socksUser, socksPass, googleHost, sniText, scriptKeys, tunnelKey) { + val portInt = socksPort.toIntOrNull()?.coerceIn(1, 65535) + val updated = profile.copy( + debugTiming = debugTiming, + socksHost = socksHost, + socksPort = portInt ?: profile.socksPort, + socksUser = socksUser, + socksPass = socksPass, + googleHost = googleHost, + sniJson = if (sniText.isBlank()) "[]" else "[\"" + sniText.split(",").map { it.trim() }.filter { it.isNotBlank() }.joinToString("\",\"") + "\"]", + scriptKeysText = scriptKeys, + tunnelKey = tunnelKey + ) + if (updated != profile) { + viewModel.saveProfile(updated) + } + } + Column( modifier = Modifier.fillMaxSize().padding(padding).padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp) @@ -119,27 +159,18 @@ fun SettingsScreen( OutlinedTextField(sniText, { sniText = it }, label = { Text("sni (comma separated)") }, modifier = Modifier.fillMaxWidth()) OutlinedTextField(scriptKeys, { scriptKeys = it }, label = { Text("script_keys (one per line)") }, minLines = 3, modifier = Modifier.fillMaxWidth()) OutlinedTextField(tunnelKey, { tunnelKey = it }, label = { Text("tunnel_key") }, modifier = Modifier.fillMaxWidth()) - Button(onClick = { - if ((socksUser.isBlank()) != (socksPass.isBlank())) { - validationMessage = "socks_user and socks_pass must both be set or both be empty" - return@Button - } - viewModel.saveProfile( - profile.copy( - debugTiming = debugTiming, - socksHost = socksHost, - socksPort = socksPort.toIntOrNull()?.coerceIn(1, 65535) ?: 1080, - socksUser = socksUser, - socksPass = socksPass, - googleHost = googleHost, - sniJson = "[\"" + sniText.split(",").map { it.trim() }.filter { it.isNotBlank() }.joinToString("\",\"") + "\"]", - scriptKeysText = scriptKeys, - tunnelKey = tunnelKey - ) - ) - }) { Text("Save Settings") } - Button(onClick = { importLauncher.launch(arrayOf("application/json", "text/plain", "*/*")) }) { Text("Import JSON") } - Button(onClick = { exportLauncher.launch("goose_profile.json") }) { Text("Export JSON") } + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) { + Button(onClick = { importLauncher.launch(arrayOf("application/json", "text/plain", "*/*")) }, modifier = Modifier.weight(1f)) { Text("Import File") } + Button(onClick = { + val text = clipboardManager.getText()?.text + if (text.isNullOrBlank()) { + validationMessage = "Clipboard is empty" + } else { + applyImportedProfile(text) + } + }, modifier = Modifier.weight(1f)) { Text("From Clipboard") } + } + Button(onClick = { exportLauncher.launch("goose_profile.json") }, modifier = Modifier.fillMaxWidth()) { Text("Export JSON File") } } } } diff --git a/android/app/src/main/java/com/gooserelay/gooserelayvpn/util/ConfigGenerator.kt b/android/app/src/main/java/com/gooserelay/gooserelayvpn/util/ConfigGenerator.kt index d93063c..659986a 100644 --- a/android/app/src/main/java/com/gooserelay/gooserelayvpn/util/ConfigGenerator.kt +++ b/android/app/src/main/java/com/gooserelay/gooserelayvpn/util/ConfigGenerator.kt @@ -14,9 +14,15 @@ object ConfigGenerator { val root = JsonObject().apply { addProperty("debug_timing", profile.debugTiming) addProperty("socks_host", profile.socksHost) - addProperty("socks_port", profile.socksPort) - addProperty("socks_user", profile.socksUser) - addProperty("socks_pass", profile.socksPass) + if (profile.socksPort != 1080) { + addProperty("socks_port", profile.socksPort) + } + if (profile.socksUser.isNotBlank()) { + addProperty("socks_user", profile.socksUser) + } + if (profile.socksPass.isNotBlank()) { + addProperty("socks_pass", profile.socksPass) + } addProperty("google_host", profile.googleHost) add("sni", parseSni(profile.sniJson)) add("script_keys", parseScriptKeys(profile.scriptKeysText)) diff --git a/android/app/src/main/java/com/gooserelay/gooserelayvpn/util/GlobalSettingsStore.kt b/android/app/src/main/java/com/gooserelay/gooserelayvpn/util/GlobalSettingsStore.kt index e37b99a..4d7806c 100644 --- a/android/app/src/main/java/com/gooserelay/gooserelayvpn/util/GlobalSettingsStore.kt +++ b/android/app/src/main/java/com/gooserelay/gooserelayvpn/util/GlobalSettingsStore.kt @@ -25,7 +25,7 @@ data class GlobalSettings( val internetSharingSocksPort: Int = 8090, val internetSharingHttpPort: Int = 8091, val internetSharingUser: String = "", - val internetSharingPass: String = "" + val internetSharingPass: String = "", ) object GlobalSettingsStore { diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 8beec50..3820cdd 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -144,4 +144,9 @@ https://github.com/Kianmhz/GooseRelayVPN https://github.com/Hidden-Node/GooseRelayVPN-AndroidClient v1.7.1 + Profile updated from remote successfully + Failed to update profile: %1$s + Updating from remote... + Please select a profile to update + This profile is local and doesn\'t have a remote URL to update from. diff --git a/android/build.gradle.kts b/android/build.gradle.kts index e032c08..d51d65c 100644 --- a/android/build.gradle.kts +++ b/android/build.gradle.kts @@ -1,5 +1,5 @@ plugins { - id("com.android.application") version "8.7.3" apply false + id("com.android.application") version "8.13.0" apply false id("org.jetbrains.kotlin.android") version "2.1.0" apply false id("org.jetbrains.kotlin.plugin.compose") version "2.1.0" apply false id("com.google.dagger.hilt.android") version "2.51.1" apply false diff --git a/android/build_go_mobile.sh b/android/build_go_mobile.sh index 1a1ee31..b4388d6 100644 --- a/android/build_go_mobile.sh +++ b/android/build_go_mobile.sh @@ -7,6 +7,7 @@ cd "$ROOT_DIR" MOBILE_TOOLS_VERSION="v0.0.0-20260312152759-81488f6aeb60" # Always install a pinned, known-good gomobile/gobind pair. +# go install skipped - using patched gomobile go install "golang.org/x/mobile/cmd/gomobile@${MOBILE_TOOLS_VERSION}" go install "golang.org/x/mobile/cmd/gobind@${MOBILE_TOOLS_VERSION}" diff --git a/android/gradle.properties b/android/gradle.properties index 17451ee..5bcbeed 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -1,4 +1,5 @@ android.useAndroidX=true kotlin.code.style=official -org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +org.gradle.jvmargs=-Xmx4096m -Dfile.encoding=UTF-8 org.gradle.parallel=true +kotlin.compiler.execution.strategy=in-process diff --git a/android/gradle/gradle-daemon-jvm.properties b/android/gradle/gradle-daemon-jvm.properties new file mode 100644 index 0000000..baa28d1 --- /dev/null +++ b/android/gradle/gradle-daemon-jvm.properties @@ -0,0 +1,13 @@ +#This file is generated by updateDaemonJvm +toolchainUrl.FREE_BSD.AARCH64=https\://api.foojay.io/disco/v3.0/ids/491f83666ae7f4d6ebb28fee72ebb035/redirect +toolchainUrl.FREE_BSD.X86_64=https\://api.foojay.io/disco/v3.0/ids/0d1a1acdc708062093673f65aa9aba4b/redirect +toolchainUrl.LINUX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/491f83666ae7f4d6ebb28fee72ebb035/redirect +toolchainUrl.LINUX.X86_64=https\://api.foojay.io/disco/v3.0/ids/0d1a1acdc708062093673f65aa9aba4b/redirect +toolchainUrl.MAC_OS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/7083b89563e7ce20943037b8cd2b8cc2/redirect +toolchainUrl.MAC_OS.X86_64=https\://api.foojay.io/disco/v3.0/ids/060bbb778a1f55ea705fdebd2ccfeab9/redirect +toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/491f83666ae7f4d6ebb28fee72ebb035/redirect +toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/0d1a1acdc708062093673f65aa9aba4b/redirect +toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/d09679dc60fe5aa05ef7d03efdefac20/redirect +toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/ed4e3bf2f5e7c5d9aabc4cbd8acd555e/redirect +toolchainVendor=JETBRAINS +toolchainVersion=21 diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties index dedd5d1..ed4c299 100644 --- a/android/gradle/wrapper/gradle-wrapper.properties +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-all.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts index 012061b..01f9844 100644 --- a/android/settings.gradle.kts +++ b/android/settings.gradle.kts @@ -5,6 +5,9 @@ pluginManagement { gradlePluginPortal() } } +plugins { + id("org.gradle.toolchains.foojay-resolver-convention") version "0.10.0" +} dependencyResolutionManagement { repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) diff --git a/go.mod b/go.mod index b7561f8..1ed59f4 100644 --- a/go.mod +++ b/go.mod @@ -1,13 +1,13 @@ module github.com/kianmhz/GooseRelayVPN -go 1.23.1 +go 1.25.0 require github.com/things-go/go-socks5 v0.1.1 require ( github.com/klauspost/compress v1.17.11 github.com/xjasonlyu/tun2socks/v2 v2.6.0 - golang.org/x/net v0.40.0 + golang.org/x/net v0.52.0 ) require ( @@ -22,16 +22,25 @@ require ( github.com/google/uuid v1.6.0 // indirect github.com/gorilla/schema v1.4.1 // indirect github.com/gorilla/websocket v1.5.3 // indirect + github.com/kardianos/osext v0.0.0-20190222173326-2bc1f35cddc0 // indirect go.uber.org/atomic v1.11.0 // indirect go.uber.org/automaxprocs v1.6.0 // indirect go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap v1.27.0 // indirect - golang.org/x/crypto v0.38.0 // indirect - golang.org/x/sys v0.33.0 // indirect - golang.org/x/text v0.25.0 // indirect + golang.org/x/crypto v0.49.0 // indirect + golang.org/x/mobile v0.0.0-20260312152759-81488f6aeb60 // indirect + golang.org/x/mod v0.34.0 // indirect + golang.org/x/sync v0.20.0 // indirect + golang.org/x/sys v0.42.0 // indirect + golang.org/x/text v0.35.0 // indirect golang.org/x/time v0.11.0 // indirect + golang.org/x/tools v0.43.0 // indirect golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb // indirect gopkg.in/yaml.v3 v3.0.1 // indirect gvisor.dev/gvisor v0.0.0-20250523182742-eede7a881b20 // indirect ) + +replace github.com/mitchellh/osext => github.com/kardianos/osext v0.0.0-20190222173326-2bc1f35cddc0 + +exclude github.com/mitchellh/osext v0.0.0-20151018003038-5e2d6d41470f diff --git a/go.sum b/go.sum index aefc35a..eb026fd 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,25 @@ +github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/Microsoft/go-winio v0.6.0/go.mod h1:cTAf44im0RAYeL23bpB+fzCyDH2MJiz2BO69KH/soAE= +github.com/Microsoft/hcsshim v0.9.12/go.mod h1:qAiPvMgZoM0wpkVg6qMdSEu+1VtI6/qHOOPkTGt8ftQ= github.com/ajg/form v1.5.1 h1:t9c7v8JUKu/XxOGBU0yjNpaMloxGEJhUkqFRq0ibGeU= github.com/ajg/form v1.5.1/go.mod h1:uL1WgH+h2mgNtvBq0339dVnzXdBETtL2LeUXaIv25UY= +github.com/bazelbuild/rules_go v0.44.2/go.mod h1:Dhcz716Kqg1RHNWos+N6MlXNkjNP2EwZQ0LukRKJfMs= +github.com/cenkalti/backoff v2.2.1+incompatible/go.mod h1:90ReRw6GdpyfrHakVjL/QHaoyV4aDUVVkXQJJJ3NXXM= +github.com/cilium/ebpf v0.12.3/go.mod h1:TctK1ivibvI3znr66ljgi4hqOT8EYQjz1KWBfb1UVgM= +github.com/containerd/cgroups v1.0.4/go.mod h1:nLNQtsF7Sl2HxNebu77i1R0oDlhiTG+kO4JTrUzo6IA= +github.com/containerd/console v1.0.3/go.mod h1:7LqA/THxQ86k76b8c/EMSiaJ3h1eZkMkXar0TQ1gf3U= +github.com/containerd/containerd v1.6.36/go.mod h1:gSufNaPbqri6ifEQ3eihFSXoGwqTENkqB7j//aEgE0s= +github.com/containerd/continuity v0.3.0/go.mod h1:wJEAIwKOm/pBZuBd0JmeTvnLquTB1Ag8espWhkykbPM= +github.com/containerd/errdefs v0.1.0/go.mod h1:YgWiiHtLmSeBrvpw+UfPijzbLaB77mEG1WwJTDETIV0= +github.com/containerd/fifo v1.0.0/go.mod h1:ocF/ME1SX5b1AOlWi9r677YJmCPSwwWnQ9O123vzpE4= +github.com/containerd/go-runc v1.0.0/go.mod h1:cNU0ZbCgCQVZK4lgG3P+9tn9/PaJNmoDXPpoJhDR+Ok= +github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= +github.com/containerd/ttrpc v1.1.2/go.mod h1:XX4ZTnoOId4HklF4edwc4DcqskFZuvXB1Evzy5KFQpQ= +github.com/containerd/typeurl v1.0.2/go.mod h1:9trJWW2sRlGub4wZJRTW83VtbOLS6hwcDZXTn6oPz9s= +github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= 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/docker/go-events v0.0.0-20190806004212-e31b211e4f1c/go.mod h1:Uw6UezgYA44ePAFQYUehOuCzmy5zmg/+nl2ZfMWGkpA= github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= github.com/go-chi/chi/v5 v5.2.1 h1:KOIHODQj58PmL80G2Eak4WdvUzjSJSm0vG72crDCqb8= @@ -12,54 +30,143 @@ github.com/go-chi/render v1.0.3 h1:AsXqd2a1/INaIfUSKq3G5uA8weYx20FOsM7uSoCyyt4= github.com/go-chi/render v1.0.3/go.mod h1:/gr3hVkmYR0YlEy3LxCuVRFzEu9Ruok+gFqbIofjao0= github.com/go-gost/relay v0.5.0 h1:JG1tgy/KWiVXS0ukuVXvbM0kbYuJTWxYpJ5JwzsCf/c= github.com/go-gost/relay v0.5.0/go.mod h1:lcX+23LCQ3khIeASBo+tJ/WbwXFO32/N5YN6ucuYTG8= +github.com/go-logr/logr v1.3.0/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/gofrs/flock v0.8.0/go.mod h1:F1TvTiK9OcQqauNUHlbJvyl9Qa1QvF/gOUDKA14jxHU= +github.com/gogo/googleapis v1.4.0/go.mod h1:5YRNX2z1oM5gXdAkurHa942MDgEJyk02w4OecKY87+c= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/mock v1.7.0-rc.1/go.mod h1:s42URUywIqd+OcERslBJvOjepvNymP31m3q8d/GkuRs= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg= github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-github/v56 v56.0.0/go.mod h1:D8cdcX98YWJvi7TLo7zM4/h8ZTx6u6fwGEkCdisopo0= +github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= +github.com/google/subcommands v1.0.2-0.20190508160503-636abe8753b8/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/googleapis/gnostic v0.5.5/go.mod h1:7+EbHbldMins07ALC74bsA81Ovc97DwqyJO1AENw9kA= github.com/gorilla/schema v1.4.1 h1:jUg5hUjCSDZpNGLuXQOgIWGdlgrIdYvgQ0wZtdK1M3E= github.com/gorilla/schema v1.4.1/go.mod h1:Dg5SSm5PV60mhF2NFaTV1xuYYj8tV8NOPRo4FggUMnM= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/hanwen/go-fuse/v2 v2.3.0/go.mod h1:xKwi1cF7nXAOBCXujD5ie0ZKsxc8GGSA1rlMJc+8IJs= +github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/kardianos/osext v0.0.0-20190222173326-2bc1f35cddc0 h1:iQTw/8FWTuc7uiaSepXwyf3o52HaUYcV+Tu66S3F5GA= +github.com/kardianos/osext v0.0.0-20190222173326-2bc1f35cddc0/go.mod h1:1NbS8ALrpOvjt0rHPNLyCIeMtbizbir8U//inJ+zuB8= github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc= github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pty v1.1.5/go.mod h1:9r2w37qlBe7rQ6e1fg1S/9xpWHSnaqNdHD3WcMdbPDA= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mattbaird/jsonpatch v0.0.0-20171005235357-81af80346b1a/go.mod h1:M1qoD/MqPgTZIk0EWKB38wE28ACRfVcn+cU08jyArI0= +github.com/moby/locker v1.0.1/go.mod h1:S7SDdo5zpBK84bzzVlKr2V0hz+7x9hWbYC/kq7oQppc= +github.com/moby/sys/capability v0.4.0/go.mod h1:4g9IK291rVkms3LKCDOoYlnV8xKwoDTpIrNEE35Wq0I= +github.com/moby/sys/mountinfo v0.6.2/go.mod h1:IJb6JQeOklcdMU9F5xQ8ZALD+CUr5VlGpwtX+VE0rpI= +github.com/moby/sys/signal v0.6.0/go.mod h1:GQ6ObYZfqacOwTtlXvcmh9A26dVRul/hbOZn88Kg8Tg= +github.com/moby/sys/user v0.1.0/go.mod h1:fKJhFOnsCN6xZ5gSfbM6zaHGgDJMrqt9/reuj4T7MmU= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/mohae/deepcopy v0.0.0-20170308212314-bb9b5e7adda9/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM= +github.com/opencontainers/runtime-spec v1.1.0-rc.1/go.mod h1:jwyrGlmzljRJv/Fgzds9SsS/C5hL+LL3ko9hs6T5lQ0= +github.com/opencontainers/selinux v1.10.1/go.mod h1:2i0OySw99QjzBBQByd1Gr9gSjvuho1lHsJxIJ3gGbJI= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 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/prashantv/gostub v1.1.0/go.mod h1:A5zLQHz7ieHGG7is6LLXLz7I8+3LZzsrV0P1IAHhP5U= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= 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/things-go/go-socks5 v0.1.1 h1:48hy9cHEXPKeG91G/g4n8zW4uynzPUQy/FkcrJ7r5AY= github.com/things-go/go-socks5 v0.1.1/go.mod h1:1YBHVYG7Oli5ae+Pwkp630cPAwY1pjUPmohO1n0Emg0= +github.com/vishvananda/netlink v1.1.1-0.20211118161826-650dca95af54/go.mod h1:twkDnbuQxJYemMlGd4JFIcuhgX83tXhKS2B/PRMpOho= +github.com/vishvananda/netns v0.0.0-20210104183010-2eb08e3e575f/go.mod h1:DD4vA1DwXk04H54A1oHXtwZmA0grkVMdPxx/VGLCah0= github.com/xjasonlyu/tun2socks/v2 v2.6.0 h1:gI9saJT3XgH4e6v9jBuHRLwK7l3aN9YFWec/SsDTDx4= github.com/xjasonlyu/tun2socks/v2 v2.6.0/go.mod h1:35AwqxIxnMkfBfT0UJ1Lku7PZm2ZiZJ8sxHyp0gt1yw= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo= go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs= go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8= golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw= +golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= +golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= +golang.org/x/exp v0.0.0-20230725093048-515e97ebf090/go.mod h1:FXUEEKJgO7OQYeo8N01OfiKP8RXMtf6e8aTskBGqWdc= +golang.org/x/exp/shiny v0.0.0-20260218203240-3dfff04db8fa/go.mod h1:zxsA7NyDTOUjcveVwAMFI/YIErWwayTW/4RGqB/RzKk= +golang.org/x/image v0.37.0/go.mod h1:/3f6vaXC+6CEanU4KJxbcUZyEePbyKbaLoDOe4ehFYY= +golang.org/x/mobile v0.0.0-20260312152759-81488f6aeb60 h1:MOzyaj0wu2xneBkzkg9LHNYjDBB4W5vP043A2SYQRPA= +golang.org/x/mobile v0.0.0-20260312152759-81488f6aeb60/go.mod h1:th6VJvzjMbrYF8SduQY5rpD0HG0GleGxjadkqSxFs3k= +golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI= +golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY= golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8= golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk= golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY= golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds= +golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= +golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= +golang.org/x/oauth2 v0.23.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/telemetry v0.0.0-20260311193753-579e4da9a98c/go.mod h1:TpUTTEp9frx7rTdLpC9gFG9kdI7zVLFTFFlqaH2Cncw= +golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A= golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4= golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA= +golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= +golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0= golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= +golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s= +golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0= +golang.org/x/tools/go/expect v0.1.1-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY= +golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated/go.mod h1:RVAQXBGNv1ib0J382/DPCRS/BPnsGebyM1Gj5VSDpG8= +golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8= golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg= golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI= golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb h1:whnFRlWMcXI9d+ZbWg+4sHnLp52d5yiIPUxMBSt4X9A= golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb/go.mod h1:rpwXGsirqLqN2L0JDJQlwOboGHmptD5ZD6T2VmcqhTw= +google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= +google.golang.org/genproto v0.0.0-20230920204549-e6e6cdab5c13/go.mod h1:CCviP9RmpZ1mxVr8MUjCnSiY09IbAXZxhLE6EhHIdPU= +google.golang.org/genproto/googleapis/rpc v0.0.0-20231002182017-d307bd883b97/go.mod h1:v7nGkzlmW8P3n/bKmWBn2WpBjpOEx8Q6gMueudAmKfY= +google.golang.org/grpc v1.59.0/go.mod h1:aUPDwccQo6OTjy7Hct4AfBPD1GptF4fyUjIkQ9YtF98= +google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.3.0/go.mod h1:Dk1tviKTvMCz5tvh7t+fh94dhmQVHuCt2OzJB3CTW9Y= +google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gotest.tools/v3 v3.4.0/go.mod h1:CtbdzLSsqVhDgMtKsx03ird5YTGB3ar27v0u/yKBW5g= gvisor.dev/gvisor v0.0.0-20250523182742-eede7a881b20 h1:0DxLu8hxI1OGp1qVRPqNd+2k1a7hMNUNqbZG0IrtKlM= gvisor.dev/gvisor v0.0.0-20250523182742-eede7a881b20/go.mod h1:3r5CMtNQMKIvBlrmM9xWUNamjKBYPOWyXOjmg5Kts3g= +honnef.co/go/tools v0.5.1/go.mod h1:e9irvo83WDG9/irijV44wr3tbhcFeRnfpVlRqVwpzMs= +k8s.io/api v0.23.16/go.mod h1:Fk/eWEGf3ZYZTCVLbsgzlxekG6AtnT3QItT3eOSyFRE= +k8s.io/apimachinery v0.23.16/go.mod h1:RMMUoABRwnjoljQXKJ86jT5FkTZPPnZsNv70cMsKIP0= +k8s.io/client-go v0.23.16/go.mod h1:CUfIIQL+hpzxnD9nxiVGb99BNTp00mPFp3Pk26sTFys= +k8s.io/klog/v2 v2.30.0/go.mod h1:y1WjHnz7Dj687irZUWR/WLkLc5N1YHtjLdmgWjndZn0= +k8s.io/kube-openapi v0.0.0-20211115234752-e816edb12b65/go.mod h1:sX9MT8g7NVZM5lVL/j8QyCCJe8YSMW30QvGZWaCIDIk= +k8s.io/utils v0.0.0-20211116205334-6203023598ed/go.mod h1:jPW/WVKK9YHAvNhRxK0md/EJ228hCsBRufyofKtW8HA= +sigs.k8s.io/json v0.0.0-20211020170558-c049b76a60c6/go.mod h1:p4QtZmO4uMYipTQNzagwnNoseA6OxSUutVw05NhYDRs= +sigs.k8s.io/structured-merge-diff/v4 v4.2.3/go.mod h1:qjx8mGObPmV2aSZepjQjbmb2ihdVs8cGKBraizNC69E= +sigs.k8s.io/yaml v1.2.0/go.mod h1:yfXDCHCao9+ENCvLSE62v9VSji2MKu5jeNfTrofGhJc= diff --git a/screenshots/home.png b/screenshots/home.png new file mode 100644 index 0000000..84af74c Binary files /dev/null and b/screenshots/home.png differ diff --git a/screenshots/import&share-settings.png b/screenshots/import&share-settings.png new file mode 100644 index 0000000..3ba6d11 Binary files /dev/null and b/screenshots/import&share-settings.png differ diff --git a/screenshots/import-profile.png b/screenshots/import-profile.png new file mode 100644 index 0000000..3078e1e Binary files /dev/null and b/screenshots/import-profile.png differ diff --git a/screenshots/logs.png b/screenshots/logs.png new file mode 100644 index 0000000..32d0797 Binary files /dev/null and b/screenshots/logs.png differ diff --git a/screenshots/profiles.png b/screenshots/profiles.png new file mode 100644 index 0000000..d55081c Binary files /dev/null and b/screenshots/profiles.png differ diff --git a/screenshots/settings.png b/screenshots/settings.png new file mode 100644 index 0000000..3298270 Binary files /dev/null and b/screenshots/settings.png differ diff --git a/screenshots/share-profile.png b/screenshots/share-profile.png new file mode 100644 index 0000000..c62eacc Binary files /dev/null and b/screenshots/share-profile.png differ