feat(client/linux): CI screenshot capture
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||||
@@ -13,6 +13,7 @@ clients/apple/PunktfunkCore.xcframework/
|
|||||||
clients/apple/.swiftpm/
|
clients/apple/.swiftpm/
|
||||||
# Generated App Store screenshots (tools/screenshots.sh output; uploaded as a CI artifact)
|
# Generated App Store screenshots (tools/screenshots.sh output; uploaded as a CI artifact)
|
||||||
clients/apple/screenshots/
|
clients/apple/screenshots/
|
||||||
|
clients/linux/screenshots/
|
||||||
# Xcode per-user state
|
# Xcode per-user state
|
||||||
xcuserdata/
|
xcuserdata/
|
||||||
|
|
||||||
|
|||||||
@@ -43,7 +43,13 @@ pub fn run() -> glib::ExitCode {
|
|||||||
if let Some(pin) = arg_value("--pair") {
|
if let Some(pin) = arg_value("--pair") {
|
||||||
return headless_pair(&pin);
|
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);
|
app.connect_activate(build_ui);
|
||||||
// GTK doesn't see our argv (`--connect` is handled in `build_ui`); an empty argv also
|
// GTK doesn't see our argv (`--connect` is handled in `build_ui`); an empty argv also
|
||||||
// keeps GApplication from rejecting unknown options.
|
// keeps GApplication from rejecting unknown options.
|
||||||
@@ -199,11 +205,65 @@ fn build_ui(gtk_app: &adw::Application) {
|
|||||||
nav.add(&hosts_page);
|
nav.add(&hosts_page);
|
||||||
window.present();
|
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() {
|
if let Some(req) = cli_connect_request() {
|
||||||
initiate_connect(app, req);
|
initiate_connect(app, req);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `PUNKTFUNK_SHOT_SCENE`, when set, selects a scripted host-free scene for CI screenshots.
|
||||||
|
fn shot_scene() -> Option<String> {
|
||||||
|
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<NativeClient>`).
|
||||||
|
fn run_shot(app: Rc<App>, 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
|
/// 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
|
/// advertises `pair=optional` only when it accepts unpaired clients); the client renders
|
||||||
/// its trust UI from that:
|
/// its trust UI from that:
|
||||||
|
|||||||
Executable
+123
@@ -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/<scene>.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"
|
||||||
Reference in New Issue
Block a user