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)."