Compare commits
21
Commits
a332857973
...
v0.3.9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
97cd9d4553 | ||
|
|
56c802c385 | ||
|
|
6026beb107 | ||
|
|
8c903860c2 | ||
|
|
7abbf9e6cc | ||
|
|
45b9fa24a1 | ||
|
|
4108adf1f7 | ||
|
|
6074dd5a73 | ||
|
|
3c076785f6 | ||
|
|
019df7bc23 | ||
|
|
9a1aca5d1b | ||
|
|
027d837cb2 | ||
|
|
0c5e996bfd | ||
|
|
65a5932eba | ||
|
|
6b9960076d | ||
|
|
56102589f6 | ||
|
|
6daa73fdb0 | ||
|
|
abcbb6ae68 | ||
|
|
9c16d804d9 | ||
|
|
e08480f609 | ||
|
|
4420532a66 |
@@ -0,0 +1,114 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: 'Version (e.g. 0.3.9, 不带 v 前缀)'
|
||||
required: true
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
build-macos:
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Resolve version
|
||||
id: ver
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
echo "version=${{ inputs.version }}" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Vet
|
||||
run: make vet
|
||||
|
||||
- name: Build .app
|
||||
run: make app SEMVER=${{ steps.ver.outputs.version }}
|
||||
|
||||
- name: Package .app as zip
|
||||
run: |
|
||||
VERSION="${{ steps.ver.outputs.version }}-$(git rev-parse --short HEAD)"
|
||||
ditto -c -k --sequesterRsrc --keepParent LMVPN.app "LMVPN-${VERSION}-macos.zip"
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: macos-build
|
||||
path: LMVPN-*-macos.zip
|
||||
|
||||
build-windows:
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Install make and Inno Setup
|
||||
shell: pwsh
|
||||
run: choco install make innosetup -y --no-progress
|
||||
|
||||
- name: Add MinGW to PATH
|
||||
shell: bash
|
||||
run: echo "C:/msys64/mingw64/bin" >> "$GITHUB_PATH"
|
||||
|
||||
- name: Resolve version
|
||||
id: ver
|
||||
shell: bash
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
echo "version=${{ inputs.version }}" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Vet
|
||||
shell: bash
|
||||
run: make vet
|
||||
|
||||
- name: Build installer
|
||||
shell: bash
|
||||
env:
|
||||
MSYS_NO_PATHCONV: '1'
|
||||
run: make installer-windows SEMVER=${{ steps.ver.outputs.version }} MINGW_CC=gcc SHELL=bash
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: windows-build
|
||||
path: build/LMVPN-Setup-*.exe
|
||||
|
||||
release:
|
||||
needs: [build-macos, build-windows]
|
||||
runs-on: ubuntu-latest
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
steps:
|
||||
- name: Download artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: artifacts
|
||||
merge-multiple: true
|
||||
|
||||
- name: Create release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: artifacts/*
|
||||
generate_release_notes: true
|
||||
@@ -9,11 +9,14 @@ APP_BUNDLE = $(APP_NAME).app
|
||||
|
||||
GO = go
|
||||
CGO_ENABLED = 1
|
||||
WINDRES ?= x86_64-w64-mingw32-windres
|
||||
MINGW_CC ?= x86_64-w64-mingw32-gcc
|
||||
SEMVER ?= 0.3.9
|
||||
GIT_HASH = $(shell git rev-parse --short HEAD 2>/dev/null || echo unknown)
|
||||
VERSION = 0.2.5-$(GIT_HASH)
|
||||
LDFLAGS = -s -w -X lmvpn/internal/ui.Version=$(VERSION)
|
||||
VERSION = $(SEMVER)-$(GIT_HASH)
|
||||
LDFLAGS = -s -w -X lmvpn/internal/version.Version=$(VERSION)
|
||||
|
||||
.PHONY: all build app run daemon clean vet tidy fmt icon
|
||||
.PHONY: all build app run daemon clean vet tidy fmt icon icon-windows build-windows installer-windows
|
||||
|
||||
## all: build the .app bundle (default)
|
||||
all: app
|
||||
@@ -32,6 +35,9 @@ app: build
|
||||
cp $(BUILD_DIR)/$(GUI_BIN) $(APP_BUNDLE)/Contents/MacOS/$(GUI_BIN)
|
||||
cp $(BUILD_DIR)/$(DAEMON_BIN) $(APP_BUNDLE)/Contents/MacOS/$(DAEMON_BIN)
|
||||
cp resources/Info.plist $(APP_BUNDLE)/Contents/Info.plist
|
||||
/usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString $(SEMVER)" \
|
||||
-c "Set :CFBundleVersion $(GIT_HASH)" \
|
||||
$(APP_BUNDLE)/Contents/Info.plist
|
||||
@if [ -f resources/icon.icns ]; then \
|
||||
cp resources/icon.icns $(APP_BUNDLE)/Contents/Resources/icon.icns; \
|
||||
else echo " (no icon.icns found, skipping icon)"; fi
|
||||
@@ -80,3 +86,36 @@ fmt:
|
||||
## clean: remove build artifacts
|
||||
clean:
|
||||
rm -rf $(BUILD_DIR) $(APP_BUNDLE)
|
||||
|
||||
## icon-windows: generate icon.ico and compile Windows resource (.syso)
|
||||
icon-windows:
|
||||
go run resources/genico/main.go
|
||||
$(WINDRES) -i resources/lmvpn.rc -O coff -o cmd/lmvpn/resource_windows_amd64.syso
|
||||
$(WINDRES) -i resources/lmvpn.rc -O coff -o cmd/lmvpnd/resource_windows_amd64.syso
|
||||
@echo "Generated Windows icon resources"
|
||||
|
||||
## build-windows: cross-compile Windows x86_64 exes (requires mingw-w64)
|
||||
## The .syso icon resources are committed in cmd/*/; run `make icon-windows` to regenerate.
|
||||
build-windows:
|
||||
mkdir -p $(BUILD_DIR)
|
||||
CGO_ENABLED=1 GOOS=windows GOARCH=amd64 CC=$(MINGW_CC) \
|
||||
$(GO) build -ldflags "$(LDFLAGS) -H windowsgui" -o $(BUILD_DIR)/$(GUI_BIN).exe ./cmd/lmvpn
|
||||
CGO_ENABLED=1 GOOS=windows GOARCH=amd64 CC=$(MINGW_CC) \
|
||||
$(GO) build -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(DAEMON_BIN).exe ./cmd/lmvpnd
|
||||
@echo "Built $(BUILD_DIR)/$(GUI_BIN).exe and $(BUILD_DIR)/$(DAEMON_BIN).exe"
|
||||
|
||||
## installer-windows: build exes and compile Inno Setup installer (.exe)
|
||||
## Requires ISCC on PATH (Windows) or Wine + Inno Setup 6 (macOS/Linux).
|
||||
installer-windows: build-windows
|
||||
@echo "Compiling installer with version $(VERSION)..."
|
||||
@if command -v ISCC >/dev/null 2>&1; then \
|
||||
ISCC /DAppVersion=$(VERSION) installer/lmvpn.iss; \
|
||||
elif command -v wine >/dev/null 2>&1; then \
|
||||
wine "C:\Program Files (x86)\Inno Setup 6\ISCC.exe" /DAppVersion=$(VERSION) installer/lmvpn.iss; \
|
||||
else \
|
||||
echo "ERROR: ISCC not found. Install Inno Setup 6 on Windows,"; \
|
||||
echo " or install Wine + Inno Setup 6 on macOS/Linux."; \
|
||||
echo " Download: https://jrsoftware.org/isdl.php"; \
|
||||
exit 1; \
|
||||
fi
|
||||
@echo "Installer: $(BUILD_DIR)/LMVPN-Setup-$(VERSION).exe"
|
||||
|
||||
@@ -0,0 +1,349 @@
|
||||
# LMVPN Client
|
||||
|
||||
LMVPN 是一个基于 WebSocket 隧道与 TUN 虚拟网卡的**三层(网络层)VPN 客户端**。客户端通过 WebSocket(`ws://`/`wss://`)连接服务端,完成认证后在本地 TUN 虚拟网卡与 WebSocket 连接之间双向转发原始 IP 数据包,实现透明隧道。
|
||||
|
||||
客户端使用 [Go](https://go.dev/) + [Fyne](https://fyne.io/) 开发,支持 macOS、Windows、Linux 三大桌面平台。
|
||||
|
||||
> **服务端项目**:<https://github.com/wuwenfengmi1998/lmvpn_server>
|
||||
|
||||
---
|
||||
|
||||
## 目录
|
||||
|
||||
- [功能特性](#功能特性)
|
||||
- [系统要求](#系统要求)
|
||||
- [各平台编译](#各平台编译)
|
||||
- [通用前置依赖](#通用前置依赖)
|
||||
- [macOS](#macos)
|
||||
- [Windows(交叉编译)](#windows交叉编译)
|
||||
- [Linux](#linux)
|
||||
- [Make 目标速查表](#make-目标速查表)
|
||||
- [运行使用](#运行使用)
|
||||
- [配置与数据目录](#配置与数据目录)
|
||||
- [架构概览](#架构概览)
|
||||
- [开发指南](#开发指南)
|
||||
- [注意事项](#注意事项)
|
||||
|
||||
---
|
||||
|
||||
## 功能特性
|
||||
|
||||
- **双进程架构**:GUI(`lmvpn`,普通用户)+ 守护进程(`lmvpnd`,root/管理员),自动拉起与生命周期管理
|
||||
- **多种认证**:JWT 令牌 / 用户名密码
|
||||
- **隧道模式**:全量隧道、分流隧道(按目标绕过)、自定义隧道
|
||||
- **多服务器管理**:配置文件 + SQLite 存储多个服务器配置(Profile)
|
||||
- **国际化**:中文(简体)、英文,跟随系统语言
|
||||
- **安全存储**:macOS Keychain / Windows Credential Manager 加密保存凭据
|
||||
- **系统集成**:系统托盘图标、Dock 显隐、开机自启
|
||||
- **日志轮转**:基于 lumberjack 的文件日志自动轮转
|
||||
|
||||
---
|
||||
|
||||
## 系统要求
|
||||
|
||||
| 平台 | 最低版本 | 架构 | 备注 |
|
||||
|------|---------|------|------|
|
||||
| macOS | 11.0(Big Sur) | amd64 / arm64 | 需 Xcode Command Line Tools |
|
||||
| Windows | 10 | x86_64 | 仅支持 64 位,需 WinTun 驱动(已内置) |
|
||||
| Linux | 现代发行版 | amd64 / arm64 | 需 OpenGL/GLFW/libdbus 开发库 |
|
||||
|
||||
**所有平台均要求 `CGO_ENABLED=1`**(Fyne 依赖 OpenGL/GLFW 的 CGO 绑定,不可关闭)。
|
||||
|
||||
---
|
||||
|
||||
## 各平台编译
|
||||
|
||||
### 通用前置依赖
|
||||
|
||||
- **Go 1.26** 或更高版本(见 `go.mod`)
|
||||
- **Git**:用于在编译时注入版本号,格式为 `0.3.7-<git短哈希>`(如 `0.3.7-019df7b`)
|
||||
- **C 编译器**:GCC(Linux/Windows 交叉编译)或 clang(macOS),由 CGO 调用
|
||||
- **网络访问**:首次构建需拉取 Go 模块依赖
|
||||
|
||||
版本号通过 `-ldflags "-X lmvpn/internal/version.Version=$(VERSION)"` 在链接期注入到 `internal/version` 包,GUI 与守护进程共享同一版本字符串。
|
||||
|
||||
### macOS
|
||||
|
||||
#### 依赖
|
||||
|
||||
- **Xcode Command Line Tools**:提供 clang(CGO)、Cocoa 框架、`sips`、`iconutil`(图标生成)
|
||||
|
||||
```bash
|
||||
xcode-select --install
|
||||
```
|
||||
|
||||
- 最低部署目标 macOS 11.0(见 `resources/Info.plist` 的 `LSMinimumSystemVersion`)
|
||||
|
||||
#### 编译命令
|
||||
|
||||
```bash
|
||||
# 默认目标:编译二进制并组装 LMVPN.app bundle
|
||||
make # 等价于 make app
|
||||
|
||||
# 仅编译裸二进制(build/lmvpn、build/lmvpnd),不打包 .app
|
||||
make build
|
||||
|
||||
# 重新生成 macOS 图标 resources/icon.icns(需要 icon.png 或 logo.svg)
|
||||
make icon
|
||||
|
||||
# 编译并直接运行 GUI
|
||||
make run
|
||||
```
|
||||
|
||||
`make app` 会将 `build/lmvpn`、`build/lmvpnd` 连同 `resources/Info.plist`、`resources/icon.icns` 组装成 `LMVPN.app/`。
|
||||
|
||||
#### 运行
|
||||
|
||||
守护进程创建 TUN 网卡与修改路由需要 root 权限,GUI 通过 `osascript ... with administrator privileges` 弹出系统授权对话框提权拉起守护进程。日常使用直接双击 `LMVPN.app` 即可。
|
||||
|
||||
---
|
||||
|
||||
### Windows(交叉编译)
|
||||
|
||||
Windows 版本从 macOS 或 Linux **交叉编译**生成(Fyne 无法用 `CGO_ENABLED=0` 构建,因此必须借助 mingw-w64 提供 C 交叉编译器)。仅支持 **x86_64** 架构。
|
||||
|
||||
#### 依赖
|
||||
|
||||
- **mingw-w64 工具链**:提供 `x86_64-w64-mingw32-gcc`(C 编译器)与 `x86_64-w64-mingw32-windres`(资源编译器)
|
||||
|
||||
```bash
|
||||
# macOS
|
||||
brew install mingw-w64
|
||||
```
|
||||
|
||||
- **Inno Setup 6**(仅打包安装程序时需要):用于生成 `.exe` 安装包
|
||||
- 原生 Windows:将 `ISCC` 加入 `PATH`
|
||||
- macOS/Linux:通过 Wine 调用,需安装 Wine 并将 Inno Setup 6 装到 Wine 的 `C:\Program Files (x86)\Inno Setup 6\`
|
||||
|
||||
#### 编译命令
|
||||
|
||||
```bash
|
||||
# 1. 生成 Windows 图标与资源(.ico + .syso),并交叉编译 exe
|
||||
make build-windows
|
||||
|
||||
# 2. 单独生成图标资源(生成 resource_windows_amd64.syso,会被 go build 自动链接)
|
||||
make icon-windows
|
||||
|
||||
# 3. 编译并打包 Inno Setup 安装程序
|
||||
make installer-windows
|
||||
```
|
||||
|
||||
`make build-windows` 实际执行:
|
||||
|
||||
```bash
|
||||
# GUI(带 -H windowsgui,无控制台窗口)
|
||||
CGO_ENABLED=1 GOOS=windows GOARCH=amd64 CC=x86_64-w64-mingw32-gcc \
|
||||
go build -ldflags "-s -w -X ... -H windowsgui" -o build/lmvpn.exe ./cmd/lmvpn
|
||||
|
||||
# 守护进程(控制台程序)
|
||||
CGO_ENABLED=1 GOOS=windows GOARCH=amd64 CC=x86_64-w64-mingw32-gcc \
|
||||
go build -ldflags "-s -w -X ..." -o build/lmvpnd.exe ./cmd/lmvpnd
|
||||
```
|
||||
|
||||
产物:`build/lmvpn.exe`、`build/lmvpnd.exe`,安装包 `build/LMVPN-Setup-<version>.exe`。
|
||||
|
||||
#### 关于 WinTun
|
||||
|
||||
Windows 版使用 [WinTun](https://www.wintun.net/) 驱动创建虚拟网卡。`wintun.dll` 在编译期通过 `//go:embed` 嵌入二进制,运行时释放到 exe 同级目录;安装包也会单独安装一份 `wintun.dll`。
|
||||
|
||||
#### 运行
|
||||
|
||||
守护进程需要管理员权限,GUI 通过 UAC(`ShellExecuteW` + `runas`)提权拉起。
|
||||
|
||||
---
|
||||
|
||||
### Linux
|
||||
|
||||
Linux 没有独立的 Make 目标,使用平台无关的 `make build` 原生编译。
|
||||
|
||||
#### 依赖
|
||||
|
||||
- **GCC**(CGO 编译器)
|
||||
- **OpenGL 与 GLFW 开发库**(Fyne 依赖):`libgl`、`libglfw`、`libx11`、xorg 开发头
|
||||
- **libdbus-1 开发库**(系统托盘,经 `godbus/dbus`):`libdbus-1-dev` 或对应开发包
|
||||
|
||||
> 请根据所用发行版(apt / dnf / pacman 等)自行安装上述开发库。
|
||||
|
||||
#### 编译命令
|
||||
|
||||
```bash
|
||||
# 原生编译(平台无关,直接产出 build/lmvpn、build/lmvpnd)
|
||||
make build
|
||||
|
||||
# 编译并运行 GUI
|
||||
make run
|
||||
|
||||
# 以 root 运行守护进程(调试用)
|
||||
make daemon
|
||||
```
|
||||
|
||||
#### 运行
|
||||
|
||||
- TUN 网卡创建与路由配置需要 **root 或 `CAP_NET_ADMIN`** 能力
|
||||
- 提权方式:`pkexec`(`internal/ui/elevation_other.go`)
|
||||
- 系统命令依赖:`ip`、`ip route`(`internal/route/route_linux.go`、`internal/tun/tun_linux.go`)
|
||||
- **密钥链**:Linux 暂使用内存存储(不持久化),重启后凭据丢失,后续需接入 Secret Service 等后端
|
||||
|
||||
---
|
||||
|
||||
### Make 目标速查表
|
||||
|
||||
| 目标 | 说明 |
|
||||
|------|------|
|
||||
| `make` / `make all` / `make app` | 编译并组装 macOS `LMVPN.app` bundle(默认) |
|
||||
| `make build` | 仅编译 `lmvpn` + `lmvpnd` 裸二进制(macOS/Linux 原生) |
|
||||
| `make run` | 编译并运行 GUI |
|
||||
| `make daemon` | 编译并以 `sudo` 运行守护进程 |
|
||||
| `make build-windows` | 交叉编译 Windows x64 exe(含图标资源生成) |
|
||||
| `make icon-windows` | 生成 Windows `.ico` 与 `.syso` 资源文件 |
|
||||
| `make installer-windows` | 编译 exe 并打包 Inno Setup 安装程序 |
|
||||
| `make icon` | 由 `icon.png`/`logo.svg` 重新生成 macOS `icon.icns` |
|
||||
| `make vet` | 运行 `go vet ./...` |
|
||||
| `make fmt` | 运行 `go fmt ./...` |
|
||||
| `make tidy` | 运行 `go mod tidy` |
|
||||
| `make clean` | 清理 `build/` 与 `LMVPN.app/` |
|
||||
|
||||
---
|
||||
|
||||
## 运行使用
|
||||
|
||||
GUI 会自动管理守护进程的生命周期(拉起、停止),无需手动启动 `lmvpnd`。
|
||||
|
||||
- **macOS**:双击 `LMVPN.app`,首次连接时系统弹出授权对话框输入密码以提权守护进程
|
||||
- **Windows**:运行安装包或直接运行 `lmvpn.exe`,首次连接时 UAC 弹窗确认提权
|
||||
- **Linux**:`make run` 或运行编译好的 `lmvpn`,首次连接时 `pkexec` 弹窗输入密码提权
|
||||
|
||||
GUI 与守护进程通过本地 IPC 通信:macOS/Linux 使用 Unix socket `/tmp/lmvpn.sock`;Windows 使用 TCP `127.0.0.1:18923`(因 Windows 上 AF_UNIX 有完整性级别校验,会阻止非提权 GUI 访问提权守护进程的 socket)。
|
||||
|
||||
---
|
||||
|
||||
## 配置与数据目录
|
||||
|
||||
各平台的数据目录布局(Bundle ID 为 `com.lmvpn.client`):
|
||||
|
||||
| 平台 | 配置/数据 | 缓存 | 日志 |
|
||||
|------|----------|------|------|
|
||||
| macOS | `~/Library/Application Support/com.lmvpn.client/` | `~/Library/Caches/com.lmvpn.client/` | `~/Library/Logs/com.lmvpn.client/` |
|
||||
| Windows | `%APPDATA%\com.lmvpn.client\` | `%LOCALAPPDATA%\com.lmvpn.client\` | 同数据目录下 `log/` |
|
||||
| Linux | `~/.local/share/com.lmvpn.client/` | `~/.cache/com.lmvpn.client/` | `~/.local/state/com.lmvpn.client/log/` |
|
||||
|
||||
- 配置文件支持 TOML 与 YAML 两种格式(`internal/config`)
|
||||
- 服务器配置(Profile)存储于 SQLite 数据库(`internal/db`,使用纯 Go 的 `modernc.org/sqlite`,无需 CGO)
|
||||
- 凭据保存于系统密钥链(macOS Keychain / Windows Credential Manager / Linux 内存)
|
||||
|
||||
---
|
||||
|
||||
## 架构概览
|
||||
|
||||
### 双进程设计
|
||||
|
||||
```
|
||||
┌──────────────┐ IPC (Unix socket / TCP) ┌──────────────┐
|
||||
│ lmvpn │ ◄─────────────────────────► │ lmvpnd │
|
||||
│ (GUI) │ 控制命令 / 状态 │ (守护进程) │
|
||||
│ Fyne UI │ │ WebSocket │
|
||||
│ 普通用户 │ │ TUN 网卡 │
|
||||
│ │ │ root/管理员 │
|
||||
└──────────────┘ └──────┬───────┘
|
||||
│
|
||||
WebSocket (ws/wss)
|
||||
▼
|
||||
┌──────────────┐
|
||||
│ LMVPN 服务端 │
|
||||
└──────────────┘
|
||||
```
|
||||
|
||||
拆分 GUI 与守护进程的原因:避免 Fyne(及其 locale/字体初始化)加载进 root 进程,同时让提权范围最小化——仅守护进程需要 root 权限操作网卡与路由。
|
||||
|
||||
### 目录结构
|
||||
|
||||
```
|
||||
lmvpn_client/
|
||||
├── cmd/
|
||||
│ ├── lmvpn/ # GUI 入口
|
||||
│ └── lmvpnd/ # 守护进程入口
|
||||
├── internal/
|
||||
│ ├── auth/ # 认证(JWT / 用户名密码)
|
||||
│ ├── config/ # 配置文件解析(TOML / YAML)
|
||||
│ ├── daemon/ # 守护进程生命周期、IPC、提权拉起
|
||||
│ ├── db/ # SQLite 存储、Profile、日志记录
|
||||
│ ├── i18n/ # 国际化(en / zh-Hans)
|
||||
│ ├── ipc/ # GUI ↔ 守护进程 IPC 协议
|
||||
│ ├── keychain/ # 密钥链(darwin/windows/other)
|
||||
│ ├── log/ # 日志(lumberjack 轮转)
|
||||
│ ├── model/ # 数据模型
|
||||
│ ├── paths/ # 平台路径解析(darwin/windows/other)
|
||||
│ ├── protocol/ # 与服务端的 WebSocket 协议
|
||||
│ ├── route/ # 路由管理(全量/分流/自定义)
|
||||
│ ├── stats/ # 流量统计
|
||||
│ ├── transport/ # WebSocket 传输层
|
||||
│ ├── tun/ # TUN 虚拟网卡(darwin/linux/windows)
|
||||
│ ├── ui/ # Fyne 界面、托盘、提权、Dock
|
||||
│ ├── version/ # 版本号(链接期注入)
|
||||
│ └── vpn/ # VPN 会话管理
|
||||
├── resources/ # 图标、Info.plist、wintun.dll、资源生成器
|
||||
├── installer/ # Inno Setup 安装脚本(lmvpn.iss)
|
||||
├── docs/ # 开发文档(协议规范)
|
||||
└── Makefile
|
||||
```
|
||||
|
||||
### 平台适配约定
|
||||
|
||||
项目使用 Go 文件后缀构建约束实现平台适配,主要分布:
|
||||
|
||||
| 模块 | macOS | Windows | Linux |
|
||||
|------|-------|---------|-------|
|
||||
| `tun` | `tun_darwin.go`(water/utun) | `tun_windows.go`(wintun + embed dll) | `tun_linux.go`(water/tun) |
|
||||
| `route` | `route_darwin.go`(`route`) | `route_windows.go`(`route`) | `route_linux.go`(`ip route`) |
|
||||
| `keychain` | `keychain_darwin.go`(Keychain) | `keychain_windows.go`(Credential Manager) | `keychain_other.go`(内存) |
|
||||
| `paths` | `paths_darwin.go` | `paths_windows.go` | `paths_other.go`(XDG) |
|
||||
| `daemon` 提权 | `launch_unix.go` + osascript | `launch_windows.go` + UAC runas | `launch_unix.go` + pkexec |
|
||||
| `ui` Dock | `dock_darwin.go`(Cocoa CGO) | `dock_other.go`(no-op) | `dock_other.go`(no-op) |
|
||||
|
||||
---
|
||||
|
||||
## 开发指南
|
||||
|
||||
```bash
|
||||
# 静态检查
|
||||
make vet
|
||||
|
||||
# 格式化
|
||||
make fmt
|
||||
|
||||
# 整理依赖
|
||||
make tidy
|
||||
|
||||
# 清理构建产物
|
||||
make clean
|
||||
```
|
||||
|
||||
### 添加新平台适配
|
||||
|
||||
如需适配新的平台能力,参考现有约定新增带构建约束的文件,例如:
|
||||
|
||||
- TUN 实现 → `internal/tun/tun_<os>.go`
|
||||
- 路由命令 → `internal/route/route_<os>.go`
|
||||
- 密钥存储 → `internal/keychain/keychain_<os>.go`
|
||||
- 路径解析 → `internal/paths/paths_<os>.go`
|
||||
|
||||
### 协议规范
|
||||
|
||||
客户端与服务端的完整通信协议见 [`docs/client-development.md`](docs/client-development.md)(含认证、握手、数据面、心跳、错误码等)。
|
||||
|
||||
---
|
||||
|
||||
## 注意事项
|
||||
|
||||
- **不支持移动端**:虽 Fyne 支持 Android/iOS,本项目未配置移动端构建目标与适配代码
|
||||
- **Windows 仅 x64**:交叉编译仅目标 `GOARCH=amd64`,内置的 `wintun.dll` 为 x64 版本
|
||||
- **Linux 密钥链待完善**:当前为内存存储,凭据不持久化,生产环境建议接入 Secret Service
|
||||
- **CI/CD**:通过 GitHub Actions 自动构建 macOS / Windows 产物并发布 Release(见 [`.github/workflows/release.yml`](.github/workflows/release.yml)),打 `v*` tag 即触发
|
||||
- **CGO 必开**:任何平台都不可关闭 CGO,否则 Fyne 无法编译
|
||||
- **版本一致性**:GUI 与守护进程共享同一版本字符串,若版本不一致通常意味着旧守护进程仍在运行
|
||||
|
||||
---
|
||||
|
||||
## 许可证
|
||||
|
||||
详见仓库 LICENSE 文件。
|
||||
Binary file not shown.
Binary file not shown.
@@ -4,10 +4,12 @@ go 1.26
|
||||
|
||||
require (
|
||||
github.com/BurntSushi/toml v1.5.0
|
||||
github.com/danieljoos/wincred v1.2.3
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/jeandeaual/go-locale v0.0.0-20250612000132-0ef82f21eade
|
||||
github.com/nicksnyder/go-i18n/v2 v2.5.1
|
||||
github.com/songgao/water v0.0.0-20200317203138-2b4b6d7c09d8
|
||||
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
modernc.org/sqlite v1.34.1
|
||||
@@ -54,7 +56,7 @@ require (
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
golang.org/x/sys v0.46.0 // indirect
|
||||
golang.org/x/sys v0.46.0
|
||||
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect
|
||||
modernc.org/libc v1.55.3 // indirect
|
||||
modernc.org/mathutil v1.6.0 // indirect
|
||||
|
||||
@@ -4,6 +4,8 @@ fyne.io/systray v1.12.1 h1:ygBD6aZXwiOmZoY5N+ukbH9pih0Kq6fYgVeMYbr5skQ=
|
||||
fyne.io/systray v1.12.1/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs=
|
||||
github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg=
|
||||
github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
||||
github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ=
|
||||
github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7MRLQK4X0bs=
|
||||
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/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
@@ -83,6 +85,8 @@ github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c h1:km8GpoQut05eY3GiY
|
||||
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c/go.mod h1:cNQ3dwVJtS5Hmnjxy6AgTPd0Inb3pW05ftPSX7NZO7Q=
|
||||
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef h1:Ch6Q+AZUxDBCVqdkI8FSpFyZDtCVBc2VmejdNrm5rRQ=
|
||||
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef/go.mod h1:nXTWP6+gD5+LUJ8krVhhoeHjvHTutPxMYl5SvkcnJNE=
|
||||
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic=
|
||||
@@ -102,6 +106,8 @@ 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.24.1 h1:vxuHLTNS3Np5zrYoPRpcheASHX/7KiGo+8Y4ZM1J2O8=
|
||||
golang.org/x/tools v0.24.1/go.mod h1:YhNqVBIfWHdzvTLs0d8LCuMhkKUgSUKldakyV7W/WDQ=
|
||||
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg=
|
||||
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU=
|
||||
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
|
||||
@@ -0,0 +1,417 @@
|
||||
; *** Inno Setup version 6.5.0+ Chinese Simplified messages ***
|
||||
;
|
||||
; To download user-contributed translations of this file, go to:
|
||||
; https://jrsoftware.org/files/istrans/
|
||||
;
|
||||
; Note: When translating this text, do not add periods (.) to the end of
|
||||
; messages that didn't have them already, because on those messages Inno
|
||||
; Setup adds the periods automatically (appending a period would result in
|
||||
; two periods being displayed).
|
||||
;
|
||||
; Maintainer: Zhenghan Yang (Kira)
|
||||
; Email: 847320916@QQ.com
|
||||
; Github: https://github.com/kira-96/Inno-Setup-Chinese-Simplified-Translation
|
||||
; Encoding: UTF-8
|
||||
; Translation based on network resource
|
||||
;
|
||||
|
||||
[LangOptions]
|
||||
; The following three entries are very important. Be sure to read and
|
||||
; understand the '[LangOptions] section' topic in the help file.
|
||||
LanguageName=简体中文
|
||||
; About LanguageID, to reference link:
|
||||
; https://docs.microsoft.com/en-us/openspecs/windows_protocols/ms-lcid/a9eac961-e77d-41a6-90a5-ce1a8b0cdb9c
|
||||
LanguageID=$0804
|
||||
; LanguageCodePage should always be set if possible, even if this file is Unicode
|
||||
; For English it's set to zero anyway because English only uses ASCII characters
|
||||
LanguageCodePage=936
|
||||
; If the language you are translating to requires special font faces or
|
||||
; sizes, uncomment any of the following entries and change them accordingly.
|
||||
;DialogFontName=
|
||||
;DialogFontSize=9
|
||||
;DialogFontBaseScaleWidth=7
|
||||
;DialogFontBaseScaleHeight=15
|
||||
;WelcomeFontName=Segoe UI
|
||||
;WelcomeFontSize=14
|
||||
|
||||
[Messages]
|
||||
|
||||
; *** Application titles
|
||||
SetupAppTitle=安装
|
||||
SetupWindowTitle=安装 - %1
|
||||
UninstallAppTitle=卸载
|
||||
UninstallAppFullTitle=%1 卸载
|
||||
|
||||
; *** Misc. common
|
||||
InformationTitle=信息
|
||||
ConfirmTitle=确认
|
||||
ErrorTitle=错误
|
||||
|
||||
; *** SetupLdr messages
|
||||
SetupLdrStartupMessage=现在将安装 %1。您想要继续吗?
|
||||
LdrCannotCreateTemp=无法创建临时文件。安装程序已中止
|
||||
LdrCannotExecTemp=无法执行临时目录中的文件。安装程序已中止
|
||||
HelpTextNote=
|
||||
|
||||
; *** Startup error messages
|
||||
LastErrorMessage=%1。%n%n错误 %2: %3
|
||||
SetupFileMissing=安装目录中缺少文件 %1。请修正这个问题或者获取程序的新副本。
|
||||
SetupFileCorrupt=安装文件已损坏。请获取程序的新副本。
|
||||
SetupFileCorruptOrWrongVer=安装文件已损坏,或是与这个安装程序的版本不兼容。请修正这个问题或获取新的程序副本。
|
||||
InvalidParameter=无效的命令行参数:%n%n%1
|
||||
SetupAlreadyRunning=安装程序已在运行。
|
||||
WindowsVersionNotSupported=此程序不支持当前计算机运行的 Windows 版本。
|
||||
WindowsServicePackRequired=此程序需要 %1 服务包 %2 或更高版本。
|
||||
NotOnThisPlatform=此程序不能在 %1 上运行。
|
||||
OnlyOnThisPlatform=此程序只能在 %1 上运行。
|
||||
OnlyOnTheseArchitectures=此程序只能安装到为下列处理器架构设计的 Windows 版本中:%n%n%1
|
||||
WinVersionTooLowError=此程序需要 %1 版本 %2 或更高。
|
||||
WinVersionTooHighError=此程序不能安装于 %1 版本 %2 或更高。
|
||||
AdminPrivilegesRequired=在安装此程序时您必须以管理员身份登录。
|
||||
PowerUserPrivilegesRequired=在安装此程序时您必须以管理员身份或高级用户组身份登录。
|
||||
SetupAppRunningError=安装程序检测到 %1 当前正在运行。%n%n请先关闭正在运行的程序,然后点击“确定”继续,或点击“取消”退出。
|
||||
UninstallAppRunningError=卸载程序检测到 %1 当前正在运行。%n%n请先关闭正在运行的程序,然后点击“确定”继续,或点击“取消”退出。
|
||||
|
||||
; *** Startup questions
|
||||
PrivilegesRequiredOverrideTitle=选择安装程序安装模式
|
||||
PrivilegesRequiredOverrideInstruction=选择安装模式
|
||||
PrivilegesRequiredOverrideText1=%1 可以为所有用户安装(需要管理员权限),或仅为您安装。
|
||||
PrivilegesRequiredOverrideText2=%1 可以仅为您安装,或为所有用户安装(需要管理员权限)。
|
||||
PrivilegesRequiredOverrideAllUsers=为所有用户安装(&A)
|
||||
PrivilegesRequiredOverrideAllUsersRecommended=为所有用户安装(&A)(推荐)
|
||||
PrivilegesRequiredOverrideCurrentUser=仅为我安装(&M)
|
||||
PrivilegesRequiredOverrideCurrentUserRecommended=仅为我安装(&M)(推荐)
|
||||
|
||||
; *** Misc. errors
|
||||
ErrorCreatingDir=安装程序无法创建目录“%1”
|
||||
ErrorTooManyFilesInDir=无法在目录“%1”中创建文件,因为里面包含太多文件
|
||||
|
||||
; *** Setup common messages
|
||||
ExitSetupTitle=退出安装程序
|
||||
ExitSetupMessage=安装程序尚未完成。如果现在退出,将不会安装该程序。%n%n您之后可以再次运行安装程序完成安装。%n%n现在退出安装程序吗?
|
||||
AboutSetupMenuItem=关于安装程序(&A)...
|
||||
AboutSetupTitle=关于安装程序
|
||||
AboutSetupMessage=%1 版本 %2%n%3%n%n%1 主页:%n%4
|
||||
AboutSetupNote=
|
||||
TranslatorNote=简体中文翻译由 Kira(847320916@qq.com)维护。项目地址:https://github.com/kira-96/Inno-Setup-Chinese-Simplified-Translation
|
||||
|
||||
; *** Buttons
|
||||
ButtonBack=< 上一步(&B)
|
||||
ButtonNext=下一步(&N) >
|
||||
ButtonInstall=安装(&I)
|
||||
ButtonOK=确定
|
||||
ButtonCancel=取消
|
||||
ButtonYes=是(&Y)
|
||||
ButtonYesToAll=全是(&A)
|
||||
ButtonNo=否(&N)
|
||||
ButtonNoToAll=全否(&O)
|
||||
ButtonFinish=完成(&F)
|
||||
ButtonBrowse=浏览(&B)...
|
||||
ButtonWizardBrowse=浏览(&R)...
|
||||
ButtonNewFolder=新建文件夹(&M)
|
||||
|
||||
; *** "Select Language" dialog messages
|
||||
SelectLanguageTitle=选择安装语言
|
||||
SelectLanguageLabel=选择安装时使用的语言。
|
||||
|
||||
; *** Common wizard text
|
||||
ClickNext=点击“下一步”继续,或点击“取消”退出安装程序。
|
||||
BeveledLabel=
|
||||
BrowseDialogTitle=浏览文件夹
|
||||
BrowseDialogLabel=在下面的列表中选择一个文件夹,然后点击“确定”。
|
||||
NewFolderName=新建文件夹
|
||||
|
||||
; *** "Welcome" wizard page
|
||||
WelcomeLabel1=欢迎使用 [name] 安装向导
|
||||
WelcomeLabel2=即将在您的计算机上安装 [name/ver]。%n%n建议您在继续安装前关闭所有其他应用程序。
|
||||
|
||||
; *** "Password" wizard page
|
||||
WizardPassword=密码
|
||||
PasswordLabel1=此安装程序需要密码验证。
|
||||
PasswordLabel3=请输入密码,然后点击“下一步”继续。密码区分大小写。
|
||||
PasswordEditLabel=密码(&P):
|
||||
IncorrectPassword=您输入的密码不正确,请重新输入。
|
||||
|
||||
; *** "License Agreement" wizard page
|
||||
WizardLicense=许可协议
|
||||
LicenseLabel=请在继续安装前阅读以下重要信息。
|
||||
LicenseLabel3=请阅读下列许可协议。在继续安装前您必须同意这些协议条款。
|
||||
LicenseAccepted=我同意此协议(&A)
|
||||
LicenseNotAccepted=我不同意此协议(&D)
|
||||
|
||||
; *** "Information" wizard pages
|
||||
WizardInfoBefore=信息
|
||||
InfoBeforeLabel=请在继续安装前阅读以下重要信息。
|
||||
InfoBeforeClickLabel=准备好继续安装后,点击“下一步”。
|
||||
WizardInfoAfter=信息
|
||||
InfoAfterLabel=请在继续安装前阅读以下重要信息。
|
||||
InfoAfterClickLabel=准备好继续安装后,点击“下一步”。
|
||||
|
||||
; *** "User Information" wizard page
|
||||
WizardUserInfo=用户信息
|
||||
UserInfoDesc=请输入您的信息。
|
||||
UserInfoName=用户名(&U):
|
||||
UserInfoOrg=组织(&O):
|
||||
UserInfoSerial=序列号(&S):
|
||||
UserInfoNameRequired=请输入用户名。
|
||||
|
||||
; *** "Select Destination Location" wizard page
|
||||
WizardSelectDir=选择目标位置
|
||||
SelectDirDesc=您想将 [name] 安装在哪里?
|
||||
SelectDirLabel3=安装程序将安装 [name] 到下面的文件夹中。
|
||||
SelectDirBrowseLabel=点击“下一步”继续。如果您想选择其他文件夹,点击“浏览”。
|
||||
DiskSpaceGBLabel=至少需要有 [gb] GB 的可用磁盘空间。
|
||||
DiskSpaceMBLabel=至少需要有 [mb] MB 的可用磁盘空间。
|
||||
CannotInstallToNetworkDrive=安装程序无法安装到一个网络驱动器。
|
||||
CannotInstallToUNCPath=安装程序无法安装到一个 UNC 路径。
|
||||
InvalidPath=您必须输入一个带驱动器盘符的完整路径,例如:%n%nC:\App%n%n或UNC路径:%n%n\\server\share
|
||||
InvalidDrive=您选定的驱动器或 UNC 共享不存在或不能访问。请选择其他位置。
|
||||
DiskSpaceWarningTitle=磁盘空间不足
|
||||
DiskSpaceWarning=安装程序至少需要 %1 KB 的可用空间才能安装,但选定驱动器只有 %2 KB 的可用空间。%n%n您确定要继续吗?
|
||||
DirNameTooLong=文件夹名称或路径太长。
|
||||
InvalidDirName=文件夹名称无效。
|
||||
BadDirName32=文件夹名称不能包含下列任何字符:%n%n%1
|
||||
DirExistsTitle=文件夹已存在
|
||||
DirExists=文件夹:%n%n%1%n%n已经存在。您确定安装到这个文件夹中吗?
|
||||
DirDoesntExistTitle=文件夹不存在
|
||||
DirDoesntExist=文件夹:%n%n%1%n%n不存在。您想要创建此文件夹吗?
|
||||
|
||||
; *** "Select Components" wizard page
|
||||
WizardSelectComponents=选择组件
|
||||
SelectComponentsDesc=您想安装哪些程序组件?
|
||||
SelectComponentsLabel2=选中您想安装的组件;取消您不想安装的组件。然后点击“下一步”继续。
|
||||
FullInstallation=完全安装
|
||||
; if possible don't translate 'Compact' as 'Minimal' (I mean 'Minimal' in your language)
|
||||
CompactInstallation=简洁安装
|
||||
CustomInstallation=自定义安装
|
||||
NoUninstallWarningTitle=组件已存在
|
||||
NoUninstallWarning=安装程序检测到下列组件已安装在您的计算机中:%n%n%1%n%n取消选中这些组件不会卸载它们。%n%n您确定要继续吗?
|
||||
ComponentSize1=%1 KB
|
||||
ComponentSize2=%1 MB
|
||||
ComponentsDiskSpaceGBLabel=当前选择的组件需要至少 [gb] GB 的磁盘空间。
|
||||
ComponentsDiskSpaceMBLabel=当前选择的组件需要至少 [mb] MB 的磁盘空间。
|
||||
|
||||
; *** "Select Additional Tasks" wizard page
|
||||
WizardSelectTasks=选择附加任务
|
||||
SelectTasksDesc=您想要安装程序执行哪些附加任务?
|
||||
SelectTasksLabel2=选择您想要安装程序在安装 [name] 时执行的附加任务,然后点击“下一步”。
|
||||
|
||||
; *** "Select Start Menu Folder" wizard page
|
||||
WizardSelectProgramGroup=选择开始菜单文件夹
|
||||
SelectStartMenuFolderDesc=安装程序应该在哪里放置程序的快捷方式?
|
||||
SelectStartMenuFolderLabel3=安装程序将在下列“开始”菜单文件夹中创建程序的快捷方式。
|
||||
SelectStartMenuFolderBrowseLabel=点击“下一步”继续。如果您想选择其他文件夹,点击“浏览”。
|
||||
MustEnterGroupName=您必须输入一个文件夹名称。
|
||||
GroupNameTooLong=文件夹名称或路径太长。
|
||||
InvalidGroupName=文件夹名称无效。
|
||||
BadGroupName=文件夹名称不能包含下列任何字符:%n%n%1
|
||||
NoProgramGroupCheck2=不创建开始菜单文件夹(&D)
|
||||
|
||||
; *** "Ready to Install" wizard page
|
||||
WizardReady=准备安装
|
||||
ReadyLabel1=安装程序准备就绪,现在可以开始安装 [name] 到您的计算机。
|
||||
ReadyLabel2a=点击“安装”继续此安装程序。如果您想重新查看或修改任何设置,点击“上一步”。
|
||||
ReadyLabel2b=点击“安装”继续此安装程序。
|
||||
ReadyMemoUserInfo=用户信息:
|
||||
ReadyMemoDir=目标位置:
|
||||
ReadyMemoType=安装类型:
|
||||
ReadyMemoComponents=已选择组件:
|
||||
ReadyMemoGroup=开始菜单文件夹:
|
||||
ReadyMemoTasks=附加任务:
|
||||
|
||||
; *** TDownloadWizardPage wizard page and DownloadTemporaryFile
|
||||
DownloadingLabel2=正在下载文件...
|
||||
ButtonStopDownload=停止下载(&S)
|
||||
StopDownload=您确定要停止下载吗?
|
||||
ErrorDownloadAborted=下载已中止
|
||||
ErrorDownloadFailed=下载失败:%1 %2
|
||||
ErrorDownloadSizeFailed=获取大小失败:%1 %2
|
||||
ErrorProgress=无效的进度:%1 / %2
|
||||
ErrorFileSize=文件大小错误:预期 %1,实际 %2
|
||||
|
||||
; *** TExtractionWizardPage wizard page and ExtractArchive
|
||||
ExtractingLabel=正在提取文件...
|
||||
ButtonStopExtraction=停止提取(&S)
|
||||
StopExtraction=您确定要停止提取吗?
|
||||
ErrorExtractionAborted=提取已中止
|
||||
ErrorExtractionFailed=提取失败:%1
|
||||
|
||||
; *** Archive extraction failure details
|
||||
ArchiveIncorrectPassword=密码不正确
|
||||
ArchiveIsCorrupted=压缩包已损坏
|
||||
ArchiveUnsupportedFormat=不支持的压缩包格式
|
||||
|
||||
; *** "Preparing to Install" wizard page
|
||||
WizardPreparing=正在准备安装
|
||||
PreparingDesc=安装程序正在准备安装 [name] 到您的计算机。
|
||||
PreviousInstallNotCompleted=先前的程序安装或卸载未完成,需要您重启计算机以完成该安装。%n%n在重启计算机后,再次运行安装程序以完成 [name] 的安装。
|
||||
CannotContinue=安装程序不能继续。请点击“取消”退出。
|
||||
ApplicationsFound=以下应用程序正在使用将由安装程序更新的文件。建议您允许安装程序自动关闭这些应用程序。
|
||||
ApplicationsFound2=以下应用程序正在使用将由安装程序更新的文件。建议您允许安装程序自动关闭这些应用程序。安装完成后,安装程序将尝试重新启动这些应用程序。
|
||||
CloseApplications=自动关闭应用程序(&A)
|
||||
DontCloseApplications=不要关闭应用程序(&D)
|
||||
ErrorCloseApplications=安装程序无法自动关闭所有应用程序。建议您在继续之前,关闭所有在使用需要由安装程序更新的文件的应用程序。
|
||||
PrepareToInstallNeedsRestart=安装程序必须重启您的计算机。计算机重启后,请再次运行安装程序以完成 [name] 的安装。%n%n要立即重启吗?
|
||||
|
||||
; *** "Installing" wizard page
|
||||
WizardInstalling=正在安装
|
||||
InstallingLabel=安装程序正在安装 [name] 到您的计算机,请稍候。
|
||||
|
||||
; *** "Setup Completed" wizard page
|
||||
FinishedHeadingLabel=完成 [name] 安装向导
|
||||
FinishedLabelNoIcons=安装程序已在您的计算机中安装了 [name]。
|
||||
FinishedLabel=安装程序已在您的计算机中安装了 [name]。您可以通过已安装的快捷方式运行此应用程序。
|
||||
ClickFinish=点击“完成”退出安装程序。
|
||||
FinishedRestartLabel=为完成 [name] 的安装,安装程序必须重新启动您的计算机。要立即重启吗?
|
||||
FinishedRestartMessage=为完成 [name] 的安装,安装程序必须重新启动您的计算机。%n%n要立即重启吗?
|
||||
ShowReadmeCheck=是,我想查阅自述文件
|
||||
YesRadio=是,立即重启计算机(&Y)
|
||||
NoRadio=否,稍后重启计算机(&N)
|
||||
; used for example as 'Run MyProg.exe'
|
||||
RunEntryExec=运行 %1
|
||||
; used for example as 'View Readme.txt'
|
||||
RunEntryShellExec=查阅 %1
|
||||
|
||||
; *** "Setup Needs the Next Disk" stuff
|
||||
ChangeDiskTitle=安装程序需要下一张磁盘
|
||||
SelectDiskLabel2=请插入磁盘 %1 并点击“确定”。%n%n如果这个磁盘中的文件可以在下列文件夹之外的文件夹中找到,请输入正确的路径或点击“浏览”。
|
||||
PathLabel=路径(&P):
|
||||
FileNotInDir2=“%2”中找不到文件“%1”。请插入正确的磁盘或选择其他文件夹。
|
||||
SelectDirectoryLabel=请指定下一张磁盘的位置。
|
||||
|
||||
; *** Installation phase messages
|
||||
SetupAborted=安装程序未完成安装。%n%n请修正这个问题并重新运行安装程序。
|
||||
AbortRetryIgnoreSelectAction=选择操作
|
||||
AbortRetryIgnoreRetry=重试(&T)
|
||||
AbortRetryIgnoreIgnore=忽略错误并继续(&I)
|
||||
AbortRetryIgnoreCancel=取消安装
|
||||
RetryCancelSelectAction=选择操作
|
||||
RetryCancelRetry=重试(&T)
|
||||
RetryCancelCancel=取消
|
||||
|
||||
; *** Installation status messages
|
||||
StatusClosingApplications=正在关闭应用程序...
|
||||
StatusCreateDirs=正在创建目录...
|
||||
StatusExtractFiles=正在提取文件...
|
||||
StatusDownloadFiles=正在下载文件...
|
||||
StatusCreateIcons=正在创建快捷方式...
|
||||
StatusCreateIniEntries=正在创建 INI 条目...
|
||||
StatusCreateRegistryEntries=正在创建注册表条目...
|
||||
StatusRegisterFiles=正在注册文件...
|
||||
StatusSavingUninstall=正在保存卸载信息...
|
||||
StatusRunProgram=正在完成安装...
|
||||
StatusRestartingApplications=正在重启应用程序...
|
||||
StatusRollback=正在撤销更改...
|
||||
|
||||
; *** Misc. errors
|
||||
ErrorInternal2=内部错误:%1
|
||||
ErrorFunctionFailedNoCode=%1 失败
|
||||
ErrorFunctionFailed=%1 失败;错误代码 %2
|
||||
ErrorFunctionFailedWithMessage=%1 失败;错误代码 %2.%n%3
|
||||
ErrorExecutingProgram=无法执行文件:%n%1
|
||||
|
||||
; *** Registry errors
|
||||
ErrorRegOpenKey=打开注册表项时出错:%n%1\%2
|
||||
ErrorRegCreateKey=创建注册表项时出错:%n%1\%2
|
||||
ErrorRegWriteKey=写入注册表项时出错:%n%1\%2
|
||||
|
||||
; *** INI errors
|
||||
ErrorIniEntry=在文件“%1”中创建 INI 条目时出错。
|
||||
|
||||
; *** File copying errors
|
||||
FileAbortRetryIgnoreSkipNotRecommended=跳过此文件(&S)(不推荐)
|
||||
FileAbortRetryIgnoreIgnoreNotRecommended=忽略错误并继续(&I)(不推荐)
|
||||
SourceIsCorrupted=源文件已损坏
|
||||
SourceDoesntExist=源文件“%1”不存在
|
||||
SourceVerificationFailed=源文件验证失败:%1
|
||||
VerificationSignatureDoesntExist=签名文件“%1”不存在
|
||||
VerificationSignatureInvalid=签名文件“%1”无效
|
||||
VerificationKeyNotFound=签名文件“%1”使用了未知的密钥
|
||||
VerificationFileNameIncorrect=文件名不正确
|
||||
VerificationFileTagIncorrect=文件标签不正确
|
||||
VerificationFileSizeIncorrect=文件大小不正确
|
||||
VerificationFileHashIncorrect=文件哈希值不正确
|
||||
ExistingFileReadOnly2=无法替换已存在的文件,它是只读的。
|
||||
ExistingFileReadOnlyRetry=移除只读属性并重试(&R)
|
||||
ExistingFileReadOnlyKeepExisting=保留已存在的文件(&K)
|
||||
ErrorReadingExistingDest=尝试读取已存在的文件时出错:
|
||||
FileExistsSelectAction=选择操作
|
||||
FileExists2=文件已经存在。
|
||||
FileExistsOverwriteExisting=覆盖已存在的文件(&O)
|
||||
FileExistsKeepExisting=保留已存在的文件(&K)
|
||||
FileExistsOverwriteOrKeepAll=为接下来的冲突文件执行此操作(&D)
|
||||
ExistingFileNewerSelectAction=选择操作
|
||||
ExistingFileNewer2=已存在的文件比安装程序将要安装的文件还要新。
|
||||
ExistingFileNewerOverwriteExisting=覆盖已存在的文件(&O)
|
||||
ExistingFileNewerKeepExisting=保留已存在的文件(&K)(推荐)
|
||||
ExistingFileNewerOverwriteOrKeepAll=为接下来的冲突文件执行此操作(&D)
|
||||
ErrorChangingAttr=尝试更改下列已存在的文件属性时出错:
|
||||
ErrorCreatingTemp=尝试在目标目录创建文件时出错:
|
||||
ErrorReadingSource=尝试读取下列源文件时出错:
|
||||
ErrorCopying=尝试复制下列文件时出错:
|
||||
ErrorDownloading=尝试下载文件时出错:
|
||||
ErrorExtracting=尝试提取压缩包时出错:
|
||||
ErrorReplacingExistingFile=尝试替换已存在的文件时出错:
|
||||
ErrorRestartReplace=重启并替换失败:
|
||||
ErrorRenamingTemp=尝试重命名下列目标目录中的一个文件时出错:
|
||||
ErrorRegisterServer=无法注册 DLL/OCX:%1
|
||||
ErrorRegSvr32Failed=RegSvr32 失败;退出代码 %1
|
||||
ErrorRegisterTypeLib=无法注册类型库:%1
|
||||
|
||||
; *** Uninstall display name markings
|
||||
; used for example as 'My Program (32-bit)'
|
||||
UninstallDisplayNameMark=%1 (%2)
|
||||
; used for example as 'My Program (32-bit, All users)'
|
||||
UninstallDisplayNameMarks=%1 (%2, %3)
|
||||
UninstallDisplayNameMark32Bit=32 位
|
||||
UninstallDisplayNameMark64Bit=64 位
|
||||
UninstallDisplayNameMarkAllUsers=所有用户
|
||||
UninstallDisplayNameMarkCurrentUser=当前用户
|
||||
|
||||
; *** Post-installation errors
|
||||
ErrorOpeningReadme=尝试打开自述文件时出错。
|
||||
ErrorRestartingComputer=安装程序无法重启计算机,请手动重启。
|
||||
|
||||
; *** Uninstaller messages
|
||||
UninstallNotFound=文件“%1”不存在。无法卸载。
|
||||
UninstallOpenError=文件“%1”不能被打开。无法卸载
|
||||
UninstallUnsupportedVer=此版本的卸载程序无法识别卸载日志文件“%1”的格式。无法卸载
|
||||
UninstallUnknownEntry=卸载日志中遇到一个未知条目(%1)
|
||||
ConfirmUninstall=您确认要完全移除 %1 及其所有组件吗?
|
||||
UninstallOnlyOnWin64=仅允许在 64 位 Windows 中卸载此程序。
|
||||
OnlyAdminCanUninstall=仅使用管理员权限的用户能完成此卸载。
|
||||
UninstallStatusLabel=正在从您的计算机中移除 %1,请稍候。
|
||||
UninstalledAll=已顺利从您的计算机中移除 %1。
|
||||
UninstalledMost=%1 卸载完成。%n%n有部分内容未能被删除,但您可以手动删除它们。
|
||||
UninstalledAndNeedsRestart=为完成 %1 的卸载,需要重启您的计算机。%n%n要立即重启吗?
|
||||
UninstallDataCorrupted=文件“%1”已损坏。无法卸载
|
||||
|
||||
; *** Uninstallation phase messages
|
||||
ConfirmDeleteSharedFileTitle=删除共享文件?
|
||||
ConfirmDeleteSharedFile2=系统表示下列共享文件已不再有任何程序使用。您希望卸载程序删除此共享文件吗?%n%n如果仍有程序正在使用此文件,删除后这些程序可能无法正常运行。如果您不能确定,请选择“否”,保留此文件在系统中不会造成任何损害。
|
||||
SharedFileNameLabel=文件名:
|
||||
SharedFileLocationLabel=位置:
|
||||
WizardUninstalling=卸载状态
|
||||
StatusUninstalling=正在卸载 %1...
|
||||
|
||||
; *** Shutdown block reasons
|
||||
ShutdownBlockReasonInstallingApp=正在安装 %1。
|
||||
ShutdownBlockReasonUninstallingApp=正在卸载 %1。
|
||||
|
||||
; The custom messages below aren't used by Setup itself, but if you make
|
||||
; use of them in your scripts, you'll want to translate them.
|
||||
|
||||
[CustomMessages]
|
||||
|
||||
NameAndVersion=%1 版本 %2
|
||||
AdditionalIcons=附加快捷方式:
|
||||
CreateDesktopIcon=创建桌面快捷方式(&D)
|
||||
CreateQuickLaunchIcon=创建快速启动栏快捷方式(&Q)
|
||||
ProgramOnTheWeb=%1 网站
|
||||
UninstallProgram=卸载 %1
|
||||
LaunchProgram=运行 %1
|
||||
AssocFileExtension=将 %2 文件扩展名与 %1 建立关联(&A)
|
||||
AssocingFileExtension=正在将 %2 文件扩展名与 %1 建立关联...
|
||||
AutoStartProgramGroupDescription=启动:
|
||||
AutoStartProgram=自动启动 %1
|
||||
AddonHostProgramNotFound=您选择的文件夹中无法找到 %1。%n%n您确定要继续吗?
|
||||
@@ -0,0 +1,145 @@
|
||||
; LMVPN Windows Installer — Inno Setup script
|
||||
;
|
||||
; Build:
|
||||
; ISCC /DAppVersion=0.3.6-abc123 installer/lmvpn.iss
|
||||
; wine "C:\Program Files (x86)\Inno Setup 6\ISCC.exe" /DAppVersion=0.3.6-abc123 installer/lmvpn.iss
|
||||
;
|
||||
; The exes (lmvpn.exe, lmvpnd.exe) must exist in ../build/ before compiling.
|
||||
; Run `make build-windows` first (or `make installer-windows` which does both).
|
||||
|
||||
#ifndef AppVersion
|
||||
#define AppVersion "dev"
|
||||
#endif
|
||||
|
||||
#define AppName "LMVPN"
|
||||
#define AppPublisher "LMVPN"
|
||||
#define AppExeName "lmvpn.exe"
|
||||
#define DaemonExeName "lmvpnd.exe"
|
||||
#define AppURL "https://github.com/lmvpn/lmvpn_client"
|
||||
|
||||
[Setup]
|
||||
AppId={{8F7B3A2E-1D4C-4B5E-9F6A-2E3C7D8E1F0A}
|
||||
AppName={#AppName}
|
||||
AppVersion={#AppVersion}
|
||||
AppVerName={#AppName} {#AppVersion}
|
||||
AppPublisher={#AppPublisher}
|
||||
AppPublisherURL={#AppURL}
|
||||
AppSupportURL={#AppURL}
|
||||
AppUpdatesURL={#AppURL}
|
||||
DefaultDirName={autopf}\{#AppName}
|
||||
DefaultGroupName={#AppName}
|
||||
DisableProgramGroupPage=yes
|
||||
PrivilegesRequired=admin
|
||||
ArchitecturesAllowed=x64compatible
|
||||
ArchitecturesInstallIn64BitMode=x64compatible
|
||||
Compression=lzma2
|
||||
SolidCompression=yes
|
||||
WizardStyle=modern
|
||||
OutputDir=..\build
|
||||
OutputBaseFilename=LMVPN-Setup-{#AppVersion}
|
||||
SetupIconFile=..\resources\icon.ico
|
||||
UninstallDisplayIcon={app}\{#AppExeName}
|
||||
UninstallDisplayName={#AppName}
|
||||
|
||||
[Languages]
|
||||
Name: "chinesesimp"; MessagesFile: "ChineseSimplified.isl"
|
||||
Name: "english"; MessagesFile: "compiler:Default.isl"
|
||||
|
||||
[Tasks]
|
||||
Name: "desktopicon"; Description: "创建桌面快捷方式(&D)"; GroupDescription: "附加选项:"; Flags: unchecked
|
||||
|
||||
[Files]
|
||||
Source: "..\build\{#AppExeName}"; DestDir: "{app}"; Flags: ignoreversion
|
||||
Source: "..\build\{#DaemonExeName}"; DestDir: "{app}"; Flags: ignoreversion
|
||||
Source: "..\resources\wintun.dll"; DestDir: "{app}"; Flags: ignoreversion
|
||||
|
||||
[Icons]
|
||||
Name: "{group}\{#AppName}"; Filename: "{app}\{#AppExeName}"
|
||||
Name: "{group}\卸载 {#AppName}"; Filename: "{uninstallexe}"
|
||||
Name: "{commondesktop}\{#AppName}"; Filename: "{app}\{#AppExeName}"; Tasks: desktopicon
|
||||
|
||||
[Run]
|
||||
Filename: "{app}\{#AppExeName}"; Description: "立即启动 {#AppName}"; Flags: nowait postinstall skipifsilent
|
||||
|
||||
[UninstallDelete]
|
||||
Type: files; Name: "{app}\wintun.dll"
|
||||
|
||||
[Code]
|
||||
|
||||
// Kill running LMVPN processes (GUI + daemon) by image name.
|
||||
// Returns True if at least one process was found and killed.
|
||||
function KillProcess(const ExeName: String): Boolean;
|
||||
var
|
||||
ResultCode: Integer;
|
||||
begin
|
||||
Result := False;
|
||||
// taskkill /IM <name> /F /T — force-kill process tree
|
||||
if Exec(ExpandConstant('{cmd}'), '/C taskkill /IM ' + ExeName + ' /F /T',
|
||||
'', SW_HIDE, ewWaitUntilTerminated, ResultCode) then
|
||||
begin
|
||||
// exit code 0 = killed, 128 = not found — both are fine
|
||||
Result := (ResultCode = 0);
|
||||
end;
|
||||
end;
|
||||
|
||||
// PrepareToInstall runs before file extraction. We kill any running
|
||||
// LMVPN processes here so that the exes can be overwritten cleanly.
|
||||
function PrepareToInstall(var NeedsRestart: Boolean): String;
|
||||
begin
|
||||
KillProcess('{#DaemonExeName}');
|
||||
KillProcess('{#AppExeName}');
|
||||
// Give the OS a moment to release file handles
|
||||
Sleep(800);
|
||||
Result := '';
|
||||
end;
|
||||
|
||||
// Kill processes before uninstall as well.
|
||||
procedure CurUninstallStepChanged(CurUninstallStep: TUninstallStep);
|
||||
begin
|
||||
if CurUninstallStep = usUninstall then
|
||||
begin
|
||||
KillProcess('{#DaemonExeName}');
|
||||
KillProcess('{#AppExeName}');
|
||||
Sleep(500);
|
||||
end;
|
||||
end;
|
||||
|
||||
// Check if a process with the given image name is currently running.
|
||||
function IsProcessRunning(const ExeName: String): Boolean;
|
||||
var
|
||||
ResultCode: Integer;
|
||||
begin
|
||||
Result := False;
|
||||
// tasklist exits 0 if the process is found, 1 if not found
|
||||
if Exec(ExpandConstant('{cmd}'), '/C tasklist /FI "IMAGENAME eq ' + ExeName + '" /NH /FO CSV | findstr /I "' + ExeName + '"',
|
||||
'', SW_HIDE, ewWaitUntilTerminated, ResultCode) then
|
||||
Result := (ResultCode = 0);
|
||||
end;
|
||||
|
||||
// Warn the user if LMVPN is still running and offer to kill it
|
||||
// before proceeding from the directory selection page.
|
||||
function NextButtonClick(CurPageID: Integer): Boolean;
|
||||
begin
|
||||
Result := True;
|
||||
if CurPageID = wpSelectDir then
|
||||
begin
|
||||
if IsProcessRunning('{#AppExeName}') or IsProcessRunning('{#DaemonExeName}') then
|
||||
begin
|
||||
if MsgBox('{#AppName} 正在运行中,需要先关闭才能继续安装。' + #13#10 + #13#10 + '是否立即关闭?',
|
||||
mbConfirmation, MB_YESNO) = IDYES then
|
||||
begin
|
||||
KillProcess('{#DaemonExeName}');
|
||||
KillProcess('{#AppExeName}');
|
||||
Sleep(800);
|
||||
// Verify they're actually dead
|
||||
if IsProcessRunning('{#AppExeName}') or IsProcessRunning('{#DaemonExeName}') then
|
||||
begin
|
||||
MsgBox('无法关闭 {#AppName} 进程,请手动结束任务后重试。', mbError, MB_OK);
|
||||
Result := False;
|
||||
end;
|
||||
end
|
||||
else
|
||||
Result := False;
|
||||
end;
|
||||
end;
|
||||
end;
|
||||
@@ -26,6 +26,7 @@ import (
|
||||
"lmvpn/internal/model"
|
||||
"lmvpn/internal/paths"
|
||||
"lmvpn/internal/stats"
|
||||
"lmvpn/internal/version"
|
||||
"lmvpn/internal/vpn"
|
||||
)
|
||||
|
||||
@@ -59,8 +60,8 @@ func Run(userHome string, uid, gid int) error {
|
||||
// Chown the IPC socket so the non-root GUI process can connect.
|
||||
// This is the critical fix: without it, the socket is owned by
|
||||
// root:wheel with mode 0660, and the user cannot dial it.
|
||||
chownToUser(paths.IPCSocketPath(), uid, gid)
|
||||
log.L().Info("daemon listening", "socket", paths.IPCSocketPath())
|
||||
chownToUser(paths.IPCAddress(), uid, gid)
|
||||
log.L().Info("daemon listening", "socket", paths.IPCAddress())
|
||||
|
||||
d := &daemon{server: server}
|
||||
|
||||
@@ -113,6 +114,8 @@ func (d *daemon) handle(conn net.Conn, req ipc.Request) {
|
||||
d.server.Broadcast(ipc.Event{Event: ipc.EvStats, Stats: &snap})
|
||||
}
|
||||
_ = ipc.WriteOK(conn)
|
||||
case ipc.CmdVersion:
|
||||
_ = ipc.WriteVersion(conn, version.Version)
|
||||
default:
|
||||
_ = ipc.WriteErr(conn, "unknown command: "+req.Cmd)
|
||||
}
|
||||
@@ -150,6 +153,9 @@ func (d *daemon) startSession(conn net.Conn, req ipc.Request) {
|
||||
s := snap
|
||||
d.server.Broadcast(ipc.Event{Event: ipc.EvStats, Stats: &s})
|
||||
},
|
||||
func(code string, msg string) {
|
||||
d.server.Broadcast(ipc.Event{Event: ipc.EvError, Code: code, Message: msg})
|
||||
},
|
||||
)
|
||||
|
||||
if err := d.session.Connect(ctx, cfg); err != nil {
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
//go:build !windows
|
||||
|
||||
package daemon
|
||||
|
||||
const daemonBinaryName = "lmvpnd"
|
||||
@@ -0,0 +1,5 @@
|
||||
//go:build windows
|
||||
|
||||
package daemon
|
||||
|
||||
const daemonBinaryName = "lmvpnd.exe"
|
||||
@@ -0,0 +1,32 @@
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// resolveDaemonBinary locates the lmvpnd binary relative to the
|
||||
// launcher's executable path. In a .app bundle, lmvpnd lives in the
|
||||
// same Contents/MacOS/ directory as lmvpn. In development, it lives
|
||||
// in the same build directory.
|
||||
func resolveDaemonBinary() (string, error) {
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("resolve launcher executable: %w", err)
|
||||
}
|
||||
|
||||
// Resolve any symlinks.
|
||||
exe, err = filepath.EvalSymlinks(exe)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("resolve symlink: %w", err)
|
||||
}
|
||||
|
||||
dir := filepath.Dir(exe)
|
||||
candidate := filepath.Join(dir, daemonBinaryName)
|
||||
if _, err := os.Stat(candidate); err == nil {
|
||||
return candidate, nil
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("could not find %s near %s", daemonBinaryName, exe)
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
//go:build !windows
|
||||
|
||||
package daemon
|
||||
|
||||
import (
|
||||
@@ -120,33 +122,4 @@ func Launch(userHome string, uid, gid int, daemonBin string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// resolveDaemonBinary locates the lmvpnd binary relative to the
|
||||
// launcher's executable path. In a .app bundle, lmvpnd lives in the
|
||||
// same Contents/MacOS/ directory as lmvpn. In development, it lives
|
||||
// in the same build directory.
|
||||
func resolveDaemonBinary() (string, error) {
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("resolve launcher executable: %w", err)
|
||||
}
|
||||
|
||||
// Resolve any symlinks.
|
||||
exe, err = filepath.EvalSymlinks(exe)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("resolve symlink: %w", err)
|
||||
}
|
||||
|
||||
dir := filepath.Dir(exe)
|
||||
candidates := []string{
|
||||
filepath.Join(dir, "lmvpnd"),
|
||||
filepath.Join(dir, "lmvpn-daemon"),
|
||||
}
|
||||
|
||||
for _, c := range candidates {
|
||||
if _, err := os.Stat(c); err == nil {
|
||||
return c, nil
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("could not find lmvpnd binary near %s (tried %v)", exe, candidates)
|
||||
}
|
||||
// resolveDaemonBinary is in launch_common.go.
|
||||
@@ -0,0 +1,93 @@
|
||||
//go:build windows
|
||||
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
|
||||
"lmvpn/internal/paths"
|
||||
)
|
||||
|
||||
// Launch starts the daemon binary (lmvpnd.exe) as a detached process
|
||||
// and returns immediately. On Windows, detachment is achieved via
|
||||
// CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS creation flags instead
|
||||
// of the Unix Setsid call.
|
||||
//
|
||||
// The launcher itself is invoked by the GUI via ShellExecute "runas"
|
||||
// (UAC elevation). It:
|
||||
// 1. Computes the daemon log path using the user's home directory
|
||||
// 2. Opens the log file (append) and NUL
|
||||
// 3. Starts the lmvpnd.exe binary, redirecting stdio
|
||||
// 4. Returns immediately — the child continues running independently
|
||||
func Launch(userHome string, uid, gid int, daemonBin string) error {
|
||||
paths.SetUserHome(userHome)
|
||||
if err := paths.EnsureDirs(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "ensure dirs: %v\n", err)
|
||||
}
|
||||
|
||||
if daemonBin == "" {
|
||||
var err error
|
||||
daemonBin, err = resolveDaemonBinary()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
absBin, err := filepath.Abs(daemonBin)
|
||||
if err != nil {
|
||||
return fmt.Errorf("resolve absolute path for %s: %w", daemonBin, err)
|
||||
}
|
||||
daemonBin = absBin
|
||||
|
||||
if _, err := os.Stat(daemonBin); err != nil {
|
||||
return fmt.Errorf("daemon binary not found at %s: %w", daemonBin, err)
|
||||
}
|
||||
|
||||
logPath := paths.DaemonLogFile()
|
||||
logFile, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open daemon log %s: %w", logPath, err)
|
||||
}
|
||||
|
||||
devNull, err := os.Open(os.DevNull)
|
||||
if err != nil {
|
||||
logFile.Close()
|
||||
return fmt.Errorf("open NUL: %w", err)
|
||||
}
|
||||
|
||||
args := []string{
|
||||
"--user-home", userHome,
|
||||
"--uid", fmt.Sprintf("%d", uid),
|
||||
"--gid", fmt.Sprintf("%d", gid),
|
||||
}
|
||||
|
||||
fmt.Fprintf(logFile, "=== launcher: starting daemon %s %v ===\n", daemonBin, args)
|
||||
|
||||
// CREATE_NEW_PROCESS_GROUP (0x200) | DETACHED_PROCESS (0x8)
|
||||
procAttr := &os.ProcAttr{
|
||||
Dir: os.TempDir(),
|
||||
Env: append(os.Environ(),
|
||||
"LMVPN_DAEMON=1",
|
||||
),
|
||||
Files: []*os.File{devNull, logFile, logFile},
|
||||
Sys: &syscall.SysProcAttr{
|
||||
CreationFlags: 0x00000200 | 0x00000008,
|
||||
},
|
||||
}
|
||||
|
||||
pid, err := os.StartProcess(daemonBin, append([]string{daemonBin}, args...), procAttr)
|
||||
logFile.Close()
|
||||
devNull.Close()
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("start daemon process: %w", err)
|
||||
}
|
||||
|
||||
_ = pid.Release()
|
||||
|
||||
fmt.Fprintf(os.Stderr, "daemon launched (pid %d, bin %s)\n", pid.Pid, daemonBin)
|
||||
return nil
|
||||
}
|
||||
+24
-4
@@ -24,10 +24,18 @@ Ip6None = "IPv6: —"
|
||||
IpNone = "IP: —"
|
||||
UptimeLabel = "Uptime: {{.uptime}}"
|
||||
UptimeNone = "Uptime: —"
|
||||
RxLabel = "↓ {{.bytes}}"
|
||||
TxLabel = "↑ {{.bytes}}"
|
||||
RxZero = "↓ 0 B"
|
||||
TxZero = "↑ 0 B"
|
||||
RxV4Label = "IPv4 ↓ {{.bytes}} {{.speed}}"
|
||||
TxV4Label = "↑ {{.bytes}} {{.speed}}"
|
||||
RxV6Label = "IPv6 ↓ {{.bytes}} {{.speed}}"
|
||||
TxV6Label = "↑ {{.bytes}} {{.speed}}"
|
||||
RxTotalLabel = "Total ↓ {{.bytes}} {{.speed}}"
|
||||
TxTotalLabel = "↑ {{.bytes}} {{.speed}}"
|
||||
RxV4Zero = "IPv4 ↓ 0 B 0 bps"
|
||||
TxV4Zero = "↑ 0 B 0 bps"
|
||||
RxV6Zero = "IPv6 ↓ 0 B 0 bps"
|
||||
TxV6Zero = "↑ 0 B 0 bps"
|
||||
RxTotalZero = "Total ↓ 0 B 0 bps"
|
||||
TxTotalZero = "↑ 0 B 0 bps"
|
||||
|
||||
DlgNoProfileTitle = "No Profile"
|
||||
DlgNoProfileEditMsg = "Select a profile to edit."
|
||||
@@ -45,11 +53,23 @@ DlgVPNError = "VPN Error"
|
||||
DlgSaveError = "Save Error"
|
||||
DlgKeychainError = "Keychain Error"
|
||||
DlgError = "Error"
|
||||
DlgAuthError = "Authentication Failed"
|
||||
AuthErrWrongCredentials = "Wrong username or password. Please check your credentials."
|
||||
AuthErrUserDisabled = "This user account does not exist or has been disabled."
|
||||
AuthErrTokenInvalid = "The authentication token is invalid or expired."
|
||||
AuthErrRateLimited = "Too many authentication attempts. Please try again later."
|
||||
AuthErrMalformed = "Authentication message format error."
|
||||
|
||||
BtnResetDB = "Reset Database"
|
||||
DlgResetDBTitle = "Reset Database"
|
||||
DlgResetDBMsg = "Delete all profiles and connection logs? This cannot be undone."
|
||||
|
||||
BtnProfileList = "Profile List"
|
||||
ProfileListTitle = "Profile List"
|
||||
DlgNoListSelectTitle = "No Selection"
|
||||
DlgNoListSelectEditMsg = "Select a profile in the list to edit."
|
||||
DlgNoListSelectDeleteMsg = "Select a profile in the list to delete."
|
||||
|
||||
TrayShowWindow = "Show Window"
|
||||
TrayConnect = "Connect"
|
||||
TrayDisconnect = "Disconnect"
|
||||
|
||||
@@ -24,10 +24,18 @@ Ip6None = "IPv6: —"
|
||||
IpNone = "IP: —"
|
||||
UptimeLabel = "运行时长: {{.uptime}}"
|
||||
UptimeNone = "运行时长: —"
|
||||
RxLabel = "↓ {{.bytes}}"
|
||||
TxLabel = "↑ {{.bytes}}"
|
||||
RxZero = "↓ 0 B"
|
||||
TxZero = "↑ 0 B"
|
||||
RxV4Label = "IPv4 ↓ {{.bytes}} {{.speed}}"
|
||||
TxV4Label = "↑ {{.bytes}} {{.speed}}"
|
||||
RxV6Label = "IPv6 ↓ {{.bytes}} {{.speed}}"
|
||||
TxV6Label = "↑ {{.bytes}} {{.speed}}"
|
||||
RxTotalLabel = "合计 ↓ {{.bytes}} {{.speed}}"
|
||||
TxTotalLabel = "↑ {{.bytes}} {{.speed}}"
|
||||
RxV4Zero = "IPv4 ↓ 0 B 0 bps"
|
||||
TxV4Zero = "↑ 0 B 0 bps"
|
||||
RxV6Zero = "IPv6 ↓ 0 B 0 bps"
|
||||
TxV6Zero = "↑ 0 B 0 bps"
|
||||
RxTotalZero = "合计 ↓ 0 B 0 bps"
|
||||
TxTotalZero = "↑ 0 B 0 bps"
|
||||
|
||||
DlgNoProfileTitle = "无配置"
|
||||
DlgNoProfileEditMsg = "请选择要编辑的配置。"
|
||||
@@ -45,11 +53,23 @@ DlgVPNError = "VPN 错误"
|
||||
DlgSaveError = "保存错误"
|
||||
DlgKeychainError = "钥匙串错误"
|
||||
DlgError = "错误"
|
||||
DlgAuthError = "认证失败"
|
||||
AuthErrWrongCredentials = "用户名或密码错误,请检查凭据。"
|
||||
AuthErrUserDisabled = "用户不存在或已被禁用。"
|
||||
AuthErrTokenInvalid = "认证令牌无效或已过期。"
|
||||
AuthErrRateLimited = "认证尝试过于频繁,请稍后再试。"
|
||||
AuthErrMalformed = "认证消息格式错误。"
|
||||
|
||||
BtnResetDB = "重置数据库"
|
||||
DlgResetDBTitle = "重置数据库"
|
||||
DlgResetDBMsg = "删除所有配置和连接记录?此操作无法撤销。"
|
||||
|
||||
BtnProfileList = "配置列表"
|
||||
ProfileListTitle = "配置列表"
|
||||
DlgNoListSelectTitle = "无选择"
|
||||
DlgNoListSelectEditMsg = "请在列表中选择一个配置。"
|
||||
DlgNoListSelectDeleteMsg = "请在列表中选择一个配置。"
|
||||
|
||||
TrayShowWindow = "显示窗口"
|
||||
TrayConnect = "连接"
|
||||
TrayDisconnect = "断开连接"
|
||||
|
||||
+46
-10
@@ -29,6 +29,7 @@ const (
|
||||
CmdStop = "stop"
|
||||
CmdShutdown = "shutdown"
|
||||
CmdStats = "stats"
|
||||
CmdVersion = "version" // query daemon build version
|
||||
)
|
||||
|
||||
// Event types sent from daemon to GUI.
|
||||
@@ -49,8 +50,8 @@ type Request struct {
|
||||
// package (which needs root-only TUN) into the GUI.
|
||||
type ClientConfig struct {
|
||||
ServerURL string `json:"server_url"`
|
||||
SNIHost string `json:"sni_host"` // TLS SNI hostname for CDN
|
||||
ServerIPs []string `json:"server_ips"` // CDN edge IP list for failover
|
||||
SNIHost string `json:"sni_host"` // TLS SNI hostname for CDN
|
||||
ServerIPs []string `json:"server_ips"` // CDN edge IP list for failover
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
Token string `json:"token"`
|
||||
@@ -65,6 +66,7 @@ type Event struct {
|
||||
Event string `json:"event"`
|
||||
State string `json:"state,omitempty"`
|
||||
Stats *stats.Snapshot `json:"stats,omitempty"`
|
||||
Code string `json:"code,omitempty"` // stable auth-error code for EvError
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
||||
|
||||
@@ -98,15 +100,26 @@ type Server struct {
|
||||
}
|
||||
|
||||
// NewServer creates (but does not start) the IPC server. It removes
|
||||
// any stale socket file first.
|
||||
// any stale socket file first (unix only).
|
||||
func NewServer() (*Server, error) {
|
||||
_ = os.Remove(paths.IPCSocketPath())
|
||||
l, err := net.Listen("unix", paths.IPCSocketPath())
|
||||
netType := paths.IPCNetwork()
|
||||
addr := paths.IPCAddress()
|
||||
|
||||
// Clean up stale unix socket file (not needed for TCP).
|
||||
if netType == "unix" {
|
||||
_ = os.Remove(addr)
|
||||
}
|
||||
|
||||
l, err := net.Listen(netType, addr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("listen ipc: %w", err)
|
||||
}
|
||||
// Mode 0660 so group members (admin) can connect.
|
||||
_ = os.Chmod(paths.IPCSocketPath(), 0o660)
|
||||
|
||||
// Set socket permissions so group members can connect (unix only).
|
||||
if netType == "unix" {
|
||||
_ = os.Chmod(addr, 0o660)
|
||||
}
|
||||
|
||||
return &Server{listener: l, clients: make(map[net.Conn]bool)}, nil
|
||||
}
|
||||
|
||||
@@ -176,7 +189,7 @@ type Client struct {
|
||||
|
||||
// Dial connects to the daemon.
|
||||
func Dial() (*Client, error) {
|
||||
conn, err := net.Dial("unix", paths.IPCSocketPath())
|
||||
conn, err := net.Dial(paths.IPCNetwork(), paths.IPCAddress())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("dial daemon: %w", err)
|
||||
}
|
||||
@@ -203,8 +216,9 @@ func (c *Client) Close() error { return c.conn.Close() }
|
||||
|
||||
// Response is a simple ack/error reply to a command.
|
||||
type Response struct {
|
||||
OK bool `json:"ok"`
|
||||
Error string `json:"error,omitempty"`
|
||||
OK bool `json:"ok"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Version string `json:"version,omitempty"` // set for CmdVersion replies
|
||||
}
|
||||
|
||||
// WriteOK sends a success response to a connection.
|
||||
@@ -232,6 +246,28 @@ func SendShutdown(c *Client) error {
|
||||
return c.Send(Request{Cmd: CmdShutdown})
|
||||
}
|
||||
|
||||
// WriteVersion sends a version response to a connection.
|
||||
func WriteVersion(conn net.Conn, ver string) error {
|
||||
return writeMsg(conn, Response{OK: true, Version: ver})
|
||||
}
|
||||
|
||||
// QueryVersion sends a CmdVersion request and reads the daemon's
|
||||
// build version. It must be called before any VPN session starts (so
|
||||
// no Event messages are in flight on the connection).
|
||||
func (c *Client) QueryVersion() (string, error) {
|
||||
if err := c.Send(Request{Cmd: CmdVersion}); err != nil {
|
||||
return "", err
|
||||
}
|
||||
var resp Response
|
||||
if err := readMsg(c.r, &resp); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !resp.OK {
|
||||
return "", fmt.Errorf("version query failed: %s", resp.Error)
|
||||
}
|
||||
return resp.Version, nil
|
||||
}
|
||||
|
||||
// RoutingModeFromIPC converts an IPC routing mode string to route.Mode.
|
||||
func RoutingModeFromIPC(s string) route.Mode {
|
||||
switch s {
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
//go:build !darwin
|
||||
//go:build !darwin && !windows
|
||||
|
||||
package keychain
|
||||
|
||||
// MemStore is an in-memory fallback used on non-darwin platforms.
|
||||
// MemStore is an in-memory fallback used on non-darwin, non-windows
|
||||
// platforms (currently Linux). Passwords do not persist across
|
||||
// restarts; a platform-appropriate backend (e.g. Secret Service)
|
||||
// should be added for production use.
|
||||
type MemStore struct {
|
||||
data map[string]string
|
||||
}
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
//go:build windows
|
||||
|
||||
package keychain
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/danieljoos/wincred"
|
||||
)
|
||||
|
||||
// keyPrefixes distinguish password vs token entries in the credential
|
||||
// store, mirroring the macOS implementation.
|
||||
const (
|
||||
passwordAccountPrefix = "password:"
|
||||
tokenAccountPrefix = "token:"
|
||||
)
|
||||
|
||||
// WinStore implements Store using the Windows Credential Manager.
|
||||
type WinStore struct{}
|
||||
|
||||
// New returns a Windows Credential Manager-backed Store.
|
||||
func New() Store {
|
||||
return WinStore{}
|
||||
}
|
||||
|
||||
// targetName builds the credential TargetName from the account prefix
|
||||
// and profile name: "<BundleID>/<prefix><profile>".
|
||||
func targetName(account string) string {
|
||||
return ServiceName + "/" + account
|
||||
}
|
||||
|
||||
func (WinStore) setItem(account, secret string) error {
|
||||
cred := wincred.NewGenericCredential(targetName(account))
|
||||
cred.CredentialBlob = []byte(secret)
|
||||
if err := cred.Write(); err != nil {
|
||||
return fmt.Errorf("wincred write %s: %w", account, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (WinStore) getItem(account string) (string, error) {
|
||||
cred, err := wincred.GetGenericCredential(targetName(account))
|
||||
if err != nil {
|
||||
if errors.Is(err, wincred.ErrElementNotFound) {
|
||||
return "", ErrNotFound
|
||||
}
|
||||
return "", fmt.Errorf("wincred read %s: %w", account, err)
|
||||
}
|
||||
return string(cred.CredentialBlob), nil
|
||||
}
|
||||
|
||||
func (WinStore) deleteItem(account string) error {
|
||||
cred, err := wincred.GetGenericCredential(targetName(account))
|
||||
if err != nil {
|
||||
if errors.Is(err, wincred.ErrElementNotFound) {
|
||||
return nil // already gone — treat as success
|
||||
}
|
||||
return fmt.Errorf("wincred read %s: %w", account, err)
|
||||
}
|
||||
if err := cred.Delete(); err != nil {
|
||||
return fmt.Errorf("wincred delete %s: %w", account, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s WinStore) SetPassword(profileName, password string) error {
|
||||
return s.setItem(passwordAccountPrefix+profileName, password)
|
||||
}
|
||||
|
||||
func (s WinStore) GetPassword(profileName string) (string, error) {
|
||||
return s.getItem(passwordAccountPrefix + profileName)
|
||||
}
|
||||
|
||||
func (s WinStore) DeletePassword(profileName string) error {
|
||||
return s.deleteItem(passwordAccountPrefix + profileName)
|
||||
}
|
||||
|
||||
func (s WinStore) SetToken(profileName, token string) error {
|
||||
return s.setItem(tokenAccountPrefix+profileName, token)
|
||||
}
|
||||
|
||||
func (s WinStore) GetToken(profileName string) (string, error) {
|
||||
return s.getItem(tokenAccountPrefix + profileName)
|
||||
}
|
||||
|
||||
func (s WinStore) DeleteToken(profileName string) error {
|
||||
return s.deleteItem(tokenAccountPrefix + profileName)
|
||||
}
|
||||
|
||||
func (s WinStore) DeleteAll(profileName string) error {
|
||||
_ = s.DeletePassword(profileName)
|
||||
return s.DeleteToken(profileName)
|
||||
}
|
||||
@@ -57,6 +57,4 @@ func LogFile() string { return Paths.Log + "/lmvpn.log" }
|
||||
// DaemonLogFile returns the path to the daemon log file.
|
||||
func DaemonLogFile() string { return Paths.Log + "/lmvpn-daemon.log" }
|
||||
|
||||
// IPCSocketPath returns the path to the unix domain socket used for
|
||||
// GUI <-> daemon communication.
|
||||
func IPCSocketPath() string { return "/tmp/lmvpn.sock" }
|
||||
|
||||
|
||||
@@ -7,6 +7,12 @@ import (
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// IPCNetwork returns the transport network for GUI <-> daemon IPC.
|
||||
func IPCNetwork() string { return "unix" }
|
||||
|
||||
// IPCAddress returns the listen/dial address for GUI <-> daemon IPC.
|
||||
func IPCAddress() string { return "/tmp/lmvpn.sock" }
|
||||
|
||||
func init() {
|
||||
home, _ := os.UserHomeDir()
|
||||
recomputePaths(home)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//go:build !darwin
|
||||
//go:build !darwin && !windows
|
||||
|
||||
package paths
|
||||
|
||||
@@ -7,6 +7,12 @@ import (
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// IPCNetwork returns the transport network for GUI <-> daemon IPC.
|
||||
func IPCNetwork() string { return "unix" }
|
||||
|
||||
// IPCAddress returns the listen/dial address for GUI <-> daemon IPC.
|
||||
func IPCAddress() string { return "/tmp/lmvpn.sock" }
|
||||
|
||||
func init() {
|
||||
home, _ := os.UserHomeDir()
|
||||
recomputePaths(home)
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
//go:build windows
|
||||
|
||||
package paths
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// IPCNetwork returns the transport network for GUI <-> daemon IPC.
|
||||
// Windows uses TCP because AF_UNIX sockets enforce mandatory
|
||||
// integrity-level checks: a socket created by the elevated daemon
|
||||
// (High Integrity) cannot be connected to by the non-elevated GUI
|
||||
// (Medium Integrity). TCP on 127.0.0.1 has no such restriction.
|
||||
func IPCNetwork() string { return "tcp" }
|
||||
|
||||
// IPCAddress returns the listen/dial address for GUI <-> daemon IPC.
|
||||
// On Windows this is a TCP address on localhost.
|
||||
const ipcPort = "18923"
|
||||
|
||||
func IPCAddress() string { return "127.0.0.1:" + ipcPort }
|
||||
|
||||
func init() {
|
||||
home, _ := os.UserHomeDir()
|
||||
recomputePaths(home)
|
||||
}
|
||||
|
||||
// recomputePaths sets Paths based on the given home directory.
|
||||
// On Windows the layout follows platform conventions:
|
||||
//
|
||||
// %APPDATA%\<BundleID>\ data (db, config)
|
||||
// %LOCALAPPDATA%\<BundleID>\ cache
|
||||
// %LOCALAPPDATA%\<BundleID>\Logs\ logs
|
||||
func recomputePaths(home string) {
|
||||
appData := os.Getenv("APPDATA")
|
||||
if appData == "" {
|
||||
appData = filepath.Join(home, "AppData", "Roaming")
|
||||
}
|
||||
localAppData := os.Getenv("LOCALAPPDATA")
|
||||
if localAppData == "" {
|
||||
localAppData = filepath.Join(home, "AppData", "Local")
|
||||
}
|
||||
Paths = Dirs{
|
||||
Data: filepath.Join(appData, BundleID),
|
||||
Cache: filepath.Join(localAppData, BundleID),
|
||||
Log: filepath.Join(localAppData, BundleID, "Logs"),
|
||||
}
|
||||
}
|
||||
@@ -69,3 +69,39 @@ type AuthResponse struct {
|
||||
func IsError(msgType string) bool {
|
||||
return msgType == TypeAuthErr || msgType == TypeError
|
||||
}
|
||||
|
||||
// AuthErrorCode is a stable, locale-independent identifier for a fatal
|
||||
// authentication failure. It is derived from the server's auth_err
|
||||
// message (or HTTP status for the JWT login path) and carried over IPC
|
||||
// to the GUI, which maps it to a localized user-facing string.
|
||||
type AuthErrorCode string
|
||||
|
||||
const (
|
||||
AuthCodeWrongCredentials AuthErrorCode = "wrong_credentials" // 用户名或密码错误 / HTTP 401,403
|
||||
AuthCodeUserDisabled AuthErrorCode = "user_disabled" // 用户不存在或已禁用
|
||||
AuthCodeTokenInvalid AuthErrorCode = "token_invalid" // 令牌无效或已过期
|
||||
AuthCodeRateLimited AuthErrorCode = "rate_limited" // 认证尝试过于频繁 / HTTP 429
|
||||
AuthCodeMalformed AuthErrorCode = "malformed" // 消息格式错误
|
||||
)
|
||||
|
||||
// AuthErrorCodeFromMessage maps a server auth_err message string to a
|
||||
// stable AuthErrorCode. It returns the empty string for an unrecognized
|
||||
// message, in which case the caller should treat the failure as
|
||||
// non-categorical (and typically still fatal, since the server closes
|
||||
// the connection after auth_err).
|
||||
func AuthErrorCodeFromMessage(msg string) AuthErrorCode {
|
||||
switch msg {
|
||||
case "用户名或密码错误":
|
||||
return AuthCodeWrongCredentials
|
||||
case "用户不存在或已禁用":
|
||||
return AuthCodeUserDisabled
|
||||
case "令牌无效或已过期":
|
||||
return AuthCodeTokenInvalid
|
||||
case "认证尝试过于频繁,请稍后再试":
|
||||
return AuthCodeRateLimited
|
||||
case "消息格式错误":
|
||||
return AuthCodeMalformed
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,12 +138,14 @@ func (m *Manager) applyFull() error {
|
||||
}
|
||||
|
||||
// Bypass: server's public IPv6 via the original v6 gateway.
|
||||
// Non-fatal: if this fails, full-tunnel routes are still added.
|
||||
if v6 != "" && gw6 != "" {
|
||||
bypassSpec := v6 + "/128"
|
||||
if err := addRouteVia6(bypassSpec, gw6); err != nil {
|
||||
return fmt.Errorf("add server bypass6 route: %w", err)
|
||||
m.serverBypass6 = false
|
||||
} else {
|
||||
m.serverBypass6 = true
|
||||
}
|
||||
m.serverBypass6 = true
|
||||
}
|
||||
|
||||
// Two /1 routes cover the entire IPv4 space and are more specific
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//go:build !darwin
|
||||
//go:build !darwin && !windows
|
||||
|
||||
package route
|
||||
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
//go:build windows
|
||||
|
||||
package route
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"os/exec"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// cidrToNetworkMask splits a CIDR string into network address and
|
||||
// dotted-decimal mask, as required by the Windows `route` command.
|
||||
func cidrToNetworkMask(cidr string) (network, mask string, err error) {
|
||||
_, ipNet, err := net.ParseCIDR(cidr)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
return ipNet.IP.String(), net.IP(ipNet.Mask).String(), nil
|
||||
}
|
||||
|
||||
// ifaceIndex resolves a Windows interface name to its index.
|
||||
func ifaceIndex(name string) (int, error) {
|
||||
ifc, err := net.InterfaceByName(name)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("resolve interface %s: %w", name, err)
|
||||
}
|
||||
return ifc.Index, nil
|
||||
}
|
||||
|
||||
// --- IPv4 ---
|
||||
|
||||
func addRoute(cidr, iface string) error {
|
||||
network, mask, err := cidrToNetworkMask(cidr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
idx, err := ifaceIndex(iface)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return runCmd("route", "add", network, "mask", mask, "0.0.0.0",
|
||||
"if", fmt.Sprintf("%d", idx), "metric", "1")
|
||||
}
|
||||
|
||||
func deleteRoute(cidr, iface string) error {
|
||||
network, mask, err := cidrToNetworkMask(cidr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return runCmd("route", "delete", network, "mask", mask)
|
||||
}
|
||||
|
||||
func addRouteVia(cidr, gateway string) error {
|
||||
network, mask, err := cidrToNetworkMask(cidr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return runCmd("route", "add", network, "mask", mask, gateway, "metric", "1")
|
||||
}
|
||||
|
||||
func deleteRouteVia(cidr, gateway string) error {
|
||||
network, mask, err := cidrToNetworkMask(cidr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return runCmd("route", "delete", network, "mask", mask, gateway)
|
||||
}
|
||||
|
||||
// --- IPv6 ---
|
||||
|
||||
// cidrToV6Prefix splits an IPv6 CIDR into network and prefix length.
|
||||
func cidrToV6Prefix(cidr string) (network, prefix string, err error) {
|
||||
_, ipNet, err := net.ParseCIDR(cidr)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
ones, _ := ipNet.Mask.Size()
|
||||
return ipNet.IP.String(), fmt.Sprintf("%d", ones), nil
|
||||
}
|
||||
|
||||
func addRoute6(cidr, iface string) error {
|
||||
network, prefix, err := cidrToV6Prefix(cidr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
idx, err := ifaceIndex(iface)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return runCmd("route", "-6", "add", network+"/"+prefix, "::",
|
||||
"if", fmt.Sprintf("%d", idx), "metric", "1")
|
||||
}
|
||||
|
||||
func deleteRoute6(cidr, iface string) error {
|
||||
network, prefix, err := cidrToV6Prefix(cidr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return runCmd("route", "-6", "delete", network+"/"+prefix, "::")
|
||||
}
|
||||
|
||||
func addRouteVia6(cidr, gateway string) error {
|
||||
network, prefix, err := cidrToV6Prefix(cidr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return runCmd("route", "-6", "add", network+"/"+prefix, gateway, "metric", "1")
|
||||
}
|
||||
|
||||
func deleteRouteVia6(cidr, gateway string) error {
|
||||
network, prefix, err := cidrToV6Prefix(cidr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return runCmd("route", "-6", "delete", network+"/"+prefix, gateway)
|
||||
}
|
||||
|
||||
// --- Default gateway ---
|
||||
|
||||
func defaultGateway() (string, error) {
|
||||
out, err := exec.Command("route", "print", "0.0.0.0").Output()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return parseRouteGateway(string(out))
|
||||
}
|
||||
|
||||
func defaultGateway6() (string, error) {
|
||||
out, err := exec.Command("route", "-6", "print").Output()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return parseRoute6Gateway(string(out))
|
||||
}
|
||||
|
||||
// parseRouteGateway extracts the IPv4 default gateway from `route print`
|
||||
// output. Looks for the line with Network Destination 0.0.0.0 and
|
||||
// Netmask 0.0.0.0, and returns the Gateway column.
|
||||
func parseRouteGateway(out string) (string, error) {
|
||||
lines := strings.Split(out, "\n")
|
||||
for _, line := range lines {
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) >= 3 && fields[0] == "0.0.0.0" && fields[1] == "0.0.0.0" {
|
||||
return fields[2], nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("no default gateway found")
|
||||
}
|
||||
|
||||
// parseRoute6Gateway extracts the IPv6 default gateway from
|
||||
// `route -6 print` output. Looks for ::/0 route and returns the
|
||||
// Gateway column.
|
||||
func parseRoute6Gateway(out string) (string, error) {
|
||||
lines := strings.Split(out, "\n")
|
||||
for _, line := range lines {
|
||||
fields := strings.Fields(line)
|
||||
for i, f := range fields {
|
||||
if f == "::/0" && i+1 < len(fields) {
|
||||
return fields[i+1], nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("no IPv6 default gateway found")
|
||||
}
|
||||
|
||||
func runCmd(name string, args ...string) error {
|
||||
cmd := exec.Command(name, args...)
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("%s %s: %w (%s)", name, strings.Join(args, " "), err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
+74
-6
@@ -19,15 +19,57 @@ const (
|
||||
|
||||
// Stats holds live session statistics. Counters are atomic for
|
||||
// lock-free reads from the UI/IPC layer.
|
||||
//
|
||||
// Counters are split by IP address family (v4/v6) at the recording
|
||||
// site via AddRx/AddTx, which inspect the IP version nibble. The
|
||||
// combined RxBytes/TxBytes are derived as the sum of the per-family
|
||||
// counters in Snapshot, so callers that only need the total still
|
||||
// work.
|
||||
type Stats struct {
|
||||
RxBytes atomic.Int64
|
||||
TxBytes atomic.Int64
|
||||
RxBytesV4 atomic.Int64
|
||||
RxBytesV6 atomic.Int64
|
||||
TxBytesV4 atomic.Int64
|
||||
TxBytesV6 atomic.Int64
|
||||
ConnectedAt atomic.Int64 // unix timestamp, 0 = not connected
|
||||
state atomic.Value // State
|
||||
assignedIP atomic.Value // string (IPv4)
|
||||
assignedIP6 atomic.Value // string (IPv6, may be empty)
|
||||
}
|
||||
|
||||
// AddRx records a downloaded packet (WebSocket → TUN) of length n,
|
||||
// routing the bytes to the v4 or v6 counter based on the IP version
|
||||
// nibble of the packet. Packets too short to contain a version byte
|
||||
// or with an unknown version are ignored.
|
||||
func (s *Stats) AddRx(p []byte) {
|
||||
if len(p) == 0 {
|
||||
return
|
||||
}
|
||||
n := int64(len(p))
|
||||
switch p[0] >> 4 {
|
||||
case 4:
|
||||
s.RxBytesV4.Add(n)
|
||||
case 6:
|
||||
s.RxBytesV6.Add(n)
|
||||
}
|
||||
}
|
||||
|
||||
// AddTx records an uploaded packet (TUN → WebSocket) of length n,
|
||||
// routing the bytes to the v4 or v6 counter based on the IP version
|
||||
// nibble of the packet. Packets too short to contain a version byte
|
||||
// or with an unknown version are ignored.
|
||||
func (s *Stats) AddTx(p []byte) {
|
||||
if len(p) == 0 {
|
||||
return
|
||||
}
|
||||
n := int64(len(p))
|
||||
switch p[0] >> 4 {
|
||||
case 4:
|
||||
s.TxBytesV4.Add(n)
|
||||
case 6:
|
||||
s.TxBytesV6.Add(n)
|
||||
}
|
||||
}
|
||||
|
||||
// New creates a Stats instance initialised to the disconnected state.
|
||||
func New() *Stats {
|
||||
s := &Stats{}
|
||||
@@ -67,9 +109,27 @@ func (s *Stats) AssignedIP() string { return s.assignedIP.Load().(string) }
|
||||
func (s *Stats) AssignedIP6() string { return s.assignedIP6.Load().(string) }
|
||||
|
||||
// Snapshot returns a point-in-time copy of all counters.
|
||||
//
|
||||
// Per-family byte counters are read directly. The combined RxBytes/
|
||||
// TxBytes are derived as v4+v6. Speed fields (RxSpeedV4 etc.) are
|
||||
// left zero here; the SessionManager fills them in by computing
|
||||
// per-tick deltas with EWMA smoothing.
|
||||
type Snapshot struct {
|
||||
RxBytes int64
|
||||
TxBytes int64
|
||||
RxBytesV4 int64
|
||||
RxBytesV6 int64
|
||||
TxBytesV4 int64
|
||||
TxBytesV6 int64
|
||||
RxBytes int64 // combined (v4+v6)
|
||||
TxBytes int64 // combined (v4+v6)
|
||||
|
||||
// Speeds in bytes/sec (EWMA-smoothed). The UI converts to bits/sec.
|
||||
RxSpeedV4 int64
|
||||
TxSpeedV4 int64
|
||||
RxSpeedV6 int64
|
||||
TxSpeedV6 int64
|
||||
RxSpeed int64 // combined
|
||||
TxSpeed int64 // combined
|
||||
|
||||
ConnectedAt time.Time
|
||||
AssignedIP string
|
||||
AssignedIP6 string
|
||||
@@ -79,9 +139,17 @@ type Snapshot struct {
|
||||
|
||||
// Snapshot returns a point-in-time copy of the statistics.
|
||||
func (s *Stats) Snapshot() Snapshot {
|
||||
rxv4 := s.RxBytesV4.Load()
|
||||
rxv6 := s.RxBytesV6.Load()
|
||||
txv4 := s.TxBytesV4.Load()
|
||||
txv6 := s.TxBytesV6.Load()
|
||||
snap := Snapshot{
|
||||
RxBytes: s.RxBytes.Load(),
|
||||
TxBytes: s.TxBytes.Load(),
|
||||
RxBytesV4: rxv4,
|
||||
RxBytesV6: rxv6,
|
||||
TxBytesV4: txv4,
|
||||
TxBytesV6: txv6,
|
||||
RxBytes: rxv4 + rxv6,
|
||||
TxBytes: txv4 + txv6,
|
||||
AssignedIP: s.AssignedIP(),
|
||||
AssignedIP6: s.AssignedIP6(),
|
||||
State: s.State(),
|
||||
|
||||
@@ -158,7 +158,7 @@ func (c *Conn) passwordAuth(username, password string) error {
|
||||
return fmt.Errorf("parse auth response: %w", err)
|
||||
}
|
||||
if resp.Type == protocol.TypeAuthErr {
|
||||
return &AuthError{Message: resp.Message}
|
||||
return &AuthError{Message: resp.Message, Code: protocol.AuthErrorCodeFromMessage(resp.Message)}
|
||||
}
|
||||
if resp.Type != protocol.TypeAuthOK {
|
||||
return fmt.Errorf("unexpected auth response type: %s", resp.Type)
|
||||
@@ -186,7 +186,11 @@ func (c *Conn) readInit() (protocol.InitMessage, error) {
|
||||
return protocol.InitMessage{}, fmt.Errorf("parse init/error: %w (raw: %s)", err, data)
|
||||
}
|
||||
if ctrl.Type == protocol.TypeError || ctrl.Type == protocol.TypeAuthErr {
|
||||
return protocol.InitMessage{}, &ServerError{Type: ctrl.Type, Message: ctrl.Message}
|
||||
return protocol.InitMessage{}, &ServerError{
|
||||
Type: ctrl.Type,
|
||||
Message: ctrl.Message,
|
||||
Code: protocol.AuthErrorCodeFromMessage(ctrl.Message),
|
||||
}
|
||||
}
|
||||
return protocol.InitMessage{}, fmt.Errorf("unexpected message type: %s", ctrl.Type)
|
||||
}
|
||||
@@ -254,7 +258,10 @@ func (c *Conn) Close() error {
|
||||
}
|
||||
|
||||
// AuthError indicates authentication failure (auth_err from server).
|
||||
type AuthError struct{ Message string }
|
||||
type AuthError struct {
|
||||
Message string
|
||||
Code protocol.AuthErrorCode
|
||||
}
|
||||
|
||||
func (e *AuthError) Error() string { return "auth failed: " + e.Message }
|
||||
|
||||
@@ -262,6 +269,7 @@ func (e *AuthError) Error() string { return "auth failed: " + e.Message }
|
||||
type ServerError struct {
|
||||
Type string
|
||||
Message string
|
||||
Code protocol.AuthErrorCode
|
||||
}
|
||||
|
||||
func (e *ServerError) Error() string {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//go:build !darwin
|
||||
//go:build !darwin && !windows
|
||||
|
||||
package tun
|
||||
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
//go:build windows
|
||||
|
||||
// Package tun — Windows implementation using WinTun.
|
||||
//
|
||||
// WinTun is a userspace TUN driver for Windows developed by the
|
||||
// WireGuard project. It requires only a single wintun.dll (MIT
|
||||
// licensed) placed next to the executable — no driver installation
|
||||
// is needed.
|
||||
//
|
||||
// The DLL is embedded at compile time via //go:embed and extracted
|
||||
// to the executable's directory on first use.
|
||||
package tun
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
"golang.zx2c4.com/wintun"
|
||||
)
|
||||
|
||||
//go:embed wintun.dll
|
||||
var wintunDLL []byte
|
||||
|
||||
type wintunDevice struct {
|
||||
adapter *wintun.Adapter
|
||||
session wintun.Session
|
||||
readWait windows.Handle
|
||||
name string
|
||||
}
|
||||
|
||||
func createTUN(name string) (Device, error) {
|
||||
if err := ensureWintunDLL(); err != nil {
|
||||
return nil, fmt.Errorf("extract wintun.dll: %w", err)
|
||||
}
|
||||
if name == "" {
|
||||
name = "LMVPN"
|
||||
}
|
||||
adapter, err := wintun.CreateAdapter(name, "Wintun", nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create wintun adapter: %w", err)
|
||||
}
|
||||
session, err := adapter.StartSession(0x800000) // 8 MiB ring
|
||||
if err != nil {
|
||||
adapter.Close()
|
||||
return nil, fmt.Errorf("start wintun session: %w", err)
|
||||
}
|
||||
return &wintunDevice{
|
||||
adapter: adapter,
|
||||
session: session,
|
||||
readWait: session.ReadWaitEvent(),
|
||||
name: name,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (d *wintunDevice) Name() string { return d.name }
|
||||
func (d *wintunDevice) Close() error { d.session.End(); return d.adapter.Close() }
|
||||
|
||||
func (d *wintunDevice) Read(p []byte) (int, error) {
|
||||
for {
|
||||
packet, err := d.session.ReceivePacket()
|
||||
if err == nil {
|
||||
n := copy(p, packet)
|
||||
d.session.ReleaseReceivePacket(packet)
|
||||
return n, nil
|
||||
}
|
||||
if err == windows.ERROR_NO_MORE_ITEMS {
|
||||
windows.WaitForSingleObject(d.readWait, windows.INFINITE)
|
||||
continue
|
||||
}
|
||||
if err == windows.ERROR_HANDLE_EOF {
|
||||
return 0, fmt.Errorf("wintun closed")
|
||||
}
|
||||
return 0, fmt.Errorf("wintun read: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (d *wintunDevice) Write(p []byte) (int, error) {
|
||||
buf, err := d.session.AllocateSendPacket(len(p))
|
||||
if err != nil {
|
||||
if err == windows.ERROR_HANDLE_EOF {
|
||||
return 0, fmt.Errorf("wintun closed")
|
||||
}
|
||||
return 0, fmt.Errorf("wintun allocate: %w", err)
|
||||
}
|
||||
copy(buf, p)
|
||||
d.session.SendPacket(buf)
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
func (d *wintunDevice) Configure(localIP net.IP, prefix int, peerIP net.IP) error {
|
||||
if localIP == nil {
|
||||
return execCmd("netsh", "interface", "ip", "set", "address", "name="+d.name, "source=dhcp")
|
||||
}
|
||||
if localIP.To4() == nil {
|
||||
return d.configureIPv6Addr(localIP, prefix)
|
||||
}
|
||||
mask := net.IP(net.CIDRMask(prefix, 32))
|
||||
return execCmd("netsh", "interface", "ip", "set", "address",
|
||||
"name="+d.name, "source=static",
|
||||
"addr="+localIP.String(), "mask="+mask.String())
|
||||
}
|
||||
|
||||
func (d *wintunDevice) ConfigureIPv6(localIP6 net.IP, prefix6 int) error {
|
||||
if localIP6 == nil {
|
||||
return nil
|
||||
}
|
||||
return d.configureIPv6Addr(localIP6, prefix6)
|
||||
}
|
||||
|
||||
func (d *wintunDevice) configureIPv6Addr(ip net.IP, prefix int) error {
|
||||
addr := fmt.Sprintf("%s/%d", ip.String(), prefix)
|
||||
return execCmd("netsh", "interface", "ipv6", "add", "address",
|
||||
"interface="+d.name, "address="+addr)
|
||||
}
|
||||
|
||||
func (d *wintunDevice) SetMTU(mtu int) error {
|
||||
if err := execCmd("netsh", "interface", "ipv4", "set", "subinterface",
|
||||
"name="+d.name, fmt.Sprintf("mtu=%d", mtu)); err != nil {
|
||||
return err
|
||||
}
|
||||
return execCmd("netsh", "interface", "ipv6", "set", "subinterface",
|
||||
"interface="+d.name, fmt.Sprintf("mtu=%d", mtu))
|
||||
}
|
||||
|
||||
// ensureWintunDLL extracts the embedded wintun.dll to the executable's
|
||||
// directory if it does not already exist. The wintun Go package loads
|
||||
// the DLL via LoadLibrary which searches the exe's directory first.
|
||||
func ensureWintunDLL() error {
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
path := filepath.Join(filepath.Dir(exe), "wintun.dll")
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
return nil
|
||||
}
|
||||
return os.WriteFile(path, wintunDLL, 0o644)
|
||||
}
|
||||
|
||||
// execCmd runs a command, forwarding stdout/stderr.
|
||||
func execCmd(name string, arg ...string) error {
|
||||
cmd := exec.Command(name, arg...)
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
return fmt.Errorf("%s %s: %w", name, strings.Join(arg, " "), err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
Binary file not shown.
+59
-52
@@ -27,14 +27,23 @@ type App struct {
|
||||
window fyne.Window
|
||||
profileWindow fyne.Window
|
||||
|
||||
// Profile list window.
|
||||
profileListWindow fyne.Window
|
||||
profileList *widget.List
|
||||
listSelectedIndex int
|
||||
|
||||
// UI widgets
|
||||
profileSelect *widget.Select
|
||||
stateLabel *widget.Label
|
||||
ipLabel *widget.Label
|
||||
ip6Label *widget.Label
|
||||
uptimeLabel *widget.Label
|
||||
rxLabel *widget.Label
|
||||
txLabel *widget.Label
|
||||
rxV4Label *widget.Label
|
||||
txV4Label *widget.Label
|
||||
rxV6Label *widget.Label
|
||||
txV6Label *widget.Label
|
||||
rxTotalLabel *widget.Label
|
||||
txTotalLabel *widget.Label
|
||||
connectBtn *widget.Button
|
||||
disconnectBtn *widget.Button
|
||||
|
||||
@@ -71,10 +80,11 @@ func Run() {
|
||||
}
|
||||
|
||||
a := &App{
|
||||
fyneApp: app.NewWithID(paths.BundleID),
|
||||
db: store,
|
||||
kc: keychain.New(),
|
||||
langSetting: cfg.Language,
|
||||
fyneApp: app.NewWithID(paths.BundleID),
|
||||
db: store,
|
||||
kc: keychain.New(),
|
||||
langSetting: cfg.Language,
|
||||
listSelectedIndex: -1,
|
||||
}
|
||||
|
||||
a.window = a.fyneApp.NewWindow(i18n.T("WindowTitle"))
|
||||
@@ -102,13 +112,26 @@ func Run() {
|
||||
hideDockIcon()
|
||||
a.window.Hide()
|
||||
} else {
|
||||
a.fyneApp.Quit()
|
||||
a.quit()
|
||||
}
|
||||
})
|
||||
|
||||
a.window.ShowAndRun()
|
||||
}
|
||||
|
||||
// quit disconnects any active VPN session (via the daemon) and then
|
||||
// quits the application. The daemon process itself remains running.
|
||||
func (a *App) quit() {
|
||||
a.mu.Lock()
|
||||
client := a.ipcClient
|
||||
a.ipcClient = nil
|
||||
a.mu.Unlock()
|
||||
if client != nil {
|
||||
_ = ipc.SendStop(client)
|
||||
}
|
||||
a.fyneApp.Quit()
|
||||
}
|
||||
|
||||
// loadProfiles loads all profiles from the database and populates the
|
||||
// selector.
|
||||
func (a *App) loadProfiles() {
|
||||
@@ -128,6 +151,16 @@ func (a *App) loadProfiles() {
|
||||
a.profileSelect.SetSelected("")
|
||||
a.profileSelect.Refresh()
|
||||
}
|
||||
a.listSelectedIndex = -1
|
||||
a.refreshProfileList()
|
||||
}
|
||||
|
||||
// refreshProfileList refreshes the profile list widget if the profile
|
||||
// list window is currently open.
|
||||
func (a *App) refreshProfileList() {
|
||||
if a.profileList != nil {
|
||||
a.profileList.Refresh()
|
||||
}
|
||||
}
|
||||
|
||||
// profileNames returns the names of all loaded profiles.
|
||||
@@ -154,39 +187,6 @@ func (a *App) onAddProfile() {
|
||||
a.showProfileDialog(nil)
|
||||
}
|
||||
|
||||
// onEditProfile shows a dialog to edit the current profile.
|
||||
func (a *App) onEditProfile() {
|
||||
if a.currentProfile == nil {
|
||||
dialog.NewCustom(i18n.T("DlgNoProfileTitle"), i18n.T("BtnOK"),
|
||||
widget.NewLabel(i18n.T("DlgNoProfileEditMsg")), a.window).Show()
|
||||
return
|
||||
}
|
||||
p := *a.currentProfile
|
||||
a.showProfileDialog(&p)
|
||||
}
|
||||
|
||||
// onDeleteProfile deletes the current profile after confirmation.
|
||||
func (a *App) onDeleteProfile() {
|
||||
if a.currentProfile == nil {
|
||||
return
|
||||
}
|
||||
name := a.currentProfile.Name
|
||||
dialog.NewCustomConfirm(i18n.T("DlgDeleteProfileTitle"),
|
||||
i18n.T("BtnDelete"), i18n.T("BtnCancel"),
|
||||
widget.NewLabel(i18n.T("DlgDeleteProfileMsg", map[string]interface{}{"name": name})),
|
||||
func(ok bool) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := a.db.DeleteProfile(a.currentProfile.ID); err != nil {
|
||||
showError(i18n.T("DlgError"), err.Error(), a.window)
|
||||
return
|
||||
}
|
||||
_ = a.kc.DeleteAll(name)
|
||||
a.loadProfiles()
|
||||
}, a.window).Show()
|
||||
}
|
||||
|
||||
// onResetDB deletes the SQLite database file after confirmation,
|
||||
// then re-creates it. All profiles, credentials, and logs are lost.
|
||||
func (a *App) onResetDB() {
|
||||
@@ -231,14 +231,17 @@ func (a *App) onResetDB() {
|
||||
// Reset UI state.
|
||||
a.currentProfile = nil
|
||||
a.loadProfiles()
|
||||
a.stateLabel.SetText(i18n.T("StateDisconnected"))
|
||||
a.ipLabel.SetText(i18n.T("IpNone"))
|
||||
a.ip6Label.SetText(i18n.T("Ip6None"))
|
||||
a.uptimeLabel.SetText(i18n.T("UptimeNone"))
|
||||
a.rxLabel.SetText(i18n.T("RxZero"))
|
||||
a.txLabel.SetText(i18n.T("TxZero"))
|
||||
a.connectBtn.Enable()
|
||||
a.disconnectBtn.Disable()
|
||||
a.stateLabel.SetText(i18n.T("StateDisconnected"))
|
||||
a.ipLabel.SetText(i18n.T("IpNone"))
|
||||
a.ip6Label.SetText(i18n.T("Ip6None"))
|
||||
a.uptimeLabel.SetText(i18n.T("UptimeNone"))
|
||||
a.rxV4Label.SetText(i18n.T("RxV4Zero"))
|
||||
a.txV4Label.SetText(i18n.T("TxV4Zero"))
|
||||
a.rxV6Label.SetText(i18n.T("RxV6Zero"))
|
||||
a.txV6Label.SetText(i18n.T("TxV6Zero"))
|
||||
a.rxTotalLabel.SetText(i18n.T("RxTotalZero"))
|
||||
a.txTotalLabel.SetText(i18n.T("TxTotalZero"))
|
||||
a.setConnButtons(true, false)
|
||||
}, a.window).Show()
|
||||
}
|
||||
|
||||
@@ -279,6 +282,12 @@ func (a *App) rebuildUI() {
|
||||
selectedName = a.currentProfile.Name
|
||||
}
|
||||
|
||||
// Close the profile list window if open; it holds cached strings
|
||||
// and will be recreated with the new language on next open.
|
||||
if a.profileListWindow != nil {
|
||||
a.profileListWindow.Close()
|
||||
}
|
||||
|
||||
// Rebuild window content (creates fresh widgets with new strings).
|
||||
a.window.SetTitle(i18n.T("WindowTitle"))
|
||||
a.window.SetContent(a.buildMainWindow())
|
||||
@@ -299,12 +308,10 @@ func (a *App) rebuildUI() {
|
||||
|
||||
// Restore connection state.
|
||||
if wasConnected {
|
||||
a.connectBtn.Disable()
|
||||
a.disconnectBtn.Enable()
|
||||
a.setConnButtons(false, true)
|
||||
a.stateLabel.SetText(i18n.T("StateConnected"))
|
||||
} else {
|
||||
a.connectBtn.Enable()
|
||||
a.disconnectBtn.Disable()
|
||||
a.setConnButtons(true, false)
|
||||
a.stateLabel.SetText(i18n.T("StateDisconnected"))
|
||||
}
|
||||
|
||||
|
||||
+30
-33
@@ -3,13 +3,13 @@ package ui
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"lmvpn/internal/ipc"
|
||||
"lmvpn/internal/log"
|
||||
"lmvpn/internal/paths"
|
||||
"lmvpn/internal/version"
|
||||
)
|
||||
|
||||
// ensureDaemon checks if the daemon is running and launches it (as
|
||||
@@ -25,9 +25,27 @@ import (
|
||||
// The --daemon-bin flag tells daemon-launch where to find lmvpnd. If
|
||||
// not given, daemon-launch auto-detects it relative to its own path.
|
||||
func ensureDaemon() (*ipc.Client, error) {
|
||||
// Fast path: daemon already running.
|
||||
// Fast path: a daemon is already running. Verify its build version
|
||||
// matches ours; if not, it's a stale process from a previous build
|
||||
// and must be restarted so the new code takes effect.
|
||||
if c, err := ipc.Dial(); err == nil {
|
||||
return c, nil
|
||||
if ver, qerr := c.QueryVersion(); qerr == nil && ver == version.Version {
|
||||
return c, nil
|
||||
} else {
|
||||
log.L().Info("stale daemon detected, restarting",
|
||||
"got", ver, "expected", version.Version, "query_err", qerr)
|
||||
_ = ipc.SendShutdown(c)
|
||||
c.Close()
|
||||
// Wait for the old daemon to exit and release the socket.
|
||||
for i := 0; i < 30; i++ {
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
if cc, err := ipc.Dial(); err == nil {
|
||||
cc.Close()
|
||||
} else {
|
||||
break // socket gone
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
exe, err := os.Executable()
|
||||
@@ -41,7 +59,7 @@ func ensureDaemon() (*ipc.Client, error) {
|
||||
}
|
||||
|
||||
// Compute the daemon binary path: same directory as the GUI binary.
|
||||
daemonBin := filepath.Join(filepath.Dir(exe), "lmvpnd")
|
||||
daemonBin := filepath.Join(filepath.Dir(exe), daemonBinaryName)
|
||||
if _, err := os.Stat(daemonBin); err != nil {
|
||||
return nil, fmt.Errorf("daemon binary not found at %s: %w", daemonBin, err)
|
||||
}
|
||||
@@ -50,44 +68,23 @@ func ensureDaemon() (*ipc.Client, error) {
|
||||
uid := os.Getuid()
|
||||
gid := os.Getgid()
|
||||
|
||||
// Launch the daemon via osascript (prompts for admin password).
|
||||
// The `daemon-launch` subcommand forks lmvpnd with Setsid and exits
|
||||
// immediately, so osascript returns right away.
|
||||
script := fmt.Sprintf(
|
||||
`do shell script %q with administrator privileges`,
|
||||
fmt.Sprintf("%s daemon-launch --user-home %s --uid %d --gid %d --daemon-bin %s",
|
||||
shellQuote(exe), shellQuote(home), uid, gid, shellQuote(daemonBin)),
|
||||
)
|
||||
cmd := exec.Command("osascript", "-e", script)
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
return nil, fmt.Errorf("launch daemon: %w (output: %s)", err, string(out))
|
||||
if err := launchElevated(exe, daemonBin, home, uid, gid); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
log.L().Info("daemon launched via osascript",
|
||||
"uid", uid, "gid", gid, "home", home, "daemon_bin", daemonBin)
|
||||
|
||||
// Wait for the daemon to become reachable.
|
||||
logFile := paths.DaemonLogFile()
|
||||
for i := 0; i < 40; i++ {
|
||||
for i := 0; i < 60; i++ {
|
||||
time.Sleep(250 * time.Millisecond)
|
||||
if c, err := ipc.Dial(); err == nil {
|
||||
log.L().Info("daemon connected", "socket", paths.IPCSocketPath())
|
||||
log.L().Info("daemon connected", "socket", paths.IPCAddress())
|
||||
return c, nil
|
||||
} else if i == 0 || i == 10 || i == 30 || i == 59 {
|
||||
log.L().Warn("daemon not reachable yet, retrying",
|
||||
"attempt", i+1, "socket", paths.IPCAddress(), "error", err)
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("daemon did not become reachable (check %s)", logFile)
|
||||
}
|
||||
|
||||
// shellQuote wraps a string in single quotes for shell safety.
|
||||
// Embedded single quotes are escaped using the '\'' pattern.
|
||||
func shellQuote(s string) string {
|
||||
result := "'"
|
||||
for _, r := range s {
|
||||
if r == '\'' {
|
||||
result += "'\\''"
|
||||
} else {
|
||||
result += string(r)
|
||||
}
|
||||
}
|
||||
result += "'"
|
||||
return result
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
//go:build darwin
|
||||
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os/exec"
|
||||
|
||||
"lmvpn/internal/log"
|
||||
)
|
||||
|
||||
const daemonBinaryName = "lmvpnd"
|
||||
|
||||
// launchElevated launches the daemon-launch subcommand with admin
|
||||
// privileges via osascript on macOS.
|
||||
func launchElevated(exe, daemonBin, home string, uid, gid int) error {
|
||||
script := fmt.Sprintf(
|
||||
`do shell script %q with administrator privileges`,
|
||||
fmt.Sprintf("%s daemon-launch --user-home %s --uid %d --gid %d --daemon-bin %s",
|
||||
shellQuote(exe), shellQuote(home), uid, gid, shellQuote(daemonBin)),
|
||||
)
|
||||
cmd := exec.Command("osascript", "-e", script)
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("launch daemon: %w (output: %s)", err, string(out))
|
||||
}
|
||||
log.L().Info("daemon launched via osascript",
|
||||
"uid", uid, "gid", gid, "home", home, "daemon_bin", daemonBin)
|
||||
return nil
|
||||
}
|
||||
|
||||
// shellQuote wraps a string in single quotes for shell safety.
|
||||
// Embedded single quotes are escaped using the '\'' pattern.
|
||||
func shellQuote(s string) string {
|
||||
result := "'"
|
||||
for _, r := range s {
|
||||
if r == '\'' {
|
||||
result += "'\\''"
|
||||
} else {
|
||||
result += string(r)
|
||||
}
|
||||
}
|
||||
result += "'"
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
//go:build !darwin && !windows
|
||||
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os/exec"
|
||||
|
||||
"lmvpn/internal/log"
|
||||
)
|
||||
|
||||
const daemonBinaryName = "lmvpnd"
|
||||
|
||||
// launchElevated launches the daemon-launch subcommand with admin
|
||||
// privileges. On Linux, pkexec is used (common on desktop Linux).
|
||||
func launchElevated(exe, daemonBin, home string, uid, gid int) error {
|
||||
cmd := exec.Command("pkexec", exe, "daemon-launch",
|
||||
"--user-home", home,
|
||||
"--uid", fmt.Sprintf("%d", uid),
|
||||
"--gid", fmt.Sprintf("%d", gid),
|
||||
"--daemon-bin", daemonBin)
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("launch daemon: %w (output: %s)", err, string(out))
|
||||
}
|
||||
log.L().Info("daemon launched via pkexec",
|
||||
"uid", uid, "gid", gid, "home", home, "daemon_bin", daemonBin)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
//go:build windows
|
||||
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"syscall"
|
||||
"unsafe"
|
||||
|
||||
"lmvpn/internal/log"
|
||||
)
|
||||
|
||||
const daemonBinaryName = "lmvpnd.exe"
|
||||
|
||||
// launchElevated launches the daemon-launch subcommand with UAC
|
||||
// elevation via ShellExecute "runas" on Windows.
|
||||
func launchElevated(exe, daemonBin, home string, uid, gid int) error {
|
||||
args := fmt.Sprintf("daemon-launch --user-home \"%s\" --uid %d --gid %d --daemon-bin \"%s\"",
|
||||
home, uid, gid, daemonBin)
|
||||
|
||||
shell32 := syscall.NewLazyDLL("shell32.dll")
|
||||
proc := shell32.NewProc("ShellExecuteW")
|
||||
|
||||
verb, _ := syscall.UTF16PtrFromString("runas")
|
||||
file, _ := syscall.UTF16PtrFromString(exe)
|
||||
params, _ := syscall.UTF16PtrFromString(args)
|
||||
dir, _ := syscall.UTF16PtrFromString("")
|
||||
|
||||
ret, _, err := proc.Call(
|
||||
0,
|
||||
uintptr(unsafe.Pointer(verb)),
|
||||
uintptr(unsafe.Pointer(file)),
|
||||
uintptr(unsafe.Pointer(params)),
|
||||
uintptr(unsafe.Pointer(dir)),
|
||||
1, // SW_SHOWNORMAL
|
||||
)
|
||||
|
||||
if ret <= 32 {
|
||||
return fmt.Errorf("UAC elevation failed (code %d): %w", ret, err)
|
||||
}
|
||||
log.L().Info("daemon launched via UAC",
|
||||
"uid", uid, "gid", gid, "home", home, "daemon_bin", daemonBin)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="24" height="24">
|
||||
<path fill="#24292f" d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.012 8.012 0 0 0 16 8c0-4.42-3.58-8-8-8z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 691 B |
+97
-1
@@ -1,6 +1,7 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
|
||||
"lmvpn/internal/i18n"
|
||||
@@ -8,6 +9,7 @@ import (
|
||||
|
||||
"fyne.io/fyne/v2"
|
||||
"fyne.io/fyne/v2/container"
|
||||
"fyne.io/fyne/v2/dialog"
|
||||
"fyne.io/fyne/v2/widget"
|
||||
)
|
||||
|
||||
@@ -18,7 +20,7 @@ var (
|
||||
authCodes = []string{string(model.AuthModeBoth), string(model.AuthModeJWT), string(model.AuthModePassword)}
|
||||
routeCodes = []string{string(model.RoutingFull), string(model.RoutingSplit), string(model.RoutingCustom)}
|
||||
|
||||
protoCodes = []string{"wss", "ws"}
|
||||
protoCodes = []string{"wss", "ws"}
|
||||
)
|
||||
|
||||
func authModeLabels() []string {
|
||||
@@ -267,3 +269,97 @@ func parseIntDefault(s string, def int) int {
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// showProfileListWindow opens a window listing all saved profiles with
|
||||
// buttons to add, edit, delete, and reset the database. Only one
|
||||
// instance is allowed; if already open it is brought to the front.
|
||||
func (a *App) showProfileListWindow() {
|
||||
if a.profileListWindow != nil {
|
||||
a.profileListWindow.RequestFocus()
|
||||
return
|
||||
}
|
||||
|
||||
a.profileList = widget.NewList(
|
||||
func() int { return len(a.profiles) },
|
||||
func() fyne.CanvasObject {
|
||||
name := widget.NewLabel("")
|
||||
name.TextStyle = fyne.TextStyle{Bold: true}
|
||||
host := widget.NewLabel("")
|
||||
return container.NewVBox(name, host)
|
||||
},
|
||||
func(id widget.ListItemID, obj fyne.CanvasObject) {
|
||||
box := obj.(*fyne.Container)
|
||||
nameLbl := box.Objects[0].(*widget.Label)
|
||||
hostLbl := box.Objects[1].(*widget.Label)
|
||||
if id >= 0 && id < len(a.profiles) {
|
||||
p := a.profiles[id]
|
||||
nameLbl.SetText(p.Name)
|
||||
hostLbl.SetText(fmt.Sprintf("%s:%d", p.Host, p.Port))
|
||||
}
|
||||
},
|
||||
)
|
||||
a.profileList.OnSelected = func(id widget.ListItemID) {
|
||||
a.listSelectedIndex = id
|
||||
}
|
||||
a.profileList.OnUnselected = func(_ widget.ListItemID) {
|
||||
a.listSelectedIndex = -1
|
||||
}
|
||||
|
||||
addBtn := widget.NewButton(i18n.T("BtnAddProfile"), a.onAddProfile)
|
||||
editBtn := widget.NewButton(i18n.T("BtnEdit"), a.onEditProfileFromList)
|
||||
deleteBtn := widget.NewButton(i18n.T("BtnDelete"), a.onDeleteProfileFromList)
|
||||
resetDBBtn := widget.NewButton(i18n.T("BtnResetDB"), a.onResetDB)
|
||||
|
||||
buttons := container.NewGridWithColumns(4, addBtn, editBtn, deleteBtn, resetDBBtn)
|
||||
|
||||
win := a.fyneApp.NewWindow(i18n.T("ProfileListTitle"))
|
||||
a.profileListWindow = win
|
||||
win.SetOnClosed(func() {
|
||||
a.profileListWindow = nil
|
||||
a.profileList = nil
|
||||
a.listSelectedIndex = -1
|
||||
})
|
||||
|
||||
win.SetContent(container.NewBorder(nil, buttons, nil, nil, a.profileList))
|
||||
win.Resize(fyne.NewSize(460, 400))
|
||||
win.Show()
|
||||
}
|
||||
|
||||
// onEditProfileFromList opens the profile editor for the profile
|
||||
// selected in the list window.
|
||||
func (a *App) onEditProfileFromList() {
|
||||
idx := a.listSelectedIndex
|
||||
if idx < 0 || idx >= len(a.profiles) {
|
||||
dialog.NewCustom(i18n.T("DlgNoListSelectTitle"), i18n.T("BtnOK"),
|
||||
widget.NewLabel(i18n.T("DlgNoListSelectEditMsg")), a.profileListWindow).Show()
|
||||
return
|
||||
}
|
||||
p := a.profiles[idx]
|
||||
a.showProfileDialog(&p)
|
||||
}
|
||||
|
||||
// onDeleteProfileFromList deletes the profile selected in the list
|
||||
// window after confirmation.
|
||||
func (a *App) onDeleteProfileFromList() {
|
||||
idx := a.listSelectedIndex
|
||||
if idx < 0 || idx >= len(a.profiles) {
|
||||
dialog.NewCustom(i18n.T("DlgNoListSelectTitle"), i18n.T("BtnOK"),
|
||||
widget.NewLabel(i18n.T("DlgNoListSelectDeleteMsg")), a.profileListWindow).Show()
|
||||
return
|
||||
}
|
||||
p := a.profiles[idx]
|
||||
dialog.NewCustomConfirm(i18n.T("DlgDeleteProfileTitle"),
|
||||
i18n.T("BtnDelete"), i18n.T("BtnCancel"),
|
||||
widget.NewLabel(i18n.T("DlgDeleteProfileMsg", map[string]interface{}{"name": p.Name})),
|
||||
func(ok bool) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := a.db.DeleteProfile(p.ID); err != nil {
|
||||
showError(i18n.T("DlgError"), err.Error(), a.profileListWindow)
|
||||
return
|
||||
}
|
||||
_ = a.kc.DeleteAll(p.Name)
|
||||
a.loadProfiles()
|
||||
}, a.profileListWindow).Show()
|
||||
}
|
||||
|
||||
+21
-12
@@ -46,24 +46,33 @@ func (a *App) setupTray() {
|
||||
autoItem, enItem, zhItem,
|
||||
)
|
||||
|
||||
menu := fyne.NewMenu(i18n.T("WindowTitle"),
|
||||
fyne.NewMenuItem(i18n.T("TrayShowWindow"), func() {
|
||||
showDockIcon()
|
||||
a.window.Show()
|
||||
a.window.RequestFocus()
|
||||
}),
|
||||
fyne.NewMenuItemSeparator(),
|
||||
fyne.NewMenuItem(i18n.T("TrayConnect"), func() {
|
||||
connectItem := fyne.NewMenuItem(i18n.T("TrayConnect"), func() {
|
||||
a.onConnect()
|
||||
}),
|
||||
fyne.NewMenuItem(i18n.T("TrayDisconnect"), func() {
|
||||
})
|
||||
disconnectItem := fyne.NewMenuItem(i18n.T("TrayDisconnect"), func() {
|
||||
a.onDisconnect()
|
||||
}),
|
||||
})
|
||||
if a.connectBtn != nil {
|
||||
connectItem.Disabled = a.connectBtn.Disabled()
|
||||
}
|
||||
if a.disconnectBtn != nil {
|
||||
disconnectItem.Disabled = a.disconnectBtn.Disabled()
|
||||
}
|
||||
|
||||
menu := fyne.NewMenu(i18n.T("WindowTitle"),
|
||||
fyne.NewMenuItem(i18n.T("TrayShowWindow"), func() {
|
||||
showDockIcon()
|
||||
a.window.Show()
|
||||
a.window.RequestFocus()
|
||||
}),
|
||||
fyne.NewMenuItemSeparator(),
|
||||
connectItem,
|
||||
disconnectItem,
|
||||
fyne.NewMenuItemSeparator(),
|
||||
langItem,
|
||||
fyne.NewMenuItemSeparator(),
|
||||
fyne.NewMenuItem(i18n.T("TrayQuit"), func() {
|
||||
a.fyneApp.Quit()
|
||||
a.quit()
|
||||
}),
|
||||
)
|
||||
deskApp.SetSystemTrayMenu(menu)
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
package ui
|
||||
|
||||
var Version = "dev"
|
||||
import "lmvpn/internal/version"
|
||||
|
||||
// Version is the application version, sourced from the shared version
|
||||
// package so the GUI and daemon report the same string.
|
||||
var Version = version.Version
|
||||
|
||||
+126
-43
@@ -1,7 +1,9 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"lmvpn/internal/i18n"
|
||||
@@ -11,14 +13,35 @@ import (
|
||||
"fyne.io/fyne/v2"
|
||||
"fyne.io/fyne/v2/container"
|
||||
"fyne.io/fyne/v2/dialog"
|
||||
"fyne.io/fyne/v2/layout"
|
||||
"fyne.io/fyne/v2/theme"
|
||||
"fyne.io/fyne/v2/widget"
|
||||
)
|
||||
|
||||
//go:embed github.svg
|
||||
var githubIconBytes []byte
|
||||
|
||||
// showError displays a titled error dialog.
|
||||
func showError(title, message string, parent fyne.Window) {
|
||||
dialog.NewCustom(title, i18n.T("BtnOK"), widget.NewLabel(message), parent).Show()
|
||||
}
|
||||
|
||||
// setConnButtons enables/disables the connect and disconnect buttons
|
||||
// and refreshes the tray menu to match.
|
||||
func (a *App) setConnButtons(connectEnabled, disconnectEnabled bool) {
|
||||
if connectEnabled {
|
||||
a.connectBtn.Enable()
|
||||
} else {
|
||||
a.connectBtn.Disable()
|
||||
}
|
||||
if disconnectEnabled {
|
||||
a.disconnectBtn.Enable()
|
||||
} else {
|
||||
a.disconnectBtn.Disable()
|
||||
}
|
||||
a.setupTray()
|
||||
}
|
||||
|
||||
// buildMainWindow creates the main application window layout.
|
||||
func (a *App) buildMainWindow() fyne.CanvasObject {
|
||||
// Profile selector.
|
||||
@@ -32,15 +55,21 @@ func (a *App) buildMainWindow() fyne.CanvasObject {
|
||||
a.ipLabel = widget.NewLabel(i18n.T("IpNone"))
|
||||
a.ip6Label = widget.NewLabel(i18n.T("Ip6None"))
|
||||
a.uptimeLabel = widget.NewLabel(i18n.T("UptimeNone"))
|
||||
a.rxLabel = widget.NewLabel(i18n.T("RxZero"))
|
||||
a.txLabel = widget.NewLabel(i18n.T("TxZero"))
|
||||
a.rxV4Label = widget.NewLabel(i18n.T("RxV4Zero"))
|
||||
a.txV4Label = widget.NewLabel(i18n.T("TxV4Zero"))
|
||||
a.rxV6Label = widget.NewLabel(i18n.T("RxV6Zero"))
|
||||
a.txV6Label = widget.NewLabel(i18n.T("TxV6Zero"))
|
||||
a.rxTotalLabel = widget.NewLabel(i18n.T("RxTotalZero"))
|
||||
a.txTotalLabel = widget.NewLabel(i18n.T("TxTotalZero"))
|
||||
|
||||
statusCard := widget.NewCard(i18n.T("StatusLabel"), "", container.NewVBox(
|
||||
a.stateLabel,
|
||||
a.ipLabel,
|
||||
a.ip6Label,
|
||||
a.uptimeLabel,
|
||||
container.NewHBox(a.rxLabel, a.txLabel),
|
||||
container.NewHBox(a.rxV4Label, a.txV4Label),
|
||||
container.NewHBox(a.rxV6Label, a.txV6Label),
|
||||
container.NewHBox(a.rxTotalLabel, a.txTotalLabel),
|
||||
))
|
||||
|
||||
// Buttons.
|
||||
@@ -49,28 +78,35 @@ func (a *App) buildMainWindow() fyne.CanvasObject {
|
||||
a.disconnectBtn = widget.NewButton(i18n.T("BtnDisconnect"), a.onDisconnect)
|
||||
a.disconnectBtn.Disable()
|
||||
|
||||
addBtn := widget.NewButton(i18n.T("BtnAddProfile"), a.onAddProfile)
|
||||
editBtn := widget.NewButton(i18n.T("BtnEdit"), a.onEditProfile)
|
||||
deleteBtn := widget.NewButton(i18n.T("BtnDelete"), a.onDeleteProfile)
|
||||
|
||||
resetDBBtn := widget.NewButton(i18n.T("BtnResetDB"), a.onResetDB)
|
||||
profileListBtn := widget.NewButton(i18n.T("BtnProfileList"), a.showProfileListWindow)
|
||||
|
||||
buttons := container.NewGridWithColumns(2,
|
||||
a.connectBtn, a.disconnectBtn,
|
||||
)
|
||||
profileButtons := container.NewGridWithColumns(3,
|
||||
addBtn, editBtn, deleteBtn,
|
||||
)
|
||||
|
||||
return container.NewVBox(
|
||||
githubBtn := widget.NewButtonWithIcon("", theme.NewThemedResource(fyne.NewStaticResource("github.svg", githubIconBytes)), func() {
|
||||
u, err := url.Parse("https://github.com/wuwenfengmi1998/lmvpn_client")
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
_ = a.fyneApp.OpenURL(u)
|
||||
})
|
||||
|
||||
content := container.NewVBox(
|
||||
widget.NewLabel(i18n.T("ProfileLabel")),
|
||||
a.profileSelect,
|
||||
buttons,
|
||||
profileButtons,
|
||||
resetDBBtn,
|
||||
profileListBtn,
|
||||
statusCard,
|
||||
widget.NewLabel(fmt.Sprintf("v%s", Version)),
|
||||
)
|
||||
|
||||
bottomBar := container.NewHBox(
|
||||
widget.NewLabel(fmt.Sprintf("v%s", Version)),
|
||||
layout.NewSpacer(),
|
||||
githubBtn,
|
||||
)
|
||||
|
||||
return container.NewBorder(nil, bottomBar, nil, nil, content)
|
||||
}
|
||||
|
||||
// onConnect handles the Connect button click.
|
||||
@@ -80,8 +116,7 @@ func (a *App) onConnect() {
|
||||
return
|
||||
}
|
||||
|
||||
a.connectBtn.Disable()
|
||||
a.disconnectBtn.Enable()
|
||||
a.setConnButtons(false, true)
|
||||
a.stateLabel.SetText(i18n.T("StateConnecting"))
|
||||
|
||||
go func() {
|
||||
@@ -91,8 +126,7 @@ func (a *App) onConnect() {
|
||||
fyne.Do(func() {
|
||||
showError(i18n.T("DlgDaemonError"), err.Error(), a.window)
|
||||
a.stateLabel.SetText(i18n.T("StateDisconnected"))
|
||||
a.connectBtn.Enable()
|
||||
a.disconnectBtn.Disable()
|
||||
a.setConnButtons(true, false)
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -109,8 +143,7 @@ func (a *App) onConnect() {
|
||||
i18n.T("DlgCredentialErrorMsg"),
|
||||
a.window)
|
||||
a.stateLabel.SetText(i18n.T("StateDisconnected"))
|
||||
a.connectBtn.Enable()
|
||||
a.disconnectBtn.Disable()
|
||||
a.setConnButtons(true, false)
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -139,8 +172,7 @@ func (a *App) onConnect() {
|
||||
fyne.Do(func() {
|
||||
showError(i18n.T("DlgIPCError"), err.Error(), a.window)
|
||||
a.stateLabel.SetText(i18n.T("StateDisconnected"))
|
||||
a.connectBtn.Enable()
|
||||
a.disconnectBtn.Disable()
|
||||
a.setConnButtons(true, false)
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -159,8 +191,7 @@ func (a *App) onDisconnect() {
|
||||
return
|
||||
}
|
||||
_ = ipc.SendStop(client)
|
||||
a.disconnectBtn.Disable()
|
||||
a.connectBtn.Enable()
|
||||
a.setConnButtons(true, false)
|
||||
a.stateLabel.SetText(i18n.T("StateDisconnected"))
|
||||
}
|
||||
|
||||
@@ -185,10 +216,13 @@ func (a *App) eventLoop() {
|
||||
a.ipLabel.SetText(i18n.T("IpNone"))
|
||||
a.ip6Label.SetText(i18n.T("Ip6None"))
|
||||
a.uptimeLabel.SetText(i18n.T("UptimeNone"))
|
||||
a.rxLabel.SetText(i18n.T("RxZero"))
|
||||
a.txLabel.SetText(i18n.T("TxZero"))
|
||||
a.connectBtn.Enable()
|
||||
a.disconnectBtn.Disable()
|
||||
a.rxV4Label.SetText(i18n.T("RxV4Zero"))
|
||||
a.txV4Label.SetText(i18n.T("TxV4Zero"))
|
||||
a.rxV6Label.SetText(i18n.T("RxV6Zero"))
|
||||
a.txV6Label.SetText(i18n.T("TxV6Zero"))
|
||||
a.rxTotalLabel.SetText(i18n.T("RxTotalZero"))
|
||||
a.txTotalLabel.SetText(i18n.T("TxTotalZero"))
|
||||
a.setConnButtons(true, false)
|
||||
}
|
||||
})
|
||||
return
|
||||
@@ -207,39 +241,55 @@ func (a *App) eventLoop() {
|
||||
}
|
||||
case ipc.EvError:
|
||||
fyne.Do(func() {
|
||||
if ev.Message != "" {
|
||||
showError(i18n.T("DlgVPNError"), ev.Message, a.window)
|
||||
msg := authErrorMessage(ev.Code, ev.Message)
|
||||
if msg != "" {
|
||||
showError(i18n.T("DlgAuthError"), msg, a.window)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// authErrorMessage maps a stable auth-error code (carried by the EvError
|
||||
// IPC event from the daemon) to a localized user-facing string. For an
|
||||
// unknown or empty code it falls back to the raw server message.
|
||||
func authErrorMessage(code, fallback string) string {
|
||||
switch code {
|
||||
case "wrong_credentials":
|
||||
return i18n.T("AuthErrWrongCredentials")
|
||||
case "user_disabled":
|
||||
return i18n.T("AuthErrUserDisabled")
|
||||
case "token_invalid":
|
||||
return i18n.T("AuthErrTokenInvalid")
|
||||
case "rate_limited":
|
||||
return i18n.T("AuthErrRateLimited")
|
||||
case "malformed":
|
||||
return i18n.T("AuthErrMalformed")
|
||||
default:
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
|
||||
// applyState updates UI elements for a state change.
|
||||
func (a *App) applyState(state string) {
|
||||
switch stats.State(state) {
|
||||
case stats.StateConnected:
|
||||
a.stateLabel.SetText(i18n.T("StateConnected"))
|
||||
a.connectBtn.Disable()
|
||||
a.disconnectBtn.Enable()
|
||||
a.setConnButtons(false, true)
|
||||
case stats.StateConnecting:
|
||||
a.stateLabel.SetText(i18n.T("StateConnecting"))
|
||||
a.connectBtn.Disable()
|
||||
a.disconnectBtn.Enable()
|
||||
a.setConnButtons(false, true)
|
||||
case stats.StateReconnecting:
|
||||
a.stateLabel.SetText(i18n.T("StateReconnecting"))
|
||||
a.connectBtn.Disable()
|
||||
a.disconnectBtn.Enable()
|
||||
a.setConnButtons(false, true)
|
||||
case stats.StateDisconnected:
|
||||
a.stateLabel.SetText(i18n.T("StateDisconnected"))
|
||||
a.ipLabel.SetText(i18n.T("IpNone"))
|
||||
a.ip6Label.SetText(i18n.T("Ip6None"))
|
||||
a.connectBtn.Enable()
|
||||
a.disconnectBtn.Disable()
|
||||
a.setConnButtons(true, false)
|
||||
case stats.StateError:
|
||||
a.stateLabel.SetText(i18n.T("StateError"))
|
||||
a.connectBtn.Enable()
|
||||
a.disconnectBtn.Disable()
|
||||
a.setConnButtons(true, false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -256,8 +306,24 @@ func (a *App) applyStats(s stats.Snapshot) {
|
||||
if s.State == stats.StateConnected {
|
||||
a.stateLabel.SetText(i18n.T("StateConnected"))
|
||||
}
|
||||
a.rxLabel.SetText(i18n.T("RxLabel", map[string]interface{}{"bytes": formatBytes(s.RxBytes)}))
|
||||
a.txLabel.SetText(i18n.T("TxLabel", map[string]interface{}{"bytes": formatBytes(s.TxBytes)}))
|
||||
a.rxV4Label.SetText(i18n.T("RxV4Label", map[string]interface{}{
|
||||
"bytes": formatBytes(s.RxBytesV4), "speed": formatSpeed(s.RxSpeedV4),
|
||||
}))
|
||||
a.txV4Label.SetText(i18n.T("TxV4Label", map[string]interface{}{
|
||||
"bytes": formatBytes(s.TxBytesV4), "speed": formatSpeed(s.TxSpeedV4),
|
||||
}))
|
||||
a.rxV6Label.SetText(i18n.T("RxV6Label", map[string]interface{}{
|
||||
"bytes": formatBytes(s.RxBytesV6), "speed": formatSpeed(s.RxSpeedV6),
|
||||
}))
|
||||
a.txV6Label.SetText(i18n.T("TxV6Label", map[string]interface{}{
|
||||
"bytes": formatBytes(s.TxBytesV6), "speed": formatSpeed(s.TxSpeedV6),
|
||||
}))
|
||||
a.rxTotalLabel.SetText(i18n.T("RxTotalLabel", map[string]interface{}{
|
||||
"bytes": formatBytes(s.RxBytes), "speed": formatSpeed(s.RxSpeed),
|
||||
}))
|
||||
a.txTotalLabel.SetText(i18n.T("TxTotalLabel", map[string]interface{}{
|
||||
"bytes": formatBytes(s.TxBytes), "speed": formatSpeed(s.TxSpeed),
|
||||
}))
|
||||
if s.Uptime > 0 {
|
||||
a.uptimeLabel.SetText(i18n.T("UptimeLabel", map[string]interface{}{"uptime": formatDuration(s.Uptime)}))
|
||||
}
|
||||
@@ -277,6 +343,23 @@ func formatBytes(b int64) string {
|
||||
return fmt.Sprintf("%.1f %ciB", float64(b)/float64(div), "KMGTPE"[exp])
|
||||
}
|
||||
|
||||
// formatSpeed formats a bytes/sec rate as a decimal bits/sec rate
|
||||
// (kbps / Mbps / Gbps). bps is the byte rate; it is converted to bits
|
||||
// by multiplying by 8 and scaled by 1000 (decimal, network convention).
|
||||
func formatSpeed(bps int64) string {
|
||||
const unit = 1000
|
||||
bits := bps * 8
|
||||
if bits < unit {
|
||||
return fmt.Sprintf("%d bps", bits)
|
||||
}
|
||||
div, exp := int64(unit), 0
|
||||
for n := bits / unit; n >= unit; n /= unit {
|
||||
div *= unit
|
||||
exp++
|
||||
}
|
||||
return fmt.Sprintf("%.1f %cbps", float64(bits)/float64(div), "kMGTPE"[exp])
|
||||
}
|
||||
|
||||
// formatDuration formats an uptime duration.
|
||||
func formatDuration(d time.Duration) string {
|
||||
d = d.Round(time.Second)
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
// Package version holds the build version string shared by the GUI
|
||||
// (lmvpn) and daemon (lmvpnd) binaries. The value is injected at link
|
||||
// time via ldflags:
|
||||
//
|
||||
// -X lmvpn/internal/version.Version=$(VERSION)
|
||||
//
|
||||
// Both binaries are built with the same LDFLAGS, so a version mismatch
|
||||
// between the running daemon and the GUI indicates a stale daemon
|
||||
// process that predates the current build.
|
||||
package version
|
||||
|
||||
// Version is the application version. Overridden at link time; "dev"
|
||||
// when running an untagged build (e.g. go run / go build without
|
||||
// ldflags).
|
||||
var Version = "dev"
|
||||
+126
-7
@@ -12,6 +12,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -44,6 +45,7 @@ type SessionManager struct {
|
||||
stats *stats.Stats
|
||||
onState func(stats.State)
|
||||
onStats func(stats.Snapshot)
|
||||
onError func(code string, msg string)
|
||||
|
||||
mu sync.Mutex
|
||||
running bool
|
||||
@@ -51,16 +53,31 @@ type SessionManager struct {
|
||||
dev tun.Device
|
||||
routeMgr *route.Manager
|
||||
conn *transport.Conn
|
||||
|
||||
// EWMA speed smoothing state. Only touched by reportStats (single
|
||||
// goroutine), so no lock needed. ewma* fields hold the smoothed
|
||||
// bytes/sec; prev* hold the last snapshot's cumulative counters and
|
||||
// tick time for delta computation.
|
||||
ewmaRxV4 float64
|
||||
ewmaTxV4 float64
|
||||
ewmaRxV6 float64
|
||||
ewmaTxV6 float64
|
||||
prevSnap stats.Snapshot
|
||||
prevTick time.Time
|
||||
speedReady bool
|
||||
}
|
||||
|
||||
// New creates a SessionManager. The onState callback (if non-nil) is
|
||||
// invoked on every state transition. The onStats callback (if non-nil)
|
||||
// is invoked periodically while connected.
|
||||
func New(onState func(stats.State), onStats func(stats.Snapshot)) *SessionManager {
|
||||
// is invoked periodically while connected. The onError callback (if
|
||||
// non-nil) is invoked once when a fatal, non-retryable error (such as
|
||||
// an authentication failure) terminates the session.
|
||||
func New(onState func(stats.State), onStats func(stats.Snapshot), onError func(string, string)) *SessionManager {
|
||||
return &SessionManager{
|
||||
stats: stats.New(),
|
||||
onState: onState,
|
||||
onStats: onStats,
|
||||
onError: onError,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,7 +131,12 @@ func (sm *SessionManager) Disconnect() {
|
||||
// run is the main session loop with exponential-backoff reconnection
|
||||
// and CDN IP failover.
|
||||
func (sm *SessionManager) run(ctx context.Context, cfg SessionConfig) {
|
||||
defer sm.setState(stats.StateDisconnected)
|
||||
fatal := false
|
||||
defer func() {
|
||||
if !fatal {
|
||||
sm.setState(stats.StateDisconnected)
|
||||
}
|
||||
}()
|
||||
|
||||
backoff := time.Second
|
||||
maxBackoff := 60 * time.Second
|
||||
@@ -141,6 +163,22 @@ func (sm *SessionManager) run(ctx context.Context, cfg SessionConfig) {
|
||||
|
||||
if err != nil {
|
||||
log.L().Error("VPN connection failed", "error", err)
|
||||
|
||||
// A fatal authentication failure (wrong password, disabled
|
||||
// account, expired token, rate limit) is not retryable:
|
||||
// stop the loop and surface the reason to the user instead
|
||||
// of hammering the server forever.
|
||||
if code, msg, isFatal := fatalAuthError(err); isFatal {
|
||||
log.L().Warn("fatal auth error, stopping reconnect", "code", code, "message", msg)
|
||||
sm.setState(stats.StateError)
|
||||
if sm.onError != nil {
|
||||
sm.onError(string(code), msg)
|
||||
}
|
||||
fatal = true
|
||||
sm.cleanup()
|
||||
return
|
||||
}
|
||||
|
||||
sm.setState(stats.StateReconnecting)
|
||||
|
||||
// Try next CDN IP immediately.
|
||||
@@ -169,6 +207,41 @@ func (sm *SessionManager) run(ctx context.Context, cfg SessionConfig) {
|
||||
}
|
||||
}
|
||||
|
||||
// fatalAuthError inspects err and, if it represents a permanent
|
||||
// authentication failure that should not be retried, returns the
|
||||
// stable error code, the raw server message, and true. It recognises
|
||||
// all three auth-failure shapes produced by the transport/auth layers:
|
||||
// - *transport.AuthError (WebSocket auth_err at the auth stage)
|
||||
// - *transport.ServerError (auth_err at the init stage, JWT path)
|
||||
// - *auth.LoginError (HTTP /api/login failure, JWT path)
|
||||
//
|
||||
// errors.As transparently unwraps fmt.Errorf("...: %w", err) chains,
|
||||
// so the wrapped LoginError returned by connectOnce is matched too.
|
||||
// A non-empty code is required: an auth_err with an unrecognized
|
||||
// message is treated as non-fatal so the loop falls back to retrying
|
||||
// (the server still closed the connection, but we lack a categorical
|
||||
// reason to give up).
|
||||
func fatalAuthError(err error) (protocol.AuthErrorCode, string, bool) {
|
||||
var authErr *transport.AuthError
|
||||
if errors.As(err, &authErr) {
|
||||
return authErr.Code, authErr.Message, authErr.Code != ""
|
||||
}
|
||||
var serverErr *transport.ServerError
|
||||
if errors.As(err, &serverErr) && serverErr.Type == protocol.TypeAuthErr {
|
||||
return serverErr.Code, serverErr.Message, serverErr.Code != ""
|
||||
}
|
||||
var loginErr *auth.LoginError
|
||||
if errors.As(err, &loginErr) {
|
||||
switch loginErr.Code {
|
||||
case http.StatusTooManyRequests:
|
||||
return protocol.AuthCodeRateLimited, loginErr.Message, true
|
||||
case http.StatusUnauthorized, http.StatusForbidden:
|
||||
return protocol.AuthCodeWrongCredentials, loginErr.Message, true
|
||||
}
|
||||
}
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
// connectOnce performs a single connection lifecycle: authenticate,
|
||||
// handshake, configure TUN, apply routes, pump packets until failure.
|
||||
func (sm *SessionManager) connectOnce(ctx context.Context, cfg SessionConfig, targetIP string) error {
|
||||
@@ -359,7 +432,7 @@ func (sm *SessionManager) pumpPackets(ctx context.Context, conn *transport.Conn)
|
||||
}
|
||||
return
|
||||
}
|
||||
sm.stats.TxBytes.Add(int64(n))
|
||||
sm.stats.AddTx(buf[:n])
|
||||
}
|
||||
}()
|
||||
|
||||
@@ -386,7 +459,7 @@ func (sm *SessionManager) pumpPackets(ctx context.Context, conn *transport.Conn)
|
||||
conn.Close()
|
||||
return
|
||||
}
|
||||
sm.stats.RxBytes.Add(int64(len(data)))
|
||||
sm.stats.AddRx(data)
|
||||
}
|
||||
}()
|
||||
|
||||
@@ -446,9 +519,15 @@ func (sm *SessionManager) setState(s stats.State) {
|
||||
}
|
||||
|
||||
// reportStats periodically calls the onStats callback while connected.
|
||||
// On each tick it derives per-family speeds (bytes/sec) from the
|
||||
// delta between the current and previous cumulative counters, then
|
||||
// applies EWMA smoothing (0.7 old + 0.3 new) so the displayed rates
|
||||
// don't jitter. The combined speeds are the sum of the per-family
|
||||
// smoothed values.
|
||||
func (sm *SessionManager) reportStats(done <-chan struct{}, ctx context.Context) {
|
||||
ticker := time.NewTicker(time.Second)
|
||||
defer ticker.Stop()
|
||||
const ewmaAlpha = 0.3
|
||||
for {
|
||||
select {
|
||||
case <-done:
|
||||
@@ -456,9 +535,49 @@ func (sm *SessionManager) reportStats(done <-chan struct{}, ctx context.Context)
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
if sm.onStats != nil {
|
||||
sm.onStats(sm.stats.Snapshot())
|
||||
if sm.onStats == nil {
|
||||
continue
|
||||
}
|
||||
snap := sm.stats.Snapshot()
|
||||
now := time.Now()
|
||||
if sm.speedReady {
|
||||
elapsed := now.Sub(sm.prevTick).Seconds()
|
||||
if elapsed <= 0 {
|
||||
elapsed = 1
|
||||
}
|
||||
// Per-second deltas (bytes/sec), clamped to >= 0 in case
|
||||
// of counter resets between reconnects within the same
|
||||
// SessionManager lifetime.
|
||||
rxV4 := max(0.0, float64(snap.RxBytesV4-sm.prevSnap.RxBytesV4)/elapsed)
|
||||
txV4 := max(0.0, float64(snap.TxBytesV4-sm.prevSnap.TxBytesV4)/elapsed)
|
||||
rxV6 := max(0.0, float64(snap.RxBytesV6-sm.prevSnap.RxBytesV6)/elapsed)
|
||||
txV6 := max(0.0, float64(snap.TxBytesV6-sm.prevSnap.TxBytesV6)/elapsed)
|
||||
if sm.ewmaRxV4 == 0 && sm.ewmaTxV4 == 0 && sm.ewmaRxV6 == 0 && sm.ewmaTxV6 == 0 {
|
||||
// First real sample: seed instead of ramping from 0.
|
||||
sm.ewmaRxV4, sm.ewmaTxV4, sm.ewmaRxV6, sm.ewmaTxV6 = rxV4, txV4, rxV6, txV6
|
||||
} else {
|
||||
sm.ewmaRxV4 = sm.ewmaRxV4*(1-ewmaAlpha) + rxV4*ewmaAlpha
|
||||
sm.ewmaTxV4 = sm.ewmaTxV4*(1-ewmaAlpha) + txV4*ewmaAlpha
|
||||
sm.ewmaRxV6 = sm.ewmaRxV6*(1-ewmaAlpha) + rxV6*ewmaAlpha
|
||||
sm.ewmaTxV6 = sm.ewmaTxV6*(1-ewmaAlpha) + txV6*ewmaAlpha
|
||||
}
|
||||
snap.RxSpeedV4 = int64(sm.ewmaRxV4)
|
||||
snap.TxSpeedV4 = int64(sm.ewmaTxV4)
|
||||
snap.RxSpeedV6 = int64(sm.ewmaRxV6)
|
||||
snap.TxSpeedV6 = int64(sm.ewmaTxV6)
|
||||
snap.RxSpeed = snap.RxSpeedV4 + snap.RxSpeedV6
|
||||
snap.TxSpeed = snap.TxSpeedV4 + snap.TxSpeedV6
|
||||
}
|
||||
sm.prevSnap = snap
|
||||
// Clear speed fields on the stored prev copy so we don't
|
||||
// accidentally carry stale speed into the next delta base
|
||||
// (only cumulative bytes matter for deltas).
|
||||
sm.prevSnap.RxSpeedV4, sm.prevSnap.TxSpeedV4 = 0, 0
|
||||
sm.prevSnap.RxSpeedV6, sm.prevSnap.TxSpeedV6 = 0, 0
|
||||
sm.prevSnap.RxSpeed, sm.prevSnap.TxSpeed = 0, 0
|
||||
sm.prevTick = now
|
||||
sm.speedReady = true
|
||||
sm.onStats(snap)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,9 +24,9 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0.0</string>
|
||||
<string>0.3.7</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1</string>
|
||||
<string>0</string>
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
<string>11.0</string>
|
||||
<key>NSHighResolutionCapable</key>
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
// genico converts resources/icon.png into a Windows .ico file using the
|
||||
// PNG-in-ICO format (the PNG bytes are wrapped in a minimal ICO container
|
||||
// without decoding/re-encoding). Works on Windows Vista and later.
|
||||
//
|
||||
// Run: go run resources/genico/main.go
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"os"
|
||||
)
|
||||
|
||||
func main() {
|
||||
pngData, err := os.ReadFile("resources/icon.png")
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "genico: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// ICO header (6 bytes) + one directory entry (16 bytes) = 22 bytes overhead.
|
||||
const headerSize = 6
|
||||
const entrySize = 16
|
||||
dataOffset := uint32(headerSize + entrySize)
|
||||
|
||||
ico := make([]byte, 0, headerSize+entrySize+len(pngData))
|
||||
|
||||
// ICONDIR
|
||||
ico = binary.LittleEndian.AppendUint16(ico, 0) // reserved
|
||||
ico = binary.LittleEndian.AppendUint16(ico, 1) // type = icon
|
||||
ico = binary.LittleEndian.AppendUint16(ico, 1) // count = 1 image
|
||||
|
||||
// ICONDIRENTRY
|
||||
ico = append(ico, 0) // width (0 = 256+, actual size in PNG header)
|
||||
ico = append(ico, 0) // height (0 = 256+, actual size in PNG header)
|
||||
ico = append(ico, 0) // color count (0 = ≥256 colors)
|
||||
ico = append(ico, 0) // reserved
|
||||
ico = binary.LittleEndian.AppendUint16(ico, 1) // planes
|
||||
ico = binary.LittleEndian.AppendUint16(ico, 32) // bit count
|
||||
ico = binary.LittleEndian.AppendUint32(ico, uint32(len(pngData)))
|
||||
ico = binary.LittleEndian.AppendUint32(ico, dataOffset)
|
||||
|
||||
// PNG image data
|
||||
ico = append(ico, pngData...)
|
||||
|
||||
if err := os.WriteFile("resources/icon.ico", ico, 0644); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "genico: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Println("Generated resources/icon.ico")
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 51 KiB |
@@ -0,0 +1 @@
|
||||
LMVPN ICON "resources/icon.ico"
|
||||
Binary file not shown.
Reference in New Issue
Block a user