Files
punktfunk/clients/windows/src/app/pair.rs
T
enricobuehlerandClaude Opus 5 a2be3f4de9 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>
2026-07-28 20:22:33 +02:00

147 lines
6.3 KiB
Rust

//! The SPAKE2 PIN pairing screen: the host is armed and displays a 4-digit PIN; proving
//! knowledge of it pins the host's certificate (and registers ours) with no offline-guessable
//! transcript. Also offers the no-PIN "request access" (delegated-approval) alternative.
use super::connect::{connect, request_access};
use super::style::*;
use super::{Screen, Svc};
use crate::trust::{self, KnownHost, KnownHosts};
use punktfunk_core::client::NativeClient;
use windows_reactor::*;
pub(crate) fn pair_page(props: &Svc, cx: &mut RenderCx) -> Element {
let ctx = &props.ctx;
let set_screen = &props.set_screen;
let set_status = &props.set_status;
let (code, set_code) = cx.use_state(String::new());
// The PIN's live value, read directly by the click handler. This page's props (`Svc`) never
// change, and root wraps every screen in an animated `border` that compares equal once the
// entrance tween settles — so the top-down reconcile `can_skip_update`s this subtree and never
// re-renders the pair component off its *local* `use_state`. A button rebuilt only at mount
// would forever capture the empty mount-time PIN (pairing then fails as a "wrong PIN"). Mirror
// every keystroke into this stable ref instead, so the click reads exactly what was typed.
let live_pin = cx.use_ref(String::new());
let target = ctx.shared.target.lock().unwrap().clone();
let pair_btn = {
let (ctx2, ss, st, live, target2) = (
ctx.clone(),
set_screen.clone(),
set_status.clone(),
live_pin.clone(),
target.clone(),
);
button("Pair & Connect")
.accent()
.icon(Symbol::Accept)
.on_click(move || {
let pin = live.borrow().trim().to_string();
let (ctx3, ss, st, target3) =
(ctx2.clone(), ss.clone(), st.clone(), target2.clone());
std::thread::spawn(move || {
let name =
std::env::var("COMPUTERNAME").unwrap_or_else(|_| "windows-client".into());
match NativeClient::pair(
&target3.addr,
target3.port,
(&ctx3.identity.0, &ctx3.identity.1),
&pin,
&name,
std::time::Duration::from_secs(90),
) {
Ok(fp) => {
let mut k = KnownHosts::load();
k.upsert(KnownHost {
name: target3.name.clone(),
addr: target3.addr.clone(),
port: target3.port,
fp_hex: trust::hex(&fp),
paired: true,
mac: target3.mac.clone(),
..Default::default()
});
let _ = k.save();
connect(&ctx3, &target3, Some(fp), &ss, &st);
}
Err(e) => {
// Cause-specific: wrong PIN vs pairing-not-armed vs unreachable —
// never blame the PIN for a dead network path (shared wording).
st.call(trust::pair_error_message(&e));
ss.call(Screen::Hosts);
}
}
});
})
};
let cancel_btn = {
let ss = set_screen.clone();
button("Cancel")
.icon(Symbol::Cancel)
.on_click(move || ss.call(Screen::Hosts))
};
// The no-PIN alternative offered alongside the PIN ceremony: open an identified connect that
// the host parks until the operator approves this device in its console (delegated approval).
let request_btn = {
let (svc, target2) = (props.clone(), target.clone());
button("Request access without a PIN")
.icon(Symbol::Send)
.on_click(move || request_access(&svc, &target2))
.horizontal_alignment(HorizontalAlignment::Stretch)
};
let content = card(vstack((
grid((
avatar(&target.name)
.grid_column(0)
.vertical_alignment(VerticalAlignment::Center),
vstack((
text_block(format!("Pair with {}", target.name))
.font_size(20.0)
.semibold(),
text_block(format!("{}:{}", target.addr, target.port))
.font_size(12.0)
.foreground(ThemeRef::SecondaryText),
))
.spacing(2.0)
.grid_column(1)
.vertical_alignment(VerticalAlignment::Center)
.margin(edges(12.0, 0.0, 0.0, 0.0)),
))
.columns([GridLength::Auto, GridLength::Star(1.0)]),
InfoBar::new("Arm pairing on the host")
.message(
"On the host's console or web console, start pairing — it shows a 4-digit PIN. \
Enter it below within 90 seconds.",
)
.informational()
.is_closable(false),
text_box(code)
.placeholder_text("PIN")
.font_size(28.0)
.on_text_changed({
let live = live_pin.clone();
move |s: String| {
// Record the live value for the click handler (the source of truth for the
// PIN), and mirror it into `code` so the field stays correct if anything ever
// does re-render this page (theme/DPI change).
live.set(s.clone());
set_code.call(s);
}
}),
hstack((pair_btn, cancel_btn)).spacing(8.0),
text_block(
"Don\u{2019}t have a PIN? Request access instead and approve this device on the host \
(its console or web UI) \u{2014} no PIN needed.",
)
.font_size(12.0)
.foreground(ThemeRef::SecondaryText),
request_btn,
))
.spacing(16.0))
.max_width(480.0)
.horizontal_alignment(HorizontalAlignment::Center)
.margin(edges(0.0, 60.0, 0.0, 0.0));
page(vec![content.into()])
}