init commit
This commit is contained in:
44
.gitignore
vendored
Normal file
44
.gitignore
vendored
Normal 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
404
app.go
Normal 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
BIN
build/appicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 130 KiB |
BIN
build/windows/icon.ico
Normal file
BIN
build/windows/icon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 21 KiB |
15
build/windows/info.json
Normal file
15
build/windows/info.json
Normal 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}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
15
build/windows/wails.exe.manifest
Normal file
15
build/windows/wails.exe.manifest
Normal 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
61
core/config_manager.go
Normal 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
431
core/core_manager.go
Normal 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
128
core/models.go
Normal 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
177
core/parser.go
Normal 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
78
core/ping.go
Normal 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
70
core/process_windows.go
Normal 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
44
core/settings.go
Normal 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
30
core/settings_windows.go
Normal 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
156
core/singbox_config.go
Normal 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
31
core/util.go
Normal 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
177
core/xray_config.go
Normal 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
152
frontend/index.html
Normal 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
1143
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
12
frontend/package.json
Normal file
12
frontend/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
1
frontend/package.json.md5
Normal file
1
frontend/package.json.md5
Normal file
@@ -0,0 +1 @@
|
||||
51c73c9dd7c8a498b3e4e7db2243affe
|
||||
477
frontend/src/main.js
Normal file
477
frontend/src/main.js
Normal 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
494
frontend/src/style.css
Normal 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
11
frontend/vite.config.js
Normal 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
30
frontend/wailsjs/go/main/App.d.ts
vendored
Normal 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>;
|
||||
55
frontend/wailsjs/go/main/App.js
Normal file
55
frontend/wailsjs/go/main/App.js
Normal 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);
|
||||
}
|
||||
50
frontend/wailsjs/go/models.ts
Normal file
50
frontend/wailsjs/go/models.ts
Normal 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"];
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
24
frontend/wailsjs/runtime/package.json
Normal file
24
frontend/wailsjs/runtime/package.json
Normal 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
330
frontend/wailsjs/runtime/runtime.d.ts
vendored
Normal 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>;
|
||||
298
frontend/wailsjs/runtime/runtime.js
Normal file
298
frontend/wailsjs/runtime/runtime.js
Normal 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
49
go.mod
Normal 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
113
go.sum
Normal 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
61
hotkey_windows.go
Normal 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
45
main.go
Normal 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
17
notification_windows.go
Normal 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
185
tray.go
Normal 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
12
wails.json
Normal 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"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user