all: share the repeated ssh, viewer and theme plumbing
Signed-off-by: dawn <90008@klbr.net>
33 unmodified lines34353637383839404142434445464748474849505114 unmodified lines33 unmodified linesrequires tssh (or ssh) and tailscale in PATH.`
func dispatch(args []string) error { switch { switch op := os.Getenv("TOBI_RPC"); { case os.Getenv("TOBI_COMPLETE") == "1": return app.Complete(strings.Join(args, " ")) case os.Getenv("TOBI_OWNER") == "1": id := os.Getenv("TOBI_ID") for _, k := range []string{"TOBI_OWNER", "TOBI_ID", "TOBI_RPC", "TOBI_COMPLETE"} { os.Unsetenv(k) } return wrapErr("owner", session.RunOwner(id, args)) case os.Getenv("TOBI_RPC") != "": op := os.Getenv("TOBI_RPC") case op != "": return wrapErr("rpc "+op, remote.RunRPC(op, os.Getenv("TOBI_ID"))) } if len(args) > 0 && (args[0] == "--help" || args[0] == "-h") { fmt.Print(usage)14 unmodified lines1234567819 unmodified lines28293031313233333435363738393940414243211 unmodified linesExpand all25525625725825926026126226325926026126226326426726826526626726827227327427527627727826927027127227328027427527627727810 unmodified lines289290291292299300301302303293294295296297307298299300301package app
import ( "cmp" "context" "encoding/json" "errors" "fmt"19 unmodified lines
func findSessions(ctx context.Context, query string, peers []remote.Peer) []session.Session { raw := strings.TrimSpace(query) q := strings.ToLower(raw) self := strings.ToLower(session.ShortHostname()) t := target.Parse(raw) id, host := strings.ToLower(t.ID), strings.ToLower(t.Host) id := strings.ToLower(t.ID) matchID := func(list []session.Session) []session.Session { return lo.Filter(list, func(s session.Session, _ int) bool { return strings.HasPrefix(strings.ToLower(s.ID), id) }) }
if t.ID != "" || t.User != "" { if host == self || host == "localhost" { if isLocal(t.Host) { return matchID(session.ListLocal()) } peer := lo.ContainsBy(peers, func(p remote.Peer) bool { return strings.EqualFold(p.Name, t.Host) }) || len(remote.KnownDests(t.Host)) > 1 if peer {211 unmodified linesExpand allfunc Spawn(addr string, cmdArgs []string) error { if len(cmdArgs) == 0 { return errors.New("usage: tobi [host.[id]] -- <command> [args...]") }
self := strings.ToLower(session.ShortHostname()) t := target.Parse(strings.TrimSpace(addr)) host := lo.Ternary(t.Host != "", t.Host, self) id := lo.Ternary(t.ID != "", t.ID, session.GenerateID()) t := target.Parse(addr) t.Host = cmp.Or(t.Host, strings.ToLower(session.ShortHostname())) id := cmp.Or(t.ID, session.GenerateID()) if !session.ValidateID(id) { return fmt.Errorf("invalid session id: %q (must be 1-64 alphanumeric, - or _)", id) }
if t.User == "" && (strings.EqualFold(host, self) || strings.EqualFold(host, "localhost")) { if t.User == "" && isLocal(t.Host) { if err := session.EnsureOwner(id, cmdArgs); err != nil { return err } host = session.ShortHostname() } else { t.Host = host host = t.Dest() if err := remote.SpawnRemote(host, id, cmdArgs); err != nil { return err } return connectOrHop(session.Session{ID: id, Host: session.ShortHostname()}) } if err := remote.SpawnRemote(t.Dest(), id, cmdArgs); err != nil { return err } return connectOrHop(session.Session{ID: id, Host: host}) return connectOrHop(session.Session{ID: id, Host: t.Dest()})}
func Complete(rawPrefix string) error { ctx, cancel := context.WithTimeout(context.Background(), 800*time.Millisecond)10 unmodified lines }))}
func hostCandidates(prefix string, peers []remote.Peer) []session.Session { t := target.Parse(strings.TrimSpace(prefix)) q := strings.ToLower(t.Host) name := lo.Ternary(t.User != "", func(n string) string { return t.User + "@" + n }, func(n string) string { return n }) t := target.Parse(prefix) q, user := strings.ToLower(t.Host), lo.Ternary(t.User != "", t.User+"@", "") return lo.FilterMap( append([]remote.Peer{{Name: session.ShortHostname()}}, peers...), func(p remote.Peer, _ int) (session.Session, bool) { return session.Session{Host: name(p.Name)}, strings.HasPrefix(strings.ToLower(p.Name), q) return session.Session{Host: user + p.Name}, strings.HasPrefix(strings.ToLower(p.Name), q) }, )}27 unmodified lines2829303132323334353660 unmodified lines27 unmodified lines m.rows = []hostRow{{name: "selfish", local: true}} for i := range hosts { m.rows = append(m.rows, hostRow{name: fmt.Sprintf("host%03d", i), probing: true, started: time.Now()}) } m.probes, m.pending = make(chan probeResult, hosts), hosts m.probes = make(chan probeResult, hosts) m.move(0)
master, tty, err := pty.Open() if err != nil {60 unmodified lines2 unmodified lines3456789101125 unmodified lines37383940404142434142434445464748495051525354555645464748495051525354555657585960616263646566676869707125 unmodified lines9798991009510110210310421 unmodified lines126127128129125126127128130131132131132133134133136137134135136139137138139140141169 unmodified linesExpand all3113123133143173183193153163173182 unmodified lines321322323324330325326327328329119 unmodified linesExpand all44945045145245845345445546146245645745845946046846947047147247347447546146246346447948046546648246746846947048548648748848947147247347449247547647749549649749849950047847948048150250350450550650750850948248348448548648748848951851952049049149252252349349449552552649649749849950050150250350450550650750850951051117 unmodified lines2 unmodified linesimport ( "context" "errors" "fmt" "image/color" "math/rand/v2" "os" "slices" "strings"25 unmodified lines pill, pick, plain, border lipgloss.Style}
func newTheme(dark bool) theme { c := lipgloss.LightDark(dark) fg := func(pair [2]string) lipgloss.Style { return lipgloss.NewStyle().Foreground(c(lipgloss.Color(pair[0]), lipgloss.Color(pair[1]))) } ld := lipgloss.LightDark(dark) c := func(l, d string) color.Color { return ld(lipgloss.Color(l), lipgloss.Color(d)) } fg := func(l, d string) lipgloss.Style { return lipgloss.NewStyle().Foreground(c(l, d)) } t := theme{ text: fg([2]string{"#4c4f69", "#cdd6f4"}), dim: fg([2]string{"#9ca0b0", "#6c7086"}), lavender: fg([2]string{"#7287fd", "#b4befe"}), mauve: fg([2]string{"#8839ef", "#cba6f7"}), mint: fg([2]string{"#40a02b", "#a6e3a1"}), yellow: fg([2]string{"#df8e1d", "#f9e2af"}), salmon: fg([2]string{"#d20f39", "#f38ba8"}), pill: lipgloss.NewStyle(). Background(c(lipgloss.Color("#7287fd"), lipgloss.Color("#b4befe"))). Foreground(c(lipgloss.Color("#eff1f5"), lipgloss.Color("#1e1e2e"))).Padding(0, 1), pick: lipgloss.NewStyle().Background(c(lipgloss.Color("#ccd0da"), lipgloss.Color("#313244"))), plain: lipgloss.NewStyle(), text: fg("#4c4f69", "#cdd6f4"), dim: fg("#9ca0b0", "#6c7086"), lavender: fg("#7287fd", "#b4befe"), mauve: fg("#8839ef", "#cba6f7"), mint: fg("#40a02b", "#a6e3a1"), yellow: fg("#df8e1d", "#f9e2af"), salmon: fg("#d20f39", "#f38ba8"), pill: lipgloss.NewStyle().Background(c("#7287fd", "#b4befe")).Foreground(c("#eff1f5", "#1e1e2e")).Padding(0, 1), pick: lipgloss.NewStyle().Background(c("#ccd0da", "#313244")), plain: lipgloss.NewStyle(), } t.faint, t.border = t.dim.Faint(true), t.dim return t}
// setTheme restyles the picker and its filter for a light or dark terminalfunc (m *pickerModel) setTheme(dark bool) { m.th = newTheme(dark) st := textinput.DefaultStyles(dark) st.Focused.Placeholder, st.Blurred.Placeholder = m.th.faint, m.th.faint m.filter.SetStyles(st)}
type hostRow struct { name string local bool probing bool25 unmodified lines nextBlink int lookDir int // -1 left, +1 right; persists after the blink holdTicks int // how long the gaze-hold lasts after a blink probes chan probeResult pending int picked *pickEntry killing map[string]bool // keyed by canonical name, in flight notice string // the last kill failure, until the next key}21 unmodified lines m.filter.SetWidth(min(msg.Width, maxUIWidth) - 8) m.filter.SetCursor(m.filter.Position()) // recompute the scroll offset for the new width
case tea.BackgroundColorMsg: m.th = newTheme(msg.IsDark()) st := textinput.DefaultStyles(msg.IsDark()) st.Focused.Placeholder, st.Blurred.Placeholder = m.th.faint, m.th.faint m.filter.SetStyles(st) m.setTheme(msg.IsDark())
case probeResult: r := &m.rows[msg.row] r.probing = false r.unreachable = errors.Is(msg.err, remote.ErrUnreachable) r.duration = msg.duration byNewest(msg.sessions) r.sessions = msg.sessions m.pending-- r := &m.rows[msg.row] r.probing, r.unreachable, r.duration, r.sessions = false, errors.Is(msg.err, remote.ErrUnreachable), msg.duration, msg.sessions m.move(0) if m.pending > 0 { if lo.SomeBy(m.rows, func(row hostRow) bool { return row.probing }) { return m, waitForProbe(m.probes) } return m, nil
169 unmodified linesExpand all if m.notice != "" { b.WriteString(m.th.salmon.MaxWidth(w).Render(m.notice)) return b.String() } hint := func(k, act string) string { return m.th.dim.Render("[") + m.th.text.Render(k) + m.th.dim.Render("] "+act) } hs := m.hints() span := func(hs []pickerHint) int { return lo.SumBy(hs, pickerHint.width) + 3*(len(hs)-1) } for len(hs) > 1 && span(hs) > w { hs = hs[:len(hs)-1]2 unmodified lines for i, h := range hs { if i > 0 { b.WriteString(strings.Repeat(" ", free/gaps+lo.Ternary(i <= free%gaps, 1, 0))) } b.WriteString(hint(h.k, h.act)) b.WriteString(m.th.dim.Render("[") + m.th.text.Render(h.k) + m.th.dim.Render("] "+h.act)) } return b.String()}
119 unmodified linesExpand all}
func runPicker(local []session.Session, initial string) error { peersCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second) peers := remote.DiscoverPeers(peersCtx) peers := remote.DiscoverPeers(peersCtx) // never includes this host cancel()
self := strings.ToLower(session.ShortHostname()) probes := make(chan probeResult, len(peers)) filter := textinput.New() filter.Prompt = "› " filter.Placeholder = "search sessions…" filter.Focus() filter.SetWidth(startWidth - 8) th := newTheme(true) st := textinput.DefaultStyles(true) st.Focused.Placeholder, st.Blurred.Placeholder = th.faint, th.faint filter.SetStyles(st)
if initial = strings.TrimSpace(initial); initial != "" { filter.SetValue(initial) } filter.SetValue(strings.TrimSpace(initial)) m := pickerModel{ filter: filter, width: startWidth, th: th, probes: probes, probes: make(chan probeResult, len(peers)), spinner: spinner.New(spinner.WithSpinner(spinner.Dot)), rows: []hostRow{{name: self, local: true, sessions: local}}, rows: []hostRow{{name: strings.ToLower(session.ShortHostname()), local: true, sessions: local}}, } m.setTheme(true) // dark until the terminal answers the background query m.lookDir, m.nextBlink = []int{-1, 1}[rand.IntN(2)], 30+rand.IntN(31) online := lo.Filter(peers, func(p remote.Peer, _ int) bool { return p.Online && !strings.EqualFold(p.Name, self) }) var offline []remote.Peer if os.Getenv("TOBI_OFFLINE") == "1" { offline = lo.Reject(peers, func(p remote.Peer, _ int) bool { return p.Online || strings.EqualFold(p.Name, self) }) } online, offline := lo.FilterReject(peers, func(p remote.Peer, _ int) bool { return p.Online }) for _, p := range online { for _, dest := range remote.ProbeDests(p.Name) { go probe(m.probes, len(m.rows), dest) m.rows = append(m.rows, hostRow{name: dest, probing: true}) m.rows = append(m.rows, hostRow{name: dest, probing: true, started: time.Now()}) } } for _, p := range offline { m.rows = append(m.rows, hostRow{name: p.Name, peerOffline: true}) } for i := range m.rows { if !m.rows[i].probing { continue if os.Getenv("TOBI_OFFLINE") == "1" { for _, p := range offline { m.rows = append(m.rows, hostRow{name: p.Name, peerOffline: true}) } m.pending++ m.rows[i].started = time.Now() go func(row int, name string) { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() list, err := remote.QueryRemote(ctx, name) probes <- probeResult{row: row, sessions: list, err: err, duration: probeClock(time.Since(m.rows[row].started))} }(i, m.rows[i].name) }
m.move(0) model, err := tea.NewProgram(m).Run() if err != nil { return err } fin := model.(pickerModel) picked := fin.picked switch { case picked == nil: switch p := fin.picked; { case p == nil: return nil case picked.sess != nil: return connectOrHop(*picked.sess) case p.sess != nil: return connectOrHop(*p.sess) } fmt.Fprintln(os.Stderr, fin.th.dim.Render("starting a shell on "+picked.host+"…")) return Spawn(picked.host, loginShell) fmt.Fprintln(os.Stderr, fin.th.dim.Render("starting a shell on "+fin.picked.host+"…")) return Spawn(fin.picked.host, loginShell)}
func probe(probes chan<- probeResult, row int, dest string) { start := time.Now() ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() list, err := remote.QueryRemote(ctx, dest) probes <- probeResult{row: row, sessions: list, err: err, duration: probeClock(time.Since(start))}}
// the target's own login shell, named so the picker need not show the wrappervar loginShell = []string{"sh", "-lc", `printf '\033]2;shell\a'; exec "${SHELL:-/bin/sh}"`}
func relTime(t time.Time) string {17 unmodified lines35 unmodified lines36373839404140414243449 unmodified lines35 unmodified lines return FrameType(h[0]), p, nil}
func WriteFrame(w io.Writer, t FrameType, payload []byte) error { head := append([]byte{byte(t)}, binary.BigEndian.AppendUint32(nil, uint32(len(payload)))...) _, err := w.Write(append(head, payload...)) _, err := w.Write(append(binary.BigEndian.AppendUint32([]byte{byte(t)}, uint32(len(payload))), payload...)) return err}
func EncodeSize(c, r int) []byte {9 unmodified lines87 unmodified lines888990919293929493949596124 unmodified linesExpand all2212222232242272252262272282292 unmodified lines23223323423523623723823924024124224324424524624724824925025125225324624724824925025425525625725835 unmodified lines294295296297294295298299300301298299300302303304305150 unmodified linesExpand all45645745845945946046146246346046146246346446546646747047146846947047147347447547247347447548048148248348448548648748848947647747847948048148287 unmodified lines if len(m[t.Host]) > 0 && m[t.Host][0] == dest { return } m[t.Host] = append([]string{dest}, slices.DeleteFunc(m[t.Host], func(d string) bool { return d == dest })...) if len(m[t.Host]) > 8 { m[t.Host] = m[t.Host][:8] m[t.Host] = m[t.Host][:min(len(m[t.Host]), 8)] } saveHosts(m)}
func saveHosts(m map[string][]string) {124 unmodified linesExpand all var out, errBuf bytes.Buffer c.Stdout, c.Stderr = &out, &errBuf if err := c.Run(); err != nil { var ee *exec.ExitError if (errors.As(err, &ee) && ee.ExitCode() == 255) || errors.Is(err, context.DeadlineExceeded) || ctx.Err() != nil { if (errors.As(err, &ee) && ee.ExitCode() == 255) || ctx.Err() != nil { err = fmt.Errorf("%w: %w", ErrUnreachable, err) } if msg := strings.TrimSpace(errBuf.String()); msg != "" { return out.String(), fmt.Errorf("%w: %s", err, msg)2 unmodified lines } return out.String(), nil}
// sshConfirmed runs opts.cmd and wants marker in its output: a remote that// exits clean without printing it did not do the workfunc sshConfirmed(ctx context.Context, dest string, in io.Reader, opts sshOpts, marker, missing string) error { out, err := sshRun(ctx, dest, in, opts) if err == nil && !strings.Contains(out, marker) { return errors.New(missing) } return err}
func getBinaryPayload(goos, arch string) ([]byte, error) { if data, err := embeddedPayloads.ReadFile(fmt.Sprintf("payloads/tobi-%s-%s.zst", goos, arch)); err == nil && len(data) > 0 { if zr, err := zstd.NewReader(bytes.NewReader(data)); err == nil { defer zr.Close() return io.ReadAll(zr) } } if goos == runtime.GOOS && arch == runtime.GOARCH { self, err := os.Executable() if err != nil { self = os.Args[0] } return os.ReadFile(self) return os.ReadFile(session.Executable()) } return nil, fmt.Errorf("no binary available for %s/%s (build the carrier slices: nix build or scripts/build-universal.sh)", goos, arch)}
35 unmodified lines bin, err := getBinaryPayload(osName, arch) if err != nil { return fmt.Errorf("prepare tobi for %s (%s/%s): %w", dest, osName, arch, err) } out, err = sshRun(ctx, dest, bytes.NewReader(bin), sshOpts{cmd: uploadCmd + " && echo " + uploadMarker, batch: true, timeout: "5"}) if err != nil { upload := sshOpts{cmd: uploadCmd + " && echo " + uploadMarker, batch: true, timeout: "5"} if err := sshConfirmed(ctx, dest, bytes.NewReader(bin), upload, uploadMarker, "upload did not complete"); err != nil { return fmt.Errorf("upload tobi to %s: %w", dest, err) } if !strings.Contains(out, uploadMarker) { return fmt.Errorf("upload tobi to %s: upload did not complete", dest) } remoteInstalled.Store(dest, true) return nil}
150 unmodified linesExpand all
// an older remote tobi takes "kill" for an attach and exits clean, so only the marker proves itfunc KillRemote(ctx context.Context, host, id string) error { dest := hostDest(host) out, err := sshRun(ctx, dest, nil, sshOpts{cmd: rpcCommand("kill", id), batch: true, timeout: "5", master: true}) if err == nil && !strings.Contains(out, killMarker) { err = errors.New("remote tobi is too old to kill sessions") } if err != nil { kill := sshOpts{cmd: rpcCommand("kill", id), batch: true, timeout: "5", master: true} if err := sshConfirmed(ctx, dest, nil, kill, killMarker, "remote tobi is too old to kill sessions"); err != nil { return fmt.Errorf("kill %s.%s: %w", dest, id, err) } return nil}
func SpawnRemote(host, id string, cmdArgs []string) error { if !session.ValidateID(id) { return fmt.Errorf("invalid session id: %q", id) req := spawnRequest{ID: id, Argv: cmdArgs} if err := validateSpawnRequest(req); err != nil { return err } if len(cmdArgs) == 0 { return errors.New("empty command") } dest := hostDest(host) if err := EnsureRemote(context.Background(), dest); err != nil { return err } req, err := json.Marshal(spawnRequest{ID: id, Argv: cmdArgs}) if err != nil { return err } out, err := sshRun(context.Background(), dest, bytes.NewReader(append(req, '\n')), sshOpts{ cmd: rpcCommand("spawn", "") + " && echo " + spawnMarker, timeout: "5", master: true}) if err == nil && !strings.Contains(out, spawnMarker) { err = fmt.Errorf("did not confirm: %s", strings.TrimSpace(out)) } if err != nil { body, _ := json.Marshal(req) // strings only, it cannot fail spawn := sshOpts{cmd: rpcCommand("spawn", "") + " && echo " + spawnMarker, timeout: "5", master: true} if err := sshConfirmed(context.Background(), dest, bytes.NewReader(append(body, '\n')), spawn, spawnMarker, "did not confirm"); err != nil { return fmt.Errorf("spawn on %s: %w", dest, err) } return nil}107 unmodified linesExpand all10810911011111211311211311411511611711912012112212311811912012112210 unmodified lines1331341351361421431371381391401413 unmodified lines145146147148149150151152153154155156157158159160114 unmodified linesExpand all27527627727827727928028128228328428528628728628828929029129229329429329429529629729829930027 unmodified lines32832933033133233333433533633733833934034134234334450 unmodified lines395396397398388389390391392399400401402403404398399400401405406407404405406407408408409410411412185 unmodified linesExpand all598599600601602603604605606607608609610611612613602603604605606607104 unmodified linesExpand all71271371471572671671771872973073173271973473573673772072173974072272372472572672712 unmodified lines107 unmodified linesExpand all if len(argv) == 0 { return errors.New("empty command") } sockPath := SocketPath(id) if conn, err := net.DialTimeout("unix", sockPath, 30*time.Millisecond); err == nil { _ = conn.Close() if answers(sockPath, 30*time.Millisecond) { return nil } if _, err := exec.LookPath(argv[0]); err != nil { return err } self, _ := os.Executable() if self == "" { self = os.Args[0] } cmd := exec.Command(self, argv...) cmd := exec.Command(Executable(), argv...) cmd.Env = append(os.Environ(), "TOBI_OWNER=1", "TOBI_ID="+id) cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true} // the owner is detached, so its stderr is the only way it can explain itself var stderr syncBuffer10 unmodified lines } return fmt.Errorf("owner for session %q %s", id, reason) } for deadline := time.Now().Add(2 * time.Second); time.Now().Before(deadline); time.Sleep(10 * time.Millisecond) { if conn, err := net.DialTimeout("unix", sockPath, 20*time.Millisecond); err == nil { _ = conn.Close() if answers(sockPath, 20*time.Millisecond) { return nil } select { case <-exited:3 unmodified lines } return fail("did not start")}
// Executable is this binary, to re-run as an owner or ship to a remotefunc Executable() string { if self, err := os.Executable(); err == nil { return self } return os.Args[0]}
// read while the child may still be writing, so the copier must not be racedtype syncBuffer struct { mu sync.Mutex buf bytes.Buffer114 unmodified linesExpand all _ = conn.Close() switch { case err != nil: return err case !answers(sock): case !answers(sock, 250*time.Millisecond): return nil case pid <= 0: return fmt.Errorf("session %q outlived the kill and its owner pid is unknown", id) } if err := syscall.Kill(pid, syscall.SIGTERM); err != nil { return fmt.Errorf("signal the owner of %q: %w", id, err) } for deadline := time.Now().Add(time.Second); time.Now().Before(deadline); time.Sleep(20 * time.Millisecond) { if !answers(sock) { if !answers(sock, 250*time.Millisecond) { return nil } } return fmt.Errorf("session %q outlived SIGTERM", id)}
func answers(sock string) bool { c, err := net.DialTimeout("unix", sock, 250*time.Millisecond)func answers(sock string, timeout time.Duration) bool { c, err := net.DialTimeout("unix", sock, timeout) if err == nil { _ = c.Close() } return err == nil27 unmodified lines rows int colors map[int]termcolor.Color}
// send queues msg, dropping a viewer too slow to keep upfunc (vc *viewerClient) send(msg outMsg) { select { case vc.ch <- msg: default: vc.Close() }}
func (vc *viewerClient) Close() { vc.once.Do(func() { close(vc.done) _ = vc.conn.Close()50 unmodified lines}
func (o *Owner) outputLocked(p []byte) { for vc := range o.viewers { select { case vc.ch <- outMsg{t: protocol.TypeOutput, p: p}: default: vc.Close() } vc.send(outMsg{t: protocol.TypeOutput, p: p}) }}
func (o *Owner) hop(p []byte) { o.mu.Lock() viewers := make([]*viewerClient, 0, len(o.viewers)) for vc := range o.viewers { viewers = append(viewers, vc) } viewers := lo.Keys(o.viewers) o.mu.Unlock() for _, vc := range viewers { select { case vc.ch <- outMsg{t: protocol.TypeHop, p: p}: default: vc.Close() } vc.send(outMsg{t: protocol.TypeHop, p: p}) }}
func (o *Owner) addViewer(conn net.Conn, cols, rows int, colors map[int]termcolor.Color) (*viewerClient, []byte) {185 unmodified linesExpand all return }
cols, rows, _ := protocol.DecodeSize(payload) if cols < 1 { cols = 80 } if rows < 1 { rows = 24 } var colors map[int]termcolor.Color if len(payload) > 4 { colors, _ = termcolor.DecodeColors(payload[4:]) }
vc, snap := o.addViewer(conn, cols, rows, colors) colors, _ := termcolor.DecodeColors(payload[min(4, len(payload)):]) vc, snap := o.addViewer(conn, cmp.Or(cols, 80), cmp.Or(rows, 24), colors) if vc == nil { _ = conn.Close() return }104 unmodified linesExpand all _, _ = owner.ptyFile.Write([]byte(s)) }) vt.OnColor(func(events []xterm.ColorEvent) { for _, ev := range events { if ev.Type != xterm.ColorRequestReport { if ev.Type != xterm.ColorRequestReport || ev.Index < 0 || ev.Index > int(xterm.SpecialColorCursor) { continue } i := ev.Index if i < 0 || i > int(xterm.SpecialColorCursor) { continue } owner.colorMu.Lock() sets := make([]map[int]termcolor.Color, 0, len(owner.colorSets)) for _, set := range owner.colorSets { sets = append(sets, set) } sets := lo.Values(owner.colorSets) owner.colorMu.Unlock() if c, ok := termcolor.Agreed(i, sets); ok { fmt.Fprint(owner.ptyFile, termcolor.Reply(i, c)) if c, ok := termcolor.Agreed(ev.Index, sets); ok { fmt.Fprint(owner.ptyFile, termcolor.Reply(ev.Index, c)) } } }) owner.idleTimer = time.AfterFunc(time.Duration(detachedTTL.Load()), owner.Close)12 unmodified lines120 unmodified linesExpand all121122123124125125126127128129165 unmodified linesExpand all120 unmodified linesExpand all if err := owner.Start(); err != nil { t.Fatal(err) } t.Cleanup(func() { _ = owner.Process.Kill() }) for i := 0; i < 100 && !answers(SocketPath(id)); i++ { for i := 0; i < 100 && !answers(SocketPath(id), 250*time.Millisecond); i++ { time.Sleep(20 * time.Millisecond) } if err := Kill(id); err != nil { t.Fatal(err)165 unmodified linesExpand all92 unmodified lines9394959697989910097989910010153 unmodified lines15515615715816216316416515916016116216316416547 unmodified lines92 unmodified lines case "10", "11", "12": if len(parts) != 2 { return 0, Color{}, false } n, err := strconv.Atoi(parts[0]) if err != nil { return 0, Color{}, false } n, _ := strconv.Atoi(parts[0]) // one of the case labels c, ok := parseColor(parts[1]) return Foreground + n - 10, c, ok } return 0, Color{}, false53 unmodified lines keys := slices.Sorted(maps.Keys(colors)) b := []byte{colorsVersion, byte(len(keys))} for _, k := range keys { c := colors[k] b = binary.BigEndian.AppendUint16(b, uint16(k)) b = binary.BigEndian.AppendUint16(b, c.R) b = binary.BigEndian.AppendUint16(b, c.G) b = binary.BigEndian.AppendUint16(b, c.B) for _, v := range []uint16{uint16(k), c.R, c.G, c.B} { b = binary.BigEndian.AppendUint16(b, v) } } return b}
47 unmodified lines