init commit

This commit is contained in:
2026-03-31 14:40:03 +03:00
commit 41c8e186ef
37 changed files with 5420 additions and 0 deletions

44
.gitignore vendored Normal file
View File

@@ -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

404
app.go Normal file
View File

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

BIN
build/appicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

BIN
build/windows/icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

15
build/windows/info.json Normal file
View File

@@ -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}}"
}
}
}

View File

@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1" xmlns:asmv3="urn:schemas-microsoft-com:asm.v3">
<assemblyIdentity type="win32" name="com.wails.{{.Name}}" version="{{.Info.ProductVersion}}.0" processorArchitecture="*"/>
<dependency>
<dependentAssembly>
<assemblyIdentity type="win32" name="Microsoft.Windows.Common-Controls" version="6.0.0.0" processorArchitecture="*" publicKeyToken="6595b64144ccf1df" language="*"/>
</dependentAssembly>
</dependency>
<asmv3:application>
<asmv3:windowsSettings>
<dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true/pm</dpiAware> <!-- fallback for Windows 7 and 8 -->
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">permonitorv2,permonitor</dpiAwareness> <!-- falls back to per-monitor if per-monitor v2 is not supported -->
</asmv3:windowsSettings>
</asmv3:application>
</assembly>

61
core/config_manager.go Normal file
View File

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

431
core/core_manager.go Normal file
View File

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

128
core/models.go Normal file
View File

@@ -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"
}

177
core/parser.go Normal file
View File

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

78
core/ping.go Normal file
View File

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

70
core/process_windows.go Normal file
View File

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

44
core/settings.go Normal file
View File

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

30
core/settings_windows.go Normal file
View File

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

156
core/singbox_config.go Normal file
View File

@@ -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,
}
}

31
core/util.go Normal file
View File

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

177
core/xray_config.go Normal file
View File

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

152
frontend/index.html Normal file
View File

