summaryrefslogtreecommitdiff
path: root/cmd/client
diff options
context:
space:
mode:
authorSergeiEU <39683682+SergeiEU@users.noreply.github.com>2026-04-01 10:17:15 +0400
committerSergeiEU <39683682+SergeiEU@users.noreply.github.com>2026-04-01 10:17:15 +0400
commit1bd203c5555046b7ee4fbfe2f822eb3d03571ad7 (patch)
treed8c85273ede547e03a5727bf185f5d07e87b4a08 /cmd/client
downloadvpnem-1bd203c5555046b7ee4fbfe2f822eb3d03571ad7.tar.gz
vpnem-1bd203c5555046b7ee4fbfe2f822eb3d03571ad7.tar.bz2
vpnem-1bd203c5555046b7ee4fbfe2f822eb3d03571ad7.zip
Initial importHEADmain
Diffstat (limited to 'cmd/client')
-rw-r--r--cmd/client/app.go427
-rw-r--r--cmd/client/build/appicon.pngbin0 -> 132625 bytes
-rw-r--r--cmd/client/build/windows/icon.icobin0 -> 21677 bytes
-rw-r--r--cmd/client/build/windows/info.json15
-rw-r--r--cmd/client/build/windows/wails.exe.manifest15
-rw-r--r--cmd/client/frontend/dist/index.html538
-rwxr-xr-xcmd/client/frontend/wailsjs/go/main/App.d.ts44
-rwxr-xr-xcmd/client/frontend/wailsjs/go/main/App.js83
-rwxr-xr-xcmd/client/frontend/wailsjs/go/models.ts123
-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.go96
-rw-r--r--cmd/client/wails.json7
16 files changed, 2072 insertions, 0 deletions
diff --git a/cmd/client/app.go b/cmd/client/app.go
new file mode 100644
index 0000000..8257847
--- /dev/null
+++ b/cmd/client/app.go
@@ -0,0 +1,427 @@
+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.6"
+
+// 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
+
+ servers []models.Server
+ ruleSets []models.RuleSet
+}
+
+// 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 in background, no auto-connect
+ go func() {
+ if err := a.Sync(); err != nil {
+ a.logEvent("initial sync failed: " + err.Error())
+ } else {
+ a.logEvent("initial sync ok")
+ }
+
+ // Periodic sync
+ 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())
+ }
+ 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 {
+ serversResp, err := a.fetcher.FetchServers()
+ if err != nil {
+ return fmt.Errorf("sync servers: %w", err)
+ }
+ a.servers = serversResp.Servers
+
+ rsResp, err := a.fetcher.FetchRuleSets()
+ if err != nil {
+ return fmt.Errorf("sync rulesets: %w", err)
+ }
+ a.ruleSets = rsResp.RuleSets
+
+ a.state.SetLastSync(time.Now())
+ _ = a.state.Save()
+ a.logEvent(fmt.Sprintf("synced: %d servers, %d rulesets", len(a.servers), len(a.ruleSets)))
+
+ // Notify frontend to refresh
+ if a.ctx != nil {
+ wailsRuntime.EventsEmit(a.ctx, "synced")
+ }
+ return nil
+}
+
+// Connect starts the VPN with the given server and mode.
+func (a *App) Connect(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)
+ }
+
+ serverIPs := syncpkg.ServerIPs(a.servers)
+ activeRuleSets := a.activeRuleSets(*mode)
+ customBypass := a.state.GetCustomBypass()
+
+ a.logEvent("connecting: " + serverTag + " [" + modeName + "]")
+
+ // Flush DNS cache before connecting (Windows caches poisoned responses)
+ flushDNS()
+
+ if err := a.engine.RestartFull(*server, *mode, activeRuleSets, serverIPs, customBypass); err != nil {
+ a.logEvent("connect failed: " + err.Error())
+ return err
+ }
+
+ a.watchdog.StartWatching(*server, *mode, activeRuleSets, serverIPs)
+ a.state.SetServer(serverTag)
+ a.state.SetMode(modeName)
+ _ = a.state.Save()
+ a.logEvent("connected: " + serverTag)
+
+ // Validate connection in background
+ go a.validateConnection()
+ 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
+ }
+
+ // Check exit IP
+ ip := a.GetExitIP()
+ if ip != "" {
+ a.logEvent("exit IP: " + ip)
+ } else {
+ a.logEvent("WARNING: could not verify exit IP")
+ }
+
+ // Check blocked site
+ client := &http.Client{Timeout: 10 * time.Second}
+ resp, err := client.Head("https://rutracker.org")
+ if err == nil {
+ resp.Body.Close()
+ a.logEvent(fmt.Sprintf("validation: rutracker.org → %d OK", resp.StatusCode))
+ } else {
+ a.logEvent("validation: rutracker.org FAILED — " + err.Error())
+ }
+}
+
+// Disconnect stops the VPN and clears system proxy.
+func (a *App) Disconnect() error {
+ 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 {
+ server := a.findServer(serverTag)
+ if server == nil {
+ return fmt.Errorf("server not found: %s", serverTag)
+ }
+ addr := fmt.Sprintf("%s:%d", server.Server, server.ServerPort)
+ if runtime.GOOS == "windows" {
+ // Set SOCKS proxy via registry
+ 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: " + 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
+}
+
+// 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()
+ return map[string]any{
+ "connected": a.engine.IsRunning(),
+ "server": st.SelectedServer,
+ "mode": st.SelectedMode,
+ "lastSync": st.LastSync,
+ }
+}
+
+// GetExitIP checks the actual exit IP through the proxy.
+func (a *App) GetExitIP() string {
+ client := &http.Client{Timeout: 5 * time.Second}
+ 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 {
+ return map[string]any{
+ "default": config.BypassProcesses,
+ "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 the path.
+func (a *App) DownloadUpdate() (string, error) {
+ return a.updater.Download()
+}
+
+// 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))]
+}
+
+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
+}
diff --git a/cmd/client/build/appicon.png b/cmd/client/build/appicon.png
new file mode 100644
index 0000000..63617fe
--- /dev/null
+++ b/cmd/client/build/appicon.png
Binary files differ
diff --git a/cmd/client/build/windows/icon.ico b/cmd/client/build/windows/icon.ico
new file mode 100644
index 0000000..bfa0690
--- /dev/null
+++ b/cmd/client/build/windows/icon.ico
Binary files differ
diff --git a/cmd/client/build/windows/info.json b/cmd/client/build/windows/info.json
new file mode 100644
index 0000000..9727946
--- /dev/null
+++ b/cmd/client/build/windows/info.json
@@ -0,0 +1,15 @@
+{
+ "fixed": {
+ "file_version": "{{.Info.ProductVersion}}"
+ },
+ "info": {
+ "0000": {
+ "ProductVersion": "{{.Info.ProductVersion}}",
+ "CompanyName": "{{.Info.CompanyName}}",
+ "FileDescription": "{{.Info.ProductName}}",
+ "LegalCopyright": "{{.Info.Copyright}}",
+ "ProductName": "{{.Info.ProductName}}",
+ "Comments": "{{.Info.Comments}}"
+ }
+ }
+} \ No newline at end of file
diff --git a/cmd/client/build/windows/wails.exe.manifest b/cmd/client/build/windows/wails.exe.manifest
new file mode 100644
index 0000000..d82139b
--- /dev/null
+++ b/cmd/client/build/windows/wails.exe.manifest
@@ -0,0 +1,15 @@
+<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
+<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1" xmlns:asmv3="urn:schemas-microsoft-com:asm.v3">
+ <assemblyIdentity type="win32" name="com.wails.{{.Name}}" version="{{.Info.ProductVersion}}.0" processorArchitecture="*"/>
+ <dependency>
+ <dependentAssembly>
+ <assemblyIdentity type="win32" name="Microsoft.Windows.Common-Controls" version="6.0.0.0" processorArchitecture="*" publicKeyToken="6595b64144ccf1df" language="*"/>
+ </dependentAssembly>
+ </dependency>
+ <asmv3:application>
+ <asmv3:windowsSettings>
+ <dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true/pm</dpiAware>
+ <dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">permonitorv2,permonitor</dpiAwareness>
+ </asmv3:windowsSettings>
+ </asmv3:application>
+</assembly> \ No newline at end of file
diff --git a/cmd/client/frontend/dist/index.html b/cmd/client/frontend/dist/index.html
new file mode 100644
index 0000000..6a77f85
--- /dev/null
+++ b/cmd/client/frontend/dist/index.html
@@ -0,0 +1,538 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+ <meta charset="UTF-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <title>vpnem</title>
+ <style>
+:root {
+ --bg: #191919;
+ --surface: #222;
+ --surface2: #2a2a2a;
+ --border: #333;
+ --border-focus: #555;
+ --text: #c8c4bd;
+ --text-dim: #706c64;
+ --text-faint: #4a4740;
+ --accent: #c9885a;
+ --accent-hover: #dda070;
+ --on: #7aad6a;
+ --on-dim: #3a5032;
+ --off: #b05050;
+ --off-dim: #4a2828;
+ --warn: #c9a84a;
+ --mono: 'Consolas', 'SF Mono', 'Menlo', monospace;
+}
+
+* { margin: 0; padding: 0; box-sizing: border-box; }
+html, body { height: 100%; }
+body {
+ font: 14px/1.5 'Segoe UI', 'Inter', system-ui, sans-serif;
+ background: var(--bg); color: var(--text);
+ user-select: none; overflow: hidden;
+}
+::-webkit-scrollbar { width:6px; }
+::-webkit-scrollbar-track { background:transparent; }
+::-webkit-scrollbar-thumb { background:#383838; border-radius:3px; }
+::-webkit-scrollbar-thumb:hover { background:#484848; }
+
+/* Layout */
+.app { display: flex; flex-direction: column; height: 100vh; }
+
+/* Header */
+.header {
+ display: flex; align-items: center; justify-content: space-between;
+ padding: 10px 14px; border-bottom: 1px solid var(--border);
+ background: var(--surface); -webkit-app-region: drag;
+}
+.header .brand { font: 700 16px var(--mono); color: var(--accent); letter-spacing: 1.5px; text-transform: uppercase; }
+.header .conn-info { flex: 1; text-align: center; font: 12px var(--mono); color: var(--text-faint); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; padding: 0 10px; transition: color .3s; }
+.header .conn-info.on { color: var(--text-dim); }
+.header .status { display: flex; align-items: center; gap: 8px; font-size: 13px; }
+.header .status .indicator {
+ width: 8px; height: 8px; border-radius: 50%; background: var(--off);
+ transition: background .3s;
+}
+.header .status .indicator.on { background: var(--on); }
+.header .status .label { color: var(--text-dim); transition: color .3s; }
+.header .status .label.on { color: var(--on); }
+
+/* Toast notifications */
+.toast {
+ position: fixed; bottom: 40px; left: 50%; transform: translateX(-50%);
+ padding: 8px 18px; font: 13px var(--mono); border-radius: 3px;
+ background: var(--surface2); border: 1px solid var(--border); color: var(--text);
+ opacity: 0; transition: opacity .3s; pointer-events: none; z-index: 100;
+}
+.toast.show { opacity: 1; }
+.toast.ok { border-color: var(--on); color: var(--on); }
+.toast.err { border-color: var(--off); color: var(--off); }
+
+/* Nav */
+nav {
+ display: flex; border-bottom: 1px solid var(--border); background: var(--surface);
+}
+nav button {
+ flex: 1; padding: 10px 0; font: 600 12px/1 inherit; text-transform: uppercase;
+ letter-spacing: 1px; color: var(--text-dim); background: none;
+ border: none; border-bottom: 2px solid transparent; cursor: pointer;
+ transition: color .15s, border-color .15s;
+}
+nav button:hover { color: var(--text); }
+nav button.active { color: var(--accent); border-bottom-color: var(--accent); }
+
+/* Content area */
+.content { flex: 1; overflow-y: auto; padding: 16px 18px; }
+.panel { display: none; }
+.panel.active { display: block; }
+
+/* Form elements */
+label.field { display: block; margin-bottom: 14px; }
+label.field .lbl { display: block; font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 1px; color: var(--text-dim); margin-bottom: 6px; }
+select, input[type=text] {
+ width: 100%; padding: 9px 12px; font: 14px inherit; color: var(--text);
+ background: var(--surface); border: 1px solid var(--border); border-radius: 3px;
+ outline: none; cursor: pointer; transition: border-color .15s;
+}
+select:focus, input[type=text]:focus { border-color: var(--border-focus); }
+input[type=text] { cursor: text; }
+
+/* Primary action */
+.btn-primary {
+ width: 100%; padding: 12px; margin-top: 6px; font: 700 14px inherit;
+ text-transform: uppercase; letter-spacing: 1.5px; border: 1px solid var(--on);
+ border-radius: 3px; background: var(--on-dim); color: var(--on); cursor: pointer;
+ transition: all .15s;
+}
+.btn-primary:active { transform: scale(.98); }
+.btn-primary:hover { background: var(--on); color: var(--bg); }
+.btn-primary.disconnect { border-color: var(--off); background: var(--off-dim); color: var(--off); }
+.btn-primary.disconnect:hover { background: var(--off); color: var(--bg); }
+.btn-primary:disabled { opacity: .5; cursor: default; }
+
+/* Secondary buttons */
+.btn { display: inline-block; padding: 5px 12px; font: 11px inherit; color: var(--text-dim); background: var(--surface2); border: 1px solid var(--border); border-radius: 3px; cursor: pointer; }
+.btn:hover { color: var(--text); border-color: var(--border-focus); }
+
+/* Exit IP line */
+.meta { margin-top: 10px; font: 13px var(--mono); color: var(--text-dim); text-align: center; }
+.meta em { font-style: normal; color: var(--accent); }
+
+/* Checkbox row */
+.check-row { display: flex; align-items: center; gap: 8px; margin-top: 12px; font-size: 13px; color: var(--text-dim); }
+.check-row input[type=checkbox] { accent-color: var(--accent); width: 15px; height: 15px; cursor: pointer; }
+
+/* Toggle list rows */
+.row {
+ display: flex; align-items: center; gap: 8px;
+ padding: 6px 8px; border-bottom: 1px solid var(--border);
+ font-size: 12px;
+}
+.row:last-child { border-bottom: none; }
+.row .name { flex: 1; font-family: var(--mono); font-size: 13px; color: var(--text); }
+.row .desc { flex: 2; color: var(--text-dim); font-size: 12px; }
+.row .badge { font: 600 9px var(--mono); text-transform: uppercase; letter-spacing: 0.5px; padding: 2px 5px; border-radius: 2px; }
+.row .badge.on { background: var(--on-dim); color: var(--on); }
+.row .badge.always { background: #333; color: var(--text-dim); }
+
+/* Toggle switch */
+.sw { position: relative; width: 32px; height: 18px; flex-shrink: 0; }
+.sw input { display: none; }
+.sw span {
+ position: absolute; inset: 0; background: #3a3a3a; border-radius: 9px;
+ cursor: pointer; transition: .2s;
+}
+.sw span::after {
+ content: ''; position: absolute; width: 12px; height: 12px;
+ left: 3px; top: 3px; background: #666; border-radius: 50%; transition: .2s;
+}
+.sw input:checked + span { background: var(--on-dim); }
+.sw input:checked + span::after { transform: translateX(14px); background: var(--on); }
+
+/* Process items */
+.proc { display: flex; align-items: center; padding: 6px 8px; font: 13px var(--mono); border-bottom: 1px solid var(--border); transition: background .1s; }
+.proc:hover { background: var(--surface); }
+.proc .pname { flex: 1; }
+.proc.builtin .pname { color: var(--text-dim); }
+.proc .x { background: none; border: none; color: var(--off); cursor: pointer; font-size: 13px; padding: 0 4px; }
+.proc .x:hover { color: #e06060; }
+.add-row { display: flex; gap: 4px; margin-top: 6px; }
+.add-row input { flex: 1; font-size: 12px; padding: 5px 8px; }
+.add-row .btn { padding: 5px 14px; }
+
+/* Latency rows */
+.lat { display: flex; padding: 6px 8px; font: 13px var(--mono); border-bottom: 1px solid var(--border); transition: background .1s; }
+.lat:hover { background: var(--surface); }
+.lat .tag { flex: 1; }
+.lat .ms { min-width: 55px; text-align: right; }
+.lat .ms.g { color: var(--on); }
+.lat .ms.m { color: var(--warn); }
+.lat .ms.b { color: var(--off); }
+.lat .ms.d { color: var(--text-faint); }
+
+/* Log viewer */
+.logview {
+ background: #111; border: 1px solid var(--border); border-radius: 3px;
+ padding: 8px 10px; max-height: 220px; overflow-y: auto;
+ font: 11px/1.7 var(--mono); color: var(--text-dim); white-space: pre-wrap; word-break: break-all;
+}
+
+/* Section headers inside panels */
+.sh { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 1px; color: var(--text-faint); margin: 14px 0 8px; }
+.sh:first-child { margin-top: 0; }
+
+/* Footer */
+.footer {
+ display: flex; align-items: center; justify-content: space-between;
+ padding: 8px 16px; border-top: 1px solid var(--border);
+ background: var(--surface); font-size: 12px; color: var(--text-faint);
+}
+.footer .update { color: var(--accent); cursor: pointer; }
+ </style>
+</head>
+<body>
+<div class="app">
+ <div class="header">
+ <span class="brand">vpnem</span>
+ <span class="conn-info" id="connInfo"></span>
+ <div class="status">
+ <div class="indicator" id="ind"></div>
+ <span class="label" id="statusLabel">offline</span>
+ </div>
+ </div>
+ <div class="toast" id="toast"></div>
+
+ <nav>
+ <button class="active" onclick="tab('vpn')">vpn</button>
+ <button onclick="tab('rules')">rules</button>
+ <button onclick="tab('bypass')">bypass</button>
+ <button onclick="tab('diag')">diag</button>
+ </nav>
+
+ <div class="content">
+ <!-- VPN panel -->
+ <div id="p-vpn" class="panel active">
+ <label class="field">
+ <span class="lbl">Server</span>
+ <select id="selServer"></select>
+ </label>
+ <label class="field">
+ <span class="lbl">Routing mode</span>
+ <select id="selMode"></select>
+ </label>
+ <button class="btn-primary" id="btnConn" onclick="doConnect()">Connect</button>
+ <div class="meta" id="exitIp"></div>
+ <div class="check-row">
+ <input type="checkbox" id="chkAuto" onchange="window.go.main.App.SetAutoConnect(this.checked)">
+ <span>Auto-connect on launch</span>
+ </div>
+ </div>
+
+ <!-- Rules panel -->
+ <div id="p-rules" class="panel">
+ <div class="sh">Rule sets</div>
+ <div id="rsList"></div>
+ </div>
+
+ <!-- Bypass panel -->
+ <div id="p-bypass" class="panel">
+ <div class="sh">Built-in exclusions</div>
+ <div id="procDefault"></div>
+ <div class="sh">Custom exclusions</div>
+ <div id="procCustom"></div>
+ <div class="add-row">
+ <input type="text" id="inpProc" placeholder="process.exe" onkeydown="if(event.key==='Enter')addProc()">
+ <button class="btn" onclick="addProc()">Add</button>
+ </div>
+ </div>
+
+ <!-- Diag panel -->
+ <div id="p-diag" class="panel">
+ <div class="sh">Latency <button class="btn" onclick="measureLat()" style="margin-left:6px">Measure</button></div>
+ <div id="latList"></div>
+ <div class="sh" style="margin-top:14px">Log <button class="btn" onclick="loadLog()" style="margin-left:6px">Refresh</button></div>
+ <div class="logview" id="logBox">No logs</div>
+ </div>
+ </div>
+
+ <div class="footer">
+ <span id="syncInfo">--</span>
+ <span>
+ <span id="updInfo" style="display:none" class="update"></span>
+ <button id="updBtn" style="display:none" class="btn" onclick="doUpdate()">update</button>
+ <button class="btn" onclick="doSync()">sync</button>
+ </span>
+ </div>
+</div>
+
+<script>
+let on = false;
+let toastTimer = null;
+const $ = s => document.getElementById(s);
+const A = () => window.go.main.App;
+
+function toast(msg, type) {
+ const t = $('toast');
+ t.textContent = msg;
+ t.className = 'toast show' + (type ? ' ' + type : '');
+ clearTimeout(toastTimer);
+ toastTimer = setTimeout(() => t.className = 'toast', 3000);
+}
+
+function tab(name) {
+ document.querySelectorAll('nav button').forEach((b,i) => b.classList.toggle('active', b.textContent.trim() === name));
+ document.querySelectorAll('.panel').forEach(p => p.classList.remove('active'));
+ $('p-' + name).classList.add('active');
+ if (name === 'rules') loadRules();
+ if (name === 'bypass') loadProcs();
+ if (name === 'diag') loadLog();
+}
+
+async function init() {
+ for (let i = 0; i < 50; i++) {
+ if (window.go && window.go.main && window.go.main.App) break;
+ await new Promise(r => setTimeout(r, 100));
+ }
+ if (!A()) { $('syncInfo').textContent = 'runtime error'; return; }
+
+ try {
+ // Load modes (always available, static)
+ const modes = await A().GetModes();
+ const sel = $('selMode');
+ (modes||[]).forEach(m => { const o = document.createElement('option'); o.value = m; o.textContent = m; sel.appendChild(o); });
+
+ // Wait for sync to complete (may still be running in background)
+ let servers = await A().GetServers();
+ if (!servers || !servers.length) {
+ $('syncInfo').textContent = 'syncing...';
+ // Wait up to 10s for background sync
+ for (let i = 0; i < 20; i++) {
+ await new Promise(r => setTimeout(r, 500));
+ servers = await A().GetServers();
+ if (servers && servers.length) break;
+ }
+ if (!servers || !servers.length) {
+ // Force sync
+ await A().Sync();
+ servers = await A().GetServers();
+ }
+ }
+
+ const status = await A().GetStatus();
+
+ fillServers(servers || []);
+
+ // Set saved or random NL server
+ if (status.server) {
+ $('selServer').value = status.server;
+ } else {
+ try {
+ const rnd = await A().RandomNLServer();
+ if (rnd) $('selServer').value = rnd;
+ } catch(e) {}
+ }
+
+ // Set saved mode or default to last (Combo)
+ if (status.mode) {
+ $('selMode').value = status.mode;
+ } else if (modes && modes.length) {
+ $('selMode').value = modes[modes.length - 1];
+ }
+ if (status.connected) { setOn(true); setTimeout(getIP, 2000); }
+ $('chkAuto').checked = !!status.autoConnect;
+ $('syncInfo').textContent = (servers||[]).length + ' servers';
+ } catch(e) { $('syncInfo').textContent = String(e); }
+
+ setTimeout(checkUpd, 5000);
+
+ // Listen for backend sync events and refresh UI
+ if (window.runtime && window.runtime.EventsOn) {
+ window.runtime.EventsOn('connected', (serverTag) => {
+ $('selServer').value = serverTag;
+ setOn(true, serverTag);
+ });
+ window.runtime.EventsOn('synced', async () => {
+ try {
+ const servers = await A().GetServers();
+ const status = await A().GetStatus();
+ $('selServer').textContent = '';
+ fillServers(servers || []);
+ if (status.server) {
+ $('selServer').value = status.server;
+ } else {
+ const rnd = await A().RandomNLServer();
+ if (rnd) $('selServer').value = rnd;
+ }
+ if (status.mode) $('selMode').value = status.mode;
+ $('syncInfo').textContent = (servers||[]).length + ' servers';
+ } catch(e) {}
+ });
+ }
+}
+
+function fillServers(list) {
+ const sel = $('selServer');
+ const groups = {};
+ list.forEach(s => { (groups[s.region] = groups[s.region]||[]).push(s); });
+ for (const [r, ss] of Object.entries(groups)) {
+ const g = document.createElement('optgroup'); g.label = r;
+ ss.forEach(s => { const o = document.createElement('option'); o.value = s.tag; o.textContent = s.tag + ' \u00b7 ' + s.type; g.appendChild(o); });
+ sel.appendChild(g);
+ }
+}
+
+async function doConnect() {
+ $('btnConn').disabled = true;
+ $('btnConn').textContent = on ? 'Disconnecting...' : 'Connecting...';
+ try {
+ if (on) {
+ await A().Disconnect();
+ setOn(false);
+ } else {
+ const srv = $('selServer').value;
+ const mode = $('selMode').value;
+ if (!srv) { toast('Select a server', 'err'); $('btnConn').disabled = false; $('btnConn').textContent = 'Connect'; return; }
+ await A().Connect(srv, mode);
+ setOn(true);
+ setTimeout(getIP, 2500);
+ }
+ } catch(e) {
+ setOn(false);
+ toast(String(e).substring(0, 80), 'err');
+ }
+ $('btnConn').disabled = false;
+}
+
+function setOn(state, serverTag) {
+ const wasOn = on;
+ on = state;
+ $('ind').className = 'indicator' + (state ? ' on' : '');
+ $('statusLabel').textContent = state ? 'connected' : 'offline';
+ $('statusLabel').className = 'label' + (state ? ' on' : '');
+ const b = $('btnConn');
+ b.textContent = state ? 'Disconnect' : 'Connect';
+ b.className = 'btn-primary' + (state ? ' disconnect' : '');
+ if (state) {
+ const srv = serverTag || $('selServer').value;
+ const mode = $('selMode').value;
+ $('connInfo').textContent = srv + ' \u00b7 ' + mode;
+ $('connInfo').className = 'conn-info on';
+ if (!wasOn) toast('Connected: ' + srv, 'ok');
+ } else {
+ $('connInfo').textContent = '';
+ $('connInfo').className = 'conn-info';
+ $('exitIp').textContent = '';
+ if (wasOn) toast('Disconnected', 'err');
+ }
+}
+
+async function getIP() {
+ try {
+ const ip = await A().GetExitIP();
+ if (ip) { $('exitIp').textContent = ''; const t = document.createTextNode('exit '); $('exitIp').appendChild(t); const e = document.createElement('em'); e.textContent = ip.trim(); $('exitIp').appendChild(e); }
+ } catch(e) {}
+}
+
+async function doSync() {
+ $('syncInfo').textContent = 'syncing\u2026';
+ try { await A().Sync(); const s = await A().GetServers(); $('selServer').textContent = ''; fillServers(s||[]); $('syncInfo').textContent = (s||[]).length + ' servers'; }
+ catch(e) { $('syncInfo').textContent = 'error'; }
+}
+
+// Rules
+async function loadRules() {
+ const c = $('rsList'); c.textContent = '';
+ try {
+ const rs = await A().GetRuleSets();
+ (rs||[]).forEach(r => {
+ const row = document.createElement('div'); row.className = 'row';
+ const nm = document.createElement('span'); nm.className = 'name'; nm.textContent = r.tag; row.appendChild(nm);
+ const ds = document.createElement('span'); ds.className = 'desc'; ds.textContent = r.description; row.appendChild(ds);
+ if (r.optional) {
+ const sw = document.createElement('label'); sw.className = 'sw';
+ const inp = document.createElement('input'); inp.type = 'checkbox'; inp.checked = r.enabled;
+ inp.onchange = () => A().SetRuleSetEnabled(r.tag, inp.checked);
+ const sp = document.createElement('span');
+ sw.appendChild(inp); sw.appendChild(sp); row.appendChild(sw);
+ } else {
+ const b = document.createElement('span'); b.className = 'badge always'; b.textContent = 'active'; row.appendChild(b);
+ }
+ c.appendChild(row);
+ });
+ } catch(e) {}
+}
+
+// Bypass processes
+async function loadProcs() {
+ try {
+ const d = await A().GetBypassProcesses();
+ const df = $('procDefault'); df.textContent = '';
+ (d.default||[]).forEach(p => { const r = document.createElement('div'); r.className = 'proc builtin'; const n = document.createElement('span'); n.className = 'pname'; n.textContent = p; r.appendChild(n); df.appendChild(r); });
+ const cf = $('procCustom'); cf.textContent = '';
+ (d.custom||[]).forEach(p => {
+ const r = document.createElement('div'); r.className = 'proc';
+ const n = document.createElement('span'); n.className = 'pname'; n.textContent = p; r.appendChild(n);
+ const x = document.createElement('button'); x.className = 'x'; x.textContent = '\u00d7'; x.onclick = async()=>{ await A().RemoveBypassProcess(p); loadProcs(); }; r.appendChild(x);
+ cf.appendChild(r);
+ });
+ } catch(e) {}
+}
+
+async function addProc() {
+ const v = $('inpProc').value.trim(); if (!v) return;
+ await A().AddBypassProcess(v); $('inpProc').value = ''; loadProcs();
+}
+
+// Latency
+async function measureLat() {
+ const c = $('latList'); c.textContent = 'measuring\u2026';
+ try {
+ const res = await A().MeasureLatency(); c.textContent = '';
+ res.forEach(r => {
+ const row = document.createElement('div'); row.className = 'lat';
+ const t = document.createElement('span'); t.className = 'tag'; t.textContent = r.tag; row.appendChild(t);
+ const m = document.createElement('span'); m.className = 'ms';
+ if (r.latency_ms < 0) { m.textContent = '\u2014'; m.classList.add('d'); }
+ else if (r.latency_ms < 80) { m.textContent = r.latency_ms + 'ms'; m.classList.add('g'); }
+ else if (r.latency_ms < 200) { m.textContent = r.latency_ms + 'ms'; m.classList.add('m'); }
+ else { m.textContent = r.latency_ms + 'ms'; m.classList.add('b'); }
+ row.appendChild(m); c.appendChild(row);
+ });
+ } catch(e) { c.textContent = String(e); }
+}
+
+async function loadLog() {
+ try { const l = await A().GetLogs(); $('logBox').textContent = (l||[]).join('\n') || 'empty'; $('logBox').scrollTop = $('logBox').scrollHeight; } catch(e) {}
+}
+
+async function checkUpd() {
+ try {
+ const i = await A().CheckUpdate();
+ if (i && i.available) {
+ $('updInfo').style.display = 'inline';
+ $('updInfo').textContent = 'v' + i.version + ' available';
+ $('updBtn').style.display = 'inline-block';
+ }
+ } catch(e) {}
+}
+
+async function doUpdate() {
+ $('updBtn').disabled = true;
+ $('updBtn').textContent = 'updating...';
+ $('updInfo').textContent = 'downloading, app will restart';
+ try {
+ await A().DownloadUpdate();
+ // If we get here, restart failed — shouldn't normally happen
+ $('updInfo').textContent = 'restart manually';
+ } catch(e) {
+ $('updBtn').textContent = 'failed';
+ $('updInfo').textContent = String(e).substring(0, 40);
+ setTimeout(() => { $('updBtn').textContent = 'retry'; $('updBtn').disabled = false; }, 3000);
+ }
+}
+
+setInterval(async()=>{ try { const s = await A().GetStatus(); if (s.connected !== on) { setOn(s.connected); if (s.connected) getIP(); } } catch(e) {} }, 5000);
+init();
+</script>
+</body>
+</html>
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..72187a0
--- /dev/null
+++ b/cmd/client/frontend/wailsjs/go/main/App.d.ts
@@ -0,0 +1,44 @@
+// 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 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 GetExitIP():Promise<string>;
+
+export function GetGeneratedConfig():Promise<string>;
+
+export function GetLogs():Promise<Array<string>>;
+
+export function GetModes():Promise<Array<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 MeasureLatency():Promise<Array<sync.LatencyResult>>;
+
+export function RandomNLServer():Promise<string>;
+
+export function RemoveBypassProcess(arg1: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..917c8f0
--- /dev/null
+++ b/cmd/client/frontend/wailsjs/go/main/App.js
@@ -0,0 +1,83 @@
+// @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 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 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 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 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 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..241727b
--- /dev/null
+++ b/cmd/client/frontend/wailsjs/go/models.ts
@@ -0,0 +1,123 @@
+export namespace models {
+
+ 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 TLS {
+ enabled: boolean;
+ server_name?: string;
+
+ 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"];
+ }
+ }
+ export class Server {
+ tag: string;
+ region: string;
+ type: string;
+ server: string;
+ server_port: number;
+ udp_over_tcp?: boolean;
+ uuid?: string;
+ method?: string;
+ password?: string;
+ tls?: TLS;
+ transport?: Transport;
+
+ 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.tls = this.convertValues(source["tls"], TLS);
+ this.transport = this.convertValues(source["transport"], Transport);
+ }
+
+ 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..0b2fd6f
--- /dev/null
+++ b/cmd/client/main.go
@@ -0,0 +1,96 @@
+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()
+
+ // 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"
+ }
+}