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>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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=
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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(`<svg width="%d" height="%d" xmlns="http://www.w3.org/2000/svg"></svg>`, 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(`<svg width="%d" height="%d" xmlns="http://www.w3.org/2000/svg"><polygon points="%s" fill="rgba(74, 222, 128, 0.2)" /><polyline points="%s" fill="none" stroke="#4ade80" stroke-width="1.5" /></svg>`, 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
|
||||
}
|
||||
@@ -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, "<polyline") {
|
||||
t.Fatalf("expected polyline in sparkline svg")
|
||||
}
|
||||
if !strings.Contains(svg, `width="120"`) {
|
||||
t.Fatalf("expected width to be present")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalculateCPUPercentUsesPreviousStats(t *testing.T) {
|
||||
collector := NewDockerStatsCollector("/tmp/does-not-exist.sock", "")
|
||||
cpuStats := map[string]any{
|
||||
"cpu_usage": map[string]any{
|
||||
"total_usage": float64(200),
|
||||
"percpu_usage": []any{1, 2},
|
||||
},
|
||||
"system_cpu_usage": float64(400),
|
||||
"online_cpus": float64(2),
|
||||
}
|
||||
preCPU := map[string]any{
|
||||
"cpu_usage": map[string]any{"total_usage": float64(100)},
|
||||
"system_cpu_usage": float64(200),
|
||||
}
|
||||
value, ok := collector.calculateCPUPercent("svc", cpuStats, preCPU)
|
||||
if !ok || value <= 0 {
|
||||
t.Fatalf("expected cpu percent, got ok=%v value=%v", ok, value)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
package webterm
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
WebtermLabelName = "webterm-command"
|
||||
WebtermThemeLabel = "webterm-theme"
|
||||
)
|
||||
|
||||
type DockerWatcher struct {
|
||||
sessionManager *SessionManager
|
||||
socketPath string
|
||||
|
||||
onContainerAdded func(slug, name, command string)
|
||||
onContainerRemoved func(slug string)
|
||||
|
||||
mu sync.RWMutex
|
||||
managed map[string]string
|
||||
running bool
|
||||
cancel context.CancelFunc
|
||||
waitDone chan struct{}
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
func NewDockerWatcher(
|
||||
sessionManager *SessionManager,
|
||||
socketPath string,
|
||||
onAdded func(slug, name, command string),
|
||||
onRemoved func(slug string),
|
||||
) *DockerWatcher {
|
||||
if socketPath == "" {
|
||||
socketPath = DockerSocketPath()
|
||||
}
|
||||
return &DockerWatcher{
|
||||
sessionManager: sessionManager,
|
||||
socketPath: socketPath,
|
||||
onContainerAdded: onAdded,
|
||||
onContainerRemoved: onRemoved,
|
||||
managed: map[string]string{},
|
||||
waitDone: make(chan struct{}),
|
||||
httpClient: newUnixHTTPClient(socketPath, 0),
|
||||
}
|
||||
}
|
||||
|
||||
func hasWebtermLabel(labels map[string]string) bool {
|
||||
_, hasCommand := labels[WebtermLabelName]
|
||||
_, hasTheme := labels[WebtermThemeLabel]
|
||||
return hasCommand || hasTheme
|
||||
}
|
||||
|
||||
func isAutoLabel(value string) bool {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return true
|
||||
}
|
||||
return strings.EqualFold(strings.TrimSpace(value), "auto")
|
||||
}
|
||||
|
||||
func (w *DockerWatcher) getContainerCommand(container map[string]any) string {
|
||||
labels := toStringMap(container["Labels"])
|
||||
value := labels[WebtermLabelName]
|
||||
if isAutoLabel(value) {
|
||||
return AutoCommandSentinel
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func (w *DockerWatcher) getContainerTheme(container map[string]any) string {
|
||||
labels := toStringMap(container["Labels"])
|
||||
return strings.TrimSpace(labels[WebtermThemeLabel])
|
||||
}
|
||||
|
||||
func (w *DockerWatcher) getContainerName(container map[string]any) string {
|
||||
names := toStringSlice(container["Names"])
|
||||
if len(names) > 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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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(`<!DOCTYPE html><html><head><title>Session Dashboard</title><link rel="manifest" href="/static/manifest.json"><meta name="theme-color" content="#0d1117"><link rel="icon" href="/static/icons/webterm-192.png" sizes="192x192"><style>body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;margin:16px;background:#0f172a;color:#e2e8f0}.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:12px}.tile{background:#1e293b;border:1px solid #334155;border-radius:8px;overflow:hidden;cursor:pointer}.tile-header{padding:10px 12px;font-weight:bold;border-bottom:1px solid #334155;display:flex;justify-content:space-between}.thumb{width:100%%;height:180px;object-fit:contain;background:#0b1220;display:block}.meta{padding:8px 12px;color:#94a3b8;font-size:12px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.empty{color:#64748b;text-align:center;padding:40px}</style></head><body><h1>Sessions</h1><div id="subtitle"></div><div class="grid" id="grid"></div><script>let tiles=%s;const composeMode=%s;const dockerWatchMode=%s;let cardsBySlug={};const grid=document.getElementById("grid");const subtitle=document.getElementById("subtitle");function openTile(tile){const url='/?route_key='+encodeURIComponent(tile.slug);const target='webterm-'+tile.slug;let win=window.open(url,target);if(!win){window.location.href=url;return}if(typeof win.focus==='function'){win.focus()}}function makeTile(tile){const card=document.createElement('div');card.className='tile';const header=document.createElement('div');header.className='tile-header';header.innerHTML='<span>'+tile.name+'</span>';if(composeMode){const spark=document.createElement('img');spark.width=80;spark.height=16;spark.alt='CPU';header.appendChild(spark);card.sparkline=spark}const img=document.createElement('img');img.className='thumb';img.alt=tile.name;const meta=document.createElement('div');meta.className='meta';meta.textContent=tile.command||'';const body=document.createElement('div');body.appendChild(img);card.appendChild(header);card.appendChild(body);card.appendChild(meta);card.onclick=()=>openTile(tile);card.img=img;return card}function refreshTile(slug){const card=cardsBySlug[slug];if(card){card.img.src='/screenshot.svg?route_key='+encodeURIComponent(slug)+'&_t='+Date.now()}}function refreshSparklines(){if(!composeMode)return;tiles.forEach(tile=>{const card=cardsBySlug[tile.slug];if(card&&card.sparkline){card.sparkline.src='/cpu-sparkline.svg?container='+encodeURIComponent(tile.slug)+'&width=80&height=16&_t='+Date.now()}})}async function refreshTiles(){try{const resp=await fetch('/tiles');const next=await resp.json();const oldSlugs=tiles.map(t=>t.slug).sort().join(',');const newSlugs=next.map(t=>t.slug).sort().join(',');if(oldSlugs!==newSlugs){tiles=next;render()}}catch(_){}}function render(){grid.innerHTML='';cardsBySlug={};if(!tiles.length){grid.innerHTML='<div class="empty">No containers found. Start containers with the webterm-command label.</div>';subtitle.textContent=dockerWatchMode?'Watching for containers with webterm-command label...':'';return}subtitle.textContent=dockerWatchMode?tiles.length+' container(s) found':'';tiles.forEach(tile=>{const card=makeTile(tile);card.img.src='/screenshot.svg?route_key='+encodeURIComponent(tile.slug);grid.appendChild(card);cardsBySlug[tile.slug]=card});refreshSparklines()}let source=null;function startSSE(){if(source)return;source=new EventSource('/events');source.addEventListener('activity',(e)=>{if(e.data==='__dashboard__'){refreshTiles()}else{refreshTile(e.data)}});source.onerror=()=>{source.close();source=null;setTimeout(startSSE,2000)}}render();if(!document.hidden)startSSE();document.addEventListener('visibilitychange',()=>{if(document.hidden){if(source){source.close();source=null}}else startSSE()});if(composeMode){refreshSparklines();setInterval(refreshSparklines,30000)}</script></body></html>`, 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, "<!DOCTYPE html><html><head><title>Webterm Server</title></head><body><h2>No Apps Available</h2><p>No terminal applications are configured.</p></body></html>")
|
||||
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(`<!DOCTYPE html><html><head><title>%s</title><link rel="stylesheet" href="/static/monospace.css"><style>html,body{width:100%%;height:100%%}body{background:%s;margin:0;padding:0;overflow:hidden;font-family:var(--webterm-mono)}.webterm-terminal{width:100%%;height:100%%;display:block;overflow:hidden}</style></head><body><div id="terminal" class="webterm-terminal" %s></div><script type="module" src="/static/js/terminal.js"></script></body></html>`, 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
|
||||
}
|
||||
}
|
||||
@@ -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), "<svg") {
|
||||
t.Fatalf("expected svg body and etag")
|
||||
}
|
||||
|
||||
req, _ := http.NewRequest(http.MethodGet, httpServer.URL+"/screenshot.svg?route_key=shell", nil)
|
||||
req.Header.Set("If-None-Match", etag)
|
||||
resp2, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("etag request error = %v", err)
|
||||
}
|
||||
_ = resp2.Body.Close()
|
||||
if resp2.StatusCode != http.StatusNotModified {
|
||||
t.Fatalf("expected 304, got %d", resp2.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRootTerminalPageAndSparklineValidation(t *testing.T) {
|
||||
_, httpServer, _ := newServerForTests(t, false)
|
||||
resp, err := http.Get(httpServer.URL + "/?route_key=shell")
|
||||
if err != nil {
|
||||
t.Fatalf("root request error = %v", err)
|
||||
}
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
_ = resp.Body.Close()
|
||||
text := string(body)
|
||||
if !strings.Contains(text, "/static/js/terminal.js") || !strings.Contains(text, "data-session-websocket-url") {
|
||||
t.Fatalf("unexpected root page: %q", text)
|
||||
}
|
||||
|
||||
resp2, err := http.Get(httpServer.URL + "/cpu-sparkline.svg")
|
||||
if err != nil {
|
||||
t.Fatalf("sparkline request error = %v", err)
|
||||
}
|
||||
_ = resp2.Body.Close()
|
||||
if resp2.StatusCode != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400 for missing container, got %d", resp2.StatusCode)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package webterm
|
||||
|
||||
import (
|
||||
"github.com/rcarmo/webterm-go-port/terminalstate"
|
||||
)
|
||||
|
||||
type SessionConnector interface {
|
||||
OnData(data []byte)
|
||||
OnBinary(payload []byte)
|
||||
OnMeta(meta map[string]any)
|
||||
OnClose()
|
||||
}
|
||||
|
||||
type Session interface {
|
||||
Open(width, height int) error
|
||||
Start(connector SessionConnector) error
|
||||
Close() error
|
||||
Wait() error
|
||||
SetTerminalSize(width, height int) error
|
||||
SendBytes(data []byte) bool
|
||||
SendMeta(meta map[string]any) bool
|
||||
IsRunning() bool
|
||||
GetReplayBuffer() []byte
|
||||
GetScreenSnapshot() terminalstate.Snapshot
|
||||
ForceRedraw() error
|
||||
UpdateConnector(connector SessionConnector)
|
||||
}
|
||||
|
||||
type noopConnector struct{}
|
||||
|
||||
func (noopConnector) OnData([]byte) {}
|
||||
func (noopConnector) OnBinary([]byte) {}
|
||||
func (noopConnector) OnMeta(map[string]any) {}
|
||||
func (noopConnector) OnClose() {}
|
||||
@@ -0,0 +1,233 @@
|
||||
package webterm
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
type SessionManager struct {
|
||||
mu sync.RWMutex
|
||||
apps []App
|
||||
appsBySlug map[string]App
|
||||
sessions map[string]Session
|
||||
routes *TwoWayMap[string, string]
|
||||
|
||||
sessionFactory func(app App, sessionID string) Session
|
||||
}
|
||||
|
||||
func NewSessionManager(apps []App) *SessionManager {
|
||||
m := &SessionManager{
|
||||
apps: append([]App{}, apps...),
|
||||
appsBySlug: map[string]App{},
|
||||
sessions: map[string]Session{},
|
||||
routes: NewTwoWayMap[string, string](),
|
||||
}
|
||||
for _, app := range m.apps {
|
||||
m.appsBySlug[app.Slug] = app
|
||||
}
|
||||
m.sessionFactory = m.defaultSessionFactory
|
||||
return m
|
||||
}
|
||||
|
||||
func (m *SessionManager) SetSessionFactory(factory func(app App, sessionID string) Session) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.sessionFactory = factory
|
||||
}
|
||||
|
||||
func (m *SessionManager) defaultSessionFactory(app App, sessionID string) Session {
|
||||
if app.Command == AutoCommandSentinel {
|
||||
auto := os.Getenv(DockerAutoCommandEnv)
|
||||
if strings.TrimSpace(auto) == "" {
|
||||
auto = "/bin/bash"
|
||||
}
|
||||
auto = strings.ReplaceAll(auto, "{container}", app.Name)
|
||||
spec := DockerExecSpec{
|
||||
Container: app.Name,
|
||||
Command: splitCommand(auto),
|
||||
User: os.Getenv(DockerUsernameEnv),
|
||||
}
|
||||
return NewDockerExecSession(sessionID, spec, "")
|
||||
}
|
||||
return NewTerminalSession(sessionID, app.Command)
|
||||
}
|
||||
|
||||
func splitCommand(command string) []string {
|
||||
argv, err := shlexSplit(command)
|
||||
if err != nil || len(argv) == 0 {
|
||||
return []string{"/bin/sh", "-lc", command}
|
||||
}
|
||||
return argv
|
||||
}
|
||||
|
||||
func shlexSplit(command string) ([]string, error) {
|
||||
return shlexSplitImpl(command)
|
||||
}
|
||||
|
||||
func (m *SessionManager) AddApp(name, command, slug string, terminal bool, theme string) string {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if strings.TrimSpace(slug) == "" {
|
||||
slug = strings.ToLower(GenerateID(12))
|
||||
}
|
||||
app := App{
|
||||
Name: name,
|
||||
Slug: slug,
|
||||
Path: "./",
|
||||
Command: command,
|
||||
Terminal: terminal,
|
||||
Theme: theme,
|
||||
}
|
||||
m.apps = append(m.apps, app)
|
||||
m.appsBySlug[slug] = app
|
||||
return slug
|
||||
}
|
||||
|
||||
func (m *SessionManager) RemoveApp(slug string) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
delete(m.appsBySlug, slug)
|
||||
filtered := m.apps[:0]
|
||||
for _, app := range m.apps {
|
||||
if app.Slug != slug {
|
||||
filtered = append(filtered, app)
|
||||
}
|
||||
}
|
||||
m.apps = filtered
|
||||
}
|
||||
|
||||
func (m *SessionManager) Apps() []App {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
return append([]App{}, m.apps...)
|
||||
}
|
||||
|
||||
func (m *SessionManager) AppBySlug(slug string) (App, bool) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
app, ok := m.appsBySlug[slug]
|
||||
return app, ok
|
||||
}
|
||||
|
||||
func (m *SessionManager) GetDefaultApp() (App, bool) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
if len(m.apps) == 0 {
|
||||
return App{}, false
|
||||
}
|
||||
return m.apps[0], true
|
||||
}
|
||||
|
||||
func (m *SessionManager) NewSession(slug, sessionID, routeKey string, width, height int) (Session, error) {
|
||||
m.mu.Lock()
|
||||
app, ok := m.appsBySlug[slug]
|
||||
if !ok {
|
||||
m.mu.Unlock()
|
||||
return nil, fmt.Errorf("app not found")
|
||||
}
|
||||
// Check if this routeKey already has an active session
|
||||
if existingID, exists := m.routes.UnsafeForward()[routeKey]; exists {
|
||||
if existingSession, alive := m.sessions[existingID]; alive && existingSession.IsRunning() {
|
||||
m.mu.Unlock()
|
||||
return existingSession, nil
|
||||
}
|
||||
// Stale mapping — clean up
|
||||
delete(m.sessions, existingID)
|
||||
m.routes.DeleteKey(routeKey)
|
||||
}
|
||||
factory := m.sessionFactory
|
||||
m.mu.Unlock()
|
||||
|
||||
session := factory(app, sessionID)
|
||||
if session == nil {
|
||||
return nil, fmt.Errorf("session factory returned nil")
|
||||
}
|
||||
if err := session.Open(width, height); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
// Re-check after re-acquiring lock
|
||||
if existingID, exists := m.routes.UnsafeForward()[routeKey]; exists {
|
||||
if existingSession, alive := m.sessions[existingID]; alive && existingSession.IsRunning() {
|
||||
// Another goroutine won the race; close ours and return theirs
|
||||
go func() { _ = session.Close() }()
|
||||
return existingSession, nil
|
||||
}
|
||||
delete(m.sessions, existingID)
|
||||
m.routes.DeleteKey(routeKey)
|
||||
}
|
||||
m.sessions[sessionID] = session
|
||||
_ = m.routes.Set(routeKey, sessionID)
|
||||
return session, nil
|
||||
}
|
||||
|
||||
func (m *SessionManager) OnSessionEnd(sessionID string) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
delete(m.sessions, sessionID)
|
||||
if route, ok := m.routes.GetKey(sessionID); ok {
|
||||
m.routes.DeleteKey(route)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *SessionManager) CloseAll() {
|
||||
m.mu.RLock()
|
||||
sessions := make([]Session, 0, len(m.sessions))
|
||||
for _, session := range m.sessions {
|
||||
sessions = append(sessions, session)
|
||||
}
|
||||
m.mu.RUnlock()
|
||||
for _, session := range sessions {
|
||||
_ = session.Close()
|
||||
_ = session.Wait()
|
||||
}
|
||||
}
|
||||
|
||||
func (m *SessionManager) CloseSession(sessionID string) {
|
||||
m.mu.RLock()
|
||||
session := m.sessions[sessionID]
|
||||
m.mu.RUnlock()
|
||||
if session == nil {
|
||||
return
|
||||
}
|
||||
_ = session.Close()
|
||||
_ = session.Wait()
|
||||
m.OnSessionEnd(sessionID)
|
||||
}
|
||||
|
||||
func (m *SessionManager) GetSession(sessionID string) Session {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
return m.sessions[sessionID]
|
||||
}
|
||||
|
||||
func (m *SessionManager) GetSessionByRouteKey(routeKey string) Session {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
sessionID, ok := m.routes.Get(routeKey)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return m.sessions[sessionID]
|
||||
}
|
||||
|
||||
func (m *SessionManager) GetSessionIDByRouteKey(routeKey string) (string, bool) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
return m.routes.Get(routeKey)
|
||||
}
|
||||
|
||||
func (m *SessionManager) GetFirstRunningSession() (string, Session, bool) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
for routeKey, sessionID := range m.routes.UnsafeForward() {
|
||||
session := m.sessions[sessionID]
|
||||
if session != nil && session.IsRunning() {
|
||||
return routeKey, session, true
|
||||
}
|
||||
}
|
||||
return "", nil, false
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package webterm
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSessionManagerRouteMappingAndCleanup(t *testing.T) {
|
||||
manager := NewSessionManager([]App{{Name: "Shell", Slug: "shell", Command: "/bin/sh", Terminal: true}})
|
||||
var created *fakeSession
|
||||
manager.SetSessionFactory(func(app App, sessionID string) Session {
|
||||
created = newFakeSession()
|
||||
return created
|
||||
})
|
||||
|
||||
session, err := manager.NewSession("shell", "sid-1", "route-1", 80, 24)
|
||||
if err != nil {
|
||||
t.Fatalf("NewSession() error = %v", err)
|
||||
}
|
||||
if session == nil || created == nil {
|
||||
t.Fatalf("expected session to be created")
|
||||
}
|
||||
if got := manager.GetSessionByRouteKey("route-1"); got == nil {
|
||||
t.Fatalf("expected session lookup by route key")
|
||||
}
|
||||
manager.OnSessionEnd("sid-1")
|
||||
if got := manager.GetSessionByRouteKey("route-1"); got != nil {
|
||||
t.Fatalf("expected route mapping to be removed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitCommandWithFallback(t *testing.T) {
|
||||
parts := splitCommand(`echo "hello world"`)
|
||||
if len(parts) != 2 || parts[1] != "hello world" {
|
||||
t.Fatalf("unexpected split command: %#v", parts)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package webterm
|
||||
|
||||
import "github.com/google/shlex"
|
||||
|
||||
func shlexSplitImpl(command string) ([]string, error) {
|
||||
return shlex.Split(command)
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package webterm
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var (
|
||||
slugNonWord = regexp.MustCompile(`[^\w\s-]`)
|
||||
slugSpaces = regexp.MustCompile(`[-\s]+`)
|
||||
)
|
||||
|
||||
func Slugify(value string) string {
|
||||
v := strings.ToLower(value)
|
||||
v = slugNonWord.ReplaceAllString(v, "")
|
||||
v = slugSpaces.ReplaceAllString(v, "-")
|
||||
return strings.Trim(v, "-_")
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
package webterm
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"html"
|
||||
"strings"
|
||||
|
||||
"github.com/rcarmo/webterm-go-port/terminalstate"
|
||||
)
|
||||
|
||||
var ansiColors = map[string]string{
|
||||
"black": "#000000",
|
||||
"red": "#cc0000",
|
||||
"green": "#4e9a06",
|
||||
"yellow": "#c4a000",
|
||||
"blue": "#3465a4",
|
||||
"magenta": "#75507b",
|
||||
"cyan": "#06989a",
|
||||
"white": "#d3d7cf",
|
||||
"brightblack": "#555753",
|
||||
"brightred": "#ef2929",
|
||||
"brightgreen": "#8ae234",
|
||||
"brightyellow": "#fce94f",
|
||||
"brightblue": "#729fcf",
|
||||
"brightmagenta": "#ad7fa8",
|
||||
"brightcyan": "#34e2e2",
|
||||
"brightwhite": "#eeeeec",
|
||||
"gray": "#555753",
|
||||
"grey": "#555753",
|
||||
"lightgray": "#d3d7cf",
|
||||
"lightgrey": "#d3d7cf",
|
||||
"brown": "#c4a000",
|
||||
}
|
||||
|
||||
func RenderTerminalSVG(
|
||||
buffer [][]terminalstate.Cell,
|
||||
width, height int,
|
||||
title, background, foreground string,
|
||||
palette map[string]string,
|
||||
) string {
|
||||
if background == "" {
|
||||
background = "#000000"
|
||||
}
|
||||
if foreground == "" {
|
||||
foreground = "#d3d7cf"
|
||||
}
|
||||
if title == "" {
|
||||
title = "webterm"
|
||||
}
|
||||
if palette == nil {
|
||||
palette = ansiColors
|
||||
}
|
||||
fontSize := 14.0
|
||||
charWidth := 8.0
|
||||
lineHeight := 1.2
|
||||
cellHeight := fontSize * lineHeight
|
||||
svgWidth := float64(width)*charWidth + 20
|
||||
svgHeight := float64(height)*cellHeight + 20
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString(fmt.Sprintf(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 %.1f %.1f" class="terminal-svg">`, svgWidth, svgHeight))
|
||||
b.WriteString("<title>" + html.EscapeString(title) + "</title>")
|
||||
b.WriteString(`<defs><style>.terminal-bg{fill:` + background + `}.terminal-text{font-family:ui-monospace,"SFMono-Regular","Fira Code",Menlo,Monaco,Consolas,"Liberation Mono","DejaVu Sans Mono","Courier New",monospace;font-size:14px;fill:` + foreground + `;white-space:pre;text-rendering:optimizeLegibility}.bold{font-weight:bold}.italic{font-style:italic}.underline{text-decoration:underline}</style></defs>`)
|
||||
b.WriteString(fmt.Sprintf(`<rect class="terminal-bg" x="0" y="0" width="%.1f" height="%.1f"/>`, svgWidth, svgHeight))
|
||||
b.WriteString(`<g class="terminal-text">`)
|
||||
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(`<rect x="%.1f" y="%.1f" width="%.1f" height="%.1f" fill="%s"/>`, 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(`<tspan ` + strings.Join(attrs, " ") + `>` + html.EscapeString(charData) + `</tspan>`)
|
||||
}
|
||||
if rowText.Len() > 0 {
|
||||
b.WriteString(fmt.Sprintf(`<text y="%.1f">%s</text>`, textY, rowText.String()))
|
||||
}
|
||||
}
|
||||
b.WriteString(`</g></svg>`)
|
||||
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
|
||||
}
|
||||
@@ -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, "<svg") || !strings.Contains(svg, "<tspan") {
|
||||
t.Fatalf("expected svg output with tspan, got %q", svg)
|
||||
}
|
||||
if !strings.Contains(svg, "A") {
|
||||
t.Fatalf("expected rendered cell data")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
package webterm
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
|
||||
"github.com/creack/pty"
|
||||
"github.com/google/shlex"
|
||||
"github.com/rcarmo/webterm-go-port/terminalstate"
|
||||
)
|
||||
|
||||
type TerminalSession struct {
|
||||
sessionID string
|
||||
command string
|
||||
|
||||
mu sync.RWMutex
|
||||
connector SessionConnector
|
||||
cmd *exec.Cmd
|
||||
ptyFile *os.File
|
||||
tracker *terminalstate.Tracker
|
||||
replay *ReplayBuffer
|
||||
escapeBuffer []byte
|
||||
utf8Buffer []byte
|
||||
width int
|
||||
height int
|
||||
started bool
|
||||
running bool
|
||||
done chan struct{}
|
||||
doneOnce sync.Once
|
||||
waitErr error
|
||||
writeMu sync.Mutex
|
||||
}
|
||||
|
||||
func NewTerminalSession(sessionID string, command string) *TerminalSession {
|
||||
return &TerminalSession{
|
||||
sessionID: sessionID,
|
||||
command: command,
|
||||
connector: noopConnector{},
|
||||
replay: NewReplayBuffer(replayBufferSize),
|
||||
done: make(chan struct{}),
|
||||
width: DefaultTerminalWidth,
|
||||
height: DefaultTerminalHeight,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *TerminalSession) Open(width, height int) error {
|
||||
if width <= 0 {
|
||||
width = 80
|
||||
}
|
||||
if height <= 0 {
|
||||
height = 24
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.running {
|
||||
return nil
|
||||
}
|
||||
command := strings.TrimSpace(s.command)
|
||||
if command == "" {
|
||||
command = os.Getenv("SHELL")
|
||||
}
|
||||
if command == "" {
|
||||
command = "/bin/sh"
|
||||
}
|
||||
argv, err := shlex.Split(command)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(argv) == 0 {
|
||||
return errors.New("empty command")
|
||||
}
|
||||
cmd := exec.Command(argv[0], argv[1:]...)
|
||||
cmd.Env = append(os.Environ(), "TERM_PROGRAM=webterm-go", "TERM_PROGRAM_VERSION=0.0.0")
|
||||
file, err := pty.StartWithSize(cmd, &pty.Winsize{Cols: uint16(width), Rows: uint16(height)})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.cmd = cmd
|
||||
s.ptyFile = file
|
||||
s.tracker = terminalstate.NewTracker(width, height)
|
||||
s.width = width
|
||||
s.height = height
|
||||
s.running = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *TerminalSession) Start(connector SessionConnector) error {
|
||||
s.mu.Lock()
|
||||
if connector != nil {
|
||||
s.connector = connector
|
||||
}
|
||||
if s.started {
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
if s.ptyFile == nil {
|
||||
s.mu.Unlock()
|
||||
return errors.New("session not open")
|
||||
}
|
||||
s.started = true
|
||||
file := s.ptyFile
|
||||
s.mu.Unlock()
|
||||
go s.readLoop(file)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *TerminalSession) readLoop(file *os.File) {
|
||||
buf := make([]byte, 32*1024)
|
||||
for {
|
||||
n, err := file.Read(buf)
|
||||
if n > 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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user