@@ -0,0 +1,152 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>kettuRay</title>
<link rel="stylesheet" href="/src/style.css" />
</head>
<body>
<div id="app">
<!-- Custom title bar -->
<div class="titlebar" data-wails-drag>
<span class="titlebar-title">kettuRay <span class="titlebar-sub">by @khton</span></span>
<div class="titlebar-actions">
<button class="icon-btn" id="minimizeBtn" title="Minimize to tray">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="15 3 21 3 21 9"/><polyline points="9 21 3 21 3 15"/>
<line x1="21" y1="3" x2="14" y2="10"/><line x1="3" y1="21" x2="10" y2="14"/>
</svg>
</button>
</div>
</div>
<!-- Main content column -->
<div id="mainCol">
<!-- Status card -->
<div class="status-card">
<div class="status-top">
<span id="stateDot" class="state-dot disconnected"></span>
<span id="stateText" class="state-text">Disconnected</span>
</div>
<div id="selectedName" class="status-name">not selected</div>
<div class="status-meta">
<span id="protoBadgeStatus" class="proto-badge" style="display:none"></span>
<span id="pingStatus" class="ping-status"></span>
<span id="latencyStatus" class="latency-status"></span>
</div>
</div>
<!-- Configs header -->
<div class="section-header" style="margin-top:18px">
<span class="section-title">Configurations</span>
<div class="header-actions">
<button class="icon-btn" id="pingAllBtn" title="Ping all">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="10" cy="12" r="2"/><path d="M13.4 10.6 L19 5"/><path d="M15.6 2.7a10 10 0 1 0 5.7 5.7"/>
</svg>
</button>
<button class="icon-btn" id="addLinkBtn" title="Add manually">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M15 7h2a5 5 0 0 1 0 10h-2m-6 0H7A5 5 0 0 1 7 7h2"/>
<line x1="8" y1="12" x2="16" y2="12"/>
</svg>
</button>
<button class="icon-btn" id="addClipboardBtn" title="Add from clipboard">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/>
<rect x="8" y="2" width="8" height="4" rx="1" ry="1"/>
</svg>
</button>
</div>
</div>
<!-- Link input panel (hidden by default) -->
<div id="linkInputPanel" class="link-input-panel hidden">
<input type="text" id="linkInputBox" placeholder="vless:// or trojan://..." spellcheck="false" autocomplete="off" />
<button id="submitLinkBtn" class="add-btn">+</button>
</div>
<!-- Config list -->
<div id="configList" class="config-list">
<div id="emptyState" class="empty-state">
No configurations.<br>Copy a link and add it from the clipboard.
</div>
</div>
<!-- Bottom controls -->
<div class="bottom-bar">
<div class="bottom-left">
<button class="icon-btn" id="settingsBtn" title="Settings">
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path id="settingsChevron" d="M2,1 L7,6 L2,11"/>
</svg>
</button>
<button id="connectBtn" class="solid-btn green" style="display:none">Start</button>
<button id="disconnectBtn" class="solid-btn red" style="display:none">Stop</button>
<button id="logBtn" class="solid-btn gray">Log</button>
</div>
<div class="bottom-right">
<button id="exitBtn" class="exit-btn">Exit</button>
</div>
</div>
<!-- Settings panel (hidden by default) -->
<div id="settingsPanel" class="settings-panel hidden">
<div class="settings-card">
<label class="toggle-row">
<span class="toggle-wrap">
<input type="checkbox" id="runOnStartup" />
<span class="toggle-track"><span class="toggle-knob"></span></span>
</span>
<span class="toggle-label">Run on startup</span>
</label>
<label class="toggle-row">
<span class="toggle-wrap">
<input type="checkbox" id="autoConnect" />
<span class="toggle-track"><span class="toggle-knob"></span></span>
</span>
<span class="toggle-label">Auto-connect VPN</span>
</label>
<label class="toggle-row" style="margin-bottom:0">
<span class="toggle-wrap">
<input type="checkbox" id="autoReconnect" />
<span class="toggle-track"><span class="toggle-knob"></span></span>
</span>
<span class="toggle-label">Auto-reconnect</span>
</label>
</div>
</div>
</div>
<!-- Log sidebar -->
<div id="logCol" class="log-col hidden">
<div class="log-header">
<span class="section-title">Logs</span>
<div class="header-actions">
<button class="icon-btn small" id="copyLogsBtn">Copy</button>
<button class="icon-btn small" id="clearLogsBtn">Clear</button>
</div>
</div>
<div id="logArea" class="log-area"></div>
</div>
<!-- Toast notification -->
<div id="toast" class="toast hidden"></div>
<!-- Context menu -->
<div id="ctxMenu" class="ctx-menu hidden">
<div class="ctx-item" id="ctxCopyLink">Copy Link</div>
<div class="ctx-separator"></div>
<div class="ctx-item danger" id="ctxDelete">Delete</div>
</div>
<!-- Notification stack -->
<div id="notifStack" class="notif-stack"></div>
</div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

1143
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

12
frontend/package.json Normal file
View File

@@ -0,0 +1,12 @@
{
"name": "ketturay-frontend",
"private": true,
"version": "0.0.1",
"scripts": {
"dev": "vite",
"build": "vite build"
},
"devDependencies": {
"vite": "^6.0.0"
}
}

View File

@@ -0,0 +1 @@
51c73c9dd7c8a498b3e4e7db2243affe

477
frontend/src/main.js Normal file
View File

@@ -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();
}

494
frontend/src/style.css Normal file
View File

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

11
frontend/vite.config.js Normal file
View File

@@ -0,0 +1,11 @@
import { defineConfig } from 'vite'
export default defineConfig({
build: {
outDir: 'dist',
emptyOutDir: true,
rollupOptions: {
input: 'index.html'
}
}
})

30
frontend/wailsjs/go/main/App.d.ts vendored Normal file
View File

