feat(client): settings profiles — the override catalog, the one resolver, and the session flag

A client setting is global today: pick 4K@120 HDR and the retro box gets it too.
The one escape hatch (per-host `clipboard_sync`) proves the shape works but covers a
single field. This is P0 of design/client-settings-profiles.md — the headless core the
shells, Apple, Android and the deep-link grammar all build against. No UI yet.

A profile is a NAMED BUNDLE OF OVERRIDES, not a snapshot: `SettingsOverlay` is sparse
`Option`s, so a field the user never touched keeps following the global value live, and
fixing a global once fixes it everywhere. `Some(x)` equal to today's global is still
meaningful — it pins x against a later global change — which is why the UI will write
`Some` on touch and `None` on an explicit reset, never by diffing.

The catalog is its own `client-profiles.json`, deliberately not part of the settings
file: that file has five whole-file load-modify-save writers with no merge, so a profile
written by one would be dropped by the next. Which host uses which profile is a field on
the host record instead of a map keyed by host — that is what dissolves the client's
long-standing "what identifies a host record" question for this feature.

Resolution has exactly one implementation, `trust::effective_settings()`:

    effective = overlay(profile).apply(global)
    profile   = one-off pick ?? host binding ?? none

Both session construction sites go through it (`main.rs` AND `console.rs` — touching one
and not the other is a Windows-only build break), so the console, and therefore Decky,
honor host bindings with no work of their own. Nothing here can fail a connect: a
deleted binding resolves as "no profile", and a one-off that can't be honored falls back
to the DEFAULTS rather than to the host's own profile — "connect with Work" must never
quietly stream "Game". `--profile <id|name>` is the one-off door for the shells' coming
"Connect with ▸" and for `punktfunk://…&profile=`; `--profile ""` forces the defaults on
a bound host. The active profile's name closes the stats overlay's first line, so
"which profile am I on?" is answerable without leaving the stream.

Also here, because this effort touches every one of these anyway:

- `KnownHost` gains `profile_id`, `pinned_profiles` and a lazily minted stable `id`. The
  id is groundwork (design §4.5) — no lookup is re-keyed, `fp_hex`/`addr:port` stay.
- `upsert` now preserves the state the USER set on a record — the profile binding, its
  pins, the clipboard decision, the id — against refreshes that carry none of it.
  `clipboard_sync` survived that only because the function happened not to mention it.
- `KnownHost::default()` exists so construction sites read `..Default::default()`; five
  hand-written literals were exactly how a new field gets silently dropped on re-pair.
- All three client stores now write temp+rename. A torn settings file loads as `Default`
  — i.e. silently resets every setting the user has.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-28 20:22:33 +02:00
