From 4dbfc73e5a9358538163b3f037a85578d25df3f9 Mon Sep 17 00:00:00 2001 From: luynrs <157303229+luynrs@users.noreply.github.com> Date: Sat, 5 Sep 2026 14:15:00 +0300 Subject: [PATCH 1/3] refactor: simplify probe streaming, dns lookup and fix tui spinner animation --- flake.nix | 4 +- internal/client/tui/actions.go | 23 +++---- internal/client/tui/model.go | 3 - internal/client/tui/tree/nodes.go | 9 +-- internal/client/tui/tree/tree.go | 2 - internal/client/tui/update.go | 20 +----- internal/daemon/core/core.go | 80 ++++++++++++++++++----- internal/domain/settings.go | 2 +- internal/engine/build.go | 58 ++++++++--------- internal/engine/lookup.go | 101 +++++++++--------------------- internal/engine/probe.go | 41 +++++------- internal/ipc/types.go | 7 ++- 12 files changed, 161 insertions(+), 189 deletions(-) diff --git a/flake.nix b/flake.nix index 0279c21..de513ec 100644 --- a/flake.nix +++ b/flake.nix @@ -113,7 +113,7 @@ config = lib.mkIf cfg.enable { environment.systemPackages = [ cfg.package ]; - security.wrappers.justrayd = lib.mkIf pkgs.stdenv.isLinux { + security.wrappers.justrayd = lib.mkIf pkgs.stdenv.hostPlatform.isLinux { source = "${cfg.package}/bin/justrayd"; capabilities = "cap_net_admin+ep"; owner = "root"; @@ -148,7 +148,7 @@ config = lib.mkIf cfg.enable { home.packages = [ cfg.package ]; - systemd.user.services.justrayd = lib.mkIf pkgs.stdenv.isLinux { + systemd.user.services.justrayd = lib.mkIf pkgs.stdenv.hostPlatform.isLinux { Unit = { After = [ "network-online.target" ]; Wants = [ "network-online.target" ]; diff --git a/internal/client/tui/actions.go b/internal/client/tui/actions.go index 69afcd8..7a521b8 100644 --- a/internal/client/tui/actions.go +++ b/internal/client/tui/actions.go @@ -6,7 +6,6 @@ import ( tea "charm.land/bubbletea/v2" "github.com/luynrs/justray/internal/client/tui/tree" - "github.com/luynrs/justray/internal/domain" "github.com/luynrs/justray/internal/ipc" ) @@ -30,7 +29,7 @@ func (m Model) activate() (tea.Model, tea.Cmd) { ref := r.Node.Ref() act = func() (ipc.Snapshot, error) { return m.client.Connect(ref) } } - return m, tea.Batch(m.spin.Tick, snapshotCmd("connect", act)) + return m, snapshotCmd("connect", act) } func (m Model) collapse() (tea.Model, tea.Cmd) { @@ -69,27 +68,19 @@ func (m Model) probe() (tea.Model, tea.Cmd) { if !ok { return m, nil } - m.probing = map[domain.NodeRef]bool{} if r.Kind == tree.Node { - m.probing[r.Node.Ref()] = true + if r.Node.Probing { + return m, nil + } return m, snapshotCmd("probe", func() (ipc.Snapshot, error) { return m.client.Probe(r.Node.Sub, r.Node.ID) }) } if r.Sub.ID == tree.Default { return m, nil } - for _, n := range m.nodes { - if n.Sub == r.Sub.ID { - m.probing[n.Ref()] = true - } - } return m, snapshotCmd("probe", func() (ipc.Snapshot, error) { return m.client.Probe(r.Sub.ID, "") }) } func (m Model) probeAll() (tea.Model, tea.Cmd) { - m.probing = map[domain.NodeRef]bool{} - for _, n := range m.nodes { - m.probing[n.Ref()] = true - } return m, snapshotCmd("probe", func() (ipc.Snapshot, error) { return m.client.Probe("", "") }) } @@ -103,7 +94,7 @@ func (m Model) refresh() (tea.Model, tea.Cmd) { return m, nil } m.refreshing = map[string]bool{id: true} - return m, tea.Batch(m.spin.Tick, snapshotCmd("refresh", func() (ipc.Snapshot, error) { return m.client.Refresh(id) })) + return m, snapshotCmd("refresh", func() (ipc.Snapshot, error) { return m.client.Refresh(id) }) } func (m Model) refreshAll() (tea.Model, tea.Cmd) { @@ -111,7 +102,7 @@ func (m Model) refreshAll() (tea.Model, tea.Cmd) { for _, sub := range m.subs { m.refreshing[sub.ID] = true } - return m, tea.Batch(m.spin.Tick, snapshotCmd("refresh", m.client.RefreshAll)) + return m, snapshotCmd("refresh", m.client.RefreshAll) } func (m Model) moveSub(dir int) (tea.Model, tea.Cmd) { @@ -133,5 +124,5 @@ func (m Model) setTun(enable bool) (tea.Model, tea.Cmd) { return m, nil } m.connecting = true - return m, tea.Batch(m.spin.Tick, snapshotCmd("connect", func() (ipc.Snapshot, error) { return m.client.SetTun(enable) })) + return m, snapshotCmd("connect", func() (ipc.Snapshot, error) { return m.client.SetTun(enable) }) } diff --git a/internal/client/tui/model.go b/internal/client/tui/model.go index b9118ae..478816c 100644 --- a/internal/client/tui/model.go +++ b/internal/client/tui/model.go @@ -12,7 +12,6 @@ import ( "github.com/luynrs/justray/internal/client/tui/settings" "github.com/luynrs/justray/internal/client/tui/tree" - "github.com/luynrs/justray/internal/domain" "github.com/luynrs/justray/internal/ipc" ) @@ -28,7 +27,6 @@ type Model struct { nodes []ipc.Node collapsed map[string]bool - probing map[domain.NodeRef]bool refreshing map[string]bool spin spinner.Model cursor int @@ -86,7 +84,6 @@ func (m Model) data() tree.Data { Subs: m.subs, Nodes: m.nodes, Collapsed: m.collapsed, - Probing: m.probing, Refreshing: m.refreshing, Query: m.filter.Value(), Status: m.status, diff --git a/internal/client/tui/tree/nodes.go b/internal/client/tui/tree/nodes.go index 727b7cc..b04a460 100644 --- a/internal/client/tui/tree/nodes.go +++ b/internal/client/tui/tree/nodes.go @@ -1,6 +1,7 @@ package tree import ( + "cmp" "fmt" "github.com/luynrs/justray/internal/client/tui/style" @@ -69,20 +70,20 @@ func info(n ipc.Node) string { func latency(n ipc.Node) string { switch { - case !n.Probed: + case n.Probing || !n.Probed: return "" case n.Alive: return fmt.Sprintf("%dms", n.MS) } - return "timeout" + return "fail" } func (d Data) dot(n ipc.Node) string { switch { case d.connected() && d.Status.NodeRef == n.Ref(): return style.Alive.Render("●") - case d.Probing[n.Ref()]: - return style.Pending.Render("○") + case n.Probing: + return style.Pending.Render(cmp.Or(d.Spinner, "○")) case !n.Probed: return style.Unknown.Render("○") case n.Alive: diff --git a/internal/client/tui/tree/tree.go b/internal/client/tui/tree/tree.go index 3a19599..360246d 100644 --- a/internal/client/tui/tree/tree.go +++ b/internal/client/tui/tree/tree.go @@ -3,7 +3,6 @@ package tree import ( "strings" - "github.com/luynrs/justray/internal/domain" "github.com/luynrs/justray/internal/ipc" ) @@ -32,7 +31,6 @@ type Data struct { Subs []ipc.Sub Nodes []ipc.Node Collapsed map[string]bool - Probing map[domain.NodeRef]bool Refreshing map[string]bool Query string Status ipc.Status diff --git a/internal/client/tui/update.go b/internal/client/tui/update.go index 97b605b..119ac04 100644 --- a/internal/client/tui/update.go +++ b/internal/client/tui/update.go @@ -58,9 +58,6 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, tickCmd() case spinner.TickMsg: - if len(m.refreshing) == 0 && !m.connecting && m.live { - return m, nil - } var cmd tea.Cmd m.spin, cmd = m.spin.Update(msg) return m, cmd @@ -72,9 +69,6 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } if msg.err != nil { m.err = msg.err.Error() - if msg.op == "probe" { - m.probing = nil - } if msg.op == "refresh" { m.refreshing = nil } @@ -90,15 +84,6 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.since = time.Now().Add(-time.Duration(m.status.Uptime) * time.Second) m.emoji = msg.snapshot.Settings.Emoji == "on" m.live = true - if msg.op == "probe" { - m.probing = nil - } else if len(m.probing) > 0 { - for _, n := range m.nodes { - if n.Probed { - delete(m.probing, n.Ref()) - } - } - } if msg.op == "refresh" { m.refreshing = nil } else if len(m.refreshing) > 0 { @@ -125,6 +110,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if msg.op == "settings" { m.settings = settings.New(msg.snapshot.Settings, topLines) } + return m, nil case pushed: if msg.live { @@ -134,7 +120,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, next(m.statusCh) } m.live = false - return m, tea.Batch(next(m.statusCh), m.spin.Tick) + return m, next(m.statusCh) } return m, nil } @@ -314,5 +300,5 @@ func (m Model) closeSettings() (Model, tea.Cmd) { } m.emoji = next.Emoji == "on" m.connecting = true - return m, tea.Batch(m.spin.Tick, snapshotCmd("connect", func() (ipc.Snapshot, error) { return m.client.SetSettings(next) })) + return m, snapshotCmd("connect", func() (ipc.Snapshot, error) { return m.client.SetSettings(next) }) } diff --git a/internal/daemon/core/core.go b/internal/daemon/core/core.go index c8e68ce..3de9d19 100644 --- a/internal/daemon/core/core.go +++ b/internal/daemon/core/core.go @@ -22,12 +22,13 @@ import ( type Core struct { opMu sync.Mutex stateMu sync.RWMutex - probeMu sync.Mutex store store.Disk state store.PersistentState - probes map[domain.NodeRef]engine.Result - conn *connection.Service - subs *subscription.Service + probeMu sync.Mutex + probes map[domain.NodeRef]engine.Result + probing map[domain.NodeRef]bool + conn *connection.Service + subs *subscription.Service jobsMu sync.Mutex refreshes map[string]*refreshCall @@ -36,6 +37,7 @@ type Core struct { snapshot atomic.Pointer[ipc.Snapshot] watchMu sync.Mutex watchers map[chan ipc.Changed]struct{} + pubMu sync.Mutex } func New(st store.Disk, conn *connection.Service, subs *subscription.Service) (*Core, error) { @@ -51,7 +53,7 @@ func New(st store.Disk, conn *connection.Service, subs *subscription.Service) (* settings.Autostart = "on" } state.Settings = settings - c := &Core{store: st, state: state, probes: map[domain.NodeRef]engine.Result{}, conn: conn, subs: subs, refreshes: map[string]*refreshCall{}, watchers: map[chan ipc.Changed]struct{}{}} + c := &Core{store: st, state: state, probes: map[domain.NodeRef]engine.Result{}, probing: map[domain.NodeRef]bool{}, conn: conn, subs: subs, refreshes: map[string]*refreshCall{}, watchers: map[chan ipc.Changed]struct{}{}} c.publish() return c, nil } @@ -104,16 +106,40 @@ func (c *Core) Watch() (ipc.Changed, <-chan ipc.Changed, func()) { } func (c *Core) Probe(ctx context.Context, sub, id string) error { - c.probeMu.Lock() - defer c.probeMu.Unlock() - state := c.current() refs, nodes, err := probeTargets(state.Subscriptions, sub, id) if err != nil { return err } - refMap := make(map[string]domain.NodeRef, len(refs)) - for _, ref := range refs { + + c.probeMu.Lock() + var targets []domain.Node + var targetRefs []domain.NodeRef + for i, ref := range refs { + if !c.probing[ref] { + c.probing[ref] = true + targetRefs = append(targetRefs, ref) + targets = append(targets, nodes[i]) + } + } + c.probeMu.Unlock() + + if len(targets) == 0 { + return nil + } + c.publish() + + defer func() { + c.probeMu.Lock() + for _, ref := range targetRefs { + delete(c.probing, ref) + } + c.probeMu.Unlock() + c.publish() + }() + + refMap := make(map[string]domain.NodeRef, len(targetRefs)) + for _, ref := range targetRefs { refMap[ref.NodeID] = ref } @@ -122,13 +148,15 @@ func (c *Core) Probe(ctx context.Context, sub, id string) error { if !ok { return } - c.opMu.Lock() + c.probeMu.Lock() c.probes[ref] = res - c.opMu.Unlock() + delete(c.probing, ref) + c.probeMu.Unlock() + c.publish() } - _, err = c.conn.Probe(ctx, nodes, state.Settings, onResult) + _, err = c.conn.Probe(ctx, targets, state.Settings, onResult) return err } @@ -435,6 +463,9 @@ func (c *Core) commit(state store.PersistentState) error { } func (c *Core) publish() { + c.pubMu.Lock() + defer c.pubMu.Unlock() + state := c.current() subs := make([]ipc.Sub, len(state.Subscriptions)) for i, sub := range state.Subscriptions { @@ -458,6 +489,11 @@ func (c *Core) publish() { select { case ch <- ipc.Changed{Revision: snapshot.Revision}: default: + select { + case <-ch: + default: + } + ch <- ipc.Changed{Revision: snapshot.Revision} } } c.watchMu.Unlock() @@ -484,13 +520,24 @@ func (c *Core) status(state store.PersistentState) ipc.Status { } func (c *Core) nodes(subscriptions []store.Subscription) []ipc.Node { + c.probeMu.Lock() + defer c.probeMu.Unlock() + live := map[domain.NodeRef]bool{} out := []ipc.Node{} for _, subscription := range subscriptions { for _, node := range subscription.Nodes { ref := domain.NodeRef{SubscriptionID: subscription.ID, NodeID: node.ID} live[ref] = true - item := ipc.Node{ID: node.ID, Name: node.Name, Protocol: string(node.Protocol), Server: node.Server, Port: node.Port, Sub: subscription.ID} + item := ipc.Node{ + ID: node.ID, + Name: node.Name, + Protocol: string(node.Protocol), + Server: node.Server, + Port: node.Port, + Sub: subscription.ID, + Probing: c.probing[ref], + } if result, ok := c.probes[ref]; ok { item.Probed, item.Alive, item.MS = true, result.Alive, result.MS } @@ -502,6 +549,11 @@ func (c *Core) nodes(subscriptions []store.Subscription) []ipc.Node { delete(c.probes, ref) } } + for ref := range c.probing { + if !live[ref] { + delete(c.probing, ref) + } + } return out } diff --git a/internal/domain/settings.go b/internal/domain/settings.go index 868aa35..f9710c8 100644 --- a/internal/domain/settings.go +++ b/internal/domain/settings.go @@ -17,7 +17,7 @@ const ( DefaultTunMTU = 9000 DefaultTunStack = "gvisor" DefaultRefresh = 6 - DefaultProbeURL = "https://connectivitycheck.gstatic.com/generate_204" + DefaultProbeURL = "http://cp.cloudflare.com/generate_204" TunInterface = "justray" ) diff --git a/internal/engine/build.go b/internal/engine/build.go index becf59f..5f40abe 100644 --- a/internal/engine/build.go +++ b/internal/engine/build.go @@ -18,11 +18,13 @@ import ( "github.com/luynrs/justray/internal/engine/resolvers" ) -const ( - Tag = "proxy" - maxProbeNodes = 512 - probeWorkers = 32 -) +const Tag = "proxy" + +var dnsStrategy = map[string]option.DomainStrategy{ + "auto": option.DomainStrategy(C.DomainStrategyPreferIPv4), + "ipv4": option.DomainStrategy(C.DomainStrategyIPv4Only), + "ipv6": option.DomainStrategy(C.DomainStrategyIPv6Only), +} func Build(ctx context.Context, n domain.Node, s domain.Settings, logPath string, tun bool) (*option.Options, error) { ep, obs, err := Proxy(ctx, n, s) @@ -76,48 +78,40 @@ func Proxy(ctx context.Context, n domain.Node, s domain.Settings) (*option.Endpo func ProbeTag(i int) string { return "p" + strconv.Itoa(i) } -func ProbeConfig(ctx context.Context, nodes []domain.Node, s domain.Settings, logPath string) (*option.Options, error) { +func ProbeConfig(ctx context.Context, nodes []domain.Node, s domain.Settings, logPath string) *option.Options { opts := &option.Options{ Log: &option.LogOptions{Level: s.LogLevel, Output: logPath}, Route: &option.RouteOptions{AutoDetectInterface: true}, } - resolvedNodes := make([]*domain.Node, len(nodes)) - jobs := make(chan int) + uniqueHosts := map[string]string{} + for _, n := range nodes { + if _, err := netip.ParseAddr(n.Server); err != nil && n.Server != "" { + uniqueHosts[n.Server] = "" + } + } var wg sync.WaitGroup - for range min(probeWorkers, len(nodes)) { + var mu sync.Mutex + for host := range uniqueHosts { wg.Go(func() { - for i := range jobs { - n := nodes[i] - if r, err := resolved(ctx, n, s); err == nil { - resolvedNodes[i] = &r - } + dummy := domain.Node{Server: host} + if r, err := resolved(ctx, dummy, s); err == nil { + mu.Lock() + uniqueHosts[host] = r.Server + mu.Unlock() } }) } - for i := range nodes { - select { - case jobs <- i: - case <-ctx.Done(): - close(jobs) - wg.Wait() - return nil, ctx.Err() - } - } - close(jobs) wg.Wait() - if err := ctx.Err(); err != nil { - return nil, err - } - for i, n := range resolvedNodes { - if n == nil { - continue + for i, n := range nodes { + if ip, ok := uniqueHosts[n.Server]; ok && ip != "" { + n = withServerIP(n, ip) } - if ep, obs, err := outbound.New(*n, ProbeTag(i)); err == nil { + if ep, obs, err := outbound.New(n, ProbeTag(i)); err == nil { attach(opts, ep, obs) } } - return opts, nil + return opts } func attach(opts *option.Options, ep *option.Endpoint, obs []option.Outbound) { diff --git a/internal/engine/lookup.go b/internal/engine/lookup.go index 6b544bb..7df02e8 100644 --- a/internal/engine/lookup.go +++ b/internal/engine/lookup.go @@ -8,33 +8,50 @@ import ( "sync" "time" - C "github.com/sagernet/sing-box/constant" - "github.com/sagernet/sing-box/option" - "github.com/luynrs/justray/internal/domain" "github.com/luynrs/justray/internal/engine/outbound" ) -var ( - dnsMu sync.Mutex - dnsCache = map[string]dnsEntry{} -) - -const maxDNSCache = 4096 - type dnsEntry struct { ip string exp time.Time } +var ( + dnsMu sync.RWMutex + dnsCache = map[string]dnsEntry{} +) + func resolved(ctx context.Context, n domain.Node, s domain.Settings) (domain.Node, error) { if _, err := netip.ParseAddr(n.Server); err == nil { return n, nil } - ip, err := lookup(ctx, n.Server, s) - if err != nil { - return n, err + key := s.IPVersion + ":" + n.Server + dnsMu.RLock() + entry, ok := dnsCache[key] + dnsMu.RUnlock() + if ok && time.Now().Before(entry.exp) { + return withServerIP(n, entry.ip), nil } + ctx, cancel := context.WithTimeout(ctx, 4*time.Second) + defer cancel() + + ips, err := net.DefaultResolver.LookupNetIP(ctx, network(s), n.Server) + switch { + case err != nil: + return n, fmt.Errorf("could not resolve %s: %w", n.Server, err) + case len(ips) == 0: + return n, fmt.Errorf("no addresses for %s", n.Server) + } + + ip := ips[0].Unmap().String() + dnsMu.Lock() + dnsCache[key] = dnsEntry{ip: ip, exp: time.Now().Add(10 * time.Minute)} + dnsMu.Unlock() + return withServerIP(n, ip), nil +} + +func withServerIP(n domain.Node, ip string) domain.Node { switch { case n.TLS != nil && n.TLS.SNI == "": tls := *n.TLS @@ -47,57 +64,7 @@ func resolved(ctx context.Context, n domain.Node, s domain.Settings) (domain.Nod n.Transport.Host = n.Server } n.Server = ip - return n, nil -} - -func dnsKey(host string, s domain.Settings) string { return s.IPVersion + ":" + host } - -func forget(host string, s domain.Settings) { - dnsMu.Lock() - delete(dnsCache, dnsKey(host, s)) - dnsMu.Unlock() -} - -func lookup(ctx context.Context, host string, s domain.Settings) (string, error) { - key := dnsKey(host, s) - dnsMu.Lock() - e, ok := dnsCache[key] - if ok && !time.Now().Before(e.exp) { - delete(dnsCache, key) - ok = false - } - dnsMu.Unlock() - if ok { - return e.ip, nil - } - - ctx, cancel := context.WithTimeout(ctx, 8*time.Second) - defer cancel() - ips, err := net.DefaultResolver.LookupNetIP(ctx, network(s), host) - switch { - case err != nil: - return "", fmt.Errorf("could not resolve %s: %w", host, err) - case len(ips) == 0: - return "", fmt.Errorf("no addresses for %s", host) - } - - e = dnsEntry{ip: ips[0].Unmap().String(), exp: time.Now().Add(10 * time.Minute)} - dnsMu.Lock() - now := time.Now() - for key, cached := range dnsCache { - if !now.Before(cached.exp) { - delete(dnsCache, key) - } - } - if len(dnsCache) >= maxDNSCache { - for key := range dnsCache { - delete(dnsCache, key) - break - } - } - dnsCache[key] = e - dnsMu.Unlock() - return e.ip, nil + return n } func network(s domain.Settings) string { @@ -109,9 +76,3 @@ func network(s domain.Settings) string { } return "ip" } - -var dnsStrategy = map[string]option.DomainStrategy{ - "auto": option.DomainStrategy(C.DomainStrategyPreferIPv4), - "ipv4": option.DomainStrategy(C.DomainStrategyIPv4Only), - "ipv6": option.DomainStrategy(C.DomainStrategyIPv6Only), -} diff --git a/internal/engine/probe.go b/internal/engine/probe.go index 24cfa26..cdf0964 100644 --- a/internal/engine/probe.go +++ b/internal/engine/probe.go @@ -5,6 +5,7 @@ import ( "fmt" "net" "net/http" + "runtime" "sync" "time" @@ -16,13 +17,10 @@ import ( ) func Probe(ctx context.Context, nodes []domain.Node, s domain.Settings, logPath string, onResult func(string, Result)) (map[string]Result, error) { - if len(nodes) > maxProbeNodes { - return nil, fmt.Errorf("too many nodes to probe: %d (maximum %d)", len(nodes), maxProbeNodes) - } - opts, err := ProbeConfig(ctx, nodes, s, logPath) - if err != nil { - return nil, err + if len(nodes) == 0 { + return map[string]Result{}, nil } + opts := ProbeConfig(ctx, nodes, s, logPath) inst, err := sbox.New(sbox.Options{Options: *opts, Context: Context(ctx)}) if err != nil { return nil, fmt.Errorf("build probe engine: %w", err) @@ -34,12 +32,19 @@ func Probe(ctx context.Context, nodes []domain.Node, s domain.Settings, logPath defer func() { _ = inst.Close() }() out := map[string]Result{} - sem := make(chan struct{}, probeWorkers) + workers := min(len(nodes), max(runtime.NumCPU()*2, 4)) + sem := make(chan struct{}, workers) var mu sync.Mutex var wg sync.WaitGroup for i, n := range nodes { - dialer, ok := inst.Outbound().Outbound(ProbeTag(i)) - if !ok { + tag := ProbeTag(i) + var dialer N.Dialer + if ob, ok := inst.Outbound().Outbound(tag); ok { + dialer = ob + } else if ep, ok := inst.Endpoint().Get(tag); ok { + dialer = ep + } + if dialer == nil { res := Result{} mu.Lock() out[n.ID] = res @@ -59,9 +64,6 @@ func Probe(ctx context.Context, nodes []domain.Node, s domain.Settings, logPath defer func() { <-sem }() ms, err := delay(ctx, dialer, s.ProbeURL) - if err != nil { - forget(n.Server, s) - } res := Result{Alive: err == nil, MS: ms} mu.Lock() out[n.ID] = res @@ -77,20 +79,9 @@ func Probe(ctx context.Context, nodes []domain.Node, s domain.Settings, logPath func delay(ctx context.Context, dialer N.Dialer, url string) (int, error) { client := &http.Client{ - Timeout: 5 * time.Second, - CheckRedirect: func(r *http.Request, via []*http.Request) error { - if r.URL.Scheme != "https" && r.URL.Scheme != "http" { - return fmt.Errorf("probe redirect must use http or https") - } - if via[len(via)-1].URL.Scheme == "https" && r.URL.Scheme == "http" { - return fmt.Errorf("probe redirect must not downgrade to http") - } - if len(via) >= 10 { - return fmt.Errorf("stopped after 10 redirects") - } - return nil - }, + Timeout: 4 * time.Second, Transport: &http.Transport{ + DisableKeepAlives: true, DialContext: func(ctx context.Context, _, addr string) (net.Conn, error) { return dialer.DialContext(ctx, N.NetworkTCP, M.ParseSocksaddr(addr)) }, diff --git a/internal/ipc/types.go b/internal/ipc/types.go index 7910322..3e71b04 100644 --- a/internal/ipc/types.go +++ b/internal/ipc/types.go @@ -48,9 +48,10 @@ type Node struct { Sub string // false until Probe has run - Probed bool - Alive bool - MS int + Probed bool + Alive bool + MS int + Probing bool } func (n Node) Ref() domain.NodeRef { From 7524007c3d19694775535adfd3d8037e3dfbb154 Mon Sep 17 00:00:00 2001 From: luynrs <157303229+luynrs@users.noreply.github.com> Date: Sat, 5 Sep 2026 14:50:17 +0300 Subject: [PATCH 2/3] refactor: deduplication, deleted junkcode --- internal/client/tui/actions.go | 5 --- internal/client/tui/model.go | 40 +++++++++++----------- internal/client/tui/tree/nodes.go | 6 ++-- internal/client/tui/tree/tree.go | 17 +++++----- internal/client/tui/update.go | 55 +++++++++++-------------------- internal/client/tui/view.go | 22 ++++++------- internal/daemon/core/core.go | 14 +++++++- internal/daemon/core/core_test.go | 6 ++++ internal/ipc/types.go | 13 ++++---- 9 files changed, 87 insertions(+), 91 deletions(-) diff --git a/internal/client/tui/actions.go b/internal/client/tui/actions.go index 7a521b8..76c2714 100644 --- a/internal/client/tui/actions.go +++ b/internal/client/tui/actions.go @@ -93,15 +93,10 @@ func (m Model) refresh() (tea.Model, tea.Cmd) { if id == tree.Default { return m, nil } - m.refreshing = map[string]bool{id: true} return m, snapshotCmd("refresh", func() (ipc.Snapshot, error) { return m.client.Refresh(id) }) } func (m Model) refreshAll() (tea.Model, tea.Cmd) { - m.refreshing = map[string]bool{} - for _, sub := range m.subs { - m.refreshing[sub.ID] = true - } return m, snapshotCmd("refresh", m.client.RefreshAll) } diff --git a/internal/client/tui/model.go b/internal/client/tui/model.go index 478816c..d589383 100644 --- a/internal/client/tui/model.go +++ b/internal/client/tui/model.go @@ -12,6 +12,7 @@ import ( "github.com/luynrs/justray/internal/client/tui/settings" "github.com/luynrs/justray/internal/client/tui/tree" + "github.com/luynrs/justray/internal/domain" "github.com/luynrs/justray/internal/ipc" ) @@ -26,23 +27,21 @@ type Model struct { subs []ipc.Sub nodes []ipc.Node - collapsed map[string]bool - refreshing map[string]bool - spin spinner.Model - cursor int - scroll int - wheel time.Time + collapsed map[string]bool + spin spinner.Model + cursor int + scroll int + wheel time.Time - editor textinput.Model - confirmQ string - confirmID string - settings *settings.Settings - filter textinput.Model + editor textinput.Model + confirmSub ipc.Sub + dialog *settings.Settings + filter textinput.Model status ipc.Status revision uint64 live bool - emoji bool + cfg domain.Settings since time.Time statusCh chan pushed watchCtx context.Context @@ -81,15 +80,14 @@ func (m Model) Init() tea.Cmd { func (m Model) data() tree.Data { return tree.Data{ - Subs: m.subs, - Nodes: m.nodes, - Collapsed: m.collapsed, - Refreshing: m.refreshing, - Query: m.filter.Value(), - Status: m.status, - Live: m.live, - Emoji: m.emoji, - Spinner: m.spin.View(), + Subs: m.subs, + Nodes: m.nodes, + Collapsed: m.collapsed, + Query: m.filter.Value(), + Status: m.status, + Live: m.live, + Emoji: m.cfg.Emoji == "on", + Spinner: m.spin.View(), } } diff --git a/internal/client/tui/tree/nodes.go b/internal/client/tui/tree/nodes.go index b04a460..86002df 100644 --- a/internal/client/tui/tree/nodes.go +++ b/internal/client/tui/tree/nodes.go @@ -18,7 +18,7 @@ func (d Data) Render(r Row, selected bool, width int) string { case Gap: return "" case Meta: - return bar + style.Flush(" "+style.Usage(r.Sub.Traffic), subMeta(r.Sub, d.Refreshing[r.Sub.ID], d.Spinner), width-2) + return bar + style.Flush(" "+style.Usage(r.Sub.Traffic), subMeta(r.Sub, d.Spinner), width-2) case Header: return bar + subHeader(r.Sub, d.Collapsed[r.Sub.ID], selected, d.Emoji) } @@ -37,10 +37,10 @@ func subHeader(s ipc.Sub, collapsed, selected, emoji bool) string { return arrow + " " + style.Name.Render(clean) } -func subMeta(s ipc.Sub, refreshing bool, spinner string) string { +func subMeta(s ipc.Sub, spinner string) string { age := "never updated" switch { - case refreshing: + case s.Refreshing: age = "updated " + spinner + " ago" case !s.UpdatedAt.IsZero(): age = "updated " + style.Since(s.UpdatedAt) diff --git a/internal/client/tui/tree/tree.go b/internal/client/tui/tree/tree.go index 360246d..15c0d2a 100644 --- a/internal/client/tui/tree/tree.go +++ b/internal/client/tui/tree/tree.go @@ -28,15 +28,14 @@ func (r Row) SubID() string { func (r Row) Selectable() bool { return r.Kind == Header || r.Kind == Node } type Data struct { - Subs []ipc.Sub - Nodes []ipc.Node - Collapsed map[string]bool - Refreshing map[string]bool - Query string - Status ipc.Status - Live bool - Emoji bool - Spinner string + Subs []ipc.Sub + Nodes []ipc.Node + Collapsed map[string]bool + Query string + Status ipc.Status + Live bool + Emoji bool + Spinner string } func (d Data) connected() bool { return d.Live && d.Status.Connected } diff --git a/internal/client/tui/update.go b/internal/client/tui/update.go index 119ac04..049e4d2 100644 --- a/internal/client/tui/update.go +++ b/internal/client/tui/update.go @@ -23,25 +23,25 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.KeyPressMsg: switch { case msg.String() == "ctrl+c": - if m.settings != nil { - m.settings = nil + if m.dialog != nil { + m.dialog = nil return m, tea.Quit } return m.quit() - case m.settings != nil: + case m.dialog != nil: return m.updateSettings(msg) } return m.key(msg) case tea.MouseMsg: - if m.settings != nil { + if m.dialog != nil { return m.updateSettings(msg) } return m.mouse(msg) case tea.PasteMsg: switch { - case m.settings != nil: + case m.dialog != nil: return m.updateSettings(msg) case m.editor.Focused(): var cmd tea.Cmd @@ -69,9 +69,6 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } if msg.err != nil { m.err = msg.err.Error() - if msg.op == "refresh" { - m.refreshing = nil - } return m, nil } if msg.snapshot.Revision < m.revision { @@ -82,17 +79,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.subs, m.nodes = msg.snapshot.Subscriptions, msg.snapshot.Nodes m.status = msg.snapshot.Status m.since = time.Now().Add(-time.Duration(m.status.Uptime) * time.Second) - m.emoji = msg.snapshot.Settings.Emoji == "on" + m.cfg = msg.snapshot.Settings m.live = true - if msg.op == "refresh" { - m.refreshing = nil - } else if len(m.refreshing) > 0 { - for _, sub := range msg.snapshot.Subscriptions { - if time.Since(sub.UpdatedAt) < 10*time.Second { - delete(m.refreshing, sub.ID) - } - } - } if selectedOK { if selected.Kind == tree.Header { m.toHeader(selected.Sub.ID) @@ -107,9 +95,6 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } } m.clamp() - if msg.op == "settings" { - m.settings = settings.New(msg.snapshot.Settings, topLines) - } return m, nil case pushed: @@ -129,10 +114,10 @@ func (m Model) key(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { k := msg.String() switch { - case m.confirmID != "": - id := m.confirmID - m.confirmQ, m.confirmID = "", "" - if k == "y" { + case m.confirmSub.ID != "": + id := m.confirmSub.ID + m.confirmSub = ipc.Sub{} + if k == "y" || k == "Y" { return m, snapshotCmd("mutation", func() (ipc.Snapshot, error) { return m.client.RemoveSub(id) }) } return m, nil @@ -187,12 +172,13 @@ func (m Model) key(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { m.editor.SetValue("") return m, tea.Batch(m.editor.Focus(), textinput.Blink) case "o": - return m, snapshotCmd("settings", m.client.Snapshot) + m.dialog = settings.New(m.cfg, topLines) + return m, nil case "/": return m, m.startFiltering() case "d": - if r, ok := m.at(); ok { - m.confirmQ, m.confirmID = "Delete "+r.Sub.Name+"?", r.Sub.ID + if r, ok := m.at(); ok && r.Sub.ID != "" && r.Sub.ID != tree.Default { + m.confirmSub = r.Sub } case "q": return m.quit() @@ -229,7 +215,7 @@ func (m *Model) startFiltering() tea.Cmd { } func (m Model) mouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) { - if m.editor.Focused() || m.confirmID != "" { + if m.editor.Focused() || m.confirmSub.ID != "" { return m, nil } mouse := msg.Mouse() @@ -280,7 +266,7 @@ func (m Model) click(x, y int) (tea.Model, tea.Cmd) { } func (m Model) updateSettings(msg tea.Msg) (tea.Model, tea.Cmd) { - closed, cmd := m.settings.Update(msg) + closed, cmd := m.dialog.Update(msg) if !closed { return m, cmd } @@ -289,8 +275,8 @@ func (m Model) updateSettings(msg tea.Msg) (tea.Model, tea.Cmd) { // closeSettings saves on the way out func (m Model) closeSettings() (Model, tea.Cmd) { - next, changed, err := m.settings.Result() - m.settings = nil + next, changed, err := m.dialog.Result() + m.dialog = nil switch { case err != nil: m.err = err.Error() @@ -298,7 +284,6 @@ func (m Model) closeSettings() (Model, tea.Cmd) { case !changed: return m, nil } - m.emoji = next.Emoji == "on" - m.connecting = true - return m, snapshotCmd("connect", func() (ipc.Snapshot, error) { return m.client.SetSettings(next) }) + m.cfg = next + return m, snapshotCmd("settings", func() (ipc.Snapshot, error) { return m.client.SetSettings(next) }) } diff --git a/internal/client/tui/view.go b/internal/client/tui/view.go index f3b34f1..db57df0 100644 --- a/internal/client/tui/view.go +++ b/internal/client/tui/view.go @@ -42,8 +42,8 @@ func (m Model) content() string { switch { case m.quitting, m.w == 0: return "" - case m.settings != nil: - body := m.titleLine() + "\n\n" + m.settings.View(m.w, max(m.h-topLines-1, 1)) + case m.dialog != nil: + body := m.titleLine() + "\n\n" + m.dialog.View(m.w, max(m.h-topLines-1, 1)) return style.Fit(body, m.h-1) + "\n" + m.clip(style.Indent(m.hints(m.w-2))) case m.h < topLines+footerLines+1: return m.titleLine() @@ -58,14 +58,14 @@ func (m Model) content() string { func (m Model) titleLine() string { left := style.Title.Render("JustRay") + " " + style.Dim.Render(version.String()) - if m.settings != nil { - left += " " + m.settings.TabBar(max(m.w-lipgloss.Width(left)-2, 10)) + if m.dialog != nil { + left += " " + m.dialog.TabBar(max(m.w-lipgloss.Width(left)-2, 10)) } else if m.filter.Focused() || m.filter.Value() != "" { left += " " + style.Dim.Render("~ Search:") + " " + m.filter.View() } var right string - if m.settings == nil { + if m.dialog == nil { right = style.Segment(modeProxy, !m.status.Tun) + style.Segment(modeTun, m.status.Tun) } return m.clip(style.Flush(left, right, m.w)) @@ -101,9 +101,9 @@ func (m Model) tree() string { func (m Model) keys() [][2]string { switch { - case m.settings != nil: - return m.settings.Hints() - case m.confirmID != "": + case m.dialog != nil: + return m.dialog.Hints() + case m.confirmSub.ID != "": return [][2]string{{"y", "Delete"}, {"any", "Cancel"}} case m.editor.Focused(): return [][2]string{{"↵", "Add"}, {"esc", "Cancel"}} @@ -145,7 +145,7 @@ func (m Model) footer() string { if m.connecting { iconStyle = style.Pending } - status = iconStyle.Render(icon) + " " + style.Strong.Render(style.Sanitize(m.status.NodeName, m.emoji)) + " " + style.Dim.Render("·") + " " + style.Strong.Render(style.Uptime(time.Since(m.since))) + status = iconStyle.Render(icon) + " " + style.Strong.Render(style.Sanitize(m.status.NodeName, m.cfg.Emoji == "on")) + " " + style.Dim.Render("·") + " " + style.Strong.Render(style.Uptime(time.Since(m.since))) case m.live: iconStyle := style.Dim if m.connecting { @@ -160,8 +160,8 @@ func (m Model) footer() string { } hints := m.hints(m.w) - if m.confirmID != "" { - q := style.Err.Render(style.Sanitize(m.confirmQ, true)) + if m.confirmSub.ID != "" { + q := style.Err.Render(style.Sanitize("Delete "+m.confirmSub.Name+"?", true)) hints = q + " " + m.hints(max(m.w-lipgloss.Width(q)-2, 0)) } diff --git a/internal/daemon/core/core.go b/internal/daemon/core/core.go index 3de9d19..5378542 100644 --- a/internal/daemon/core/core.go +++ b/internal/daemon/core/core.go @@ -350,12 +350,14 @@ func (c *Core) refresh(ctx context.Context, sub store.Subscription) (store.Subsc call := &refreshCall{done: make(chan struct{})} c.refreshes[sub.ID] = call c.jobsMu.Unlock() + c.publish() call.sub, call.err = c.subs.Refresh(ctx, sub) c.jobsMu.Lock() delete(c.refreshes, sub.ID) close(call.done) c.jobsMu.Unlock() + c.publish() return call.sub, call.err } @@ -467,10 +469,20 @@ func (c *Core) publish() { defer c.pubMu.Unlock() state := c.current() + c.jobsMu.Lock() subs := make([]ipc.Sub, len(state.Subscriptions)) for i, sub := range state.Subscriptions { - subs[i] = ipc.Sub{ID: sub.ID, Name: sub.Name, Nodes: len(sub.Nodes), UpdatedAt: sub.UpdatedAt, Traffic: sub.Traffic, Direct: parser.IsLink(sub.URL)} + subs[i] = ipc.Sub{ + ID: sub.ID, + Name: sub.Name, + Nodes: len(sub.Nodes), + UpdatedAt: sub.UpdatedAt, + Traffic: sub.Traffic, + Direct: parser.IsLink(sub.URL), + Refreshing: c.refreshes[sub.ID] != nil, + } } + c.jobsMu.Unlock() active := state.Active if active.NodeID == "" { active = state.Last diff --git a/internal/daemon/core/core_test.go b/internal/daemon/core/core_test.go index f3427ec..73b37ff 100644 --- a/internal/daemon/core/core_test.go +++ b/internal/daemon/core/core_test.go @@ -62,6 +62,9 @@ func TestRefreshRunsOutsideMutationLockAndJoins(t *testing.T) { case <-ctx.Done(): t.Fatal(ctx.Err()) } + if snap := app.Snapshot(); len(snap.Subscriptions) == 0 || !snap.Subscriptions[0].Refreshing { + t.Fatalf("expected sub to be refreshing, got %+v", snap.Subscriptions) + } moved := make(chan error, 1) go func() { moved <- app.MoveSubscription("sub", 1) }() select { @@ -80,6 +83,9 @@ func TestRefreshRunsOutsideMutationLockAndJoins(t *testing.T) { if err := <-first; err != nil { t.Fatal(err) } + if snap := app.Snapshot(); len(snap.Subscriptions) == 0 || snap.Subscriptions[0].Refreshing { + t.Fatalf("expected sub not to be refreshing, got %+v", snap.Subscriptions) + } if got := calls.Load(); got != 1 { t.Fatalf("HTTP calls = %d, want 1", got) } diff --git a/internal/ipc/types.go b/internal/ipc/types.go index 3e71b04..3890be2 100644 --- a/internal/ipc/types.go +++ b/internal/ipc/types.go @@ -31,12 +31,13 @@ type Resp struct { } type Sub struct { - ID string - Name string - Nodes int - UpdatedAt time.Time - Traffic domain.Traffic - Direct bool // a bare share link + ID string + Name string + Nodes int + UpdatedAt time.Time + Traffic domain.Traffic + Direct bool // a bare share link + Refreshing bool } type Node struct { From 5d0f9ce841f7e49df00eabb24b0b554055bc6249 Mon Sep 17 00:00:00 2001 From: luynrs <157303229+luynrs@users.noreply.github.com> Date: Sat, 5 Sep 2026 15:49:08 +0300 Subject: [PATCH 3/3] refactor: simplify client state and bound probe work --- internal/client/cli/output.go | 8 ++--- internal/client/cli/root.go | 13 ++++--- internal/client/cli/stop.go | 22 ++++++++++-- internal/client/cli/version.go | 45 +++++++++++------------- internal/client/tui/settings/form.go | 10 +++--- internal/client/tui/settings/settings.go | 4 +-- internal/client/tui/style/format.go | 8 ----- internal/client/tui/tree/tree.go | 16 +++++---- internal/client/tui/view.go | 2 +- internal/daemon/core/core.go | 23 +++--------- internal/engine/build.go | 24 ++++++++++--- internal/engine/lookup.go | 24 +------------ internal/engine/probe.go | 7 ++-- 13 files changed, 100 insertions(+), 106 deletions(-) diff --git a/internal/client/cli/output.go b/internal/client/cli/output.go index 3f952cd..a22db36 100644 --- a/internal/client/cli/output.go +++ b/internal/client/cli/output.go @@ -20,9 +20,7 @@ func out(s string) { _, _ = lipgloss.Println(s) } func done(text string) { out(style.Alive.Bold(true).Render("✓") + " " + text) } -func fields(pairs ...[2]string) { out(fieldLines(pairs...)) } - -func fieldLines(pairs ...[2]string) string { +func fields(pairs ...[2]string) { w := 0 for _, p := range pairs { w = max(w, lipgloss.Width(p[0])) @@ -31,13 +29,13 @@ func fieldLines(pairs ...[2]string) string { for i, p := range pairs { lines[i] = " " + style.Pad(style.Dim.Render(p[0]+":"), w+1) + " " + p[1] } - return strings.Join(lines, "\n") + out(strings.Join(lines, "\n")) } func state(st ipc.Status) string { if st.Connected { text := "connected via " + strings.ToUpper(modeWord(st.Tun)) - if st.Uptime >= 0 { + if st.Uptime > 0 { text += " for " + style.Uptime(time.Duration(st.Uptime)*time.Second) } return text diff --git a/internal/client/cli/root.go b/internal/client/cli/root.go index 33e2a52..918bd45 100644 --- a/internal/client/cli/root.go +++ b/internal/client/cli/root.go @@ -70,7 +70,7 @@ func init() { rootCmd.SetUsageTemplate(usageTemplate) rootCmd.SetVersionTemplate("{{versionBlock}}") rootCmd.AddGroup(&cobra.Group{ID: cmdGroup, Title: "AVAILABLE COMMANDS"}) - rootCmd.AddCommand(upCmd, downCmd, stopCmd, statusCmd, subCmd) + rootCmd.AddCommand(upCmd, downCmd, stopCmd, statusCmd, subCmd, versionCmd) } // Execute runs the justray CLI. The caller (cmd/justray) handles the error. @@ -80,7 +80,7 @@ func Execute() error { rootCmd.Use = filepath.Base(os.Args[0]) + " " rootCmd.PersistentPreRunE = func(cmd *cobra.Command, args []string) error { for c := cmd; c != nil; c = c.Parent() { - if c.Name() == "completion" || c.Name() == "help" || c.Name() == "stop" { + if c.Name() == "completion" || c.Name() == "help" || c.Name() == "stop" || c.Name() == "version" { return nil } } @@ -181,13 +181,18 @@ func spawn(dir string) error { } func justrayd() (string, error) { - bin, err := exec.LookPath(exeName("justrayd")) - if err == nil { + if bin, err := exec.LookPath(exeName("justrayd")); err == nil { return bin, nil } if bin := nextToSelf("justrayd"); bin != "" { return bin, nil } + if dir, err := ipc.Dir(); err == nil { + p := filepath.Join(dir, "elevated", exeName("justrayd")) + if _, err := os.Stat(p); err == nil { + return p, nil + } + } return "", fmt.Errorf("daemon not in PATH or next to client") } diff --git a/internal/client/cli/stop.go b/internal/client/cli/stop.go index 5259c8f..6476c25 100644 --- a/internal/client/cli/stop.go +++ b/internal/client/cli/stop.go @@ -1,6 +1,13 @@ package cli -import "github.com/spf13/cobra" +import ( + "errors" + "time" + + "github.com/spf13/cobra" + + "github.com/luynrs/justray/internal/ipc" +) var stopCmd = &cobra.Command{ Use: "stop", @@ -16,7 +23,8 @@ func (a *app) stop(cmd *cobra.Command, args []string) error { return nil } stop := spin("Stopping daemon") - err := c.Shutdown() + _ = c.Shutdown() + err := waitStopped(c, 5*time.Second) stop() if err != nil { return err @@ -24,3 +32,13 @@ func (a *app) stop(cmd *cobra.Command, args []string) error { done("Daemon stopped") return nil } + +func waitStopped(c *ipc.Client, timeout time.Duration) error { + for deadline := time.Now().Add(timeout); time.Now().Before(deadline); { + if c.Ping() != nil { + return nil + } + time.Sleep(50 * time.Millisecond) + } + return errors.New("timed out waiting for daemon to stop") +} diff --git a/internal/client/cli/version.go b/internal/client/cli/version.go index 293ca2c..34661b9 100644 --- a/internal/client/cli/version.go +++ b/internal/client/cli/version.go @@ -1,37 +1,32 @@ package cli import ( - "debug/buildinfo" + "fmt" + "os" + "path/filepath" "runtime" + "strings" + + "github.com/spf13/cobra" - "github.com/luynrs/justray/internal/client/tui/style" "github.com/luynrs/justray/internal/version" ) -func versionBlock() string { - var pairs [][2]string - if v := singboxVersion(); v != "" { - pairs = append(pairs, [2]string{"sing-box", v}) - } - pairs = append(pairs, [2]string{"Platform", runtime.GOOS + "/" + runtime.GOARCH}) - - head := style.Dim.Render("·") + " JustRay " + style.Dim.Render(version.String()) - return head + "\n" + fieldLines(pairs...) + "\n" +var versionCmd = &cobra.Command{ + Use: "version", + Hidden: true, + Args: cobra.NoArgs, + Run: func(cmd *cobra.Command, args []string) { + out(strings.TrimRight(versionBlock(), "\n")) + }, } -func singboxVersion() string { - bin, err := justrayd() - if err != nil { - return "" - } - info, err := buildinfo.ReadFile(bin) - if err != nil { - return "" - } - for _, d := range info.Deps { - if d.Path == "github.com/sagernet/sing-box" { - return d.Version - } +func versionBlock() string { + bin := "justray" + if len(os.Args) > 0 && os.Args[0] != "" { + bin = filepath.Base(os.Args[0]) } - return "" + ver := strings.TrimPrefix(version.String(), "v") + p := runtime.GOOS + "/" + runtime.GOARCH + return fmt.Sprintf("%s version %s (%s)\nhttps://github.com/luynrs/justray/releases/tag/%s\n", bin, ver, p, version.String()) } diff --git a/internal/client/tui/settings/form.go b/internal/client/tui/settings/form.go index 22ec58d..58f4469 100644 --- a/internal/client/tui/settings/form.go +++ b/internal/client/tui/settings/form.go @@ -44,8 +44,8 @@ type hit struct { } func (s *Settings) lines(width, height int) []string { - w := max(width-4, 20) - s.input.SetWidth(max(w-8, 12)) + w := max(width-2, 20) + s.input.SetWidth(max(w-6, 12)) rows := s.rows() blocks := make([][]string, len(rows)) @@ -124,9 +124,9 @@ func tabAt(x int) (int, bool) { func (s *Settings) fieldBlock(f field, i, width int) (lines, choices []string) { selected := i == s.cursor - bar := " " + bar := " " if selected { - bar = " " + style.Accent.Render("▎") + " " + bar = style.Accent.Render("▎ ") } switch { @@ -145,7 +145,7 @@ func (s *Settings) fieldBlock(f field, i, width int) (lines, choices []string) { lines = append(lines, value...) choices = append(choices, picks...) if selected && s.err != "" { - lines = append(lines, bar+style.Err.Render(style.Clip(style.FirstLine(s.err), width-4))) + lines = append(lines, bar+style.Err.Render(style.Clip(style.FirstLine(s.err), width-2))) choices = append(choices, "") } } diff --git a/internal/client/tui/settings/settings.go b/internal/client/tui/settings/settings.go index ed8ad1c..59b2f86 100644 --- a/internal/client/tui/settings/settings.go +++ b/internal/client/tui/settings/settings.go @@ -386,14 +386,14 @@ func (s *Settings) listRows(l list) []field { set := func(v *domain.Settings, in string) error { at := l.at(v) if in = strings.TrimSpace(in); in == "" { - *at = append((*at)[:i:i], (*at)[i+1:]...) + *at = slices.Delete(*at, i, i+1) return nil } rule, err := domain.ParseRule(in) if err != nil { return err } - *at = append(append((*at)[:i:i], rule), (*at)[i+1:]...) + (*at)[i] = rule return nil } out = append(out, field{ diff --git a/internal/client/tui/style/format.go b/internal/client/tui/style/format.go index 9112429..a7d5c38 100644 --- a/internal/client/tui/style/format.go +++ b/internal/client/tui/style/format.go @@ -50,14 +50,6 @@ func Fit(body string, n int) string { return strings.Join(lines[:max(n, 0)], "\n") } -func Indent(s string) string { - var b strings.Builder - for line := range strings.Lines(s) { - b.WriteString(" " + line) - } - return b.String() -} - func FirstLine(s string) string { line, _, _ := strings.Cut(s, "\n") return line diff --git a/internal/client/tui/tree/tree.go b/internal/client/tui/tree/tree.go index 15c0d2a..227e75f 100644 --- a/internal/client/tui/tree/tree.go +++ b/internal/client/tui/tree/tree.go @@ -16,12 +16,16 @@ const ( ) type Row struct { - Kind Kind - Sub ipc.Sub - Node ipc.Node + Kind Kind + GroupID string + Sub ipc.Sub + Node ipc.Node } func (r Row) SubID() string { + if r.GroupID != "" { + return r.GroupID + } return r.Sub.ID } @@ -89,13 +93,13 @@ func (d Data) Rows() []Row { rows = append(rows, Row{Kind: Gap}) } - rows = append(rows, Row{Kind: Header, Sub: g.Sub}) + rows = append(rows, Row{Kind: Header, GroupID: g.Sub.ID, Sub: g.Sub}) if g.Sub.ID != Default { - rows = append(rows, Row{Kind: Meta, Sub: g.Sub}) + rows = append(rows, Row{Kind: Meta, GroupID: g.Sub.ID, Sub: g.Sub}) } for _, n := range nodes { if q != "" || !d.Collapsed[g.Sub.ID] || (d.connected() && d.Status.NodeRef == n.Ref()) { - rows = append(rows, Row{Kind: Node, Sub: subs[n.Sub], Node: n}) + rows = append(rows, Row{Kind: Node, GroupID: g.Sub.ID, Sub: subs[n.Sub], Node: n}) } } } diff --git a/internal/client/tui/view.go b/internal/client/tui/view.go index db57df0..c19fc7a 100644 --- a/internal/client/tui/view.go +++ b/internal/client/tui/view.go @@ -44,7 +44,7 @@ func (m Model) content() string { return "" case m.dialog != nil: body := m.titleLine() + "\n\n" + m.dialog.View(m.w, max(m.h-topLines-1, 1)) - return style.Fit(body, m.h-1) + "\n" + m.clip(style.Indent(m.hints(m.w-2))) + return style.Fit(body, m.h-1) + "\n" + m.clip(m.hints(m.w)) case m.h < topLines+footerLines+1: return m.titleLine() } diff --git a/internal/daemon/core/core.go b/internal/daemon/core/core.go index 5378542..479041c 100644 --- a/internal/daemon/core/core.go +++ b/internal/daemon/core/core.go @@ -25,10 +25,10 @@ type Core struct { store store.Disk state store.PersistentState probeMu sync.Mutex - probes map[domain.NodeRef]engine.Result - probing map[domain.NodeRef]bool - conn *connection.Service - subs *subscription.Service + probes map[domain.NodeRef]engine.Result + probing map[domain.NodeRef]bool + conn *connection.Service + subs *subscription.Service jobsMu sync.Mutex refreshes map[string]*refreshCall @@ -244,20 +244,7 @@ func (c *Core) RefreshSubscriptions(ctx context.Context) error { if len(subs) == 0 { return nil } - onUpdated := func(sub store.Subscription) { - c.opMu.Lock() - defer c.opMu.Unlock() - if ctx.Err() != nil { - return - } - next := c.current() - if i := slices.IndexFunc(next.Subscriptions, func(s store.Subscription) bool { return s.ID == sub.ID }); i >= 0 { - next.Subscriptions[i] = sub - dropConn := c.sanitizeRefs(&next, sub) - _ = c.syncAfterRefresh(ctx, next, dropConn) - } - } - updated, refreshErr := c.subs.RefreshAll(ctx, subs, c.refresh, onUpdated) + updated, refreshErr := c.subs.RefreshAll(ctx, subs, c.refresh, nil) c.opMu.Lock() defer c.opMu.Unlock() if err := ctx.Err(); err != nil { diff --git a/internal/engine/build.go b/internal/engine/build.go index 5f40abe..dacb10e 100644 --- a/internal/engine/build.go +++ b/internal/engine/build.go @@ -4,6 +4,7 @@ import ( "context" "net/netip" "net/url" + "runtime" "strconv" "strings" "sync" @@ -18,7 +19,10 @@ import ( "github.com/luynrs/justray/internal/engine/resolvers" ) -const Tag = "proxy" +const ( + Tag = "proxy" + maxProbeNodes = 512 +) var dnsStrategy = map[string]option.DomainStrategy{ "auto": option.DomainStrategy(C.DomainStrategyPreferIPv4), @@ -78,6 +82,8 @@ func Proxy(ctx context.Context, n domain.Node, s domain.Settings) (*option.Endpo func ProbeTag(i int) string { return "p" + strconv.Itoa(i) } +func probeWorkers(n int) int { return min(n, max(runtime.NumCPU()*2, 4)) } + func ProbeConfig(ctx context.Context, nodes []domain.Node, s domain.Settings, logPath string) *option.Options { opts := &option.Options{ Log: &option.LogOptions{Level: s.LogLevel, Output: logPath}, @@ -89,10 +95,17 @@ func ProbeConfig(ctx context.Context, nodes []domain.Node, s domain.Settings, lo uniqueHosts[n.Server] = "" } } - var wg sync.WaitGroup - var mu sync.Mutex + hosts := make([]string, 0, len(uniqueHosts)) for host := range uniqueHosts { + hosts = append(hosts, host) + } + var mu sync.Mutex + sem := make(chan struct{}, probeWorkers(len(hosts))) + var wg sync.WaitGroup + for _, host := range hosts { + sem <- struct{}{} wg.Go(func() { + defer func() { <-sem }() dummy := domain.Node{Server: host} if r, err := resolved(ctx, dummy, s); err == nil { mu.Lock() @@ -122,7 +135,10 @@ func attach(opts *option.Options, ep *option.Endpoint, obs []option.Outbound) { } func dnsServer(s domain.Settings) option.DNSServerOptions { - detour := strings.TrimPrefix(final(s), "direct") + detour := "" + if final(s) == Tag { + detour = Tag + } remote := option.RemoteDNSServerOptions{ RawLocalDNSServerOptions: option.RawLocalDNSServerOptions{ DialerOptions: option.DialerOptions{Detour: detour}, diff --git a/internal/engine/lookup.go b/internal/engine/lookup.go index 7df02e8..d4005ec 100644 --- a/internal/engine/lookup.go +++ b/internal/engine/lookup.go @@ -5,34 +5,16 @@ import ( "fmt" "net" "net/netip" - "sync" "time" "github.com/luynrs/justray/internal/domain" "github.com/luynrs/justray/internal/engine/outbound" ) -type dnsEntry struct { - ip string - exp time.Time -} - -var ( - dnsMu sync.RWMutex - dnsCache = map[string]dnsEntry{} -) - func resolved(ctx context.Context, n domain.Node, s domain.Settings) (domain.Node, error) { if _, err := netip.ParseAddr(n.Server); err == nil { return n, nil } - key := s.IPVersion + ":" + n.Server - dnsMu.RLock() - entry, ok := dnsCache[key] - dnsMu.RUnlock() - if ok && time.Now().Before(entry.exp) { - return withServerIP(n, entry.ip), nil - } ctx, cancel := context.WithTimeout(ctx, 4*time.Second) defer cancel() @@ -44,11 +26,7 @@ func resolved(ctx context.Context, n domain.Node, s domain.Settings) (domain.Nod return n, fmt.Errorf("no addresses for %s", n.Server) } - ip := ips[0].Unmap().String() - dnsMu.Lock() - dnsCache[key] = dnsEntry{ip: ip, exp: time.Now().Add(10 * time.Minute)} - dnsMu.Unlock() - return withServerIP(n, ip), nil + return withServerIP(n, ips[0].Unmap().String()), nil } func withServerIP(n domain.Node, ip string) domain.Node { diff --git a/internal/engine/probe.go b/internal/engine/probe.go index cdf0964..bc405bf 100644 --- a/internal/engine/probe.go +++ b/internal/engine/probe.go @@ -5,7 +5,6 @@ import ( "fmt" "net" "net/http" - "runtime" "sync" "time" @@ -17,6 +16,9 @@ import ( ) func Probe(ctx context.Context, nodes []domain.Node, s domain.Settings, logPath string, onResult func(string, Result)) (map[string]Result, error) { + if len(nodes) > maxProbeNodes { + return nil, fmt.Errorf("too many nodes to probe: %d (maximum %d)", len(nodes), maxProbeNodes) + } if len(nodes) == 0 { return map[string]Result{}, nil } @@ -32,8 +34,7 @@ func Probe(ctx context.Context, nodes []domain.Node, s domain.Settings, logPath defer func() { _ = inst.Close() }() out := map[string]Result{} - workers := min(len(nodes), max(runtime.NumCPU()*2, 4)) - sem := make(chan struct{}, workers) + sem := make(chan struct{}, probeWorkers(len(nodes))) var mu sync.Mutex var wg sync.WaitGroup for i, n := range nodes {