diff --git a/build/lmvpn b/build/lmvpn index 738f0d7..0aa2b6f 100755 Binary files a/build/lmvpn and b/build/lmvpn differ diff --git a/go.mod b/go.mod index 1f35a67..e233636 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,10 @@ module lmvpn go 1.26 require ( + github.com/BurntSushi/toml v1.5.0 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 gopkg.in/natefinch/lumberjack.v2 v2.2.1 gopkg.in/yaml.v3 v3.0.1 @@ -13,7 +16,6 @@ require ( require ( fyne.io/fyne/v2 v2.7.4 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/fredbi/uri v1.1.1 // 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/hack-pad/go-indexeddb v0.3.2 // 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/kr/text v0.1.0 // indirect github.com/mattn/go-runewidth v0.0.17 // 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/rivo/uniseg v0.2.0 // indirect github.com/rymdport/portal v0.4.2 // indirect @@ -43,7 +43,7 @@ require ( github.com/yuin/goldmark v1.7.8 // indirect golang.org/x/image v0.24.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 ( diff --git a/internal/config/config.go b/internal/config/config.go index b58d4da..bddc89e 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -18,6 +18,7 @@ type AppConfig struct { CloseToTray bool `yaml:"close_to_tray"` DefaultProfileID int64 `yaml:"default_profile_id"` LogLevel string `yaml:"log_level"` // debug, info, warn, error + Language string `yaml:"language"` // "", "auto", "en", "zh-Hans" } // Default returns the default configuration. @@ -28,6 +29,7 @@ func Default() AppConfig { CloseToTray: true, DefaultProfileID: 0, LogLevel: "info", + Language: "", // empty = auto-detect from OS } } diff --git a/internal/i18n/en.toml b/internal/i18n/en.toml new file mode 100644 index 0000000..1658b63 --- /dev/null +++ b/internal/i18n/en.toml @@ -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" diff --git a/internal/i18n/i18n.go b/internal/i18n/i18n.go new file mode 100644 index 0000000..9ee7c86 --- /dev/null +++ b/internal/i18n/i18n.go @@ -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 +} diff --git a/internal/i18n/zh-Hans.toml b/internal/i18n/zh-Hans.toml new file mode 100644 index 0000000..c577630 --- /dev/null +++ b/internal/i18n/zh-Hans.toml @@ -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 = "自定义" diff --git a/internal/ui/app.go b/internal/ui/app.go index 6a23e35..98491db 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -5,6 +5,7 @@ import ( "lmvpn/internal/config" "lmvpn/internal/db" + "lmvpn/internal/i18n" "lmvpn/internal/ipc" "lmvpn/internal/keychain" "lmvpn/internal/log" @@ -39,6 +40,7 @@ type App struct { ipcClient *ipc.Client profiles []model.ServerProfile currentProfile *model.ServerProfile + langSetting string } // Run initialises and starts the GUI application. @@ -60,13 +62,19 @@ func Run() { // Load app config. cfg, _ := config.Load() - a := &App{ - fyneApp: app.NewWithID(paths.BundleID), - db: store, - kc: keychain.New(), + // Initialise i18n (detect system locale when unset). + if err := i18n.Init(cfg.Language); err != nil { + log.L().Error("init i18n", "error", err) } - 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.Resize(fyne.NewSize(420, 480)) @@ -141,7 +149,8 @@ func (a *App) onAddProfile() { // onEditProfile shows a dialog to edit the current profile. func (a *App) onEditProfile() { 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 } p := *a.currentProfile @@ -154,17 +163,88 @@ func (a *App) onDeleteProfile() { return } name := a.currentProfile.Name - dialog.ShowConfirm("Delete Profile", - "Delete profile \""+name+"\" and its stored credentials?", + 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("Error", err.Error(), a.window) + showError(i18n.T("DlgError"), err.Error(), a.window) return } _ = a.kc.DeleteAll(name) 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() } diff --git a/internal/ui/profile.go b/internal/ui/profile.go index c4d3c83..d82b5b8 100644 --- a/internal/ui/profile.go +++ b/internal/ui/profile.go @@ -3,6 +3,7 @@ package ui import ( "strconv" + "lmvpn/internal/i18n" "lmvpn/internal/model" "fyne.io/fyne/v2" @@ -11,6 +12,40 @@ import ( "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. // If editing is nil, a new profile is created. func (a *App) showProfileDialog(editing *model.ServerProfile) { @@ -20,46 +55,47 @@ func (a *App) showProfileDialog(editing *model.ServerProfile) { serverEntry := widget.NewEntry() userEntry := widget.NewEntry() passEntry := widget.NewPasswordEntry() - authSelect := widget.NewSelect([]string{"both", "jwt", "password"}, nil) - routeSelect := widget.NewSelect([]string{"full", "split", "custom"}, nil) + authSelect := widget.NewSelect(authModeLabels(), nil) + routeSelect := widget.NewSelect(routeModeLabels(), nil) cidrEntry := widget.NewMultiLineEntry() - cidrEntry.SetPlaceHolder("10.0.0.0/8, 172.16.0.0/12") + cidrEntry.SetPlaceHolder(i18n.T("PlaceholderCIDRs")) mtuEntry := widget.NewEntry() - mtuEntry.SetPlaceHolder("0 = use server MTU") + mtuEntry.SetPlaceHolder(i18n.T("PlaceholderMTU")) if !isNew { nameEntry.SetText(editing.Name) serverEntry.SetText(editing.ServerURL) userEntry.SetText(editing.Username) - authSelect.SetSelected(string(editing.AuthMode)) - routeSelect.SetSelected(string(editing.RoutingMode)) + authSelect.SetSelectedIndex(codeIndex(authCodes, string(editing.AuthMode))) + routeSelect.SetSelectedIndex(codeIndex(routeCodes, string(editing.RoutingMode))) cidrEntry.SetText(editing.CustomCIDRs) mtuEntry.SetText(fmtInt(editing.MTUOverride)) - passEntry.SetPlaceHolder("(unchanged)") + passEntry.SetPlaceHolder(i18n.T("PlaceholderPasswordUnchanged")) } else { - authSelect.SetSelected(string(model.AuthModeBoth)) - routeSelect.SetSelected(string(model.RoutingFull)) + authSelect.SetSelectedIndex(codeIndex(authCodes, string(model.AuthModeBoth))) + routeSelect.SetSelectedIndex(codeIndex(routeCodes, string(model.RoutingFull))) mtuEntry.SetText("0") } form := container.NewVBox( - widget.NewLabel("Name"), nameEntry, - widget.NewLabel("Server URL"), serverEntry, - widget.NewLabel("Username"), userEntry, - widget.NewLabel("Password"), passEntry, - widget.NewLabel("Auth Mode"), authSelect, - widget.NewLabel("Routing Mode"), routeSelect, - widget.NewLabel("Custom CIDRs (comma-separated)"), cidrEntry, - widget.NewLabel("MTU Override"), mtuEntry, + widget.NewLabel(i18n.T("FieldName")), nameEntry, + widget.NewLabel(i18n.T("FieldServerURL")), serverEntry, + widget.NewLabel(i18n.T("FieldUsername")), userEntry, + widget.NewLabel(i18n.T("FieldPassword")), passEntry, + widget.NewLabel(i18n.T("FieldAuthMode")), authSelect, + widget.NewLabel(i18n.T("FieldRoutingMode")), routeSelect, + widget.NewLabel(i18n.T("FieldCustomCIDRs")), cidrEntry, + 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 { return } a.saveProfile(editing, nameEntry.Text, serverEntry.Text, userEntry.Text, passEntry.Text, - authSelect.Selected, routeSelect.Selected, + selectedCode(authCodes, authSelect.SelectedIndex()), + selectedCode(routeCodes, routeSelect.SelectedIndex()), cidrEntry.Text, mtuEntry.Text, isNew) }, a.window) d.Resize(fyne.NewSize(400, 500)) @@ -70,7 +106,7 @@ func (a *App) showProfileDialog(editing *model.ServerProfile) { func (a *App) saveProfile(editing *model.ServerProfile, name, server, user, password, authMode, routeMode, cidrs, mtuStr string, isNew bool) { 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 } @@ -88,13 +124,13 @@ func (a *App) saveProfile(editing *model.ServerProfile, } id, err := a.db.CreateProfile(p) if err != nil { - showError("Save Error", err.Error(), a.window) + showError(i18n.T("DlgSaveError"), err.Error(), a.window) return } _ = id if password != "" { 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 { @@ -107,13 +143,13 @@ func (a *App) saveProfile(editing *model.ServerProfile, editing.CustomCIDRs = cidrs editing.MTUOverride = mtu if err := a.db.UpdateProfile(editing); err != nil { - showError("Save Error", err.Error(), a.window) + showError(i18n.T("DlgSaveError"), err.Error(), a.window) return } if password != "" { _ = a.kc.DeleteAll(oldName) if err := a.kc.SetPassword(name, password); err != nil { - showError("Keychain Error", err.Error(), a.window) + showError(i18n.T("DlgKeychainError"), err.Error(), a.window) } } } diff --git a/internal/ui/tray.go b/internal/ui/tray.go index 5d8be04..a66e6f4 100644 --- a/internal/ui/tray.go +++ b/internal/ui/tray.go @@ -1,6 +1,8 @@ package ui import ( + "lmvpn/internal/i18n" + "fyne.io/fyne/v2" "fyne.io/fyne/v2/driver/desktop" ) @@ -11,20 +13,51 @@ func (a *App) setupTray() { if !ok { 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.RequestFocus() }), fyne.NewMenuItemSeparator(), - fyne.NewMenuItem("Connect", func() { + fyne.NewMenuItem(i18n.T("TrayConnect"), func() { a.onConnect() }), - fyne.NewMenuItem("Disconnect", func() { + fyne.NewMenuItem(i18n.T("TrayDisconnect"), func() { a.onDisconnect() }), fyne.NewMenuItemSeparator(), - fyne.NewMenuItem("Quit", func() { + langItem, + fyne.NewMenuItemSeparator(), + fyne.NewMenuItem(i18n.T("TrayQuit"), func() { a.fyneApp.Quit() }), ) diff --git a/internal/ui/view.go b/internal/ui/view.go index b1b9698..d287780 100644 --- a/internal/ui/view.go +++ b/internal/ui/view.go @@ -4,6 +4,7 @@ import ( "fmt" "time" + "lmvpn/internal/i18n" "lmvpn/internal/ipc" "lmvpn/internal/stats" @@ -15,7 +16,7 @@ import ( // showError displays a titled error dialog. 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. @@ -26,14 +27,14 @@ func (a *App) buildMainWindow() fyne.CanvasObject { }) // Status display. - a.stateLabel = widget.NewLabel("Disconnected") + a.stateLabel = widget.NewLabel(i18n.T("StateDisconnected")) a.stateLabel.TextStyle = fyne.TextStyle{Bold: true} - a.ipLabel = widget.NewLabel("IP: —") - a.uptimeLabel = widget.NewLabel("Uptime: —") - a.rxLabel = widget.NewLabel("↓ 0 B") - a.txLabel = widget.NewLabel("↑ 0 B") + a.ipLabel = widget.NewLabel(i18n.T("IpNone")) + a.uptimeLabel = widget.NewLabel(i18n.T("UptimeNone")) + a.rxLabel = widget.NewLabel(i18n.T("RxZero")) + a.txLabel = widget.NewLabel(i18n.T("TxZero")) - statusCard := widget.NewCard("Status", "", container.NewVBox( + statusCard := widget.NewCard(i18n.T("StatusLabel"), "", container.NewVBox( a.stateLabel, a.ipLabel, a.uptimeLabel, @@ -41,14 +42,14 @@ func (a *App) buildMainWindow() fyne.CanvasObject { )) // Buttons. - a.connectBtn = widget.NewButton("Connect", a.onConnect) + a.connectBtn = widget.NewButton(i18n.T("BtnConnect"), a.onConnect) a.connectBtn.Importance = widget.HighImportance - a.disconnectBtn = widget.NewButton("Disconnect", a.onDisconnect) + a.disconnectBtn = widget.NewButton(i18n.T("BtnDisconnect"), a.onDisconnect) a.disconnectBtn.Disable() - addBtn := widget.NewButton("Add Profile", a.onAddProfile) - editBtn := widget.NewButton("Edit", a.onEditProfile) - deleteBtn := widget.NewButton("Delete", a.onDeleteProfile) + addBtn := widget.NewButton(i18n.T("BtnAddProfile"), a.onAddProfile) + editBtn := widget.NewButton(i18n.T("BtnEdit"), a.onEditProfile) + deleteBtn := widget.NewButton(i18n.T("BtnDelete"), a.onDeleteProfile) buttons := container.NewGridWithColumns(2, a.connectBtn, a.disconnectBtn, @@ -58,7 +59,7 @@ func (a *App) buildMainWindow() fyne.CanvasObject { ) return container.NewVBox( - widget.NewLabel("Profile"), + widget.NewLabel(i18n.T("ProfileLabel")), a.profileSelect, buttons, profileButtons, @@ -69,20 +70,20 @@ func (a *App) buildMainWindow() fyne.CanvasObject { // onConnect handles the Connect button click. func (a *App) onConnect() { 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 } a.connectBtn.Disable() - a.stateLabel.SetText("Connecting...") + a.stateLabel.SetText(i18n.T("StateConnecting")) go func() { // Ensure daemon is running. client, err := ensureDaemon() if err != nil { fyne.Do(func() { - showError("Daemon Error", err.Error(), a.window) - a.stateLabel.SetText("Disconnected") + showError(i18n.T("DlgDaemonError"), err.Error(), a.window) + a.stateLabel.SetText(i18n.T("StateDisconnected")) a.connectBtn.Enable() }) return @@ -96,10 +97,10 @@ func (a *App) onConnect() { password, err := a.kc.GetPassword(a.currentProfile.Name) if err != nil { fyne.Do(func() { - showError("Credential Error", - "No password stored for this profile. Edit the profile to set it.", + showError(i18n.T("DlgCredentialError"), + i18n.T("DlgCredentialErrorMsg"), a.window) - a.stateLabel.SetText("Disconnected") + a.stateLabel.SetText(i18n.T("StateDisconnected")) a.connectBtn.Enable() }) return @@ -117,8 +118,8 @@ func (a *App) onConnect() { } if err := ipc.SendStart(client, cfg); err != nil { fyne.Do(func() { - showError("IPC Error", err.Error(), a.window) - a.stateLabel.SetText("Disconnected") + showError(i18n.T("DlgIPCError"), err.Error(), a.window) + a.stateLabel.SetText(i18n.T("StateDisconnected")) a.connectBtn.Enable() }) return @@ -140,7 +141,7 @@ func (a *App) onDisconnect() { _ = ipc.SendStop(client) a.disconnectBtn.Disable() a.connectBtn.Enable() - a.stateLabel.SetText("Disconnected") + a.stateLabel.SetText(i18n.T("StateDisconnected")) } // eventLoop reads IPC events from the daemon and updates the UI. @@ -155,11 +156,11 @@ func (a *App) eventLoop() { ev, err := client.Recv() if err != nil { fyne.Do(func() { - a.stateLabel.SetText("Disconnected") - a.ipLabel.SetText("IP: —") - a.uptimeLabel.SetText("Uptime: —") - a.rxLabel.SetText("↓ 0 B") - a.txLabel.SetText("↑ 0 B") + a.stateLabel.SetText(i18n.T("StateDisconnected")) + a.ipLabel.SetText(i18n.T("IpNone")) + a.uptimeLabel.SetText(i18n.T("UptimeNone")) + a.rxLabel.SetText(i18n.T("RxZero")) + a.txLabel.SetText(i18n.T("TxZero")) a.connectBtn.Enable() a.disconnectBtn.Disable() }) @@ -180,7 +181,7 @@ func (a *App) eventLoop() { case ipc.EvError: fyne.Do(func() { 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) { switch stats.State(state) { case stats.StateConnected: - a.stateLabel.SetText("Connected") + a.stateLabel.SetText(i18n.T("StateConnected")) a.connectBtn.Disable() a.disconnectBtn.Enable() case stats.StateConnecting: - a.stateLabel.SetText("Connecting...") + a.stateLabel.SetText(i18n.T("StateConnecting")) a.connectBtn.Disable() a.disconnectBtn.Enable() case stats.StateReconnecting: - a.stateLabel.SetText("Reconnecting...") + a.stateLabel.SetText(i18n.T("StateReconnecting")) a.disconnectBtn.Enable() case stats.StateDisconnected: - a.stateLabel.SetText("Disconnected") - a.ipLabel.SetText("IP: —") + a.stateLabel.SetText(i18n.T("StateDisconnected")) + a.ipLabel.SetText(i18n.T("IpNone")) a.connectBtn.Enable() a.disconnectBtn.Disable() case stats.StateError: - a.stateLabel.SetText("Error") + a.stateLabel.SetText(i18n.T("StateError")) a.connectBtn.Enable() a.disconnectBtn.Disable() } @@ -216,15 +217,15 @@ func (a *App) applyState(state string) { // applyStats updates the stats display. func (a *App) applyStats(s stats.Snapshot) { 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 { - a.stateLabel.SetText("Connected") + a.stateLabel.SetText(i18n.T("StateConnected")) } - a.rxLabel.SetText("↓ " + formatBytes(s.RxBytes)) - a.txLabel.SetText("↑ " + formatBytes(s.TxBytes)) + 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)})) if s.Uptime > 0 { - a.uptimeLabel.SetText("Uptime: " + formatDuration(s.Uptime)) + a.uptimeLabel.SetText(i18n.T("UptimeLabel", map[string]interface{}{"uptime": formatDuration(s.Uptime)})) } } diff --git a/resources/Info.plist b/resources/Info.plist index 401381e..8b2bb84 100644 --- a/resources/Info.plist +++ b/resources/Info.plist @@ -4,6 +4,11 @@ CFBundleDevelopmentRegion en + CFBundleLocalizations + + en + zh-Hans + CFBundleExecutable lmvpn CFBundleIconFile