Files
kettuRay/app.go
2026-03-31 14:40:03 +03:00

405 lines
8.1 KiB
Go

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)
}
}