Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0d407a866d | ||
|
|
bf2d8505cf | ||
|
|
414380fc9e | ||
|
|
6267dcdcd3 | ||
|
|
8042a2fd52 | ||
|
|
7e40098bc6 | ||
|
|
ac5299d4ce | ||
|
|
017c37b78a | ||
|
|
2fd303e22f | ||
|
|
f84c5b8114 | ||
|
|
aec02b9d26 | ||
|
|
48bb1769b4 |
@@ -79,7 +79,7 @@ final class GamepadWireTests: XCTestCase {
|
||||
XCTAssertEqual(GamepadWire.axisRSY, UInt32(PUNKTFUNK_AXIS_RS_Y))
|
||||
XCTAssertEqual(GamepadWire.axisLT, UInt32(PUNKTFUNK_AXIS_LT))
|
||||
XCTAssertEqual(GamepadWire.axisRT, UInt32(PUNKTFUNK_AXIS_RT))
|
||||
XCTAssertEqual(GamepadWire.maxPads, Int(PUNKTFUNK_MAX_PADS))
|
||||
XCTAssertEqual(GamepadWire.maxPads, Int(MAX_PADS))
|
||||
}
|
||||
|
||||
func testPadIndexRidesFlagsOnEveryPerPadEvent() {
|
||||
|
||||
+399
-12
@@ -41,16 +41,23 @@ mod cli {
|
||||
|
||||
const PROBE_TIMEOUT: Duration = Duration::from_millis(2500);
|
||||
|
||||
/// The handshake budget `--request-access` runs on. Matches the host's `PENDING_APPROVAL_WAIT`
|
||||
/// — the connect is PARKED for that long while an operator decides, so anything shorter would
|
||||
/// give up while the approval prompt is still on their screen.
|
||||
const REQUEST_ACCESS_TIMEOUT_SECS: u64 = 185;
|
||||
|
||||
const USAGE: &str = "\
|
||||
punktfunk — the Punktfunk client, headless
|
||||
|
||||
punktfunk discover [--json] [--timeout SECS]
|
||||
punktfunk pair <host[:port]> [--pin N] [--name LABEL]
|
||||
punktfunk hosts list [--probe] [--json]
|
||||
punktfunk hosts add <host[:port]> [--name LABEL] [--fp HEX]
|
||||
punktfunk hosts forget <host-ref>
|
||||
punktfunk wake <host-ref> [--wait]
|
||||
punktfunk library <host-ref> [--json]
|
||||
punktfunk launch <host-ref> [--game ID] [--profile REF] [--exec] [--fullscreen]
|
||||
punktfunk launch <host-ref> [--game ID] [--profile REF] [--request-access]
|
||||
[--exec] [--fullscreen]
|
||||
punktfunk open <punktfunk://…>
|
||||
punktfunk reachable <host-ref>
|
||||
punktfunk speed-test <host-ref>
|
||||
@@ -68,6 +75,24 @@ punktfunk:// link takes. Exit codes: 0 ok, 2 connect, 3 trust, 4 renderer, 5 not
|
||||
/// (what goes to stdout vs stderr, and which exit codes mean what).
|
||||
fn verb_help(verb: &str) -> Option<&'static str> {
|
||||
Some(match verb {
|
||||
"discover" => {
|
||||
"\
|
||||
punktfunk discover [--json] [--timeout SECS] — browse the LAN for hosts
|
||||
|
||||
Listens for Punktfunk hosts advertising over mDNS and prints what answered:
|
||||
name TAB addr:port TAB saved|new TAB paired|unpaired. `saved` means this
|
||||
device already has a record for it, matched by fingerprint first and address
|
||||
second — the same rule every other surface joins the two lists by.
|
||||
|
||||
--timeout SECS how long to browse (default 3, capped at 30) — a bounded
|
||||
call, so a panel can wait for it
|
||||
--json {\"hosts\":[{\"name\",\"addr\",\"port\",\"fp\",\"pair\",\"id\",\"mgmt\",
|
||||
\"os\",\"saved\",\"paired\"}]}
|
||||
|
||||
Nothing answering is an answer, not a failure: an empty list exits 0. A host
|
||||
mDNS never sees (Tailscale, another subnet) will not appear here — save it by
|
||||
address with `punktfunk hosts add` and it shows in `hosts list --probe`."
|
||||
}
|
||||
"pair" => {
|
||||
"\
|
||||
punktfunk pair <host[:port]> — enrol this device with a host (PIN ceremony)
|
||||
@@ -96,6 +121,13 @@ punktfunk hosts — the saved-hosts store (shared with the desktop client)
|
||||
another subnet). Without --fp it is a placeholder to pair later; with a
|
||||
64-hex fingerprint it is pinned immediately (still unpaired).
|
||||
|
||||
Idempotent, and keyed on the FINGERPRINT once there is one: re-running it
|
||||
for a host already saved is a no-op, and giving a known fingerprint a new
|
||||
address MOVES that host's record there rather than filing a second one
|
||||
(which is how a host that changed DHCP lease stays reachable by its id).
|
||||
A different fingerprint for an address already saved is refused, exit 3 —
|
||||
a changed identity is a decision for a person.
|
||||
|
||||
punktfunk hosts forget <host-ref>
|
||||
Remove a saved host, its pinned fingerprint included. A later connect
|
||||
must pair or trust it again."
|
||||
@@ -119,7 +151,8 @@ this. Needs a paired host (exit 6 otherwise)."
|
||||
}
|
||||
"launch" => {
|
||||
"\
|
||||
punktfunk launch <host-ref> [--game ID] [--profile REF] [--exec] [--fullscreen]
|
||||
punktfunk launch <host-ref> [--game ID] [--profile REF] [--request-access]
|
||||
[--exec] [--fullscreen]
|
||||
|
||||
Start a stream — waking the host first if it is asleep and its MAC is known.
|
||||
The stream runs in the punktfunk-session renderer; this command supervises it
|
||||
@@ -132,6 +165,16 @@ and relays its lifecycle to stderr.
|
||||
--exec become the session process instead of supervising it — the
|
||||
gamescope-wrapper mode, where the launched process must BE
|
||||
the streaming one for focus and lifecycle to work
|
||||
--request-access
|
||||
ask the host's operator to let this device in instead of
|
||||
typing a PIN. The host PARKS the connect until somebody
|
||||
approves it in its console or web UI (up to ~185 s), then
|
||||
admits it and the stream starts by itself; the host is
|
||||
recorded as paired once that happens, so later streams are
|
||||
silent. Needs the host's fingerprint pinned already
|
||||
(`punktfunk hosts add <addr> --fp <hex>`), and cannot be
|
||||
combined with --exec — under --exec there is no process
|
||||
left to record the approval.
|
||||
|
||||
Exit 0 when the stream ends cleanly, 2 connect failed, 3 the host no longer
|
||||
trusts this device (re-pair), 4 the renderer could not start."
|
||||
@@ -222,7 +265,7 @@ from the config directory for a true factory reset."
|
||||
fn flag_takes_value(flag: &str) -> bool {
|
||||
matches!(
|
||||
flag,
|
||||
"--pin" | "--name" | "--fp" | "--game" | "--profile" | "--port"
|
||||
"--pin" | "--name" | "--fp" | "--game" | "--profile" | "--port" | "--timeout"
|
||||
)
|
||||
}
|
||||
|
||||
@@ -269,6 +312,7 @@ from the config directory for a true factory reset."
|
||||
return OK;
|
||||
}
|
||||
match verb.as_str() {
|
||||
"discover" => discover(&rest),
|
||||
"pair" => pair(&rest),
|
||||
"hosts" => hosts(&rest),
|
||||
"wake" => wake(&rest),
|
||||
@@ -306,6 +350,104 @@ from the config directory for a true factory reset."
|
||||
}
|
||||
}
|
||||
|
||||
/// How long `discover` browses when nobody says, and the ceiling on what they can ask for.
|
||||
/// The cap is not politeness: this verb is called from a Quick Access panel, and a typo'd
|
||||
/// `--timeout 3000` would hang that panel with no way to cancel it.
|
||||
const DISCOVER_DEFAULT_SECS: f64 = 3.0;
|
||||
const DISCOVER_MAX_SECS: f64 = 30.0;
|
||||
|
||||
/// `discover [--json] [--timeout SECS]` — browse the LAN over mDNS and print what answered,
|
||||
/// annotated against the saved-hosts store.
|
||||
///
|
||||
/// The annotation is the point: a caller wants "can I stream this", which is a question
|
||||
/// about BOTH lists, and joining them itself is how two surfaces end up disagreeing about
|
||||
/// the same host. So the match rule lives here, once, and is the same one every other
|
||||
/// surface uses — fingerprint first (survives a DHCP move), address second.
|
||||
fn discover(args: &[String]) -> u8 {
|
||||
let secs = value(args, "--timeout")
|
||||
.and_then(|v| v.parse::<f64>().ok())
|
||||
.filter(|s| *s > 0.0)
|
||||
.unwrap_or(DISCOVER_DEFAULT_SECS)
|
||||
.min(DISCOVER_MAX_SECS);
|
||||
let found = pf_client_core::discovery::discover_for(Duration::from_secs_f64(secs));
|
||||
// `read`, not `load`: this verb only LOOKS at the records to annotate what it found, and
|
||||
// never hands their ids back. `load` would mint ids for a pre-mint store and save them —
|
||||
// a write from a read-only verb, and one that races the `hosts list` a caller is very
|
||||
// likely running at the same moment (the Decky panel issues both together).
|
||||
let known = KnownHosts::read();
|
||||
let rows: Vec<(
|
||||
&pf_client_core::discovery::DiscoveredHost,
|
||||
Option<&KnownHost>,
|
||||
)> = found.iter().map(|d| (d, match_saved(&known, d))).collect();
|
||||
if has(args, "--json") {
|
||||
let hosts: Vec<serde_json::Value> = rows
|
||||
.iter()
|
||||
.map(|(d, saved)| {
|
||||
serde_json::json!({
|
||||
"name": d.name,
|
||||
"addr": d.addr,
|
||||
"port": d.port,
|
||||
"fp": d.fp_hex,
|
||||
"pair": d.pair,
|
||||
"id": d.advertised_id(),
|
||||
// 0 = not advertised, which is what a consumer's own "no mgmt port"
|
||||
// already means — an older host simply omits the TXT.
|
||||
"mgmt": d.mgmt_port.unwrap_or(0),
|
||||
"os": d.os,
|
||||
"saved": saved.is_some(),
|
||||
"paired": saved.is_some_and(|h| h.paired),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
println!("{}", serde_json::json!({ "hosts": hosts }));
|
||||
} else {
|
||||
for (d, saved) in &rows {
|
||||
println!(
|
||||
"{}\t{}:{}\t{}\t{}",
|
||||
d.name,
|
||||
d.addr,
|
||||
d.port,
|
||||
if saved.is_some() { "saved" } else { "new" },
|
||||
if saved.is_some_and(|h| h.paired) {
|
||||
"paired"
|
||||
} else {
|
||||
"unpaired"
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
// An empty LAN is an answer, not a failure — a caller branching on the exit code is
|
||||
// asking "did the browse run", and it did.
|
||||
OK
|
||||
}
|
||||
|
||||
/// The saved record an advert belongs to, if any: fingerprint first, address second.
|
||||
///
|
||||
/// Fingerprint FIRST is deliberate and load-bearing — a host that moved to a new DHCP lease
|
||||
/// still matches its record, and a *different* host that inherited the old address does not
|
||||
/// inherit its pairing. This is the rule the plugin's `mergeHosts` and the shells' hosts
|
||||
/// pages already use; keeping one copy is what stops two surfaces disagreeing about whether
|
||||
/// the box in front of you is paired.
|
||||
fn match_saved<'a>(
|
||||
known: &'a KnownHosts,
|
||||
advert: &pf_client_core::discovery::DiscoveredHost,
|
||||
) -> Option<&'a KnownHost> {
|
||||
known
|
||||
.hosts
|
||||
.iter()
|
||||
.find(|h| {
|
||||
!h.fp_hex.is_empty()
|
||||
&& !advert.fp_hex.is_empty()
|
||||
&& h.fp_hex.eq_ignore_ascii_case(&advert.fp_hex)
|
||||
})
|
||||
.or_else(|| {
|
||||
known
|
||||
.hosts
|
||||
.iter()
|
||||
.find(|h| h.addr == advert.addr && h.port == advert.port)
|
||||
})
|
||||
}
|
||||
|
||||
/// `pair <host[:port]> [--pin N]` — the SPAKE2 ceremony. Without `--pin` it prompts, which
|
||||
/// is the interactive shape; with one it is scriptable. Refuses rather than prompting when
|
||||
/// stdin isn't a terminal and no PIN was given: a pairing that silently blocks a CI job
|
||||
@@ -424,16 +566,69 @@ from the config directory for a true factory reset."
|
||||
return UNRESOLVED;
|
||||
};
|
||||
let (addr, port) = split_host_port(&target);
|
||||
let fp = value(args, "--fp").unwrap_or_default();
|
||||
let name = value(args, "--name");
|
||||
let mut known = KnownHosts::load();
|
||||
if known.hosts.iter().any(|h| h.addr == addr && h.port == port) {
|
||||
eprintln!("{addr}:{port} is already saved");
|
||||
return OK;
|
||||
if let Some(i) = known
|
||||
.hosts
|
||||
.iter()
|
||||
.position(|h| h.addr == addr && h.port == port)
|
||||
{
|
||||
return match merge_saved_host(&mut known, i, &fp, name.as_deref()) {
|
||||
AddOutcome::Unchanged => {
|
||||
eprintln!("{addr}:{port} is already saved");
|
||||
OK
|
||||
}
|
||||
AddOutcome::Conflict => {
|
||||
eprintln!(
|
||||
"{addr}:{port} is already saved with a different fingerprint — \
|
||||
forget it first if you really mean to replace it \
|
||||
(punktfunk hosts forget {addr}:{port})"
|
||||
);
|
||||
TRUST_REJECTED
|
||||
}
|
||||
AddOutcome::Pinned => match known.save() {
|
||||
Ok(()) => {
|
||||
println!("updated {addr}:{port}");
|
||||
OK
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("saving: {e:#}");
|
||||
CONNECT_FAILED
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
// No record at this address — but a record carrying this exact FINGERPRINT is
|
||||
// this same host at a new one. Re-point it rather than filing a second record:
|
||||
// the fingerprint is the identity, and a host that changed DHCP lease is the
|
||||
// whole reason `hosts add --fp` is idempotent in the first place. Without this a
|
||||
// moved host accumulates one record per address it has ever held, and the one a
|
||||
// stable id resolves to keeps the address it can no longer be reached at.
|
||||
if let Some(i) = known
|
||||
.hosts
|
||||
.iter()
|
||||
.position(|h| !fp.is_empty() && h.fp_hex.eq_ignore_ascii_case(&fp))
|
||||
{
|
||||
let was = format!("{}:{}", known.hosts[i].addr, known.hosts[i].port);
|
||||
known.hosts[i].addr = addr.clone();
|
||||
known.hosts[i].port = port;
|
||||
return match known.save() {
|
||||
Ok(()) => {
|
||||
println!("moved {was} to {addr}:{port}");
|
||||
OK
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("saving: {e:#}");
|
||||
CONNECT_FAILED
|
||||
}
|
||||
};
|
||||
}
|
||||
known.hosts.push(KnownHost {
|
||||
name: value(args, "--name").unwrap_or_else(|| addr.clone()),
|
||||
name: name.unwrap_or_else(|| addr.clone()),
|
||||
addr: addr.clone(),
|
||||
port,
|
||||
fp_hex: value(args, "--fp").unwrap_or_default(),
|
||||
fp_hex: fp,
|
||||
..Default::default()
|
||||
});
|
||||
match known.save() {
|
||||
@@ -475,6 +670,55 @@ from the config directory for a true factory reset."
|
||||
}
|
||||
}
|
||||
|
||||
/// What `hosts add` did to a record that was ALREADY saved for this address.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
enum AddOutcome {
|
||||
/// Nothing to do — no fingerprint was offered, or the record already carries this one.
|
||||
/// Exits 0 on purpose: a panel retrying step 1 of request access must not have to
|
||||
/// invent an error to show for a state that is already correct.
|
||||
Unchanged,
|
||||
/// The record had no fingerprint and now has this one.
|
||||
Pinned,
|
||||
/// The record carries a DIFFERENT fingerprint. Refused, never overwritten.
|
||||
Conflict,
|
||||
}
|
||||
|
||||
/// `hosts add --fp` against an address that is already saved. The difference between these
|
||||
/// three is a trust decision, not bookkeeping.
|
||||
///
|
||||
/// Filling in an empty fingerprint is step 1 of request access (design §5): a host found by
|
||||
/// advert is saved by address first and pinned second. Without it the `--fp` is dropped on
|
||||
/// the floor and the launch that follows refuses for want of a pin — which is what this did
|
||||
/// before, silently and with exit 0.
|
||||
///
|
||||
/// A *different* fingerprint is refused because a changed identity is a decision for a
|
||||
/// person, at a surface that can show them both. That is what `upsert_trusted` exists to
|
||||
/// enforce; quietly overwriting it here would be a back door through the pinning the rest
|
||||
/// of the client is built on.
|
||||
fn merge_saved_host(
|
||||
known: &mut KnownHosts,
|
||||
i: usize,
|
||||
fp: &str,
|
||||
name: Option<&str>,
|
||||
) -> AddOutcome {
|
||||
let existing = known.hosts[i].fp_hex.clone();
|
||||
if fp.is_empty() || existing.eq_ignore_ascii_case(fp) {
|
||||
return AddOutcome::Unchanged;
|
||||
}
|
||||
if !existing.is_empty() {
|
||||
return AddOutcome::Conflict;
|
||||
}
|
||||
known.hosts[i].fp_hex = fp.to_string();
|
||||
// Only a record still named after its own address is renamed: a label the user chose is
|
||||
// theirs, and an advert's name must not quietly overwrite it.
|
||||
if let Some(label) = name {
|
||||
if known.hosts[i].name == known.hosts[i].addr {
|
||||
known.hosts[i].name = label.to_string();
|
||||
}
|
||||
}
|
||||
AddOutcome::Pinned
|
||||
}
|
||||
|
||||
/// `wake <host-ref> [--wait]` — a magic packet, and with `--wait` the same bounded
|
||||
/// wake-and-wait the shells run (`WakeWait`: a packet every 6 s, presence polled every
|
||||
/// second, 90 s budget).
|
||||
@@ -585,6 +829,19 @@ from the config directory for a true factory reset."
|
||||
eprintln!("usage: punktfunk launch <host-ref> [--game ID] [--profile REF] [--exec]");
|
||||
return UNRESOLVED;
|
||||
};
|
||||
let exec = has(args, "--exec");
|
||||
let request_access = has(args, "--request-access");
|
||||
// Refused rather than silently downgraded: under `--exec` this process BECOMES the
|
||||
// session, so nothing survives to see `Ready` and record the approval. A launch that
|
||||
// quietly dropped the persistence would leave hosts reading "trusted" forever with
|
||||
// nobody able to say why.
|
||||
if request_access && exec {
|
||||
eprintln!(
|
||||
"--request-access can't be combined with --exec: under --exec there is no \
|
||||
process left to record the host's approval"
|
||||
);
|
||||
return UNRESOLVED;
|
||||
}
|
||||
let (known, i) = match resolve(&reference) {
|
||||
Ok(v) => v,
|
||||
Err(code) => return code,
|
||||
@@ -597,7 +854,10 @@ from the config directory for a true factory reset."
|
||||
if has(args, "--fullscreen") {
|
||||
plan.settings.fullscreen_on_stream = true;
|
||||
}
|
||||
run_plan(plan, has(args, "--exec"))
|
||||
if request_access {
|
||||
plan.connect_timeout_secs = Some(REQUEST_ACCESS_TIMEOUT_SECS);
|
||||
}
|
||||
run_plan(plan, exec, request_access)
|
||||
}
|
||||
|
||||
/// `open <url>` — the `punktfunk://` grammar, headless. Same parser, same refusal rules and
|
||||
@@ -622,7 +882,7 @@ from the config directory for a true factory reset."
|
||||
&trust::Settings::load(),
|
||||
);
|
||||
match outcome {
|
||||
Ok(PlanOutcome::Connect(plan)) => run_plan(*plan, has(args, "--exec")),
|
||||
Ok(PlanOutcome::Connect(plan)) => run_plan(*plan, has(args, "--exec"), false),
|
||||
// A URL may never pair or trust on its own — that is a decision for a person, at a
|
||||
// surface that can show them the fingerprint.
|
||||
Ok(PlanOutcome::ConfirmUnknown(u)) => {
|
||||
@@ -646,7 +906,13 @@ from the config directory for a true factory reset."
|
||||
}
|
||||
|
||||
/// Wake if needed, then run the session — supervising it, or becoming it under `--exec`.
|
||||
fn run_plan(plan: ConnectPlan, exec: bool) -> u8 {
|
||||
///
|
||||
/// `persist_paired` records the host as *paired* when the child reports ready. Only
|
||||
/// `launch --request-access` passes true: there, the host parked the connect until an
|
||||
/// operator approved this device, so `Ready` IS the approval arriving — the same thing
|
||||
/// `SpawnOpts::persist_paired` means in the GTK shell. Every other launch records nothing,
|
||||
/// which is correct: a plain connect proves reachability, not a new trust decision.
|
||||
fn run_plan(plan: ConnectPlan, exec: bool, persist_paired: bool) -> u8 {
|
||||
if plan.host.fp_hex.is_none() {
|
||||
eprintln!(
|
||||
"{} has no pinned fingerprint — punktfunk pair {}",
|
||||
@@ -708,7 +974,24 @@ from the config directory for a true factory reset."
|
||||
let mut failure: Option<(String, bool)> = None;
|
||||
while let Ok(ev) = rx.recv() {
|
||||
match ev {
|
||||
SessionEvent::Ready => eprintln!("streaming"),
|
||||
SessionEvent::Ready => {
|
||||
eprintln!("streaming");
|
||||
// The pin we connected WITH, not one re-derived from the store: the record
|
||||
// is what we are about to rewrite, and the session proved the host holds
|
||||
// exactly this identity by completing a pinned handshake against it.
|
||||
if persist_paired {
|
||||
if let Some(fp_hex) = &plan.host.fp_hex {
|
||||
trust::persist_host(
|
||||
&plan.host.name,
|
||||
&plan.host.addr,
|
||||
plan.host.port,
|
||||
fp_hex,
|
||||
true,
|
||||
);
|
||||
trust::forget_placeholder(&plan.host.addr, plan.host.port);
|
||||
}
|
||||
}
|
||||
}
|
||||
SessionEvent::Error {
|
||||
msg,
|
||||
trust_rejected,
|
||||
@@ -967,6 +1250,7 @@ from the config directory for a true factory reset."
|
||||
#[test]
|
||||
fn every_usage_verb_has_help() {
|
||||
for verb in [
|
||||
"discover",
|
||||
"pair",
|
||||
"hosts",
|
||||
"wake",
|
||||
@@ -988,6 +1272,109 @@ from the config directory for a true factory reset."
|
||||
assert!(verb_help("bogus").is_none());
|
||||
}
|
||||
|
||||
fn saved(name: &str, addr: &str, fp: &str) -> KnownHost {
|
||||
KnownHost {
|
||||
name: name.into(),
|
||||
addr: addr.into(),
|
||||
port: 9777,
|
||||
fp_hex: fp.into(),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Step 1 of request access: a host saved by address gains the fingerprint its advert
|
||||
/// carried. Before this, `hosts add --fp` on an existing record exited 0 having done
|
||||
/// NOTHING — the launch that followed then refused for want of a pin, and the panel had
|
||||
/// no way to tell why.
|
||||
#[test]
|
||||
fn adding_a_fingerprint_to_a_placeholder_fills_it_in() {
|
||||
let mut known = KnownHosts {
|
||||
hosts: vec![saved("192.168.1.9", "192.168.1.9", "")],
|
||||
};
|
||||
assert_eq!(
|
||||
merge_saved_host(&mut known, 0, "abc123", Some("living-room")),
|
||||
AddOutcome::Pinned
|
||||
);
|
||||
assert_eq!(known.hosts[0].fp_hex, "abc123");
|
||||
assert_eq!(
|
||||
known.hosts[0].name, "living-room",
|
||||
"a record still named after its address takes the offered label"
|
||||
);
|
||||
}
|
||||
|
||||
/// A label the user chose is theirs — an advert's name must not overwrite it.
|
||||
#[test]
|
||||
fn filling_in_a_fingerprint_keeps_a_user_chosen_name() {
|
||||
let mut known = KnownHosts {
|
||||
hosts: vec![saved("Basement rig", "192.168.1.9", "")],
|
||||
};
|
||||
merge_saved_host(&mut known, 0, "abc123", Some("living-room"));
|
||||
assert_eq!(known.hosts[0].name, "Basement rig");
|
||||
}
|
||||
|
||||
/// Idempotent: the panel may retry step 1, and re-offering the fingerprint a record
|
||||
/// already carries is a state that is already correct, not an error to render.
|
||||
#[test]
|
||||
fn re_adding_the_same_fingerprint_changes_nothing() {
|
||||
let mut known = KnownHosts {
|
||||
hosts: vec![saved("desk", "192.168.1.9", "ABC123")],
|
||||
};
|
||||
assert_eq!(
|
||||
merge_saved_host(&mut known, 0, "abc123", None),
|
||||
AddOutcome::Unchanged,
|
||||
"fingerprints compare case-insensitively"
|
||||
);
|
||||
// And a bare `hosts add` with no --fp at all leaves the pin alone.
|
||||
assert_eq!(
|
||||
merge_saved_host(&mut known, 0, "", None),
|
||||
AddOutcome::Unchanged
|
||||
);
|
||||
assert_eq!(known.hosts[0].fp_hex, "ABC123");
|
||||
}
|
||||
|
||||
/// A changed identity is a decision for a person. Never a silent overwrite — this is the
|
||||
/// same rule `upsert_trusted` enforces, and a back door here would defeat it everywhere.
|
||||
#[test]
|
||||
fn a_different_fingerprint_is_refused_not_overwritten() {
|
||||
let mut known = KnownHosts {
|
||||
hosts: vec![saved("desk", "192.168.1.9", "abc123")],
|
||||
};
|
||||
assert_eq!(
|
||||
merge_saved_host(&mut known, 0, "deadbeef", None),
|
||||
AddOutcome::Conflict
|
||||
);
|
||||
assert_eq!(
|
||||
known.hosts[0].fp_hex, "abc123",
|
||||
"the pin must survive intact"
|
||||
);
|
||||
}
|
||||
|
||||
/// A host that changed DHCP lease is re-pointed, not filed a second time. Without this
|
||||
/// the record a stable id resolves to keeps an address the host has left, so a launch
|
||||
/// dials into the void while the panel shows the live one.
|
||||
#[test]
|
||||
fn a_known_fingerprint_at_a_new_address_moves_the_record() {
|
||||
let mut known = KnownHosts {
|
||||
hosts: vec![saved("desk", "192.168.1.9", "abc123")],
|
||||
};
|
||||
// Simulates `hosts add 192.168.1.50 --fp abc123` finding no record at that address.
|
||||
let by_addr = known
|
||||
.hosts
|
||||
.iter()
|
||||
.position(|h| h.addr == "192.168.1.50" && h.port == 9777);
|
||||
assert!(
|
||||
by_addr.is_none(),
|
||||
"the new address is not yet on any record"
|
||||
);
|
||||
let by_fp = known
|
||||
.hosts
|
||||
.iter()
|
||||
.position(|h| h.fp_hex.eq_ignore_ascii_case("abc123"));
|
||||
assert_eq!(by_fp, Some(0), "the fingerprint still identifies the host");
|
||||
known.hosts[0].addr = "192.168.1.50".into();
|
||||
assert_eq!(known.hosts.len(), 1, "one host, one record");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn value_reads_the_argument_after_its_flag() {
|
||||
let a = argv(&["--game", "steam:570", "--exec"]);
|
||||
|
||||
@@ -67,3 +67,21 @@ fn unknown_verbs_refuse_with_the_not_found_code() {
|
||||
let out = punktfunk(&["help", "frobnicate"]);
|
||||
assert_eq!(out.status.code(), Some(5), "unknown help topic exits 5");
|
||||
}
|
||||
|
||||
/// `discover` and `launch --request-access` document themselves. Help only — the verbs
|
||||
/// themselves browse the LAN and dial a host, which no runner may be asked to do.
|
||||
///
|
||||
/// The Decky panel detects a too-old client by exactly the signature the test above pins
|
||||
/// (exit 5 + `unknown command`), so this is the other half of that contract: on a client new
|
||||
/// enough, `discover` is a verb with help rather than an unknown word.
|
||||
#[test]
|
||||
fn the_request_access_surfaces_document_themselves() {
|
||||
let out = punktfunk(&["help", "discover"]);
|
||||
assert!(out.status.success(), "discover has its own help topic");
|
||||
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||
assert!(stdout.contains("--timeout"), "discover documents --timeout");
|
||||
assert!(stdout.contains("--json"), "discover documents --json");
|
||||
|
||||
let out = punktfunk(&["launch", "--help"]);
|
||||
assert!(String::from_utf8_lossy(&out.stdout).contains("--request-access"));
|
||||
}
|
||||
|
||||
+86
-56
@@ -2,49 +2,61 @@
|
||||
|
||||
Stream to your **Steam Deck** without ever leaving Gaming Mode. This
|
||||
**[Decky Loader](https://decky.xyz/)** plugin adds a **Punktfunk** panel to the Quick Access Menu
|
||||
(the `…` button): discover hosts on your network, pair with a PIN, tweak stream settings, and launch
|
||||
a fullscreen, gamescope-focused stream — all from the couch, gamepad-navigable.
|
||||
(the `…` button): the hosts you can stream, the pinned cards you set up, and one tap into each.
|
||||
|
||||
The video itself is the native GTK4 Linux client (the `io.unom.Punktfunk` flatpak); the plugin
|
||||
discovers, pairs, configures, and *launches it the right way* so gamescope fullscreens it — the same
|
||||
Steam-shortcut trick MoonDeck uses. Because it's built from real Steam UI primitives (`@decky/ui`),
|
||||
the panel looks and feels native to Gaming Mode.
|
||||
The plugin is a **launcher**, not a client. It doesn't decode video, browse your library, or hold
|
||||
any settings of its own — the Rust client does all of that, and the plugin's job is to start it
|
||||
*the right way* so gamescope fullscreens and focuses it (the same Steam-shortcut trick MoonDeck
|
||||
uses). Everything the panel doesn't do is one tap away in the client's own gamepad UI.
|
||||
|
||||
## What it does
|
||||
|
||||
1. **Discover** — browses the LAN over mDNS for Punktfunk hosts, in both the QAM panel and a
|
||||
fullscreen page; each host row opens a details view (address, pairing policy, certificate
|
||||
fingerprint to cross-check against the host's log).
|
||||
2. **Pair** — for a host that requires it, a gamepad-navigable PIN keypad runs the SPAKE2 pairing
|
||||
ceremony headlessly, then remembers the host so future streams connect silently.
|
||||
3. **Stream** — launches fullscreen via a branded "Punktfunk" Steam shortcut so gamescope focuses it.
|
||||
4. **Games** — each host row has a games button that opens its **library picker**: pin titles as
|
||||
one-tap "Stream <Game>" rows in the QAM (jump straight into e.g. Playnite on the host), or
|
||||
**"Open library on screen"** to launch the client's controller-driven, console-style library
|
||||
browser (aurora backdrop + poster coverflow; A plays, B returns to Gaming Mode). Pins survive
|
||||
plugin reinstalls (stored next to the client's config) and follow a host across IP changes
|
||||
(matched by certificate fingerprint).
|
||||
5. **Settings** — the client's whole settings store, written to its config. Laid out like SteamOS's
|
||||
own Settings: a left rail of categories (`SidebarNavigation`), one page each, so no page needs
|
||||
scrolling. The categories and their order are the console settings screen's — Stream (resolution
|
||||
/ refresh / render scale / bitrate / compositor), Video (codec / decoder / GPU / HDR / 4:4:4),
|
||||
Presentation (prioritize / smoothness buffer / V-Sync / VRR), Audio (channels / output + mic
|
||||
device / echo cancellation), Controllers, Touch & mouse, Interface (stats overlay / auto-wake /
|
||||
library / fullscreen). The device pickers are populated
|
||||
from the session binary (`--list-adapters` / `--list-audio`); the GPU row appears only where
|
||||
there is more than one adapter.
|
||||
6. **About** — plugin version, an explicit "Check for updates" button, the setup-guide link, and
|
||||
a force-stop for a wedged stream client.
|
||||
1. **Hosts** — the hosts on your network plus the ones you've saved, in one list. Discovery is
|
||||
mDNS; saved hosts are also probed directly, so a box reached over Tailscale or a VPN shows as
|
||||
online even though it never advertises. Rows sort online-first, then most recently used.
|
||||
2. **Trust** — an unpaired host opens a small sheet with two ways in:
|
||||
- **Request access** (the default) — no PIN. The host's operator approves this Deck in its
|
||||
console or web UI and the stream starts by itself. See [Request access](#request-access).
|
||||
- **Use a PIN instead** — the gamepad-navigable keypad, running the same SPAKE2 ceremony.
|
||||
3. **Stream** — launches fullscreen via a branded "Punktfunk" Steam shortcut so gamescope focuses
|
||||
it. A sleeping host is woken first (the client runs the real wake-and-wait loop, then dials).
|
||||
4. **Pinned cards** — a *(host, profile)* pair renders nested under its host as `▸ <Profile name>`
|
||||
and streams with that settings profile applied. Cards are the **shared** pinning model every
|
||||
other client speaks, stored on the host's record — so one you make in the desktop client shows
|
||||
up here, and vice versa. The plugin renders them; it doesn't create or edit them.
|
||||
5. **Open Punktfunk** — launches the client's **console home**: the host picker, add-host by
|
||||
address, PIN pairing, the game library browser, and the **full settings screen**. This is where
|
||||
everything the panel no longer does now lives.
|
||||
6. **About** — plugin version, "Check for updates", "Recreate library shortcut", and a force-stop
|
||||
for a wedged stream.
|
||||
|
||||
To leave a stream: the in-client controller chord (**L1 + R1 + Start + Select**), or close the
|
||||
"game" from the Steam overlay — either returns you to Gaming Mode.
|
||||
|
||||
### Request access
|
||||
|
||||
Request access is not a second pairing ceremony — it is a **launch**. The plugin saves the host
|
||||
with the fingerprint it **advertised**, then starts an ordinary identified connect with the
|
||||
handshake budget stretched to 185 s. The host *parks* that connection until its operator approves
|
||||
the device, then admits the same connection; the stream starts on its own, and the record flips
|
||||
to **paired** so every later stream is silent.
|
||||
|
||||
**No advertised fingerprint, no request access.** That pinned fingerprint is the only thing
|
||||
standing between a 185-second wait and an impostor answering for the host, so a host you typed in
|
||||
by address gets the PIN path only — and the sheet says why. The plugin never trusts-on-first-use
|
||||
past a missing fingerprint.
|
||||
|
||||
## Install on the Deck
|
||||
|
||||
You need **[Decky Loader](https://decky.xyz/)** and the **`io.unom.Punktfunk` flatpak**
|
||||
([`packaging/flatpak`](../../packaging/flatpak/README.md)) installed on the Deck — SteamOS `/usr` is
|
||||
read-only, so the flatpak (which bundles libadwaita/SDL3) is the canonical client. Discovery uses
|
||||
`avahi-browse`, which ships on SteamOS/Bazzite.
|
||||
You need **[Decky Loader](https://decky.xyz/)** and a **Punktfunk client** on the Deck. On a normal
|
||||
Deck that's the `io.unom.Punktfunk` flatpak ([`packaging/flatpak`](../../packaging/flatpak/README.md)) —
|
||||
SteamOS `/usr` is read-only, so the flatpak (which bundles libadwaita/SDL3) is the canonical client.
|
||||
A native install (sysext, distro package, nix profile, your own build) works too.
|
||||
|
||||
**The client must be v0.22.0 or newer** — that is when the headless `punktfunk` CLI shipped, and
|
||||
the panel drives everything through it. An older client says so in the panel, with the update
|
||||
button that fixes it right there. (Discovery no longer needs `avahi-browse` on the Deck; the
|
||||
client's own mDNS does it.)
|
||||
|
||||
**Recommended — install from URL** (published by CI): in Decky → Settings → **Developer Mode** →
|
||||
**Install Plugin from URL**, paste:
|
||||
@@ -55,17 +67,15 @@ https://unom.io/pf-decky
|
||||
|
||||
(short link for `https://git.unom.io/api/packages/unom/generic/punktfunk-decky/latest/punktfunk.zip`;
|
||||
for a pinned version use `https://git.unom.io/api/packages/unom/generic/punktfunk-decky/<version>/punktfunk.zip`
|
||||
directly). The plugin then **self-updates** without
|
||||
the Decky store — when a newer build exists, an **Update** button appears and drives Decky
|
||||
Loader's own (SHA-256-verified) install. Installs and updates can take a couple of minutes on some
|
||||
networks: Decky's installer also contacts its plugin store first, which may be slow or blackholed
|
||||
before the actual download proceeds.
|
||||
directly). The plugin then **self-updates** without the Decky store — when a newer build exists, an
|
||||
**Update** button appears and drives Decky Loader's own (SHA-256-verified) install. Installs and
|
||||
updates can take a couple of minutes on some networks: Decky's installer also contacts its plugin
|
||||
store first, which may be slow or blackholed before the actual download proceeds.
|
||||
|
||||
### Updating the client
|
||||
|
||||
The plugin also reports — and where it can, installs — updates for the **client** it launches.
|
||||
What is possible depends on how that client was installed, and the About tab names the install
|
||||
kind so the answer is never a mystery:
|
||||
What is possible depends on how that client was installed:
|
||||
|
||||
| Install | Update |
|
||||
| --- | --- |
|
||||
@@ -88,6 +98,8 @@ pnpm install
|
||||
pnpm build # rollup → dist/index.js
|
||||
pnpm run package # → out/punktfunk/ + out/punktfunk-v<ver>.zip
|
||||
DECK=deck@<deck-ip> pnpm run deploy # rsync → /tmp, sudo-install into the root-owned plugins dir, restart loader
|
||||
|
||||
python3.13 scripts/test-backend.py # backend unit checks (needs Python ≥3.10)
|
||||
```
|
||||
|
||||
`~/homebrew/plugins/` is root-owned (the loader runs as root), so `deploy.sh` stages to a temp dir
|
||||
@@ -96,28 +108,46 @@ restart is required for an out-of-band install to appear.
|
||||
|
||||
## Architecture
|
||||
|
||||
Everything below the panel is the CLI. `main.py` builds argv and maps exit codes; it parses none of
|
||||
the client's data files and re-implements none of its rules.
|
||||
|
||||
| File | Role |
|
||||
| --- | --- |
|
||||
| `src/index.tsx` | Plugin entry: the QAM panel + route registration. |
|
||||
| `src/page.tsx` | The `/punktfunk` fullscreen page — Hosts (with per-host details) / Settings / About tabs. |
|
||||
| `src/settings.tsx` · `src/pair.tsx` | The settings screen (a `SidebarNavigation` of seven category pages over one shared settings object); the gamepad-navigable PIN-pairing modal. |
|
||||
| `src/library.tsx` | The per-host game picker (pin/unpin, "Open library on screen") + the pinned-game launch helper. |
|
||||
| `src/hostmgmt.tsx` | Add / edit host dialogs — mutate the shared known-hosts store (`client-known-hosts.json`) via the flatpak client's headless modes, so a host saved here shows up in the desktop client too. |
|
||||
| `src/ui.tsx` | Shared UI primitives for the fullscreen page + modals (right-aligned row actions, consistent Field layout). |
|
||||
| `src/hooks.ts` · `src/boundary.tsx` | Shared discovery/update/pins hooks + actions; the render error boundary. |
|
||||
| `src/steam.ts` | Steam-shortcut launch (`AddShortcut` / `SetAppLaunchOptions` / `RunGame`) — the focus-correct stream start. The shortcut's exe is `/bin/sh` with the wrapper passed as an argument, so the script never needs an exec bit (Decky's zip extraction drops it and the root-owned plugins dir can't be chmodded by the unprivileged backend). Launch extras ride env-prefix tokens: `PF_LAUNCH=<id>` (pinned game) / `PF_BROWSE=1` + `PF_MGMT=<port>` (on-screen library); ids are validated space/quote-free at pin AND launch time. |
|
||||
| `src/backend.ts` | Typed `callable` bridges to `main.py`. |
|
||||
| `bin/punktfunkrun.sh` | The launch wrapper the Steam shortcut runs (so the window is focusable); maps `PF_LAUNCH`/`PF_BROWSE`/`PF_MGMT` to `--launch`/`--browse`/`--mgmt`. An older flatpak ignores the flags harmlessly (plain stream / hosts page). |
|
||||
| `main.py` | Backend: `discover` (via `avahi-browse`) / `pair` / `library` (headless flatpak `--library`, TSV) / pins store (`decky-pinned.json`) / settings / `kill_stream` / `check_update` (with an explicit CA-bundle search — Decky's embedded Python has no usable default TLS roots on SteamOS). |
|
||||
| `scripts/test-backend.py` | Stdlib-only checks for the backend's pure parsers (TSV, error classes, avahi TXT) + the pins round trip. |
|
||||
| `src/index.tsx` | Plugin entry + the QAM panel: update banner, hosts (with nested pinned cards), the console-home door, about. |
|
||||
| `src/hooks.ts` | `useHosts` (one call merging discovery and the saved store), the update hooks, and the launch action. Also the trust-state model the rows render. |
|
||||
| `src/trust.tsx` · `src/pair.tsx` | The trust sheet (Request access / Use a PIN instead / Cancel) and the gamepad-navigable PIN keypad. |
|
||||
| `src/steam.ts` | Steam-shortcut launch (`AddShortcut` / `SetAppLaunchOptions` / `RunGame`) — the focus-correct stream start. The shortcut's exe is `/bin/sh` with the wrapper passed as an argument, so the script never needs an exec bit (Decky's zip extraction drops it and the root-owned plugins dir can't be chmodded by the unprivileged backend). |
|
||||
| `src/backend.ts` · `src/boundary.tsx` · `src/os-icon.tsx` | Typed `callable` bridges to `main.py`; the render error boundary; the host row's OS mark. |
|
||||
| `bin/punktfunkrun.sh` | The launch wrapper the Steam shortcut runs (so the window is focusable). Reads `PF_REF` / `PF_PROFILE` / `PF_REQUEST_ACCESS` / `PF_BROWSE` and runs `punktfunk launch` — or the session's `--browse` for console home. |
|
||||
| `main.py` | Backend: four thin CLI shells (`discover` / `hosts` / `pair` / `trust_host`) plus the Steam-side work only a plugin can do — `runner_info`, `shortcut_art`, `apply_controller_config`, `kill_stream`, `check_update` / `update_client` (with an explicit CA-bundle search — Decky's embedded Python has no usable default TLS roots on SteamOS). |
|
||||
| `scripts/test-backend.py` | Stdlib-only checks: argv shape, the CLI exit-code mapping, and the Steam configset editor. |
|
||||
| `plugin.json` · `update.json` | Decky manifest; CI-baked update channel. |
|
||||
|
||||
### Why the launch goes through Steam
|
||||
|
||||
gamescope only gives focus and fullscreen to the window tree Steam launched via `reaper` (it
|
||||
detects the "current app" by AppID — gamescope#484). A client spawned from the plugin's own
|
||||
backend comes up invisible and unfocused. So the plugin registers non-Steam shortcuts whose exe is
|
||||
`/bin/sh` running `bin/punktfunkrun.sh`, and starts them with `RunGame`.
|
||||
|
||||
There are **two** shortcuts, both named `Punktfunk` so Steam keys them to one Steam Input
|
||||
configset (the key is the lowercase name): a hidden, stateful one that carries the stream, and the
|
||||
visible, stateless library entry that opens console home.
|
||||
|
||||
## Limitations / next steps
|
||||
|
||||
- No manual "add host by IP" entry yet (discovery is mDNS-only).
|
||||
- No in-stream overlay inside the plugin — the client owns the session once launched.
|
||||
- Pairing needs the operator to **arm pairing on the host** so it shows the PIN; the plugin can't arm
|
||||
it remotely.
|
||||
- **Profiles and pinned cards can't be created here** — the panel renders them; making one needs
|
||||
the desktop client, or the client's own gamepad UI once that work lands. A Deck with no profiles
|
||||
simply sees host rows, and nothing is broken.
|
||||
- **Per-game pins are on hold.** The shared model pins *host+profile*; nothing in the shared store
|
||||
persists a pinned *game* yet. The old `decky-pinned.json` is left on disk untouched so a later
|
||||
migration can read it.
|
||||
- Pairing with a PIN needs the operator to **arm pairing on the host** so it shows the PIN; the
|
||||
plugin can't arm it remotely. Request access needs no arming — just an approval.
|
||||
- **A parked connect looks like a hanging one.** The plugin toasts before launching a request-access
|
||||
stream to set expectations, which is a patch rather than a fix; teaching the session's connect
|
||||
screen the same "waiting for approval" copy the console shell already has would pay off for every
|
||||
shell.
|
||||
|
||||
## Related
|
||||
|
||||
|
||||
@@ -1,33 +1,32 @@
|
||||
#!/usr/bin/env bash
|
||||
# punktfunk stream runner — the target of the hidden non-Steam shortcut the plugin creates.
|
||||
# punktfunk stream runner — the target of the non-Steam shortcuts the plugin creates.
|
||||
#
|
||||
# WHY A WRAPPER SCRIPT (load-bearing, from MoonDeck's hard-won knowledge): the stream client
|
||||
# must be a descendant of the process Steam launches via `reaper`, or gamescope never gives
|
||||
# its window focus/fullscreen in Gaming Mode (gamescope detects the "current app" by AppID,
|
||||
# which only attaches to reaper's descendants — see gamescope#484). So the Decky plugin
|
||||
# launches THIS script through SteamClient.Apps.RunGame; the script then execs the flatpak
|
||||
# client, which inherits the shortcut's AppID and is focused. Launching the flatpak directly
|
||||
# from the (root) Decky backend produces an unfocused, invisible window.
|
||||
# launches THIS script through SteamClient.Apps.RunGame; the script then runs the client,
|
||||
# which inherits the shortcut's AppID and is focused. Launching the client directly from the
|
||||
# (root) Decky backend produces an unfocused, invisible window.
|
||||
#
|
||||
# Per-session parameters arrive as environment variables, set as the shortcut's Steam launch
|
||||
# options by the plugin (SteamClient.Apps.SetAppLaunchOptions), so ONE generic shortcut serves
|
||||
# every host (and every pinned game):
|
||||
# PF_HOST host[:port] to connect to (required for streaming; optional for browse)
|
||||
# PF_LAUNCH library id to launch on connect (optional, e.g. steam:570 — pinned games)
|
||||
# PF_BROWSE non-empty = open the gamepad library (optional; --browse instead of --connect)
|
||||
# PF_MGMT management-API port for --browse (optional; client defaults to 47990)
|
||||
# PF_CONNECT_TIMEOUT connect budget in seconds (optional; the plugin stretches it after
|
||||
# firing Wake-on-LAN so the connect survives the host's resume)
|
||||
# PF_APPID flatpak app id (default io.unom.Punktfunk)
|
||||
# PF_FLATPAK override the flatpak binary path (default: `flatpak` on PATH)
|
||||
# every host:
|
||||
# PF_REF host reference — a saved host's stable id, or addr[:port] (required to stream)
|
||||
# PF_PROFILE settings-profile id for a pinned card (optional)
|
||||
# PF_REQUEST_ACCESS non-empty = ask the host's operator to admit this device instead of
|
||||
# pairing with a PIN. The connect PARKS until somebody approves it.
|
||||
# PF_BROWSE non-empty = open the client's console home instead of streaming
|
||||
# PF_APPID flatpak app id (default io.unom.Punktfunk)
|
||||
# PF_FLATPAK override the flatpak binary path (default: `flatpak` on PATH)
|
||||
# PF_CLIENT_BIN absolute path of a NATIVE client (optional; set by the plugin when it
|
||||
# resolved a non-flatpak install — then the client is exec'd directly and
|
||||
# resolved a non-flatpak install — then the client is run directly and
|
||||
# PF_APPID/PF_FLATPAK are unused)
|
||||
#
|
||||
# Values are plain tokens (the plugin validates launch ids to space/quote-free ASCII before
|
||||
# they ever reach Steam launch options). An older flatpak without --launch/--browse ignores
|
||||
# the unknown flags harmlessly (hand-scanned argv): PF_LAUNCH degrades to the plain desktop
|
||||
# session, PF_BROWSE to the client's hosts page.
|
||||
# A REFERENCE, NEVER A VALUE. Host refs and profile ids are the only things that ride this
|
||||
# channel; no resolution, bitrate or codec ever does. The client resolves both against its own
|
||||
# stores, which is what keeps a Steam launch option from becoming a second settings surface.
|
||||
# The plugin validates them to space/quote-free ASCII before they reach Steam's tokenizer.
|
||||
#
|
||||
# Runs as the `deck` user (Steam launched it), so the --user flatpak install is visible and
|
||||
# WAYLAND_DISPLAY / XDG_RUNTIME_DIR are already correct for gamescope.
|
||||
@@ -42,13 +41,22 @@ APPID="${PF_APPID:-io.unom.Punktfunk}"
|
||||
FLATPAK="${PF_FLATPAK:-flatpak}"
|
||||
|
||||
# The client is not always the flatpak: a sysext, a .deb/.rpm, an AUR build or a nix profile
|
||||
# installs a native `punktfunk-client`, and the plugin passes its absolute path here when that
|
||||
# is what it resolved. Both kinds take the same argv and share ~/.config/punktfunk, so the only
|
||||
# difference is the prefix in front of it.
|
||||
# installs a native `punktfunk-client` with the CLI as its sibling, and the plugin passes the
|
||||
# client's absolute path here when that is what it resolved.
|
||||
#
|
||||
# exec so the client IS the game process — when it exits, Steam ends the "game" and Gaming Mode
|
||||
# reclaims focus automatically (no manual refocus needed).
|
||||
run_client() {
|
||||
# run_cli execs the HEADLESS CLI (`punktfunk`); run_session execs the GTK/console shell
|
||||
# (`punktfunk-client`). Both live in the same place in both install kinds — /app/bin inside the
|
||||
# flatpak, reachable with `--command=`, and one bindir natively.
|
||||
run_cli() {
|
||||
if [ -n "${PF_CLIENT_BIN:-}" ]; then
|
||||
# `${VAR%/*}` rather than `dirname`: pure parameter expansion, so this works with no
|
||||
# PATH at all — which is the environment a Steam launch option can leave us in.
|
||||
exec "${PF_CLIENT_BIN%/*}/punktfunk" "$@"
|
||||
fi
|
||||
exec "$FLATPAK" run --arch=x86_64 --command=punktfunk "$APPID" "$@"
|
||||
}
|
||||
|
||||
run_session() {
|
||||
if [ -n "${PF_CLIENT_BIN:-}" ]; then
|
||||
exec "$PF_CLIENT_BIN" "$@"
|
||||
fi
|
||||
@@ -58,40 +66,35 @@ run_client() {
|
||||
# What we are about to run, for the log line each branch prints.
|
||||
CLIENT_LABEL="${PF_CLIENT_BIN:-$APPID}"
|
||||
|
||||
# --fullscreen: present the stream chrome-less and fullscreen (the client also auto-detects the
|
||||
# Deck/gamescope env, and ignores the flag harmlessly on older builds that predate it).
|
||||
# The console home: the client's own gamepad UI (host picker, pairing, add-host by address, the
|
||||
# library browser and the full settings screen). UNCHANGED from before this rework — the shell
|
||||
# binary already execs the session for `--browse`, so there is nothing to repoint here.
|
||||
if [ -n "${PF_BROWSE:-}" ]; then
|
||||
# The gamepad UI. BARE `--browse` (no PF_HOST) opens the console home — the self-contained
|
||||
# host picker + pairing + settings, gamepad-navigable — which is what the stateless, visible
|
||||
# library shortcut launches. `--browse <host>` opens straight into that host's library (the
|
||||
# per-host "open on screen" action). A streams a game, session end returns here, B quits.
|
||||
if [ -z "${PF_HOST:-}" ]; then
|
||||
echo "punktfunkrun: gamepad UI $CLIENT_LABEL --browse (console home)" >&2
|
||||
run_client --browse --fullscreen
|
||||
fi
|
||||
echo "punktfunkrun: library $CLIENT_LABEL --browse $PF_HOST" >&2
|
||||
if [ -n "${PF_MGMT:-}" ]; then
|
||||
run_client --browse "$PF_HOST" --mgmt "$PF_MGMT" --fullscreen
|
||||
fi
|
||||
run_client --browse "$PF_HOST" --fullscreen
|
||||
echo "punktfunkrun: gamepad UI $CLIENT_LABEL --browse (console home)" >&2
|
||||
run_session --browse --fullscreen
|
||||
fi
|
||||
|
||||
# Streaming modes need a host (browse above is the only host-less path).
|
||||
if [ -z "${PF_HOST:-}" ]; then
|
||||
echo "punktfunkrun: PF_HOST is not set (the plugin sets it as a launch option)" >&2
|
||||
if [ -z "${PF_REF:-}" ]; then
|
||||
echo "punktfunkrun: PF_REF is not set (the plugin sets it as a launch option)" >&2
|
||||
exit 2
|
||||
fi
|
||||
# Trailing args shared by both streaming execs. A stretched connect budget rides along when the
|
||||
# plugin set one (it just fired Wake-on-LAN, so the host may still be resuming); an older flatpak
|
||||
# without --connect-timeout ignores the flag harmlessly (hand-scanned argv).
|
||||
|
||||
set -- --fullscreen
|
||||
if [ -n "${PF_CONNECT_TIMEOUT:-}" ]; then
|
||||
set -- --connect-timeout "$PF_CONNECT_TIMEOUT" "$@"
|
||||
if [ -n "${PF_PROFILE:-}" ]; then
|
||||
set -- --profile "$PF_PROFILE" "$@"
|
||||
fi
|
||||
if [ -n "${PF_LAUNCH:-}" ]; then
|
||||
# A pinned game: the id rides the session Hello and the host launches that title.
|
||||
echo "punktfunkrun: streaming $CLIENT_LABEL --connect $PF_HOST --launch $PF_LAUNCH" >&2
|
||||
run_client --connect "$PF_HOST" --launch "$PF_LAUNCH" "$@"
|
||||
|
||||
# REQUEST ACCESS RUNS SUPERVISED — no `--exec`. Under --exec the CLI BECOMES the session, so no
|
||||
# process survives to see the stream come up and record the host as paired; the CLI refuses the
|
||||
# combination outright rather than downgrading silently. This is safe for gamescope because
|
||||
# focus follows reaper's DESCENDANT TREE, not a single process, and `flatpak run`/`bwrap`
|
||||
# already sit between reaper and the client on every other path.
|
||||
if [ -n "${PF_REQUEST_ACCESS:-}" ]; then
|
||||
echo "punktfunkrun: request access $CLIENT_LABEL launch $PF_REF (waiting for approval)" >&2
|
||||
run_cli launch "$PF_REF" --request-access "$@"
|
||||
fi
|
||||
echo "punktfunkrun: streaming $CLIENT_LABEL --connect $PF_HOST" >&2
|
||||
run_client --connect "$PF_HOST" "$@"
|
||||
|
||||
# The ordinary stream. `--exec` is the documented gamescope-wrapper mode: the CLI becomes the
|
||||
# session, so the process tree stays flat and Steam's "game" ends exactly when the stream does.
|
||||
echo "punktfunkrun: streaming $CLIENT_LABEL launch $PF_REF" >&2
|
||||
run_cli launch "$PF_REF" --exec "$@"
|
||||
|
||||
+214
-725
File diff suppressed because it is too large
Load Diff
@@ -1,12 +1,12 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Unit checks for main.py's pure helpers — stdlib only, no Decky runtime needed.
|
||||
|
||||
Stubs the ``decky`` module (main.py imports it at module level), then asserts the
|
||||
avahi/TSV/error parsers against fixture strings. The LibraryError fixtures are pinned to
|
||||
the REAL Display strings in clients/linux/src/library.rs — if those are reworded, the
|
||||
classifier degrades to ``client-error`` and the matching assertion here fails on purpose.
|
||||
Stubs the ``decky`` module (main.py imports it at module level), then asserts the argv
|
||||
shapes, the exit-code mapping and the Steam VDF editor against fixtures.
|
||||
|
||||
python3 clients/decky/scripts/test-backend.py
|
||||
Needs Python >= 3.10 for `X | None` annotations — macOS ships 3.9, so run it explicitly:
|
||||
|
||||
python3.13 clients/decky/scripts/test-backend.py
|
||||
"""
|
||||
|
||||
import sys
|
||||
@@ -40,136 +40,135 @@ def check(name: str, cond: bool):
|
||||
failures += 1
|
||||
|
||||
|
||||
# ---- _parse_library_tsv -----------------------------------------------------------------
|
||||
tsv = (
|
||||
"steam:570\tsteam\tDota 2\n"
|
||||
"custom:abc\tcustom\tTabs\tin\ttitle\n" # tabs inside the title survive (split max 2)
|
||||
"2 game(s)\n" # the count trailer has no tabs — self-skips
|
||||
# ---- _cli_argv: the flatpak app id must stay LAST ---------------------------------------
|
||||
#
|
||||
# `flatpak run --command=X <app-id> ARGS` — everything after the app id is the APP's argv, so
|
||||
# an app id that drifts left silently turns our flags into the client's. This is the shape the
|
||||
# deleted _session_argv used and the one thing about it that is easy to get wrong.
|
||||
main._client_argv = lambda: ["/usr/bin/flatpak", "run", "--arch=x86_64", "io.unom.Punktfunk"]
|
||||
main._flatpak = lambda: "/usr/bin/flatpak"
|
||||
check(
|
||||
"cli argv: flatpak form, app id last",
|
||||
main._cli_argv()
|
||||
== [
|
||||
"/usr/bin/flatpak",
|
||||
"run",
|
||||
"--arch=x86_64",
|
||||
"--command=punktfunk",
|
||||
"io.unom.Punktfunk",
|
||||
],
|
||||
)
|
||||
games = main._parse_library_tsv(tsv)
|
||||
check("tsv: two games parsed", len(games) == 2)
|
||||
check("tsv: fields", games[0] == {"id": "steam:570", "store": "steam", "title": "Dota 2"})
|
||||
check("tsv: tabs in title preserved", games[1]["title"] == "Tabs\tin\ttitle")
|
||||
check("tsv: empty input", main._parse_library_tsv("0 game(s)\n") == [])
|
||||
|
||||
# ---- _classify_library_error (fixtures = library.rs Display strings) --------------------
|
||||
check(
|
||||
"err: not-paired",
|
||||
main._classify_library_error(
|
||||
"library: The host didn't recognize this device. Pair with the host first — the "
|
||||
"library is authorized by this device's certificate (no token needed)."
|
||||
)
|
||||
== "not-paired",
|
||||
)
|
||||
check(
|
||||
"err: pin-mismatch",
|
||||
main._classify_library_error(
|
||||
"library: The host's certificate doesn't match the pinned fingerprint. "
|
||||
"Re-pair with a PIN to re-establish trust."
|
||||
)
|
||||
== "pin-mismatch",
|
||||
)
|
||||
check(
|
||||
"err: unreachable",
|
||||
main._classify_library_error(
|
||||
"library: Couldn't reach the host's management API: connection refused. Check the "
|
||||
"host is updated and reachable."
|
||||
)
|
||||
== "unreachable",
|
||||
)
|
||||
check(
|
||||
"err: http",
|
||||
main._classify_library_error("library: The management API returned HTTP 500.") == "http",
|
||||
)
|
||||
check(
|
||||
"err: outdated client (GTK init noise)",
|
||||
main._classify_library_error("cannot open display: \nGtk-WARNING: init failed")
|
||||
== "client-outdated",
|
||||
)
|
||||
check("err: generic fallback", main._classify_library_error("boom") == "client-error")
|
||||
|
||||
# ---- _parse_avahi_browse (incl. the new id/mgmt TXT keys) --------------------------------
|
||||
avahi = (
|
||||
"+;eth0;IPv4;living-room;_punktfunk._udp;local\n"
|
||||
"=;eth0;IPv4;living-room;_punktfunk._udp;local;lr.local;192.168.1.42;9777;"
|
||||
'"proto=punktfunk/1" "fp=aabbcc" "pair=required" "id=abc123" "mgmt=47990"\n'
|
||||
"=;eth0;IPv6;living-room;_punktfunk._udp;local;lr.local;fe80::1;9777;"
|
||||
'"proto=punktfunk/1" "fp=aabbcc" "pair=required" "id=abc123" "mgmt=47990"\n'
|
||||
"=;eth0;IPv4;bare-host;_punktfunk._udp;local;bh.local;192.168.1.77;9777;"
|
||||
'"proto=punktfunk/1" "fp=ddeeff" "pair=optional"\n'
|
||||
)
|
||||
hosts = main._parse_avahi_browse(avahi)
|
||||
check("avahi: two hosts (id-dedup, IPv4 preferred)", len(hosts) == 2)
|
||||
lr = next(h for h in hosts if h["name"] == "living-room")
|
||||
check("avahi: ipv4 wins", lr["host"] == "192.168.1.42")
|
||||
check("avahi: mgmt parsed", lr["mgmt"] == 47990)
|
||||
check("avahi: id parsed", lr["id"] == "abc123")
|
||||
bare = next(h for h in hosts if h["name"] == "bare-host")
|
||||
check("avahi: mgmt absent -> 0", bare["mgmt"] == 0)
|
||||
check("avahi: id absent -> empty", bare["id"] == "")
|
||||
|
||||
# ---- pins store (round-trip through the real methods, isolated HOME) --------------------
|
||||
import asyncio # noqa: E402
|
||||
# A native install: the CLI is the client binary's sibling. Absent => no CLI at all, which the
|
||||
# caller must see as "unavailable" rather than as an empty result.
|
||||
#
|
||||
# The fixture dir is torn down FIRST, not just created: leaving the sibling behind made the
|
||||
# "absent" assertion below pass only on the first run of the day and fail on every rerun.
|
||||
import shutil # noqa: E402
|
||||
|
||||
shutil.rmtree(decky.DECKY_USER_HOME, ignore_errors=True)
|
||||
plugin = main.Plugin()
|
||||
pin = {
|
||||
"game_id": "steam:570",
|
||||
"title": "Dota 2",
|
||||
"store": "steam",
|
||||
"host_fp": "AABBCC",
|
||||
"host_id": "abc123",
|
||||
"host_name": "living-room",
|
||||
"host": "192.168.1.42",
|
||||
"port": 9777,
|
||||
"mgmt": 47990,
|
||||
"added_at": 1780000000,
|
||||
}
|
||||
dupe = dict(pin, title="Dota 2 again")
|
||||
junk = {"title": "no game id"}
|
||||
res = asyncio.run(plugin.set_pins([pin, dupe, junk]))
|
||||
check("pins: write ok", res.get("ok") is True)
|
||||
got = asyncio.run(plugin.get_pins())["pins"]
|
||||
check("pins: dedup + junk dropped", len(got) == 1)
|
||||
check("pins: unpaired without known-hosts", got[0]["paired"] is False)
|
||||
# Mark the host paired in the client's known-hosts store — get_pins must pick it up.
|
||||
cfg = main._client_config_dir()
|
||||
cfg.mkdir(parents=True, exist_ok=True)
|
||||
(cfg / "client-known-hosts.json").write_text(
|
||||
'{"hosts": [{"name": "living-room", "addr": "192.168.1.42", "port": 9777, '
|
||||
'"fp_hex": "aabbcc", "paired": true}]}'
|
||||
)
|
||||
got = asyncio.run(plugin.get_pins())["pins"]
|
||||
check("pins: paired via known-hosts fp (case-insensitive)", got[0]["paired"] is True)
|
||||
shutil.rmtree(decky.DECKY_USER_HOME, ignore_errors=True)
|
||||
shutil.rmtree("/tmp/pf-test-native", ignore_errors=True)
|
||||
tmp = Path("/tmp/pf-test-native/bin")
|
||||
tmp.mkdir(parents=True, exist_ok=True)
|
||||
(tmp / "punktfunk-client").write_text("")
|
||||
main._client_argv = lambda: [str(tmp / "punktfunk-client")]
|
||||
check("cli argv: native without a sibling CLI is None", main._cli_argv() is None)
|
||||
(tmp / "punktfunk").write_text("")
|
||||
check("cli argv: native sibling found", main._cli_argv() == [str(tmp / "punktfunk")])
|
||||
|
||||
# ---- `--list-audio` parsing (the settings tab's device pickers) --------------------------
|
||||
sinks, sources = main._parse_audio_endpoints(
|
||||
"sink\talsa_output.pci-0000_04_00.6.analog-stereo\tSteam Deck Speakers\n"
|
||||
"sink\tbluez_output.AC_12_2F.1\tWH-1000XM4\n"
|
||||
"source\talsa_input.pci-0000_04_00.6.analog-stereo\tSteam Deck Microphone\n"
|
||||
# ---- _cli_error: the CLI's exit-code contract -------------------------------------------
|
||||
#
|
||||
# Exit 5 + `unknown command` is how a client too old for a verb announces itself — the ONE
|
||||
# signature the panel turns into "update the client" plus the button that fixes it. Getting it
|
||||
# wrong makes an out-of-date client look like a broken plugin.
|
||||
check(
|
||||
"err: unknown verb => client-outdated",
|
||||
main._cli_error(5, 'unknown command "discover"\n\npunktfunk — the Punktfunk client')
|
||||
== "client-outdated",
|
||||
)
|
||||
check("audio: sinks parsed", [d["name"] for d in sinks] == [
|
||||
"alsa_output.pci-0000_04_00.6.analog-stereo", "bluez_output.AC_12_2F.1"
|
||||
])
|
||||
check("audio: sources parsed", len(sources) == 1)
|
||||
check("audio: description kept", sinks[1]["description"] == "WH-1000XM4")
|
||||
check(
|
||||
"err: exit 5 without that phrase is NOT outdated",
|
||||
main._cli_error(5, 'no saved host matches "desk"') == "unresolved",
|
||||
)
|
||||
check("err: connect failed", main._cli_error(2, "unreachable 10.0.0.1:9777") == "unreachable")
|
||||
check("err: trust rejected", main._cli_error(3, "wrong PIN") == "refused")
|
||||
check("err: needs a person", main._cli_error(6, "pair it first") == "needs-pairing")
|
||||
check("err: nothing ran", main._cli_error(-1, "") == "client-unavailable")
|
||||
check("err: unmapped code falls back", main._cli_error(4, "renderer") == "client-error")
|
||||
|
||||
# Junk the picker must not offer: no node.name is unusable (it is the id that gets stored), a
|
||||
# short line is malformed, and an unknown kind belongs to neither list. A blank description
|
||||
# falls back to the name so no entry renders unlabelled.
|
||||
sinks, sources = main._parse_audio_endpoints(
|
||||
"sink\t\tNo node name\n"
|
||||
"sink\tonly-two-columns\n"
|
||||
"monitor\tsome.monitor\tNot a sink or source\n"
|
||||
"source\tbare.node\t\n"
|
||||
"\n"
|
||||
# ---- _cli_json: a zero exit with junk on stdout is a FAILURE, not an empty result --------
|
||||
import asyncio # noqa: E402
|
||||
|
||||
|
||||
def _fake_cli(rc: int, out: str, err: str = ""):
|
||||
async def run(_args, timeout=20.0):
|
||||
return rc, out, err
|
||||
|
||||
return run
|
||||
|
||||
|
||||
main._run_cli = _fake_cli(0, '{"hosts": [{"name": "desk"}]}')
|
||||
got = asyncio.run(main._cli_json(["discover", "--json"]))
|
||||
check("json: payload merged under ok", got == {"ok": True, "hosts": [{"name": "desk"}]})
|
||||
|
||||
main._run_cli = _fake_cli(0, "not json at all")
|
||||
got = asyncio.run(main._cli_json(["discover", "--json"]))
|
||||
check("json: unparseable stdout is an error, not an empty list", got["ok"] is False)
|
||||
check("json: ...and says so specifically", got["error"] == "client-error")
|
||||
|
||||
main._run_cli = _fake_cli(5, "", 'unknown command "discover"')
|
||||
got = asyncio.run(main._cli_json(["discover", "--json"]))
|
||||
check("json: old client surfaces as client-outdated", got["error"] == "client-outdated")
|
||||
check("json: detail carries the CLI's own last line", "unknown command" in got["detail"])
|
||||
|
||||
# ---- _field_from (flatpak info parsing, drives the client update check) ------------------
|
||||
info = " ID: io.unom.Punktfunk\n Origin: punktfunk-origin\n Commit: abc123def\n"
|
||||
check("field: commit", main._field_from(info, "Commit") == "abc123def")
|
||||
check("field: origin", main._field_from(info, "Origin") == "punktfunk-origin")
|
||||
check("field: absent", main._field_from(info, "Nope") == "")
|
||||
|
||||
# ---- _looks_outdated (the GTK-init signature of a client predating a headless flag) ------
|
||||
check("outdated: gtk init noise", main._looks_outdated("cannot open display: \nGtk-WARNING") is True)
|
||||
check("outdated: an ordinary error is not", main._looks_outdated("connection refused") is False)
|
||||
|
||||
# ---- _semver_tuple (plugin update comparison) --------------------------------------------
|
||||
check("semver: plain", main._semver_tuple("1.2.3") == (1, 2, 3))
|
||||
check("semver: pre-release suffix dropped", main._semver_tuple("1.2.3-rc1") == (1, 2, 3))
|
||||
check("semver: short forms pad", main._semver_tuple("2") == (2, 0, 0))
|
||||
check("semver: ordering", main._semver_tuple("0.10.0") > main._semver_tuple("0.9.9"))
|
||||
|
||||
# ---- _upsert_configset_entry (Steam Input layout binding) --------------------------------
|
||||
#
|
||||
# Untested until now, and the riskiest thing that survived the cut: it edits a file holding
|
||||
# HUNDREDS of other games' controller bindings, in place. Every assertion below is about not
|
||||
# touching them.
|
||||
empty = main._upsert_configset_entry("", "punktfunk", "template", "punktfunk.vdf")
|
||||
check("vdf: builds the skeleton when the file is new", '"controller_config"' in empty)
|
||||
check("vdf: the entry lands", '"punktfunk"' in empty and '"punktfunk.vdf"' in empty)
|
||||
|
||||
existing = (
|
||||
'"controller_config"\n'
|
||||
"{\n"
|
||||
'\t"halflife2"\n'
|
||||
"\t{\n"
|
||||
'\t\t"template"\t\t"other.vdf"\n'
|
||||
"\t}\n"
|
||||
"}\n"
|
||||
)
|
||||
check("audio: junk lines dropped", sinks == [])
|
||||
check("audio: blank description falls back to the node name", sources == [
|
||||
{"name": "bare.node", "description": "bare.node"}
|
||||
])
|
||||
added = main._upsert_configset_entry(existing, "punktfunk", "template", "punktfunk.vdf")
|
||||
check("vdf: an existing game's entry survives insertion", '"halflife2"' in added)
|
||||
check("vdf: ours is inserted", '"punktfunk"' in added)
|
||||
|
||||
# Re-running must REPLACE our block, not accumulate a second one (this runs on every plugin
|
||||
# session gated only by a localStorage marker, so idempotence is the whole contract).
|
||||
twice = main._upsert_configset_entry(added, "punktfunk", "template", "punktfunk.vdf")
|
||||
check("vdf: idempotent", twice.count('"punktfunk"\n') == 1)
|
||||
check("vdf: neighbour still intact after the rewrite", '"halflife2"' in twice)
|
||||
|
||||
# Steam keys non-Steam games by their LOWERCASE name, and files on disk may carry either case —
|
||||
# a case-sensitive match would append a duplicate the game never reads.
|
||||
mixed = existing.replace('"halflife2"', '"Punktfunk"')
|
||||
replaced = main._upsert_configset_entry(mixed, "punktfunk", "template", "punktfunk.vdf")
|
||||
check("vdf: matches an existing key case-insensitively", replaced.count("unktfunk\"\n") == 1)
|
||||
|
||||
print()
|
||||
if failures:
|
||||
|
||||
+92
-218
@@ -1,95 +1,94 @@
|
||||
// Bridge to the Python backend (main.py) + shared types.
|
||||
//
|
||||
// Every call here is a thin shell over the headless `punktfunk` CLI, so these types are the
|
||||
// CLI's JSON shapes rather than anything this plugin invents. That is deliberate: the plugin
|
||||
// used to model the client's stores itself and drifted from them with every field the client
|
||||
// added.
|
||||
|
||||
import { callable } from "@decky/api";
|
||||
|
||||
export interface Host {
|
||||
name: string;
|
||||
host: string;
|
||||
port: number;
|
||||
pair: string; // "required" | "optional" — the HOST's policy
|
||||
fp: string; // host cert SHA-256 fingerprint (lowercase hex) from the mDNS advert
|
||||
proto: string; // advertised protocol, e.g. "punktfunk/1"
|
||||
paired: boolean; // whether THIS device has already PIN-paired this host (by fingerprint)
|
||||
id: string; // the host's stable instance id (mDNS TXT `id`; "" when not advertised)
|
||||
mgmt: number; // management-API port (mDNS TXT `mgmt`; 0 = not advertised → default 47990)
|
||||
os: string; // OS-identity chain (mDNS TXT `os`, e.g. "linux/fedora/bazzite"); "" on older hosts
|
||||
}
|
||||
|
||||
// One title from a host's game library (the flatpak client's --library TSV, parsed by the
|
||||
// backend). `id` is store-qualified (steam:<appid> / custom:<id>) and doubles as the
|
||||
// launch handle (PF_LAUNCH → the session Hello).
|
||||
export interface GameEntry {
|
||||
/** A settings profile as the CLI resolves it — ids are dangling-checked and names attached. */
|
||||
export interface Profile {
|
||||
id: string;
|
||||
store: string; // "steam" | "custom" | "heroic" | "lutris" | …
|
||||
title: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface LibraryResult {
|
||||
ok: boolean;
|
||||
games?: GameEntry[];
|
||||
// "flatpak-not-found" | "timeout" | "not-paired" | "pin-mismatch" | "unreachable" |
|
||||
// "http" | "client-outdated" | "client-error"
|
||||
error?: string;
|
||||
detail?: string; // the client's own one-line reason, for the generic error copy
|
||||
}
|
||||
|
||||
// A pinned game — a one-tap stream row in the QAM. The host is identified primarily by
|
||||
// cert fingerprint (survives IP changes; pairing is fp-keyed too), with the stored
|
||||
// address as the launch fallback when the host isn't currently advertising.
|
||||
export interface PinnedGame {
|
||||
game_id: string;
|
||||
title: string;
|
||||
store: string;
|
||||
host_fp: string;
|
||||
host_id: string;
|
||||
host_name: string;
|
||||
host: string;
|
||||
port: number;
|
||||
mgmt: number;
|
||||
added_at: number; // unix seconds
|
||||
paired?: boolean; // annotated by get_pins from the client's known-hosts store
|
||||
}
|
||||
|
||||
export interface PairResult {
|
||||
ok: boolean;
|
||||
fp?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// A host in the SHARED saved-hosts store (client-known-hosts.json) — the same file the desktop
|
||||
// client reads/writes, so add/rename/pair in either surface shows up in both. `online` comes
|
||||
// from a mDNS-INDEPENDENT reachability probe (a Tailscale/VPN host isn't shown offline just
|
||||
// because it doesn't advertise); `null` means reachability is unknown (probe skipped or a client
|
||||
// too old for `--list-hosts`, which then also can't probe).
|
||||
export interface SavedHost {
|
||||
/**
|
||||
* A host answering on mDNS right now (`punktfunk discover --json`).
|
||||
*
|
||||
* `saved`/`paired` are annotated BY THE CLI against the saved-hosts store — fingerprint first,
|
||||
* address second. The plugin does not join the two lists itself; that rule living in one place
|
||||
* is what stops this surface disagreeing with the desktop client about the same box.
|
||||
*/
|
||||
export interface DiscoveredHost {
|
||||
name: string;
|
||||
addr: string;
|
||||
port: number;
|
||||
fp_hex: string; // host cert fingerprint (lowercase hex); "" for a not-yet-paired manual entry
|
||||
fp: string; // advertised cert fingerprint (lowercase hex); "" when not advertised
|
||||
pair: string; // the HOST's policy: "required" | "optional"
|
||||
id: string; // the host's advertised stable id; "" when not advertised
|
||||
mgmt: number; // management-API port; 0 = not advertised
|
||||
os: string; // OS-identity chain, e.g. "linux/fedora/bazzite"; "" on older hosts
|
||||
saved: boolean;
|
||||
paired: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* A host in the shared saved-hosts store (`punktfunk hosts list --probe --json`) — the same
|
||||
* `client-known-hosts.json` the desktop client owns.
|
||||
*
|
||||
* `online` comes from a mDNS-INDEPENDENT probe, so a host reached over Tailscale/VPN is not
|
||||
* shown offline merely because it never advertises; `null` means the probe was skipped.
|
||||
*
|
||||
* `profile` is the host's DEFAULT binding, which a plain connect applies silently. It is not
|
||||
* the same thing as `pinned_profiles`, which are the cards a user chose to surface. Both come
|
||||
* back already resolved against the profile catalog, so this plugin never opens it.
|
||||
*/
|
||||
export interface SavedHost {
|
||||
id: string | null; // the record's stable id — the reference a launch should use
|
||||
name: string;
|
||||
addr: string;
|
||||
port: number;
|
||||
fp_hex: string; // "" for a placeholder saved by address with no pin yet
|
||||
paired: boolean;
|
||||
mac: string[];
|
||||
// OS-identity chain learned by the desktop client; optional because the installed
|
||||
// flatpak client may predate the field.
|
||||
os?: string;
|
||||
os: string;
|
||||
last_used: number | null;
|
||||
clipboard_sync: boolean;
|
||||
profile: Profile | null;
|
||||
pinned_profiles: Profile[];
|
||||
online: boolean | null;
|
||||
}
|
||||
|
||||
export interface HostsResult {
|
||||
ok: boolean;
|
||||
hosts: SavedHost[];
|
||||
probed: boolean;
|
||||
fallback?: boolean; // true when read straight off disk (client too old for --list-hosts)
|
||||
}
|
||||
|
||||
// The result of a host-store mutation (add/edit/forget). `error` is a stable code:
|
||||
// "client-unavailable" (flatpak missing) | "client-outdated" (client predates the mode) |
|
||||
// "unreachable"/"http"/… (from the client) | "client-error" (generic; see `detail`).
|
||||
export interface MutationResult {
|
||||
/**
|
||||
* Every backend call answers in this shape. `error` is a stable code, never prose:
|
||||
*
|
||||
* - `client-unavailable` — no client is installed, or the call never ran
|
||||
* - `client-outdated` — the installed client predates the verb (exit 5 + `unknown command`)
|
||||
* - `unreachable` — the host did not answer
|
||||
* - `refused` — trust rejected: a wrong PIN, or a fingerprint that already differs
|
||||
* - `needs-pairing` — the CLI refused because it needs a person
|
||||
* - `unresolved` — nothing matched what was named
|
||||
* - `client-error` — anything else; `detail` carries the CLI's own last line
|
||||
*/
|
||||
export interface CliResult {
|
||||
ok: boolean;
|
||||
error?: string;
|
||||
detail?: string;
|
||||
}
|
||||
|
||||
export interface DiscoverResult extends CliResult {
|
||||
hosts?: DiscoveredHost[];
|
||||
}
|
||||
|
||||
export interface HostsResult extends CliResult {
|
||||
hosts?: SavedHost[];
|
||||
}
|
||||
|
||||
export interface PairResult extends CliResult {
|
||||
fp?: string;
|
||||
}
|
||||
|
||||
export interface RunnerInfo {
|
||||
runner: string; // absolute path to bin/punktfunkrun.sh
|
||||
app_id: string; // flatpak app id
|
||||
@@ -101,99 +100,6 @@ export interface RunnerInfo {
|
||||
client_bin?: string;
|
||||
}
|
||||
|
||||
// The flatpak client's settings JSON — the SAME `client-gtk-settings.json` the desktop client
|
||||
// and the console's settings screen own, so a value changed in any of them shows in the others.
|
||||
//
|
||||
// Every field the client's `Settings` struct persists is modelled here EXCEPT the ones that
|
||||
// cannot be answered from a plugin backend or aren't settings at all:
|
||||
// • `forward_pad` — which physical pad is player 1. Needs SDL's live device list, which only
|
||||
// the client process has; there is no CLI that enumerates pads.
|
||||
// • `last_window_w/h` — the session's remembered window size, written BY the client, not a
|
||||
// preference anyone sets.
|
||||
// Both round-trip untouched: get_settings returns the whole parsed file, patches are object
|
||||
// spreads, and set_settings merges onto what's on disk.
|
||||
//
|
||||
// Optional (`?`) marks a key the client writes with a serde `default`, so a store written before
|
||||
// that key existed simply lacks it. Read those through the same fallback the client uses —
|
||||
// `?? true` for the default-on ones, never `!!` — or a pre-existing file reads as "off" here
|
||||
// while the stream runs with it on.
|
||||
export interface StreamSettings {
|
||||
// ---- Stream mode ----
|
||||
width: number; // 0 = native
|
||||
height: number; // 0 = native
|
||||
refresh_hz: number; // 0 = native
|
||||
render_scale?: number; // render-resolution multiplier; 1.0 = native (absent in pre-scale files)
|
||||
bitrate_kbps: number; // 0 = host default
|
||||
compositor: string; // "auto" | "kwin" | "wlroots" | "mutter" | "gamescope"
|
||||
// Stream mode follows the session window instead of width/height, renegotiating on resize.
|
||||
// Overrides width/height while on; degenerates to the display's native mode on fullscreen.
|
||||
match_window?: boolean;
|
||||
|
||||
// ---- Video ----
|
||||
codec?: string; // "auto" | "hevc" | "h264" | "av1" | "pyrowave" (absent in pre-codec files)
|
||||
decoder?: string; // "auto" | "vulkan" | "vaapi" | "software"
|
||||
hdr_enabled?: boolean; // default ON — advertise 10-bit/HDR10
|
||||
enable_444?: boolean; // default off — ask for full chroma
|
||||
adapter?: string; // decode/present GPU by marketing name; "" = automatic
|
||||
|
||||
// ---- Presentation ----
|
||||
// What the client optimises for when a decoded frame is ready: "latency" | "smooth". Shared
|
||||
// with the Apple and Android clients under this name, so one profile reads the same everywhere.
|
||||
present_priority?: string;
|
||||
smooth_buffer?: number; // frames held back under "smooth"; 0 = Automatic (resolves to 2), else 1–3
|
||||
vsync?: boolean; // default ON — tear-free; off asks for a tearing present mode (best-effort)
|
||||
allow_vrr?: boolean; // default ON — let a VRR panel refresh in step with the stream
|
||||
|
||||
// ---- Audio ----
|
||||
audio_channels?: number; // 2 (stereo) | 6 (5.1) | 8 (7.1)
|
||||
speaker_device?: string; // PipeWire node.name for playback; "" = system default
|
||||
mic_enabled: boolean;
|
||||
mic_device?: string; // PipeWire node.name for capture; "" = system default
|
||||
echo_cancel?: boolean; // default ON; only meaningful while mic_enabled
|
||||
|
||||
// ---- Controllers ----
|
||||
gamepad: string; // "auto" | "xbox360" | "xboxone" | "dualsense" | "dualshock4" | "steamdeck"
|
||||
// Forward this device's controllers at all. Absent in pre-forwarding files, where the
|
||||
// client's own serde default (true) applies — so `?? true` at every read, never `!!`.
|
||||
gamepad_forwarding?: boolean;
|
||||
|
||||
// ---- Touchscreen, mouse & keyboard ----
|
||||
touch_mode?: string; // "trackpad" | "pointer" | "touch"
|
||||
mouse_mode?: string; // "capture" | "desktop"
|
||||
invert_scroll?: boolean;
|
||||
// Whether the session grabs the keyboard so Alt+Tab/Super reach the host.
|
||||
inhibit_shortcuts: boolean;
|
||||
|
||||
// ---- Interface & behaviour ----
|
||||
// Stats-overlay tier: "off" | "compact" | "normal" | "detailed". Absent in a pre-tier file,
|
||||
// which resolves through `show_stats` — read both the way the client's
|
||||
// `Settings::stats_verbosity` does, and write both the way `set_stats_verbosity` does.
|
||||
stats_verbosity?: string;
|
||||
// The legacy on/off the tier supersedes; kept written in sync so a client that predates the
|
||||
// tiers still honours an Off chosen here.
|
||||
show_stats?: boolean;
|
||||
fullscreen_on_stream?: boolean;
|
||||
auto_wake?: boolean; // default ON — Wake-on-LAN a sleeping host before connecting
|
||||
library_enabled?: boolean; // the CLIENT's own library browser (this plugin has its own)
|
||||
}
|
||||
|
||||
// One audio endpoint from the client's enumeration: the stable id that gets stored, plus the
|
||||
// human name to show.
|
||||
export interface AudioDevice {
|
||||
name: string; // PipeWire node.name — what `speaker_device` / `mic_device` store
|
||||
description: string; // human label ("Steam Deck Speakers")
|
||||
}
|
||||
|
||||
// What the device pickers need, read from the session binary (`--list-adapters` / `--list-audio`).
|
||||
// `ok: false` = the session binary couldn't be run or failed; every list is then empty and the
|
||||
// pickers stay on their stored value rather than pretending the device is gone.
|
||||
export interface DeviceLists {
|
||||
ok: boolean;
|
||||
adapters: string[]; // Vulkan physical devices, discrete first
|
||||
sinks: AudioDevice[]; // playback endpoints
|
||||
sources: AudioDevice[]; // capture endpoints
|
||||
}
|
||||
|
||||
export interface UpdateInfo {
|
||||
current: string; // installed PLUGIN version (package.json)
|
||||
latest: string; // newest plugin version in our registry for this channel
|
||||
@@ -229,21 +135,30 @@ export interface ShortcutArt {
|
||||
icon_path: string;
|
||||
}
|
||||
|
||||
export const discover = callable<[], Host[]>("discover");
|
||||
// ---- The four CLI shells --------------------------------------------------------------
|
||||
|
||||
/** Browse the LAN over mDNS. Bounded by the CLI (3 s) plus a cold-start allowance. */
|
||||
export const discover = callable<[], DiscoverResult>("discover");
|
||||
/** The saved hosts, probed for reachability, with profiles and pinned cards resolved. */
|
||||
export const hosts = callable<[], HostsResult>("hosts");
|
||||
/** The PIN ceremony. `refused` = wrong PIN or a host that isn't armed. */
|
||||
export const pair = callable<
|
||||
[host: string, port: number, pin: string, name: string],
|
||||
[addr: string, port: number, pin: string, name: string],
|
||||
PairResult
|
||||
>("pair");
|
||||
// Fetch a paired host's game library (headless flatpak --library; can take seconds on a
|
||||
// cold client start — show a spinner). Pass fp whenever known so the pin can't degrade.
|
||||
export const library = callable<
|
||||
[host: string, mgmt_port: number, fp: string],
|
||||
LibraryResult
|
||||
>("library");
|
||||
export const getPins = callable<[], { pins: PinnedGame[] }>("get_pins");
|
||||
export const setPins = callable<[pins: PinnedGame[]], { ok: boolean; error?: string }>(
|
||||
"set_pins",
|
||||
);
|
||||
/**
|
||||
* Step 1 of request access: save the host with its ADVERTISED fingerprint, pinned but unpaired.
|
||||
* The launch that follows pins the same fingerprint, which is the only thing standing between a
|
||||
* 185 s wait for approval and an impostor answering for the host. Idempotent; a host already
|
||||
* saved under a DIFFERENT fingerprint comes back `refused` rather than being overwritten.
|
||||
*/
|
||||
export const trustHost = callable<
|
||||
[addr: string, port: number, fp: string, name: string],
|
||||
CliResult
|
||||
>("trust_host");
|
||||
|
||||
// ---- Steam / plugin business (only a Decky plugin can do these) ------------------------
|
||||
|
||||
export const runnerInfo = callable<[], RunnerInfo>("runner_info");
|
||||
export const shortcutArt = callable<[], ShortcutArt>("shortcut_art");
|
||||
// Install the Steam Input layout (native touchscreen `ts_n` + gamepad passthrough) and point our
|
||||
@@ -254,48 +169,7 @@ export const applyControllerConfig = callable<
|
||||
[name: string],
|
||||
{ ok: boolean; applied?: string[]; errors?: string[]; accounts?: number; error?: string; detail?: string }
|
||||
>("apply_controller_config");
|
||||
export const getSettings = callable<[], StreamSettings>("get_settings");
|
||||
export const setSettings = callable<[settings: StreamSettings], { ok: boolean }>(
|
||||
"set_settings",
|
||||
);
|
||||
// GPUs + audio endpoints for the device pickers. Costs a subprocess that initialises Vulkan and
|
||||
// PipeWire, so it is called ONCE when the settings tab mounts and never on the launch path.
|
||||
export const listDevices = callable<[], DeviceLists>("list_devices");
|
||||
// The same, bypassing the backend's cache — for the user who just plugged in a headset.
|
||||
export const refreshDevices = callable<[], DeviceLists>("refresh_devices");
|
||||
export const killStream = callable<[], { ok: boolean }>("kill_stream");
|
||||
// Send a Wake-on-LAN magic packet to a saved host (headless flatpak --wake) so a sleeping host is
|
||||
// up by the time the stream connects. The MAC is looked up from the flatpak client's own
|
||||
// known-hosts store; `ok: false` (no-op) when none has been learned yet. Fire before launching.
|
||||
export const wake = callable<[host: string, port: number], { ok: boolean; error?: string }>(
|
||||
"wake",
|
||||
);
|
||||
// ---- Shared saved-hosts store (the SAME client-known-hosts.json the desktop client owns) ----
|
||||
// The saved hosts, each annotated with a live (mDNS-independent) `online` probe when `probe` is
|
||||
// true. Falls back to a direct JSON read (no reachability) on a client too old for --list-hosts.
|
||||
export const listHosts = callable<[probe: boolean], HostsResult>("list_hosts");
|
||||
// Save a host by address (survives mDNS-blind networks). `fp` empty = unpaired placeholder to
|
||||
// pair next; a later pair replaces it with the fingerprinted entry.
|
||||
export const addHost = callable<[target: string, name: string, fp: string], MutationResult>(
|
||||
"add_host",
|
||||
);
|
||||
// Rename and/or re-point a saved host. `selector` = its fingerprint (survives IP change) or
|
||||
// current addr[:port]; empty fields are left untouched.
|
||||
export const editHost = callable<
|
||||
[selector: string, name: string, addr: string, port: number],
|
||||
MutationResult
|
||||
>("edit_host");
|
||||
// Remove a saved host by fingerprint or addr[:port] (idempotent).
|
||||
export const forgetHost = callable<[selector: string], MutationResult>("forget_host");
|
||||
// Reset this device's Punktfunk state (saved hosts + stream settings + pins); KEEPS the client
|
||||
// identity so the box isn't seen as new everywhere (re-pairing re-adds hosts).
|
||||
export const resetConfig = callable<[], { ok: boolean; error?: string }>("reset_config");
|
||||
// Reachability of one host[:port] via the client's mDNS-independent QUIC probe (a "test address"
|
||||
// check). `{ ok: true, online }` when determined, else `{ ok: false, error }`.
|
||||
export const probeHost = callable<
|
||||
[target: string],
|
||||
{ ok: boolean; online?: boolean; error?: string }
|
||||
>("probe_host");
|
||||
export const checkUpdate = callable<[force: boolean], UpdateInfo>("check_update");
|
||||
// Update the client by whichever route its install supports: `flatpak update --user` for the
|
||||
// flatpak, `punktfunk-client --apply-update` (the packaged root helper) for a one-tap-capable
|
||||
|
||||
+194
-347
@@ -1,18 +1,14 @@
|
||||
// Shared state hooks + user actions for the QAM panel and the fullscreen page.
|
||||
// Shared state hooks + user actions for the QAM panel.
|
||||
import { toaster } from "@decky/api";
|
||||
import { Navigation } from "@decky/ui";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
checkUpdate,
|
||||
discover,
|
||||
GameEntry,
|
||||
getPins,
|
||||
Host,
|
||||
listHosts,
|
||||
PinnedGame,
|
||||
resetConfig,
|
||||
DiscoveredHost,
|
||||
hosts as listHosts,
|
||||
Profile,
|
||||
SavedHost,
|
||||
setPins as setPinsBackend,
|
||||
updateClient,
|
||||
UpdateInfo,
|
||||
} from "./backend";
|
||||
@@ -37,19 +33,191 @@ declare global {
|
||||
// PluginInstallType.UPDATE in decky-loader's browser.py (INSTALL=0/REINSTALL=1/UPDATE=2/…).
|
||||
const INSTALL_TYPE_UPDATE = 2;
|
||||
|
||||
/**
|
||||
* How far this device has got with a host. The three states are what the row says under the
|
||||
* name, and which of them a host is in decides whether pressing it streams or opens the trust
|
||||
* sheet.
|
||||
*
|
||||
* - `paired` — the host approved this device (a PIN ceremony, or request access).
|
||||
* - `trusted` — its fingerprint is pinned but nobody has approved us yet. Streams work if
|
||||
* the host's policy is `optional`; under `required` the connect parks.
|
||||
* - `needs-access` — no pinned fingerprint. Not streamable until the trust sheet runs.
|
||||
*/
|
||||
export type TrustState = "paired" | "trusted" | "needs-access";
|
||||
|
||||
/**
|
||||
* One host as the panel shows it — the union of the saved store and the live mDNS browse.
|
||||
*
|
||||
* A saved host is ONLINE when it either advertises or answers the reachability probe, so a box
|
||||
* reached over Tailscale/VPN stops reading as offline. Discovered hosts that aren't saved are
|
||||
* appended as extra rows.
|
||||
*/
|
||||
export interface HostView {
|
||||
name: string;
|
||||
addr: string;
|
||||
port: number;
|
||||
/**
|
||||
* The fingerprint PINNED ON THE RECORD. "" means nothing is pinned, which is exactly what
|
||||
* makes a host unstreamable — the session binary refuses a pinless connect.
|
||||
*
|
||||
* Deliberately NOT filled in from a live advert. A host saved by address that happens to be
|
||||
* advertising right now still has an empty pin on disk, and borrowing the advert's here would
|
||||
* draw it as ready to stream while every launch refused for want of a fingerprint. What the
|
||||
* advert offers is [`advertisedFp`], and moving it onto the record is a trust decision the
|
||||
* user makes in the sheet.
|
||||
*/
|
||||
fp: string;
|
||||
/** What the host is advertising right now, if anything — what request access would pin. */
|
||||
advertisedFp: string;
|
||||
/**
|
||||
* The host is answering at an address its record does not carry — it changed DHCP lease.
|
||||
*
|
||||
* This matters because a launch names the host by [`ref`], and the CLI dials whatever address
|
||||
* the RECORD holds. So the row would show the live address and dial the dead one. The record
|
||||
* has to be re-pointed before such a host can stream; `startStream` does it.
|
||||
*/
|
||||
moved: boolean;
|
||||
paired: boolean;
|
||||
online: boolean;
|
||||
saved: boolean;
|
||||
/** The advert's policy ("required"|"optional"); "" when the host isn't advertising. */
|
||||
pairPolicy: string;
|
||||
/** OS-identity chain (live advert preferred, else the stored one); "" unknown. */
|
||||
os: string;
|
||||
/**
|
||||
* What a launch should NAME this host by: the record's stable id, which survives renames and
|
||||
* DHCP moves, falling back to `addr:port` for a row that has no record yet (a discovered host
|
||||
* the trust sheet is about to save, or a client too old to have minted ids).
|
||||
*/
|
||||
ref: string;
|
||||
/** The host's default profile binding — applied silently by a plain connect, not a card. */
|
||||
profile: Profile | null;
|
||||
/** The cards to render nested under this host; already resolved against the catalog. */
|
||||
pinnedProfiles: Profile[];
|
||||
lastUsed: number | null;
|
||||
}
|
||||
|
||||
export function trustState(v: HostView): TrustState {
|
||||
if (v.paired) return "paired";
|
||||
return v.fp ? "trusted" : "needs-access";
|
||||
}
|
||||
|
||||
/**
|
||||
* Must this host go through the trust sheet before it can stream?
|
||||
*
|
||||
* A pinned fingerprint is the ONLY rule. The session binary refuses a pinless connect, so a row
|
||||
* without one can offer nothing but a button that fails; with one, the connect is verified and
|
||||
* the host either admits it or parks it for an operator. The old rule also consulted the
|
||||
* advertised policy for unsaved hosts, which made the answer depend on which of two lists a row
|
||||
* came from — the same box could read differently before and after being saved.
|
||||
*/
|
||||
export function needsPair(v: HostView): boolean {
|
||||
return v.fp === "";
|
||||
}
|
||||
|
||||
function advertMatchesSaved(a: DiscoveredHost, s: SavedHost): boolean {
|
||||
return (
|
||||
(!!s.fp_hex && !!a.fp && s.fp_hex.toLowerCase() === a.fp.toLowerCase()) ||
|
||||
(s.addr === a.addr && s.port === a.port)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Join the saved store and the live browse into the rows the panel draws.
|
||||
*
|
||||
* Fingerprint first, address second — a host that moved DHCP lease still matches its record,
|
||||
* and a different box that inherited the old address does not inherit its pairing. The CLI's
|
||||
* `discover` annotates `saved`/`paired` by exactly this rule too, so the two can't disagree.
|
||||
*/
|
||||
export function mergeHosts(saved: SavedHost[], discovered: DiscoveredHost[]): HostView[] {
|
||||
const views: HostView[] = saved.map((s) => {
|
||||
// Prefer a live advert's address: the host may have moved since it was last saved.
|
||||
const advert = discovered.find((a) => advertMatchesSaved(a, s));
|
||||
return {
|
||||
name: s.name || s.addr,
|
||||
addr: advert?.addr ?? s.addr,
|
||||
port: advert?.port ?? s.port,
|
||||
fp: s.fp_hex,
|
||||
advertisedFp: advert?.fp ?? "",
|
||||
moved: !!advert && (advert.addr !== s.addr || advert.port !== s.port),
|
||||
paired: s.paired,
|
||||
online: !!advert || s.online === true,
|
||||
saved: true,
|
||||
pairPolicy: advert?.pair ?? "",
|
||||
os: advert?.os || s.os || "",
|
||||
ref: s.id || `${advert?.addr ?? s.addr}:${advert?.port ?? s.port}`,
|
||||
profile: s.profile,
|
||||
pinnedProfiles: s.pinned_profiles ?? [],
|
||||
lastUsed: s.last_used,
|
||||
};
|
||||
});
|
||||
for (const a of discovered) {
|
||||
if (saved.some((s) => advertMatchesSaved(a, s))) {
|
||||
continue; // already rendered as its saved row, with a live pip
|
||||
}
|
||||
views.push({
|
||||
name: a.name,
|
||||
addr: a.addr,
|
||||
port: a.port,
|
||||
// No record, so nothing is pinned — whatever it advertises is an OFFER, not a pin.
|
||||
fp: "",
|
||||
advertisedFp: a.fp,
|
||||
moved: false, // no record, so nothing to be stale
|
||||
paired: a.paired,
|
||||
online: true,
|
||||
saved: false,
|
||||
pairPolicy: a.pair,
|
||||
os: a.os,
|
||||
ref: `${a.addr}:${a.port}`,
|
||||
profile: null,
|
||||
pinnedProfiles: [],
|
||||
lastUsed: null,
|
||||
});
|
||||
}
|
||||
return views.sort(sortRows);
|
||||
}
|
||||
|
||||
/**
|
||||
* Online first, then most recently used, then by name. The host you streamed last night should
|
||||
* be the first thing under your thumb; a host that is off right now should never be.
|
||||
*/
|
||||
function sortRows(a: HostView, b: HostView): number {
|
||||
if (a.online !== b.online) return a.online ? -1 : 1;
|
||||
if ((a.lastUsed ?? 0) !== (b.lastUsed ?? 0)) return (b.lastUsed ?? 0) - (a.lastUsed ?? 0);
|
||||
return a.name.localeCompare(b.name);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------------------
|
||||
// Discovery — mDNS scan state shared by the QAM panel and the full page.
|
||||
// Hosts — ONE call site for both lists. They were separate hooks when the plugin had two
|
||||
// views mounting them independently; the panel is the only view now, and merging them means
|
||||
// the "scanning" state covers the whole row set rather than half of it flickering in first.
|
||||
// ----------------------------------------------------------------------------------------
|
||||
export function useHosts() {
|
||||
const [hosts, setHosts] = useState<Host[]>([]);
|
||||
const [views, setViews] = useState<HostView[]>([]);
|
||||
const [scanning, setScanning] = useState(false);
|
||||
// Why the list is empty, when it is empty for a reason other than an empty LAN. Rendering
|
||||
// either of these as "No hosts yet" would blame the user's network for the plugin's problem:
|
||||
// "client-outdated" — the installed client predates `punktfunk discover`
|
||||
// "client-unavailable" — there is no client installed at all
|
||||
const [problem, setProblem] = useState<string | null>(null);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setScanning(true);
|
||||
try {
|
||||
setHosts(await discover());
|
||||
// Both in flight at once: the browse is time-bounded and the probe is network-bound, so
|
||||
// running them in sequence would cost the sum of two waits for no benefit.
|
||||
const [d, s] = await Promise.all([discover(), listHosts()]);
|
||||
// Both calls run the same binary, so they fail the same way; take whichever answered.
|
||||
setProblem(
|
||||
d.error === "client-unavailable" || s.error === "client-unavailable"
|
||||
? "client-unavailable"
|
||||
: d.error === "client-outdated" || s.error === "client-outdated"
|
||||
? "client-outdated"
|
||||
: null,
|
||||
);
|
||||
setViews(mergeHosts(s.hosts ?? [], d.hosts ?? []));
|
||||
} catch (e) {
|
||||
toaster.toast({ title: "Punktfunk", body: `Discovery failed: ${e}` });
|
||||
toaster.toast({ title: "Punktfunk", body: `Couldn't list hosts: ${e}` });
|
||||
} finally {
|
||||
setScanning(false);
|
||||
}
|
||||
@@ -59,157 +227,7 @@ export function useHosts() {
|
||||
void refresh();
|
||||
}, [refresh]);
|
||||
|
||||
return { hosts, scanning, refresh };
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------------------
|
||||
// Saved hosts — the SHARED known-hosts store (client-known-hosts.json), the same file the
|
||||
// desktop client reads/writes. Fetched WITH a reachability probe so a host reached over a
|
||||
// routed network (Tailscale/VPN) reports online without ever appearing on mDNS.
|
||||
// ----------------------------------------------------------------------------------------
|
||||
export function useSavedHosts() {
|
||||
const [saved, setSaved] = useState<SavedHost[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const r = await listHosts(true);
|
||||
setSaved(r.hosts ?? []);
|
||||
} catch {
|
||||
/* backend unavailable — keep the current view */
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
}, [refresh]);
|
||||
|
||||
return { saved, loading, refresh };
|
||||
}
|
||||
|
||||
/**
|
||||
* One host as the UI shows it — the union of the saved store and the live mDNS scan. A saved
|
||||
* host is ONLINE when it either advertises on mDNS OR answers the reachability probe (so
|
||||
* mDNS-blind-but-reachable hosts stop reading as offline). Discovered hosts not in the store
|
||||
* are appended as unsaved rows.
|
||||
*/
|
||||
export interface HostView {
|
||||
name: string;
|
||||
addr: string;
|
||||
port: number;
|
||||
fp: string; // "" for a saved-but-unpaired placeholder
|
||||
paired: boolean; // PIN-paired specifically (a TOFU host has fp but paired=false)
|
||||
online: boolean;
|
||||
saved: boolean; // present in the known-hosts store
|
||||
pairPolicy: string; // the advert's policy ("required"|"optional"), "" when not advertising
|
||||
mgmt: number; // advertised mgmt-API port (0 = not advertised → default)
|
||||
id: string; // advertised stable host id ("" when not advertising)
|
||||
os: string; // OS-identity chain (live advert preferred, else the stored one); "" unknown
|
||||
}
|
||||
|
||||
function advertMatchesSaved(a: Host, s: SavedHost): boolean {
|
||||
return (
|
||||
(!!s.fp_hex && !!a.fp && s.fp_hex.toLowerCase() === a.fp.toLowerCase()) ||
|
||||
(s.addr === a.host && s.port === a.port)
|
||||
);
|
||||
}
|
||||
|
||||
export function mergeHosts(saved: SavedHost[], discovered: Host[]): HostView[] {
|
||||
const views: HostView[] = saved.map((s) => {
|
||||
// Prefer a live advert's address (a host may have moved DHCP leases since it was saved).
|
||||
const advert = discovered.find((a) => advertMatchesSaved(a, s));
|
||||
return {
|
||||
name: s.name || s.addr,
|
||||
addr: advert?.host ?? s.addr,
|
||||
port: advert?.port ?? s.port,
|
||||
fp: s.fp_hex || advert?.fp || "",
|
||||
paired: s.paired,
|
||||
online: !!advert || s.online === true,
|
||||
saved: true,
|
||||
pairPolicy: advert?.pair ?? "",
|
||||
mgmt: advert?.mgmt ?? 0,
|
||||
id: advert?.id ?? "",
|
||||
os: advert?.os || s.os || "",
|
||||
};
|
||||
});
|
||||
for (const a of discovered) {
|
||||
if (saved.some((s) => advertMatchesSaved(a, s))) {
|
||||
continue; // already rendered as its saved card (with a live pip)
|
||||
}
|
||||
views.push({
|
||||
name: a.name,
|
||||
addr: a.host,
|
||||
port: a.port,
|
||||
fp: a.fp,
|
||||
paired: a.paired,
|
||||
online: true,
|
||||
saved: false,
|
||||
pairPolicy: a.pair,
|
||||
mgmt: a.mgmt,
|
||||
id: a.id,
|
||||
os: a.os,
|
||||
});
|
||||
}
|
||||
return views;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when this host must be paired before it can stream. A saved host is streamable once it
|
||||
* has a pinned fingerprint (PIN-paired OR TOFU-trusted); a saved placeholder (no fp yet) must be
|
||||
* paired. For an unsaved discovered host we keep the advertised-policy rule the UI always used.
|
||||
*/
|
||||
export function needsPair(v: HostView): boolean {
|
||||
return v.saved ? v.fp === "" : v.pairPolicy === "required" && !v.paired;
|
||||
}
|
||||
|
||||
/** Adapt a merged view back into the `Host` shape the pair/library/stream helpers consume. */
|
||||
export function toHost(v: HostView): Host {
|
||||
return {
|
||||
name: v.name,
|
||||
host: v.addr,
|
||||
port: v.port,
|
||||
pair: v.pairPolicy || (needsPair(v) ? "required" : "optional"),
|
||||
fp: v.fp,
|
||||
proto: "",
|
||||
paired: v.paired,
|
||||
id: v.id,
|
||||
mgmt: v.mgmt,
|
||||
os: v.os,
|
||||
};
|
||||
}
|
||||
|
||||
/** Is a pinned game's host currently online, considering BOTH the live scan and saved probe? */
|
||||
export function pinIsOnline(pin: PinnedGame, views: HostView[]): boolean {
|
||||
const fp = pin.host_fp.toLowerCase();
|
||||
return views.some(
|
||||
(v) =>
|
||||
v.online &&
|
||||
((!!fp && v.fp.toLowerCase() === fp) ||
|
||||
(!!pin.host_id && v.id === pin.host_id) ||
|
||||
(v.addr === pin.host && v.port === pin.port)),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset all Punktfunk state (saved hosts + stream settings + pins), keeping the client identity.
|
||||
* Refreshes whatever views are passed so the UI clears immediately. Ends in a toast.
|
||||
*/
|
||||
export async function resetAll(refreshers: Array<() => void | Promise<void>>): Promise<void> {
|
||||
try {
|
||||
const r = await resetConfig();
|
||||
for (const fn of refreshers) void fn();
|
||||
toaster.toast({
|
||||
title: "Punktfunk",
|
||||
body: r.ok
|
||||
? "Reset — saved hosts, settings, and pins cleared."
|
||||
: `Reset failed${r.error ? ` (${r.error})` : ""}.`,
|
||||
});
|
||||
} catch {
|
||||
toaster.toast({ title: "Punktfunk", body: "Reset failed." });
|
||||
}
|
||||
return { views, scanning, problem, refresh };
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------------------
|
||||
@@ -260,36 +278,6 @@ export function clientUpdateIsOneTap(info: UpdateInfo | null | undefined): boole
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* How the client got onto this box, in words a Deck user recognises. The raw kind comes from
|
||||
* the client's own detector (`pf_update_check::detect`); anything unmapped falls through as
|
||||
* itself rather than as "unknown", because the raw word is still more useful than a shrug.
|
||||
*/
|
||||
export function clientInstallLabel(kind: string): string {
|
||||
switch (kind) {
|
||||
case "flatpak":
|
||||
return "Flatpak (per-user)";
|
||||
case "apt":
|
||||
return "System package (apt)";
|
||||
case "dnf":
|
||||
return "System package (dnf)";
|
||||
case "rpm-ostree":
|
||||
return "Layered package (rpm-ostree)";
|
||||
case "pacman":
|
||||
return "System package (pacman)";
|
||||
case "sysext":
|
||||
return "System extension (sysext)";
|
||||
case "nix":
|
||||
return "Nix profile";
|
||||
case "steamos-source":
|
||||
return "On-device build";
|
||||
case "source":
|
||||
return "Built from source";
|
||||
default:
|
||||
return kind;
|
||||
}
|
||||
}
|
||||
|
||||
/** True when the only pending update is one this Deck can't apply itself. */
|
||||
export function clientUpdateIsManualOnly(info: UpdateInfo | null | undefined): boolean {
|
||||
return !!info && info.client_update_available && !clientUpdateIsOneTap(info);
|
||||
@@ -427,167 +415,26 @@ export async function applyUpdate(
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------------------
|
||||
// Stream launch — via the hidden Steam shortcut (see steam.ts for why).
|
||||
// Stream launch — via the hidden Steam shortcut (see steam.ts for why it can't be direct).
|
||||
// ----------------------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Stream this host. `opts.profileId` streams one of its pinned cards; `opts.requestAccess`
|
||||
* runs the supervised launch that waits for the host's operator to approve this Deck.
|
||||
*
|
||||
* The host is named by REFERENCE (`v.ref`), never by value — no resolution, bitrate or codec
|
||||
* ever rides the launch path, which is the same rule the deep-link grammar enforces.
|
||||
*/
|
||||
export async function startStream(
|
||||
h: Host,
|
||||
v: HostView,
|
||||
opts: LaunchOpts = {},
|
||||
label?: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await launchStream(h.host, h.port, opts);
|
||||
await launchStream(v.ref, opts);
|
||||
Navigation.CloseSideMenus();
|
||||
toaster.toast({ title: "Punktfunk", body: `Starting ${label ?? "stream"} — ${h.name}` });
|
||||
toaster.toast({ title: "Punktfunk", body: `Starting ${label ?? "stream"} — ${v.name}` });
|
||||
} catch (e) {
|
||||
toaster.toast({ title: "Punktfunk", body: `Launch failed: ${e}` });
|
||||
}
|
||||
}
|
||||
|
||||
/** Open the GTK client's gamepad library launcher for a host (`--browse` via PF_BROWSE). */
|
||||
export async function startBrowse(h: Host): Promise<void> {
|
||||
try {
|
||||
await launchStream(h.host, h.port, { browse: true, mgmt: h.mgmt });
|
||||
Navigation.CloseSideMenus();
|
||||
toaster.toast({ title: "Punktfunk", body: `Opening library — ${h.name}` });
|
||||
} catch (e) {
|
||||
toaster.toast({ title: "Punktfunk", body: `Launch failed: ${e}` });
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------------------
|
||||
// Pinned games — the QAM's one-tap game rows, persisted by the backend next to the
|
||||
// client's config (survives plugin reinstalls).
|
||||
// ----------------------------------------------------------------------------------------
|
||||
export interface PinsApi {
|
||||
pins: PinnedGame[];
|
||||
addPin: (h: Host, g: GameEntry) => void;
|
||||
removePin: (hostFp: string, gameId: string) => void;
|
||||
isPinned: (hostFp: string, gameId: string) => boolean;
|
||||
/** Refresh a pin's stored address from a live advert (hosts change IPs). */
|
||||
updatePinHost: (pin: PinnedGame, h: Host) => void;
|
||||
refresh: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function usePins(): PinsApi {
|
||||
const [pins, setPins] = useState<PinnedGame[]>([]);
|
||||
// A live mirror of `pins`. The Games picker is mounted by Decky's `showModal` into a
|
||||
// detached portal that captures this hook's callbacks ONCE and never re-renders with fresh
|
||||
// props, so a mutator closing over the `pins` array reads a frozen base — pinning a second
|
||||
// game in the same session would compute from the stale `[]` and clobber the first (silent
|
||||
// data loss). Reading the ref keeps every mutation based on the current set, and lets the
|
||||
// callbacks keep a stable identity (deps free of `pins`).
|
||||
const pinsRef = useRef<PinnedGame[]>([]);
|
||||
pinsRef.current = pins;
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
setPins((await getPins()).pins);
|
||||
} catch {
|
||||
/* backend unavailable — keep the current view */
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
}, [refresh]);
|
||||
|
||||
// Optimistic local state; the backend validates/dedups and is re-read on failure.
|
||||
const save = useCallback(
|
||||
(next: PinnedGame[]) => {
|
||||
pinsRef.current = next;
|
||||
setPins(next);
|
||||
setPinsBackend(next).catch(() => void refresh());
|
||||
},
|
||||
[refresh],
|
||||
);
|
||||
|
||||
const addPin = useCallback(
|
||||
(h: Host, g: GameEntry) => {
|
||||
const pin: PinnedGame = {
|
||||
game_id: g.id,
|
||||
title: g.title,
|
||||
store: g.store,
|
||||
host_fp: h.fp,
|
||||
host_id: h.id,
|
||||
host_name: h.name,
|
||||
host: h.host,
|
||||
port: h.port,
|
||||
mgmt: h.mgmt,
|
||||
added_at: Math.floor(Date.now() / 1000),
|
||||
paired: h.paired,
|
||||
};
|
||||
save([
|
||||
...pinsRef.current.filter(
|
||||
(p) => !(p.host_fp === pin.host_fp && p.game_id === pin.game_id),
|
||||
),
|
||||
pin,
|
||||
]);
|
||||
},
|
||||
[save],
|
||||
);
|
||||
|
||||
const removePin = useCallback(
|
||||
(hostFp: string, gameId: string) => {
|
||||
save(pinsRef.current.filter((p) => !(p.host_fp === hostFp && p.game_id === gameId)));
|
||||
},
|
||||
[save],
|
||||
);
|
||||
|
||||
const isPinned = useCallback(
|
||||
(hostFp: string, gameId: string) =>
|
||||
pins.some((p) => p.host_fp === hostFp && p.game_id === gameId),
|
||||
[pins],
|
||||
);
|
||||
|
||||
const updatePinHost = useCallback(
|
||||
(pin: PinnedGame, h: Host) => {
|
||||
if (pin.host === h.host && pin.port === h.port && pin.mgmt === h.mgmt) {
|
||||
return;
|
||||
}
|
||||
save(
|
||||
pinsRef.current.map((p) =>
|
||||
p.host_fp === pin.host_fp && p.game_id === pin.game_id
|
||||
? { ...p, host: h.host, port: h.port, mgmt: h.mgmt, host_name: h.name }
|
||||
: p,
|
||||
),
|
||||
);
|
||||
},
|
||||
[save],
|
||||
);
|
||||
|
||||
return { pins, addPin, removePin, isPinned, updatePinHost, refresh };
|
||||
}
|
||||
|
||||
/**
|
||||
* The host a pin should launch against right now: match the live mDNS scan by cert
|
||||
* fingerprint first (pairing is fp-keyed, survives IP changes), then by the host's stable
|
||||
* id, else fall back to the stored address (host offline or scan flaky — still launch).
|
||||
*/
|
||||
export function resolvePinHost(
|
||||
pin: PinnedGame,
|
||||
live: Host[],
|
||||
): { host: Host; online: boolean } {
|
||||
const fp = pin.host_fp.toLowerCase();
|
||||
const match =
|
||||
(fp && live.find((h) => h.fp && h.fp.toLowerCase() === fp)) ||
|
||||
(pin.host_id && live.find((h) => h.id && h.id === pin.host_id)) ||
|
||||
undefined;
|
||||
if (match) {
|
||||
return { host: match, online: true };
|
||||
}
|
||||
return {
|
||||
host: {
|
||||
name: pin.host_name || pin.host,
|
||||
host: pin.host,
|
||||
port: pin.port,
|
||||
pair: pin.paired ? "optional" : "required",
|
||||
fp: pin.host_fp,
|
||||
proto: "",
|
||||
paired: !!pin.paired,
|
||||
id: pin.host_id,
|
||||
mgmt: pin.mgmt,
|
||||
os: "", // pins don't store the chain; the icon is a hosts-tab affordance
|
||||
},
|
||||
online: false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,164 +0,0 @@
|
||||
// Add / edit host dialogs for the fullscreen page. These mutate the SHARED known-hosts store
|
||||
// (client-known-hosts.json) through the flatpak client's headless modes, so a host saved or
|
||||
// renamed here shows up in the desktop client too. Text entry uses @decky/ui's TextField, which
|
||||
// brings up Steam's on-screen keyboard on focus (the digit-grid trick in pair.tsx is only needed
|
||||
// for the numeric PIN).
|
||||
import { DialogButton, Focusable, ModalRoot, Spinner, TextField } from "@decky/ui";
|
||||
import { toaster } from "@decky/api";
|
||||
import { ChangeEvent, FC, useState } from "react";
|
||||
import { addHost, editHost, MutationResult } from "./backend";
|
||||
import { HostView } from "./hooks";
|
||||
import { actionButton } from "./ui";
|
||||
|
||||
/** Stable copy for a failed host-store mutation. */
|
||||
export function mutationError(r: MutationResult): string {
|
||||
switch (r.error) {
|
||||
case "client-unavailable":
|
||||
return "The Punktfunk client isn't installed (flatpak io.unom.Punktfunk).";
|
||||
case "client-outdated":
|
||||
return "The installed client is too old for host management — update it from the About tab.";
|
||||
default:
|
||||
return r.detail || "Couldn't save the host.";
|
||||
}
|
||||
}
|
||||
|
||||
// Split a typed address: a pasted `host:port` wins over the separate port field. IPv6 literals
|
||||
// aren't supported by the host advert/known-hosts format, so a bare colon is treated as host:port.
|
||||
function targetFrom(addr: string, port: string): string {
|
||||
const a = addr.trim();
|
||||
if (a.includes(":")) {
|
||||
return a;
|
||||
}
|
||||
const p = port.trim() || "9777";
|
||||
return `${a}:${p}`;
|
||||
}
|
||||
|
||||
const field: React.CSSProperties = { marginBottom: "0.8em" };
|
||||
|
||||
const HostForm: FC<{
|
||||
title: string;
|
||||
submitLabel: string;
|
||||
initial: { addr: string; port: string; name: string };
|
||||
addrDisabled?: boolean;
|
||||
onSubmit: (addr: string, port: string, name: string) => Promise<MutationResult>;
|
||||
onDone: () => void;
|
||||
closeModal?: () => void;
|
||||
}> = ({ title, submitLabel, initial, addrDisabled, onSubmit, onDone, closeModal }) => {
|
||||
const [addr, setAddr] = useState(initial.addr);
|
||||
const [port, setPort] = useState(initial.port);
|
||||
const [name, setName] = useState(initial.name);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const submit = async () => {
|
||||
if (!addr.trim()) {
|
||||
setError("Enter an address.");
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const r = await onSubmit(addr.trim(), port.trim(), name.trim());
|
||||
if (r.ok) {
|
||||
onDone();
|
||||
closeModal?.();
|
||||
} else {
|
||||
setError(mutationError(r));
|
||||
}
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ModalRoot closeModal={closeModal}>
|
||||
<div style={{ fontWeight: "bold", fontSize: "1.3em", marginBottom: "0.6em" }}>{title}</div>
|
||||
<div style={field}>
|
||||
<TextField
|
||||
label="Address"
|
||||
description="IP or hostname (a Tailscale/VPN name works too). Add :port to override."
|
||||
value={addr}
|
||||
disabled={addrDisabled || busy}
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>) => setAddr(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div style={field}>
|
||||
<TextField
|
||||
label="Port"
|
||||
value={port}
|
||||
mustBeNumeric
|
||||
disabled={busy}
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>) => setPort(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div style={field}>
|
||||
<TextField
|
||||
label="Name (optional)"
|
||||
value={name}
|
||||
disabled={busy}
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>) => setName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{error && (
|
||||
<div style={{ color: "#ff6b6b", marginBottom: "0.6em" }}>{error}</div>
|
||||
)}
|
||||
<Focusable style={{ display: "flex", gap: "0.5em", justifyContent: "flex-end" }}>
|
||||
<DialogButton style={actionButton} disabled={busy} onClick={() => closeModal?.()}>
|
||||
Cancel
|
||||
</DialogButton>
|
||||
<DialogButton style={actionButton} disabled={busy} onClick={submit}>
|
||||
{busy ? <Spinner style={{ height: "1em" }} /> : submitLabel}
|
||||
</DialogButton>
|
||||
</Focusable>
|
||||
</ModalRoot>
|
||||
);
|
||||
};
|
||||
|
||||
/** "+" — save a new host by address (unpaired placeholder; the user pairs it next). */
|
||||
export const AddHostModal: FC<{ onDone: () => void; closeModal?: () => void }> = ({
|
||||
onDone,
|
||||
closeModal,
|
||||
}) => (
|
||||
<HostForm
|
||||
title="Add host"
|
||||
submitLabel="Add"
|
||||
initial={{ addr: "", port: "9777", name: "" }}
|
||||
onSubmit={async (addr, port, name) => {
|
||||
const r = await addHost(targetFrom(addr, port), name, "");
|
||||
if (r.ok) {
|
||||
toaster.toast({ title: "Punktfunk", body: `Added ${name || addr}` });
|
||||
}
|
||||
return r;
|
||||
}}
|
||||
onDone={onDone}
|
||||
closeModal={closeModal}
|
||||
/>
|
||||
);
|
||||
|
||||
/** Rename / re-point a saved host. Identified by fingerprint when it has one (survives IP
|
||||
* changes), else by its current address. */
|
||||
export const EditHostModal: FC<{
|
||||
host: HostView;
|
||||
onDone: () => void;
|
||||
closeModal?: () => void;
|
||||
}> = ({ host, onDone, closeModal }) => {
|
||||
const selector = host.fp || `${host.addr}:${host.port}`;
|
||||
return (
|
||||
<HostForm
|
||||
title={`Edit ${host.name}`}
|
||||
submitLabel="Save"
|
||||
initial={{ addr: host.addr, port: String(host.port), name: host.name }}
|
||||
onSubmit={async (addr, port, name) => {
|
||||
const r = await editHost(selector, name, addr, parseInt(port, 10) || 0);
|
||||
if (r.ok) {
|
||||
toaster.toast({ title: "Punktfunk", body: `Updated ${name || addr}` });
|
||||
}
|
||||
return r;
|
||||
}}
|
||||
onDone={onDone}
|
||||
closeModal={closeModal}
|
||||
/>
|
||||
);
|
||||
};
|
||||
+148
-117
@@ -1,46 +1,47 @@
|
||||
// Plugin entry: the Quick Access Menu panel + route registration. The fullscreen page lives
|
||||
// in page.tsx; shared hooks/actions in hooks.ts; the Steam-shortcut launch in steam.ts.
|
||||
// Plugin entry: the Quick Access Menu panel. That is the whole plugin now — the fullscreen
|
||||
// route, the settings screen, the host editor and the games picker are gone, because the
|
||||
// client's own console home does all four one shortcut away (and is gamepad-navigable, which
|
||||
// a QAM panel re-implementing them never quite was).
|
||||
//
|
||||
// What is left is what only a Decky plugin can do: start a stream through Steam so gamescope
|
||||
// focuses it (see steam.ts), and stand in front of the trust decision that gates it.
|
||||
import {
|
||||
ButtonItem,
|
||||
Field,
|
||||
Navigation,
|
||||
PanelSection,
|
||||
PanelSectionRow,
|
||||
Spinner,
|
||||
showModal,
|
||||
staticClasses,
|
||||
} from "@decky/ui";
|
||||
import { definePlugin, routerHook, toaster } from "@decky/api";
|
||||
import { definePlugin, toaster } from "@decky/api";
|
||||
import { FC } from "react";
|
||||
import {
|
||||
FaDownload,
|
||||
FaLock,
|
||||
FaLockOpen,
|
||||
FaPlay,
|
||||
FaPlus,
|
||||
FaStopCircle,
|
||||
FaSyncAlt,
|
||||
FaTv,
|
||||
} from "react-icons/fa";
|
||||
import { killStream } from "./backend";
|
||||
import { PluginErrorBoundary } from "./boundary";
|
||||
import {
|
||||
applyUpdate,
|
||||
checkForUpdatesNow,
|
||||
clientUpdateIsManualOnly,
|
||||
hasUpdate,
|
||||
mergeHosts,
|
||||
HostView,
|
||||
needsPair,
|
||||
pinIsOnline,
|
||||
startStream,
|
||||
toHost,
|
||||
trustState,
|
||||
useHosts,
|
||||
usePins,
|
||||
useSavedHosts,
|
||||
useUpdate,
|
||||
} from "./hooks";
|
||||
import { streamPin } from "./library";
|
||||
import { PunktfunkRoute, ROUTE } from "./page";
|
||||
import { PairModal } from "./pair";
|
||||
import { ensureGamepadUiShortcut, recreateShortcuts } from "./steam";
|
||||
import { OsMark } from "./os-icon";
|
||||
import { ensureGamepadUiShortcut, launchGamepadUi, recreateShortcuts, stopStream } from "./steam";
|
||||
import { TrustSheet } from "./trust";
|
||||
|
||||
// Recovery action for "the Punktfunk library entry vanished" — recreates the visible shortcut.
|
||||
// Deleting the shortcut (optionally + reinstalling the plugin) leaves a stale appId in Steam's
|
||||
@@ -54,22 +55,78 @@ async function recreatePunktfunkShortcut(): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------------------
|
||||
// QAM panel — quick status + entry into the full page + one-tap stream for known hosts
|
||||
// and pinned games.
|
||||
// ----------------------------------------------------------------------------------------
|
||||
const QamPanel: FC = () => {
|
||||
const { hosts: discovered, scanning, refresh: refreshDiscovered } = useHosts();
|
||||
const { saved, loading: loadingSaved, refresh: refreshSaved } = useSavedHosts();
|
||||
const { info: update, checking, check } = useUpdate();
|
||||
const pins = usePins();
|
||||
/** Force-stop a wedged stream: end Steam's "game", then make sure the client itself is gone. */
|
||||
async function forceStop(): Promise<void> {
|
||||
stopStream();
|
||||
try {
|
||||
await killStream();
|
||||
} catch {
|
||||
/* best-effort — the TerminateApp above is usually enough */
|
||||
}
|
||||
toaster.toast({ title: "Punktfunk", body: "Stopped the stream" });
|
||||
}
|
||||
|
||||
const hosts = mergeHosts(saved, discovered);
|
||||
const busy = scanning || loadingSaved;
|
||||
const refresh = () => {
|
||||
void refreshDiscovered();
|
||||
void refreshSaved();
|
||||
};
|
||||
/** The line under a host's name: where it is, whether it's up, and how far trust has got. */
|
||||
function hostDescription(v: HostView): string {
|
||||
const trust = {
|
||||
paired: "paired",
|
||||
trusted: "trusted",
|
||||
"needs-access": "needs access",
|
||||
}[trustState(v)];
|
||||
return `${v.addr}:${v.port} · ${v.online ? "online" : "offline"} · ${trust}`;
|
||||
}
|
||||
|
||||
const HostRow: FC<{ host: HostView; refresh: () => void }> = ({ host, refresh }) => {
|
||||
const gated = needsPair(host);
|
||||
const stream = (opts: { requestAccess?: boolean } = {}) => void startStream(host, opts);
|
||||
return (
|
||||
<>
|
||||
<PanelSectionRow>
|
||||
<ButtonItem
|
||||
layout="below"
|
||||
onClick={() =>
|
||||
gated
|
||||
? showModal(
|
||||
<TrustSheet host={host} onStream={stream} onChanged={refresh} />,
|
||||
)
|
||||
: stream()
|
||||
}
|
||||
label={
|
||||
<span style={{ display: "inline-flex", alignItems: "center", gap: "0.4em" }}>
|
||||
{gated ? <FaLock /> : <OsMark os={host.os} />}
|
||||
{host.name}
|
||||
</span>
|
||||
}
|
||||
description={hostDescription(host)}
|
||||
>
|
||||
{gated ? "Connect…" : "Stream"}
|
||||
</ButtonItem>
|
||||
</PanelSectionRow>
|
||||
{/* Pinned cards, nested under their host rather than in a section of their own: a card
|
||||
IS a (host, profile) pair, and a row that floats free of its host is the "a pinned
|
||||
tile reads as a duplicate host" problem the desktop shells still have. The host's
|
||||
own BOUND profile is deliberately not a card — it applies silently on the plain row
|
||||
above, and showing it twice would suggest they do different things. */}
|
||||
{!gated &&
|
||||
host.pinnedProfiles.map((p) => (
|
||||
<PanelSectionRow key={`${host.ref}:${p.id}`}>
|
||||
<ButtonItem
|
||||
layout="below"
|
||||
onClick={() => void startStream(host, { profileId: p.id }, `“${p.name}”`)}
|
||||
label={`▸ ${p.name}`}
|
||||
>
|
||||
<FaPlay style={{ marginRight: "0.5em" }} />
|
||||
Stream
|
||||
</ButtonItem>
|
||||
</PanelSectionRow>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const QamPanel: FC = () => {
|
||||
const { views, scanning, problem, refresh } = useHosts();
|
||||
const { info: update, checking, check } = useUpdate();
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -110,15 +167,62 @@ const QamPanel: FC = () => {
|
||||
</PanelSection>
|
||||
))}
|
||||
|
||||
<PanelSection title="Hosts">
|
||||
<PanelSectionRow>
|
||||
<ButtonItem layout="below" onClick={() => void refresh()} disabled={scanning}>
|
||||
{scanning ? (
|
||||
<Spinner style={{ height: "1em", marginRight: "0.5em" }} />
|
||||
) : (
|
||||
<FaSyncAlt style={{ marginRight: "0.5em" }} />
|
||||
)}
|
||||
{scanning ? "Scanning…" : "Refresh"}
|
||||
</ButtonItem>
|
||||
</PanelSectionRow>
|
||||
{/* A client that is missing or too old explains itself rather than rendering an empty
|
||||
list — "no hosts on your LAN" would blame the network for the plugin's problem, and
|
||||
for the outdated case the button that fixes it is in this same panel. */}
|
||||
{problem && (
|
||||
<PanelSectionRow>
|
||||
<Field
|
||||
focusable={false}
|
||||
label={
|
||||
problem === "client-unavailable"
|
||||
? "Punktfunk isn’t installed"
|
||||
: "Update the Punktfunk client"
|
||||
}
|
||||
description={
|
||||
problem === "client-unavailable"
|
||||
? "This panel launches the Punktfunk app, which isn’t on this Deck yet. Install it in Desktop Mode."
|
||||
: "This client is too old to find hosts on your network. Saved hosts still work."
|
||||
}
|
||||
/>
|
||||
</PanelSectionRow>
|
||||
)}
|
||||
{views.length === 0 && scanning && (
|
||||
<PanelSectionRow>
|
||||
<Field focusable={false} description="Scanning your network…" />
|
||||
</PanelSectionRow>
|
||||
)}
|
||||
{views.length === 0 && !scanning && !problem && (
|
||||
<PanelSectionRow>
|
||||
<Field
|
||||
focusable={false}
|
||||
label="No hosts yet"
|
||||
description="Open Punktfunk to find and pair one."
|
||||
/>
|
||||
</PanelSectionRow>
|
||||
)}
|
||||
{views.map((v) => (
|
||||
<HostRow key={v.ref} host={v} refresh={refresh} />
|
||||
))}
|
||||
</PanelSection>
|
||||
|
||||
<PanelSection title="Punktfunk">
|
||||
<PanelSectionRow>
|
||||
<ButtonItem
|
||||
layout="below"
|
||||
description="Host details, stream settings, and help"
|
||||
onClick={() => {
|
||||
Navigation.Navigate(ROUTE);
|
||||
Navigation.CloseSideMenus();
|
||||
}}
|
||||
description="Settings, adding a host by address, and browsing a host's games all live here."
|
||||
onClick={() => void launchGamepadUi()}
|
||||
>
|
||||
<FaTv style={{ marginRight: "0.5em" }} />
|
||||
Open Punktfunk
|
||||
@@ -126,85 +230,6 @@ const QamPanel: FC = () => {
|
||||
</PanelSectionRow>
|
||||
</PanelSection>
|
||||
|
||||
{/* Pinned games — the "jump straight into Playnite" rows. Pin games from a host's
|
||||
picker (fullscreen page → host row → games button). */}
|
||||
{pins.pins.length > 0 && (
|
||||
<PanelSection title="Pinned Games">
|
||||
{pins.pins.map((pin) => {
|
||||
const online = pinIsOnline(pin, hosts);
|
||||
return (
|
||||
<PanelSectionRow key={`${pin.host_fp}:${pin.game_id}`}>
|
||||
<ButtonItem
|
||||
layout="below"
|
||||
onClick={() => streamPin(pin, hosts.map(toHost), pins)}
|
||||
label={pin.title}
|
||||
description={`${pin.host_name}${online ? "" : " · offline?"}${
|
||||
pin.paired ? "" : " · pairing required"
|
||||
}`}
|
||||
>
|
||||
<FaPlay style={{ marginRight: "0.5em" }} />
|
||||
Stream
|
||||
</ButtonItem>
|
||||
</PanelSectionRow>
|
||||
);
|
||||
})}
|
||||
</PanelSection>
|
||||
)}
|
||||
|
||||
<PanelSection title="Hosts">
|
||||
<PanelSectionRow>
|
||||
<ButtonItem layout="below" onClick={refresh} disabled={busy}>
|
||||
{busy ? (
|
||||
<Spinner style={{ height: "1em", marginRight: "0.5em" }} />
|
||||
) : (
|
||||
<FaSyncAlt style={{ marginRight: "0.5em" }} />
|
||||
)}
|
||||
{busy ? "Scanning…" : "Refresh"}
|
||||
</ButtonItem>
|
||||
</PanelSectionRow>
|
||||
{hosts.length === 0 && busy && (
|
||||
<PanelSectionRow>
|
||||
<Field focusable={false} description="Scanning your network…" />
|
||||
</PanelSectionRow>
|
||||
)}
|
||||
{hosts.length === 0 && !busy && (
|
||||
<PanelSectionRow>
|
||||
<Field
|
||||
focusable={false}
|
||||
label="No hosts found"
|
||||
description="Open Punktfunk to add a host by address, or start a host on this network and refresh."
|
||||
/>
|
||||
</PanelSectionRow>
|
||||
)}
|
||||
{hosts.map((v) => {
|
||||
const pair = needsPair(v);
|
||||
const h = toHost(v);
|
||||
return (
|
||||
<PanelSectionRow key={v.fp || `${v.addr}:${v.port}`}>
|
||||
<ButtonItem
|
||||
layout="below"
|
||||
onClick={() =>
|
||||
pair
|
||||
? showModal(<PairModal host={h} onPaired={() => startStream(h)} />)
|
||||
: startStream(h)
|
||||
}
|
||||
label={
|
||||
<span style={{ display: "inline-flex", alignItems: "center", gap: "0.4em" }}>
|
||||
{pair ? <FaLock /> : <FaLockOpen />}
|
||||
{v.name}
|
||||
</span>
|
||||
}
|
||||
description={`${v.addr}:${v.port} · ${v.online ? "online" : "offline"}${
|
||||
pair ? " · pairing required" : v.paired ? " · paired" : ""
|
||||
}`}
|
||||
>
|
||||
{pair ? "Pair & Stream" : "Stream"}
|
||||
</ButtonItem>
|
||||
</PanelSectionRow>
|
||||
);
|
||||
})}
|
||||
</PanelSection>
|
||||
|
||||
<PanelSection title="About">
|
||||
<PanelSectionRow>
|
||||
<Field
|
||||
@@ -236,13 +261,22 @@ const QamPanel: FC = () => {
|
||||
Recreate library shortcut
|
||||
</ButtonItem>
|
||||
</PanelSectionRow>
|
||||
<PanelSectionRow>
|
||||
<ButtonItem
|
||||
layout="below"
|
||||
description="Ends a stream that stopped responding."
|
||||
onClick={() => void forceStop()}
|
||||
>
|
||||
<FaStopCircle style={{ marginRight: "0.5em" }} />
|
||||
Force-stop
|
||||
</ButtonItem>
|
||||
</PanelSectionRow>
|
||||
</PanelSection>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default definePlugin(() => {
|
||||
routerHook.addRoute(ROUTE, PunktfunkRoute, { exact: true });
|
||||
// Ensure the visible, stateless "Punktfunk" library entry (opens the gamepad UI / console
|
||||
// home) exists and is repointed to the current plugin dir — also installs the native-touch
|
||||
// controller config. Fire-and-forget: cosmetic library upkeep must never block plugin load.
|
||||
@@ -260,8 +294,5 @@ export default definePlugin(() => {
|
||||
</PluginErrorBoundary>
|
||||
),
|
||||
icon: <FaTv />,
|
||||
onDismount() {
|
||||
routerHook.removeRoute(ROUTE);
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
@@ -1,230 +0,0 @@
|
||||
// The per-host game picker + pinned-game launch helper. The picker fetches a paired
|
||||
// host's library through the backend (headless flatpak --library — a cold client start
|
||||
// can take seconds, hence the explicit spinner copy) and pins titles as one-tap rows in
|
||||
// the QAM's Games section; its header also launches the GTK client's on-screen gamepad
|
||||
// library (`--browse`).
|
||||
import { DialogButton, Field, ModalRoot, Spinner, showModal } from "@decky/ui";
|
||||
import { FC, useEffect, useState } from "react";
|
||||
import { FaThLarge, FaTv } from "react-icons/fa";
|
||||
import { GameEntry, Host, library, LibraryResult, PinnedGame } from "./backend";
|
||||
import { PinsApi, resolvePinHost, startBrowse, startStream } from "./hooks";
|
||||
import { isSafeLaunchId } from "./steam";
|
||||
import { PairModal } from "./pair";
|
||||
import { RowActions, actionButton } from "./ui";
|
||||
|
||||
/** Human store tag (mirrors the GTK client's `store_label`). */
|
||||
export function storeLabel(store: string): string {
|
||||
switch (store) {
|
||||
case "steam":
|
||||
return "Steam";
|
||||
case "custom":
|
||||
return "Custom";
|
||||
case "heroic":
|
||||
return "Heroic";
|
||||
case "lutris":
|
||||
return "Lutris";
|
||||
case "epic":
|
||||
return "Epic";
|
||||
case "gog":
|
||||
return "GOG";
|
||||
case "xbox":
|
||||
return "Xbox";
|
||||
default:
|
||||
return "Game";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream a pinned game: resolve the host from the live scan (fp → id → stored address),
|
||||
* opportunistically refresh a drifted stored address, and route through pairing first if
|
||||
* this device is no longer paired with the host.
|
||||
*/
|
||||
export function streamPin(pin: PinnedGame, live: Host[], pins: PinsApi): void {
|
||||
const { host, online } = resolvePinHost(pin, live);
|
||||
if (online) {
|
||||
pins.updatePinHost(pin, host); // no-op unless the address actually drifted
|
||||
}
|
||||
if (!pin.paired) {
|
||||
showModal(
|
||||
<PairModal
|
||||
host={host}
|
||||
onPaired={() => {
|
||||
void pins.refresh(); // pick up the now-paired annotation
|
||||
void startStream(host, { launchId: pin.game_id }, pin.title);
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
return;
|
||||
}
|
||||
void startStream(host, { launchId: pin.game_id }, pin.title);
|
||||
}
|
||||
|
||||
// Copy per backend error code (LibraryResult.error); `detail` covers the generic case.
|
||||
function errorCopy(res: LibraryResult): string {
|
||||
switch (res.error) {
|
||||
case "not-paired":
|
||||
return "This Deck isn't paired with the host — pair first, then browse its library.";
|
||||
case "pin-mismatch":
|
||||
return "The host's identity changed — re-pair to re-establish trust.";
|
||||
case "unreachable":
|
||||
return "Couldn't reach the host's management API. Is the host online and up to date?";
|
||||
case "timeout":
|
||||
return "Timed out talking to the host — try again.";
|
||||
case "flatpak-not-found":
|
||||
return "The Punktfunk client isn't installed (flatpak io.unom.Punktfunk).";
|
||||
case "client-outdated":
|
||||
return "The installed client is too old for library browsing — update it from the About tab.";
|
||||
default:
|
||||
return res.detail || "Couldn't fetch the library.";
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------------------
|
||||
// The picker modal: "open on screen" + a pin-toggle list of the host's games.
|
||||
// ----------------------------------------------------------------------------------------
|
||||
export const GamePickerModal: FC<{
|
||||
host: Host;
|
||||
pins: PinsApi;
|
||||
clientUpdatePending?: boolean;
|
||||
closeModal?: () => void;
|
||||
}> = ({ host, pins, clientUpdatePending, closeModal }) => {
|
||||
const [result, setResult] = useState<LibraryResult | null>(null);
|
||||
const [attempt, setAttempt] = useState(0); // bump to refetch (retry / after pairing)
|
||||
// The modal is a detached `showModal` portal that never re-renders from the page's pin
|
||||
// state, so `pins.isPinned` would read a frozen snapshot and the Pin/Unpin label would
|
||||
// never flip within a session. Track this host's pinned ids locally, seeded once from the
|
||||
// snapshot at open; persistence still goes through the (stale-closure-safe) pins API.
|
||||
const [pinnedIds, setPinnedIds] = useState<Set<string>>(
|
||||
() => new Set(pins.pins.filter((p) => p.host_fp === host.fp).map((p) => p.game_id)),
|
||||
);
|
||||
const togglePin = (g: GameEntry) => {
|
||||
const wasPinned = pinnedIds.has(g.id);
|
||||
setPinnedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (wasPinned) next.delete(g.id);
|
||||
else next.add(g.id);
|
||||
return next;
|
||||
});
|
||||
if (wasPinned) pins.removePin(host.fp, g.id);
|
||||
else pins.addPin(host, g);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
let stale = false;
|
||||
setResult(null);
|
||||
library(host.host, host.mgmt, host.fp)
|
||||
.then((res) => {
|
||||
if (!stale) setResult(res);
|
||||
})
|
||||
.catch((e) => {
|
||||
if (!stale) setResult({ ok: false, error: "client-error", detail: String(e) });
|
||||
});
|
||||
return () => {
|
||||
stale = true;
|
||||
};
|
||||
}, [host.host, host.mgmt, host.fp, attempt]);
|
||||
|
||||
const games = (result?.ok && result.games) || [];
|
||||
const sorted = [...games].sort((a, b) => a.title.localeCompare(b.title));
|
||||
|
||||
return (
|
||||
<ModalRoot closeModal={closeModal}>
|
||||
<div style={{ fontWeight: "bold", fontSize: "1.3em", marginBottom: "0.4em" }}>
|
||||
{host.name} — Games
|
||||
</div>
|
||||
|
||||
<Field
|
||||
label="Open library on screen"
|
||||
description="Browse this host's games with the controller, full screen"
|
||||
childrenContainerWidth="max"
|
||||
>
|
||||
<RowActions>
|
||||
<DialogButton
|
||||
style={actionButton}
|
||||
onClick={() => {
|
||||
closeModal?.();
|
||||
void startBrowse(host);
|
||||
}}
|
||||
>
|
||||
<FaTv style={{ marginRight: "0.4em" }} />
|
||||
Open
|
||||
</DialogButton>
|
||||
</RowActions>
|
||||
</Field>
|
||||
|
||||
{clientUpdatePending && (
|
||||
<Field
|
||||
focusable={false}
|
||||
description="A client update is available — direct game launch and on-screen browsing need the latest client."
|
||||
/>
|
||||
)}
|
||||
|
||||
{result === null && (
|
||||
<Field
|
||||
focusable={false}
|
||||
label={
|
||||
<span style={{ display: "inline-flex", alignItems: "center", gap: "0.6em" }}>
|
||||
<Spinner style={{ height: "1em" }} />
|
||||
Fetching the library…
|
||||
</span>
|
||||
}
|
||||
description="This starts the client headlessly — a cold start can take a few seconds."
|
||||
/>
|
||||
)}
|
||||
|
||||
{result !== null && !result.ok && (
|
||||
<Field label="Couldn't fetch the library" description={errorCopy(result)} childrenContainerWidth="max">
|
||||
<RowActions>
|
||||
{result.error === "not-paired" && (
|
||||
<DialogButton
|
||||
style={actionButton}
|
||||
onClick={() =>
|
||||
showModal(<PairModal host={host} onPaired={() => setAttempt((n) => n + 1)} />)
|
||||
}
|
||||
>
|
||||
Pair
|
||||
</DialogButton>
|
||||
)}
|
||||
<DialogButton style={actionButton} onClick={() => setAttempt((n) => n + 1)}>
|
||||
Retry
|
||||
</DialogButton>
|
||||
</RowActions>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
{result?.ok && sorted.length === 0 && (
|
||||
<Field
|
||||
focusable={false}
|
||||
label="No games found"
|
||||
description="Install Steam titles or add custom entries in the host's web console."
|
||||
/>
|
||||
)}
|
||||
|
||||
{sorted.length > 0 && (
|
||||
<div style={{ maxHeight: "55vh", overflowY: "auto" }}>
|
||||
{sorted.map((g: GameEntry) => {
|
||||
const pinned = pinnedIds.has(g.id);
|
||||
const safe = isSafeLaunchId(g.id);
|
||||
return (
|
||||
<Field
|
||||
key={g.id}
|
||||
label={g.title}
|
||||
description={
|
||||
storeLabel(g.store) + (safe ? "" : " · unsupported id — can't be pinned")
|
||||
}
|
||||
childrenContainerWidth="max"
|
||||
>
|
||||
<RowActions>
|
||||
<DialogButton style={actionButton} disabled={!safe} onClick={() => togglePin(g)}>
|
||||
<FaThLarge style={{ marginRight: "0.4em" }} />
|
||||
{pinned ? "Unpin" : "Pin"}
|
||||
</DialogButton>
|
||||
</RowActions>
|
||||
</Field>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</ModalRoot>
|
||||
);
|
||||
};
|
||||
@@ -1,596 +0,0 @@
|
||||
// The fullscreen page (registered as the /punktfunk route) — Hosts / Settings / About tabs.
|
||||
import {
|
||||
ConfirmModal,
|
||||
DialogButton,
|
||||
Field,
|
||||
Focusable,
|
||||
ModalRoot,
|
||||
Navigation,
|
||||
Spinner,
|
||||
Tabs,
|
||||
showModal,
|
||||
staticClasses,
|
||||
} from "@decky/ui";
|
||||
import { RowActions, actionButton, iconButton } from "./ui";
|
||||
import { toaster } from "@decky/api";
|
||||
import { CSSProperties, FC, useState } from "react";
|
||||
import {
|
||||
FaArrowLeft,
|
||||
FaDownload,
|
||||
FaExternalLinkAlt,
|
||||
FaInfoCircle,
|
||||
FaLock,
|
||||
FaLockOpen,
|
||||
FaPen,
|
||||
FaPlay,
|
||||
FaPlus,
|
||||
FaSyncAlt,
|
||||
FaThLarge,
|
||||
FaTrashAlt,
|
||||
} from "react-icons/fa";
|
||||
import { UpdateInfo, forgetHost, killStream } from "./backend";
|
||||
import { PluginErrorBoundary } from "./boundary";
|
||||
import { OsMark } from "./os-icon";
|
||||
import {
|
||||
DOCS_URL,
|
||||
HostView,
|
||||
PinsApi,
|
||||
applyUpdate,
|
||||
checkForUpdatesNow,
|
||||
clientInstallLabel,
|
||||
clientUpdateIsManualOnly,
|
||||
hasUpdate,
|
||||
mergeHosts,
|
||||
needsPair,
|
||||
pinIsOnline,
|
||||
resetAll,
|
||||
startStream,
|
||||
toHost,
|
||||
useHosts,
|
||||
usePins,
|
||||
useSavedHosts,
|
||||
useUpdate,
|
||||
} from "./hooks";
|
||||
import { AddHostModal, EditHostModal, mutationError } from "./hostmgmt";
|
||||
import { GamePickerModal, storeLabel, streamPin } from "./library";
|
||||
import { PairModal } from "./pair";
|
||||
import { SettingsSection } from "./settings";
|
||||
import { stopStream } from "./steam";
|
||||
|
||||
export const ROUTE = "/punktfunk";
|
||||
|
||||
// Bottom inset so the last control clears Gaming Mode's footer hint bar. Routed pages render
|
||||
// *under* that bar otherwise — that's why the last Stream-settings row was getting hidden. The
|
||||
// value is generous on purpose (and harmless where the tab area already insets); tune to taste.
|
||||
const SAFE_BOTTOM = "80px";
|
||||
|
||||
// Each tab is its own scroll area so long content is always reachable above the footer.
|
||||
const tabScroll: CSSProperties = {
|
||||
height: "100%",
|
||||
overflowY: "auto",
|
||||
padding: "0.5em 2.5em",
|
||||
paddingBottom: SAFE_BOTTOM,
|
||||
boxSizing: "border-box",
|
||||
};
|
||||
|
||||
// The one-line status under a host name: address, live presence, and trust state.
|
||||
function hostSubtitle(v: HostView): string {
|
||||
const parts = [`${v.addr}:${v.port}`, v.online ? "online" : "offline"];
|
||||
if (needsPair(v)) {
|
||||
parts.push("pairing required");
|
||||
} else if (v.paired) {
|
||||
parts.push("paired");
|
||||
} else if (v.saved) {
|
||||
parts.push("trusted");
|
||||
}
|
||||
return parts.join(" · ");
|
||||
}
|
||||
|
||||
/** Confirm + forget a saved host, then refresh the list. */
|
||||
function confirmForget(v: HostView, refresh: () => void): void {
|
||||
const selector = v.fp || `${v.addr}:${v.port}`;
|
||||
showModal(
|
||||
<ConfirmModal
|
||||
strTitle={`Forget ${v.name}?`}
|
||||
strDescription="You'll need to pair or trust it again to reconnect."
|
||||
strOKButtonText="Forget"
|
||||
bDestructiveWarning
|
||||
onOK={async () => {
|
||||
const r = await forgetHost(selector);
|
||||
toaster.toast({
|
||||
title: "Punktfunk",
|
||||
body: r.ok ? `Forgot ${v.name}` : mutationError(r),
|
||||
});
|
||||
refresh();
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------------------
|
||||
// Host details — everything we know, plus (for a saved host) rename / edit / forget.
|
||||
// ----------------------------------------------------------------------------------------
|
||||
const HostDetailsModal: FC<{
|
||||
host: HostView;
|
||||
onChanged: () => void;
|
||||
closeModal?: () => void;
|
||||
}> = ({ host, onChanged, closeModal }) => {
|
||||
const fp = host.fp ? (host.fp.match(/.{1,4}/g) ?? [host.fp]).join(" ") : "not known yet";
|
||||
return (
|
||||
<ModalRoot closeModal={closeModal}>
|
||||
<div style={{ fontWeight: "bold", fontSize: "1.3em", marginBottom: "0.4em" }}>
|
||||
{host.name}
|
||||
</div>
|
||||
<Field focusable={false} label="Address">
|
||||
{host.addr}:{host.port}
|
||||
</Field>
|
||||
<Field focusable={false} label="Presence">
|
||||
{host.online ? "Online" : "Offline"}
|
||||
</Field>
|
||||
<Field focusable={false} label="This Deck">
|
||||
{host.paired ? "Paired" : host.fp ? "Trusted" : "Not paired yet"}
|
||||
</Field>
|
||||
<Field
|
||||
focusable={false}
|
||||
label="Certificate fingerprint (SHA-256)"
|
||||
description={
|
||||
<span
|
||||
style={{ fontFamily: "monospace", fontSize: "0.85em", wordBreak: "break-word" }}
|
||||
>
|
||||
{fp}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
{host.saved && (
|
||||
<Field label="Manage" childrenContainerWidth="max">
|
||||
<RowActions>
|
||||
<DialogButton
|
||||
style={actionButton}
|
||||
onClick={() => {
|
||||
closeModal?.();
|
||||
showModal(<EditHostModal host={host} onDone={onChanged} />);
|
||||
}}
|
||||
>
|
||||
<FaPen style={{ marginRight: "0.4em" }} />
|
||||
Edit
|
||||
</DialogButton>
|
||||
<DialogButton
|
||||
style={actionButton}
|
||||
onClick={() => {
|
||||
closeModal?.();
|
||||
confirmForget(host, onChanged);
|
||||
}}
|
||||
>
|
||||
<FaTrashAlt style={{ marginRight: "0.4em" }} />
|
||||
Forget
|
||||
</DialogButton>
|
||||
</RowActions>
|
||||
</Field>
|
||||
)}
|
||||
</ModalRoot>
|
||||
);
|
||||
};
|
||||
|
||||
// ----------------------------------------------------------------------------------------
|
||||
// One host row: status icon + address, details / pair / stream actions.
|
||||
// ----------------------------------------------------------------------------------------
|
||||
const HostRow: FC<{
|
||||
host: HostView;
|
||||
onChanged: () => void;
|
||||
onGames: () => void;
|
||||
}> = ({ host, onChanged, onGames }) => {
|
||||
const pair = needsPair(host);
|
||||
const h = toHost(host);
|
||||
return (
|
||||
<Field
|
||||
label={
|
||||
<span style={{ display: "inline-flex", alignItems: "center", gap: "0.4em" }}>
|
||||
<OsMark os={host.os} />
|
||||
{pair ? <FaLock /> : <FaLockOpen />}
|
||||
{host.name}
|
||||
</span>
|
||||
}
|
||||
description={hostSubtitle(host)}
|
||||
childrenContainerWidth="max"
|
||||
>
|
||||
<RowActions>
|
||||
<DialogButton
|
||||
style={iconButton}
|
||||
onClick={() => showModal(<HostDetailsModal host={host} onChanged={onChanged} />)}
|
||||
>
|
||||
<FaInfoCircle />
|
||||
</DialogButton>
|
||||
{/* Labeled, not icon-only: this is the entry to the game picker AND the on-screen
|
||||
library browser, and controller nav has no hover tooltip to explain a bare icon. */}
|
||||
<DialogButton style={actionButton} onClick={onGames}>
|
||||
<FaThLarge style={{ marginRight: "0.4em" }} />
|
||||
Games
|
||||
</DialogButton>
|
||||
{pair && (
|
||||
<DialogButton
|
||||
style={actionButton}
|
||||
onClick={() => showModal(<PairModal host={h} onPaired={onChanged} />)}
|
||||
>
|
||||
Pair
|
||||
</DialogButton>
|
||||
)}
|
||||
<DialogButton
|
||||
style={actionButton}
|
||||
onClick={() =>
|
||||
pair
|
||||
? showModal(<PairModal host={h} onPaired={() => startStream(h)} />)
|
||||
: startStream(h)
|
||||
}
|
||||
>
|
||||
<FaPlay style={{ marginRight: "0.4em" }} />
|
||||
Stream
|
||||
</DialogButton>
|
||||
</RowActions>
|
||||
</Field>
|
||||
);
|
||||
};
|
||||
|
||||
const HostsTab: FC<{
|
||||
hosts: HostView[];
|
||||
scanning: boolean;
|
||||
refresh: () => void;
|
||||
pins: PinsApi;
|
||||
clientUpdatePending: boolean;
|
||||
}> = ({ hosts, scanning, refresh, pins, clientUpdatePending }) => (
|
||||
<div style={tabScroll}>
|
||||
<Field
|
||||
label="Hosts"
|
||||
description={
|
||||
scanning
|
||||
? "Scanning the LAN…"
|
||||
: `${hosts.length} host${hosts.length === 1 ? "" : "s"} — saved and on your network`
|
||||
}
|
||||
childrenContainerWidth="max"
|
||||
bottomSeparator={hosts.length ? "standard" : "none"}
|
||||
>
|
||||
<RowActions>
|
||||
<DialogButton
|
||||
style={actionButton}
|
||||
onClick={() => showModal(<AddHostModal onDone={refresh} />)}
|
||||
>
|
||||
<FaPlus style={{ marginRight: "0.5em" }} />
|
||||
Add
|
||||
</DialogButton>
|
||||
<DialogButton style={actionButton} disabled={scanning} onClick={refresh}>
|
||||
{scanning ? (
|
||||
<Spinner style={{ height: "1em", marginRight: "0.5em" }} />
|
||||
) : (
|
||||
<FaSyncAlt style={{ marginRight: "0.5em" }} />
|
||||
)}
|
||||
{scanning ? "Scanning…" : "Refresh"}
|
||||
</DialogButton>
|
||||
</RowActions>
|
||||
</Field>
|
||||
|
||||
{hosts.length === 0 && !scanning && (
|
||||
<Field
|
||||
focusable={false}
|
||||
label="No hosts yet"
|
||||
description="Add one by address with +, or start a Punktfunk host on this network and refresh. The setup guide (About tab) covers installing a host."
|
||||
/>
|
||||
)}
|
||||
{hosts.map((h) => (
|
||||
<HostRow
|
||||
key={h.fp || `${h.addr}:${h.port}`}
|
||||
host={h}
|
||||
onChanged={refresh}
|
||||
onGames={() =>
|
||||
showModal(
|
||||
<GamePickerModal
|
||||
host={toHost(h)}
|
||||
pins={pins}
|
||||
clientUpdatePending={clientUpdatePending}
|
||||
/>,
|
||||
)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Pinned games — also the cleanup surface for pins whose host is gone from the scan. */}
|
||||
{pins.pins.length > 0 && (
|
||||
<>
|
||||
<Field
|
||||
focusable={false}
|
||||
label="Pinned games"
|
||||
description="One-tap streams — they also live in the quick-access menu"
|
||||
bottomSeparator="standard"
|
||||
/>
|
||||
{pins.pins.map((pin) => {
|
||||
const online = pinIsOnline(pin, hosts);
|
||||
return (
|
||||
<Field
|
||||
key={`${pin.host_fp}:${pin.game_id}`}
|
||||
label={pin.title}
|
||||
description={`${storeLabel(pin.store)} · ${pin.host_name}${
|
||||
online ? "" : " · offline?"
|
||||
}${pin.paired ? "" : " · pairing required"}`}
|
||||
childrenContainerWidth="max"
|
||||
>
|
||||
<RowActions>
|
||||
<DialogButton
|
||||
style={actionButton}
|
||||
onClick={() => streamPin(pin, hosts.map(toHost), pins)}
|
||||
>
|
||||
<FaPlay style={{ marginRight: "0.4em" }} />
|
||||
Play
|
||||
</DialogButton>
|
||||
<DialogButton
|
||||
style={actionButton}
|
||||
onClick={() => pins.removePin(pin.host_fp, pin.game_id)}
|
||||
>
|
||||
Remove
|
||||
</DialogButton>
|
||||
</RowActions>
|
||||
</Field>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
// NOT `tabScroll`: the settings screen is a SidebarNavigation, which lays out its own rail +
|
||||
// content pane and scrolls the pane itself. Wrapping it in an outer scroll area would give it an
|
||||
// indefinite height to fill, collapsing the rail — so this pane only hands it the full height and
|
||||
// keeps its hands off the overflow. The footer inset lives inside the pages instead.
|
||||
const settingsPane: CSSProperties = { height: "100%", overflow: "hidden" };
|
||||
|
||||
const SettingsTab: FC = () => (
|
||||
<div style={settingsPane}>
|
||||
<SettingsSection />
|
||||
</div>
|
||||
);
|
||||
|
||||
// ----------------------------------------------------------------------------------------
|
||||
// About — plugin version + explicit update check, docs link, stream-exit help, force-stop,
|
||||
// and the destructive "reset everything" action.
|
||||
// ----------------------------------------------------------------------------------------
|
||||
async function forceStopStream(): Promise<void> {
|
||||
stopStream(); // ask Steam to end the "game" first (clean path)
|
||||
const res = await killStream(); // then the flatpak-level hammer for a wedged client
|
||||
toaster.toast({
|
||||
title: "Punktfunk",
|
||||
body: res.ok ? "Stream client stopped." : "Couldn’t stop the stream client.",
|
||||
});
|
||||
}
|
||||
|
||||
function confirmReset(refreshers: Array<() => void | Promise<void>>): void {
|
||||
showModal(
|
||||
<ConfirmModal
|
||||
strTitle="Reset Punktfunk?"
|
||||
strDescription="Clears every saved host, your stream settings, and all pinned games on this Deck. Your client identity is kept, so you'll re-pair hosts to reconnect. This can't be undone."
|
||||
strOKButtonText="Reset"
|
||||
bDestructiveWarning
|
||||
onOK={() => void resetAll(refreshers)}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
const AboutTab: FC<{
|
||||
update: UpdateInfo | null;
|
||||
checking: boolean;
|
||||
check: (force: boolean) => Promise<UpdateInfo | null>;
|
||||
onReset: () => void;
|
||||
}> = ({ update, checking, check, onReset }) => (
|
||||
<div style={tabScroll}>
|
||||
<Field
|
||||
label="Version"
|
||||
description={
|
||||
update
|
||||
? `v${update.current}${
|
||||
update.channel ? ` · ${update.channel} channel` : " · development build"
|
||||
}`
|
||||
: "…"
|
||||
}
|
||||
childrenContainerWidth="max"
|
||||
>
|
||||
<RowActions>
|
||||
<DialogButton
|
||||
style={actionButton}
|
||||
disabled={checking}
|
||||
onClick={() => void checkForUpdatesNow(check)}
|
||||
>
|
||||
{checking ? <Spinner style={{ height: "1em" }} /> : "Check for updates"}
|
||||
</DialogButton>
|
||||
</RowActions>
|
||||
</Field>
|
||||
{/* What the client IS, so "why is there no Update button?" has a visible answer. The
|
||||
install kind decides everything below it. */}
|
||||
{!!update?.client_install && (
|
||||
<Field
|
||||
label="Client"
|
||||
description={`${clientInstallLabel(update.client_install)}${
|
||||
update.client_current ? ` · ${update.client_current}` : ""
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
{hasUpdate(update) && (
|
||||
<Field
|
||||
label={
|
||||
update!.update_available
|
||||
? `Plugin update — v${update!.latest}${
|
||||
update!.client_update_available ? " + client" : ""
|
||||
}`
|
||||
: `Client update — ${update!.client_latest || "available"}`
|
||||
}
|
||||
description={
|
||||
// Only promise a one-tap install when there is one. On a notify-only install the
|
||||
// row becomes the command itself, which is the whole answer for that box.
|
||||
clientUpdateIsManualOnly(update) && !update!.update_available
|
||||
? update!.client_opt_in || update!.client_command
|
||||
: "Installing can take a couple of minutes; Decky reloads the plugin when done"
|
||||
}
|
||||
childrenContainerWidth="max"
|
||||
>
|
||||
{clientUpdateIsManualOnly(update) && !update!.update_available ? null : (
|
||||
<RowActions>
|
||||
<DialogButton style={actionButton} onClick={() => applyUpdate(update!, check)}>
|
||||
<FaDownload style={{ marginRight: "0.4em" }} />
|
||||
Update
|
||||
</DialogButton>
|
||||
</RowActions>
|
||||
)}
|
||||
</Field>
|
||||
)}
|
||||
{!!update?.client_error && (
|
||||
<Field
|
||||
label="Client update check"
|
||||
description={
|
||||
update.client_error === "client-outdated"
|
||||
? "This client predates update checks — update it once by hand and the check starts working."
|
||||
: "Couldn’t check the client for updates."
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<Field
|
||||
label="Setup guide"
|
||||
description="Hosts, pairing, controllers, and troubleshooting — docs.punktfunk.unom.io"
|
||||
childrenContainerWidth="max"
|
||||
>
|
||||
<RowActions>
|
||||
<DialogButton
|
||||
style={actionButton}
|
||||
onClick={() => Navigation.NavigateToExternalWeb(DOCS_URL)}
|
||||
>
|
||||
<FaExternalLinkAlt style={{ marginRight: "0.4em" }} />
|
||||
Open
|
||||
</DialogButton>
|
||||
</RowActions>
|
||||
</Field>
|
||||
<Field
|
||||
focusable={false}
|
||||
label="Leaving a stream"
|
||||
description="Hold L1 + R1 + Start + Select inside the stream, or close the “game” from the Steam overlay — either returns you to Gaming Mode."
|
||||
/>
|
||||
<Field
|
||||
label="Stream stuck?"
|
||||
description="Force-stop the stream client if a session wedges"
|
||||
childrenContainerWidth="max"
|
||||
>
|
||||
<RowActions>
|
||||
<DialogButton style={actionButton} onClick={() => void forceStopStream()}>
|
||||
Force-stop
|
||||
</DialogButton>
|
||||
</RowActions>
|
||||
</Field>
|
||||
<Field
|
||||
label="Reset Punktfunk"
|
||||
description="Clear saved hosts, stream settings, and pinned games on this Deck (keeps your client identity)"
|
||||
childrenContainerWidth="max"
|
||||
>
|
||||
<RowActions>
|
||||
<DialogButton style={actionButton} onClick={onReset}>
|
||||
<FaTrashAlt style={{ marginRight: "0.4em" }} />
|
||||
Reset
|
||||
</DialogButton>
|
||||
</RowActions>
|
||||
</Field>
|
||||
</div>
|
||||
);
|
||||
|
||||
const PunktfunkPage: FC = () => {
|
||||
const { hosts: discovered, scanning, refresh: refreshDiscovered } = useHosts();
|
||||
const { saved, loading: loadingSaved, refresh: refreshSaved } = useSavedHosts();
|
||||
const { info: update, checking, check } = useUpdate();
|
||||
const pins = usePins();
|
||||
const [tab, setTab] = useState("hosts");
|
||||
|
||||
const hosts = mergeHosts(saved, discovered);
|
||||
// A host action (pair/add/edit/forget) can change either store, so refresh both.
|
||||
const refreshHosts = () => {
|
||||
void refreshDiscovered();
|
||||
void refreshSaved();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
marginTop: "40px",
|
||||
height: "calc(100% - 40px)",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
}}
|
||||
>
|
||||
{/* Header is title + back only — updates live on the About tab (and the QAM banner). */}
|
||||
<Focusable
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "1em",
|
||||
padding: "0 2.5em",
|
||||
marginBottom: "0.4em",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<DialogButton style={iconButton} onClick={() => Navigation.NavigateBack()}>
|
||||
<FaArrowLeft />
|
||||
</DialogButton>
|
||||
<div className={staticClasses?.Title} style={{ flex: 1, margin: 0 }}>
|
||||
Punktfunk
|
||||
</div>
|
||||
</Focusable>
|
||||
|
||||
{/* Two things fight each other on an L1/R1 tab switch:
|
||||
1. Valve's Tabs slides the incoming panel in from the right with a CSS transform.
|
||||
2. `autoFocusContents` then focuses a control inside that still-offscreen panel, which
|
||||
fires scrollIntoView. Because the panel is offset by a *transform* (not by scroll
|
||||
position), scrollIntoView can't satisfy it by scrolling any one ancestor, so it walks
|
||||
up and pans the whole page — the "screen jumps right, then animates back" glitch.
|
||||
Dropping autoFocusContents removes the scrollIntoView entirely, so nothing fights the
|
||||
slide. L1/R1 still cycles tabs (that handler lives on the Tabs focus scope, active while
|
||||
focus is anywhere inside — including the tab strip); after a switch, focus stays on the
|
||||
strip and Down enters the content, which is how Steam's own tabbed pages behave.
|
||||
The overflow:hidden clip stays as defense-in-depth against any stray horizontal pan. */}
|
||||
<div style={{ flex: 1, minHeight: 0, overflow: "hidden" }}>
|
||||
<Tabs
|
||||
activeTab={tab}
|
||||
onShowTab={(id: string) => setTab(id)}
|
||||
tabs={[
|
||||
{
|
||||
id: "hosts",
|
||||
title: "Hosts",
|
||||
content: (
|
||||
<HostsTab
|
||||
hosts={hosts}
|
||||
scanning={scanning || loadingSaved}
|
||||
refresh={refreshHosts}
|
||||
pins={pins}
|
||||
clientUpdatePending={!!update?.client_update_available}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "settings",
|
||||
title: "Settings",
|
||||
content: <SettingsTab />,
|
||||
},
|
||||
{
|
||||
id: "about",
|
||||
title: "About",
|
||||
content: (
|
||||
<AboutTab
|
||||
update={update}
|
||||
checking={checking}
|
||||
check={check}
|
||||
onReset={() => confirmReset([refreshHosts, pins.refresh])}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Full page behind the boundary — registered as the /punktfunk route.
|
||||
export const PunktfunkRoute: FC = () => (
|
||||
<PluginErrorBoundary>
|
||||
<PunktfunkPage />
|
||||
</PluginErrorBoundary>
|
||||
);
|
||||
@@ -3,10 +3,32 @@
|
||||
import { DialogButton, Focusable, ModalRoot, Spinner } from "@decky/ui";
|
||||
import { toaster } from "@decky/api";
|
||||
import { FC, useState } from "react";
|
||||
import { Host, pair } from "./backend";
|
||||
import { pair } from "./backend";
|
||||
import { HostView } from "./hooks";
|
||||
|
||||
/**
|
||||
* User-facing copy for a failed ceremony. The CLI's stable exit codes say WHICH failure it was,
|
||||
* so the keypad can name the fix instead of echoing a log line: `refused` is overwhelmingly a
|
||||
* mistyped PIN or a host nobody armed, and telling someone to check their network for that
|
||||
* would send them the wrong way entirely.
|
||||
*/
|
||||
function pairErrorBody(error: string | undefined, name: string): string {
|
||||
switch (error) {
|
||||
case "refused":
|
||||
return "Wrong PIN, or the host isn’t showing one. Arm pairing again and retry.";
|
||||
case "unreachable":
|
||||
return `Couldn’t reach ${name}.`;
|
||||
case "client-outdated":
|
||||
return "Update the Punktfunk client to pair from here.";
|
||||
case "client-unavailable":
|
||||
return "Couldn’t reach the Punktfunk client — is it still installed?";
|
||||
default:
|
||||
return "Pairing failed.";
|
||||
}
|
||||
}
|
||||
|
||||
export const PairModal: FC<{
|
||||
host: Host;
|
||||
host: HostView;
|
||||
closeModal?: () => void;
|
||||
onPaired: () => void;
|
||||
}> = ({ host, closeModal, onPaired }) => {
|
||||
@@ -21,13 +43,13 @@ export const PairModal: FC<{
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await pair(host.host, host.port, pin, "Steam Deck");
|
||||
const res = await pair(host.addr, host.port, pin, "Steam Deck");
|
||||
if (res.ok) {
|
||||
toaster.toast({ title: "Punktfunk", body: `Paired with ${host.name}` });
|
||||
onPaired();
|
||||
closeModal?.();
|
||||
} else {
|
||||
setError(res.error ?? "pairing failed");
|
||||
setError(pairErrorBody(res.error, host.name));
|
||||
setPin("");
|
||||
}
|
||||
} catch (e) {
|
||||
|
||||
@@ -1,657 +0,0 @@
|
||||
// Stream settings — the client's WHOLE settings store, written to the JSON the client reads on
|
||||
// launch (main.py set_settings, merged onto what's on disk). This is the same
|
||||
// `client-gtk-settings.json` the desktop client and the console's settings screen own, so a value
|
||||
// changed in any of the three shows in the other two.
|
||||
//
|
||||
// SHAPE OF THIS SCREEN. Thirty rows is too many to scroll past on a thumbstick, so they are split
|
||||
// across a `SidebarNavigation` — the same left-rail-of-categories layout SteamOS's own Settings
|
||||
// uses, and the one Deck users already know. Every page fits on screen without scrolling, which is
|
||||
// the whole point of the split: the rail is the index, so nothing is more than one hop away.
|
||||
//
|
||||
// The categories, their order, and the wording of the rows are the console's settings screen
|
||||
// (pf-console-ui/src/screens/settings.rs) — that screen is the other settings editor a user
|
||||
// reaches without leaving Gaming Mode, and two different orders for one store is how people stop
|
||||
// trusting either. It shows them as one steppable list because it has no pointer and no room for
|
||||
// a rail; here they become the rail's pages, same groups, same sequence. Three more rules:
|
||||
//
|
||||
// • A setting that depends on another is INDENTED under it and DISABLED, never hidden — the
|
||||
// console dims those rows rather than dropping them, and a row that vanishes as you toggle
|
||||
// the one above it is a moving target for a thumbstick.
|
||||
// • A picker whose options this device doesn't have doesn't appear at all (the GPU row on a
|
||||
// one-GPU Deck). A dead control is worse than an absent one.
|
||||
// • Anything that behaves differently *here* than it does on a desktop says so in its own
|
||||
// description, rather than being silently dropped from the screen.
|
||||
//
|
||||
// The accepted gamepad/compositor/codec/decoder names mirror punktfunk-core's `*Pref::from_name`
|
||||
// and the console's tables; the tier/mode names mirror the `StatsVerbosity` / `TouchMode` /
|
||||
// `MouseMode` enums, which serialize lowercase.
|
||||
import {
|
||||
DialogButton,
|
||||
Dropdown,
|
||||
Field,
|
||||
SidebarNavigation,
|
||||
SliderField,
|
||||
Spinner,
|
||||
ToggleField,
|
||||
} from "@decky/ui";
|
||||
import { CSSProperties, FC, ReactElement, ReactNode, useEffect, useState } from "react";
|
||||
import {
|
||||
FaDesktop,
|
||||
FaGamepad,
|
||||
FaHandPointer,
|
||||
FaSlidersH,
|
||||
FaTv,
|
||||
FaVideo,
|
||||
FaVolumeUp,
|
||||
} from "react-icons/fa";
|
||||
import {
|
||||
AudioDevice,
|
||||
DeviceLists,
|
||||
getSettings,
|
||||
listDevices,
|
||||
refreshDevices,
|
||||
setSettings,
|
||||
StreamSettings,
|
||||
} from "./backend";
|
||||
import { actionButton, RowActions } from "./ui";
|
||||
|
||||
// Decky's Dropdown has no width prop — it fills whatever container it's in, and a
|
||||
// `childrenContainerWidth="max"` Field is the whole row. Wrapping it in this fit-content shell
|
||||
// (inside the right-aligned RowActions) shrinks the control to its selected label, with a floor
|
||||
// so short values like "60 Hz" don't collapse to a nub and a ceiling so nothing runs edge to
|
||||
// edge. Matches the right-aligned, content-sized buttons everywhere else.
|
||||
const selectShell: CSSProperties = {
|
||||
width: "fit-content",
|
||||
minWidth: "10em",
|
||||
maxWidth: "24em",
|
||||
};
|
||||
|
||||
// ----------------------------------------------------------------------------------------
|
||||
// Option tables — the console's, so the two Gaming-Mode editors offer the same choices.
|
||||
// ----------------------------------------------------------------------------------------
|
||||
|
||||
// "native" and "match" are virtual: they store `width`/`height` of 0 with `match_window` off/on.
|
||||
// Match window is offered even though this plugin's launches are always fullscreen (where it
|
||||
// degenerates to the display's native mode) — leaving it out would make the row lie about a
|
||||
// store the desktop client can set it in.
|
||||
const MATCH_WINDOW = "match";
|
||||
const RESOLUTIONS: [number, number, string][] = [
|
||||
[0, 0, "Native display"],
|
||||
[1280, 720, "1280 × 720"],
|
||||
[1280, 800, "1280 × 800 (Deck)"],
|
||||
[1920, 1080, "1920 × 1080"],
|
||||
[2560, 1440, "2560 × 1440"],
|
||||
[3840, 2160, "3840 × 2160"],
|
||||
];
|
||||
const resolutionKey = (w: number, h: number): string => (w === 0 && h === 0 ? "native" : `${w}x${h}`);
|
||||
|
||||
const REFRESH = [0, 30, 60, 90, 120];
|
||||
// Render-resolution multipliers (mirrors punktfunk_core::render_scale::PRESETS). 1.0 = native.
|
||||
const RENDER_SCALES = [0.5, 0.67, 0.75, 1.0, 1.25, 1.5, 2.0, 3.0, 4.0];
|
||||
const renderScaleLabel = (x: number): string =>
|
||||
x === 1 ? "Native (1×)" : x > 1 ? `${x}× · supersample` : `${x}×`;
|
||||
|
||||
const COMPOSITORS: [string, string][] = [
|
||||
["auto", "Automatic"],
|
||||
["kwin", "KDE Plasma (KWin)"],
|
||||
["wlroots", "Sway (wlroots)"],
|
||||
["mutter", "GNOME (Mutter)"],
|
||||
["gamescope", "gamescope"],
|
||||
];
|
||||
const CODECS: [string, string][] = [
|
||||
["auto", "Automatic"],
|
||||
["hevc", "HEVC (H.265)"],
|
||||
["h264", "H.264 (AVC)"],
|
||||
["av1", "AV1"],
|
||||
// Opt-in wired-LAN low-latency codec (100–400 Mbit/s class, 8-bit SDR). Only ever selected
|
||||
// when the host advertises it too; anything else falls back to HEVC.
|
||||
["pyrowave", "PyroWave (wired LAN)"],
|
||||
];
|
||||
const DECODERS: [string, string][] = [
|
||||
["auto", "Automatic"],
|
||||
["vulkan", "Vulkan Video"],
|
||||
["vaapi", "VAAPI"],
|
||||
["software", "Software"],
|
||||
];
|
||||
// Presentation intent — the `present_priority` key shared with the Apple and Android clients, so
|
||||
// one profile reads the same on every device.
|
||||
const PRESENT_PRIORITIES: [string, string][] = [
|
||||
["latency", "Lowest latency"],
|
||||
["smooth", "Smoothness"],
|
||||
];
|
||||
// Smoothness buffer depth in frames; 0 = Automatic (resolves to 2).
|
||||
const SMOOTH_BUFFERS: [number, string][] = [
|
||||
[0, "Automatic"],
|
||||
[1, "1 frame"],
|
||||
[2, "2 frames"],
|
||||
[3, "3 frames"],
|
||||
];
|
||||
const AUDIO_CHANNELS: [number, string][] = [
|
||||
[2, "Stereo"],
|
||||
[6, "5.1 surround"],
|
||||
[8, "7.1 surround"],
|
||||
];
|
||||
const GAMEPADS: [string, string][] = [
|
||||
["auto", "Automatic"],
|
||||
["xbox360", "Xbox 360"],
|
||||
["xboxone", "Xbox One"],
|
||||
["dualsense", "DualSense"],
|
||||
["dualshock4", "DualShock 4"],
|
||||
["steamdeck", "Steam Deck"],
|
||||
];
|
||||
const TOUCH_MODES: [string, string][] = [
|
||||
["trackpad", "Trackpad"],
|
||||
["pointer", "Direct pointer"],
|
||||
["touch", "Touch passthrough"],
|
||||
];
|
||||
const MOUSE_MODES: [string, string][] = [
|
||||
["capture", "Capture (games)"],
|
||||
["desktop", "Desktop (absolute)"],
|
||||
];
|
||||
const STATS_TIERS: [string, string][] = [
|
||||
["off", "Off"],
|
||||
["compact", "Compact"],
|
||||
["normal", "Normal"],
|
||||
["detailed", "Detailed"],
|
||||
];
|
||||
|
||||
// ----------------------------------------------------------------------------------------
|
||||
// Row primitives — every picker row is Field + right-aligned, content-sized Dropdown, so the
|
||||
// twelve of them below stay one line each and can't drift apart.
|
||||
// ----------------------------------------------------------------------------------------
|
||||
|
||||
const SelectRow = <T extends string | number>({
|
||||
label,
|
||||
description,
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
formatUnknown,
|
||||
disabled,
|
||||
indent,
|
||||
}: {
|
||||
label: string;
|
||||
description?: ReactNode;
|
||||
options: [T, string][];
|
||||
value: T;
|
||||
onChange: (v: T) => void;
|
||||
// How to name a stored value this table doesn't list (see below); defaults to the raw value.
|
||||
formatUnknown?: (v: T) => string;
|
||||
disabled?: boolean;
|
||||
indent?: boolean;
|
||||
}): ReactElement => {
|
||||
// A Dropdown can only display a value that is one of its options, and this store has four other
|
||||
// writers — the desktop client, the console, a settings profile, a newer client with presets
|
||||
// this build doesn't know. Rather than render a blank control (or, worse, silently show a
|
||||
// different value than the stream will actually use), carry the stored one as its own entry.
|
||||
const shown: [T, string][] = options.some(([v]) => v === value)
|
||||
? options
|
||||
: [...options, [value, formatUnknown ? formatUnknown(value) : String(value)]];
|
||||
return (
|
||||
<Field
|
||||
label={label}
|
||||
description={description}
|
||||
disabled={disabled}
|
||||
indentLevel={indent ? 1 : undefined}
|
||||
childrenContainerWidth="max"
|
||||
>
|
||||
<RowActions>
|
||||
<div style={selectShell}>
|
||||
<Dropdown
|
||||
disabled={disabled}
|
||||
rgOptions={shown.map(([data, l]) => ({ data, label: l }))}
|
||||
selectedOption={value}
|
||||
onChange={(o) => onChange(o.data as T)}
|
||||
/>
|
||||
</div>
|
||||
</RowActions>
|
||||
</Field>
|
||||
);
|
||||
};
|
||||
|
||||
// An audio-endpoint picker. The stored value is a PipeWire `node.name`; "" means "whatever the OS
|
||||
// is using". A stored endpoint that isn't in the current enumeration still gets an entry — it is
|
||||
// a real preference that simply isn't plugged in right now, and dropping it would silently
|
||||
// re-point the next stream at the default without ever showing the user why.
|
||||
const DeviceRow: FC<{
|
||||
label: string;
|
||||
description: string;
|
||||
devices: AudioDevice[] | null;
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
disabled?: boolean;
|
||||
indent?: boolean;
|
||||
}> = ({ label, description, devices, value, onChange, disabled, indent }) => {
|
||||
const options: [string, string][] = [["", "System default"]];
|
||||
for (const d of devices ?? []) options.push([d.name, d.description]);
|
||||
if (value && !options.some(([name]) => name === value)) {
|
||||
options.push([value, `${value} (not connected)`]);
|
||||
}
|
||||
return (
|
||||
<SelectRow
|
||||
label={label}
|
||||
description={devices === null ? "Reading this device's audio endpoints…" : description}
|
||||
options={options}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
disabled={disabled || devices === null}
|
||||
indent={indent}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
// ----------------------------------------------------------------------------------------
|
||||
// The pages. One settings object, seven views on it — every page takes the same context rather
|
||||
// than fetching or holding state of its own, so a change on one page is visible on the others
|
||||
// the moment you switch.
|
||||
// ----------------------------------------------------------------------------------------
|
||||
|
||||
interface PageCtx {
|
||||
s: StreamSettings;
|
||||
patch: (p: Partial<StreamSettings>) => void;
|
||||
devices: DeviceLists | null;
|
||||
reading: boolean;
|
||||
readDevices: (again: boolean) => void;
|
||||
}
|
||||
|
||||
// SidebarNavigation gives each page Steam's own padding, but the routed page still renders
|
||||
// UNDER Gaming Mode's footer hint bar, so the last row of a page needs to clear it (the same
|
||||
// inset the tabs use).
|
||||
const pageBody: CSSProperties = { paddingBottom: "80px" };
|
||||
|
||||
const StreamPage: FC<PageCtx> = ({ s, patch }) => {
|
||||
const renderScale = s.render_scale ?? 1;
|
||||
const resolution = s.match_window ? MATCH_WINDOW : resolutionKey(s.width, s.height);
|
||||
return (
|
||||
<div style={pageBody}>
|
||||
<SelectRow
|
||||
label="Resolution"
|
||||
description="The host creates a virtual display at exactly this size — no scaling. Match window follows the stream window instead, which in Gaming Mode means the Deck's native size."
|
||||
options={[
|
||||
...RESOLUTIONS.map(([w, h, label]) => [resolutionKey(w, h), label] as [string, string]),
|
||||
[MATCH_WINDOW, "Match window"] as [string, string],
|
||||
]}
|
||||
value={resolution}
|
||||
// A size set from a desktop profile that isn't one of these presets, spelled the way the
|
||||
// presets are rather than left as the raw "1600x900" key.
|
||||
formatUnknown={(v) => v.replace("x", " × ")}
|
||||
onChange={(v) => {
|
||||
if (v === MATCH_WINDOW) {
|
||||
// The tri-state the console stores: the flag on, the explicit size cleared.
|
||||
patch({ match_window: true, width: 0, height: 0 });
|
||||
return;
|
||||
}
|
||||
const found = RESOLUTIONS.find(([w, h]) => resolutionKey(w, h) === v);
|
||||
patch({ match_window: false, width: found?.[0] ?? 0, height: found?.[1] ?? 0 });
|
||||
}}
|
||||
/>
|
||||
<SelectRow
|
||||
label="Refresh rate"
|
||||
description="Native follows the display the stream is on."
|
||||
options={REFRESH.map((r) => [r, r === 0 ? "Native" : `${r} Hz`] as [number, string])}
|
||||
value={s.refresh_hz}
|
||||
formatUnknown={(v) => `${v} Hz`}
|
||||
onChange={(v) => patch({ refresh_hz: v })}
|
||||
/>
|
||||
<SelectRow
|
||||
label="Render scale"
|
||||
description="The host renders larger or smaller than the stream mode and the Deck resamples — above 1× supersamples for sharpness, below 1× saves bandwidth."
|
||||
options={RENDER_SCALES.map((x) => [x, renderScaleLabel(x)] as [number, string])}
|
||||
// Snap the stored value to the nearest preset so the dropdown always shows a match.
|
||||
value={RENDER_SCALES.reduce((best, x) =>
|
||||
Math.abs(x - renderScale) < Math.abs(best - renderScale) ? x : best,
|
||||
)}
|
||||
onChange={(v) => patch({ render_scale: v })}
|
||||
/>
|
||||
<SliderField
|
||||
label="Bitrate"
|
||||
description="0 = the host's own default (20 Mbit/s)."
|
||||
value={Math.round(s.bitrate_kbps / 1000)}
|
||||
min={0}
|
||||
max={150}
|
||||
step={5}
|
||||
showValue
|
||||
valueSuffix=" Mbit/s"
|
||||
onChange={(v) => patch({ bitrate_kbps: v * 1000 })}
|
||||
/>
|
||||
<SelectRow
|
||||
label="Host compositor"
|
||||
description="Which compositor drives the virtual display — honoured only if it's available on the host. Automatic suits almost every host."
|
||||
options={COMPOSITORS}
|
||||
value={s.compositor}
|
||||
onChange={(v) => patch({ compositor: v })}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const VideoPage: FC<PageCtx> = ({ s, patch, devices }) => {
|
||||
// Only worth a row on a box that actually has a choice to make. A Deck has one adapter, and a
|
||||
// picker with a single option is a control that can't do anything.
|
||||
const showGpuRow = (devices?.adapters.length ?? 0) > 1;
|
||||
return (
|
||||
<div style={pageBody}>
|
||||
<SelectRow
|
||||
label="Video codec"
|
||||
description="A preference — the host falls back when its GPU can't encode this one."
|
||||
options={CODECS}
|
||||
value={s.codec ?? "auto"}
|
||||
onChange={(v) => patch({ codec: v })}
|
||||
/>
|
||||
<SelectRow
|
||||
label="Video decoder"
|
||||
description="How the Deck decodes the stream. Automatic prefers Vulkan Video, then VAAPI, then software."
|
||||
options={DECODERS}
|
||||
value={s.decoder ?? "auto"}
|
||||
onChange={(v) => patch({ decoder: v })}
|
||||
/>
|
||||
{showGpuRow && (
|
||||
<SelectRow
|
||||
label="Decode GPU"
|
||||
description="Which adapter decodes and presents the stream. Automatic picks the discrete GPU where there is one."
|
||||
options={[
|
||||
["", "Automatic"],
|
||||
...(devices?.adapters ?? []).map((a) => [a, a] as [string, string]),
|
||||
]}
|
||||
value={s.adapter ?? ""}
|
||||
onChange={(v) => patch({ adapter: v })}
|
||||
/>
|
||||
)}
|
||||
<ToggleField
|
||||
label="10-bit HDR"
|
||||
description="Advertise HDR10 so the host sends 10-bit when the content is HDR. Off means never ask for 10-bit."
|
||||
checked={s.hdr_enabled ?? true}
|
||||
onChange={(v) => patch({ hdr_enabled: v })}
|
||||
/>
|
||||
<ToggleField
|
||||
label="Full chroma (4:4:4)"
|
||||
description="Full-colour video: crisp small text and thin lines, at more bandwidth. Needs an NVIDIA host (NVENC) or the PyroWave codec — other encoders stream 4:2:0 and the session falls back silently."
|
||||
checked={s.enable_444 ?? false}
|
||||
onChange={(v) => patch({ enable_444: v })}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const PresentationPage: FC<PageCtx> = ({ s, patch }) => {
|
||||
const smooth = (s.present_priority ?? "latency") === "smooth";
|
||||
return (
|
||||
<div style={pageBody}>
|
||||
<SelectRow
|
||||
label="Prioritize"
|
||||
description="What to optimise for when a decoded frame is ready. Lowest latency shows each frame the moment the display can take it — a network hiccup becomes an occasional repeated or skipped frame. Smoothness buffers a little to even those out."
|
||||
options={PRESENT_PRIORITIES}
|
||||
value={s.present_priority ?? "latency"}
|
||||
onChange={(v) => patch({ present_priority: v })}
|
||||
/>
|
||||
<SelectRow
|
||||
label="Smoothness buffer"
|
||||
description="Frames held back before showing. Each one absorbs about a refresh of network hiccup and adds a refresh of delay. Automatic holds two."
|
||||
options={SMOOTH_BUFFERS}
|
||||
value={s.smooth_buffer ?? 0}
|
||||
formatUnknown={(v) => `${v} frames`}
|
||||
onChange={(v) => patch({ smooth_buffer: v })}
|
||||
disabled={!smooth}
|
||||
indent
|
||||
/>
|
||||
<ToggleField
|
||||
label="V-Sync"
|
||||
description="Tear-free. Off removes the wait for the screen's refresh — the lowest possible delay, at the cost of visible tearing. Best-effort: not every driver offers it, and the Detailed stats overlay names the mode actually in use."
|
||||
checked={s.vsync ?? true}
|
||||
onChange={(v) => patch({ vsync: v })}
|
||||
/>
|
||||
<ToggleField
|
||||
label="Follow variable refresh"
|
||||
description="On a VRR screen, let the panel refresh in step with the stream instead of on a fixed cadence. Applies to fullscreen sessions — which a Gaming-Mode stream always is — and is harmless on a fixed-refresh screen."
|
||||
checked={s.allow_vrr ?? true}
|
||||
onChange={(v) => patch({ allow_vrr: v })}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const AudioPage: FC<PageCtx> = ({ s, patch, devices, reading, readDevices }) => {
|
||||
const micOn = s.mic_enabled;
|
||||
// What the pickers get: null while the enumeration is in flight (they show a loading state),
|
||||
// [] when it answered but couldn't read the endpoints (System default plus whatever is
|
||||
// stored), and the real list otherwise.
|
||||
const endpoints = (list: AudioDevice[] | undefined): AudioDevice[] | null =>
|
||||
reading || !devices ? null : devices.ok ? (list ?? []) : [];
|
||||
return (
|
||||
<div style={pageBody}>
|
||||
<SelectRow
|
||||
label="Audio channels"
|
||||
description="The speaker layout requested from the host, which clamps it to what it can capture."
|
||||
options={AUDIO_CHANNELS}
|
||||
value={s.audio_channels ?? 2}
|
||||
formatUnknown={(v) => `${v} channels`}
|
||||
onChange={(v) => patch({ audio_channels: v })}
|
||||
/>
|
||||
<DeviceRow
|
||||
label="Output device"
|
||||
description="Where stream audio plays. System default follows whatever the Deck is using, including a headset you plug in mid-stream."
|
||||
devices={endpoints(devices?.sinks)}
|
||||
value={s.speaker_device ?? ""}
|
||||
onChange={(v) => patch({ speaker_device: v })}
|
||||
/>
|
||||
<ToggleField
|
||||
label="Stream microphone"
|
||||
description="Send the Deck's microphone to the host's virtual mic. Ctrl+Alt+Shift+V mutes and unmutes it mid-stream."
|
||||
checked={micOn}
|
||||
onChange={(v) => patch({ mic_enabled: v })}
|
||||
/>
|
||||
<DeviceRow
|
||||
label="Microphone device"
|
||||
description="Which input the mic uplink captures from."
|
||||
devices={endpoints(devices?.sources)}
|
||||
value={s.mic_device ?? ""}
|
||||
onChange={(v) => patch({ mic_device: v })}
|
||||
disabled={!micOn}
|
||||
indent
|
||||
/>
|
||||
<ToggleField
|
||||
label="Echo cancellation"
|
||||
description="Stops the host's audio, playing from the Deck's speakers, being picked up and sent back. Turn it off if your microphone already runs its own processing."
|
||||
checked={s.echo_cancel ?? true}
|
||||
onChange={(v) => patch({ echo_cancel: v })}
|
||||
disabled={!micOn}
|
||||
indentLevel={1}
|
||||
/>
|
||||
{/* The escape hatch for a headset plugged in after this page was opened, and the honest
|
||||
answer when the enumeration failed outright (a client too old to ship the session
|
||||
binary). Rendered unconditionally, including while it is reading: a row that comes and
|
||||
goes under a thumbstick is a moving target, so only its wording changes. */}
|
||||
<Field
|
||||
label={
|
||||
!reading && devices && !devices.ok ? "Couldn't read this device's hardware" : "Devices"
|
||||
}
|
||||
description={
|
||||
reading
|
||||
? "Reading this device's audio endpoints and GPUs…"
|
||||
: devices && !devices.ok
|
||||
? "The output, microphone and GPU pickers fall back to Automatic. Reading them needs the client's session binary, which a client older than the two-binary split doesn't ship — update it from the About tab."
|
||||
: "Plugged something in just now? Read the audio endpoints and GPUs again."
|
||||
}
|
||||
childrenContainerWidth="max"
|
||||
>
|
||||
<RowActions>
|
||||
<DialogButton style={actionButton} disabled={reading} onClick={() => readDevices(true)}>
|
||||
{reading ? <Spinner style={{ height: "1em" }} /> : "Refresh"}
|
||||
</DialogButton>
|
||||
</RowActions>
|
||||
</Field>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ControllersPage: FC<PageCtx> = ({ s, patch }) => {
|
||||
const forwarding = s.gamepad_forwarding ?? true;
|
||||
return (
|
||||
<div style={pageBody}>
|
||||
<ToggleField
|
||||
label="Forward controllers"
|
||||
description="Send controllers connected to the Deck to the host. Turn it off when your controller already reaches the host another way — USB passthrough such as VirtualHere, or a pad plugged into the host — so games don't see two of them."
|
||||
checked={forwarding}
|
||||
onChange={(v) => patch({ gamepad_forwarding: v })}
|
||||
/>
|
||||
<SelectRow
|
||||
label="Controller type"
|
||||
description="The virtual pad the host creates. Automatic matches the controller you're holding."
|
||||
options={GAMEPADS}
|
||||
value={s.gamepad}
|
||||
onChange={(v) => patch({ gamepad: v })}
|
||||
disabled={!forwarding}
|
||||
indent
|
||||
/>
|
||||
{forwarding && (s.gamepad === "steamdeck" || s.gamepad === "auto") && (
|
||||
<Field
|
||||
label="⚠ Disable Steam Input"
|
||||
description="On a Deck, Automatic forwards the built-in controller as a Steam Deck pad — paddles, both trackpads, and gyro included. For that, Steam Input must be OFF for Punktfunk: on the game page tap ⚙ → Controller Settings → set Steam Input to Off. Otherwise Steam keeps the Deck's controls and only the sticks + buttons reach the host."
|
||||
indentLevel={1}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const PointerPage: FC<PageCtx> = ({ s, patch }) => (
|
||||
<div style={pageBody}>
|
||||
<SelectRow
|
||||
label="Touch mode"
|
||||
description="How the touchscreen drives the host: Trackpad (relative cursor, tap to click), Direct pointer (the cursor jumps to your finger), or Touch passthrough (every finger is a host contact — only helps apps that understand touch)."
|
||||
options={TOUCH_MODES}
|
||||
value={s.touch_mode ?? "trackpad"}
|
||||
onChange={(v) => patch({ touch_mode: v })}
|
||||
/>
|
||||
<SelectRow
|
||||
label="Mouse mode"
|
||||
description="How a physical mouse drives the host: Capture locks the pointer for games, Desktop leaves it free and sends absolute positions. Ctrl+Alt+Shift+M switches it live mid-stream."
|
||||
options={MOUSE_MODES}
|
||||
value={s.mouse_mode ?? "capture"}
|
||||
onChange={(v) => patch({ mouse_mode: v })}
|
||||
/>
|
||||
<ToggleField
|
||||
label="Invert scroll direction"
|
||||
description="Reverses the wheel and trackpad scroll direction sent to the host."
|
||||
checked={s.invert_scroll ?? false}
|
||||
onChange={(v) => patch({ invert_scroll: v })}
|
||||
/>
|
||||
<ToggleField
|
||||
label="Capture system shortcuts"
|
||||
description="Sends Alt+Tab, Super and friends to the host while input is captured, instead of leaving them to the local desktop. Gaming Mode is gamescope, which has no shortcuts to hold back — this is for a keyboard attached to the Deck in Desktop Mode, and for the desktop client sharing these settings."
|
||||
checked={s.inhibit_shortcuts}
|
||||
onChange={(v) => patch({ inhibit_shortcuts: v })}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
const InterfacePage: FC<PageCtx> = ({ s, patch }) => {
|
||||
// `Settings::stats_verbosity`: no tier = a pre-tier store, resolved through the legacy bool,
|
||||
// which itself defaults to true.
|
||||
const statsTier = s.stats_verbosity ?? ((s.show_stats ?? true) ? "normal" : "off");
|
||||
return (
|
||||
<div style={pageBody}>
|
||||
<SelectRow
|
||||
label="Statistics overlay"
|
||||
description="How much the in-stream overlay shows: Compact (fps · latency · bitrate on one line) → Normal → Detailed. A three-finger tap on the touchscreen cycles it mid-stream."
|
||||
options={STATS_TIERS}
|
||||
value={statsTier}
|
||||
// Both keys, in sync — the same pairing `Settings::set_stats_verbosity` keeps, so a
|
||||
// client too old for the tiers still honours an Off chosen here.
|
||||
onChange={(v) => patch({ stats_verbosity: v, show_stats: v !== "off" })}
|
||||
/>
|
||||
<ToggleField
|
||||
label="Wake hosts automatically"
|
||||
description="Send Wake-on-LAN to a sleeping host before connecting and wait for it to boot. Turn it off for hosts reached over a VPN, where an offline-looking host is really just unreachable by broadcast and the wait only adds delay."
|
||||
checked={s.auto_wake ?? true}
|
||||
onChange={(v) => patch({ auto_wake: v })}
|
||||
/>
|
||||
<ToggleField
|
||||
label="Show game library in the client"
|
||||
description="Lets the client's own host cards browse a paired host's games. This plugin's library browser works either way — this is for the client's screens."
|
||||
checked={s.library_enabled ?? false}
|
||||
onChange={(v) => patch({ library_enabled: v })}
|
||||
/>
|
||||
<ToggleField
|
||||
label="Start streams fullscreen"
|
||||
description="Streams open fullscreen instead of windowed. Launches from this plugin are always fullscreen whatever this says — it's here because the desktop client reads the same settings."
|
||||
checked={s.fullscreen_on_stream ?? true}
|
||||
onChange={(v) => patch({ fullscreen_on_stream: v })}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ----------------------------------------------------------------------------------------
|
||||
|
||||
export const SettingsSection: FC = () => {
|
||||
const [s, setS] = useState<StreamSettings | null>(null);
|
||||
// null until the enumeration answers — the pickers show a loading state rather than briefly
|
||||
// claiming this device has no endpoints.
|
||||
const [devices, setDevices] = useState<DeviceLists | null>(null);
|
||||
const [reading, setReading] = useState(true);
|
||||
|
||||
const readDevices = (again: boolean) => {
|
||||
setReading(true);
|
||||
void (again ? refreshDevices() : listDevices())
|
||||
.then(setDevices)
|
||||
.finally(() => setReading(false));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void getSettings().then(setS);
|
||||
// Deliberately not awaited together with the settings: a cold flatpak initialising Vulkan
|
||||
// takes seconds, and the rest of the screen must not wait for it.
|
||||
readDevices(false);
|
||||
}, []);
|
||||
|
||||
const patch = (p: Partial<StreamSettings>) => {
|
||||
setS((cur) => {
|
||||
if (!cur) return cur;
|
||||
const next = { ...cur, ...p };
|
||||
void setSettings(next);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
if (!s) return <Spinner style={{ height: "1.5em" }} />;
|
||||
|
||||
const ctx: PageCtx = { s, patch, devices, reading, readDevices };
|
||||
return (
|
||||
<SidebarNavigation
|
||||
// We are already inside the plugin's own `/punktfunk` route, rendered in a tab. Route
|
||||
// reporting would have this nav push entries of its own onto the router and fight the
|
||||
// page for the back gesture; the pages are addressed by `identifier` instead.
|
||||
disableRouteReporting
|
||||
pages={[
|
||||
{ title: "Stream", identifier: "stream", icon: <FaDesktop />, content: <StreamPage {...ctx} /> },
|
||||
{ title: "Video", identifier: "video", icon: <FaVideo />, content: <VideoPage {...ctx} /> },
|
||||
{
|
||||
title: "Presentation",
|
||||
identifier: "presentation",
|
||||
icon: <FaTv />,
|
||||
content: <PresentationPage {...ctx} />,
|
||||
},
|
||||
{ title: "Audio", identifier: "audio", icon: <FaVolumeUp />, content: <AudioPage {...ctx} /> },
|
||||
{
|
||||
title: "Controllers",
|
||||
identifier: "controllers",
|
||||
icon: <FaGamepad />,
|
||||
content: <ControllersPage {...ctx} />,
|
||||
},
|
||||
{
|
||||
title: "Touch & mouse",
|
||||
identifier: "pointer",
|
||||
icon: <FaHandPointer />,
|
||||
content: <PointerPage {...ctx} />,
|
||||
},
|
||||
{
|
||||
title: "Interface",
|
||||
identifier: "interface",
|
||||
icon: <FaSlidersH />,
|
||||
content: <InterfacePage {...ctx} />,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
};
|
||||
+64
-59
@@ -8,16 +8,16 @@
|
||||
//
|
||||
// TWO shortcuts, both named "Punktfunk" (so they share ONE Steam Input controller-config key —
|
||||
// see applyControllerConfig):
|
||||
// • STREAM — hidden, stateful: the per-session launcher. Its launch options carry the host /
|
||||
// pinned game (PF_HOST/PF_LAUNCH/PF_BROWSE), rewritten per launch, so one shortcut serves
|
||||
// every host. Driven by the QAM/pins/host-library actions. Hidden — an implementation detail.
|
||||
// • STREAM — hidden, stateful: the per-session launcher. Its launch options carry the host
|
||||
// reference and the card's profile (PF_REF/PF_PROFILE/PF_REQUEST_ACCESS), rewritten per
|
||||
// launch, so one shortcut serves every host. Hidden — an implementation detail.
|
||||
// • GAMEPAD UI — visible, stateless: fixed launch options = bare `--browse` (PF_BROWSE, no
|
||||
// host) → the client's console home (host picker + pairing + settings, gamepad-navigable).
|
||||
// This is the library-visible "Punktfunk" app the user opens directly.
|
||||
//
|
||||
// Both get the shipped artwork and the native-touch controller config.
|
||||
|
||||
import { applyControllerConfig, runnerInfo, shortcutArt, wake } from "./backend";
|
||||
import { applyControllerConfig, runnerInfo, shortcutArt } from "./backend";
|
||||
|
||||
// SteamClient is a Steam-internal global injected into the CEF context; it is not fully typed
|
||||
// by @decky/ui, so declare the surface we use. Signatures verified against MoonDeck + the
|
||||
@@ -257,11 +257,12 @@ export async function ensureGamepadUiShortcut(): Promise<number | null> {
|
||||
}
|
||||
const startDir = info.runner.replace(/\/[^/]*$/, "");
|
||||
void ensureControllerConfig();
|
||||
// Bare browse: PF_BROWSE with no PF_HOST → the wrapper runs `--browse --fullscreen` (console
|
||||
// home). %command% expands to the shortcut exe (/bin/sh); the wrapper rides behind as an arg.
|
||||
// PF_CLIENT_BIN only when the backend resolved a NATIVE client — else the wrapper's flatpak
|
||||
// default stands and this shortcut is exactly what it always was.
|
||||
const clientBin = info.client_bin ? `PF_CLIENT_BIN=${info.client_bin} ` : "";
|
||||
// PF_BROWSE → the wrapper runs the SESSION's `--browse --fullscreen` (console home), which is
|
||||
// the one branch this rework deliberately left alone. %command% expands to the shortcut exe
|
||||
// (/bin/sh); the wrapper rides behind as an arg. PF_CLIENT_BIN only when the backend resolved
|
||||
// a NATIVE client — else the wrapper's flatpak default stands and this shortcut is exactly
|
||||
// what it always was.
|
||||
const clientBin = safeClientBin(info.client_bin) ? `PF_CLIENT_BIN=${info.client_bin} ` : "";
|
||||
const launchOpts = `${clientBin}PF_BROWSE=1 %command% "${info.runner}"`;
|
||||
|
||||
// Reuse the remembered entry only if it still exists; a stale appId (deleted shortcut whose
|
||||
@@ -319,77 +320,81 @@ export async function launchGamepadUi(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
/** Per-launch extras beyond the host target (all optional — {} is the plain stream). */
|
||||
/** Per-launch extras beyond the host reference (all optional — {} is the plain stream). */
|
||||
export interface LaunchOpts {
|
||||
/** Library id to launch on connect (a pinned game) — rides PF_LAUNCH → `--launch`. */
|
||||
launchId?: string;
|
||||
/** Open the gamepad library launcher instead of streaming (PF_BROWSE → `--browse`). */
|
||||
browse?: boolean;
|
||||
/** Management-API port for the launcher's library fetch (PF_MGMT; 0/absent = default). */
|
||||
mgmt?: number;
|
||||
/** A pinned card: stream with this settings profile, one-off (PF_PROFILE → `--profile`). */
|
||||
profileId?: string;
|
||||
/**
|
||||
* Ask the host's operator to admit this Deck rather than typing a PIN (PF_REQUEST_ACCESS).
|
||||
* The connect PARKS until somebody approves it, and the launch runs SUPERVISED — see the
|
||||
* wrapper for why `--exec` is dropped on this path alone.
|
||||
*/
|
||||
requestAccess?: boolean;
|
||||
}
|
||||
|
||||
// Launch ids ride Steam launch options as an env-prefix token (`PF_LAUNCH=<id>`), so they
|
||||
// must be space/quote-free — Steam's tokenizer and the wrapper's env both break otherwise.
|
||||
// Real ids are `steam:<digits>` / `custom:<slug>`, so this rejects nothing in practice;
|
||||
// it's VALIDATION, never encoding (the host must match the opaque token verbatim).
|
||||
const UNSAFE_LAUNCH_ID = /["'\\$`\s]/;
|
||||
// Host refs and profile ids ride Steam launch options as env-prefix tokens (`PF_REF=<ref>`),
|
||||
// so they must be space/quote-free — Steam's tokenizer and the wrapper's env both break
|
||||
// otherwise. Real values are UUIDs or `addr:port`, so this rejects nothing in practice; it is
|
||||
// VALIDATION, never encoding (the client must receive the opaque token verbatim).
|
||||
const UNSAFE_TOKEN = /["'\\$`\s]/;
|
||||
export function isSafeLaunchId(id: string): boolean {
|
||||
return (
|
||||
id.length > 0 &&
|
||||
id.length <= 128 &&
|
||||
UNSAFE_LAUNCH_ID.exec(id) === null &&
|
||||
UNSAFE_TOKEN.exec(id) === null &&
|
||||
/^[\x21-\x7e]+$/.test(id)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Launch a stream to `host:port` fullscreen in Gaming Mode (optionally straight into a
|
||||
* library title, or into a host's gamepad library). Encodes the target into the STREAM
|
||||
* shortcut's launch options (so one hidden shortcut serves every host and every pinned game),
|
||||
* then RunGame.
|
||||
* Is a resolved native-client path safe to put in Steam's launch options? Same rule, separate
|
||||
* name because the failure is different: an unsafe id is a bug in our own data, an unsafe path
|
||||
* is just where the user installed the client — so the browse shortcut degrades to its flatpak
|
||||
* default rather than refusing to exist.
|
||||
*/
|
||||
export async function launchStream(
|
||||
host: string,
|
||||
port: number,
|
||||
opts: LaunchOpts = {},
|
||||
): Promise<void> {
|
||||
// Wake-on-LAN: if this host is asleep, nudge it awake before the stream connects. Kicked off now
|
||||
// so it races with the shortcut setup (near-zero added latency); its outcome is needed below
|
||||
// (the connect budget), and RunGame follows the await either way, so nothing is slower for it.
|
||||
// Best-effort — the flatpak client's --wake looks up the host's learned MAC (a no-op if none is
|
||||
// known), and the connect that follows has its own retry window, so a failure never blocks launch.
|
||||
const waking = wake(host, port).catch(() => ({ ok: false }));
|
||||
const [{ appId, runner, clientBin }, woke] = await Promise.all([ensureStreamShortcut(), waking]);
|
||||
const target = port && port !== 9777 ? `${host}:${port}` : host;
|
||||
const env = [`PF_HOST=${target}`];
|
||||
function safeClientBin(bin: string | undefined): bin is string {
|
||||
return !!bin && isSafeLaunchId(bin);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream `ref` fullscreen in Gaming Mode, optionally with a pinned card's profile. Encodes the
|
||||
* target into the STREAM shortcut's launch options — one hidden shortcut serves every host —
|
||||
* then RunGame.
|
||||
*
|
||||
* No Wake-on-LAN here any more. The plugin used to fire a magic packet itself and then stretch
|
||||
* the connect budget to 75 s to cover the host's resume, which was a workaround for the era
|
||||
* before the CLI existed. `punktfunk launch` now runs the real wake-and-wait loop (packet at
|
||||
* t=0, re-sent every 6 s, presence polled every second) and only dials once the host answers —
|
||||
* strictly better, and it deletes a backend method, a frontend call and a shell branch.
|
||||
*/
|
||||
export async function launchStream(ref: string, opts: LaunchOpts = {}): Promise<void> {
|
||||
if (!isSafeLaunchId(ref)) {
|
||||
throw new Error(`unsupported host reference: ${ref}`);
|
||||
}
|
||||
if (opts.profileId && !isSafeLaunchId(opts.profileId)) {
|
||||
throw new Error(`unsupported profile id: ${opts.profileId}`);
|
||||
}
|
||||
const { appId, runner, clientBin } = await ensureStreamShortcut();
|
||||
const env = [`PF_REF=${ref}`];
|
||||
// Set only for a NATIVE client install; absent, the wrapper takes its flatpak default, so every
|
||||
// existing Deck install produces byte-identical launch options to before.
|
||||
if (clientBin) {
|
||||
// The one launch-option value that comes from the backend rather than a store id, and so
|
||||
// the one that could carry a space: a path like `/home/deck/my apps/punktfunk-client` would
|
||||
// split Steam's tokenizer and land its tail in front of %command% as a bogus env token.
|
||||
if (!isSafeLaunchId(clientBin)) {
|
||||
throw new Error(`client path can't ride Steam's launch options: ${clientBin}`);
|
||||
}
|
||||
env.push(`PF_CLIENT_BIN=${clientBin}`);
|
||||
}
|
||||
// A magic packet actually went out (a MAC was known), so the host may be mid-resume from
|
||||
// suspend — that takes far longer than the client's default 15 s connect budget. Stretch the
|
||||
// budget so the client's wake-tolerant dial keeps retrying across the resume; against an
|
||||
// already-awake host the connect still lands in under a second, so this costs nothing.
|
||||
if (woke.ok) {
|
||||
env.push("PF_CONNECT_TIMEOUT=75");
|
||||
if (opts.profileId) {
|
||||
env.push(`PF_PROFILE=${opts.profileId}`);
|
||||
}
|
||||
if (opts.browse) {
|
||||
env.push("PF_BROWSE=1");
|
||||
if (opts.mgmt) {
|
||||
env.push(`PF_MGMT=${Math.floor(opts.mgmt)}`);
|
||||
}
|
||||
} else if (opts.launchId) {
|
||||
if (!isSafeLaunchId(opts.launchId)) {
|
||||
// Enforced at pin time too (the picker disables Pin) — this is the backstop.
|
||||
throw new Error(`unsupported launch id: ${opts.launchId}`);
|
||||
}
|
||||
env.push(`PF_LAUNCH=${opts.launchId}`);
|
||||
if (opts.requestAccess) {
|
||||
env.push("PF_REQUEST_ACCESS=1");
|
||||
}
|
||||
// KEY=value ... %command% args — %command% expands to the shortcut exe (/bin/sh); the wrapper
|
||||
// script rides behind it as an argument and reads PF_* from the environment. The wake was
|
||||
// awaited above, so the magic packet is out before the connect attempt.
|
||||
// script rides behind it as an argument and reads PF_* from the environment.
|
||||
SteamClient.Apps.SetAppLaunchOptions(appId, `${env.join(" ")} %command% "${runner}"`);
|
||||
SteamClient.Apps.RunGame(gameIdFromAppId(appId), "", -1, 100);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
// The trust sheet — the step between "I can see a host" and "I can stream it".
|
||||
//
|
||||
// Two ways in, in the order the GTK dialog and the console's pair screen offer them:
|
||||
//
|
||||
// • REQUEST ACCESS (default) — no PIN. Save the host with the fingerprint it ADVERTISED,
|
||||
// then launch. The host parks that connect until its operator approves this Deck in the
|
||||
// console or web UI, admits it, and the stream starts by itself. It is not a second
|
||||
// pairing ceremony; it is an ordinary identified connect with a stretched budget, which
|
||||
// is why it costs no ceremony surface here at all.
|
||||
// • USE A PIN INSTEAD — the existing gamepad-navigable keypad (pair.tsx).
|
||||
//
|
||||
// NO FINGERPRINT, NO REQUEST ACCESS. The parked connect pins the advertised fingerprint, and
|
||||
// that pin is the only thing standing between a 185 s wait and an impostor answering for the
|
||||
// host. A host typed in by address advertises nothing, so it gets the PIN path only — and is
|
||||
// told why, rather than being shown a button that could only fail. Under no circumstances does
|
||||
// this sheet trust-on-first-use its way past a missing fingerprint.
|
||||
import { DialogButton, Focusable, ModalRoot, Spinner, showModal } from "@decky/ui";
|
||||
import { toaster } from "@decky/api";
|
||||
import { FC, useRef, useState } from "react";
|
||||
import { trustHost } from "./backend";
|
||||
import { HostView } from "./hooks";
|
||||
import { PairModal } from "./pair";
|
||||
|
||||
/** User-facing copy for a `trustHost` failure code. */
|
||||
function trustErrorBody(error: string | undefined, name: string): string {
|
||||
switch (error) {
|
||||
case "refused":
|
||||
return `${name} is already saved under a different identity. Forget it in the Punktfunk app before trusting it again.`;
|
||||
case "client-outdated":
|
||||
return "Update the Punktfunk client to use request access.";
|
||||
case "client-unavailable":
|
||||
return "Couldn’t reach the Punktfunk client — is it still installed?";
|
||||
default:
|
||||
return `Couldn’t save ${name}.`;
|
||||
}
|
||||
}
|
||||
|
||||
export const TrustSheet: FC<{
|
||||
host: HostView;
|
||||
closeModal?: () => void;
|
||||
/** Stream this host, having just been let in. */
|
||||
onStream: (opts: { requestAccess?: boolean }) => void;
|
||||
/** Re-read the host list — the record changed underneath the panel. */
|
||||
onChanged: () => void;
|
||||
}> = ({ host, closeModal, onStream, onChanged }) => {
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// ⚠ This sheet is a `showModal` PORTAL: it captures its callbacks ONCE and never re-renders
|
||||
// from panel state. Anything it needs to act on later must be read through a ref, not out of
|
||||
// a captured value — reading a captured array is exactly what made pinning a second game
|
||||
// compute from a stale base and clobber the first.
|
||||
const props = useRef({ host, onStream, onChanged });
|
||||
props.current = { host, onStream, onChanged };
|
||||
|
||||
// Request access pins what the host ADVERTISES. The record's own pin is a different thing:
|
||||
// a host that already has one streams without ever opening this sheet.
|
||||
const hasIdentity = host.advertisedFp !== "";
|
||||
// A host advertising `pair=optional` admits anyone who pins its identity — there is no
|
||||
// operator decision to wait for, and asking for one would be a wait that never ends and a
|
||||
// record claiming somebody approved this Deck when nobody did. `paired` means the PIN
|
||||
// ceremony or a real approval; the desktop client records exactly this case as *trusted*.
|
||||
const needsApproval = host.pairPolicy !== "optional";
|
||||
const canRequestAccess = hasIdentity && needsApproval;
|
||||
const canTrustDirectly = hasIdentity && !needsApproval;
|
||||
|
||||
/**
|
||||
* Pin the advertised identity, then stream.
|
||||
*
|
||||
* `approval` is what differs between the two doors, and it is not cosmetic: it decides whether
|
||||
* the launch waits ~185 s for an operator AND whether the record ends up marked paired.
|
||||
*/
|
||||
const letIn = async (approval: boolean) => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
const { host: h, onStream: stream, onChanged: changed } = props.current;
|
||||
try {
|
||||
// Step 1: save it with the ADVERTISED fingerprint, pinned but unpaired ("trusted").
|
||||
// Idempotent, so a retry after a declined approval is free.
|
||||
const r = await trustHost(h.addr, h.port, h.advertisedFp, h.name);
|
||||
if (!r.ok) {
|
||||
setError(trustErrorBody(r.error, h.name));
|
||||
setBusy(false);
|
||||
return;
|
||||
}
|
||||
changed();
|
||||
// Step 2: the launch. Under approval it PARKS — and the session's plain connecting screen
|
||||
// looks identical whether it is parked or hanging, so say what is about to happen BEFORE
|
||||
// it starts. That toast is a patch over that, and the real fix belongs in the session.
|
||||
if (approval) {
|
||||
toaster.toast({
|
||||
title: "Punktfunk",
|
||||
body: `Approve this Deck in ${h.name}’s console — the stream starts by itself`,
|
||||
duration: 10_000,
|
||||
});
|
||||
}
|
||||
stream({ requestAccess: approval });
|
||||
closeModal?.();
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const usePin = () => {
|
||||
// Hand off to the keypad. Closing first keeps one modal on screen at a time, which is what
|
||||
// the gamepad focus model expects.
|
||||
const { host: h, onStream: stream, onChanged: changed } = props.current;
|
||||
closeModal?.();
|
||||
showModal(
|
||||
<PairModal
|
||||
host={h}
|
||||
onPaired={() => {
|
||||
changed();
|
||||
stream({});
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<ModalRoot closeModal={closeModal}>
|
||||
<div style={{ fontWeight: "bold", fontSize: "1.3em", marginBottom: "0.3em" }}>
|
||||
Connect to {host.name}
|
||||
</div>
|
||||
<div style={{ opacity: 0.8, marginBottom: "1em" }}>
|
||||
{!hasIdentity
|
||||
? "No advertised identity for this host — pair with a PIN instead."
|
||||
: canTrustDirectly
|
||||
? `${host.name} accepts new devices. Connecting pins its identity so later streams are silent.`
|
||||
: `${host.name} needs to let this device in before it can stream.`}
|
||||
</div>
|
||||
{error && (
|
||||
<div style={{ color: "#ff6b6b", marginBottom: "0.6em" }}>{error}</div>
|
||||
)}
|
||||
|
||||
<Focusable style={{ display: "flex", flexDirection: "column", gap: "0.5em" }}>
|
||||
{canRequestAccess && (
|
||||
<DialogButton disabled={busy} onClick={() => void letIn(true)}>
|
||||
{busy ? <Spinner style={{ height: "1em" }} /> : "Request access"}
|
||||
</DialogButton>
|
||||
)}
|
||||
{canTrustDirectly && (
|
||||
<DialogButton disabled={busy} onClick={() => void letIn(false)}>
|
||||
{busy ? <Spinner style={{ height: "1em" }} /> : "Connect"}
|
||||
</DialogButton>
|
||||
)}
|
||||
<DialogButton disabled={busy} onClick={usePin}>
|
||||
Use a PIN instead…
|
||||
</DialogButton>
|
||||
<DialogButton disabled={busy} onClick={() => closeModal?.()}>
|
||||
Cancel
|
||||
</DialogButton>
|
||||
</Focusable>
|
||||
|
||||
{canRequestAccess && (
|
||||
<div style={{ opacity: 0.6, fontSize: "0.85em", marginTop: "0.8em" }}>
|
||||
Request access asks {host.name}’s operator to approve this Deck in its console or web
|
||||
UI. No PIN to type — the stream starts as soon as they do.
|
||||
</div>
|
||||
)}
|
||||
</ModalRoot>
|
||||
);
|
||||
};
|
||||
@@ -1,46 +0,0 @@
|
||||
// Shared UI primitives for the fullscreen page + modals. The one rule that keeps every row
|
||||
// looking consistent: a Field's action(s) always sit right-aligned, with real space between
|
||||
// them and the label text — never hugging it.
|
||||
//
|
||||
// Decky lays a Field out as `[ label .......... children ]`. When the children container is
|
||||
// grown (`childrenContainerWidth="max"`, which we want so multi-button clusters have room), a
|
||||
// bare `fit-content` button LEFT-aligns inside that grown container and ends up pressed against
|
||||
// the label with the space wasted to its right. Wrapping the action(s) in `RowActions` pushes
|
||||
// them to the right edge and evenly spaces multiples — the same treatment every row now gets.
|
||||
import { Focusable } from "@decky/ui";
|
||||
import { CSSProperties, FC, ReactNode } from "react";
|
||||
|
||||
export const RowActions: FC<{ children: ReactNode }> = ({ children }) => (
|
||||
<Focusable
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: "0.5em",
|
||||
justifyContent: "flex-end",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Focusable>
|
||||
);
|
||||
|
||||
// A single action button sized to its content (not the gamepad-UI default of 100% width), with
|
||||
// a floor so short labels ("Pair", "Remove") don't render as tiny nubs and every row's button
|
||||
// reads at the same weight.
|
||||
export const actionButton: CSSProperties = {
|
||||
width: "fit-content",
|
||||
minWidth: "7em",
|
||||
flexShrink: 0,
|
||||
};
|
||||
|
||||
// Square icon-only button (details ⓘ, header back arrow). Needs an explicit height or the zero
|
||||
// padding collapses it to the icon's line height.
|
||||
export const iconButton: CSSProperties = {
|
||||
width: "40px",
|
||||
minWidth: "40px",
|
||||
height: "40px",
|
||||
padding: 0,
|
||||
flexShrink: 0,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
};
|
||||
@@ -4,6 +4,8 @@
|
||||
//! cards and flip a saved host's online pip when its advert disappears.
|
||||
|
||||
use mdns_sd::{ServiceDaemon, ServiceEvent};
|
||||
use std::collections::BTreeMap;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct DiscoveredHost {
|
||||
@@ -31,6 +33,19 @@ pub struct DiscoveredHost {
|
||||
pub os: String,
|
||||
}
|
||||
|
||||
impl DiscoveredHost {
|
||||
/// The host's advertised stable id (mDNS TXT `id`), or `""` when it doesn't advertise one.
|
||||
/// [`DiscoveredHost::key`] falls back to the mDNS fullname in that case, so the two being
|
||||
/// equal is exactly the "no id" signal — read it through here rather than re-deriving it.
|
||||
pub fn advertised_id(&self) -> &str {
|
||||
if self.key == self.fullname {
|
||||
""
|
||||
} else {
|
||||
&self.key
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One discovery update for the UI's advert map.
|
||||
pub enum DiscoveryEvent {
|
||||
/// A host advert appeared or refreshed (new address, pairing flipped, …).
|
||||
@@ -39,8 +54,8 @@ pub enum DiscoveryEvent {
|
||||
Removed { fullname: String },
|
||||
}
|
||||
|
||||
/// Browse continuously for the app's lifetime. The thread exits when the receiver is
|
||||
/// dropped (the send fails) or the daemon dies.
|
||||
/// Browse continuously. The worker exits when the returned receiver is dropped, or when the
|
||||
/// daemon dies — checked on a tick, so it stops even on a LAN where no advert ever arrives.
|
||||
pub fn browse() -> async_channel::Receiver<DiscoveryEvent> {
|
||||
let (tx, rx) = async_channel::unbounded();
|
||||
std::thread::Builder::new()
|
||||
@@ -60,7 +75,24 @@ pub fn browse() -> async_channel::Receiver<DiscoveryEvent> {
|
||||
return;
|
||||
}
|
||||
};
|
||||
while let Ok(event) = receiver.recv() {
|
||||
// Polled rather than blocked on: the worker has to notice that its consumer went
|
||||
// away even when NOTHING is arriving, which is the normal state of a LAN with no
|
||||
// hosts on it. A plain `recv()` parks forever there, and the ignored-event arm below
|
||||
// never touches `tx` — so a bounded consumer like `discover_for` would leak this
|
||||
// thread and its daemon (another thread, and a socket bound to :5353) on every call.
|
||||
loop {
|
||||
// Checked at the TOP so it also covers the arms below that `continue` without
|
||||
// ever touching `tx` — the ignored event kinds, and an advert with no IPv4
|
||||
// address. Those are the paths that would otherwise keep this thread alive with
|
||||
// nobody to send to.
|
||||
if tx.is_closed() {
|
||||
break;
|
||||
}
|
||||
let event = match receiver.recv_timeout(Duration::from_millis(250)) {
|
||||
Ok(event) => event,
|
||||
Err(_) if receiver.is_disconnected() => break,
|
||||
Err(_) => continue,
|
||||
};
|
||||
let update = match event {
|
||||
ServiceEvent::ServiceResolved(info) => {
|
||||
let props = info.get_properties();
|
||||
@@ -117,3 +149,154 @@ pub fn browse() -> async_channel::Receiver<DiscoveryEvent> {
|
||||
.expect("spawn mdns thread");
|
||||
rx
|
||||
}
|
||||
|
||||
/// The advert map one browse window folded down to. Kept separate from [`discover_for`] so the
|
||||
/// fold — which is where dedupe and removal actually live — is testable without a network.
|
||||
type Adverts = BTreeMap<String, DiscoveredHost>;
|
||||
|
||||
/// Apply one event to the map. A refreshed advert WINS over the one already there (it carries
|
||||
/// the newer address — a host that changed DHCP lease re-announces), and a removal drops
|
||||
/// whichever entry that mDNS fullname produced, whatever it was keyed under.
|
||||
fn fold(adverts: &mut Adverts, event: DiscoveryEvent) {
|
||||
match event {
|
||||
DiscoveryEvent::Resolved(host) => {
|
||||
adverts.insert(host.key.clone(), host);
|
||||
}
|
||||
DiscoveryEvent::Removed { fullname } => {
|
||||
adverts.retain(|_, h| h.fullname != fullname);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Browse for `timeout`, then return what answered — deduped by `key`, address-sorted.
|
||||
///
|
||||
/// Blocking; intended for one-shot consumers (the CLI's `discover` verb, a plugin backend that
|
||||
/// wants one bounded call rather than a stream). The streaming [`browse`] stays the UI's door:
|
||||
/// a live hosts page wants adverts as they land, not a snapshot taken `timeout` after it opened.
|
||||
pub fn discover_for(timeout: Duration) -> Vec<DiscoveredHost> {
|
||||
let rx = browse();
|
||||
let deadline = Instant::now() + timeout;
|
||||
let mut adverts = Adverts::new();
|
||||
while Instant::now() < deadline {
|
||||
while let Ok(event) = rx.try_recv() {
|
||||
fold(&mut adverts, event);
|
||||
}
|
||||
// A short tick rather than a blocking recv with a deadline: `async_channel`'s blocking
|
||||
// receive has no timeout, and the whole point of this call is that it is bounded.
|
||||
std::thread::sleep(Duration::from_millis(50).min(timeout));
|
||||
}
|
||||
while let Ok(event) = rx.try_recv() {
|
||||
fold(&mut adverts, event);
|
||||
}
|
||||
// Dropping the receiver is what stops the worker — it polls for that, so this holds even
|
||||
// when nothing is advertising. Without it a one-shot consumer would leak a browse per call.
|
||||
drop(rx);
|
||||
sorted(adverts)
|
||||
}
|
||||
|
||||
/// The map as the list a caller gets: sorted by address, then port. IPv4 is compared
|
||||
/// NUMERICALLY (a lexical sort puts `.10` before `.9`, which reads as scrambled in a host list).
|
||||
fn sorted(adverts: Adverts) -> Vec<DiscoveredHost> {
|
||||
let mut hosts: Vec<DiscoveredHost> = adverts.into_values().collect();
|
||||
hosts.sort_by_key(|h| {
|
||||
(
|
||||
h.addr.parse::<std::net::Ipv4Addr>().ok().map(u32::from),
|
||||
h.addr.clone(),
|
||||
h.port,
|
||||
)
|
||||
});
|
||||
hosts
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn host(key: &str, fullname: &str, addr: &str) -> DiscoveredHost {
|
||||
DiscoveredHost {
|
||||
key: key.into(),
|
||||
fullname: fullname.into(),
|
||||
name: fullname.split('.').next().unwrap_or("?").into(),
|
||||
addr: addr.into(),
|
||||
port: 9777,
|
||||
fp_hex: "aa".into(),
|
||||
pair: "required".into(),
|
||||
mgmt_port: Some(47990),
|
||||
mac: vec![],
|
||||
os: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Two adverts for the same host collapse to one row, and the LATER one wins — that is how
|
||||
/// a host that moved to a new address stops being listed at the stale one.
|
||||
#[test]
|
||||
fn refreshed_advert_supersedes_the_earlier_one() {
|
||||
let mut adverts = Adverts::new();
|
||||
fold(
|
||||
&mut adverts,
|
||||
DiscoveryEvent::Resolved(host("id-1", "desk._punktfunk._udp.local.", "192.168.1.9")),
|
||||
);
|
||||
fold(
|
||||
&mut adverts,
|
||||
DiscoveryEvent::Resolved(host("id-1", "desk._punktfunk._udp.local.", "192.168.1.20")),
|
||||
);
|
||||
let out = sorted(adverts);
|
||||
assert_eq!(out.len(), 1, "same key must not render twice");
|
||||
assert_eq!(out[0].addr, "192.168.1.20", "the newer address wins");
|
||||
}
|
||||
|
||||
/// A host that goes away during the browse window is not in the answer.
|
||||
#[test]
|
||||
fn removal_drops_the_advert_it_names() {
|
||||
let mut adverts = Adverts::new();
|
||||
fold(
|
||||
&mut adverts,
|
||||
DiscoveryEvent::Resolved(host("id-1", "desk._punktfunk._udp.local.", "192.168.1.9")),
|
||||
);
|
||||
fold(
|
||||
&mut adverts,
|
||||
DiscoveryEvent::Resolved(host("id-2", "tv._punktfunk._udp.local.", "192.168.1.10")),
|
||||
);
|
||||
fold(
|
||||
&mut adverts,
|
||||
DiscoveryEvent::Removed {
|
||||
fullname: "desk._punktfunk._udp.local.".into(),
|
||||
},
|
||||
);
|
||||
let out = sorted(adverts);
|
||||
assert_eq!(out.len(), 1);
|
||||
assert_eq!(out[0].key, "id-2");
|
||||
}
|
||||
|
||||
/// A host with no `id` TXT is keyed by its fullname — and must not then report that
|
||||
/// fullname as an id, which would send a caller launching against a nonexistent reference.
|
||||
#[test]
|
||||
fn advertised_id_is_empty_without_the_txt() {
|
||||
let named = host("id-1", "desk._punktfunk._udp.local.", "10.0.0.1");
|
||||
assert_eq!(named.advertised_id(), "id-1");
|
||||
let anonymous = host(
|
||||
"desk._punktfunk._udp.local.",
|
||||
"desk._punktfunk._udp.local.",
|
||||
"10.0.0.1",
|
||||
);
|
||||
assert_eq!(anonymous.advertised_id(), "");
|
||||
}
|
||||
|
||||
/// Addresses sort the way a person reads them, not the way strings compare.
|
||||
#[test]
|
||||
fn addresses_sort_numerically() {
|
||||
let mut adverts = Adverts::new();
|
||||
for (i, addr) in ["192.168.1.20", "192.168.1.9", "192.168.1.100"]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
fold(
|
||||
&mut adverts,
|
||||
DiscoveryEvent::Resolved(host(&format!("id-{i}"), &format!("h{i}."), addr)),
|
||||
);
|
||||
}
|
||||
let out = sorted(adverts);
|
||||
let addrs: Vec<&str> = out.iter().map(|h| h.addr.as_str()).collect();
|
||||
assert_eq!(addrs, ["192.168.1.9", "192.168.1.20", "192.168.1.100"]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -232,17 +232,29 @@ impl KnownHosts {
|
||||
/// A read-only config dir just keeps re-minting in memory, which harms nothing: no lookup
|
||||
/// is keyed by the id yet (design §4.5).
|
||||
pub fn load() -> KnownHosts {
|
||||
let mut k: KnownHosts = Self::path()
|
||||
.and_then(|p| Ok(std::fs::read_to_string(p)?))
|
||||
.ok()
|
||||
.and_then(|s| serde_json::from_str(&s).ok())
|
||||
.unwrap_or_default();
|
||||
let mut k = Self::read();
|
||||
if k.mint_missing_ids() {
|
||||
let _ = k.save();
|
||||
}
|
||||
k
|
||||
}
|
||||
|
||||
/// The store exactly as it is on disk — no mint, and so no write.
|
||||
///
|
||||
/// For a consumer that only needs to LOOK at the records (annotating a discovery result
|
||||
/// against them, say) and never dials one by id. [`KnownHosts::load`]'s mint is a write, and
|
||||
/// two processes started together against a pre-mint store will each mint a *different* id
|
||||
/// for the same record and race to save it — after which whichever one already handed its
|
||||
/// ids to a caller has handed out references that no longer resolve. A read that stays a
|
||||
/// read cannot take part in that.
|
||||
pub fn read() -> KnownHosts {
|
||||
Self::path()
|
||||
.and_then(|p| Ok(std::fs::read_to_string(p)?))
|
||||
.ok()
|
||||
.and_then(|s| serde_json::from_str(&s).ok())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Give every record still missing one a stable id; returns true if anything changed
|
||||
/// (i.e. whether this needs persisting). Idempotent — a store that has been through it
|
||||
/// once is left byte-identical.
|
||||
|
||||
@@ -56,167 +56,6 @@ exclude = ["MsghdrX", "recvmsg_x", "mmsghdr", "sendmmsg", "recvmmsg"]
|
||||
"FRAME_MS" = "PUNKTFUNK_AUDIO_FRAME_MS"
|
||||
"SAMPLE_RATE_HZ" = "PUNKTFUNK_AUDIO_SAMPLE_RATE_HZ"
|
||||
|
||||
# R21: every remaining exported constant, prefixed. cbindgen emits a bare `#define` per
|
||||
# `pub const`, so without an entry here names as generic as MAX_PADS, TAG_LEN, ABI_VERSION and
|
||||
# INPUT_MAGIC land in the namespace of every C embedder that includes this header — and, as the
|
||||
# note above says, a clashing #define silently takes the last definition rather than failing to
|
||||
# compile. The table above had been doing this by hand for the handful someone noticed; this is
|
||||
# the rest of them, so the stated rule finally holds for the whole surface.
|
||||
#
|
||||
# NOT covered, deliberately: associated constants (`ColorInfo_CP_BT709`, `ClockResync_ROUNDS`,
|
||||
# `ResyncGuard_MAX_REJECTED_STREAK`). cbindgen already qualifies those with their type name,
|
||||
# which is the very property whose absence makes a bare `MAX_PADS` dangerous — they are
|
||||
# namespaced, just not by us.
|
||||
"ABI_VERSION" = "PUNKTFUNK_ABI_VERSION"
|
||||
"APP_EXITED_CLOSE_CODE" = "PUNKTFUNK_APP_EXITED_CLOSE_CODE"
|
||||
"BTN_MISC1" = "PUNKTFUNK_BTN_MISC1"
|
||||
"BTN_PADDLE1" = "PUNKTFUNK_BTN_PADDLE1"
|
||||
"BTN_PADDLE2" = "PUNKTFUNK_BTN_PADDLE2"
|
||||
"BTN_PADDLE3" = "PUNKTFUNK_BTN_PADDLE3"
|
||||
"BTN_PADDLE4" = "PUNKTFUNK_BTN_PADDLE4"
|
||||
"CHROMA_IDC_420" = "PUNKTFUNK_CHROMA_IDC_420"
|
||||
"CHROMA_IDC_444" = "PUNKTFUNK_CHROMA_IDC_444"
|
||||
"CIPHER_AES_128_GCM" = "PUNKTFUNK_CIPHER_AES_128_GCM"
|
||||
"CIPHER_CHACHA20_POLY1305" = "PUNKTFUNK_CIPHER_CHACHA20_POLY1305"
|
||||
"CLIENT_CAP_AUDIO_RED" = "PUNKTFUNK_CLIENT_CAP_AUDIO_RED"
|
||||
"CLIENT_CAP_CURSOR" = "PUNKTFUNK_CLIENT_CAP_CURSOR"
|
||||
"CLIENT_CAP_PHASE_LOCK" = "PUNKTFUNK_CLIENT_CAP_PHASE_LOCK"
|
||||
"CLIP_CANCELLED_CODE" = "PUNKTFUNK_CLIP_CANCELLED_CODE"
|
||||
"CLIP_CHUNK" = "PUNKTFUNK_CLIP_CHUNK"
|
||||
"CLIP_FETCH_CAP" = "PUNKTFUNK_CLIP_FETCH_CAP"
|
||||
"CLIP_FETCH_DENIED" = "PUNKTFUNK_CLIP_FETCH_DENIED"
|
||||
"CLIP_FETCH_OK" = "PUNKTFUNK_CLIP_FETCH_OK"
|
||||
"CLIP_FETCH_STALE" = "PUNKTFUNK_CLIP_FETCH_STALE"
|
||||
"CLIP_FETCH_UNAVAILABLE" = "PUNKTFUNK_CLIP_FETCH_UNAVAILABLE"
|
||||
"CLIP_FILE_INDEX_NONE" = "PUNKTFUNK_CLIP_FILE_INDEX_NONE"
|
||||
"CLIP_FLAG_FILES" = "PUNKTFUNK_CLIP_FLAG_FILES"
|
||||
"CLIP_MAX_KINDS" = "PUNKTFUNK_CLIP_MAX_KINDS"
|
||||
"CLIP_MAX_MIME" = "PUNKTFUNK_CLIP_MAX_MIME"
|
||||
"CLIP_POLICY_FILES" = "PUNKTFUNK_CLIP_POLICY_FILES"
|
||||
"CLIP_POLICY_TEXT" = "PUNKTFUNK_CLIP_POLICY_TEXT"
|
||||
"CLIP_REASON_BACKEND_UNAVAILABLE" = "PUNKTFUNK_CLIP_REASON_BACKEND_UNAVAILABLE"
|
||||
"CLIP_REASON_NO_FILES" = "PUNKTFUNK_CLIP_REASON_NO_FILES"
|
||||
"CLIP_REASON_OK" = "PUNKTFUNK_CLIP_REASON_OK"
|
||||
"CLIP_REASON_POLICY_DISABLED" = "PUNKTFUNK_CLIP_REASON_POLICY_DISABLED"
|
||||
"CLIP_REASON_TAKEN_OVER" = "PUNKTFUNK_CLIP_REASON_TAKEN_OVER"
|
||||
"CLIP_STREAM_KIND_FETCH" = "PUNKTFUNK_CLIP_STREAM_KIND_FETCH"
|
||||
"ClockResync_ROUNDS" = "PUNKTFUNK_ClockResync_ROUNDS"
|
||||
"CODEC_AV1" = "PUNKTFUNK_CODEC_AV1"
|
||||
"CODEC_H264" = "PUNKTFUNK_CODEC_H264"
|
||||
"CODEC_HEVC" = "PUNKTFUNK_CODEC_HEVC"
|
||||
"CODEC_PYROWAVE" = "PUNKTFUNK_CODEC_PYROWAVE"
|
||||
"ColorInfo_CP_BT2020" = "PUNKTFUNK_ColorInfo_CP_BT2020"
|
||||
"ColorInfo_CP_BT709" = "PUNKTFUNK_ColorInfo_CP_BT709"
|
||||
"ColorInfo_MC_BT2020_NCL" = "PUNKTFUNK_ColorInfo_MC_BT2020_NCL"
|
||||
"ColorInfo_MC_BT709" = "PUNKTFUNK_ColorInfo_MC_BT709"
|
||||
"ColorInfo_TRC_BT709" = "PUNKTFUNK_ColorInfo_TRC_BT709"
|
||||
"ColorInfo_TRC_HLG" = "PUNKTFUNK_ColorInfo_TRC_HLG"
|
||||
"ColorInfo_TRC_PQ" = "PUNKTFUNK_ColorInfo_TRC_PQ"
|
||||
"CURSOR_RELATIVE_HINT" = "PUNKTFUNK_CURSOR_RELATIVE_HINT"
|
||||
"CURSOR_SHAPE_MAX_SIDE" = "PUNKTFUNK_CURSOR_SHAPE_MAX_SIDE"
|
||||
"CURSOR_STATE_MAGIC" = "PUNKTFUNK_CURSOR_STATE_MAGIC"
|
||||
"CURSOR_VISIBLE" = "PUNKTFUNK_CURSOR_VISIBLE"
|
||||
"FLAG_EOF" = "PUNKTFUNK_FLAG_EOF"
|
||||
"FLAG_PIC" = "PUNKTFUNK_FLAG_PIC"
|
||||
"FLAG_PROBE" = "PUNKTFUNK_FLAG_PROBE"
|
||||
"FLAG_SOF" = "PUNKTFUNK_FLAG_SOF"
|
||||
"HDR_META_BODY_LEN" = "PUNKTFUNK_HDR_META_BODY_LEN"
|
||||
"HDR_META_MAGIC" = "PUNKTFUNK_HDR_META_MAGIC"
|
||||
"HELLO_LAUNCH_MAX" = "PUNKTFUNK_HELLO_LAUNCH_MAX"
|
||||
"HELLO_NAME_MAX" = "PUNKTFUNK_HELLO_NAME_MAX"
|
||||
"HID_RAW_FEATURE" = "PUNKTFUNK_HID_RAW_FEATURE"
|
||||
"HID_RAW_OUTPUT" = "PUNKTFUNK_HID_RAW_OUTPUT"
|
||||
"HID_REPORT_MAX" = "PUNKTFUNK_HID_REPORT_MAX"
|
||||
"HIDOUT_MAGIC" = "PUNKTFUNK_HIDOUT_MAGIC"
|
||||
"HOST_CAP_AUDIO_RED" = "PUNKTFUNK_HOST_CAP_AUDIO_RED"
|
||||
"HOST_CAP_CLIPBOARD" = "PUNKTFUNK_HOST_CAP_CLIPBOARD"
|
||||
"HOST_CAP_CURSOR" = "PUNKTFUNK_HOST_CAP_CURSOR"
|
||||
"HOST_CAP_GAMEPAD_STATE" = "PUNKTFUNK_HOST_CAP_GAMEPAD_STATE"
|
||||
"HOST_CAP_PEN" = "PUNKTFUNK_HOST_CAP_PEN"
|
||||
"HOST_CAP_TEXT_INPUT" = "PUNKTFUNK_HOST_CAP_TEXT_INPUT"
|
||||
"HOST_TIMING_MAGIC" = "PUNKTFUNK_HOST_TIMING_MAGIC"
|
||||
"INBOUND_REQ_FLAG" = "PUNKTFUNK_INBOUND_REQ_FLAG"
|
||||
"INPUT_MAGIC" = "PUNKTFUNK_INPUT_MAGIC"
|
||||
"INPUT_WIRE_LEN" = "PUNKTFUNK_INPUT_WIRE_LEN"
|
||||
"LEGACY_STALE_MS" = "PUNKTFUNK_LEGACY_STALE_MS"
|
||||
"MAX_DATAGRAM_BYTES" = "PUNKTFUNK_MAX_DATAGRAM_BYTES"
|
||||
"MAX_PADS" = "PUNKTFUNK_MAX_PADS"
|
||||
"MAX_SCALE" = "PUNKTFUNK_MAX_SCALE"
|
||||
"MIC_MAGIC" = "PUNKTFUNK_MIC_MAGIC"
|
||||
"MIN_SCALE" = "PUNKTFUNK_MIN_SCALE"
|
||||
"MIN_SHARD_PAYLOAD" = "PUNKTFUNK_MIN_SHARD_PAYLOAD"
|
||||
"MIN_STREAM_BLOCK_SHARDS" = "PUNKTFUNK_MIN_STREAM_BLOCK_SHARDS"
|
||||
"MSG_BITRATE_CHANGED" = "PUNKTFUNK_MSG_BITRATE_CHANGED"
|
||||
"MSG_CLIP_CONTROL" = "PUNKTFUNK_MSG_CLIP_CONTROL"
|
||||
"MSG_CLIP_FETCH" = "PUNKTFUNK_MSG_CLIP_FETCH"
|
||||
"MSG_CLIP_FETCH_HDR" = "PUNKTFUNK_MSG_CLIP_FETCH_HDR"
|
||||
"MSG_CLIP_OFFER" = "PUNKTFUNK_MSG_CLIP_OFFER"
|
||||
"MSG_CLIP_STATE" = "PUNKTFUNK_MSG_CLIP_STATE"
|
||||
"MSG_CLOCK_ECHO" = "PUNKTFUNK_MSG_CLOCK_ECHO"
|
||||
"MSG_CLOCK_PROBE" = "PUNKTFUNK_MSG_CLOCK_PROBE"
|
||||
"MSG_CURSOR_RENDER" = "PUNKTFUNK_MSG_CURSOR_RENDER"
|
||||
"MSG_CURSOR_SHAPE" = "PUNKTFUNK_MSG_CURSOR_SHAPE"
|
||||
"MSG_LOSS_REPORT" = "PUNKTFUNK_MSG_LOSS_REPORT"
|
||||
"MSG_PAIR_CHALLENGE" = "PUNKTFUNK_MSG_PAIR_CHALLENGE"
|
||||
"MSG_PAIR_PROOF" = "PUNKTFUNK_MSG_PAIR_PROOF"
|
||||
"MSG_PAIR_REQUEST" = "PUNKTFUNK_MSG_PAIR_REQUEST"
|
||||
"MSG_PAIR_RESULT" = "PUNKTFUNK_MSG_PAIR_RESULT"
|
||||
"MSG_PHASE_REPORT" = "PUNKTFUNK_MSG_PHASE_REPORT"
|
||||
"MSG_PROBE_REQUEST" = "PUNKTFUNK_MSG_PROBE_REQUEST"
|
||||
"MSG_PROBE_RESULT" = "PUNKTFUNK_MSG_PROBE_RESULT"
|
||||
"MSG_RECONFIGURE" = "PUNKTFUNK_MSG_RECONFIGURE"
|
||||
"MSG_RECONFIGURED" = "PUNKTFUNK_MSG_RECONFIGURED"
|
||||
"MSG_REQUEST_KEYFRAME" = "PUNKTFUNK_MSG_REQUEST_KEYFRAME"
|
||||
"MSG_RFI_REQUEST" = "PUNKTFUNK_MSG_RFI_REQUEST"
|
||||
"MSG_SET_BITRATE" = "PUNKTFUNK_MSG_SET_BITRATE"
|
||||
"MSG_SHARD_PAYLOAD_ACK" = "PUNKTFUNK_MSG_SHARD_PAYLOAD_ACK"
|
||||
"MSG_SHARD_PAYLOAD_CHANGED" = "PUNKTFUNK_MSG_SHARD_PAYLOAD_CHANGED"
|
||||
"NO_OUTPUT_KEYFRAME_STREAK" = "PUNKTFUNK_NO_OUTPUT_KEYFRAME_STREAK"
|
||||
"PAIR_APPROVAL_TIMEOUT_CLOSE_CODE" = "PUNKTFUNK_PAIR_APPROVAL_TIMEOUT_CLOSE_CODE"
|
||||
"PAIR_BOUND_OTHER_CLOSE_CODE" = "PUNKTFUNK_PAIR_BOUND_OTHER_CLOSE_CODE"
|
||||
"PAIR_DENIED_CLOSE_CODE" = "PUNKTFUNK_PAIR_DENIED_CLOSE_CODE"
|
||||
"PAIR_NO_IDENTITY_CLOSE_CODE" = "PUNKTFUNK_PAIR_NO_IDENTITY_CLOSE_CODE"
|
||||
"PAIR_NOT_ARMED_CLOSE_CODE" = "PUNKTFUNK_PAIR_NOT_ARMED_CLOSE_CODE"
|
||||
"PAIR_RATE_LIMITED_CLOSE_CODE" = "PUNKTFUNK_PAIR_RATE_LIMITED_CLOSE_CODE"
|
||||
"PAIR_SUPERSEDED_CLOSE_CODE" = "PUNKTFUNK_PAIR_SUPERSEDED_CLOSE_CODE"
|
||||
"PEN_ANGLE_UNKNOWN" = "PUNKTFUNK_PEN_ANGLE_UNKNOWN"
|
||||
"PEN_BARREL1" = "PUNKTFUNK_PEN_BARREL1"
|
||||
"PEN_BARREL2" = "PUNKTFUNK_PEN_BARREL2"
|
||||
"PEN_BATCH_MAX" = "PUNKTFUNK_PEN_BATCH_MAX"
|
||||
"PEN_DISTANCE_UNKNOWN" = "PUNKTFUNK_PEN_DISTANCE_UNKNOWN"
|
||||
"PEN_IN_RANGE" = "PUNKTFUNK_PEN_IN_RANGE"
|
||||
"PEN_PREDICTED" = "PUNKTFUNK_PEN_PREDICTED"
|
||||
"PEN_SAMPLE_WIRE_LEN" = "PUNKTFUNK_PEN_SAMPLE_WIRE_LEN"
|
||||
"PEN_TILT_UNKNOWN" = "PUNKTFUNK_PEN_TILT_UNKNOWN"
|
||||
"PEN_TOUCH_TIMEOUT_MS" = "PUNKTFUNK_PEN_TOUCH_TIMEOUT_MS"
|
||||
"PEN_TOUCHING" = "PUNKTFUNK_PEN_TOUCHING"
|
||||
"PRESETS" = "PUNKTFUNK_PRESETS"
|
||||
"QUIT_CLOSE_CODE" = "PUNKTFUNK_QUIT_CLOSE_CODE"
|
||||
"REANCHOR_MARKS_TO_LIFT" = "PUNKTFUNK_REANCHOR_MARKS_TO_LIFT"
|
||||
"REJECT_BUSY_CLOSE_CODE" = "PUNKTFUNK_REJECT_BUSY_CLOSE_CODE"
|
||||
"ResyncGuard_MAX_REJECTED_STREAK" = "PUNKTFUNK_ResyncGuard_MAX_REJECTED_STREAK"
|
||||
"RFI_MAX_RANGE" = "PUNKTFUNK_RFI_MAX_RANGE"
|
||||
"RICH_INPUT_MAGIC" = "PUNKTFUNK_RICH_INPUT_MAGIC"
|
||||
"RUMBLE_V1_LEN" = "PUNKTFUNK_RUMBLE_V1_LEN"
|
||||
"RUMBLE_V2_LEN" = "PUNKTFUNK_RUMBLE_V2_LEN"
|
||||
"SETUP_FAILED_CLOSE_CODE" = "PUNKTFUNK_SETUP_FAILED_CLOSE_CODE"
|
||||
"TAG_LEN" = "PUNKTFUNK_TAG_LEN"
|
||||
"TRIGGER_EFFECT_MAX" = "PUNKTFUNK_TRIGGER_EFFECT_MAX"
|
||||
"USER_FLAG_CHUNK_ALIGNED" = "PUNKTFUNK_USER_FLAG_CHUNK_ALIGNED"
|
||||
"USER_FLAG_RECOVERY_ANCHOR" = "PUNKTFUNK_USER_FLAG_RECOVERY_ANCHOR"
|
||||
"USER_FLAG_RECOVERY_POINT" = "PUNKTFUNK_USER_FLAG_RECOVERY_POINT"
|
||||
"USER_FLAG_SLICE_STREAM" = "PUNKTFUNK_USER_FLAG_SLICE_STREAM"
|
||||
"VIDEO_CAP_10BIT" = "PUNKTFUNK_VIDEO_CAP_10BIT"
|
||||
"VIDEO_CAP_444" = "PUNKTFUNK_VIDEO_CAP_444"
|
||||
"VIDEO_CAP_CHACHA20" = "PUNKTFUNK_VIDEO_CAP_CHACHA20"
|
||||
"VIDEO_CAP_HDR" = "PUNKTFUNK_VIDEO_CAP_HDR"
|
||||
"VIDEO_CAP_HOST_TIMING" = "PUNKTFUNK_VIDEO_CAP_HOST_TIMING"
|
||||
"VIDEO_CAP_MULTI_SLICE" = "PUNKTFUNK_VIDEO_CAP_MULTI_SLICE"
|
||||
"VIDEO_CAP_PROBE_SEQ" = "PUNKTFUNK_VIDEO_CAP_PROBE_SEQ"
|
||||
"VIDEO_CAP_STREAMED_AU" = "PUNKTFUNK_VIDEO_CAP_STREAMED_AU"
|
||||
"WIRE_VERSION" = "PUNKTFUNK_WIRE_VERSION"
|
||||
"WIRE_VERSION_CLOSE_CODE" = "PUNKTFUNK_WIRE_VERSION_CLOSE_CODE"
|
||||
|
||||
# QualifiedScreamingSnakeCase already qualifies each variant with the enum name
|
||||
# (PunktfunkStatus::Ok -> PUNKTFUNK_STATUS_OK); do NOT also set prefix_with_name or it doubles.
|
||||
[enum]
|
||||
|
||||
@@ -60,28 +60,22 @@ pub(super) async fn run(
|
||||
}
|
||||
Some(&crate::quic::RUMBLE_MAGIC) => {
|
||||
if let Some(u) = crate::quic::decode_rumble_envelope(&d) {
|
||||
// A pad index the client cannot represent is dropped outright, before either
|
||||
// consumer sees it. It used to be waved through: the seq gate was skipped (its
|
||||
// per-pad cursor has no slot for it) and it was handed to the legacy queue,
|
||||
// while the policy engine silently discarded it on its own bounds check — so
|
||||
// "both consumers are fed" below was false for exactly these, and an embedder
|
||||
// draining the queue could be handed an index it would use to subscript its
|
||||
// own per-pad array. The host never emits one; this is malformed or hostile.
|
||||
let idx = u.pad as usize;
|
||||
if idx >= crate::input::MAX_PADS {
|
||||
continue;
|
||||
}
|
||||
// Gate v2 envelopes on their per-pad seq; forward v1 (envelope: None) as-is.
|
||||
let fresh = match u.envelope {
|
||||
Some(env) => {
|
||||
if crate::input::GamepadSnapshot::seq_newer(
|
||||
env.seq,
|
||||
rumble_last_seq[idx],
|
||||
) {
|
||||
rumble_last_seq[idx] = Some(env.seq);
|
||||
true
|
||||
let idx = u.pad as usize;
|
||||
if idx < crate::input::MAX_PADS {
|
||||
if crate::input::GamepadSnapshot::seq_newer(
|
||||
env.seq,
|
||||
rumble_last_seq[idx],
|
||||
) {
|
||||
rumble_last_seq[idx] = Some(env.seq);
|
||||
true
|
||||
} else {
|
||||
false // reordered/duplicate — drop, keep the newer state
|
||||
}
|
||||
} else {
|
||||
false // reordered/duplicate — drop, keep the newer state
|
||||
true // out-of-range pad (host never sends these): no gate
|
||||
}
|
||||
}
|
||||
None => true,
|
||||
|
||||
@@ -107,10 +107,6 @@ pub use stats::Stats;
|
||||
/// v10: added `punktfunk_connection_clock_offset_now_ns` — the LIVE (mid-stream re-synced)
|
||||
/// clock offset ongoing latency math must use; the connect-time getter stays frozen by
|
||||
/// contract. Additive, client-local — no wire change, so [`WIRE_VERSION`] is unchanged.
|
||||
/// v11: added `punktfunk_connect_ex9` — `connect_ex8` plus a `client_caps` bitfield
|
||||
/// (`PUNKTFUNK_CLIENT_CAP_CURSOR`, later `…_PHASE_LOCK`), which is how a client tells the host it
|
||||
/// renders the pointer itself. Additive; the caps ride the existing Hello, so [`WIRE_VERSION`] is
|
||||
/// unchanged. (Documented late — the bump shipped without its line here.)
|
||||
/// v12: added `punktfunk_connection_set_cursor_render` — the mid-stream cursor-render flip
|
||||
/// (design/remote-desktop-sweep.md §8): the client's mouse-model chord tells the host who
|
||||
/// renders the pointer. Additive; rides the existing control stream (a new message TYPE, which
|
||||
@@ -124,15 +120,7 @@ pub use stats::Stats;
|
||||
/// uncertainty and the circular arrival-lead statistic the host's controller steers on. Additive;
|
||||
/// the wire grows only a new control message (`PhaseReport`, 0x32) an old host never reads and a
|
||||
/// strict-prefix append on the 0xCF host-timing tail, so [`WIRE_VERSION`] is unchanged.
|
||||
/// v15: versions the shared rumble policy engine's C surface —
|
||||
/// `punktfunk_connection_next_rumble_cmd`, `punktfunk_connection_set_rumble_quirks` and the
|
||||
/// `PUNKTFUNK_RUMBLE_QUIRK_*` bits. These symbols are NOT new: they landed while this constant
|
||||
/// still read 7 and no bump was made, so every core since has exported them while advertising a
|
||||
/// version that never promised them. That cannot be corrected retroactively — a shipped binary
|
||||
/// says what it says — so v15 is the floor that *guarantees* them: at or above it the surface is
|
||||
/// present, below it an embedder must probe for the symbol. Purely a version statement; no code
|
||||
/// changed with this bump, and no wire change, so [`WIRE_VERSION`] is unchanged.
|
||||
pub const ABI_VERSION: u32 = 15;
|
||||
pub const ABI_VERSION: u32 = 14;
|
||||
|
||||
/// The punktfunk/1 **wire** version — what `Hello`/`Welcome` carry and hosts equality-check.
|
||||
/// Deliberately its own constant: [`ABI_VERSION`] tracks the embeddable **C surface**
|
||||
|
||||
@@ -401,16 +401,6 @@ impl RichInput {
|
||||
}
|
||||
}
|
||||
|
||||
/// Longest [`HidOutput::Trigger`] `effect` the wire carries: the DualSense adaptive-trigger
|
||||
/// parameter block is a mode byte plus ten parameters, and every consumer copies at most this many
|
||||
/// into its report.
|
||||
///
|
||||
/// The single source for the clamp on BOTH sides. `Trigger` was the only variable-length variant
|
||||
/// bounded on neither: encode appended whatever it was handed and decode took the entire tail, so
|
||||
/// an attacker-sized datagram was reproduced verbatim into a `Vec` while its sibling `HidRaw` had
|
||||
/// been bounded on both ends all along.
|
||||
pub const TRIGGER_EFFECT_MAX: usize = 11;
|
||||
|
||||
const HIDOUT_LED: u8 = 0x01;
|
||||
const HIDOUT_PLAYER_LEDS: u8 = 0x02;
|
||||
const HIDOUT_TRIGGER: u8 = 0x03;
|
||||
@@ -470,7 +460,7 @@ impl HidOutput {
|
||||
}
|
||||
HidOutput::Trigger { pad, which, effect } => {
|
||||
out.extend_from_slice(&[HIDOUT_TRIGGER, *pad, *which]);
|
||||
out.extend_from_slice(&effect[..effect.len().min(TRIGGER_EFFECT_MAX)]);
|
||||
out.extend_from_slice(effect);
|
||||
}
|
||||
HidOutput::TrackpadHaptic {
|
||||
pad,
|
||||
@@ -507,17 +497,10 @@ impl HidOutput {
|
||||
pad: b[2],
|
||||
bits: b[3],
|
||||
}),
|
||||
// `> 4`, not `>= 4`: a body with no effect bytes at all is malformed, and decoding it
|
||||
// as an EMPTY effect was actively harmful — downstream an empty block is written as an
|
||||
// all-zero trigger report, which is mode 0x00, which RELEASES a held effect. A
|
||||
// truncated datagram could therefore silently cancel the trigger a game was holding.
|
||||
// A genuine "no effect" is a full-length zero block and still decodes fine.
|
||||
HIDOUT_TRIGGER if b.len() > 4 => Some(HidOutput::Trigger {
|
||||
HIDOUT_TRIGGER if b.len() >= 4 => Some(HidOutput::Trigger {
|
||||
pad: b[2],
|
||||
which: b[3],
|
||||
// Bounded like `HidRaw` below: at most the parameter block is kept from the
|
||||
// (attacker-sized) tail.
|
||||
effect: b[4..b.len().min(4 + TRIGGER_EFFECT_MAX)].to_vec(),
|
||||
effect: b[4..].to_vec(),
|
||||
}),
|
||||
HIDOUT_TRACKPAD_HAPTIC if b.len() >= 10 => Some(HidOutput::TrackpadHaptic {
|
||||
pad: b[2],
|
||||
@@ -998,82 +981,6 @@ mod tests {
|
||||
assert!(decode_rumble_datagram(&d[..6]).is_none());
|
||||
}
|
||||
|
||||
/// `Trigger` is the only variable-length variant that used to be bounded on NEITHER side.
|
||||
/// Pinned here because both halves matter: an over-long effect must be clamped on the way out
|
||||
/// AND on the way in, and a body with no effect bytes must not decode at all.
|
||||
#[test]
|
||||
fn trigger_effect_is_clamped_on_both_encode_and_decode() {
|
||||
// Encode clamps: a caller handing over an over-long block cannot put it on the wire.
|
||||
let long = HidOutput::Trigger {
|
||||
pad: 1,
|
||||
which: 0,
|
||||
effect: vec![0xAB; 200],
|
||||
};
|
||||
let d = long.encode();
|
||||
assert_eq!(
|
||||
d.len(),
|
||||
4 + TRIGGER_EFFECT_MAX,
|
||||
"magic + kind + pad + which + at most the parameter block"
|
||||
);
|
||||
|
||||
// Decode clamps independently of encode — a hostile peer does not use our encoder.
|
||||
let mut hostile = vec![HIDOUT_MAGIC, super::HIDOUT_TRIGGER, 1, 0];
|
||||
hostile.extend_from_slice(&[0xCD; 500]);
|
||||
match HidOutput::decode(&hostile) {
|
||||
Some(HidOutput::Trigger { effect, .. }) => {
|
||||
assert_eq!(effect.len(), TRIGGER_EFFECT_MAX, "tail is bounded");
|
||||
}
|
||||
other => panic!("expected a clamped Trigger, got {other:?}"),
|
||||
}
|
||||
|
||||
// An exact-length effect survives untouched, and round-trips.
|
||||
let ok = HidOutput::Trigger {
|
||||
pad: 2,
|
||||
which: 1,
|
||||
effect: vec![0x02, 0x90, 0xA0, 0xFF, 0, 0, 0, 0, 0, 0, 0],
|
||||
};
|
||||
assert_eq!(HidOutput::decode(&ok.encode()), Some(ok));
|
||||
}
|
||||
|
||||
/// A body with no effect bytes is malformed and must be REJECTED, not read as an empty effect:
|
||||
/// downstream an empty block becomes an all-zero trigger report, which is mode 0x00 — it
|
||||
/// releases whatever effect the game was holding. A truncated datagram must not do that.
|
||||
#[test]
|
||||
fn a_trigger_with_no_effect_bytes_is_rejected_not_read_as_cancel() {
|
||||
let empty = [HIDOUT_MAGIC, super::HIDOUT_TRIGGER, 0, 0];
|
||||
assert_eq!(HidOutput::decode(&empty), None);
|
||||
|
||||
// One byte of effect is a legitimate short block (consumers zero-pad it) and still decodes.
|
||||
let one = [HIDOUT_MAGIC, super::HIDOUT_TRIGGER, 0, 0, 0x02];
|
||||
assert_eq!(
|
||||
HidOutput::decode(&one),
|
||||
Some(HidOutput::Trigger {
|
||||
pad: 0,
|
||||
which: 0,
|
||||
effect: vec![0x02]
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/// `HidRaw`'s bound was already correct on both sides — pinned alongside `Trigger` so the pair
|
||||
/// cannot drift apart again.
|
||||
#[test]
|
||||
fn hid_raw_stays_bounded_on_both_sides() {
|
||||
let long = HidOutput::HidRaw {
|
||||
pad: 0,
|
||||
kind: HID_RAW_OUTPUT,
|
||||
data: vec![0x11; 500],
|
||||
};
|
||||
assert_eq!(long.encode().len(), 4 + HID_REPORT_MAX);
|
||||
|
||||
let mut hostile = vec![HIDOUT_MAGIC, super::HIDOUT_HID_RAW, 0, HID_RAW_FEATURE];
|
||||
hostile.extend_from_slice(&[0x22; 900]);
|
||||
match HidOutput::decode(&hostile) {
|
||||
Some(HidOutput::HidRaw { data, .. }) => assert_eq!(data.len(), HID_REPORT_MAX),
|
||||
other => panic!("expected a clamped HidRaw, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rumble_envelope_roundtrip_and_legacy_tolerance() {
|
||||
// v2 envelope round-trips seq + ttl.
|
||||
|
||||
@@ -15,13 +15,14 @@ The Linux, Windows, Mac, iPhone/iPad and Android apps group settings the same wa
|
||||
**Display**, **Input**, **Audio**, **Controllers** — under *Preferences* on Linux and *Settings*
|
||||
elsewhere. The Apple TV app shows one scrolling list instead, and so does any client's settings
|
||||
screen reached with a controller. A controller-driven launch (Steam Deck Gaming Mode) opens the
|
||||
client's **console home**, whose settings screen is one steppable list; the Decky plugin's Settings
|
||||
tab covers the same store in the same groups and the same order, as a left rail of categories the
|
||||
way SteamOS's own Settings looks. The console home is part of the
|
||||
client — it is not the host's
|
||||
[web console](/docs/web-console).
|
||||
client's **console home**, whose settings screen is one steppable list of sections — **Stream**,
|
||||
**Video**, **Presentation**, **Audio**, **Controller**, **Touchscreen**, **Interface**,
|
||||
**Profiles**. On a Steam Deck that list *is* the settings surface: the
|
||||
[Decky plugin](/docs/steam-deck) is a launcher and keeps no settings of its own, and its **Open
|
||||
Punktfunk** button puts the console home one tap from the Quick Access Menu. The console home is
|
||||
part of the client — it is not the host's [web console](/docs/web-console).
|
||||
|
||||
Linux stores them in `~/.config/punktfunk/client-gtk-settings.json`, the same file the Decky plugin
|
||||
Linux stores them in `~/.config/punktfunk/client-gtk-settings.json`, the same file the console home
|
||||
writes, so a change in either shows up in the other. Windows uses
|
||||
`%APPDATA%\punktfunk\client-windows-settings.json`; the Apple and Android apps use their own stores.
|
||||
|
||||
@@ -45,9 +46,9 @@ and your client scales what it gets — see
|
||||
|
||||
**Match window** — *default: off.* The stream mode follows your window instead, and each resize
|
||||
renegotiates the host's display and encoder, so a windowed session stays pixel-exact. Fullscreen
|
||||
degenerates to the display's native mode. Offered by the Linux, Windows, Mac, iPhone/iPad, console
|
||||
home and Decky screens (on Decky it sits in the Resolution picker, and Gaming-Mode streams are
|
||||
always fullscreen, so it lands on native); not by Android.
|
||||
degenerates to the display's native mode. Offered by the Linux, Windows, Mac, iPhone/iPad and
|
||||
console-home screens (in the console home it is an option inside the Resolution picker, and a
|
||||
Gaming-Mode stream is always fullscreen, so there it lands on native); not by Android.
|
||||
|
||||
**Refresh rate** — *default: Native*, the refresh of the display your window is on. The Apple app
|
||||
stores an explicit rate (60 Hz by default): iPhone and iPad offer the rates the device can display,
|
||||
@@ -68,11 +69,11 @@ capacity probe stay off for the whole session.
|
||||
multiplied by this, and your device resamples the result to its window. Above 1× supersamples for
|
||||
sharpness, at more bandwidth *and* more decode work; below 1× is lighter on both the host and the
|
||||
link. The stops run 0.5× to 4×. The result is floored to an even size and capped per axis at
|
||||
4096 px for H.264, 8192 px otherwise. Offered everywhere except the console home's list.
|
||||
4096 px for H.264, 8192 px otherwise. Offered everywhere.
|
||||
|
||||
**Video codec** — *default: Automatic.* A soft preference: the host emits your choice when it can
|
||||
also produce it, otherwise the best codec you both speak, in the order HEVC → AV1 → H.264.
|
||||
**PyroWave** is never auto-picked — pick it explicitly on Linux, Windows, the console home, Decky, or
|
||||
**PyroWave** is never auto-picked — pick it explicitly on Linux, Windows, the console home, or
|
||||
an Apple device whose decode probe passes; anywhere else it isn't offered, and asking for it lands on
|
||||
that same order. See [PyroWave](/docs/pyrowave). The Android and Apple apps hide AV1 unless the
|
||||
device has a hardware AV1 decoder; Android never offers PyroWave.
|
||||
@@ -86,13 +87,13 @@ Full detail: [HDR](/docs/hdr).
|
||||
needs HEVC or PyroWave, the host's own 4:4:4 policy left on, a capture path that delivers full
|
||||
chroma, and a GPU that can encode it; if any gate fails the host says 4:2:0 before your decoder is
|
||||
built. The Apple, Linux and Windows apps all advertise it (Apple additionally requires its hardware
|
||||
decode probe to pass). The console home and Decky offer the toggle; Android doesn't.
|
||||
decode probe to pass). The console home offers the toggle; Android doesn't.
|
||||
|
||||
**Prioritize** — *default: Lowest latency.* What the client optimizes for when a decoded frame is
|
||||
ready. **Lowest latency** shows every frame the moment the display can take it, so a network hiccup
|
||||
becomes an occasional repeated or skipped frame. **Smoothness** holds a small buffer that evens
|
||||
those hiccups out, at that buffer's worth of added delay. Linux and Windows apps, the console home
|
||||
and Decky; the Apple and Android apps have carried the same setting for a while, and it is stored
|
||||
those hiccups out, at that buffer's worth of added delay. Linux and Windows apps and the console
|
||||
home; the Apple and Android apps have carried the same setting for a while, and it is stored
|
||||
under the same name, so a [profile](/docs/profiles-and-links) means the same thing on every device.
|
||||
|
||||
**Smoothness buffer** — *default: Automatic (two frames).* Only shown under **Smoothness**. How
|
||||
@@ -106,7 +107,7 @@ the instant it's ready instead of waiting for the screen's next refresh: the low
|
||||
can give you, at the cost of visible tearing on fast motion. It is **best-effort** — not every
|
||||
driver or compositor offers a tearing mode, and where none is available the stream stays tear-free.
|
||||
The Detailed [stats overlay](/docs/stats) names the mode actually in use, so you can tell "off"
|
||||
from "off but unavailable". Linux and Windows apps, the console home and Decky.
|
||||
from "off but unavailable". Linux and Windows apps and the console home.
|
||||
|
||||
**Follow variable refresh rate** — *default: on.* On a VRR / FreeSync / G-Sync screen, let the panel
|
||||
refresh in step with the stream rather than on a fixed cadence — which removes the wait between a
|
||||
@@ -115,8 +116,8 @@ windowed one is at the compositor's mercy) and is harmless on a fixed-refresh sc
|
||||
graphics driver that offers the modern queue-free display mode; on an older driver it does nothing
|
||||
unless you also set `PUNKTFUNK_VRR_FIFO=1` (see [configuration](/docs/configuration)), because the
|
||||
older way of following a panel costs noticeable latency on a fixed-refresh screen. The stats overlay
|
||||
reports `vrr yes` once it has *measured* that the panel really is following. Linux and Windows apps,
|
||||
the console home and Decky.
|
||||
reports `vrr yes` once it has *measured* that the panel really is following. Linux and Windows apps
|
||||
and the console home.
|
||||
|
||||
**Host compositor** — *default: Automatic.* Which backend a **Linux** host uses to drive the virtual
|
||||
output. Advisory: a host without that backend quietly auto-detects instead.
|
||||
@@ -130,7 +131,7 @@ claims a sink advertising exactly that many channels, so applications produce re
|
||||
**Windows** host loopback-captures your current output endpoint and lets Windows convert it — so 5.1
|
||||
from a stereo endpoint is an upmix, not new channels. Offered everywhere.
|
||||
|
||||
**Microphone** — *default: off on Linux, Windows, Android, the console home and Decky; on in the
|
||||
**Microphone** — *default: off on Linux, Windows, Android and the console home; on in the
|
||||
Apple app.* Sends this device's microphone to the host's virtual mic. On Linux and Windows the
|
||||
row is spelled *Stream microphone*, and **Ctrl+Alt+Shift+V** mutes it mid-stream without ending
|
||||
anything — see [Muting your microphone](/docs/input#muting-your-microphone).
|
||||
@@ -142,18 +143,18 @@ from an echo-cancelled PipeWire source when your desktop provides one, on **Wind
|
||||
for the Communications stream category so the endpoint's processing engages, and on **Apple** and
|
||||
**Android** the platform's voice-processing mode. Turn it off if your microphone already runs its
|
||||
own processing, or if the canceller makes your voice sound thin. The row sits under the microphone
|
||||
toggle and greys out while the microphone is off. Offered by the Linux, Windows, Apple, Android,
|
||||
console-home and Decky clients. What it can and can't fix is in
|
||||
toggle and greys out while the microphone is off. Offered by the Linux, Windows, Apple, Android and
|
||||
console-home clients. What it can and can't fix is in
|
||||
[Why do I hear myself](/docs/echo).
|
||||
|
||||
**Speaker** and **Microphone** device pickers — *default: System default.* Which endpoint stream
|
||||
audio plays out of, and which input feeds the uplink. Only the Linux app (PipeWire nodes), the
|
||||
**Mac** app (which also has a microphone *channel* picker) and **Decky** have these — iPhone, iPad,
|
||||
Apple TV, Android and the console home have none, and the Windows app has none and ignores a stored
|
||||
speaker choice. On Linux, a device that has since disappeared keeps a "(not detected)" entry rather
|
||||
than silently snapping back to the default; the Mac shows it as "Unavailable device" and Decky as
|
||||
"(not connected)". Decky reads the endpoint list from the client's session binary, so a client
|
||||
older than the two-binary split leaves these pickers on Automatic.
|
||||
audio plays out of, and which input feeds the uplink. Only the Linux app (PipeWire nodes) and the
|
||||
**Mac** app (which also has a microphone *channel* picker) have these — iPhone, iPad, Apple TV,
|
||||
Android and the console home have none, and the Windows app has none and ignores a stored speaker
|
||||
choice. On Linux, a device that has since disappeared keeps a "(not detected)" entry rather than
|
||||
silently snapping back to the default; the Mac shows it as "Unavailable device". A Steam Deck in
|
||||
Gaming Mode therefore has no endpoint picker at all: the session uses whatever the Desktop-Mode app
|
||||
last stored, and the system default otherwise.
|
||||
|
||||
## Input
|
||||
|
||||
@@ -182,7 +183,7 @@ client greys them out to say so.
|
||||
|
||||
**Gamepad type** (*Controller type* on Apple, Android and the console home) — *default: Automatic*,
|
||||
which matches each physical controller. The pickers offer Xbox 360, Xbox One, DualSense and
|
||||
DualShock 4 everywhere, plus Steam Deck on Linux, Android, the console home and Decky. Your client
|
||||
DualShock 4 everywhere, plus Steam Deck on Linux, Android and the console home. Your client
|
||||
declares a type per pad as it connects — Automatic declares what that controller really is, an
|
||||
explicit choice declares your choice — and the host builds each virtual pad from that. A type the
|
||||
host has no backend for degrades to an Xbox 360 pad rather than failing: Xbox One on a Windows host,
|
||||
@@ -193,8 +194,8 @@ which forwards *every* connected controller, each as its own player, on Linux, W
|
||||
console home. Pinning one restricts the session to that controller alone — single-player. The Android
|
||||
app has no such picker.
|
||||
|
||||
**Capture system shortcuts** — *default: on.* Offered by the Linux and Windows apps, the console home
|
||||
and Decky; Windows spells the row out as *Capture system shortcuts (Alt+Tab, Win, …)*. On a Deck it
|
||||
**Capture system shortcuts** — *default: on.* Offered by the Linux and Windows apps and the console
|
||||
home; Windows spells the row out as *Capture system shortcuts (Alt+Tab, Win, …)*. On a Deck it
|
||||
matters only for a keyboard you attached yourself, for the reason the paragraph below gives: Gaming
|
||||
Mode is gamescope, which has nothing to hold back. On, Alt+Tab and the Windows key
|
||||
(Super on Linux) reach the host while the stream has input captured. Off, they act on this machine
|
||||
@@ -215,21 +216,22 @@ the wlroots compositors all do, and X11 sessions grab the keyboard directly. Und
|
||||
Wake-on-LAN and waits for it to boot — only for a host whose MAC address this client has already
|
||||
learned. Turn it off for hosts you reach over a VPN, where "offline" usually means "not reachable by
|
||||
broadcast" and the wake only adds a delay. The Linux, Windows, Apple and Android apps have this
|
||||
toggle, as do the console home and Decky — and note that the Decky plugin sends a wake of its own
|
||||
before a stream starts whatever this setting says, so on a Deck it governs the client's connect
|
||||
rather than the launch. The console home also offers wake as an explicit action on an offline host.
|
||||
See
|
||||
toggle, as does the console home — and on a Steam Deck it governs the
|
||||
[Decky plugin's](/docs/steam-deck) launches too, because the plugin starts every stream through the
|
||||
client, which reads this setting like any other connect. The console home also offers wake as an
|
||||
explicit action on an offline host, whatever the toggle says. See
|
||||
[Wake-on-LAN](/docs/wake-on-lan).
|
||||
|
||||
**Show game library** — *default: off on Linux and Windows; on in the Apple and Android apps.* Browse
|
||||
a paired host's games and launch one directly; the Windows app still labels it experimental. The
|
||||
console home and Decky have the toggle too — on Decky it governs the *client's* screens, since the
|
||||
plugin's own library browser works either way. See [Game library](/docs/game-library).
|
||||
console home has the toggle too, and it governs the desktop clients that share the store — the
|
||||
console's own **Library** button is offered on any paired host either way. See
|
||||
[Game library](/docs/game-library).
|
||||
|
||||
**Start streams in fullscreen** — *default: on.* On Linux and Windows, F11 or Alt+Enter leaves
|
||||
fullscreen live. On a Mac the setting is **Fullscreen while streaming**, and the window comes back
|
||||
when you return to the host list. The console home and Decky carry the row for the desktop client
|
||||
that shares the store — a Gaming-Mode launch is fullscreen whatever it says. iPhone, iPad, Apple TV
|
||||
when you return to the host list. The console home carries the row for the desktop client that
|
||||
shares the store — a Gaming-Mode launch is fullscreen whatever it says. iPhone, iPad, Apple TV
|
||||
and Android have no equivalent.
|
||||
|
||||
## Overlay
|
||||
@@ -237,9 +239,9 @@ and Android have no equivalent.
|
||||
**Statistics overlay** — *default: Normal.* Four tiers — Off, Compact, Normal, Detailed — each a
|
||||
superset of the one before. This setting only picks the tier a session *starts* at — you can cycle
|
||||
them live in-stream, with a shortcut that differs by platform. The Apple app additionally lets you
|
||||
choose which corner the overlay sits in (Top Left, Top Right, Bottom Left, Bottom Right). The Decky
|
||||
plugin has the tier picker too, in its Settings section. The shortcuts, and every number in the
|
||||
overlay, are in
|
||||
choose which corner the overlay sits in (Top Left, Top Right, Bottom Left, Bottom Right). The
|
||||
console home has the tier picker too, as **Statistics overlay** under **Interface**. The shortcuts,
|
||||
and every number in the overlay, are in
|
||||
[Understanding the stats overlay](/docs/stats).
|
||||
|
||||
## Settings that are facts about your device
|
||||
@@ -251,8 +253,8 @@ stay global and **cannot be put in a settings profile**:
|
||||
vendor-ordered and falls back on its own; change it only when debugging, and note that
|
||||
`PUNKTFUNK_DECODER` overrides it
|
||||
([Configuration](/docs/configuration#client-side-native-clients)). The decoder picker is on Linux,
|
||||
Windows, in the console home and in Decky; the GPU picker on Windows, and on Linux and Decky only
|
||||
when the machine has more than one adapter — which a Deck doesn't, so the row isn't there. The
|
||||
Windows and in the console home; the GPU picker on Windows, and on Linux only when the machine has
|
||||
more than one adapter — the console home has none, and a Deck has a single adapter anyway. The
|
||||
Apple and Android apps have neither.
|
||||
- **Speaker** and **Microphone** device pickers — this device's audio endpoints.
|
||||
- **Forwarded controller** — which physical pad is in your hands. The *type* the host creates is a
|
||||
|
||||
@@ -77,7 +77,8 @@ The setting is read when a session starts, so if you change it while streaming,
|
||||
macOS can also flip it mid-session: **Stream ▸ Share Clipboard** (⌃⌥⇧C), which becomes **Stop
|
||||
Sharing Clipboard** once the host has acknowledged it.
|
||||
|
||||
iOS, iPadOS, tvOS and the Steam Deck Decky plugin have no clipboard switch — see
|
||||
iOS, iPadOS, tvOS and a Steam Deck in Gaming Mode have no clipboard switch — neither the Decky
|
||||
panel nor the client's console home has a host edit sheet — see
|
||||
[what each client does](#which-hosts-and-clients-support-it) below.
|
||||
|
||||
## Nothing crosses until something pastes
|
||||
@@ -134,8 +135,9 @@ when a host application pastes.
|
||||
|
||||
The **Linux client has the switch but no working clipboard bridge**: it enables the plane and then
|
||||
has no code to read or write the desktop's own clipboard, so nothing is announced and nothing is
|
||||
pasted. Turning it on there is harmless but has no effect today. The Decky plugin on the Steam Deck
|
||||
has no switch at all.
|
||||
pasted. Turning it on there is harmless but has no effect today. On a Steam Deck in Gaming Mode
|
||||
there is no switch at all — the Decky panel doesn't edit hosts — and since a Deck streams with that
|
||||
same Linux client, a switch there would have nothing to move anyway.
|
||||
|
||||
When you copy **on the Windows client**, images cross only if the copying application publishes the
|
||||
registered `PNG` clipboard format. Many Windows apps publish only a bitmap, and those copies aren't
|
||||
|
||||
@@ -132,11 +132,10 @@ and runs what it already knows about the title, so a client can never hand the h
|
||||
- **Android** — the library lives only in the controller-optimized home, which a TV always uses and a
|
||||
phone or tablet switches to when a controller is connected. Press **Y** on a saved host, or open its
|
||||
options and choose **Library**.
|
||||
- **Steam Deck (Decky)** — the plugin's per-host **Games** picker lists the library and lets you
|
||||
**Pin** titles; a pinned game becomes a one-tap row under **Pinned Games** in the Quick Access Menu.
|
||||
The picker itself doesn't launch anything — either tap a pinned row, or use **Open library on
|
||||
screen** to browse the host's games full-screen on the Deck and launch from there. See
|
||||
[Steam Deck](/docs/steam-deck).
|
||||
- **Steam Deck (Decky)** — the panel is a launcher and browses nothing itself: tap **Open
|
||||
Punktfunk**, which opens the client's console home, and a paired host's **Library** button is
|
||||
right there — full-screen covers, gamepad-navigable, and a press starts the stream with the title
|
||||
launching. See [Steam Deck](/docs/steam-deck).
|
||||
- **Moonlight** — when the host runs with `--gamestream`, your library appears in Moonlight's app
|
||||
list beside `Desktop`, with covers served by the host. A title keeps the same app id across host
|
||||
restarts, so Moonlight's cached tiles stay correct. Titles with no launch recipe are left out.
|
||||
|
||||
@@ -49,8 +49,9 @@ your settings. If the stream isn't sending a microphone at all (**Stream microph
|
||||
[client settings](/docs/client-settings#audio)) the shortcut does nothing and no badge appears,
|
||||
rather than pretending to mute something.
|
||||
|
||||
This is on the **Linux and Windows** clients. The Apple, Android and Decky clients have no mute
|
||||
shortcut yet; turn **Stream microphone** off in their settings instead.
|
||||
This is on the **Linux and Windows** clients — including a Steam Deck stream, which is the Linux
|
||||
client, so an attached keyboard gets the chord. The Apple and Android clients have no mute shortcut
|
||||
yet; turn **Stream microphone** off in their settings instead.
|
||||
|
||||
Alt-Tabbing away releases input on its own and takes it back when you return. A release you asked
|
||||
for with the chord stays released until you opt back in. Either way, keys and buttons you were
|
||||
|
||||
@@ -79,9 +79,11 @@ list: [Clients → the `punktfunk` CLI](/docs/clients#scripting-the-punktfunk-cl
|
||||
## Steam Deck
|
||||
|
||||
Most Deck users want **Gaming Mode**: install the **[Decky plugin](/docs/steam-deck)** and a
|
||||
**Punktfunk** panel lands in the Quick Access Menu, so you can discover hosts, pair with a PIN, and
|
||||
stream **without dropping to the desktop**. Follow the **[Steam Deck (Decky) guide](/docs/steam-deck)**
|
||||
— it walks through Decky Loader, the plugin, and the one-time client install.
|
||||
**Punktfunk** panel lands in the Quick Access Menu, so you can find a host, get let in (a PIN, or a
|
||||
request the host's operator approves), and stream **without dropping to the desktop**. Everything
|
||||
else — settings, the game library, adding a host by address — is one tap away in the client's own
|
||||
gamepad UI. Follow the **[Steam Deck (Decky) guide](/docs/steam-deck)** — it walks through Decky
|
||||
Loader, the plugin, and the one-time client install.
|
||||
|
||||
> The plugin doesn't decode video itself — it drives whichever `punktfunk-client` is installed on
|
||||
> the Deck. The Flatpak below is the tested default; a native package or a sysext works too. If your
|
||||
|
||||
@@ -61,8 +61,8 @@ Then, on the client:
|
||||
- **[Native clients](/docs/clients) (Apple, Linux, Windows, Android):** select the host (or use
|
||||
*Pair with PIN…* from its menu) and enter the PIN the host displays.
|
||||
- **[Steam Deck](/docs/steam-deck) (the Decky plugin):** open Punktfunk from the Quick Access menu
|
||||
and pick the host — an unpaired one's button reads **Pair & Stream**. Enter the PIN on the
|
||||
4-digit pad it opens.
|
||||
and pick the host — an unpaired one opens a sheet offering **Request access** (no PIN: somebody
|
||||
approves the Deck at the host) or **Use a PIN instead**, which opens the 4-digit pad.
|
||||
- **[Moonlight](/docs/moonlight):** choose **Pair**; Moonlight shows a 4-digit PIN, and you type
|
||||
that PIN into the console's **Moonlight (GameStream) pairing** card and press **Submit PIN**.
|
||||
(This direction is the reverse of the native flow, and arming doesn't apply to it.)
|
||||
|
||||
@@ -11,8 +11,9 @@ Both live in the client apps — the Apple app, the Linux GTK client, the Window
|
||||
Android app. Neither exists in the host's [web console](/docs/web-console).
|
||||
|
||||
The controller-driven surfaces are a half-exception: Apple TV, the Android app's console mode and
|
||||
the Steam Deck console the Decky plugin launches all *use* the profile a host is bound to, but none
|
||||
of them can create or edit one. Do that on a desktop or a phone first.
|
||||
the Steam Deck console the Decky plugin launches all *use* the profile a host is bound to and can
|
||||
pin one as its own card, but none of them can create or edit one. Do that on a desktop or a phone
|
||||
first. The Decky panel itself only *shows* those pins, nested under their host as one-tap cards.
|
||||
|
||||
## What a profile is
|
||||
|
||||
|
||||
@@ -7,10 +7,12 @@ The **Decky plugin** adds a **Punktfunk** panel to the Steam Deck's Quick Access
|
||||
button), so you can find a host, pair, and start streaming **without leaving Gaming Mode**. It's the
|
||||
couch-friendly front end for the Steam Deck — built from real Steam UI, gamepad-navigable end to end.
|
||||
|
||||
Under the hood the plugin doesn't decode video itself: it discovers hosts, runs the PIN pairing, and
|
||||
**launches the regular [Linux client](/docs/clients#linux-desktop-client-gtk4)** (usually the
|
||||
`io.unom.Punktfunk` Flatpak) the way gamescope needs so it fullscreens correctly. So the Deck has two
|
||||
ways to stream, and they share one client + one paired identity:
|
||||
The plugin is a **launcher**, not a second client. It doesn't decode video, browse your library or
|
||||
hold settings of its own — it starts the regular
|
||||
[Linux client](/docs/clients#linux-desktop-client-gtk4) (usually the `io.unom.Punktfunk` Flatpak)
|
||||
the way gamescope needs so it fullscreens correctly. Everything the panel doesn't do is one tap
|
||||
away in that client's own gamepad UI. So the Deck has two ways to stream, and they share one
|
||||
client + one paired identity:
|
||||
|
||||
- **Gaming Mode** → the **Decky plugin** (this page).
|
||||
- **Desktop Mode** → run the [Flatpak](/docs/install-client#steam-deck) directly, like any Linux app.
|
||||
@@ -30,11 +32,13 @@ You need three things on the Deck:
|
||||
|
||||
(Full options: [Install a Client → Steam Deck](/docs/install-client#steam-deck).) If you have
|
||||
no Flatpak but a native `punktfunk-client` — a sysext, a distro package, a nix profile, your own
|
||||
build — the plugin launches that instead; with both installed the Flatpak wins, unless
|
||||
`PF_DECKY_CLIENT=native` (or `flatpak`) is set in the plugin backend's environment. But
|
||||
**pairing, Wake-on-LAN and the host game library still go through the Flatpak**, so install it
|
||||
on the Deck even then. Both kinds share `~/.config/punktfunk`, so your identity, known hosts
|
||||
and settings are the same either way.
|
||||
build — the plugin uses that instead; with both installed the Flatpak wins, unless
|
||||
`PF_DECKY_CLIENT=native` (or `flatpak`) is set in the plugin backend's environment. Both kinds
|
||||
share `~/.config/punktfunk`, so your identity, known hosts and settings are the same either way.
|
||||
|
||||
**The client must be v0.22.0 or newer.** The panel drives everything through the client's
|
||||
headless `punktfunk` command, which shipped in that release. An older client says so in the
|
||||
panel, with the update button that fixes it right there.
|
||||
3. **A Punktfunk host** running on your LAN — see [Install the Host](/docs/install). The Deck finds
|
||||
it automatically over mDNS, so nothing to configure here.
|
||||
|
||||
@@ -64,40 +68,68 @@ The **Punktfunk** panel appears in the Quick Access Menu right away — no Deck
|
||||
|
||||
## Use it
|
||||
|
||||
Open the **Punktfunk** panel from the Quick Access Menu, or **Open Punktfunk** for the full-screen
|
||||
page (host list + stream settings).
|
||||
Open the **Punktfunk** panel from the Quick Access Menu. It has one list — the hosts you can
|
||||
stream — plus a door into the client's own gamepad UI for everything else.
|
||||
|
||||
- **Discover** — hosts on your network appear automatically (mDNS). Tap **Refresh** to rescan. A
|
||||
lock icon means the host requires [pairing](/docs/pairing).
|
||||
- **Add a host by hand** — if mDNS can't reach it (another subnet, a VPN), tap **+** on the Hosts
|
||||
tab and enter its address; the port defaults to **9777**. Saved hosts can be renamed, re-pointed
|
||||
at a new address, or forgotten from the same row.
|
||||
- **Sleeping host?** Streaming sends a [Wake-on-LAN](/docs/wake-on-lan) packet first, and when one
|
||||
actually went out the Deck waits far longer than usual for the host to answer, so a stream
|
||||
survives a resume from sleep. Nothing to enable — it's a no-op until the plugin has learned that
|
||||
host's MAC address, and the packet only lands if the host machine is armed to wake in its
|
||||
BIOS and its network card.
|
||||
- **Pair** — for a locked host, [arm pairing on the host](/docs/pairing) (its console or web
|
||||
console shows a 4-digit PIN), then enter that PIN on the Deck's keypad. Pairing persists, so the
|
||||
next connection is silent.
|
||||
- **Stream** — pick a host and the stream launches fullscreen in Gaming Mode. The plugin drives a
|
||||
- **Hosts** — hosts on your network appear automatically (mDNS), alongside the ones you've already
|
||||
saved. A saved host is also probed directly, so a box reached over a VPN or Tailscale shows as
|
||||
online even though it never advertises. Tap **Refresh** to rescan. The list sorts online hosts
|
||||
first, then whichever you streamed most recently. A lock icon means the host still has to let
|
||||
this Deck in.
|
||||
- **Let a host in** — tapping a locked host opens a small sheet with two ways through:
|
||||
- **Request access** — no PIN at all. See [Request access](#request-access) below.
|
||||
- **Use a PIN instead** — [arm pairing on the host](/docs/pairing) (its console or web console
|
||||
shows a 4-digit PIN), then enter it on the Deck's keypad.
|
||||
|
||||
Either way the host is remembered, so the next connection is silent.
|
||||
- **Stream** — tap a host and the stream launches fullscreen in Gaming Mode. The plugin drives a
|
||||
hidden Steam shortcut behind the scenes so gamescope focuses and fullscreens it.
|
||||
- **Library entry** — a visible, branded **Punktfunk** app also appears in your Steam library.
|
||||
Launching it opens the client's console home (host picker, pairing, settings), gamepad-navigable
|
||||
— it does not resume a stream. If it ever disappears, the Quick Access Menu panel has a button to
|
||||
put it back.
|
||||
- **Games** — tap **Games** on a host row to browse that host's [library](/docs/game-library), and
|
||||
**Pin** the ones you play. Pinned games show up on the full page *and* in the Quick Access Menu
|
||||
as one-tap streams that launch straight into the game.
|
||||
- **Settings** — resolution, refresh rate, **render scale**, bitrate, **video codec**, gamepad type,
|
||||
**host compositor**, and mic, written to the client the plugin launches. Leave **Resolution** /
|
||||
**Refresh** on *Native* to get the Deck's own mode, **Render scale** at 1× unless you want to
|
||||
trade bandwidth for sharpness (>1×) or sharpness for bandwidth (<1×), and **Video codec** /
|
||||
**Host compositor** on *Automatic* — that suits almost every host, so change them only when
|
||||
you're troubleshooting. With **Gamepad type** on *Automatic* the Deck's built-in controller is
|
||||
forwarded as a **Steam Deck** pad (paddles, both trackpads, gyro) — that needs Steam Input set to
|
||||
**Off** for Punktfunk (game page → ⚙ → Controller Settings), else Steam keeps those controls and
|
||||
only sticks + buttons reach the host.
|
||||
- **Sleeping host?** Streaming sends a [Wake-on-LAN](/docs/wake-on-lan) packet and waits for the
|
||||
host to actually come back before dialling, so a stream survives a resume from sleep. Nothing to
|
||||
enable — it's a no-op until the client has learned that host's MAC address, and the packet only
|
||||
lands if the host machine is armed to wake in its BIOS and its network card.
|
||||
- **Pinned cards** — a host with pinned [settings profiles](/docs/client-settings) shows them
|
||||
nested underneath it as `▸ <Profile name>`. Tapping one streams that host with that profile
|
||||
applied — your "4K on the TV" and "battery saver" presets, one tap each. Pins are made in the
|
||||
Punktfunk app (or any other client) and shared across all of them; the panel shows them, it
|
||||
doesn't create them.
|
||||
- **Open Punktfunk** — opens the client's console home: the host picker, adding a host by address,
|
||||
pairing, browsing a host's [game library](/docs/game-library), and the **full settings screen**.
|
||||
This is where resolution, bitrate, codec, audio, controllers and the stats overlay live.
|
||||
- **Library entry** — a visible, branded **Punktfunk** app also appears in your Steam library, and
|
||||
launching it opens that same console home — it does not resume a stream. If it ever disappears,
|
||||
the Quick Access Menu panel has a button to put it back.
|
||||
|
||||
> **Where did the plugin's settings tab go?** Into the app, at **Open Punktfunk → Settings** — the
|
||||
> same rows over the same settings, gamepad-navigable, and one tap from the same panel. The plugin
|
||||
> used to carry its own copy of that screen, which meant two places to change one setting and a
|
||||
> copy that fell behind. There is now one.
|
||||
|
||||
With **Controller type** on *Automatic* the Deck's built-in controller is forwarded as a **Steam
|
||||
Deck** pad (paddles, both trackpads, gyro) — that needs Steam Input set to **Off** for Punktfunk
|
||||
(game page → ⚙ → Controller Settings), else Steam keeps those controls and only sticks + buttons
|
||||
reach the host.
|
||||
|
||||
### Request access
|
||||
|
||||
**Request access lets you in without typing a PIN**: instead of the host showing you a code, you
|
||||
ask, and whoever is at the host approves the Deck in its [web console](/docs/web-console) or on
|
||||
screen.
|
||||
|
||||
Tap the host → **Request access**. The Deck says *"Approve this Deck in <host>'s console — the
|
||||
stream starts by itself"*, and the stream opens and waits. The moment somebody approves it, the
|
||||
picture comes up — no going back to the panel, nothing else to tap. If nobody approves within
|
||||
about three minutes, it gives up like any failed connection and you can try again or use a PIN.
|
||||
|
||||
It's the better option when you're not the person sitting at the host, or when reading a PIN off
|
||||
another screen is awkward. Two things to know:
|
||||
|
||||
- The host must be **advertising on your network** for this to be offered. A host you added by
|
||||
address (a VPN box, another subnet) has no advertised identity for the Deck to pin, so the sheet
|
||||
offers the PIN path only and says so. That's a safety rule, not a limitation to work around:
|
||||
pinning the advertised identity is what stops something else answering in the host's place while
|
||||
the Deck waits.
|
||||
- Once approved, the host shows as **paired** and every later stream connects silently.
|
||||
|
||||
> **Steam Input off is a trade-off, not a free win.** The plugin installs a Steam Input layout
|
||||
> called **Punktfunk** and points its shortcuts at it, and that layout's whole job is making the
|
||||
@@ -115,9 +147,9 @@ input, so it is safe to hit by accident.
|
||||
|
||||
The plugin **checks for updates itself** — no Decky store needed. It covers **both** the plugin *and*
|
||||
the streaming client (they version independently), so when either has a newer build the panel shows an
|
||||
**Update** button (in the Quick Access Menu and on the full page). Tap it: the client updates in
|
||||
place, and if the plugin itself changed it downloads, verifies, replaces itself, and reloads — all
|
||||
without leaving Gaming Mode.
|
||||
**Update** button at the top of the panel. Tap it: the client updates in place, and if the plugin
|
||||
itself changed it downloads, verifies, replaces itself, and reloads — all without leaving Gaming
|
||||
Mode.
|
||||
|
||||
One exception: if your client isn't one the plugin can install for you (a sysext, a nix profile, a
|
||||
source build), the panel shows you the update **command** instead of a button — tap-to-install would
|
||||
@@ -139,13 +171,16 @@ The plugin check follows the [channel](/docs/channels) you installed from: a plu
|
||||
|
||||
| Symptom | Fix |
|
||||
|---|---|
|
||||
| The stream never starts, **Pair** reports `flatpak-not-found`, or **Games** says the client isn't installed | Install the client Flatpak in Desktop Mode (see [Before you start](#before-you-start)). |
|
||||
| No hosts listed | Make sure the host is running and on the **same LAN**; the Deck needs `avahi` (shipped on SteamOS). Tap **Refresh**. |
|
||||
| Pairing fails / "not armed" | The PIN is shown only after you **arm pairing on the host**. Arm it, then enter the PIN within the window. |
|
||||
| Stream launches but doesn't focus | Start it from the panel (not by launching the Flatpak by hand) so Steam/gamescope focuses it. |
|
||||
| The stream wedges — black, or won't close | Open the full page → **About** tab → **Force-stop**, then start it again. |
|
||||
| The **Punktfunk** library entry disappeared | Quick Access Menu → **Recreate library shortcut**; it puts the entry back in place. |
|
||||
| You want a clean slate | **About** tab → **Reset Punktfunk** — clears saved hosts, stream settings and pinned games on this Deck, and keeps your paired identity. |
|
||||
| The panel says **"Update the Punktfunk client"** | The installed client predates v0.22.0 and has no `punktfunk` command to drive. Tap the update button in the same panel, or update it in Desktop Mode. |
|
||||
| The stream never starts, or the panel can't reach the client | Install the client Flatpak in Desktop Mode (see [Before you start](#before-you-start)). |
|
||||
| No hosts listed | Make sure the host is running and on the **same LAN**. Tap **Refresh**. For a host mDNS can't reach, add it by address in **Open Punktfunk → Add host**. |
|
||||
| Pairing fails / "not armed" | The PIN is shown only after you **arm pairing on the host**. Arm it, then enter the PIN within the window — or use **Request access** instead, which needs no PIN. |
|
||||
| **Request access** isn't offered | The host isn't advertising on this network, so there's no identity to pin. Use the PIN path. |
|
||||
| A request-access stream sits there | That's it waiting — somebody has to approve the Deck on the host. It gives up after about three minutes. |
|
||||
| Stream launches but doesn't focus | Start it from the panel (not by launching the client by hand) so Steam/gamescope focuses it. |
|
||||
| The stream wedges — black, or won't close | Panel → **About** → **Force-stop**, then start it again. |
|
||||
| The **Punktfunk** library entry disappeared | Panel → **Recreate library shortcut**; it puts the entry back in place. |
|
||||
| You want a clean slate | **Open Punktfunk → Settings** for stream settings, or `punktfunk reset` in Desktop Mode to forget every saved host. Your paired identity is kept either way. |
|
||||
|
||||
Nothing here matching? The problem is probably on the host side — start at
|
||||
[Troubleshooting](/docs/troubleshooting), which is organised by symptom (host not found, pairing
|
||||
|
||||
@@ -354,7 +354,7 @@ macOS, iOS/iPadOS and tvOS. Android is one app, with Android TV being the same a
|
||||
| iPhone · iPad | ✅ | ✅ | ✅ ⁴ | ✅ | ✅ | ❌ ³ |
|
||||
| Apple TV | ⚠️ ⁵ | ✅ | ✅ ⁴ | ✅ | ✅ | ❌ ³ |
|
||||
| Android · Android TV | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ ³ |
|
||||
| Decky (Steam Deck) | ❌ ⁶ | ❌ | ✅ ⁷ | ❌ | ✅ | ✅ ⁸ |
|
||||
| Decky (Steam Deck) | ⚠️ ⁶ | ❌ | ⚠️ ⁷ | ❌ | ✅ | ✅ ⁸ |
|
||||
| `punktfunk` CLI | ✅ | ✅ ⁹ | ✅ | ✅ | ✅ | ❌ |
|
||||
| Moonlight | ❌ ¹⁰ | ❌ ¹⁰ | ✅ ¹¹ | ❓ ¹² | ❓ ¹² | ❓ ¹² |
|
||||
|
||||
@@ -375,10 +375,12 @@ macOS, iOS/iPadOS and tvOS. Android is one app, with Android TV being the same a
|
||||
exists, so a fresh Apple TV has none. Of the settings a profile can carry, tvOS also drops the
|
||||
ones the platform has no input for: inverted scroll, modifier layout, variable refresh rate,
|
||||
mouse mode and touch mode.
|
||||
6. The plugin writes flat values into the shared client settings; it has no profile surface. The
|
||||
client it launches still honours whatever profile that settings file names.
|
||||
7. Including pinned one-tap "Stream *game*" rows in the Quick Access Menu, which follow a host
|
||||
across IP changes. Not subject to the desktop opt-in.
|
||||
6. The panel *shows* the profiles a host has pinned, as nested one-tap cards, and streams with
|
||||
them; it has no profile surface of its own. Pins are made in a client's own UI — including the
|
||||
console home **Open Punktfunk** opens — and are shared, so every client shows the same cards.
|
||||
Creating and editing a profile stays a desktop-app job.
|
||||
7. Not in the panel: **Open Punktfunk** opens the client's console home, and a paired host's
|
||||
library is one button from there.
|
||||
8. Both the plugin itself and, where the install kind allows it, the client it launches.
|
||||
9. The CLI parses and follows links; it does not register the URL scheme — the graphical apps do.
|
||||
10. [Profiles and links](/docs/profiles-and-links) are Punktfunk-app concepts and do not exist on
|
||||
@@ -400,7 +402,8 @@ macOS, iOS/iPadOS and tvOS. Android is one app, with Android TV being the same a
|
||||
|
||||
1. Multiple controllers, each on its own stable slot, arriving and leaving independently. The pad
|
||||
**type** the host emulates is picked per pad; the pickers are not identical across apps — Linux,
|
||||
Android and Decky offer six presets including Steam Deck, Windows and Apple offer five.
|
||||
Android and the console home offer six presets including Steam Deck, Windows and Apple offer
|
||||
five.
|
||||
2. DualSense and DualShock 4 touchpad and motion are forwarded, and the host's adaptive-trigger and
|
||||
lightbar effects are replayed on a real DualSense. On the desktop clients any controller SDL
|
||||
exposes a gyro on forwards motion — a Switch Pro or the Steam Deck's own pad included — and the
|
||||
@@ -514,7 +517,7 @@ capability.
|
||||
| **Linux and Windows desktop clients** | Packaged and current. They are one codebase: the same session binary streams for both, and for the Decky plugin and the `punktfunk` CLI. |
|
||||
| **Apple client** (macOS · iOS · iPadOS · tvOS) | One universal build, distributed as a **TestFlight beta**; the Mac also has a notarized DMG. Feature-complete apart from the platform gaps named above (no microphone on tvOS, clipboard on macOS only). |
|
||||
| **Android client** (phone · TV) | Published on **Google Play** as a public listing for releases, with an invite-only Internal testing track for canary, plus a sideloadable APK. The same app in leanback mode is the TV client. |
|
||||
| **Decky plugin** (Steam Deck) | Ships through install-from-URL rather than the Decky store, and keeps itself and the client it launches up to date. It launches the Linux client rather than streaming itself, and has no settings surface of its own beyond the flat values it writes into the shared client settings. |
|
||||
| **Decky plugin** (Steam Deck) | Ships through install-from-URL rather than the Decky store, and keeps itself and the client it launches up to date. It is a launcher, not a second client: it starts the Linux client rather than streaming itself, and holds no settings, no library and no host editor of its own — its **Open Punktfunk** button hands all of that to the client's console home. |
|
||||
| **Web console** | The full management surface — dashboard and sessions, pairing, library, displays, plugins and the plugin store, logs, stats, settings, and host updates. It cannot yet run a speed test or set a bitrate; the client apps can. |
|
||||
| **Plugins** | Three first-party ones (ROM Manager, Playnite, VirtualHere) plus the SDK, installed from the console. See [Plugins](/docs/plugins). |
|
||||
| **`pf-webos`** (LG TV) | A community client in a separate repository. Nothing here can establish its state; ask that project. |
|
||||
|
||||
@@ -74,9 +74,10 @@ saved host's own menu, and only appears when that host is offline *and* an addre
|
||||
| Android · Android TV | **Wake host** — waits, showing the "Waking…" screen | **Wake-on-LAN MAC** in **Edit host** |
|
||||
| Punktfunk Console (controller shell) | on an offline host with a known address, the confirm button reads **Wake & Connect** — it waits, then connects | not offered |
|
||||
|
||||
Punktfunk Console has no auto-wake setting of its own, and offers **Wake & Connect** whatever the
|
||||
desktop app's setting says. In the Apple apps the same button appears when you drive them with a
|
||||
controller, but there it does follow the auto-wake setting.
|
||||
Punktfunk Console carries the row too — **Wake hosts automatically**, in the same settings list the
|
||||
desktop apps write — but its **Wake & Connect** button is an explicit action and appears whatever
|
||||
that row says. In the Apple apps the same button appears when you drive them with a controller, but
|
||||
there it does follow the auto-wake setting.
|
||||
|
||||
The Apple apps also publish a **Wake Host** action to Shortcuts, so an automation can wake a host
|
||||
without opening the app. On iPhone and iPad it has a ready-made phrase: *"Wake ⟨host⟩ with
|
||||
@@ -88,10 +89,14 @@ host list, and shows an explanation with a link to system settings if you declin
|
||||
|
||||
### On the Steam Deck
|
||||
|
||||
The [Decky plugin](/docs/steam-deck) has no wake button and no wake setting. It sends a wake through
|
||||
the Flatpak client just before **every** stream launch, and it is a no-op until that client has
|
||||
learned the host's address. When a packet really did go out, the plugin also stretches the stream's
|
||||
connect budget to 75 seconds, so the connection survives the host resuming from sleep.
|
||||
The [Decky plugin](/docs/steam-deck) has no wake button and no wake setting of its own. It starts
|
||||
every stream through the client, so the wake is the client's, on exactly the terms above: a packet
|
||||
the moment the host doesn't answer, re-sent every 6 seconds while the client watches for it once a
|
||||
second, and the dial only when it really is back. It follows **Wake hosts automatically** in the
|
||||
client's own settings — **Open Punktfunk → Settings** from the same panel — and is a no-op until the
|
||||
client has learned that host's MAC address. (The plugin used to fire a packet itself and stretch the
|
||||
connect budget to 75 seconds to cover the resume; a wait that watches for the host beats a fixed
|
||||
budget, so that is gone.)
|
||||
|
||||
### From the command line
|
||||
|
||||
|
||||
+139
-163
@@ -45,10 +45,6 @@
|
||||
// v10: added `punktfunk_connection_clock_offset_now_ns` — the LIVE (mid-stream re-synced)
|
||||
// clock offset ongoing latency math must use; the connect-time getter stays frozen by
|
||||
// contract. Additive, client-local — no wire change, so [`WIRE_VERSION`] is unchanged.
|
||||
// v11: added `punktfunk_connect_ex9` — `connect_ex8` plus a `client_caps` bitfield
|
||||
// (`PUNKTFUNK_CLIENT_CAP_CURSOR`, later `…_PHASE_LOCK`), which is how a client tells the host it
|
||||
// renders the pointer itself. Additive; the caps ride the existing Hello, so [`WIRE_VERSION`] is
|
||||
// unchanged. (Documented late — the bump shipped without its line here.)
|
||||
// v12: added `punktfunk_connection_set_cursor_render` — the mid-stream cursor-render flip
|
||||
// (design/remote-desktop-sweep.md §8): the client's mouse-model chord tells the host who
|
||||
// renders the pointer. Additive; rides the existing control stream (a new message TYPE, which
|
||||
@@ -62,15 +58,7 @@
|
||||
// uncertainty and the circular arrival-lead statistic the host's controller steers on. Additive;
|
||||
// the wire grows only a new control message (`PhaseReport`, 0x32) an old host never reads and a
|
||||
// strict-prefix append on the 0xCF host-timing tail, so [`WIRE_VERSION`] is unchanged.
|
||||
// v15: versions the shared rumble policy engine's C surface —
|
||||
// `punktfunk_connection_next_rumble_cmd`, `punktfunk_connection_set_rumble_quirks` and the
|
||||
// `PUNKTFUNK_RUMBLE_QUIRK_*` bits. These symbols are NOT new: they landed while this constant
|
||||
// still read 7 and no bump was made, so every core since has exported them while advertising a
|
||||
// version that never promised them. That cannot be corrected retroactively — a shipped binary
|
||||
// says what it says — so v15 is the floor that *guarantees* them: at or above it the surface is
|
||||
// present, below it an embedder must probe for the symbol. Purely a version statement; no code
|
||||
// changed with this bump, and no wire change, so [`WIRE_VERSION`] is unchanged.
|
||||
#define PUNKTFUNK_ABI_VERSION 15
|
||||
#define ABI_VERSION 14
|
||||
|
||||
// The punktfunk/1 **wire** version — what `Hello`/`Welcome` carry and hosts equality-check.
|
||||
// Deliberately its own constant: [`ABI_VERSION`] tracks the embeddable **C surface**
|
||||
@@ -78,7 +66,7 @@
|
||||
// `punktfunk_wake_on_lan` is client-local, and riding the C-ABI bump onto the wire locked
|
||||
// every new client out of every deployed host ("ABI mismatch: client 3 host 2", observed
|
||||
// live). Bump this ONLY when the handshake/planes actually change incompatibly.
|
||||
#define PUNKTFUNK_WIRE_VERSION 2
|
||||
#define WIRE_VERSION 2
|
||||
|
||||
// `PunktfunkHidOutput::kind` — lightbar RGB (`r`/`g`/`b` valid).
|
||||
#define PUNKTFUNK_HIDOUT_LED 1
|
||||
@@ -335,41 +323,41 @@
|
||||
// The uniform no-TTL-host staleness bound: a legacy host refreshes state every 500 ms, so two
|
||||
// missed refreshes = quiet host → silence. Replaces the per-platform zoo (1.6 s / 60 s / 1.5 s /
|
||||
// 1 s), and matches the ratio the Steam Deck ceiling shipped with.
|
||||
#define PUNKTFUNK_LEGACY_STALE_MS 1000
|
||||
#define LEGACY_STALE_MS 1000
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// Per-fetch requester-side size cap (bytes). A holder that streams more than this is treated as a
|
||||
// cap breach and the fetch fails rather than buffering unboundedly (§7). Phase 0 uses one fixed
|
||||
// value; a future host-policy `PUNKTFUNK_CLIP_MAX_MB` tightens it per session.
|
||||
#define PUNKTFUNK_CLIP_FETCH_CAP (64 << 20)
|
||||
#define CLIP_FETCH_CAP (64 << 20)
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// Inbound-serve `req_id`s carry this high bit so they never collide with the client-assigned
|
||||
// outbound-fetch `xfer_id`s (which count up from 1). A single [`ClipCommand::Cancel`] `id` can
|
||||
// then be routed to the right table.
|
||||
#define PUNKTFUNK_INBOUND_REQ_FLAG 2147483648
|
||||
#define INBOUND_REQ_FLAG 2147483648
|
||||
#endif
|
||||
|
||||
// Floor for a negotiated `shard_payload` (even, well under every real path). A path whose UDP
|
||||
// budget lands below this can't carry the QUIC control plane either (QUIC's own minimum is a
|
||||
// 1200-byte UDP payload), so shrinking video shards further buys nothing — the clamp helpers
|
||||
// bottom out here instead of producing degenerate confetti-sized shards.
|
||||
#define PUNKTFUNK_MIN_SHARD_PAYLOAD 512
|
||||
#define MIN_SHARD_PAYLOAD 512
|
||||
|
||||
// 16-byte AEAD authentication tag appended by either session cipher.
|
||||
#define PUNKTFUNK_TAG_LEN 16
|
||||
#define TAG_LEN 16
|
||||
|
||||
// Wire tag distinguishing an input datagram from a video packet.
|
||||
#define PUNKTFUNK_INPUT_MAGIC 200
|
||||
#define INPUT_MAGIC 200
|
||||
|
||||
// Fixed serialized size of an [`InputEvent`] on the wire (tag + fields).
|
||||
#define PUNKTFUNK_INPUT_WIRE_LEN (((((1 + 1) + 4) + 4) + 4) + 4)
|
||||
#define INPUT_WIRE_LEN (((((1 + 1) + 4) + 4) + 4) + 4)
|
||||
|
||||
// The number of gamepads addressable on the wire (`flags` pad index 0..15). Shared by the
|
||||
// client's snapshot fold and the host's per-pad accumulators.
|
||||
#define PUNKTFUNK_MAX_PADS 16
|
||||
#define MAX_PADS 16
|
||||
|
||||
#define PUNKTFUNK_BTN_DPAD_UP 1
|
||||
|
||||
@@ -402,16 +390,16 @@
|
||||
#define PUNKTFUNK_BTN_Y 32768
|
||||
|
||||
// Back grip R4 — SDL `RightPaddle1` / GameStream `PADDLE1`.
|
||||
#define PUNKTFUNK_BTN_PADDLE1 65536
|
||||
#define BTN_PADDLE1 65536
|
||||
|
||||
// Back grip L4 — SDL `LeftPaddle1` / GameStream `PADDLE2`.
|
||||
#define PUNKTFUNK_BTN_PADDLE2 131072
|
||||
#define BTN_PADDLE2 131072
|
||||
|
||||
// Back grip R5 — SDL `RightPaddle2` / GameStream `PADDLE3`.
|
||||
#define PUNKTFUNK_BTN_PADDLE3 262144
|
||||
#define BTN_PADDLE3 262144
|
||||
|
||||
// Back grip L5 — SDL `LeftPaddle2` / GameStream `PADDLE4`.
|
||||
#define PUNKTFUNK_BTN_PADDLE4 524288
|
||||
#define BTN_PADDLE4 524288
|
||||
|
||||
// DualSense touchpad click. Moonlight's extended-button position (`buttonFlags2`
|
||||
// merges in at `<< 16`, see `gamestream/gamepad.rs`), so GameStream clients land on
|
||||
@@ -419,7 +407,7 @@
|
||||
#define PUNKTFUNK_BTN_TOUCHPAD 1048576
|
||||
|
||||
// Misc / capture button — the Deck `…`/quick-access, Share/Capture / GameStream `MISC`.
|
||||
#define PUNKTFUNK_BTN_MISC1 2097152
|
||||
#define BTN_MISC1 2097152
|
||||
|
||||
// Axis ids for `InputKind::GamepadAxis`.
|
||||
#define PUNKTFUNK_AXIS_LS_X 0
|
||||
@@ -438,16 +426,16 @@
|
||||
// Identifies a punktfunk video packet (vs. an input datagram, see [`crate::input`]).
|
||||
#define PUNKTFUNK_MAGIC 201
|
||||
|
||||
#define PUNKTFUNK_FLAG_PIC 1
|
||||
#define FLAG_PIC 1
|
||||
|
||||
#define PUNKTFUNK_FLAG_EOF 2
|
||||
#define FLAG_EOF 2
|
||||
|
||||
#define PUNKTFUNK_FLAG_SOF 4
|
||||
#define FLAG_SOF 4
|
||||
|
||||
// Bandwidth-probe filler, not decodable video: a [`crate::quic::ProbeRequest`] speed test makes
|
||||
// the host burst access units carrying this flag so the client measures throughput/loss without
|
||||
// feeding them to the decoder. Punktfunk/1 only (GameStream never sets it).
|
||||
#define PUNKTFUNK_FLAG_PROBE 8
|
||||
#define FLAG_PROBE 8
|
||||
|
||||
// Application `user_flags` bit (the u32 [`PacketHeader::user_flags`] word, surfaced to the client
|
||||
// as [`crate::session::Frame::flags`]) — NOT a transport packet flag. Marks the access unit that
|
||||
@@ -456,7 +444,7 @@
|
||||
// post-loss display freeze on this bit as well as on a real keyframe — the only bitstream-invisible
|
||||
// clean point it can honor without forcing a full IDR. Lives above the low nibble because the host
|
||||
// reuses `FLAG_PIC`/`FLAG_SOF`/`FLAG_PROBE` bit values inside `user_flags`; `0x10` clears all four.
|
||||
#define PUNKTFUNK_USER_FLAG_RECOVERY_POINT 16
|
||||
#define USER_FLAG_RECOVERY_POINT 16
|
||||
|
||||
// Application `user_flags` bit — a **definitive single-frame clean re-anchor**. Unlike
|
||||
// [`USER_FLAG_RECOVERY_POINT`] (an intra-refresh wave boundary, where the first boundary after a loss
|
||||
@@ -466,7 +454,7 @@
|
||||
// already has, not an IDR. The picture is loss-free the instant this AU decodes, so the client lifts
|
||||
// its post-loss freeze on the **first** such mark. Coded `P` (no IDR), so the decoder never sets
|
||||
// `AV_FRAME_FLAG_KEY` — this host flag is the only signal.
|
||||
#define PUNKTFUNK_USER_FLAG_RECOVERY_ANCHOR 32
|
||||
#define USER_FLAG_RECOVERY_ANCHOR 32
|
||||
|
||||
// `user_flags` bit: the AU's content is **shard-aligned self-delimiting chunks** — every
|
||||
// `shard_payload`-sized window of the frame buffer starts a fresh codec packet, padded to the
|
||||
@@ -474,7 +462,7 @@
|
||||
// consequences: a receiver that opted into partial delivery can use an aged-out frame's buffer
|
||||
// AS-IS (missing shards stay zeroed; the codec's block walk skips zero windows), and even a
|
||||
// COMPLETE frame must be consumed window-by-window (the padding is not part of the stream).
|
||||
#define PUNKTFUNK_USER_FLAG_CHUNK_ALIGNED 64
|
||||
#define USER_FLAG_CHUNK_ALIGNED 64
|
||||
|
||||
// `user_flags` bit: this AU was packetized as a **slice-streamed** frame (the P2 slice
|
||||
// pipeline): its sentinel blocks (`block_count == 0`) are SLICE-granularity and carry their
|
||||
@@ -487,7 +475,7 @@
|
||||
// [`VIDEO_CAP_STREAMED_AU`](crate::quic::VIDEO_CAP_STREAMED_AU) ∧
|
||||
// [`VIDEO_CAP_MULTI_SLICE`](crate::quic::VIDEO_CAP_MULTI_SLICE) — the pair whose receivers
|
||||
// know this contract.
|
||||
#define PUNKTFUNK_USER_FLAG_SLICE_STREAM 128
|
||||
#define USER_FLAG_SLICE_STREAM 128
|
||||
|
||||
// Widest lost-frame range (frames, wrapping `last - first`) a reference-frame-invalidation
|
||||
// recovery may be asked to repair; anything wider goes straight to the keyframe path on BOTH
|
||||
@@ -496,7 +484,7 @@
|
||||
// reference anywhere, so an RFI request for it is either hopeless or (worse) a phantom range
|
||||
// from a desynced counter. Shared by the host's RFI dispatch (range → keyframe fallback) and the
|
||||
// client-side gap detectors (huge gap → resync + keyframe request, no RFI).
|
||||
#define PUNKTFUNK_RFI_MAX_RANGE 256
|
||||
#define RFI_MAX_RANGE 256
|
||||
|
||||
// Largest UDP datagram the core will send or accept. `Config::validate` bounds
|
||||
// `shard_payload` so `HEADER_LEN + shard_payload + CRYPTO_OVERHEAD ≤ MAX_DATAGRAM_BYTES`.
|
||||
@@ -510,22 +498,22 @@
|
||||
// for never having to resize buffers on a mid-session grow. Senders still derive their
|
||||
// shard payload from the path MTU (`config::mtu1500_shard_payload*`, the wire-MTU clamps);
|
||||
// this is the acceptance ceiling, not a transmit size.
|
||||
#define PUNKTFUNK_MAX_DATAGRAM_BYTES 9216
|
||||
#define MAX_DATAGRAM_BYTES 9216
|
||||
|
||||
// The slice-flush floor: a sentinel block below this many data shards costs disproportionate
|
||||
// per-block FEC parity (`ceil(k × pct/100)` ≥ 1 whatever `k`), so slice boundaries only flush
|
||||
// once this much has accumulated (~22 KB at the standard shard payload). Small slices simply
|
||||
// ride with the next one; the wire is never worse than one flush per slice.
|
||||
#define PUNKTFUNK_MIN_STREAM_BLOCK_SHARDS 16
|
||||
#define MIN_STREAM_BLOCK_SHARDS 16
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// [`Hello::video_caps`] bit: the client can decode a 10-bit (Main10) HEVC stream.
|
||||
#define PUNKTFUNK_VIDEO_CAP_10BIT 1
|
||||
#define VIDEO_CAP_10BIT 1
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// [`Hello::video_caps`] bit: the client can present BT.2020 PQ HDR10 (implies 10-bit).
|
||||
#define PUNKTFUNK_VIDEO_CAP_HDR 2
|
||||
#define VIDEO_CAP_HDR 2
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
@@ -537,7 +525,7 @@
|
||||
// 4:2:0 and [`Welcome::chroma_format`] reflects the real resolved value. Independent of
|
||||
// 10-bit/HDR (4:4:4 is a chroma decision, bit depth is a depth decision; the two may combine
|
||||
// where the hardware allows).
|
||||
#define PUNKTFUNK_VIDEO_CAP_444 4
|
||||
#define VIDEO_CAP_444 4
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
@@ -547,7 +535,7 @@
|
||||
// (design/stats-unification.md Phase 2). The host emits 0xCF ONLY when this bit is set (an older
|
||||
// host ignores it and simply never sends any); a client that doesn't set it keeps the combined
|
||||
// stage. Purely observability — never changes what the host encodes.
|
||||
#define PUNKTFUNK_VIDEO_CAP_HOST_TIMING 8
|
||||
#define VIDEO_CAP_HOST_TIMING 8
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
@@ -562,7 +550,7 @@
|
||||
// depends on. The host runs mid-session probe bursts ONLY against clients that set this bit — an
|
||||
// older client gets a declined (zeroed) [`ProbeResult`] instead of a measurement its single-window
|
||||
// reassembler would silently drop as stale.
|
||||
#define PUNKTFUNK_VIDEO_CAP_PROBE_SEQ 16
|
||||
#define VIDEO_CAP_PROBE_SEQ 16
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
@@ -577,7 +565,7 @@
|
||||
// — a mismatch drops the frame wholesale. The host streams ONLY to clients advertising this
|
||||
// bit; every other client gets today's whole-AU path (chunks concatenated before sealing), so
|
||||
// the fallback is zero-risk.
|
||||
#define PUNKTFUNK_VIDEO_CAP_STREAMED_AU 32
|
||||
#define VIDEO_CAP_STREAMED_AU 32
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
@@ -591,7 +579,7 @@
|
||||
// toward every other client the Welcome stays byte-identical AES-128-GCM. Purely a
|
||||
// performance choice — both AEADs are full-strength, and Hello/Welcome ride the pinned-TLS
|
||||
// control channel, so there is no downgrade surface.
|
||||
#define PUNKTFUNK_VIDEO_CAP_CHACHA20 64
|
||||
#define VIDEO_CAP_CHACHA20 64
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
@@ -607,7 +595,7 @@
|
||||
// bit (`PUNKTFUNK_NVENC_SLICES` stays the explicit operator override in both directions);
|
||||
// every other client gets single-slice frames — the pre-0.17 wire shape. NOTE: this takes the
|
||||
// video_caps byte's last free bit — the next video cap needs a second byte (ABI bump).
|
||||
#define PUNKTFUNK_VIDEO_CAP_MULTI_SLICE 128
|
||||
#define VIDEO_CAP_MULTI_SLICE 128
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
@@ -616,7 +604,7 @@
|
||||
// sequence number. A capable client then sends gamepad state as snapshots (idempotent on the
|
||||
// lossy datagram plane, periodically refreshed) instead of the fragile per-transition
|
||||
// button/axis events; toward a host that doesn't set the bit it keeps the legacy events.
|
||||
#define PUNKTFUNK_HOST_CAP_GAMEPAD_STATE 1
|
||||
#define HOST_CAP_GAMEPAD_STATE 1
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
@@ -626,7 +614,7 @@
|
||||
// out. Purely additive: nothing clipboard-related happens until a [`ClipControl`]`{ enabled:
|
||||
// true }` crosses (see `design/clipboard-and-file-transfer.md` §3.1). Packs into the existing
|
||||
// trailing `host_caps` byte — no wire-layout change.
|
||||
#define PUNKTFUNK_HOST_CAP_CLIPBOARD 2
|
||||
#define HOST_CAP_CLIPBOARD 2
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
@@ -638,7 +626,7 @@
|
||||
// non-Latin scripts, emoji) through `TextInput` instead of lossy VK synthesis; absent the bit it
|
||||
// keeps the VK fallback. Packs into the existing trailing `host_caps` byte — no wire-layout
|
||||
// change; an older host ignores the unknown input tag anyway (input is lossy by design).
|
||||
#define PUNKTFUNK_HOST_CAP_TEXT_INPUT 4
|
||||
#define HOST_CAP_TEXT_INPUT 4
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
@@ -650,7 +638,7 @@
|
||||
// (`SessionPlan.cursor_blend = false`) or the user sees it twice. Active only when the host
|
||||
// answers with [`HOST_CAP_CURSOR`] (capable-and-agreed, the 444/clipboard precedent); toward
|
||||
// an older or incapable host nothing changes.
|
||||
#define PUNKTFUNK_CLIENT_CAP_CURSOR 1
|
||||
#define CLIENT_CAP_CURSOR 1
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
@@ -659,7 +647,7 @@
|
||||
// capture/send tick to the client's display latch (design/phase-locked-capture.md). Without
|
||||
// the bit the host never arms the phase controller; toward an older host the reports are
|
||||
// simply ignored — no behavior change in either direction.
|
||||
#define PUNKTFUNK_CLIENT_CAP_PHASE_LOCK 2
|
||||
#define CLIENT_CAP_PHASE_LOCK 2
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
@@ -672,7 +660,7 @@
|
||||
// cursor/clipboard precedent). Toward an older host, or a host that declines because the link is
|
||||
// clean, the client keeps receiving the plain `0xC9` plane — so a client may always set this bit.
|
||||
// `0x04` — `0x01`/`0x02` are cursor / phase-lock.
|
||||
#define PUNKTFUNK_CLIENT_CAP_AUDIO_RED 4
|
||||
#define CLIENT_CAP_AUDIO_RED 4
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
@@ -683,7 +671,7 @@
|
||||
// host stops blending and ships [`CursorShape`](super::control::CursorShape) +
|
||||
// [`CursorState`](super::datagram::CursorState) instead. `0x08` — `0x04` is
|
||||
// [`HOST_CAP_TEXT_INPUT`], `0x01`/`0x02` are gamepad-state / clipboard.
|
||||
#define PUNKTFUNK_HOST_CAP_CURSOR 8
|
||||
#define HOST_CAP_CURSOR 8
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
@@ -697,7 +685,7 @@
|
||||
// wire ships ahead of the backend (P0): no host sets this bit until the P1 injector lands —
|
||||
// which is exactly why the gate exists. `0x10` — `0x08` is [`HOST_CAP_CURSOR`], `0x04` is
|
||||
// [`HOST_CAP_TEXT_INPUT`], `0x01`/`0x02` are gamepad-state / clipboard.
|
||||
#define PUNKTFUNK_HOST_CAP_PEN 16
|
||||
#define HOST_CAP_PEN 16
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
@@ -711,25 +699,25 @@
|
||||
// loss-gated — a clean LAN shouldn't pay for it), which is why clients decode BOTH tags
|
||||
// unconditionally and treat this bit as "expect redundancy", not "only redundancy".
|
||||
// `0x20` — `0x10` is [`HOST_CAP_PEN`], `0x08` is [`HOST_CAP_CURSOR`].
|
||||
#define PUNKTFUNK_HOST_CAP_AUDIO_RED 32
|
||||
#define HOST_CAP_AUDIO_RED 32
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// [`Hello::video_codecs`] bit: the client can decode H.264 / AVC. The GPU-less **software**
|
||||
// encode path (openh264) emits H.264, so a client that wants to stream from a software host MUST
|
||||
// advertise this.
|
||||
#define PUNKTFUNK_CODEC_H264 1
|
||||
#define CODEC_H264 1
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// [`Hello::video_codecs`] bit: the client can decode H.265 / HEVC — the default every existing
|
||||
// build produces and decodes (a peer that omits [`Hello::video_codecs`] is treated as HEVC-only).
|
||||
#define PUNKTFUNK_CODEC_HEVC 2
|
||||
#define CODEC_HEVC 2
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// [`Hello::video_codecs`] bit: the client can decode AV1.
|
||||
#define PUNKTFUNK_CODEC_AV1 4
|
||||
#define CODEC_AV1 4
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
@@ -743,18 +731,18 @@
|
||||
// (`crates/pyrowave-sys/vendor/pyrowave/PUNKTFUNK-VENDOR.txt`): upstream has no bitstream
|
||||
// version field, so a vendored bump that changes the bitstream bumps the punktfunk protocol
|
||||
// version instead (plan §4.2).
|
||||
#define PUNKTFUNK_CODEC_PYROWAVE 8
|
||||
#define CODEC_PYROWAVE 8
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// HEVC `chroma_format_idc` for 4:2:0 — what every pre-4:4:4 build produced and the back-compat
|
||||
// default when a peer omits [`Welcome::chroma_format`].
|
||||
#define PUNKTFUNK_CHROMA_IDC_420 1
|
||||
#define CHROMA_IDC_420 1
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// HEVC `chroma_format_idc` for full-chroma 4:4:4 (Range Extensions).
|
||||
#define PUNKTFUNK_CHROMA_IDC_444 3
|
||||
#define CHROMA_IDC_444 3
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
@@ -805,195 +793,195 @@
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// Type byte of [`Reconfigure`] (first byte after the magic).
|
||||
#define PUNKTFUNK_MSG_RECONFIGURE 1
|
||||
#define MSG_RECONFIGURE 1
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// Type byte of [`Reconfigured`].
|
||||
#define PUNKTFUNK_MSG_RECONFIGURED 2
|
||||
#define MSG_RECONFIGURED 2
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// Type byte of [`RequestKeyframe`].
|
||||
#define PUNKTFUNK_MSG_REQUEST_KEYFRAME 3
|
||||
#define MSG_REQUEST_KEYFRAME 3
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// Type byte of [`LossReport`].
|
||||
#define PUNKTFUNK_MSG_LOSS_REPORT 4
|
||||
#define MSG_LOSS_REPORT 4
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// Type byte of [`SetBitrate`].
|
||||
#define PUNKTFUNK_MSG_SET_BITRATE 5
|
||||
#define MSG_SET_BITRATE 5
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// Type byte of [`BitrateChanged`].
|
||||
#define PUNKTFUNK_MSG_BITRATE_CHANGED 6
|
||||
#define MSG_BITRATE_CHANGED 6
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// Type byte of [`RfiRequest`].
|
||||
#define PUNKTFUNK_MSG_RFI_REQUEST 7
|
||||
#define MSG_RFI_REQUEST 7
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// Type byte of [`ShardPayloadChanged`].
|
||||
#define PUNKTFUNK_MSG_SHARD_PAYLOAD_CHANGED 8
|
||||
#define MSG_SHARD_PAYLOAD_CHANGED 8
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// Type byte of [`ShardPayloadAck`].
|
||||
#define PUNKTFUNK_MSG_SHARD_PAYLOAD_ACK 9
|
||||
#define MSG_SHARD_PAYLOAD_ACK 9
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// Type byte of [`ProbeRequest`].
|
||||
#define PUNKTFUNK_MSG_PROBE_REQUEST 32
|
||||
#define MSG_PROBE_REQUEST 32
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// Type byte of [`ProbeResult`].
|
||||
#define PUNKTFUNK_MSG_PROBE_RESULT 33
|
||||
#define MSG_PROBE_RESULT 33
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// Type byte of [`ClockProbe`].
|
||||
#define PUNKTFUNK_MSG_CLOCK_PROBE 48
|
||||
#define MSG_CLOCK_PROBE 48
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// Type byte of [`ClockEcho`].
|
||||
#define PUNKTFUNK_MSG_CLOCK_ECHO 49
|
||||
#define MSG_CLOCK_ECHO 49
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// Type byte of [`PhaseReport`].
|
||||
#define PUNKTFUNK_MSG_PHASE_REPORT 50
|
||||
#define MSG_PHASE_REPORT 50
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// Type byte of [`ClipControl`] (client → host): enable/disable the shared clipboard for this
|
||||
// session. Idempotent; opt-in is enforced here, not just in UI.
|
||||
#define PUNKTFUNK_MSG_CLIP_CONTROL 64
|
||||
#define MSG_CLIP_CONTROL 64
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// Type byte of [`ClipState`] (host → client): ack + unsolicited policy/backend updates.
|
||||
#define PUNKTFUNK_MSG_CLIP_STATE 65
|
||||
#define MSG_CLIP_STATE 65
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// Type byte of [`ClipOffer`] (symmetric): the lazy announcement — format list only, no bytes.
|
||||
#define PUNKTFUNK_MSG_CLIP_OFFER 66
|
||||
#define MSG_CLIP_OFFER 66
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// Type byte of [`ClipFetch`] (requester → holder, **fetch stream only**): pull one format of the
|
||||
// current offer.
|
||||
#define PUNKTFUNK_MSG_CLIP_FETCH 67
|
||||
#define MSG_CLIP_FETCH 67
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// Type byte of [`ClipFetchHdr`] (holder → requester, **fetch stream only**): the fetch response
|
||||
// header that precedes the data chunks.
|
||||
#define PUNKTFUNK_MSG_CLIP_FETCH_HDR 68
|
||||
#define MSG_CLIP_FETCH_HDR 68
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// [`ClipControl::flags`] bit: the client permits file kinds to be offered/fetched this session.
|
||||
// Absent ⇒ files are filtered out of offers in both directions (text/rich/image only).
|
||||
#define PUNKTFUNK_CLIP_FLAG_FILES 1
|
||||
#define CLIP_FLAG_FILES 1
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// [`ClipState::policy`] bit: the host permits non-file formats (text/RTF/HTML/image). Always set
|
||||
// while enabled unless a future direction limit clears it.
|
||||
#define PUNKTFUNK_CLIP_POLICY_TEXT 1
|
||||
#define CLIP_POLICY_TEXT 1
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// [`ClipState::policy`] bit: the host permits file formats. Cleared by the operator `no-files`
|
||||
// / `text-only` policy so the client can grey out "Include files".
|
||||
#define PUNKTFUNK_CLIP_POLICY_FILES 2
|
||||
#define CLIP_POLICY_FILES 2
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// [`ClipState::reason`]: normal ack, nothing exceptional.
|
||||
#define PUNKTFUNK_CLIP_REASON_OK 0
|
||||
#define CLIP_REASON_OK 0
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// [`ClipState::reason`]: this session type has no working clipboard backend (e.g. a gamescope
|
||||
// session with no data-control global) — the client shows "not supported in this session type".
|
||||
#define PUNKTFUNK_CLIP_REASON_BACKEND_UNAVAILABLE 1
|
||||
#define CLIP_REASON_BACKEND_UNAVAILABLE 1
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// [`ClipState::reason`]: another client took over the single per-desktop clipboard binding; this
|
||||
// one was disabled (last `ClipControl{enabled}` wins).
|
||||
#define PUNKTFUNK_CLIP_REASON_TAKEN_OVER 2
|
||||
#define CLIP_REASON_TAKEN_OVER 2
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// [`ClipState::reason`]: the host operator policy (`PUNKTFUNK_CLIPBOARD=off`) disables clipboard.
|
||||
#define PUNKTFUNK_CLIP_REASON_POLICY_DISABLED 3
|
||||
#define CLIP_REASON_POLICY_DISABLED 3
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// [`ClipState::reason`]: enabled, but the host policy forbids file transfer (`no-files` /
|
||||
// `text-only`) — surfaced so the client greys "Include files" with a footnote.
|
||||
#define PUNKTFUNK_CLIP_REASON_NO_FILES 4
|
||||
#define CLIP_REASON_NO_FILES 4
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// [`ClipFetchHdr::status`]: the requested format is being served; data chunks follow until FIN.
|
||||
#define PUNKTFUNK_CLIP_FETCH_OK 0
|
||||
#define CLIP_FETCH_OK 0
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// [`ClipFetchHdr::status`]: the fetch named a `seq` that is no longer the holder's current offer;
|
||||
// the requester degrades the paste to "nothing inserted" rather than wrong data. No chunks follow.
|
||||
#define PUNKTFUNK_CLIP_FETCH_STALE 1
|
||||
#define CLIP_FETCH_STALE 1
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// [`ClipFetchHdr::status`]: the format/index is not available (no backend, or it vanished). No
|
||||
// chunks follow.
|
||||
#define PUNKTFUNK_CLIP_FETCH_UNAVAILABLE 2
|
||||
#define CLIP_FETCH_UNAVAILABLE 2
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// [`ClipFetchHdr::status`]: policy/cap denies this fetch (e.g. a file fetch under `no-files`). No
|
||||
// chunks follow.
|
||||
#define PUNKTFUNK_CLIP_FETCH_DENIED 3
|
||||
#define CLIP_FETCH_DENIED 3
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// Maximum number of [`ClipKind`] entries in one [`ClipOffer`] (resource cap, §7).
|
||||
#define PUNKTFUNK_CLIP_MAX_KINDS 16
|
||||
#define CLIP_MAX_KINDS 16
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// Maximum length in bytes of a [`ClipKind::mime`] string (resource cap, §7).
|
||||
#define PUNKTFUNK_CLIP_MAX_MIME 128
|
||||
#define CLIP_MAX_MIME 128
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// [`ClipFetch::file_index`] sentinel meaning "not a file fetch" (a whole non-file format, or the
|
||||
// file *manifest* itself). Real file fetches use `0..n`.
|
||||
#define PUNKTFUNK_CLIP_FILE_INDEX_NONE UINT32_MAX
|
||||
#define CLIP_FILE_INDEX_NONE UINT32_MAX
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// Type byte of [`CursorShape`] (host → client): the pointer's bitmap + hotspot changed.
|
||||
#define PUNKTFUNK_MSG_CURSOR_SHAPE 80
|
||||
#define MSG_CURSOR_SHAPE 80
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// Type byte of [`CursorRenderMode`] (client → host): who renders the pointer right now.
|
||||
#define PUNKTFUNK_MSG_CURSOR_RENDER 81
|
||||
#define MSG_CURSOR_RENDER 81
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
@@ -1002,7 +990,7 @@
|
||||
// overshoots before the 17-byte header. 120² (57.6 KiB + header) fits with headroom and covers
|
||||
// real cursors (typically ≤ 64 px, ≤ 96 px at HiDPI scale); the HOST downscales anything
|
||||
// larger before forwarding, so the cap is invisible to clients.
|
||||
#define PUNKTFUNK_CURSOR_SHAPE_MAX_SIDE 120
|
||||
#define CURSOR_SHAPE_MAX_SIDE 120
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
@@ -1022,21 +1010,21 @@
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// Microphone uplink: the client's mic, Opus-encoded, client → host (the inverse of
|
||||
// [`AUDIO_MAGIC`]). The host feeds it into a virtual PipeWire source so its apps can record it.
|
||||
#define PUNKTFUNK_MIC_MAGIC 203
|
||||
#define MIC_MAGIC 203
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// Rich client→host input: events too big for the fixed 18-byte [`InputEvent`]
|
||||
// (crate::input::InputEvent) — the DualSense touchpad and motion sensors. Variable-length,
|
||||
// kind-tagged (see [`RichInput`]).
|
||||
#define PUNKTFUNK_RICH_INPUT_MAGIC 204
|
||||
#define RICH_INPUT_MAGIC 204
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// HID output, host → client: DualSense feedback a game wrote to the host's virtual controller
|
||||
// (lightbar, player LEDs, adaptive triggers) — the rich analog of [`RUMBLE_MAGIC`]. See
|
||||
// [`HidOutput`].
|
||||
#define PUNKTFUNK_HIDOUT_MAGIC 205
|
||||
#define HIDOUT_MAGIC 205
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
@@ -1076,7 +1064,7 @@
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// Wire length of a v1 (legacy, level) rumble datagram.
|
||||
#define PUNKTFUNK_RUMBLE_V1_LEN 7
|
||||
#define RUMBLE_V1_LEN 7
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
@@ -1084,60 +1072,48 @@
|
||||
// tail. Decoders are length-tolerant (see [`decode_rumble_envelope`]): an old client reads the
|
||||
// first 7 bytes as a plain level and ignores the tail, so no wire-version bump is needed — the
|
||||
// same dual-size idiom the HDR-luminance `AddRequest` tail uses.
|
||||
#define PUNKTFUNK_RUMBLE_V2_LEN 10
|
||||
#define RUMBLE_V2_LEN 10
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// Longest raw HID report a [`RichInput::HidReport`] / [`HidOutput::HidRaw`] can carry — the
|
||||
// 64-byte interrupt/feature report size every Valve controller uses (Triton input reports are
|
||||
// 46–54 bytes; feature and output reports are at most 64).
|
||||
#define PUNKTFUNK_HID_REPORT_MAX 64
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// Longest [`HidOutput::Trigger`] `effect` the wire carries: the DualSense adaptive-trigger
|
||||
// parameter block is a mode byte plus ten parameters, and every consumer copies at most this many
|
||||
// into its report.
|
||||
//
|
||||
// The single source for the clamp on BOTH sides. `Trigger` was the only variable-length variant
|
||||
// bounded on neither: encode appended whatever it was handed and decode took the entire tail, so
|
||||
// an attacker-sized datagram was reproduced verbatim into a `Vec` while its sibling `HidRaw` had
|
||||
// been bounded on both ends all along.
|
||||
#define PUNKTFUNK_TRIGGER_EFFECT_MAX 11
|
||||
#define HID_REPORT_MAX 64
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// [`HidOutput::HidRaw`] `kind`: an OUTPUT report — what the host's hidraw client wrote with
|
||||
// `write()`/`SDL_hid_write` (Triton rumble `0x80`, haptic pulse `0x81`, …). The client replays
|
||||
// it on the physical device's interrupt-OUT endpoint / GATT write.
|
||||
#define PUNKTFUNK_HID_RAW_OUTPUT 0
|
||||
#define HID_RAW_OUTPUT 0
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// [`HidOutput::HidRaw`] `kind`: a FEATURE report — what the host's hidraw client sent with
|
||||
// `SET_REPORT` (`SDL_hid_send_feature_report`: lizard mode, IMU enable, settings). The client
|
||||
// replays it as a USB `SET_REPORT(Feature)` control transfer / GATT feature write.
|
||||
#define PUNKTFUNK_HID_RAW_FEATURE 1
|
||||
#define HID_RAW_FEATURE 1
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// HDR static-metadata datagram tag, host → client (the static analog of the per-frame VUI;
|
||||
// see [`HdrMeta`]). Next tag after [`HIDOUT_MAGIC`].
|
||||
#define PUNKTFUNK_HDR_META_MAGIC 206
|
||||
#define HDR_META_MAGIC 206
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// Wire length of an [`HdrMeta`] body (no tag byte): 6×u16 primaries + 2×u16 white + 2×u32
|
||||
// luminance + 2×u16 CLL/FALL = 28 bytes. Shared by the [`HDR_META_MAGIC`] datagram (which
|
||||
// prefixes the tag) and the `Hello::display_hdr` trailing field (which carries the bare body).
|
||||
#define PUNKTFUNK_HDR_META_BODY_LEN (((12 + 4) + 8) + 4)
|
||||
#define HDR_META_BODY_LEN (((12 + 4) + 8) + 4)
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// Per-AU host-timing datagram tag, host → client (see [`HostTiming`]). Next tag after
|
||||
// [`HDR_META_MAGIC`]. Emitted once per access unit, right after its last packet left the host's
|
||||
// socket, and only when the client advertised [`VIDEO_CAP_HOST_TIMING`].
|
||||
#define PUNKTFUNK_HOST_TIMING_MAGIC 207
|
||||
#define HOST_TIMING_MAGIC 207
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
@@ -1148,18 +1124,18 @@
|
||||
// self-healing under loss (latest-wins, no refresh timer). The bitmap itself rides the
|
||||
// reliable control stream ([`CursorShape`](super::control::CursorShape)); this 14-byte
|
||||
// datagram only moves/hides the pointer.
|
||||
#define PUNKTFUNK_CURSOR_STATE_MAGIC 208
|
||||
#define CURSOR_STATE_MAGIC 208
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// [`CursorState::flags`] bit: the host cursor is visible.
|
||||
#define PUNKTFUNK_CURSOR_VISIBLE 1
|
||||
#define CURSOR_VISIBLE 1
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// [`CursorState::flags`] bit: a host app captured/hid the pointer — the client SHOULD run
|
||||
// relative/captured (M3 auto-flip; advisory, user override always wins).
|
||||
#define PUNKTFUNK_CURSOR_RELATIVE_HINT 2
|
||||
#define CURSOR_RELATIVE_HINT 2
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
@@ -1168,7 +1144,7 @@
|
||||
// `ApplicationClosed` reason and tears the session's virtual display down immediately, skipping the
|
||||
// keep-alive linger; any other close reason (idle timeout, reset, a bare code 0) still lingers so a
|
||||
// reconnect can resume. Shared so host + every client agree on the code.
|
||||
#define PUNKTFUNK_QUIT_CLOSE_CODE 81
|
||||
#define QUIT_CLOSE_CODE 81
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
@@ -1178,107 +1154,107 @@
|
||||
// surfacing a failure (`design/gamemode-and-dedicated-sessions.md` §5.3). Sibling of
|
||||
// [`QUIT_CLOSE_CODE`]; a client that doesn't special-case it still ends the session (every client
|
||||
// returns to its launcher on session end), so it is purely refinement. Shared so host + clients agree.
|
||||
#define PUNKTFUNK_APP_EXITED_CLOSE_CODE 82
|
||||
#define APP_EXITED_CLOSE_CODE 82
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// Longest device name carried in a [`Hello`] (bytes of UTF-8; longer names are truncated on
|
||||
// encode, rejected on decode — a one-byte length prefix caps it at 255 anyway).
|
||||
#define PUNKTFUNK_HELLO_NAME_MAX 64
|
||||
#define HELLO_NAME_MAX 64
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// Longest library id carried in a [`Hello::launch`] (bytes of UTF-8). Ids are short
|
||||
// (`steam:<appid>` / `custom:<12 hex>`); the cap just bounds an attacker-controlled field.
|
||||
#define PUNKTFUNK_HELLO_LAUNCH_MAX 128
|
||||
#define HELLO_LAUNCH_MAX 128
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// [`Welcome::cipher`] id: AES-128-GCM — the default session AEAD every peer speaks (and the
|
||||
// only one pre-cipher builds know).
|
||||
#define PUNKTFUNK_CIPHER_AES_128_GCM 0
|
||||
#define CIPHER_AES_128_GCM 0
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// [`Welcome::cipher`] id: ChaCha20-Poly1305 (RFC 8439) — negotiated via
|
||||
// [`VIDEO_CAP_CHACHA20`] for clients without hardware AES.
|
||||
#define PUNKTFUNK_CIPHER_CHACHA20_POLY1305 1
|
||||
#define CIPHER_CHACHA20_POLY1305 1
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// Type byte of [`PairRequest`].
|
||||
#define PUNKTFUNK_MSG_PAIR_REQUEST 16
|
||||
#define MSG_PAIR_REQUEST 16
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// Type byte of [`PairChallenge`].
|
||||
#define PUNKTFUNK_MSG_PAIR_CHALLENGE 17
|
||||
#define MSG_PAIR_CHALLENGE 17
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// Type byte of [`PairProof`].
|
||||
#define PUNKTFUNK_MSG_PAIR_PROOF 18
|
||||
#define MSG_PAIR_PROOF 18
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// Type byte of [`PairResult`].
|
||||
#define PUNKTFUNK_MSG_PAIR_RESULT 19
|
||||
#define MSG_PAIR_RESULT 19
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// [`PenSample::state`] bit: the pen is in the hover range of the surface. Implied by
|
||||
// [`PEN_TOUCHING`] (decode normalizes, so a client that only sets TOUCHING still produces a
|
||||
// coherent contact).
|
||||
#define PUNKTFUNK_PEN_IN_RANGE 1
|
||||
#define PEN_IN_RANGE 1
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// [`PenSample::state`] bit: the tip is in contact with the surface.
|
||||
#define PUNKTFUNK_PEN_TOUCHING 2
|
||||
#define PEN_TOUCHING 2
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// [`PenSample::state`] bit: the primary barrel button (or the client's squeeze mapping) is held.
|
||||
#define PUNKTFUNK_PEN_BARREL1 4
|
||||
#define PEN_BARREL1 4
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// [`PenSample::state`] bit: the secondary barrel button (or the client's double-tap mapping)
|
||||
// is held.
|
||||
#define PUNKTFUNK_PEN_BARREL2 8
|
||||
#define PEN_BARREL2 8
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// [`PenSample::state`] bit, RESERVED: a predicted (not yet observed) sample. Never sent v1;
|
||||
// receivers MUST ignore samples carrying it until a capability negotiates otherwise
|
||||
// (design/pen-tablet-input.md §8).
|
||||
#define PUNKTFUNK_PEN_PREDICTED 128
|
||||
#define PEN_PREDICTED 128
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// [`PenSample::tilt_deg`] sentinel: the client has no tilt sensor / no reading.
|
||||
#define PUNKTFUNK_PEN_TILT_UNKNOWN 255
|
||||
#define PEN_TILT_UNKNOWN 255
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// [`PenSample::azimuth_deg`] / [`PenSample::roll_deg`] sentinel: no reading.
|
||||
#define PUNKTFUNK_PEN_ANGLE_UNKNOWN 65535
|
||||
#define PEN_ANGLE_UNKNOWN 65535
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// [`PenSample::distance`] sentinel: no hover-distance reading.
|
||||
#define PUNKTFUNK_PEN_DISTANCE_UNKNOWN 65535
|
||||
#define PEN_DISTANCE_UNKNOWN 65535
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// Most samples one [`PenBatch`] can carry. Sized for coalesced capture at video-frame cadence
|
||||
// (240 Hz pen ÷ 30 fps = 8); a client producing more splits into consecutive batches.
|
||||
#define PUNKTFUNK_PEN_BATCH_MAX 8
|
||||
#define PEN_BATCH_MAX 8
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// Wire length of one encoded [`PenSample`].
|
||||
#define PUNKTFUNK_PEN_SAMPLE_WIRE_LEN 21
|
||||
#define PEN_SAMPLE_WIRE_LEN 21
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
@@ -1289,13 +1265,13 @@
|
||||
// pen is naturally silent — senders MUST repeat the last sample at least every ~100 ms while
|
||||
// the pen is in range or touching (it re-decodes as pure Motion, harmless), keeping a live
|
||||
// stationary stroke two heartbeats clear of the deadline.
|
||||
#define PUNKTFUNK_PEN_TOUCH_TIMEOUT_MS 200
|
||||
#define PEN_TOUCH_TIMEOUT_MS 200
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// Stream-kind byte: a clipboard fetch (request/response of one format). Future stream kinds
|
||||
// (e.g. a bulk file-content push) mux under the same [`STREAM_MAGIC`] with a different byte.
|
||||
#define PUNKTFUNK_CLIP_STREAM_KIND_FETCH 1
|
||||
#define CLIP_STREAM_KIND_FETCH 1
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
@@ -1305,18 +1281,18 @@
|
||||
// `0x52`), the connection reject code `0x42`, and the pairing-rejection close block
|
||||
// `0x60`–`0x67` — stream reset codes and connection close codes are separate QUIC namespaces,
|
||||
// but the vocabularies stay disjoint on purpose so a captured code is unambiguous.
|
||||
#define PUNKTFUNK_CLIP_CANCELLED_CODE 112
|
||||
#define CLIP_CANCELLED_CODE 112
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// Chunk size for streaming fetch data (64 KiB writes — matches the control-frame bound).
|
||||
#define PUNKTFUNK_CLIP_CHUNK (64 * 1024)
|
||||
#define CLIP_CHUNK (64 * 1024)
|
||||
#endif
|
||||
|
||||
// Consecutive no-output AUs that force a keyframe request. ~50 ms at 60 Hz — long enough not to fire
|
||||
// on a one-frame decoder hiccup, short enough that a lost initial IDR (or a mid-GOP join) unfreezes
|
||||
// almost immediately instead of never.
|
||||
#define PUNKTFUNK_NO_OUTPUT_KEYFRAME_STREAK 3
|
||||
#define NO_OUTPUT_KEYFRAME_STREAK 3
|
||||
|
||||
// How many host intra-refresh recovery marks ([`USER_FLAG_RECOVERY_POINT`]) must arrive since the
|
||||
// latest loss before the gate lifts its freeze on an IDR-free stream. TWO, not one: with a continuous
|
||||
@@ -1328,12 +1304,12 @@
|
||||
// deliberate "hold longer, never show garbage" trade.
|
||||
//
|
||||
// [`USER_FLAG_RECOVERY_POINT`]: crate::packet::USER_FLAG_RECOVERY_POINT
|
||||
#define PUNKTFUNK_REANCHOR_MARKS_TO_LIFT 2
|
||||
#define REANCHOR_MARKS_TO_LIFT 2
|
||||
|
||||
// QUIC application error code the host closes with on a `mode_conflict = reject` admission
|
||||
// refusal, carrying the human-readable busy reason (live mode + client label). A distinct code
|
||||
// lets a client tell "host busy" apart from a transport failure. Shared so clients can render it.
|
||||
#define PUNKTFUNK_REJECT_BUSY_CLOSE_CODE 66
|
||||
#define REJECT_BUSY_CLOSE_CODE 66
|
||||
|
||||
// QUIC application close codes the host sends on **pairing-gate rejections**, so a client can
|
||||
// tell the user WHY it was turned away instead of collapsing every close into a generic
|
||||
@@ -1342,44 +1318,44 @@
|
||||
// their own 0x60 block, disjoint from [`REJECT_BUSY_CLOSE_CODE`] (0x42) and the deliberate-end
|
||||
// codes (0x51/0x52). Purely additive: an older client treats them as a bare close (exactly the
|
||||
// pre-code behavior), an older host never sends them. Decode with [`RejectReason::from_close_code`].
|
||||
#define PUNKTFUNK_PAIR_NOT_ARMED_CLOSE_CODE 96
|
||||
#define PAIR_NOT_ARMED_CLOSE_CODE 96
|
||||
|
||||
// Pairing window armed, but bound to a DIFFERENT device fingerprint (the attempt does not
|
||||
// consume the window). See [`PAIR_NOT_ARMED_CLOSE_CODE`] for the block's contract.
|
||||
#define PUNKTFUNK_PAIR_BOUND_OTHER_CLOSE_CODE 97
|
||||
#define PAIR_BOUND_OTHER_CLOSE_CODE 97
|
||||
|
||||
// PIN attempt inside the host's global pairing cooldown — retry shortly.
|
||||
#define PUNKTFUNK_PAIR_RATE_LIMITED_CLOSE_CODE 98
|
||||
#define PAIR_RATE_LIMITED_CLOSE_CODE 98
|
||||
|
||||
// Unpaired client presented no certificate: nothing to approve, and the SPAKE2 ceremony needs an
|
||||
// identity to bind — the PIN flow with a client identity is the way in.
|
||||
#define PUNKTFUNK_PAIR_NO_IDENTITY_CLOSE_CODE 99
|
||||
#define PAIR_NO_IDENTITY_CLOSE_CODE 99
|
||||
|
||||
// The operator explicitly denied this pairing request in the host console.
|
||||
#define PUNKTFUNK_PAIR_DENIED_CLOSE_CODE 100
|
||||
#define PAIR_DENIED_CLOSE_CODE 100
|
||||
|
||||
// Nobody decided on the parked pairing request before the host's approval wait elapsed.
|
||||
#define PUNKTFUNK_PAIR_APPROVAL_TIMEOUT_CLOSE_CODE 101
|
||||
#define PAIR_APPROVAL_TIMEOUT_CLOSE_CODE 101
|
||||
|
||||
// This parked knock was superseded by a newer connection from the same device — only the
|
||||
// newest is admitted on approval.
|
||||
#define PUNKTFUNK_PAIR_SUPERSEDED_CLOSE_CODE 102
|
||||
#define PAIR_SUPERSEDED_CLOSE_CODE 102
|
||||
|
||||
// The client's wire (protocol) version does not match the host's — one side needs updating.
|
||||
#define PUNKTFUNK_WIRE_VERSION_CLOSE_CODE 103
|
||||
#define WIRE_VERSION_CLOSE_CODE 103
|
||||
|
||||
// The host admitted the connection but could not stand the stream session up (compositor /
|
||||
// capture / encoder setup failed host-side). The close reason bytes carry the specific error
|
||||
// text for logs/diagnostics; clients render a stable "host-side failure" sentence. Before this
|
||||
// code, a setup failure reached the client as a bare dropped connection ("control stream
|
||||
// finished mid-frame") — indistinguishable from transport trouble.
|
||||
#define PUNKTFUNK_SETUP_FAILED_CLOSE_CODE 104
|
||||
#define SETUP_FAILED_CLOSE_CODE 104
|
||||
|
||||
// Minimum supported multiplier (renders under native, upscaled on present).
|
||||
#define PUNKTFUNK_MIN_SCALE 0.5
|
||||
#define MIN_SCALE 0.5
|
||||
|
||||
// Maximum supported multiplier (supersamples, clamped to the codec ceiling per axis).
|
||||
#define PUNKTFUNK_MAX_SCALE 4.0
|
||||
#define MAX_SCALE 4.0
|
||||
|
||||
// Stable C ABI status codes. `Ok` is 0; all errors are negative so callers can
|
||||
// test `rc < 0`. Do not renumber existing variants — only append.
|
||||
@@ -1925,7 +1901,7 @@ typedef struct {
|
||||
|
||||
// The multipliers a picker offers. `1.0` (Native) is the default; the rest are the round stops
|
||||
// users reason about. Shared so every client's list stays identical.
|
||||
#define PUNKTFUNK_PRESETS { 0.5, 0.67, 0.75, 1.0, 1.25, 1.5, 2.0, 3.0, 4.0, }
|
||||
#define PRESETS { 0.5, 0.67, 0.75, 1.0, 1.25, 1.5, 2.0, 3.0, 4.0, }
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
|
||||
Reference in New Issue
Block a user