nandi/oripublic Fork 0
35061753be581c0ba47a3189520d64e7788c183d
Commits
Clone
git clone https://git.rickub.com/nandi/ori.git
git clone ssh://git@rickub.com/nandi/ori.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

forked from bots-garden/ori

terminal.go · 149 lines · 4.0 KBGo Blame HistoryRaw
✨ Workspace panel, selectors, previews, desktop app, sandbox template, resizable file tree, light/dark theme 76d62ac k33g yesterday1// Package terminal gives the browser an interactive shell: each WebSocket
2// connection to its handler spawns one shell inside a pseudo-terminal and
3// pipes bytes both ways, which is exactly what xterm.js expects to drive.
4//
5// Wire protocol: the server sends raw terminal output as binary frames; the
6// client sends JSON text frames — {"type":"input","data":"…"} for keystrokes
7// and {"type":"resize","cols":N,"rows":N} when the viewport changes.
8package terminal
9
10import (
11 "context"
12 "encoding/json"
13 "log/slog"
14 "net/http"
15 "os"
16 "os/exec"
17
18 "github.com/coder/websocket"
19 "github.com/creack/pty"
20)
21
22// Service spawns one shell per WebSocket connection.
23type Service struct {
24 // Shell is the command to run; when empty, $SHELL then bash then sh.
25 Shell []string
26 // Cwd is the directory each shell starts in.
27 Cwd string
28
29 logger *slog.Logger
30}
31
32// New creates a terminal service starting shells in cwd.
33//
34// Example:
35//
36// mux.Handle("GET /ws/terminal", terminal.New(cfg.Cwd, logger).WebSocketHandler())
37func New(cwd string, logger *slog.Logger) *Service {
38 if logger == nil {
39 logger = slog.Default()
40 }
41 return &Service{Cwd: cwd, logger: logger}
42}
43
44// controlMessage is what the client sends over text frames.
45type controlMessage struct {
46 Type string `json:"type"`
47 Data string `json:"data,omitempty"`
48 Cols uint16 `json:"cols,omitempty"`
49 Rows uint16 `json:"rows,omitempty"`
50}
51
52// WebSocketHandler upgrades the request and runs one shell session until the
53// socket closes or the shell exits.
54func (s *Service) WebSocketHandler() http.Handler {
55 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
56 conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{
57 OriginPatterns: []string{"localhost:*", "127.0.0.1:*"},
58 })
59 if err != nil {
60 s.logger.Warn("terminal websocket accept failed", "error", err)
61 return
62 }
63 defer func() { _ = conn.CloseNow() }()
64 s.serveShell(r.Context(), conn)
65 })
66}
67
68// serveShell runs the shell and the two pumps for one connection.
69func (s *Service) serveShell(ctx context.Context, conn *websocket.Conn) {
70 cmd := exec.Command(s.shellCommand()[0], s.shellCommand()[1:]...)
71 cmd.Dir = s.Cwd
72 cmd.Env = append(os.Environ(), "TERM=xterm-256color")
73
74 ptmx, err := pty.Start(cmd)
75 if err != nil {
76 s.logger.Error("pty start failed", "error", err)
77 _ = conn.Close(websocket.StatusInternalError, "pty start failed")
78 return
79 }
80 defer func() {
81 _ = ptmx.Close()
82 _ = cmd.Process.Kill()
83 _ = cmd.Wait()
84 }()
85
86 // Output pump: pty → socket, as binary frames.
87 outputDone := make(chan struct{})
88 go func() {
89 defer close(outputDone)
90 buffer := make([]byte, 8192)
91 for {
92 n, readErr := ptmx.Read(buffer)
93 if n > 0 {
94 if writeErr := conn.Write(ctx, websocket.MessageBinary, buffer[:n]); writeErr != nil {
95 return
96 }
97 }
98 if readErr != nil {
99 // The shell exited (or the pty closed): tell the client.
100 _ = conn.Close(websocket.StatusNormalClosure, "shell exited")
101 return
102 }
103 }
104 }()
105
106 // Input pump: socket → pty, until the client disconnects.
107 s.readControlMessages(ctx, conn, ptmx)
108 <-outputDone
109}
110
111// readControlMessages feeds client frames into the pty.
112func (s *Service) readControlMessages(ctx context.Context, conn *websocket.Conn, ptmx *os.File) {
113 for {
114 kind, data, err := conn.Read(ctx)
115 if err != nil {
116 return
117 }
118 if kind != websocket.MessageText {
119 continue
120 }
121 var msg controlMessage
122 if err := json.Unmarshal(data, &msg); err != nil {
123 continue // a malformed frame must not kill the session
124 }
125 switch msg.Type {
126 case "input":
127 _, _ = ptmx.WriteString(msg.Data)
128 case "resize":
129 if msg.Cols > 0 && msg.Rows > 0 {
130 _ = pty.Setsize(ptmx, &pty.Winsize{Cols: msg.Cols, Rows: msg.Rows})
131 }
132 }
133 }
134}
135
136// shellCommand picks the shell to run: the configured one, else $SHELL, else
137// bash, else sh.
138func (s *Service) shellCommand() []string {
139 if len(s.Shell) > 0 {
140 return s.Shell
141 }
142 if shell := os.Getenv("SHELL"); shell != "" {
143 return []string{shell, "-l"}
144 }
145 if path, err := exec.LookPath("bash"); err == nil {
146 return []string{path, "-l"}
147 }
148 return []string{"sh"}
149}