From fd6c1c4e0d6ea33f3582031ff4e041a6a5a932d9 Mon Sep 17 00:00:00 2001 From: GitHub Copilot Date: Sat, 14 Feb 2026 16:18:23 +0000 Subject: [PATCH] feat: complete Go port with go-te terminal emulator Full Go implementation under go/ replacing Python pyte with go-te: - HTTP server with WebSocket protocol, SSE, screenshot SVG rendering - PTY terminal sessions and Docker exec sessions - Docker watcher (label-based container discovery + event stream) - CPU stats collection with sparkline SVG rendering - Session manager with TwoWayMap routing and replay buffers - C1 normalization, DA filtering, identity generation, theme palettes Audit fixes for 9 concurrency/correctness issues: - HTTP transport leak: shared client pool for Docker socket calls - WebSocket concurrent writes: all writes routed through send channel - Closed channel panic: atomic.Bool guard on enqueueWSData - GetFirstRunningSession: use UnsafeForward under SessionManager lock - NewSession TOCTOU: re-check routeKey after re-acquiring lock - waitErr data race: protect with mutex in both session types - Replay buffer fragmentation: copy to new slice on eviction - go-te dirty tracking: check screen.Dirty before incrementing counter - Identity modulo bias: rejection sampling for uniform distribution All Go tests pass (including -race). Python baseline unchanged (397 tests). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- README.md | 4 +- go/cmd/webterm/main.go | 15 + go/go.mod | 18 + go/go.sum | 20 + go/terminalstate/tracker.go | 153 ++++++ go/terminalstate/tracker_test.go | 53 ++ go/webterm/cli.go | 106 ++++ go/webterm/cli_test.go | 9 + go/webterm/config.go | 146 +++++ go/webterm/config_test.go | 62 +++ go/webterm/constants.go | 38 ++ go/webterm/docker_exec_session.go | 340 ++++++++++++ go/webterm/docker_http.go | 90 ++++ go/webterm/docker_stats.go | 387 +++++++++++++ go/webterm/docker_stats_test.go | 36 ++ go/webterm/docker_watcher.go | 276 ++++++++++ go/webterm/docker_watcher_test.go | 23 + go/webterm/identity.go | 36 ++ go/webterm/normalize.go | 97 ++++ go/webterm/normalize_test.go | 55 ++ go/webterm/replay.go | 51 ++ go/webterm/replay_test.go | 13 + go/webterm/server.go | 834 +++++++++++++++++++++++++++++ go/webterm/server_test.go | 216 ++++++++ go/webterm/session.go | 34 ++ go/webterm/session_manager.go | 233 ++++++++ go/webterm/session_manager_test.go | 36 ++ go/webterm/shellsplit.go | 7 + go/webterm/slugify.go | 18 + go/webterm/svg_exporter.go | 150 ++++++ go/webterm/svg_exporter_test.go | 23 + go/webterm/terminal_session.go | 257 +++++++++ go/webterm/test_helpers_test.go | 102 ++++ go/webterm/themes.go | 219 ++++++++ go/webterm/twoway.go | 72 +++ 35 files changed, 4228 insertions(+), 1 deletion(-) create mode 100644 go/cmd/webterm/main.go create mode 100644 go/go.mod create mode 100644 go/go.sum create mode 100644 go/terminalstate/tracker.go create mode 100644 go/terminalstate/tracker_test.go create mode 100644 go/webterm/cli.go create mode 100644 go/webterm/cli_test.go create mode 100644 go/webterm/config.go create mode 100644 go/webterm/config_test.go create mode 100644 go/webterm/constants.go create mode 100644 go/webterm/docker_exec_session.go create mode 100644 go/webterm/docker_http.go create mode 100644 go/webterm/docker_stats.go create mode 100644 go/webterm/docker_stats_test.go create mode 100644 go/webterm/docker_watcher.go create mode 100644 go/webterm/docker_watcher_test.go create mode 100644 go/webterm/identity.go create mode 100644 go/webterm/normalize.go create mode 100644 go/webterm/normalize_test.go create mode 100644 go/webterm/replay.go create mode 100644 go/webterm/replay_test.go create mode 100644 go/webterm/server.go create mode 100644 go/webterm/server_test.go create mode 100644 go/webterm/session.go create mode 100644 go/webterm/session_manager.go create mode 100644 go/webterm/session_manager_test.go create mode 100644 go/webterm/shellsplit.go create mode 100644 go/webterm/slugify.go create mode 100644 go/webterm/svg_exporter.go create mode 100644 go/webterm/svg_exporter_test.go create mode 100644 go/webterm/terminal_session.go create mode 100644 go/webterm/test_helpers_test.go create mode 100644 go/webterm/themes.go create mode 100644 go/webterm/twoway.go diff --git a/README.md b/README.md index 47cac61..9ded018 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ Coupled with [`agentbox`](https://github.com/rcarmo/agentbox), you can use it to ## Known Issues -- `pyte` (the library used to capture the underlying terminal state for screenshots) does not implement some standard escape sequences, resulting in occasionally mis-rendered screenshots. We monkeypatch pyte at runtime to add missing support (CSI S/T scroll, alternate screen buffers, etc.) — see [docs/pyte-patches.md](docs/pyte-patches.md) for details. I'm waiting for `libghostty-vt` to be finished to port this whole thing to Go (or even plain C) and have full fidelity. +- `pyte` (the library used to capture the underlying terminal state for screenshots) does not implement some standard escape sequences, resulting in occasionally mis-rendered screenshots. We monkeypatch pyte at runtime to add missing support (CSI S/T scroll, alternate screen buffers, etc.) — see [docs/pyte-patches.md](docs/pyte-patches.md) for details. ## Installation @@ -268,6 +268,7 @@ make bundle-watch - WebSocket protocol (browser ↔ server) is JSON: `["stdin", data]`, `["resize", {"width": w, "height": h}]`, `["ping", data]`. - Frontend source is in `src/webterm/static/js/terminal.ts`. - Screenshots use [pyte](https://github.com/selectel/pyte) for ANSI interpretation and custom SVG rendering. `AltScreen` adds alternate screen buffer support, [CSI S/T scroll handling, and Ink partial clear expansion](docs/pyte-patches.md). +- Go runtime port is in `go/webterm` (entrypoint: `go/cmd/webterm`), using [go-te](https://github.com/rcarmo/go-te) for terminal state and screenshots. - CPU stats are read directly from Docker socket using asyncio (no additional dependencies). ## Requirements @@ -285,3 +286,4 @@ MIT License - see [LICENSE](LICENSE) for details. - [ghostty-web](https://github.com/rcarmo/ghostty-web) - Patched Ghostty terminal for the web (vendored fork with theme support) - [ghostty-web upstream](https://github.com/coder/ghostty-web) - Original Ghostty terminal for the web - [pyte](https://github.com/selectel/pyte) - PYTE terminal emulator (used for SVG screenshots) +- [go-te](https://github.com/rcarmo/go-te) - Go VT terminal emulator used for the in-progress port diff --git a/go/cmd/webterm/main.go b/go/cmd/webterm/main.go new file mode 100644 index 0000000..f9aaade --- /dev/null +++ b/go/cmd/webterm/main.go @@ -0,0 +1,15 @@ +package main + +import ( + "fmt" + "os" + + "github.com/rcarmo/webterm-go-port/webterm" +) + +func main() { + if err := webterm.RunCLI(os.Args[1:]); err != nil { + _, _ = fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} diff --git a/go/go.mod b/go/go.mod new file mode 100644 index 0000000..c7ffde0 --- /dev/null +++ b/go/go.mod @@ -0,0 +1,18 @@ +module github.com/rcarmo/webterm-go-port + +go 1.24.0 + +require ( + github.com/creack/pty v1.1.18 + github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 + github.com/gorilla/websocket v1.5.3 + github.com/rcarmo/go-te v0.0.0-20260214100434-edf070e453db + gopkg.in/yaml.v3 v3.0.1 +) + +require ( + github.com/clipperhouse/uax29/v2 v2.2.0 // indirect + github.com/mattn/go-runewidth v0.0.19 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + golang.org/x/text v0.34.0 // indirect +) diff --git a/go/go.sum b/go/go.sum new file mode 100644 index 0000000..0798803 --- /dev/null +++ b/go/go.sum @@ -0,0 +1,20 @@ +github.com/clipperhouse/uax29/v2 v2.2.0 h1:ChwIKnQN3kcZteTXMgb1wztSgaU+ZemkgWdohwgs8tY= +github.com/clipperhouse/uax29/v2 v2.2.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= +github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY= +github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= +github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= +github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= +github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/rcarmo/go-te v0.0.0-20260214100434-edf070e453db h1:wJrQ4ABeQey49QEDHbHHZISpXoC5OtIeihEF4WA2M14= +github.com/rcarmo/go-te v0.0.0-20260214100434-edf070e453db/go.mod h1:cLsrtroxCubS+OHHwH0riB6xeNESfntaHEeI1jPAedk= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/go/terminalstate/tracker.go b/go/terminalstate/tracker.go new file mode 100644 index 0000000..0c12496 --- /dev/null +++ b/go/terminalstate/tracker.go @@ -0,0 +1,153 @@ +package terminalstate + +import ( + "fmt" + "strconv" + "strings" + "sync" + + "github.com/rcarmo/go-te/pkg/te" +) + +var ansi16Names = [...]string{ + "black", + "red", + "green", + "yellow", + "blue", + "magenta", + "cyan", + "white", + "brightblack", + "brightred", + "brightgreen", + "brightyellow", + "brightblue", + "brightmagenta", + "brightcyan", + "brightwhite", +} + +type Cell struct { + Data string `json:"data"` + FG string `json:"fg"` + BG string `json:"bg"` + Bold bool `json:"bold"` + Italics bool `json:"italics"` + Underscore bool `json:"underscore"` + Reverse bool `json:"reverse"` +} + +type Snapshot struct { + Width int `json:"width"` + Height int `json:"height"` + Buffer [][]Cell `json:"buffer"` + HasChanges bool `json:"has_changes"` +} + +type Tracker struct { + mu sync.Mutex + screen *te.DiffScreen + stream *te.ByteStream + changeCounter uint64 + lastSnapshotCounter uint64 +} + +func NewTracker(width, height int) *Tracker { + screen := te.NewDiffScreen(width, height) + return &Tracker{ + screen: screen, + stream: te.NewByteStream(screen, false), + } +} + +func (t *Tracker) Feed(data []byte) error { + if len(data) == 0 { + return nil + } + t.mu.Lock() + defer t.mu.Unlock() + if err := t.stream.Feed(data); err != nil { + return err + } + if len(t.screen.Dirty) > 0 { + t.changeCounter++ + // Clear dirty set so subsequent feeds detect new changes + for k := range t.screen.Dirty { + delete(t.screen.Dirty, k) + } + } + return nil +} + +func (t *Tracker) Resize(width, height int) { + t.mu.Lock() + defer t.mu.Unlock() + if width == t.screen.Columns && height == t.screen.Lines { + return + } + t.screen.Resize(height, width) + t.changeCounter++ +} + +func (t *Tracker) Snapshot() Snapshot { + t.mu.Lock() + defer t.mu.Unlock() + + snapshot := Snapshot{ + Width: t.screen.Columns, + Height: t.screen.Lines, + HasChanges: t.changeCounter > t.lastSnapshotCounter, + Buffer: make([][]Cell, t.screen.Lines), + } + t.lastSnapshotCounter = t.changeCounter + + for row := 0; row < t.screen.Lines; row++ { + line := make([]Cell, t.screen.Columns) + for col := 0; col < t.screen.Columns; col++ { + raw := t.screen.Buffer[row][col] + data := raw.Data + if data == "" { + data = " " + } + line[col] = Cell{ + Data: data, + FG: colorToString(raw.Attr.Fg), + BG: colorToString(raw.Attr.Bg), + Bold: raw.Attr.Bold, + Italics: raw.Attr.Italics, + Underscore: raw.Attr.Underline, + Reverse: raw.Attr.Reverse, + } + } + snapshot.Buffer[row] = line + } + return snapshot +} + +func colorToString(color te.Color) string { + if color.Name != "" { + name := strings.ToLower(strings.TrimPrefix(color.Name, "#")) + if len(name) == 6 { + if _, err := strconv.ParseUint(name, 16, 32); err == nil { + return name + } + } + return name + } + switch color.Mode { + case te.ColorDefault: + return "default" + case te.ColorANSI16: + if int(color.Index) < len(ansi16Names) { + return ansi16Names[color.Index] + } + return "default" + case te.ColorANSI256: + return fmt.Sprintf("%d", color.Index) + case te.ColorTrueColor: + return "default" + default: + return "default" + } +} diff --git a/go/terminalstate/tracker_test.go b/go/terminalstate/tracker_test.go new file mode 100644 index 0000000..b54f64a --- /dev/null +++ b/go/terminalstate/tracker_test.go @@ -0,0 +1,53 @@ +package terminalstate + +import "testing" + +func TestTrackerSnapshotChangeTracking(t *testing.T) { + tracker := NewTracker(10, 3) + if err := tracker.Feed([]byte("hi")); err != nil { + t.Fatalf("Feed() error = %v", err) + } + + snapshot := tracker.Snapshot() + if !snapshot.HasChanges { + t.Fatalf("expected first snapshot to report changes") + } + if got := snapshot.Buffer[0][0].Data; got != "h" { + t.Fatalf("expected first cell to be h, got %q", got) + } + if got := snapshot.Buffer[0][1].Data; got != "i" { + t.Fatalf("expected second cell to be i, got %q", got) + } + + again := tracker.Snapshot() + if again.HasChanges { + t.Fatalf("expected second snapshot without new input to report no changes") + } +} + +func TestTrackerAnsiStyles(t *testing.T) { + tracker := NewTracker(10, 3) + if err := tracker.Feed([]byte("\x1b[31;1mA\x1b[0m")); err != nil { + t.Fatalf("Feed() error = %v", err) + } + snapshot := tracker.Snapshot() + cell := snapshot.Buffer[0][0] + if !cell.Bold { + t.Fatalf("expected bold attribute to be true") + } + if cell.FG != "red" { + t.Fatalf("expected red foreground, got %q", cell.FG) + } +} + +func TestTrackerResize(t *testing.T) { + tracker := NewTracker(10, 3) + tracker.Resize(20, 4) + snapshot := tracker.Snapshot() + if snapshot.Width != 20 || snapshot.Height != 4 { + t.Fatalf("unexpected dimensions: got %dx%d", snapshot.Width, snapshot.Height) + } + if !snapshot.HasChanges { + t.Fatalf("expected resize to mark snapshot as changed") + } +} diff --git a/go/webterm/cli.go b/go/webterm/cli.go new file mode 100644 index 0000000..ee54f47 --- /dev/null +++ b/go/webterm/cli.go @@ -0,0 +1,106 @@ +package webterm + +import ( + "context" + "flag" + "fmt" + "os" + "os/signal" + "path/filepath" + "strings" + "syscall" +) + +func RunCLI(args []string) error { + fs := flag.NewFlagSet("webterm", flag.ContinueOnError) + fs.SetOutput(os.Stdout) + + port := DefaultPort + host := DefaultHost + landingManifest := "" + composeManifest := "" + dockerWatch := false + theme := DefaultTheme + fontFamily := "" + fontSize := DefaultFontSize + showVersion := false + + fs.IntVar(&port, "port", DefaultPort, "Port for server.") + fs.IntVar(&port, "p", DefaultPort, "Port for server.") + fs.StringVar(&host, "host", DefaultHost, "Host for server.") + fs.StringVar(&host, "H", DefaultHost, "Host for server.") + fs.StringVar(&landingManifest, "landing-manifest", "", "YAML manifest describing landing page tiles.") + fs.StringVar(&landingManifest, "L", "", "YAML manifest describing landing page tiles.") + fs.StringVar(&composeManifest, "compose-manifest", "", "Docker compose YAML; services with label \"webterm-command\" become landing tiles.") + fs.StringVar(&composeManifest, "C", "", "Docker compose YAML; services with label \"webterm-command\" become landing tiles.") + fs.BoolVar(&dockerWatch, "docker-watch", false, "Watch Docker for containers with labels and add/remove sessions dynamically.") + fs.BoolVar(&dockerWatch, "D", false, "Watch Docker for containers with labels and add/remove sessions dynamically.") + fs.StringVar(&theme, "theme", DefaultTheme, "Terminal color theme.") + fs.StringVar(&theme, "t", DefaultTheme, "Terminal color theme.") + fs.StringVar(&fontFamily, "font-family", "", "Terminal font family (CSS font stack).") + fs.StringVar(&fontFamily, "f", "", "Terminal font family (CSS font stack).") + fs.IntVar(&fontSize, "font-size", DefaultFontSize, "Terminal font size in pixels.") + fs.IntVar(&fontSize, "s", DefaultFontSize, "Terminal font size in pixels.") + fs.BoolVar(&showVersion, "version", false, "Print version and exit.") + fs.BoolVar(&showVersion, "v", false, "Print version and exit.") + + if err := fs.Parse(args); err != nil { + return err + } + if showVersion { + _, _ = fmt.Fprintln(os.Stdout, "0.0.0") + return nil + } + + command := strings.TrimSpace(strings.Join(fs.Args(), " ")) + config := DefaultConfig() + landingApps := []App{} + composeMode := false + composeProject := "" + + if landingManifest != "" { + apps, err := LoadLandingYAML(landingManifest) + if err != nil { + return err + } + landingApps = apps + } + if composeManifest != "" { + apps, project, err := LoadComposeManifest(composeManifest) + if err != nil { + return err + } + landingApps = apps + composeMode = true + composeProject = project + } + if composeProject == "" && composeManifest != "" { + composeProject = filepath.Base(filepath.Dir(composeManifest)) + } + + server := NewLocalServer(config, ServerOptions{ + Host: host, + Port: port, + Theme: theme, + FontFamily: fontFamily, + FontSize: fontSize, + LandingApps: landingApps, + ComposeMode: composeMode, + ComposeProject: composeProject, + DockerWatch: dockerWatch, + }) + + if command != "" { + server.sessionManager.AddApp("Terminal", command, "", true, "") + } else if !dockerWatch && len(landingApps) == 0 { + shell := os.Getenv("SHELL") + if shell == "" { + shell = "/bin/sh" + } + server.sessionManager.AddApp("Terminal", shell, "", true, "") + } + + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + return server.Run(ctx) +} diff --git a/go/webterm/cli_test.go b/go/webterm/cli_test.go new file mode 100644 index 0000000..4fefaac --- /dev/null +++ b/go/webterm/cli_test.go @@ -0,0 +1,9 @@ +package webterm + +import "testing" + +func TestRunCLIVersion(t *testing.T) { + if err := RunCLI([]string{"--version"}); err != nil { + t.Fatalf("RunCLI(--version) error = %v", err) + } +} diff --git a/go/webterm/config.go b/go/webterm/config.go new file mode 100644 index 0000000..58bfbf3 --- /dev/null +++ b/go/webterm/config.go @@ -0,0 +1,146 @@ +package webterm + +import ( + "errors" + "os" + "path/filepath" + + "gopkg.in/yaml.v3" +) + +type App struct { + Name string `yaml:"name" json:"name"` + Slug string `yaml:"slug" json:"slug"` + Path string `yaml:"path" json:"-"` + Color string `yaml:"color" json:"-"` + Command string `yaml:"command" json:"command"` + Terminal bool `yaml:"terminal" json:"-"` + Theme string `yaml:"theme" json:"-"` +} + +type Config struct { + Apps []App +} + +func DefaultConfig() Config { + return Config{Apps: []App{}} +} + +func LoadLandingYAML(manifestPath string) ([]App, error) { + data, err := os.ReadFile(manifestPath) + if err != nil { + return nil, err + } + var entries []map[string]any + if err := yaml.Unmarshal(data, &entries); err != nil { + return nil, err + } + apps := make([]App, 0, len(entries)) + for _, entry := range entries { + name, _ := entry["name"].(string) + command, _ := entry["command"].(string) + if name == "" || command == "" { + continue + } + slug := asString(entry["slug"]) + if slug == "" { + slug = Slugify(name) + } + path := asString(entry["path"]) + if path == "" { + path = "./" + } + terminal := true + if value, ok := entry["terminal"].(bool); ok { + terminal = value + } + apps = append(apps, App{ + Name: name, + Slug: slug, + Command: command, + Path: path, + Color: asString(entry["color"]), + Terminal: terminal, + Theme: asString(entry["theme"]), + }) + } + return apps, nil +} + +func LoadComposeManifest(manifestPath string) ([]App, string, error) { + data, err := os.ReadFile(manifestPath) + if err != nil { + return nil, "", err + } + var root map[string]any + if err := yaml.Unmarshal(data, &root); err != nil { + return nil, "", err + } + servicesAny, ok := root["services"] + if !ok { + return []App{}, filepath.Base(filepath.Dir(manifestPath)), nil + } + services, ok := servicesAny.(map[string]any) + if !ok { + return nil, "", errors.New("compose services must be mapping") + } + apps := make([]App, 0, len(services)) + for name, serviceAny := range services { + service, ok := serviceAny.(map[string]any) + if !ok { + continue + } + command := extractLabel(service["labels"], "webterm-command") + if command == "" { + continue + } + theme := extractLabel(service["labels"], "webterm-theme") + workingDir := asString(service["working_dir"]) + if workingDir == "" { + workingDir = "./" + } + apps = append(apps, App{ + Name: name, + Slug: Slugify(name), + Command: command, + Path: workingDir, + Terminal: true, + Theme: theme, + }) + } + return apps, filepath.Base(filepath.Dir(manifestPath)), nil +} + +func extractLabel(labels any, key string) string { + switch raw := labels.(type) { + case map[string]any: + return asString(raw[key]) + case []any: + for _, item := range raw { + text, ok := item.(string) + if !ok { + continue + } + for i := 0; i < len(text); i++ { + if text[i] != '=' { + continue + } + if text[:i] == key { + return text[i+1:] + } + break + } + } + } + return "" +} + +func asString(value any) string { + if value == nil { + return "" + } + if s, ok := value.(string); ok { + return os.ExpandEnv(s) + } + return "" +} diff --git a/go/webterm/config_test.go b/go/webterm/config_test.go new file mode 100644 index 0000000..e3c6b25 --- /dev/null +++ b/go/webterm/config_test.go @@ -0,0 +1,62 @@ +package webterm + +import ( + "os" + "path/filepath" + "testing" +) + +func TestLoadLandingYAML(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "landing.yaml") + content := ` +- name: Shell + command: /bin/sh + slug: shell +- name: Missing Command +` + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + apps, err := LoadLandingYAML(path) + if err != nil { + t.Fatalf("LoadLandingYAML() error = %v", err) + } + if len(apps) != 1 { + t.Fatalf("expected 1 app, got %d", len(apps)) + } + if apps[0].Slug != "shell" || apps[0].Command != "/bin/sh" { + t.Fatalf("unexpected app: %+v", apps[0]) + } +} + +func TestLoadComposeManifestReadsLabels(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "compose.yaml") + content := ` +services: + web: + labels: + webterm-command: auto + webterm-theme: monokai + db: + labels: + - webterm-command=docker exec -it db psql +` + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + apps, project, err := LoadComposeManifest(path) + if err != nil { + t.Fatalf("LoadComposeManifest() error = %v", err) + } + if project != filepath.Base(dir) { + t.Fatalf("unexpected project name: %q", project) + } + if len(apps) != 2 { + t.Fatalf("expected 2 apps, got %d", len(apps)) + } + if apps[0].Theme != "monokai" && apps[1].Theme != "monokai" { + t.Fatalf("expected theme to be parsed") + } +} diff --git a/go/webterm/constants.go b/go/webterm/constants.go new file mode 100644 index 0000000..e5a7616 --- /dev/null +++ b/go/webterm/constants.go @@ -0,0 +1,38 @@ +package webterm + +import ( + "os" + "runtime" + "strings" +) + +const ( + DefaultHost = "0.0.0.0" + DefaultPort = 8080 + DefaultTheme = "xterm" + DefaultFontSize = 16 + DefaultTerminalWidth = 132 + DefaultTerminalHeight = 45 + + ScreenshotForceRedrawEnv = "WEBTERM_SCREENSHOT_FORCE_REDRAW" + DockerUsernameEnv = "WEBTERM_DOCKER_USERNAME" + DockerAutoCommandEnv = "WEBTERM_DOCKER_AUTO_COMMAND" + DockerHostEnv = "DOCKER_HOST" + + AutoCommandSentinel = "__docker_exec__" +) + +var Windows = runtime.GOOS == "windows" + +func EnvBool(name string) bool { + v, ok := os.LookupEnv(name) + if !ok { + return false + } + switch strings.ToLower(strings.TrimSpace(v)) { + case "1", "true", "yes", "on": + return true + default: + return false + } +} diff --git a/go/webterm/docker_exec_session.go b/go/webterm/docker_exec_session.go new file mode 100644 index 0000000..2ebb69e --- /dev/null +++ b/go/webterm/docker_exec_session.go @@ -0,0 +1,340 @@ +package webterm + +import ( + "bufio" + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "net/http" + "net/url" + "strings" + "sync" + + "github.com/rcarmo/webterm-go-port/terminalstate" +) + +type DockerExecSpec struct { + Container string + Command []string + User string +} + +type readBufferedConn struct { + net.Conn + reader *bufio.Reader +} + +func (r *readBufferedConn) Read(p []byte) (int, error) { + return r.reader.Read(p) +} + +type DockerExecSession struct { + sessionID string + spec DockerExecSpec + socket string + + mu sync.RWMutex + connector SessionConnector + execID string + conn net.Conn + tracker *terminalstate.Tracker + replay *ReplayBuffer + escapeBuffer []byte + utf8Buffer []byte + width int + height int + running bool + started bool + done chan struct{} + doneOnce sync.Once + waitErr error + writeMu sync.Mutex +} + +func NewDockerExecSession(sessionID string, spec DockerExecSpec, socketPath string) *DockerExecSession { + if socketPath == "" { + socketPath = DockerSocketPath() + } + return &DockerExecSession{ + sessionID: sessionID, + spec: spec, + socket: socketPath, + connector: noopConnector{}, + replay: NewReplayBuffer(replayBufferSize), + done: make(chan struct{}), + width: DefaultTerminalWidth, + height: DefaultTerminalHeight, + } +} + +func (s *DockerExecSession) Open(width, height int) error { + if width <= 0 { + width = 80 + } + if height <= 0 { + height = 24 + } + execID, err := s.createExec() + if err != nil { + return err + } + conn, err := s.startExecSocket(execID) + if err != nil { + return err + } + s.mu.Lock() + s.execID = execID + s.conn = conn + s.tracker = terminalstate.NewTracker(width, height) + s.width = width + s.height = height + s.running = true + s.mu.Unlock() + _ = s.resizeExec(width, height) + return nil +} + +func (s *DockerExecSession) Start(connector SessionConnector) error { + s.mu.Lock() + if connector != nil { + s.connector = connector + } + if s.started { + s.mu.Unlock() + return nil + } + if s.conn == nil { + s.mu.Unlock() + return errors.New("docker session not open") + } + s.started = true + conn := s.conn + s.mu.Unlock() + go s.readLoop(conn) + return nil +} + +func (s *DockerExecSession) readLoop(conn net.Conn) { + buf := make([]byte, 32*1024) + for { + n, err := conn.Read(buf) + if n > 0 { + s.handleOutput(buf[:n]) + } + if err != nil { + if !errors.Is(err, io.EOF) { + s.mu.Lock() + s.waitErr = err + s.mu.Unlock() + } + break + } + } + s.mu.Lock() + s.running = false + connector := s.connector + s.mu.Unlock() + connector.OnClose() + s.doneOnce.Do(func() { close(s.done) }) +} + +func (s *DockerExecSession) handleOutput(data []byte) { + s.mu.Lock() + filtered, escapeBuffer := FilterDASequences(data, s.escapeBuffer) + s.escapeBuffer = escapeBuffer + normalized, utf8Buffer := NormalizeC1Controls(filtered, s.utf8Buffer) + s.utf8Buffer = utf8Buffer + tracker := s.tracker + connector := s.connector + s.mu.Unlock() + if len(normalized) == 0 { + return + } + s.replay.Add(normalized) + if tracker != nil { + _ = tracker.Feed(normalized) + } + connector.OnData(normalized) +} + +func (s *DockerExecSession) createExec() (string, error) { + payload := map[string]any{ + "AttachStdin": true, + "AttachStdout": true, + "AttachStderr": true, + "Tty": true, + "Cmd": s.spec.Command, + } + if strings.TrimSpace(s.spec.User) != "" { + payload["User"] = s.spec.User + } + path := fmt.Sprintf("/containers/%s/exec", url.PathEscape(s.spec.Container)) + status, body, err := unixJSONRequest(s.socket, http.MethodPost, path, payload) + if err != nil { + return "", err + } + if status < 200 || status >= 300 { + return "", fmt.Errorf("docker exec create failed (%d): %s", status, string(body)) + } + var resp map[string]any + if err := json.Unmarshal(body, &resp); err != nil { + return "", err + } + id, _ := resp["Id"].(string) + if id == "" { + return "", errors.New("docker exec id missing") + } + return id, nil +} + +func (s *DockerExecSession) startExecSocket(execID string) (net.Conn, error) { + conn, err := net.Dial("unix", s.socket) + if err != nil { + return nil, err + } + payload, _ := json.Marshal(map[string]any{"Detach": false, "Tty": true}) + req, err := http.NewRequest(http.MethodPost, "http://unix/exec/"+url.PathEscape(execID)+"/start", bytes.NewReader(payload)) + if err != nil { + _ = conn.Close() + return nil, err + } + req.Header.Set("Host", "localhost") + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Connection", "Upgrade") + req.Header.Set("Upgrade", "tcp") + req.ContentLength = int64(len(payload)) + if err := req.Write(conn); err != nil { + _ = conn.Close() + return nil, err + } + reader := bufio.NewReader(conn) + resp, err := http.ReadResponse(reader, req) + if err != nil { + _ = conn.Close() + return nil, err + } + if resp.StatusCode != http.StatusSwitchingProtocols && (resp.StatusCode < 200 || resp.StatusCode >= 300) { + body, _ := io.ReadAll(resp.Body) + _ = resp.Body.Close() + _ = conn.Close() + return nil, fmt.Errorf("docker exec start failed (%d): %s", resp.StatusCode, string(body)) + } + _ = resp.Body.Close() + return &readBufferedConn{Conn: conn, reader: reader}, nil +} + +func (s *DockerExecSession) resizeExec(width, height int) error { + s.mu.RLock() + execID := s.execID + s.mu.RUnlock() + if execID == "" { + return nil + } + path := fmt.Sprintf("/exec/%s/resize?h=%d&w=%d", url.PathEscape(execID), height, width) + status, body, err := unixJSONRequest(s.socket, http.MethodPost, path, nil) + if err != nil { + return err + } + if status < 200 || status >= 300 { + return fmt.Errorf("docker resize failed (%d): %s", status, string(body)) + } + return nil +} + +func (s *DockerExecSession) Close() error { + s.mu.Lock() + conn := s.conn + s.conn = nil + s.running = false + s.mu.Unlock() + if conn != nil { + _ = conn.Close() + } + s.doneOnce.Do(func() { close(s.done) }) + return nil +} + +func (s *DockerExecSession) Wait() error { + <-s.done + s.mu.RLock() + defer s.mu.RUnlock() + return s.waitErr +} + +func (s *DockerExecSession) SetTerminalSize(width, height int) error { + if width <= 0 { + width = 1 + } + if height <= 0 { + height = 1 + } + s.mu.Lock() + s.width = width + s.height = height + if s.tracker != nil { + s.tracker.Resize(width, height) + } + s.mu.Unlock() + return s.resizeExec(width, height) +} + +func (s *DockerExecSession) ForceRedraw() error { + s.mu.RLock() + width, height := s.width, s.height + s.mu.RUnlock() + return s.SetTerminalSize(width, height) +} + +func (s *DockerExecSession) SendBytes(data []byte) bool { + s.mu.RLock() + conn := s.conn + s.mu.RUnlock() + if conn == nil { + return false + } + s.writeMu.Lock() + defer s.writeMu.Unlock() + _, err := conn.Write(data) + return err == nil +} + +func (s *DockerExecSession) SendMeta(_ map[string]any) bool { + return true +} + +func (s *DockerExecSession) IsRunning() bool { + s.mu.RLock() + defer s.mu.RUnlock() + return s.running +} + +func (s *DockerExecSession) GetReplayBuffer() []byte { + return s.replay.Bytes() +} + +func (s *DockerExecSession) GetScreenSnapshot() terminalstate.Snapshot { + s.mu.RLock() + tracker := s.tracker + width, height := s.width, s.height + s.mu.RUnlock() + if tracker == nil { + return terminalstate.Snapshot{ + Width: width, + Height: height, + Buffer: make([][]terminalstate.Cell, height), + } + } + return tracker.Snapshot() +} + +func (s *DockerExecSession) UpdateConnector(connector SessionConnector) { + if connector == nil { + return + } + s.mu.Lock() + defer s.mu.Unlock() + s.connector = connector +} diff --git a/go/webterm/docker_http.go b/go/webterm/docker_http.go new file mode 100644 index 0000000..71e0aed --- /dev/null +++ b/go/webterm/docker_http.go @@ -0,0 +1,90 @@ +package webterm + +import ( + "bytes" + "context" + "encoding/json" + "io" + "net" + "net/http" + "os" + "strings" + "sync" + "time" +) + +var ( + sharedClientsMu sync.RWMutex + sharedClients = map[string]*http.Client{} +) + +const defaultDockerSocket = "/var/run/docker.sock" + +func DockerSocketPath() string { + dockerHost := strings.TrimSpace(os.Getenv(DockerHostEnv)) + if dockerHost == "" { + return defaultDockerSocket + } + if strings.HasPrefix(dockerHost, "unix://") { + return strings.TrimPrefix(dockerHost, "unix://") + } + if strings.HasPrefix(dockerHost, "/") { + return dockerHost + } + return defaultDockerSocket +} + +func newUnixHTTPClient(socketPath string, timeout time.Duration) *http.Client { + transport := &http.Transport{ + DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) { + var dialer net.Dialer + return dialer.DialContext(ctx, "unix", socketPath) + }, + } + return &http.Client{Transport: transport, Timeout: timeout} +} + +func sharedUnixClient(socketPath string) *http.Client { + sharedClientsMu.RLock() + client, ok := sharedClients[socketPath] + sharedClientsMu.RUnlock() + if ok { + return client + } + sharedClientsMu.Lock() + defer sharedClientsMu.Unlock() + if client, ok = sharedClients[socketPath]; ok { + return client + } + client = newUnixHTTPClient(socketPath, 15*time.Second) + sharedClients[socketPath] = client + return client +} + +func unixJSONRequest(socketPath, method, path string, payload any) (int, []byte, error) { + var body io.Reader + if payload != nil { + data, err := json.Marshal(payload) + if err != nil { + return 0, nil, err + } + body = bytes.NewReader(data) + } + req, err := http.NewRequest(method, "http://unix"+path, body) + if err != nil { + return 0, nil, err + } + if payload != nil { + req.Header.Set("Content-Type", "application/json") + } + resp, err := sharedUnixClient(socketPath).Do(req) + if err != nil { + return 0, nil, err + } + defer resp.Body.Close() + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return 0, nil, err + } + return resp.StatusCode, respBody, nil +} diff --git a/go/webterm/docker_stats.go b/go/webterm/docker_stats.go new file mode 100644 index 0000000..cc1508f --- /dev/null +++ b/go/webterm/docker_stats.go @@ -0,0 +1,387 @@ +package webterm + +import ( + "encoding/json" + "fmt" + "math" + "os" + "path/filepath" + "strings" + "sync" + "time" +) + +const ( + StatsHistorySize = 180 + PollInterval = 10 * time.Second +) + +type DockerStatsCollector struct { + socketPath string + composeProject string + + mu sync.RWMutex + cpuHistory map[string][]float64 + prevCPU map[string][2]uint64 + serviceList []string + + running bool + stopCh chan struct{} + doneCh chan struct{} +} + +func NewDockerStatsCollector(socketPath, composeProject string) *DockerStatsCollector { + if socketPath == "" { + socketPath = DockerSocketPath() + } + return &DockerStatsCollector{ + socketPath: socketPath, + composeProject: composeProject, + cpuHistory: map[string][]float64{}, + prevCPU: map[string][2]uint64{}, + stopCh: make(chan struct{}), + doneCh: make(chan struct{}), + } +} + +func (d *DockerStatsCollector) Available() bool { + path := d.socketPath + if !filepath.IsAbs(path) { + path = filepath.Clean(path) + } + if _, err := os.Stat(path); err != nil { + return false + } + client := newUnixHTTPClient(d.socketPath, 2*time.Second) + resp, err := client.Get("http://unix/_ping") + if err != nil { + return false + } + _ = resp.Body.Close() + return true +} + +func (d *DockerStatsCollector) Start(serviceNames []string) { + d.mu.Lock() + if d.running { + d.mu.Unlock() + return + } + d.serviceList = append([]string{}, serviceNames...) + d.running = true + d.mu.Unlock() + go d.pollLoop() +} + +func (d *DockerStatsCollector) Stop() { + d.mu.Lock() + if !d.running { + d.mu.Unlock() + return + } + d.running = false + close(d.stopCh) + d.mu.Unlock() + <-d.doneCh +} + +func (d *DockerStatsCollector) AddService(name string) { + d.mu.Lock() + defer d.mu.Unlock() + for _, existing := range d.serviceList { + if existing == name { + return + } + } + d.serviceList = append(d.serviceList, name) +} + +func (d *DockerStatsCollector) RemoveService(name string) { + d.mu.Lock() + defer d.mu.Unlock() + filtered := d.serviceList[:0] + for _, item := range d.serviceList { + if item != name { + filtered = append(filtered, item) + } + } + d.serviceList = filtered + delete(d.cpuHistory, name) + delete(d.prevCPU, name) +} + +func (d *DockerStatsCollector) GetCPUHistory(name string) []float64 { + d.mu.RLock() + defer d.mu.RUnlock() + history := d.cpuHistory[name] + out := make([]float64, len(history)) + copy(out, history) + return out +} + +func (d *DockerStatsCollector) pollLoop() { + defer close(d.doneCh) + ticker := time.NewTicker(PollInterval) + defer ticker.Stop() + serviceToContainer := map[string]string{} + refreshCounter := 0 + + for { + select { + case <-d.stopCh: + return + default: + } + + d.mu.RLock() + services := append([]string{}, d.serviceList...) + d.mu.RUnlock() + if refreshCounter%30 == 0 || len(serviceToContainer) != len(services) { + serviceToContainer = d.discoverContainers(services) + } + refreshCounter++ + for _, service := range services { + containerID := serviceToContainer[service] + if containerID == "" { + continue + } + d.pollContainer(service, containerID) + } + select { + case <-d.stopCh: + return + case <-ticker.C: + } + } +} + +func (d *DockerStatsCollector) discoverContainers(serviceNames []string) map[string]string { + status, body, err := unixJSONRequest(d.socketPath, "GET", "/containers/json", nil) + if err != nil || status != 200 { + return map[string]string{} + } + var containers []map[string]any + if err := json.Unmarshal(body, &containers); err != nil { + return map[string]string{} + } + mapping := map[string]string{} + for _, container := range containers { + labels := toStringMap(container["Labels"]) + if d.composeProject != "" && labels["com.docker.compose.project"] != d.composeProject { + continue + } + service := labels["com.docker.compose.service"] + containerID := asString(container["Id"]) + if len(containerID) > 12 { + containerID = containerID[:12] + } + for _, target := range serviceNames { + if service == target { + mapping[target] = containerID + break + } + names := toStringSlice(container["Names"]) + for _, name := range names { + clean := strings.TrimPrefix(name, "/") + if clean == target || strings.Contains(clean, target) { + mapping[target] = containerID + break + } + } + } + } + return mapping +} + +func (d *DockerStatsCollector) pollContainer(serviceName, containerID string) { + path := fmt.Sprintf("/containers/%s/stats?stream=false", containerID) + status, body, err := unixJSONRequest(d.socketPath, "GET", path, nil) + if err != nil || status != 200 { + return + } + var stats map[string]any + if err := json.Unmarshal(body, &stats); err != nil { + return + } + cpuStats := toAnyMap(stats["cpu_stats"]) + precpuStats := toAnyMap(stats["precpu_stats"]) + value, ok := d.calculateCPUPercent(serviceName, cpuStats, precpuStats) + if !ok { + return + } + d.mu.Lock() + defer d.mu.Unlock() + history := append(d.cpuHistory[serviceName], value) + if len(history) > StatsHistorySize { + history = history[len(history)-StatsHistorySize:] + } + d.cpuHistory[serviceName] = history +} + +func (d *DockerStatsCollector) calculateCPUPercent(container string, cpuStats, precpuStats map[string]any) (float64, bool) { + cpuUsage := toAnyMap(cpuStats["cpu_usage"]) + precpuUsage := toAnyMap(precpuStats["cpu_usage"]) + cpuTotal := toUint(cpuUsage["total_usage"]) + preTotal := toUint(precpuUsage["total_usage"]) + systemCPU := toUint(cpuStats["system_cpu_usage"]) + preSystem := toUint(precpuStats["system_cpu_usage"]) + + d.mu.Lock() + if preTotal == 0 { + if previous, ok := d.prevCPU[container]; ok { + preTotal = previous[0] + preSystem = previous[1] + } + } + d.prevCPU[container] = [2]uint64{cpuTotal, systemCPU} + d.mu.Unlock() + + cpuDelta := int64(cpuTotal) - int64(preTotal) + systemDelta := int64(systemCPU) - int64(preSystem) + if cpuDelta < 0 || systemDelta <= 0 { + return 0, false + } + onlineCPUs := toInt(cpuStats["online_cpus"]) + if onlineCPUs <= 0 { + perCPU := toAnySlice(cpuUsage["percpu_usage"]) + if len(perCPU) == 0 { + onlineCPUs = 1 + } else { + onlineCPUs = len(perCPU) + } + } + percent := (float64(cpuDelta) / float64(systemDelta)) * float64(onlineCPUs) * 100.0 + maxValue := float64(onlineCPUs) * 100.0 + return math.Min(percent, maxValue), true +} + +func RenderSparklineSVG(values []float64, width, height int) string { + if width <= 0 { + width = 100 + } + if height <= 0 { + height = 20 + } + if len(values) == 0 { + return fmt.Sprintf(``, width, height) + } + points := make([]float64, 0, len(values)+1) + points = append(points, 0) + points = append(points, values...) + maxValue := 1.0 + for _, value := range points { + if value > maxValue { + maxValue = value + } + } + if maxValue <= 0 { + maxValue = 1 + } + xStep := float64(width) / float64(max(1, len(points)-1)) + line := strings.Builder{} + fill := strings.Builder{} + for i, value := range points { + x := float64(i) * xStep + y := float64(height) - ((value / maxValue) * float64(height-2)) - 1 + if i > 0 { + line.WriteByte(' ') + fill.WriteByte(' ') + } + line.WriteString(fmt.Sprintf("%.1f,%.1f", x, y)) + fill.WriteString(fmt.Sprintf("%.1f,%.1f", x, y)) + } + fill.WriteString(fmt.Sprintf(" %d,%d 0,%d", width, height, height)) + return fmt.Sprintf(``, width, height, fill.String(), line.String()) +} + +func max(a, b int) int { + if a > b { + return a + } + return b +} + +func toAnyMap(value any) map[string]any { + switch raw := value.(type) { + case map[string]any: + return raw + case map[any]any: + out := map[string]any{} + for key, val := range raw { + if text, ok := key.(string); ok { + out[text] = val + } + } + return out + default: + return map[string]any{} + } +} + +func toStringMap(value any) map[string]string { + out := map[string]string{} + for key, val := range toAnyMap(value) { + out[key] = asString(val) + } + return out +} + +func toAnySlice(value any) []any { + switch raw := value.(type) { + case []any: + return raw + default: + return nil + } +} + +func toStringSlice(value any) []string { + raw := toAnySlice(value) + out := make([]string, 0, len(raw)) + for _, item := range raw { + if text, ok := item.(string); ok { + out = append(out, text) + } + } + return out +} + +func toUint(value any) uint64 { + switch v := value.(type) { + case uint64: + return v + case int: + if v > 0 { + return uint64(v) + } + case int64: + if v > 0 { + return uint64(v) + } + case float64: + if v > 0 { + return uint64(v) + } + case json.Number: + n, _ := v.Int64() + if n > 0 { + return uint64(n) + } + } + return 0 +} + +func toInt(value any) int { + switch v := value.(type) { + case int: + return v + case int64: + return int(v) + case float64: + return int(v) + case json.Number: + n, _ := v.Int64() + return int(n) + } + return 0 +} diff --git a/go/webterm/docker_stats_test.go b/go/webterm/docker_stats_test.go new file mode 100644 index 0000000..d0c3d94 --- /dev/null +++ b/go/webterm/docker_stats_test.go @@ -0,0 +1,36 @@ +package webterm + +import ( + "strings" + "testing" +) + +func TestRenderSparklineSVG(t *testing.T) { + svg := RenderSparklineSVG([]float64{10, 20, 30}, 120, 24) + if !strings.Contains(svg, " 0 { + return strings.TrimPrefix(names[0], "/") + } + id := asString(container["Id"]) + if len(id) > 12 { + id = id[:12] + } + return id +} + +func (w *DockerWatcher) containerToSlug(container map[string]any) string { + name := w.getContainerName(container) + return strings.NewReplacer("_", "-", ".", "-").Replace(name) +} + +func (w *DockerWatcher) addContainer(container map[string]any) { + slug := w.containerToSlug(container) + name := w.getContainerName(container) + command := w.getContainerCommand(container) + theme := w.getContainerTheme(container) + containerID := asString(container["Id"]) + + w.mu.Lock() + if _, exists := w.managed[slug]; exists { + w.mu.Unlock() + return + } + w.managed[slug] = containerID + w.mu.Unlock() + + w.sessionManager.AddApp(name, command, slug, true, theme) + if w.onContainerAdded != nil { + w.onContainerAdded(slug, name, command) + } +} + +func (w *DockerWatcher) removeContainer(containerID string) { + w.mu.Lock() + slug := "" + for s, id := range w.managed { + if id == containerID || strings.HasPrefix(id, containerID) { + slug = s + delete(w.managed, s) + break + } + } + w.mu.Unlock() + if slug == "" { + return + } + + if sessionID, ok := w.sessionManager.GetSessionIDByRouteKey(slug); ok { + w.sessionManager.CloseSession(sessionID) + } + w.sessionManager.RemoveApp(slug) + if w.onContainerRemoved != nil { + w.onContainerRemoved(slug) + } +} + +func (w *DockerWatcher) listLabeledContainers() ([]map[string]any, error) { + seen := map[string]bool{} + containers := []map[string]any{} + for _, label := range []string{WebtermLabelName, WebtermThemeLabel} { + path := fmt.Sprintf(`/containers/json?filters={"label":["%s"]}`, label) + status, body, err := unixJSONRequest(w.socketPath, http.MethodGet, path, nil) + if err != nil || status != http.StatusOK { + continue + } + var payload []map[string]any + if err := json.Unmarshal(body, &payload); err != nil { + continue + } + for _, container := range payload { + id := asString(container["Id"]) + if id == "" || seen[id] { + continue + } + seen[id] = true + containers = append(containers, container) + } + } + return containers, nil +} + +func (w *DockerWatcher) handleEvent(event map[string]any) { + action := asString(event["Action"]) + actor := toAnyMap(event["Actor"]) + containerID := asString(actor["ID"]) + if containerID == "" { + return + } + switch action { + case "start": + path := fmt.Sprintf("/containers/%s/json", url.PathEscape(containerID)) + status, body, err := unixJSONRequest(w.socketPath, http.MethodGet, path, nil) + if err != nil || status != http.StatusOK { + return + } + var detail map[string]any + if err := json.Unmarshal(body, &detail); err != nil { + return + } + config := toAnyMap(detail["Config"]) + labels := toStringMap(config["Labels"]) + if !hasWebtermLabel(labels) { + return + } + container := map[string]any{ + "Id": containerID, + "Names": []any{"/" + strings.TrimPrefix(asString(detail["Name"]), "/")}, + "Labels": config["Labels"], + } + w.addContainer(container) + case "die": + w.removeContainer(containerID) + } +} + +func (w *DockerWatcher) watchEvents(ctx context.Context) { + defer close(w.waitDone) + filters := url.QueryEscape(`{"event":["start","die"],"type":["container"]}`) + requestURL := "http://unix/events?filters=" + filters + for { + select { + case <-ctx.Done(): + return + default: + } + req, _ := http.NewRequestWithContext(ctx, http.MethodGet, requestURL, nil) + resp, err := w.httpClient.Do(req) + if err != nil { + select { + case <-ctx.Done(): + return + case <-time.After(5 * time.Second): + continue + } + } + decoder := json.NewDecoder(resp.Body) + for { + var event map[string]any + if err := decoder.Decode(&event); err != nil { + break + } + w.handleEvent(event) + } + _ = resp.Body.Close() + select { + case <-ctx.Done(): + return + case <-time.After(2 * time.Second): + } + } +} + +func (w *DockerWatcher) ScanExisting() { + containers, err := w.listLabeledContainers() + if err != nil { + return + } + for _, container := range containers { + w.addContainer(container) + } +} + +func (w *DockerWatcher) Start() { + w.mu.Lock() + if w.running { + w.mu.Unlock() + return + } + ctx, cancel := context.WithCancel(context.Background()) + w.cancel = cancel + w.running = true + w.mu.Unlock() + w.ScanExisting() + go w.watchEvents(ctx) +} + +func (w *DockerWatcher) Stop() { + w.mu.Lock() + if !w.running { + w.mu.Unlock() + return + } + w.running = false + cancel := w.cancel + w.mu.Unlock() + if cancel != nil { + cancel() + } + <-w.waitDone +} diff --git a/go/webterm/docker_watcher_test.go b/go/webterm/docker_watcher_test.go new file mode 100644 index 0000000..f8f35a6 --- /dev/null +++ b/go/webterm/docker_watcher_test.go @@ -0,0 +1,23 @@ +package webterm + +import "testing" + +func TestDockerWatcherCommandAndThemeParsing(t *testing.T) { + watcher := NewDockerWatcher(NewSessionManager(nil), "/tmp/docker.sock", nil, nil) + container := map[string]any{ + "Labels": map[string]any{ + WebtermLabelName: "auto", + WebtermThemeLabel: "nord", + }, + "Names": []any{"/my_container.1"}, + } + if cmd := watcher.getContainerCommand(container); cmd != AutoCommandSentinel { + t.Fatalf("expected auto command sentinel, got %q", cmd) + } + if theme := watcher.getContainerTheme(container); theme != "nord" { + t.Fatalf("unexpected theme: %q", theme) + } + if slug := watcher.containerToSlug(container); slug != "my-container-1" { + t.Fatalf("unexpected slug: %q", slug) + } +} diff --git a/go/webterm/identity.go b/go/webterm/identity.go new file mode 100644 index 0000000..a162ef2 --- /dev/null +++ b/go/webterm/identity.go @@ -0,0 +1,36 @@ +package webterm + +import ( + "crypto/rand" +) + +const ( + identityAlphabet = "0123456789ABCDEFGHJKMNPQRSTUVWYZ" + identitySize = 12 +) + +func GenerateID(size int) string { + if size <= 0 { + size = identitySize + } + const alphabetLen = len(identityAlphabet) // 31 + // Largest multiple of 31 that fits in a byte: 31*8 = 248 + const maxUnbiased = alphabetLen * (256 / alphabetLen) // 248 + out := make([]byte, size) + buf := make([]byte, size+16) // extra bytes for rejection sampling + filled := 0 + for filled < size { + _, _ = rand.Read(buf) + for _, b := range buf { + if int(b) >= maxUnbiased { + continue // reject to avoid modulo bias + } + out[filled] = identityAlphabet[int(b)%alphabetLen] + filled++ + if filled >= size { + break + } + } + } + return string(out) +} diff --git a/go/webterm/normalize.go b/go/webterm/normalize.go new file mode 100644 index 0000000..6b42b82 --- /dev/null +++ b/go/webterm/normalize.go @@ -0,0 +1,97 @@ +package webterm + +import ( + "bytes" + "regexp" +) + +var ( + daResponsePattern = regexp.MustCompile(`\x1b\[[?>=][\d;]*c`) + daPartialPattern = regexp.MustCompile(`\x1b(?:\[(?:[?>=][\d;]*)?)?$`) +) + +func NormalizeC1Controls(data []byte, utf8Buffer []byte) ([]byte, []byte) { + if len(data) == 0 && len(utf8Buffer) == 0 { + return nil, nil + } + merged := append(append([]byte{}, utf8Buffer...), data...) + out := make([]byte, 0, len(merged)) + pending := make([]byte, 0, 4) + expectedContinuations := 0 + + c1Map := map[byte][]byte{ + 0x9B: []byte("\x1b["), + 0x9D: []byte("\x1b]"), + 0x9C: []byte("\x1b\\"), + 0x90: []byte("\x1bP"), + 0x98: []byte("\x1bX"), + 0x9E: []byte("\x1b^"), + 0x9F: []byte("\x1b_"), + } + + for i := 0; i < len(merged); { + b := merged[i] + if expectedContinuations > 0 { + if b >= 0x80 && b <= 0xBF { + pending = append(pending, b) + expectedContinuations-- + i++ + if expectedContinuations == 0 { + out = append(out, pending...) + pending = pending[:0] + } + continue + } + out = append(out, pending...) + pending = pending[:0] + expectedContinuations = 0 + continue + } + switch { + case b >= 0xC2 && b <= 0xDF: + pending = append(pending, b) + expectedContinuations = 1 + i++ + continue + case b >= 0xE0 && b <= 0xEF: + pending = append(pending, b) + expectedContinuations = 2 + i++ + continue + case b >= 0xF0 && b <= 0xF4: + pending = append(pending, b) + expectedContinuations = 3 + i++ + continue + } + if replacement, ok := c1Map[b]; ok { + out = append(out, replacement...) + } else { + out = append(out, b) + } + i++ + } + if len(pending) > 0 { + return out, pending + } + return out, nil +} + +func FilterDASequences(data []byte, escapeBuffer []byte) ([]byte, []byte) { + merged := append(append([]byte{}, escapeBuffer...), data...) + if len(merged) == 0 { + return nil, nil + } + filtered := daResponsePattern.ReplaceAll(merged, nil) + if len(filtered) == 0 { + return nil, nil + } + match := daPartialPattern.FindIndex(filtered) + if match == nil { + return filtered, nil + } + if match[0] == len(filtered)-1 || bytes.HasPrefix(filtered[match[0]:], []byte("\x1b[")) || bytes.Equal(filtered[match[0]:], []byte("\x1b")) { + return filtered[:match[0]], filtered[match[0]:] + } + return filtered, nil +} diff --git a/go/webterm/normalize_test.go b/go/webterm/normalize_test.go new file mode 100644 index 0000000..7a10225 --- /dev/null +++ b/go/webterm/normalize_test.go @@ -0,0 +1,55 @@ +package webterm + +import "testing" + +func TestNormalizeC1Controls(t *testing.T) { + input := []byte{0x9B, '3', '1', 'm', 'A'} + normalized, pending := NormalizeC1Controls(input, nil) + if string(pending) != "" { + t.Fatalf("expected no pending bytes, got %q", string(pending)) + } + if string(normalized) != "\x1b[31mA" { + t.Fatalf("unexpected normalized output: %q", string(normalized)) + } +} + +func TestNormalizeC1ControlsPreservesSplitUTF8(t *testing.T) { + first := []byte{0xC3} + normalized, pending := NormalizeC1Controls(first, nil) + if len(normalized) != 0 { + t.Fatalf("expected no output for incomplete utf8") + } + second, pending2 := NormalizeC1Controls([]byte{0xA9}, pending) + if len(pending2) != 0 { + t.Fatalf("expected no pending bytes after completion") + } + if string(second) != "é" { + t.Fatalf("unexpected utf8 output: %q", string(second)) + } +} + +func TestFilterDASequencesCompleteAndPartial(t *testing.T) { + data := []byte("a\x1b[?1;10;0cb") + filtered, buffer := FilterDASequences(data, nil) + if string(buffer) != "" { + t.Fatalf("expected empty buffer, got %q", string(buffer)) + } + if string(filtered) != "ab" { + t.Fatalf("unexpected filtered output: %q", string(filtered)) + } + + part1, partBuffer := FilterDASequences([]byte("x\x1b[?1;10"), nil) + if string(part1) != "x" { + t.Fatalf("unexpected part1 output: %q", string(part1)) + } + if string(partBuffer) == "" { + t.Fatalf("expected buffered partial sequence") + } + part2, partBuffer2 := FilterDASequences([]byte(";0cy"), partBuffer) + if string(partBuffer2) != "" { + t.Fatalf("expected empty buffer after completion") + } + if string(part2) != "y" { + t.Fatalf("unexpected part2 output: %q", string(part2)) + } +} diff --git a/go/webterm/replay.go b/go/webterm/replay.go new file mode 100644 index 0000000..0688428 --- /dev/null +++ b/go/webterm/replay.go @@ -0,0 +1,51 @@ +package webterm + +import "sync" + +const replayBufferSize = 256 * 1024 + +type ReplayBuffer struct { + mu sync.Mutex + parts [][]byte + size int + limit int +} + +func NewReplayBuffer(limit int) *ReplayBuffer { + if limit <= 0 { + limit = replayBufferSize + } + return &ReplayBuffer{limit: limit} +} + +func (r *ReplayBuffer) Add(data []byte) { + if len(data) == 0 { + return + } + r.mu.Lock() + defer r.mu.Unlock() + chunk := append([]byte{}, data...) + r.parts = append(r.parts, chunk) + r.size += len(chunk) + evicted := 0 + for r.size > r.limit && evicted < len(r.parts) { + r.size -= len(r.parts[evicted]) + evicted++ + } + if evicted > 0 { + // Copy remaining to a new slice to release old backing array + remaining := make([][]byte, len(r.parts)-evicted) + copy(remaining, r.parts[evicted:]) + r.parts = remaining + } +} + +func (r *ReplayBuffer) Bytes() []byte { + r.mu.Lock() + defer r.mu.Unlock() + joined := make([]byte, 0, r.size) + for _, chunk := range r.parts { + joined = append(joined, chunk...) + } + return joined +} diff --git a/go/webterm/replay_test.go b/go/webterm/replay_test.go new file mode 100644 index 0000000..9281dc3 --- /dev/null +++ b/go/webterm/replay_test.go @@ -0,0 +1,13 @@ +package webterm + +import "testing" + +func TestReplayBufferTrimsOldData(t *testing.T) { + buffer := NewReplayBuffer(5) + buffer.Add([]byte("abc")) + buffer.Add([]byte("de")) + buffer.Add([]byte("f")) + if got := string(buffer.Bytes()); got != "def" { + t.Fatalf("expected trimmed replay buffer, got %q", got) + } +} diff --git a/go/webterm/server.go b/go/webterm/server.go new file mode 100644 index 0000000..89cb276 --- /dev/null +++ b/go/webterm/server.go @@ -0,0 +1,834 @@ +package webterm + +import ( + "context" + "crypto/sha1" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strconv" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/gorilla/websocket" +) + +const ( + wsSendQueueMax = 256 + wsSendTimeout = 2 * time.Second + stdinWriteTimeout = 2 * time.Second + screenshotCacheSeconds = 300 * time.Millisecond + maxScreenshotCacheTTL = 20 * time.Second +) + +type ServerOptions struct { + Host string + Port int + Theme string + FontFamily string + FontSize int + LandingApps []App + ComposeMode bool + ComposeProject string + DockerWatch bool + StaticPath string +} + +type screenshotCacheEntry struct { + when time.Time + svg string + etag string +} + +type wsClient struct { + routeKey string + conn *websocket.Conn + send chan []byte + done chan struct{} + closed atomic.Bool +} + +type LocalServer struct { + host string + port int + theme string + fontFamily string + fontSize int + + sessionManager *SessionManager + landingApps []App + composeMode bool + composeProject string + dockerWatch bool + staticPath string + + upgrader websocket.Upgrader + + mu sync.RWMutex + wsClients map[string]*wsClient + screenshotCache map[string]screenshotCacheEntry + routeLastActivity map[string]time.Time + routeLastSSE map[string]time.Time + sseSubscribers map[chan string]struct{} + slugToService map[string]string + dockerStats *DockerStatsCollector + dockerWatcher *DockerWatcher + screenshotForceRedraw bool +} + +type localClientConnector struct { + server *LocalServer + sessionID string + routeKey string +} + +func (c *localClientConnector) OnData(data []byte) { + c.server.markRouteActivity(c.routeKey) + c.server.enqueueWSData(c.routeKey, data) +} + +func (c *localClientConnector) OnBinary(payload []byte) { + c.server.markRouteActivity(c.routeKey) + c.server.enqueueWSData(c.routeKey, payload) +} + +func (c *localClientConnector) OnMeta(_ map[string]any) {} + +func (c *localClientConnector) OnClose() { + c.server.sessionManager.OnSessionEnd(c.sessionID) + c.server.stopWSClient(c.routeKey) +} + +func NewLocalServer(config Config, options ServerOptions) *LocalServer { + host := options.Host + if host == "" { + host = DefaultHost + } + port := options.Port + if port == 0 { + port = DefaultPort + } + theme := strings.TrimSpace(options.Theme) + if theme == "" { + theme = DefaultTheme + } + fontSize := options.FontSize + if fontSize <= 0 { + fontSize = DefaultFontSize + } + apps := append([]App{}, config.Apps...) + for _, app := range options.LandingApps { + apps = append(apps, app) + } + server := &LocalServer{ + host: host, + port: port, + theme: theme, + fontFamily: options.FontFamily, + fontSize: fontSize, + + sessionManager: NewSessionManager(apps), + landingApps: append([]App{}, options.LandingApps...), + composeMode: options.ComposeMode, + composeProject: options.ComposeProject, + dockerWatch: options.DockerWatch, + staticPath: options.StaticPath, + upgrader: websocket.Upgrader{ + CheckOrigin: func(r *http.Request) bool { return true }, + }, + wsClients: map[string]*wsClient{}, + screenshotCache: map[string]screenshotCacheEntry{}, + routeLastActivity: map[string]time.Time{}, + routeLastSSE: map[string]time.Time{}, + sseSubscribers: map[chan string]struct{}{}, + slugToService: map[string]string{}, + screenshotForceRedraw: EnvBool(ScreenshotForceRedrawEnv), + } + if server.staticPath == "" { + server.staticPath = findStaticPath() + } + return server +} + +func findStaticPath() string { + if p := strings.TrimSpace(os.Getenv("WEBTERM_STATIC_PATH")); p != "" { + if stat, err := os.Stat(p); err == nil && stat.IsDir() { + return p + } + } + candidates := []string{ + filepath.Join(".", "src", "webterm", "static"), + filepath.Join("..", "src", "webterm", "static"), + filepath.Join("..", "..", "src", "webterm", "static"), + } + for _, candidate := range candidates { + if stat, err := os.Stat(candidate); err == nil && stat.IsDir() { + return candidate + } + } + return "" +} + +func (s *LocalServer) markRouteActivity(routeKey string) { + now := time.Now() + s.mu.Lock() + s.routeLastActivity[routeKey] = now + last := s.routeLastSSE[routeKey] + if now.Sub(last) >= 250*time.Millisecond { + s.routeLastSSE[routeKey] = now + for subscriber := range s.sseSubscribers { + select { + case subscriber <- routeKey: + default: + } + } + } + s.mu.Unlock() +} + +func (s *LocalServer) enqueueWSData(routeKey string, data []byte) { + s.mu.RLock() + client := s.wsClients[routeKey] + s.mu.RUnlock() + if client == nil || client.closed.Load() { + return + } + payload := append([]byte{}, data...) + select { + case client.send <- payload: + default: + // Drop oldest, try again + select { + case <-client.send: + default: + } + select { + case client.send <- payload: + default: + } + } +} + +func (s *LocalServer) stopWSClient(routeKey string) { + s.mu.Lock() + client := s.wsClients[routeKey] + delete(s.wsClients, routeKey) + s.mu.Unlock() + if client == nil { + return + } + client.closed.Store(true) + close(client.send) + <-client.done +} + +func (s *LocalServer) wsSender(client *wsClient) { + defer close(client.done) + for payload := range client.send { + _ = client.conn.SetWriteDeadline(time.Now().Add(wsSendTimeout)) + // Detect JSON messages (start with '[') vs binary terminal data + msgType := websocket.BinaryMessage + if len(payload) > 0 && payload[0] == '[' { + msgType = websocket.TextMessage + } + if err := client.conn.WriteMessage(msgType, payload); err != nil { + return + } + } +} + +func (s *LocalServer) createTerminalSession(routeKey string, width, height int) error { + app, ok := s.sessionManager.AppBySlug(routeKey) + if !ok { + app, ok = s.sessionManager.GetDefaultApp() + if !ok { + return fmt.Errorf("no apps configured") + } + } + sessionID := GenerateID(identitySize) + session, err := s.sessionManager.NewSession(app.Slug, sessionID, routeKey, width, height) + if err != nil { + return err + } + connector := &localClientConnector{ + server: s, + sessionID: sessionID, + routeKey: routeKey, + } + session.UpdateConnector(connector) + return session.Start(connector) +} + +func clampInt(value, minValue, maxValue int) int { + if value < minValue { + return minValue + } + if value > maxValue { + return maxValue + } + return value +} + +func parseResizePayload(value any) (int, int) { + width, height := 80, 24 + payload, ok := value.(map[string]any) + if !ok { + return width, height + } + if raw, ok := payload["width"]; ok { + width = toInt(raw) + } + if raw, ok := payload["height"]; ok { + height = toInt(raw) + } + return clampInt(width, 1, 500), clampInt(height, 1, 500) +} + +func (s *LocalServer) handleWebSocket(w http.ResponseWriter, r *http.Request) { + routeKey := strings.TrimPrefix(r.URL.Path, "/ws/") + if routeKey == "" { + http.Error(w, "missing route key", http.StatusBadRequest) + return + } + conn, err := s.upgrader.Upgrade(w, r, nil) + if err != nil { + return + } + defer conn.Close() + + client := &wsClient{ + routeKey: routeKey, + conn: conn, + send: make(chan []byte, wsSendQueueMax), + done: make(chan struct{}), + } + s.mu.Lock() + s.wsClients[routeKey] = client + s.mu.Unlock() + go s.wsSender(client) + defer s.stopWSClient(routeKey) + + // Helper to send JSON through the send channel (avoids concurrent conn writes) + sendJSON := func(v any) { + data, err := json.Marshal(v) + if err != nil || client.closed.Load() { + return + } + select { + case client.send <- data: + default: + } + } + + sessionCreated := false + sessionID, ok := s.sessionManager.GetSessionIDByRouteKey(routeKey) + if ok { + session := s.sessionManager.GetSession(sessionID) + if session != nil && session.IsRunning() { + sessionCreated = true + replay := daResponsePattern.ReplaceAll(session.GetReplayBuffer(), nil) + if len(replay) > 0 { + s.enqueueWSData(routeKey, replay) + } + } else { + s.sessionManager.OnSessionEnd(sessionID) + } + } + + _ = conn.SetReadDeadline(time.Time{}) + conn.SetPongHandler(func(string) error { return nil }) + + for { + messageType, payload, err := conn.ReadMessage() + if err != nil { + return + } + if messageType != websocket.TextMessage { + continue + } + var envelope []any + if err := json.Unmarshal(payload, &envelope); err != nil || len(envelope) == 0 { + continue + } + msgType, _ := envelope[0].(string) + switch msgType { + case "stdin": + s.markRouteActivity(routeKey) + session := s.sessionManager.GetSessionByRouteKey(routeKey) + if session != nil { + data := "" + if len(envelope) > 1 { + data, _ = envelope[1].(string) + } + done := make(chan struct{}) + go func() { + defer close(done) + _ = session.SendBytes([]byte(data)) + }() + select { + case <-done: + case <-time.After(stdinWriteTimeout): + } + } + case "resize": + s.markRouteActivity(routeKey) + width, height := 80, 24 + if len(envelope) > 1 { + width, height = parseResizePayload(envelope[1]) + } + session := s.sessionManager.GetSessionByRouteKey(routeKey) + if session == nil { + if err := s.createTerminalSession(routeKey, width, height); err == nil { + sessionCreated = true + } else { + sendJSON([]any{"error", "Failed to create session"}) + } + } else { + _ = session.SetTerminalSize(width, height) + s.mu.Lock() + delete(s.screenshotCache, routeKey) + s.mu.Unlock() + } + case "ping": + value := "" + if len(envelope) > 1 { + value, _ = envelope[1].(string) + } + sendJSON([]any{"pong", value}) + } + if !sessionCreated && msgType == "resize" { + sessionCreated = true + } + } +} + +func (s *LocalServer) chooseRouteForScreenshot(requested string) (string, Session, bool) { + if requested != "" { + session := s.sessionManager.GetSessionByRouteKey(requested) + if session != nil { + return requested, session, true + } + } + if requested == "" { + if routeKey, session, ok := s.sessionManager.GetFirstRunningSession(); ok { + return routeKey, session, true + } + } + return "", nil, false +} + +func (s *LocalServer) screenshotTTL(routeKey string) time.Duration { + s.mu.RLock() + lastActivity := s.routeLastActivity[routeKey] + s.mu.RUnlock() + idle := time.Since(lastActivity) + switch { + case idle < 3*time.Second: + return screenshotCacheSeconds + case idle < 15*time.Second: + return 2 * time.Second + case idle < 120*time.Second: + return 5 * time.Second + default: + return maxScreenshotCacheTTL + } +} + +func (s *LocalServer) handleScreenshot(w http.ResponseWriter, r *http.Request) { + routeKey := r.URL.Query().Get("route_key") + routeKey, session, ok := s.chooseRouteForScreenshot(routeKey) + if !ok && routeKey != "" { + if _, exists := s.sessionManager.AppBySlug(routeKey); exists { + _ = s.createTerminalSession(routeKey, DefaultTerminalWidth, DefaultTerminalHeight) + time.Sleep(500 * time.Millisecond) + session = s.sessionManager.GetSessionByRouteKey(routeKey) + ok = session != nil + } + } + if !ok || session == nil { + http.Error(w, "Session not found", http.StatusNotFound) + return + } + + s.mu.RLock() + cached, hasCached := s.screenshotCache[routeKey] + s.mu.RUnlock() + if hasCached && time.Since(cached.when) < s.screenshotTTL(routeKey) { + if match := r.Header.Get("If-None-Match"); match != "" && match == cached.etag { + w.WriteHeader(http.StatusNotModified) + return + } + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("ETag", cached.etag) + w.Header().Set("Content-Type", "image/svg+xml") + _, _ = io.WriteString(w, cached.svg) + return + } + + if s.screenshotForceRedraw { + _ = session.ForceRedraw() + } + + snapshot := session.GetScreenSnapshot() + if hasCached && !snapshot.HasChanges { + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("ETag", cached.etag) + w.Header().Set("Content-Type", "image/svg+xml") + _, _ = io.WriteString(w, cached.svg) + return + } + + app, _ := s.sessionManager.AppBySlug(routeKey) + theme := strings.ToLower(strings.TrimSpace(app.Theme)) + if theme == "" { + theme = strings.ToLower(s.theme) + } + palette := ThemePalettes[theme] + if palette == nil { + palette = ThemePalettes["xterm"] + } + background := palette["background"] + if background == "" { + background = ThemeBackgrounds["xterm"] + } + foreground := palette["foreground"] + if foreground == "" { + foreground = "#e5e5e5" + } + + svg := RenderTerminalSVG(snapshot.Buffer, snapshot.Width, snapshot.Height, "webterm", background, foreground, palette) + hash := sha1.Sum([]byte(svg)) + etag := fmt.Sprintf("%x", hash[:]) + s.mu.Lock() + s.screenshotCache[routeKey] = screenshotCacheEntry{when: time.Now(), svg: svg, etag: etag} + s.mu.Unlock() + + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("ETag", etag) + w.Header().Set("Content-Type", "image/svg+xml") + _, _ = io.WriteString(w, svg) +} + +func (s *LocalServer) handleCPUSparkline(w http.ResponseWriter, r *http.Request) { + container := r.URL.Query().Get("container") + if strings.TrimSpace(container) == "" { + http.Error(w, "Missing container parameter", http.StatusBadRequest) + return + } + width := clampInt(toIntFromQuery(r.URL.Query().Get("width"), 100), 50, 300) + height := clampInt(toIntFromQuery(r.URL.Query().Get("height"), 20), 10, 100) + + values := []float64{} + s.mu.RLock() + stats := s.dockerStats + serviceName := s.slugToService[container] + s.mu.RUnlock() + if serviceName == "" { + serviceName = container + } + if stats != nil { + values = stats.GetCPUHistory(serviceName) + } + w.Header().Set("Cache-Control", "no-cache, max-age=0") + w.Header().Set("Content-Type", "image/svg+xml") + _, _ = io.WriteString(w, RenderSparklineSVG(values, width, height)) +} + +func (s *LocalServer) handleEvents(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + flusher, ok := w.(http.Flusher) + if !ok { + http.Error(w, "streaming unsupported", http.StatusInternalServerError) + return + } + channel := make(chan string, 100) + s.mu.Lock() + s.sseSubscribers[channel] = struct{}{} + s.mu.Unlock() + defer func() { + s.mu.Lock() + delete(s.sseSubscribers, channel) + close(channel) + s.mu.Unlock() + }() + + ticker := time.NewTicker(30 * time.Second) + defer ticker.Stop() + notify := r.Context().Done() + for { + select { + case <-notify: + return + case routeKey := <-channel: + _, _ = fmt.Fprintf(w, "event: activity\ndata: %s\n\n", routeKey) + flusher.Flush() + case <-ticker.C: + _, _ = io.WriteString(w, ": keepalive\n\n") + flusher.Flush() + } + } +} + +func toIntFromQuery(value string, fallback int) int { + if n, err := strconv.Atoi(strings.TrimSpace(value)); err == nil { + return n + } + return fallback +} + +func (s *LocalServer) dashboardTiles() []map[string]string { + var apps []App + if s.dockerWatch { + apps = s.sessionManager.Apps() + } else { + apps = append([]App{}, s.landingApps...) + } + tiles := make([]map[string]string, 0, len(apps)) + for _, app := range apps { + command := app.Command + if command == AutoCommandSentinel { + command = "" + } + tiles = append(tiles, map[string]string{ + "slug": app.Slug, + "name": app.Name, + "command": command, + }) + } + return tiles +} + +func (s *LocalServer) handleTiles(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(s.dashboardTiles()) +} + +func (s *LocalServer) getWSURL(r *http.Request, routeKey string) string { + header := func(name string) string { + value := strings.TrimSpace(strings.Split(r.Header.Get(name), ",")[0]) + return strings.ToLower(value) + } + forwardedProto := header("X-Forwarded-Proto") + forwardedHost := header("X-Forwarded-Host") + forwardedPort := header("X-Forwarded-Port") + + wsProto := "ws" + if forwardedProto == "https" || forwardedProto == "wss" { + wsProto = "wss" + } else if forwardedProto == "" && r.TLS != nil { + wsProto = "wss" + } + host := forwardedHost + if host == "" { + host = r.Host + } + if host == "" { + if s.host == "0.0.0.0" { + host = "localhost" + } else { + host = s.host + } + if s.port != 80 && s.port != 443 { + host = fmt.Sprintf("%s:%d", host, s.port) + } + } + if forwardedPort != "" && !strings.Contains(host, ":") && forwardedPort != "80" && forwardedPort != "443" { + host += ":" + forwardedPort + } + return fmt.Sprintf("%s://%s/ws/%s", wsProto, host, routeKey) +} + +func (s *LocalServer) handleRoot(w http.ResponseWriter, r *http.Request) { + routeKeyParam := r.URL.Query().Get("route_key") + showDashboard := (len(s.landingApps) > 0 || s.dockerWatch) && routeKeyParam == "" + if showDashboard { + tilesJSON, _ := json.Marshal(s.dashboardTiles()) + composeModeJS := "false" + if s.composeMode || s.dockerWatch { + composeModeJS = "true" + } + dockerWatchJS := "false" + if s.dockerWatch { + dockerWatchJS = "true" + } + html := fmt.Sprintf(`Session Dashboard

Sessions

`, string(tilesJSON), composeModeJS, dockerWatchJS) + w.Header().Set("Content-Type", "text/html") + _, _ = io.WriteString(w, html) + return + } + + var app App + var ok bool + if routeKeyParam != "" { + app, ok = s.sessionManager.AppBySlug(routeKeyParam) + } + if !ok { + app, ok = s.sessionManager.GetDefaultApp() + } + if !ok { + w.Header().Set("Content-Type", "text/html") + _, _ = io.WriteString(w, "Webterm Server

No Apps Available

No terminal applications are configured.

") + return + } + + routeKey := routeKeyParam + if routeKey == "" { + if runningKey, _, exists := s.sessionManager.GetFirstRunningSession(); exists { + routeKey = runningKey + } else { + routeKey = strings.ToLower(GenerateID(identitySize)) + } + } + wsURL := s.getWSURL(r, routeKey) + theme := app.Theme + if strings.TrimSpace(theme) == "" { + theme = s.theme + } + themeBG := ThemeBackgrounds[strings.ToLower(theme)] + if themeBG == "" { + themeBG = "#000000" + } + fontFamily := s.fontFamily + if strings.TrimSpace(fontFamily) == "" { + fontFamily = "var(--webterm-mono)" + } + escapedFont := strings.ReplaceAll(fontFamily, `"`, """) + dataAttrs := fmt.Sprintf(`data-session-websocket-url="%s" data-font-size="%d" data-scrollback="1000" data-theme="%s" data-font-family="%s"`, htmlAttrEscape(wsURL), s.fontSize, htmlAttrEscape(theme), escapedFont) + page := fmt.Sprintf(`%s
`, htmlEscape(app.Name), themeBG, dataAttrs) + w.Header().Set("Content-Type", "text/html") + _, _ = io.WriteString(w, page) +} + +func htmlEscape(value string) string { + return strings.NewReplacer("&", "&", "<", "<", ">", ">").Replace(value) +} + +func htmlAttrEscape(value string) string { + return strings.NewReplacer("&", "&", `"`, """, "<", "<", ">", ">").Replace(value) +} + +func (s *LocalServer) handleHealth(w http.ResponseWriter, _ *http.Request) { + _, _ = io.WriteString(w, "Local server is running") +} + +func (s *LocalServer) setupDockerFeatures() { + if (s.composeMode && len(s.landingApps) > 0) || s.dockerWatch { + stats := NewDockerStatsCollector("", s.composeProject) + if stats.Available() { + serviceNames := []string{} + apps := s.landingApps + if s.dockerWatch { + apps = s.sessionManager.Apps() + } + for _, app := range apps { + serviceNames = append(serviceNames, app.Name) + s.slugToService[app.Slug] = app.Name + } + stats.Start(serviceNames) + s.dockerStats = stats + } + } + if s.dockerWatch { + watcher := NewDockerWatcher( + s.sessionManager, + "", + func(slug, name, _ string) { + s.mu.Lock() + s.slugToService[slug] = name + if s.dockerStats != nil { + s.dockerStats.AddService(name) + } + s.mu.Unlock() + s.markRouteActivity("__dashboard__") + }, + func(slug string) { + s.mu.Lock() + serviceName := s.slugToService[slug] + delete(s.slugToService, slug) + delete(s.screenshotCache, slug) + if s.dockerStats != nil && serviceName != "" { + s.dockerStats.RemoveService(serviceName) + } + s.mu.Unlock() + s.markRouteActivity("__dashboard__") + }, + ) + s.dockerWatcher = watcher + watcher.Start() + } +} + +func (s *LocalServer) shutdown() { + if s.dockerWatcher != nil { + s.dockerWatcher.Stop() + } + if s.dockerStats != nil { + s.dockerStats.Stop() + } + s.sessionManager.CloseAll() + s.mu.Lock() + clients := map[string]*wsClient{} + for key, client := range s.wsClients { + clients[key] = client + } + s.wsClients = map[string]*wsClient{} + s.mu.Unlock() + for _, client := range clients { + client.closed.Store(true) + close(client.send) + <-client.done + _ = client.conn.Close() + } +} + +func (s *LocalServer) Handler() http.Handler { + mux := http.NewServeMux() + mux.HandleFunc("/ws/", s.handleWebSocket) + mux.HandleFunc("/screenshot.svg", s.handleScreenshot) + mux.HandleFunc("/cpu-sparkline.svg", s.handleCPUSparkline) + mux.HandleFunc("/events", s.handleEvents) + mux.HandleFunc("/health", s.handleHealth) + mux.HandleFunc("/tiles", s.handleTiles) + mux.HandleFunc("/", s.handleRoot) + if strings.TrimSpace(s.staticPath) != "" { + mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir(s.staticPath)))) + } + return mux +} + +func (s *LocalServer) Run(ctx context.Context) error { + s.setupDockerFeatures() + server := &http.Server{ + Addr: fmt.Sprintf("%s:%d", s.host, s.port), + Handler: s.Handler(), + } + errCh := make(chan error, 1) + go func() { + err := server.ListenAndServe() + if err != nil && err != http.ErrServerClosed { + errCh <- err + return + } + errCh <- nil + }() + + select { + case <-ctx.Done(): + shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + _ = server.Shutdown(shutdownCtx) + cancel() + s.shutdown() + <-errCh + return nil + case err := <-errCh: + s.shutdown() + return err + } +} diff --git a/go/webterm/server_test.go b/go/webterm/server_test.go new file mode 100644 index 0000000..35e1538 --- /dev/null +++ b/go/webterm/server_test.go @@ -0,0 +1,216 @@ +package webterm + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + + "github.com/gorilla/websocket" +) + +func newServerForTests(t *testing.T, withLanding bool) (*LocalServer, *httptest.Server, *syncSessionMap) { + t.Helper() + config := Config{ + Apps: []App{{Name: "Shell", Slug: "shell", Command: "/bin/sh", Terminal: true}}, + } + options := ServerOptions{} + if withLanding { + options.LandingApps = []App{{Name: "Shell", Slug: "shell", Command: "/bin/sh", Terminal: true}} + } + server := NewLocalServer(config, options) + sessions := &syncSessionMap{m: map[string]*fakeSession{}} + server.sessionManager.SetSessionFactory(func(app App, sessionID string) Session { + s := newFakeSession() + sessions.mu.Lock() + sessions.m[sessionID] = s + sessions.mu.Unlock() + return s + }) + httpServer := httptest.NewServer(server.Handler()) + t.Cleanup(httpServer.Close) + return server, httpServer, sessions +} + +type syncSessionMap struct { + mu sync.Mutex + m map[string]*fakeSession +} + +func TestHealthAndTilesEndpoints(t *testing.T) { + _, httpServer, _ := newServerForTests(t, true) + resp, err := http.Get(httpServer.URL + "/health") + if err != nil { + t.Fatalf("health request error = %v", err) + } + body, _ := io.ReadAll(resp.Body) + _ = resp.Body.Close() + if !strings.Contains(string(body), "Local server is running") { + t.Fatalf("unexpected health response: %q", string(body)) + } + + resp, err = http.Get(httpServer.URL + "/tiles") + if err != nil { + t.Fatalf("tiles request error = %v", err) + } + var tiles []map[string]string + if err := json.NewDecoder(resp.Body).Decode(&tiles); err != nil { + t.Fatalf("decode tiles: %v", err) + } + _ = resp.Body.Close() + if len(tiles) != 1 || tiles[0]["slug"] != "shell" { + t.Fatalf("unexpected tiles: %+v", tiles) + } +} + +func TestWebSocketPingResizeAndStdin(t *testing.T) { + server, httpServer, sessions := newServerForTests(t, false) + wsURL := "ws" + strings.TrimPrefix(httpServer.URL, "http") + "/ws/shell" + conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil) + if err != nil { + t.Fatalf("ws dial error = %v", err) + } + defer conn.Close() + + if err := conn.WriteJSON([]any{"ping", "ok"}); err != nil { + t.Fatalf("write ping: %v", err) + } + _, payload, err := conn.ReadMessage() + if err != nil { + t.Fatalf("read pong: %v", err) + } + var pong []any + if err := json.Unmarshal(payload, &pong); err != nil { + t.Fatalf("decode pong: %v", err) + } + if pong[0] != "pong" || pong[1] != "ok" { + t.Fatalf("unexpected pong payload: %v", pong) + } + + if err := conn.WriteJSON([]any{"resize", map[string]any{"width": 100, "height": 40}}); err != nil { + t.Fatalf("write resize: %v", err) + } + deadline := time.Now().Add(200 * time.Millisecond) + for time.Now().Before(deadline) && server.sessionManager.GetSessionByRouteKey("shell") == nil { + time.Sleep(10 * time.Millisecond) + } + if session := server.sessionManager.GetSessionByRouteKey("shell"); session == nil { + t.Fatalf("expected session to be created on resize") + } + + if err := conn.WriteJSON([]any{"stdin", "ls\n"}); err != nil { + t.Fatalf("write stdin: %v", err) + } + time.Sleep(20 * time.Millisecond) + found := false + sessions.mu.Lock() + for _, session := range sessions.m { + session.mu.Lock() + if len(session.received) > 0 && string(session.received[0]) == "ls\n" { + found = true + } + session.mu.Unlock() + } + sessions.mu.Unlock() + if !found { + t.Fatalf("expected stdin to reach session") + } +} + +func TestWebSocketReplayOnReconnect(t *testing.T) { + _, httpServer, sessions := newServerForTests(t, false) + wsURL := "ws" + strings.TrimPrefix(httpServer.URL, "http") + "/ws/shell" + + conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil) + if err != nil { + t.Fatalf("first dial error = %v", err) + } + if err := conn.WriteJSON([]any{"resize", map[string]any{"width": 80, "height": 24}}); err != nil { + t.Fatalf("resize write: %v", err) + } + time.Sleep(20 * time.Millisecond) + _ = conn.Close() + + sessions.mu.Lock() + for _, session := range sessions.m { + session.mu.Lock() + session.replay = []byte("abc\x1b[?1;10;0cdef") + session.mu.Unlock() + } + sessions.mu.Unlock() + + conn2, _, err := websocket.DefaultDialer.Dial(wsURL, nil) + if err != nil { + t.Fatalf("second dial error = %v", err) + } + defer conn2.Close() + _ = conn2.SetReadDeadline(time.Now().Add(2 * time.Second)) + msgType, replay, err := conn2.ReadMessage() + if err != nil { + t.Fatalf("read replay: %v", err) + } + if msgType != websocket.BinaryMessage { + t.Fatalf("expected binary replay message, got %d", msgType) + } + if string(replay) != "abcdef" { + t.Fatalf("unexpected replay payload: %q", string(replay)) + } +} + +func TestScreenshotAndETag(t *testing.T) { + server, httpServer, _ := newServerForTests(t, false) + if _, err := server.sessionManager.NewSession("shell", "sid", "shell", 80, 24); err != nil { + t.Fatalf("NewSession error = %v", err) + } + resp, err := http.Get(httpServer.URL + "/screenshot.svg?route_key=shell") + if err != nil { + t.Fatalf("screenshot request error = %v", err) + } + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected 200, got %d", resp.StatusCode) + } + etag := resp.Header.Get("ETag") + body, _ := io.ReadAll(resp.Body) + _ = resp.Body.Close() + if etag == "" || !strings.Contains(string(body), "`, svgWidth, svgHeight)) + b.WriteString("" + html.EscapeString(title) + "") + b.WriteString(``) + b.WriteString(fmt.Sprintf(``, svgWidth, svgHeight)) + b.WriteString(``) + for rowIdx := 0; rowIdx < len(buffer); rowIdx++ { + row := buffer[rowIdx] + rectY := 10 + float64(rowIdx)*cellHeight + textY := rectY + fontSize + var rowText strings.Builder + for col := 0; col < len(row); col++ { + cell := row[col] + charData := cell.Data + if charData == "" { + continue + } + x := 10 + float64(col)*charWidth + fg := colorToHex(cell.FG, true, palette, foreground, background) + bg := colorToHex(cell.BG, false, palette, foreground, background) + if cell.Reverse { + fg, bg = bg, fg + } + if bg != background { + b.WriteString(fmt.Sprintf(``, x, rectY, charWidth+0.5, cellHeight+0.5, bg)) + } + attrs := []string{fmt.Sprintf(`x="%.1f"`, x)} + if fg != foreground { + attrs = append(attrs, `fill="`+fg+`"`) + } + classes := []string{} + if cell.Bold { + classes = append(classes, "bold") + } + if cell.Italics { + classes = append(classes, "italic") + } + if cell.Underscore { + classes = append(classes, "underline") + } + if len(classes) > 0 { + attrs = append(attrs, `class="`+strings.Join(classes, " ")+`"`) + } + rowText.WriteString(`` + html.EscapeString(charData) + ``) + } + if rowText.Len() > 0 { + b.WriteString(fmt.Sprintf(`%s`, textY, rowText.String())) + } + } + b.WriteString(``) + return b.String() +} + +func colorToHex(color string, isFG bool, palette map[string]string, defaultFG, defaultBG string) string { + if color == "" || strings.EqualFold(color, "default") { + if isFG { + return defaultFG + } + return defaultBG + } + if strings.HasPrefix(color, "#") { + return color + } + if len(color) == 6 && isHex(color) { + return "#" + color + } + key := strings.ToLower(color) + if value, ok := palette[key]; ok { + return value + } + if value, ok := ansiColors[key]; ok { + return value + } + if isFG { + return defaultFG + } + return defaultBG +} + +func isHex(value string) bool { + for _, ch := range value { + switch { + case ch >= '0' && ch <= '9': + case ch >= 'a' && ch <= 'f': + case ch >= 'A' && ch <= 'F': + default: + return false + } + } + return true +} diff --git a/go/webterm/svg_exporter_test.go b/go/webterm/svg_exporter_test.go new file mode 100644 index 0000000..b326fc5 --- /dev/null +++ b/go/webterm/svg_exporter_test.go @@ -0,0 +1,23 @@ +package webterm + +import ( + "strings" + "testing" + + "github.com/rcarmo/webterm-go-port/terminalstate" +) + +func TestRenderTerminalSVG(t *testing.T) { + buffer := [][]terminalstate.Cell{ + { + {Data: "A", FG: "red", BG: "default", Bold: true}, + }, + } + svg := RenderTerminalSVG(buffer, 1, 1, "webterm", "#000000", "#ffffff", ThemePalettes["xterm"]) + if !strings.Contains(svg, " 0 { + s.handleOutput(buf[:n]) + } + if err != nil { + break + } + } + s.mu.Lock() + if s.cmd != nil { + s.waitErr = s.cmd.Wait() + } + s.running = false + connector := s.connector + s.mu.Unlock() + connector.OnClose() + s.doneOnce.Do(func() { close(s.done) }) +} + +func (s *TerminalSession) handleOutput(data []byte) { + s.mu.Lock() + filtered, escapeBuffer := FilterDASequences(data, s.escapeBuffer) + s.escapeBuffer = escapeBuffer + normalized, utf8Buffer := NormalizeC1Controls(filtered, s.utf8Buffer) + s.utf8Buffer = utf8Buffer + tracker := s.tracker + connector := s.connector + s.mu.Unlock() + + if len(normalized) == 0 { + return + } + s.replay.Add(normalized) + if tracker != nil { + _ = tracker.Feed(normalized) + } + connector.OnData(normalized) +} + +func (s *TerminalSession) Close() error { + s.mu.Lock() + file := s.ptyFile + cmd := s.cmd + s.ptyFile = nil + s.running = false + s.mu.Unlock() + + if cmd != nil && cmd.Process != nil { + _ = cmd.Process.Signal(syscall.SIGHUP) + } + if file != nil { + _ = file.Close() + } + s.doneOnce.Do(func() { close(s.done) }) + return nil +} + +func (s *TerminalSession) Wait() error { + <-s.done + s.mu.RLock() + defer s.mu.RUnlock() + return s.waitErr +} + +func (s *TerminalSession) SetTerminalSize(width, height int) error { + s.mu.Lock() + defer s.mu.Unlock() + if s.ptyFile == nil { + return errors.New("session closed") + } + if width <= 0 { + width = 1 + } + if height <= 0 { + height = 1 + } + if err := pty.Setsize(s.ptyFile, &pty.Winsize{Cols: uint16(width), Rows: uint16(height)}); err != nil { + return err + } + s.width = width + s.height = height + if s.tracker != nil { + s.tracker.Resize(width, height) + } + return nil +} + +func (s *TerminalSession) ForceRedraw() error { + s.mu.RLock() + width := s.width + height := s.height + s.mu.RUnlock() + return s.SetTerminalSize(width, height) +} + +func (s *TerminalSession) SendBytes(data []byte) bool { + s.mu.RLock() + file := s.ptyFile + s.mu.RUnlock() + if file == nil { + return false + } + s.writeMu.Lock() + defer s.writeMu.Unlock() + _, err := file.Write(data) + return err == nil +} + +func (s *TerminalSession) SendMeta(_ map[string]any) bool { + return true +} + +func (s *TerminalSession) IsRunning() bool { + s.mu.RLock() + defer s.mu.RUnlock() + return s.running +} + +func (s *TerminalSession) GetReplayBuffer() []byte { + return s.replay.Bytes() +} + +func (s *TerminalSession) GetScreenSnapshot() terminalstate.Snapshot { + s.mu.RLock() + tracker := s.tracker + s.mu.RUnlock() + if tracker == nil { + return terminalstate.Snapshot{ + Width: s.width, + Height: s.height, + Buffer: make([][]terminalstate.Cell, s.height), + } + } + return tracker.Snapshot() +} + +func (s *TerminalSession) UpdateConnector(connector SessionConnector) { + if connector == nil { + return + } + s.mu.Lock() + defer s.mu.Unlock() + s.connector = connector +} diff --git a/go/webterm/test_helpers_test.go b/go/webterm/test_helpers_test.go new file mode 100644 index 0000000..0d78499 --- /dev/null +++ b/go/webterm/test_helpers_test.go @@ -0,0 +1,102 @@ +package webterm + +import ( + "sync" + + "github.com/rcarmo/webterm-go-port/terminalstate" +) + +type fakeSession struct { + mu sync.Mutex + running bool + replay []byte + snapshot terminalstate.Snapshot + received [][]byte + width int + height int + connector SessionConnector +} + +func newFakeSession() *fakeSession { + return &fakeSession{ + snapshot: terminalstate.Snapshot{ + Width: 80, + Height: 24, + Buffer: [][]terminalstate.Cell{{{Data: "h", FG: "default", BG: "default"}}}, + HasChanges: true, + }, + } +} + +func (f *fakeSession) Open(width, height int) error { + f.mu.Lock() + defer f.mu.Unlock() + f.running = true + f.width = width + f.height = height + f.snapshot.Width = width + f.snapshot.Height = height + return nil +} + +func (f *fakeSession) Start(connector SessionConnector) error { + f.mu.Lock() + defer f.mu.Unlock() + f.connector = connector + return nil +} + +func (f *fakeSession) Close() error { + f.mu.Lock() + defer f.mu.Unlock() + f.running = false + return nil +} + +func (f *fakeSession) Wait() error { return nil } + +func (f *fakeSession) SetTerminalSize(width, height int) error { + f.mu.Lock() + defer f.mu.Unlock() + f.width = width + f.height = height + f.snapshot.Width = width + f.snapshot.Height = height + return nil +} + +func (f *fakeSession) SendBytes(data []byte) bool { + f.mu.Lock() + defer f.mu.Unlock() + chunk := append([]byte{}, data...) + f.received = append(f.received, chunk) + return true +} + +func (f *fakeSession) SendMeta(_ map[string]any) bool { return true } + +func (f *fakeSession) IsRunning() bool { + f.mu.Lock() + defer f.mu.Unlock() + return f.running +} + +func (f *fakeSession) GetReplayBuffer() []byte { + f.mu.Lock() + defer f.mu.Unlock() + return append([]byte{}, f.replay...) +} + +func (f *fakeSession) GetScreenSnapshot() terminalstate.Snapshot { + f.mu.Lock() + defer f.mu.Unlock() + return f.snapshot +} + +func (f *fakeSession) ForceRedraw() error { return nil } + +func (f *fakeSession) UpdateConnector(connector SessionConnector) { + f.mu.Lock() + defer f.mu.Unlock() + f.connector = connector +} diff --git a/go/webterm/themes.go b/go/webterm/themes.go new file mode 100644 index 0000000..d7e01cf --- /dev/null +++ b/go/webterm/themes.go @@ -0,0 +1,219 @@ +package webterm + +var ThemeBackgrounds = map[string]string{ + "tango": "#000000", + "xterm": "#000000", + "monokai": "#2d2a2e", + "ristretto": "#2d2525", + "dark": "#1e1e1e", + "light": "#ffffff", + "dracula": "#282a36", + "catppuccin": "#1e1e2e", + "nord": "#2e3440", + "gruvbox": "#282828", + "solarized": "#002b36", + "tokyo": "#1a1b26", +} + +var ThemePalettes = map[string]map[string]string{ + "xterm": { + "background": "#000000", + "foreground": "#e5e5e5", + "black": "#000000", + "red": "#cd0000", + "green": "#00cd00", + "yellow": "#cdcd00", + "blue": "#0000cd", + "magenta": "#cd00cd", + "cyan": "#00cdcd", + "white": "#e5e5e5", + "brightblack": "#4d4d4d", + "brightred": "#ff0000", + "brightgreen": "#00ff00", + "brightyellow": "#ffff00", + "brightblue": "#0000ff", + "brightmagenta": "#ff00ff", + "brightcyan": "#00ffff", + "brightwhite": "#ffffff", + }, + "monokai": { + "background": "#2d2a2e", + "foreground": "#fcfcfa", + "black": "#403e41", + "red": "#ff6188", + "green": "#a9dc76", + "yellow": "#ffd866", + "blue": "#fc9867", + "magenta": "#ab9df2", + "cyan": "#78dce8", + "white": "#fcfcfa", + "brightblack": "#727072", + "brightred": "#ff6188", + "brightgreen": "#a9dc76", + "brightyellow": "#ffd866", + "brightblue": "#fc9867", + "brightmagenta": "#ab9df2", + "brightcyan": "#78dce8", + "brightwhite": "#fcfcfa", + }, + "dark": { + "background": "#1e1e1e", + "foreground": "#d4d4d4", + "black": "#000000", + "red": "#cd3131", + "green": "#0dbc79", + "yellow": "#e5e510", + "blue": "#2472c8", + "magenta": "#bc3fbc", + "cyan": "#11a8cd", + "white": "#e5e5e5", + "brightblack": "#666666", + "brightred": "#f14c4c", + "brightgreen": "#23d18b", + "brightyellow": "#f5f543", + "brightblue": "#3b8eea", + "brightmagenta": "#d670d6", + "brightcyan": "#29b8db", + "brightwhite": "#ffffff", + }, + "light": { + "background": "#ffffff", + "foreground": "#383a42", + "black": "#000000", + "red": "#e45649", + "green": "#50a14f", + "yellow": "#c18401", + "blue": "#4078f2", + "magenta": "#a626a4", + "cyan": "#0184bc", + "white": "#a0a1a7", + "brightblack": "#5c6370", + "brightred": "#e06c75", + "brightgreen": "#98c379", + "brightyellow": "#d19a66", + "brightblue": "#61afef", + "brightmagenta": "#c678dd", + "brightcyan": "#56b6c2", + "brightwhite": "#ffffff", + }, + "dracula": { + "background": "#282a36", + "foreground": "#f8f8f2", + "black": "#21222c", + "red": "#ff5555", + "green": "#50fa7b", + "yellow": "#f1fa8c", + "blue": "#bd93f9", + "magenta": "#ff79c6", + "cyan": "#8be9fd", + "white": "#f8f8f2", + "brightblack": "#6272a4", + "brightred": "#ff6e6e", + "brightgreen": "#69ff94", + "brightyellow": "#ffffa5", + "brightblue": "#d6acff", + "brightmagenta": "#ff92df", + "brightcyan": "#a4ffff", + "brightwhite": "#ffffff", + }, + "catppuccin": { + "background": "#1e1e2e", + "foreground": "#cdd6f4", + "black": "#45475a", + "red": "#f38ba8", + "green": "#a6e3a1", + "yellow": "#f9e2af", + "blue": "#89b4fa", + "magenta": "#f5c2e7", + "cyan": "#94e2d5", + "white": "#bac2de", + "brightblack": "#585b70", + "brightred": "#f38ba8", + "brightgreen": "#a6e3a1", + "brightyellow": "#f9e2af", + "brightblue": "#89b4fa", + "brightmagenta": "#f5c2e7", + "brightcyan": "#94e2d5", + "brightwhite": "#a6adc8", + }, + "nord": { + "background": "#2e3440", + "foreground": "#d8dee9", + "black": "#3b4252", + "red": "#bf616a", + "green": "#a3be8c", + "yellow": "#ebcb8b", + "blue": "#81a1c1", + "magenta": "#b48ead", + "cyan": "#88c0d0", + "white": "#e5e9f0", + "brightblack": "#4c566a", + "brightred": "#bf616a", + "brightgreen": "#a3be8c", + "brightyellow": "#ebcb8b", + "brightblue": "#81a1c1", + "brightmagenta": "#b48ead", + "brightcyan": "#8fbcbb", + "brightwhite": "#eceff4", + }, + "gruvbox": { + "background": "#282828", + "foreground": "#ebdbb2", + "black": "#282828", + "red": "#cc241d", + "green": "#98971a", + "yellow": "#d79921", + "blue": "#458588", + "magenta": "#b16286", + "cyan": "#689d6a", + "white": "#a89984", + "brightblack": "#928374", + "brightred": "#fb4934", + "brightgreen": "#b8bb26", + "brightyellow": "#fabd2f", + "brightblue": "#83a598", + "brightmagenta": "#d3869b", + "brightcyan": "#8ec07c", + "brightwhite": "#ebdbb2", + }, + "solarized": { + "background": "#002b36", + "foreground": "#839496", + "black": "#073642", + "red": "#dc322f", + "green": "#859900", + "yellow": "#b58900", + "blue": "#268bd2", + "magenta": "#d33682", + "cyan": "#2aa198", + "white": "#eee8d5", + "brightblack": "#586e75", + "brightred": "#cb4b16", + "brightgreen": "#586e75", + "brightyellow": "#657b83", + "brightblue": "#839496", + "brightmagenta": "#6c71c4", + "brightcyan": "#93a1a1", + "brightwhite": "#fdf6e3", + }, + "tokyo": { + "background": "#1a1b26", + "foreground": "#a9b1d6", + "black": "#15161e", + "red": "#f7768e", + "green": "#9ece6a", + "yellow": "#e0af68", + "blue": "#7aa2f7", + "magenta": "#bb9af7", + "cyan": "#7dcfff", + "white": "#a9b1d6", + "brightblack": "#414868", + "brightred": "#f7768e", + "brightgreen": "#9ece6a", + "brightyellow": "#e0af68", + "brightblue": "#7aa2f7", + "brightmagenta": "#bb9af7", + "brightcyan": "#7dcfff", + "brightwhite": "#c0caf5", + }, +} diff --git a/go/webterm/twoway.go b/go/webterm/twoway.go new file mode 100644 index 0000000..2da3a10 --- /dev/null +++ b/go/webterm/twoway.go @@ -0,0 +1,72 @@ +package webterm + +import ( + "fmt" + "sync" +) + +type TwoWayMap[K comparable, V comparable] struct { + mu sync.RWMutex + forward map[K]V + reverse map[V]K +} + +func NewTwoWayMap[K comparable, V comparable]() *TwoWayMap[K, V] { + return &TwoWayMap[K, V]{ + forward: map[K]V{}, + reverse: map[V]K{}, + } +} + +func (m *TwoWayMap[K, V]) Set(key K, value V) error { + m.mu.Lock() + defer m.mu.Unlock() + + if old, ok := m.forward[key]; ok && old != value { + delete(m.reverse, old) + } + if existingKey, ok := m.reverse[value]; ok && existingKey != key { + return fmt.Errorf("value already mapped") + } + m.forward[key] = value + m.reverse[value] = key + return nil +} + +func (m *TwoWayMap[K, V]) DeleteKey(key K) { + m.mu.Lock() + defer m.mu.Unlock() + if value, ok := m.forward[key]; ok { + delete(m.forward, key) + delete(m.reverse, value) + } +} + +func (m *TwoWayMap[K, V]) Get(key K) (V, bool) { + m.mu.RLock() + defer m.mu.RUnlock() + v, ok := m.forward[key] + return v, ok +} + +func (m *TwoWayMap[K, V]) GetKey(value V) (K, bool) { + m.mu.RLock() + defer m.mu.RUnlock() + k, ok := m.reverse[value] + return k, ok +} + +func (m *TwoWayMap[K, V]) Keys() []K { + m.mu.RLock() + defer m.mu.RUnlock() + keys := make([]K, 0, len(m.forward)) + for key := range m.forward { + keys = append(keys, key) + } + return keys +} + +// UnsafeForward returns the forward map directly. Caller must hold external synchronization. +func (m *TwoWayMap[K, V]) UnsafeForward() map[K]V { + return m.forward +}