增加多语言支持
This commit is contained in:
BIN
Binary file not shown.
@@ -3,7 +3,10 @@ module lmvpn
|
|||||||
go 1.26
|
go 1.26
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/BurntSushi/toml v1.5.0
|
||||||
github.com/gorilla/websocket v1.5.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
|
github.com/songgao/water v0.0.0-20200317203138-2b4b6d7c09d8
|
||||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1
|
gopkg.in/natefinch/lumberjack.v2 v2.2.1
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
@@ -13,7 +16,6 @@ require (
|
|||||||
require (
|
require (
|
||||||
fyne.io/fyne/v2 v2.7.4
|
fyne.io/fyne/v2 v2.7.4
|
||||||
fyne.io/systray v1.12.1 // indirect
|
fyne.io/systray v1.12.1 // indirect
|
||||||
github.com/BurntSushi/toml v1.5.0 // indirect
|
|
||||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||||
github.com/fredbi/uri v1.1.1 // indirect
|
github.com/fredbi/uri v1.1.1 // indirect
|
||||||
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||||
@@ -28,12 +30,10 @@ require (
|
|||||||
github.com/godbus/dbus/v5 v5.1.0 // indirect
|
github.com/godbus/dbus/v5 v5.1.0 // indirect
|
||||||
github.com/hack-pad/go-indexeddb v0.3.2 // indirect
|
github.com/hack-pad/go-indexeddb v0.3.2 // indirect
|
||||||
github.com/hack-pad/safejs v0.1.0 // indirect
|
github.com/hack-pad/safejs v0.1.0 // indirect
|
||||||
github.com/jeandeaual/go-locale v0.0.0-20250612000132-0ef82f21eade // indirect
|
|
||||||
github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25 // indirect
|
github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25 // indirect
|
||||||
github.com/kr/text v0.1.0 // indirect
|
github.com/kr/text v0.1.0 // indirect
|
||||||
github.com/mattn/go-runewidth v0.0.17 // indirect
|
github.com/mattn/go-runewidth v0.0.17 // indirect
|
||||||
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 // indirect
|
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 // indirect
|
||||||
github.com/nicksnyder/go-i18n/v2 v2.5.1 // indirect
|
|
||||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||||
github.com/rivo/uniseg v0.2.0 // indirect
|
github.com/rivo/uniseg v0.2.0 // indirect
|
||||||
github.com/rymdport/portal v0.4.2 // indirect
|
github.com/rymdport/portal v0.4.2 // indirect
|
||||||
@@ -43,7 +43,7 @@ require (
|
|||||||
github.com/yuin/goldmark v1.7.8 // indirect
|
github.com/yuin/goldmark v1.7.8 // indirect
|
||||||
golang.org/x/image v0.24.0 // indirect
|
golang.org/x/image v0.24.0 // indirect
|
||||||
golang.org/x/net v0.35.0 // indirect
|
golang.org/x/net v0.35.0 // indirect
|
||||||
golang.org/x/text v0.22.0 // indirect
|
golang.org/x/text v0.22.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ type AppConfig struct {
|
|||||||
CloseToTray bool `yaml:"close_to_tray"`
|
CloseToTray bool `yaml:"close_to_tray"`
|
||||||
DefaultProfileID int64 `yaml:"default_profile_id"`
|
DefaultProfileID int64 `yaml:"default_profile_id"`
|
||||||
LogLevel string `yaml:"log_level"` // debug, info, warn, error
|
LogLevel string `yaml:"log_level"` // debug, info, warn, error
|
||||||
|
Language string `yaml:"language"` // "", "auto", "en", "zh-Hans"
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default returns the default configuration.
|
// Default returns the default configuration.
|
||||||
@@ -28,6 +29,7 @@ func Default() AppConfig {
|
|||||||
CloseToTray: true,
|
CloseToTray: true,
|
||||||
DefaultProfileID: 0,
|
DefaultProfileID: 0,
|
||||||
LogLevel: "info",
|
LogLevel: "info",
|
||||||
|
Language: "", // empty = auto-detect from OS
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
WindowTitle = "LMVPN"
|
||||||
|
|
||||||
|
StatusLabel = "Status"
|
||||||
|
ProfileLabel = "Profile"
|
||||||
|
|
||||||
|
StateDisconnected = "Disconnected"
|
||||||
|
StateConnecting = "Connecting..."
|
||||||
|
StateConnected = "Connected"
|
||||||
|
StateReconnecting = "Reconnecting..."
|
||||||
|
StateError = "Error"
|
||||||
|
|
||||||
|
BtnConnect = "Connect"
|
||||||
|
BtnDisconnect = "Disconnect"
|
||||||
|
BtnAddProfile = "Add Profile"
|
||||||
|
BtnEdit = "Edit"
|
||||||
|
BtnDelete = "Delete"
|
||||||
|
BtnSave = "Save"
|
||||||
|
BtnCancel = "Cancel"
|
||||||
|
BtnOK = "OK"
|
||||||
|
|
||||||
|
IpLabel = "IP: {{.ip}}"
|
||||||
|
IpNone = "IP: —"
|
||||||
|
UptimeLabel = "Uptime: {{.uptime}}"
|
||||||
|
UptimeNone = "Uptime: —"
|
||||||
|
RxLabel = "↓ {{.bytes}}"
|
||||||
|
TxLabel = "↑ {{.bytes}}"
|
||||||
|
RxZero = "↓ 0 B"
|
||||||
|
TxZero = "↑ 0 B"
|
||||||
|
|
||||||
|
DlgNoProfileTitle = "No Profile"
|
||||||
|
DlgNoProfileEditMsg = "Select a profile to edit."
|
||||||
|
DlgNoProfileConnectMsg = "Please select or create a profile first."
|
||||||
|
DlgDeleteProfileTitle = "Delete Profile"
|
||||||
|
DlgDeleteProfileMsg = 'Delete profile "{{.name}}" and its stored credentials?'
|
||||||
|
DlgProfileTitle = "Profile"
|
||||||
|
DlgValidationTitle = "Validation"
|
||||||
|
DlgValidationMsg = "Name, Server URL, and Username are required."
|
||||||
|
DlgDaemonError = "Daemon Error"
|
||||||
|
DlgCredentialError = "Credential Error"
|
||||||
|
DlgCredentialErrorMsg = "No password stored for this profile. Edit the profile to set it."
|
||||||
|
DlgIPCError = "IPC Error"
|
||||||
|
DlgVPNError = "VPN Error"
|
||||||
|
DlgSaveError = "Save Error"
|
||||||
|
DlgKeychainError = "Keychain Error"
|
||||||
|
DlgError = "Error"
|
||||||
|
|
||||||
|
TrayShowWindow = "Show Window"
|
||||||
|
TrayConnect = "Connect"
|
||||||
|
TrayDisconnect = "Disconnect"
|
||||||
|
TrayLanguage = "Language"
|
||||||
|
TrayLanguageAuto = "Auto"
|
||||||
|
TrayQuit = "Quit"
|
||||||
|
|
||||||
|
FieldName = "Name"
|
||||||
|
FieldServerURL = "Server URL"
|
||||||
|
FieldUsername = "Username"
|
||||||
|
FieldPassword = "Password"
|
||||||
|
FieldAuthMode = "Auth Mode"
|
||||||
|
FieldRoutingMode = "Routing Mode"
|
||||||
|
FieldCustomCIDRs = "Custom CIDRs (comma-separated)"
|
||||||
|
FieldMTUOverride = "MTU Override"
|
||||||
|
|
||||||
|
PlaceholderCIDRs = "10.0.0.0/8, 172.16.0.0/12"
|
||||||
|
PlaceholderMTU = "0 = use server MTU"
|
||||||
|
PlaceholderPasswordUnchanged = "(unchanged)"
|
||||||
|
|
||||||
|
AuthModeBoth = "Both (JWT + Password)"
|
||||||
|
AuthModeJWT = "JWT"
|
||||||
|
AuthModePassword = "Password"
|
||||||
|
|
||||||
|
RoutingModeFull = "Full Tunnel"
|
||||||
|
RoutingModeSplit = "Split Tunnel"
|
||||||
|
RoutingModeCustom = "Custom"
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
// Package i18n provides internationalization support for the LMVPN
|
||||||
|
// UI. It loads translation catalogs (embedded TOML files) via go-i18n
|
||||||
|
// and exposes a simple T() lookup helper. The active language is
|
||||||
|
// chosen from the user's saved preference or, when unset, detected
|
||||||
|
// from the operating system locale.
|
||||||
|
package i18n
|
||||||
|
|
||||||
|
import (
|
||||||
|
"embed"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/BurntSushi/toml"
|
||||||
|
"github.com/jeandeaual/go-locale"
|
||||||
|
"github.com/nicksnyder/go-i18n/v2/i18n"
|
||||||
|
"golang.org/x/text/language"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed en.toml zh-Hans.toml
|
||||||
|
var localeFS embed.FS
|
||||||
|
|
||||||
|
// Supported language identifiers. LangAuto means "detect from the OS".
|
||||||
|
const (
|
||||||
|
LangAuto = "auto"
|
||||||
|
LangEn = "en"
|
||||||
|
LangZhHans = "zh-Hans"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
mu sync.RWMutex
|
||||||
|
bundle *i18n.Bundle
|
||||||
|
localizer *i18n.Localizer
|
||||||
|
currentLang string
|
||||||
|
)
|
||||||
|
|
||||||
|
// Init loads the embedded catalogs and activates the given language.
|
||||||
|
// An empty string or LangAuto triggers OS locale detection.
|
||||||
|
func Init(lang string) error {
|
||||||
|
bundle = i18n.NewBundle(language.English)
|
||||||
|
bundle.RegisterUnmarshalFunc("toml", toml.Unmarshal)
|
||||||
|
|
||||||
|
for _, name := range []string{"en.toml", "zh-Hans.toml"} {
|
||||||
|
data, err := localeFS.ReadFile(name)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("i18n: read %s: %w", name, err)
|
||||||
|
}
|
||||||
|
if _, err := bundle.ParseMessageFileBytes(data, name); err != nil {
|
||||||
|
return fmt.Errorf("i18n: parse %s: %w", name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resolved := resolveLanguage(lang)
|
||||||
|
localizer = i18n.NewLocalizer(bundle, resolved)
|
||||||
|
currentLang = resolved
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetLanguage switches the active language at runtime. The caller is
|
||||||
|
// responsible for rebuilding any cached UI strings afterwards.
|
||||||
|
func SetLanguage(lang string) {
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
resolved := resolveLanguage(lang)
|
||||||
|
localizer = i18n.NewLocalizer(bundle, resolved)
|
||||||
|
currentLang = resolved
|
||||||
|
}
|
||||||
|
|
||||||
|
// CurrentLanguage returns the active language tag (e.g. "en", "zh-Hans").
|
||||||
|
func CurrentLanguage() string {
|
||||||
|
mu.RLock()
|
||||||
|
defer mu.RUnlock()
|
||||||
|
return currentLang
|
||||||
|
}
|
||||||
|
|
||||||
|
// T translates a message identified by key. An optional map is used as
|
||||||
|
// template data (e.g. {"name": "work"}). If the bundle is not yet
|
||||||
|
// initialised or the key is missing, the key itself is returned.
|
||||||
|
func T(key string, data ...interface{}) string {
|
||||||
|
mu.RLock()
|
||||||
|
loc := localizer
|
||||||
|
mu.RUnlock()
|
||||||
|
if loc == nil {
|
||||||
|
return key
|
||||||
|
}
|
||||||
|
cfg := &i18n.LocalizeConfig{MessageID: key}
|
||||||
|
if len(data) > 0 {
|
||||||
|
if m, ok := data[0].(map[string]interface{}); ok {
|
||||||
|
cfg.TemplateData = m
|
||||||
|
}
|
||||||
|
}
|
||||||
|
msg, err := loc.Localize(cfg)
|
||||||
|
if err != nil || msg == "" {
|
||||||
|
return key
|
||||||
|
}
|
||||||
|
return msg
|
||||||
|
}
|
||||||
|
|
||||||
|
// DetectSystemLanguage inspects the OS locale list and returns the
|
||||||
|
// best supported match. Any Chinese variant maps to zh-Hans; everything
|
||||||
|
// else falls back to en.
|
||||||
|
func DetectSystemLanguage() string {
|
||||||
|
tags, err := locale.GetLocales()
|
||||||
|
if err == nil {
|
||||||
|
for _, t := range tags {
|
||||||
|
if strings.HasPrefix(strings.ToLower(t), "zh") {
|
||||||
|
return LangZhHans
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Fall back to the single-locale API.
|
||||||
|
tag, err := locale.GetLocale()
|
||||||
|
if err == nil && strings.HasPrefix(strings.ToLower(tag), "zh") {
|
||||||
|
return LangZhHans
|
||||||
|
}
|
||||||
|
return LangEn
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveLanguage maps LangAuto / "" to a concrete language tag.
|
||||||
|
func resolveLanguage(lang string) string {
|
||||||
|
if lang == "" || lang == LangAuto {
|
||||||
|
return DetectSystemLanguage()
|
||||||
|
}
|
||||||
|
return lang
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
WindowTitle = "LMVPN"
|
||||||
|
|
||||||
|
StatusLabel = "状态"
|
||||||
|
ProfileLabel = "配置"
|
||||||
|
|
||||||
|
StateDisconnected = "已断开"
|
||||||
|
StateConnecting = "连接中..."
|
||||||
|
StateConnected = "已连接"
|
||||||
|
StateReconnecting = "重新连接中..."
|
||||||
|
StateError = "错误"
|
||||||
|
|
||||||
|
BtnConnect = "连接"
|
||||||
|
BtnDisconnect = "断开连接"
|
||||||
|
BtnAddProfile = "添加配置"
|
||||||
|
BtnEdit = "编辑"
|
||||||
|
BtnDelete = "删除"
|
||||||
|
BtnSave = "保存"
|
||||||
|
BtnCancel = "取消"
|
||||||
|
BtnOK = "确定"
|
||||||
|
|
||||||
|
IpLabel = "IP: {{.ip}}"
|
||||||
|
IpNone = "IP: —"
|
||||||
|
UptimeLabel = "运行时长: {{.uptime}}"
|
||||||
|
UptimeNone = "运行时长: —"
|
||||||
|
RxLabel = "↓ {{.bytes}}"
|
||||||
|
TxLabel = "↑ {{.bytes}}"
|
||||||
|
RxZero = "↓ 0 B"
|
||||||
|
TxZero = "↑ 0 B"
|
||||||
|
|
||||||
|
DlgNoProfileTitle = "无配置"
|
||||||
|
DlgNoProfileEditMsg = "请选择要编辑的配置。"
|
||||||
|
DlgNoProfileConnectMsg = "请先选择或创建一个配置。"
|
||||||
|
DlgDeleteProfileTitle = "删除配置"
|
||||||
|
DlgDeleteProfileMsg = '删除配置"{{.name}}"及其存储的凭据?'
|
||||||
|
DlgProfileTitle = "配置"
|
||||||
|
DlgValidationTitle = "验证"
|
||||||
|
DlgValidationMsg = "名称、服务器地址和用户名为必填项。"
|
||||||
|
DlgDaemonError = "守护进程错误"
|
||||||
|
DlgCredentialError = "凭据错误"
|
||||||
|
DlgCredentialErrorMsg = "此配置未存储密码。请编辑配置以设置密码。"
|
||||||
|
DlgIPCError = "IPC 错误"
|
||||||
|
DlgVPNError = "VPN 错误"
|
||||||
|
DlgSaveError = "保存错误"
|
||||||
|
DlgKeychainError = "钥匙串错误"
|
||||||
|
DlgError = "错误"
|
||||||
|
|
||||||
|
TrayShowWindow = "显示窗口"
|
||||||
|
TrayConnect = "连接"
|
||||||
|
TrayDisconnect = "断开连接"
|
||||||
|
TrayLanguage = "语言"
|
||||||
|
TrayLanguageAuto = "自动"
|
||||||
|
TrayQuit = "退出"
|
||||||
|
|
||||||
|
FieldName = "名称"
|
||||||
|
FieldServerURL = "服务器地址"
|
||||||
|
FieldUsername = "用户名"
|
||||||
|
FieldPassword = "密码"
|
||||||
|
FieldAuthMode = "认证方式"
|
||||||
|
FieldRoutingMode = "路由模式"
|
||||||
|
FieldCustomCIDRs = "自定义 CIDR(逗号分隔)"
|
||||||
|
FieldMTUOverride = "MTU 覆盖"
|
||||||
|
|
||||||
|
PlaceholderCIDRs = "10.0.0.0/8, 172.16.0.0/12"
|
||||||
|
PlaceholderMTU = "0 = 使用服务器 MTU"
|
||||||
|
PlaceholderPasswordUnchanged = "(未更改)"
|
||||||
|
|
||||||
|
AuthModeBoth = "全部(JWT + 密码)"
|
||||||
|
AuthModeJWT = "JWT"
|
||||||
|
AuthModePassword = "密码"
|
||||||
|
|
||||||
|
RoutingModeFull = "全隧道"
|
||||||
|
RoutingModeSplit = "分离隧道"
|
||||||
|
RoutingModeCustom = "自定义"
|
||||||
+90
-10
@@ -5,6 +5,7 @@ import (
|
|||||||
|
|
||||||
"lmvpn/internal/config"
|
"lmvpn/internal/config"
|
||||||
"lmvpn/internal/db"
|
"lmvpn/internal/db"
|
||||||
|
"lmvpn/internal/i18n"
|
||||||
"lmvpn/internal/ipc"
|
"lmvpn/internal/ipc"
|
||||||
"lmvpn/internal/keychain"
|
"lmvpn/internal/keychain"
|
||||||
"lmvpn/internal/log"
|
"lmvpn/internal/log"
|
||||||
@@ -39,6 +40,7 @@ type App struct {
|
|||||||
ipcClient *ipc.Client
|
ipcClient *ipc.Client
|
||||||
profiles []model.ServerProfile
|
profiles []model.ServerProfile
|
||||||
currentProfile *model.ServerProfile
|
currentProfile *model.ServerProfile
|
||||||
|
langSetting string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run initialises and starts the GUI application.
|
// Run initialises and starts the GUI application.
|
||||||
@@ -60,13 +62,19 @@ func Run() {
|
|||||||
// Load app config.
|
// Load app config.
|
||||||
cfg, _ := config.Load()
|
cfg, _ := config.Load()
|
||||||
|
|
||||||
a := &App{
|
// Initialise i18n (detect system locale when unset).
|
||||||
fyneApp: app.NewWithID(paths.BundleID),
|
if err := i18n.Init(cfg.Language); err != nil {
|
||||||
db: store,
|
log.L().Error("init i18n", "error", err)
|
||||||
kc: keychain.New(),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
a.window = a.fyneApp.NewWindow("LMVPN")
|
a := &App{
|
||||||
|
fyneApp: app.NewWithID(paths.BundleID),
|
||||||
|
db: store,
|
||||||
|
kc: keychain.New(),
|
||||||
|
langSetting: cfg.Language,
|
||||||
|
}
|
||||||
|
|
||||||
|
a.window = a.fyneApp.NewWindow(i18n.T("WindowTitle"))
|
||||||
a.window.SetContent(a.buildMainWindow())
|
a.window.SetContent(a.buildMainWindow())
|
||||||
a.window.Resize(fyne.NewSize(420, 480))
|
a.window.Resize(fyne.NewSize(420, 480))
|
||||||
|
|
||||||
@@ -141,7 +149,8 @@ func (a *App) onAddProfile() {
|
|||||||
// onEditProfile shows a dialog to edit the current profile.
|
// onEditProfile shows a dialog to edit the current profile.
|
||||||
func (a *App) onEditProfile() {
|
func (a *App) onEditProfile() {
|
||||||
if a.currentProfile == nil {
|
if a.currentProfile == nil {
|
||||||
dialog.ShowInformation("No Profile", "Select a profile to edit.", a.window)
|
dialog.NewCustom(i18n.T("DlgNoProfileTitle"), i18n.T("BtnOK"),
|
||||||
|
widget.NewLabel(i18n.T("DlgNoProfileEditMsg")), a.window).Show()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
p := *a.currentProfile
|
p := *a.currentProfile
|
||||||
@@ -154,17 +163,88 @@ func (a *App) onDeleteProfile() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
name := a.currentProfile.Name
|
name := a.currentProfile.Name
|
||||||
dialog.ShowConfirm("Delete Profile",
|
dialog.NewCustomConfirm(i18n.T("DlgDeleteProfileTitle"),
|
||||||
"Delete profile \""+name+"\" and its stored credentials?",
|
i18n.T("BtnDelete"), i18n.T("BtnCancel"),
|
||||||
|
widget.NewLabel(i18n.T("DlgDeleteProfileMsg", map[string]interface{}{"name": name})),
|
||||||
func(ok bool) {
|
func(ok bool) {
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := a.db.DeleteProfile(a.currentProfile.ID); err != nil {
|
if err := a.db.DeleteProfile(a.currentProfile.ID); err != nil {
|
||||||
showError("Error", err.Error(), a.window)
|
showError(i18n.T("DlgError"), err.Error(), a.window)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
_ = a.kc.DeleteAll(name)
|
_ = a.kc.DeleteAll(name)
|
||||||
a.loadProfiles()
|
a.loadProfiles()
|
||||||
}, a.window)
|
}, a.window).Show()
|
||||||
|
}
|
||||||
|
|
||||||
|
// changeLanguage switches the active language, persists the choice to
|
||||||
|
// the config file, and rebuilds the UI so the new strings take effect
|
||||||
|
// immediately.
|
||||||
|
func (a *App) changeLanguage(lang string) {
|
||||||
|
a.langSetting = lang
|
||||||
|
|
||||||
|
// Persist the choice.
|
||||||
|
cfg, err := config.Load()
|
||||||
|
if err != nil {
|
||||||
|
cfg = config.Default()
|
||||||
|
}
|
||||||
|
cfg.Language = lang
|
||||||
|
if err := config.Save(cfg); err != nil {
|
||||||
|
log.L().Error("save config", "error", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Switch the active localizer.
|
||||||
|
i18n.SetLanguage(lang)
|
||||||
|
|
||||||
|
// Rebuild everything that holds cached strings.
|
||||||
|
a.rebuildUI()
|
||||||
|
}
|
||||||
|
|
||||||
|
// rebuildUI recreates the main window content and system tray menu so
|
||||||
|
// that all labels pick up the currently active language. It preserves
|
||||||
|
// the selected profile and connection state across the rebuild.
|
||||||
|
func (a *App) rebuildUI() {
|
||||||
|
// Snapshot the state we need to restore.
|
||||||
|
a.mu.Lock()
|
||||||
|
wasConnected := a.ipcClient != nil
|
||||||
|
a.mu.Unlock()
|
||||||
|
|
||||||
|
selectedName := ""
|
||||||
|
if a.currentProfile != nil {
|
||||||
|
selectedName = a.currentProfile.Name
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rebuild window content (creates fresh widgets with new strings).
|
||||||
|
a.window.SetTitle(i18n.T("WindowTitle"))
|
||||||
|
a.window.SetContent(a.buildMainWindow())
|
||||||
|
|
||||||
|
// Restore profiles and the previous selection.
|
||||||
|
if a.db != nil {
|
||||||
|
a.loadProfiles()
|
||||||
|
if selectedName != "" {
|
||||||
|
for _, name := range a.profileSelect.Options {
|
||||||
|
if name == selectedName {
|
||||||
|
a.profileSelect.SetSelected(selectedName)
|
||||||
|
a.selectProfileByName(selectedName)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore connection state.
|
||||||
|
if wasConnected {
|
||||||
|
a.connectBtn.Disable()
|
||||||
|
a.disconnectBtn.Enable()
|
||||||
|
a.stateLabel.SetText(i18n.T("StateConnected"))
|
||||||
|
} else {
|
||||||
|
a.connectBtn.Enable()
|
||||||
|
a.disconnectBtn.Disable()
|
||||||
|
a.stateLabel.SetText(i18n.T("StateDisconnected"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rebuild the tray menu (new labels + checked language item).
|
||||||
|
a.setupTray()
|
||||||
}
|
}
|
||||||
|
|||||||
+60
-24
@@ -3,6 +3,7 @@ package ui
|
|||||||
import (
|
import (
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
|
"lmvpn/internal/i18n"
|
||||||
"lmvpn/internal/model"
|
"lmvpn/internal/model"
|
||||||
|
|
||||||
"fyne.io/fyne/v2"
|
"fyne.io/fyne/v2"
|
||||||
@@ -11,6 +12,40 @@ import (
|
|||||||
"fyne.io/fyne/v2/widget"
|
"fyne.io/fyne/v2/widget"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// authCodes and routeCodes keep the canonical enum values in a fixed
|
||||||
|
// order so that dropdown display labels (which are localised) can be
|
||||||
|
// mapped back to the codes stored in the database.
|
||||||
|
var (
|
||||||
|
authCodes = []string{string(model.AuthModeBoth), string(model.AuthModeJWT), string(model.AuthModePassword)}
|
||||||
|
routeCodes = []string{string(model.RoutingFull), string(model.RoutingSplit), string(model.RoutingCustom)}
|
||||||
|
)
|
||||||
|
|
||||||
|
func authModeLabels() []string {
|
||||||
|
return []string{i18n.T("AuthModeBoth"), i18n.T("AuthModeJWT"), i18n.T("AuthModePassword")}
|
||||||
|
}
|
||||||
|
|
||||||
|
func routeModeLabels() []string {
|
||||||
|
return []string{i18n.T("RoutingModeFull"), i18n.T("RoutingModeSplit"), i18n.T("RoutingModeCustom")}
|
||||||
|
}
|
||||||
|
|
||||||
|
// codeIndex returns the position of code in codes, or 0 if not found.
|
||||||
|
func codeIndex(codes []string, code string) int {
|
||||||
|
for i, c := range codes {
|
||||||
|
if c == code {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// selectedCode returns the enum code for the given dropdown index.
|
||||||
|
func selectedCode(codes []string, idx int) string {
|
||||||
|
if idx < 0 || idx >= len(codes) {
|
||||||
|
return codes[0]
|
||||||
|
}
|
||||||
|
return codes[idx]
|
||||||
|
}
|
||||||
|
|
||||||
// showProfileDialog displays an add/edit dialog for a server profile.
|
// showProfileDialog displays an add/edit dialog for a server profile.
|
||||||
// If editing is nil, a new profile is created.
|
// If editing is nil, a new profile is created.
|
||||||
func (a *App) showProfileDialog(editing *model.ServerProfile) {
|
func (a *App) showProfileDialog(editing *model.ServerProfile) {
|
||||||
@@ -20,46 +55,47 @@ func (a *App) showProfileDialog(editing *model.ServerProfile) {
|
|||||||
serverEntry := widget.NewEntry()
|
serverEntry := widget.NewEntry()
|
||||||
userEntry := widget.NewEntry()
|
userEntry := widget.NewEntry()
|
||||||
passEntry := widget.NewPasswordEntry()
|
passEntry := widget.NewPasswordEntry()
|
||||||
authSelect := widget.NewSelect([]string{"both", "jwt", "password"}, nil)
|
authSelect := widget.NewSelect(authModeLabels(), nil)
|
||||||
routeSelect := widget.NewSelect([]string{"full", "split", "custom"}, nil)
|
routeSelect := widget.NewSelect(routeModeLabels(), nil)
|
||||||
cidrEntry := widget.NewMultiLineEntry()
|
cidrEntry := widget.NewMultiLineEntry()
|
||||||
cidrEntry.SetPlaceHolder("10.0.0.0/8, 172.16.0.0/12")
|
cidrEntry.SetPlaceHolder(i18n.T("PlaceholderCIDRs"))
|
||||||
mtuEntry := widget.NewEntry()
|
mtuEntry := widget.NewEntry()
|
||||||
mtuEntry.SetPlaceHolder("0 = use server MTU")
|
mtuEntry.SetPlaceHolder(i18n.T("PlaceholderMTU"))
|
||||||
|
|
||||||
if !isNew {
|
if !isNew {
|
||||||
nameEntry.SetText(editing.Name)
|
nameEntry.SetText(editing.Name)
|
||||||
serverEntry.SetText(editing.ServerURL)
|
serverEntry.SetText(editing.ServerURL)
|
||||||
userEntry.SetText(editing.Username)
|
userEntry.SetText(editing.Username)
|
||||||
authSelect.SetSelected(string(editing.AuthMode))
|
authSelect.SetSelectedIndex(codeIndex(authCodes, string(editing.AuthMode)))
|
||||||
routeSelect.SetSelected(string(editing.RoutingMode))
|
routeSelect.SetSelectedIndex(codeIndex(routeCodes, string(editing.RoutingMode)))
|
||||||
cidrEntry.SetText(editing.CustomCIDRs)
|
cidrEntry.SetText(editing.CustomCIDRs)
|
||||||
mtuEntry.SetText(fmtInt(editing.MTUOverride))
|
mtuEntry.SetText(fmtInt(editing.MTUOverride))
|
||||||
passEntry.SetPlaceHolder("(unchanged)")
|
passEntry.SetPlaceHolder(i18n.T("PlaceholderPasswordUnchanged"))
|
||||||
} else {
|
} else {
|
||||||
authSelect.SetSelected(string(model.AuthModeBoth))
|
authSelect.SetSelectedIndex(codeIndex(authCodes, string(model.AuthModeBoth)))
|
||||||
routeSelect.SetSelected(string(model.RoutingFull))
|
routeSelect.SetSelectedIndex(codeIndex(routeCodes, string(model.RoutingFull)))
|
||||||
mtuEntry.SetText("0")
|
mtuEntry.SetText("0")
|
||||||
}
|
}
|
||||||
|
|
||||||
form := container.NewVBox(
|
form := container.NewVBox(
|
||||||
widget.NewLabel("Name"), nameEntry,
|
widget.NewLabel(i18n.T("FieldName")), nameEntry,
|
||||||
widget.NewLabel("Server URL"), serverEntry,
|
widget.NewLabel(i18n.T("FieldServerURL")), serverEntry,
|
||||||
widget.NewLabel("Username"), userEntry,
|
widget.NewLabel(i18n.T("FieldUsername")), userEntry,
|
||||||
widget.NewLabel("Password"), passEntry,
|
widget.NewLabel(i18n.T("FieldPassword")), passEntry,
|
||||||
widget.NewLabel("Auth Mode"), authSelect,
|
widget.NewLabel(i18n.T("FieldAuthMode")), authSelect,
|
||||||
widget.NewLabel("Routing Mode"), routeSelect,
|
widget.NewLabel(i18n.T("FieldRoutingMode")), routeSelect,
|
||||||
widget.NewLabel("Custom CIDRs (comma-separated)"), cidrEntry,
|
widget.NewLabel(i18n.T("FieldCustomCIDRs")), cidrEntry,
|
||||||
widget.NewLabel("MTU Override"), mtuEntry,
|
widget.NewLabel(i18n.T("FieldMTUOverride")), mtuEntry,
|
||||||
)
|
)
|
||||||
|
|
||||||
d := dialog.NewCustomConfirm("Profile", "Save", "Cancel", form, func(save bool) {
|
d := dialog.NewCustomConfirm(i18n.T("DlgProfileTitle"), i18n.T("BtnSave"), i18n.T("BtnCancel"), form, func(save bool) {
|
||||||
if !save {
|
if !save {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
a.saveProfile(editing, nameEntry.Text, serverEntry.Text,
|
a.saveProfile(editing, nameEntry.Text, serverEntry.Text,
|
||||||
userEntry.Text, passEntry.Text,
|
userEntry.Text, passEntry.Text,
|
||||||
authSelect.Selected, routeSelect.Selected,
|
selectedCode(authCodes, authSelect.SelectedIndex()),
|
||||||
|
selectedCode(routeCodes, routeSelect.SelectedIndex()),
|
||||||
cidrEntry.Text, mtuEntry.Text, isNew)
|
cidrEntry.Text, mtuEntry.Text, isNew)
|
||||||
}, a.window)
|
}, a.window)
|
||||||
d.Resize(fyne.NewSize(400, 500))
|
d.Resize(fyne.NewSize(400, 500))
|
||||||
@@ -70,7 +106,7 @@ func (a *App) showProfileDialog(editing *model.ServerProfile) {
|
|||||||
func (a *App) saveProfile(editing *model.ServerProfile,
|
func (a *App) saveProfile(editing *model.ServerProfile,
|
||||||
name, server, user, password, authMode, routeMode, cidrs, mtuStr string, isNew bool) {
|
name, server, user, password, authMode, routeMode, cidrs, mtuStr string, isNew bool) {
|
||||||
if name == "" || server == "" || user == "" {
|
if name == "" || server == "" || user == "" {
|
||||||
showError("Validation", "Name, Server URL, and Username are required.", a.window)
|
showError(i18n.T("DlgValidationTitle"), i18n.T("DlgValidationMsg"), a.window)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,13 +124,13 @@ func (a *App) saveProfile(editing *model.ServerProfile,
|
|||||||
}
|
}
|
||||||
id, err := a.db.CreateProfile(p)
|
id, err := a.db.CreateProfile(p)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
showError("Save Error", err.Error(), a.window)
|
showError(i18n.T("DlgSaveError"), err.Error(), a.window)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
_ = id
|
_ = id
|
||||||
if password != "" {
|
if password != "" {
|
||||||
if err := a.kc.SetPassword(name, password); err != nil {
|
if err := a.kc.SetPassword(name, password); err != nil {
|
||||||
showError("Keychain Error", err.Error(), a.window)
|
showError(i18n.T("DlgKeychainError"), err.Error(), a.window)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -107,13 +143,13 @@ func (a *App) saveProfile(editing *model.ServerProfile,
|
|||||||
editing.CustomCIDRs = cidrs
|
editing.CustomCIDRs = cidrs
|
||||||
editing.MTUOverride = mtu
|
editing.MTUOverride = mtu
|
||||||
if err := a.db.UpdateProfile(editing); err != nil {
|
if err := a.db.UpdateProfile(editing); err != nil {
|
||||||
showError("Save Error", err.Error(), a.window)
|
showError(i18n.T("DlgSaveError"), err.Error(), a.window)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if password != "" {
|
if password != "" {
|
||||||
_ = a.kc.DeleteAll(oldName)
|
_ = a.kc.DeleteAll(oldName)
|
||||||
if err := a.kc.SetPassword(name, password); err != nil {
|
if err := a.kc.SetPassword(name, password); err != nil {
|
||||||
showError("Keychain Error", err.Error(), a.window)
|
showError(i18n.T("DlgKeychainError"), err.Error(), a.window)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+38
-5
@@ -1,6 +1,8 @@
|
|||||||
package ui
|
package ui
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"lmvpn/internal/i18n"
|
||||||
|
|
||||||
"fyne.io/fyne/v2"
|
"fyne.io/fyne/v2"
|
||||||
"fyne.io/fyne/v2/driver/desktop"
|
"fyne.io/fyne/v2/driver/desktop"
|
||||||
)
|
)
|
||||||
@@ -11,20 +13,51 @@ func (a *App) setupTray() {
|
|||||||
if !ok {
|
if !ok {
|
||||||
return // not a desktop app, skip tray
|
return // not a desktop app, skip tray
|
||||||
}
|
}
|
||||||
menu := fyne.NewMenu("LMVPN",
|
|
||||||
fyne.NewMenuItem("Show Window", func() {
|
// Language submenu — labels for English and 中文 are always shown in
|
||||||
|
// their native script so the user can recognise them regardless of
|
||||||
|
// the active language.
|
||||||
|
autoItem := fyne.NewMenuItem(i18n.T("TrayLanguageAuto"), func() {
|
||||||
|
a.changeLanguage(i18n.LangAuto)
|
||||||
|
})
|
||||||
|
enItem := fyne.NewMenuItem("English", func() {
|
||||||
|
a.changeLanguage(i18n.LangEn)
|
||||||
|
})
|
||||||
|
zhItem := fyne.NewMenuItem("中文", func() {
|
||||||
|
a.changeLanguage(i18n.LangZhHans)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Mark the currently selected option.
|
||||||
|
switch {
|
||||||
|
case a.langSetting == "" || a.langSetting == i18n.LangAuto:
|
||||||
|
autoItem.Checked = true
|
||||||
|
case a.langSetting == i18n.LangEn:
|
||||||
|
enItem.Checked = true
|
||||||
|
case a.langSetting == i18n.LangZhHans:
|
||||||
|
zhItem.Checked = true
|
||||||
|
}
|
||||||
|
|
||||||
|
langItem := fyne.NewMenuItem(i18n.T("TrayLanguage"), nil)
|
||||||
|
langItem.ChildMenu = fyne.NewMenu(i18n.T("TrayLanguage"),
|
||||||
|
autoItem, enItem, zhItem,
|
||||||
|
)
|
||||||
|
|
||||||
|
menu := fyne.NewMenu(i18n.T("WindowTitle"),
|
||||||
|
fyne.NewMenuItem(i18n.T("TrayShowWindow"), func() {
|
||||||
a.window.Show()
|
a.window.Show()
|
||||||
a.window.RequestFocus()
|
a.window.RequestFocus()
|
||||||
}),
|
}),
|
||||||
fyne.NewMenuItemSeparator(),
|
fyne.NewMenuItemSeparator(),
|
||||||
fyne.NewMenuItem("Connect", func() {
|
fyne.NewMenuItem(i18n.T("TrayConnect"), func() {
|
||||||
a.onConnect()
|
a.onConnect()
|
||||||
}),
|
}),
|
||||||
fyne.NewMenuItem("Disconnect", func() {
|
fyne.NewMenuItem(i18n.T("TrayDisconnect"), func() {
|
||||||
a.onDisconnect()
|
a.onDisconnect()
|
||||||
}),
|
}),
|
||||||
fyne.NewMenuItemSeparator(),
|
fyne.NewMenuItemSeparator(),
|
||||||
fyne.NewMenuItem("Quit", func() {
|
langItem,
|
||||||
|
fyne.NewMenuItemSeparator(),
|
||||||
|
fyne.NewMenuItem(i18n.T("TrayQuit"), func() {
|
||||||
a.fyneApp.Quit()
|
a.fyneApp.Quit()
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|||||||
+41
-40
@@ -4,6 +4,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"lmvpn/internal/i18n"
|
||||||
"lmvpn/internal/ipc"
|
"lmvpn/internal/ipc"
|
||||||
"lmvpn/internal/stats"
|
"lmvpn/internal/stats"
|
||||||
|
|
||||||
@@ -15,7 +16,7 @@ import (
|
|||||||
|
|
||||||
// showError displays a titled error dialog.
|
// showError displays a titled error dialog.
|
||||||
func showError(title, message string, parent fyne.Window) {
|
func showError(title, message string, parent fyne.Window) {
|
||||||
dialog.NewCustom(title, "OK", widget.NewLabel(message), parent).Show()
|
dialog.NewCustom(title, i18n.T("BtnOK"), widget.NewLabel(message), parent).Show()
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildMainWindow creates the main application window layout.
|
// buildMainWindow creates the main application window layout.
|
||||||
@@ -26,14 +27,14 @@ func (a *App) buildMainWindow() fyne.CanvasObject {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// Status display.
|
// Status display.
|
||||||
a.stateLabel = widget.NewLabel("Disconnected")
|
a.stateLabel = widget.NewLabel(i18n.T("StateDisconnected"))
|
||||||
a.stateLabel.TextStyle = fyne.TextStyle{Bold: true}
|
a.stateLabel.TextStyle = fyne.TextStyle{Bold: true}
|
||||||
a.ipLabel = widget.NewLabel("IP: —")
|
a.ipLabel = widget.NewLabel(i18n.T("IpNone"))
|
||||||
a.uptimeLabel = widget.NewLabel("Uptime: —")
|
a.uptimeLabel = widget.NewLabel(i18n.T("UptimeNone"))
|
||||||
a.rxLabel = widget.NewLabel("↓ 0 B")
|
a.rxLabel = widget.NewLabel(i18n.T("RxZero"))
|
||||||
a.txLabel = widget.NewLabel("↑ 0 B")
|
a.txLabel = widget.NewLabel(i18n.T("TxZero"))
|
||||||
|
|
||||||
statusCard := widget.NewCard("Status", "", container.NewVBox(
|
statusCard := widget.NewCard(i18n.T("StatusLabel"), "", container.NewVBox(
|
||||||
a.stateLabel,
|
a.stateLabel,
|
||||||
a.ipLabel,
|
a.ipLabel,
|
||||||
a.uptimeLabel,
|
a.uptimeLabel,
|
||||||
@@ -41,14 +42,14 @@ func (a *App) buildMainWindow() fyne.CanvasObject {
|
|||||||
))
|
))
|
||||||
|
|
||||||
// Buttons.
|
// Buttons.
|
||||||
a.connectBtn = widget.NewButton("Connect", a.onConnect)
|
a.connectBtn = widget.NewButton(i18n.T("BtnConnect"), a.onConnect)
|
||||||
a.connectBtn.Importance = widget.HighImportance
|
a.connectBtn.Importance = widget.HighImportance
|
||||||
a.disconnectBtn = widget.NewButton("Disconnect", a.onDisconnect)
|
a.disconnectBtn = widget.NewButton(i18n.T("BtnDisconnect"), a.onDisconnect)
|
||||||
a.disconnectBtn.Disable()
|
a.disconnectBtn.Disable()
|
||||||
|
|
||||||
addBtn := widget.NewButton("Add Profile", a.onAddProfile)
|
addBtn := widget.NewButton(i18n.T("BtnAddProfile"), a.onAddProfile)
|
||||||
editBtn := widget.NewButton("Edit", a.onEditProfile)
|
editBtn := widget.NewButton(i18n.T("BtnEdit"), a.onEditProfile)
|
||||||
deleteBtn := widget.NewButton("Delete", a.onDeleteProfile)
|
deleteBtn := widget.NewButton(i18n.T("BtnDelete"), a.onDeleteProfile)
|
||||||
|
|
||||||
buttons := container.NewGridWithColumns(2,
|
buttons := container.NewGridWithColumns(2,
|
||||||
a.connectBtn, a.disconnectBtn,
|
a.connectBtn, a.disconnectBtn,
|
||||||
@@ -58,7 +59,7 @@ func (a *App) buildMainWindow() fyne.CanvasObject {
|
|||||||
)
|
)
|
||||||
|
|
||||||
return container.NewVBox(
|
return container.NewVBox(
|
||||||
widget.NewLabel("Profile"),
|
widget.NewLabel(i18n.T("ProfileLabel")),
|
||||||
a.profileSelect,
|
a.profileSelect,
|
||||||
buttons,
|
buttons,
|
||||||
profileButtons,
|
profileButtons,
|
||||||
@@ -69,20 +70,20 @@ func (a *App) buildMainWindow() fyne.CanvasObject {
|
|||||||
// onConnect handles the Connect button click.
|
// onConnect handles the Connect button click.
|
||||||
func (a *App) onConnect() {
|
func (a *App) onConnect() {
|
||||||
if a.currentProfile == nil {
|
if a.currentProfile == nil {
|
||||||
showError("No Profile", "Please select or create a profile first.", a.window)
|
showError(i18n.T("DlgNoProfileTitle"), i18n.T("DlgNoProfileConnectMsg"), a.window)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
a.connectBtn.Disable()
|
a.connectBtn.Disable()
|
||||||
a.stateLabel.SetText("Connecting...")
|
a.stateLabel.SetText(i18n.T("StateConnecting"))
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
// Ensure daemon is running.
|
// Ensure daemon is running.
|
||||||
client, err := ensureDaemon()
|
client, err := ensureDaemon()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fyne.Do(func() {
|
fyne.Do(func() {
|
||||||
showError("Daemon Error", err.Error(), a.window)
|
showError(i18n.T("DlgDaemonError"), err.Error(), a.window)
|
||||||
a.stateLabel.SetText("Disconnected")
|
a.stateLabel.SetText(i18n.T("StateDisconnected"))
|
||||||
a.connectBtn.Enable()
|
a.connectBtn.Enable()
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
@@ -96,10 +97,10 @@ func (a *App) onConnect() {
|
|||||||
password, err := a.kc.GetPassword(a.currentProfile.Name)
|
password, err := a.kc.GetPassword(a.currentProfile.Name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fyne.Do(func() {
|
fyne.Do(func() {
|
||||||
showError("Credential Error",
|
showError(i18n.T("DlgCredentialError"),
|
||||||
"No password stored for this profile. Edit the profile to set it.",
|
i18n.T("DlgCredentialErrorMsg"),
|
||||||
a.window)
|
a.window)
|
||||||
a.stateLabel.SetText("Disconnected")
|
a.stateLabel.SetText(i18n.T("StateDisconnected"))
|
||||||
a.connectBtn.Enable()
|
a.connectBtn.Enable()
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
@@ -117,8 +118,8 @@ func (a *App) onConnect() {
|
|||||||
}
|
}
|
||||||
if err := ipc.SendStart(client, cfg); err != nil {
|
if err := ipc.SendStart(client, cfg); err != nil {
|
||||||
fyne.Do(func() {
|
fyne.Do(func() {
|
||||||
showError("IPC Error", err.Error(), a.window)
|
showError(i18n.T("DlgIPCError"), err.Error(), a.window)
|
||||||
a.stateLabel.SetText("Disconnected")
|
a.stateLabel.SetText(i18n.T("StateDisconnected"))
|
||||||
a.connectBtn.Enable()
|
a.connectBtn.Enable()
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
@@ -140,7 +141,7 @@ func (a *App) onDisconnect() {
|
|||||||
_ = ipc.SendStop(client)
|
_ = ipc.SendStop(client)
|
||||||
a.disconnectBtn.Disable()
|
a.disconnectBtn.Disable()
|
||||||
a.connectBtn.Enable()
|
a.connectBtn.Enable()
|
||||||
a.stateLabel.SetText("Disconnected")
|
a.stateLabel.SetText(i18n.T("StateDisconnected"))
|
||||||
}
|
}
|
||||||
|
|
||||||
// eventLoop reads IPC events from the daemon and updates the UI.
|
// eventLoop reads IPC events from the daemon and updates the UI.
|
||||||
@@ -155,11 +156,11 @@ func (a *App) eventLoop() {
|
|||||||
ev, err := client.Recv()
|
ev, err := client.Recv()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fyne.Do(func() {
|
fyne.Do(func() {
|
||||||
a.stateLabel.SetText("Disconnected")
|
a.stateLabel.SetText(i18n.T("StateDisconnected"))
|
||||||
a.ipLabel.SetText("IP: —")
|
a.ipLabel.SetText(i18n.T("IpNone"))
|
||||||
a.uptimeLabel.SetText("Uptime: —")
|
a.uptimeLabel.SetText(i18n.T("UptimeNone"))
|
||||||
a.rxLabel.SetText("↓ 0 B")
|
a.rxLabel.SetText(i18n.T("RxZero"))
|
||||||
a.txLabel.SetText("↑ 0 B")
|
a.txLabel.SetText(i18n.T("TxZero"))
|
||||||
a.connectBtn.Enable()
|
a.connectBtn.Enable()
|
||||||
a.disconnectBtn.Disable()
|
a.disconnectBtn.Disable()
|
||||||
})
|
})
|
||||||
@@ -180,7 +181,7 @@ func (a *App) eventLoop() {
|
|||||||
case ipc.EvError:
|
case ipc.EvError:
|
||||||
fyne.Do(func() {
|
fyne.Do(func() {
|
||||||
if ev.Message != "" {
|
if ev.Message != "" {
|
||||||
showError("VPN Error", ev.Message, a.window)
|
showError(i18n.T("DlgVPNError"), ev.Message, a.window)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -191,23 +192,23 @@ func (a *App) eventLoop() {
|
|||||||
func (a *App) applyState(state string) {
|
func (a *App) applyState(state string) {
|
||||||
switch stats.State(state) {
|
switch stats.State(state) {
|
||||||
case stats.StateConnected:
|
case stats.StateConnected:
|
||||||
a.stateLabel.SetText("Connected")
|
a.stateLabel.SetText(i18n.T("StateConnected"))
|
||||||
a.connectBtn.Disable()
|
a.connectBtn.Disable()
|
||||||
a.disconnectBtn.Enable()
|
a.disconnectBtn.Enable()
|
||||||
case stats.StateConnecting:
|
case stats.StateConnecting:
|
||||||
a.stateLabel.SetText("Connecting...")
|
a.stateLabel.SetText(i18n.T("StateConnecting"))
|
||||||
a.connectBtn.Disable()
|
a.connectBtn.Disable()
|
||||||
a.disconnectBtn.Enable()
|
a.disconnectBtn.Enable()
|
||||||
case stats.StateReconnecting:
|
case stats.StateReconnecting:
|
||||||
a.stateLabel.SetText("Reconnecting...")
|
a.stateLabel.SetText(i18n.T("StateReconnecting"))
|
||||||
a.disconnectBtn.Enable()
|
a.disconnectBtn.Enable()
|
||||||
case stats.StateDisconnected:
|
case stats.StateDisconnected:
|
||||||
a.stateLabel.SetText("Disconnected")
|
a.stateLabel.SetText(i18n.T("StateDisconnected"))
|
||||||
a.ipLabel.SetText("IP: —")
|
a.ipLabel.SetText(i18n.T("IpNone"))
|
||||||
a.connectBtn.Enable()
|
a.connectBtn.Enable()
|
||||||
a.disconnectBtn.Disable()
|
a.disconnectBtn.Disable()
|
||||||
case stats.StateError:
|
case stats.StateError:
|
||||||
a.stateLabel.SetText("Error")
|
a.stateLabel.SetText(i18n.T("StateError"))
|
||||||
a.connectBtn.Enable()
|
a.connectBtn.Enable()
|
||||||
a.disconnectBtn.Disable()
|
a.disconnectBtn.Disable()
|
||||||
}
|
}
|
||||||
@@ -216,15 +217,15 @@ func (a *App) applyState(state string) {
|
|||||||
// applyStats updates the stats display.
|
// applyStats updates the stats display.
|
||||||
func (a *App) applyStats(s stats.Snapshot) {
|
func (a *App) applyStats(s stats.Snapshot) {
|
||||||
if s.AssignedIP != "" {
|
if s.AssignedIP != "" {
|
||||||
a.ipLabel.SetText("IP: " + s.AssignedIP)
|
a.ipLabel.SetText(i18n.T("IpLabel", map[string]interface{}{"ip": s.AssignedIP}))
|
||||||
}
|
}
|
||||||
if s.State == stats.StateConnected {
|
if s.State == stats.StateConnected {
|
||||||
a.stateLabel.SetText("Connected")
|
a.stateLabel.SetText(i18n.T("StateConnected"))
|
||||||
}
|
}
|
||||||
a.rxLabel.SetText("↓ " + formatBytes(s.RxBytes))
|
a.rxLabel.SetText(i18n.T("RxLabel", map[string]interface{}{"bytes": formatBytes(s.RxBytes)}))
|
||||||
a.txLabel.SetText("↑ " + formatBytes(s.TxBytes))
|
a.txLabel.SetText(i18n.T("TxLabel", map[string]interface{}{"bytes": formatBytes(s.TxBytes)}))
|
||||||
if s.Uptime > 0 {
|
if s.Uptime > 0 {
|
||||||
a.uptimeLabel.SetText("Uptime: " + formatDuration(s.Uptime))
|
a.uptimeLabel.SetText(i18n.T("UptimeLabel", map[string]interface{}{"uptime": formatDuration(s.Uptime)}))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,11 @@
|
|||||||
<dict>
|
<dict>
|
||||||
<key>CFBundleDevelopmentRegion</key>
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
<string>en</string>
|
<string>en</string>
|
||||||
|
<key>CFBundleLocalizations</key>
|
||||||
|
<array>
|
||||||
|
<string>en</string>
|
||||||
|
<string>zh-Hans</string>
|
||||||
|
</array>
|
||||||
<key>CFBundleExecutable</key>
|
<key>CFBundleExecutable</key>
|
||||||
<string>lmvpn</string>
|
<string>lmvpn</string>
|
||||||
<key>CFBundleIconFile</key>
|
<key>CFBundleIconFile</key>
|
||||||
|
|||||||
Reference in New Issue
Block a user