package nativeui import ( "context" "errors" "fmt" "image/color" "io" "strconv" "strings" "time" "fyne.io/fyne/v2" fyneapp "fyne.io/fyne/v2/app" "fyne.io/fyne/v2/canvas" "fyne.io/fyne/v2/container" "fyne.io/fyne/v2/dialog" "fyne.io/fyne/v2/storage" "fyne.io/fyne/v2/theme" "fyne.io/fyne/v2/widget" coreapp "socksrevivepc/internal/app" "socksrevivepc/internal/config" "socksrevivepc/internal/crash" ) type UI struct { core *coreapp.App app fyne.App win fyne.Window profiles []config.Profile current config.Profile lastLog int64 logText string profileList *widget.List status *widget.Label subStatus *widget.Label connectBtn *widget.Button modeContent *fyne.Container logView *widget.Label logScroller *container.Scroll name *widget.Entry mode *widget.Select sshHost *widget.Entry sshPort *widget.Entry sshUser *widget.Entry sshPass *widget.Entry keepAlive *widget.Entry handshake *widget.Entry proxyHost *widget.Entry proxyPort *widget.Entry payloadText *widget.Entry payloadWait *widget.Check payloadAny *widget.Check payloadReply *widget.Entry payloadSplit *widget.Entry tlsHost *widget.Entry tlsPort *widget.Entry tlsSNI *widget.Entry tlsInsecure *widget.Check dnsttEmbedded *widget.Check dnsttResolverType *widget.Select dnsttResolverAddr *widget.Entry dnsttDomain *widget.Entry dnsttPublicKey *widget.Entry dnsttUTLS *widget.Entry dnsttExe *widget.Entry dnsttArgs *widget.Entry dnsttLocalHost *widget.Entry dnsttLocalPort *widget.Entry xrayExe *widget.Entry xrayArgs *widget.Entry xrayConfig *widget.Entry xraySocksHost *widget.Entry xraySocksPort *widget.Entry udpgwEnabled *widget.Check udpgwProtocol *widget.Select udpgwHost *widget.Entry udpgwPort *widget.Entry localSocksHost *widget.Entry localSocksPort *widget.Entry reconnectEnabled *widget.Check reconnectDelay *widget.Entry reconnectMax *widget.Entry reconnectCheck *widget.Entry tunEnabled *widget.Check tunDevice *widget.Entry tunIface *widget.Entry tunMTU *widget.Entry tunCIDR *widget.Entry tunDNS *widget.Entry tunRoute *widget.Check tunIPv6Enabled *widget.Check tunIPv6CIDR *widget.Entry tunIPv6DNS *widget.Entry tunBlockIPv6Leak *widget.Check } func Run(core *coreapp.App) { a := fyneapp.NewWithID("com.socksrevive.pc") a.Settings().SetTheme(theme.DarkTheme()) w := a.NewWindow("SocksRevive PC") w.Resize(fyne.NewSize(1220, 780)) u := &UI{core: core, app: a, win: w} u.build() u.loadProfiles() u.refreshStatus() u.startLogPoller() w.SetCloseIntercept(func() { u.core.Engine.Stop() w.Close() }) w.CenterOnScreen() w.ShowAndRun() } func (u *UI) build() { u.profileList = widget.NewList( func() int { return len(u.profiles) }, func() fyne.CanvasObject { name := widget.NewLabel("Profile") name.TextStyle = fyne.TextStyle{Bold: true} name.Truncation = fyne.TextTruncateEllipsis mode := widget.NewLabel("Tunnel mode") mode.Truncation = fyne.TextTruncateEllipsis return container.NewVBox(name, mode) }, func(id widget.ListItemID, obj fyne.CanvasObject) { if id < 0 || id >= len(u.profiles) { return } p := u.profiles[id] box := obj.(*fyne.Container) name := box.Objects[0].(*widget.Label) mode := box.Objects[1].(*widget.Label) prefix := "" if p.ID == u.current.ID { prefix = "● " } name.SetText(prefix + p.Name) mode.SetText(modeLabel(p.Mode)) }, ) u.profileList.OnSelected = func(id widget.ListItemID) { if id < 0 || id >= len(u.profiles) { return } p, err := u.core.Store.Load(u.profiles[id].ID) if err != nil { dialog.ShowError(err, u.win) return } u.setProfile(p) } u.status = widget.NewLabel("Disconnected") u.status.TextStyle = fyne.TextStyle{Bold: true} u.subStatus = widget.NewLabel("No active tunnel") u.subStatus.Truncation = fyne.TextTruncateEllipsis u.connectBtn = widget.NewButtonWithIcon("Connect", theme.MediaPlayIcon(), u.toggleConnection) side := fixedWidth(315, sidebarPanel(u.sidebar())) center := minSize(860, 620, container.NewBorder(u.topBar(), nil, nil, nil, u.profileEditor())) u.win.SetContent(container.NewBorder(nil, nil, side, nil, center)) } func (u *UI) sidebar() fyne.CanvasObject { title := widget.NewLabel("SocksRevive PC") title.TextStyle = fyne.TextStyle{Bold: true} subtitle := widget.NewLabel("Offline profiles") subtitle.Importance = widget.LowImportance newBtn := widget.NewButtonWithIcon("New", theme.ContentAddIcon(), u.newProfile) deleteBtn := widget.NewButtonWithIcon("Delete", theme.DeleteIcon(), u.deleteProfile) importBtn := widget.NewButtonWithIcon("Import", theme.FolderOpenIcon(), u.importProfile) exportBtn := widget.NewButtonWithIcon("Export", theme.DocumentSaveIcon(), u.exportProfile) buttons := container.NewGridWithColumns(2, newBtn, deleteBtn, importBtn, exportBtn) emptyHint := widget.NewLabel("Create or import a .srpc profile to start.") emptyHint.Wrapping = fyne.TextWrapWord emptyHint.Importance = widget.LowImportance top := container.NewVBox(title, subtitle, buttons, widget.NewSeparator()) body := container.NewBorder(top, container.NewVBox(widget.NewSeparator(), emptyHint), nil, nil, u.profileList) return container.NewPadded(body) } func (u *UI) topBar() fyne.CanvasObject { brand := widget.NewLabel("Tunnel Control") brand.TextStyle = fyne.TextStyle{Bold: true} statusBlock := container.NewVBox(u.status, u.subStatus) actions := container.NewHBox( widget.NewButtonWithIcon("Save", theme.DocumentSaveIcon(), u.saveProfile), u.connectBtn, ) content := container.NewBorder(nil, nil, container.NewVBox(brand), actions, statusBlock) return softPanel(container.NewPadded(content)) } func (u *UI) profileEditor() fyne.CanvasObject { u.name = widget.NewEntry() u.name.SetPlaceHolder("My server") u.mode = widget.NewSelect(modeLabels(), func(_ string) { u.updateModePanel() }) u.sshHost = widget.NewEntry() u.sshHost.SetPlaceHolder("ssh.example.com") u.sshPort = widget.NewEntry() u.sshPort.SetPlaceHolder("22") u.sshUser = widget.NewEntry() u.sshUser.SetPlaceHolder("username") u.sshPass = widget.NewPasswordEntry() u.sshPass.SetPlaceHolder("password") u.keepAlive = widget.NewEntry() u.handshake = widget.NewEntry() u.proxyHost = widget.NewEntry() u.proxyHost.SetPlaceHolder("proxy1.com#proxy2.com#52.85.78.104") u.proxyPort = widget.NewEntry() u.proxyPort.SetPlaceHolder("80 or 443") u.payloadText = widget.NewMultiLineEntry() u.payloadText.SetMinRowsVisible(8) u.payloadText.Wrapping = fyne.TextWrapOff u.payloadText.SetPlaceHolder("CONNECT [host]:[port] HTTP/1.1[crlf]Host: [host][crlf][crlf]") u.payloadWait = widget.NewCheck("Wait for proxy response", nil) u.payloadAny = widget.NewCheck("Accept any HTTP status", nil) u.payloadReply = widget.NewEntry() u.payloadSplit = widget.NewEntry() u.tlsHost = widget.NewEntry() u.tlsHost.SetPlaceHolder("cdn.example.com") u.tlsPort = widget.NewEntry() u.tlsSNI = widget.NewEntry() u.tlsSNI.SetPlaceHolder("server name / SNI") u.tlsInsecure = widget.NewCheck("Allow insecure TLS / skip certificate verify", nil) u.dnsttEmbedded = widget.NewCheck("Use embedded DNSTT engine / no external EXE", nil) u.dnsttResolverType = widget.NewSelect([]string{"doh", "dot", "udp"}, nil) u.dnsttResolverAddr = widget.NewEntry() u.dnsttResolverAddr.SetPlaceHolder("DoH URL, DoT host:853, or UDP DNS host:53") u.dnsttDomain = widget.NewEntry() u.dnsttDomain.SetPlaceHolder("t.example.com") u.dnsttPublicKey = widget.NewEntry() u.dnsttPublicKey.SetPlaceHolder("server public key hex") u.dnsttUTLS = widget.NewEntry() u.dnsttExe = widget.NewEntry() u.dnsttArgs = widget.NewMultiLineEntry() u.dnsttArgs.SetMinRowsVisible(3) u.dnsttLocalHost = widget.NewEntry() u.dnsttLocalPort = widget.NewEntry() u.xrayExe = widget.NewEntry() u.xrayExe.SetPlaceHolder("tools/xray/xray.exe") u.xrayArgs = widget.NewEntry() u.xrayConfig = widget.NewEntry() u.xrayConfig.SetPlaceHolder("configs/xray.json") u.xraySocksHost = widget.NewEntry() u.xraySocksPort = widget.NewEntry() u.udpgwEnabled = widget.NewCheck("Enable UDPGW for real UDP in SSH modes", nil) u.udpgwProtocol = widget.NewSelect([]string{"badvpn", "legacy"}, nil) u.udpgwProtocol.SetSelected("badvpn") u.udpgwHost = widget.NewEntry() u.udpgwHost.SetPlaceHolder("127.0.0.1") u.udpgwPort = widget.NewEntry() u.udpgwPort.SetPlaceHolder("7400") u.localSocksHost = widget.NewEntry() u.localSocksPort = widget.NewEntry() u.reconnectEnabled = widget.NewCheck("Auto reconnect if the tunnel is lost", nil) u.reconnectDelay = widget.NewEntry() u.reconnectDelay.SetPlaceHolder("3") u.reconnectMax = widget.NewEntry() u.reconnectMax.SetPlaceHolder("0 = forever") u.reconnectCheck = widget.NewEntry() u.reconnectCheck.SetPlaceHolder("10") u.tunEnabled = widget.NewCheck("Enable full-device TUN mode", nil) u.tunDevice = widget.NewEntry() u.tunDevice.SetPlaceHolder("Windows: wintun | Linux: tun://socksrevive0") u.tunIface = widget.NewEntry() u.tunIface.SetPlaceHolder("Windows: wintun | Linux: socksrevive0") u.tunMTU = widget.NewEntry() u.tunCIDR = widget.NewEntry() u.tunCIDR.SetPlaceHolder("198.18.0.1/15") u.tunDNS = widget.NewEntry() u.tunDNS.SetPlaceHolder("1.1.1.1, 8.8.8.8") u.tunRoute = widget.NewCheck("Route all IPv4 traffic", nil) u.tunIPv6Enabled = widget.NewCheck("Enable IPv6 through tunnel", nil) u.tunIPv6CIDR = widget.NewEntry() u.tunIPv6CIDR.SetPlaceHolder("fd00:534f:434b::1/64") u.tunIPv6DNS = widget.NewEntry() u.tunIPv6DNS.SetPlaceHolder("2606:4700:4700::1111, 2001:4860:4860::8888") u.tunBlockIPv6Leak = widget.NewCheck("Block IPv6 leaks when IPv6 tunnel is off", nil) u.logView = widget.NewLabel("No logs yet.") u.logView.Wrapping = fyne.TextWrapWord u.logView.TextStyle = fyne.TextStyle{Monospace: true} u.logScroller = container.NewVScroll(u.logView) u.logScroller.SetMinSize(fyne.NewSize(760, 430)) u.modeContent = container.NewVBox() u.updateModePanel() return container.NewPadded(u.modeContent) } func (u *UI) updateModePanel() { if u.modeContent == nil || u.mode == nil { return } mode := modeValue(u.mode.Selected) tabs := container.NewAppTabs( tabPage("Main", u.mainSection()), ) switch mode { case config.ModeDirect: tabs.Append(tabPage("SSH", u.sshSection(true))) case config.ModePayload: tabs.Append(tabPage("SSH", u.sshSection(true))) tabs.Append(tabPage("Proxy", u.proxySection())) tabs.Append(tabPage("Payload", u.payloadSection())) case config.ModeSSL: tabs.Append(tabPage("SSH", u.sshSection(true))) tabs.Append(tabPage("SSL/SNI", u.tlsSection())) case config.ModePayloadSSL: tabs.Append(tabPage("SSH", u.sshSection(true))) tabs.Append(tabPage("SSL/SNI", u.tlsSection())) tabs.Append(tabPage("Payload", u.payloadSection())) case config.ModeDNSTT: tabs.Append(tabPage("DNSTT", u.dnsttSection())) tabs.Append(tabPage("SSH", u.sshSection(false))) case config.ModeXray: tabs.Append(tabPage("Xray", u.xraySection())) default: tabs.Append(tabPage("SSH", u.sshSection(true))) } if mode != config.ModeXray { tabs.Append(tabPage("UDPGW", u.udpgwSection())) } tabs.Append(tabPage("TUN", u.tunSection())) tabs.Append(tabPage("Logs", u.logsSection())) tabs.SetTabLocation(container.TabLocationTop) u.modeContent.Objects = []fyne.CanvasObject{tabs} u.modeContent.Refresh() } func (u *UI) mainSection() fyne.CanvasObject { return section("Profile", "Choose the tunnel mode first. Only the tabs needed for that mode are shown.", widget.NewForm( widget.NewFormItem("Profile name", u.name), widget.NewFormItem("Tunnel mode", u.mode), widget.NewFormItem("Local SOCKS host", u.localSocksHost), widget.NewFormItem("Local SOCKS port", u.localSocksPort), widget.NewFormItem("Reconnect", u.reconnectEnabled), widget.NewFormItem("Reconnect delay seconds", u.reconnectDelay), widget.NewFormItem("Reconnect max retries", u.reconnectMax), widget.NewFormItem("Reconnect check seconds", u.reconnectCheck), )) } func (u *UI) sshSection(includeHost bool) fyne.CanvasObject { items := []*widget.FormItem{} if includeHost { items = append(items, widget.NewFormItem("SSH host", u.sshHost), widget.NewFormItem("SSH port", u.sshPort), ) } items = append(items, widget.NewFormItem("Username", u.sshUser), widget.NewFormItem("Password", u.sshPass), widget.NewFormItem("Keep alive seconds", u.keepAlive), widget.NewFormItem("Handshake timeout ms", u.handshake), ) subtitle := "Credentials used after the transport is ready." if includeHost { subtitle = "SSH server and login credentials." } return section("SSH", subtitle, widget.NewForm(items...)) } func (u *UI) proxySection() fyne.CanvasObject { return section("Optional proxy / rotate", "Leave empty to connect directly. For rotation, separate hosts with #, comma, semicolon, or new lines. The app tries each proxy until payload + SSH succeeds.", widget.NewForm( widget.NewFormItem("Proxy host(s)", u.proxyHost), widget.NewFormItem("Proxy port", u.proxyPort), )) } func (u *UI) payloadSection() fyne.CanvasObject { settings := widget.NewForm( widget.NewFormItem("Response timeout ms", u.payloadReply), widget.NewFormItem("Split delay ms", u.payloadSplit), ) content := container.NewVBox(u.payloadText, u.payloadWait, u.payloadAny, settings) return section("HTTP payload", "Supports [host], [port], [crlf], [lf], [rotate=a;b;c] or [rotate=a#b#c], [split] and [instant_split].", content) } func (u *UI) tlsSection() fyne.CanvasObject { return section("SSL/SNI", "TLS front host and SNI used before SSH or payload injection.", widget.NewForm( widget.NewFormItem("TLS/front host", u.tlsHost), widget.NewFormItem("TLS port", u.tlsPort), widget.NewFormItem("SNI/server name", u.tlsSNI), widget.NewFormItem("Security", u.tlsInsecure), )) } func (u *UI) dnsttSection() fyne.CanvasObject { resolver := widget.NewForm( widget.NewFormItem("Embedded engine", u.dnsttEmbedded), widget.NewFormItem("Resolver type", u.dnsttResolverType), widget.NewFormItem("Resolver", u.dnsttResolverAddr), widget.NewFormItem("Tunnel domain", u.dnsttDomain), widget.NewFormItem("Server public key", u.dnsttPublicKey), widget.NewFormItem("uTLS profile", u.dnsttUTLS), widget.NewFormItem("Local SSH host", u.dnsttLocalHost), widget.NewFormItem("Local SSH port", u.dnsttLocalPort), ) fallback := widget.NewAccordion(widget.NewAccordionItem("External DNSTT fallback", widget.NewForm( widget.NewFormItem("Executable", u.dnsttExe), widget.NewFormItem("Arguments", u.dnsttArgs), ))) return section("DNSTT", "Embedded client is used by default. External executable is only for legacy fallback.", container.NewVBox(resolver, fallback)) } func (u *UI) xraySection() fyne.CanvasObject { return section("Xray", "Place xray.exe in tools/xray or browse to your own executable path.", widget.NewForm( widget.NewFormItem("Executable", u.xrayExe), widget.NewFormItem("Arguments", u.xrayArgs), widget.NewFormItem("Config file", u.xrayConfig), widget.NewFormItem("Xray SOCKS host", u.xraySocksHost), widget.NewFormItem("Xray SOCKS port", u.xraySocksPort), )) } func (u *UI) udpgwSection() fyne.CanvasObject { return section("UDPGW / UDP support", "Optional UDP gateway for SSH-based modes. Use badvpn for Android-compatible badvpn-udpgw servers and IPv6 UDP. Use legacy only for the old experimental PC frame.", widget.NewForm( widget.NewFormItem("Enabled", u.udpgwEnabled), widget.NewFormItem("Protocol", u.udpgwProtocol), widget.NewFormItem("Remote UDPGW host", u.udpgwHost), widget.NewFormItem("Remote UDPGW port", u.udpgwPort), )) } func (u *UI) tunSection() fyne.CanvasObject { return section("TUN / full device mode", "Optional OpenVPN-style routing through the active SOCKS tunnel. IPv6 is optional; enable it only when your server/tunnel has IPv6 egress. Keep leak blocking on if you do not want Windows to expose IPv6 outside the VPN.", widget.NewForm( widget.NewFormItem("Enabled", u.tunEnabled), widget.NewFormItem("Device", u.tunDevice), widget.NewFormItem("Interface name", u.tunIface), widget.NewFormItem("MTU", u.tunMTU), widget.NewFormItem("IPv4 CIDR", u.tunCIDR), widget.NewFormItem("IPv4 DNS", u.tunDNS), widget.NewFormItem("IPv4 routing", u.tunRoute), widget.NewFormItem("IPv6 support", u.tunIPv6Enabled), widget.NewFormItem("IPv6 CIDR", u.tunIPv6CIDR), widget.NewFormItem("IPv6 DNS", u.tunIPv6DNS), widget.NewFormItem("IPv6 leak protection", u.tunBlockIPv6Leak), )) } func (u *UI) logsSection() fyne.CanvasObject { return section("Logs", "Runtime messages from SSH, DNSTT, Xray, TUN and routing.", u.logScroller) } func (u *UI) newProfile() { p := config.Profile{ Name: "New Profile", Mode: config.ModeDirect, Payload: config.PayloadConfig{Text: "CONNECT [host]:[port] HTTP/1.1[crlf]Host: [host][crlf]User-Agent: SocksRevivePC[crlf][crlf]", WaitForResponse: true, AcceptAnyStatus: true}, TLS: config.TLSConfig{Port: 443}, DNSTT: config.DNSTTConfig{UseEmbedded: true, ResolverType: "doh"}, UDPGW: config.UDPGWConfig{Host: "127.0.0.1", Port: 7400, Protocol: "badvpn"}, Reconnect: config.ReconnectConfig{Enabled: true, DelaySeconds: 3, MaxRetries: 0, CheckIntervalSeconds: 10}, Tun: config.TunConfig{RouteAll: true}, } config.ApplyDefaults(&p) u.setProfile(p) } func (u *UI) loadProfiles() { profiles, err := u.core.Store.List() if err != nil { dialog.ShowError(err, u.win) return } u.profiles = profiles u.profileList.Refresh() if u.current.ID == "" && len(profiles) > 0 { p, err := u.core.Store.Load(profiles[0].ID) if err == nil { u.setProfile(p) } } if len(profiles) == 0 { u.newProfile() } } func (u *UI) setProfile(p config.Profile) { config.ApplyDefaults(&p) u.current = p u.name.SetText(p.Name) u.mode.SetSelected(modeLabel(p.Mode)) u.sshHost.SetText(p.SSH.Host) u.sshPort.SetText(itoa(p.SSH.Port)) u.sshUser.SetText(p.SSH.Username) u.sshPass.SetText(p.SSH.Password) u.keepAlive.SetText(itoa(p.SSH.KeepAliveSeconds)) u.handshake.SetText(itoa(p.SSH.HandshakeTimeoutMs)) u.proxyHost.SetText(p.Proxy.Host) u.proxyPort.SetText(itoa(p.Proxy.Port)) u.payloadText.SetText(p.Payload.Text) u.payloadWait.SetChecked(p.Payload.WaitForResponse) u.payloadAny.SetChecked(p.Payload.AcceptAnyStatus) u.payloadReply.SetText(itoa(p.Payload.ResponseTimeoutMs)) u.payloadSplit.SetText(itoa(p.Payload.SplitDelayMs)) u.tlsHost.SetText(p.TLS.Host) u.tlsPort.SetText(itoa(p.TLS.Port)) u.tlsSNI.SetText(p.TLS.ServerName) u.tlsInsecure.SetChecked(p.TLS.InsecureSkipVerify) u.dnsttEmbedded.SetChecked(p.DNSTT.UseEmbedded) u.dnsttResolverType.SetSelected(p.DNSTT.ResolverType) u.dnsttResolverAddr.SetText(p.DNSTT.ResolverAddress) u.dnsttDomain.SetText(p.DNSTT.Domain) u.dnsttPublicKey.SetText(p.DNSTT.PublicKey) u.dnsttUTLS.SetText(p.DNSTT.UTLSDistribution) u.dnsttExe.SetText(p.DNSTT.Executable) u.dnsttArgs.SetText(strings.Join(p.DNSTT.Args, " ")) u.dnsttLocalHost.SetText(p.DNSTT.LocalSSHHost) u.dnsttLocalPort.SetText(itoa(p.DNSTT.LocalSSHPort)) u.xrayExe.SetText(p.Xray.Executable) u.xrayArgs.SetText(strings.Join(p.Xray.Args, " ")) u.xrayConfig.SetText(p.Xray.ConfigPath) u.xraySocksHost.SetText(p.Xray.LocalSocksHost) u.xraySocksPort.SetText(itoa(p.Xray.LocalSocksPort)) u.udpgwEnabled.SetChecked(p.UDPGW.Enabled) u.udpgwProtocol.SetSelected(p.UDPGW.Protocol) u.udpgwHost.SetText(p.UDPGW.Host) u.udpgwPort.SetText(itoa(p.UDPGW.Port)) u.localSocksHost.SetText(p.Local.SocksHost) u.localSocksPort.SetText(itoa(p.Local.SocksPort)) u.reconnectEnabled.SetChecked(p.Reconnect.Enabled) u.reconnectDelay.SetText(itoa(p.Reconnect.DelaySeconds)) u.reconnectMax.SetText(itoa(p.Reconnect.MaxRetries)) u.reconnectCheck.SetText(itoa(p.Reconnect.CheckIntervalSeconds)) u.tunEnabled.SetChecked(p.Tun.Enabled) u.tunDevice.SetText(p.Tun.Device) u.tunIface.SetText(p.Tun.InterfaceName) u.tunMTU.SetText(itoa(p.Tun.MTU)) u.tunCIDR.SetText(p.Tun.CIDR) u.tunDNS.SetText(strings.Join(p.Tun.DNS, ", ")) u.tunRoute.SetChecked(p.Tun.RouteAll) u.tunIPv6Enabled.SetChecked(p.Tun.IPv6Enabled) u.tunIPv6CIDR.SetText(p.Tun.IPv6CIDR) u.tunIPv6DNS.SetText(strings.Join(p.Tun.IPv6DNS, ", ")) u.tunBlockIPv6Leak.SetChecked(!p.Tun.AllowIPv6Leak) u.updateModePanel() u.profileList.Refresh() } func (u *UI) readProfileFromForm() (config.Profile, error) { p := u.current p.Name = strings.TrimSpace(u.name.Text) p.Mode = modeValue(u.mode.Selected) p.SSH.Host = strings.TrimSpace(u.sshHost.Text) p.SSH.Port = mustInt(u.sshPort.Text, 22) p.SSH.Username = strings.TrimSpace(u.sshUser.Text) p.SSH.Password = u.sshPass.Text p.SSH.KeepAliveSeconds = mustInt(u.keepAlive.Text, 20) p.SSH.HandshakeTimeoutMs = mustInt(u.handshake.Text, 15000) p.Proxy.Host = strings.TrimSpace(u.proxyHost.Text) p.Proxy.Port = mustInt(u.proxyPort.Text, 0) p.Payload.Text = u.payloadText.Text p.Payload.WaitForResponse = u.payloadWait.Checked p.Payload.AcceptAnyStatus = u.payloadAny.Checked p.Payload.ResponseTimeoutMs = mustInt(u.payloadReply.Text, 8000) p.Payload.SplitDelayMs = mustInt(u.payloadSplit.Text, 120) p.TLS.Enabled = p.Mode == config.ModeSSL || p.Mode == config.ModePayloadSSL || u.tlsHost.Text != "" || u.tlsSNI.Text != "" p.TLS.Host = strings.TrimSpace(u.tlsHost.Text) p.TLS.Port = mustInt(u.tlsPort.Text, 443) p.TLS.ServerName = strings.TrimSpace(u.tlsSNI.Text) p.TLS.InsecureSkipVerify = u.tlsInsecure.Checked p.DNSTT.Enabled = p.Mode == config.ModeDNSTT p.DNSTT.UseEmbedded = u.dnsttEmbedded.Checked p.DNSTT.ResolverType = strings.TrimSpace(u.dnsttResolverType.Selected) p.DNSTT.ResolverAddress = strings.TrimSpace(u.dnsttResolverAddr.Text) p.DNSTT.Domain = strings.TrimSpace(u.dnsttDomain.Text) p.DNSTT.PublicKey = strings.TrimSpace(u.dnsttPublicKey.Text) p.DNSTT.UTLSDistribution = strings.TrimSpace(u.dnsttUTLS.Text) p.DNSTT.Executable = strings.TrimSpace(u.dnsttExe.Text) p.DNSTT.Args = splitArgs(u.dnsttArgs.Text) p.DNSTT.LocalSSHHost = strings.TrimSpace(u.dnsttLocalHost.Text) p.DNSTT.LocalSSHPort = mustInt(u.dnsttLocalPort.Text, 2222) p.Xray.Executable = strings.TrimSpace(u.xrayExe.Text) p.Xray.Args = splitArgs(u.xrayArgs.Text) p.Xray.ConfigPath = strings.TrimSpace(u.xrayConfig.Text) p.Xray.LocalSocksHost = strings.TrimSpace(u.xraySocksHost.Text) p.Xray.LocalSocksPort = mustInt(u.xraySocksPort.Text, 10808) p.UDPGW.Enabled = u.udpgwEnabled.Checked p.UDPGW.Protocol = strings.TrimSpace(u.udpgwProtocol.Selected) p.UDPGW.Host = strings.TrimSpace(u.udpgwHost.Text) p.UDPGW.Port = mustInt(u.udpgwPort.Text, 7400) p.Local.SocksHost = strings.TrimSpace(u.localSocksHost.Text) p.Local.SocksPort = mustInt(u.localSocksPort.Text, 10809) p.Reconnect.Enabled = u.reconnectEnabled.Checked p.Reconnect.DelaySeconds = mustInt(u.reconnectDelay.Text, 3) p.Reconnect.MaxRetries = mustInt(u.reconnectMax.Text, 0) p.Reconnect.CheckIntervalSeconds = mustInt(u.reconnectCheck.Text, 10) p.Tun.Enabled = u.tunEnabled.Checked p.Tun.Device = strings.TrimSpace(u.tunDevice.Text) p.Tun.InterfaceName = strings.TrimSpace(u.tunIface.Text) p.Tun.MTU = mustInt(u.tunMTU.Text, 1500) p.Tun.CIDR = strings.TrimSpace(u.tunCIDR.Text) p.Tun.DNS = splitCSV(u.tunDNS.Text) p.Tun.RouteAll = u.tunRoute.Checked p.Tun.IPv6Enabled = u.tunIPv6Enabled.Checked p.Tun.IPv6CIDR = strings.TrimSpace(u.tunIPv6CIDR.Text) p.Tun.IPv6DNS = splitCSV(u.tunIPv6DNS.Text) p.Tun.AllowIPv6Leak = !u.tunBlockIPv6Leak.Checked config.ApplyDefaults(&p) return p, config.Validate(p) } func (u *UI) saveProfile() { p, err := u.readProfileFromForm() if err != nil { dialog.ShowError(err, u.win) return } saved, err := u.core.Store.Save(p) if err != nil { dialog.ShowError(err, u.win) return } u.current = saved u.loadProfiles() u.setProfile(saved) dialog.ShowInformation("Saved", "Profile saved as .srpc", u.win) } func (u *UI) deleteProfile() { if u.current.ID == "" { return } dialog.ShowConfirm("Delete profile", "Delete this offline profile?", func(ok bool) { if !ok { return } if err := u.core.Store.Delete(u.current.ID); err != nil { dialog.ShowError(err, u.win) return } u.current = config.Profile{} u.loadProfiles() }, u.win) } func (u *UI) toggleConnection() { s := u.core.Engine.Status() if s.Running || s.Connecting { u.disconnect() return } u.connect() } func (u *UI) connect() { p, err := u.readProfileFromForm() if err != nil { dialog.ShowError(err, u.win) return } saved, err := u.core.Store.Save(p) if err != nil { dialog.ShowError(err, u.win) return } u.current = saved u.loadProfiles() u.setProfile(saved) u.status.SetText("Connecting...") u.subStatus.SetText("Starting tunnel. Logs will keep updating while proxy rotation is tested.") u.connectBtn.SetText("Cancel") u.connectBtn.SetIcon(theme.MediaStopIcon()) crash.Go(u.core.Root, func() { err := u.core.Engine.Start(saved) fyne.Do(func() { if err != nil && !errors.Is(err, context.Canceled) { dialog.ShowError(err, u.win) } u.refreshStatus() }) }) } func (u *UI) disconnect() { u.status.SetText("Disconnecting...") u.subStatus.SetText("Stopping tunnel and restoring routes.") u.connectBtn.Disable() crash.Go(u.core.Root, func() { u.core.Engine.Stop() fyne.Do(func() { u.connectBtn.Enable() u.refreshStatus() }) }) } func (u *UI) importProfile() { fd := dialog.NewFileOpen(func(r fyne.URIReadCloser, err error) { if err != nil { dialog.ShowError(err, u.win) return } if r == nil { return } defer r.Close() b, err := io.ReadAll(r) if err != nil { dialog.ShowError(err, u.win) return } p, err := config.DecodeProfileFile(b) if err != nil { dialog.ShowError(err, u.win) return } p.ID = "" saved, err := u.core.Store.Save(p) if err != nil { dialog.ShowError(err, u.win) return } u.loadProfiles() u.setProfile(saved) }, u.win) fd.SetFilter(storage.NewExtensionFileFilter([]string{config.ProfileExtension})) fd.Show() } func (u *UI) exportProfile() { p, err := u.readProfileFromForm() if err != nil { dialog.ShowError(err, u.win) return } p, err = u.core.Store.Save(p) if err != nil { dialog.ShowError(err, u.win) return } data, err := config.EncodeProfileFile(p) if err != nil { dialog.ShowError(err, u.win) return } fd := dialog.NewFileSave(func(w fyne.URIWriteCloser, err error) { if err != nil { dialog.ShowError(err, u.win) return } if w == nil { return } defer w.Close() if _, err := w.Write(data); err != nil { dialog.ShowError(err, u.win) return } }, u.win) fd.SetFileName(safeFileName(p.Name) + config.ProfileExtension) fd.SetFilter(storage.NewExtensionFileFilter([]string{config.ProfileExtension})) fd.Show() } func (u *UI) refreshStatus() { s := u.core.Engine.Status() if s.Running { u.status.SetText("Connected") u.subStatus.SetText(fmt.Sprintf("Mode: %s | SOCKS: %s | TUN: %v", s.Mode, s.SocksAddr, s.Tun)) if u.connectBtn != nil { u.connectBtn.Enable() u.connectBtn.SetText("Disconnect") u.connectBtn.SetIcon(theme.MediaStopIcon()) } } else if s.Connecting { u.status.SetText("Connecting...") if s.SocksAddr != "" { u.subStatus.SetText(fmt.Sprintf("Mode: %s | SOCKS starting: %s | TUN: %v", s.Mode, s.SocksAddr, s.Tun)) } else { u.subStatus.SetText(fmt.Sprintf("Mode: %s | trying connection attempts...", s.Mode)) } if u.connectBtn != nil { u.connectBtn.Enable() u.connectBtn.SetText("Cancel") u.connectBtn.SetIcon(theme.MediaStopIcon()) } } else { u.status.SetText("Disconnected") u.subStatus.SetText("No active tunnel") if u.connectBtn != nil { u.connectBtn.Enable() u.connectBtn.SetText("Connect") u.connectBtn.SetIcon(theme.MediaPlayIcon()) } } } func (u *UI) startLogPoller() { crash.Go(u.core.Root, func() { ticker := time.NewTicker(900 * time.Millisecond) defer ticker.Stop() for range ticker.C { logs := u.core.Engine.LogsSince(u.lastLog) if len(logs) == 0 { fyne.Do(u.refreshStatus) continue } var b strings.Builder old := u.logText if old != "" { b.WriteString(old) if !strings.HasSuffix(old, "\n") { b.WriteByte('\n') } } for _, l := range logs { u.lastLog = l.ID b.WriteString("[") b.WriteString(l.Time) b.WriteString("] ") b.WriteString(strings.ToUpper(l.Level)) b.WriteString(" ") b.WriteString(l.Message) b.WriteByte('\n') } text := b.String() if len(text) > 30000 { text = text[len(text)-30000:] } u.logText = text fyne.Do(func() { u.logView.SetText(text) if u.logScroller != nil { u.logScroller.ScrollToBottom() } u.refreshStatus() }) } }) } func sidebarPanel(obj fyne.CanvasObject) fyne.CanvasObject { bg := canvas.NewRectangle(color.NRGBA{R: 16, G: 17, B: 21, A: 255}) return container.NewStack(bg, obj) } func softPanel(obj fyne.CanvasObject) fyne.CanvasObject { bg := canvas.NewRectangle(color.NRGBA{R: 24, G: 25, B: 31, A: 255}) return container.NewStack(bg, obj) } func section(title, subtitle string, content fyne.CanvasObject) fyne.CanvasObject { titleLabel := widget.NewLabel(title) titleLabel.TextStyle = fyne.TextStyle{Bold: true} items := []fyne.CanvasObject{titleLabel} if strings.TrimSpace(subtitle) != "" { sub := widget.NewLabel(subtitle) sub.Wrapping = fyne.TextWrapWord sub.Importance = widget.LowImportance items = append(items, sub) } items = append(items, widget.NewSeparator(), content) return softPanel(container.NewPadded(container.NewVBox(items...))) } func helpCard(title, text string) fyne.CanvasObject { label := widget.NewLabel(text) label.Wrapping = fyne.TextWrapWord label.Importance = widget.LowImportance return section(title, "", label) } func tabPage(title string, content fyne.CanvasObject) *container.TabItem { scroll := container.NewVScroll(container.NewPadded(content)) scroll.SetMinSize(fyne.NewSize(820, 560)) return container.NewTabItem(title, scroll) } type fixedWidthLayout struct { width float32 } func (l *fixedWidthLayout) Layout(objects []fyne.CanvasObject, size fyne.Size) { for _, obj := range objects { obj.Move(fyne.NewPos(0, 0)) obj.Resize(fyne.NewSize(l.width, size.Height)) } } func (l *fixedWidthLayout) MinSize(objects []fyne.CanvasObject) fyne.Size { height := float32(0) for _, obj := range objects { min := obj.MinSize() if min.Height > height { height = min.Height } } return fyne.NewSize(l.width, height) } func fixedWidth(width float32, obj fyne.CanvasObject) fyne.CanvasObject { return container.New(&fixedWidthLayout{width: width}, obj) } type minSizeLayout struct { width float32 height float32 } func (l *minSizeLayout) Layout(objects []fyne.CanvasObject, size fyne.Size) { for _, obj := range objects { obj.Move(fyne.NewPos(0, 0)) obj.Resize(size) } } func (l *minSizeLayout) MinSize(objects []fyne.CanvasObject) fyne.Size { width := l.width height := l.height for _, obj := range objects { min := obj.MinSize() if min.Width > width { width = min.Width } if min.Height > height { height = min.Height } } return fyne.NewSize(width, height) } func minSize(width, height float32, obj fyne.CanvasObject) fyne.CanvasObject { return container.New(&minSizeLayout{width: width, height: height}, obj) } func modeLabels() []string { return []string{"Direct SSH", "Payload SSH", "SSL/TLS SSH", "Payload + SSL", "DNSTT + SSH", "Xray Core"} } func modeLabel(m config.Mode) string { switch m { case config.ModePayload: return "Payload SSH" case config.ModeSSL: return "SSL/TLS SSH" case config.ModePayloadSSL: return "Payload + SSL" case config.ModeDNSTT: return "DNSTT + SSH" case config.ModeXray: return "Xray Core" default: return "Direct SSH" } } func modeValue(label string) config.Mode { switch label { case "Payload SSH": return config.ModePayload case "SSL/TLS SSH": return config.ModeSSL case "Payload + SSL": return config.ModePayloadSSL case "DNSTT + SSH": return config.ModeDNSTT case "Xray Core": return config.ModeXray default: return config.ModeDirect } } func itoa(v int) string { if v == 0 { return "" } return strconv.Itoa(v) } func mustInt(s string, def int) int { s = strings.TrimSpace(s) if s == "" { return def } v, err := strconv.Atoi(s) if err != nil { return def } return v } func splitArgs(s string) []string { return strings.Fields(strings.TrimSpace(s)) } func splitCSV(s string) []string { parts := strings.FieldsFunc(s, func(r rune) bool { return r == ',' || r == '\n' || r == ';' }) out := make([]string, 0, len(parts)) for _, p := range parts { p = strings.TrimSpace(p) if p != "" { out = append(out, p) } } return out } func safeFileName(s string) string { s = strings.TrimSpace(strings.ToLower(s)) if s == "" { return "profile" } replacer := strings.NewReplacer("/", "-", "\\", "-", ":", "-", "*", "-", "?", "-", "\"", "-", "<", "-", ">", "-", "|", "-") s = replacer.Replace(s) s = strings.ReplaceAll(s, " ", "-") return s }