From d0668755f9d930382008dd7672c0c1b5bdaa2325 Mon Sep 17 00:00:00 2001 From: Maxim Esipov Date: Tue, 29 Sep 2026 11:17:44 +0300 Subject: [PATCH] scripts/screenshots: how to screenshot the app, with the scripts The README set and UI checks go through WebView2's DevTools protocol: start.ps1 starts a build portable with remote debugging, cdp.mjs runs JS and saves shots, shoot.sh makes one README shot, frame.py adds the window frame. README.md covers the build (embedded frontend, test identifier against single-instance handover), the README set, quick checks, what doesn't work and the clean-up. --- scripts/screenshots/README.md | 83 +++++++++++++++++++++++++++++++++++ scripts/screenshots/cdp.mjs | 74 +++++++++++++++++++++++++++++++ scripts/screenshots/frame.py | 37 ++++++++++++++++ scripts/screenshots/shoot.sh | 41 +++++++++++++++++ scripts/screenshots/start.ps1 | 31 +++++++++++++ 5 files changed, 266 insertions(+) create mode 100644 scripts/screenshots/README.md create mode 100644 scripts/screenshots/cdp.mjs create mode 100644 scripts/screenshots/frame.py create mode 100644 scripts/screenshots/shoot.sh create mode 100644 scripts/screenshots/start.ps1 diff --git a/scripts/screenshots/README.md b/scripts/screenshots/README.md new file mode 100644 index 0000000..84dd609 --- /dev/null +++ b/scripts/screenshots/README.md @@ -0,0 +1,83 @@ +# Screenshots of the app + +How to take screenshots of Plugcam, for the README and site (`docs/screenshots/`) or to check a UI change. +The window is a WebView2 page, so the reliable way is the Chrome DevTools Protocol (CDP), not screen capture. + +| File | What it does | +|---|---| +| `start.ps1` | Starts a build portable, with WebView2's remote debugging on port 9222 | +| `cdp.mjs` | Runs JS in the page and saves screenshots (`eval`, `shot`, `shotdark`, `console`) | +| `shoot.sh` | One README shot: language, theme, screen | +| `frame.py` | Rounded corners, border and shadow, as in `docs/screenshots/` (needs Pillow) | + +## 1. Build + +Build into a separate target folder, with the frontend embedded and a test identifier: + +```bash +pnpm build +cd src-tauri +CARGO_TARGET_DIR="$TEMP/plugcam-shots" TAURI_CONFIG='{"identifier":"io.github.plugcam.test"}' \ + cargo build --features tauri/custom-protocol +``` + +- `--features tauri/custom-protocol` embeds `dist/`. Without it a debug build loads `http://127.0.0.1:1420` and shows an empty window unless `pnpm dev` runs. +- The test identifier matters. Plugcam is single-instance per identifier: if the installed app or a dev build from another worktree is running, the new copy hands over to it and exits at once. +- The separate target folder keeps the main `src-tauri/target` cache intact. A first build takes about 7 minutes. + +A release build (`pnpm tauri build`) works too: point `start.ps1` at `src-tauri/target/release/plugcam.exe`. + +## 2. Start + +```bash +pwsh scripts/screenshots/start.ps1 -Exe "$TEMP/plugcam-shots/debug/plugcam.exe" +``` + +- It puts `portable.txt` next to the exe, so settings, logs and the WebView cache go to `data\` there. Without it the build reads and rewrites `%APPDATA%\io.github.plugcam\settings.json`, the real one, and drops any field it doesn't know, for example a setting from another branch. +- `WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS` replaces the WebView2 flags from `tauri.conf.json` (`--disable-gpu` among them), so the script passes them along with `--remote-debugging-port`. +- If Chrome already uses port 9222, pass `-Port 9333` and set `CDP_PORT=9333` for `cdp.mjs`. +- A fresh `data\` starts in the first-run guide. `shoot.sh` skips it; by hand, click "Skip" (see below). + +## 3. Shoot + +For the README set: 5 screens in each of the 8 README languages, 980×668 at 2x. + +```bash +S=scripts/screenshots; OUT="$TEMP/plugcam-shots/shots"; mkdir -p "$OUT" +for L in en ru de es fr pt-BR zh-CN ja; do + bash $S/shoot.sh $L light main "$OUT/main-light-$L.png" + bash $S/shoot.sh $L dark main "$OUT/main-dark-$L.png" color + bash $S/shoot.sh $L light settings "$OUT/settings-$L.png" + bash $S/shoot.sh $L dark settings "$OUT/settings-dark-$L.png" + bash $S/shoot.sh $L light wizard "$OUT/wizard-$L.png" +done +python $S/frame.py docs/screenshots "$OUT"/*.png +``` + +- The main screens need a phone connected and streaming; the picture is whatever its camera sees, so point it at something you're fine publishing. A fresh `data\` doesn't start the camera by itself: `node cdp.mjs eval "window.__TAURI_INTERNALS__.invoke('set_camera', {on: true})"`, and `{on: false}` afterwards. Only one Plugcam can stream from a phone at a time. +- The theme comes from `prefers-color-scheme` emulation, not from Windows. The Mica class is removed before each shot, since Mica shows whatever is behind the window. +- Check the set on one contact sheet before committing: text cut off in German or Japanese, a stray dialog, a wrong theme. + +For a quick look while working on the UI: + +```bash +cd scripts/screenshots +node cdp.mjs eval "[...document.querySelectorAll('button')].map(b => b.getAttribute('aria-label') || b.innerText.trim())" +node cdp.mjs eval "[...document.querySelectorAll('button')].find(b => b.innerText.trim() === 'Skip').click()" +node cdp.mjs eval "window.__TAURI_INTERNALS__.invoke('update_settings', {patch: {detailedLog: true}})" +node cdp.mjs shot "$TEMP/look.png" +node cdp.mjs console 4 +``` + +`eval` clicks the real buttons and `__TAURI_INTERNALS__.invoke` calls the same commands the UI calls, so this checks the whole path, backend included. + +## What doesn't work + +- **Screen-capture and computer-use tools.** They look for apps in the Start menu, and a dev build isn't there. Tools that do find the window (cua-driver) returned blank captures: the window is transparent (Mica) and WebView2 draws without the GPU. +- **Your everyday Plugcam.** Don't point these scripts at the installed app: `shoot.sh` changes the language setting, and clicks change whatever they touch. + +## Clean up + +- Stop the copy: `Get-Process plugcam | Where-Object Path -like "$env:TEMP\plugcam-shots\*" | Stop-Process`. +- Delete `$TEMP/plugcam-shots` (a few GB). +- "Save report" in Settings writes a file to Downloads and opens Explorer and a GitHub page. Delete that report if you pressed it while testing. diff --git a/scripts/screenshots/cdp.mjs b/scripts/screenshots/cdp.mjs new file mode 100644 index 0000000..f1ae8e8 --- /dev/null +++ b/scripts/screenshots/cdp.mjs @@ -0,0 +1,74 @@ +// A tiny Chrome DevTools Protocol client for Plugcam's WebView2, started with +// WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS="--remote-debugging-port=9222 ..." (see README.md here). +// node cdp.mjs eval "" prints the value; promises are awaited +// node cdp.mjs shot out.png light theme +// node cdp.mjs shotdark out.png dark theme +// node cdp.mjs console [seconds] reloads and prints console messages and exceptions +// SIZE=980x668 DPR=2 renders the shot at that CSS size and pixel ratio, whatever the window size. +// CDP_PORT picks another port than 9222. +import { writeFileSync } from "node:fs"; + +const [action, arg] = process.argv.slice(2); +const port = process.env.CDP_PORT || 9222; +const targets = await (await fetch(`http://127.0.0.1:${port}/json`)).json(); +const page = targets.find((t) => t.type === "page"); +if (!page) throw new Error("no page target: " + JSON.stringify(targets)); + +const ws = new WebSocket(page.webSocketDebuggerUrl); +await new Promise((r) => ws.addEventListener("open", r, { once: true })); +let id = 0; +const pending = new Map(); +const events = []; +ws.addEventListener("message", (m) => { + const msg = JSON.parse(m.data); + if (msg.id && pending.has(msg.id)) { + pending.get(msg.id)(msg); + pending.delete(msg.id); + } else if (msg.method) events.push(msg); +}); +const send = (method, params = {}) => + new Promise((resolve) => { + const i = ++id; + pending.set(i, resolve); + ws.send(JSON.stringify({ id: i, method, params })); + }); + +if (action === "eval") { + const r = await send("Runtime.evaluate", { expression: arg, awaitPromise: true, returnByValue: true }); + console.log(JSON.stringify(r.result?.result?.value ?? r.result, null, 2)); +} else if (action === "shot" || action === "shotdark") { + if (process.env.SIZE) { + const [width, height] = process.env.SIZE.split("x").map(Number); + await send("Emulation.setDeviceMetricsOverride", { + width, + height, + deviceScaleFactor: Number(process.env.DPR || 1), + mobile: false, + }); + await new Promise((r) => setTimeout(r, 300)); + } + const scheme = action === "shotdark" ? "dark" : "light"; + await send("Emulation.setEmulatedMedia", { features: [{ name: "prefers-color-scheme", value: scheme }] }); + const r = await send("Page.captureScreenshot", { format: "png" }); + writeFileSync(arg, Buffer.from(r.result.data, "base64")); + if (process.env.SIZE) await send("Emulation.clearDeviceMetricsOverride"); + await send("Emulation.setEmulatedMedia", { features: [] }); + console.log("saved", arg); +} else if (action === "console") { + await send("Runtime.enable"); + await send("Log.enable"); + await send("Page.enable"); + await send("Page.reload"); + await new Promise((r) => setTimeout(r, (Number(arg) || 4) * 1000)); + for (const e of events) { + if (e.method === "Runtime.consoleAPICalled") + console.log(e.params.type, e.params.args.map((a) => a.value ?? a.description).join(" ")); + if (e.method === "Runtime.exceptionThrown") + console.log("EXCEPTION", e.params.exceptionDetails.exception?.description ?? e.params.exceptionDetails.text); + if (e.method === "Log.entryAdded") console.log("log", e.params.entry.level, e.params.entry.text, e.params.entry.url ?? ""); + } +} else { + console.error("usage: node cdp.mjs eval | shot | shotdark | console [seconds]"); + process.exit(2); +} +ws.close(); diff --git a/scripts/screenshots/frame.py b/scripts/screenshots/frame.py new file mode 100644 index 0000000..52df300 --- /dev/null +++ b/scripts/screenshots/frame.py @@ -0,0 +1,37 @@ +"""Rounded window corners, a hairline border and a soft shadow on transparent padding. + +python frame.py ... (needs Pillow) +""" +import sys +from pathlib import Path +from PIL import Image, ImageDraw, ImageFilter + +RADIUS = 16 # Windows 11 uses 8 px; screenshots are 2x +PAD = 48 + +def frame(src: Path, dst: Path): + img = Image.open(src).convert("RGBA") + w, h = img.size + mask = Image.new("L", (w, h), 0) + ImageDraw.Draw(mask).rounded_rectangle((0, 0, w - 1, h - 1), RADIUS, fill=255) + dark = sum(img.getpixel((5, 5))[:3]) < 300 + border = Image.new("RGBA", (w, h), (0, 0, 0, 0)) + ImageDraw.Draw(border).rounded_rectangle( + (0, 0, w - 1, h - 1), RADIUS, + outline=(255, 255, 255, 40) if dark else (0, 0, 0, 38), width=2) + img = Image.alpha_composite(img, border) + img.putalpha(mask) + + out = Image.new("RGBA", (w + 2 * PAD, h + 2 * PAD), (0, 0, 0, 0)) + shadow = Image.new("L", out.size, 0) + ImageDraw.Draw(shadow).rounded_rectangle((PAD, PAD + 12, PAD + w, PAD + h + 12), RADIUS, fill=90) + shadow = shadow.filter(ImageFilter.GaussianBlur(20)) + out.paste((0, 0, 0, 255), (0, 0), shadow) + out.putalpha(shadow) + out.alpha_composite(img, (PAD, PAD)) + out.save(dst, optimize=True) + +for s in sys.argv[2:]: + p = Path(s) + frame(p, Path(sys.argv[1]) / p.name) + print(p.name) diff --git a/scripts/screenshots/shoot.sh b/scripts/screenshots/shoot.sh new file mode 100644 index 0000000..b8071e0 --- /dev/null +++ b/scripts/screenshots/shoot.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash +# One README screenshot of a running Plugcam started with remote debugging (see README.md here). +# bash shoot.sh [color] +# `color` opens the colour controls on the main screen. Shots are 980x668 CSS px at 2x. +# Note: switching the language saves it in that Plugcam's settings. +set -euo pipefail +cd "$(dirname "$0")" +LANG_=$1 THEME=$2 SCREEN=$3 OUT=$4 EXTRA=${5:-} +case "$OUT" in /* | [A-Za-z]:*) ;; *) OUT="$OLDPWD/$OUT" ;; esac + +node cdp.mjs eval "window.__TAURI_INTERNALS__.invoke('update_settings', {patch: {language: '$LANG_'}}).then(() => 'ok')" >/dev/null +sleep 0.8 + +# From wherever the app is: back to the main screen, past the first-run guide, then to $SCREEN. +node cdp.mjs eval "(async () => { + const wait = (ms) => new Promise((r) => setTimeout(r, ms)); + const back = [...document.querySelectorAll('header button')].find((b) => b.querySelector('.lucide-arrow-left')); + if (back) { back.click(); await wait(200); } + const skip = document.querySelector('.top .text-btn'); + if (skip) { skip.click(); await wait(200); } + if ('$SCREEN' !== 'main') { [...document.querySelectorAll('header .icon-btn')].pop().click(); await wait(300); } + if ('$SCREEN' === 'wizard') { + const rows = [...document.querySelectorAll('.link')]; + rows[rows.length - 1].click(); // Run the first-run setup again + await wait(300); + for (let i = 0; i < 2; i++) { document.querySelector('.nav .btn.accent').click(); await wait(200); } + } + if ('$EXTRA' === 'color') { document.querySelector('.head[aria-expanded]').click(); await wait(200); } + // Mica is see-through: it would show whatever is behind the window. + document.documentElement.classList.remove('mica'); + document.querySelector('aside')?.scrollTo(0, 0); + return 'ok'; +})()" >/dev/null +sleep 0.5 + +if [ "$THEME" = dark ]; then SHOT=shotdark; else SHOT=shot; fi +SIZE=980x668 DPR=2 node cdp.mjs "$SHOT" "$OUT" + +if [ "$EXTRA" = color ]; then + node cdp.mjs eval "document.querySelector('.head[aria-expanded]').click()" >/dev/null +fi diff --git a/scripts/screenshots/start.ps1 b/scripts/screenshots/start.ps1 new file mode 100644 index 0000000..a32d350 --- /dev/null +++ b/scripts/screenshots/start.ps1 @@ -0,0 +1,31 @@ +# Starts a Plugcam build for screenshots and UI checks: portable, so it keeps its own settings in +# data\ next to the exe and never touches the installed app's, and with WebView2's remote +# debugging on, so cdp.mjs can drive it. +# pwsh scripts/screenshots/start.ps1 -Exe [-Port 9222] +# Stops an earlier copy of that same exe first; any other Plugcam keeps running. +param( + [Parameter(Mandatory)][string]$Exe, + [int]$Port = 9222 +) +$ErrorActionPreference = 'Stop' +$Exe = (Resolve-Path $Exe).Path +$dir = Split-Path $Exe + +Get-Process plugcam -ErrorAction SilentlyContinue | Where-Object { $_.Path -eq $Exe } | Stop-Process -Confirm:$false +$marker = Join-Path $dir 'portable.txt' +if (-not (Test-Path $marker)) { New-Item -ItemType File $marker | Out-Null } + +# The variable replaces the flags tauri.conf.json gives WebView2, so pass those too. +$conf = Get-Content (Join-Path $PSScriptRoot '..\..\src-tauri\tauri.conf.json') -Raw | ConvertFrom-Json +$appArgs = $conf.app.windows[0].additionalBrowserArgs +$env:WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS = "--remote-debugging-port=$Port $appArgs" +Start-Process -FilePath $Exe -WorkingDirectory $dir + +for ($i = 0; $i -lt 40; $i++) { + Start-Sleep -Milliseconds 500 + try { + $pages = Invoke-RestMethod "http://127.0.0.1:$Port/json" -TimeoutSec 2 + if ($pages | Where-Object type -eq 'page') { "ready on port $Port"; exit 0 } + } catch {} +} +Write-Error "no WebView2 page on port $Port after 20 s: is another Plugcam with the same identifier running? It takes over (single instance)."