docs(site): real screenshots, an English example, and the Rune said plainly
The landing page and README now show the app rather than describing it. The images are captured by tools/shots, which drives a headless Chrome over the DevTools protocol — Chrome's own --screenshot flag cannot set a session cookie, so it would only ever photograph the sign-in page. Kept in the repository so they can be regenerated when the interface changes, rather than being something somebody did once by hand. The quick-add example on the site is in English, with the Danish equivalent shown underneath — the page is read by people who do not speak Danish, and the point being made is that both work. Yggdrasil is stated up front rather than left to the footer: being a Rune is what this was built for, and somebody running the panel should learn that in the first paragraph instead of the last line. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
5546e191ac
commit
a8e523b377
@@ -0,0 +1,225 @@
|
||||
// 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 site/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")
|
||||
out := flag.String("out", "site/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},
|
||||
}
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user