summaryrefslogtreecommitdiff
path: root/cmd/client
diff options
context:
space:
mode:
authorsergei <sergei@em-sysadmin.xyz>2026-04-14 06:23:55 +0400
committersergei <sergei@em-sysadmin.xyz>2026-04-14 06:23:55 +0400
commit3d51aa455006903345f554a2dd90034993796114 (patch)
tree62a7be2faf047f5eb7886feebc3b815556f03d7f /cmd/client
downloadvpnem-3d51aa455006903345f554a2dd90034993796114.tar.gz
vpnem-3d51aa455006903345f554a2dd90034993796114.tar.bz2
vpnem-3d51aa455006903345f554a2dd90034993796114.zip
vpnem: VPN infrastructure with load-balanced multi-protocol nodesHEADmain
- Multi-protocol VPS nodes (VLESS-REALITY + Hysteria2 + SOCKS5) - Smart load balancing via recommendation API - Windows/Linux client (Go + Wails + sing-box) - Server API with RealIP detection and connection tracking - Auto-deployment via vpnui control plane - Silent Windows installer with UAC elevation - Load-based server recommendation (no sticky sessions) - Best Server one-click connection workflow
Diffstat (limited to 'cmd/client')
-rw-r--r--cmd/client/app.go729
-rwxr-xr-xcmd/client/frontend/wailsjs/go/main/App.d.ts60
-rwxr-xr-xcmd/client/frontend/wailsjs/go/main/App.js115
-rwxr-xr-xcmd/client/frontend/wailsjs/go/models.ts319
-rw-r--r--cmd/client/frontend/wailsjs/runtime/package.json24
-rw-r--r--cmd/client/frontend/wailsjs/runtime/runtime.d.ts330
-rw-r--r--cmd/client/frontend/wailsjs/runtime/runtime.js298
-rw-r--r--cmd/client/kill_other.go16
-rw-r--r--cmd/client/kill_windows.go56
-rw-r--r--cmd/client/main.go108
-rw-r--r--cmd/client/wails.json7
11 files changed, 2062 insertions, 0 deletions
diff --git a/cmd/client/app.go b/cmd/client/app.go
new file mode 100644
index 0000000..6d26318
--- /dev/null
+++ b/cmd/client/app.go
@@ -0,0 +1,729 @@
+package main
+
+import (
+ "context"
+ "fmt"
+ "log"
+ "math/rand"
+ "net/http"
+ "os"
+ "os/exec"
+ "runtime"
+ "time"
+
+ wailsRuntime "github.com/wailsapp/wails/v2/pkg/runtime"
+
+ "vpnem/internal/config"
+ "vpnem/internal/engine"
+ "vpnem/internal/models"
+ "vpnem/internal/state"
+ syncpkg "vpnem/internal/sync"
+)
+
+const Version = "2.0.11"
+
+// App is the Wails backend.
+type App struct {
+ ctx context.Context
+ engine *engine.Engine
+ watchdog *engine.Watchdog
+ fetcher *syncpkg.Fetcher
+ updater *syncpkg.Updater
+ state *state.Store
+ log *engine.RingLog
+
+ catalog *models.CatalogV2
+ servers []models.Server
+ ruleSets []models.RuleSet
+ policy *models.RoutingPolicy
+
+ recommendedServerIP string
+ recommendedNodeID string
+ recommendReason string
+ studioClients int
+}
+
+// NewApp creates a new App instance.
+func NewApp(dataDir, apiURL string) *App {
+ eng := engine.New(dataDir)
+ fetcher := syncpkg.NewFetcher(apiURL)
+ rl := engine.NewRingLog(200, dataDir)
+ return &App{
+ engine: eng,
+ watchdog: engine.NewWatchdog(eng, engine.DefaultWatchdogConfig()),
+ fetcher: fetcher,
+ updater: syncpkg.NewUpdater(fetcher, Version, dataDir),
+ state: state.NewStore(dataDir),
+ log: rl,
+ }
+}
+
+// startup is called when the app starts. Must not block — Wails UI won't render until this returns.
+func (a *App) startup(ctx context.Context) {
+ a.ctx = ctx
+ _ = a.state.Load()
+ a.logEvent("vpnem " + Version + " started")
+
+ // Sync + recommendation synchronously — UI won't render until done
+ if err := a.Sync(); err != nil {
+ a.logEvent("initial sync failed: " + err.Error())
+ a.reportError("initial sync failed: " + err.Error())
+ } else {
+ a.logEvent("initial sync ok")
+ }
+
+ // Fetch recommendation synchronously — blocks until ready
+ a.fetchRecommendation()
+
+ // Periodic refresh in background
+ go func() {
+ ticker := time.NewTicker(30 * time.Minute)
+ defer ticker.Stop()
+ for {
+ select {
+ case <-ticker.C:
+ if err := a.Sync(); err != nil {
+ a.logEvent("sync failed: " + err.Error())
+ }
+ a.fetchRecommendation()
+ case <-ctx.Done():
+ return
+ }
+ }
+ }()
+}
+
+// shutdown is called when the app closes.
+func (a *App) shutdown(ctx context.Context) {
+ a.logEvent("shutting down")
+ a.watchdog.StopWatching()
+ if err := a.engine.Stop(); err != nil {
+ a.logEvent("engine stop error: " + err.Error())
+ }
+ // Fallback: kill any orphaned sing-box
+ killSingbox()
+ _ = a.state.Save()
+ a.logEvent("shutdown complete")
+ a.log.Close()
+}
+
+func killSingbox() {
+ if runtime.GOOS == "windows" {
+ exec.Command("taskkill", "/F", "/IM", "sing-box.exe").Run()
+ } else {
+ exec.Command("pkill", "-f", "sing-box").Run()
+ }
+}
+
+func (a *App) logEvent(msg string) {
+ line := time.Now().Format("15:04:05") + " " + msg
+ a.log.Add(line)
+ log.Println(msg)
+}
+
+// --- Wails bindings ---
+
+// Sync fetches servers and rulesets from the API.
+func (a *App) Sync() error {
+ catalog, err := a.fetcher.FetchCatalog()
+ if err != nil {
+ return fmt.Errorf("sync catalog: %w", err)
+ }
+ a.catalog = catalog
+ a.servers = syncpkg.CatalogToServers(catalog)
+
+ rsResp, err := a.fetcher.FetchRuleSets()
+ if err != nil {
+ return fmt.Errorf("sync rulesets: %w", err)
+ }
+
+ dataDir := a.state.DataDir()
+ a.ruleSets, err = a.fetcher.DownloadRuleSets(rsResp.RuleSets, dataDir)
+ if err != nil {
+ return fmt.Errorf("download rule-sets: %w", err)
+ }
+
+ policy, err := a.fetcher.FetchRoutingPolicy()
+ if err != nil {
+ return fmt.Errorf("sync routing policy: %w", err)
+ }
+ a.policy = policy
+
+ a.state.SetLastSync(time.Now())
+ _ = a.state.Save()
+ a.logEvent(fmt.Sprintf("synced: %d servers, %d rulesets, routing policy %s", len(a.servers), len(a.ruleSets), a.policy.Version))
+
+ // Notify frontend to refresh
+ if a.ctx != nil {
+ wailsRuntime.EventsEmit(a.ctx, "synced")
+ }
+ return nil
+}
+
+func (a *App) applyProfile(serverTag, modeName string, logAction string) error {
+ server := a.findServer(serverTag)
+ if server == nil {
+ return fmt.Errorf("server not found: %s", serverTag)
+ }
+ mode := config.ModeByName(modeName)
+ if mode == nil {
+ return fmt.Errorf("mode not found: %s", modeName)
+ }
+
+ serverIPs := syncpkg.ServerIPs(a.servers)
+ activeRuleSets := a.activeRuleSets(*mode)
+ customBypass := a.state.GetCustomBypass()
+ localProxyPort, err := engine.ResolveLocalProxyPort()
+ if err != nil {
+ return fmt.Errorf("allocate local proxy port: %w", err)
+ }
+
+ a.logEvent(logAction + ": " + serverTag + " [" + modeName + "]")
+
+ // Flush DNS cache before applying a new profile (Windows caches poisoned responses).
+ flushDNS()
+
+ if err := a.engine.RestartFull(*server, *mode, activeRuleSets, serverIPs, customBypass, localProxyPort, a.policy); err != nil {
+ a.logEvent(logAction + " failed: " + err.Error())
+ return err
+ }
+
+ a.watchdog.StartWatching(*server, *mode, activeRuleSets, serverIPs, customBypass, localProxyPort, a.policy)
+ a.state.SetServer(serverTag)
+ a.state.SetMode(modeName)
+ a.state.SetLocalProxyPort(localProxyPort)
+ _ = a.state.Save()
+ a.logEvent("connected: " + serverTag)
+
+ if a.ctx != nil {
+ wailsRuntime.EventsEmit(a.ctx, "connected", serverTag)
+ }
+
+ // Report connection to server for recommendation tracking
+ go a.ReportConnection(serverTag, server.Server, a.extractNodeID(serverTag))
+
+ go a.validateConnection()
+ return nil
+}
+
+// Connect starts the VPN with the given server and mode.
+func (a *App) Connect(serverTag, modeName string) error {
+ return a.applyProfile(serverTag, modeName, "connecting")
+}
+
+// ApplyProfile switches the active server/mode live when connected.
+// If the engine is not running yet, it only persists the selection for the next connect.
+func (a *App) ApplyProfile(serverTag, modeName string) error {
+ server := a.findServer(serverTag)
+ if server == nil {
+ return fmt.Errorf("server not found: %s", serverTag)
+ }
+ mode := config.ModeByName(modeName)
+ if mode == nil {
+ return fmt.Errorf("mode not found: %s", modeName)
+ }
+
+ current := a.state.Get()
+ if !a.engine.IsRunning() {
+ a.state.SetServer(serverTag)
+ a.state.SetMode(modeName)
+ _ = a.state.Save()
+ a.logEvent("profile selected: " + serverTag + " [" + modeName + "]")
+ return nil
+ }
+
+ if current.SelectedServer == serverTag && current.SelectedMode == modeName {
+ return nil
+ }
+
+ a.watchdog.StopWatching()
+ if err := a.applyProfile(serverTag, modeName, "switching profile"); err != nil {
+ return err
+ }
+
+ a.state.SetServer(serverTag)
+ a.state.SetMode(modeName)
+ _ = a.state.Save()
+ return nil
+}
+
+func flushDNS() {
+ if runtime.GOOS == "windows" {
+ exec.Command("ipconfig", "/flushdns").Run()
+ log.Println("DNS cache flushed")
+ }
+}
+
+func (a *App) validateConnection() {
+ time.Sleep(3 * time.Second)
+ if !a.engine.IsRunning() {
+ return
+ }
+
+ localProxyPort := a.engine.LocalProxyPort()
+ if localProxyPort == 0 {
+ localProxyPort = a.state.Get().LocalProxyPort
+ }
+ client, err := engine.HTTPClientViaSOCKS5(config.LocalProxyHost, localProxyPort, 10*time.Second)
+ if err != nil {
+ a.logEvent("validation setup failed — " + err.Error())
+ return
+ }
+
+ mode := config.ModeByName(a.state.Get().SelectedMode)
+ requiresExitIP := mode != nil && engine.ModeRequiresExitIP(*mode)
+
+ ip := getExitIP(client)
+ switch {
+ case ip != "":
+ a.logEvent("exit IP: " + ip)
+ case requiresExitIP:
+ a.logEvent("WARNING: could not verify exit IP")
+ default:
+ a.logEvent("validation: exit IP skipped for direct-final mode")
+ }
+
+ statusCode, err := engine.ProbeBlockedSite(localProxyPort, engine.DefaultBlockedSiteProbeURL, 10*time.Second)
+ if err == nil {
+ a.logEvent(fmt.Sprintf("validation: rutracker.org → %d OK", statusCode))
+ } else {
+ a.logEvent("validation: rutracker.org FAILED — " + err.Error())
+ }
+}
+
+// Disconnect stops the VPN and clears system proxy.
+func (a *App) Disconnect() error {
+ // Report disconnect before stopping
+ if a.engine.IsRunning() {
+ st := a.state.Get()
+ server := a.findServer(st.SelectedServer)
+ if server != nil {
+ nodeID := a.extractNodeID(st.SelectedServer)
+ go a.ReportDisconnect(server.Server, nodeID)
+ }
+ }
+
+ a.watchdog.StopWatching()
+ clearSystemProxy()
+ a.logEvent("disconnected")
+ return a.engine.Stop()
+}
+
+// SetSystemProxy sets Windows system SOCKS5 proxy directly (no TUN needed).
+// Fallback for when TUN/sing-box doesn't work with browser.
+func (a *App) SetSystemProxy(serverTag string) error {
+ localProxyPort := a.engine.LocalProxyPort()
+ if localProxyPort == 0 {
+ localProxyPort = a.state.Get().LocalProxyPort
+ }
+ if !a.engine.IsRunning() || localProxyPort == 0 {
+ return fmt.Errorf("connect first to start the local proxy")
+ }
+ addr := fmt.Sprintf("%s:%d", config.LocalProxyHost, localProxyPort)
+ if runtime.GOOS == "windows" {
+ // Route Windows system proxy through the local SOCKS5 inbound.
+ exec.Command("reg", "add", `HKCU\Software\Microsoft\Windows\CurrentVersion\Internet Settings`,
+ "/v", "ProxyEnable", "/t", "REG_DWORD", "/d", "1", "/f").Run()
+ exec.Command("reg", "add", `HKCU\Software\Microsoft\Windows\CurrentVersion\Internet Settings`,
+ "/v", "ProxyServer", "/t", "REG_SZ", "/d", "socks="+addr, "/f").Run()
+ a.logEvent("system proxy set to local SOCKS5 inbound: " + addr)
+ }
+ return nil
+}
+
+func clearSystemProxy() {
+ if runtime.GOOS == "windows" {
+ exec.Command("reg", "add", `HKCU\Software\Microsoft\Windows\CurrentVersion\Internet Settings`,
+ "/v", "ProxyEnable", "/t", "REG_DWORD", "/d", "0", "/f").Run()
+ log.Println("system proxy cleared")
+ }
+}
+
+// GetServers returns the server list grouped by region.
+func (a *App) GetServers() []models.Server {
+ if a.servers == nil {
+ return []models.Server{}
+ }
+ return a.servers
+}
+
+// GetRecommendedServerTag returns the tag of the recommended MULTI server.
+// Only MULTI tags are returned (vless-reality + hysteria2). SOCKS5-only tags are never recommended.
+// If the recommended IP doesn't match any MULTI server, falls back to first MULTI.
+func (a *App) GetRecommendedServerTag() string {
+ // If we have a recommended IP, try to find a MULTI server for it
+ if a.recommendedServerIP != "" && len(a.servers) > 0 {
+ // Find MULTI server matching recommended IP
+ for _, s := range a.servers {
+ if s.Server == a.recommendedServerIP && isMultiServer(s) {
+ a.logEvent("GetRecommendedServerTag: MATCH " + s.Tag)
+ return s.Tag
+ }
+ }
+ a.logEvent("GetRecommendedServerTag: no MULTI match for IP " + a.recommendedServerIP)
+ }
+
+ // Fallback: pick first MULTI server from the list
+ for _, s := range a.servers {
+ if isMultiServer(s) {
+ a.logEvent("GetRecommendedServerTag: fallback to " + s.Tag)
+ return s.Tag
+ }
+ }
+
+ a.logEvent("GetRecommendedServerTag: no MULTI servers found")
+ return ""
+}
+
+// isMultiServer checks if a server is a MULTI node (split TCP/UDP routing).
+func isMultiServer(s models.Server) bool {
+ return s.Type == "multi" || len(s.Companions) > 0
+}
+
+// IsServerRecommended checks if a given server tag matches the recommendation.
+func (a *App) IsServerRecommended(tag string) bool {
+ recTag := a.GetRecommendedServerTag()
+ return recTag != "" && tag == recTag
+}
+
+// GetRecommendationReason returns the human-readable reason for the recommendation.
+func (a *App) GetRecommendationReason() string {
+ return a.recommendReason
+}
+
+// GetCatalog returns the current canonical server catalog.
+func (a *App) GetCatalog() *models.CatalogV2 {
+ if a.catalog == nil {
+ return &models.CatalogV2{Version: "uninitialized", Nodes: []models.CatalogNode{}}
+ }
+ return a.catalog
+}
+
+// GetModes returns all available mode names.
+func (a *App) GetModes() []string {
+ return config.ModeNames()
+}
+
+// GetStatus returns the current connection status.
+func (a *App) GetStatus() map[string]any {
+ st := a.state.Get()
+ localProxyPort := a.engine.LocalProxyPort()
+ if localProxyPort == 0 {
+ localProxyPort = st.LocalProxyPort
+ }
+ return map[string]any{
+ "connected": a.engine.IsRunning(),
+ "server": st.SelectedServer,
+ "mode": st.SelectedMode,
+ "lastSync": st.LastSync,
+ "autoConnect": st.AutoConnect,
+ "localProxyHost": config.LocalProxyHost,
+ "localProxyPort": localProxyPort,
+ "localProxyURL": fmt.Sprintf("%s:%d", config.LocalProxyHost, localProxyPort),
+ "localProxyScheme": "socks5",
+ }
+}
+
+// GetExitIP checks the actual exit IP through the proxy.
+func (a *App) GetExitIP() string {
+ localProxyPort := a.engine.LocalProxyPort()
+ if localProxyPort == 0 {
+ localProxyPort = a.state.Get().LocalProxyPort
+ }
+ client, err := engine.HTTPClientViaSOCKS5(config.LocalProxyHost, localProxyPort, 5*time.Second)
+ if err != nil {
+ return ""
+ }
+ return getExitIP(client)
+}
+
+func getExitIP(client *http.Client) string {
+ resp, err := client.Get("http://ifconfig.me/ip")
+ if err != nil {
+ return ""
+ }
+ defer resp.Body.Close()
+ buf := make([]byte, 64)
+ n, _ := resp.Body.Read(buf)
+ return string(buf[:n])
+}
+
+// SetAutoConnect updates the auto-connect setting.
+func (a *App) SetAutoConnect(v bool) {
+ a.state.SetAutoConnect(v)
+ _ = a.state.Save()
+}
+
+// GetRuleSets returns all rule-sets with their enabled status.
+func (a *App) GetRuleSets() []map[string]any {
+ result := make([]map[string]any, 0)
+ for _, rs := range a.ruleSets {
+ enabled := !rs.Optional || a.state.IsRuleSetEnabled(rs.Tag)
+ result = append(result, map[string]any{
+ "tag": rs.Tag,
+ "description": rs.Description,
+ "type": rs.Type,
+ "optional": rs.Optional,
+ "enabled": enabled,
+ })
+ }
+ return result
+}
+
+// SetRuleSetEnabled enables or disables an optional rule-set.
+func (a *App) SetRuleSetEnabled(tag string, enabled bool) {
+ a.state.SetRuleSetEnabled(tag, enabled)
+ _ = a.state.Save()
+}
+
+// GetBypassProcesses returns default + custom bypass processes.
+func (a *App) GetBypassProcesses() map[string]any {
+ policy := config.EffectiveRoutingPolicy(a.policy)
+ return map[string]any{
+ "default": policy.AlwaysDirectProcesses,
+ "custom": a.state.GetCustomBypass(),
+ }
+}
+
+// AddBypassProcess adds a custom bypass process.
+func (a *App) AddBypassProcess(name string) {
+ current := a.state.GetCustomBypass()
+ for _, p := range current {
+ if p == name {
+ return
+ }
+ }
+ a.state.SetCustomBypass(append(current, name))
+ _ = a.state.Save()
+}
+
+// RemoveBypassProcess removes a custom bypass process.
+func (a *App) RemoveBypassProcess(name string) {
+ current := a.state.GetCustomBypass()
+ var filtered []string
+ for _, p := range current {
+ if p != name {
+ filtered = append(filtered, p)
+ }
+ }
+ a.state.SetCustomBypass(filtered)
+ _ = a.state.Save()
+}
+
+// MeasureLatency pings all servers and returns sorted results.
+func (a *App) MeasureLatency() []syncpkg.LatencyResult {
+ a.logEvent("measuring latency...")
+ results := syncpkg.MeasureLatency(a.servers, 3*time.Second)
+ for _, r := range results {
+ if r.Latency >= 0 {
+ a.logEvent(fmt.Sprintf(" %s: %dms", r.Tag, r.Latency))
+ }
+ }
+ return results
+}
+
+// GetLogs returns the last N log lines.
+func (a *App) GetLogs() []string {
+ return a.log.Lines()
+}
+
+// GetGeneratedConfig returns the current sing-box config JSON for diagnostics.
+func (a *App) GetGeneratedConfig() string {
+ path := a.engine.ConfigPath()
+ data, err := os.ReadFile(path)
+ if err != nil {
+ return ""
+ }
+ return string(data)
+}
+
+// CheckUpdate checks if a new version is available.
+func (a *App) CheckUpdate() (*syncpkg.UpdateInfo, error) {
+ return a.updater.Check()
+}
+
+// DownloadUpdate downloads the new binary. Returns "restart_pending" on success.
+// The caller should then shut down Wails gracefully — the OS will restart the app.
+func (a *App) DownloadUpdate() (string, error) {
+ result, err := a.updater.Download()
+ if err != nil {
+ a.logEvent("update download failed: " + err.Error())
+ return "", err
+ }
+ if result == "restart_pending" {
+ a.logEvent("update installed, restarting...")
+ // Shut down Wails gracefully — the OS Scheduled Task will relaunch
+ go func() {
+ time.Sleep(2 * time.Second)
+ wailsRuntime.Quit(a.ctx)
+ }()
+ }
+ return result, nil
+}
+
+// RandomNLServer picks a random non-RU server tag.
+func (a *App) RandomNLServer() string {
+ var candidates []string
+ for _, s := range a.servers {
+ if s.Region != "RU" {
+ candidates = append(candidates, s.Tag)
+ }
+ }
+ if len(candidates) == 0 {
+ return ""
+ }
+ return candidates[rand.Intn(len(candidates))]
+}
+
+// fetchRecommendation fetches a recommended server from the API.
+// Server auto-detects client real IP from X-Forwarded-For header.
+func (a *App) fetchRecommendation() {
+ a.logEvent("fetchRecommendation: starting")
+
+ // Check if we have a recent recommendation
+ recServer, recNodeID, recTime := a.state.GetRecommendation()
+ if recServer != "" && time.Since(recTime) < 15*time.Minute {
+ a.recommendedServerIP = recServer
+ a.recommendedNodeID = recNodeID
+ a.recommendReason = "cached"
+ a.logEvent("fetchRecommendation: using cached " + recServer)
+ return
+ }
+
+ // Server detects client IP via X-Forwarded-For
+ rec, err := a.fetcher.GetRecommendation()
+ if err != nil {
+ a.logEvent("fetchRecommendation: error — " + err.Error())
+ // Fallback: keep stale recommendation if available
+ if recServer != "" {
+ a.recommendedServerIP = recServer
+ a.recommendedNodeID = recNodeID
+ a.recommendReason = "stale (server unreachable)"
+ a.logEvent("fetchRecommendation: using stale " + recServer)
+ }
+ return
+ }
+
+ if rec.RecommendedServerIP == "" {
+ a.logEvent("fetchRecommendation: empty recommendation from server")
+ return
+ }
+
+ a.recommendedServerIP = rec.RecommendedServerIP
+ a.recommendedNodeID = rec.RecommendedNodeID
+ a.recommendReason = rec.Reason
+ if rec.IsRebalance {
+ a.recommendReason += " (ребалансировка)"
+ }
+
+ a.state.SetRecommendation(rec.RecommendedServerIP, rec.RecommendedNodeID)
+ _ = a.state.Save()
+
+ a.logEvent("fetchRecommendation: set " + rec.RecommendedServerIP + " — " + rec.Reason)
+ if rec.LoadInfo != "" {
+ a.logEvent(" load: " + rec.LoadInfo)
+ }
+}
+
+// GetRecommendation returns the current recommendation data.
+func (a *App) GetRecommendation() map[string]any {
+ return map[string]any{
+ "server_ip": a.recommendedServerIP,
+ "node_id": a.recommendedNodeID,
+ "reason": a.recommendReason,
+ "studio_clients": a.studioClients,
+ }
+}
+
+// ReportConnection sends a connect notification to the server.
+// Server auto-detects client real IP from X-Forwarded-For.
+func (a *App) ReportConnection(serverTag, serverIP, nodeID string) {
+ a.logEvent("report connect: " + serverTag + " → " + serverIP)
+
+ resp, err := a.fetcher.ReportConnect(serverIP, nodeID)
+ if err != nil {
+ a.logEvent("report connect error: " + err.Error())
+ return
+ }
+
+ // Update recommendation if server returned a different one
+ if resp.RecommendedServerIP != "" && resp.RecommendedServerIP != a.recommendedServerIP {
+ old := a.recommendedServerIP
+ a.recommendedServerIP = resp.RecommendedServerIP
+ a.recommendedNodeID = resp.RecommendedNodeID
+ a.recommendReason = resp.Reason
+ if resp.IsRebalance {
+ a.recommendReason += " (ребалансировка)"
+ }
+ a.state.SetRecommendation(resp.RecommendedServerIP, resp.RecommendedNodeID)
+ _ = a.state.Save()
+ a.logEvent("recommendation updated: " + old + " → " + resp.RecommendedServerIP)
+ }
+}
+
+// ReportDisconnect notifies the server of disconnection.
+func (a *App) ReportDisconnect(serverIP, nodeID string) {
+ a.logEvent("report disconnect: " + serverIP)
+ if err := a.fetcher.ReportDisconnect(serverIP, nodeID); err != nil {
+ a.logEvent("report disconnect error: " + err.Error())
+ }
+}
+
+func (a *App) extractNodeID(serverTag string) string {
+ // Tags like "nl-198-multi", "nl-198-vless-reality", "nl-198-socks5" -> "nl-198"
+ for _, suffix := range []string{"-multi", "-vless-reality", "-vless", "-vmess", "-shadowsocks", "-hysteria2", "-socks5", "-socks"} {
+ if len(serverTag) > len(suffix) && serverTag[len(serverTag)-len(suffix):] == suffix {
+ return serverTag[:len(serverTag)-len(suffix)]
+ }
+ }
+ return serverTag
+}
+
+func (a *App) findServer(tag string) *models.Server {
+ for _, s := range a.servers {
+ if s.Tag == tag {
+ return &s
+ }
+ }
+ return nil
+}
+
+// activeRuleSets returns rule-sets for the mode PLUS domain rule-sets
+// always needed for DNS anti-poisoning (refilter-domains etc).
+func (a *App) activeRuleSets(mode config.Mode) []models.RuleSet {
+ needed := make(map[string]bool)
+
+ // Rule-sets referenced by route rules
+ for _, r := range mode.Rules {
+ for _, tag := range r.RuleSet {
+ needed[tag] = true
+ }
+ }
+
+ // Always include domain-type rule-sets for DNS rules
+ // (prevents ISP DNS poisoning for blocked domains)
+ for _, rs := range a.ruleSets {
+ if !rs.Optional && rs.Type == "domain" {
+ needed[rs.Tag] = true
+ }
+ }
+
+ var result []models.RuleSet
+ for _, rs := range a.ruleSets {
+ if needed[rs.Tag] {
+ result = append(result, rs)
+ }
+ }
+ return result
+}
+
+func (a *App) reportError(msg string) {
+ if a.fetcher == nil {
+ return
+ }
+ osName := "linux"
+ if runtime.GOOS == "windows" {
+ osName = "windows"
+ }
+ go a.fetcher.ReportError(Version, osName, a.log.Lines())
+}
diff --git a/cmd/client/frontend/wailsjs/go/main/App.d.ts b/cmd/client/frontend/wailsjs/go/main/App.d.ts
new file mode 100755
index 0000000..9f3f061
--- /dev/null
+++ b/cmd/client/frontend/wailsjs/go/main/App.d.ts
@@ -0,0 +1,60 @@
+// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
+// This file is automatically generated. DO NOT EDIT
+import {sync} from '../models';
+import {models} from '../models';
+
+export function AddBypassProcess(arg1:string):Promise<void>;
+
+export function ApplyProfile(arg1:string,arg2:string):Promise<void>;
+
+export function CheckUpdate():Promise<sync.UpdateInfo>;
+
+export function Connect(arg1:string,arg2:string):Promise<void>;
+
+export function Disconnect():Promise<void>;
+
+export function DownloadUpdate():Promise<string>;
+
+export function GetBypassProcesses():Promise<Record<string, any>>;
+
+export function GetCatalog():Promise<models.CatalogV2>;
+
+export function GetExitIP():Promise<string>;
+
+export function GetGeneratedConfig():Promise<string>;
+
+export function GetLogs():Promise<Array<string>>;
+
+export function GetModes():Promise<Array<string>>;
+
+export function GetRecommendation():Promise<Record<string, any>>;
+
+export function GetRecommendationReason():Promise<string>;
+
+export function GetRecommendedServerTag():Promise<string>;
+
+export function GetRuleSets():Promise<Array<Record<string, any>>>;
+
+export function GetServers():Promise<Array<models.Server>>;
+
+export function GetStatus():Promise<Record<string, any>>;
+
+export function IsServerRecommended(arg1:string):Promise<boolean>;
+
+export function MeasureLatency():Promise<Array<sync.LatencyResult>>;
+
+export function RandomNLServer():Promise<string>;
+
+export function RemoveBypassProcess(arg1:string):Promise<void>;
+
+export function ReportConnection(arg1:string,arg2:string,arg3:string):Promise<void>;
+
+export function ReportDisconnect(arg1:string,arg2:string):Promise<void>;
+
+export function SetAutoConnect(arg1:boolean):Promise<void>;
+
+export function SetRuleSetEnabled(arg1:string,arg2:boolean):Promise<void>;
+
+export function SetSystemProxy(arg1:string):Promise<void>;
+
+export function Sync():Promise<void>;
diff --git a/cmd/client/frontend/wailsjs/go/main/App.js b/cmd/client/frontend/wailsjs/go/main/App.js
new file mode 100755
index 0000000..e85beea
--- /dev/null
+++ b/cmd/client/frontend/wailsjs/go/main/App.js
@@ -0,0 +1,115 @@
+// @ts-check
+// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
+// This file is automatically generated. DO NOT EDIT
+
+export function AddBypassProcess(arg1) {
+ return window['go']['main']['App']['AddBypassProcess'](arg1);
+}
+
+export function ApplyProfile(arg1, arg2) {
+ return window['go']['main']['App']['ApplyProfile'](arg1, arg2);
+}
+
+export function CheckUpdate() {
+ return window['go']['main']['App']['CheckUpdate']();
+}
+
+export function Connect(arg1, arg2) {
+ return window['go']['main']['App']['Connect'](arg1, arg2);
+}
+
+export function Disconnect() {
+ return window['go']['main']['App']['Disconnect']();
+}
+
+export function DownloadUpdate() {
+ return window['go']['main']['App']['DownloadUpdate']();
+}
+
+export function GetBypassProcesses() {
+ return window['go']['main']['App']['GetBypassProcesses']();
+}
+
+export function GetCatalog() {
+ return window['go']['main']['App']['GetCatalog']();
+}
+
+export function GetExitIP() {
+ return window['go']['main']['App']['GetExitIP']();
+}
+
+export function GetGeneratedConfig() {
+ return window['go']['main']['App']['GetGeneratedConfig']();
+}
+
+export function GetLogs() {
+ return window['go']['main']['App']['GetLogs']();
+}
+
+export function GetModes() {
+ return window['go']['main']['App']['GetModes']();
+}
+
+export function GetRecommendation() {
+ return window['go']['main']['App']['GetRecommendation']();
+}
+
+export function GetRecommendationReason() {
+ return window['go']['main']['App']['GetRecommendationReason']();
+}
+
+export function GetRecommendedServerTag() {
+ return window['go']['main']['App']['GetRecommendedServerTag']();
+}
+
+export function GetRuleSets() {
+ return window['go']['main']['App']['GetRuleSets']();
+}
+
+export function GetServers() {
+ return window['go']['main']['App']['GetServers']();
+}
+
+export function GetStatus() {
+ return window['go']['main']['App']['GetStatus']();
+}
+
+export function IsServerRecommended(arg1) {
+ return window['go']['main']['App']['IsServerRecommended'](arg1);
+}
+
+export function MeasureLatency() {
+ return window['go']['main']['App']['MeasureLatency']();
+}
+
+export function RandomNLServer() {
+ return window['go']['main']['App']['RandomNLServer']();
+}
+
+export function RemoveBypassProcess(arg1) {
+ return window['go']['main']['App']['RemoveBypassProcess'](arg1);
+}
+
+export function ReportConnection(arg1, arg2, arg3) {
+ return window['go']['main']['App']['ReportConnection'](arg1, arg2, arg3);
+}
+
+export function ReportDisconnect(arg1, arg2) {
+ return window['go']['main']['App']['ReportDisconnect'](arg1, arg2);
+}
+
+export function SetAutoConnect(arg1) {
+ return window['go']['main']['App']['SetAutoConnect'](arg1);
+}
+
+export function SetRuleSetEnabled(arg1, arg2) {
+ return window['go']['main']['App']['SetRuleSetEnabled'](arg1, arg2);
+}
+
+export function SetSystemProxy(arg1) {
+ return window['go']['main']['App']['SetSystemProxy'](arg1);
+}
+
+export function Sync() {
+ return window['go']['main']['App']['Sync']();
+}
diff --git a/cmd/client/frontend/wailsjs/go/models.ts b/cmd/client/frontend/wailsjs/go/models.ts
new file mode 100755
index 0000000..0f8dc72
--- /dev/null
+++ b/cmd/client/frontend/wailsjs/go/models.ts
@@ -0,0 +1,319 @@
+export namespace models {
+
+ export class CatalogAuth {
+ uuid?: string;
+ method?: string;
+ password?: string;
+
+ static createFrom(source: any = {}) {
+ return new CatalogAuth(source);
+ }
+
+ constructor(source: any = {}) {
+ if ('string' === typeof source) source = JSON.parse(source);
+ this.uuid = source["uuid"];
+ this.method = source["method"];
+ this.password = source["password"];
+ }
+ }
+ export class Reality {
+ enabled?: boolean;
+ public_key?: string;
+ private_key?: string;
+ short_id?: string;
+ fingerprint?: string;
+
+ static createFrom(source: any = {}) {
+ return new Reality(source);
+ }
+
+ constructor(source: any = {}) {
+ if ('string' === typeof source) source = JSON.parse(source);
+ this.enabled = source["enabled"];
+ this.public_key = source["public_key"];
+ this.private_key = source["private_key"];
+ this.short_id = source["short_id"];
+ this.fingerprint = source["fingerprint"];
+ }
+ }
+ export class TLS {
+ enabled: boolean;
+ server_name?: string;
+ insecure?: boolean;
+ alpn?: string[];
+ min_version?: string;
+ max_version?: string;
+ reality?: Reality;
+
+ static createFrom(source: any = {}) {
+ return new TLS(source);
+ }
+
+ constructor(source: any = {}) {
+ if ('string' === typeof source) source = JSON.parse(source);
+ this.enabled = source["enabled"];
+ this.server_name = source["server_name"];
+ this.insecure = source["insecure"];
+ this.alpn = source["alpn"];
+ this.min_version = source["min_version"];
+ this.max_version = source["max_version"];
+ this.reality = this.convertValues(source["reality"], Reality);
+ }
+
+ convertValues(a: any, classs: any, asMap: boolean = false): any {
+ if (!a) {
+ return a;
+ }
+ if (a.slice && a.map) {
+ return (a as any[]).map(elem => this.convertValues(elem, classs));
+ } else if ("object" === typeof a) {
+ if (asMap) {
+ for (const key of Object.keys(a)) {
+ a[key] = new classs(a[key]);
+ }
+ return a;
+ }
+ return new classs(a);
+ }
+ return a;
+ }
+ }
+ export class CatalogProtocol {
+ type: string;
+ enabled: boolean;
+ port: number;
+ tls?: TLS;
+ auth?: CatalogAuth;
+ extra?: Record<string, any>;
+
+ static createFrom(source: any = {}) {
+ return new CatalogProtocol(source);
+ }
+
+ constructor(source: any = {}) {
+ if ('string' === typeof source) source = JSON.parse(source);
+ this.type = source["type"];
+ this.enabled = source["enabled"];
+ this.port = source["port"];
+ this.tls = this.convertValues(source["tls"], TLS);
+ this.auth = this.convertValues(source["auth"], CatalogAuth);
+ this.extra = source["extra"];
+ }
+
+ convertValues(a: any, classs: any, asMap: boolean = false): any {
+ if (!a) {
+ return a;
+ }
+ if (a.slice && a.map) {
+ return (a as any[]).map(elem => this.convertValues(elem, classs));
+ } else if ("object" === typeof a) {
+ if (asMap) {
+ for (const key of Object.keys(a)) {
+ a[key] = new classs(a[key]);
+ }
+ return a;
+ }
+ return new classs(a);
+ }
+ return a;
+ }
+ }
+ export class CatalogNode {
+ id: string;
+ name: string;
+ provider?: string;
+ region: string;
+ host: string;
+ domain?: string;
+ public_host: string;
+ protocols: CatalogProtocol[];
+ status?: string;
+ tags?: string[];
+ metadata?: Record<string, any>;
+
+ static createFrom(source: any = {}) {
+ return new CatalogNode(source);
+ }
+
+ constructor(source: any = {}) {
+ if ('string' === typeof source) source = JSON.parse(source);
+ this.id = source["id"];
+ this.name = source["name"];
+ this.provider = source["provider"];
+ this.region = source["region"];
+ this.host = source["host"];
+ this.domain = source["domain"];
+ this.public_host = source["public_host"];
+ this.protocols = this.convertValues(source["protocols"], CatalogProtocol);
+ this.status = source["status"];
+ this.tags = source["tags"];
+ this.metadata = source["metadata"];
+ }
+
+ convertValues(a: any, classs: any, asMap: boolean = false): any {
+ if (!a) {
+ return a;
+ }
+ if (a.slice && a.map) {
+ return (a as any[]).map(elem => this.convertValues(elem, classs));
+ } else if ("object" === typeof a) {
+ if (asMap) {
+ for (const key of Object.keys(a)) {
+ a[key] = new classs(a[key]);
+ }
+ return a;
+ }
+ return new classs(a);
+ }
+ return a;
+ }
+ }
+
+ export class CatalogV2 {
+ version: string;
+ nodes: CatalogNode[];
+
+ static createFrom(source: any = {}) {
+ return new CatalogV2(source);
+ }
+
+ constructor(source: any = {}) {
+ if ('string' === typeof source) source = JSON.parse(source);
+ this.version = source["version"];
+ this.nodes = this.convertValues(source["nodes"], CatalogNode);
+ }
+
+ convertValues(a: any, classs: any, asMap: boolean = false): any {
+ if (!a) {
+ return a;
+ }
+ if (a.slice && a.map) {
+ return (a as any[]).map(elem => this.convertValues(elem, classs));
+ } else if ("object" === typeof a) {
+ if (asMap) {
+ for (const key of Object.keys(a)) {
+ a[key] = new classs(a[key]);
+ }
+ return a;
+ }
+ return new classs(a);
+ }
+ return a;
+ }
+ }
+
+ export class Transport {
+ type?: string;
+ path?: string;
+
+ static createFrom(source: any = {}) {
+ return new Transport(source);
+ }
+
+ constructor(source: any = {}) {
+ if ('string' === typeof source) source = JSON.parse(source);
+ this.type = source["type"];
+ this.path = source["path"];
+ }
+ }
+ export class Server {
+ tag: string;
+ region: string;
+ type: string;
+ server: string;
+ server_port: number;
+ udp_over_tcp?: boolean;
+ uuid?: string;
+ method?: string;
+ password?: string;
+ obfs_password?: string;
+ up_mbps?: number;
+ down_mbps?: number;
+ tls?: TLS;
+ transport?: Transport;
+ companions?: Server[];
+
+ static createFrom(source: any = {}) {
+ return new Server(source);
+ }
+
+ constructor(source: any = {}) {
+ if ('string' === typeof source) source = JSON.parse(source);
+ this.tag = source["tag"];
+ this.region = source["region"];
+ this.type = source["type"];
+ this.server = source["server"];
+ this.server_port = source["server_port"];
+ this.udp_over_tcp = source["udp_over_tcp"];
+ this.uuid = source["uuid"];
+ this.method = source["method"];
+ this.password = source["password"];
+ this.obfs_password = source["obfs_password"];
+ this.up_mbps = source["up_mbps"];
+ this.down_mbps = source["down_mbps"];
+ this.tls = this.convertValues(source["tls"], TLS);
+ this.transport = this.convertValues(source["transport"], Transport);
+ this.companions = this.convertValues(source["companions"], Server);
+ }
+
+ convertValues(a: any, classs: any, asMap: boolean = false): any {
+ if (!a) {
+ return a;
+ }
+ if (a.slice && a.map) {
+ return (a as any[]).map(elem => this.convertValues(elem, classs));
+ } else if ("object" === typeof a) {
+ if (asMap) {
+ for (const key of Object.keys(a)) {
+ a[key] = new classs(a[key]);
+ }
+ return a;
+ }
+ return new classs(a);
+ }
+ return a;
+ }
+ }
+
+
+}
+
+export namespace sync {
+
+ export class LatencyResult {
+ tag: string;
+ region: string;
+ latency_ms: number;
+
+ static createFrom(source: any = {}) {
+ return new LatencyResult(source);
+ }
+
+ constructor(source: any = {}) {
+ if ('string' === typeof source) source = JSON.parse(source);
+ this.tag = source["tag"];
+ this.region = source["region"];
+ this.latency_ms = source["latency_ms"];
+ }
+ }
+ export class UpdateInfo {
+ available: boolean;
+ version: string;
+ changelog: string;
+ current_version: string;
+
+ static createFrom(source: any = {}) {
+ return new UpdateInfo(source);
+ }
+
+ constructor(source: any = {}) {
+ if ('string' === typeof source) source = JSON.parse(source);
+ this.available = source["available"];
+ this.version = source["version"];
+ this.changelog = source["changelog"];
+ this.current_version = source["current_version"];
+ }
+ }
+
+}
+
diff --git a/cmd/client/frontend/wailsjs/runtime/package.json b/cmd/client/frontend/wailsjs/runtime/package.json
new file mode 100644
index 0000000..1e7c8a5
--- /dev/null
+++ b/cmd/client/frontend/wailsjs/runtime/package.json
@@ -0,0 +1,24 @@
+{
+ "name": "@wailsapp/runtime",
+ "version": "2.0.0",
+ "description": "Wails Javascript runtime library",
+ "main": "runtime.js",
+ "types": "runtime.d.ts",
+ "scripts": {
+ },
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/wailsapp/wails.git"
+ },
+ "keywords": [
+ "Wails",
+ "Javascript",
+ "Go"
+ ],
+ "author": "Lea Anthony <lea.anthony@gmail.com>",
+ "license": "MIT",
+ "bugs": {
+ "url": "https://github.com/wailsapp/wails/issues"
+ },
+ "homepage": "https://github.com/wailsapp/wails#readme"
+}
diff --git a/cmd/client/frontend/wailsjs/runtime/runtime.d.ts b/cmd/client/frontend/wailsjs/runtime/runtime.d.ts
new file mode 100644
index 0000000..3bbea84
--- /dev/null
+++ b/cmd/client/frontend/wailsjs/runtime/runtime.d.ts
@@ -0,0 +1,330 @@
+/*
+ _ __ _ __
+| | / /___ _(_) /____
+| | /| / / __ `/ / / ___/
+| |/ |/ / /_/ / / (__ )
+|__/|__/\__,_/_/_/____/
+The electron alternative for Go
+(c) Lea Anthony 2019-present
+*/
+
+export interface Position {
+ x: number;
+ y: number;
+}
+
+export interface Size {
+ w: number;
+ h: number;
+}
+
+export interface Screen {
+ isCurrent: boolean;
+ isPrimary: boolean;
+ width : number
+ height : number
+}
+
+// Environment information such as platform, buildtype, ...
+export interface EnvironmentInfo {
+ buildType: string;
+ platform: string;
+ arch: string;
+}
+
+// [EventsEmit](https://wails.io/docs/reference/runtime/events#eventsemit)
+// emits the given event. Optional data may be passed with the event.
+// This will trigger any event listeners.
+export function EventsEmit(eventName: string, ...data: any): void;
+
+// [EventsOn](https://wails.io/docs/reference/runtime/events#eventson) sets up a listener for the given event name.
+export function EventsOn(eventName: string, callback: (...data: any) => void): () => void;
+
+// [EventsOnMultiple](https://wails.io/docs/reference/runtime/events#eventsonmultiple)
+// sets up a listener for the given event name, but will only trigger a given number times.
+export function EventsOnMultiple(eventName: string, callback: (...data: any) => void, maxCallbacks: number): () => void;
+
+// [EventsOnce](https://wails.io/docs/reference/runtime/events#eventsonce)
+// sets up a listener for the given event name, but will only trigger once.
+export function EventsOnce(eventName: string, callback: (...data: any) => void): () => void;
+
+// [EventsOff](https://wails.io/docs/reference/runtime/events#eventsoff)
+// unregisters the listener for the given event name.
+export function EventsOff(eventName: string, ...additionalEventNames: string[]): void;
+
+// [EventsOffAll](https://wails.io/docs/reference/runtime/events#eventsoffall)
+// unregisters all listeners.
+export function EventsOffAll(): void;
+
+// [LogPrint](https://wails.io/docs/reference/runtime/log#logprint)
+// logs the given message as a raw message
+export function LogPrint(message: string): void;
+
+// [LogTrace](https://wails.io/docs/reference/runtime/log#logtrace)
+// logs the given message at the `trace` log level.
+export function LogTrace(message: string): void;
+
+// [LogDebug](https://wails.io/docs/reference/runtime/log#logdebug)
+// logs the given message at the `debug` log level.
+export function LogDebug(message: string): void;
+
+// [LogError](https://wails.io/docs/reference/runtime/log#logerror)
+// logs the given message at the `error` log level.
+export function LogError(message: string): void;
+
+// [LogFatal](https://wails.io/docs/reference/runtime/log#logfatal)
+// logs the given message at the `fatal` log level.
+// The application will quit after calling this method.
+export function LogFatal(message: string): void;
+
+// [LogInfo](https://wails.io/docs/reference/runtime/log#loginfo)
+// logs the given message at the `info` log level.
+export function LogInfo(message: string): void;
+
+// [LogWarning](https://wails.io/docs/reference/runtime/log#logwarning)
+// logs the given message at the `warning` log level.
+export function LogWarning(message: string): void;
+
+// [WindowReload](https://wails.io/docs/reference/runtime/window#windowreload)
+// Forces a reload by the main application as well as connected browsers.
+export function WindowReload(): void;
+
+// [WindowReloadApp](https://wails.io/docs/reference/runtime/window#windowreloadapp)
+// Reloads the application frontend.
+export function WindowReloadApp(): void;
+
+// [WindowSetAlwaysOnTop](https://wails.io/docs/reference/runtime/window#windowsetalwaysontop)
+// Sets the window AlwaysOnTop or not on top.
+export function WindowSetAlwaysOnTop(b: boolean): void;
+
+// [WindowSetSystemDefaultTheme](https://wails.io/docs/next/reference/runtime/window#windowsetsystemdefaulttheme)
+// *Windows only*
+// Sets window theme to system default (dark/light).
+export function WindowSetSystemDefaultTheme(): void;
+
+// [WindowSetLightTheme](https://wails.io/docs/next/reference/runtime/window#windowsetlighttheme)
+// *Windows only*
+// Sets window to light theme.
+export function WindowSetLightTheme(): void;
+
+// [WindowSetDarkTheme](https://wails.io/docs/next/reference/runtime/window#windowsetdarktheme)
+// *Windows only*
+// Sets window to dark theme.
+export function WindowSetDarkTheme(): void;
+
+// [WindowCenter](https://wails.io/docs/reference/runtime/window#windowcenter)
+// Centers the window on the monitor the window is currently on.
+export function WindowCenter(): void;
+
+// [WindowSetTitle](https://wails.io/docs/reference/runtime/window#windowsettitle)
+// Sets the text in the window title bar.
+export function WindowSetTitle(title: string): void;
+
+// [WindowFullscreen](https://wails.io/docs/reference/runtime/window#windowfullscreen)
+// Makes the window full screen.
+export function WindowFullscreen(): void;
+
+// [WindowUnfullscreen](https://wails.io/docs/reference/runtime/window#windowunfullscreen)
+// Restores the previous window dimensions and position prior to full screen.
+export function WindowUnfullscreen(): void;
+
+// [WindowIsFullscreen](https://wails.io/docs/reference/runtime/window#windowisfullscreen)
+// Returns the state of the window, i.e. whether the window is in full screen mode or not.
+export function WindowIsFullscreen(): Promise<boolean>;
+
+// [WindowSetSize](https://wails.io/docs/reference/runtime/window#windowsetsize)
+// Sets the width and height of the window.
+export function WindowSetSize(width: number, height: number): void;
+
+// [WindowGetSize](https://wails.io/docs/reference/runtime/window#windowgetsize)
+// Gets the width and height of the window.
+export function WindowGetSize(): Promise<Size>;
+
+// [WindowSetMaxSize](https://wails.io/docs/reference/runtime/window#windowsetmaxsize)
+// Sets the maximum window size. Will resize the window if the window is currently larger than the given dimensions.
+// Setting a size of 0,0 will disable this constraint.
+export function WindowSetMaxSize(width: number, height: number): void;
+
+// [WindowSetMinSize](https://wails.io/docs/reference/runtime/window#windowsetminsize)
+// Sets the minimum window size. Will resize the window if the window is currently smaller than the given dimensions.
+// Setting a size of 0,0 will disable this constraint.
+export function WindowSetMinSize(width: number, height: number): void;
+
+// [WindowSetPosition](https://wails.io/docs/reference/runtime/window#windowsetposition)
+// Sets the window position relative to the monitor the window is currently on.
+export function WindowSetPosition(x: number, y: number): void;
+
+// [WindowGetPosition](https://wails.io/docs/reference/runtime/window#windowgetposition)
+// Gets the window position relative to the monitor the window is currently on.
+export function WindowGetPosition(): Promise<Position>;
+
+// [WindowHide](https://wails.io/docs/reference/runtime/window#windowhide)
+// Hides the window.
+export function WindowHide(): void;
+
+// [WindowShow](https://wails.io/docs/reference/runtime/window#windowshow)
+// Shows the window, if it is currently hidden.
+export function WindowShow(): void;
+
+// [WindowMaximise](https://wails.io/docs/reference/runtime/window#windowmaximise)
+// Maximises the window to fill the screen.
+export function WindowMaximise(): void;
+
+// [WindowToggleMaximise](https://wails.io/docs/reference/runtime/window#windowtogglemaximise)
+// Toggles between Maximised and UnMaximised.
+export function WindowToggleMaximise(): void;
+
+// [WindowUnmaximise](https://wails.io/docs/reference/runtime/window#windowunmaximise)
+// Restores the window to the dimensions and position prior to maximising.
+export function WindowUnmaximise(): void;
+
+// [WindowIsMaximised](https://wails.io/docs/reference/runtime/window#windowismaximised)
+// Returns the state of the window, i.e. whether the window is maximised or not.
+export function WindowIsMaximised(): Promise<boolean>;
+
+// [WindowMinimise](https://wails.io/docs/reference/runtime/window#windowminimise)
+// Minimises the window.
+export function WindowMinimise(): void;
+
+// [WindowUnminimise](https://wails.io/docs/reference/runtime/window#windowunminimise)
+// Restores the window to the dimensions and position prior to minimising.
+export function WindowUnminimise(): void;
+
+// [WindowIsMinimised](https://wails.io/docs/reference/runtime/window#windowisminimised)
+// Returns the state of the window, i.e. whether the window is minimised or not.
+export function WindowIsMinimised(): Promise<boolean>;
+
+// [WindowIsNormal](https://wails.io/docs/reference/runtime/window#windowisnormal)
+// Returns the state of the window, i.e. whether the window is normal or not.
+export function WindowIsNormal(): Promise<boolean>;
+
+// [WindowSetBackgroundColour](https://wails.io/docs/reference/runtime/window#windowsetbackgroundcolour)
+// Sets the background colour of the window to the given RGBA colour definition. This colour will show through for all transparent pixels.
+export function WindowSetBackgroundColour(R: number, G: number, B: number, A: number): void;
+
+// [ScreenGetAll](https://wails.io/docs/reference/runtime/window#screengetall)
+// Gets the all screens. Call this anew each time you want to refresh data from the underlying windowing system.
+export function ScreenGetAll(): Promise<Screen[]>;
+
+// [BrowserOpenURL](https://wails.io/docs/reference/runtime/browser#browseropenurl)
+// Opens the given URL in the system browser.
+export function BrowserOpenURL(url: string): void;
+
+// [Environment](https://wails.io/docs/reference/runtime/intro#environment)
+// Returns information about the environment
+export function Environment(): Promise<EnvironmentInfo>;
+
+// [Quit](https://wails.io/docs/reference/runtime/intro#quit)
+// Quits the application.
+export function Quit(): void;
+
+// [Hide](https://wails.io/docs/reference/runtime/intro#hide)
+// Hides the application.
+export function Hide(): void;
+
+// [Show](https://wails.io/docs/reference/runtime/intro#show)
+// Shows the application.
+export function Show(): void;
+
+// [ClipboardGetText](https://wails.io/docs/reference/runtime/clipboard#clipboardgettext)
+// Returns the current text stored on clipboard
+export function ClipboardGetText(): Promise<string>;
+
+// [ClipboardSetText](https://wails.io/docs/reference/runtime/clipboard#clipboardsettext)
+// Sets a text on the clipboard
+export function ClipboardSetText(text: string): Promise<boolean>;
+
+// [OnFileDrop](https://wails.io/docs/reference/runtime/draganddrop#onfiledrop)
+// OnFileDrop listens to drag and drop events and calls the callback with the coordinates of the drop and an array of path strings.
+export function OnFileDrop(callback: (x: number, y: number ,paths: string[]) => void, useDropTarget: boolean) :void
+
+// [OnFileDropOff](https://wails.io/docs/reference/runtime/draganddrop#dragandddropoff)
+// OnFileDropOff removes the drag and drop listeners and handlers.
+export function OnFileDropOff() :void
+
+// Check if the file path resolver is available
+export function CanResolveFilePaths(): boolean;
+
+// Resolves file paths for an array of files
+export function ResolveFilePaths(files: File[]): void
+
+// Notification types
+export interface NotificationOptions {
+ id: string;
+ title: string;
+ subtitle?: string; // macOS and Linux only
+ body?: string;
+ categoryId?: string;
+ data?: { [key: string]: any };
+}
+
+export interface NotificationAction {
+ id?: string;
+ title?: string;
+ destructive?: boolean; // macOS-specific
+}
+
+export interface NotificationCategory {
+ id?: string;
+ actions?: NotificationAction[];
+ hasReplyField?: boolean;
+ replyPlaceholder?: string;
+ replyButtonTitle?: string;
+}
+
+// [InitializeNotifications](https://wails.io/docs/reference/runtime/notification#initializenotifications)
+// Initializes the notification service for the application.
+// This must be called before sending any notifications.
+export function InitializeNotifications(): Promise<void>;
+
+// [CleanupNotifications](https://wails.io/docs/reference/runtime/notification#cleanupnotifications)
+// Cleans up notification resources and releases any held connections.
+export function CleanupNotifications(): Promise<void>;
+
+// [IsNotificationAvailable](https://wails.io/docs/reference/runtime/notification#isnotificationavailable)
+// Checks if notifications are available on the current platform.
+export function IsNotificationAvailable(): Promise<boolean>;
+
+// [RequestNotificationAuthorization](https://wails.io/docs/reference/runtime/notification#requestnotificationauthorization)
+// Requests notification authorization from the user (macOS only).
+export function RequestNotificationAuthorization(): Promise<boolean>;
+
+// [CheckNotificationAuthorization](https://wails.io/docs/reference/runtime/notification#checknotificationauthorization)
+// Checks the current notification authorization status (macOS only).
+export function CheckNotificationAuthorization(): Promise<boolean>;
+
+// [SendNotification](https://wails.io/docs/reference/runtime/notification#sendnotification)
+// Sends a basic notification with the given options.
+export function SendNotification(options: NotificationOptions): Promise<void>;
+
+// [SendNotificationWithActions](https://wails.io/docs/reference/runtime/notification#sendnotificationwithactions)
+// Sends a notification with action buttons. Requires a registered category.
+export function SendNotificationWithActions(options: NotificationOptions): Promise<void>;
+
+// [RegisterNotificationCategory](https://wails.io/docs/reference/runtime/notification#registernotificationcategory)
+// Registers a notification category that can be used with SendNotificationWithActions.
+export function RegisterNotificationCategory(category: NotificationCategory): Promise<void>;
+
+// [RemoveNotificationCategory](https://wails.io/docs/reference/runtime/notification#removenotificationcategory)
+// Removes a previously registered notification category.
+export function RemoveNotificationCategory(categoryId: string): Promise<void>;
+
+// [RemoveAllPendingNotifications](https://wails.io/docs/reference/runtime/notification#removeallpendingnotifications)
+// Removes all pending notifications from the notification center.
+export function RemoveAllPendingNotifications(): Promise<void>;
+
+// [RemovePendingNotification](https://wails.io/docs/reference/runtime/notification#removependingnotification)
+// Removes a specific pending notification by its identifier.
+export function RemovePendingNotification(identifier: string): Promise<void>;
+
+// [RemoveAllDeliveredNotifications](https://wails.io/docs/reference/runtime/notification#removealldeliverednotifications)
+// Removes all delivered notifications from the notification center.
+export function RemoveAllDeliveredNotifications(): Promise<void>;
+
+// [RemoveDeliveredNotification](https://wails.io/docs/reference/runtime/notification#removedeliverednotification)
+// Removes a specific delivered notification by its identifier.
+export function RemoveDeliveredNotification(identifier: string): Promise<void>;
+
+// [RemoveNotification](https://wails.io/docs/reference/runtime/notification#removenotification)
+// Removes a notification by its identifier (cross-platform convenience function).
+export function RemoveNotification(identifier: string): Promise<void>; \ No newline at end of file
diff --git a/cmd/client/frontend/wailsjs/runtime/runtime.js b/cmd/client/frontend/wailsjs/runtime/runtime.js
new file mode 100644
index 0000000..556621e
--- /dev/null
+++ b/cmd/client/frontend/wailsjs/runtime/runtime.js
@@ -0,0 +1,298 @@
+/*
+ _ __ _ __
+| | / /___ _(_) /____
+| | /| / / __ `/ / / ___/
+| |/ |/ / /_/ / / (__ )
+|__/|__/\__,_/_/_/____/
+The electron alternative for Go
+(c) Lea Anthony 2019-present
+*/
+
+export function LogPrint(message) {
+ window.runtime.LogPrint(message);
+}
+
+export function LogTrace(message) {
+ window.runtime.LogTrace(message);
+}
+
+export function LogDebug(message) {
+ window.runtime.LogDebug(message);
+}
+
+export function LogInfo(message) {
+ window.runtime.LogInfo(message);
+}
+
+export function LogWarning(message) {
+ window.runtime.LogWarning(message);
+}
+
+export function LogError(message) {
+ window.runtime.LogError(message);
+}
+
+export function LogFatal(message) {
+ window.runtime.LogFatal(message);
+}
+
+export function EventsOnMultiple(eventName, callback, maxCallbacks) {
+ return window.runtime.EventsOnMultiple(eventName, callback, maxCallbacks);
+}
+
+export function EventsOn(eventName, callback) {
+ return EventsOnMultiple(eventName, callback, -1);
+}
+
+export function EventsOff(eventName, ...additionalEventNames) {
+ return window.runtime.EventsOff(eventName, ...additionalEventNames);
+}
+
+export function EventsOffAll() {
+ return window.runtime.EventsOffAll();
+}
+
+export function EventsOnce(eventName, callback) {
+ return EventsOnMultiple(eventName, callback, 1);
+}
+
+export function EventsEmit(eventName) {
+ let args = [eventName].slice.call(arguments);
+ return window.runtime.EventsEmit.apply(null, args);
+}
+
+export function WindowReload() {
+ window.runtime.WindowReload();
+}
+
+export function WindowReloadApp() {
+ window.runtime.WindowReloadApp();
+}
+
+export function WindowSetAlwaysOnTop(b) {
+ window.runtime.WindowSetAlwaysOnTop(b);
+}
+
+export function WindowSetSystemDefaultTheme() {
+ window.runtime.WindowSetSystemDefaultTheme();
+}
+
+export function WindowSetLightTheme() {
+ window.runtime.WindowSetLightTheme();
+}
+
+export function WindowSetDarkTheme() {
+ window.runtime.WindowSetDarkTheme();
+}
+
+export function WindowCenter() {
+ window.runtime.WindowCenter();
+}
+
+export function WindowSetTitle(title) {
+ window.runtime.WindowSetTitle(title);
+}
+
+export function WindowFullscreen() {
+ window.runtime.WindowFullscreen();
+}
+
+export function WindowUnfullscreen() {
+ window.runtime.WindowUnfullscreen();
+}
+
+export function WindowIsFullscreen() {
+ return window.runtime.WindowIsFullscreen();
+}
+
+export function WindowGetSize() {
+ return window.runtime.WindowGetSize();
+}
+
+export function WindowSetSize(width, height) {
+ window.runtime.WindowSetSize(width, height);
+}
+
+export function WindowSetMaxSize(width, height) {
+ window.runtime.WindowSetMaxSize(width, height);
+}
+
+export function WindowSetMinSize(width, height) {
+ window.runtime.WindowSetMinSize(width, height);
+}
+
+export function WindowSetPosition(x, y) {
+ window.runtime.WindowSetPosition(x, y);
+}
+
+export function WindowGetPosition() {
+ return window.runtime.WindowGetPosition();
+}
+
+export function WindowHide() {
+ window.runtime.WindowHide();
+}
+
+export function WindowShow() {
+ window.runtime.WindowShow();
+}
+
+export function WindowMaximise() {
+ window.runtime.WindowMaximise();
+}
+
+export function WindowToggleMaximise() {
+ window.runtime.WindowToggleMaximise();
+}
+
+export function WindowUnmaximise() {
+ window.runtime.WindowUnmaximise();
+}
+
+export function WindowIsMaximised() {
+ return window.runtime.WindowIsMaximised();
+}
+
+export function WindowMinimise() {
+ window.runtime.WindowMinimise();
+}
+
+export function WindowUnminimise() {
+ window.runtime.WindowUnminimise();
+}
+
+export function WindowSetBackgroundColour(R, G, B, A) {
+ window.runtime.WindowSetBackgroundColour(R, G, B, A);
+}
+
+export function ScreenGetAll() {
+ return window.runtime.ScreenGetAll();
+}
+
+export function WindowIsMinimised() {
+ return window.runtime.WindowIsMinimised();
+}
+
+export function WindowIsNormal() {
+ return window.runtime.WindowIsNormal();
+}
+
+export function BrowserOpenURL(url) {
+ window.runtime.BrowserOpenURL(url);
+}
+
+export function Environment() {
+ return window.runtime.Environment();
+}
+
+export function Quit() {
+ window.runtime.Quit();
+}
+
+export function Hide() {
+ window.runtime.Hide();
+}
+
+export function Show() {
+ window.runtime.Show();
+}
+
+export function ClipboardGetText() {
+ return window.runtime.ClipboardGetText();
+}
+
+export function ClipboardSetText(text) {
+ return window.runtime.ClipboardSetText(text);
+}
+
+/**
+ * Callback for OnFileDrop returns a slice of file path strings when a drop is finished.
+ *
+ * @export
+ * @callback OnFileDropCallback
+ * @param {number} x - x coordinate of the drop
+ * @param {number} y - y coordinate of the drop
+ * @param {string[]} paths - A list of file paths.
+ */
+
+/**
+ * OnFileDrop listens to drag and drop events and calls the callback with the coordinates of the drop and an array of path strings.
+ *
+ * @export
+ * @param {OnFileDropCallback} callback - Callback for OnFileDrop returns a slice of file path strings when a drop is finished.
+ * @param {boolean} [useDropTarget=true] - Only call the callback when the drop finished on an element that has the drop target style. (--wails-drop-target)
+ */
+export function OnFileDrop(callback, useDropTarget) {
+ return window.runtime.OnFileDrop(callback, useDropTarget);
+}
+
+/**
+ * OnFileDropOff removes the drag and drop listeners and handlers.
+ */
+export function OnFileDropOff() {
+ return window.runtime.OnFileDropOff();
+}
+
+export function CanResolveFilePaths() {
+ return window.runtime.CanResolveFilePaths();
+}
+
+export function ResolveFilePaths(files) {
+ return window.runtime.ResolveFilePaths(files);
+}
+
+export function InitializeNotifications() {
+ return window.runtime.InitializeNotifications();
+}
+
+export function CleanupNotifications() {
+ return window.runtime.CleanupNotifications();
+}
+
+export function IsNotificationAvailable() {
+ return window.runtime.IsNotificationAvailable();
+}
+
+export function RequestNotificationAuthorization() {
+ return window.runtime.RequestNotificationAuthorization();
+}
+
+export function CheckNotificationAuthorization() {
+ return window.runtime.CheckNotificationAuthorization();
+}
+
+export function SendNotification(options) {
+ return window.runtime.SendNotification(options);
+}
+
+export function SendNotificationWithActions(options) {
+ return window.runtime.SendNotificationWithActions(options);
+}
+
+export function RegisterNotificationCategory(category) {
+ return window.runtime.RegisterNotificationCategory(category);
+}
+
+export function RemoveNotificationCategory(categoryId) {
+ return window.runtime.RemoveNotificationCategory(categoryId);
+}
+
+export function RemoveAllPendingNotifications() {
+ return window.runtime.RemoveAllPendingNotifications();
+}
+
+export function RemovePendingNotification(identifier) {
+ return window.runtime.RemovePendingNotification(identifier);
+}
+
+export function RemoveAllDeliveredNotifications() {
+ return window.runtime.RemoveAllDeliveredNotifications();
+}
+
+export function RemoveDeliveredNotification(identifier) {
+ return window.runtime.RemoveDeliveredNotification(identifier);
+}
+
+export function RemoveNotification(identifier) {
+ return window.runtime.RemoveNotification(identifier);
+} \ No newline at end of file
diff --git a/cmd/client/kill_other.go b/cmd/client/kill_other.go
new file mode 100644
index 0000000..75ac0a5
--- /dev/null
+++ b/cmd/client/kill_other.go
@@ -0,0 +1,16 @@
+//go:build !windows
+
+package main
+
+import (
+ "log"
+ "os/exec"
+ "time"
+)
+
+func killPrevious() {
+ log.Println("killing previous instances")
+ exec.Command("pkill", "-f", "sing-box").Run()
+ // Don't pkill vpnem — we ARE vpnem
+ time.Sleep(500 * time.Millisecond)
+}
diff --git a/cmd/client/kill_windows.go b/cmd/client/kill_windows.go
new file mode 100644
index 0000000..ba8c75a
--- /dev/null
+++ b/cmd/client/kill_windows.go
@@ -0,0 +1,56 @@
+//go:build windows
+
+package main
+
+import (
+ "log"
+ "os"
+ "os/exec"
+ "strconv"
+ "time"
+)
+
+func killPrevious() {
+ myPID := os.Getpid()
+ log.Printf("killing previous instances (my PID: %d)", myPID)
+
+ // Kill other vpnem.exe processes (not ourselves)
+ // Use WMIC to find PIDs, then taskkill each except ours
+ out, _ := exec.Command("wmic", "process", "where",
+ "name='vpnem.exe'", "get", "processid", "/format:list").Output()
+ for _, line := range splitLines(string(out)) {
+ if len(line) > 10 && line[:10] == "ProcessId=" {
+ pidStr := line[10:]
+ pid, err := strconv.Atoi(pidStr)
+ if err == nil && pid != myPID {
+ log.Printf("killing old vpnem.exe PID %d", pid)
+ exec.Command("taskkill", "/F", "/PID", strconv.Itoa(pid)).Run()
+ }
+ }
+ }
+
+ // Kill any orphaned sing-box.exe
+ exec.Command("taskkill", "/F", "/IM", "sing-box.exe").Run()
+ time.Sleep(500 * time.Millisecond)
+}
+
+func splitLines(s string) []string {
+ var lines []string
+ start := 0
+ for i := 0; i < len(s); i++ {
+ if s[i] == '\n' || s[i] == '\r' {
+ line := s[start:i]
+ if len(line) > 0 && line[len(line)-1] == '\r' {
+ line = line[:len(line)-1]
+ }
+ if line != "" {
+ lines = append(lines, line)
+ }
+ start = i + 1
+ }
+ }
+ if start < len(s) {
+ lines = append(lines, s[start:])
+ }
+ return lines
+}
diff --git a/cmd/client/main.go b/cmd/client/main.go
new file mode 100644
index 0000000..a2cabbf
--- /dev/null
+++ b/cmd/client/main.go
@@ -0,0 +1,108 @@
+package main
+
+import (
+ "embed"
+ "flag"
+ "log"
+ "os"
+ "path/filepath"
+ "runtime"
+
+ "github.com/wailsapp/wails/v2"
+ "github.com/wailsapp/wails/v2/pkg/options"
+ "github.com/wailsapp/wails/v2/pkg/options/assetserver"
+)
+
+//go:embed frontend/dist
+var assets embed.FS
+
+func main() {
+ apiURL := flag.String("api", "https://vpn.em-sysadmin.xyz", "API server URL")
+ dataDir := flag.String("data", defaultDataDir(), "data directory")
+ flag.Parse()
+
+ // Ensure data dir exists
+ os.MkdirAll(*dataDir, 0o755)
+
+ // Setup file logging so we always have diagnostics
+ logPath := filepath.Join(*dataDir, "vpnem.log")
+ logFile, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
+ if err == nil {
+ log.SetOutput(logFile)
+ defer logFile.Close()
+ }
+
+ log.Printf("vpnem starting, data=%s, api=%s, os=%s", *dataDir, *apiURL, runtime.GOOS)
+
+ // Kill previous instances of vpnem and sing-box
+ killPrevious()
+
+ // Clean stale update artifacts from crashed updates
+ if runtime.GOOS == "windows" {
+ exe, _ := os.Executable()
+ os.Remove(exe + ".old") // leftover from failed update
+ os.Remove(exe + ".tmp") // leftover temp
+ os.Remove(filepath.Join(*dataDir, "vpnem-new.exe"))
+ } else {
+ exe, _ := os.Executable()
+ os.Remove(exe + ".old")
+ os.Remove(filepath.Join(*dataDir, "vpnem-new"))
+ }
+
+ // Check wintun.dll on Windows
+ if runtime.GOOS == "windows" {
+ exe, _ := os.Executable()
+ exeDir := filepath.Dir(exe)
+ wintunPaths := []string{
+ filepath.Join(exeDir, "wintun.dll"),
+ filepath.Join(*dataDir, "wintun.dll"),
+ "wintun.dll",
+ }
+ found := false
+ for _, p := range wintunPaths {
+ if _, err := os.Stat(p); err == nil {
+ log.Printf("wintun.dll found: %s", p)
+ found = true
+ break
+ }
+ }
+ if !found {
+ log.Printf("WARNING: wintun.dll not found! TUN will fail. Searched: %v", wintunPaths)
+ }
+ }
+
+ app := NewApp(*dataDir, *apiURL)
+
+ log.Println("starting Wails UI")
+ if err := wails.Run(&options.App{
+ Title: "vpnem",
+ Width: 480,
+ Height: 600,
+ MinWidth: 400,
+ MinHeight: 500,
+ AssetServer: &assetserver.Options{
+ Assets: assets,
+ },
+ OnStartup: app.startup,
+ OnShutdown: app.shutdown,
+ Bind: []interface{}{
+ app,
+ },
+ }); err != nil {
+ log.Printf("FATAL wails error: %v", err)
+ // On Windows, also write to a visible error file
+ if runtime.GOOS == "windows" {
+ errPath := filepath.Join(*dataDir, "ERROR.txt")
+ os.WriteFile(errPath, []byte("vpnem failed to start:\n"+err.Error()+"\n\nCheck vpnem.log for details.\n"), 0o644)
+ }
+ os.Exit(1)
+ }
+}
+
+func defaultDataDir() string {
+ if runtime.GOOS == "windows" {
+ return `C:\ProgramData\vpnem`
+ }
+ home, _ := os.UserHomeDir()
+ return filepath.Join(home, ".local", "share", "vpnem")
+}
diff --git a/cmd/client/wails.json b/cmd/client/wails.json
new file mode 100644
index 0000000..0f64984
--- /dev/null
+++ b/cmd/client/wails.json
@@ -0,0 +1,7 @@
+{
+ "name": "vpnem",
+ "outputfilename": "vpnem",
+ "author": {
+ "name": "sergei"
+ }
+}