Files
verdande/tools/shots/main.go
T
Kristian WindandClaude Opus 5 9d743a5d46 fix(tools): shots skrev PNG'er ned i mappen med WebP
Standardstien var site/screenshots, som siden landingssiden gik over til WebP
kun indeholder WebP. En kørsel lagde tolv PNG'er ved siden af dem, som ingen
tekst peger på. Den skriver nu til docs/, hvor de andre PNG'er ligger.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 07:27:48 +02:00

232 lines
6.7 KiB
Go

// Command shots captures the screenshots used on the site and in the README.
//
// It drives a headless Chrome over the DevTools protocol rather than using
// `--screenshot`, because the app needs a session and that flag cannot set a
// cookie. Kept in the repository so the images can be regenerated when the
// interface changes, rather than being a thing somebody did once by hand.
//
// go run ./tools/shots -url http://localhost:8096 -cookie <session> -out docs/screenshots
package main
import (
"context"
"encoding/base64"
"encoding/json"
"flag"
"fmt"
"net/http"
"os"
"os/exec"
"path/filepath"
"time"
"github.com/coder/websocket"
)
var chromePath = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
func main() {
base := flag.String("url", "http://localhost:8096", "the running instance")
cookie := flag.String("cookie", "", "session cookie value")
// docs/, not site/: site/screenshots holds the WebP the landing page reads, and
// PNGs dropped in beside them are files nothing points at.
out := flag.String("out", "docs/screenshots", "where to write the PNGs")
width := flag.Int("width", 1400, "viewport width")
height := flag.Int("height", 900, "viewport height")
flag.Parse()
if err := run(*base, *cookie, *out, *width, *height); err != nil {
fmt.Fprintln(os.Stderr, "shots:", err)
os.Exit(1)
}
}
type shot struct {
name string
path string
// theme is applied before the capture, so both palettes can be shown without
// a second run.
theme string
wait time.Duration
}
func run(base, cookie, out string, width, height int) error {
if err := os.MkdirAll(out, 0o755); err != nil {
return err
}
profile, err := os.MkdirTemp("", "verdande-shots-*")
if err != nil {
return err
}
defer os.RemoveAll(profile)
chrome := exec.Command(chromePath,
"--headless=new", "--disable-gpu", "--no-first-run", "--no-default-browser-check",
"--disable-extensions", "--disable-sync", "--hide-scrollbars",
"--remote-debugging-port=9223", "--user-data-dir="+profile,
fmt.Sprintf("--window-size=%d,%d", width, height), "about:blank")
if err := chrome.Start(); err != nil {
return fmt.Errorf("start chrome: %w", err)
}
// Killed rather than asked to quit: headless Chrome does not exit on its own
// after a screenshot, which is what made the plain --screenshot flag hang.
defer chrome.Process.Kill()
wsURL, err := waitForDevTools()
if err != nil {
return err
}
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
defer cancel()
conn, _, err := websocket.Dial(ctx, wsURL, nil)
if err != nil {
return fmt.Errorf("connect to devtools: %w", err)
}
defer conn.CloseNow()
// A screenshot arrives base64-encoded in one frame, so the default 32 KiB
// read limit rejects every capture bigger than a blank page.
conn.SetReadLimit(64 << 20)
c := &client{conn: conn, ctx: ctx}
if _, err := c.send("Network.enable", nil); err != nil {
return err
}
if _, err := c.send("Page.enable", nil); err != nil {
return err
}
// The session cookie, so the captures show the app rather than the sign-in page.
if _, err := c.send("Network.setCookie", map[string]any{
"name": "verdande_session", "value": cookie,
"domain": "localhost", "path": "/", "httpOnly": true,
}); err != nil {
return err
}
shots := []shot{
{name: "today", path: "/", theme: "dark", wait: 2500 * time.Millisecond},
{name: "today-light", path: "/", theme: "light", wait: 1500 * time.Millisecond},
{name: "upcoming", path: "/upcoming", theme: "dark", wait: 2000 * time.Millisecond},
// The integrations page rather than the account one: it shows the feed
// address and the mail-to-task address, which are the parts people ask
// what they look like. Account settings are a password form.
{name: "settings", path: "/indstillinger/integrationer", theme: "dark", wait: 2000 * time.Millisecond},
}
if project := os.Getenv("SHOT_PROJECT"); project != "" {
shots = append(shots,
shot{name: "board", path: "/projekt/" + project, theme: "dark", wait: 2500 * time.Millisecond})
}
for _, s := range shots {
if err := c.capture(base+s.path, s.theme, s.wait, filepath.Join(out, s.name+".png")); err != nil {
return fmt.Errorf("%s: %w", s.name, err)
}
fmt.Println("wrote", filepath.Join(out, s.name+".png"))
}
return nil
}
func waitForDevTools() (string, error) {
for i := 0; i < 60; i++ {
resp, err := http.Get("http://127.0.0.1:9223/json/list")
if err == nil {
var targets []struct {
Type string `json:"type"`
WS string `json:"webSocketDebuggerUrl"`
}
json.NewDecoder(resp.Body).Decode(&targets)
resp.Body.Close()
for _, t := range targets {
if t.Type == "page" && t.WS != "" {
return t.WS, nil
}
}
}
time.Sleep(250 * time.Millisecond)
}
return "", fmt.Errorf("devtools never came up")
}
type client struct {
conn *websocket.Conn
ctx context.Context
id int
}
// send issues one command and waits for the reply with the matching id. Events
// arrive on the same socket and are skipped — they have no id.
func (c *client) send(method string, params map[string]any) (json.RawMessage, error) {
c.id++
id := c.id
body, err := json.Marshal(map[string]any{"id": id, "method": method, "params": params})
if err != nil {
return nil, err
}
if err := c.conn.Write(c.ctx, websocket.MessageText, body); err != nil {
return nil, err
}
for {
_, raw, err := c.conn.Read(c.ctx)
if err != nil {
return nil, err
}
var msg struct {
ID int `json:"id"`
Result json.RawMessage `json:"result"`
Error *struct {
Message string `json:"message"`
} `json:"error"`
}
if err := json.Unmarshal(raw, &msg); err != nil {
continue
}
if msg.ID != id {
continue
}
if msg.Error != nil {
return nil, fmt.Errorf("%s: %s", method, msg.Error.Message)
}
return msg.Result, nil
}
}
func (c *client) capture(url, theme string, wait time.Duration, path string) error {
if _, err := c.send("Page.navigate", map[string]any{"url": url}); err != nil {
return err
}
// A fixed wait rather than waiting on a load event: the interesting part is
// after hydration and after the first data fetch, neither of which the load
// event says anything about.
time.Sleep(wait)
if theme != "" {
if _, err := c.send("Runtime.evaluate", map[string]any{
"expression": fmt.Sprintf("document.documentElement.dataset.theme=%q", theme),
}); err != nil {
return err
}
time.Sleep(400 * time.Millisecond)
}
result, err := c.send("Page.captureScreenshot", map[string]any{"format": "png"})
if err != nil {
return err
}
var shot struct {
Data string `json:"data"`
}
if err := json.Unmarshal(result, &shot); err != nil {
return err
}
decoded, err := base64.StdEncoding.DecodeString(shot.Data)
if err != nil {
return err
}
return os.WriteFile(path, decoded, 0o644)
}