增加多语言支持

This commit is contained in:
2026-07-06 16:52:04 +08:00
parent 1e3db0a724
commit 1f1794fcc7
11 changed files with 511 additions and 83 deletions
+90 -10
View File
@@ -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()
}
+60 -24
View File
@@ -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)
}
}
}
+38 -5
View File
@@ -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()
}),
)
+41 -40
View File
@@ -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)}))
}
}