feat: 添加IP竞速拨号器与IP偏好设置,连接状态显示域名与实际IP
Release / build-macos (push) Canceled after 0s
Release / build-windows (push) Canceled after 0s
Release / release (push) Canceled after 0s

- 新增竞速拨号器(racedial.go):域名连接时并行解析所有A/AAAA记录,
  同时对所有IP发起TCP连接,第一个成功的胜出,确保选择延迟最低的地址
- ServerProfile新增IPPreference字段(auto/v4/v6),支持用户指定IP版本偏好
- 填写服务器IP时跳过域名解析直接使用IP,顺序failover
- 连接成功后状态栏显示「已连接 (域名 -> 实际IP)」
- DB迁移v6:server_profiles表添加ip_preference列
- WebSocket拨号与HTTP登录均使用竞速拨号器
- 补充中英文i18n翻译
This commit is contained in:
2026-07-09 18:46:55 +08:00
parent 7febed50ac
commit b81b702433
22 changed files with 398 additions and 146 deletions
+30 -30
View File
@@ -35,32 +35,32 @@ type App struct {
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
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
connectBtn *widget.Button
disconnectBtn *widget.Button
// State
mu sync.Mutex
ipcClient *ipc.Client
profiles []model.ServerProfile
currentProfile *model.ServerProfile
mu sync.Mutex
ipcClient *ipc.Client
profiles []model.ServerProfile
currentProfile *model.ServerProfile
defaultProfileID int64
langSetting string
windowHidden bool
langSetting string
windowHidden bool
}
// Run initialises and starts the GUI application.
@@ -328,17 +328,17 @@ func (a *App) onResetDB() {
// 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.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()
}
-2
View File
@@ -86,5 +86,3 @@ func ensureDaemon() (*ipc.Client, error) {
}
return nil, fmt.Errorf("daemon did not become reachable (check %s)", logFile)
}
+1 -1
View File
@@ -29,7 +29,7 @@ func launchElevated(exe, daemonBin, home string, uid, gid int) error {
}
// shellQuote wraps a string in single quotes for shell safety.
// Embedded single quotes are escaped using the '\'' pattern.
// Embedded single quotes are escaped using the '\ pattern.
func shellQuote(s string) string {
result := "'"
for _, r := range s {
+20 -6
View File
@@ -25,6 +25,8 @@ var (
protoCodes = []string{"wss", "ws"}
fetchTimingCodes = []string{string(model.FetchBefore), string(model.FetchAfter)}
ipPrefCodes = []string{string(model.IPPrefAuto), string(model.IPPrefV4), string(model.IPPrefV6)}
)
func authModeLabels() []string {
@@ -43,6 +45,10 @@ func fetchTimingLabels() []string {
return []string{i18n.T("FetchTimingBefore"), i18n.T("FetchTimingAfter")}
}
func ipPrefLabels() []string {
return []string{i18n.T("IPPrefAuto"), i18n.T("IPPrefV4"), i18n.T("IPPrefV6")}
}
// 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 {
@@ -63,14 +69,14 @@ func selectedCode(codes []string, idx int) string {
// urlEntryRow holds the widgets for a single CIDR URL source row.
type urlEntryRow struct {
urlEntry *widget.Entry
urlEntry *widget.Entry
timingSelect *widget.Select
container *fyne.Container
container *fyne.Container
}
// cidrURLList manages a dynamic list of CIDR URL source rows.
type cidrURLList struct {
rows []*urlEntryRow
rows []*urlEntryRow
container *fyne.Container
parent fyne.Window
}
@@ -198,6 +204,7 @@ func (a *App) showProfileDialog(editing *model.ServerProfile) {
authSelect := widget.NewSelect(authModeLabels(), nil)
routeSelect := widget.NewSelect(routeModeLabels(), nil)
ipPrefSelect := widget.NewSelect(ipPrefLabels(), nil)
cidrV4Entry := widget.NewMultiLineEntry()
cidrV4Entry.Wrapping = fyne.TextWrapOff
@@ -300,6 +307,7 @@ func (a *App) showProfileDialog(editing *model.ServerProfile) {
userEntry.SetText(editing.Username)
authSelect.SetSelectedIndex(codeIndex(authCodes, string(editing.AuthMode)))
routeSelect.SetSelectedIndex(codeIndex(routeCodes, string(editing.RoutingMode)))
ipPrefSelect.SetSelectedIndex(codeIndex(ipPrefCodes, editing.IPPreference))
cidrV4Entry.SetText(editing.CIDRV4)
cidrV6Entry.SetText(editing.CIDRV6)
v4URLList.loadFromJSON(editing.CIDRV4URLs)
@@ -316,6 +324,7 @@ func (a *App) showProfileDialog(editing *model.ServerProfile) {
pathEntry.SetText("/ws")
authSelect.SetSelectedIndex(codeIndex(authCodes, string(model.AuthModeBoth)))
routeSelect.SetSelectedIndex(codeIndex(routeCodes, string(model.RoutingFull)))
ipPrefSelect.SetSelectedIndex(0) // auto
mtuEntry.SetText("0")
}
@@ -349,9 +358,10 @@ func (a *App) showProfileDialog(editing *model.ServerProfile) {
container.NewVBox(widget.NewLabel(i18n.T("FieldPassword")), passEntry),
),
container.NewGridWithColumns(2,
container.NewGridWithColumns(3,
container.NewVBox(widget.NewLabel(i18n.T("FieldAuthMode")), authSelect),
container.NewVBox(widget.NewLabel(i18n.T("FieldRoutingMode")), routeSelect),
container.NewVBox(widget.NewLabel(i18n.T("FieldIPPreference")), ipPrefSelect),
),
cidrSection,
@@ -394,7 +404,8 @@ func (a *App) showProfileDialog(editing *model.ServerProfile) {
mtuEntry.Text,
tlsCaPEMEntry.Text, tlsCaPathEntry.Text,
tlsPinnedHashEntry.Text, tlsInsecureCheck.Checked,
isNew) {
isNew,
selectedCode(ipPrefCodes, ipPrefSelect.SelectedIndex())) {
profileWin.Close()
}
})
@@ -414,7 +425,8 @@ func (a *App) showProfileDialog(editing *model.ServerProfile) {
func (a *App) saveProfile(editing *model.ServerProfile,
name, protocol, host, ips, portStr, pathStr, user, password, authMode, routeMode,
cidrV4, cidrV6, cidrV4URLs, cidrV6URLs, mtuStr,
tlsCaPEM, tlsCaPath, tlsPinnedHash string, tlsInsecure, isNew bool) bool {
tlsCaPEM, tlsCaPath, tlsPinnedHash string, tlsInsecure, isNew bool,
ipPreference string) bool {
if name == "" || host == "" || user == "" {
showError(i18n.T("DlgValidationTitle"), i18n.T("DlgValidationMsg"), a.window)
return false
@@ -454,6 +466,7 @@ func (a *App) saveProfile(editing *model.ServerProfile,
TLSCAPath: tlsCaPath,
TLSInsecure: tlsInsecure,
TLSPinnedHash: tlsPinnedHash,
IPPreference: ipPreference,
}
id, err := a.db.CreateProfile(p)
if err != nil {
@@ -486,6 +499,7 @@ func (a *App) saveProfile(editing *model.ServerProfile,
editing.TLSCAPath = tlsCaPath
editing.TLSInsecure = tlsInsecure
editing.TLSPinnedHash = tlsPinnedHash
editing.IPPreference = ipPreference
if err := a.db.UpdateProfile(editing); err != nil {
showError(i18n.T("DlgSaveError"), err.Error(), a.window)
return false
+16 -16
View File
@@ -46,20 +46,20 @@ func (a *App) setupTray() {
autoItem, enItem, zhItem,
)
connectItem := fyne.NewMenuItem(i18n.T("TrayConnect"), func() {
a.onConnect()
})
disconnectItem := fyne.NewMenuItem(i18n.T("TrayDisconnect"), func() {
a.onDisconnect()
})
if a.connectBtn != nil {
connectItem.Disabled = a.connectBtn.Disabled()
}
if a.disconnectBtn != nil {
disconnectItem.Disabled = a.disconnectBtn.Disabled()
}
connectItem := fyne.NewMenuItem(i18n.T("TrayConnect"), func() {
a.onConnect()
})
disconnectItem := fyne.NewMenuItem(i18n.T("TrayDisconnect"), func() {
a.onDisconnect()
})
if a.connectBtn != nil {
connectItem.Disabled = a.connectBtn.Disabled()
}
if a.disconnectBtn != nil {
disconnectItem.Disabled = a.disconnectBtn.Disabled()
}
menu := fyne.NewMenu(i18n.T("WindowTitle"),
menu := fyne.NewMenu(i18n.T("WindowTitle"),
fyne.NewMenuItem(i18n.T("TrayShowWindow"), func() {
a.windowHidden = false
activateApp()
@@ -67,9 +67,9 @@ func (a *App) setupTray() {
a.window.Show()
a.window.RequestFocus()
}),
fyne.NewMenuItemSeparator(),
connectItem,
disconnectItem,
fyne.NewMenuItemSeparator(),
connectItem,
disconnectItem,
fyne.NewMenuItemSeparator(),
langItem,
fyne.NewMenuItemSeparator(),
+4
View File
@@ -215,6 +215,7 @@ func (a *App) onConnect() {
TLSCAPath: p.TLSCAPath,
TLSInsecure: p.TLSInsecure,
TLSPinnedHash: p.TLSPinnedHash,
IPPreference: p.IPPreference,
}
if err := ipc.SendStart(client, cfg); err != nil {
fyne.Do(func() {
@@ -428,6 +429,9 @@ func (a *App) applyStats(s stats.Snapshot) {
stepLabel := connectStepLabel(s.ConnectStep)
if stepLabel != "" {
a.stateLabel.SetText(i18n.T("StateConnected") + " (" + stepLabel + ")")
} else if s.ServerHost != "" && s.ConnectedIP != "" {
a.stateLabel.SetText(fmt.Sprintf("%s (%s -> %s)",
i18n.T("StateConnected"), s.ServerHost, s.ConnectedIP))
} else {
a.stateLabel.SetText(i18n.T("StateConnected"))
}