Files
navi/cmd/vpnapp/main_windows.go
T

416 lines
11 KiB
Go

//go:build windows
package main
import (
"bytes"
"context"
"fmt"
"log"
"os"
"path/filepath"
"strings"
"sync"
"time"
"unsafe"
"github.com/jchv/go-webview2"
"golang.org/x/sys/windows"
"vpnclient/internal/appui"
"vpnclient/internal/config"
"vpnclient/internal/core"
"vpnclient/internal/netcheck"
"vpnclient/internal/protocols/hysteria2"
"vpnclient/internal/protocols/naive"
"vpnclient/internal/update"
)
type app struct {
mu sync.Mutex
mgr *core.Manager
cfgPath string
logBuf *bytes.Buffer
updateStatus update.Status
pings []netcheck.Result
webview webview2.WebView
}
type uiState struct {
Connected bool `json:"connected"`
Profile string `json:"profile,omitempty"`
ActiveProfile string `json:"active_profile,omitempty"`
Protocol string `json:"protocol,omitempty"`
HTTPProxy string `json:"http_proxy,omitempty"`
SOCKSProxy string `json:"socks_proxy,omitempty"`
SystemProxy bool `json:"system_proxy"`
Proxy string `json:"proxy"`
CoreReady bool `json:"core_ready"`
CorePath string `json:"core_path,omitempty"`
ConfigPath string `json:"config_path"`
Profiles []config.ProfileInfo `json:"profiles"`
Version string `json:"version"`
Update update.Status `json:"update"`
Pings []netcheck.Result `json:"pings"`
Subscription string `json:"subscription_url"`
Hy2 core.Hy2Options `json:"hy2"`
}
func main() {
log.SetFlags(log.LstdFlags | log.Lshortfile)
cfgPath, err := config.LocateConfig()
if err != nil {
fatalDialog("Не удалось найти путь конфига: %v", err)
}
if _, err := os.Stat(cfgPath); os.IsNotExist(err) {
if err := config.WriteExample(cfgPath); err != nil {
fatalDialog("Не удалось создать конфиг: %v", err)
}
}
cfg, err := config.Load(cfgPath)
if err != nil {
fatalDialog("Ошибка конфига %s: %v", cfgPath, err)
}
logBuf := &bytes.Buffer{}
mgr, err := core.NewManager(cfgPath, cfg, logBuf)
if err != nil {
fatalDialog("%v", err)
}
a := &app{
mgr: mgr,
cfgPath: cfgPath,
logBuf: logBuf,
updateStatus: update.Status{
Current: update.CurrentVersion,
ManifestURL: update.DefaultManifestURL,
},
}
dataPath := filepath.Join(filepath.Dir(cfgPath), ".navis-webview")
_ = os.MkdirAll(dataPath, 0o755)
// Do NOT auto-download cores on startup — silent network+write looks like malware to AV.
w := webview2.NewWithOptions(webview2.WebViewOptions{
Debug: false,
AutoFocus: true,
DataPath: dataPath,
WindowOptions: webview2.WindowOptions{
Title: "Navis",
Width: 520,
Height: 860,
Center: true,
IconId: 1,
},
})
if w == nil {
fatalDialog("Не удалось создать окно WebView2.\nУстановите Microsoft Edge WebView2 Runtime.")
}
a.webview = w
defer func() {
_ = a.mgr.Disconnect()
w.Destroy()
}()
applyWindowIcon(w.Window())
w.SetSize(520, 860, webview2.HintNone)
mustBind(w, "getState", a.getState)
mustBind(w, "connect", a.connect)
mustBind(w, "disconnect", a.disconnect)
mustBind(w, "saveProfile", a.saveProfile)
mustBind(w, "createProfile", a.createProfile)
mustBind(w, "selectProfile", a.selectProfile)
mustBind(w, "deleteProfile", a.deleteProfile)
mustBind(w, "installCore", a.installCore)
mustBind(w, "openURL", openURL)
mustBind(w, "pingServers", a.pingServers)
mustBind(w, "checkUpdate", a.checkUpdate)
mustBind(w, "applyUpdate", a.applyUpdate)
mustBind(w, "saveHy2", a.saveHy2)
mustBind(w, "importSubscription", a.importSubscription)
w.SetHtml(appui.IndexHTML)
w.Run()
}
func mustBind(w webview2.WebView, name string, fn interface{}) {
if err := w.Bind(name, fn); err != nil {
fatalDialog("bind %s: %v", name, err)
}
}
func (a *app) getState() (uiState, error) {
a.mu.Lock()
defer a.mu.Unlock()
st := a.mgr.Status()
cfg := a.mgr.Config()
proxy := ""
active := cfg.Active
if p, err := cfg.ActiveProfile(); err == nil {
proxy = p.Proxy
active = p.Name
}
corePath := ""
coreReady := false
if p, err := cfg.ActiveProfile(); err == nil {
switch p.Protocol {
case config.ProtocolHysteria2:
if path, err := hysteria2.ResolveBinary(a.mgr.BinDir()); err == nil {
corePath, coreReady = path, true
}
default:
if path, err := naive.ResolveBinary(a.mgr.BinDir()); err == nil {
corePath, coreReady = path, true
}
}
} else if path, err := naive.ResolveBinary(a.mgr.BinDir()); err == nil {
corePath, coreReady = path, true
}
out := uiState{
Connected: st.Connected,
Profile: st.Profile,
ActiveProfile: active,
Protocol: string(st.Protocol),
HTTPProxy: st.HTTPProxy,
SOCKSProxy: st.SOCKSProxy,
SystemProxy: cfg.SystemProxy,
Proxy: proxy,
CoreReady: coreReady,
CorePath: corePath,
ConfigPath: a.cfgPath,
Profiles: cfg.ListProfiles(),
Version: update.CurrentVersion,
Update: a.updateStatus,
Pings: append([]netcheck.Result(nil), a.pings...),
Subscription: cfg.SubscriptionURL,
Hy2: a.mgr.ActiveHy2Options(),
}
if out.Protocol == "" {
if p, err := cfg.ActiveProfile(); err == nil {
out.Protocol = string(p.Protocol)
}
}
if st.Connected {
out.SystemProxy = st.SystemProxy
}
return out, nil
}
func (a *app) saveProfile(name, proxy string, systemProxy bool) error {
a.mu.Lock()
defer a.mu.Unlock()
name = strings.TrimSpace(name)
if name == "" {
return fmt.Errorf("укажите название профиля")
}
a.mgr.SetSystemProxy(systemProxy)
return a.mgr.SaveActiveProfile(name, proxy)
}
func (a *app) createProfile(name, proxy string, systemProxy bool) error {
a.mu.Lock()
defer a.mu.Unlock()
name = strings.TrimSpace(name)
if name == "" {
return fmt.Errorf("укажите название профиля")
}
a.mgr.SetSystemProxy(systemProxy)
return a.mgr.SaveProfile(name, proxy)
}
func (a *app) selectProfile(name string) error {
a.mu.Lock()
defer a.mu.Unlock()
return a.mgr.SetActiveProfile(name)
}
func (a *app) deleteProfile(name string) error {
a.mu.Lock()
defer a.mu.Unlock()
return a.mgr.DeleteProfile(name)
}
func (a *app) connect() error {
a.mu.Lock()
defer a.mu.Unlock()
if p, err := a.mgr.Config().ActiveProfile(); err != nil {
return err
} else if strings.TrimSpace(p.Proxy) == "" {
return fmt.Errorf("сначала вставьте ссылку сервера")
}
if _, err := a.mgr.EnsureCore(""); err != nil {
return err
}
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
defer cancel()
return a.mgr.Connect(ctx, "")
}
func (a *app) disconnect() error {
a.mu.Lock()
defer a.mu.Unlock()
return a.mgr.Disconnect()
}
func (a *app) installCore() (string, error) {
a.mu.Lock()
defer a.mu.Unlock()
paths, err := a.mgr.EnsureAllCores()
if err != nil {
return "", err
}
parts := make([]string, 0, len(paths))
for k, v := range paths {
parts = append(parts, k+": "+v)
}
return strings.Join(parts, " | "), nil
}
func (a *app) saveHy2(opts core.Hy2Options) error {
a.mu.Lock()
defer a.mu.Unlock()
return a.mgr.SaveHy2Options(opts)
}
func (a *app) importSubscription(rawURL string) (int, error) {
return a.mgr.ImportSubscription(rawURL)
}
func (a *app) pingServers() ([]netcheck.Result, error) {
a.mu.Lock()
list := a.mgr.Profiles()
a.mu.Unlock()
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
out := make([]netcheck.Result, 0, len(list))
for _, p := range list {
proxy := p.Proxy
if proxy == "" {
out = append(out, netcheck.Result{Name: p.Name, Error: "нет ссылки сервера"})
continue
}
out = append(out, netcheck.PingProfile(ctx, p.Name, config.Protocol(p.Protocol), proxy))
}
a.mu.Lock()
a.pings = out
a.mu.Unlock()
return out, nil
}
func (a *app) checkUpdate() (update.Status, error) {
ctx, cancel := context.WithTimeout(context.Background(), 25*time.Second)
defer cancel()
st, err := update.Check(ctx, update.DefaultManifestURL)
a.mu.Lock()
if err != nil {
st.Current = update.CurrentVersion
st.ManifestURL = update.DefaultManifestURL
st.Error = err.Error()
}
a.updateStatus = st
a.mu.Unlock()
return st, nil
}
func (a *app) applyUpdate() (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
defer cancel()
return update.Apply(ctx, update.DefaultManifestURL)
}
func openURL(raw string) error {
raw = strings.TrimSpace(raw)
if raw == "" {
return fmt.Errorf("пустая ссылка")
}
lower := strings.ToLower(raw)
switch {
case lower == "https://evilfox.win/" || lower == "https://evilfox.win" ||
lower == "http://evilfox.win/" || lower == "http://evilfox.win":
raw = "https://evilfox.win/"
case strings.HasPrefix(lower, "https://evilfox.win/") || strings.HasPrefix(lower, "http://evilfox.win/"):
// allow shop deep-links on evilfox.win only
default:
return fmt.Errorf("разрешена только ссылка evilfox.win")
}
return shellOpen(raw)
}
func shellOpen(raw string) error {
verb, err := windows.UTF16PtrFromString("open")
if err != nil {
return err
}
file, err := windows.UTF16PtrFromString(raw)
if err != nil {
return err
}
return windows.ShellExecute(0, verb, file, nil, nil, windows.SW_SHOWNORMAL)
}
func applyWindowIcon(hwnd unsafe.Pointer) {
ico := findIconPath()
if ico == "" || hwnd == nil {
return
}
pathPtr, err := windows.UTF16PtrFromString(ico)
if err != nil {
return
}
user32 := windows.NewLazySystemDLL("user32.dll")
loadImage := user32.NewProc("LoadImageW")
sendMessage := user32.NewProc("SendMessageW")
const (
imageIcon = 1
lrLoadFromFile = 0x0010
lrDefaultSize = 0x0040
wmSetIcon = 0x0080
iconSmall = 0
iconBig = 1
)
h, _, _ := loadImage.Call(0, uintptr(unsafe.Pointer(pathPtr)), imageIcon, 0, 0, lrLoadFromFile|lrDefaultSize)
if h == 0 {
return
}
sendMessage.Call(uintptr(hwnd), wmSetIcon, iconSmall, h)
sendMessage.Call(uintptr(hwnd), wmSetIcon, iconBig, h)
}
func findIconPath() string {
candidates := []string{}
if exe, err := os.Executable(); err == nil {
dir := filepath.Dir(exe)
candidates = append(candidates,
filepath.Join(dir, "assets", "navis.ico"),
filepath.Join(dir, "navis.ico"),
)
}
if cwd, err := os.Getwd(); err == nil {
candidates = append(candidates, filepath.Join(cwd, "assets", "navis.ico"))
}
for _, c := range candidates {
if st, err := os.Stat(c); err == nil && !st.IsDir() {
return c
}
}
return ""
}
func fatalDialog(format string, args ...interface{}) {
msg := fmt.Sprintf(format, args...)
log.Println(msg)
messageBox(msg)
os.Exit(1)
}