Files
lmvpn_client/internal/ui/app.go
T
2026-07-06 16:52:04 +08:00

251 lines
5.7 KiB
Go

package ui
import (
"sync"
"lmvpn/internal/config"
"lmvpn/internal/db"
"lmvpn/internal/i18n"
"lmvpn/internal/ipc"
"lmvpn/internal/keychain"
"lmvpn/internal/log"
"lmvpn/internal/model"
"lmvpn/internal/paths"
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/app"
"fyne.io/fyne/v2/dialog"
"fyne.io/fyne/v2/widget"
)
// App is the GUI application controller.
type App struct {
fyneApp fyne.App
db *db.Store
kc keychain.Store
window fyne.Window
// UI widgets
profileSelect *widget.Select
stateLabel *widget.Label
ipLabel *widget.Label
uptimeLabel *widget.Label
rxLabel *widget.Label
txLabel *widget.Label
connectBtn *widget.Button
disconnectBtn *widget.Button
// State
mu sync.Mutex
ipcClient *ipc.Client
profiles []model.ServerProfile
currentProfile *model.ServerProfile
langSetting string
}
// Run initialises and starts the GUI application.
func Run() {
// Ensure platform directories exist.
if err := paths.EnsureDirs(); err != nil {
log.L().Error("ensure dirs", "error", err)
}
// Logging.
log.Init(log.RoleGUI, paths.LogFile())
// Database.
store, err := db.Open()
if err != nil {
log.L().Error("open db", "error", err)
}
// Load app config.
cfg, _ := config.Load()
// Initialise i18n (detect system locale when unset).
if err := i18n.Init(cfg.Language); err != nil {
log.L().Error("init i18n", "error", err)
}
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))
// Load profiles.
if store != nil {
a.loadProfiles()
}
// System tray.
a.setupTray()
// Auto-connect if configured.
if cfg.AutoConnect && a.currentProfile != nil {
go func() {
// Small delay to let window render.
a.onConnect()
}()
}
a.window.SetCloseIntercept(func() {
if cfg.CloseToTray {
a.window.Hide()
} else {
a.fyneApp.Quit()
}
})
a.window.ShowAndRun()
}
// loadProfiles loads all profiles from the database and populates the
// selector.
func (a *App) loadProfiles() {
profiles, err := a.db.ListProfiles()
if err != nil {
log.L().Error("list profiles", "error", err)
return
}
a.profiles = profiles
names := a.profileNames()
a.profileSelect.Options = names
if len(names) > 0 {
a.profileSelect.SetSelectedIndex(0)
a.selectProfileByName(names[0])
}
}
// profileNames returns the names of all loaded profiles.
func (a *App) profileNames() []string {
names := make([]string, len(a.profiles))
for i, p := range a.profiles {
names[i] = p.Name
}
return names
}
// selectProfileByName sets the current profile by name.
func (a *App) selectProfileByName(name string) {
for i := range a.profiles {
if a.profiles[i].Name == name {
a.currentProfile = &a.profiles[i]
return
}
}
}
// onAddProfile shows a dialog to create a new profile.
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()
}
// 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()
}