@@ -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<string>;
export function Connect(arg1:string):Promise<void>;
export function DeleteConfig(arg1:string):Promise<void>;
export function Disconnect():Promise<void>;
export function GetConfigs():Promise<Array<main.VpnConfigItem>>;
export function GetSelectedConfigID():Promise<string>;
export function GetSettings():Promise<core.AppSettings>;
export function GetState():Promise<string>;
export function HideWindow():Promise<void>;
export function PingAll():Promise<void>;
export function ResizeWindow(arg1:number,arg2:number):Promise<void>;
export function SaveSettings(arg1:boolean,arg2:boolean,arg3:boolean):Promise<void>;
export function SetSelectedConfig(arg1:string):Promise<void>;

View File

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

View File

@@ -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"];
}
}
}

View File

@@ -0,0 +1,24 @@
{
"name": "@wailsapp/runtime",
"version": "2.0.0",
"description": "Wails Javascript runtime library",
"main": "runtime.js",
"types": "runtime.d.ts",
"scripts": {
},
"repository": {
"type": "git",
"url": "git+https://github.com/wailsapp/wails.git"
},
"keywords": [
"Wails",
"Javascript",
"Go"
],
"author": "Lea Anthony <lea.anthony@gmail.com>",
"license": "MIT",
"bugs": {
"url": "https://github.com/wailsapp/wails/issues"
},
"homepage": "https://github.com/wailsapp/wails#readme"
}

330
frontend/wailsjs/runtime/runtime.d.ts vendored Normal file
View File

