termd/internal/attach/client.go
2026-07-02 22:13:49 -03:00

220 lines
5.2 KiB
Go

// Package attach implements the interactive attach client: it proxies the
// local terminal to a session over the daemon's websocket endpoint.
package attach
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"os/signal"
"strings"
"syscall"
"github.com/coder/websocket"
"golang.org/x/term"
"termd/internal/api"
"termd/internal/client"
)
// control mirrors the server's attach control frames.
type control struct {
Hello *api.SessionInfo `json:"hello,omitempty"`
Resize *api.ResizeRequest `json:"resize,omitempty"`
Exited *struct {
Code int `json:"code"`
} `json:"exited,omitempty"`
}
// Options configures an attach run.
type Options struct {
DetachByte byte // 0x1d = C-], the default
Resize bool // push local terminal size to the session
Write bool // forward keystrokes; default is a read-only view
Render bool // emulator-rendered frames instead of the raw stream
// (for viewers larger than the session)
// I/O; in real use these are os.Stdin/os.Stdout with RawMode set.
Stdin io.Reader
Stdout io.Writer
RawMode bool // put Stdin's fd into raw mode (requires *os.File)
}
// errDetached signals a clean user detach.
var errDetached = errors.New("detached")
// Run attaches to the session until the user detaches, the session ends, or
// ctx is canceled. Returns nil on clean detach/session end.
func Run(ctx context.Context, c *client.Client, id string, opts Options) error {
if opts.DetachByte == 0 {
opts.DetachByte = 0x1d // C-]
}
if opts.Stdin == nil {
opts.Stdin = os.Stdin
}
if opts.Stdout == nil {
opts.Stdout = os.Stdout
}
var params []string
if !opts.Write {
// The server enforces read-only; the client not sending is courtesy.
params = append(params, "readonly=1")
}
if opts.Render {
params = append(params, "view=render")
}
url := c.AttachURL(id)
if len(params) > 0 {
url += "?" + strings.Join(params, "&")
}
conn, _, err := websocket.Dial(ctx, url, &websocket.DialOptions{
HTTPClient: c.HTTPClient(),
HTTPHeader: authHeader(c),
})
if err != nil {
return fmt.Errorf("dialing attach websocket: %w", err)
}
defer conn.CloseNow()
// The repaint snapshot is one big frame; raise the limit generously.
conn.SetReadLimit(16 << 20)
ctx, cancel := context.WithCancel(ctx)
defer cancel()
if opts.RawMode {
f, ok := opts.Stdin.(*os.File)
if !ok {
return fmt.Errorf("raw mode requires stdin to be a real terminal")
}
oldState, err := term.MakeRaw(int(f.Fd()))
if err != nil {
return fmt.Errorf("setting raw mode: %w", err)
}
defer term.Restore(int(f.Fd()), oldState) //nolint:errcheck
}
sendResize := func() {
f, ok := opts.Stdout.(*os.File)
if !ok {
return
}
cols, rows, err := term.GetSize(int(f.Fd()))
if err != nil {
return
}
b, _ := json.Marshal(control{Resize: &api.ResizeRequest{Cols: cols, Rows: rows}})
_ = conn.Write(ctx, websocket.MessageText, b)
}
if opts.Resize {
sendResize()
winch := make(chan os.Signal, 1)
signal.Notify(winch, syscall.SIGWINCH)
defer signal.Stop(winch)
go func() {
for {
select {
case <-winch:
sendResize()
case <-ctx.Done():
return
}
}
}()
}
// stdin → ws, scanning for the detach byte. In read-only mode stdin is
// still consumed (the detach byte must work) but nothing is forwarded.
inErr := make(chan error, 1)
go func() {
buf := make([]byte, 4096)
for {
n, err := opts.Stdin.Read(buf)
if n > 0 {
chunk := buf[:n]
for i, b := range chunk {
if b == opts.DetachByte {
if opts.Write && i > 0 {
if werr := conn.Write(ctx, websocket.MessageBinary, chunk[:i]); werr != nil {
inErr <- werr
return
}
}
inErr <- errDetached
return
}
}
if opts.Write {
if werr := conn.Write(ctx, websocket.MessageBinary, chunk); werr != nil {
inErr <- werr
return
}
}
}
if err != nil {
inErr <- err
return
}
}
}()
// ws → stdout.
outErr := make(chan error, 1)
exited := make(chan int, 1)
go func() {
for {
typ, data, err := conn.Read(ctx)
if err != nil {
outErr <- err
return
}
switch typ {
case websocket.MessageBinary:
if _, err := opts.Stdout.Write(data); err != nil {
outErr <- err
return
}
case websocket.MessageText:
var c control
if err := json.Unmarshal(data, &c); err == nil && c.Exited != nil {
exited <- c.Exited.Code
return
}
}
}
}()
select {
case err := <-inErr:
if errors.Is(err, errDetached) {
return nil
}
return err
case code := <-exited:
// Leave raw mode land before printing (deferred Restore runs after,
// but a trailing CRLF keeps the shell prompt clean either way).
fmt.Fprintf(opts.Stdout, "\r\n[session exited with code %d]\r\n", code)
return nil
case err := <-outErr:
var closeErr websocket.CloseError
if errors.As(err, &closeErr) && closeErr.Code == websocket.StatusNormalClosure {
return nil
}
if errors.Is(err, context.Canceled) {
return nil
}
return fmt.Errorf("connection lost: %w", err)
case <-ctx.Done():
return nil
}
}
func authHeader(c *client.Client) map[string][]string {
if c.Token() == "" {
return nil
}
return map[string][]string{"Authorization": {"Bearer " + c.Token()}}
}