405 lines
8.1 KiB
Go
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)
|
|
}
|
|
}
|