Files
navi/internal/config/profiles.go
T
M4andCursor 2ad376b49e
ci / test (macos-latest) (push) Waiting to run
ci / test (ubuntu-latest) (push) Waiting to run
ci / test (windows-latest) (push) Waiting to run
Release 3.8.2.5: cheaper idle UI poll and tighter hot paths.
Delta getState by rev, pause when hidden, cancellable background loops,
hostCache bounds, unlock PollUI around engine status, corebin singleflight,
PingAll worker pool.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 16:13:59 +03:00

277 lines
6.7 KiB
Go

package config
import (
"fmt"
"strings"
"sync"
)
// ProfileInfo is a safe summary for UI lists (no secrets beyond proxy URI the user already owns).
type ProfileInfo struct {
Name string `json:"name"`
Protocol string `json:"protocol"`
Proxy string `json:"proxy"`
Host string `json:"host"`
Active bool `json:"active"`
}
// hostCache avoids re-parsing large AWG conf bodies on every UI poll.
var hostCache sync.Map // proxy URI/conf → host string
const hostCacheMax = 2048
func cachedProxyHost(proxy string) string {
proxy = strings.TrimSpace(proxy)
if proxy == "" {
return ""
}
if v, ok := hostCache.Load(proxy); ok {
return v.(string)
}
h := proxyHost(proxy)
hostCache.Store(proxy, h)
// Soft bound: subscription churn can retain stale URI keys; drop all if oversized.
n := 0
over := false
hostCache.Range(func(_, _ any) bool {
n++
if n > hostCacheMax {
over = true
return false
}
return true
})
if over {
InvalidateHostCache()
hostCache.Store(proxy, h)
}
return h
}
// InvalidateHostCache clears parsed host strings (call after profile mutations).
func InvalidateHostCache() {
hostCache.Range(func(k, _ any) bool {
hostCache.Delete(k)
return true
})
}
// ListProfiles returns UI-friendly profile summaries.
func (c *Config) ListProfiles() []ProfileInfo {
out := make([]ProfileInfo, 0, len(c.Profiles))
for _, p := range c.Profiles {
out = append(out, ProfileInfo{
Name: p.Name,
Protocol: string(p.Protocol),
Proxy: p.Proxy,
Host: cachedProxyHost(p.Proxy),
Active: p.Name == c.Active,
})
}
return out
}
// ListProfilesForPoll is like ListProfiles but omits Proxy URIs (cheaper idle polls).
func (c *Config) ListProfilesForPoll() []ProfileInfo {
out := make([]ProfileInfo, 0, len(c.Profiles))
for _, p := range c.Profiles {
out = append(out, ProfileInfo{
Name: p.Name,
Protocol: string(p.Protocol),
Host: cachedProxyHost(p.Proxy),
Active: p.Name == c.Active,
})
}
return out
}
// RedactProfileList clears Proxy URIs from a profile list (host/protocol remain).
// Use for periodic UI polls so credentials are not echoed for every profile.
func RedactProfileList(in []ProfileInfo) []ProfileInfo {
out := make([]ProfileInfo, len(in))
for i, p := range in {
out[i] = p
out[i].Proxy = ""
}
return out
}
func RedactProxyURI(proxy string) string {
proxy = strings.TrimSpace(proxy)
if proxy == "" {
return ""
}
lower := strings.ToLower(proxy)
if strings.Contains(lower, "[interface]") || strings.Contains(lower, "privatekey") {
return "[конфиг скрыт — откройте редактор профиля]"
}
if i := strings.Index(proxy, "://"); i >= 0 {
scheme := proxy[:i+3]
rest := proxy[i+3:]
if at := strings.LastIndex(rest, "@"); at >= 0 {
return scheme + "***@" + rest[at+1:]
}
return scheme + rest
}
if at := strings.LastIndex(proxy, "@"); at >= 0 {
return "***@" + proxy[at+1:]
}
return proxyHost(proxy)
}
func proxyHost(proxy string) string {
proxy = strings.TrimSpace(proxy)
if proxy == "" {
return ""
}
lower := strings.ToLower(proxy)
if strings.Contains(lower, "[interface]") {
for _, line := range strings.Split(proxy, "\n") {
line = strings.TrimSpace(line)
if len(line) >= 9 && strings.EqualFold(line[:8], "endpoint") {
_, val, ok := strings.Cut(line, "=")
if !ok {
_, val, ok = strings.Cut(line, ":")
}
if ok {
return strings.TrimSpace(val)
}
}
}
}
if i := strings.Index(proxy, "://"); i >= 0 {
proxy = proxy[i+3:]
}
if at := strings.LastIndex(proxy, "@"); at >= 0 {
proxy = proxy[at+1:]
}
if i := strings.IndexAny(proxy, "/?#"); i >= 0 {
proxy = proxy[:i]
}
return proxy
}
// SetActive switches the active profile name.
func (c *Config) SetActive(name string) error {
name = strings.TrimSpace(name)
for _, p := range c.Profiles {
if p.Name == name {
c.Active = name
return nil
}
}
return fmt.Errorf("profile %q not found", name)
}
// UpsertProfile creates or updates a profile by name.
func (c *Config) UpsertProfile(name, proxy string) error {
return c.UpsertProfileWithProtocol(name, proxy, "")
}
// UpsertProfileWithProtocol creates/updates a profile and optionally sets protocol.
// The upserted profile becomes Active.
func (c *Config) UpsertProfileWithProtocol(name, proxy string, proto Protocol) error {
return c.upsertProfile(name, proxy, proto, true)
}
// UpsertProfileKeepActive creates/updates a profile without changing Active.
func (c *Config) UpsertProfileKeepActive(name, proxy string, proto Protocol) error {
return c.upsertProfile(name, proxy, proto, false)
}
func (c *Config) upsertProfile(name, proxy string, proto Protocol, activate bool) error {
name = strings.TrimSpace(name)
if name == "" {
return fmt.Errorf("имя профиля пустое")
}
proxy = strings.TrimSpace(proxy)
if proto == "" {
proto = DetectProtocol(proxy)
}
if proto == "" {
proto = ProtocolNaive
}
for i := range c.Profiles {
if c.Profiles[i].Name == name {
c.Profiles[i].Proxy = proxy
c.Profiles[i].Protocol = proto
if len(c.Profiles[i].Listen) == 0 {
c.Profiles[i].Listen = defaultListen()
}
if activate {
c.Active = name
}
return nil
}
}
c.Profiles = append(c.Profiles, Profile{
Name: name,
Protocol: proto,
Proxy: proxy,
Listen: defaultListen(),
})
if activate {
c.Active = name
}
return nil
}
// RenameProfile renames a profile and keeps active pointer in sync.
func (c *Config) RenameProfile(oldName, newName string) error {
oldName = strings.TrimSpace(oldName)
newName = strings.TrimSpace(newName)
if oldName == "" || newName == "" {
return fmt.Errorf("имя профиля пустое")
}
if oldName == newName {
return nil
}
for _, p := range c.Profiles {
if p.Name == newName {
return fmt.Errorf("профиль %q уже существует", newName)
}
}
for i := range c.Profiles {
if c.Profiles[i].Name == oldName {
c.Profiles[i].Name = newName
if c.Active == oldName {
c.Active = newName
}
return nil
}
}
return fmt.Errorf("profile %q not found", oldName)
}
// DeleteProfile removes a profile. Keeps at least one profile.
func (c *Config) DeleteProfile(name string) error {
name = strings.TrimSpace(name)
if len(c.Profiles) <= 1 {
return fmt.Errorf("нельзя удалить последний профиль")
}
idx := -1
for i, p := range c.Profiles {
if p.Name == name {
idx = i
break
}
}
if idx < 0 {
return fmt.Errorf("profile %q not found", name)
}
c.Profiles = append(c.Profiles[:idx], c.Profiles[idx+1:]...)
if c.Active == name {
c.Active = c.Profiles[0].Name
}
return nil
}
func defaultListen() []string {
return []string{
"socks://127.0.0.1:1080",
"http://127.0.0.1:1081",
}
}