Add optional PNG screenshot mode

PNG screenshots are now gated by WEBTERM_SCREENSHOT_MODE.
The dashboard selects SVG by default and switches to PNG when enabled,
with ETag caching and eviction for both formats.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
GitHub Copilot
2026-02-18 15:45:22 +00:00
parent 8d54de6407
commit a6ada31aa6
6 changed files with 393 additions and 4 deletions
+1
View File
@@ -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)
+1
View File
@@ -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"
+76
View File
@@ -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
}
+118
View File
@@ -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}
}
+147 -4
View File
@@ -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(`<!DOCTYPE html>
<html>
<head>
@@ -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) {
}
</script>
</body>
</html>`, string(tilesJSON), composeModeJS, dockerWatchJS)
</html>`, 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()
}
}
+50
View File
@@ -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) {