co-authored by Claude Opus 5
parent 33a31427ae
commit a2be3f4de9
12 changed files with 1043 additions and 34 deletions
+1 -5
View File
@@ -336,11 +336,7 @@ pub fn headless_add_host(target: &str) -> glib::ExitCode {
name,
addr: addr.clone(),
port,
fp_hex: String::new(),
paired: false,
last_used: None,
mac: Vec::new(),
clipboard_sync: false,
..Default::default()
});
}
match known.save() {
+9 -8
View File
@@ -201,11 +201,16 @@ pub fn run(target: Option<&str>) -> u8 {
tracing::info!(%addr, %title, request_access,
launch = launch.as_deref().unwrap_or("desktop"),
"launching from the console");
// Settings re-load per launch: the console's own settings screen
// may have changed them since the last stream.
let settings = trust::Settings::load();
// Settings re-resolve per launch: the console's own settings screen may
// have changed the defaults since the last stream, and the host may carry
// a profile binding. Console (and therefore Decky, which spawns this
// binary) honors bindings with no console-side work — the resolver is the
// same one `--connect` goes through. No one-off here: picking a profile is
// a desktop-shell affordance in v1, pinned cards are the console's.
let (settings, profile) = trust::effective_settings(&addr, port, None);
let mut params = session_params(
&settings,
profile.map(|p| p.name),
addr.clone(),
port,
pin,
@@ -434,11 +439,7 @@ impl ServiceState {
name: if name.is_empty() { addr.clone() } else { name },
addr,
port,
fp_hex: String::new(),
paired: false,
last_used: None,
mac: Vec::new(),
clipboard_sync: false,
..Default::default()
});
}
if let Err(e) = known.save() {
+30 -4
View File
@@ -124,6 +124,15 @@ mod session_main {
}
}
/// `--profile <id|name>` — the settings profile this one session runs with, overriding the
/// host's own binding for this launch only (never rebinding it): the shells' "Connect
/// with ▸ X" and a `punktfunk://…&profile=` link both land here. Absent = honor the host's
/// binding; `--profile ""` (or a bare `--profile`) forces the global defaults, which is
/// how "Connect with ▸ Default settings" reaches a bound host.
fn profile_arg() -> Option<String> {
arg_flag("--profile").then(|| arg_value("--profile").unwrap_or_default())
}
/// The connect budget: 15 s normally; `--connect-timeout SECS` overrides — the
/// shell's request-access flow passes ~185 s because the host PARKS the connection
/// until the operator clicks Approve.
@@ -135,13 +144,20 @@ mod session_main {
)
}
/// One session's pump parameters from the Settings store — shared by `--connect`
/// One session's pump parameters from the EFFECTIVE settings — shared by `--connect`
/// and every `--browse` launch. Explicit settings, `0` fields resolved to the
/// window's display (the GTK client reads the monitor under its window — same
/// contract).
///
/// `settings` is what [`trust::effective_settings`] returned, never a raw
/// `Settings::load()`: both callers resolve the host's profile first, so the two
/// construction sites cannot drift (they historically did — touching one and not the
/// other is a Windows-only build break). `profile` is that profile's name, for the
/// stats overlay's first line.
#[allow(clippy::too_many_arguments)]
pub(crate) fn session_params(
settings: &trust::Settings,
profile: Option<String>,
addr: String,
port: u16,
pin: [u8; 32],
@@ -246,6 +262,7 @@ mod session_main {
identity,
connect_timeout: connect_timeout(),
force_software,
profile,
}
}
@@ -431,14 +448,16 @@ mod session_main {
}
let Some(target) = arg_value("--connect") else {
eprintln!(
"usage: punktfunk-session --connect host[:port] [--fp HEX] [--launch id] [--fullscreen]\n\
"usage: punktfunk-session --connect host[:port] [--fp HEX] [--launch id] [--profile REF] [--fullscreen]\n\
\x20 punktfunk-session --browse [host[:port]] [--mgmt PORT] [--fullscreen] [--json-status]\n\
\x20 punktfunk-session --pair <PIN> --connect host[:port] [--name LABEL]\n\
\n\
Streams from a paired punktfunk host in a Vulkan window. --browse opens the\n\
gamepad console instead: bare --browse is the host list (discovery, PIN\n\
pairing, settings, wake-on-LAN); with a target it opens that host's game\n\
library. --connect never dials a host it has no pinned fingerprint for —\n\
library. --profile picks a settings profile by id or name for this session\n\
only (\"\" = the global defaults); without it the host's own profile applies.\n\
--connect never dials a host it has no pinned fingerprint for —\n\
enrol with --pair (no display needed), in the console, or from the desktop\n\
client."
);
@@ -453,7 +472,13 @@ mod session_main {
return EXIT_CONNECT_FAILED;
}
};
let settings = trust::Settings::load();
// Global defaults with this host's settings profile overlaid — the binding on the
// host record, or `--profile <id|name>` for a one-off (`--profile ""` forces the
// defaults). Resolved through the shared helper, exactly like the console's launches.
let (settings, profile) = trust::effective_settings(&addr, port, profile_arg().as_deref());
if let Some(p) = &profile {
tracing::info!(profile = %p.name, id = %p.id, "streaming with a settings profile");
}
// Trust follows the GTK client's `--connect` rules: a stored (or `--fp`) pin
// connects silently; an unknown host is REFUSED — there is no dialog here, and a
@@ -523,6 +548,7 @@ mod session_main {
pf_presenter::run_session(opts, move |gamepad, native, force_software, vulkan| {
session_params(
&settings,
profile.map(|p| p.name),
addr,
port,
pin,
+2 -5
View File
@@ -272,9 +272,8 @@ fn connect_spawn(
port: target.port,
fp_hex: fp_hex.clone(),
paired: persist_paired,
last_used: None,
mac: target.mac.clone(),
clipboard_sync: false,
..Default::default()
});
let _ = k.save();
}
@@ -481,10 +480,8 @@ fn wake_and_connect(
addr: target.addr.clone(),
port: target.port,
fp_hex: fp,
paired: false,
last_used: None,
mac: target.mac.clone(),
clipboard_sync: false,
..Default::default()
});
let _ = k.save();
}
+1 -2
View File
@@ -57,9 +57,8 @@ pub(crate) fn pair_page(props: &Svc, cx: &mut RenderCx) -> Element {
port: target3.port,
fp_hex: trust::hex(&fp),
paired: true,
last_used: None,
mac: target3.mac.clone(),
clipboard_sync: false,
..Default::default()
});
let _ = k.save();
connect(&ctx3, &target3, Some(fp), &ss, &st);