From 6a93d164a0ce4fd5df9acbc5ddd0b583b5da2f6a Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Sun, 28 Jun 2026 15:05:38 +0000 Subject: [PATCH] feat(client/linux): CI screenshot capture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Host-free UI screenshots of the GTK4/libadwaita client under a virtual X display (clients/linux/tools/screenshots.sh) — Xvfb + software GL (llvmpipe) + a root-window grab, one app launch per scene. PUNKTFUNK_SHOT_SCENE routes build_ui to render one mock-populated REAL view (hosts grid / settings dialog / TOFU + PIN dialogs) and print PF_SHOT_READY once it has settled; the saved-hosts grid is driven by a seeded client-known-hosts.json. NON_UNIQUE in shot mode so back-to-back launches don't collide. The stream scene is deferred — its page needs a live NativeClient. Gated to stable release tags in a standalone best-effort workflow that builds the client in the rust-ci image and captures under Xvfb; PNGs upload as a 30-day artifact, not committed. Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitea/workflows/linux-client-screenshots.yml | 67 ++++++++++ .gitignore | 1 + clients/linux/src/app.rs | 62 ++++++++- clients/linux/tools/screenshots.sh | 123 ++++++++++++++++++ 4 files changed, 252 insertions(+), 1 deletion(-) create mode 100644 .gitea/workflows/linux-client-screenshots.yml create mode 100755 clients/linux/tools/screenshots.sh diff --git a/.gitea/workflows/linux-client-screenshots.yml b/.gitea/workflows/linux-client-screenshots.yml new file mode 100644 index 0000000..8aec5c9 --- /dev/null +++ b/.gitea/workflows/linux-client-screenshots.yml @@ -0,0 +1,67 @@ +# Native Linux client screenshots for the app/marketing listings. The client renders +# host-free mock scenes (PUNKTFUNK_SHOT_SCENE) under a virtual X display; the driver +# (clients/linux/tools/screenshots.sh) grabs each one — no host, GPU, or Wayland. The +# Linux analogue of apple.yml's `screenshots` job, gated to STABLE RELEASE tags only. +# Standalone + best-effort: a failure here reds nothing else. PNGs land as a 30-day +# artifact; they are not committed or published. +name: linux-client-screenshots + +on: + push: + tags: ["v*"] + workflow_dispatch: + +jobs: + screenshots: + if: startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch' + runs-on: ubuntu-24.04 + # Same image as ci.yml/deb.yml — already carries the Rust toolchain + GTK/SDL build deps. + container: + image: git.unom.io/unom/punktfunk-rust-ci:latest + timeout-minutes: 90 + steps: + - uses: actions/checkout@v4 + + # Client link deps (baked into the image; kept here so the job is green across image + # rebuilds — a no-op once present) PLUS the headless-render extras: a virtual X server, + # software GL+Vulkan (llvmpipe/lavapipe), the icon theme + fonts the UI draws with, and a + # root-window grab tool. + - name: Client link + headless-render deps + run: | + apt-get update + apt-get install -y --no-install-recommends \ + libgtk-4-dev libadwaita-1-dev libsdl3-dev \ + xvfb x11-utils imagemagick scrot \ + libgl1-mesa-dri mesa-vulkan-drivers \ + adwaita-icon-theme fonts-cantarell fonts-dejavu-core + + # Reuse the workspace cargo caches (same keys as ci.yml/deb.yml). + - name: Cache keys + run: echo "rustc=$(rustc --version | cut -d' ' -f2)" >> "$GITHUB_ENV" + - uses: actions/cache@v4 + with: + path: | + /usr/local/cargo/registry + /usr/local/cargo/git + key: cargo-home-${{ hashFiles('Cargo.lock') }} + restore-keys: cargo-home- + - uses: actions/cache@v4 + with: + path: target + key: cargo-target-v3-${{ env.rustc }}-${{ hashFiles('Cargo.lock') }} + restore-keys: cargo-target-v3-${{ env.rustc }}- + + - name: Build client + run: cargo build --release -p punktfunk-client-linux --locked + + - name: Capture screenshots + run: bash clients/linux/tools/screenshots.sh + + - name: Upload screenshots + if: always() + # v3: Gitea's API rejects upload-artifact@v4 (see apple.yml). Download is a zip. + uses: actions/upload-artifact@v3 + with: + name: punktfunk-linux-client-screenshots + path: clients/linux/screenshots + retention-days: 30 diff --git a/.gitignore b/.gitignore index 751ed40..7533c33 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,7 @@ clients/apple/PunktfunkCore.xcframework/ clients/apple/.swiftpm/ # Generated App Store screenshots (tools/screenshots.sh output; uploaded as a CI artifact) clients/apple/screenshots/ +clients/linux/screenshots/ # Xcode per-user state xcuserdata/ diff --git a/clients/linux/src/app.rs b/clients/linux/src/app.rs index b14db3d..349b53a 100644 --- a/clients/linux/src/app.rs +++ b/clients/linux/src/app.rs @@ -43,7 +43,13 @@ pub fn run() -> glib::ExitCode { if let Some(pin) = arg_value("--pair") { return headless_pair(&pin); } - let app = adw::Application::builder().application_id(APP_ID).build(); + let mut builder = adw::Application::builder().application_id(APP_ID); + // Screenshot mode launches the app once per scene back-to-back; NON_UNIQUE keeps each + // launch its own primary instance instead of forwarding to a still-registered name. + if shot_scene().is_some() { + builder = builder.flags(gtk::gio::ApplicationFlags::NON_UNIQUE); + } + let app = builder.build(); app.connect_activate(build_ui); // GTK doesn't see our argv (`--connect` is handled in `build_ui`); an empty argv also // keeps GApplication from rejecting unknown options. @@ -199,11 +205,65 @@ fn build_ui(gtk_app: &adw::Application) { nav.add(&hosts_page); window.present(); + // CI screenshot mode: render one scripted, host-free scene and signal readiness + // (clients/linux/tools/screenshots.sh). Mutually exclusive with a real connect. + if let Some(scene) = shot_scene() { + run_shot(app, &scene); + return; + } + if let Some(req) = cli_connect_request() { initiate_connect(app, req); } } +/// `PUNKTFUNK_SHOT_SCENE`, when set, selects a scripted host-free scene for CI screenshots. +fn shot_scene() -> Option { + std::env::var("PUNKTFUNK_SHOT_SCENE") + .ok() + .filter(|s| !s.is_empty()) +} + +/// Render one mock-populated, host-free scene over the already-presented window, then print +/// `PF_SHOT_READY` once it has had a moment to map + settle so the driver knows when to capture. +/// No `NativeClient` or session is created. The stream scene is deliberately absent — its page +/// requires a live connector (`ui_stream::new` takes an `Arc`). +fn run_shot(app: Rc, scene: &str) { + // A plausible host for the trust/pair dialogs (fp_hex is 64 hex chars, like a real SHA-256). + let mock_req = || ConnectRequest { + name: "Living Room PC".to_string(), + addr: "192.168.1.42".to_string(), + port: 9777, + fp_hex: Some( + "9f8e7d6c5b4a39281706f5e4d3c2b1a0998877665544332211ffeeddccbbaa00".to_string(), + ), + pair_optional: true, + }; + + match scene { + // The saved-hosts grid reads ~/.config/punktfunk/client-known-hosts.json, which the + // driver seeds — so the already-shown hosts page is the scene; nothing to do here. + "hosts" | "02-hosts" => {} + "settings" | "03-settings" => { + crate::ui_settings::show(&app.window, app.settings.clone(), &app.gamepad); + } + "trust" | "04-trust" => tofu_dialog(app.clone(), mock_req()), + "pair" | "05-pair" => pin_dialog(app.clone(), mock_req()), + other => tracing::warn!("unknown PUNKTFUNK_SHOT_SCENE={other:?}; showing hosts only"), + } + + let settle_ms = std::env::var("PUNKTFUNK_SHOT_SETTLE_MS") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(900); + let scene = scene.to_string(); + glib::timeout_add_local_once(std::time::Duration::from_millis(settle_ms), move || { + use std::io::Write as _; + println!("PF_SHOT_READY scene={scene}"); + let _ = std::io::stdout().flush(); + }); +} + /// The trust gate in front of every connect. The host is the policy authority (it /// advertises `pair=optional` only when it accepts unpaired clients); the client renders /// its trust UI from that: diff --git a/clients/linux/tools/screenshots.sh b/clients/linux/tools/screenshots.sh new file mode 100755 index 0000000..311893d --- /dev/null +++ b/clients/linux/tools/screenshots.sh @@ -0,0 +1,123 @@ +#!/usr/bin/env bash +# Capture host-free UI screenshots of the native Linux client under a virtual X +# display. Mirrors the iOS harness (clients/apple/tools/screenshots.sh): one app +# launch per scene (PUNKTFUNK_SHOT_SCENE), the app renders a mock-populated REAL +# view and prints `PF_SHOT_READY`, then we grab the X root window. No host, GPU, or +# live stream — only the chrome scenes (the stream page needs a live connector). +# +# cargo build --release -p punktfunk-client-linux +# bash clients/linux/tools/screenshots.sh # → clients/linux/screenshots/.png +# bash clients/linux/tools/screenshots.sh hosts pair # a subset +# +# Env knobs: BIN (client binary), OUT (output dir), GEOMETRY (Xvfb WxHxDepth), +# SETTLE (extra seconds after PF_SHOT_READY), SHOT_DISPLAY (X display), GSK_RENDERER +# (gl|ngl|cairo — gl/llvmpipe by default for full libadwaita fidelity). +set -euo pipefail + +here="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" # clients/linux +BIN="${BIN:-$here/../../target/release/punktfunk-client}" +OUT="${OUT:-$here/screenshots}" +# The client window maps at its 1100x720 default; with no WM under Xvfb it lands at the +# top-left, so keep the root just larger so the full window (incl. its CSD shadow) is +# captured by a root grab with only a thin margin to crop. +GEOMETRY="${GEOMETRY:-1280x800x24}" +SETTLE="${SETTLE:-1.2}" +SHOT_DISPLAY="${SHOT_DISPLAY:-:99}" + +if [ "$#" -gt 0 ]; then SCENES=("$@"); else SCENES=(hosts settings trust pair); fi + +[ -x "$BIN" ] || { + echo "client binary not found: $BIN (build it first: cargo build --release -p punktfunk-client-linux)" >&2 + exit 1 +} + +# Isolated scratch HOME: the client generates its identity here on first run, and the +# saved-hosts grid is read from client-known-hosts.json, so seed mock hosts for the +# `hosts` scene (the dialogs/settings build their own mock state in-app). +WORK="$(mktemp -d)" +export HOME="$WORK" +mkdir -p "$HOME/.config/punktfunk" +cat >"$HOME/.config/punktfunk/client-known-hosts.json" <<'JSON' +{ + "hosts": [ + { "name": "Living Room PC", "addr": "192.168.1.42", "port": 9777, + "fp_hex": "9f8e7d6c5b4a39281706f5e4d3c2b1a0998877665544332211ffeeddccbbaa00", + "paired": true }, + { "name": "Office", "addr": "192.168.1.50", "port": 9777, + "fp_hex": "a1b2c3d4e5f60718293a4b5c6d7e8f90112233445566778899aabbccddeeff00", + "paired": false } + ] +} +JSON + +# Software-rendered X session — no GPU/Wayland. GL/llvmpipe runs the real NGL renderer +# (cairo is documented-incomplete for 3D-transformed content / libadwaita transitions). +unset WAYLAND_DISPLAY +export DISPLAY="$SHOT_DISPLAY" +export GDK_BACKEND=x11 +export LIBGL_ALWAYS_SOFTWARE=1 +export GALLIUM_DRIVER="${GALLIUM_DRIVER:-llvmpipe}" +export GSK_RENDERER="${GSK_RENDERER:-gl}" + +Xvfb "$SHOT_DISPLAY" -screen 0 "$GEOMETRY" -nolisten tcp >"$WORK/xvfb.log" 2>&1 & +XVFB_PID=$! +cleanup() { + kill "$XVFB_PID" 2>/dev/null || true + rm -rf "$WORK" +} +trap cleanup EXIT + +# Wait for the display to accept connections. +for _ in $(seq 1 50); do + if command -v xdpyinfo >/dev/null 2>&1; then + xdpyinfo -display "$SHOT_DISPLAY" >/dev/null 2>&1 && break + else + [ -e "/tmp/.X11-unix/X${SHOT_DISPLAY#:}" ] && break + fi + sleep 0.1 +done + +capture() { + local out="$1" + if command -v import >/dev/null 2>&1; then + import -silent -window root "$out" + elif command -v scrot >/dev/null 2>&1; then + scrot -o "$out" + else + echo "no screenshot tool — install imagemagick or scrot" >&2 + return 1 + fi +} + +mkdir -p "$OUT" +rc=0 +for scene in "${SCENES[@]}"; do + : >"$WORK/log" + PUNKTFUNK_SHOT_SCENE="$scene" "$BIN" >"$WORK/log" 2>&1 & + pid=$! + ready=0 + for _ in $(seq 1 200); do # up to ~20s + if grep -q "PF_SHOT_READY" "$WORK/log"; then + ready=1 + break + fi + if ! kill -0 "$pid" 2>/dev/null; then break; fi + sleep 0.1 + done + if [ "$ready" = 1 ]; then + sleep "$SETTLE" + if capture "$OUT/$scene.png"; then + echo "✓ $scene → $OUT/$scene.png" + else + rc=1 + fi + else + echo "✗ $scene: client never signalled PF_SHOT_READY" >&2 + sed 's/^/ /' "$WORK/log" >&2 || true + rc=1 + fi + kill "$pid" 2>/dev/null || true + wait "$pid" 2>/dev/null || true +done + +exit "$rc"