diff --git a/README.md b/README.md index 4099323..458b0e6 100644 --- a/README.md +++ b/README.md @@ -97,6 +97,7 @@ go run ./cmd/webterm -- --compose-manifest ./docker-compose.yaml - `WEBTERM_DOCKER_USERNAME`: user for Docker exec sessions - `WEBTERM_DOCKER_AUTO_COMMAND`: override auto command (`/bin/bash` default) - `WEBTERM_SCREENSHOT_FORCE_REDRAW`: force redraw before screenshots (`true/1/yes/on`) +- `WEBTERM_SCREENSHOT_MODE`: screenshot format for dashboard thumbnails (`svg` default, set `png` to enable PNG) - `DOCKER_HOST`: Docker daemon endpoint override ## Development (Makefile-first) diff --git a/webterm/constants.go b/webterm/constants.go index 2955caf..2a38fdc 100644 --- a/webterm/constants.go +++ b/webterm/constants.go @@ -15,6 +15,7 @@ const ( DefaultTerminalHeight = 45 ScreenshotForceRedrawEnv = "WEBTERM_SCREENSHOT_FORCE_REDRAW" + ScreenshotModeEnv = "WEBTERM_SCREENSHOT_MODE" DockerUsernameEnv = "WEBTERM_DOCKER_USERNAME" DockerAutoCommandEnv = "WEBTERM_DOCKER_AUTO_COMMAND" DockerHostEnv = "DOCKER_HOST" diff --git a/webterm/coverage_table.go b/webterm/coverage_table.go new file mode 100644 index 0000000..0310c3d --- /dev/null +++ b/webterm/coverage_table.go @@ -0,0 +1,76 @@ +package webterm + +import "unicode" + +const ( + coverageSpace = 0 + coveragePunctLight = 32 + coveragePunct = 64 + coverageLower = 96 + coverageUpper = 112 + coverageDigit = 104 + coverageDefault = 96 +) + +var firaCodeCoverage = map[rune]uint8{} + +func init() { + firaCodeCoverage[' '] = coverageSpace + for _, r := range []rune(".,:;'\"") { + firaCodeCoverage[r] = coveragePunctLight + } + for _, r := range []rune("`") { + firaCodeCoverage[r] = coveragePunctLight + } + for _, r := range []rune("+-*/=<>()[]{}") { + firaCodeCoverage[r] = coveragePunct + } + for _, r := range []rune("|!#@$%^&?_") { + firaCodeCoverage[r] = coveragePunct + } + + // Box drawing block — approximate light line density. + for r := rune(0x2500); r <= 0x257F; r++ { + firaCodeCoverage[r] = 72 + } + + // Block elements and shading. + firaCodeCoverage['░'] = 64 + firaCodeCoverage['▒'] = 128 + firaCodeCoverage['▓'] = 192 + firaCodeCoverage['█'] = 255 + firaCodeCoverage['▀'] = 128 + firaCodeCoverage['▄'] = 128 + firaCodeCoverage['▌'] = 128 + firaCodeCoverage['▐'] = 128 + + verticalBlocks := []rune{'▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'} + for i, r := range verticalBlocks { + firaCodeCoverage[r] = uint8((i + 1) * 255 / len(verticalBlocks)) + } + leftBlocks := []rune{'▏', '▎', '▍', '▌', '▋', '▊', '▉', '█'} + for i, r := range leftBlocks { + firaCodeCoverage[r] = uint8((i + 1) * 255 / len(leftBlocks)) + } +} + +func coverageForRune(r rune) uint8 { + if value, ok := firaCodeCoverage[r]; ok { + return value + } + if unicode.IsSpace(r) { + return coverageSpace + } + switch { + case r >= '0' && r <= '9': + return coverageDigit + case r >= 'A' && r <= 'Z': + return coverageUpper + case r >= 'a' && r <= 'z': + return coverageLower + } + if unicode.IsPunct(r) || unicode.IsSymbol(r) { + return coveragePunct + } + return coverageDefault +} diff --git a/webterm/png_exporter.go b/webterm/png_exporter.go new file mode 100644 index 0000000..bc2fe83 --- /dev/null +++ b/webterm/png_exporter.go @@ -0,0 +1,118 @@ +package webterm + +import ( + "bytes" + "image" + "image/color" + "image/draw" + "image/png" + "strconv" + "strings" + + "github.com/rcarmo/webterm/internal/terminalstate" +) + +const ( + pngCharWidth = 8 + pngCellHeight = 17 + pngPadding = 10 +) + +func RenderTerminalPNG( + buffer [][]terminalstate.Cell, + width, height int, + background, foreground string, + palette map[string]string, +) ([]byte, error) { + if background == "" { + background = "#000000" + } + if foreground == "" { + foreground = "#d3d7cf" + } + if palette == nil { + palette = ansiColors + } + cellHeight := pngCellHeight + imgWidth := width*pngCharWidth + pngPadding*2 + imgHeight := height*cellHeight + pngPadding*2 + if imgWidth <= 0 || imgHeight <= 0 { + return nil, nil + } + + bgColor := mustParseHexColor(background) + img := image.NewRGBA(image.Rect(0, 0, imgWidth, imgHeight)) + draw.Draw(img, img.Bounds(), &image.Uniform{bgColor}, image.Point{}, draw.Src) + + for rowIdx := 0; rowIdx < len(buffer); rowIdx++ { + row := buffer[rowIdx] + rectY := pngPadding + rowIdx*cellHeight + for col := 0; col < len(row); col++ { + cell := row[col] + if cell.Data == "" && cell.BG == "" { + continue + } + x := pngPadding + col*pngCharWidth + fg := colorToHex(cell.FG, true, palette, foreground, background) + bg := colorToHex(cell.BG, false, palette, foreground, background) + if cell.Reverse { + fg, bg = bg, fg + } + bgColor := mustParseHexColor(bg) + coverage := uint8(0) + if cell.Data != "" { + coverage = coverageForRune(firstRune(cell.Data)) + } + cellColor := blendColors(mustParseHexColor(fg), bgColor, coverage) + draw.Draw( + img, + image.Rect(x, rectY, x+pngCharWidth, rectY+cellHeight), + &image.Uniform{cellColor}, + image.Point{}, + draw.Src, + ) + } + } + + var out bytes.Buffer + if err := png.Encode(&out, img); err != nil { + return nil, err + } + return out.Bytes(), nil +} + +func firstRune(value string) rune { + for _, r := range value { + return r + } + return ' ' +} + +func blendColors(fg, bg color.RGBA, coverage uint8) color.RGBA { + inv := 255 - uint16(coverage) + cov := uint16(coverage) + r := (uint16(fg.R)*cov + uint16(bg.R)*inv + 127) / 255 + g := (uint16(fg.G)*cov + uint16(bg.G)*inv + 127) / 255 + b := (uint16(fg.B)*cov + uint16(bg.B)*inv + 127) / 255 + return color.RGBA{R: uint8(r), G: uint8(g), B: uint8(b), A: 255} +} + +func mustParseHexColor(value string) color.RGBA { + value = strings.TrimSpace(strings.TrimPrefix(value, "#")) + if len(value) != 6 { + return color.RGBA{A: 255} + } + r, err := strconv.ParseUint(value[0:2], 16, 8) + if err != nil { + return color.RGBA{A: 255} + } + g, err := strconv.ParseUint(value[2:4], 16, 8) + if err != nil { + return color.RGBA{A: 255} + } + b, err := strconv.ParseUint(value[4:6], 16, 8) + if err != nil { + return color.RGBA{A: 255} + } + return color.RGBA{R: uint8(r), G: uint8(g), B: uint8(b), A: 255} +} diff --git a/webterm/server.go b/webterm/server.go index d4df423..d85ac7b 100644 --- a/webterm/server.go +++ b/webterm/server.go @@ -52,6 +52,12 @@ type screenshotCacheEntry struct { etag string } +type screenshotPNGCacheEntry struct { + when time.Time + png []byte + etag string +} + type wsClient struct { routeKey string conn *websocket.Conn @@ -164,6 +170,7 @@ type LocalServer struct { theme string fontFamily string fontSize int + screenshotMode string sessionManager *SessionManager landingApps []App @@ -177,6 +184,7 @@ type LocalServer struct { mu sync.RWMutex wsClients map[string]*wsClient screenshotCache map[string]screenshotCacheEntry + screenshotPNGCache map[string]screenshotPNGCacheEntry routeLastActivity map[string]time.Time routeLastSSE map[string]time.Time sseSubscribers map[chan string]struct{} @@ -231,6 +239,10 @@ func NewLocalServer(config Config, options ServerOptions) *LocalServer { if fontSize <= 0 { fontSize = DefaultFontSize } + screenshotMode := strings.ToLower(strings.TrimSpace(os.Getenv(ScreenshotModeEnv))) + if screenshotMode != "png" { + screenshotMode = "svg" + } apps := append([]App{}, config.Apps...) for _, app := range options.LandingApps { apps = append(apps, app) @@ -241,6 +253,7 @@ func NewLocalServer(config Config, options ServerOptions) *LocalServer { theme: theme, fontFamily: options.FontFamily, fontSize: fontSize, + screenshotMode: screenshotMode, sessionManager: NewSessionManager(apps), landingApps: append([]App{}, options.LandingApps...), @@ -253,6 +266,7 @@ func NewLocalServer(config Config, options ServerOptions) *LocalServer { }, wsClients: map[string]*wsClient{}, screenshotCache: map[string]screenshotCacheEntry{}, + screenshotPNGCache: map[string]screenshotPNGCacheEntry{}, routeLastActivity: map[string]time.Time{}, routeLastSSE: map[string]time.Time{}, sseSubscribers: map[chan string]struct{}{}, @@ -812,6 +826,118 @@ func (s *LocalServer) handleScreenshot(w http.ResponseWriter, r *http.Request) { writeSVG(responseSVG, responseETag) } +func (s *LocalServer) handleScreenshotPNG(w http.ResponseWriter, r *http.Request) { + if s.screenshotMode != "png" { + http.Error(w, "PNG screenshots disabled", http.StatusNotFound) + return + } + download := r.URL.Query().Get("download") == "1" + 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) + deadline := time.Now().Add(500 * time.Millisecond) + for { + session = s.sessionManager.GetSessionByRouteKey(routeKey) + if session != nil { + ok = true + break + } + if time.Now().After(deadline) { + break + } + time.Sleep(20 * time.Millisecond) + } + } + } + if !ok || session == nil { + http.Error(w, "Session not found", http.StatusNotFound) + return + } + + writeNotModified := func(etag string) { + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("ETag", etag) + w.WriteHeader(http.StatusNotModified) + } + writePNG := func(pngBytes []byte, etag string) { + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("ETag", etag) + w.Header().Set("Content-Type", "image/png") + if download { + filename := sanitizeFilenameToken(routeKey) + "-screenshot.png" + w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename)) + } + _, _ = w.Write(pngBytes) + } + useConditional := !download + + s.mu.RLock() + cached, hasCached := s.screenshotPNGCache[routeKey] + lastActivity := s.routeLastActivity[routeKey] + s.mu.RUnlock() + if hasCached && time.Since(cached.when) < s.screenshotTTL(routeKey) && len(cached.png) > 0 { + if useConditional && etagMatches(r.Header.Get("If-None-Match"), cached.etag) { + if !lastActivity.After(cached.when) { + writeNotModified(cached.etag) + return + } + } else { + writePNG(cached.png, cached.etag) + return + } + } + + if s.screenshotForceRedraw { + _ = session.ForceRedraw() + } + + snapshot := session.GetScreenSnapshot() + if hasCached && !snapshot.HasChanges && len(cached.png) > 0 { + if useConditional && etagMatches(r.Header.Get("If-None-Match"), cached.etag) { + writeNotModified(cached.etag) + return + } + writePNG(cached.png, cached.etag) + 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" + } + + pngBytes, err := RenderTerminalPNG(snapshot.Buffer, snapshot.Width, snapshot.Height, background, foreground, palette) + if err != nil || len(pngBytes) == 0 { + http.Error(w, "Screenshot render failed", http.StatusInternalServerError) + return + } + hash := sha1.Sum(pngBytes) + etag := fmt.Sprintf(`"%x"`, hash[:]) + s.mu.Lock() + s.screenshotPNGCache[routeKey] = screenshotPNGCacheEntry{when: time.Now(), png: pngBytes, etag: etag} + s.mu.Unlock() + if useConditional && etagMatches(r.Header.Get("If-None-Match"), etag) { + writeNotModified(etag) + return + } + writePNG(pngBytes, etag) +} + func (s *LocalServer) handleCPUSparkline(w http.ResponseWriter, r *http.Request) { container := r.URL.Query().Get("container") if strings.TrimSpace(container) == "" { @@ -960,6 +1086,14 @@ func (s *LocalServer) handleRoot(w http.ResponseWriter, r *http.Request) { if s.dockerWatch { dockerWatchJS = "true" } + screenshotEndpoint := "/screenshot.svg" + screenshotDownloadQuery := "sanitize_font_urls=1&download=1" + screenshotDownloadExt := "svg" + if s.screenshotMode == "png" { + screenshotEndpoint = "/screenshot.png" + screenshotDownloadQuery = "download=1" + screenshotDownloadExt = "png" + } html := fmt.Sprintf(` @@ -1016,6 +1150,9 @@ func (s *LocalServer) handleRoot(w http.ResponseWriter, r *http.Request) { let tiles = %s; const composeMode = %s; const dockerWatchMode = %s; + const screenshotEndpoint = %q; + const screenshotDownloadQuery = %q; + const screenshotDownloadExt = %q; let cardsBySlug = {}; let searchQuery = ''; @@ -1035,8 +1172,8 @@ func (s *LocalServer) handleRoot(w http.ResponseWriter, r *http.Request) { function downloadSanitizedScreenshot(slug) { if (!slug) return; const link = document.createElement('a'); - link.href = '/screenshot.svg?route_key=' + encodeURIComponent(slug) + '&sanitize_font_urls=1&download=1&_t=' + Date.now(); - link.download = slug + '-screenshot.svg'; + link.href = screenshotEndpoint + '?route_key=' + encodeURIComponent(slug) + '&' + screenshotDownloadQuery + '&_t=' + Date.now(); + link.download = slug + '-screenshot.' + screenshotDownloadExt; document.body.appendChild(link); link.click(); link.remove(); @@ -1282,7 +1419,7 @@ func (s *LocalServer) handleRoot(w http.ResponseWriter, r *http.Request) { return; } screenshotRequestInFlight = true; - const url = '/screenshot.svg?route_key=' + encodeURIComponent(slug); + const url = screenshotEndpoint + '?route_key=' + encodeURIComponent(slug); const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 5000); const headers = {}; @@ -1468,7 +1605,7 @@ func (s *LocalServer) handleRoot(w http.ResponseWriter, r *http.Request) { } -`, string(tilesJSON), composeModeJS, dockerWatchJS) +`, string(tilesJSON), composeModeJS, dockerWatchJS, screenshotEndpoint, screenshotDownloadQuery, screenshotDownloadExt) w.Header().Set("Content-Type", "text/html; charset=utf-8") _, _ = io.WriteString(w, html) return @@ -1604,6 +1741,7 @@ func (s *LocalServer) Handler() http.Handler { mux := http.NewServeMux() mux.HandleFunc("/ws/", s.handleWebSocket) mux.HandleFunc("/screenshot.svg", s.handleScreenshot) + mux.HandleFunc("/screenshot.png", s.handleScreenshotPNG) mux.HandleFunc("/cpu-sparkline.svg", s.handleCPUSparkline) mux.HandleFunc("/events", s.handleEvents) mux.HandleFunc("/health", s.handleHealth) @@ -1633,6 +1771,11 @@ func (s *LocalServer) evictStaleScreenshots(ctx context.Context) { delete(s.screenshotCache, key) } } + for key, entry := range s.screenshotPNGCache { + if time.Since(entry.when) > maxScreenshotCacheTTL { + delete(s.screenshotPNGCache, key) + } + } s.mu.Unlock() } } diff --git a/webterm/server_test.go b/webterm/server_test.go index c3d7e0c..afd20ba 100644 --- a/webterm/server_test.go +++ b/webterm/server_test.go @@ -455,6 +455,38 @@ func TestScreenshotAndETag(t *testing.T) { } } +func TestScreenshotPNGAndETag(t *testing.T) { + t.Setenv(ScreenshotModeEnv, "png") + 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.png?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 == "" || len(body) < 8 || string(body[:8]) != "\x89PNG\r\n\x1a\n" { + t.Fatalf("expected png body and etag") + } + + req, _ := http.NewRequest(http.MethodGet, httpServer.URL+"/screenshot.png?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 TestScreenshotCreatesSessionFromRequestedRoute(t *testing.T) { _, httpServer, _ := newServerForTests(t, false) resp, err := http.Get(httpServer.URL + "/screenshot.svg?route_key=shell") @@ -502,6 +534,24 @@ func TestDashboardIncludesContextMenuSanitizedDownload(t *testing.T) { if !strings.Contains(text, "contextmenu") || !strings.Contains(text, "sanitize_font_urls=1&download=1") { t.Fatalf("expected contextmenu sanitized download wiring in dashboard page") } + if !strings.Contains(text, "screenshot.svg") { + t.Fatalf("expected dashboard to request svg screenshots by default") + } +} + +func TestDashboardUsesPNGWhenEnabled(t *testing.T) { + t.Setenv(ScreenshotModeEnv, "png") + _, httpServer, _ := newServerForTests(t, true) + resp, err := http.Get(httpServer.URL + "/") + if err != nil { + t.Fatalf("dashboard request error = %v", err) + } + body, _ := io.ReadAll(resp.Body) + _ = resp.Body.Close() + text := string(body) + if !strings.Contains(text, "screenshot.png") { + t.Fatalf("expected dashboard to request png screenshots when enabled") + } } func TestRootTerminalPageAndSparklineValidation(t *testing.T) {