package main import ( "context" "fmt" "kettuRay/core" "sync" "time" "github.com/getlantern/systray" "github.com/google/uuid" "github.com/wailsapp/wails/v2/pkg/runtime" ) // VpnConfigItem is the frontend-facing config model (includes transient ping info). type VpnConfigItem struct { ID string `json:"id"` Name string `json:"name"` Link string `json:"link"` ProtocolType string `json:"protocolType"` Ping string `json:"ping"` PingColor string `json:"pingColor"` } // App is the Wails application binding. type App struct { ctx context.Context manager *core.CoreManager cfgMgr *core.ConfigManager settings *core.AppSettings mu sync.Mutex configs []core.VpnConfig pingResults map[string]core.PingResult selectedConfigID string // Auto-reconnect state isReconnecting bool reconnectFailLog []time.Time // Tray trayToggleItem *systray.MenuItem } func NewApp() *App { return &App{ pingResults: make(map[string]core.PingResult), } } func (a *App) startup(ctx context.Context) { a.ctx = ctx var err error a.manager, err = core.NewCoreManager() if err != nil { runtime.LogFatalf(ctx, "Failed to init core manager: %v", err) return } a.cfgMgr, err = core.NewConfigManager() if err != nil { runtime.LogFatalf(ctx, "Failed to init config manager: %v", err) return } a.settings = core.LoadSettings() configs, _ := a.cfgMgr.LoadConfigs() if configs != nil { a.configs = configs } a.manager.OnStateChanged = func(state core.ConnectionState) { s := state.String() runtime.EventsEmit(a.ctx, "state-changed", s) a.updateTrayState(s) a.sendStateNotification(state) if state == core.Error && a.settings.AutoReconnect && !a.isReconnecting { go a.tryAutoReconnect() } if state == core.Connected { a.mu.Lock() a.isReconnecting = false a.reconnectFailLog = nil a.mu.Unlock() } } a.manager.OnLog = func(source, message string) { runtime.EventsEmit(a.ctx, "log", source, message) } // Restore last selected config if a.settings.LastConfigID != "" { a.mu.Lock() a.selectedConfigID = a.settings.LastConfigID a.mu.Unlock() } else if len(a.configs) > 0 { a.mu.Lock() a.selectedConfigID = a.configs[0].ID a.mu.Unlock() } // Ping all on startup in background if len(a.configs) > 0 { go a.pingAll() } // Auto-connect on startup if a.settings.AutoConnect { go func() { time.Sleep(500 * time.Millisecond) a.mu.Lock() id := a.selectedConfigID a.mu.Unlock() if id != "" { a.connectByID(id) } }() } a.settings.ApplyStartupRegistry() // System tray a.setupTray() // Global hotkey Win+Alt+V a.registerHotkey() } // sendStateNotification emits a custom notification event to the frontend. func (a *App) sendStateNotification(state core.ConnectionState) { a.mu.Lock() cfg := a.findSelectedConfig() reconnecting := a.isReconnecting a.mu.Unlock() name := "" if cfg != nil { name = cfg.DisplayName() } switch state { case core.Connected: msg := "VPN Connected" if name != "" { msg += " — " + name } runtime.EventsEmit(a.ctx, "notification", msg, "#4CAF50") case core.Disconnected: if !reconnecting { runtime.EventsEmit(a.ctx, "notification", "VPN Disconnected", "#E53935") } case core.Error: if !a.settings.AutoReconnect { runtime.EventsEmit(a.ctx, "notification", "VPN Connection Error", "#E53935") } } } func (a *App) findSelectedConfig() *core.VpnConfig { for i := range a.configs { if a.configs[i].ID == a.selectedConfigID { return &a.configs[i] } } return nil } func (a *App) shutdown(ctx context.Context) { if a.manager != nil { a.manager.Close() } systray.Quit() } // --- Configs --- func (a *App) GetConfigs() []VpnConfigItem { a.mu.Lock() defer a.mu.Unlock() items := make([]VpnConfigItem, 0, len(a.configs)) for _, c := range a.configs { ping := a.pingResults[c.ID] display := "" color := "#666" if ping.Ms >= 0 && ping.Color != "" { display = fmt.Sprintf("%dms", ping.Ms) color = ping.Color } items = append(items, VpnConfigItem{ ID: c.ID, Name: c.DisplayName(), Link: c.Link, ProtocolType: c.ProtocolType(), Ping: display, PingColor: color, }) } return items } // AddConfig adds a new VPN config. Returns error message or "" on success. func (a *App) AddConfig(link string) string { if link == "" { return "Link cannot be empty" } if core.TryParseLink(link) == nil { return "Invalid or unsupported link format" } cfg := core.VpnConfig{ ID: uuid.NewString(), Name: core.ExtractNameFromLink(link), Link: link, } a.mu.Lock() a.configs = append(a.configs, cfg) a.mu.Unlock() _ = a.cfgMgr.SaveConfigs(a.configs) // Ping new config in background go func() { result := core.PingConfig(&cfg) a.mu.Lock() a.pingResults[cfg.ID] = result a.mu.Unlock() display := "" if result.Ms >= 0 { display = fmt.Sprintf("%dms", result.Ms) } runtime.EventsEmit(a.ctx, "ping-update", cfg.ID, display, result.Color) }() return "" } func (a *App) DeleteConfig(id string) { a.mu.Lock() for i, c := range a.configs { if c.ID == id { a.configs = append(a.configs[:i], a.configs[i+1:]...) delete(a.pingResults, id) break } } if a.selectedConfigID == id { a.selectedConfigID = "" } a.mu.Unlock() _ = a.cfgMgr.SaveConfigs(a.configs) } func (a *App) SetSelectedConfig(id string) { a.mu.Lock() a.selectedConfigID = id a.mu.Unlock() a.settings.LastConfigID = id _ = a.settings.Save() } func (a *App) GetSelectedConfigID() string { a.mu.Lock() defer a.mu.Unlock() return a.selectedConfigID } // --- Connection --- func (a *App) Connect(id string) { go a.connectByID(id) } func (a *App) connectByID(id string) { a.mu.Lock() var link string for _, c := range a.configs { if c.ID == id { link = c.Link break } } a.mu.Unlock() if link == "" { runtime.EventsEmit(a.ctx, "toast", "Config not found", true) return } if err := a.manager.Connect(link); err != nil { runtime.EventsEmit(a.ctx, "toast", "Connection failed: "+err.Error(), true) } } func (a *App) Disconnect() { go a.manager.Disconnect() } func (a *App) GetState() string { if a.manager == nil { return "Disconnected" } return a.manager.State.String() } // --- Ping --- func (a *App) PingAll() { go a.pingAll() } func (a *App) pingAll() { a.mu.Lock() configs := make([]core.VpnConfig, len(a.configs)) copy(configs, a.configs) a.mu.Unlock() var wg sync.WaitGroup for _, c := range configs { c := c wg.Add(1) go func() { defer wg.Done() result := core.PingConfig(&c) a.mu.Lock() a.pingResults[c.ID] = result a.mu.Unlock() display := "" if result.Ms >= 0 { display = fmt.Sprintf("%dms", result.Ms) } runtime.EventsEmit(a.ctx, "ping-update", c.ID, display, result.Color) }() } wg.Wait() } // --- Settings --- func (a *App) GetSettings() *core.AppSettings { return a.settings } func (a *App) SaveSettings(runOnStartup, autoConnect, autoReconnect bool) { a.settings.RunOnStartup = runOnStartup a.settings.AutoConnect = autoConnect a.settings.AutoReconnect = autoReconnect _ = a.settings.Save() a.settings.ApplyStartupRegistry() } // --- Window --- func (a *App) ResizeWindow(width, height int) { runtime.WindowSetSize(a.ctx, width, height) } // --- Auto-reconnect --- const reconnectFailWindow = 30 * time.Second const reconnectFailThreshold = 5 func (a *App) tryAutoReconnect() { a.mu.Lock() now := time.Now() fresh := make([]time.Time, 0, len(a.reconnectFailLog)+1) for _, t := range a.reconnectFailLog { if now.Sub(t) < reconnectFailWindow { fresh = append(fresh, t) } } fresh = append(fresh, now) a.reconnectFailLog = fresh count := len(fresh) id := a.selectedConfigID a.mu.Unlock() if count >= reconnectFailThreshold { a.mu.Lock() a.reconnectFailLog = nil a.isReconnecting = false a.mu.Unlock() runtime.EventsEmit(a.ctx, "notification", "Auto-reconnect stopped", "#E53935") return } a.mu.Lock() a.isReconnecting = true a.mu.Unlock() runtime.EventsEmit(a.ctx, "notification", fmt.Sprintf("Reboot: attempt %d...", count), "#FDD835") time.Sleep(1 * time.Second) if id != "" { a.connectByID(id) } }