diff --git a/.gitea/workflows/windows.yml b/.gitea/workflows/windows.yml index 3f75d354..c3e6c877 100644 --- a/.gitea/workflows/windows.yml +++ b/.gitea/workflows/windows.yml @@ -139,16 +139,20 @@ jobs: # Both client binaries. ARM64: no skia-binaries prebuilt for the target, so the session # drops its `ui` feature there (pf-console-ui excluded; --no-default-features is a no-op # for the shell, which has no features). + # punktfunk-cli is in every gate: windows-msix.yml ships its `punktfunk.exe` alias, so + # a CLI that only the release workflow compiles is a release-day surprise. Its tests + # RUN the binary (help contract), as the session's contract_smoke runs the session — + # the gate class that catches a compiling-but-wrong binary (the 0.22.0 clobber). - name: Build shell: pwsh run: | $sf = @(); if ('${{ matrix.target }}' -eq 'aarch64-pc-windows-msvc') { $sf = @('--no-default-features') } - cargo build -p punktfunk-client-windows -p punktfunk-client-session @sf --target ${{ matrix.target }} + cargo build -p punktfunk-client-windows -p punktfunk-client-session -p punktfunk-cli @sf --target ${{ matrix.target }} - name: Clippy (-D warnings) shell: pwsh run: | - $pkgs = @('-p','punktfunk-client-windows','-p','punktfunk-client-session','-p','pf-client-core','-p','pf-presenter','-p','pf-ffvk') + $pkgs = @('-p','punktfunk-client-windows','-p','punktfunk-client-session','-p','punktfunk-cli','-p','pf-client-core','-p','pf-presenter','-p','pf-ffvk') $sf = @() if ('${{ matrix.target }}' -eq 'aarch64-pc-windows-msvc') { $sf = @('--no-default-features') } else { $pkgs += @('-p','pf-console-ui') } cargo clippy @pkgs --all-targets @sf --target ${{ matrix.target }} -- -D warnings @@ -156,9 +160,9 @@ jobs: - name: Rustfmt check if: matrix.target == 'x86_64-pc-windows-msvc' shell: pwsh - run: cargo fmt -p punktfunk-client-windows -p punktfunk-client-session -p pf-client-core -p pf-presenter -p pf-console-ui -p pf-ffvk -- --check + run: cargo fmt -p punktfunk-client-windows -p punktfunk-client-session -p punktfunk-cli -p pf-client-core -p pf-presenter -p pf-console-ui -p pf-ffvk -- --check - name: Test if: matrix.target == 'x86_64-pc-windows-msvc' shell: pwsh - run: cargo test -p punktfunk-client-windows -p punktfunk-client-session -p pf-client-core -p pf-presenter -p pf-console-ui -p pf-ffvk --target ${{ matrix.target }} + run: cargo test -p punktfunk-client-windows -p punktfunk-client-session -p punktfunk-cli -p pf-client-core -p pf-presenter -p pf-console-ui -p pf-ffvk --target ${{ matrix.target }} diff --git a/clients/session/README.md b/clients/session/README.md index 4bc44e12..bf1517c4 100644 --- a/clients/session/README.md +++ b/clients/session/README.md @@ -5,10 +5,15 @@ no widgets, terminal stats. The power-user / gamescope stream client, and the st presenter of the Linux client re-architecture (punktfunk-planning: `linux-client-rearchitecture.md`). +This binary is deliberately dumb: a renderer the front-ends call INTO — the GTK shell +(`punktfunk-client`), the WinUI shell, and the `punktfunk` CLI all spawn it through the +same brain (`pf_client_core::orchestrate`), which resolves policy (profiles, settings, +wake) and hands the result down, normally as a `--resolved-spec` file. It reads the +shared stores only as the compat fallback for a bare hand-launched invocation. + ``` punktfunk-session --connect host[:port] [--fp HEX] [--launch id] [--fullscreen] [--stats] punktfunk-session --browse host[:port] [--mgmt PORT] [--fullscreen] -punktfunk-session --pair --connect host[:port] [--name LABEL] ``` `--browse` opens the console game library (the Skia coverflow over the animated aurora) @@ -21,12 +26,10 @@ Reads the same identity / known-hosts / settings stores as the desktop client (`punktfunk-client`), so enrolling on either side makes the other work; this binary never connects to a host it has no pinned fingerprint for (`--fp HEX` overrides the store). -`--pair --connect host[:port]` runs the SPAKE2 ceremony with no window and no -toolkit, prints `paired : fp=`, and exits — the route for a machine that -has only SSH (an embedded/kiosk client, an image being provisioned). `--name` sets the -label the host files this client under, defaulting to the hostname. It is in the -`--no-default-features` build too: enrolling must never be the reason a minimal image has -to pull in Skia. +Pairing is `punktfunk pair ` — the CLI, which ships alongside this binary in every +package and needs no window and no toolkit either. `punktfunk-session --pair` still works +for one release (someone's provisioning script calls it today) but prints a deprecation +notice: pairing is a trust ceremony and belongs to the brain, not a renderer. Stdout is the machine interface: `{"ready":true}` after the first presented frame, `stats: …` once per second while the overlay tier isn't Off (always the full detailed diff --git a/clients/session/tests/contract_smoke.rs b/clients/session/tests/contract_smoke.rs new file mode 100644 index 00000000..52379b3f --- /dev/null +++ b/clients/session/tests/contract_smoke.rs @@ -0,0 +1,69 @@ +//! Runs the REAL `punktfunk-session` binary and asserts it speaks the stdout contract. +//! +//! 0.22.0 shipped a stub as `punktfunk-session` — a stray copy of the GTK shell replaced +//! this crate's `main.rs`, and every build gate stayed green because the wrong program +//! compiled perfectly. Nothing in CI ever *ran* the binary; the failure only existed at +//! runtime, as a connect that silently bounced back to the host list. This test is the +//! gate that would have caught it: whatever the binary does, it must SAY so on stdout. +#![cfg(any(target_os = "linux", windows))] + +use std::io::Read as _; +use std::process::{Command, Stdio}; +use std::time::{Duration, Instant}; + +/// A failing connect must still produce a contract line. Port 1 on loopback refuses +/// instantly and the all-zero pin keeps trust logic out of the way; whatever fails first +/// on this machine — presenter init on a headless runner, the dial on a box with a +/// display — the contract requires one `{"error"…}` JSON line on stdout saying so. (On a +/// desktop this may flash a window for under a second; the connect refuses immediately.) +#[test] +fn a_failing_connect_still_speaks_the_contract() { + let mut child = Command::new(env!("CARGO_BIN_EXE_punktfunk-session")) + .args([ + "--connect", + "127.0.0.1:1", + "--fp", + &"0".repeat(64), + "--connect-timeout", + "5", + ]) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .expect("spawn punktfunk-session"); + + let mut stdout = child.stdout.take().expect("piped stdout"); + let reader = std::thread::spawn(move || { + let mut buf = String::new(); + let _ = stdout.read_to_string(&mut buf); + buf + }); + + // Generous bound: presenter init on a cold CI runner can be slow, but a healthy + // binary answers in seconds. Only a hang (or a stub that inherited our stdout and + // blocked) gets anywhere near it. + let deadline = Instant::now() + Duration::from_secs(120); + loop { + match child.try_wait().expect("wait on punktfunk-session") { + Some(_) => break, + None if Instant::now() > deadline => { + let _ = child.kill(); + let _ = child.wait(); + panic!("punktfunk-session neither exited nor spoke within 120 s"); + } + None => std::thread::sleep(Duration::from_millis(200)), + } + } + + let out = reader.join().expect("stdout reader"); + let spoke = out.lines().map(str::trim).any(|l| { + l.starts_with('{') + && (l.contains("\"error\"") || l.contains("\"ready\"") || l.contains("\"ended\"")) + }); + assert!( + spoke, + "no stdout-contract line — the wrong program may be wearing this binary's name. \ + stdout was:\n{out}" + ); +}