android / android (push) Canceled after 36s
apple / swift (push) Canceled after 0s
apple / screenshots (push) Canceled after 0s
arch / build-publish (push) Canceled after 37s
ci / rust (push) Canceled after 37s
ci / rust-arm64 (push) Canceled after 36s
ci / web (push) Canceled after 34s
ci / docs-site (push) Canceled after 34s
deb / build-publish (push) Canceled after 19s
deb / build-publish-host (push) Canceled after 0s
deb / build-publish-client-arm64 (push) Canceled after 0s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 0s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 0s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Canceled after 0s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
windows-host / canary-manifest (push) Canceled after 0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 0s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 0s
windows-host / package (push) Canceled after 33s
windows-host / winget-source (push) Canceled after 0s
flatpak / build-publish (push) Successful in 5m43s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 6m59s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 4m9s
windows / build (aarch64-pc-windows-msvc) (push) Canceled after 2m19s
windows / build (x86_64-pc-windows-msvc) (push) Canceled after 0s
A channel nobody has published to answers `manifest.json` with a 404, and the check reported that the same way it reports a dead registry or a bad signature: "Last check failed: feed returned HTTP 404". Every host on the stable channel shows it today, because the stable manifest only publishes when someone dispatches `announce` for a release tag — so the first thing an operator sees from the new Updates card is a red failure caused by nothing being wrong. The shared checker now distinguishes the two. `feed::fetch_manifest_blocking` returns a typed `FeedError` instead of a string, and only a 404 on the manifest ITSELF becomes `NotPublished` — a 404 on the detached signature still fails loudly, because that is the half-published pair the manifest-then-signature upload order can produce, and it must stay fail-closed. The host carries that through as `UpdateStatus.not_published`, mutually exclusive with `last_error`. It is benign only while no manifest has ever been seen for the channel: once a check has succeeded, the same 404 means the feed LOST a document it used to serve, which stays an error. The console then shows a plain sentence naming the channel instead of the failure banner, and "None published yet" rather than "Not checked yet". The Linux client makes the same distinction but deliberately NOT the same choice: `--check-update` keeps exiting 1 and keeps `error` set, because its consumer is a shell script and an empty channel is the absence of evidence that this build is current — not a confirmation that it is. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
826 lines
32 KiB
Rust
826 lines
32 KiB
Rust
//! Command-line entry paths: argv helpers, the headless flows (pair/wake/library), the
|
|
//! exec handoff to `punktfunk-session` for `--connect`/`--browse`, and the CI screenshot
|
|
//! scenes.
|
|
|
|
use crate::app::AppModel;
|
|
use crate::trust::{forget_placeholder, KnownHost, KnownHosts};
|
|
use crate::ui_hosts::{ConnectRequest, HostsMsg};
|
|
use gtk::glib;
|
|
use gtk::prelude::*;
|
|
use punktfunk_core::client::NativeClient;
|
|
use relm4::prelude::*;
|
|
use std::cell::RefCell;
|
|
use std::rc::Rc;
|
|
use std::time::Duration;
|
|
|
|
/// The handles `run_shot` needs — cloned out of `AppModel` before it moves into the
|
|
/// component parts, so the scene can be dispatched from the window's `map` callback.
|
|
pub struct ShotCtx {
|
|
pub window: adw::ApplicationWindow,
|
|
pub nav: adw::NavigationView,
|
|
pub hosts: relm4::Sender<HostsMsg>,
|
|
pub settings: Rc<RefCell<crate::trust::Settings>>,
|
|
pub gamepad: crate::gamepad::GamepadService,
|
|
pub identity: (String, String),
|
|
pub sender: ComponentSender<AppModel>,
|
|
}
|
|
|
|
/// The value following `flag` in argv, if present (`--flag value`).
|
|
pub fn arg_value(flag: &str) -> Option<String> {
|
|
std::env::args()
|
|
.skip_while(|a| a != flag)
|
|
.nth(1)
|
|
.filter(|v| !v.starts_with("--"))
|
|
}
|
|
|
|
/// True if argv contains `flag` (a valueless switch).
|
|
pub fn arg_flag(flag: &str) -> bool {
|
|
std::env::args().any(|a| a == flag)
|
|
}
|
|
|
|
/// A positional `punktfunk://` (or the `pf://` input alias) anywhere in argv — the deep-link
|
|
/// door (design/client-deep-links.md §4.1). It is positional, not a flag, because that is what
|
|
/// `Exec=punktfunk-client %u` hands us, what a `.desktop` shortcut embeds, and what a browser's
|
|
/// "Open Punktfunk?" prompt ends up invoking. Validation happens later, in the shared parser —
|
|
/// this only decides whether argv contains something addressed to us.
|
|
pub fn deep_link_arg() -> Option<String> {
|
|
std::env::args().skip(1).find(|a| {
|
|
let lower = a.to_ascii_lowercase();
|
|
lower.starts_with("punktfunk://") || lower.starts_with("pf://")
|
|
})
|
|
}
|
|
|
|
/// Fullscreen the shell — the Gaming-Mode fallback for a bare launch (streams and the
|
|
/// console library exec the session binary, which handles its own fullscreen).
|
|
pub fn fullscreen_mode() -> bool {
|
|
arg_flag("--fullscreen")
|
|
|| std::env::var_os("SteamDeck").is_some()
|
|
|| std::env::var_os("GAMESCOPE_WAYLAND_DISPLAY").is_some()
|
|
}
|
|
|
|
/// Split `host[:port]`: no colon defaults the port to 9777; a colon with an unparsable
|
|
/// port yields `None` for it (callers decide whether to default or bail).
|
|
fn parse_host_port(target: &str) -> (String, Option<u16>) {
|
|
match target.rsplit_once(':') {
|
|
Some((a, p)) => (a.to_string(), p.parse().ok()),
|
|
None => (target.to_string(), Some(9777)),
|
|
}
|
|
}
|
|
|
|
/// `--connect` / `--browse`: streams and the console library live in the
|
|
/// `punktfunk-session` Vulkan binary — replace this process with it, forwarding the
|
|
/// relevant argv verbatim. This keeps the Decky wrapper (which launches the SHELL with
|
|
/// these flags) working unchanged until it's repointed at the session binary.
|
|
pub fn exec_session() -> glib::ExitCode {
|
|
use std::os::unix::process::CommandExt as _;
|
|
let forward = [
|
|
"--connect",
|
|
"--browse",
|
|
"--fp",
|
|
"--launch",
|
|
"--mgmt",
|
|
"--connect-timeout",
|
|
// A one-off profile pick, same grammar the session documents (`--profile ""` forces
|
|
// the global defaults). Without it here, `punktfunk --connect … --profile Work` from a
|
|
// script or a Decky wrapper streamed with the host's binding instead.
|
|
"--profile",
|
|
];
|
|
let mut cmd = std::process::Command::new(crate::spawn::session_binary());
|
|
let mut args = std::env::args().skip(1).peekable();
|
|
while let Some(a) = args.next() {
|
|
if a == "--fullscreen" || a == "--stats" {
|
|
cmd.arg(a);
|
|
} else if forward.contains(&a.as_str()) {
|
|
cmd.arg(&a);
|
|
if let Some(v) = args.peek() {
|
|
if !v.starts_with("--") {
|
|
cmd.arg(args.next().unwrap());
|
|
}
|
|
}
|
|
}
|
|
}
|
|
let err = cmd.exec(); // only returns on failure
|
|
eprintln!("exec punktfunk-session: {err}");
|
|
glib::ExitCode::FAILURE
|
|
}
|
|
|
|
/// Run the SPAKE2 PIN ceremony without a GTK window and persist the verified host to the
|
|
/// known-hosts store as paired, so a later `--connect` connects silently. Same identity
|
|
/// store the streaming path uses, so pairing here makes the stream work.
|
|
/// Prints a one-line `paired <addr>:<port> fp=<hex>` on success; exits non-zero on failure.
|
|
pub fn headless_pair(pin: &str) -> glib::ExitCode {
|
|
let Some(target) = arg_value("--connect") else {
|
|
eprintln!("--pair requires --connect host[:port]");
|
|
return glib::ExitCode::FAILURE;
|
|
};
|
|
let (addr, port) = parse_host_port(&target);
|
|
let port = port.unwrap_or(9777);
|
|
// The label the HOST stores this client under (its paired-devices list).
|
|
let name = arg_value("--name").unwrap_or_else(|| "Steam Deck".to_string());
|
|
|
|
let identity = match crate::trust::load_or_create_identity() {
|
|
Ok(i) => i,
|
|
Err(e) => {
|
|
eprintln!("client identity: {e:#}");
|
|
return glib::ExitCode::FAILURE;
|
|
}
|
|
};
|
|
match crate::trust::pair_with_host(&addr, port, &identity, pin, &name) {
|
|
Ok(fp) => {
|
|
let fp_hex = crate::trust::hex(&fp);
|
|
crate::trust::persist_host(
|
|
&arg_value("--host-label").unwrap_or_else(|| addr.clone()),
|
|
&addr,
|
|
port,
|
|
&fp_hex,
|
|
true,
|
|
);
|
|
// A host manually added via `--add-host` (no fingerprint yet) is stored as an
|
|
// addr-keyed placeholder; now that the ceremony yielded the real fingerprint,
|
|
// `persist_host` created the fp-keyed entry — drop the placeholder so the list
|
|
// shows this host once, not twice.
|
|
forget_placeholder(&addr, port);
|
|
println!("paired {addr}:{port} fp={fp_hex}");
|
|
glib::ExitCode::SUCCESS
|
|
}
|
|
Err(e) => {
|
|
eprintln!(
|
|
"pairing failed: {} ({e:?})",
|
|
crate::trust::pair_error_message(&e)
|
|
);
|
|
glib::ExitCode::FAILURE
|
|
}
|
|
}
|
|
}
|
|
|
|
/// `--wake host[:port]` — send a Wake-on-LAN magic packet to a saved host and exit,
|
|
/// without opening a window. The MAC comes from the known-hosts store (learned from the
|
|
/// host's mDNS `mac` TXT while it was online); exits non-zero if none is known yet.
|
|
pub fn cli_wake() -> glib::ExitCode {
|
|
let Some(target) = arg_value("--wake") else {
|
|
eprintln!("--wake requires host[:port]");
|
|
return glib::ExitCode::FAILURE;
|
|
};
|
|
let (addr, port) = parse_host_port(&target);
|
|
let port = port.unwrap_or(9777);
|
|
let mac = crate::trust::KnownHosts::load()
|
|
.find_by_addr(&addr, port)
|
|
.map(|h| h.mac.clone())
|
|
.unwrap_or_default();
|
|
if mac.is_empty() {
|
|
eprintln!(
|
|
"--wake: no MAC known for {addr}:{port} — connect once while the host is awake so its \
|
|
advertised MAC is learned"
|
|
);
|
|
return glib::ExitCode::FAILURE;
|
|
}
|
|
crate::wol::wake(&mac, addr.parse().ok());
|
|
println!("woke {addr}:{port} ({} MAC(s) targeted)", mac.len());
|
|
glib::ExitCode::SUCCESS
|
|
}
|
|
|
|
/// `--library host[:mgmt_port]` — fetch and print the host's game library over the real
|
|
/// mTLS + pinned-fingerprint client, no GTK window. The pin comes from `--fp HEX` when
|
|
/// given, else the known-hosts store (matched by address), else none (TOFU-accept).
|
|
pub fn headless_library(target: &str) -> glib::ExitCode {
|
|
let (addr, port) = match target.rsplit_once(':') {
|
|
Some((a, p)) if p.parse::<u16>().is_ok() => (a.to_string(), p.parse().unwrap()),
|
|
_ => (target.to_string(), crate::library::DEFAULT_MGMT_PORT),
|
|
};
|
|
let identity = match crate::trust::load_or_create_identity() {
|
|
Ok(i) => i,
|
|
Err(e) => {
|
|
eprintln!("client identity: {e:#}");
|
|
return glib::ExitCode::FAILURE;
|
|
}
|
|
};
|
|
let pin = arg_value("--fp")
|
|
.as_deref()
|
|
.and_then(crate::trust::parse_hex32)
|
|
.or_else(|| {
|
|
crate::trust::KnownHosts::load()
|
|
.find_by_addr(&addr, port)
|
|
.and_then(|h| crate::trust::parse_hex32(&h.fp_hex))
|
|
});
|
|
match crate::library::fetch_games(&addr, port, &identity, pin) {
|
|
Ok(games) => {
|
|
for g in &games {
|
|
println!("{}\t{}\t{}", g.id, g.store, g.title);
|
|
}
|
|
println!("{} game(s)", games.len());
|
|
glib::ExitCode::SUCCESS
|
|
}
|
|
Err(e) => {
|
|
eprintln!("library: {e}");
|
|
glib::ExitCode::FAILURE
|
|
}
|
|
}
|
|
}
|
|
|
|
// -----------------------------------------------------------------------------------------
|
|
// Headless host-store management — the shared known-hosts store (`client-known-hosts.json`)
|
|
// is the SINGLE source of truth for every client on this device (the GTK shell, the Vulkan
|
|
// session, and the Decky plugin, which shells out to these modes). Exposing add/edit/forget/
|
|
// list/reset here lets the Decky Gaming-Mode UI mutate exactly the store the desktop client
|
|
// reads, so a change in one surface shows up in the other. Reachability (`--list-hosts
|
|
// --probe`, `--reachable`) answers "is this host online?" WITHOUT mDNS, so a host reached
|
|
// over a routed network (Tailscale/VPN/another subnet) no longer reads as offline.
|
|
// -----------------------------------------------------------------------------------------
|
|
|
|
/// The per-probe budget: a cold host on a routed link answers in well under this, and every
|
|
/// saved host is probed in parallel so the wall-clock cost is one timeout, not the sum.
|
|
const PROBE_TIMEOUT: Duration = Duration::from_millis(2500);
|
|
|
|
/// Selector for `--set-host`/`--forget-host`: a 64-hex fingerprint pins one entry across IP
|
|
/// changes; anything else is treated as `addr[:port]` (manual entries have no fingerprint).
|
|
enum Selector {
|
|
Fp(String),
|
|
Addr(String, u16),
|
|
}
|
|
|
|
fn parse_selector(s: &str) -> Selector {
|
|
if s.len() == 64 && s.bytes().all(|b| b.is_ascii_hexdigit()) {
|
|
Selector::Fp(s.to_lowercase())
|
|
} else {
|
|
let (addr, port) = parse_host_port(s);
|
|
Selector::Addr(addr, port.unwrap_or(9777))
|
|
}
|
|
}
|
|
|
|
impl Selector {
|
|
fn matches(&self, h: &KnownHost) -> bool {
|
|
match self {
|
|
Selector::Fp(fp) => h.fp_hex.eq_ignore_ascii_case(fp),
|
|
Selector::Addr(addr, port) => h.addr == *addr && h.port == *port,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Probe every saved host for reachability in parallel (shared with the hosts-page presence pips).
|
|
fn probe_all(hosts: &[KnownHost]) -> Vec<bool> {
|
|
crate::trust::probe_reachable_many(
|
|
hosts.iter().map(|h| (h.addr.clone(), h.port)).collect(),
|
|
PROBE_TIMEOUT,
|
|
)
|
|
}
|
|
|
|
/// `--list-hosts [--probe]` — the saved known-hosts store as JSON (the store the Decky plugin
|
|
/// renders). With `--probe`, each host carries an `online` bool from a live reachability probe
|
|
/// (mDNS-independent); without it, `online` is `null` (unknown — the caller falls back to its
|
|
/// own mDNS view). Shape: `{"hosts":[{name,addr,port,fp_hex,paired,mac,last_used,online}]}`.
|
|
pub fn headless_list_hosts() -> glib::ExitCode {
|
|
let known = KnownHosts::load();
|
|
let online: Option<Vec<bool>> = arg_flag("--probe").then(|| probe_all(&known.hosts));
|
|
let hosts: Vec<serde_json::Value> = known
|
|
.hosts
|
|
.iter()
|
|
.enumerate()
|
|
.map(|(i, h)| {
|
|
serde_json::json!({
|
|
"name": h.name,
|
|
"addr": h.addr,
|
|
"port": h.port,
|
|
"fp_hex": h.fp_hex,
|
|
"paired": h.paired,
|
|
"mac": h.mac,
|
|
"os": h.os,
|
|
"last_used": h.last_used,
|
|
"online": online.as_ref().map(|v| serde_json::Value::Bool(v[i]))
|
|
.unwrap_or(serde_json::Value::Null),
|
|
})
|
|
})
|
|
.collect();
|
|
match serde_json::to_string(&serde_json::json!({ "hosts": hosts })) {
|
|
Ok(s) => {
|
|
println!("{s}");
|
|
glib::ExitCode::SUCCESS
|
|
}
|
|
Err(e) => {
|
|
eprintln!("list-hosts: {e}");
|
|
glib::ExitCode::FAILURE
|
|
}
|
|
}
|
|
}
|
|
|
|
/// `--reachable host[:port]` — probe one target and exit 0 (reachable) / 1 (not). A cheap
|
|
/// "is it online / test this address" check that never touches mDNS.
|
|
pub fn headless_reachable(target: &str) -> glib::ExitCode {
|
|
let (addr, port) = parse_host_port(target);
|
|
let port = port.unwrap_or(9777);
|
|
if NativeClient::probe(&addr, port, PROBE_TIMEOUT) {
|
|
println!("reachable {addr}:{port}");
|
|
glib::ExitCode::SUCCESS
|
|
} else {
|
|
eprintln!("unreachable {addr}:{port}");
|
|
glib::ExitCode::FAILURE
|
|
}
|
|
}
|
|
|
|
/// `--add-host host[:port] [--host-label NAME] [--fp HEX]` — save a host by address so it can
|
|
/// be paired/streamed even when mDNS never sees it (a Tailscale/VPN box). Without `--fp` the
|
|
/// entry is an unpaired placeholder (keyed by address) the user pairs later — `headless_pair`
|
|
/// then replaces it with the fingerprinted entry. With `--fp` (e.g. carried from an advert)
|
|
/// it is pinned immediately as trusted-but-not-PIN-paired. Prints `added <addr>:<port>`.
|
|
pub fn headless_add_host(target: &str) -> glib::ExitCode {
|
|
let (addr, port) = parse_host_port(target);
|
|
let port = port.unwrap_or(9777);
|
|
let name = arg_value("--host-label")
|
|
.map(|n| n.trim().to_string())
|
|
.filter(|n| !n.is_empty())
|
|
.unwrap_or_else(|| addr.clone());
|
|
if let Some(fp_hex) = arg_value("--fp").filter(|f| crate::trust::parse_hex32(f).is_some()) {
|
|
// Fingerprint known up front: upsert the pinned entry (paired stays false — no PIN
|
|
// ceremony happened; a later `--pair` upgrades it).
|
|
crate::trust::persist_host(&name, &addr, port, &fp_hex.to_lowercase(), false);
|
|
forget_placeholder(&addr, port);
|
|
println!("added {addr}:{port} fp={}", fp_hex.to_lowercase());
|
|
return glib::ExitCode::SUCCESS;
|
|
}
|
|
// No fingerprint yet — an address-keyed placeholder. Refresh the name if it already exists.
|
|
let mut known = KnownHosts::load();
|
|
if let Some(h) = known
|
|
.index_by_addr(&addr, port)
|
|
.and_then(|i| known.hosts.get_mut(i))
|
|
{
|
|
h.name = name;
|
|
} else {
|
|
known.hosts.push(KnownHost {
|
|
name,
|
|
addr: addr.clone(),
|
|
port,
|
|
..Default::default()
|
|
});
|
|
}
|
|
match known.save() {
|
|
Ok(()) => {
|
|
println!("added {addr}:{port}");
|
|
glib::ExitCode::SUCCESS
|
|
}
|
|
Err(e) => {
|
|
eprintln!("add-host: {e:#}");
|
|
glib::ExitCode::FAILURE
|
|
}
|
|
}
|
|
}
|
|
|
|
/// `--set-host <fp|host[:port]> [--host-label NAME] [--addr ADDR] [--port PORT]` — edit a saved
|
|
/// host: rename and/or re-point its address. Identified by fingerprint (survives IP changes) or
|
|
/// current address. Prints `updated <name>`; fails if nothing matched.
|
|
pub fn headless_set_host(selector: &str) -> glib::ExitCode {
|
|
let sel = parse_selector(selector);
|
|
let mut known = KnownHosts::load();
|
|
let Some(h) = known.hosts.iter_mut().find(|h| sel.matches(h)) else {
|
|
eprintln!("set-host: no saved host matches {selector:?}");
|
|
return glib::ExitCode::FAILURE;
|
|
};
|
|
if let Some(name) = arg_value("--host-label").map(|n| n.trim().to_string()) {
|
|
if !name.is_empty() {
|
|
h.name = name;
|
|
}
|
|
}
|
|
if let Some(addr) = arg_value("--addr").map(|a| a.trim().to_string()) {
|
|
if !addr.is_empty() {
|
|
h.addr = addr;
|
|
}
|
|
}
|
|
if let Some(port) = arg_value("--port").and_then(|p| p.trim().parse::<u16>().ok()) {
|
|
h.port = port;
|
|
}
|
|
let label = h.name.clone();
|
|
match known.save() {
|
|
Ok(()) => {
|
|
println!("updated {label}");
|
|
glib::ExitCode::SUCCESS
|
|
}
|
|
Err(e) => {
|
|
eprintln!("set-host: {e:#}");
|
|
glib::ExitCode::FAILURE
|
|
}
|
|
}
|
|
}
|
|
|
|
/// `--forget-host <fp|host[:port]>` — remove a saved host (drops the pinned fingerprint; a later
|
|
/// connect must re-pair/trust). Prints `forgot N`; succeeds even if nothing matched (idempotent).
|
|
pub fn headless_forget_host(selector: &str) -> glib::ExitCode {
|
|
let sel = parse_selector(selector);
|
|
let mut known = KnownHosts::load();
|
|
let before = known.hosts.len();
|
|
known.hosts.retain(|h| !sel.matches(h));
|
|
let removed = before - known.hosts.len();
|
|
if removed > 0 {
|
|
if let Err(e) = known.save() {
|
|
eprintln!("forget-host: {e:#}");
|
|
return glib::ExitCode::FAILURE;
|
|
}
|
|
}
|
|
println!("forgot {removed}");
|
|
glib::ExitCode::SUCCESS
|
|
}
|
|
|
|
/// `--reset` — clear this device's client state: the saved known-hosts and the stream
|
|
/// settings. The persistent IDENTITY (`client-cert.pem`/`client-key.pem`) is deliberately
|
|
/// KEPT so the box isn't seen as a brand-new device everywhere (a re-pair still re-adds hosts);
|
|
/// a caller wanting a true factory reset removes those separately. Missing files are fine.
|
|
pub fn headless_reset() -> glib::ExitCode {
|
|
let Ok(dir) = crate::trust::config_dir() else {
|
|
eprintln!("reset: could not resolve config dir (HOME unset?)");
|
|
return glib::ExitCode::FAILURE;
|
|
};
|
|
let mut ok = true;
|
|
for name in ["client-known-hosts.json", "client-gtk-settings.json"] {
|
|
match std::fs::remove_file(dir.join(name)) {
|
|
Ok(()) => {}
|
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
|
|
Err(e) => {
|
|
eprintln!("reset: {name}: {e}");
|
|
ok = false;
|
|
}
|
|
}
|
|
}
|
|
if ok {
|
|
println!("reset");
|
|
glib::ExitCode::SUCCESS
|
|
} else {
|
|
glib::ExitCode::FAILURE
|
|
}
|
|
}
|
|
|
|
/// The version this binary was built as — the CI-stamped string (`0.23.0~ci10250.gab12cd34`
|
|
/// and friends) when built by a packaging job, the crate version otherwise. See `build.rs`.
|
|
pub fn version_string() -> &'static str {
|
|
env!("PUNKTFUNK_VERSION")
|
|
}
|
|
|
|
/// `--version` — one line, so a packaging script's run-the-binary gate and the Decky plugin
|
|
/// can both ask "what is installed here?" without a display or a config dir.
|
|
fn headless_version() -> glib::ExitCode {
|
|
println!("punktfunk-client {}", version_string());
|
|
glib::ExitCode::SUCCESS
|
|
}
|
|
|
|
/// `--check-update [--json]` — is a newer client available for this box's channel, and can
|
|
/// anything here install it? The answer comes from the same Ed25519-signed manifest the host
|
|
/// checks (`pf_client_core::update`); this is only its presentation.
|
|
///
|
|
/// Exit code carries the verdict for scripts: 0 = up to date, 10 = an update is available,
|
|
/// 1 = the check could not be completed (offline, bad signature, disabled). The distinction
|
|
/// matters — "could not tell" must never be scripted as "up to date".
|
|
fn headless_check_update() -> glib::ExitCode {
|
|
let status = pf_client_core::update::check(version_string());
|
|
if arg_flag("--json") {
|
|
match serde_json::to_string(&status) {
|
|
Ok(s) => println!("{s}"),
|
|
Err(e) => {
|
|
eprintln!("check-update: {e}");
|
|
return glib::ExitCode::FAILURE;
|
|
}
|
|
}
|
|
} else {
|
|
println!(
|
|
"installed {} ({}, {})",
|
|
status.current, status.kind, status.channel
|
|
);
|
|
// `latest` falls back to `current` when the check couldn't run — printing that as
|
|
// "available" would read as a confirmed answer we don't have.
|
|
if status.error.is_some() {
|
|
println!("available unknown");
|
|
} else {
|
|
println!("available {}", status.latest);
|
|
}
|
|
if status.not_published {
|
|
// Says what it is, in words, instead of a raw HTTP status. The exit code still
|
|
// reports "could not tell" (see the doc comment above): an empty channel is the
|
|
// absence of evidence that this build is current, and a mistyped
|
|
// PUNKTFUNK_UPDATE_FEED is indistinguishable from one out here.
|
|
println!(
|
|
"update nothing published on the {} channel yet",
|
|
status.channel
|
|
);
|
|
} else if let Some(err) = &status.error {
|
|
eprintln!("check-update: {err}");
|
|
} else if status.update_available {
|
|
println!("update yes");
|
|
println!("apply {}", update_apply_line(&status));
|
|
} else {
|
|
println!("update no");
|
|
}
|
|
if let Some(hint) = &status.opt_in_hint {
|
|
println!("opt-in {hint}");
|
|
}
|
|
}
|
|
if status.error.is_some() {
|
|
glib::ExitCode::FAILURE
|
|
} else if status.update_available {
|
|
// Distinct from both success and failure so `--check-update` is scriptable.
|
|
glib::ExitCode::from(10u8)
|
|
} else {
|
|
glib::ExitCode::SUCCESS
|
|
}
|
|
}
|
|
|
|
/// The human line describing how an update would be applied.
|
|
fn update_apply_line(status: &pf_client_core::update::Status) -> String {
|
|
use pf_client_core::update::Applier;
|
|
match status.applier {
|
|
Applier::Helper => format!("punktfunk-client --apply-update ({})", status.command),
|
|
Applier::Flatpak => status.command.clone(),
|
|
Applier::None => status.command.clone(),
|
|
}
|
|
}
|
|
|
|
/// `--apply-update [--json]` — drive the packaged root helper for this install. Refuses (with
|
|
/// the command to run instead) for every kind it does not own; see
|
|
/// `pf_client_core::update::apply`.
|
|
fn headless_apply_update() -> glib::ExitCode {
|
|
let outcome = pf_client_core::update::apply(version_string());
|
|
if arg_flag("--json") {
|
|
match serde_json::to_string(&outcome) {
|
|
Ok(s) => println!("{s}"),
|
|
Err(e) => {
|
|
eprintln!("apply-update: {e}");
|
|
return glib::ExitCode::FAILURE;
|
|
}
|
|
}
|
|
} else if let Some(err) = &outcome.error {
|
|
eprintln!("apply-update: {err}");
|
|
} else if outcome.staged {
|
|
println!(
|
|
"staged {} -> {} (reboot to finish)",
|
|
outcome.before, outcome.after
|
|
);
|
|
} else if outcome.changed {
|
|
println!("updated {} -> {}", outcome.before, outcome.after);
|
|
} else {
|
|
println!("already up to date ({})", outcome.before);
|
|
}
|
|
if outcome.ok {
|
|
glib::ExitCode::SUCCESS
|
|
} else {
|
|
glib::ExitCode::FAILURE
|
|
}
|
|
}
|
|
|
|
/// Dispatch the headless host-store modes (returns `None` when argv names none of them, so the
|
|
/// caller proceeds to launch the GTK app). Kept in one place so `arg_flag` stays private and the
|
|
/// dispatch in `app.rs` is a single line.
|
|
pub fn headless_host_command() -> Option<glib::ExitCode> {
|
|
if arg_flag("--list-hosts") {
|
|
return Some(headless_list_hosts());
|
|
}
|
|
if let Some(t) = arg_value("--reachable") {
|
|
return Some(headless_reachable(&t));
|
|
}
|
|
if let Some(t) = arg_value("--add-host") {
|
|
return Some(headless_add_host(&t));
|
|
}
|
|
if let Some(s) = arg_value("--set-host") {
|
|
return Some(headless_set_host(&s));
|
|
}
|
|
if let Some(s) = arg_value("--forget-host") {
|
|
return Some(headless_forget_host(&s));
|
|
}
|
|
if arg_flag("--reset") {
|
|
return Some(headless_reset());
|
|
}
|
|
if arg_flag("--version") {
|
|
return Some(headless_version());
|
|
}
|
|
if arg_flag("--check-update") {
|
|
return Some(headless_check_update());
|
|
}
|
|
if arg_flag("--apply-update") {
|
|
return Some(headless_apply_update());
|
|
}
|
|
None
|
|
}
|
|
|
|
/// `PUNKTFUNK_SHOT_SCENE`, when set, selects a scripted host-free scene for CI screenshots.
|
|
pub 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 settled. When `PUNKTFUNK_SHOT_OUT=/path.png` is set
|
|
/// the app CAPTURES ITSELF (widget snapshot → gsk render → PNG) — no Xvfb/ImageMagick
|
|
/// needed. The stream and gamepad-library scenes are gone with the pages (both live in
|
|
/// the session binary now).
|
|
pub fn run_shot(ctx: &ShotCtx, scene: &str) {
|
|
let sender = &ctx.sender;
|
|
// A plausible host for the trust/pair dialogs (fp_hex = 64 hex chars).
|
|
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,
|
|
launch: None,
|
|
mac: Vec::new(),
|
|
profile: None,
|
|
};
|
|
let mock_advert =
|
|
|key: &str, name: &str, addr: &str, fp: &str| crate::discovery::DiscoveredHost {
|
|
key: key.to_string(),
|
|
fullname: format!("{key}._punktfunk._udp.local."),
|
|
name: name.to_string(),
|
|
addr: addr.to_string(),
|
|
port: 9777,
|
|
fp_hex: fp.to_string(),
|
|
pair: "required".to_string(),
|
|
mgmt_port: None,
|
|
mac: Vec::new(),
|
|
os: "linux/arch/steamos".to_string(),
|
|
};
|
|
|
|
// What the self-capture renders: the main window, except for scenes that open their
|
|
// own toplevel (the shortcuts window).
|
|
let mut target: gtk::Widget = ctx.window.clone().upcast();
|
|
let hosts = &ctx.hosts;
|
|
match scene {
|
|
// Saved hosts come from the seeded known-hosts store; on top, inject synthetic
|
|
// adverts through the same path the mDNS stream feeds.
|
|
"hosts" | "02-hosts" => {
|
|
let _ = hosts.send(HostsMsg::Advert(mock_advert(
|
|
"mock-online",
|
|
"Living Room PC",
|
|
"192.168.1.42",
|
|
"9f8e7d6c5b4a39281706f5e4d3c2b1a0998877665544332211ffeeddccbbaa00",
|
|
)));
|
|
let _ = hosts.send(HostsMsg::Advert(mock_advert(
|
|
"mock-new",
|
|
"steamdeck",
|
|
"192.168.1.77",
|
|
"00aabbccddeeff112233445566778899a0b1c2d3e4f5061728394a5b6c7d8e9f",
|
|
)));
|
|
}
|
|
"about" | "08-about" => {
|
|
crate::ui_settings::show_about(&ctx.window);
|
|
}
|
|
"settings" | "03-settings" => {
|
|
// Mock devices so the shot shows the probe-dependent pickers populated.
|
|
let dev = |name: &str, description: &str| pf_client_core::audio::AudioDevice {
|
|
name: name.to_string(),
|
|
description: description.to_string(),
|
|
};
|
|
let probes = crate::ui_settings::DeviceProbes {
|
|
adapters: vec![
|
|
"NVIDIA GeForce RTX 4070".to_string(),
|
|
"AMD Radeon 780M".to_string(),
|
|
],
|
|
speakers: vec![dev("alsa_output.mock-hdmi", "HDMI / DisplayPort Audio")],
|
|
mics: vec![dev("alsa_input.mock-usb", "USB Microphone Analog Stereo")],
|
|
};
|
|
// `PUNKTFUNK_SHOT_SETTINGS_SCOPE=<profile id|name>` captures the dialog in
|
|
// PROFILE scope — the second half of the settings surface (design
|
|
// client-settings-profiles.md §5.1), where only profileable rows render.
|
|
let scope = std::env::var("PUNKTFUNK_SHOT_SETTINGS_SCOPE")
|
|
.ok()
|
|
.filter(|v| !v.is_empty())
|
|
.and_then(|reference| {
|
|
pf_client_core::profiles::ProfilesFile::load()
|
|
.resolve(&reference)
|
|
.0
|
|
.map(|p| crate::ui_settings::Scope::Profile(p.id.clone()))
|
|
})
|
|
.unwrap_or(crate::ui_settings::Scope::Defaults);
|
|
let dialog = crate::ui_settings::show_scoped(
|
|
&ctx.window,
|
|
ctx.settings.clone(),
|
|
&ctx.gamepad,
|
|
&probes,
|
|
scope,
|
|
|_| {},
|
|
|| {},
|
|
);
|
|
// Optional page for the capture (general/display/input/audio/controllers);
|
|
// the dialog opens on General otherwise.
|
|
if let Ok(page) = std::env::var("PUNKTFUNK_SHOT_SETTINGS_PAGE") {
|
|
if !page.is_empty() {
|
|
use adw::prelude::PreferencesDialogExt as _;
|
|
dialog.set_visible_page_name(&page);
|
|
}
|
|
}
|
|
}
|
|
"trust" | "04-trust" => crate::ui_trust::tofu_dialog(&ctx.window, sender, mock_req()),
|
|
"pair" | "05-pair" => {
|
|
crate::ui_trust::pin_dialog(&ctx.window, sender, ctx.identity.clone(), mock_req())
|
|
}
|
|
"addhost" | "06-addhost" => {
|
|
let _ = hosts.send(HostsMsg::ShowAddHost);
|
|
}
|
|
"shortcuts" | "07-shortcuts" => {
|
|
let w = crate::app::shortcuts_window(&ctx.window);
|
|
w.present();
|
|
target = w.upcast();
|
|
}
|
|
// The library page with injected entries: mixed stores exercising the badge set,
|
|
// no-art placeholders, and one solid-color texture standing in for a poster.
|
|
"library" | "08-library" => {
|
|
let (games, art) = mock_library();
|
|
crate::ui_library::open_mock(
|
|
&ctx.nav,
|
|
ctx.identity.clone(),
|
|
sender,
|
|
mock_req(),
|
|
games,
|
|
art,
|
|
);
|
|
}
|
|
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 _;
|
|
// Self-capture of the dialog scenes (trust/pair/settings/addhost) needs a GL
|
|
// renderer: `WidgetPaintable(window)` under the cairo software renderer doesn't
|
|
// composite the `AdwDialog` overlay layer (the dialog IS presented — the
|
|
// page-content scenes capture fine either way; CI uses GL or the X11 root-grab).
|
|
let self_capture = std::env::var("PUNKTFUNK_SHOT_OUT")
|
|
.ok()
|
|
.filter(|p| !p.is_empty());
|
|
if let Some(out) = &self_capture {
|
|
if let Err(e) = save_png(&target, out) {
|
|
eprintln!("PF_SHOT_ERROR scene={scene}: {e:#}");
|
|
}
|
|
}
|
|
println!("PF_SHOT_READY scene={scene}");
|
|
let _ = std::io::stdout().flush();
|
|
// Self-capture mode: the shot is on disk — exit so back-to-back scene runs
|
|
// don't stack windows on a live desktop.
|
|
if self_capture.is_some() {
|
|
std::process::exit(0);
|
|
}
|
|
});
|
|
}
|
|
|
|
/// The mock game set for the `library` scene: mixed stores exercising the badge set,
|
|
/// plus one solid-colour poster texture.
|
|
fn mock_library() -> (
|
|
Vec<crate::library::GameEntry>,
|
|
Vec<(String, gtk::gdk::Texture)>,
|
|
) {
|
|
let game = |id: &str, store: &str, title: &str| crate::library::GameEntry {
|
|
id: id.to_string(),
|
|
store: store.to_string(),
|
|
title: title.to_string(),
|
|
art: crate::library::Artwork::default(),
|
|
platform: None,
|
|
};
|
|
let games = vec![
|
|
game("steam:570", "steam", "Dota 2"),
|
|
game("steam:1091500", "steam", "Cyberpunk 2077"),
|
|
game("custom:emu-1", "custom", "RetroArch"),
|
|
game("heroic:fortnite", "heroic", "Fortnite"),
|
|
game("gog:witcher3", "gog", "The Witcher 3"),
|
|
game("lutris:osu", "lutris", "osu!"),
|
|
];
|
|
let art = vec![(
|
|
"steam:570".to_string(),
|
|
solid_texture(300, 450, 0x35, 0x84, 0xe4),
|
|
)];
|
|
(games, art)
|
|
}
|
|
|
|
/// A WxH single-colour RGBA texture — the `library` scene's stand-in for a fetched poster.
|
|
fn solid_texture(w: i32, h: i32, r: u8, g: u8, b: u8) -> gtk::gdk::Texture {
|
|
let px = [r, g, b, 0xff].repeat((w * h) as usize);
|
|
gtk::gdk::MemoryTexture::new(
|
|
w,
|
|
h,
|
|
gtk::gdk::MemoryFormat::R8g8b8a8,
|
|
&glib::Bytes::from_owned(px),
|
|
(w * 4) as usize,
|
|
)
|
|
.upcast()
|
|
}
|
|
|
|
/// Snapshot `widget` (the whole window, dialogs included) into a PNG: WidgetPaintable →
|
|
/// `gtk::Snapshot` → the realized native's gsk renderer → `GdkTexture::save_to_png`.
|
|
fn save_png(widget: >k::Widget, path: &str) -> anyhow::Result<()> {
|
|
use anyhow::Context as _;
|
|
let (w, h) = (widget.width(), widget.height());
|
|
anyhow::ensure!(w > 0 && h > 0, "widget not laid out yet ({w}x{h})");
|
|
let paintable = gtk::WidgetPaintable::new(Some(widget));
|
|
let snapshot = gtk::Snapshot::new();
|
|
paintable.snapshot(&snapshot, f64::from(w), f64::from(h));
|
|
let node = snapshot.to_node().context("empty snapshot")?;
|
|
let renderer = widget
|
|
.native()
|
|
.context("widget not realized")?
|
|
.renderer()
|
|
.context("no gsk renderer")?;
|
|
let texture = renderer.render_texture(node, None);
|
|
texture
|
|
.save_to_png(path)
|
|
.with_context(|| format!("save {path}"))?;
|
|
Ok(())
|
|
}
|