commit 41c8e186ef555f5593f756430ab1a8b45efc9e66 Author: khton Date: Tue Mar 31 14:40:03 2026 +0300 init commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9d67588 --- /dev/null +++ b/.gitignore @@ -0,0 +1,44 @@ +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Project Binaries +build/bin/ +build/obj/ + +# Test binary, built with `go test -c` +*.test + +# Output of the go coverage tool, specifically when used with LiteIDE +*.out + +# Dependency directories (remove the comment below to include it) +# vendor/ +go.work + +# Frontend +frontend/node_modules/ +frontend/dist/ + +# Env files +.env +.env.local +.env.*.local + +# IDEs and AI Editors +.idea/ +.vscode/ +*.swp +*.swo +*~ +.claude/ + +# OS Specific +.DS_Store +Thumbs.db + +# Logs +*.log diff --git a/app.go b/app.go new file mode 100644 index 0000000..8b0eec8 --- /dev/null +++ b/app.go @@ -0,0 +1,404 @@ +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) + } +} diff --git a/build/appicon.png b/build/appicon.png new file mode 100644 index 0000000..63617fe Binary files /dev/null and b/build/appicon.png differ diff --git a/build/windows/icon.ico b/build/windows/icon.ico new file mode 100644 index 0000000..bfa0690 Binary files /dev/null and b/build/windows/icon.ico differ diff --git a/build/windows/info.json b/build/windows/info.json new file mode 100644 index 0000000..9727946 --- /dev/null +++ b/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/build/windows/wails.exe.manifest b/build/windows/wails.exe.manifest new file mode 100644 index 0000000..17e1a23 --- /dev/null +++ b/build/windows/wails.exe.manifest @@ -0,0 +1,15 @@ + + + + + + + + + + + true/pm + permonitorv2,permonitor + + + \ No newline at end of file diff --git a/core/config_manager.go b/core/config_manager.go new file mode 100644 index 0000000..6283c75 --- /dev/null +++ b/core/config_manager.go @@ -0,0 +1,61 @@ +package core + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" +) + +// ConfigManager handles saving/loading VPN configs to disk. +type ConfigManager struct { + configFilePath string +} + +// NewConfigManager creates a new ConfigManager with config file under %APPDATA%/kettuRay. +func NewConfigManager() (*ConfigManager, error) { + appData, err := os.UserConfigDir() + if err != nil { + return nil, fmt.Errorf("failed to get app data dir: %w", err) + } + + appDir := filepath.Join(appData, "kettuRay") + if err := os.MkdirAll(appDir, 0o755); err != nil { + return nil, fmt.Errorf("failed to create app dir: %w", err) + } + + return &ConfigManager{ + configFilePath: filepath.Join(appDir, "configs.json"), + }, nil +} + +// LoadConfigs loads saved VPN configs from disk. +func (m *ConfigManager) LoadConfigs() ([]VpnConfig, error) { + data, err := os.ReadFile(m.configFilePath) + if err != nil { + if os.IsNotExist(err) { + return make([]VpnConfig, 0), nil + } + return nil, err + } + + var configs []VpnConfig + if len(data) == 0 { + return make([]VpnConfig, 0), nil + } + if err := json.Unmarshal(data, &configs); err != nil { + fmt.Printf("Error parsing configs.json: %v\n", err) + // return empty slice but still error so app knows it failed + return make([]VpnConfig, 0), err + } + return configs, nil +} + +// SaveConfigs writes VPN configs to disk. +func (m *ConfigManager) SaveConfigs(configs []VpnConfig) error { + data, err := json.MarshalIndent(configs, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal configs: %w", err) + } + return os.WriteFile(m.configFilePath, data, 0o644) +} diff --git a/core/core_manager.go b/core/core_manager.go new file mode 100644 index 0000000..b13cdbf --- /dev/null +++ b/core/core_manager.go @@ -0,0 +1,431 @@ +package core + +import ( + "context" + "fmt" + "net" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" + "time" +) + +// StateChangedHandler is called when VPN connection state changes. +type StateChangedHandler func(state ConnectionState) + +// LogHandler is called when a log message is produced. (source, message) +type LogHandler func(source, message string) + +// CoreManager manages the lifecycle of xray (proxy backend) and sing-box (TUN frontend). +// +// Connection flow: +// 1. Parse VPN link +// 2. Generate configs for xray and sing-box +// 3. Start xray (SOCKS5 inbound -> proxy outbound) +// 4. Wait for SOCKS5 port readiness +// 5. Start sing-box (TUN -> SOCKS5) +type CoreManager struct { + mu sync.Mutex + + xrayProcess *exec.Cmd + singBoxProcess *exec.Cmd + xrayConfigPath string + singBoxConfigPath string + cancel context.CancelFunc + + coresPath string + configsPath string + + State ConnectionState + CurrentServer *ProxyLink + + OnStateChanged StateChangedHandler + OnLog LogHandler +} + +// NewCoreManager creates a new CoreManager with paths under %APPDATA%/kettuRay. +func NewCoreManager() (*CoreManager, error) { + appData, err := os.UserConfigDir() + if err != nil { + return nil, fmt.Errorf("failed to get app data dir: %w", err) + } + + appDir := filepath.Join(appData, "kettuRay") + coresPath := filepath.Join(appDir, "cores") + configsPath := filepath.Join(appDir, "configs") + + if err := os.MkdirAll(coresPath, 0o755); err != nil { + return nil, fmt.Errorf("failed to create cores dir: %w", err) + } + if err := os.MkdirAll(configsPath, 0o755); err != nil { + return nil, fmt.Errorf("failed to create configs dir: %w", err) + } + + cm := &CoreManager{ + coresPath: coresPath, + configsPath: configsPath, + State: Disconnected, + } + // Kill any leftover xray/sing-box processes from a previous crash + cm.killStaleProcesses() + return cm, nil +} + +// Connect starts the VPN connection using the provided link. +func (cm *CoreManager) Connect(link string) error { + cm.mu.Lock() + if cm.State == Connected || cm.State == Connecting { + cm.mu.Unlock() + cm.log("Core", "Already connected or connecting. Disconnect first.") + return nil + } + cm.mu.Unlock() + + ctx, cancel := context.WithCancel(context.Background()) + cm.mu.Lock() + cm.cancel = cancel + cm.mu.Unlock() + + defer func() { + if cm.State != Connected { + cancel() + } + }() + + cm.setState(Connecting) + + // 0. Kill stale processes + cm.killStaleProcesses() + if err := sleepCtx(ctx, 1*time.Second); err != nil { + cm.log("Core", "Connection cancelled.") + cm.cleanup() + cm.setState(Disconnected) + return nil + } + + // 1. Parse link + cm.log("Core", "Parsing link...") + proxyLink, err := ParseLink(link) + if err != nil { + cm.log("Core", fmt.Sprintf("Failed to parse link: %v", err)) + cm.cleanup() + cm.setState(Error) + return err + } + cm.mu.Lock() + cm.CurrentServer = proxyLink + cm.mu.Unlock() + cm.log("Core", fmt.Sprintf("Server: %s", proxyLink)) + + // 2. Generate configs + cm.log("Core", "Generating configurations...") + xrayConfig, err := GenerateXrayConfig(proxyLink, DefaultSocksPort) + if err != nil { + cm.log("Core", fmt.Sprintf("Failed to generate xray config: %v", err)) + cm.cleanup() + cm.setState(Error) + return err + } + singBoxConfig, err := GenerateSingBoxConfig(proxyLink.Address, DefaultSocksPort, "kettuTun") + if err != nil { + cm.log("Core", fmt.Sprintf("Failed to generate sing-box config: %v", err)) + cm.cleanup() + cm.setState(Error) + return err + } + + // 3. Save configs + cm.xrayConfigPath = filepath.Join(cm.configsPath, "xray-config.json") + cm.singBoxConfigPath = filepath.Join(cm.configsPath, "singbox-config.json") + + if err := os.WriteFile(cm.xrayConfigPath, []byte(xrayConfig), 0o644); err != nil { + cm.log("Core", fmt.Sprintf("Failed to save xray config: %v", err)) + cm.cleanup() + cm.setState(Error) + return err + } + if err := os.WriteFile(cm.singBoxConfigPath, []byte(singBoxConfig), 0o644); err != nil { + cm.log("Core", fmt.Sprintf("Failed to save sing-box config: %v", err)) + cm.cleanup() + cm.setState(Error) + return err + } + + cm.log("Core", fmt.Sprintf("Xray config saved: %s", cm.xrayConfigPath)) + cm.log("Core", fmt.Sprintf("Sing-box config saved: %s", cm.singBoxConfigPath)) + + // 4. Start xray + cm.log("Xray", "Starting xray-core...") + xrayPath := filepath.Join(cm.coresPath, "xray.exe") + xrayCmd, err := cm.startProcess(xrayPath, []string{"run", "-config", cm.xrayConfigPath}, "Xray") + if err != nil { + cm.log("Core", fmt.Sprintf("Failed to start xray: %v", err)) + cm.cleanup() + cm.setState(Error) + return err + } + cm.mu.Lock() + cm.xrayProcess = xrayCmd + cm.mu.Unlock() + + // 5. Wait for SOCKS5 port + cm.log("Core", fmt.Sprintf("Waiting for SOCKS5 port %d...", DefaultSocksPort)) + if err := waitForPort(ctx, DefaultSocksPort, 10*time.Second); err != nil { + cm.log("Core", fmt.Sprintf("Xray failed to open port %d: %v", DefaultSocksPort, err)) + cm.cleanup() + cm.setState(Error) + return fmt.Errorf("xray failed to open port %d within timeout", DefaultSocksPort) + } + cm.log("Core", "SOCKS5 port ready.") + + if ctx.Err() != nil { + cm.log("Core", "Connection cancelled.") + cm.cleanup() + cm.setState(Disconnected) + return nil + } + + // 6. Start sing-box + cm.log("SingBox", "Starting sing-box (TUN)...") + singBoxPath := filepath.Join(cm.coresPath, "sing-box.exe") + singBoxCmd, err := cm.startProcess(singBoxPath, []string{"run", "-c", cm.singBoxConfigPath}, "SingBox") + if err != nil { + cm.log("Core", fmt.Sprintf("Failed to start sing-box: %v", err)) + cm.cleanup() + cm.setState(Error) + return err + } + cm.mu.Lock() + cm.singBoxProcess = singBoxCmd + cm.mu.Unlock() + + // 7. Wait for TUN setup + if err := sleepCtx(ctx, 2*time.Second); err != nil { + cm.log("Core", "Connection cancelled.") + cm.cleanup() + cm.setState(Disconnected) + return nil + } + + // Check both processes are alive + if cm.xrayProcess.ProcessState != nil { + err := fmt.Errorf("xray exited with code %d", cm.xrayProcess.ProcessState.ExitCode()) + cm.log("Core", err.Error()) + cm.cleanup() + cm.setState(Error) + return err + } + if cm.singBoxProcess.ProcessState != nil { + err := fmt.Errorf("sing-box exited with code %d", cm.singBoxProcess.ProcessState.ExitCode()) + cm.log("Core", err.Error()) + cm.cleanup() + cm.setState(Error) + return err + } + + cm.setState(Connected) + cm.log("Core", fmt.Sprintf("Connected to %s (%s:%d)", proxyLink.Remark, proxyLink.Address, proxyLink.Port)) + return nil +} + +// Disconnect stops the VPN connection. +func (cm *CoreManager) Disconnect() { + cm.mu.Lock() + if cm.State == Disconnected || cm.State == Disconnecting { + cm.mu.Unlock() + return + } + cm.mu.Unlock() + + cm.setState(Disconnecting) + cm.log("Core", "Disconnecting...") + + cm.mu.Lock() + if cm.cancel != nil { + cm.cancel() + } + cm.mu.Unlock() + + cm.cleanup() + + cm.mu.Lock() + cm.CurrentServer = nil + cm.mu.Unlock() + + cm.setState(Disconnected) + cm.log("Core", "Disconnected.") +} + +func (cm *CoreManager) startProcess(path string, args []string, source string) (*exec.Cmd, error) { + if _, err := os.Stat(path); err != nil { + return nil, fmt.Errorf("binary not found: %s", path) + } + + cmd := exec.Command(path, args...) + cmd.Dir = filepath.Dir(path) + cmd.SysProcAttr = procAttr() // platform-specific: hide window on Windows + + stdout, _ := cmd.StdoutPipe() + stderr, _ := cmd.StderrPipe() + + if err := cmd.Start(); err != nil { + return nil, fmt.Errorf("failed to start %s: %w", source, err) + } + + cm.log(source, fmt.Sprintf("Process started (PID: %d)", cmd.Process.Pid)) + + // Read stdout/stderr in background + go scanPipe(stdout, func(line string) { cm.log(source, line) }) + go scanPipe(stderr, func(line string) { cm.log(source, markIfError(line)) }) + + // Monitor process exit + go func() { + _ = cmd.Wait() + cm.log(source, fmt.Sprintf("Process exited (code: %d)", cmd.ProcessState.ExitCode())) + + cm.mu.Lock() + state := cm.State + isOurs := cmd == cm.xrayProcess || cmd == cm.singBoxProcess + cm.mu.Unlock() + + if isOurs && (state == Connected || state == Connecting) { + cm.log(source, fmt.Sprintf("CRITICAL: Process %s crashed! Cleaning up...", source)) + cm.setState(Error) + cm.mu.Lock() + if cm.cancel != nil { + cm.cancel() + } + cm.mu.Unlock() + } + }() + + return cmd, nil +} + +func waitForPort(ctx context.Context, port int, timeout time.Duration) error { + ctx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + addr := fmt.Sprintf("127.0.0.1:%d", port) + for { + select { + case <-ctx.Done(): + return ctx.Err() + default: + conn, err := net.DialTimeout("tcp", addr, 1*time.Second) + if err == nil { + conn.Close() + return nil + } + time.Sleep(300 * time.Millisecond) + } + } +} + +func (cm *CoreManager) cleanup() { + // Stop sing-box first (TUN), then xray + cm.mu.Lock() + singBox := cm.singBoxProcess + xray := cm.xrayProcess + cm.singBoxProcess = nil + cm.xrayProcess = nil + xrayConfig := cm.xrayConfigPath + singBoxConfig := cm.singBoxConfigPath + cm.xrayConfigPath = "" + cm.singBoxConfigPath = "" + cm.mu.Unlock() + + stopProcess(singBox, "SingBox", cm.log) + stopProcess(xray, "Xray", cm.log) + + tryDeleteFile(xrayConfig) + tryDeleteFile(singBoxConfig) +} + +func stopProcess(cmd *exec.Cmd, source string, logFn func(string, string)) { + if cmd == nil || cmd.Process == nil { + return + } + if cmd.ProcessState != nil { + return // already exited + } + + logFn(source, fmt.Sprintf("Terminating process (PID: %d)...", cmd.Process.Pid)) + if err := cmd.Process.Kill(); err != nil { + logFn(source, fmt.Sprintf("Error terminating process: %v", err)) + return + } + logFn(source, "Process terminated.") +} + +func tryDeleteFile(path string) { + if path == "" { + return + } + _ = os.Remove(path) +} + +func (cm *CoreManager) killStaleProcesses() { + for _, name := range []string{"xray", "sing-box"} { + killProcessByName(name, func(pid int) { + cm.log("Core", fmt.Sprintf("Killing stale %s process (PID: %d)", name, pid)) + }) + } +} + +func (cm *CoreManager) setState(state ConnectionState) { + cm.mu.Lock() + if cm.State == state { + cm.mu.Unlock() + return + } + cm.State = state + handler := cm.OnStateChanged + cm.mu.Unlock() + + if handler != nil { + handler(state) + } +} + +func (cm *CoreManager) log(source, message string) { + cm.mu.Lock() + handler := cm.OnLog + cm.mu.Unlock() + + if handler != nil { + handler(source, message) + } +} + +// markIfError prefixes a line with [ERR] only if it actually contains an error/fatal level. +// sing-box and xray write INFO/WARN/ERROR to stderr, so we can't blindly mark all stderr as errors. +func markIfError(line string) string { + upper := strings.ToUpper(line) + if strings.Contains(upper, "ERROR") || strings.Contains(upper, "FATAL") || strings.Contains(upper, "PANIC") { + return "[ERR] " + line + } + return line +} + +func sleepCtx(ctx context.Context, d time.Duration) error { + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(d): + return nil + } +} + +// Close stops all processes and cleans up. +func (cm *CoreManager) Close() { + cm.mu.Lock() + if cm.cancel != nil { + cm.cancel() + } + cm.mu.Unlock() + + cm.cleanup() +} diff --git a/core/models.go b/core/models.go new file mode 100644 index 0000000..3344134 --- /dev/null +++ b/core/models.go @@ -0,0 +1,128 @@ +package core + +// ConnectionState represents VPN connection state. +type ConnectionState int + +const ( + Disconnected ConnectionState = iota + Connecting + Connected + Disconnecting + Error +) + +func (s ConnectionState) String() string { + switch s { + case Disconnected: + return "Disconnected" + case Connecting: + return "Connecting" + case Connected: + return "Connected" + case Disconnecting: + return "Disconnecting" + case Error: + return "Error" + default: + return "Unknown" + } +} + +// ProxyLink represents a parsed VPN link (trojan://, vless://, etc.) +type ProxyLink struct { + // Protocol: trojan, vless, vmess, shadowsocks + Protocol string + // Server IP or domain + Address string + // Server port + Port int + // Password (trojan) or UUID (vless/vmess) + Credential string + // Security type: reality, tls, none + Security string + // Server Name Indication for TLS/REALITY + Sni string + // TLS fingerprint (chrome, firefox, safari, etc.) + Fingerprint string + // REALITY public key + PublicKey string + // REALITY short ID + ShortId string + // Transport type: tcp, grpc, ws, h2, xhttp + Transport string + // gRPC service name + ServiceName string + // WebSocket/HTTP2 path + Path string + // Host header for WebSocket/HTTP2 + Host string + // Server name from link fragment (#remark) + Remark string + // XTLS Flow (e.g. xtls-rprx-vision) + Flow string +} + +func (p *ProxyLink) String() string { + return "[" + p.Protocol + "] " + p.Remark + " (" + p.Address + ":" + itoa(p.Port) + ") security=" + p.Security + " transport=" + p.Transport +} + +// VpnConfig represents a saved VPN configuration. +type VpnConfig struct { + ID string `json:"Id"` + Name string `json:"Name"` + Link string `json:"Link"` +} + +func (c *VpnConfig) DisplayName() string { + if c.Name == "" { + return "Unknown Server" + } + return c.Name +} + +func (c *VpnConfig) ProtocolType() string { + if len(c.Link) < 5 { + return "UNKNOWN" + } + switch { + case hasPrefix(c.Link, "vless://"): + return "VLESS" + case hasPrefix(c.Link, "trojan://"): + return "TROJAN" + case hasPrefix(c.Link, "hy2://"): + return "HYSTERIA2" + case hasPrefix(c.Link, "ss://"): + return "SHADOWSOCKS" + default: + return "UNKNOWN" + } +} + +// ExtractNameFromLink extracts server name from a VPN link. +func ExtractNameFromLink(link string) string { + if link == "" { + return "New Config" + } + idx := lastIndexOf(link, '#') + if idx >= 0 && idx < len(link)-1 { + encoded := link[idx+1:] + decoded, err := urlUnescape(encoded) + if err != nil { + return encoded + } + return decoded + } + parts := splitAt(link, '@') + if len(parts) > 1 { + addrPart := parts[1] + for i, ch := range addrPart { + if ch == '?' || ch == ':' || ch == '/' || ch == '#' { + if i > 0 { + return addrPart[:i] + } + break + } + } + } + return "Custom Config" +} diff --git a/core/parser.go b/core/parser.go new file mode 100644 index 0000000..cb71764 --- /dev/null +++ b/core/parser.go @@ -0,0 +1,177 @@ +package core + +import ( + "fmt" + "net/url" + "strconv" + "strings" +) + +// ParseLink parses a VPN link and returns a ProxyLink. +// Supported protocols: trojan, vless. +func ParseLink(link string) (*ProxyLink, error) { + link = strings.TrimSpace(link) + if link == "" { + return nil, fmt.Errorf("link cannot be empty") + } + + protocol, err := getProtocol(link) + if err != nil { + return nil, err + } + + switch protocol { + case "trojan", "vless": + return parseTrojanOrVless(link, protocol) + default: + return nil, fmt.Errorf("unsupported protocol: %s", protocol) + } +} + +// TryParseLink attempts to parse a VPN link, returning nil on failure. +func TryParseLink(link string) *ProxyLink { + result, err := ParseLink(link) + if err != nil { + return nil + } + return result +} + +func getProtocol(link string) (string, error) { + idx := strings.Index(link, "://") + if idx < 0 { + return "", fmt.Errorf("invalid link format: scheme (protocol://) is missing") + } + return strings.ToLower(link[:idx]), nil +} + +// parseTrojanOrVless parses links of format: protocol://credential@host:port?params#remark +func parseTrojanOrVless(link, protocol string) (*ProxyLink, error) { + result := &ProxyLink{ + Protocol: protocol, + Security: "none", + Fingerprint: "chrome", + Transport: "tcp", + } + + // Remove scheme + remainder := link[len(protocol)+3:] + + // Extract fragment (#remark) + if fragmentIdx := strings.LastIndex(remainder, "#"); fragmentIdx >= 0 { + decoded, err := url.PathUnescape(remainder[fragmentIdx+1:]) + if err != nil { + result.Remark = remainder[fragmentIdx+1:] + } else { + result.Remark = decoded + } + remainder = remainder[:fragmentIdx] + } + + // Extract query string (?params) + var queryParams url.Values + if queryIdx := strings.Index(remainder, "?"); queryIdx >= 0 { + var err error + queryParams, err = url.ParseQuery(remainder[queryIdx+1:]) + if err != nil { + queryParams = url.Values{} + } + remainder = remainder[:queryIdx] + } else { + queryParams = url.Values{} + } + + // Extract credential@host:port + atIdx := strings.Index(remainder, "@") + if atIdx < 0 { + return nil, fmt.Errorf("invalid format: missing credential@host:port") + } + + decoded, err := url.PathUnescape(remainder[:atIdx]) + if err != nil { + result.Credential = remainder[:atIdx] + } else { + result.Credential = decoded + } + + hostPort := remainder[atIdx+1:] + + // Parse host:port + if err := parseHostPort(hostPort, result); err != nil { + return nil, err + } + + // Parse query parameters + parseQueryParams(queryParams, result) + + return result, nil +} + +func parseHostPort(hostPort string, result *ProxyLink) error { + var lastColon int + + if strings.HasPrefix(hostPort, "[") { + // IPv6: [::1]:443 + bracketEnd := strings.Index(hostPort, "]") + if bracketEnd < 0 { + return fmt.Errorf("invalid IPv6 address: missing closing bracket") + } + result.Address = hostPort[1:bracketEnd] + lastColon = strings.Index(hostPort[bracketEnd:], ":") + if lastColon >= 0 { + lastColon += bracketEnd + } + } else { + lastColon = strings.LastIndex(hostPort, ":") + if lastColon < 0 { + return fmt.Errorf("invalid format: missing port") + } + result.Address = hostPort[:lastColon] + } + + if lastColon < 0 || lastColon >= len(hostPort)-1 { + return fmt.Errorf("invalid format: missing port") + } + + portStr := hostPort[lastColon+1:] + port, err := strconv.Atoi(portStr) + if err != nil || port < 1 || port > 65535 { + return fmt.Errorf("invalid port: %s", portStr) + } + result.Port = port + + return nil +} + +func parseQueryParams(params url.Values, result *ProxyLink) { + if v := params.Get("security"); v != "" { + result.Security = strings.ToLower(v) + } + if v := params.Get("sni"); v != "" { + result.Sni = v + } + if v := params.Get("fp"); v != "" { + result.Fingerprint = v + } + if v := params.Get("pbk"); v != "" { + result.PublicKey = v + } + if v := params.Get("sid"); v != "" { + result.ShortId = v + } + if v := params.Get("type"); v != "" { + result.Transport = strings.ToLower(v) + } + if v := params.Get("serviceName"); v != "" { + result.ServiceName = v + } + if v := params.Get("path"); v != "" { + result.Path = v + } + if v := params.Get("host"); v != "" { + result.Host = v + } + if v := params.Get("flow"); v != "" { + result.Flow = v + } +} diff --git a/core/ping.go b/core/ping.go new file mode 100644 index 0000000..fb2d2d4 --- /dev/null +++ b/core/ping.go @@ -0,0 +1,78 @@ +package core + +import ( + "net" + "sync" + "time" +) + +// PingResult contains the result of a ping to a server. +type PingResult struct { + Ms int64 // round-trip time in ms, -1 if failed + Color string +} + +// PingAddress pings a single address using TCP connect (ICMP requires elevated privileges). +// Returns round-trip time in ms or -1 on failure. +func PingAddress(address string, timeout time.Duration) int64 { + if timeout == 0 { + timeout = 3 * time.Second + } + + start := time.Now() + conn, err := net.DialTimeout("tcp", address+":443", timeout) + if err != nil { + return -1 + } + conn.Close() + return time.Since(start).Milliseconds() +} + +// PingConfig pings a VPN config and returns the result. +func PingConfig(config *VpnConfig) PingResult { + link := TryParseLink(config.Link) + if link == nil { + return PingResult{Ms: -1, Color: "#666"} + } + + ms := PingAddress(link.Address, 3*time.Second) + if ms < 0 { + return PingResult{Ms: -1, Color: "#666"} + } + return PingResult{Ms: ms, Color: GetPingColor(ms)} +} + +// PingAllConfigs pings all configs concurrently. +func PingAllConfigs(configs []VpnConfig) []PingResult { + results := make([]PingResult, len(configs)) + var wg sync.WaitGroup + + for i := range configs { + wg.Add(1) + go func(idx int) { + defer wg.Done() + results[idx] = PingConfig(&configs[idx]) + }(i) + } + + wg.Wait() + return results +} + +// GetPingColor returns a color hex code based on ping latency. +func GetPingColor(ms int64) string { + switch { + case ms < 50: + return "#4CAF50" // green + case ms < 100: + return "#8BC34A" // light green + case ms < 150: + return "#CDDC39" // lime + case ms < 200: + return "#FFC107" // amber + case ms < 300: + return "#FF9800" // orange + default: + return "#E53935" // red + } +} diff --git a/core/process_windows.go b/core/process_windows.go new file mode 100644 index 0000000..b83cc6e --- /dev/null +++ b/core/process_windows.go @@ -0,0 +1,70 @@ +//go:build windows + +package core + +import ( + "bufio" + "fmt" + "io" + "os/exec" + "regexp" + "strconv" + "strings" + "syscall" +) + +var ansiEscape = regexp.MustCompile(`\x1b\[[0-9;]*m`) + +// procAttr returns platform-specific process attributes. +// On Windows, hides the console window. +func procAttr() *syscall.SysProcAttr { + return &syscall.SysProcAttr{ + HideWindow: true, + CreationFlags: 0x08000000, // CREATE_NO_WINDOW + } +} + +// killProcessByName kills all processes with the given name. +func killProcessByName(name string, onKill func(pid int)) { + // Use tasklist to find processes + out, err := exec.Command("tasklist", "/FI", fmt.Sprintf("IMAGENAME eq %s.exe", name), "/FO", "CSV", "/NH").Output() + if err != nil { + return + } + + for _, line := range strings.Split(string(out), "\n") { + line = strings.TrimSpace(line) + if line == "" || strings.Contains(line, "No tasks") { + continue + } + // CSV format: "name.exe","PID","Session Name","Session#","Mem Usage" + parts := strings.Split(line, ",") + if len(parts) < 2 { + continue + } + pidStr := strings.Trim(parts[1], "\" ") + pid, err := strconv.Atoi(pidStr) + if err != nil { + continue + } + if onKill != nil { + onKill(pid) + } + _ = exec.Command("taskkill", "/F", "/T", "/PID", pidStr).Run() + } +} + +// scanPipe reads lines from a pipe and calls handler for each. +// ANSI escape codes are stripped before passing to handler. +func scanPipe(r io.Reader, handler func(string)) { + if r == nil { + return + } + scanner := bufio.NewScanner(r) + for scanner.Scan() { + line := ansiEscape.ReplaceAllString(scanner.Text(), "") + if line != "" { + handler(line) + } + } +} diff --git a/core/settings.go b/core/settings.go new file mode 100644 index 0000000..39779c3 --- /dev/null +++ b/core/settings.go @@ -0,0 +1,44 @@ +package core + +import ( + "encoding/json" + "os" + "path/filepath" +) + +// AppSettings represents persistent application settings. +type AppSettings struct { + RunOnStartup bool `json:"RunOnStartup"` + AutoConnect bool `json:"AutoConnect"` + AutoReconnect bool `json:"AutoReconnect"` + LastConfigID string `json:"LastConfigId,omitempty"` +} + +func settingsPath() string { + appData, _ := os.UserConfigDir() + return filepath.Join(appData, "kettuRay", "settings.json") +} + +// LoadSettings loads settings from disk, returning defaults on error. +func LoadSettings() *AppSettings { + data, err := os.ReadFile(settingsPath()) + if err != nil { + return &AppSettings{} + } + var s AppSettings + if err := json.Unmarshal(data, &s); err != nil { + return &AppSettings{} + } + return &s +} + +// Save writes settings to disk. +func (s *AppSettings) Save() error { + path := settingsPath() + _ = os.MkdirAll(filepath.Dir(path), 0o755) + data, err := json.MarshalIndent(s, "", " ") + if err != nil { + return err + } + return os.WriteFile(path, data, 0o644) +} diff --git a/core/settings_windows.go b/core/settings_windows.go new file mode 100644 index 0000000..e3b5108 --- /dev/null +++ b/core/settings_windows.go @@ -0,0 +1,30 @@ +//go:build windows + +package core + +import ( + "os" + + "golang.org/x/sys/windows/registry" +) + +const regRunKey = `SOFTWARE\Microsoft\Windows\CurrentVersion\Run` + +// ApplyStartupRegistry adds or removes the app from Windows startup registry. +func (s *AppSettings) ApplyStartupRegistry() { + key, err := registry.OpenKey(registry.CURRENT_USER, regRunKey, registry.SET_VALUE) + if err != nil { + return + } + defer key.Close() + + if s.RunOnStartup { + exePath, err := os.Executable() + if err != nil { + return + } + _ = key.SetStringValue("kettuRay", `"`+exePath+`"`) + } else { + _ = key.DeleteValue("kettuRay") + } +} diff --git a/core/singbox_config.go b/core/singbox_config.go new file mode 100644 index 0000000..ec553e3 --- /dev/null +++ b/core/singbox_config.go @@ -0,0 +1,156 @@ +package core + +import ( + "encoding/json" + "fmt" + "net" +) + +// GenerateSingBoxConfig generates a full JSON config for sing-box (v1.14+). +// sing-box acts as frontend: creates a TUN interface, +// captures system traffic and routes it to xray via SOCKS5. +func GenerateSingBoxConfig(serverAddress string, socksPort int, tunName string) (string, error) { + if socksPort == 0 { + socksPort = DefaultSocksPort + } + if tunName == "" { + tunName = "kettuTun" + } + + config := map[string]any{ + "log": buildSingBoxLog(), + "dns": buildSingBoxDns(), + "inbounds": []any{buildTunInbound(tunName)}, + "outbounds": buildSingBoxOutbounds(socksPort), + "route": buildSingBoxRoute(serverAddress), + } + + data, err := json.MarshalIndent(config, "", " ") + if err != nil { + return "", fmt.Errorf("failed to marshal sing-box config: %w", err) + } + return string(data), nil +} + +func buildSingBoxLog() map[string]any { + return map[string]any{ + "level": "info", + "timestamp": true, + } +} + +func buildSingBoxDns() map[string]any { + return map[string]any{ + "servers": []any{ + map[string]any{ + "type": "https", + "tag": "remote-dns", + "server": "1.1.1.1", + "server_port": 443, + "path": "/dns-query", + "detour": "proxy-out", + }, + map[string]any{ + "type": "https", + "tag": "local-dns", + "server": "8.8.8.8", + "server_port": 443, + "path": "/dns-query", + }, + }, + } +} + +func buildTunInbound(tunName string) map[string]any { + tun := map[string]any{ + "type": "tun", + "tag": "tun-in", + "address": []string{ + "172.18.0.1/30", + "fdfe:dcba:9876::1/126", + }, + "mtu": 1400, + "auto_route": true, + "strict_route": false, + "stack": "gvisor", + } + + if tunName != "" && tunName != "kettuTun" { + tun["interface_name"] = tunName + } + + return tun +} + +func buildSingBoxOutbounds(socksPort int) []any { + return []any{ + map[string]any{ + "type": "socks", + "tag": "proxy-out", + "server": "127.0.0.1", + "server_port": socksPort, + "version": "5", + "udp_over_tcp": false, + }, + map[string]any{ + "type": "direct", + "tag": "direct-out", + }, + } +} + +func buildSingBoxRoute(serverAddress string) map[string]any { + rules := []any{ + // Anti routing loop: bypass VPN core processes + map[string]any{ + "process_name": []string{"xray.exe", "sing-box.exe", "kettuRay.exe"}, + "action": "route", + "outbound": "direct-out", + }, + } + + // Anti routing loop: bypass remote VPN server + bypassRule := map[string]any{ + "action": "route", + "outbound": "direct-out", + } + + if ip := net.ParseIP(serverAddress); ip != nil { + prefix := "32" + if ip.To4() == nil { + prefix = "128" + } + bypassRule["ip_cidr"] = []string{serverAddress + "/" + prefix} + } else { + bypassRule["domain"] = []string{serverAddress} + } + rules = append(rules, bypassRule) + + // Sniff rule + rules = append(rules, map[string]any{ + "action": "sniff", + "timeout": "300ms", + }) + + // DNS hijack + rules = append(rules, map[string]any{ + "protocol": "dns", + "action": "hijack-dns", + }) + + // Private IPs go direct + rules = append(rules, map[string]any{ + "ip_is_private": true, + "action": "route", + "outbound": "direct-out", + }) + + return map[string]any{ + "rules": rules, + "default_domain_resolver": map[string]any{ + "server": "local-dns", + }, + "final": "proxy-out", + "auto_detect_interface": true, + } +} diff --git a/core/util.go b/core/util.go new file mode 100644 index 0000000..d73a80a --- /dev/null +++ b/core/util.go @@ -0,0 +1,31 @@ +package core + +import ( + "net/url" + "strconv" + "strings" +) + +func itoa(n int) string { + return strconv.Itoa(n) +} + +func hasPrefix(s, prefix string) bool { + return strings.HasPrefix(strings.ToLower(s), strings.ToLower(prefix)) +} + +func lastIndexOf(s string, ch byte) int { + return strings.LastIndexByte(s, ch) +} + +func splitAt(s string, ch byte) []string { + idx := strings.IndexByte(s, ch) + if idx < 0 { + return []string{s} + } + return []string{s[:idx], s[idx+1:]} +} + +func urlUnescape(s string) (string, error) { + return url.PathUnescape(s) +} diff --git a/core/xray_config.go b/core/xray_config.go new file mode 100644 index 0000000..81f0c1b --- /dev/null +++ b/core/xray_config.go @@ -0,0 +1,177 @@ +package core + +import ( + "encoding/json" + "fmt" +) + +const DefaultSocksPort = 10808 + +// GenerateXrayConfig generates a full JSON config for xray-core. +// Xray acts as backend: listens SOCKS5 on a local port, connects to the remote server. +func GenerateXrayConfig(link *ProxyLink, socksPort int) (string, error) { + if socksPort == 0 { + socksPort = DefaultSocksPort + } + + config := map[string]any{ + "log": map[string]any{ + "loglevel": "warning", + }, + "inbounds": []any{ + buildSocksInbound(socksPort), + }, + "outbounds": []any{ + buildProxyOutbound(link), + buildDirectOutbound(), + }, + } + + data, err := json.MarshalIndent(config, "", " ") + if err != nil { + return "", fmt.Errorf("failed to marshal xray config: %w", err) + } + return string(data), nil +} + +func buildSocksInbound(port int) map[string]any { + return map[string]any{ + "port": port, + "listen": "127.0.0.1", + "protocol": "socks", + "settings": map[string]any{ + "auth": "noauth", + "udp": true, + }, + "tag": "socks-in", + } +} + +func buildProxyOutbound(link *ProxyLink) map[string]any { + outbound := map[string]any{ + "tag": "proxy", + "protocol": link.Protocol, + } + + switch link.Protocol { + case "trojan": + outbound["settings"] = buildTrojanSettings(link) + case "vless": + outbound["settings"] = buildVlessSettings(link) + } + + outbound["streamSettings"] = buildStreamSettings(link) + return outbound +} + +func buildTrojanSettings(link *ProxyLink) map[string]any { + return map[string]any{ + "servers": []any{ + map[string]any{ + "address": link.Address, + "port": link.Port, + "password": link.Credential, + }, + }, + } +} + +func buildVlessSettings(link *ProxyLink) map[string]any { + user := map[string]any{ + "id": link.Credential, + "encryption": "none", + } + if link.Flow != "" { + user["flow"] = link.Flow + } + + return map[string]any{ + "vnext": []any{ + map[string]any{ + "address": link.Address, + "port": link.Port, + "users": []any{user}, + }, + }, + } +} + +func buildStreamSettings(link *ProxyLink) map[string]any { + stream := map[string]any{ + "network": link.Transport, + } + + // Security + if link.Security != "" && link.Security != "none" { + stream["security"] = link.Security + } + + // Transport-specific settings + switch link.Transport { + case "grpc": + stream["grpcSettings"] = map[string]any{ + "serviceName": link.ServiceName, + } + case "xhttp": + xhttp := map[string]any{ + "path": defaultIfEmpty(link.Path, "/"), + } + if link.Host != "" { + xhttp["host"] = link.Host + } + stream["xhttpSettings"] = xhttp + case "ws": + ws := map[string]any{ + "path": defaultIfEmpty(link.Path, "/"), + } + if link.Host != "" { + ws["headers"] = map[string]any{ + "Host": link.Host, + } + } + stream["wsSettings"] = ws + case "h2", "http": + h2 := map[string]any{ + "path": defaultIfEmpty(link.Path, "/"), + } + if link.Host != "" { + h2["host"] = []string{link.Host} + } + stream["httpSettings"] = h2 + } + + // Security-specific settings + switch link.Security { + case "reality": + stream["realitySettings"] = map[string]any{ + "publicKey": link.PublicKey, + "shortId": link.ShortId, + "serverName": link.Sni, + "fingerprint": link.Fingerprint, + } + case "tls": + tls := map[string]any{ + "fingerprint": link.Fingerprint, + } + if link.Sni != "" { + tls["serverName"] = link.Sni + } + stream["tlsSettings"] = tls + } + + return stream +} + +func buildDirectOutbound() map[string]any { + return map[string]any{ + "tag": "direct", + "protocol": "freedom", + } +} + +func defaultIfEmpty(s, def string) string { + if s == "" { + return def + } + return s +} diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..e4b33e1 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,152 @@ + + + + + + kettuRay + + + +
+ + +
+ kettuRay by @khton +
+ +
+
+ + +
+ + +
+
+ + Disconnected +
+
not selected
+
+ + + +
+
+ + +
+ Configurations +
+ + + +
+
+ + + + + +
+
+ No configurations.
Copy a link and add it from the clipboard. +
+
+ + +
+
+ + + + +
+
+ +
+
+ + + +
+ + + + + + + + + + + +
+
+ + + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..be324d7 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,1143 @@ +{ + "name": "ketturay-frontend", + "version": "0.0.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "ketturay-frontend", + "version": "0.0.1", + "devDependencies": { + "vite": "^6.0.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz", + "integrity": "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.1.tgz", + "integrity": "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.1.tgz", + "integrity": "sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.1.tgz", + "integrity": "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.1.tgz", + "integrity": "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.1.tgz", + "integrity": "sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.1.tgz", + "integrity": "sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.1.tgz", + "integrity": "sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.1.tgz", + "integrity": "sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.1.tgz", + "integrity": "sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.1.tgz", + "integrity": "sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.1.tgz", + "integrity": "sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.1.tgz", + "integrity": "sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.1.tgz", + "integrity": "sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.1.tgz", + "integrity": "sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.1.tgz", + "integrity": "sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.1.tgz", + "integrity": "sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.1.tgz", + "integrity": "sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.1.tgz", + "integrity": "sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.1.tgz", + "integrity": "sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.1.tgz", + "integrity": "sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.1.tgz", + "integrity": "sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.1.tgz", + "integrity": "sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.1.tgz", + "integrity": "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.1.tgz", + "integrity": "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz", + "integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.1", + "@rollup/rollup-android-arm64": "4.60.1", + "@rollup/rollup-darwin-arm64": "4.60.1", + "@rollup/rollup-darwin-x64": "4.60.1", + "@rollup/rollup-freebsd-arm64": "4.60.1", + "@rollup/rollup-freebsd-x64": "4.60.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.1", + "@rollup/rollup-linux-arm-musleabihf": "4.60.1", + "@rollup/rollup-linux-arm64-gnu": "4.60.1", + "@rollup/rollup-linux-arm64-musl": "4.60.1", + "@rollup/rollup-linux-loong64-gnu": "4.60.1", + "@rollup/rollup-linux-loong64-musl": "4.60.1", + "@rollup/rollup-linux-ppc64-gnu": "4.60.1", + "@rollup/rollup-linux-ppc64-musl": "4.60.1", + "@rollup/rollup-linux-riscv64-gnu": "4.60.1", + "@rollup/rollup-linux-riscv64-musl": "4.60.1", + "@rollup/rollup-linux-s390x-gnu": "4.60.1", + "@rollup/rollup-linux-x64-gnu": "4.60.1", + "@rollup/rollup-linux-x64-musl": "4.60.1", + "@rollup/rollup-openbsd-x64": "4.60.1", + "@rollup/rollup-openharmony-arm64": "4.60.1", + "@rollup/rollup-win32-arm64-msvc": "4.60.1", + "@rollup/rollup-win32-ia32-msvc": "4.60.1", + "@rollup/rollup-win32-x64-gnu": "4.60.1", + "@rollup/rollup-win32-x64-msvc": "4.60.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/vite": { + "version": "6.4.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", + "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..204e8d2 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,12 @@ +{ + "name": "ketturay-frontend", + "private": true, + "version": "0.0.1", + "scripts": { + "dev": "vite", + "build": "vite build" + }, + "devDependencies": { + "vite": "^6.0.0" + } +} diff --git a/frontend/package.json.md5 b/frontend/package.json.md5 new file mode 100644 index 0000000..496f2ee --- /dev/null +++ b/frontend/package.json.md5 @@ -0,0 +1 @@ +51c73c9dd7c8a498b3e4e7db2243affe \ No newline at end of file diff --git a/frontend/src/main.js b/frontend/src/main.js new file mode 100644 index 0000000..c6d320e --- /dev/null +++ b/frontend/src/main.js @@ -0,0 +1,477 @@ +import { + GetConfigs, AddConfig, DeleteConfig, + SetSelectedConfig, GetSelectedConfigID, + Connect, Disconnect, GetState, + PingAll, + GetSettings, SaveSettings, + ResizeWindow, HideWindow, +} from '../wailsjs/go/main/App.js'; +import { EventsOn, Quit } from '../wailsjs/runtime/runtime.js'; + +// ── DOM refs ────────────────────────────────────────────────────────────────── +const stateDot = document.getElementById('stateDot'); +const stateText = document.getElementById('stateText'); +const selectedName = document.getElementById('selectedName'); +const protoBadge = document.getElementById('protoBadgeStatus'); +const pingStatus = document.getElementById('pingStatus'); +const latencyStatus = document.getElementById('latencyStatus'); + +const pingAllBtn = document.getElementById('pingAllBtn'); +const addLinkBtn = document.getElementById('addLinkBtn'); +const addClipboardBtn= document.getElementById('addClipboardBtn'); +const linkInputPanel = document.getElementById('linkInputPanel'); +const linkInputBox = document.getElementById('linkInputBox'); +const submitLinkBtn = document.getElementById('submitLinkBtn'); + +const configList = document.getElementById('configList'); +const emptyState = document.getElementById('emptyState'); + +const connectBtn = document.getElementById('connectBtn'); +const disconnectBtn = document.getElementById('disconnectBtn'); +const logBtn = document.getElementById('logBtn'); +const settingsBtn = document.getElementById('settingsBtn'); +const exitBtn = document.getElementById('exitBtn'); +const minimizeBtn = document.getElementById('minimizeBtn'); + +const settingsPanel = document.getElementById('settingsPanel'); +const settingsChevron= document.getElementById('settingsChevron'); +const runOnStartup = document.getElementById('runOnStartup'); +const autoConnect = document.getElementById('autoConnect'); +const autoReconnect = document.getElementById('autoReconnect'); + +const logCol = document.getElementById('logCol'); +const logArea = document.getElementById('logArea'); +const copyLogsBtn = document.getElementById('copyLogsBtn'); +const clearLogsBtn = document.getElementById('clearLogsBtn'); + +const toast = document.getElementById('toast'); +const ctxMenu = document.getElementById('ctxMenu'); +const ctxCopyLink = document.getElementById('ctxCopyLink'); +const ctxDelete = document.getElementById('ctxDelete'); + +// ── State ───────────────────────────────────────────────────────────────────── +let currentState = 'Disconnected'; +let selectedID = ''; +let configs = []; +let logExpanded = false; +let settingsExpanded= false; +let linkPanelOpen = false; +let ctxTargetID = null; +let deleteConfirmTimeout = null; + +// Window heights +const BASE_H = 500; +const SETTINGS_H = 80; // extra height for settings panel +const BASE_W = 360; +const LOG_W = 360; + +// ── Helpers ─────────────────────────────────────────────────────────────────── +function currentWindowSize() { + const h = BASE_H + (settingsExpanded ? SETTINGS_H : 0); + const w = BASE_W + (logExpanded ? LOG_W : 0); + return { w, h }; +} + +function applyWindowSize() { + const { w, h } = currentWindowSize(); + ResizeWindow(w, h); +} + +// ── State UI ────────────────────────────────────────────────────────────────── +function updateStateUI(state) { + currentState = state; + const s = state.toLowerCase(); + stateDot.className = 'state-dot ' + s; + stateText.textContent = state; + + connectBtn.style.display = 'none'; + disconnectBtn.style.display = 'none'; + + switch (state) { + case 'Disconnected': + case 'Error': + connectBtn.style.display = 'inline-flex'; + connectBtn.disabled = !selectedID; + break; + case 'Connecting': + disconnectBtn.style.display = 'inline-flex'; + disconnectBtn.disabled = false; + break; + case 'Connected': + disconnectBtn.style.display = 'inline-flex'; + disconnectBtn.disabled = false; + break; + case 'Disconnecting': + disconnectBtn.style.display = 'inline-flex'; + disconnectBtn.disabled = true; + break; + } +} + +// ── Config list rendering ───────────────────────────────────────────────────── +function renderConfigs() { + // Remove all config items but keep emptyState + configList.querySelectorAll('.config-item').forEach(el => el.remove()); + + emptyState.style.display = configs.length === 0 ? 'block' : 'none'; + + for (const cfg of configs) { + const item = document.createElement('div'); + item.className = 'config-item' + (cfg.id === selectedID ? ' selected' : ''); + item.dataset.id = cfg.id; + + const name = document.createElement('span'); + name.className = 'config-name'; + name.textContent = cfg.name; + + const proto = document.createElement('span'); + proto.className = 'config-proto ' + cfg.protocolType; + proto.textContent = cfg.protocolType; + + const menuBtn = document.createElement('button'); + menuBtn.className = 'config-menu-btn'; + menuBtn.textContent = '⋮'; + menuBtn.addEventListener('click', e => { + e.stopPropagation(); + openCtxMenu(cfg.id, menuBtn); + }); + + item.appendChild(name); + if (cfg.ping) { + const pingBadge = document.createElement('span'); + pingBadge.className = 'config-ping'; + pingBadge.textContent = cfg.ping; + pingBadge.style.borderColor = cfg.pingColor; + pingBadge.style.color = cfg.pingColor; + item.appendChild(pingBadge); + } + item.appendChild(proto); + item.appendChild(menuBtn); + + // Single click → select + item.addEventListener('click', () => selectConfig(cfg.id)); + // Double click → connect + item.addEventListener('dblclick', async () => { + selectConfig(cfg.id); + if (currentState === 'Connected' || currentState === 'Connecting') { + await Disconnect(); + } + Connect(cfg.id); + }); + + configList.insertBefore(item, emptyState); + } + + updateStatusCard(); +} + +function selectConfig(id) { + selectedID = id; + SetSelectedConfig(id); + + configList.querySelectorAll('.config-item').forEach(el => { + el.classList.toggle('selected', el.dataset.id === id); + }); + + connectBtn.disabled = false; + updateStatusCard(); +} + +function updateStatusCard() { + const cfg = configs.find(c => c.id === selectedID); + if (!cfg) { + selectedName.textContent = 'not selected'; + protoBadge.style.display = 'none'; + pingStatus.textContent = 'timeout'; + pingStatus.style.color = '#E53935'; + latencyStatus.textContent = ''; + return; + } + + selectedName.textContent = cfg.name; + + if (currentState === 'Connected') { + protoBadge.style.display = 'none'; + pingStatus.textContent = 'connected'; + pingStatus.style.color = '#4CAF50'; + } else { + protoBadge.textContent = cfg.protocolType; + protoBadge.className = 'proto-badge ' + cfg.protocolType; + protoBadge.style.display = ''; + pingStatus.textContent = cfg.protocolType; + pingStatus.style.color = '#666'; + latencyStatus.textContent = ''; + } +} + +// Update just ping badge for one config (from ping-update event) +function updatePingBadge(id, pingText, pingColor) { + const cfg = configs.find(c => c.id === id); + if (cfg) { + cfg.ping = pingText; + cfg.pingColor = pingColor; + } + + const item = configList.querySelector(`[data-id="${id}"]`); + if (!item) return; + + let badge = item.querySelector('.config-ping'); + if (!pingText) { + if (badge) badge.remove(); + return; + } + if (!badge) { + badge = document.createElement('span'); + badge.className = 'config-ping'; + const proto = item.querySelector('.config-proto'); + item.insertBefore(badge, proto); + } + badge.textContent = pingText; + badge.style.borderColor = pingColor; + badge.style.color = pingColor; +} + +// ── Context menu ────────────────────────────────────────────────────────────── +function openCtxMenu(id, anchor) { + ctxTargetID = id; + ctxDelete.textContent = 'Delete'; + ctxDelete.className = 'ctx-item danger'; + if (deleteConfirmTimeout) { clearTimeout(deleteConfirmTimeout); deleteConfirmTimeout = null; } + + ctxMenu.classList.remove('hidden'); + const rect = anchor.getBoundingClientRect(); + ctxMenu.style.top = rect.bottom + 4 + 'px'; + ctxMenu.style.left = Math.min(rect.left, window.innerWidth - 150) + 'px'; +} + +function closeCtxMenu() { + ctxMenu.classList.add('hidden'); + ctxTargetID = null; +} + +ctxCopyLink.addEventListener('click', () => { + const cfg = configs.find(c => c.id === ctxTargetID); + if (cfg) { + navigator.clipboard.writeText(cfg.link); + showToast('Link copied!', false); + } + closeCtxMenu(); +}); + +ctxDelete.addEventListener('click', () => { + if (ctxDelete.textContent !== 'Are you sure?') { + ctxDelete.textContent = 'Are you sure?'; + ctxDelete.className = 'ctx-item danger'; + deleteConfirmTimeout = setTimeout(() => { + ctxDelete.textContent = 'Delete'; + deleteConfirmTimeout = null; + }, 3000); + return; + } + const id = ctxTargetID; + closeCtxMenu(); + DeleteConfig(id); + configs = configs.filter(c => c.id !== id); + if (selectedID === id) { + selectedID = configs.length > 0 ? configs[0].id : ''; + if (selectedID) SetSelectedConfig(selectedID); + } + renderConfigs(); +}); + +document.addEventListener('click', e => { + if (!ctxMenu.contains(e.target)) closeCtxMenu(); +}); + +// ── Link input panel ────────────────────────────────────────────────────────── +addLinkBtn.addEventListener('click', () => { + linkPanelOpen = !linkPanelOpen; + linkInputPanel.classList.toggle('hidden', !linkPanelOpen); + if (linkPanelOpen) linkInputBox.focus(); +}); + +submitLinkBtn.addEventListener('click', submitLink); +linkInputBox.addEventListener('keydown', e => { if (e.key === 'Enter') submitLink(); }); + +async function submitLink() { + const link = linkInputBox.value.trim(); + if (!link) { showToast('Enter a link', true); return; } + + const err = await AddConfig(link); + if (err) { showToast(err, true); return; } + + linkInputBox.value = ''; + linkPanelOpen = false; + linkInputPanel.classList.add('hidden'); + + const fresh = await GetConfigs(); + configs = fresh; + // Select newly added + if (fresh.length > 0) selectConfig(fresh[fresh.length - 1].id); + renderConfigs(); + showToast('Configuration added', false); +} + +addClipboardBtn.addEventListener('click', async () => { + try { + const text = await navigator.clipboard.readText(); + if (!text) { showToast('Clipboard is empty', true); return; } + const err = await AddConfig(text.trim()); + if (err) { showToast(err, true); return; } + + const fresh = await GetConfigs(); + configs = fresh; + if (fresh.length > 0) selectConfig(fresh[fresh.length - 1].id); + renderConfigs(); + showToast('Configuration added', false); + } catch { + showToast('Clipboard access denied', true); + } +}); + +// ── Connect / Disconnect ────────────────────────────────────────────────────── +connectBtn.addEventListener('click', () => { + if (!selectedID) return; + Connect(selectedID); +}); + +disconnectBtn.addEventListener('click', () => { + Disconnect(); +}); + +// ── Ping all ────────────────────────────────────────────────────────────────── +pingAllBtn.addEventListener('click', () => { + pingAllBtn.disabled = true; + PingAll(); + setTimeout(() => { pingAllBtn.disabled = false; }, 5000); +}); + +// ── Log panel ───────────────────────────────────────────────────────────────── +logBtn.addEventListener('click', () => { + logExpanded = !logExpanded; + logCol.classList.toggle('hidden', !logExpanded); + applyWindowSize(); +}); + +copyLogsBtn.addEventListener('click', () => { + navigator.clipboard.writeText(logArea.innerText); + copyLogsBtn.textContent = 'Copied!'; + setTimeout(() => { copyLogsBtn.textContent = 'Copy'; }, 1500); +}); + +clearLogsBtn.addEventListener('click', () => { logArea.innerHTML = ''; }); + +function appendLog(source, message) { + const line = document.createElement('div'); + line.className = 'log-line'; + const isErr = /\bERROR\b|\bFATAL\b|\[ERR\]/.test(message) && !/\bINFO\b|\bWARN\b/.test(message); + if (isErr) line.classList.add('err'); + const ts = new Date().toLocaleTimeString(); + line.textContent = `[${ts}] [${source}] ${message}`; + logArea.appendChild(line); + logArea.scrollTop = logArea.scrollHeight; +} + +// ── Settings panel ──────────────────────────────────────────────────────────── +settingsBtn.addEventListener('click', () => { + settingsExpanded = !settingsExpanded; + settingsPanel.classList.toggle('hidden', !settingsExpanded); + settingsChevron.setAttribute('d', settingsExpanded ? 'M1,2 L6,7 L11,2' : 'M2,1 L7,6 L2,11'); + applyWindowSize(); +}); + +function saveSettings() { + SaveSettings(runOnStartup.checked, autoConnect.checked, autoReconnect.checked); +} +runOnStartup.addEventListener('change', saveSettings); +autoConnect.addEventListener('change', saveSettings); +autoReconnect.addEventListener('change', saveSettings); + +// ── Window controls ─────────────────────────────────────────────────────────── +minimizeBtn.addEventListener('click', () => HideWindow()); +exitBtn.addEventListener('click', () => Quit()); + +// ── Toast ───────────────────────────────────────────────────────────────────── +let toastTimeout; +function showToast(msg, isError) { + clearTimeout(toastTimeout); + toast.textContent = msg; + toast.className = 'toast' + (isError ? '' : ' green'); + // force reflow + toast.offsetHeight; + toast.classList.add('show'); + toastTimeout = setTimeout(() => toast.classList.remove('show'), 3000); +} + +// ── Wails events ────────────────────────────────────────────────────────────── +EventsOn('state-changed', state => { + updateStateUI(state); + updateStatusCard(); +}); + +EventsOn('log', (source, message) => { + appendLog(source, message); +}); + +EventsOn('ping-update', (id, pingText, pingColor) => { + updatePingBadge(id, pingText, pingColor); + if (id === selectedID) updateStatusCard(); +}); + +EventsOn('toast', (msg, isError) => showToast(msg, isError)); + +// ── Custom notifications ────────────────────────────────────────────────────── +const notifStack = document.getElementById('notifStack'); + +function showNotification(message, dotColor) { + const item = document.createElement('div'); + item.className = 'notif-item'; + + const dot = document.createElement('span'); + dot.className = 'notif-dot'; + dot.style.background = dotColor; + + const text = document.createElement('span'); + text.className = 'notif-text'; + text.textContent = message; + + item.appendChild(dot); + item.appendChild(text); + notifStack.appendChild(item); + + // Auto-dismiss after 4s + setTimeout(() => { + item.classList.add('out'); + item.addEventListener('animationend', () => item.remove()); + }, 4000); +} + +EventsOn('notification', (msg, dotColor) => showNotification(msg, dotColor)); + +// ── Init ────────────────────────────────────────────────────────────────────── +async function init() { + try { + configs = await GetConfigs() || []; + const state = await GetState(); + selectedID = await GetSelectedConfigID(); + + const settings = await GetSettings(); + runOnStartup.checked = settings.RunOnStartup; + autoConnect.checked = settings.AutoConnect; + autoReconnect.checked = settings.AutoReconnect; + + renderConfigs(); + updateStateUI(state); + } catch (e) { + console.error('init error:', e); + // Retry after a short delay + setTimeout(init, 500); + } +} + +// Wait for Wails runtime to be fully ready before calling Go bindings +document.addEventListener('wails:loaded', () => init()); +// Fallback in case the event already fired +if (window?.go?.main?.App?.GetConfigs) { + init(); +} diff --git a/frontend/src/style.css b/frontend/src/style.css new file mode 100644 index 0000000..bdf83bc --- /dev/null +++ b/frontend/src/style.css @@ -0,0 +1,494 @@ +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + +body { + font-family: "Segoe UI", Inter, Roboto, sans-serif; + background: #1e1e1e; + color: #dedede; + height: 100vh; + overflow: hidden; + user-select: none; +} + +#app { + display: flex; + height: 100vh; + border: 1px solid #333; + border-radius: 8px; + overflow: hidden; +} + +/* ── Titlebar ── */ +.titlebar { + position: fixed; + top: 0; left: 0; right: 0; + height: 30px; + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 8px 0 15px; + z-index: 100; + background: transparent; + --wails-draggable: drag; +} +.titlebar-title { + font-size: 12px; + font-weight: 600; + color: #666; + pointer-events: none; +} +.titlebar-sub { font-weight: 400; } +.titlebar-actions { display: flex; gap: 2px; --wails-draggable: no-drag; } + +/* ── Main column ── */ +#mainCol { + width: 360px; + flex-shrink: 0; + display: flex; + flex-direction: column; + padding: 40px 15px 15px; + overflow: hidden; +} + +/* ── Status card ── */ +.status-card { + background: #2b2b2b; + border-radius: 8px; + padding: 14px 15px; + flex-shrink: 0; +} +.status-top { + display: flex; + align-items: center; + justify-content: center; + gap: 10px; + margin-bottom: 8px; +} +.state-dot { + width: 14px; height: 14px; + border-radius: 50%; + flex-shrink: 0; + transition: background 0.3s; +} +.state-dot.disconnected { background: #E53935; } +.state-dot.connecting { background: #FDD835; } +.state-dot.connected { background: #4CAF50; } +.state-dot.disconnecting{ background: #FDD835; } +.state-dot.error { background: #b71c1c; } + +.state-text { + font-size: 18px; + font-weight: 700; + color: #fff; +} +.status-name { + text-align: center; + font-size: 12px; + color: #aaa; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 100%; +} +.status-meta { + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + margin-top: 4px; + min-height: 18px; +} +.ping-status { + font-size: 11px; + font-weight: 600; + color: #666; +} +.latency-status { + font-size: 11px; + font-weight: 600; + color: #4CAF50; +} +.proto-badge { + font-size: 10px; + font-weight: 700; + padding: 2px 6px; + border-radius: 4px; + border: 1px solid #888; + color: #888; + line-height: 1; +} +.proto-badge.VLESS { border-color: #00E676; color: #00E676; } +.proto-badge.TROJAN { border-color: #00BFFF; color: #00BFFF; } + +/* ── Section header ── */ +.section-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 5px; + flex-shrink: 0; +} +.section-title { + font-size: 12px; + font-weight: 700; + color: #555; + text-transform: uppercase; + letter-spacing: 0.5px; +} +.header-actions { display: flex; gap: 2px; } + +/* ── Link input panel ── */ +.link-input-panel { + display: flex; + gap: 6px; + margin-bottom: 8px; + flex-shrink: 0; +} +.link-input-panel.hidden { display: none; } +.link-input-panel input { + flex: 1; + background: #222; + border: none; + border-radius: 4px; + padding: 6px 10px; + color: #ccc; + font-size: 12px; + font-family: "Consolas", monospace; + outline: none; +} +.add-btn { + background: #4CAF50; + color: #fff; + border: none; + border-radius: 4px; + width: 30px; + font-size: 18px; + font-weight: 700; + cursor: pointer; + transition: opacity 0.15s; + line-height: 1; +} +.add-btn:hover { opacity: 0.8; } + +/* ── Config list ── */ +.config-list { + flex: 1; + overflow-y: auto; + overflow-x: hidden; + scrollbar-width: thin; + scrollbar-color: #555 transparent; + min-height: 0; +} +.config-list::-webkit-scrollbar { width: 6px; } +.config-list::-webkit-scrollbar-thumb { background: #555; border-radius: 3px; } +.config-list::-webkit-scrollbar-track { background: transparent; } + +.empty-state { + text-align: center; + color: #555; + font-size: 13px; + line-height: 1.6; + margin: 20px; +} + +.config-item { + display: flex; + align-items: center; + gap: 6px; + padding: 8px; + border-radius: 4px; + cursor: pointer; + transition: background 0.12s; +} +.config-item:hover { background: #2a2a2a; } +.config-item.selected { background: #2f2f2f; } + +.config-name { + flex: 1; + font-size: 14px; + font-weight: 600; + color: #eee; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.config-ping { + font-size: 10px; + font-weight: 700; + padding: 2px 5px; + border-radius: 4px; + border: 1px solid; + line-height: 1; + flex-shrink: 0; + white-space: nowrap; +} +.config-proto { + font-size: 10px; + font-weight: 700; + padding: 2px 6px; + border-radius: 4px; + border: 1px solid #888; + color: #888; + line-height: 1; + flex-shrink: 0; +} +.config-proto.VLESS { border-color: #00E676; color: #00E676; } +.config-proto.TROJAN { border-color: #00BFFF; color: #00BFFF; } + +.config-menu-btn { + background: transparent; + border: none; + color: #888; + font-size: 16px; + font-weight: 700; + width: 24px; + height: 24px; + border-radius: 4px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + transition: background 0.12s, color 0.12s; +} +.config-menu-btn:hover { background: #333; color: #fff; } + +/* ── Bottom bar ── */ +.bottom-bar { + display: flex; + align-items: center; + justify-content: space-between; + margin-top: 15px; + flex-shrink: 0; +} +.bottom-left { display: flex; align-items: center; gap: 8px; } +.bottom-right { display: flex; align-items: center; } + +/* ── Buttons ── */ +.icon-btn { + background: transparent; + border: none; + color: #777; + padding: 5px; + border-radius: 4px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: background 0.12s, color 0.12s; + line-height: 1; +} +.icon-btn:hover { background: #333; color: #fff; } +.icon-btn.small { font-size: 11px; padding: 3px 7px; } + +.solid-btn { + border: none; + color: #fff; + font-size: 13px; + font-weight: 500; + padding: 8px 15px; + border-radius: 6px; + cursor: pointer; + transition: opacity 0.15s; +} +.solid-btn:hover { opacity: 0.8; } +.solid-btn:disabled{ opacity: 0.4; cursor: not-allowed; } +.solid-btn.green { background: #4CAF50; } +.solid-btn.red { background: #E53935; } +.solid-btn.gray { background: #404040; } + +.exit-btn { + background: transparent; + border: 1px solid #E53935; + color: #E53935; + font-size: 12px; + font-weight: 600; + padding: 4px 12px; + border-radius: 4px; + cursor: pointer; + transition: background 0.15s; +} +.exit-btn:hover { background: #E5393520; } + +/* ── Settings panel ── */ +.settings-panel { flex-shrink: 0; margin-top: 15px; } +.settings-panel.hidden { display: none; } + +.settings-card { + background: #2b2b2b; + border-radius: 8px; + padding: 15px; + display: flex; + flex-direction: column; + gap: 12px; +} +.toggle-row { + display: flex; + align-items: center; + gap: 10px; + cursor: pointer; +} +.toggle-row input[type="checkbox"] { display: none; } +.toggle-wrap { flex-shrink: 0; } +.toggle-track { + display: block; + width: 40px; height: 20px; + background: #4d4d4d; + border-radius: 10px; + position: relative; + transition: background 0.2s; +} +.toggle-knob { + display: block; + width: 16px; height: 16px; + background: #fff; + border-radius: 50%; + position: absolute; + top: 2px; left: 2px; + transition: left 0.2s; +} +input[type="checkbox"]:checked ~ .toggle-track { background: #4CAF50; } +input[type="checkbox"]:checked ~ .toggle-track .toggle-knob { left: 22px; } +.toggle-label { font-size: 13px; color: #dedede; } + +/* ── Log sidebar ── */ +.log-col { + width: 360px; + flex-shrink: 0; + border-left: 1px solid #333; + background: #161616; + display: flex; + flex-direction: column; + padding: 40px 15px 15px; +} +.log-col.hidden { display: none; } + +.log-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 10px; + flex-shrink: 0; +} +.log-area { + flex: 1; + overflow-y: auto; + font-family: "Consolas", "Courier New", monospace; + font-size: 11px; + line-height: 1.6; + color: #a1e7b2; + user-select: text; + scrollbar-width: thin; + scrollbar-color: #444 transparent; + word-break: break-all; +} +.log-area::-webkit-scrollbar { width: 6px; } +.log-area::-webkit-scrollbar-thumb { background: #444; border-radius: 3px; } + +.log-line { white-space: pre-wrap; } +.log-line.err { color: #ef9a9a; } + +/* ── Toast ── */ +.toast { + position: fixed; + top: 50px; + left: 50%; + transform: translateX(-50%) translateY(-10px); + background: #b71c1c; + color: #fff; + font-size: 13px; + padding: 10px 16px; + border-radius: 8px; + z-index: 200; + pointer-events: none; + opacity: 0; + transition: opacity 0.2s, transform 0.3s; + white-space: nowrap; + max-width: 300px; + text-align: center; +} +.toast.show { + opacity: 1; + transform: translateX(-50%) translateY(0); +} +.toast.green { background: #2e7d32; } + +/* ── Context menu ── */ +.ctx-menu { + position: fixed; + background: #252525; + border: 1px solid #3a3a3a; + border-radius: 6px; + padding: 3px; + z-index: 300; + min-width: 130px; +} +.ctx-menu.hidden { display: none; } +.ctx-item { + padding: 6px 12px; + font-size: 13px; + color: #ddd; + border-radius: 4px; + cursor: pointer; + transition: background 0.1s; +} +.ctx-item:hover { background: #3a3a3a; color: #fff; } +.ctx-item.danger { color: #E53935; } +.ctx-item.danger:hover { background: #3a1a1a; } +.ctx-separator { + height: 1px; + background: #3a3a3a; + margin: 3px 5px; +} + +/* ── Custom notifications ── */ +.notif-stack { + position: fixed; + bottom: 16px; + left: 16px; + display: flex; + flex-direction: column-reverse; + gap: 8px; + z-index: 500; + pointer-events: none; +} +.notif-item { + display: flex; + align-items: center; + gap: 10px; + background: #161b2e; + border: 1px solid #252d45; + border-radius: 10px; + padding: 10px 18px; + min-width: 200px; + max-width: 320px; + pointer-events: auto; + opacity: 0; + transform: translateY(12px); + animation: notifIn 0.3s ease forwards; +} +.notif-item.out { + animation: notifOut 0.3s ease forwards; +} +.notif-dot { + width: 10px; + height: 10px; + border-radius: 50%; + flex-shrink: 0; +} +.notif-text { + font-size: 13px; + font-weight: 500; + color: #e0e0e0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +@keyframes notifIn { + from { opacity: 0; transform: translateY(12px); } + to { opacity: 1; transform: translateY(0); } +} +@keyframes notifOut { + from { opacity: 1; transform: translateY(0); } + to { opacity: 0; transform: translateY(-8px); } +} diff --git a/frontend/vite.config.js b/frontend/vite.config.js new file mode 100644 index 0000000..809c732 --- /dev/null +++ b/frontend/vite.config.js @@ -0,0 +1,11 @@ +import { defineConfig } from 'vite' + +export default defineConfig({ + build: { + outDir: 'dist', + emptyOutDir: true, + rollupOptions: { + input: 'index.html' + } + } +}) diff --git a/frontend/wailsjs/go/main/App.d.ts b/frontend/wailsjs/go/main/App.d.ts new file mode 100644 index 0000000..3f73abf --- /dev/null +++ b/frontend/wailsjs/go/main/App.d.ts @@ -0,0 +1,30 @@ +// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL +// This file is automatically generated. DO NOT EDIT +import {main} from '../models'; +import {core} from '../models'; + +export function AddConfig(arg1:string):Promise; + +export function Connect(arg1:string):Promise; + +export function DeleteConfig(arg1:string):Promise; + +export function Disconnect():Promise; + +export function GetConfigs():Promise>; + +export function GetSelectedConfigID():Promise; + +export function GetSettings():Promise; + +export function GetState():Promise; + +export function HideWindow():Promise; + +export function PingAll():Promise; + +export function ResizeWindow(arg1:number,arg2:number):Promise; + +export function SaveSettings(arg1:boolean,arg2:boolean,arg3:boolean):Promise; + +export function SetSelectedConfig(arg1:string):Promise; diff --git a/frontend/wailsjs/go/main/App.js b/frontend/wailsjs/go/main/App.js new file mode 100644 index 0000000..b1ac78a --- /dev/null +++ b/frontend/wailsjs/go/main/App.js @@ -0,0 +1,55 @@ +// @ts-check +// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL +// This file is automatically generated. DO NOT EDIT + +export function AddConfig(arg1) { + return window['go']['main']['App']['AddConfig'](arg1); +} + +export function Connect(arg1) { + return window['go']['main']['App']['Connect'](arg1); +} + +export function DeleteConfig(arg1) { + return window['go']['main']['App']['DeleteConfig'](arg1); +} + +export function Disconnect() { + return window['go']['main']['App']['Disconnect'](); +} + +export function GetConfigs() { + return window['go']['main']['App']['GetConfigs'](); +} + +export function GetSelectedConfigID() { + return window['go']['main']['App']['GetSelectedConfigID'](); +} + +export function GetSettings() { + return window['go']['main']['App']['GetSettings'](); +} + +export function GetState() { + return window['go']['main']['App']['GetState'](); +} + +export function HideWindow() { + return window['go']['main']['App']['HideWindow'](); +} + +export function PingAll() { + return window['go']['main']['App']['PingAll'](); +} + +export function ResizeWindow(arg1, arg2) { + return window['go']['main']['App']['ResizeWindow'](arg1, arg2); +} + +export function SaveSettings(arg1, arg2, arg3) { + return window['go']['main']['App']['SaveSettings'](arg1, arg2, arg3); +} + +export function SetSelectedConfig(arg1) { + return window['go']['main']['App']['SetSelectedConfig'](arg1); +} diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts new file mode 100644 index 0000000..0db8a36 --- /dev/null +++ b/frontend/wailsjs/go/models.ts @@ -0,0 +1,50 @@ +export namespace core { + + export class AppSettings { + RunOnStartup: boolean; + AutoConnect: boolean; + AutoReconnect: boolean; + LastConfigId?: string; + + static createFrom(source: any = {}) { + return new AppSettings(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.RunOnStartup = source["RunOnStartup"]; + this.AutoConnect = source["AutoConnect"]; + this.AutoReconnect = source["AutoReconnect"]; + this.LastConfigId = source["LastConfigId"]; + } + } + +} + +export namespace main { + + export class VpnConfigItem { + id: string; + name: string; + link: string; + protocolType: string; + ping: string; + pingColor: string; + + static createFrom(source: any = {}) { + return new VpnConfigItem(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.id = source["id"]; + this.name = source["name"]; + this.link = source["link"]; + this.protocolType = source["protocolType"]; + this.ping = source["ping"]; + this.pingColor = source["pingColor"]; + } + } + +} + diff --git a/frontend/wailsjs/runtime/package.json b/frontend/wailsjs/runtime/package.json new file mode 100644 index 0000000..1e7c8a5 --- /dev/null +++ b/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 ", + "license": "MIT", + "bugs": { + "url": "https://github.com/wailsapp/wails/issues" + }, + "homepage": "https://github.com/wailsapp/wails#readme" +} diff --git a/frontend/wailsjs/runtime/runtime.d.ts b/frontend/wailsjs/runtime/runtime.d.ts new file mode 100644 index 0000000..3bbea84 --- /dev/null +++ b/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; + +// [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; + +// [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; + +// [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; + +// [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; + +// [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; + +// [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; + +// [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; + +// [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; + +// [ClipboardSetText](https://wails.io/docs/reference/runtime/clipboard#clipboardsettext) +// Sets a text on the clipboard +export function ClipboardSetText(text: string): Promise; + +// [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; + +// [CleanupNotifications](https://wails.io/docs/reference/runtime/notification#cleanupnotifications) +// Cleans up notification resources and releases any held connections. +export function CleanupNotifications(): Promise; + +// [IsNotificationAvailable](https://wails.io/docs/reference/runtime/notification#isnotificationavailable) +// Checks if notifications are available on the current platform. +export function IsNotificationAvailable(): Promise; + +// [RequestNotificationAuthorization](https://wails.io/docs/reference/runtime/notification#requestnotificationauthorization) +// Requests notification authorization from the user (macOS only). +export function RequestNotificationAuthorization(): Promise; + +// [CheckNotificationAuthorization](https://wails.io/docs/reference/runtime/notification#checknotificationauthorization) +// Checks the current notification authorization status (macOS only). +export function CheckNotificationAuthorization(): Promise; + +// [SendNotification](https://wails.io/docs/reference/runtime/notification#sendnotification) +// Sends a basic notification with the given options. +export function SendNotification(options: NotificationOptions): Promise; + +// [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; + +// [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; + +// [RemoveNotificationCategory](https://wails.io/docs/reference/runtime/notification#removenotificationcategory) +// Removes a previously registered notification category. +export function RemoveNotificationCategory(categoryId: string): Promise; + +// [RemoveAllPendingNotifications](https://wails.io/docs/reference/runtime/notification#removeallpendingnotifications) +// Removes all pending notifications from the notification center. +export function RemoveAllPendingNotifications(): Promise; + +// [RemovePendingNotification](https://wails.io/docs/reference/runtime/notification#removependingnotification) +// Removes a specific pending notification by its identifier. +export function RemovePendingNotification(identifier: string): Promise; + +// [RemoveAllDeliveredNotifications](https://wails.io/docs/reference/runtime/notification#removealldeliverednotifications) +// Removes all delivered notifications from the notification center. +export function RemoveAllDeliveredNotifications(): Promise; + +// [RemoveDeliveredNotification](https://wails.io/docs/reference/runtime/notification#removedeliverednotification) +// Removes a specific delivered notification by its identifier. +export function RemoveDeliveredNotification(identifier: string): Promise; + +// [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; \ No newline at end of file diff --git a/frontend/wailsjs/runtime/runtime.js b/frontend/wailsjs/runtime/runtime.js new file mode 100644 index 0000000..556621e --- /dev/null +++ b/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/go.mod b/go.mod new file mode 100644 index 0000000..af9bff8 --- /dev/null +++ b/go.mod @@ -0,0 +1,49 @@ +module kettuRay + +go 1.26.1 + +require ( + github.com/getlantern/systray v1.2.2 + github.com/go-toast/toast v0.0.0-20190211030409-01e6764cf0a4 + github.com/google/uuid v1.6.0 + github.com/wailsapp/wails/v2 v2.12.0 + golang.org/x/sys v0.30.0 +) + +require ( + git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 // indirect + github.com/bep/debounce v1.2.1 // indirect + github.com/getlantern/context v0.0.0-20190109183933-c447772a6520 // indirect + github.com/getlantern/errors v0.0.0-20190325191628-abdb3e3e36f7 // indirect + github.com/getlantern/golog v0.0.0-20190830074920-4ef2e798c2d7 // indirect + github.com/getlantern/hex v0.0.0-20190417191902-c6586a6fe0b7 // indirect + github.com/getlantern/hidden v0.0.0-20190325191715-f02dbb02be55 // indirect + github.com/getlantern/ops v0.0.0-20190325191751-d70cb0d6f85f // indirect + github.com/go-ole/go-ole v1.3.0 // indirect + github.com/go-stack/stack v1.8.0 // indirect + github.com/godbus/dbus/v5 v5.1.0 // indirect + github.com/gorilla/websocket v1.5.3 // indirect + github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect + github.com/labstack/echo/v4 v4.13.3 // indirect + github.com/labstack/gommon v0.4.2 // indirect + github.com/leaanthony/go-ansi-parser v1.6.1 // indirect + github.com/leaanthony/gosod v1.0.4 // indirect + github.com/leaanthony/slicer v1.6.0 // indirect + github.com/leaanthony/u v1.1.1 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d // indirect + github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c // indirect + github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/samber/lo v1.49.1 // indirect + github.com/tkrajina/go-reflector v0.5.8 // indirect + github.com/valyala/bytebufferpool v1.0.0 // indirect + github.com/valyala/fasttemplate v1.2.2 // indirect + github.com/wailsapp/go-webview2 v1.0.22 // indirect + github.com/wailsapp/mimetype v1.4.1 // indirect + golang.org/x/crypto v0.33.0 // indirect + golang.org/x/net v0.35.0 // indirect + golang.org/x/text v0.22.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..53ec94c --- /dev/null +++ b/go.sum @@ -0,0 +1,113 @@ +git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 h1:N3IGoHHp9pb6mj1cbXbuaSXV/UMKwmbKLf53nQmtqMA= +git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3/go.mod h1:QtOLZGz8olr4qH2vWK0QH0w0O4T9fEIjMuWpKUsH7nc= +github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY= +github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/getlantern/context v0.0.0-20190109183933-c447772a6520 h1:NRUJuo3v3WGC/g5YiyF790gut6oQr5f3FBI88Wv0dx4= +github.com/getlantern/context v0.0.0-20190109183933-c447772a6520/go.mod h1:L+mq6/vvYHKjCX2oez0CgEAJmbq1fbb/oNJIWQkBybY= +github.com/getlantern/errors v0.0.0-20190325191628-abdb3e3e36f7 h1:6uJ+sZ/e03gkbqZ0kUG6mfKoqDb4XMAzMIwlajq19So= +github.com/getlantern/errors v0.0.0-20190325191628-abdb3e3e36f7/go.mod h1:l+xpFBrCtDLpK9qNjxs+cHU6+BAdlBaxHqikB6Lku3A= +github.com/getlantern/golog v0.0.0-20190830074920-4ef2e798c2d7 h1:guBYzEaLz0Vfc/jv0czrr2z7qyzTOGC9hiQ0VC+hKjk= +github.com/getlantern/golog v0.0.0-20190830074920-4ef2e798c2d7/go.mod h1:zx/1xUUeYPy3Pcmet8OSXLbF47l+3y6hIPpyLWoR9oc= +github.com/getlantern/hex v0.0.0-20190417191902-c6586a6fe0b7 h1:micT5vkcr9tOVk1FiH8SWKID8ultN44Z+yzd2y/Vyb0= +github.com/getlantern/hex v0.0.0-20190417191902-c6586a6fe0b7/go.mod h1:dD3CgOrwlzca8ed61CsZouQS5h5jIzkK9ZWrTcf0s+o= +github.com/getlantern/hidden v0.0.0-20190325191715-f02dbb02be55 h1:XYzSdCbkzOC0FDNrgJqGRo8PCMFOBFL9py72DRs7bmc= +github.com/getlantern/hidden v0.0.0-20190325191715-f02dbb02be55/go.mod h1:6mmzY2kW1TOOrVy+r41Za2MxXM+hhqTtY3oBKd2AgFA= +github.com/getlantern/ops v0.0.0-20190325191751-d70cb0d6f85f h1:wrYrQttPS8FHIRSlsrcuKazukx/xqO/PpLZzZXsF+EA= +github.com/getlantern/ops v0.0.0-20190325191751-d70cb0d6f85f/go.mod h1:D5ao98qkA6pxftxoqzibIBBrLSUli+kYnJqrgBf9cIA= +github.com/getlantern/systray v1.2.2 h1:dCEHtfmvkJG7HZ8lS/sLklTH4RKUcIsKrAD9sThoEBE= +github.com/getlantern/systray v1.2.2/go.mod h1:pXFOI1wwqwYXEhLPm9ZGjS2u/vVELeIgNMY5HvhHhcE= +github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= +github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= +github.com/go-stack/stack v1.8.0 h1:5SgMzNM5HxrEjV0ww2lTmX6E2Izsfxas4+YHWRs3Lsk= +github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= +github.com/go-toast/toast v0.0.0-20190211030409-01e6764cf0a4 h1:qZNfIGkIANxGv/OqtnntR4DfOY2+BgwR60cAcu/i3SE= +github.com/go-toast/toast v0.0.0-20190211030409-01e6764cf0a4/go.mod h1:kW3HQ4UdaAyrUCSSDR4xUzBKW6O2iA4uHhk7AtyYp10= +github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= +github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e h1:Q3+PugElBCf4PFpxhErSzU3/PY5sFL5Z6rfv4AbGAck= +github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs= +github.com/labstack/echo/v4 v4.13.3 h1:pwhpCPrTl5qry5HRdM5FwdXnhXSLSY+WE+YQSeCaafY= +github.com/labstack/echo/v4 v4.13.3/go.mod h1:o90YNEeQWjDozo584l7AwhJMHN0bOC4tAfg+Xox9q5g= +github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0= +github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU= +github.com/leaanthony/debme v1.2.1 h1:9Tgwf+kjcrbMQ4WnPcEIUcQuIZYqdWftzZkBr+i/oOc= +github.com/leaanthony/debme v1.2.1/go.mod h1:3V+sCm5tYAgQymvSOfYQ5Xx2JCr+OXiD9Jkw3otUjiA= +github.com/leaanthony/go-ansi-parser v1.6.1 h1:xd8bzARK3dErqkPFtoF9F3/HgN8UQk0ed1YDKpEz01A= +github.com/leaanthony/go-ansi-parser v1.6.1/go.mod h1:+vva/2y4alzVmmIEpk9QDhA7vLC5zKDTRwfZGOp3IWU= +github.com/leaanthony/gosod v1.0.4 h1:YLAbVyd591MRffDgxUOU1NwLhT9T1/YiwjKZpkNFeaI= +github.com/leaanthony/gosod v1.0.4/go.mod h1:GKuIL0zzPj3O1SdWQOdgURSuhkF+Urizzxh26t9f1cw= +github.com/leaanthony/slicer v1.6.0 h1:1RFP5uiPJvT93TAHi+ipd3NACobkW53yUiBqZheE/Js= +github.com/leaanthony/slicer v1.6.0/go.mod h1:o/Iz29g7LN0GqH3aMjWAe90381nyZlDNquK+mtH2Fj8= +github.com/leaanthony/u v1.1.1 h1:TUFjwDGlNX+WuwVEzDqQwC2lOv0P4uhTQw7CMFdiK7M= +github.com/leaanthony/u v1.1.1/go.mod h1:9+o6hejoRljvZ3BzdYlVL0JYCwtnAsVuN9pVTQcaRfI= +github.com/lxn/walk v0.0.0-20210112085537-c389da54e794/go.mod h1:E23UucZGqpuUANJooIbHWCufXvOcT6E7Stq81gU+CSQ= +github.com/lxn/win v0.0.0-20210218163916-a377121e959e/go.mod h1:KxxjdtRkfNoYDCUP5ryK7XJJNTnpC8atvtmTheChOtk= +github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU= +github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ= +github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d h1:VhgPp6v9qf9Agr/56bj7Y/xa04UccTW04VP0Qed4vnQ= +github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d/go.mod h1:YUTz3bUH2ZwIWBy3CJBeOBEugqcmXREj14T+iG/4k4U= +github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c h1:rp5dCmg/yLR3mgFuSOe4oEnDDmGLROTvMragMUXpTQw= +github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c/go.mod h1:X07ZCGwUbLaax7L0S3Tw4hpejzu63ZrrQiUe6W0hcy0= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/samber/lo v1.49.1 h1:4BIFyVfuQSEpluc7Fua+j1NolZHiEHEpaSEKdsH0tew= +github.com/samber/lo v1.49.1/go.mod h1:dO6KHFzUKXgP8LDhU0oI8d2hekjXnGOu0DB8Jecxd6o= +github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966/go.mod h1:sUM3LWHvSMaG192sy56D9F7CNvL7jUJVXoqM1QKLnog= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/tkrajina/go-reflector v0.5.8 h1:yPADHrwmUbMq4RGEyaOUpz2H90sRsETNVpjzo3DLVQQ= +github.com/tkrajina/go-reflector v0.5.8/go.mod h1:ECbqLgccecY5kPmPmXg1MrHW585yMcDkVl6IvJe64T4= +github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo= +github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= +github.com/wailsapp/go-webview2 v1.0.22 h1:YT61F5lj+GGaat5OB96Aa3b4QA+mybD0Ggq6NZijQ58= +github.com/wailsapp/go-webview2 v1.0.22/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc= +github.com/wailsapp/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhwHs= +github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o= +github.com/wailsapp/wails/v2 v2.12.0 h1:BHO/kLNWFHYjCzucxbzAYZWUjub1Tvb4cSguQozHn5c= +github.com/wailsapp/wails/v2 v2.12.0/go.mod h1:mo1bzK1DEJrobt7YrBjgxvb5Sihb1mhAY09hppbibQg= +golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus= +golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M= +golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8= +golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk= +golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201018230417-eeed37f84f13/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= +golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= +golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +gopkg.in/Knetic/govaluate.v3 v3.0.0/go.mod h1:csKLBORsPbafmSCGTEh3U7Ozmsuq8ZSIlKk1bcqph0E= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/hotkey_windows.go b/hotkey_windows.go new file mode 100644 index 0000000..6781cfb --- /dev/null +++ b/hotkey_windows.go @@ -0,0 +1,61 @@ +//go:build windows + +package main + +import ( + goruntime "runtime" + "unsafe" + + "golang.org/x/sys/windows" +) + +const ( + wmHotkey = 0x0312 + modWin = 0x0008 + modAlt = 0x0001 + vkV = 0x56 + hotkeyID = 1 +) + +var ( + user32 = windows.NewLazySystemDLL("user32.dll") + procRegisterHotKey = user32.NewProc("RegisterHotKey") + procGetMessage = user32.NewProc("GetMessageW") + procTranslateMsg = user32.NewProc("TranslateMessage") + procDispatchMsg = user32.NewProc("DispatchMessageW") +) + +type winMSG struct { + HWND uintptr + Message uint32 + WParam uintptr + LParam uintptr + Time uint32 + PtX int32 + PtY int32 +} + +func (a *App) registerHotkey() { + go func() { + goruntime.LockOSThread() + defer goruntime.UnlockOSThread() + + r, _, _ := procRegisterHotKey.Call(0, hotkeyID, modWin|modAlt, vkV) + if r == 0 { + return + } + + var msg winMSG + for { + r, _, _ := procGetMessage.Call(uintptr(unsafe.Pointer(&msg)), 0, 0, 0) + if r == 0 || r == ^uintptr(0) { + break + } + if msg.Message == wmHotkey && msg.WParam == hotkeyID { + a.toggleVPN() + } + procTranslateMsg.Call(uintptr(unsafe.Pointer(&msg))) + procDispatchMsg.Call(uintptr(unsafe.Pointer(&msg))) + } + }() +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..0e00d61 --- /dev/null +++ b/main.go @@ -0,0 +1,45 @@ +package main + +import ( + "embed" + + "github.com/wailsapp/wails/v2" + "github.com/wailsapp/wails/v2/pkg/options" + "github.com/wailsapp/wails/v2/pkg/options/assetserver" + "github.com/wailsapp/wails/v2/pkg/options/windows" +) + +//go:embed all:frontend/dist +var assets embed.FS + +func main() { + app := NewApp() + + err := wails.Run(&options.App{ + Title: "kettuRay", + Width: 360, + Height: 500, + MinWidth: 360, + MinHeight: 400, + Frameless: true, + AssetServer: &assetserver.Options{ + Assets: assets, + }, + OnStartup: app.startup, + OnShutdown: app.shutdown, + Bind: []interface{}{ + app, + }, + Windows: &windows.Options{ + WebviewIsTransparent: true, + WindowIsTranslucent: false, + Theme: windows.Dark, + DisableWindowIcon: false, + }, + DisableResize: true, + }) + + if err != nil { + println("Error:", err.Error()) + } +} diff --git a/notification_windows.go b/notification_windows.go new file mode 100644 index 0000000..d048a6d --- /dev/null +++ b/notification_windows.go @@ -0,0 +1,17 @@ +//go:build windows + +package main + +import "github.com/go-toast/toast" + +// SendNotification shows a Windows Action Center notification. +func SendNotification(title, message string) { + n := toast.Notification{ + AppID: "kettuRay", + Title: title, + Message: message, + Duration: toast.Short, + Audio: toast.Silent, + } + _ = n.Push() +} diff --git a/tray.go b/tray.go new file mode 100644 index 0000000..f11e5b8 --- /dev/null +++ b/tray.go @@ -0,0 +1,185 @@ +package main + +import ( + "bytes" + "image" + "image/color" + "image/png" + + "github.com/getlantern/systray" + "github.com/wailsapp/wails/v2/pkg/runtime" +) + +// Precomputed tray icons +var ( + iconGray = makeVpnIcon(130, 130, 130) + iconGreen = makeVpnIcon(76, 175, 80) + iconYellow = makeVpnIcon(253, 216, 53) + iconRed = makeVpnIcon(229, 57, 53) +) + +func makeVpnIcon(r, g, b uint8) []byte { + const size = 16 + img := image.NewNRGBA(image.Rect(0, 0, size, size)) + bg := color.NRGBA{R: r, G: g, B: b, A: 255} + + type pt struct{ x, y int } + skip := make(map[pt]bool) + + // Make a horizontal pill-shaped rectangle + // Skip rows 0, 1, 14, 15 + for x := 0; x < size; x++ { + skip[pt{x, 0}] = true; skip[pt{x, 1}] = true + skip[pt{x, 14}] = true; skip[pt{x, 15}] = true + } + // Round corners of the remaining rectangle (rows 2-13) + // y=2 and y=13 skip x=0,1, 14,15 + skip[pt{0, 2}] = true; skip[pt{1, 2}] = true; skip[pt{14, 2}] = true; skip[pt{15, 2}] = true + skip[pt{0, 13}] = true; skip[pt{1, 13}] = true; skip[pt{14, 13}] = true; skip[pt{15, 13}] = true + // y=3 and y=12 skip x=0, 15 + skip[pt{0, 3}] = true; skip[pt{15, 3}] = true + skip[pt{0, 12}] = true; skip[pt{15, 12}] = true + + // Draw rounded background + for y := 0; y < size; y++ { + for x := 0; x < size; x++ { + if !skip[pt{x, y}] { + img.SetNRGBA(x, y, bg) + } + } + } + + // Draw "VPN" text in white using pixel font + white := color.NRGBA{255, 255, 255, 255} + // V (x: 2-4, y: 5-9) + img.SetNRGBA(2, 5, white); img.SetNRGBA(4, 5, white) + img.SetNRGBA(2, 6, white); img.SetNRGBA(4, 6, white) + img.SetNRGBA(2, 7, white); img.SetNRGBA(4, 7, white) + img.SetNRGBA(3, 8, white); img.SetNRGBA(3, 9, white) + + // P (x: 6-8, y: 5-9) + for y := 5; y <= 9; y++ { img.SetNRGBA(6, y, white) } + img.SetNRGBA(7, 5, white); img.SetNRGBA(8, 5, white) + img.SetNRGBA(8, 6, white) + img.SetNRGBA(7, 7, white); img.SetNRGBA(8, 7, white) + + // N (x: 10-13, y: 5-9) + for y := 5; y <= 9; y++ { img.SetNRGBA(10, y, white); img.SetNRGBA(13, y, white) } + img.SetNRGBA(11, 6, white) + img.SetNRGBA(11, 7, white); img.SetNRGBA(12, 7, white) + img.SetNRGBA(12, 8, white) + + var buf bytes.Buffer + _ = png.Encode(&buf, img) + + // Wrap PNG in ICO format + pngData := buf.Bytes() + var ico bytes.Buffer + ico.Write([]byte{0, 0, 1, 0, 1, 0}) // ICONDIR + ico.Write([]byte{16, 16, 0, 0, 1, 0, 32, 0}) // ICONDIRENTRY (16x16, 32bpp) + sizeBytes := len(pngData) + ico.WriteByte(byte(sizeBytes & 0xFF)) + ico.WriteByte(byte((sizeBytes >> 8) & 0xFF)) + ico.WriteByte(byte((sizeBytes >> 16) & 0xFF)) + ico.WriteByte(byte((sizeBytes >> 24) & 0xFF)) + ico.Write([]byte{22, 0, 0, 0}) // Image offset + ico.Write(pngData) + + return ico.Bytes() +} + +func (a *App) setupTray() { + go systray.Run(a.onTrayReady, func() {}) +} + +func (a *App) onTrayReady() { + systray.SetIcon(iconGray) + systray.SetTooltip("kettuRay — Disconnected") + + mToggle := systray.AddMenuItem("Connect", "Toggle VPN") + systray.AddSeparator() + mShow := systray.AddMenuItem("Show", "Show window") + systray.AddSeparator() + mQuit := systray.AddMenuItem("Exit", "Exit kettuRay") + + // Store menu item reference for text updates + a.mu.Lock() + a.trayToggleItem = mToggle + a.mu.Unlock() + + go func() { + for { + select { + case <-mToggle.ClickedCh: + a.toggleVPN() + case <-mShow.ClickedCh: + runtime.WindowShow(a.ctx) + case <-mQuit.ClickedCh: + runtime.Quit(a.ctx) + } + } + }() +} + +func (a *App) updateTrayState(state string) { + switch state { + case "Connected": + systray.SetIcon(iconGreen) + systray.SetTooltip("kettuRay — Connected") + a.mu.Lock() + if a.trayToggleItem != nil { + a.trayToggleItem.SetTitle("Disconnect") + } + a.mu.Unlock() + case "Connecting": + systray.SetIcon(iconYellow) + systray.SetTooltip("kettuRay — Connecting...") + a.mu.Lock() + if a.trayToggleItem != nil { + a.trayToggleItem.SetTitle("Connecting...") + } + a.mu.Unlock() + case "Disconnecting": + systray.SetIcon(iconYellow) + systray.SetTooltip("kettuRay — Disconnecting...") + case "Error": + systray.SetIcon(iconRed) + systray.SetTooltip("kettuRay — Error") + a.mu.Lock() + if a.trayToggleItem != nil { + a.trayToggleItem.SetTitle("Connect") + } + a.mu.Unlock() + default: // Disconnected + systray.SetIcon(iconGray) + systray.SetTooltip("kettuRay — Disconnected") + a.mu.Lock() + if a.trayToggleItem != nil { + a.trayToggleItem.SetTitle("Connect") + } + a.mu.Unlock() + } +} + +func (a *App) toggleVPN() { + a.mu.Lock() + state := a.manager.State + id := a.selectedConfigID + a.mu.Unlock() + + switch state { + case 2: // Connected + go a.manager.Disconnect() + case 1: // Connecting + go a.manager.Disconnect() + default: + if id != "" { + go a.connectByID(id) + } + } +} + +// HideWindow hides the app window (minimize to tray). +func (a *App) HideWindow() { + runtime.WindowHide(a.ctx) +} diff --git a/wails.json b/wails.json new file mode 100644 index 0000000..c3c89e7 --- /dev/null +++ b/wails.json @@ -0,0 +1,12 @@ +{ + "$schema": "https://wails.io/schemas/config.v2.json", + "name": "kettuRay", + "outputfilename": "kettuRay", + "frontend:install": "npm install", + "frontend:build": "npm run build", + "frontend:dev:watcher": "npm run dev", + "frontend:dev:serverUrl": "auto", + "author": { + "name": "kettuRay" + } +}