将Info.plist的LSUIElement改为true,应用以accessory模式启动, 永不显示Dock图标和系统菜单栏。移除运行时切换Dock可见性的 showDockIcon()/hideDockIcon()及其C实现setDockIconVisible, 这些调用在accessory模式下已无意义。activateApp()保留用于 窗口置于前台获取焦点。
418 lines
10 KiB
Go
418 lines
10 KiB
Go
package ui
|
|
|
|
import (
|
|
"errors"
|
|
"os"
|
|
"sync"
|
|
|
|
"lmvpn/internal/config"
|
|
"lmvpn/internal/db"
|
|
"lmvpn/internal/i18n"
|
|
"lmvpn/internal/instance"
|
|
"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
|
|
profileWindow fyne.Window
|
|
|
|
// Profile list window.
|
|
profileListWindow fyne.Window
|
|
profileList *widget.List
|
|
listSelectedIndex int
|
|
|
|
// UI widgets
|
|
profileSelect *widget.Select
|
|
stateLabel *widget.Label
|
|
ipLabel *widget.Label
|
|
ip6Label *widget.Label
|
|
uptimeLabel *widget.Label
|
|
rxV4Label *widget.Label
|
|
txV4Label *widget.Label
|
|
rxV6Label *widget.Label
|
|
txV6Label *widget.Label
|
|
rxTotalLabel *widget.Label
|
|
txTotalLabel *widget.Label
|
|
routingModeLabel *widget.Label
|
|
cidrV4Label *widget.Label
|
|
cidrV6Label *widget.Label
|
|
refreshCIDRBtn *widget.Button
|
|
connectBtn *widget.Button
|
|
disconnectBtn *widget.Button
|
|
|
|
// State
|
|
mu sync.Mutex
|
|
ipcClient *ipc.Client
|
|
profiles []model.ServerProfile
|
|
currentProfile *model.ServerProfile
|
|
defaultProfileID int64
|
|
langSetting string
|
|
windowHidden bool
|
|
}
|
|
|
|
// 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())
|
|
|
|
// Single-instance enforcement: the first instance holds the lock;
|
|
// a second instance signals "focus" to the first and exits.
|
|
focusCh, err := instance.Acquire()
|
|
if err != nil {
|
|
if errors.Is(err, instance.ErrAlreadyRunning) {
|
|
log.L().Info("another instance is running, exiting")
|
|
os.Exit(0)
|
|
}
|
|
log.L().Warn("instance lock failed, continuing", "error", err)
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
// Set the localized Touch ID prompt for keychain access (macOS).
|
|
setTouchIDPromptFromI18n()
|
|
|
|
a := &App{
|
|
fyneApp: app.NewWithID(paths.BundleID),
|
|
db: store,
|
|
kc: keychain.New(),
|
|
langSetting: cfg.Language,
|
|
defaultProfileID: cfg.DefaultProfileID,
|
|
listSelectedIndex: -1,
|
|
}
|
|
|
|
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()
|
|
|
|
// Listen for "focus" signals from second instances and bring the
|
|
// window to the front.
|
|
if focusCh != nil {
|
|
go func() {
|
|
for range focusCh {
|
|
fyne.Do(func() {
|
|
a.windowHidden = false
|
|
activateApp()
|
|
a.window.Show()
|
|
a.window.RequestFocus()
|
|
})
|
|
}
|
|
}()
|
|
}
|
|
|
|
// Register a Cocoa handler so that re-opening the .app bundle
|
|
// (which does NOT start a new process on macOS) brings the hidden
|
|
// window back. On other platforms this is a no-op. This must be
|
|
// deferred until after the Fyne/GLFW event loop has started,
|
|
// because GLFWApplicationDelegate is not created until glfw.Init()
|
|
// runs inside runGL().
|
|
a.fyneApp.Lifecycle().SetOnStarted(func() {
|
|
onAppActive = func() {
|
|
if a.windowHidden {
|
|
a.windowHidden = false
|
|
activateApp()
|
|
fyne.Do(func() {
|
|
if a.windowHidden {
|
|
return
|
|
}
|
|
a.window.Show()
|
|
a.window.RequestFocus()
|
|
})
|
|
}
|
|
}
|
|
registerReopenHandler()
|
|
})
|
|
|
|
// 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.windowHidden = true
|
|
a.window.Hide()
|
|
} else {
|
|
a.quit()
|
|
}
|
|
})
|
|
|
|
a.window.ShowAndRun()
|
|
}
|
|
|
|
// quit disconnects any active VPN session (via the daemon) and then
|
|
// quits the application. The daemon process itself remains running.
|
|
func (a *App) quit() {
|
|
a.mu.Lock()
|
|
client := a.ipcClient
|
|
a.ipcClient = nil
|
|
a.mu.Unlock()
|
|
if client != nil {
|
|
_ = ipc.SendStop(client)
|
|
}
|
|
a.fyneApp.Quit()
|
|
}
|
|
|
|
// loadProfiles loads all profiles from the database and populates the
|
|
// selector.
|
|
func (a *App) loadProfiles() {
|
|
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 {
|
|
selected := false
|
|
if a.defaultProfileID > 0 {
|
|
for i := range a.profiles {
|
|
if a.profiles[i].ID == a.defaultProfileID {
|
|
a.profileSelect.SetSelectedIndex(i)
|
|
a.selectProfileByName(a.profiles[i].Name)
|
|
selected = true
|
|
break
|
|
}
|
|
}
|
|
}
|
|
if !selected {
|
|
a.profileSelect.SetSelectedIndex(0)
|
|
a.selectProfileByName(names[0])
|
|
}
|
|
} else {
|
|
a.currentProfile = nil
|
|
a.profileSelect.SetSelected("")
|
|
a.profileSelect.Refresh()
|
|
}
|
|
a.listSelectedIndex = -1
|
|
a.refreshProfileList()
|
|
}
|
|
|
|
// refreshProfileList refreshes the profile list widget if the profile
|
|
// list window is currently open.
|
|
func (a *App) refreshProfileList() {
|
|
if a.profileList != nil {
|
|
a.profileList.Refresh()
|
|
}
|
|
}
|
|
|
|
// profileNames returns the names of all loaded profiles.
|
|
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
|
|
}
|
|
}
|
|
}
|
|
|
|
// saveDefaultProfile persists the currently selected profile ID to the
|
|
// config file so it can be restored on the next launch.
|
|
func (a *App) saveDefaultProfile() {
|
|
if a.currentProfile == nil {
|
|
return
|
|
}
|
|
a.defaultProfileID = a.currentProfile.ID
|
|
cfg, err := config.Load()
|
|
if err != nil {
|
|
cfg = config.Default()
|
|
}
|
|
if cfg.DefaultProfileID == a.currentProfile.ID {
|
|
return
|
|
}
|
|
cfg.DefaultProfileID = a.currentProfile.ID
|
|
if err := config.Save(cfg); err != nil {
|
|
log.L().Error("save default profile", "error", err)
|
|
}
|
|
}
|
|
|
|
// onAddProfile shows a dialog to create a new profile.
|
|
func (a *App) onAddProfile() {
|
|
a.showProfileDialog(nil)
|
|
}
|
|
|
|
// onResetDB deletes the SQLite database file after confirmation,
|
|
// then re-creates it. All profiles, credentials, and logs are lost.
|
|
func (a *App) onResetDB() {
|
|
dialog.NewCustomConfirm(i18n.T("DlgResetDBTitle"),
|
|
i18n.T("BtnDelete"), i18n.T("BtnCancel"),
|
|
widget.NewLabel(i18n.T("DlgResetDBMsg")),
|
|
func(ok bool) {
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
// Disconnect if connected.
|
|
a.mu.Lock()
|
|
client := a.ipcClient
|
|
a.mu.Unlock()
|
|
if client != nil {
|
|
_ = ipc.SendStop(client)
|
|
}
|
|
|
|
// Clear keychain entries for all profiles.
|
|
for _, p := range a.profiles {
|
|
_ = a.kc.DeleteAll(p.Name)
|
|
}
|
|
|
|
// Close and delete database.
|
|
if a.db != nil {
|
|
a.db.Close()
|
|
}
|
|
if err := os.Remove(paths.DBPath()); err != nil {
|
|
showError(i18n.T("DlgError"), err.Error(), a.window)
|
|
return
|
|
}
|
|
|
|
// Re-open (auto-creates new database).
|
|
store, err := db.Open()
|
|
if err != nil {
|
|
showError(i18n.T("DlgError"), err.Error(), a.window)
|
|
return
|
|
}
|
|
a.db = store
|
|
|
|
// Reset UI state.
|
|
a.currentProfile = nil
|
|
a.loadProfiles()
|
|
a.stateLabel.SetText(i18n.T("StateDisconnected"))
|
|
a.ipLabel.SetText(i18n.T("IpNone"))
|
|
a.ip6Label.SetText(i18n.T("Ip6None"))
|
|
a.uptimeLabel.SetText(i18n.T("UptimeNone"))
|
|
a.rxV4Label.SetText(i18n.T("RxV4Zero"))
|
|
a.txV4Label.SetText(i18n.T("TxV4Zero"))
|
|
a.rxV6Label.SetText(i18n.T("RxV6Zero"))
|
|
a.txV6Label.SetText(i18n.T("TxV6Zero"))
|
|
a.rxTotalLabel.SetText(i18n.T("RxTotalZero"))
|
|
a.txTotalLabel.SetText(i18n.T("TxTotalZero"))
|
|
a.setConnButtons(true, false)
|
|
}, a.window).Show()
|
|
}
|
|
|
|
// 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)
|
|
|
|
// Update the Touch ID prompt text for the new language.
|
|
setTouchIDPromptFromI18n()
|
|
|
|
// 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
|
|
}
|
|
|
|
// Close the profile list window if open; it holds cached strings
|
|
// and will be recreated with the new language on next open.
|
|
if a.profileListWindow != nil {
|
|
a.profileListWindow.Close()
|
|
}
|
|
|
|
// Rebuild window content (creates fresh widgets with new strings).
|
|
a.window.SetTitle(i18n.T("WindowTitle"))
|
|
a.window.SetContent(a.buildMainWindow())
|
|
|
|
// 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.setConnButtons(false, true)
|
|
a.stateLabel.SetText(i18n.T("StateConnected"))
|
|
} else {
|
|
a.setConnButtons(true, false)
|
|
a.stateLabel.SetText(i18n.T("StateDisconnected"))
|
|
}
|
|
|
|
// Rebuild the tray menu (new labels + checked language item).
|
|
a.setupTray()
|
|
}
|