From ed5237b4768be6730686b4317719071f70036573 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ji=C5=99=C3=AD=20Posp=C3=AD=C5=A1il?= Date: Tue, 11 Aug 2026 02:50:57 +0200 Subject: [PATCH 1/3] The app carries no webhook, and a report can only ever be text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADDED: `worker/`, a Cloudflare Worker relaying a bug report from the app to one Discord channel as an embed plus a `report.md` written to be pasted into an issue unedited. - The app ships this Worker's public URL, never a credential: the webhook is a Cloudflare secret, so extracting the URL from the bundle yields a rate-limited endpoint and nothing more. - Nothing in the binary sends a report yet, so `PRIVACY.md` is deliberately unchanged — it is part of the app-side change, where the claim that there is no server on the other end stops holding. ADDED: A payload of item text, parse output, an optional comment and an optional PNG screenshot, with per-field caps that refuse rather than truncate — half an item is a worse report than none. ADDED: Mentions disabled with `allowed_mentions`, every user-written string fenced, bidi overrides and control characters stripped, and only known fields read, so a report cannot ping anyone, link anywhere, or hijack the message shape. ADDED: A screenshot must decode to a real PNG and is attached under a filename we choose. ADDED: Optional per-IP and per-day caps in KV. - They fail open: losing a real report to a KV blip is worse than letting one extra through. ADDED: `publish.sh` and `rotate-webhook.sh`, reading a gitignored `worker/.env`, with tracing suspended across the read so `bash -x` cannot print the credentials. - That file is the whole access control for deploying, and is why no workflow in this repo or the data repo may call these scripts: neither public repo holds an Actions secret, and adding the first would change the threat model for every workflow at once. ADDED: `send-test.sh`, whose `--hostile` mode sends mentions, a masked link, a fence break and a right-to-left override, and which identifies itself so Cloudflare's edge does not answer 1010. ADDED: 18 contract tests in `worker/test.mjs`, run with `node --test`. CHANGED: `.gitignore` keeps `.env`, `.dev.vars`, `node_modules/` and `.wrangler/` out of the repo. CHANGED: `CLAUDE.md`'s doc table points at `worker/README.md`. Co-Authored-By: Claude Opus 5 (1M context) --- .gitignore | 6 + CLAUDE.md | 1 + worker/.env.example | 7 + worker/README.md | 170 +++++++++++++++++++ worker/_env.sh | 44 +++++ worker/publish.sh | 15 ++ worker/rotate-webhook.sh | 28 ++++ worker/send-test.sh | 75 +++++++++ worker/src/index.js | 347 +++++++++++++++++++++++++++++++++++++++ worker/test.mjs | 209 +++++++++++++++++++++++ worker/wrangler.toml | 23 +++ 11 files changed, 925 insertions(+) create mode 100644 worker/.env.example create mode 100644 worker/README.md create mode 100644 worker/_env.sh create mode 100755 worker/publish.sh create mode 100755 worker/rotate-webhook.sh create mode 100755 worker/send-test.sh create mode 100644 worker/src/index.js create mode 100644 worker/test.mjs create mode 100644 worker/wrangler.toml diff --git a/.gitignore b/.gitignore index 40030fc..b2724ae 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,9 @@ assets/fonts/Fontin-LICENSE.txt # built website _site/ + +# Report relay: deploy credentials are local-only, and so is anything wrangler leaves behind. +worker/.env +worker/.dev.vars +worker/node_modules/ +worker/.wrangler/ diff --git a/CLAUDE.md b/CLAUDE.md index ba597e3..9cd43da 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -41,6 +41,7 @@ read whole; each is one layer. | [docs/item-layer.md](docs/item-layer.md) | `src/item/` — parse, resolve, derive, range matching, and the plan rules every strategy shares. Where most pricing judgement lives. | | [docs/strategy-unique.md](docs/strategy-unique.md), [strategy-map.md](docs/strategy-map.md), [strategy-gem.md](docs/strategy-gem.md), [strategy-logbook.md](docs/strategy-logbook.md) | One per search strategy that has more to say than the shared rules: uniques (including unidentified), maps (with charts and Valdo maps), gems, expedition logbooks (the one item that is up to three items at once). | | [docs/quickpaste.md](docs/quickpaste.md) | The paste list — the popup at the cursor, the nine number-key slots, and the clipboard *write*, which is a seam of its own. | +| [worker/README.md](worker/README.md) | `worker/` — the Cloudflare Worker that relays a user's bug report to Discord, what has to be set up by hand, and the injection rules every payload field is held to. Not built into the app yet. | | [docs/trade-layer.md](docs/trade-layer.md) | `src/trade/` — query building, the two-step client, the rate limiter, and how results and the filter list are drawn. | | [docs/ninja.md](docs/ninja.md) | `src/ninja/` — the poe.ninja reference price. | | [docs/exchange.md](docs/exchange.md) | `src/exchange/` — GGG's hourly in-game currency exchange digests. | diff --git a/worker/.env.example b/worker/.env.example new file mode 100644 index 0000000..8d6907d --- /dev/null +++ b/worker/.env.example @@ -0,0 +1,7 @@ +# Copy to worker/.env and fill in. That file is gitignored and belongs on the maintainer's machine +# and nowhere else — no CI, no repo secret, no password manager sync into a shared vault. +# +# The Discord webhook is deliberately absent. It lives only in Cloudflare, set by rotate-webhook.sh. + +CLOUDFLARE_API_TOKEN= +CLOUDFLARE_ACCOUNT_ID= diff --git a/worker/README.md b/worker/README.md new file mode 100644 index 0000000..2d51f5a --- /dev/null +++ b/worker/README.md @@ -0,0 +1,170 @@ +# Report relay + +A Cloudflare Worker that takes a bug report from the app and posts it to one Discord channel. It +exists so the app ships **no credential**: the data bundle carries this Worker's public URL, and the +Discord webhook lives only in Cloudflare. Extracting the URL from the bundle gets you a rate-limited +endpoint that can post a formatted item report to a private channel, and nothing else. + +Nothing above this is automated. A report arrives in Discord with a `report.md` attached that is +written to be pasted into a GitHub issue unedited, by hand, if it turns out to be worth one. + +``` +app --HTTPS--> ppc-reports..workers.dev --webhook--> #ppc-reports + (public URL, in the bundle) (Cloudflare secret) +``` + +## Setup, once + +### 1. Discord — the channel and the webhook + +1. In your server, **create a channel** for this, e.g. `#ppc-reports`. +2. **Make it private.** Edit Channel → Permissions → deny `@everyone` *View Channel*. Every report + is text a stranger typed; it does not belong in a channel anyone can read. +3. Edit Channel → **Integrations** → **Webhooks** → **New Webhook**. Name it (the name is + overridden per-message anyway), confirm the channel, then **Copy Webhook URL**. +4. Do not paste that URL anywhere else — not a commit, not an issue, not a screenshot. GitHub scans + for them and Discord revokes the ones it hears about. + +The URL looks like `https://discord.com/api/webhooks//`. You need *Manage Webhooks* on +the server, which as owner you have. + +### 2. Cloudflare — account id and an API token + +You already have the account (the GitHub SSO login is fine; API tokens work the same). + +- **Account ID** — → **Workers & Pages**. The ID is in the right-hand + sidebar, and it is also the hex string in the dashboard URL. +- **API token** — → **Create Token** → **Custom + token**, with: + - `Account` → `Workers Scripts` → **Edit** + - `Account` → `Workers KV Storage` → **Edit** *(only if you set up rate limiting in step 6)* + - Account Resources → Include → your account + + The **Edit Cloudflare Workers** template also works, but it asks for zone permissions this Worker + does not need. Copy the token when it is shown — it is not shown again. +- **workers.dev subdomain** — if you have never deployed a Worker, the first deploy asks you to + claim one. Any name; it becomes `ppc-reports..workers.dev`. + +### 3. Local credentials + +```sh +cd worker +cp .env.example .env +$EDITOR .env # CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID +``` + +`.env` is gitignored. It lives on your machine and nowhere else — **no repo secret, no workflow, no +CI**. That is the entire access control for deploying this thing, and it is why neither this repo +nor the data repo may ever gain an Actions secret for it. + +### 4. Deploy + +```sh +./publish.sh +``` + +Prints the URL. Needs `node` and `npx`; wrangler is fetched on demand, so there is no `node_modules` +in the repo. + +### 5. Set the webhook + +```sh +./rotate-webhook.sh +``` + +Paste the Discord URL at the prompt. It goes straight to Cloudflare's secret store — not into +`.env`, not into any file. The same script replaces it later. + +### 6. Rate limiting (optional, recommended) + +Without this the relay works but has no brakes. + +```sh +npx --yes wrangler kv namespace create ppc-reports-rl +``` + +Put the printed id into the `[[kv_namespaces]]` block in `wrangler.toml`, uncomment it, and +`./publish.sh` again. Defaults are 5 reports per IP per hour and 300 per day across the whole relay +— both in `[vars]`. The free KV tier allows 1000 writes a day and each report costs two, so the +daily cap keeps the relay inside it. + +### 7. Check it end to end + +```sh +./send-test.sh https://ppc-reports..workers.dev +./send-test.sh https://ppc-reports..workers.dev --hostile +``` + +The first posts a real fixture item. The second posts what a malicious reporter would send — +`@everyone`, a masked link, a code-fence break, a right-to-left override. **Nobody should be +pinged, no link should be clickable, and every character should appear literally.** If any of that +is untrue, stop and say so rather than shipping the app side. + +## Day to day + +| | | +| --- | --- | +| Change the Discord channel | make a new webhook, `./rotate-webhook.sh`. Clients never notice. | +| Turn reporting off | `REPORTS_ENABLED = "0"` in `wrangler.toml`, `./publish.sh`. | +| Turn it off *now* | `npx --yes wrangler delete` — the app treats a dead endpoint as a dropped report. | +| Watch it | `npx --yes wrangler tail` | +| Run the tests | `node --test worker/test.mjs` | + +Request logging (Workers Logs) is off in `wrangler.toml`, deliberately: retaining request metadata +would undercut the one thing this feature promises. `wrangler tail` still shows errors live. + +## The payload + +`POST /report`, `content-type: application/json`. Every field is a string; only `item` is required. + +```json +{ + "item": "Item Class: Bows\nRarity: Rare\n…", + "parse": "[item] parsed: rarity=3 class='Bows' …", + "comment": "the third modifier reads as a suffix", + "screenshot_png_b64": "iVBORw0KGgo…", + "meta": { "version": "0.6.17", "os": "linux", "league": "Standard", "bundle": "2026-08-01" } +} +``` + +| Field | Cap | Notes | +| --- | --- | --- | +| `item` | 16 KiB | required; must contain a `-----` separator line, or it is refused as not-an-item | +| `parse` | 64 KiB | free-form; whatever the app's own diagnostic dump prints | +| `comment` | 2000 | what the user typed | +| `screenshot_png_b64` | 5 MiB decoded | must decode to a real PNG; a `data:` prefix is tolerated | +| `meta.*` | 64 each | all optional | + +Replies `200 {"ok":true,"id":"a1b2c3d4"}`; `400` malformed, `413` too large, `415` wrong +content-type, `429` rate-limited, `503` switched off, `502` Discord refused it. The app should treat +every non-200 the same way it treats any other failure — drop it, log it, say nothing. + +Over-cap fields are **refused, not truncated**: half an item is a worse bug report than none. + +## What is enforced, and why + +Everything in the payload is written by a stranger and ends up somewhere that renders markdown — +Discord, then usually a GitHub issue. Each of these is a way that could become something other than +text, and each has a test in `test.mjs`: + +- **`allowed_mentions: {parse: []}`** — the one thing that makes `@everyone` in a report inert. All + the escaping in the world does not help without it. +- **Everything user-typed is inside a code fence**, and a `` ``` `` in the input is rewritten so it + cannot close one. Inside a fence a masked link is punctuation and a mention is a word. +- **Only known fields are read.** `username`, `content`, `embeds`, `avatar_url` in an incoming body + are ignored — the message we send is built from scratch, so there is no shape to hijack. +- **Bidi overrides and control characters are stripped** (U+202A–U+202E, U+2066–U+2069, zero-widths, + C0). A report must not be able to *display* as something other than what it says. +- **No URL from the payload is ever used.** No embed points anywhere but `attachment://`, so the + relay cannot be aimed at a third-party host, and no report can carry a tracking pixel. +- **Attachments are checked and renamed.** PNG magic bytes must match; the filename and content type + are ours. A channel that accepts arbitrary bytes under a `.png` is still a file drop. +- **The webhook secret is validated** as a `discord.com` webhook URL before use, so a mistyped + secret cannot turn the relay into a request forwarder. +- **`application/json` is required**, which forces a preflight on any browser-originated POST, and + no `OPTIONS` is answered. A random web page cannot use this endpoint. +- **Discord's own limits are applied here** (6000 per embed, 4096 description, 1024 per field) so an + oversized report is trimmed by us rather than rejected wholesale by Discord. + +What none of this buys: the endpoint is public and anyone who watches the app's traffic will find +it. The protection against abuse is the rate limit, the daily cap and the kill switch — not secrecy. diff --git a/worker/_env.sh b/worker/_env.sh new file mode 100644 index 0000000..a5bc62a --- /dev/null +++ b/worker/_env.sh @@ -0,0 +1,44 @@ +# Loads worker/.env for the deploy scripts. Sourced, never run on its own. +# +# Tracing is suspended across the read and restored afterwards: `bash -x ./publish.sh` would +# otherwise print the API token to the terminal, which is how a local-only credential stops being +# local-only. Everything else in these scripts is safe to trace, and worth tracing. + +_ppc_xtrace=$(set +o | grep -E 'xtrace$') +set +x + +if [[ ! -f .env ]]; then + echo "worker/.env is missing — copy .env.example and fill it in (see README.md)" >&2 + exit 1 +fi + +set -a +# shellcheck disable=SC1091 +. ./.env +set +a + +# Every test below expands a credential, and an expansion is what a trace prints — so the checks +# have to happen while tracing is still off, not just the read. +if [[ -z ${CLOUDFLARE_API_TOKEN:-} ]]; then + echo "CLOUDFLARE_API_TOKEN is not set in worker/.env" >&2 + exit 1 +fi +if [[ -z ${CLOUDFLARE_ACCOUNT_ID:-} ]]; then + echo "CLOUDFLARE_ACCOUNT_ID is not set in worker/.env" >&2 + exit 1 +fi + +if ! command -v npx >/dev/null; then + echo "npx not found." >&2 + echo "node is installed through fnm, which puts it on PATH from a shell hook — a script" >&2 + echo "started without that hook will not see it. Run this from a shell where 'npx --version'" >&2 + echo "works, or point PATH at the fnm shim." >&2 + exit 1 +fi + +# wrangler asks about usage metrics the first time it runs and blocks on the answer, which looks +# exactly like a hang when the prompt is buried in script output. +export WRANGLER_SEND_METRICS=false + +eval "$_ppc_xtrace" +unset _ppc_xtrace diff --git a/worker/publish.sh b/worker/publish.sh new file mode 100755 index 0000000..894f719 --- /dev/null +++ b/worker/publish.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +# Deploy the report relay. Needs worker/.env, which is gitignored and exists only on the machine +# that is allowed to deploy — that is the whole access control, and it is why no workflow in this +# repo or the data repo may ever call this script. +set -euo pipefail + +cd "$(dirname "$0")" +# shellcheck source=_env.sh +. ./_env.sh + +npx --yes wrangler deploy "$@" + +echo +echo "Deployed. The secret is separate — if this is a first deploy, set it now:" +echo " ./rotate-webhook.sh" diff --git a/worker/rotate-webhook.sh b/worker/rotate-webhook.sh new file mode 100755 index 0000000..605e1c3 --- /dev/null +++ b/worker/rotate-webhook.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +# Set or replace the Discord webhook the relay posts to. +# +# The URL is read from the terminal and handed straight to Cloudflare. It is deliberately not in +# .env, not in this repo, and not in any file: rotating it means deleting the webhook in Discord, +# making a new one, and running this. Clients never notice — they only ever knew the relay's URL. +set -euo pipefail + +cd "$(dirname "$0")" +# shellcheck source=_env.sh +. ./_env.sh + +echo "Paste the Discord webhook URL, then press Enter." +echo "It is not echoed and not written to disk." + +set +x # the URL is a credential; keep it out of a trace for the same reason .env is +read -rs -p "> " webhook +echo + +case "$webhook" in + https://discord.com/api/webhooks/* | https://discordapp.com/api/webhooks/*) ;; + *) + echo "that is not a Discord webhook URL — expected https://discord.com/api/webhooks/..." >&2 + exit 1 + ;; +esac + +printf '%s' "$webhook" | npx --yes wrangler secret put DISCORD_WEBHOOK diff --git a/worker/send-test.sh b/worker/send-test.sh new file mode 100755 index 0000000..fec316f --- /dev/null +++ b/worker/send-test.sh @@ -0,0 +1,75 @@ +#!/usr/bin/env bash +# Post a report to the relay, to check the whole path before the app can send one itself. +# +# ./send-test.sh https://ppc-reports..workers.dev +# ./send-test.sh ../tests/data/items/currency-essence.txt +# +# With no item file it sends a fixture from tests/data/items. Add --hostile to send what a +# malicious reporter would: mentions, a masked link, a fence break and a right-to-left override. +# Everything in the resulting Discord message should be inert, and nobody should be pinged. +set -euo pipefail + +cd "$(dirname "$0")" + +url="${1:-}" +if [[ -z "$url" ]]; then + echo "usage: ./send-test.sh [item-file] [--hostile]" >&2 + exit 1 +fi +shift + +item_file="../tests/data/items/rare-bow-doom-song.txt" +hostile=0 +for arg in "$@"; do + case "$arg" in + --hostile) hostile=1 ;; + *) item_file="$arg" ;; + esac +done + +if [[ ! -f "$item_file" ]]; then + item_file="$(find ../tests/data/items -name '*.txt' | sort | head -1)" + echo "note: using $item_file" >&2 +fi + +python3 - "$url" "$item_file" "$hostile" <<'PY' +import json, sys, urllib.request, urllib.error + +url, item_file, hostile = sys.argv[1], sys.argv[2], sys.argv[3] == "1" + +# Kept as escapes rather than literals: a file carrying a real U+202E is unreadable in exactly the +# way this payload is meant to demonstrate. +HOSTILE = ( + "@everyone @here <@&1234567890>\n" + "```\n" + "[totally safe](https://example.invalid/x)\n" + "\u202egnihton ees uoy\u202c\n" + "a \u200bzero width space and a \x00 nul" +) + +payload = { + "item": open(item_file, encoding="utf-8").read(), + "parse": "[item] parsed: rarity=3 class='Bows' name='Doom Song' base='Spine Bow' 6 modifiers\n" + "[item] modifier 3 matched no stat record", + "comment": HOSTILE if hostile else + "The third modifier is read as a suffix but the game shows it as a prefix.", + "meta": {"version": "0.6.17", "os": "linux", "league": "Standard", "bundle": "2026-08-01"}, +} +req = urllib.request.Request( + url.rstrip("/") + "/report", + data=json.dumps(payload).encode(), + headers={ + "content-type": "application/json", + # Cloudflare's edge answers a scripting-library User-Agent with a 403 (error 1010) + # before the Worker is ever reached. The app sends net::user_agent(); mirror it. + "user-agent": "PathOfPriceCheck/send-test (+https://github.com/JIRPOS/PathOfPriceCheck)", + }, + method="POST", +) +try: + with urllib.request.urlopen(req) as r: + print(r.status, r.read().decode()) +except urllib.error.HTTPError as e: + print(e.code, e.read().decode()) + sys.exit(1) +PY diff --git a/worker/src/index.js b/worker/src/index.js new file mode 100644 index 0000000..6b7df6a --- /dev/null +++ b/worker/src/index.js @@ -0,0 +1,347 @@ +/** + * Report relay for Path of Price Check. + * + * The app posts a bug report here and this forwards it to one Discord webhook. The webhook is a + * Cloudflare secret, so the only thing shipped to users is this Worker's public URL — extracting + * it from the data bundle yields a rate-limited endpoint, not a credential. + * + * Everything the app sends is untrusted. A report reaches a Discord channel and from there, + * usually, a GitHub issue — both render markdown. The rule enforced below is that a report can + * only ever *be text*: it cannot mention anyone, link anywhere, or arrive as anything but a PNG. + */ + +const LIMITS = { + body: 8 * 1024 * 1024, + item: 16 * 1024, + parse: 64 * 1024, + comment: 2000, + meta: 64, + screenshot: 5 * 1024 * 1024, +}; + +// Discord's own caps. Enforced here so an oversized report is trimmed rather than 400'd away. +const DISCORD = { title: 256, description: 4096, fieldValue: 1024, total: 6000 }; + +const PNG_MAGIC = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]; + +export default { + async fetch(request, env) { + const url = new URL(request.url); + if (request.method !== 'POST' || url.pathname !== '/report') { + return new Response(null, { status: 404 }); + } + if (env.REPORTS_ENABLED === '0') { + return json(503, 'reporting is turned off'); + } + if (!webhook_ok(env.DISCORD_WEBHOOK)) { + console.error('DISCORD_WEBHOOK is unset or not a Discord webhook URL'); + return json(500, 'relay is misconfigured'); + } + // Requiring JSON forces a CORS preflight on any browser-originated POST, and we answer no + // OPTIONS — so a random web page cannot use this endpoint even though it is public. + if (!(request.headers.get('content-type') || '').includes('application/json')) { + return json(415, 'expected application/json'); + } + if (Number(request.headers.get('content-length') || 0) > LIMITS.body) { + return json(413, 'report too large'); + } + + const raw = await request.text().catch(() => null); + if (raw === null) return json(400, 'unreadable body'); + if (raw.length > LIMITS.body) return json(413, 'report too large'); + + let body; + try { + body = JSON.parse(raw); + } catch { + return json(400, 'malformed JSON'); + } + if (body === null || typeof body !== 'object' || Array.isArray(body)) { + return json(400, 'expected a JSON object'); + } + + const report = read_report(body); + if (report.error) return json(report.status, report.error); + + const ip = request.headers.get('cf-connecting-ip') || ''; + const limited = await rate_limit(env, ip); + if (limited) return json(429, limited, { 'retry-after': '3600' }); + + const id = crypto.randomUUID().split('-')[0]; + const at = new Date().toISOString(); + + const res = await post_to_discord(env.DISCORD_WEBHOOK, report, id, at); + if (!res.ok) { + console.error(`discord rejected report ${id}: ${res.status}`); + return json(502, 'could not deliver the report'); + } + return json(200, null, {}, { ok: true, id }); + }, +}; + +/** Pull exactly the fields we know about. Anything else in the body is ignored, never forwarded. */ +function read_report(body) { + const errs = []; + const item = field(body.item, LIMITS.item, 'item', errs); + const parse = field(body.parse, LIMITS.parse, 'parse', errs); + const comment = field(body.comment, LIMITS.comment, 'comment', errs); + + const meta = {}; + const src = body.meta; + if (src !== undefined && src !== null) { + if (typeof src !== 'object' || Array.isArray(src)) { + errs.push('meta must be an object'); + } else { + for (const key of META_KEYS) { + const v = field(src[key], LIMITS.meta, `meta.${key}`, errs); + if (v) meta[key] = v; + } + } + } + + if (errs.length) return { error: errs[0], status: 400 }; + if (!item) return { error: 'item is required', status: 400 }; + // Every item the game exports has a dashed separator, in every client language. Requiring one + // costs a legitimate report nothing and turns the endpoint away from anything that is not an + // item at all. + if (!/^-{3,}$/m.test(item)) { + return { error: 'item does not look like PoE clipboard text', status: 400 }; + } + + let png = null; + if (body.screenshot_png_b64 !== undefined && body.screenshot_png_b64 !== null) { + if (typeof body.screenshot_png_b64 !== 'string') { + return { error: 'screenshot_png_b64 must be a string', status: 400 }; + } + const decoded = decode_png(body.screenshot_png_b64); + if (decoded.error) return { error: decoded.error, status: decoded.status }; + png = decoded.bytes; + } + + return { item, parse, comment, meta, png }; +} + +const META_KEYS = ['version', 'os', 'league', 'bundle']; +const META_LABELS = { version: 'App', os: 'OS', league: 'League', bundle: 'Bundle' }; + +/** + * One untrusted string, cleaned. Caps are generous enough that exceeding one is not an accident, + * so this rejects rather than truncating — a silently half-sent item would be a worse bug report + * than none. + */ +function field(v, max, name, errs) { + if (v === undefined || v === null) return ''; + if (typeof v !== 'string') { + errs.push(`${name} must be a string`); + return ''; + } + if (v.length > max) { + errs.push(`${name} is longer than ${max} characters`); + return ''; + } + return clean(v); +} + +/** + * Strip everything that is not text. The bidi overrides are the ones that matter: U+202E and its + * neighbours reorder a whole line in both Discord and GitHub, so without this a report could be + * made to *display* as something other than what it says. + */ +function clean(s) { + return s + .replace(/\r\n?/g, '\n') + .replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, '') + .replace(/[\u200B-\u200F\u202A-\u202E\u2066-\u2069\uFEFF]/g, '') + .trim(); +} + +function decode_png(s) { + const b64 = s.replace(/^data:[^,]*,/, '').replace(/\s+/g, ''); + if ((b64.length / 4) * 3 > LIMITS.screenshot) { + return { error: 'screenshot too large', status: 413 }; + } + + let bin; + try { + bin = atob(b64); + } catch { + return { error: 'screenshot is not valid base64', status: 400 }; + } + const bytes = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i); + + // The filename and content type below are ours, not the reporter's, but a channel that accepts + // arbitrary bytes under a .png name is still a file drop. Check that it really is a PNG. + if (bytes.length < PNG_MAGIC.length || PNG_MAGIC.some((b, i) => bytes[i] !== b)) { + return { error: 'screenshot is not a PNG', status: 400 }; + } + return { bytes }; +} + +/** Guards against a mistyped secret turning the relay into a request forwarder for some other host. */ +function webhook_ok(u) { + if (typeof u !== 'string' || !u) return false; + try { + const p = new URL(u); + return ( + p.protocol === 'https:' && + /^(canary\.|ptb\.)?discord(app)?\.com$/.test(p.hostname) && + p.pathname.startsWith('/api/webhooks/') + ); + } catch { + return false; + } +} + +/** + * Per-IP and whole-relay caps, both in KV. Optional: with no KV binding the relay still works, it + * just has no brakes. Failures here fail *open* — losing a real report to a KV blip is worse than + * letting one extra through. + */ +async function rate_limit(env, ip) { + if (!env.RL) return null; + const per_ip = Number(env.MAX_PER_IP_PER_HOUR || 5); + const per_day = Number(env.MAX_PER_DAY_GLOBAL || 300); + try { + const day = new Date().toISOString().slice(0, 10); + if (ip && !(await bump(env.RL, `ip:${ip}`, 3600, per_ip))) { + return 'too many reports from this address, try again later'; + } + if (!(await bump(env.RL, `all:${day}`, 86400, per_day))) { + return 'the relay is over its daily limit, try again tomorrow'; + } + } catch (e) { + console.error(`rate limiter unavailable: ${e}`); + } + return null; +} + +async function bump(kv, key, ttl, max) { + const cur = Number((await kv.get(key)) || 0); + if (cur >= max) return false; + await kv.put(key, String(cur + 1), { expirationTtl: ttl }); + return true; +} + +async function post_to_discord(url, report, id, at) { + const form = new FormData(); + form.append('payload_json', JSON.stringify(message(report, id, at))); + form.append( + 'files[0]', + new Blob([issue_markdown(report, id, at)], { type: 'text/markdown' }), + 'report.md', + ); + if (report.png) { + form.append('files[1]', new Blob([report.png], { type: 'image/png' }), 'screenshot.png'); + } + // No content-type header: fetch sets it with the multipart boundary. ?wait=true so a rejected + // message comes back as a status rather than a silent 204. + return fetch(`${url}?wait=true`, { method: 'POST', body: form }); +} + +function message(report, id, at) { + const fields = []; + if (report.comment) { + fields.push({ + name: 'What the reporter said', + value: fenced(report.comment, DISCORD.fieldValue), + }); + } + for (const key of META_KEYS) { + if (report.meta[key]) { + fields.push({ name: META_LABELS[key], value: inline_code(report.meta[key]), inline: true }); + } + } + + const embed = { + title: cut(title_of(report.item), DISCORD.title), + description: fenced(report.item, DISCORD.description), + color: 0xc0a060, + fields, + footer: { text: `report ${id} · report.md is ready to paste into an issue` }, + timestamp: at, + }; + if (report.png) embed.image = { url: 'attachment://screenshot.png' }; + + return { + username: 'Path of Price Check', + // The one setting that makes @everyone in a report inert. Everything else is cosmetic; + // without this, any reporter can ping the whole server. + allowed_mentions: { parse: [] }, + embeds: [within_budget(embed, report.item)], + }; +} + +/** + * The attachment, written so it can be pasted into a GitHub issue unedited. Deliberately not a + * link to anything: the report travels as its own text. + */ +function issue_markdown(report, id, at) { + const out = [`### Item\n\n${fence(report.item)}`]; + if (report.parse) out.push(`### Parse output\n\n${fence(report.parse)}`); + if (report.comment) out.push(`### Reported problem\n\n${fence(report.comment)}`); + + const env = [`- Report: \`${id}\` (${at})`]; + for (const key of META_KEYS) { + if (report.meta[key]) env.push(`- ${META_LABELS[key]}: \`${report.meta[key]}\``); + } + out.push(`### Environment\n\n${env.join('\n')}`); + + return out.join('\n\n') + '\n'; +} + +/** + * A name for the embed. The first block of an item export is its labelled header plus the name and + * base, so the unlabelled lines are the ones worth showing. Getting this wrong costs a clumsy + * title and nothing else — the full text is directly below it. + */ +function title_of(item) { + const head = item.split(/\n-{3,}\n/)[0] || item; + const names = head + .split('\n') + .map((s) => s.trim()) + .filter((s) => s && !s.includes(': ')); + return names.slice(0, 2).join(' — ') || 'Item report'; +} + +/** + * Wrap untrusted text so Discord and GitHub render it as characters. Inside a fence `@everyone` is + * a word and `[click here](http://…)` is punctuation; the only way out is a fence of its own, + * which is what the substitution prevents. + */ +function fence(s) { + return '```text\n' + s.replace(/```/g, "'''") + '\n```'; +} + +/** `max` is the budget for the whole fenced block, not the text inside it. */ +function fenced(s, max) { + return fence(cut(s, Math.max(16, max - 20))); +} + +function inline_code(s) { + return '`' + s.replace(/`/g, "'") + '`'; +} + +function cut(s, max) { + return s.length <= max ? s : s.slice(0, Math.max(1, max - 1)) + '…'; +} + +/** Discord counts every embed string against one 6000-character budget and 400s the lot if over. */ +function within_budget(embed, item) { + const size = () => JSON.stringify(embed).length; + while (size() > DISCORD.total && embed.fields.length) embed.fields.pop(); + if (size() > DISCORD.total) { + // Re-fence from the original text rather than trimming the fenced string, which would eat + // its own closing delimiter. + embed.description = fenced(item, Math.max(80, embed.description.length - (size() - DISCORD.total))); + } + return embed; +} + +function json(status, error, headers = {}, payload = null) { + const body = payload || { ok: false, error }; + return new Response(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json; charset=utf-8', ...headers }, + }); +} diff --git a/worker/test.mjs b/worker/test.mjs new file mode 100644 index 0000000..3d2d823 --- /dev/null +++ b/worker/test.mjs @@ -0,0 +1,209 @@ +// Contract tests for the report relay. `node --test worker/test.mjs` — no dependencies, no +// network: Discord is a stub, so what is asserted is the exact request the Worker would send. +// +// The point of most of these is that a reporter controls every string in the payload. Each one +// pins a way that control could turn into something other than text. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +import worker from './src/index.js'; + +const WEBHOOK = 'https://discord.com/api/webhooks/123/abc'; +const ITEM = 'Item Class: Bows\nRarity: Rare\nDoom Song\nSpine Bow\n--------\nQuality: +20%'; + +/** Runs one report and returns what Discord would have received. */ +async function send(body, env = {}) { + let seen = null; + const real = globalThis.fetch; + globalThis.fetch = async (url, init) => { + seen = { url, form: init.body }; + return new Response('{}', { status: 200 }); + }; + try { + const res = await worker.fetch( + new Request('https://relay.example/report', { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'cf-connecting-ip': env.ip || '203.0.113.7', + }, + body: JSON.stringify(body), + }), + { DISCORD_WEBHOOK: WEBHOOK, ...env }, + ); + const payload = seen && JSON.parse(seen.form.get('payload_json')); + const files = {}; + if (seen) { + for (const [k, v] of seen.form.entries()) { + if (k.startsWith('files[')) files[v.name] = v; + } + } + return { res, json: await res.json(), payload, files, url: seen?.url }; + } finally { + globalThis.fetch = real; + } +} + +test('a plain report reaches Discord and comes back with an id', async () => { + const { res, json, payload, files } = await send({ item: ITEM }); + assert.equal(res.status, 200); + assert.equal(json.ok, true); + assert.match(json.id, /^[0-9a-f]{8}$/); + assert.equal(payload.embeds[0].title, 'Doom Song — Spine Bow'); + assert.ok(files['report.md'], 'report.md is always attached'); +}); + +test('mentions cannot ping anyone', async () => { + const { payload } = await send({ item: ITEM, comment: '@everyone @here <@&999>' }); + assert.deepEqual(payload.allowed_mentions, { parse: [] }); +}); + +test('a comment cannot break out of its code fence', async () => { + const { payload } = await send({ + item: ITEM, + comment: '```\n[click me](https://evil.invalid)', + }); + const field = payload.embeds[0].fields.find((f) => f.name === 'What the reporter said'); + assert.ok(!field.value.includes('```\n['), 'the injected fence is neutralised'); + assert.ok(field.value.startsWith('```text\n') && field.value.endsWith('\n```')); +}); + +test('bidi overrides and control characters are stripped', async () => { + const { payload, files } = await send({ + item: ITEM, + comment: 'safe\u202egnorw sdaer\u202c\u200b\u0000text', + }); + const body = JSON.stringify(payload) + (await files['report.md'].text()); + for (const ch of ['\u202e', '\u202c', '\u200b', '\u0000']) { + assert.ok(!body.includes(ch), `${JSON.stringify(ch)} survived`); + } +}); + +test('unknown fields are dropped rather than forwarded', async () => { + const { payload } = await send({ + item: ITEM, + username: 'GGG Support', + avatar_url: 'https://evil.invalid/a.png', + content: '@everyone', + embeds: [{ title: 'injected' }], + }); + assert.equal(payload.username, 'Path of Price Check'); + assert.equal(payload.content, undefined); + assert.equal(payload.embeds.length, 1); + assert.equal(payload.embeds[0].title, 'Doom Song — Spine Bow'); +}); + +test('the embed stays inside Discord\'s 6000-character budget', async () => { + const long = 'Item Class: Bows\nRarity: Rare\nDoom Song\nSpine Bow\n--------\n' + 'x'.repeat(15000); + const { res, payload } = await send({ item: long, comment: 'y'.repeat(1900) }); + assert.equal(res.status, 200); + assert.ok(JSON.stringify(payload.embeds[0]).length <= 6000); +}); + +test('oversized fields are refused, not silently trimmed', async () => { + const { res, json } = await send({ item: ITEM, comment: 'x'.repeat(2001) }); + assert.equal(res.status, 400); + assert.match(json.error, /comment is longer/); +}); + +test('non-string fields are refused', async () => { + for (const bad of [{ item: 5 }, { item: ITEM, parse: {} }, { item: ITEM, meta: [] }]) { + const { res } = await send(bad); + assert.equal(res.status, 400); + } +}); + +test('text that is not an item is refused', async () => { + const { res, json } = await send({ item: 'buy cheap currency at example.invalid' }); + assert.equal(res.status, 400); + assert.match(json.error, /does not look like/); +}); + +test('a screenshot must really be a PNG', async () => { + const gif = Buffer.from('GIF89a' + 'x'.repeat(64)).toString('base64'); + const { res, json } = await send({ item: ITEM, screenshot_png_b64: gif }); + assert.equal(res.status, 400); + assert.match(json.error, /not a PNG/); +}); + +test('a real PNG is attached under a filename we chose', async () => { + const png = Buffer.concat([ + Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), + Buffer.alloc(32), + ]).toString('base64'); + const { payload, files } = await send({ item: ITEM, screenshot_png_b64: png }); + assert.ok(files['screenshot.png']); + assert.equal(payload.embeds[0].image.url, 'attachment://screenshot.png'); +}); + +test('only POST /report exists', async () => { + for (const [method, path] of [['GET', '/report'], ['POST', '/'], ['POST', '/anything']]) { + const res = await worker.fetch( + new Request(`https://relay.example${path}`, { method }), + { DISCORD_WEBHOOK: WEBHOOK }, + ); + assert.equal(res.status, 404); + } +}); + +test('a non-JSON content type is refused, so browsers must preflight', async () => { + const res = await worker.fetch( + new Request('https://relay.example/report', { + method: 'POST', + headers: { 'content-type': 'text/plain' }, + body: JSON.stringify({ item: ITEM }), + }), + { DISCORD_WEBHOOK: WEBHOOK }, + ); + assert.equal(res.status, 415); +}); + +test('a misconfigured webhook is refused before anything is sent', async () => { + for (const bad of [undefined, 'https://evil.invalid/api/webhooks/1/2', 'not a url']) { + const { res } = await send({ item: ITEM }, { DISCORD_WEBHOOK: bad }); + assert.equal(res.status, 500); + } +}); + +test('REPORTS_ENABLED=0 turns the relay off', async () => { + const { res } = await send({ item: ITEM }, { REPORTS_ENABLED: '0' }); + assert.equal(res.status, 503); +}); + +test('the per-IP cap rejects once it is reached', async () => { + const store = new Map(); + const RL = { + get: async (k) => store.get(k) ?? null, + put: async (k, v) => void store.set(k, v), + }; + const env = { RL, MAX_PER_IP_PER_HOUR: '2' }; + for (let i = 0; i < 2; i++) { + assert.equal((await send({ item: ITEM }, env)).res.status, 200); + } + const { res, json } = await send({ item: ITEM }, env); + assert.equal(res.status, 429); + assert.match(json.error, /too many reports/); +}); + +test('a broken rate limiter fails open', async () => { + const RL = { + get: async () => { throw new Error('KV is down'); }, + put: async () => {}, + }; + const { res } = await send({ item: ITEM }, { RL }); + assert.equal(res.status, 200); +}); + +test('report.md carries the whole report, ready to paste', async () => { + const { files } = await send({ + item: ITEM, + parse: "modifier 3 matched no stat record", + comment: 'wrong affix', + meta: { version: '0.6.17', os: 'linux' }, + }); + const md = await files['report.md'].text(); + assert.ok(md.includes('### Item') && md.includes('Doom Song')); + assert.ok(md.includes('### Parse output') && md.includes('matched no stat record')); + assert.ok(md.includes('### Reported problem') && md.includes('wrong affix')); + assert.ok(md.includes('- App: `0.6.17`') && md.includes('- OS: `linux`')); +}); diff --git a/worker/wrangler.toml b/worker/wrangler.toml new file mode 100644 index 0000000..dcdb38d --- /dev/null +++ b/worker/wrangler.toml @@ -0,0 +1,23 @@ +name = "ppc-reports" +main = "src/index.js" +compatibility_date = "2026-06-01" +workers_dev = true + +# Off deliberately. Workers Logs would retain request metadata for a feature whose whole premise is +# that a report is anonymous; `console.error` still reaches `wrangler tail` when you are watching. +[observability] +enabled = false + +[vars] +# Flip to "0" and redeploy to turn reporting off without deleting the Worker. The app treats a +# refusal the same as any other failure: the report is dropped and nothing is said. +REPORTS_ENABLED = "1" +MAX_PER_IP_PER_HOUR = "5" +MAX_PER_DAY_GLOBAL = "300" + +# Rate limiting is optional — without this binding the relay still works, it just has no brakes. +# Create the namespace once, paste the id, uncomment: +# npx --yes wrangler kv namespace create ppc-reports-rl +# [[kv_namespaces]] +# binding = "RL" +# id = "paste-the-id-here" From d32d0f040316528d99f596c7697d94f280c5c8b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ji=C5=99=C3=AD=20Posp=C3=AD=C5=A1il?= Date: Tue, 11 Aug 2026 03:20:12 +0200 Subject: [PATCH 2/3] A report is a forum post, and we are the one who names it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CHANGED: The relay posts each report as its own forum thread rather than a message in a text channel, so triage is Discord's own open/resolved state and its tags instead of a convention. - A forum rejects a message with no `thread_name` and a text channel rejects one that has it, so `DISCORD_FORUM` exists to match whichever the webhook points at. CHANGED: The thread is named from the item plus the report id, capped at Discord's 100 characters with the id's room reserved before the name is trimmed. - The name is built from the item's own text, never from anything the reporter wrote, so a report cannot choose what a thread in the server is called; the id keeps two reports about the same base from becoming one post title. CHANGED: A Discord rejection now logs the response body, not just the status — the cause is a channel/payload mismatch that the status alone never names. ADDED: Four tests covering the thread name, its cap, its single line, and the text-channel fallback. CHANGED: `README.md` says the channel must be created as a forum, since Discord cannot convert one later. Co-Authored-By: Claude Opus 5 (1M context) --- worker/README.md | 16 +++++++++++++--- worker/src/index.js | 33 ++++++++++++++++++++++++++------- worker/test.mjs | 23 +++++++++++++++++++++++ worker/wrangler.toml | 4 ++++ 4 files changed, 66 insertions(+), 10 deletions(-) diff --git a/worker/README.md b/worker/README.md index 2d51f5a..640a971 100644 --- a/worker/README.md +++ b/worker/README.md @@ -17,12 +17,17 @@ app --HTTPS--> ppc-reports..workers.dev --webhook--> #ppc-reports ### 1. Discord — the channel and the webhook -1. In your server, **create a channel** for this, e.g. `#ppc-reports`. +1. In your server, **create a forum channel** for this, e.g. `#ppc-reports`. It has to be a forum, + not a text channel: each report is posted as its own thread, so triage is Discord's own + open/resolved state and its tags rather than a convention you have to remember. Discord cannot + convert a text channel into a forum, so this is decided when the channel is made. 2. **Make it private.** Edit Channel → Permissions → deny `@everyone` *View Channel*. Every report is text a stranger typed; it does not belong in a channel anyone can read. -3. Edit Channel → **Integrations** → **Webhooks** → **New Webhook**. Name it (the name is +3. Optionally add **tags** — `parse`, `data-repo`, `needs-capture`, `wontfix`. Reports arrive + untagged; nothing applies one automatically, because a tag on every post sorts nothing. +4. Edit Channel → **Integrations** → **Webhooks** → **New Webhook**. Name it (the name is overridden per-message anyway), confirm the channel, then **Copy Webhook URL**. -4. Do not paste that URL anywhere else — not a commit, not an issue, not a screenshot. GitHub scans +5. Do not paste that URL anywhere else — not a commit, not an issue, not a screenshot. GitHub scans for them and Discord revokes the ones it hears about. The URL looks like `https://discord.com/api/webhooks//`. You need *Manage Webhooks* on @@ -104,7 +109,9 @@ is untrue, stop and say so rather than shipping the app side. | | | | --- | --- | +| Mark a report handled | close the forum post. Deleting it works too, but a wording that comes back in three months is then gone. | | Change the Discord channel | make a new webhook, `./rotate-webhook.sh`. Clients never notice. | +| Move back to a text channel | `DISCORD_FORUM = "0"` in `wrangler.toml`, `./publish.sh`. It has to match the channel: a forum rejects a message with no thread name and a text channel rejects one that has it. | | Turn reporting off | `REPORTS_ENABLED = "0"` in `wrangler.toml`, `./publish.sh`. | | Turn it off *now* | `npx --yes wrangler delete` — the app treats a dead endpoint as a dropped report. | | Watch it | `npx --yes wrangler tail` | @@ -163,6 +170,9 @@ text, and each has a test in `test.mjs`: secret cannot turn the relay into a request forwarder. - **`application/json` is required**, which forces a preflight on any browser-originated POST, and no `OPTIONS` is answered. A random web page cannot use this endpoint. +- **The forum post is named by us**, from the item's own name plus the report id, capped at + Discord's 100 characters with the id's room reserved first — so a reporter cannot choose what a + thread in your server is called, and two reports about the same base are still two posts. - **Discord's own limits are applied here** (6000 per embed, 4096 description, 1024 per field) so an oversized report is trimmed by us rather than rejected wholesale by Discord. diff --git a/worker/src/index.js b/worker/src/index.js index 6b7df6a..5c31a6d 100644 --- a/worker/src/index.js +++ b/worker/src/index.js @@ -20,7 +20,7 @@ const LIMITS = { }; // Discord's own caps. Enforced here so an oversized report is trimmed rather than 400'd away. -const DISCORD = { title: 256, description: 4096, fieldValue: 1024, total: 6000 }; +const DISCORD = { title: 256, description: 4096, fieldValue: 1024, total: 6000, threadName: 100 }; const PNG_MAGIC = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]; @@ -70,9 +70,12 @@ export default { const id = crypto.randomUUID().split('-')[0]; const at = new Date().toISOString(); - const res = await post_to_discord(env.DISCORD_WEBHOOK, report, id, at); + const res = await post_to_discord(env, report, id, at); if (!res.ok) { - console.error(`discord rejected report ${id}: ${res.status}`); + // Discord says why in the body, and the reason is usually a channel/payload mismatch + // that no amount of staring at this code would reveal. It reaches `wrangler tail`. + const detail = await res.text().catch(() => ''); + console.error(`discord rejected report ${id}: ${res.status} ${detail.slice(0, 500)}`); return json(502, 'could not deliver the report'); } return json(200, null, {}, { ok: true, id }); @@ -223,9 +226,10 @@ async function bump(kv, key, ttl, max) { return true; } -async function post_to_discord(url, report, id, at) { +async function post_to_discord(env, report, id, at) { + const url = env.DISCORD_WEBHOOK; const form = new FormData(); - form.append('payload_json', JSON.stringify(message(report, id, at))); + form.append('payload_json', JSON.stringify(message(report, id, at, env.DISCORD_FORUM !== '0'))); form.append( 'files[0]', new Blob([issue_markdown(report, id, at)], { type: 'text/markdown' }), @@ -239,7 +243,7 @@ async function post_to_discord(url, report, id, at) { return fetch(`${url}?wait=true`, { method: 'POST', body: form }); } -function message(report, id, at) { +function message(report, id, at, forum) { const fields = []; if (report.comment) { fields.push({ @@ -263,13 +267,28 @@ function message(report, id, at) { }; if (report.png) embed.image = { url: 'attachment://screenshot.png' }; - return { + const payload = { username: 'Path of Price Check', // The one setting that makes @everyone in a report inert. Everything else is cosmetic; // without this, any reporter can ping the whole server. allowed_mentions: { parse: [] }, embeds: [within_budget(embed, report.item)], }; + // A forum channel takes a post, not a message: `thread_name` is what makes one, and a webhook + // aimed at a forum is rejected without it. Tags stay manual — every report carrying the same + // one would sort nothing. + if (forum) payload.thread_name = thread_name(report.item, id); + return payload; +} + +/** + * The forum post's title. The report id rides along on the end and is reserved room before the + * name is trimmed, because two reports about the same base are otherwise the same post title. + */ +function thread_name(item, id) { + const suffix = ` · ${id}`; + const name = title_of(item).replace(/\s+/g, ' ').trim(); + return cut(name, DISCORD.threadName - suffix.length) + suffix; } /** diff --git a/worker/test.mjs b/worker/test.mjs index 3d2d823..3232d62 100644 --- a/worker/test.mjs +++ b/worker/test.mjs @@ -53,6 +53,29 @@ test('a plain report reaches Discord and comes back with an id', async () => { assert.ok(files['report.md'], 'report.md is always attached'); }); +test('a report is posted as a forum thread, named and disambiguated', async () => { + const { json, payload } = await send({ item: ITEM }); + assert.equal(payload.thread_name, `Doom Song — Spine Bow · ${json.id}`); +}); + +test('a long name is trimmed but the id always survives', async () => { + const long = `Item Class: Bows\nRarity: Rare\n${'Doom '.repeat(40)}\nSpine Bow\n--------\nQuality: +20%`; + const { json, payload } = await send({ item: long }); + assert.ok(payload.thread_name.length <= 100); + assert.ok(payload.thread_name.endsWith(` · ${json.id}`)); +}); + +test('a thread name is a single line', async () => { + const { payload } = await send({ item: ITEM }); + assert.ok(!payload.thread_name.includes('\n')); +}); + +test('DISCORD_FORUM=0 posts a plain message instead', async () => { + const { payload } = await send({ item: ITEM }, { DISCORD_FORUM: '0' }); + assert.equal(payload.thread_name, undefined); + assert.equal(payload.embeds.length, 1); +}); + test('mentions cannot ping anyone', async () => { const { payload } = await send({ item: ITEM, comment: '@everyone @here <@&999>' }); assert.deepEqual(payload.allowed_mentions, { parse: [] }); diff --git a/worker/wrangler.toml b/worker/wrangler.toml index dcdb38d..ff52157 100644 --- a/worker/wrangler.toml +++ b/worker/wrangler.toml @@ -14,6 +14,10 @@ enabled = false REPORTS_ENABLED = "1" MAX_PER_IP_PER_HOUR = "5" MAX_PER_DAY_GLOBAL = "300" +# The webhook points at a forum channel, so each report is posted as its own thread. Set to "0" if +# it is ever moved back to a plain text channel — a forum rejects a message with no thread name, +# and a text channel rejects one that has it, so this has to match the channel. +DISCORD_FORUM = "1" # Rate limiting is optional — without this binding the relay still works, it just has no brakes. # Create the namespace once, paste the id, uncomment: From 94c3db895f555f3ff70b4288c19009df95f49bed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ji=C5=99=C3=AD=20Posp=C3=AD=C5=A1il?= Date: Tue, 11 Aug 2026 04:12:43 +0200 Subject: [PATCH 3/3] A price check can report itself, and the dialog is the disclosure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADDED: A **Report a Bug** button on the price-check panel, opening a dialog that shows the whole payload — the clipboard capture, what this tool made of it, the build line and the screenshot — before anything is sent. - Nothing may reach the relay that the preview does not draw, which is why `report::to_json` writes five keys and a test asserts the count. - The payload is frozen when the dialog opens: the updater can swap a bundle in and a search can land while the user is still reading, and a preview a frame behind what will be sent says nothing. ADDED: `report::describe`, the parse dump a mispricing is actually diagnosed from: what was read off the clipboard, what it resolved to in the bundle, which modifier matched which stat record — and, the line that earns the rest, which matched none. ADDED: An optional screenshot, read back from our own framebuffer with `glReadPixels` between the draw and the swap. - It is this process's own pixels and cannot be anything else, so the game behind the transparent parts of the overlay is not in it and no other window can be. - Rows are flipped and the colour is un-premultiplied on the way out: ImGui blends onto a transparent clear, which is right for a compositor and a shade too dark for every PNG reader. ADDED: A masked frame between the press and the dialog, which is the frame that gets photographed: every seller's account name is replaced by its position and no button shows its tooltip. - Masking in place would work only for as long as the action bar keeps being drawn before the results table, and that failure would be silent and would consist of publishing somebody's name. ADDED: `util/png`, an encoder with one `IDAT`, no interlacing and no ancillary chunks at all — no timestamp, no text, no gamma, which on a picture leaving a user's machine is a property rather than a shortcut. Every row is filtered Sub, which is what fits a panel under the relay's cap. ADDED: `ReportService`, off the UI thread and deliberately not behind `trade::request` — the shared limiter is GGG's policy and nothing else belongs there. ADDED: A monospace face chosen at startup, for the two payload previews. A capture is read down the line and Fontin has no figure alignment at all. ADDED: Three Font Awesome glyphs, and `report_test.cpp`. CHANGED: Search and Open in browser are square glyph buttons against the panel's right edge instead of two words, so the row is one toolbar and fits beside the listings. CHANGED: `PRIVACY.md` no longer says there is no server on the other end — there is now exactly one, reached only by a press, and a section says what a report carries and what it cannot. CHANGED: zlib becomes `ppc_core`'s one link dependency besides nlohmann/json, and a CI package. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/ci.yml | 2 +- .github/workflows/release.yml | 2 +- CLAUDE.md | 14 +- CMakeLists.txt | 12 +- PRIVACY.md | 87 +++++++- docs/architecture.md | 4 +- docs/reporting.md | 164 +++++++++++++++ docs/testing.md | 6 +- scripts/fetch-glyphs.sh | 6 +- src/app.cpp | 131 +++++++++++- src/app.hpp | 57 +++++- src/capture.hpp | 24 +++ src/fonts.cpp | 43 ++++ src/fonts.hpp | 6 + src/glyph_data.inc | 224 +++++++++++++-------- src/overlay.cpp | 67 +++++++ src/overlay.hpp | 23 +++ src/report/report.cpp | 264 +++++++++++++++++++++++++ src/report/report.hpp | 75 +++++++ src/report_service.cpp | 88 +++++++++ src/report_service.hpp | 65 ++++++ src/screens/pricecheck_screen.cpp | 118 ++++++++--- src/screens/report_screen.cpp | 319 ++++++++++++++++++++++++++++++ src/screens/report_screen.hpp | 12 ++ src/ui/glyphs.hpp | 6 +- src/ui/theme.cpp | 5 + src/util/png.cpp | 79 ++++++++ src/util/png.hpp | 19 ++ tests/report_test.cpp | 207 +++++++++++++++++++ worker/README.md | 15 +- 30 files changed, 1999 insertions(+), 145 deletions(-) create mode 100644 docs/reporting.md create mode 100644 src/capture.hpp create mode 100644 src/report/report.cpp create mode 100644 src/report/report.hpp create mode 100644 src/report_service.cpp create mode 100644 src/report_service.hpp create mode 100644 src/screens/report_screen.cpp create mode 100644 src/screens/report_screen.hpp create mode 100644 src/util/png.cpp create mode 100644 src/util/png.hpp create mode 100644 tests/report_test.cpp diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bb0635d..017d3ec 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -58,7 +58,7 @@ jobs: sudo apt-get install -y --no-install-recommends \ libx11-dev libxext-dev libxrandr-dev libxcursor-dev libxi-dev libxfixes-dev \ libxss-dev libxtst-dev libxrender-dev libxinerama-dev \ - libcurl4-openssl-dev \ + libcurl4-openssl-dev zlib1g-dev \ libxkbcommon-dev libwayland-dev wayland-protocols libgl1-mesa-dev libegl1-mesa-dev \ libasound2-dev libpulse-dev libdbus-1-dev libudev-dev - name: Configure diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index dbbe122..58abf83 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -102,7 +102,7 @@ jobs: sudo apt-get install -y --no-install-recommends \ libx11-dev libxext-dev libxrandr-dev libxcursor-dev libxi-dev libxfixes-dev \ libxss-dev libxtst-dev libxrender-dev libxinerama-dev \ - libcurl4-openssl-dev \ + libcurl4-openssl-dev zlib1g-dev \ libxkbcommon-dev libwayland-dev wayland-protocols libgl1-mesa-dev libegl1-mesa-dev \ libasound2-dev libpulse-dev libdbus-1-dev libudev-dev - name: Configure diff --git a/CLAUDE.md b/CLAUDE.md index 9cd43da..14a3973 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -20,8 +20,9 @@ Pipeline: **hotkey → auto-copy → clipboard → parse → identify → price The overlay, Settings, the league list, the static game-data layer, the item layer (parse → resolve → price-relevant numbers → search plan, plus the game-styled tooltip), the trade search, -poe.ninja reference pricing, the in-game currency exchange feed, QuickPaste and the binary updater -(with the Windows installer it depends on) are all **built and tested**. +poe.ninja reference pricing, the in-game currency exchange feed, QuickPaste, the bug reporter (with +the relay it posts to) and the binary updater (with the Windows installer it depends on) are all +**built and tested**. What is not built is [docs/roadmap.md](docs/roadmap.md) — including the fact that a language other than English cannot yet be selected, because the data build emits only English. @@ -41,7 +42,8 @@ read whole; each is one layer. | [docs/item-layer.md](docs/item-layer.md) | `src/item/` — parse, resolve, derive, range matching, and the plan rules every strategy shares. Where most pricing judgement lives. | | [docs/strategy-unique.md](docs/strategy-unique.md), [strategy-map.md](docs/strategy-map.md), [strategy-gem.md](docs/strategy-gem.md), [strategy-logbook.md](docs/strategy-logbook.md) | One per search strategy that has more to say than the shared rules: uniques (including unidentified), maps (with charts and Valdo maps), gems, expedition logbooks (the one item that is up to three items at once). | | [docs/quickpaste.md](docs/quickpaste.md) | The paste list — the popup at the cursor, the nine number-key slots, and the clipboard *write*, which is a seam of its own. | -| [worker/README.md](worker/README.md) | `worker/` — the Cloudflare Worker that relays a user's bug report to Discord, what has to be set up by hand, and the injection rules every payload field is held to. Not built into the app yet. | +| [docs/reporting.md](docs/reporting.md) | `src/report/`, the **Report a bug** button and its dialog, the screenshot read-back and the PNG encoder — the app side of a bug report, up to the request. | +| [worker/README.md](worker/README.md) | `worker/` — the Cloudflare Worker that relays that report to Discord, what has to be set up by hand, and the injection rules every payload field is held to. | | [docs/trade-layer.md](docs/trade-layer.md) | `src/trade/` — query building, the two-step client, the rate limiter, and how results and the filter list are drawn. | | [docs/ninja.md](docs/ninja.md) | `src/ninja/` — the poe.ninja reference price. | | [docs/exchange.md](docs/exchange.md) | `src/exchange/` — GGG's hourly in-game currency exchange digests. | @@ -73,7 +75,8 @@ rather than reconstructing the procedure. - **HTTP:** libcurl behind `src/net/http.hpp` (static Schannel build on Windows, so the release is a single `.exe`; gzip required, not `AUTO`). Do not re-add a `CURL::libcurl` alias — curl declares that name itself. **JSON:** nlohmann/json. **Tests:** doctest. **Clipboard:** our own platform - seam, never SDL's. + seam, never SDL's. **zlib** is `ppc_core`'s one other link dependency, and only for + `util/png`. - **Game data:** never baked into the binary. Built and published by the separate public repo **[JIRPOS/PathOfPriceCheck-Data](https://github.com/JIRPOS/PathOfPriceCheck-Data)** and downloaded at runtime, so a new league needs a data build rather than a new release. @@ -92,6 +95,9 @@ violate one of these on the strength of not having read it. - **Never issue a GGG request outside `trade::request`.** The shared rate limiter is a hard requirement, not a courtesy. poe.ninja and the currency-exchange CDN are *different hosts with different rules* and deliberately do not go through it. → trade-layer, external-apis +- **A bug report is sent only by a press, and the dialog shows the whole of it first.** Nothing may + reach the relay that the preview does not draw, and nothing about a report is gathered in the + background. → reporting, PRIVACY.md - **The clipboard is ours to read *and* to write.** `clipboard_set_text` owns the X selection from a thread of its own, because a write on X11 is a promise to answer for the text later. → quickpaste, platform diff --git a/CMakeLists.txt b/CMakeLists.txt index 6a6767c..2da2e7a 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -123,6 +123,8 @@ add_library(ppc_core STATIC src/leagues.cpp src/util/sha256.cpp src/util/base64.cpp + src/util/png.cpp + src/report/report.cpp src/util/debug_log.cpp src/data/index.cpp src/data/lexicon.cpp @@ -155,7 +157,12 @@ add_library(ppc_core STATIC src/platform/input.cpp) target_include_directories(ppc_core PUBLIC src) target_compile_definitions(ppc_core PUBLIC APP_VERSION="${APP_VERSION}") -target_link_libraries(ppc_core PUBLIC nlohmann_json::nlohmann_json) +# zlib: the PNG encoder's deflate, and nothing else. Already in the tree on the Windows path +# above, where curl fetches it; everywhere else it is a system package (libcurl needs it too). +if(NOT TARGET ZLIB::ZLIB) + find_package(ZLIB REQUIRED) +endif() +target_link_libraries(ppc_core PUBLIC nlohmann_json::nlohmann_json ZLIB::ZLIB) set(APP_SOURCES src/main.cpp @@ -169,6 +176,7 @@ set(APP_SOURCES src/ninja_service.cpp src/ninja/client.cpp src/exchange_service.cpp + src/report_service.cpp src/exchange/client.cpp src/icon_cache.cpp src/data/updater.cpp @@ -180,6 +188,7 @@ set(APP_SOURCES src/screens/settings_screen.cpp src/screens/quickpaste_screen.cpp src/screens/pricecheck_screen.cpp + src/screens/report_screen.cpp src/screens/item_view.cpp) if(WIN32) list(APPEND APP_SOURCES @@ -283,3 +292,4 @@ ppc_add_test(exchange_test) ppc_add_test(ratelimit_test) ppc_add_test(track_test) ppc_add_test(quickpaste_test) +ppc_add_test(report_test) diff --git a/PRIVACY.md b/PRIVACY.md index 5a7d566..1040f0c 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -1,10 +1,16 @@ # Privacy **This project collects nothing.** There is no account, no telemetry, no analytics, no crash -reporting and no usage counter — and, more to the point, **there is no server on the other end to -collect anything with.** The project operates no backend of any kind. Everything the application -does happens on the machine it runs on, against third-party APIs that are the same ones a browser -would talk to. +reporting and no usage counter. Nothing is gathered in the background, on a timer, or as a side +effect of anything you do. Everything the application does happens on the machine it runs on, +against third-party APIs that are the same ones a browser would talk to. + +**There is exactly one thing you can send us, and only by pressing a button that says so.** +**Report a bug** on the price-check panel opens a dialog that shows you the entire payload — the +item text, what the tool made of it, four version strings, whatever you type, and a screenshot +only if you tick the box — and sends it nowhere until you press Send. It goes to a small relay of +ours, described in full [below](#reporting-a-bug). That relay is the project's only backend, it +exists for that one button, and nothing else in the application ever talks to it. The one thing that might sound like a phone-home is the update check, so it is worth being exact: it downloads a small **static file** from the GitHub release page — the same bytes served to @@ -31,6 +37,7 @@ Exhaustively — this is every outbound request the binary can make. | `web.poecdn.com/api/currency-exchange/` | when an item is priced and the newest published hour is not already on disk; **one download covers every item and every league** | nothing but the request | | `poe.ninja/poe1/api/economy/...` | when a reference price is needed and the 30-minute cache has expired; **once per category**, not per price check | nothing but the request and the league name | | `poe.ninja/favicons/favicon-32x32.png` | once, for the reference row's source mark | nothing but the request | +| `ppc-reports.jirpos.workers.dev` | **only when you press Send in the bug reporter**, never otherwise | the report you were shown before you pressed it — see [below](#reporting-a-bug) | **Opening a search in your browser** builds the same query into a `pathofexile.com/trade/search` URL and hands it to your browser. That costs no API call, and what happens after it is between you @@ -74,6 +81,9 @@ The whole tool works by reading the clipboard, so this is worth being precise ab - If you press the hotkey while something other than an item is on your clipboard, that text is what gets parsed. It fails to parse, nothing opens, and it is discarded. But it *was* read — so the ordinary caution applies: this is a global hotkey and the clipboard is a global thing. +- Item text you priced is held in memory until the next check, and is the one thing a **bug + report** can carry off the machine — only the check you are looking at, and only if you press + Send. See [Reporting a bug](#reporting-a-bug). - **Two things write to your clipboard, both because you asked**: picking an entry from QuickPaste, which puts that entry's own text there and nothing else, and clicking the diagnostic check id in the panel footer, which copies that four-character id. On Linux the text is then served from a @@ -113,12 +123,77 @@ its shortcuts, one registry value at `HKCU\Software\PathOfPriceCheck` recording installed, and the usual Add/Remove Programs entry; uninstalling removes them. One optional setting is personal information you may type in yourself: **Account** in Settings -(`Name#1234`). It is stored in `config.json` and, as of today, is **not sent anywhere** — nothing -in the request path reads it. +(`Name#1234`). It is stored in `config.json` and is **never sent anywhere** — nothing in the +request path reads it, the bug reporter included. It is used for one thing: marking a listing in +the results as yours, and even a screenshot you choose to attach has it replaced along with every +other handle on the table. Search results contain other players' account names and the whisper text for contacting them. Those live in memory for as long as the panel is open and are dropped when the next check runs. +## Reporting a bug + +The **Report a bug** button on the price-check panel. Nothing here happens unless you press it, and +then press **Send** in the dialog it opens. + +**The dialog is the disclosure.** It shows the payload in full, in the same text that goes on the +wire, before anything is sent — there is no summary standing in for the real thing and no field it +does not display. Read it, and if you would rather not send some part of it, close the dialog. + +What a report contains, exhaustively: + +| | | +|---|---| +| the item | the clipboard text the game wrote, verbatim and unedited | +| the parse | what this tool made of that text: the fields it read, what they resolved to in the data bundle, which modifier matched which stat record and which matched none, and what a search would have asked for | +| your comment | the box you typed in, or nothing if you left it empty | +| four version strings | the application's version, the operating system's name (`Linux`, `Windows`), the league you have selected, and the data bundle's version. Nothing else, and none of them is per-machine | +| a screenshot | **only if you tick the box.** See below | + +What a report does **not** contain: your account name, your character, any identifier of your +machine or install, any path from your disk, any cookie, and anything at all from a previous check. +There is no id tying two reports to one person, because there is no id. + +### The screenshot + +The checkbox starts unticked. The picture beside it is the exact image that would be attached, at +the moment you pressed the button, so the decision is one you can make by looking. + +It is a **read-back of this application's own window**, not a capture of your screen: the pixels +this program drew, and only those. The game behind the transparent parts of the overlay is not in +it and cannot be — nothing here has the ability to photograph another window. Everything else on +your desktop is likewise absent. + +**Nobody's account name is in it.** On an item that ran a search the panel shows a results table, +and before the picture is taken the panel is redrawn with every seller's handle replaced by its +position — `seller 1`, `seller 2` — so what is photographed never had a name on it. Yours is +covered by the same rule, on the row marked as yours. Prices, ages and everything else about the +market are left exactly as they were, because those are the thing a mispricing is read against. + +What is in it, then, is the panel as you were looking at it with the names taken out — which is +still worth checking before you tick the box, and is why the preview is the size it is. + +### Where it goes + +To `ppc-reports.jirpos.workers.dev`, a Cloudflare Worker operated by the maintainer, which forwards +it to a private channel the maintainer reads and does nothing else with it. The Worker keeps no +database, writes no log of requests, and stores nothing: the report is relayed and the request is +over. Its source is in [`worker/`](worker/) in this repository, so what it does is readable rather +than promised. + +Cloudflare sits in front of it and sees your IP address, as any host you make a request to does; +their [privacy policy](https://www.cloudflare.com/privacypolicy/) applies. The Worker uses that +address for one thing — an hourly cap, so the endpoint cannot be flooded — and it is never part of +what reaches the channel. + +A report stays in that channel until it is dealt with. If you want one removed, quote its id: the +dialog shows it after a successful send and it is the only handle either of us has on it. + +### Turning it off + +There is nothing to turn off, because nothing runs. The button sends when you press it; if you +never press it, the application never contacts the relay and never has. + ## The debug log **Off by default, for everyone.** It exists because the clipboard handover between the game, Wine diff --git a/docs/architecture.md b/docs/architecture.md index 40142df..c1e7ffd 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -342,7 +342,9 @@ dismiss-on-blur for local dev; add `PPC_DEV_ITEM=` to open the price-check clipboard instead, `PPC_DEV_PASTE=1` to open the paste popup at the pointer, or `PPC_DEV_IDLE=1` to keep the idle status marker up (it otherwise only ever appears while the game is the window in front). `PPC_DEV_UPDATE_URL=` points the update check at a `latest.json` of your own, which is the only way to see its three notice surfaces before a -release publishes one — see [updater.md](updater.md). +release publishes one — see [updater.md](updater.md). `PPC_REPORT_URL=` points the bug +reporter at a relay of your own, which is how its two outcomes are seen without posting into the +real channel — see [reporting.md](reporting.md). The Windows binary is **GUI-subsystem** (`WIN32_EXECUTABLE`, entered at `WinMain` in `src/main.cpp`): a console-subsystem build pops a console window beside an application whose whole UI is an overlay diff --git a/docs/reporting.md b/docs/reporting.md new file mode 100644 index 0000000..b8b03ad --- /dev/null +++ b/docs/reporting.md @@ -0,0 +1,164 @@ +# Reporting a bad price check + + + +The **Report a bug** button on the price-check panel, the dialog it opens, and what that dialog +posts. The thing on the other end — the Cloudflare Worker, the Discord forum, and the injection +rules every field of the payload is held to — is [worker/README.md](../worker/README.md), and this +doc stops at the request. + +Files: `src/report/` (pure, in `ppc_core`), `src/report_service.cpp` (the request), +`src/screens/report_screen.cpp` (the dialog), `src/capture.hpp` and `Overlay::request_capture` +(the screenshot), `src/util/png.cpp` (the encoder). + +## The premise + +**The dialog is the disclosure.** Everything the report will send is on screen, in the same text +that goes on the wire, before Send is pressed — not a summary of it, and not a promise about it. +That is the only reason it is defensible to offer a screenshot at all, and it is what every design +decision below is downstream of. + +Two consequences that are easy to undo by accident: + +- **The payload is frozen when the dialog opens** (`App::finish_bug_report`). The item text, the + parse dump and the four version strings are copied into `ReportDraft` and never rebuilt. They + could otherwise change under a reader: the data updater swaps a bundle in from its own thread, + a search lands, the user picks a different unique. A preview that is one frame behind what will + be sent is a preview that says nothing. +- **Nothing may be added to the request that the dialog does not draw.** `report::to_json` writes + five keys and `tests/report_test.cpp` asserts the count, which is what that test is for. + +## The button + +`draw_action_bar` in `pricecheck_screen.cpp`. Three square glyph buttons against the panel's right +edge — right to left: Search, open on the site, report — and the last of them is on **every** +price check, including the items that get no search at all (currency, cards, scarabs), where it is +the only button and sits under whatever section drew last. + +The glyphs are `ui/glyphs.hpp`, which is a contract with `scripts/fetch-glyphs.sh`: a name here +that is not a codepoint there bakes as nothing. `Fonts::has_glyphs` is the check, and every button +carries a single-letter fallback. + +`icon_button` goes silent while `App::report_capture_pending()` is true — see below. + +## The screenshot + +`Overlay::request_capture()` sets a flag; `Overlay::end_frame` reads the back buffer with +`glReadPixels` **after `RenderDrawData` and before `SDL_GL_SwapWindow`** — what a swap leaves in +the back buffer is undefined. + +### Two frames, and both of them are the point + +``` +press → [this frame finishes as it was] → masked frame, read back → dialog +``` + +`App::open_bug_report` does nothing but move `Opening::No → Masking`. The run loop advances it +between frames, which is where every part of this has to happen: the panel must be *redrawn* in +the face it will be photographed in, the read-back must be of a frame that is finished, and the +resize the dialog brings must not land inside one. + +**The middle frame is the panel drawn to be photographed rather than to be read**, which +`App::report_capture_pending()` is the whole of. Two things follow from it, both in +`pricecheck_screen.cpp`: + +- **Every seller's account name is replaced by its position** — `seller 1`, `seller 2` — including + the user's own, on the row `account_name` marks as theirs. What a maintainer needs off the + picture is that these are twenty different sellers and which row is which; the handles are + somebody else's name and are worth nothing in a bug report. Prices, ages and the counts stay, + because those are what a mispricing is read against. +- **No button shows its tooltip.** The cursor is on the report button when it is pressed, so + without this every capture carries "Report a Bug" hovering over the panel — a picture of the act + of reporting rather than of the thing being reported. + +Masking on the press frame instead would work, today, only because the action bar happens to be +drawn before the results table. That is not a thing this file can promise, and the failure would be +silent and would consist of publishing somebody's name. + +The masked frame is on screen for about sixteen milliseconds before the dialog covers it. + +Two corrections on the way out, both in `Overlay::read_back`: + +- **Rows are flipped.** GL's origin is the bottom-left and every consumer here wants the top-left. +- **Colour is un-premultiplied.** ImGui blends `GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA` onto a + framebuffer cleared to transparent black, so what accumulates is colour already multiplied by + its coverage — right for a compositor, and a shade too dark for anything reading the file as + ordinary straight-alpha RGBA, which is every PNG viewer and Discord. + +**It is our own window and cannot be anything else.** Nothing here can photograph another window; +the transparent parts of the overlay come back transparent, not as the game behind them. That is +the property `PRIVACY.md` states and the reason the feature exists in this shape rather than as an +OS screen capture. + +It still contains whatever else the panel was showing, so **the checkbox starts unticked**, the +preview is large enough to read, and the dialog states in as many words both what the picture is +and what was taken out of it before it was taken. + +## The PNG + +`util/png.cpp`, zlib for the deflate. The smallest encoder that produces a file every decoder +accepts: one `IDAT`, no interlacing, and **no ancillary chunks at all** — no timestamp, no text, +no gamma. On a picture leaving a user's machine that is a property, not a shortcut. + +Every row is filtered **Sub**, which stores each byte as its difference from the pixel four bytes +to its left. A panel is mostly flat horizontal runs, and Sub turns those into runs of zeros before +deflate sees them; a 900×1080 capture lands around 190 KB, against 3.9 MB raw and a relay cap of +5 MB. Per-row filter selection would do a little better and is not worth the code for one picture +a user sends by hand. + +zlib is `ppc_core`'s only link dependency besides nlohmann/json. It was already in the tree on +Windows, where curl fetches it; elsewhere it is a system package that libcurl needs anyway. + +## The parse dump + +`report::describe` — the field this whole feature exists to deliver. Plain lines rather than JSON, +because the question it has to answer at a glance is *which line went wrong*, and a nested +structure answers that worse. + +Sections: the item as parsed, what it resolved to in the bundle, the properties, **every modifier +with the stat record it matched**, the derived numbers, and the plan the search would have been +built from. The line that earns the rest is `-> NO MATCH`: a wording nothing in the bundle +recognises is the most common thing behind "this priced wrong", and a dump that quietly dropped +such a modifier would hide the exact bug it was sent to report. + +`meta.bundle` is the version of the bundle **the item was resolved against** (`item_data_`), not +whichever is current — a mispricing is as often the data's as the code's, and the two can already +differ by the time the dialog opens. + +## The request + +`ReportService`, shaped like `LeagueService`: main thread touches every member, the worker owns +its stack and hands the result back through the SDL event queue. + +- **Not through `trade::request`.** The shared rate limiter exists for GGG's policy and nothing + else belongs behind it. What keeps this endpoint from being hammered is the relay's own per-IP + cap and the fact that a report is a thing a person types. +- The screenshot reaches the worker as **raw pixels**. Encoding it, base64-ing it and serialising + the body are together most of a second on a large panel, and none of that belongs in the frame + that drew the button. +- 30 s timeout, against 8 s everywhere else: a report is megabytes on a slow line, and a send that + gives up under a user watching it is worse than one that takes a while. +- `report::read_response` reports the **relay's own wording** for a refusal. It says why in every + error body and that is a better message than any status-code table here could be. A 200 with no + id is a failure — a proxy or a captive portal, not the relay — and reporting it as sent is the + one lie this dialog must not tell. + +## The two outcomes + +- **Sent**: the dialog closes and `Screen::ReportSent` puts up a small confirmation with the id on + it. That id names the forum post the report became, so it is the only handle either side has on + a particular report. +- **Refused**: the dialog **stays open**, with a modal over it carrying the reason. The text the + user wrote exists in that window and nowhere else; closing it on a failure would be losing the + report. The modal reopens itself from `ReportState::Failed`, so dismissing it means *clearing* + that state (`App::dismiss_report_result`), which also re-enables Send. + +Neither screen dismisses on a click away, for the reason Settings does not and one of its own. Both +take the keyboard, since the dialog is mostly a box to type in. + +## Testing it without posting anything + +`PPC_REPORT_URL` overrides the endpoint. Point it at a local server that answers +`{"ok":true,"id":"deadbeef"}` to see the success path end to end — including what actually arrives +in the body — and at an unresolvable host to see the failure modal. `worker/send-test.sh` is the +other half: it posts the same shapes at the real relay, including a `--hostile` payload. diff --git a/docs/testing.md b/docs/testing.md index 1c358b2..2284793 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -13,8 +13,10 @@ ctest --test-dir build -R -V # a single test SDL3 builds from source, so **Linux needs dev headers**: `libx11-dev libxext-dev libxrandr-dev libxcursor-dev libxi-dev libxfixes-dev libxkbcommon-dev libwayland-dev wayland-protocols libgl1-mesa-dev libegl1-mesa-dev libasound2-dev libpulse-dev libdbus-1-dev libudev-dev -libcurl4-openssl-dev` (the CI -workflows install exactly these). Windows needs only MSVC. The CI still validates the Windows build on +libcurl4-openssl-dev zlib1g-dev` (the CI +workflows install exactly these). zlib is `ppc_core`'s only link dependency besides +nlohmann/json — it is the deflate behind `util/png`, and on Windows it is already in the tree, +fetched alongside curl. Windows needs only MSVC. The CI still validates the Windows build on every push/PR — trust it for the Win32 platform code, which can't be compiled locally here. The bundled font data is committed, so a normal build needs nothing extra. To change the typeface: diff --git a/scripts/fetch-glyphs.sh b/scripts/fetch-glyphs.sh index cad01dd..acc0ce9 100755 --- a/scripts/fetch-glyphs.sh +++ b/scripts/fetch-glyphs.sh @@ -11,8 +11,10 @@ set -euo pipefail ver="6.7.2" url="https://github.com/FortAwesome/Font-Awesome/releases/download/$ver/fontawesome-free-$ver-web.zip" # f00c check (confirm), f0e2 arrow-rotate-left (reset), f0fe square-plus (add), -# f304 pen (edit), f2ed trash-can (delete), f7a4 grip-lines (drag to reorder). -codepoints="U+F00C,U+F0E2,U+F0FE,U+F304,U+F2ED,U+F7A4" +# f304 pen (edit), f2ed trash-can (delete), f7a4 grip-lines (drag to reorder), +# f002 magnifying-glass (search), f08e arrow-up-right-from-square (open in browser), +# f188 bug (report a bug). +codepoints="U+F00C,U+F0E2,U+F0FE,U+F304,U+F2ED,U+F7A4,U+F002,U+F08E,U+F188" root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" dest="$root/assets/fonts" diff --git a/src/app.cpp b/src/app.cpp index cf03f92..bdc2b8a 100644 --- a/src/app.cpp +++ b/src/app.cpp @@ -23,6 +23,7 @@ #include "quickpaste.hpp" #include "screens/pricecheck_screen.hpp" #include "screens/quickpaste_screen.hpp" +#include "screens/report_screen.hpp" #include "screens/settings_screen.hpp" #include "trade/query.hpp" #include "ui/strings.hpp" @@ -127,6 +128,11 @@ void SDLCALL tray_exit_cb(void* userdata, SDL_TrayEntry*) { // game's own height, which is the one thing that can force that scrollbar. constexpr int kSettingsW = 640, kSettingsH = 720; +// The report dialog is two columns — the payload on the left, the screenshot beside it — so it is +// wider than Settings and no taller. The confirmation that follows it is a sentence and a button. +constexpr int kReportW = 940, kReportH = 660; +constexpr int kNoticeW = 420, kNoticeH = 150; + // The idle status: two short lines over the lower half of the mana globe. Wide enough for a long // data version at the size below, and no wider — the window is what swallows mouse input, and // while idle it is only click-through because nothing else is open. @@ -260,7 +266,7 @@ int App::run(bool relaunched_after_update) { return 1; } // SDL hands back a contiguous range, so the offsets are guaranteed. - const uint32_t event_base = SDL_RegisterEvents(7); + const uint32_t event_base = SDL_RegisterEvents(8); if (!event_base) { SDL_Log("SDL_RegisterEvents failed: %s", SDL_GetError()); SDL_Quit(); @@ -273,6 +279,7 @@ int App::run(bool relaunched_after_update) { ninja_event_ = event_base + 4; exchange_event_ = event_base + 5; update_event_ = event_base + 6; + report_event_ = event_base + 7; if (!overlay_.init("Path of Price Check Overlay")) { SDL_Log("overlay init failed"); @@ -301,6 +308,7 @@ int App::run(bool relaunched_after_update) { trade_.load_cache(); // currency symbols; the search itself fetches them if they are stale ninja_.init(ninja_event_); currency_exchange_.init(exchange_event_); + report_.init(report_event_); icons_.init(); // Reclaim superseded bundles and map the installed one before anything else can hold a @@ -395,10 +403,23 @@ int App::run(bool relaunched_after_update) { draw_pricecheck_screen(*this); else if (screen_ == Screen::QuickPaste) draw_quickpaste_screen(*this); + else if (screen_ == Screen::BugReport || screen_ == Screen::ReportSent) + draw_report_screen(*this); else draw_status_marker(*this); overlay_.end_frame(); need_redraw_ = false; + // Between frames, because every part of this needs a frame boundary: the panel has + // to be redrawn in its masked face before it can be read back, the read-back has to + // be of a frame that is finished, and the resize the dialog brings must not land + // inside one. See `open_bug_report`. + if (report_opening_ == Opening::Masking) { + overlay_.request_capture(); // of the next frame, which is the masked one + report_opening_ = Opening::Capturing; + need_redraw_ = true; + } else if (report_opening_ == Opening::Capturing) { + finish_bug_report(); + } } } @@ -408,6 +429,7 @@ int App::run(bool relaunched_after_update) { trade_.shutdown(); ninja_.shutdown(); currency_exchange_.shutdown(); + report_.shutdown(); icons_.shutdown(); // frees GL textures, so before the context goes with the overlay updater_.shutdown(); app_updater_.shutdown(); @@ -469,6 +491,15 @@ void App::handle_event(const SDL_Event& e) { } } else if (e.type == update_event_) { need_redraw_ = true; + } else if (e.type == report_event_) { + report_.on_done(e); + // Only a send that landed closes the dialog. A refusal leaves it exactly as it was, with + // the reason on it: the text the user wrote is in that window and nowhere else. + if (report_.state() == ReportState::Sent && screen_ == Screen::BugReport) { + drop_report_draft(); // the dialog is done with it, and it holds a GL texture + set_screen(Screen::ReportSent); + } + need_redraw_ = true; } else if (e.type == SDL_EVENT_KEY_DOWN && capturing_) { if (e.key.key == SDLK_ESCAPE) { end_capture(); // cancel capture, don't bind Escape @@ -491,6 +522,8 @@ void App::handle_event(const SDL_Event& e) { // editor claimed the keyboard, and closing the whole check on it would throw away the row // the user was aiming at. ImGui closes its popup on the same press, so both agree. if (filter_edit_.open()) close_filter_edit(); + else if (screen_ == Screen::BugReport) close_bug_report(); + else if (screen_ == Screen::ReportSent) dismiss_report_result(); else set_screen(Screen::Hidden); } else if (e.type == SDL_EVENT_KEY_DOWN && screen_ == Screen::QuickPaste) { // The whole reason the popup claims the keyboard. A slot nothing is in is not a miss to @@ -875,7 +908,11 @@ void App::poll_click_away() { // from another application that took the keyboard with it, and this is the earliest // moment we can tell: the poll in `update_overlay_placement` is up to 400ms behind, and // in the meantime the dialog would swallow their first sentence. - if (screen_ == Screen::Settings) { + // Neither the report dialog nor its confirmation dismisses on a click away, for the reason + // Settings does not and one of its own: the text in that window exists nowhere else, and + // losing it to a stray click over the game would be losing the report. + if (screen_ == Screen::Settings || screen_ == Screen::BugReport || + screen_ == Screen::ReportSent) { if (on_panel) reclaim_keyboard(); return; } @@ -1088,7 +1125,9 @@ void App::update_overlay_placement() { /// focus-loss that dismisses it could never happen. void App::reclaim_keyboard() { if (overlay_.has_focus()) return; - if (screen_ != Screen::Settings && screen_ != Screen::QuickPaste) return; + if (screen_ != Screen::Settings && screen_ != Screen::QuickPaste && + screen_ != Screen::BugReport) + return; debug::log("[app] reclaiming the keyboard for screen %d", (int)screen_); take_keyboard(); } @@ -1112,6 +1151,16 @@ void App::place_overlay() { gx = r.x, gy = r.y, gw = r.w, gh = r.h; } + if (screen_ == Screen::BugReport || screen_ == Screen::ReportSent) { + const bool notice = screen_ == Screen::ReportSent; + const int w = std::min(notice ? kNoticeW : kReportW, gw); + const int h = std::min(notice ? kNoticeH : kReportH, gh); + SDL_SetWindowSize(overlay_.window(), w, h); + SDL_SetWindowPosition(overlay_.window(), gx + (gw - w) / 2, gy + (gh - h) / 2); + layout_ = PanelLayout{0, float(w), 0, 0}; + return; + } + if (screen_ == Screen::Settings) { const int sh = std::min(kSettingsH, gh); SDL_SetWindowSize(overlay_.window(), kSettingsW, sh); @@ -1237,6 +1286,78 @@ void App::copy_check_id() { debug::log("[app] wrote the check id to the clipboard on the user's request"); } +// The panel is mid-draw when this is called — the button that calls it is on it — so nothing here +// may resize the window, change the screen, or read anything back. All of it waits, and what waits +// is two frames rather than one: +// +// press → [this frame finishes as it was] → **masked frame, read back** → dialog +// +// The middle frame is the whole reason for the state machine. It is the panel drawn as it will be +// photographed — sellers' account names replaced, tooltips silent — and a picture can only be of a +// frame that was actually rendered. Drawing it takes about sixteen milliseconds and it is on +// screen for one frame before the dialog covers it, which is not a flicker anyone has reported +// seeing; masking on the press frame instead would work only for as long as the action bar keeps +// being drawn before the results table, which is not a thing this file can promise. +void App::open_bug_report() { + if (report_opening_ != Opening::No || screen_ != Screen::PriceCheck) return; + report_opening_ = Opening::Masking; + need_redraw_ = true; +} + +// Everything the report will say, decided here and not touched again. What the dialog shows is +// what it sends, which is a promise it cannot keep if the payload goes on being rebuilt behind it +// — the updater can swap a bundle in and a search can land while the user is still typing. +void App::finish_bug_report() { + report_opening_ = Opening::No; + drop_report_draft(); + report_draft_.shot = overlay_.take_capture(); + report_draft_.shot_tex = overlay_.upload_texture(report_draft_.shot); + // The clipboard capture verbatim, which is the one input every parse bug is against. + report_draft_.payload.item = clipboard_; + if (item_) report_draft_.payload.parse = report::describe(*item_, derived_, plan_); + report_draft_.payload.meta.version = APP_VERSION; + report_draft_.payload.meta.os = SDL_GetPlatform(); + report_draft_.payload.meta.league = config_.league; + // The bundle the item was *resolved against*, not whichever is current: a mispricing is as + // often the data's as the code's, and the two can already differ by this point. + if (item_data_) report_draft_.payload.meta.bundle = std::string(item_data_->data_version()); + report_.reset(); + debug::log("[report] opening the dialog: %dx%d capture, %zu byte parse dump", + report_draft_.shot.w, report_draft_.shot.h, report_draft_.payload.parse.size()); + set_screen(Screen::BugReport); +} + +// The draft holds a GL texture, so it is dropped through here rather than assigned over. +void App::drop_report_draft() { + overlay_.free_texture(report_draft_.shot_tex); + report_draft_ = ReportDraft{}; +} + +void App::send_bug_report() { + if (report_.state() == ReportState::Sending) return; + report::Report r = report_draft_.payload; + r.comment = report_draft_.comment; + report_.send(std::move(r), report_draft_.attach ? report_draft_.shot : Capture{}); + need_redraw_ = true; +} + +void App::close_bug_report() { + drop_report_draft(); + report_.reset(); + // Back to the check it was about rather than away entirely: the report was opened from a + // panel that is still the answer to the question the user asked. + set_screen(Screen::PriceCheck); +} + +// Both answers the send can leave behind, because clearing it is the same act either way: a +// refusal is dismissed back into the dialog it never left, and a success is dismissed off screen +// because the dialog it belonged to is already gone. +void App::dismiss_report_result() { + report_.reset(); + if (screen_ == Screen::ReportSent) set_screen(Screen::Hidden); + need_redraw_ = true; +} + void App::set_screen(Screen s) { debug::log("[app] screen %d -> %d", (int)screen_, (int)s); screen_ = s; @@ -1263,6 +1384,10 @@ void App::set_screen(Screen s) { // the same thing the range editor takes, and the same reason it is not a violation of // the rule about the game's foreground. take_keyboard(); + } else if (s == Screen::BugReport) { + // The dialog is mostly a box to type in, and without the keyboard it is a preview with + // an unusable comment field. + take_keyboard(); } else if (s == Screen::Settings) { take_keyboard(); // TTL-gated, so a warm cache makes this a no-op. A user who never opens Settings diff --git a/src/app.hpp b/src/app.hpp index d03e2ba..c6f7e83 100644 --- a/src/app.hpp +++ b/src/app.hpp @@ -18,6 +18,7 @@ #include "ninja_service.hpp" #include "overlay.hpp" #include "platform/hotkeys.hpp" +#include "report_service.hpp" #include "trade_service.hpp" struct SDL_Surface; @@ -26,7 +27,7 @@ union SDL_Event; namespace ppc { -enum class Screen { Hidden, PriceCheck, Settings, QuickPaste }; +enum class Screen { Hidden, PriceCheck, Settings, QuickPaste, BugReport, ReportSent }; /// How long a price check waits for the game to publish its copy before dropping it. Past this /// the user has moved on, and a panel that opens late is a panel about the wrong item. @@ -97,6 +98,23 @@ struct PasteEdit { Paste draft; }; +/// The bug report being written, and the exact bytes it would send. +/// +/// **Everything but the comment is fixed when the dialog opens.** The dialog's promise is that +/// what it shows is what it sends, and a payload that went on being rebuilt underneath — the +/// updater swapping a bundle in, a search landing — would quietly break that promise between the +/// reading and the pressing. +struct ReportDraft { + report::Report payload; ///< item text, parse dump and meta; `png` is filled at send time + std::string comment; ///< the one field the user can edit + /// The panel as it was the instant the button was pressed, and that capture as something + /// ImGui can draw. Held whether or not it will be sent: the checkbox is a decision about a + /// picture the user is looking at, which is the only way it can be an informed one. + Capture shot; + uint64_t shot_tex = 0; + bool attach = false; +}; + /// A search result's own item, parsed from the clipboard text the API ships with every /// listing. Parsed lazily — twenty of these up front is work for rows nobody hovers — and /// resolved against the same pinned bundle snapshot as the item in hand. @@ -222,6 +240,28 @@ class App { /// the same frame. PasteEdit& paste_edit() { return paste_edit_; } + // Bug reports. The panel's own button opens the dialog; nothing here sends anything until + // the dialog's Send is pressed, and the dialog shows the whole payload first. + /// Capture the panel as it stands and open the report dialog on it. Called from the panel + /// mid-draw, so the capture and the screen change both wait for the end of this frame — see + /// `finish_bug_report`. + void open_bug_report(); + /// The draft being written. Mutable: the dialog reads and writes it in the same frame. + ReportDraft& report_draft() { return report_draft_; } + const ReportService& report() const { return report_; } + /// Post the draft, with the screenshot if the box is ticked. Never blocks. + void send_bug_report(); + /// Abandon it and go back to the price check the report was about. + void close_bug_report(); + /// Wave away the confirmation the send left on screen. + void dismiss_report_result(); + /// The panel is being drawn to be photographed, not to be read. + /// + /// Two things follow from it, both in `pricecheck_screen`: sellers' account names are replaced + /// by positions, and no button shows its tooltip. Neither is worth anything in a bug report + /// and the first is somebody else's name. + bool report_capture_pending() const { return report_opening_ != Opening::No; } + /// Copy-path diagnostic log (util/debug_log). Toggling it takes effect immediately — /// waiting for Save would mean the run that reproduced the bug went unrecorded — but it /// still needs a Save to persist. @@ -232,6 +272,8 @@ class App { void begin_capture(Action which); ///< next key press rebinds this action bool capturing(Action which) const { return capturing_ && capture_which_ == which; } void apply_and_save_config(); ///< persist config + re-register hotkeys + /// Which screen is up. Read by the one renderer that serves two of them. + Screen screen() const { return screen_; } void close_overlay() { set_screen(Screen::Hidden); } void quit() { running_ = false; } @@ -241,6 +283,8 @@ class App { void handle_action(Action a); ///< handled on the main thread void refresh_checks(); ///< re-check for a bundle and a release, if stale void end_capture(); ///< stop capturing and re-grab hotkeys + void finish_bug_report(); ///< take the frame's capture and open the dialog on it + void drop_report_draft(); ///< clear it, freeing the capture's texture void poll_pending_copy(); ///< show the item once the clipboard is written void abandon_copy(); ///< drop the copy in flight, showing nothing void nudge_clipboard_handover(uint64_t elapsed); ///< make the game let go of the copy @@ -268,6 +312,7 @@ class App { NinjaService ninja_; ExchangeService currency_exchange_; IconCache icons_; + ReportService report_; data::DataUpdater updater_; update::Updater app_updater_; /// Waved away for this session only. Deliberately not persisted: the staged update is @@ -298,6 +343,15 @@ class App { int settings_tab_ = 0; ///< which Settings tab is open FilterEdit filter_edit_; ///< which filter row has its range editor open PasteEdit paste_edit_; ///< the paste Settings has open in its editor + ReportDraft report_draft_; ///< the bug report being written + /// How far along opening the report dialog is. Two frames pass between the press and the + /// dialog, and both of them are the point — see `open_bug_report`. + enum class Opening : uint8_t { + No, + Masking, ///< the panel is redrawing in the face it will be photographed in + Capturing, ///< that redraw is being read back at the end of this frame + }; + Opening report_opening_ = Opening::No; /// Where the cursor was when the paste hotkey fired. Sampled there rather than read at /// placement time for the same reason `side_` is: by then the hand has moved. int paste_x_ = 0, paste_y_ = 0; @@ -334,6 +388,7 @@ class App { uint32_t trade_event_ = 0; ///< carries a TradeService::Result* uint32_t ninja_event_ = 0; ///< carries a NinjaService::Result* uint32_t exchange_event_ = 0; ///< carries an ExchangeService::Result* + uint32_t report_event_ = 0; ///< carries a ReportService::Result* bool game_present_ = false; ///< the game window was found on the last poll int game_x_ = 0, game_y_ = 0, game_w_ = 0, game_h_ = 0; ///< last placed-over geometry diff --git a/src/capture.hpp b/src/capture.hpp new file mode 100644 index 0000000..4bf721b --- /dev/null +++ b/src/capture.hpp @@ -0,0 +1,24 @@ +#pragma once + +#include +#include + +namespace ppc { + +/// A read-back of our own framebuffer: straight-alpha RGBA, top row first. +/// +/// **Our own window and nothing else.** This is not a screen capture — it is the pixels this +/// process drew, so the game behind the transparent parts is not in it and cannot be. That is the +/// whole reason a bug report may carry one at all, and why the dialog can promise that what is +/// previewed is what is sent. +/// +/// A header of its own rather than a member of `Overlay`, because the overlay produces one and +/// the report worker consumes one, and neither should have to know about the other. +struct Capture { + int w = 0, h = 0; + std::vector rgba; + + bool empty() const { return rgba.empty(); } +}; + +} // namespace ppc diff --git a/src/fonts.cpp b/src/fonts.cpp index aeb94a7..93a7a74 100644 --- a/src/fonts.cpp +++ b/src/fonts.cpp @@ -199,6 +199,40 @@ std::vector math_faces() { return out; } +/// The monospace face to read a capture in, most likely first — the first that exists wins, and +/// there is no merging: this face is only ever pushed over text the tool itself captured. +/// +/// Deliberately not a Fontin: a clipboard capture and a parse dump are read down the line, and the +/// whole reason to show them at all is that a reporter and a maintainer are looking at the same +/// characters in the same places. +std::vector mono_faces() { + std::vector out; +#ifdef _WIN32 + const char* root = SDL_getenv("SystemRoot"); + const std::string dir = std::string(root ? root : "C:\\Windows") + "\\Fonts\\"; + for (const char* f : {"consola.ttf", "cour.ttf", "lucon.ttf"}) + if (exists(dir + f)) { + out.push_back(dir + f); + break; + } +#else + for (const char* f : {"/usr/share/fonts/TTF/DejaVuSansMono.ttf", + "/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf", + "/usr/share/fonts/dejavu/DejaVuSansMono.ttf", + "/usr/share/fonts/noto/NotoSansMono-Regular.ttf", + "/usr/share/fonts/truetype/noto/NotoSansMono-Regular.ttf", + "/usr/share/fonts/liberation-mono/LiberationMono-Regular.ttf", + "/usr/share/fonts/truetype/liberation/LiberationMono-Regular.ttf", + "/usr/share/fonts/liberation/LiberationMono-Regular.ttf", + "/usr/share/fonts/gnu-free/FreeMono.ttf"}) + if (exists(f)) { + out.emplace_back(f); + break; + } +#endif + return out; +} + /// Mapped font files, kept for the life of the atlas. /// /// The files are **mapped, not read**: ImGui 1.92 rasterizes glyphs on demand and keeps the @@ -331,6 +365,15 @@ Fonts load_fonts(float size_px) { SDL_Log("no system face with non-Latin coverage; names may render as boxes"); f.unicode = f.regular; } + for (const std::string& p : mono_faces()) + if (const FontBytes b = map_font(p); b && (f.mono = add_face(b, size_px, false))) { + SDL_Log("captures render in %s", p.c_str()); + break; + } + if (!f.mono) { + SDL_Log("no system monospace face; captures render in Fontin, out of column"); + f.mono = f.regular; + } io.FontDefault = f.regular; return f; } diff --git a/src/fonts.hpp b/src/fonts.hpp index 7dfa877..d4abadb 100644 --- a/src/fonts.hpp +++ b/src/fonts.hpp @@ -19,6 +19,12 @@ struct Fonts { /// whatever the OS ships; falls back to `regular`, boxes and all, when it ships nothing. ImFont* unicode = nullptr; + /// For text that is **data rather than prose** — the clipboard capture and the parse dump a + /// bug report is a preview of. Those are read column-wise and compared line against line, and + /// Fontin is a proportional face with no figure alignment at all. Whatever monospace the OS + /// ships; falls back to `regular`, which still reads, just not in columns. + ImFont* mono = nullptr; + /// `≤` and `≥` will actually draw — see `kBorrowedGlyphs`. False when the OS shipped no /// face to borrow them from, and then whatever wanted them has to spell them "<=" and ">=": /// Fontin's own are blank, and a floor of 46 losing its `≥` reads as an exact match. diff --git a/src/glyph_data.inc b/src/glyph_data.inc index 53946c6..35f70b0 100644 --- a/src/glyph_data.inc +++ b/src/glyph_data.inc @@ -3,18 +3,18 @@ // Which codepoints: scripts/fetch-glyphs.sh. What they are called: src/ui/glyphs.hpp. // See assets/fonts/README.md for the license this is bundled under. -static const unsigned char ppc_glyphs_ttf[2056] = { +static const unsigned char ppc_glyphs_ttf[2852] = { 0x00,0x01,0x00,0x00,0x00,0x0a,0x00,0x80,0x00,0x03,0x00,0x20,0x4f,0x53,0x2f,0x32, - 0x51,0x55,0x59,0xf8,0x00,0x00,0x05,0x94,0x00,0x00,0x00,0x60,0x63,0x6d,0x61,0x70, - 0xe2,0xfe,0xcd,0x39,0x00,0x00,0x05,0xf4,0x00,0x00,0x00,0x5c,0x67,0x6c,0x79,0x66, - 0xd2,0x04,0xc5,0xf6,0x00,0x00,0x00,0xac,0x00,0x00,0x04,0x3e,0x68,0x65,0x61,0x64, - 0x2b,0xa9,0x1a,0xa3,0x00,0x00,0x05,0x1c,0x00,0x00,0x00,0x36,0x68,0x68,0x65,0x61, - 0x04,0x4d,0x02,0x32,0x00,0x00,0x05,0x70,0x00,0x00,0x00,0x24,0x68,0x6d,0x74,0x78, - 0x0c,0x81,0x00,0x0d,0x00,0x00,0x05,0x54,0x00,0x00,0x00,0x1c,0x6c,0x6f,0x63,0x61, - 0x03,0x92,0x04,0xf9,0x00,0x00,0x05,0x0c,0x00,0x00,0x00,0x10,0x6d,0x61,0x78,0x70, - 0x00,0x1d,0x07,0x84,0x00,0x00,0x04,0xec,0x00,0x00,0x00,0x20,0x6e,0x61,0x6d,0x65, - 0x1d,0x87,0x38,0x73,0x00,0x00,0x06,0x50,0x00,0x00,0x01,0x98,0x70,0x6f,0x73,0x74, - 0xff,0xde,0x00,0x19,0x00,0x00,0x07,0xe8,0x00,0x00,0x00,0x20,0x00,0x05,0x00,0x00, + 0x51,0x4b,0x59,0xf8,0x00,0x00,0x08,0x98,0x00,0x00,0x00,0x60,0x63,0x6d,0x61,0x70, + 0xc5,0xb6,0xbc,0xd8,0x00,0x00,0x08,0xf8,0x00,0x00,0x00,0x74,0x67,0x6c,0x79,0x66, + 0xe3,0x82,0x07,0xdd,0x00,0x00,0x00,0xac,0x00,0x00,0x07,0x2e,0x68,0x65,0x61,0x64, + 0x2b,0xa9,0x1a,0xa3,0x00,0x00,0x08,0x14,0x00,0x00,0x00,0x36,0x68,0x68,0x65,0x61, + 0x04,0x4d,0x02,0x35,0x00,0x00,0x08,0x74,0x00,0x00,0x00,0x24,0x68,0x6d,0x74,0x78, + 0x12,0x81,0x00,0x0d,0x00,0x00,0x08,0x4c,0x00,0x00,0x00,0x28,0x6c,0x6f,0x63,0x61, + 0x0a,0xa0,0x08,0xcf,0x00,0x00,0x07,0xfc,0x00,0x00,0x00,0x16,0x6d,0x61,0x78,0x70, + 0x00,0x20,0x07,0x84,0x00,0x00,0x07,0xdc,0x00,0x00,0x00,0x20,0x6e,0x61,0x6d,0x65, + 0x1d,0x87,0x38,0x73,0x00,0x00,0x09,0x6c,0x00,0x00,0x01,0x98,0x70,0x6f,0x73,0x74, + 0xff,0xde,0x00,0x19,0x00,0x00,0x0b,0x04,0x00,0x00,0x00,0x20,0x00,0x05,0x00,0x00, 0xff,0xc0,0x01,0x80,0x01,0xc0,0x00,0x06,0x00,0x0d,0x00,0x14,0x00,0x1b,0x00,0x35, 0x00,0x00,0x37,0x37,0x07,0x37,0x27,0x31,0x11,0x17,0x33,0x23,0x33,0x27,0x31,0x07, 0x37,0x17,0x27,0x17,0x11,0x31,0x07,0x37,0x23,0x33,0x23,0x17,0x31,0x37,0x25,0x36, @@ -24,35 +24,82 @@ static const unsigned char ppc_glyphs_ttf[2056] = { 0x01,0x0d,0x0e,0x14,0x01,0x20,0x14,0x0e,0x0d,0x01,0x01,0x0d,0x0e,0x14,0xfe,0xe0, 0x14,0x0e,0x0d,0x01,0x3a,0x86,0x86,0x86,0x86,0xfe,0xf4,0x3a,0x86,0x86,0xc0,0x86, 0x86,0x86,0x01,0x0c,0x86,0xc0,0x86,0x86,0x10,0x14,0x0e,0x0d,0x01,0x01,0x0d,0x0e, - 0x14,0xfe,0x60,0x14,0x0e,0x0d,0x01,0x01,0x0d,0x0e,0x14,0x01,0xa0,0x00,0x00,0x01, - 0x00,0x00,0x00,0x20,0x01,0xc0,0x01,0x60,0x00,0x1e,0x00,0x00,0x01,0x16,0x15,0x31, - 0x31,0x14,0x07,0x01,0x31,0x06,0x23,0x22,0x27,0x27,0x31,0x26,0x35,0x34,0x37,0x36, - 0x33,0x32,0x17,0x17,0x31,0x37,0x31,0x36,0x33,0x32,0x17,0x01,0xb7,0x09,0x09,0xff, - 0x00,0x0a,0x0d,0x0d,0x0a,0x80,0x09,0x09,0x0a,0x0d,0x0d,0x0a,0x69,0xe9,0x0a,0x0d, - 0x0d,0x0a,0x01,0x57,0x0a,0x0d,0x0d,0x0a,0xff,0x00,0x09,0x09,0x80,0x0a,0x0d,0x0d, - 0x0a,0x09,0x09,0x6a,0xea,0x09,0x09,0x00,0x00,0x01,0x00,0x10,0xff,0xd9,0x01,0xe7, - 0x01,0xa7,0x00,0x46,0x00,0x00,0x13,0x33,0x23,0x33,0x32,0x17,0x16,0x15,0x14,0x07, - 0x06,0x23,0x23,0x31,0x22,0x27,0x26,0x35,0x35,0x31,0x34,0x37,0x36,0x33,0x32,0x17, - 0x16,0x15,0x15,0x31,0x37,0x31,0x36,0x37,0x36,0x17,0x16,0x17,0x16,0x17,0x16,0x07, - 0x06,0x07,0x06,0x07,0x06,0x27,0x26,0x27,0x26,0x35,0x34,0x37,0x36,0x33,0x32,0x17, - 0x16,0x33,0x32,0x37,0x36,0x35,0x34,0x27,0x26,0x23,0x22,0x07,0x07,0x7e,0x32,0x32, - 0x32,0x0e,0x09,0x09,0x09,0x09,0x0e,0x80,0x0e,0x09,0x09,0x09,0x09,0x0e,0x0e,0x09, - 0x09,0x12,0x2c,0x39,0x39,0x39,0x39,0x2c,0x2c,0x0f,0x0e,0x0e,0x0f,0x2c,0x2c,0x39, - 0x39,0x39,0x39,0x2c,0x0a,0x0a,0x09,0x0d,0x0d,0x0a,0x31,0x40,0x40,0x31,0x2f,0x2f, - 0x31,0x40,0x40,0x31,0x11,0x01,0x20,0x09,0x09,0x0e,0x0e,0x09,0x09,0x09,0x09,0x0e, - 0x80,0x0e,0x09,0x09,0x09,0x09,0x0e,0x33,0x11,0x2c,0x0f,0x0e,0x0e,0x0f,0x2c,0x2c, - 0x39,0x39,0x39,0x39,0x2c,0x2c,0x0f,0x0e,0x0e,0x0f,0x2c,0x09,0x0d,0x0d,0x0a,0x09, - 0x09,0x2f,0x2f,0x31,0x40,0x40,0x31,0x2f,0x2f,0x11,0x00,0x02,0x00,0x00,0xff,0xe0, - 0x01,0xc0,0x01,0xa0,0x00,0x19,0x00,0x3b,0x00,0x00,0x13,0x06,0x07,0x31,0x31,0x06, - 0x07,0x11,0x31,0x16,0x17,0x16,0x17,0x21,0x31,0x36,0x37,0x36,0x37,0x11,0x31,0x26, - 0x27,0x26,0x27,0x21,0x13,0x35,0x15,0x35,0x23,0x31,0x26,0x27,0x36,0x37,0x33,0x31, - 0x35,0x31,0x36,0x37,0x16,0x17,0x15,0x31,0x33,0x31,0x16,0x17,0x06,0x07,0x23,0x31, - 0x15,0x31,0x06,0x07,0x26,0x27,0x40,0x1b,0x12,0x12,0x01,0x01,0x12,0x12,0x1b,0x01, - 0x40,0x1b,0x12,0x12,0x01,0x01,0x12,0x12,0x1b,0xfe,0xc0,0x88,0x40,0x16,0x02,0x02, - 0x16,0x40,0x02,0x16,0x16,0x02,0x40,0x16,0x02,0x02,0x16,0x40,0x02,0x16,0x16,0x02, - 0x01,0xa0,0x01,0x12,0x12,0x1b,0xfe,0xc0,0x1b,0x12,0x12,0x01,0x01,0x12,0x12,0x1b, - 0x01,0x40,0x1b,0x12,0x12,0x01,0xfe,0xc8,0x40,0x40,0x40,0x02,0x16,0x16,0x02,0x40, - 0x16,0x02,0x02,0x16,0x40,0x02,0x16,0x16,0x02,0x40,0x16,0x02,0x02,0x16,0x00,0x05, + 0x14,0xfe,0x60,0x14,0x0e,0x0d,0x01,0x01,0x0d,0x0e,0x14,0x01,0xa0,0x00,0x00,0x02, + 0x00,0x00,0xff,0xc0,0x02,0x00,0x01,0xc0,0x00,0x1c,0x00,0x37,0x00,0x00,0x25,0x06, + 0x07,0x17,0x31,0x16,0x15,0x14,0x07,0x06,0x23,0x22,0x27,0x27,0x31,0x06,0x07,0x26, + 0x27,0x26,0x27,0x36,0x37,0x36,0x37,0x16,0x17,0x16,0x17,0x07,0x32,0x37,0x31,0x31, + 0x36,0x37,0x36,0x35,0x34,0x27,0x26,0x27,0x26,0x23,0x22,0x07,0x06,0x07,0x06,0x15, + 0x14,0x17,0x16,0x17,0x16,0x33,0x01,0xa0,0x01,0x27,0x7f,0x09,0x09,0x0a,0x0d,0x0d, + 0x0a,0x7e,0x35,0x46,0x58,0x3b,0x3b,0x02,0x02,0x3b,0x3b,0x58,0x58,0x3b,0x3b,0x02, + 0xd0,0x27,0x21,0x21,0x14,0x13,0x13,0x14,0x21,0x21,0x27,0x27,0x21,0x21,0x14,0x13, + 0x13,0x14,0x21,0x21,0x27,0xf0,0x46,0x35,0x7e,0x0a,0x0d,0x0d,0x0a,0x09,0x09,0x7f, + 0x27,0x01,0x02,0x3b,0x3b,0x58,0x58,0x3b,0x3b,0x02,0x02,0x3b,0x3b,0x58,0x90,0x13, + 0x13,0x22,0x22,0x26,0x26,0x22,0x22,0x13,0x13,0x13,0x13,0x22,0x22,0x26,0x26,0x22, + 0x22,0x13,0x13,0x00,0x00,0x01,0x00,0x00,0x00,0x20,0x01,0xc0,0x01,0x60,0x00,0x1e, + 0x00,0x00,0x01,0x16,0x15,0x31,0x31,0x14,0x07,0x01,0x31,0x06,0x23,0x22,0x27,0x27, + 0x31,0x26,0x35,0x34,0x37,0x36,0x33,0x32,0x17,0x17,0x31,0x37,0x31,0x36,0x33,0x32, + 0x17,0x01,0xb7,0x09,0x09,0xff,0x00,0x0a,0x0d,0x0d,0x0a,0x80,0x09,0x09,0x0a,0x0d, + 0x0d,0x0a,0x69,0xe9,0x0a,0x0d,0x0d,0x0a,0x01,0x57,0x0a,0x0d,0x0d,0x0a,0xff,0x00, + 0x09,0x09,0x80,0x0a,0x0d,0x0d,0x0a,0x09,0x09,0x6a,0xea,0x09,0x09,0x00,0x00,0x02, + 0x00,0x00,0xff,0xc0,0x02,0x00,0x01,0xc0,0x00,0x29,0x00,0x5d,0x00,0x00,0x01,0x22, + 0x07,0x31,0x31,0x06,0x15,0x14,0x17,0x16,0x33,0x33,0x31,0x07,0x31,0x06,0x15,0x14, + 0x17,0x16,0x33,0x32,0x37,0x37,0x31,0x15,0x31,0x14,0x17,0x16,0x33,0x32,0x37,0x36, + 0x35,0x35,0x31,0x34,0x27,0x26,0x23,0x23,0x07,0x06,0x07,0x31,0x31,0x06,0x07,0x11, + 0x31,0x16,0x17,0x16,0x17,0x21,0x31,0x36,0x37,0x36,0x37,0x35,0x31,0x34,0x27,0x26, + 0x23,0x22,0x07,0x06,0x15,0x15,0x31,0x06,0x07,0x21,0x31,0x26,0x27,0x11,0x31,0x36, + 0x37,0x33,0x31,0x32,0x37,0x36,0x35,0x34,0x27,0x26,0x23,0x23,0x01,0x40,0x0e,0x09, + 0x09,0x09,0x09,0x0e,0x53,0xca,0x09,0x09,0x0a,0x0d,0x0d,0x0a,0xc9,0x09,0x09,0x0e, + 0x0e,0x09,0x09,0x09,0x09,0x0e,0xa0,0xf0,0x22,0x17,0x16,0x01,0x01,0x16,0x17,0x22, + 0x01,0x40,0x22,0x17,0x16,0x01,0x09,0x09,0x0e,0x0e,0x09,0x09,0x01,0x0f,0xfe,0xc0, + 0x0f,0x01,0x01,0x0f,0x70,0x0e,0x09,0x09,0x09,0x09,0x0e,0x70,0x01,0xc0,0x09,0x09, + 0x0e,0x0e,0x09,0x09,0xc9,0x0a,0x0d,0x0d,0x0a,0x09,0x09,0xca,0x53,0x0e,0x09,0x09, + 0x09,0x09,0x0e,0xa0,0x0e,0x09,0x09,0x20,0x01,0x16,0x17,0x22,0xfe,0xc0,0x22,0x17, + 0x16,0x01,0x01,0x16,0x17,0x22,0x70,0x0e,0x09,0x09,0x09,0x09,0x0e,0x70,0x0f,0x01, + 0x01,0x0f,0x01,0x40,0x0f,0x01,0x09,0x09,0x0e,0x0e,0x09,0x09,0x00,0x01,0x00,0x10, + 0xff,0xd9,0x01,0xe7,0x01,0xa7,0x00,0x46,0x00,0x00,0x13,0x33,0x23,0x33,0x32,0x17, + 0x16,0x15,0x14,0x07,0x06,0x23,0x23,0x31,0x22,0x27,0x26,0x35,0x35,0x31,0x34,0x37, + 0x36,0x33,0x32,0x17,0x16,0x15,0x15,0x31,0x37,0x31,0x36,0x37,0x36,0x17,0x16,0x17, + 0x16,0x17,0x16,0x07,0x06,0x07,0x06,0x07,0x06,0x27,0x26,0x27,0x26,0x35,0x34,0x37, + 0x36,0x33,0x32,0x17,0x16,0x33,0x32,0x37,0x36,0x35,0x34,0x27,0x26,0x23,0x22,0x07, + 0x07,0x7e,0x32,0x32,0x32,0x0e,0x09,0x09,0x09,0x09,0x0e,0x80,0x0e,0x09,0x09,0x09, + 0x09,0x0e,0x0e,0x09,0x09,0x12,0x2c,0x39,0x39,0x39,0x39,0x2c,0x2c,0x0f,0x0e,0x0e, + 0x0f,0x2c,0x2c,0x39,0x39,0x39,0x39,0x2c,0x0a,0x0a,0x09,0x0d,0x0d,0x0a,0x31,0x40, + 0x40,0x31,0x2f,0x2f,0x31,0x40,0x40,0x31,0x11,0x01,0x20,0x09,0x09,0x0e,0x0e,0x09, + 0x09,0x09,0x09,0x0e,0x80,0x0e,0x09,0x09,0x09,0x09,0x0e,0x33,0x11,0x2c,0x0f,0x0e, + 0x0e,0x0f,0x2c,0x2c,0x39,0x39,0x39,0x39,0x2c,0x2c,0x0f,0x0e,0x0e,0x0f,0x2c,0x09, + 0x0d,0x0d,0x0a,0x09,0x09,0x2f,0x2f,0x31,0x40,0x40,0x31,0x2f,0x2f,0x11,0x00,0x02, + 0x00,0x00,0xff,0xe0,0x01,0xc0,0x01,0xa0,0x00,0x19,0x00,0x3b,0x00,0x00,0x13,0x06, + 0x07,0x31,0x31,0x06,0x07,0x11,0x31,0x16,0x17,0x16,0x17,0x21,0x31,0x36,0x37,0x36, + 0x37,0x11,0x31,0x26,0x27,0x26,0x27,0x21,0x13,0x35,0x15,0x35,0x23,0x31,0x26,0x27, + 0x36,0x37,0x33,0x31,0x35,0x31,0x36,0x37,0x16,0x17,0x15,0x31,0x33,0x31,0x16,0x17, + 0x06,0x07,0x23,0x31,0x15,0x31,0x06,0x07,0x26,0x27,0x40,0x1b,0x12,0x12,0x01,0x01, + 0x12,0x12,0x1b,0x01,0x40,0x1b,0x12,0x12,0x01,0x01,0x12,0x12,0x1b,0xfe,0xc0,0x88, + 0x40,0x16,0x02,0x02,0x16,0x40,0x02,0x16,0x16,0x02,0x40,0x16,0x02,0x02,0x16,0x40, + 0x02,0x16,0x16,0x02,0x01,0xa0,0x01,0x12,0x12,0x1b,0xfe,0xc0,0x1b,0x12,0x12,0x01, + 0x01,0x12,0x12,0x1b,0x01,0x40,0x1b,0x12,0x12,0x01,0xfe,0xc8,0x40,0x40,0x40,0x02, + 0x16,0x16,0x02,0x40,0x16,0x02,0x02,0x16,0x40,0x02,0x16,0x16,0x02,0x40,0x16,0x02, + 0x02,0x16,0x00,0x02,0x00,0x00,0xff,0xc0,0x02,0x00,0x01,0xc0,0x00,0x18,0x00,0x8b, + 0x00,0x00,0x01,0x16,0x17,0x31,0x31,0x16,0x17,0x15,0x31,0x14,0x07,0x06,0x23,0x23, + 0x31,0x22,0x27,0x26,0x35,0x35,0x31,0x36,0x37,0x36,0x37,0x07,0x36,0x33,0x31,0x31, + 0x32,0x17,0x17,0x31,0x16,0x17,0x30,0x15,0x36,0x33,0x33,0x31,0x32,0x17,0x34,0x37, + 0x37,0x31,0x36,0x33,0x32,0x17,0x16,0x15,0x14,0x07,0x07,0x31,0x06,0x07,0x16,0x17, + 0x33,0x31,0x32,0x17,0x16,0x15,0x14,0x07,0x06,0x23,0x23,0x31,0x14,0x07,0x16,0x17, + 0x17,0x31,0x16,0x15,0x14,0x07,0x06,0x23,0x22,0x27,0x27,0x31,0x06,0x07,0x35,0x31, + 0x26,0x27,0x06,0x07,0x15,0x31,0x26,0x27,0x07,0x31,0x06,0x23,0x22,0x27,0x26,0x35, + 0x34,0x37,0x37,0x31,0x36,0x37,0x26,0x35,0x23,0x31,0x22,0x27,0x26,0x35,0x34,0x37, + 0x36,0x33,0x33,0x31,0x36,0x37,0x26,0x27,0x27,0x31,0x26,0x35,0x34,0x37,0x01,0x00, + 0x29,0x1b,0x1b,0x01,0x08,0x08,0x0c,0x87,0x0d,0x08,0x08,0x01,0x1b,0x1b,0x29,0xd7, + 0x0a,0x0d,0x0d,0x0a,0x40,0x01,0x01,0x15,0x1a,0x70,0x1a,0x16,0x01,0x40,0x0a,0x0d, + 0x0d,0x0a,0x09,0x09,0x40,0x01,0x01,0x09,0x02,0x40,0x0e,0x09,0x09,0x09,0x09,0x0e, + 0x40,0x0f,0x03,0x03,0x40,0x09,0x09,0x0a,0x0d,0x0d,0x0a,0x3f,0x25,0x35,0x01,0x0f, + 0x0f,0x01,0x35,0x25,0x3f,0x0a,0x0d,0x0d,0x0a,0x09,0x09,0x40,0x03,0x03,0x0f,0x40, + 0x0e,0x09,0x09,0x09,0x09,0x0e,0x40,0x02,0x09,0x01,0x01,0x40,0x09,0x09,0x01,0xc0, + 0x01,0x1b,0x1b,0x29,0x04,0x0c,0x08,0x08,0x08,0x08,0x0c,0x04,0x29,0x1b,0x1b,0x01, + 0x69,0x09,0x09,0x40,0x01,0x01,0x01,0x0c,0x0c,0x02,0x01,0x40,0x09,0x09,0x0a,0x0d, + 0x0d,0x0a,0x40,0x01,0x01,0x12,0x15,0x09,0x09,0x0e,0x0e,0x09,0x09,0x25,0x20,0x02, + 0x02,0x40,0x0a,0x0d,0x0d,0x0a,0x09,0x09,0x3f,0x21,0x06,0xef,0x0f,0x01,0x01,0x0f, + 0xef,0x06,0x21,0x3f,0x09,0x09,0x0a,0x0d,0x0d,0x0a,0x40,0x02,0x02,0x20,0x25,0x09, + 0x09,0x0e,0x0e,0x09,0x09,0x15,0x12,0x01,0x01,0x40,0x0a,0x0d,0x0d,0x0a,0x00,0x05, 0x00,0x00,0xff,0xc0,0x01,0xc0,0x01,0xc0,0x00,0x1f,0x00,0x30,0x00,0x3d,0x00,0x4a, 0x00,0x57,0x00,0x00,0x13,0x36,0x37,0x33,0x31,0x16,0x17,0x17,0x31,0x33,0x31,0x32, 0x17,0x16,0x15,0x14,0x07,0x06,0x23,0x21,0x31,0x22,0x27,0x26,0x35,0x34,0x37,0x36, @@ -83,54 +130,57 @@ static const unsigned char ppc_glyphs_ttf[2056] = { 0x09,0x09,0x09,0x09,0x0e,0x01,0x80,0x0e,0x09,0x09,0x09,0x09,0x0e,0xfe,0x80,0xa0, 0x09,0x09,0x0e,0x0e,0x09,0x09,0x09,0x09,0x0e,0x0e,0x09,0x09,0x80,0x09,0x09,0x0e, 0x0e,0x09,0x09,0x09,0x09,0x0e,0x0e,0x09,0x09,0x00,0x00,0x00,0x00,0x01,0x00,0x00, - 0x00,0x07,0x07,0x83,0x00,0x15,0x00,0x00,0x00,0x00,0x00,0x01,0x00,0x00,0x00,0x00, + 0x00,0x0a,0x07,0x83,0x00,0x15,0x00,0x00,0x00,0x00,0x00,0x01,0x00,0x00,0x00,0x00, 0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x51, - 0x00,0x7e,0x00,0xdf,0x01,0x31,0x01,0xaa,0x01,0xe3,0x02,0x1f,0x00,0x01,0x00,0x00, - 0x03,0x07,0x05,0x00,0xdd,0xcf,0xd3,0x6b,0x5f,0x0f,0x3c,0xf5,0x00,0x0b,0x02,0x00, - 0x00,0x00,0x00,0x00,0xe3,0x82,0x6a,0x93,0x00,0x00,0x00,0x00,0xe3,0x82,0x6a,0x93, - 0xff,0xf4,0xff,0xb5,0x02,0x8b,0x01,0xcb,0x00,0x00,0x00,0x08,0x00,0x02,0x00,0x00, - 0x00,0x00,0x00,0x00,0x01,0x80,0x00,0x00,0x01,0xc0,0x00,0x00,0x02,0x00,0x00,0x10, - 0x01,0xc0,0x00,0x00,0x01,0xc0,0x00,0x00,0x02,0x00,0xff,0xfd,0x01,0xc0,0x00,0x00, - 0x00,0x01,0x00,0x00,0x01,0xcb,0xff,0xb5,0x00,0x00,0x02,0x80,0xff,0xf4,0xff,0xf5, - 0x02,0x8b,0x00,0x01,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, - 0x00,0x00,0x00,0x07,0x00,0x04,0x02,0x03,0x03,0x84,0x00,0x05,0x00,0x00,0x01,0x4c, - 0x01,0x66,0x00,0x00,0x00,0x47,0x01,0x4c,0x01,0x66,0x00,0x00,0x00,0xf5,0x00,0x19, - 0x00,0x84,0x00,0x00,0x02,0x00,0x09,0x03,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, - 0x00,0x00,0x10,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x41,0x57, - 0x53,0x4d,0x00,0x80,0xf0,0x0c,0xf7,0xa4,0x01,0xcb,0xff,0xb5,0x00,0x00,0x01,0xcb, - 0x00,0x4b,0x00,0x00,0x00,0x01,0x00,0x00,0x00,0x00,0x01,0x41,0x01,0xaf,0x00,0x00, - 0x00,0x20,0x00,0x00,0x00,0x00,0x00,0x02,0x00,0x00,0x00,0x03,0x00,0x00,0x00,0x14, - 0x00,0x03,0x00,0x01,0x00,0x00,0x00,0x14,0x00,0x04,0x00,0x48,0x00,0x00,0x00,0x0e, - 0x00,0x08,0x00,0x02,0x00,0x06,0xf0,0x0c,0xf0,0xe2,0xf0,0xfe,0xf2,0xed,0xf3,0x04, - 0xf7,0xa4,0xff,0xff,0x00,0x00,0xf0,0x0c,0xf0,0xe2,0xf0,0xfe,0xf2,0xed,0xf3,0x04, - 0xf7,0xa4,0xff,0xff,0x0f,0xf5,0x0f,0x20,0x0f,0x05,0x0d,0x17,0x0d,0x01,0x08,0x62, - 0x00,0x01,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, - 0x00,0x00,0x00,0x07,0x00,0x5a,0x00,0x03,0x00,0x01,0x04,0x09,0x00,0x00,0x00,0x34, - 0x00,0x00,0x00,0x03,0x00,0x01,0x04,0x09,0x00,0x01,0x00,0x32,0x00,0x34,0x00,0x03, - 0x00,0x01,0x04,0x09,0x00,0x02,0x00,0x0a,0x00,0x66,0x00,0x03,0x00,0x01,0x04,0x09, - 0x00,0x03,0x00,0x3e,0x00,0x70,0x00,0x03,0x00,0x01,0x04,0x09,0x00,0x04,0x00,0x32, - 0x00,0x34,0x00,0x03,0x00,0x01,0x04,0x09,0x00,0x05,0x00,0x64,0x00,0xae,0x00,0x03, - 0x00,0x01,0x04,0x09,0x00,0x06,0x00,0x2c,0x01,0x12,0x00,0x43,0x00,0x6f,0x00,0x70, - 0x00,0x79,0x00,0x72,0x00,0x69,0x00,0x67,0x00,0x68,0x00,0x74,0x00,0x20,0x00,0x28, - 0x00,0x63,0x00,0x29,0x00,0x20,0x00,0x46,0x00,0x6f,0x00,0x6e,0x00,0x74,0x00,0x20, - 0x00,0x41,0x00,0x77,0x00,0x65,0x00,0x73,0x00,0x6f,0x00,0x6d,0x00,0x65,0x00,0x46, - 0x00,0x6f,0x00,0x6e,0x00,0x74,0x00,0x20,0x00,0x41,0x00,0x77,0x00,0x65,0x00,0x73, - 0x00,0x6f,0x00,0x6d,0x00,0x65,0x00,0x20,0x00,0x36,0x00,0x20,0x00,0x46,0x00,0x72, - 0x00,0x65,0x00,0x65,0x00,0x20,0x00,0x53,0x00,0x6f,0x00,0x6c,0x00,0x69,0x00,0x64, - 0x00,0x53,0x00,0x6f,0x00,0x6c,0x00,0x69,0x00,0x64,0x00,0x46,0x00,0x6f,0x00,0x6e, + 0x00,0xa4,0x00,0xd1,0x01,0x48,0x01,0xa9,0x01,0xfb,0x02,0xa9,0x03,0x22,0x03,0x5b, + 0x03,0x97,0x00,0x00,0x00,0x01,0x00,0x00,0x03,0x07,0x05,0x00,0xdb,0x57,0x4a,0x7f, + 0x5f,0x0f,0x3c,0xf5,0x00,0x0b,0x02,0x00,0x00,0x00,0x00,0x00,0xe3,0x82,0x6a,0x93, + 0x00,0x00,0x00,0x00,0xe3,0x82,0x6a,0x93,0xff,0xf4,0xff,0xb5,0x02,0x8b,0x01,0xcb, + 0x00,0x00,0x00,0x08,0x00,0x02,0x00,0x00,0x00,0x00,0x00,0x00,0x01,0x80,0x00,0x00, + 0x02,0x00,0x00,0x00,0x01,0xc0,0x00,0x00,0x02,0x00,0x00,0x00,0x02,0x00,0x00,0x10, + 0x01,0xc0,0x00,0x00,0x02,0x00,0x00,0x00,0x01,0xc0,0x00,0x00,0x02,0x00,0xff,0xfd, + 0x01,0xc0,0x00,0x00,0x00,0x01,0x00,0x00,0x01,0xcb,0xff,0xb5,0x00,0x00,0x02,0x80, + 0xff,0xf4,0xff,0xf5,0x02,0x8b,0x00,0x01,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, + 0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x0a,0x00,0x04,0x02,0x03,0x03,0x84,0x00,0x05, + 0x00,0x00,0x01,0x4c,0x01,0x66,0x00,0x00,0x00,0x47,0x01,0x4c,0x01,0x66,0x00,0x00, + 0x00,0xf5,0x00,0x19,0x00,0x84,0x00,0x00,0x02,0x00,0x09,0x03,0x00,0x00,0x00,0x00, + 0x00,0x00,0x00,0x00,0x00,0x00,0x10,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, + 0x00,0x00,0x41,0x57,0x53,0x4d,0x00,0x80,0xf0,0x02,0xf7,0xa4,0x01,0xcb,0xff,0xb5, + 0x00,0x00,0x01,0xcb,0x00,0x4b,0x00,0x00,0x00,0x01,0x00,0x00,0x00,0x00,0x01,0x41, + 0x01,0xaf,0x00,0x00,0x00,0x20,0x00,0x00,0x00,0x00,0x00,0x02,0x00,0x00,0x00,0x03, + 0x00,0x00,0x00,0x14,0x00,0x03,0x00,0x01,0x00,0x00,0x00,0x14,0x00,0x04,0x00,0x60, + 0x00,0x00,0x00,0x14,0x00,0x10,0x00,0x03,0x00,0x04,0xf0,0x02,0xf0,0x0c,0xf0,0x8e, + 0xf0,0xe2,0xf0,0xfe,0xf1,0x88,0xf2,0xed,0xf3,0x04,0xf7,0xa4,0xff,0xff,0x00,0x00, + 0xf0,0x02,0xf0,0x0c,0xf0,0x8e,0xf0,0xe2,0xf0,0xfe,0xf1,0x88,0xf2,0xed,0xf3,0x04, + 0xf7,0xa4,0xff,0xff,0x0f,0xff,0x0f,0xf6,0x0f,0x75,0x0f,0x22,0x0f,0x07,0x0e,0x7e, + 0x0d,0x1a,0x0d,0x04,0x08,0x65,0x00,0x01,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, + 0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x07, + 0x00,0x5a,0x00,0x03,0x00,0x01,0x04,0x09,0x00,0x00,0x00,0x34,0x00,0x00,0x00,0x03, + 0x00,0x01,0x04,0x09,0x00,0x01,0x00,0x32,0x00,0x34,0x00,0x03,0x00,0x01,0x04,0x09, + 0x00,0x02,0x00,0x0a,0x00,0x66,0x00,0x03,0x00,0x01,0x04,0x09,0x00,0x03,0x00,0x3e, + 0x00,0x70,0x00,0x03,0x00,0x01,0x04,0x09,0x00,0x04,0x00,0x32,0x00,0x34,0x00,0x03, + 0x00,0x01,0x04,0x09,0x00,0x05,0x00,0x64,0x00,0xae,0x00,0x03,0x00,0x01,0x04,0x09, + 0x00,0x06,0x00,0x2c,0x01,0x12,0x00,0x43,0x00,0x6f,0x00,0x70,0x00,0x79,0x00,0x72, + 0x00,0x69,0x00,0x67,0x00,0x68,0x00,0x74,0x00,0x20,0x00,0x28,0x00,0x63,0x00,0x29, + 0x00,0x20,0x00,0x46,0x00,0x6f,0x00,0x6e,0x00,0x74,0x00,0x20,0x00,0x41,0x00,0x77, + 0x00,0x65,0x00,0x73,0x00,0x6f,0x00,0x6d,0x00,0x65,0x00,0x46,0x00,0x6f,0x00,0x6e, 0x00,0x74,0x00,0x20,0x00,0x41,0x00,0x77,0x00,0x65,0x00,0x73,0x00,0x6f,0x00,0x6d, 0x00,0x65,0x00,0x20,0x00,0x36,0x00,0x20,0x00,0x46,0x00,0x72,0x00,0x65,0x00,0x65, - 0x00,0x20,0x00,0x53,0x00,0x6f,0x00,0x6c,0x00,0x69,0x00,0x64,0x00,0x2d,0x00,0x36, - 0x00,0x2e,0x00,0x37,0x00,0x2e,0x00,0x32,0x00,0x56,0x00,0x65,0x00,0x72,0x00,0x73, - 0x00,0x69,0x00,0x6f,0x00,0x6e,0x00,0x20,0x00,0x37,0x00,0x37,0x00,0x35,0x00,0x2e, - 0x00,0x30,0x00,0x31,0x00,0x39,0x00,0x35,0x00,0x33,0x00,0x31,0x00,0x32,0x00,0x35, - 0x00,0x20,0x00,0x28,0x00,0x46,0x00,0x6f,0x00,0x6e,0x00,0x74,0x00,0x20,0x00,0x41, - 0x00,0x77,0x00,0x65,0x00,0x73,0x00,0x6f,0x00,0x6d,0x00,0x65,0x00,0x20,0x00,0x76, - 0x00,0x65,0x00,0x72,0x00,0x73,0x00,0x69,0x00,0x6f,0x00,0x6e,0x00,0x3a,0x00,0x20, - 0x00,0x36,0x00,0x2e,0x00,0x37,0x00,0x2e,0x00,0x32,0x00,0x29,0x00,0x46,0x00,0x6f, - 0x00,0x6e,0x00,0x74,0x00,0x41,0x00,0x77,0x00,0x65,0x00,0x73,0x00,0x6f,0x00,0x6d, - 0x00,0x65,0x00,0x36,0x00,0x46,0x00,0x72,0x00,0x65,0x00,0x65,0x00,0x2d,0x00,0x53, - 0x00,0x6f,0x00,0x6c,0x00,0x69,0x00,0x64,0x00,0x03,0x00,0x00,0x00,0x00,0x00,0x00, - 0xff,0xdb,0x00,0x19,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, - 0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, + 0x00,0x20,0x00,0x53,0x00,0x6f,0x00,0x6c,0x00,0x69,0x00,0x64,0x00,0x53,0x00,0x6f, + 0x00,0x6c,0x00,0x69,0x00,0x64,0x00,0x46,0x00,0x6f,0x00,0x6e,0x00,0x74,0x00,0x20, + 0x00,0x41,0x00,0x77,0x00,0x65,0x00,0x73,0x00,0x6f,0x00,0x6d,0x00,0x65,0x00,0x20, + 0x00,0x36,0x00,0x20,0x00,0x46,0x00,0x72,0x00,0x65,0x00,0x65,0x00,0x20,0x00,0x53, + 0x00,0x6f,0x00,0x6c,0x00,0x69,0x00,0x64,0x00,0x2d,0x00,0x36,0x00,0x2e,0x00,0x37, + 0x00,0x2e,0x00,0x32,0x00,0x56,0x00,0x65,0x00,0x72,0x00,0x73,0x00,0x69,0x00,0x6f, + 0x00,0x6e,0x00,0x20,0x00,0x37,0x00,0x37,0x00,0x35,0x00,0x2e,0x00,0x30,0x00,0x31, + 0x00,0x39,0x00,0x35,0x00,0x33,0x00,0x31,0x00,0x32,0x00,0x35,0x00,0x20,0x00,0x28, + 0x00,0x46,0x00,0x6f,0x00,0x6e,0x00,0x74,0x00,0x20,0x00,0x41,0x00,0x77,0x00,0x65, + 0x00,0x73,0x00,0x6f,0x00,0x6d,0x00,0x65,0x00,0x20,0x00,0x76,0x00,0x65,0x00,0x72, + 0x00,0x73,0x00,0x69,0x00,0x6f,0x00,0x6e,0x00,0x3a,0x00,0x20,0x00,0x36,0x00,0x2e, + 0x00,0x37,0x00,0x2e,0x00,0x32,0x00,0x29,0x00,0x46,0x00,0x6f,0x00,0x6e,0x00,0x74, + 0x00,0x41,0x00,0x77,0x00,0x65,0x00,0x73,0x00,0x6f,0x00,0x6d,0x00,0x65,0x00,0x36, + 0x00,0x46,0x00,0x72,0x00,0x65,0x00,0x65,0x00,0x2d,0x00,0x53,0x00,0x6f,0x00,0x6c, + 0x00,0x69,0x00,0x64,0x00,0x03,0x00,0x00,0x00,0x00,0x00,0x00,0xff,0xdb,0x00,0x19, + 0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, + 0x00,0x00,0x00,0x00, }; diff --git a/src/overlay.cpp b/src/overlay.cpp index 03700ac..dbca55c 100644 --- a/src/overlay.cpp +++ b/src/overlay.cpp @@ -1,6 +1,9 @@ #include "overlay.hpp" +#include #include +#include +#include #include #include @@ -106,9 +109,73 @@ void Overlay::end_frame() { glClearColor(0.0f, 0.0f, 0.0f, 0.0f); // transparent; ImGui paints the opaque panels glClear(GL_COLOR_BUFFER_BIT); ImGui_ImplOpenGL3_RenderDrawData(ImGui::GetDrawData()); + // Before the swap, not after: the back buffer is what was just drawn, and what a swap + // leaves in it is undefined. + if (want_capture_) { + want_capture_ = false; + read_back(w, h); + } SDL_GL_SwapWindow(window_); } +/// The drawn frame, off the back buffer and turned the right way up. +/// +/// **Un-premultiplied on the way out.** ImGui blends `GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA` onto +/// a framebuffer cleared to transparent black, so what accumulates is colour already multiplied +/// by its own coverage — correct for a compositor, and a shade too dark for anything that reads +/// the file as ordinary straight-alpha RGBA, which is every PNG viewer and Discord. Dividing it +/// back out is what makes the attachment look like the panel did. +void Overlay::read_back(int w, int h) { + if (w <= 0 || h <= 0) return; + Capture c; + c.w = w; + c.h = h; + c.rgba.resize(static_cast(w) * static_cast(h) * 4); + glPixelStorei(GL_PACK_ALIGNMENT, 1); + glReadPixels(0, 0, w, h, GL_RGBA, GL_UNSIGNED_BYTE, c.rgba.data()); + + // GL's origin is the bottom-left and every consumer of this expects the top-left, so the + // rows come back in the wrong order and are swapped in place. + const size_t stride = static_cast(w) * 4; + std::vector line(stride); + for (int y = 0; y < h / 2; ++y) { + uint8_t* top = c.rgba.data() + static_cast(y) * stride; + uint8_t* bottom = c.rgba.data() + static_cast(h - 1 - y) * stride; + std::copy_n(top, stride, line.data()); + std::copy_n(bottom, stride, top); + std::copy_n(line.data(), stride, bottom); + } + for (size_t i = 0; i + 3 < c.rgba.size(); i += 4) { + const unsigned a = c.rgba[i + 3]; + if (a == 0 || a == 255) continue; + for (int k = 0; k < 3; ++k) + c.rgba[i + k] = static_cast(std::min(255u, c.rgba[i + k] * 255u / a)); + } + capture_ = std::move(c); +} + +uint64_t Overlay::upload_texture(const Capture& c) { + if (c.empty()) return 0; + GLuint tex = 0; + glGenTextures(1, &tex); + if (!tex) return 0; + glBindTexture(GL_TEXTURE_2D, tex); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); + glPixelStorei(GL_UNPACK_ALIGNMENT, 1); + glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, c.w, c.h, 0, GL_RGBA, GL_UNSIGNED_BYTE, + c.rgba.data()); + return tex; +} + +void Overlay::free_texture(uint64_t tex) { + if (!tex) return; + const auto id = static_cast(tex); + glDeleteTextures(1, &id); +} + void Overlay::set_visible(bool v) { if (v) SDL_ShowWindow(window_); diff --git a/src/overlay.hpp b/src/overlay.hpp index 00c577c..5156143 100644 --- a/src/overlay.hpp +++ b/src/overlay.hpp @@ -1,5 +1,9 @@ #pragma once +#include +#include + +#include "capture.hpp" #include "fonts.hpp" struct SDL_Window; @@ -25,12 +29,31 @@ class Overlay { SDL_Window* window() const { return window_; } const Fonts& fonts() const { return fonts_; } + /// Read this window back at the end of the frame being drawn now. + /// + /// Asked for **during** a frame and answered at the end of it, rather than taken on the spot: + /// the caller is a button, and a button is pressed halfway through the drawing of the very + /// panel that is worth capturing. Anything that resizes or repoints the window has to wait + /// for the same moment, or the read-back and the draw data disagree about how big it is. + void request_capture() { want_capture_ = true; } + /// The pixels the last request produced, moved out. Empty until then, and again after. + Capture take_capture() { return std::exchange(capture_, {}); } + + /// An RGBA image as a texture ImGui can draw, and its release. Here rather than on the + /// caller because the GL context is this class's; `IconCache` owns the same pair for the + /// pictures it downloads. 0 is the failure, and is safe to pass back to `free_texture`. + uint64_t upload_texture(const Capture& c); + void free_texture(uint64_t tex); + private: void sync_held_mouse(); + void read_back(int w, int h); SDL_Window* window_ = nullptr; void* gl_ = nullptr; // SDL_GLContext bool visible_ = false; + bool want_capture_ = false; + Capture capture_; Fonts fonts_; }; diff --git a/src/report/report.cpp b/src/report/report.cpp new file mode 100644 index 0000000..8102bbb --- /dev/null +++ b/src/report/report.cpp @@ -0,0 +1,264 @@ +#include "report/report.hpp" + +#include +#include +#include +#include + +#include + +#include "data/types.hpp" +#include "util/base64.hpp" + +namespace ppc::report { +namespace { + +/// The deployed relay. Public on purpose: it is an endpoint, not a credential — see the header. +constexpr const char* kRelay = "https://ppc-reports.jirpos.workers.dev/report"; + +std::string num(double v, int dp = 2) { + char buf[48]; + std::snprintf(buf, sizeof buf, "%.*f", dp, v); + std::string s = buf; + // Trailing zeros on a number nobody asked to be precise are noise in a column of them. + if (s.find('.') != std::string::npos) { + s.erase(s.find_last_not_of('0') + 1); + if (!s.empty() && s.back() == '.') s.pop_back(); + } + return s; +} + +std::string opt_num(const std::optional& v, int dp = 2) { + return v ? num(*v, dp) : std::string("-"); +} + +std::string opt_int(const std::optional& v) { + return v ? std::to_string(*v) : std::string("-"); +} + +const char* yn(bool b) { return b ? "yes" : "no"; } + +/// An interval as a filter states it: `[77,90]`, `[77,]`, `[,90]`, `[]`. +std::string interval(const std::optional& lo, const std::optional& hi, int dp) { + std::string s = "["; + if (lo) s += num(*lo, dp); + s += ","; + if (hi) s += num(*hi, dp); + return s + "]"; +} + +void heading(std::string& out, const char* title) { + if (!out.empty()) out += "\n"; + out += "== "; + out += title; + out += " ==\n"; +} + +void describe_item(std::string& out, const item::Item& it) { + heading(out, "Item"); + out += "class: " + (it.item_class.empty() ? std::string("-") : it.item_class) + "\n"; + out += "rarity: " + std::string(item::to_string(it.rarity)) + "\n"; + if (!it.name.empty()) out += "name: " + it.name + "\n"; + out += "base line: " + it.base_type + "\n"; + // The two differ exactly when a magic item's affixes were stripped off the base line, which + // is one of the things a "priced the wrong item" report is usually about. + if (it.base_name != it.base_type) out += "base name: " + it.base_name + "\n"; + if (!it.type_line.empty()) out += "type line: " + it.type_line + "\n"; + out += std::string("identified: ") + yn(it.identified) + " corrupted: " + yn(it.corrupted) + + " mirrored: " + yn(it.mirrored) + " synthesised: " + yn(it.synthesised) + + " fractured: " + yn(it.fractured_item) + " foulborn: " + yn(it.foulborn) + "\n"; + out += "item level: " + opt_int(it.item_level) + " quality: " + opt_int(it.quality); + if (it.map_tier) out += " map tier: " + std::to_string(*it.map_tier); + if (it.gem_level) out += " gem level: " + std::to_string(*it.gem_level); + out += "\n"; + if (it.socket_count) + out += "sockets: " + it.sockets + " (" + std::to_string(it.socket_count) + " sockets, " + + std::to_string(it.link_count) + " links)\n"; + if (!it.influences.empty()) { + out += "influences:"; + for (const item::Influence i : it.influences) out += " " + std::string(item::to_string(i)); + out += "\n"; + } +} + +void describe_resolved(std::string& out, const item::Item& it) { + heading(out, "Resolved"); + if (it.base) + out += "base record: " + it.base->name + " [ref " + it.base->ref_name + "] category " + + (it.base->category.empty() ? "-" : it.base->category) + + (it.base->trade_disc.empty() ? "" : " disc " + it.base->trade_disc) + "\n"; + else + out += "base record: NONE — the bundle has no base type under this name\n"; + if (it.unique_entry) + out += "unique record: " + it.unique_entry->name + " on " + it.unique_entry->unique_base + + "\n"; + if (!it.unique_candidates.empty()) { + out += "unidentified, " + std::to_string(it.unique_candidates.size()) + + " unique(s) drop on this base:"; + for (const data::BaseType* u : it.unique_candidates) out += " " + u->name + ";"; + out += "\n"; + } +} + +void describe_properties(std::string& out, const item::Item& it) { + if (it.properties.empty()) return; + heading(out, "Properties"); + for (const item::Property& p : it.properties) + out += (p.label.empty() ? std::string("(prose)") : p.label) + ": " + p.value + + (p.augmented ? " (augmented)" : "") + (p.num ? " num " + num(*p.num) : "") + "\n"; +} + +/// One modifier and what it resolved to. **The interesting line is the one that says NO MATCH**: +/// a wording nothing in the bundle recognises is the single most common thing behind "this priced +/// wrong", and it is the reason the whole dump exists. +void describe_mods(std::string& out, const item::Item& it) { + heading(out, "Modifiers"); + if (it.mods.empty()) out += "(none)\n"; + for (size_t i = 0; i < it.mods.size(); ++i) { + const item::Modifier& m = it.mods[i]; + out += "[" + std::to_string(i) + "] " + std::string(data::trade_prefix(m.type)); + if (m.affix == item::Affix::Prefix) out += " prefix"; + else if (m.affix == item::Affix::Suffix) out += " suffix"; + if (!m.affix_name.empty()) out += " \"" + m.affix_name + "\""; + if (!m.generation.empty()) out += " gen \"" + m.generation + "\""; + if (m.tier) out += " tier " + std::to_string(m.tier); + if (m.rank) out += " rank " + std::to_string(m.rank); + if (!m.qualifier.empty()) out += " " + m.qualifier; + if (m.roll_incr != 0) out += " incr " + num(m.roll_incr) + "%"; + if (m.added_unique) out += " added-to-unique"; + if (!m.advanced) out += " (no info line)"; + if (m.continuation) out += " (continuation)"; + out += "\n"; + for (const std::string& line : m.lines) out += " | " + line + "\n"; + if (!m.match) { + out += " -> NO MATCH\n"; + continue; + } + const data::StatMatch& s = *m.match; + out += " -> stat \"" + (s.stat ? s.stat->ref : std::string("?")) + "\""; + if (s.stat) { + const std::vector& ids = s.stat->trade_ids(s.mod_type); + out += " id " + (ids.empty() ? std::string("NONE — matched but not searchable") + : ids.front()); + } + out += " value " + num(s.value, s.stat ? s.stat->dp : 2); + out += " bounds " + interval(s.min, s.max, s.stat ? s.stat->dp : 2); + if (s.lines_consumed != 1) out += " over " + std::to_string(s.lines_consumed) + " lines"; + if (s.negated) out += " negated"; + if (s.legacy) out += " legacy"; + if (s.unscalable) out += " unscalable"; + out += "\n"; + } +} + +void describe_derived(std::string& out, const item::Derived& d) { + heading(out, "Derived"); + out += "pdps " + opt_num(d.pdps) + " edps " + opt_num(d.edps) + " cdps " + opt_num(d.cdps) + + " dps " + opt_num(d.dps) + "\n"; + out += "at q20: pdps " + opt_num(d.pdps_q20) + " dps " + opt_num(d.dps_q20) + " ar " + + opt_int(d.armour_q20) + " ev " + opt_int(d.evasion_q20) + " es " + + opt_int(d.energy_shield_q20) + " ward " + opt_int(d.ward_q20) + "\n"; + out += "base percentile: " + opt_num(d.base_pct, 3) + "\n"; +} + +void describe_plan(std::string& out, const item::SearchPlan& plan) { + heading(out, "Search plan"); + out += "strategy: " + std::string(item::to_string(plan.strategy)) + "\n"; + out += "category: " + (plan.category.empty() ? "-" : plan.category) + + " name: " + (plan.name.empty() ? "-" : plan.name) + + " type: " + (plan.type.empty() ? "-" : plan.type) + " rarity: " + plan.rarity; + if (!plan.discriminator.empty()) out += " disc: " + plan.discriminator; + out += "\n"; + + for (const item::OptionFilter& f : plan.options) + out += std::string("option ") + (f.enabled ? "[x] " : "[ ] ") + f.key + "=" + f.option + + (f.shown ? " (shown)" : "") + "\n"; + for (const item::ChoiceGroup& g : plan.choices) + out += "choice: " + g.label + (g.note.empty() ? "" : " — " + g.note) + "\n"; + if (!plan.choices.empty()) out += "chosen: " + std::to_string(plan.choice) + "\n"; + for (const item::NumericFilter& f : plan.numerics) + out += std::string("numeric ") + (f.enabled ? "[x] " : "[ ] ") + f.key + " " + + interval(f.min, f.max, f.dp) + (f.hidden ? " hidden" : "") + " \"" + f.label + + "\"\n"; + for (const item::StatFilter& f : plan.stats) { + out += std::string("stat ") + (f.enabled ? "[x] " : "[ ] ") + + (f.id.empty() ? "(no id)" : f.id) + " " + interval(f.min, f.max, f.dp); + if (f.roll_min || f.roll_max) out += " of " + interval(f.roll_min, f.roll_max, f.dp); + if (f.negated) out += " absent"; + if (f.hidden) out += " hidden"; + if (f.pooled) out += " pooled"; + if (f.tiered) out += " tiered"; + if (f.inverted) out += " inverted"; + if (f.choice) out += " choice " + std::to_string(*f.choice); + if (!f.merged.empty()) out += " +" + std::to_string(f.merged.size()) + " merged"; + out += " \"" + f.text + "\"\n"; + } + for (const std::string& n : plan.notes) out += "note: " + n + "\n"; +} + +} // namespace + +std::string relay_url() { + if (const char* env = std::getenv("PPC_REPORT_URL"); env && *env) return env; + return kRelay; +} + +std::string describe(const item::Item& it, const item::Derived& d, const item::SearchPlan& plan) { + std::string out; + describe_item(out, it); + describe_resolved(out, it); + describe_properties(out, it); + describe_mods(out, it); + describe_derived(out, d); + describe_plan(out, plan); + if (!it.unparsed.empty()) { + heading(out, "Lines this tool did not understand"); + for (const std::string& l : it.unparsed) out += l + "\n"; + } + return out; +} + +std::string to_json(const Report& r) { + nlohmann::json meta = nlohmann::json::object(); + // Only the fields that have something to say: the relay caps each at 64 characters and an + // empty one is noise in a Discord embed. + if (!r.meta.version.empty()) meta["version"] = r.meta.version; + if (!r.meta.os.empty()) meta["os"] = r.meta.os; + if (!r.meta.league.empty()) meta["league"] = r.meta.league; + if (!r.meta.bundle.empty()) meta["bundle"] = r.meta.bundle; + + nlohmann::json j = nlohmann::json::object(); + j["item"] = r.item; + j["parse"] = r.parse; + if (!r.comment.empty()) j["comment"] = r.comment; + if (!meta.empty()) j["meta"] = meta; + if (!r.png.empty()) j["screenshot_png_b64"] = base64_encode(r.png); + return j.dump(); +} + +Outcome read_response(long status, const std::string& body, const std::string& transport) { + Outcome o; + if (!transport.empty()) { + o.error = "Could not reach the report relay: " + transport; + return o; + } + // The relay states its own reason for every refusal, and it is a better message than any + // status-code table here could be. A body that is not the JSON it promised is the only case + // this has to invent words for. + std::string reason; + std::string id; + if (const nlohmann::json j = nlohmann::json::parse(body, nullptr, false); j.is_object()) { + if (const auto e = j.find("error"); e != j.end() && e->is_string()) reason = e->get(); + if (const auto i = j.find("id"); i != j.end() && i->is_string()) id = i->get(); + } + if (status >= 200 && status < 300 && !id.empty()) { + o.ok = true; + o.id = id; + return o; + } + o.error = !reason.empty() ? reason + : "The relay answered " + std::to_string(status) + " and said nothing"; + return o; +} + +} // namespace ppc::report diff --git a/src/report/report.hpp b/src/report/report.hpp new file mode 100644 index 0000000..2920636 --- /dev/null +++ b/src/report/report.hpp @@ -0,0 +1,75 @@ +#pragma once + +#include +#include +#include + +#include "item/derive.hpp" +#include "item/item.hpp" +#include "item/plan.hpp" + +/// A bug report about a price check, and the one endpoint it is sent to. +/// +/// **The whole layer is text.** What leaves the machine is the clipboard capture the user was +/// looking at, what this tool made of it, whatever they wrote, four short version strings and — +/// only if they tick the box — a picture of our own window. There is no identifier of any kind +/// and nothing is kept locally, which is why the dialog can honestly show the payload whole +/// before it is sent: what is on screen there *is* the request body. +/// +/// Pure, and part of `ppc_core`: the request itself is `ReportService`'s. +namespace ppc::report { + +/// The relay's own cap, mirrored here so the box can refuse the 2001st character rather than +/// letting the send come back 400. See `worker/README.md`. +inline constexpr size_t kCommentMax = 2000; + +/// Where a report goes: a Cloudflare Worker that holds the Discord webhook as a secret, so the +/// only thing shipped in the binary is a public, rate-limited URL. `$PPC_REPORT_URL` overrides it +/// for anyone running their own relay, and for testing this without posting into the real channel. +std::string relay_url(); + +/// The four short strings that say which build produced the report. Nothing here identifies a +/// machine or a person; each is capped at 64 characters by the relay. +struct Meta { + std::string version; ///< the app's own + std::string os; + std::string league; + std::string bundle; ///< the data bundle's version, which is half of any mispricing +}; + +struct Report { + std::string item; ///< the clipboard capture, verbatim and unedited + std::string parse; ///< what this tool made of it — `describe` + std::string comment; ///< what the user wrote, or empty + Meta meta; + std::string png; ///< the screenshot's bytes, empty unless the user attached one +}; + +/// The request body, exactly as it goes on the wire. The screenshot is base64 here and nowhere +/// else — `Report::png` is the bytes, because the dialog previews the picture and not the +/// encoding of it. +std::string to_json(const Report& r); + +/// What this tool made of the item, as the text a maintainer needs in front of them to tell a +/// parse bug from a pricing one: what was read off the clipboard, what it resolved to in the +/// bundle, which modifier matched which stat record — **and which matched none** — and what the +/// search would have asked for. +/// +/// Written for a human reading it in a Discord post, so it is plain lines rather than JSON: the +/// question it has to answer at a glance is "which line went wrong", and a nested structure +/// answers that worse. +std::string describe(const item::Item& it, const item::Derived& d, const item::SearchPlan& plan); + +/// The relay's answer to a report. `id` is what the user is shown and what names the forum post, +/// so it is the one thing worth repeating back. +struct Outcome { + bool ok = false; + std::string id; + std::string error; ///< why not, in words fit to put in front of a user +}; + +/// Read a response. `status` is 0 for a request that never completed, and `transport` is then +/// curl's own message. Anything the relay refuses comes back with its reason in the body. +Outcome read_response(long status, const std::string& body, const std::string& transport); + +} // namespace ppc::report diff --git a/src/report_service.cpp b/src/report_service.cpp new file mode 100644 index 0000000..6e2e432 --- /dev/null +++ b/src/report_service.cpp @@ -0,0 +1,88 @@ +#include "report_service.hpp" + +#include +#include + +#include + +#include "net/http.hpp" +#include "util/debug_log.hpp" +#include "util/png.hpp" + +namespace ppc { + +ReportService::~ReportService() { shutdown(); } + +void ReportService::init(uint32_t done_event_type) { done_event_ = done_event_type; } + +void ReportService::send(report::Report r, Capture shot) { + if (busy_.exchange(true)) return; + if (worker_.joinable()) worker_.join(); // reap the previous, already-finished worker + + state_ = ReportState::Sending; + id_.clear(); + error_.clear(); + + const uint32_t ev = done_event_; + const std::string url = report::relay_url(); + debug::log("[report] sending to %s: %zu byte item, %zu byte parse, %zu byte comment, " + "%dx%d screenshot", + url.c_str(), r.item.size(), r.parse.size(), r.comment.size(), shot.w, shot.h); + + worker_ = std::thread([ev, url, r = std::move(r), shot = std::move(shot)]() mutable { + auto* out = new Result{}; + if (!shot.empty()) { + const std::vector png = encode_png(shot.rgba.data(), shot.w, shot.h); + r.png.assign(reinterpret_cast(png.data()), png.size()); + } + net::Request req; + req.url = url; + req.body = report::to_json(r); + // A report carries a screenshot and can run to megabytes on a slow line, so this is well + // past the eight seconds every other request here gets — a send that gives up under a + // user watching it is worse than one that takes a while. + req.timeout_ms = 30000; + const net::Response resp = net::get(req); + out->outcome = report::read_response(resp.status, resp.body, resp.error); + SDL_Event e{}; + e.type = ev; + e.user.data1 = out; + if (!SDL_PushEvent(&e)) delete out; // queue full, or SDL is shutting down + }); +} + +void ReportService::on_done(const SDL_Event& e) { + std::unique_ptr r(static_cast(e.user.data1)); + busy_ = false; + if (!r) return; + if (r->outcome.ok) { + id_ = std::move(r->outcome.id); + state_ = ReportState::Sent; + debug::log("[report] accepted as %s", id_.c_str()); + } else { + error_ = std::move(r->outcome.error); + state_ = ReportState::Failed; + debug::log("[report] refused: %s", error_.c_str()); + } +} + +void ReportService::reset() { + if (state_ == ReportState::Sending) return; // the answer is still coming; it has somewhere to land + state_ = ReportState::Idle; + id_.clear(); + error_.clear(); +} + +void ReportService::shutdown() { + if (worker_.joinable()) worker_.join(); + if (!done_event_) return; + + SDL_Event drop[16]; + int n; + while ((n = SDL_PeepEvents(drop, 16, SDL_GETEVENT, done_event_, done_event_)) > 0) + for (int i = 0; i < n; ++i) delete static_cast(drop[i].user.data1); + + done_event_ = 0; +} + +} // namespace ppc diff --git a/src/report_service.hpp b/src/report_service.hpp new file mode 100644 index 0000000..c4f5580 --- /dev/null +++ b/src/report_service.hpp @@ -0,0 +1,65 @@ +#pragma once + +#include +#include +#include +#include + +#include "capture.hpp" +#include "report/report.hpp" + +union SDL_Event; + +namespace ppc { + +enum class ReportState { Idle, Sending, Sent, Failed }; + +/// Sends one bug report to the relay, off the UI thread. +/// +/// Shaped like `LeagueService` and for the same reasons: every member is touched on the main +/// thread only, and the worker owns nothing but its own stack and the heap payload it hands back +/// through the SDL event queue. +/// +/// **Deliberately not through `trade::request`.** The relay is our own host with our own rules; +/// the shared rate limiter exists for GGG's policy and nothing else belongs behind it. What keeps +/// this endpoint from being hammered is the relay's own per-IP cap and the fact that a report is +/// a thing a person types. +class ReportService { +public: + struct Result { + report::Outcome outcome; + }; + + ~ReportService(); + + void init(uint32_t done_event_type); ///< after SDL_RegisterEvents + void shutdown(); ///< join the worker, drain unconsumed events; before SDL_Quit + + /// Send it, attaching `shot` when it is not empty. + /// + /// The screenshot arrives as raw pixels rather than as a PNG: encoding it, base64ing it and + /// serialising the body are together most of a second on a 4K panel, and all three belong on + /// the worker rather than in the frame that drew the button. Ignored while one is already in + /// flight — the dialog disables its own button, and this is the guard behind that. + void send(report::Report r, Capture shot); + + void on_done(const SDL_Event& e); + + ReportState state() const { return state_; } + /// The relay's id for the last accepted report, which is what the user is shown and what + /// names the forum post it became. + const std::string& id() const { return id_; } + const std::string& error() const { return error_; } + /// Back to Idle, so the dialog can be reopened without last time's answer still on it. + void reset(); + +private: + uint32_t done_event_ = 0; + std::thread worker_; + std::atomic busy_{false}; + ReportState state_ = ReportState::Idle; + std::string id_; + std::string error_; +}; + +} // namespace ppc diff --git a/src/screens/pricecheck_screen.cpp b/src/screens/pricecheck_screen.cpp index 8ba20b5..1512065 100644 --- a/src/screens/pricecheck_screen.cpp +++ b/src/screens/pricecheck_screen.cpp @@ -1342,40 +1342,87 @@ bool draw_exchange_price(App& app, const item::Item& it) { return true; } -/// The Search / Open in browser pair, plus whatever the last search had to say. Both act on -/// the filters as they are ticked right now, so changing one's mind and pressing again is -/// the whole interaction. -void draw_search_controls(App& app) { +/// A square button with a glyph on it. Rounded, and the same size as a framed widget, so a row +/// of them reads as a toolbar rather than as three words of different lengths. +/// +/// The word is never on the button: three of them would take the width the listings need, and +/// what each does is one hover away. `fallback` is a single letter for the case the bundled glyph +/// subset drifted from `ui/glyphs.hpp` — see `Fonts::has_glyphs`. A button with nothing drawn on +/// it is the failure that guards against. +constexpr float kIconRounding = 4.0f; + +bool icon_button(const char* glyph, const char* fallback, const char* tip, bool glyphs, + bool quiet) { + const float side = ImGui::GetFrameHeight(); + ImGui::PushStyleVar(ImGuiStyleVar_FrameRounding, kIconRounding); + const bool hit = ImGui::Button(glyphs ? glyph : fallback, ImVec2(side, side)); + ImGui::PopStyleVar(); + // AllowWhenDisabled: a greyed Search is exactly the button whose tooltip is worth reading. + // **Silent while the panel is being photographed**: the cursor is on this row when the report + // button is pressed, and a capture with "Report a Bug" hovering over it is a picture of the + // act of reporting rather than of the thing being reported. + if (!quiet && ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) + ImGui::SetTooltip("%s", tip); + return hit; +} + +/// The panel's action row: whatever the last search had to say on the left, and the buttons +/// against the right edge. +/// +/// **Right to left, primary first.** Search is the rightmost because it is the one the hand goes +/// to, then the same search on the site, then reporting the check — which is the one button that +/// is here on every item, including the ones with no search at all. Search and browser both act +/// on the filters as they are ticked right now, so changing one's mind and pressing again is the +/// whole interaction. +void draw_action_bar(App& app, bool searchable) { const TradeService& t = app.trade(); + const bool glyphs = app.fonts().has_glyphs; const bool busy = t.state() == TradeState::Searching; const bool can = app.can_search(); + const ImGuiStyle& style = ImGui::GetStyle(); + const float side = ImGui::GetFrameHeight(); + const int buttons = searchable ? 3 : 1; + const float cluster = buttons * side + (buttons - 1) * style.ItemSpacing.x; + + const bool quiet = app.report_capture_pending(); + const float right = ImGui::GetCursorPosX() + ImGui::GetContentRegionAvail().x; + if (searchable) { + ImGui::AlignTextToFramePadding(); + if (busy) { + ImGui::TextDisabled("Searching\xe2\x80\xa6"); + } else if (!can) { + // Where the answer is, not just that there is no search: currency, cards, scarabs + // and fragments have nothing a stat query could ask for — they are bought in bulk + // on the in-game exchange — so the poe.ninja row above is the whole price check, and + // a bare "Nothing to search" over it reads as a failure. An unidentified unique is + // the same argument: the search is one question away, not missing. + const item::Item* it = app.item(); + ImGui::TextDisabled("%s", it && it->needs_unique_choice() ? "Pick which unique it is" + : app.plan().strategy == item::Strategy::Currency + ? "Priced by poe.ninja, not by a trade search" + : "Nothing to search"); + } else if (t.state() == TradeState::Ok) { + // The total, not the number fetched: "20 of 4" would be a lie and "20 listings" + // hides that there are two thousand more. + ImGui::TextDisabled("%d match%s in %s", t.results().total, + t.results().total == 1 ? "" : "es", t.league().c_str()); + } + ImGui::SameLine(); + } + // max, not a bare subtraction: a long status line on a narrow panel would otherwise put the + // cursor back over the text it has just drawn. + ImGui::SetCursorPosX(std::max(ImGui::GetCursorPosX(), right - cluster)); + if (icon_button(ui::kGlyphBug, "B", "Report a Bug", glyphs, quiet)) app.open_bug_report(); + if (!searchable) return; + + ImGui::SameLine(); ImGui::BeginDisabled(busy || !can); - if (ImGui::Button("Search", ImVec2(110, 0))) app.start_search(); + if (icon_button(ui::kGlyphExternal, "O", "Open this search on the trade site", glyphs, quiet)) + app.open_search_in_browser(); ImGui::SameLine(); - if (ImGui::Button("Open in browser", ImVec2(150, 0))) app.open_search_in_browser(); + if (icon_button(ui::kGlyphSearch, "S", "Search", glyphs, quiet)) app.start_search(); ImGui::EndDisabled(); - - ImGui::SameLine(); - if (busy) { - ImGui::TextDisabled("Searching\xe2\x80\xa6"); - } else if (!can) { - // Where the answer is, not just that there is no search: currency, cards, scarabs and - // fragments have nothing a stat query could ask for — they are bought in bulk on the - // in-game exchange — so the poe.ninja row above is the whole price check, and a bare - // "Nothing to search" over it reads as a failure. An unidentified unique is the same - // argument: the search is one question away, not missing. - const item::Item* it = app.item(); - ImGui::TextDisabled("%s", it && it->needs_unique_choice() ? "Pick which unique it is" - : app.plan().strategy == item::Strategy::Currency - ? "Priced by poe.ninja, not by a trade search" - : "Nothing to search"); - } else if (t.state() == TradeState::Ok) { - // The total, not the number fetched: "20 of 4" would be a lie and "20 listings" hides - // that there are two thousand more. - ImGui::TextDisabled("%d match%s in %s", t.results().total, - t.results().total == 1 ? "" : "es", t.league().c_str()); - } } /// Why there is **no** search, for the items that get none at all. The filters, the buttons and @@ -1551,6 +1598,12 @@ void draw_results(App& app, float gutter_top) { // indistinguishable from the rest of the page, and the page is what a price is read off: // an own listing sitting at the top reads as the market's floor, which it is not. const std::string& me = app.config().account_name; + // **Nobody's name goes in a bug report.** On the frame the panel is being read back for one, + // every handle is replaced by its position in the results — which is all a maintainer reading + // the picture ever needed: that these are twenty different sellers, and which row is which. + // The user's own marker stays, because it names nobody and is the one thing on the row that + // explains its colour. + const bool masked = app.report_capture_pending(); for (size_t i = 0; i < t.results().listings.size(); ++i) { const trade::Listing& l = t.results().listings[i]; @@ -1572,8 +1625,9 @@ void draw_results(App& app, float gutter_top) { // The user's own row says so in words as well as in colour: the tint is what catches // the eye at a glance, and a green row is nothing at all to a reader who cannot see // green. Clipped away on a narrow panel, where the tint is still there. - const std::string label = - (mine ? l.account + " (you)" : l.account) + "##row" + std::to_string(i); + const std::string who = + masked ? "seller " + std::to_string(i + 1) : l.account; + const std::string label = (mine ? who + " (you)" : who) + "##row" + std::to_string(i); ImGui::PushFont(app.fonts().unicode, 0.0f); ImGui::Selectable(label.c_str(), false, ImGuiSelectableFlags_SpanAllColumns | @@ -1736,10 +1790,14 @@ void draw_pricecheck_screen(App& app) { // come back empty reads as the item being unsellable rather than as the wrong // market having been asked. if (searchable) { - draw_search_controls(app); + draw_action_bar(app, true); draw_results(app, gutter_top); } else { draw_no_search_note(app); + // Under whatever drew last, which for these items is the note above: the + // report button is the one action every price check has, including the ones + // that never had a search to offer. + draw_action_bar(app, false); } } } diff --git a/src/screens/report_screen.cpp b/src/screens/report_screen.cpp new file mode 100644 index 0000000..7a0a55e --- /dev/null +++ b/src/screens/report_screen.cpp @@ -0,0 +1,319 @@ +#include "screens/report_screen.hpp" + +#include +#include +#include +#include + +#include +#include + +#include "app.hpp" +#include "report/report.hpp" +#include "ui/theme.hpp" + +namespace ppc { +namespace { + +/// The dialog's own name, in the size the game gives a screen title. Same as Settings': this is +/// a second dialog of the same kind and it should not read as a different application. +constexpr float kTitleSize = 21.0f; + +/// The captured text is data rather than prose, and at the body size a monospace face is both +/// wider and heavier than Fontin — two panels of it would fill the dialog with four fields. +constexpr float kMonoSize = 14.0f; + +/// How the middle splits. The left column is the payload and the right is one picture of it, so +/// the picture gets what it needs to still be recognisable and the text gets the rest. +constexpr float kShotColumn = 300.0f; + +/// How much of the left column the clipboard capture takes, the parse dump getting the rest. The +/// capture is the shorter of the two on nearly every item, and it is the one a reporter checks +/// before pressing Send. +constexpr float kItemShare = 0.42f; + +void label(const char* text) { + ImGui::PushStyleColor(ImGuiCol_Text, ui::col::kLabel); + ImGui::TextUnformatted(text); + ImGui::PopStyleColor(); +} + +/// A read-only view of one of the payload's fields: monospace, scrollable, and selectable so a +/// reporter can copy a line out of it. Read-only rather than disabled — a disabled box cannot be +/// scrolled, and the whole point is that the payload can be read before it is sent. +void preview(App& app, const char* id, std::string& text, float height) { + ImGui::PushFont(app.fonts().mono, kMonoSize); + // Whole lines. A box sized to whatever was left over ends on half a line of text, which on a + // preview whose whole claim is "this is what will be sent" reads as truncation. + const float line = ImGui::GetTextLineHeight(); + const float pad = ImGui::GetStyle().FramePadding.y * 2; + const float rows = std::max(3.0f, std::floor((height - pad) / line)); + ImGui::InputTextMultiline(id, &text, ImVec2(-FLT_MIN, rows * line + pad), + ImGuiInputTextFlags_ReadOnly); + ImGui::PopFont(); +} + +/// Trim to the relay's cap without cutting a character in half. +/// +/// The character filter below stops typing at the cap, but a paste arrives whole and there is no +/// keystroke to refuse — so this is the one that actually enforces it, and it steps back over +/// UTF-8 continuation bytes rather than leaving a half a character the relay would then strip. +void clamp_comment(std::string& s) { + if (s.size() <= report::kCommentMax) return; + size_t n = report::kCommentMax; + while (n > 0 && (static_cast(s[n]) & 0xC0) == 0x80) --n; + s.resize(n); +} + +int refuse_past_cap(ImGuiInputTextCallbackData* data) { + const auto* s = static_cast(data->UserData); + if (s->size() >= report::kCommentMax) data->EventChar = 0; // dropped + return 0; +} + +void draw_comment(ReportDraft& draft) { + label("Describe the issue (optional)"); + const float h = ImGui::GetTextLineHeight() * 3 + ImGui::GetStyle().FramePadding.y * 2; + ImGui::InputTextMultiline("##comment", &draft.comment, ImVec2(-FLT_MIN, h), + ImGuiInputTextFlags_CallbackCharFilter, &refuse_past_cap, + &draft.comment); + clamp_comment(draft.comment); + + const bool full = draft.comment.size() >= report::kCommentMax; + const std::string count = + std::to_string(draft.comment.size()) + " / " + std::to_string(report::kCommentMax); + ImGui::SetCursorPosX(ImGui::GetCursorPosX() + ImGui::GetContentRegionAvail().x - + ImGui::CalcTextSize(count.c_str()).x); + if (full) ImGui::TextColored(ui::col::kWarn, "%s", count.c_str()); + else ImGui::TextDisabled("%s", count.c_str()); +} + +/// The picture, fitted into the column without being stretched and centred in what is left. +/// +/// It is a **preview and not the attachment** — the file sent is the capture at its own size, and +/// this is however much of the column it fits into. A panel is far taller than it is wide, so on +/// nearly every capture the height is what binds. +void draw_shot(const ReportDraft& draft) { + label("Screenshot"); + const ImVec2 room = ImGui::GetContentRegionAvail(); + if (!draft.shot_tex || draft.shot.w <= 0 || draft.shot.h <= 0) { + ImGui::TextDisabled("Nothing was captured, so there is nothing to attach."); + return; + } + const float w = static_cast(draft.shot.w); + const float h = static_cast(draft.shot.h); + const float scale = std::min(room.x / w, room.y / h); + const ImVec2 size(w * scale, h * scale); + ImGui::SetCursorPosX(ImGui::GetCursorPosX() + std::max(0.0f, (room.x - size.x) * 0.5f)); + ImGui::Image(draft.shot_tex, size); +} + +void draw_build_line(App& app, const report::Meta& m) { + label("Build"); + ImGui::PushFont(app.fonts().mono, kMonoSize); + ImGui::PushTextWrapPos(0.0f); + ImGui::TextUnformatted(("app " + m.version + " os " + m.os + " league " + + (m.league.empty() ? "-" : m.league) + " data " + + (m.bundle.empty() ? "none" : m.bundle)) + .c_str()); + ImGui::PopTextWrapPos(); + ImGui::PopFont(); +} + +/// The middle: the payload on the left, the one picture of it on the right. +void draw_body(App& app, ReportDraft& draft, float height) { + const ImGuiStyle& style = ImGui::GetStyle(); + const float shot_w = std::min(kShotColumn, ImGui::GetContentRegionAvail().x * 0.4f); + const float left_w = ImGui::GetContentRegionAvail().x - shot_w - style.ItemSpacing.x; + + ImGui::BeginChild("##payload", ImVec2(left_w, height)); + // Measured from what is left after the two labels and the build line, so the boxes fill the + // column exactly rather than being given a line count that is wrong at any other font size. + const float labels = ImGui::GetTextLineHeightWithSpacing() * 2; + const float build = ImGui::GetTextLineHeightWithSpacing() * 2 + style.ItemSpacing.y; + const float boxes = std::max(ImGui::GetTextLineHeight() * 6, + ImGui::GetContentRegionAvail().y - labels - build); + label("Item, exactly as the game copied it"); + preview(app, "##item", draft.payload.item, boxes * kItemShare); + label("What this tool made of it"); + preview(app, "##parse", draft.payload.parse, boxes * (1.0f - kItemShare)); + draw_build_line(app, draft.payload.meta); + ImGui::EndChild(); + + ImGui::SameLine(); + ImGui::BeginChild("##shot", ImVec2(0, height)); + draw_shot(draft); + ImGui::EndChild(); +} + +/// What is sent and what is not, in front of the button that sends it. Measured as well as +/// drawn, because how many lines it wraps to is what the body's height is whatever is left of. +constexpr const char* kDisclaimer = + "This report is anonymous: no login, no machine identifier, nothing that says who you are, " + "and nothing kept on this computer. What is above is the whole of what is sent \xe2\x80\x94 " + "the item text, what this tool made of it, the build line, whatever you wrote, and the " + "picture only if you tick the box. The picture is a capture of this tool's own window and " + "nothing else, so the game behind it is not in it, and sellers' names were replaced by their " + "position in the results before it was taken."; + +float disclaimer_height(float width) { + return ImGui::CalcTextSize(kDisclaimer, nullptr, false, width).y; +} + +void draw_disclaimer() { + ImGui::PushTextWrapPos(0.0f); + ImGui::TextDisabled("%s", kDisclaimer); + ImGui::PopTextWrapPos(); +} + +/// The footer: the button on the right and the consent immediately to its left, so the thing +/// being agreed to and the act of sending are read in one glance. +void draw_footer(App& app, ReportDraft& draft) { + const ReportService& svc = app.report(); + const bool sending = svc.state() == ReportState::Sending; + const ImGuiStyle& style = ImGui::GetStyle(); + constexpr float kSendW = 140.0f; + + ImGui::BeginDisabled(sending); + const char* consent = "Attach the screenshot (see preview)"; + const float consent_w = ImGui::CalcTextSize(consent).x + ImGui::GetFrameHeight() + + style.ItemInnerSpacing.x; + ImGui::SetCursorPosX(ImGui::GetCursorPosX() + ImGui::GetContentRegionAvail().x - kSendW - + consent_w - style.ItemSpacing.x * 2); + ImGui::BeginDisabled(draft.shot.empty()); + ImGui::Checkbox(consent, &draft.attach); + ImGui::EndDisabled(); + ImGui::EndDisabled(); + + ImGui::SameLine(0.0f, style.ItemSpacing.x * 2); + // Lit, as Settings lights its Save: it is the one control here that does anything outside + // this window. + ImGui::PushStyleColor(ImGuiCol_Button, ui::col::kButtonHovered); + ImGui::BeginDisabled(sending || draft.payload.item.empty()); + if (ImGui::Button(sending ? "Sending\xe2\x80\xa6" : "Send", ImVec2(kSendW, 0))) + app.send_bug_report(); + ImGui::EndDisabled(); + ImGui::PopStyleColor(); +} + +/// Why the send did not happen, over the dialog it did not close. +/// +/// A modal rather than a line in the footer: the report is still there and still sendable, and +/// the one thing the user must not conclude is that it went. `report_.reset()` on the way out is +/// what keeps this from reopening on the next frame — the failure is the state, and dismissing it +/// is clearing it. +void draw_failure(App& app) { + constexpr const char* kId = "Report not sent##fail"; + if (app.report().state() == ReportState::Failed && !ImGui::IsPopupOpen(kId)) + ImGui::OpenPopup(kId); + ImGui::SetNextWindowSize(ImVec2(380, 0)); + if (!ImGui::BeginPopupModal(kId, nullptr, + ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove | + ImGuiWindowFlags_NoSavedSettings)) + return; + ImGui::PushTextWrapPos(0.0f); + ImGui::TextColored(ui::col::kWarn, "%s", app.report().error().c_str()); + ImGui::TextDisabled("Nothing was sent. The report is still here — try again, or close the " + "dialog to drop it."); + ImGui::PopTextWrapPos(); + ImGui::Spacing(); + if (ImGui::Button("OK", ImVec2(120, 0))) { + app.dismiss_report_result(); + ImGui::CloseCurrentPopup(); + } + ImGui::EndPopup(); +} + +void draw_header(App& app, const char* title, void (App::*close)()) { + const float h = ImGui::GetFrameHeight(); + ImGui::PushFont(app.fonts().small_caps, kTitleSize); + ImGui::AlignTextToFramePadding(); + ImGui::PushStyleColor(ImGuiCol_Text, ui::col::kTitle); + ImGui::TextUnformatted(title); + ImGui::PopStyleColor(); + ImGui::PopFont(); + + ImGui::SameLine(); + ImGui::SetCursorPosX(ImGui::GetCursorPosX() + ImGui::GetContentRegionAvail().x - h); + ImGui::PushStyleVar(ImGuiStyleVar_FrameRounding, h * 0.5f); + ImGui::PushStyleColor(ImGuiCol_Button, ui::col::kClose); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ui::col::kCloseHovered); + ImGui::PushStyleColor(ImGuiCol_ButtonActive, ui::col::kCloseHovered); + const bool hit = ImGui::Button("X", ImVec2(h, h)); + ImGui::PopStyleColor(3); + ImGui::PopStyleVar(); + if (ImGui::IsItemHovered()) ImGui::SetTooltip("Close"); + if (hit) (app.*close)(); +} + +/// The confirmation, which is a window of its own because the dialog it confirms is already +/// closed. The id is the whole of the message worth keeping: it names the post the report became, +/// so quoting it is how a user can be answered about one. +void draw_sent(App& app) { + ImGuiIO& io = ImGui::GetIO(); + const ui::Theme theme(app.config().reduce_transparency); + ImGui::SetNextWindowPos(ImVec2(0, 0)); + ImGui::SetNextWindowSize(io.DisplaySize); + ImGui::Begin("Report sent", nullptr, + ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoTitleBar); + draw_header(app, "Bug report sent", &App::dismiss_report_result); + ImGui::Separator(); + ImGui::PushTextWrapPos(0.0f); + ImGui::TextUnformatted("Thank you \xe2\x80\x94 the report went through."); + ImGui::PopTextWrapPos(); + ImGui::AlignTextToFramePadding(); + label("Report id"); + ImGui::SameLine(); + ImGui::PushFont(app.fonts().mono, kMonoSize); + ImGui::TextUnformatted(app.report().id().c_str()); + ImGui::PopFont(); + ImGui::SetCursorPosY(ImGui::GetWindowHeight() - ImGui::GetFrameHeightWithSpacing() - + ImGui::GetStyle().WindowPadding.y); + ImGui::SetCursorPosX(ImGui::GetCursorPosX() + ImGui::GetContentRegionAvail().x - 120); + if (ImGui::Button("Close", ImVec2(120, 0))) app.dismiss_report_result(); + ImGui::End(); +} + +} // namespace + +void draw_report_screen(App& app) { + if (app.screen() == Screen::ReportSent) { + draw_sent(app); + return; + } + + ReportDraft& draft = app.report_draft(); + ImGuiIO& io = ImGui::GetIO(); + // Opened before Begin, so the window's own background and border are drawn with it. Same + // theme as Settings: this is a dialog of ours, not a panel read over the game at a glance. + const ui::Theme theme(app.config().reduce_transparency); + ImGui::SetNextWindowPos(ImVec2(0, 0)); + ImGui::SetNextWindowSize(io.DisplaySize); + ImGui::Begin("Bug reporter", nullptr, + ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoTitleBar); + + draw_header(app, "Path of Price Check bug reporter", &App::close_bug_report); + ImGui::Separator(); + draw_comment(draft); + ImGui::Spacing(); + + // Everything below the body is fixed height, so the body is what is left. Measured rather + // than declared, because the disclaimer wraps and how many lines it takes depends on the + // width the dialog ended up with. + const ImGuiStyle& style = ImGui::GetStyle(); + const float below = 1.0f + style.ItemSpacing.y * 4 + + disclaimer_height(ImGui::GetContentRegionAvail().x) + + ImGui::GetFrameHeightWithSpacing(); + draw_body(app, draft, std::max(ImGui::GetTextLineHeight() * 8, + ImGui::GetContentRegionAvail().y - below)); + + ImGui::Separator(); + draw_disclaimer(); + ImGui::SetCursorPosY(ImGui::GetWindowHeight() - ImGui::GetFrameHeightWithSpacing() - + style.WindowPadding.y); + draw_footer(app, draft); + draw_failure(app); + ImGui::End(); +} + +} // namespace ppc diff --git a/src/screens/report_screen.hpp b/src/screens/report_screen.hpp new file mode 100644 index 0000000..d425d1f --- /dev/null +++ b/src/screens/report_screen.hpp @@ -0,0 +1,12 @@ +#pragma once + +namespace ppc { +class App; + +/// The bug reporter: everything a report would send, laid out to be read before it is sent, plus +/// the one box the user can write in. Also draws the confirmation the send leaves behind, which +/// is `Screen::ReportSent` and a window of its own — the dialog closes first, so there is nothing +/// left to draw it over. +void draw_report_screen(App& app); + +} // namespace ppc diff --git a/src/ui/glyphs.hpp b/src/ui/glyphs.hpp index e700c00..74b316e 100644 --- a/src/ui/glyphs.hpp +++ b/src/ui/glyphs.hpp @@ -18,9 +18,13 @@ inline constexpr const char* kGlyphAdd = "\xef\x83\xbe"; ///< U+F0FE, square inline constexpr const char* kGlyphEdit = "\xef\x8c\x84"; ///< U+F304, pen inline constexpr const char* kGlyphDelete = "\xef\x8b\xad"; ///< U+F2ED, trash-can inline constexpr const char* kGlyphGrip = "\xef\x9e\xa4"; ///< U+F7A4, grip-lines +inline constexpr const char* kGlyphSearch = "\xef\x80\x82"; ///< U+F002, magnifying-glass +inline constexpr const char* kGlyphExternal = "\xef\x82\x8e"; ///< U+F08E, arrow-up-right-from-square +inline constexpr const char* kGlyphBug = "\xef\x86\x88"; ///< U+F188, bug /// The codepoints behind the above, for the one place that has to ask the atlas whether they /// actually baked rather than trusting that they did. -inline constexpr unsigned int kGlyphCodepoints[]{0xF00C, 0xF0E2, 0xF0FE, 0xF304, 0xF2ED, 0xF7A4}; +inline constexpr unsigned int kGlyphCodepoints[]{0xF00C, 0xF0E2, 0xF0FE, 0xF304, 0xF2ED, + 0xF7A4, 0xF002, 0xF08E, 0xF188}; } // namespace ppc::ui diff --git a/src/ui/theme.cpp b/src/ui/theme.cpp index c80bdcf..70dc019 100644 --- a/src/ui/theme.cpp +++ b/src/ui/theme.cpp @@ -35,6 +35,11 @@ Theme::Theme(bool opaque) { colour(ImGuiCol_PopupBg, with_alpha(col::kWindow, opaque ? 1.0f : 0.97f)); colour(ImGuiCol_Border, col::kBorder); colour(ImGuiCol_BorderShadow, ImVec4(0.0f, 0.0f, 0.0f, 0.0f)); + // Only a modal popup has one of these — every window of ours draws its own heading — and + // ImGui's default is a blue that has nothing to do with the rest of this palette. + colour(ImGuiCol_TitleBg, col::kTabIdle); + colour(ImGuiCol_TitleBgActive, col::kFrameActive); + colour(ImGuiCol_TitleBgCollapsed, col::kTabIdle); colour(ImGuiCol_Text, col::kText); colour(ImGuiCol_TextDisabled, col::kTextDim); diff --git a/src/util/png.cpp b/src/util/png.cpp new file mode 100644 index 0000000..7a4e9e6 --- /dev/null +++ b/src/util/png.cpp @@ -0,0 +1,79 @@ +#include "util/png.hpp" + +#include + +#include + +namespace ppc { +namespace { + +constexpr uint8_t kSignature[]{0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n'}; + +void put_be32(std::vector& out, uint32_t v) { + out.push_back(static_cast(v >> 24)); + out.push_back(static_cast(v >> 16)); + out.push_back(static_cast(v >> 8)); + out.push_back(static_cast(v)); +} + +/// One chunk: length, type, payload, and a CRC over the type and payload but **not** the length. +void chunk(std::vector& out, const char type[4], const std::vector& body) { + put_be32(out, static_cast(body.size())); + const size_t crc_from = out.size(); + out.insert(out.end(), type, type + 4); + out.insert(out.end(), body.begin(), body.end()); + put_be32(out, static_cast( + crc32(crc32(0, nullptr, 0), out.data() + crc_from, + static_cast(out.size() - crc_from)))); +} + +/// The raw stream deflate is handed: every row prefixed by its filter type. +/// +/// **Sub for every row**, which stores each byte as its difference from the pixel to its left. A +/// panel is mostly flat horizontal runs, and Sub turns those into runs of zeros before deflate +/// ever sees them — the difference on a real capture is several times the file size. Choosing per +/// row would do a little better again and is not worth the code for one picture a user sends by +/// hand. +std::vector filtered(const uint8_t* rgba, int w, int h) { + const size_t stride = static_cast(w) * 4; + std::vector raw(static_cast(h) * (stride + 1)); + for (int y = 0; y < h; ++y) { + const uint8_t* src = rgba + static_cast(y) * stride; + uint8_t* dst = raw.data() + static_cast(y) * (stride + 1); + *dst++ = 1; // Sub + // The first pixel of a row has nothing to its left, which the format defines as zero. + for (size_t x = 0; x < stride; ++x) dst[x] = static_cast(src[x] - (x < 4 ? 0 : src[x - 4])); + } + return raw; +} + +} // namespace + +std::vector encode_png(const uint8_t* rgba, int w, int h) { + if (!rgba || w <= 0 || h <= 0) return {}; + + const std::vector raw = filtered(rgba, w, h); + uLongf packed = compressBound(static_cast(raw.size())); + std::vector idat(packed); + if (compress2(idat.data(), &packed, raw.data(), static_cast(raw.size()), + Z_DEFAULT_COMPRESSION) != Z_OK) + return {}; + idat.resize(packed); + + std::vector ihdr; + put_be32(ihdr, static_cast(w)); + put_be32(ihdr, static_cast(h)); + ihdr.push_back(8); // bits per channel + ihdr.push_back(6); // truecolour with alpha + ihdr.push_back(0); // deflate, the only compression the format has + ihdr.push_back(0); // adaptive filtering, the only filter method the format has + ihdr.push_back(0); // not interlaced + + std::vector out(std::begin(kSignature), std::end(kSignature)); + chunk(out, "IHDR", ihdr); + chunk(out, "IDAT", idat); + chunk(out, "IEND", {}); + return out; +} + +} // namespace ppc diff --git a/src/util/png.hpp b/src/util/png.hpp new file mode 100644 index 0000000..44ea05f --- /dev/null +++ b/src/util/png.hpp @@ -0,0 +1,19 @@ +#pragma once + +#include +#include + +namespace ppc { + +/// Encode straight-alpha RGBA, top row first, as an 8-bit RGBA PNG. +/// +/// Only ever used for one thing: the read-back of our own overlay window that a bug report may +/// carry. So it is deliberately the smallest encoder that produces a file every viewer accepts — +/// one `IDAT`, no interlacing, no ancillary chunks, and **no metadata of any kind**, which on a +/// picture leaving a user's machine is a property worth having rather than a shortcut. +/// +/// Empty on a zero-sized or short input, which the caller treats as "no screenshot" rather than +/// as an error worth a message: there is nothing a user could do about it. +std::vector encode_png(const uint8_t* rgba, int w, int h); + +} // namespace ppc diff --git a/tests/report_test.cpp b/tests/report_test.cpp new file mode 100644 index 0000000..911cbe5 --- /dev/null +++ b/tests/report_test.cpp @@ -0,0 +1,207 @@ +#define DOCTEST_CONFIG_IMPLEMENT_WITH_MAIN +#include + +#include +#include +#include +#include +#include + +#include + +#include "item/derive.hpp" +#include "item/plan.hpp" +#include "item/resolve.hpp" +#include "parse_en.hpp" +#include "report/report.hpp" +#include "util/base64.hpp" +#include "util/png.hpp" + +namespace fs = std::filesystem; +using namespace ppc; + +// The two halves of a bug report that leave this machine: the request body, and the picture in +// it. Both are pinned here rather than at the relay because the relay is the thing being +// protected — what it refuses is its business, and what we promise to send is ours. + +namespace { + +std::shared_ptr fixture() { + std::string err; + auto gd = data::GameData::open(fs::path(PPC_TEST_DATA_DIR) / "bundle", "en", &err); + REQUIRE_MESSAGE(gd != nullptr, "opening the fixture bundle failed: " << err); + return gd; +} + +std::string capture(const char* name) { + std::ifstream in(fs::path(PPC_TEST_DATA_DIR) / "items" / name, std::ios::binary); + REQUIRE(in.good()); + std::ostringstream ss; + ss << in.rdbuf(); + return ss.str(); +} + +/// Everything a report carries about one captured item, built the way `App` builds it. +struct Priced { + item::Item it; + item::Derived derived; + item::SearchPlan plan; +}; + +Priced price(const data::GameData& gd, const std::string& text) { + Priced p; + std::optional parsed = item::parse_item_en(text); + REQUIRE(parsed.has_value()); + p.it = std::move(*parsed); + item::resolve_item(gd, p.it); + p.derived = item::derive(&gd, p.it); + p.plan = item::build_plan(gd, p.it, p.derived); + return p; +} + +} // namespace + +TEST_CASE("the parse dump names the item, every modifier and what the search would ask") { + const auto gd = fixture(); + const std::string text = capture("rare-bow-elder.txt"); + const Priced p = price(*gd, text); + const std::string dump = report::describe(p.it, p.derived, p.plan); + + CHECK(dump.find("== Item ==") != std::string::npos); + CHECK(dump.find("== Modifiers ==") != std::string::npos); + CHECK(dump.find("== Search plan ==") != std::string::npos); + CHECK(dump.find("strategy: ") != std::string::npos); + // One line per modifier the game printed, whatever became of it. A dump that quietly drops + // the mod nobody could match is a dump that hides the bug it exists to report. + for (size_t i = 0; i < p.it.mods.size(); ++i) + CHECK(dump.find("[" + std::to_string(i) + "] ") != std::string::npos); +} + +TEST_CASE("a wording nothing matched says so in words") { + const auto gd = fixture(); + // The fixture bundle knows a handful of stats, so an invented modifier is guaranteed to + // reach the branch that matters. + const std::string text = + "Item Class: Bows\nRarity: Rare\nDoom Song\nSpine Bow\n--------\nItem Level: 84\n" + "--------\nGrants Level 40 Nonsense Aura Skill\n"; + const Priced p = price(*gd, text); + const std::string dump = report::describe(p.it, p.derived, p.plan); + CHECK(dump.find("NO MATCH") != std::string::npos); +} + +TEST_CASE("the body carries exactly the fields the relay reads, and no others") { + report::Report r; + r.item = "Item Class: Bows\n--------\nSpine Bow\n"; + r.parse = "== Item ==\n"; + r.comment = "priced as the wrong base"; + r.meta.version = "0.6.0"; + r.meta.os = "Linux"; + r.meta.league = "Standard"; + r.meta.bundle = "2026-08-01"; + + const nlohmann::json j = nlohmann::json::parse(report::to_json(r)); + CHECK(j["item"] == r.item); + CHECK(j["parse"] == r.parse); + CHECK(j["comment"] == r.comment); + CHECK(j["meta"]["version"] == "0.6.0"); + CHECK(j["meta"]["bundle"] == "2026-08-01"); + // Nothing else at all. The relay ignores what it does not know, but a field appearing here + // that nobody agreed to send is exactly the change this test is meant to catch. + CHECK(j.size() == 4); + CHECK(j.find("screenshot_png_b64") == j.end()); +} + +TEST_CASE("an empty comment and an empty meta field are left out rather than sent blank") { + report::Report r; + r.item = "x"; + r.meta.version = "0.6.0"; + const nlohmann::json j = nlohmann::json::parse(report::to_json(r)); + CHECK(j.find("comment") == j.end()); + CHECK(j["meta"].size() == 1); +} + +TEST_CASE("a screenshot rides as base64 of the PNG bytes and nowhere else") { + report::Report r; + r.item = "x"; + r.png = "\x89PNGfake"; + const nlohmann::json j = nlohmann::json::parse(report::to_json(r)); + const auto decoded = base64_decode(j["screenshot_png_b64"].get()); + REQUIRE(decoded.has_value()); + CHECK(*decoded == r.png); +} + +TEST_CASE("$PPC_REPORT_URL wins, and the default is the deployed relay") { + CHECK(report::relay_url().find("https://") == 0); +#ifdef _WIN32 + _putenv_s("PPC_REPORT_URL", "https://example.invalid/report"); +#else + setenv("PPC_REPORT_URL", "https://example.invalid/report", 1); +#endif + CHECK(report::relay_url() == "https://example.invalid/report"); +#ifdef _WIN32 + _putenv_s("PPC_REPORT_URL", ""); +#else + unsetenv("PPC_REPORT_URL"); +#endif +} + +TEST_CASE("the relay's own reason for a refusal is what the user is shown") { + const report::Outcome o = + report::read_response(400, R"({"error":"comment is longer than 2000 characters"})", ""); + CHECK_FALSE(o.ok); + CHECK(o.error == "comment is longer than 2000 characters"); +} + +TEST_CASE("a transport failure is reported as one, and a success carries the id") { + const report::Outcome dead = report::read_response(0, "", "could not resolve host"); + CHECK_FALSE(dead.ok); + CHECK(dead.error.find("could not resolve host") != std::string::npos); + + const report::Outcome ok = report::read_response(200, R"({"ok":true,"id":"a1b2c3d4"})", ""); + CHECK(ok.ok); + CHECK(ok.id == "a1b2c3d4"); +} + +TEST_CASE("a 200 with no id is not a success") { + // The relay only ever answers 200 with an id. Anything else under that status is a proxy, + // a captive portal or a misconfiguration, and reporting it as sent would be the one lie + // this dialog must not tell. + const report::Outcome o = report::read_response(200, "hello", ""); + CHECK_FALSE(o.ok); + CHECK_FALSE(o.error.empty()); +} + +TEST_CASE("the encoder writes a PNG a decoder would accept") { + constexpr int w = 7, h = 5; + std::vector rgba(static_cast(w) * h * 4); + for (size_t i = 0; i < rgba.size(); ++i) rgba[i] = static_cast(i * 7); + const std::vector png = encode_png(rgba.data(), w, h); + REQUIRE(png.size() > 8); + + const uint8_t sig[]{0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n'}; + CHECK(std::equal(std::begin(sig), std::end(sig), png.begin())); + // IHDR is always the first chunk, and its payload is the size we asked for. + CHECK(std::string(png.begin() + 12, png.begin() + 16) == "IHDR"); + CHECK(png[16] == 0); + CHECK(png[19] == w); + CHECK(png[23] == h); + CHECK(png[24] == 8); // bits per channel + CHECK(png[25] == 6); // truecolour with alpha + CHECK(std::string(png.end() - 8, png.end() - 4) == "IEND"); +} + +TEST_CASE("a flat image compresses, which is the whole reason deflate is here") { + // A panel is mostly flat, and it is the difference between a payload the relay accepts and + // one it refuses at five megabytes. + constexpr int w = 400, h = 400; + const std::vector rgba(static_cast(w) * h * 4, 0x20); + const std::vector png = encode_png(rgba.data(), w, h); + CHECK(png.size() < rgba.size() / 100); +} + +TEST_CASE("nothing to encode is nothing, not a malformed file") { + CHECK(encode_png(nullptr, 4, 4).empty()); + const std::vector one(4); + CHECK(encode_png(one.data(), 0, 4).empty()); + CHECK(encode_png(one.data(), 4, -1).empty()); +} diff --git a/worker/README.md b/worker/README.md index 640a971..9239757 100644 --- a/worker/README.md +++ b/worker/README.md @@ -1,18 +1,23 @@ # Report relay A Cloudflare Worker that takes a bug report from the app and posts it to one Discord channel. It -exists so the app ships **no credential**: the data bundle carries this Worker's public URL, and the -Discord webhook lives only in Cloudflare. Extracting the URL from the bundle gets you a rate-limited -endpoint that can post a formatted item report to a private channel, and nothing else. +exists so the app ships **no credential**: the binary carries this Worker's public URL — an +endpoint like every other host it talks to, not a secret — and the Discord webhook lives only in +Cloudflare. Reading the URL out of the binary gets you a rate-limited endpoint that can post a +formatted item report to a private channel, and nothing else. Nothing above this is automated. A report arrives in Discord with a `report.md` attached that is written to be pasted into a GitHub issue unedited, by hand, if it turns out to be worth one. ``` app --HTTPS--> ppc-reports..workers.dev --webhook--> #ppc-reports - (public URL, in the bundle) (Cloudflare secret) + (public URL, compiled in) (Cloudflare secret) ``` +The app side — the button, the dialog that previews the whole payload, the screenshot and the +request — is [docs/reporting.md](../docs/reporting.md). `$PPC_REPORT_URL` overrides the endpoint, +which is how the app is tested against something other than the live channel. + ## Setup, once ### 1. Discord — the channel and the webhook @@ -103,7 +108,7 @@ daily cap keeps the relay inside it. The first posts a real fixture item. The second posts what a malicious reporter would send — `@everyone`, a masked link, a code-fence break, a right-to-left override. **Nobody should be pinged, no link should be clickable, and every character should appear literally.** If any of that -is untrue, stop and say so rather than shipping the app side. +is untrue, stop and say so rather than shipping a release that points at this. ## Day to day