@@ -0,0 +1,330 @@
/*
_ __ _ __
| | / /___ _(_) /____
| | /| / / __ `/ / / ___/
| |/ |/ / /_/ / / (__ )
|__/|__/\__,_/_/_/____/
The electron alternative for Go
(c) Lea Anthony 2019-present
*/
export interface Position {
x: number;
y: number;
}
export interface Size {
w: number;
h: number;
}
export interface Screen {
isCurrent: boolean;
isPrimary: boolean;
width : number
height : number
}
// Environment information such as platform, buildtype, ...
export interface EnvironmentInfo {
buildType: string;
platform: string;
arch: string;
}
// [EventsEmit](https://wails.io/docs/reference/runtime/events#eventsemit)
// emits the given event. Optional data may be passed with the event.
// This will trigger any event listeners.
export function EventsEmit(eventName: string, ...data: any): void;
// [EventsOn](https://wails.io/docs/reference/runtime/events#eventson) sets up a listener for the given event name.
export function EventsOn(eventName: string, callback: (...data: any) => void): () => void;
// [EventsOnMultiple](https://wails.io/docs/reference/runtime/events#eventsonmultiple)
// sets up a listener for the given event name, but will only trigger a given number times.
export function EventsOnMultiple(eventName: string, callback: (...data: any) => void, maxCallbacks: number): () => void;
// [EventsOnce](https://wails.io/docs/reference/runtime/events#eventsonce)
// sets up a listener for the given event name, but will only trigger once.
export function EventsOnce(eventName: string, callback: (...data: any) => void): () => void;
// [EventsOff](https://wails.io/docs/reference/runtime/events#eventsoff)
// unregisters the listener for the given event name.
export function EventsOff(eventName: string, ...additionalEventNames: string[]): void;
// [EventsOffAll](https://wails.io/docs/reference/runtime/events#eventsoffall)
// unregisters all listeners.
export function EventsOffAll(): void;
// [LogPrint](https://wails.io/docs/reference/runtime/log#logprint)
// logs the given message as a raw message
export function LogPrint(message: string): void;
// [LogTrace](https://wails.io/docs/reference/runtime/log#logtrace)
// logs the given message at the `trace` log level.
export function LogTrace(message: string): void;
// [LogDebug](https://wails.io/docs/reference/runtime/log#logdebug)
// logs the given message at the `debug` log level.
export function LogDebug(message: string): void;
// [LogError](https://wails.io/docs/reference/runtime/log#logerror)
// logs the given message at the `error` log level.
export function LogError(message: string): void;
// [LogFatal](https://wails.io/docs/reference/runtime/log#logfatal)
// logs the given message at the `fatal` log level.
// The application will quit after calling this method.
export function LogFatal(message: string): void;
// [LogInfo](https://wails.io/docs/reference/runtime/log#loginfo)
// logs the given message at the `info` log level.
export function LogInfo(message: string): void;
// [LogWarning](https://wails.io/docs/reference/runtime/log#logwarning)
// logs the given message at the `warning` log level.
export function LogWarning(message: string): void;
// [WindowReload](https://wails.io/docs/reference/runtime/window#windowreload)
// Forces a reload by the main application as well as connected browsers.
export function WindowReload(): void;
// [WindowReloadApp](https://wails.io/docs/reference/runtime/window#windowreloadapp)
// Reloads the application frontend.
export function WindowReloadApp(): void;
// [WindowSetAlwaysOnTop](https://wails.io/docs/reference/runtime/window#windowsetalwaysontop)
// Sets the window AlwaysOnTop or not on top.
export function WindowSetAlwaysOnTop(b: boolean): void;
// [WindowSetSystemDefaultTheme](https://wails.io/docs/next/reference/runtime/window#windowsetsystemdefaulttheme)
// *Windows only*
// Sets window theme to system default (dark/light).
export function WindowSetSystemDefaultTheme(): void;
// [WindowSetLightTheme](https://wails.io/docs/next/reference/runtime/window#windowsetlighttheme)
// *Windows only*
// Sets window to light theme.
export function WindowSetLightTheme(): void;
// [WindowSetDarkTheme](https://wails.io/docs/next/reference/runtime/window#windowsetdarktheme)
// *Windows only*
// Sets window to dark theme.
export function WindowSetDarkTheme(): void;
// [WindowCenter](https://wails.io/docs/reference/runtime/window#windowcenter)
// Centers the window on the monitor the window is currently on.
export function WindowCenter(): void;
// [WindowSetTitle](https://wails.io/docs/reference/runtime/window#windowsettitle)
// Sets the text in the window title bar.
export function WindowSetTitle(title: string): void;
// [WindowFullscreen](https://wails.io/docs/reference/runtime/window#windowfullscreen)
// Makes the window full screen.
export function WindowFullscreen(): void;
// [WindowUnfullscreen](https://wails.io/docs/reference/runtime/window#windowunfullscreen)
// Restores the previous window dimensions and position prior to full screen.
export function WindowUnfullscreen(): void;
// [WindowIsFullscreen](https://wails.io/docs/reference/runtime/window#windowisfullscreen)
// Returns the state of the window, i.e. whether the window is in full screen mode or not.
export function WindowIsFullscreen(): Promise<boolean>;
// [WindowSetSize](https://wails.io/docs/reference/runtime/window#windowsetsize)
// Sets the width and height of the window.
export function WindowSetSize(width: number, height: number): void;
// [WindowGetSize](https://wails.io/docs/reference/runtime/window#windowgetsize)
// Gets the width and height of the window.
export function WindowGetSize(): Promise<Size>;
// [WindowSetMaxSize](https://wails.io/docs/reference/runtime/window#windowsetmaxsize)
// Sets the maximum window size. Will resize the window if the window is currently larger than the given dimensions.
// Setting a size of 0,0 will disable this constraint.
export function WindowSetMaxSize(width: number, height: number): void;
// [WindowSetMinSize](https://wails.io/docs/reference/runtime/window#windowsetminsize)
// Sets the minimum window size. Will resize the window if the window is currently smaller than the given dimensions.
// Setting a size of 0,0 will disable this constraint.
export function WindowSetMinSize(width: number, height: number): void;
// [WindowSetPosition](https://wails.io/docs/reference/runtime/window#windowsetposition)
// Sets the window position relative to the monitor the window is currently on.
export function WindowSetPosition(x: number, y: number): void;
// [WindowGetPosition](https://wails.io/docs/reference/runtime/window#windowgetposition)
// Gets the window position relative to the monitor the window is currently on.
export function WindowGetPosition(): Promise<Position>;
// [WindowHide](https://wails.io/docs/reference/runtime/window#windowhide)
// Hides the window.
export function WindowHide(): void;
// [WindowShow](https://wails.io/docs/reference/runtime/window#windowshow)
// Shows the window, if it is currently hidden.
export function WindowShow(): void;
// [WindowMaximise](https://wails.io/docs/reference/runtime/window#windowmaximise)
// Maximises the window to fill the screen.
export function WindowMaximise(): void;
// [WindowToggleMaximise](https://wails.io/docs/reference/runtime/window#windowtogglemaximise)
// Toggles between Maximised and UnMaximised.
export function WindowToggleMaximise(): void;
// [WindowUnmaximise](https://wails.io/docs/reference/runtime/window#windowunmaximise)
// Restores the window to the dimensions and position prior to maximising.
export function WindowUnmaximise(): void;
// [WindowIsMaximised](https://wails.io/docs/reference/runtime/window#windowismaximised)
// Returns the state of the window, i.e. whether the window is maximised or not.
export function WindowIsMaximised(): Promise<boolean>;
// [WindowMinimise](https://wails.io/docs/reference/runtime/window#windowminimise)
// Minimises the window.
export function WindowMinimise(): void;
// [WindowUnminimise](https://wails.io/docs/reference/runtime/window#windowunminimise)
// Restores the window to the dimensions and position prior to minimising.
export function WindowUnminimise(): void;
// [WindowIsMinimised](https://wails.io/docs/reference/runtime/window#windowisminimised)
// Returns the state of the window, i.e. whether the window is minimised or not.
export function WindowIsMinimised(): Promise<boolean>;
// [WindowIsNormal](https://wails.io/docs/reference/runtime/window#windowisnormal)
// Returns the state of the window, i.e. whether the window is normal or not.
export function WindowIsNormal(): Promise<boolean>;
// [WindowSetBackgroundColour](https://wails.io/docs/reference/runtime/window#windowsetbackgroundcolour)
// Sets the background colour of the window to the given RGBA colour definition. This colour will show through for all transparent pixels.
export function WindowSetBackgroundColour(R: number, G: number, B: number, A: number): void;
// [ScreenGetAll](https://wails.io/docs/reference/runtime/window#screengetall)
// Gets the all screens. Call this anew each time you want to refresh data from the underlying windowing system.
export function ScreenGetAll(): Promise<Screen[]>;
// [BrowserOpenURL](https://wails.io/docs/reference/runtime/browser#browseropenurl)
// Opens the given URL in the system browser.
export function BrowserOpenURL(url: string): void;
// [Environment](https://wails.io/docs/reference/runtime/intro#environment)
// Returns information about the environment
export function Environment(): Promise<EnvironmentInfo>;
// [Quit](https://wails.io/docs/reference/runtime/intro#quit)
// Quits the application.
export function Quit(): void;
// [Hide](https://wails.io/docs/reference/runtime/intro#hide)
// Hides the application.
export function Hide(): void;
// [Show](https://wails.io/docs/reference/runtime/intro#show)
// Shows the application.
export function Show(): void;
// [ClipboardGetText](https://wails.io/docs/reference/runtime/clipboard#clipboardgettext)
// Returns the current text stored on clipboard
export function ClipboardGetText(): Promise<string>;
// [ClipboardSetText](https://wails.io/docs/reference/runtime/clipboard#clipboardsettext)
// Sets a text on the clipboard
export function ClipboardSetText(text: string): Promise<boolean>;
// [OnFileDrop](https://wails.io/docs/reference/runtime/draganddrop#onfiledrop)
// OnFileDrop listens to drag and drop events and calls the callback with the coordinates of the drop and an array of path strings.
export function OnFileDrop(callback: (x: number, y: number ,paths: string[]) => void, useDropTarget: boolean) :void
// [OnFileDropOff](https://wails.io/docs/reference/runtime/draganddrop#dragandddropoff)
// OnFileDropOff removes the drag and drop listeners and handlers.
export function OnFileDropOff() :void
// Check if the file path resolver is available
export function CanResolveFilePaths(): boolean;
// Resolves file paths for an array of files
export function ResolveFilePaths(files: File[]): void
// Notification types
export interface NotificationOptions {
id: string;
title: string;
subtitle?: string; // macOS and Linux only
body?: string;
categoryId?: string;
data?: { [key: string]: any };
}
export interface NotificationAction {
id?: string;
title?: string;
destructive?: boolean; // macOS-specific
}
export interface NotificationCategory {
id?: string;
actions?: NotificationAction[];
hasReplyField?: boolean;
replyPlaceholder?: string;
replyButtonTitle?: string;
}
// [InitializeNotifications](https://wails.io/docs/reference/runtime/notification#initializenotifications)
// Initializes the notification service for the application.
// This must be called before sending any notifications.
export function InitializeNotifications(): Promise<void>;
// [CleanupNotifications](https://wails.io/docs/reference/runtime/notification#cleanupnotifications)
// Cleans up notification resources and releases any held connections.
export function CleanupNotifications(): Promise<void>;
// [IsNotificationAvailable](https://wails.io/docs/reference/runtime/notification#isnotificationavailable)
// Checks if notifications are available on the current platform.
export function IsNotificationAvailable(): Promise<boolean>;
// [RequestNotificationAuthorization](https://wails.io/docs/reference/runtime/notification#requestnotificationauthorization)
// Requests notification authorization from the user (macOS only).
export function RequestNotificationAuthorization(): Promise<boolean>;
// [CheckNotificationAuthorization](https://wails.io/docs/reference/runtime/notification#checknotificationauthorization)
// Checks the current notification authorization status (macOS only).
export function CheckNotificationAuthorization(): Promise<boolean>;
// [SendNotification](https://wails.io/docs/reference/runtime/notification#sendnotification)
// Sends a basic notification with the given options.
export function SendNotification(options: NotificationOptions): Promise<void>;
// [SendNotificationWithActions](https://wails.io/docs/reference/runtime/notification#sendnotificationwithactions)
// Sends a notification with action buttons. Requires a registered category.
export function SendNotificationWithActions(options: NotificationOptions): Promise<void>;
// [RegisterNotificationCategory](https://wails.io/docs/reference/runtime/notification#registernotificationcategory)
// Registers a notification category that can be used with SendNotificationWithActions.
export function RegisterNotificationCategory(category: NotificationCategory): Promise<void>;
// [RemoveNotificationCategory](https://wails.io/docs/reference/runtime/notification#removenotificationcategory)
// Removes a previously registered notification category.
export function RemoveNotificationCategory(categoryId: string): Promise<void>;
// [RemoveAllPendingNotifications](https://wails.io/docs/reference/runtime/notification#removeallpendingnotifications)
// Removes all pending notifications from the notification center.
export function RemoveAllPendingNotifications(): Promise<void>;
// [RemovePendingNotification](https://wails.io/docs/reference/runtime/notification#removependingnotification)
// Removes a specific pending notification by its identifier.
export function RemovePendingNotification(identifier: string): Promise<void>;
// [RemoveAllDeliveredNotifications](https://wails.io/docs/reference/runtime/notification#removealldeliverednotifications)
// Removes all delivered notifications from the notification center.
export function RemoveAllDeliveredNotifications(): Promise<void>;
// [RemoveDeliveredNotification](https://wails.io/docs/reference/runtime/notification#removedeliverednotification)
// Removes a specific delivered notification by its identifier.
export function RemoveDeliveredNotification(identifier: string): Promise<void>;
// [RemoveNotification](https://wails.io/docs/reference/runtime/notification#removenotification)
// Removes a notification by its identifier (cross-platform convenience function).
export function RemoveNotification(identifier: string): Promise<void>;

View File

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

49
go.mod Normal file
View File

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

113
go.sum Normal file
View File

@@ -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=

61
hotkey_windows.go Normal file
View File

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

45
main.go Normal file
View File

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

17
notification_windows.go Normal file
View File

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

185
tray.go Normal file
View File

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

12
wails.json Normal file
View File

@@ -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"
}
}