拆分服务器地址配置为独立字段,支持CDN优选IP故障切换,新增重置数据库按钮
- model: ServerURL → Protocol/Host/ServerIPs/Port/Path 五个字段 - db: 自动迁移旧 server_url 列到新表结构 - ui: 配置窗口改为横向分组布局,保存失败时不再关闭窗口 - transport: 支持 TLS SNI + Host 头覆盖以连接 CDN 边缘节点 - session: CDN IP 列表连接失败自动切换到下一个 - 新增重置数据库按钮,一键清空所有配置
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"os"
|
||||
"sync"
|
||||
|
||||
"lmvpn/internal/config"
|
||||
@@ -181,6 +182,60 @@ func (a *App) onDeleteProfile() {
|
||||
}, a.window).Show()
|
||||
}
|
||||
|
||||
// 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.uptimeLabel.SetText(i18n.T("UptimeNone"))
|
||||
a.rxLabel.SetText(i18n.T("RxZero"))
|
||||
a.txLabel.SetText(i18n.T("TxZero"))
|
||||
a.connectBtn.Enable()
|
||||
a.disconnectBtn.Disable()
|
||||
}, 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.
|
||||
|
||||
+73
-19
@@ -17,6 +17,8 @@ import (
|
||||
var (
|
||||
authCodes = []string{string(model.AuthModeBoth), string(model.AuthModeJWT), string(model.AuthModePassword)}
|
||||
routeCodes = []string{string(model.RoutingFull), string(model.RoutingSplit), string(model.RoutingCustom)}
|
||||
|
||||
protoCodes = []string{"wss", "ws"}
|
||||
)
|
||||
|
||||
func authModeLabels() []string {
|
||||
@@ -27,6 +29,10 @@ func routeModeLabels() []string {
|
||||
return []string{i18n.T("RoutingModeFull"), i18n.T("RoutingModeSplit"), i18n.T("RoutingModeCustom")}
|
||||
}
|
||||
|
||||
func protoLabels() []string {
|
||||
return []string{"wss", "ws"}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
@@ -57,7 +63,12 @@ func (a *App) showProfileDialog(editing *model.ServerProfile) {
|
||||
isNew := editing == nil
|
||||
|
||||
nameEntry := widget.NewEntry()
|
||||
serverEntry := widget.NewEntry()
|
||||
protoSelect := widget.NewSelect(protoLabels(), nil)
|
||||
hostEntry := widget.NewEntry()
|
||||
ipsEntry := widget.NewEntry()
|
||||
ipsEntry.SetPlaceHolder(i18n.T("PlaceholderServerIPs"))
|
||||
portEntry := widget.NewEntry()
|
||||
pathEntry := widget.NewEntry()
|
||||
userEntry := widget.NewEntry()
|
||||
passEntry := widget.NewPasswordEntry()
|
||||
authSelect := widget.NewSelect(authModeLabels(), nil)
|
||||
@@ -69,7 +80,13 @@ func (a *App) showProfileDialog(editing *model.ServerProfile) {
|
||||
|
||||
if !isNew {
|
||||
nameEntry.SetText(editing.Name)
|
||||
serverEntry.SetText(editing.ServerURL)
|
||||
protoSelect.SetSelectedIndex(codeIndex(protoCodes, editing.Protocol))
|
||||
hostEntry.SetText(editing.Host)
|
||||
ipsEntry.SetText(editing.ServerIPs)
|
||||
if editing.Port > 0 {
|
||||
portEntry.SetText(fmtInt(editing.Port))
|
||||
}
|
||||
pathEntry.SetText(editing.Path)
|
||||
userEntry.SetText(editing.Username)
|
||||
authSelect.SetSelectedIndex(codeIndex(authCodes, string(editing.AuthMode)))
|
||||
routeSelect.SetSelectedIndex(codeIndex(routeCodes, string(editing.RoutingMode)))
|
||||
@@ -77,18 +94,39 @@ func (a *App) showProfileDialog(editing *model.ServerProfile) {
|
||||
mtuEntry.SetText(fmtInt(editing.MTUOverride))
|
||||
passEntry.SetPlaceHolder(i18n.T("PlaceholderPasswordUnchanged"))
|
||||
} else {
|
||||
protoSelect.SetSelectedIndex(0) // wss
|
||||
portEntry.SetText("443")
|
||||
pathEntry.SetText("/ws")
|
||||
authSelect.SetSelectedIndex(codeIndex(authCodes, string(model.AuthModeBoth)))
|
||||
routeSelect.SetSelectedIndex(codeIndex(routeCodes, string(model.RoutingFull)))
|
||||
mtuEntry.SetText("0")
|
||||
}
|
||||
|
||||
form := container.NewVBox(
|
||||
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("FieldName")),
|
||||
nameEntry,
|
||||
|
||||
container.NewBorder(nil, nil,
|
||||
container.NewVBox(widget.NewLabel(i18n.T("FieldProtocol")), protoSelect),
|
||||
container.NewVBox(widget.NewLabel(i18n.T("FieldPort")), portEntry),
|
||||
container.NewVBox(widget.NewLabel(i18n.T("FieldHost")), hostEntry),
|
||||
),
|
||||
|
||||
container.NewGridWithColumns(2,
|
||||
container.NewVBox(widget.NewLabel(i18n.T("FieldPath")), pathEntry),
|
||||
container.NewVBox(widget.NewLabel(i18n.T("FieldServerIPs")), ipsEntry),
|
||||
),
|
||||
|
||||
container.NewGridWithColumns(2,
|
||||
container.NewVBox(widget.NewLabel(i18n.T("FieldUsername")), userEntry),
|
||||
container.NewVBox(widget.NewLabel(i18n.T("FieldPassword")), passEntry),
|
||||
),
|
||||
|
||||
container.NewGridWithColumns(2,
|
||||
container.NewVBox(widget.NewLabel(i18n.T("FieldAuthMode")), authSelect),
|
||||
container.NewVBox(widget.NewLabel(i18n.T("FieldRoutingMode")), routeSelect),
|
||||
),
|
||||
|
||||
widget.NewLabel(i18n.T("FieldCustomCIDRs")), cidrEntry,
|
||||
widget.NewLabel(i18n.T("FieldMTUOverride")), mtuEntry,
|
||||
)
|
||||
@@ -101,12 +139,17 @@ func (a *App) showProfileDialog(editing *model.ServerProfile) {
|
||||
})
|
||||
|
||||
saveBtn := widget.NewButton(i18n.T("BtnSave"), func() {
|
||||
a.saveProfile(editing, nameEntry.Text, serverEntry.Text,
|
||||
if a.saveProfile(editing,
|
||||
nameEntry.Text,
|
||||
selectedCode(protoCodes, protoSelect.SelectedIndex()),
|
||||
hostEntry.Text, ipsEntry.Text,
|
||||
portEntry.Text, pathEntry.Text,
|
||||
userEntry.Text, passEntry.Text,
|
||||
selectedCode(authCodes, authSelect.SelectedIndex()),
|
||||
selectedCode(routeCodes, routeSelect.SelectedIndex()),
|
||||
cidrEntry.Text, mtuEntry.Text, isNew)
|
||||
profileWin.Close()
|
||||
cidrEntry.Text, mtuEntry.Text, isNew) {
|
||||
profileWin.Close()
|
||||
}
|
||||
})
|
||||
saveBtn.Importance = widget.HighImportance
|
||||
|
||||
@@ -114,25 +157,31 @@ func (a *App) showProfileDialog(editing *model.ServerProfile) {
|
||||
profileWin.Close()
|
||||
})
|
||||
|
||||
profileWin.SetContent(container.NewBorder(nil, container.NewHBox(saveBtn, cancelBtn), nil, nil, form))
|
||||
profileWin.SetContent(container.NewBorder(nil, container.NewHBox(saveBtn, cancelBtn), nil, nil, container.NewVScroll(form)))
|
||||
profileWin.Resize(fyne.NewSize(460, 560))
|
||||
profileWin.Show()
|
||||
}
|
||||
|
||||
// saveProfile creates or updates a profile and stores credentials.
|
||||
// Returns true on success, false if validation or DB operation failed.
|
||||
func (a *App) saveProfile(editing *model.ServerProfile,
|
||||
name, server, user, password, authMode, routeMode, cidrs, mtuStr string, isNew bool) {
|
||||
if name == "" || server == "" || user == "" {
|
||||
name, protocol, host, ips, portStr, pathStr, user, password, authMode, routeMode, cidrs, mtuStr string, isNew bool) bool {
|
||||
if name == "" || host == "" || user == "" {
|
||||
showError(i18n.T("DlgValidationTitle"), i18n.T("DlgValidationMsg"), a.window)
|
||||
return
|
||||
return false
|
||||
}
|
||||
|
||||
port := parseIntDefault(portStr, 443)
|
||||
mtu := parseIntDefault(mtuStr, 0)
|
||||
|
||||
if isNew {
|
||||
p := &model.ServerProfile{
|
||||
Name: name,
|
||||
ServerURL: server,
|
||||
Protocol: protocol,
|
||||
Host: host,
|
||||
ServerIPs: ips,
|
||||
Port: port,
|
||||
Path: pathStr,
|
||||
Username: user,
|
||||
AuthMode: model.AuthMode(authMode),
|
||||
RoutingMode: model.RoutingMode(routeMode),
|
||||
@@ -142,7 +191,7 @@ func (a *App) saveProfile(editing *model.ServerProfile,
|
||||
id, err := a.db.CreateProfile(p)
|
||||
if err != nil {
|
||||
showError(i18n.T("DlgSaveError"), err.Error(), a.window)
|
||||
return
|
||||
return false
|
||||
}
|
||||
_ = id
|
||||
if password != "" {
|
||||
@@ -153,7 +202,11 @@ func (a *App) saveProfile(editing *model.ServerProfile,
|
||||
} else {
|
||||
oldName := editing.Name
|
||||
editing.Name = name
|
||||
editing.ServerURL = server
|
||||
editing.Protocol = protocol
|
||||
editing.Host = host
|
||||
editing.ServerIPs = ips
|
||||
editing.Port = port
|
||||
editing.Path = pathStr
|
||||
editing.Username = user
|
||||
editing.AuthMode = model.AuthMode(authMode)
|
||||
editing.RoutingMode = model.RoutingMode(routeMode)
|
||||
@@ -161,7 +214,7 @@ func (a *App) saveProfile(editing *model.ServerProfile,
|
||||
editing.MTUOverride = mtu
|
||||
if err := a.db.UpdateProfile(editing); err != nil {
|
||||
showError(i18n.T("DlgSaveError"), err.Error(), a.window)
|
||||
return
|
||||
return false
|
||||
}
|
||||
if password != "" {
|
||||
_ = a.kc.DeleteAll(oldName)
|
||||
@@ -172,6 +225,7 @@ func (a *App) saveProfile(editing *model.ServerProfile,
|
||||
}
|
||||
|
||||
a.loadProfiles()
|
||||
return true
|
||||
}
|
||||
|
||||
func fmtInt(n int) string {
|
||||
|
||||
+19
-6
@@ -51,6 +51,8 @@ func (a *App) buildMainWindow() fyne.CanvasObject {
|
||||
editBtn := widget.NewButton(i18n.T("BtnEdit"), a.onEditProfile)
|
||||
deleteBtn := widget.NewButton(i18n.T("BtnDelete"), a.onDeleteProfile)
|
||||
|
||||
resetDBBtn := widget.NewButton(i18n.T("BtnResetDB"), a.onResetDB)
|
||||
|
||||
buttons := container.NewGridWithColumns(2,
|
||||
a.connectBtn, a.disconnectBtn,
|
||||
)
|
||||
@@ -63,6 +65,7 @@ func (a *App) buildMainWindow() fyne.CanvasObject {
|
||||
a.profileSelect,
|
||||
buttons,
|
||||
profileButtons,
|
||||
resetDBBtn,
|
||||
statusCard,
|
||||
)
|
||||
}
|
||||
@@ -109,15 +112,25 @@ func (a *App) onConnect() {
|
||||
return
|
||||
}
|
||||
|
||||
p := a.currentProfile
|
||||
serverURL := p.BuildServerURL()
|
||||
sniHost := ""
|
||||
serverIPs := p.GetServerIPList()
|
||||
if len(serverIPs) > 0 {
|
||||
sniHost = p.Host
|
||||
}
|
||||
|
||||
// Build and send the start command.
|
||||
cfg := ipc.ClientConfig{
|
||||
ServerURL: a.currentProfile.ServerURL,
|
||||
Username: a.currentProfile.Username,
|
||||
ServerURL: serverURL,
|
||||
SNIHost: sniHost,
|
||||
ServerIPs: serverIPs,
|
||||
Username: p.Username,
|
||||
Password: password,
|
||||
AuthMode: string(a.currentProfile.AuthMode),
|
||||
RoutingMode: string(a.currentProfile.RoutingMode),
|
||||
CustomCIDRs: splitCIDRs(a.currentProfile.CustomCIDRs),
|
||||
MTUOverride: a.currentProfile.MTUOverride,
|
||||
AuthMode: string(p.AuthMode),
|
||||
RoutingMode: string(p.RoutingMode),
|
||||
CustomCIDRs: splitCIDRs(p.CustomCIDRs),
|
||||
MTUOverride: p.MTUOverride,
|
||||
}
|
||||
if err := ipc.SendStart(client, cfg); err != nil {
|
||||
fyne.Do(func() {
|
||||
|
||||
Reference in New Issue
Block a user