8ab262f8f8
apple / swift (push) Successful in 54s
ci / rust (push) Failing after 1m12s
ci / web (push) Successful in 29s
android / android (push) Failing after 1m49s
ci / docs-site (push) Successful in 31s
ci / bench (push) Successful in 1m48s
decky / build-publish (push) Successful in 12s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 3s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 19s
flatpak / build-publish (push) Failing after 3s
deb / build-publish (push) Failing after 2m43s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 5m22s
docker / deploy-docs (push) Successful in 17s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 5m20s
TOFU let anyone who could reach the host click "Trust" and stream, which defeats the point on a LAN. Make SPAKE2 PIN pairing the default and only way to trust a NEW host; TOFU survives as an explicit HOST opt-in (for fully trusted networks), advertised over mDNS so clients render their trust UI from the host's policy rather than offering trust on faith. Contract: - Host advertises pair=required (default) or pair=optional. pair=required rejects unpaired clients at the handshake; pair=optional accepts them (TOFU). - Clients: a pinned host whose fingerprint matches connects silently; a pinned host whose fingerprint CHANGED forces re-pairing via PIN (no re-trust shortcut); a NEW host is offered TOFU only if it advertised pair=optional, otherwise PIN pairing is mandatory; a manually-typed or unknown-policy host is always PIN. Host (crates/punktfunk-host/src/main.rs): - m3-host now REQUIRES pairing by default (was open by default). New --allow-tofu opts into accepting unpaired clients + advertising pair=optional; pairing is always armed (PIN logged at startup). serve --native was already secure-by-default (serve --open). The mDNS advert and the accept loop already mapped require_pairing -> pair=required + reject; only the m3-host CLI default + help text changed. Clients honor the advertised policy: - Android (MainActivity.kt): TOFU only for a discovered pair=optional host; manual/unknown -> PIN; fp-change -> re-pair only (dropped the "Forget & re-TOFU" shortcut). - Apple (HostDiscovery/SessionModel/ContentView/HostCards/HostStore): new allowsTofu (pair==optional, distinct from unknown); connect() gates .awaitingTrust on it; unpinned non-optional hosts route to the PIN sheet; "Forget Identity" re-pairs rather than re-TOFUs. - Linux (app.rs/ui_hosts.rs/session.rs): ConnectRequest.pair_required -> pair_optional; initiate_connect routes pinned/fp-changed/optional/else; manual + --connect unknown -> PIN; a pinned connect rejected on trust grounds re-pairs. Docs (CLAUDE.md, README.md, docs-site/content/docs/pairing.md): describe the gated model — PIN is the default, TOFU an explicit opt-in with an impostor warning. Verified: host cargo check/clippy/fmt clean; Android built + live (emulator -> home-worker-2): a manual connect now opens the PIN dialog (no Trust button) and the PIN ceremony streams; Apple swift build clean; Linux clippy -D warnings + fmt clean on the Linux box. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
513 lines
20 KiB
Rust
513 lines
20 KiB
Rust
//! The application shell: window, navigation, trust dialogs, session lifecycle.
|
||
|
||
use crate::session::{SessionEvent, SessionParams};
|
||
use crate::trust::{KnownHost, KnownHosts, Settings};
|
||
use crate::ui_hosts::ConnectRequest;
|
||
use adw::prelude::*;
|
||
use gtk::{gdk, glib};
|
||
use punktfunk_core::client::NativeClient;
|
||
use punktfunk_core::config::{CompositorPref, GamepadPref};
|
||
use std::cell::RefCell;
|
||
use std::rc::Rc;
|
||
|
||
const APP_ID: &str = "io.unom.Punktfunk";
|
||
|
||
struct App {
|
||
window: adw::ApplicationWindow,
|
||
nav: adw::NavigationView,
|
||
toasts: adw::ToastOverlay,
|
||
settings: Rc<RefCell<Settings>>,
|
||
identity: (String, String),
|
||
/// App-lifetime SDL gamepad service: Settings list + per-session capture/feedback.
|
||
gamepad: crate::gamepad::GamepadService,
|
||
/// One session at a time — ignore connects while one is starting/running.
|
||
busy: std::cell::Cell<bool>,
|
||
}
|
||
|
||
impl App {
|
||
fn toast(&self, msg: &str) {
|
||
self.toasts.add_toast(adw::Toast::new(msg));
|
||
}
|
||
}
|
||
|
||
pub fn run() -> glib::ExitCode {
|
||
tracing_subscriber::fmt()
|
||
.with_env_filter(
|
||
tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()),
|
||
)
|
||
.init();
|
||
let app = adw::Application::builder().application_id(APP_ID).build();
|
||
app.connect_activate(build_ui);
|
||
// GTK doesn't see our argv (`--connect` is handled in `build_ui`); an empty argv also
|
||
// keeps GApplication from rejecting unknown options.
|
||
app.run_with_args(&[] as &[&str])
|
||
}
|
||
|
||
/// `--connect host[:port]` — skip the hosts page and start a session immediately
|
||
/// (scripting + headless testing). Trust follows the same rules as a manual entry: a host
|
||
/// already pinned at this address connects silently on its stored pin; an unknown host is
|
||
/// routed to the PIN ceremony (never a silent TOFU connect — `fp_hex`/`pair_optional` are
|
||
/// unset, so `initiate_connect`'s manual arm mandates pairing).
|
||
fn cli_connect_request() -> Option<ConnectRequest> {
|
||
let args: Vec<String> = std::env::args().collect();
|
||
let target = args
|
||
.iter()
|
||
.skip_while(|a| *a != "--connect")
|
||
.nth(1)?
|
||
.clone();
|
||
let (addr, port) = match target.rsplit_once(':') {
|
||
Some((a, p)) => (a.to_string(), p.parse().ok()?),
|
||
None => (target.clone(), 9777),
|
||
};
|
||
Some(ConnectRequest {
|
||
name: addr.clone(),
|
||
addr,
|
||
port,
|
||
fp_hex: None,
|
||
pair_optional: false,
|
||
})
|
||
}
|
||
|
||
fn build_ui(gtk_app: &adw::Application) {
|
||
let identity = match crate::trust::load_or_create_identity() {
|
||
Ok(i) => i,
|
||
Err(e) => {
|
||
tracing::error!("client identity: {e:#}");
|
||
std::process::exit(1);
|
||
}
|
||
};
|
||
|
||
let nav = adw::NavigationView::new();
|
||
let toasts = adw::ToastOverlay::new();
|
||
toasts.set_child(Some(&nav));
|
||
let window = adw::ApplicationWindow::builder()
|
||
.application(gtk_app)
|
||
.title("Punktfunk")
|
||
.default_width(1100)
|
||
.default_height(720)
|
||
.content(&toasts)
|
||
.build();
|
||
|
||
let app = Rc::new(App {
|
||
window: window.clone(),
|
||
nav: nav.clone(),
|
||
toasts,
|
||
settings: Rc::new(RefCell::new(Settings::load())),
|
||
identity,
|
||
gamepad: crate::gamepad::GamepadService::start(),
|
||
busy: std::cell::Cell::new(false),
|
||
});
|
||
|
||
let hosts_page = crate::ui_hosts::new(
|
||
{
|
||
let app = app.clone();
|
||
Rc::new(move |req| initiate_connect(app.clone(), req))
|
||
},
|
||
{
|
||
let app = app.clone();
|
||
Rc::new(move || {
|
||
crate::ui_settings::show(&app.window, app.settings.clone(), &app.gamepad)
|
||
})
|
||
},
|
||
{
|
||
let app = app.clone();
|
||
Rc::new(move |req| speed_test(app.clone(), req))
|
||
},
|
||
);
|
||
nav.add(&hosts_page);
|
||
window.present();
|
||
|
||
if let Some(req) = cli_connect_request() {
|
||
initiate_connect(app, req);
|
||
}
|
||
}
|
||
|
||
/// The trust gate in front of every connect. The host is the policy authority (it
|
||
/// advertises `pair=optional` only when it accepts unpaired clients); the client renders
|
||
/// its trust UI from that:
|
||
/// 1. PINNED RECONNECT — a host already pinned to this exact fingerprint connects silently.
|
||
/// 2. FINGERPRINT CHANGED — a host we know at this address but whose fingerprint no longer
|
||
/// matches is the impostor signal: force re-pairing via the PIN ceremony, regardless of
|
||
/// the advertised policy.
|
||
/// 3. NEW host — TOFU is offered only when the host advertised `pair=optional` (rule 3a);
|
||
/// otherwise (pair=required, unknown/empty policy, or a manual entry) PIN pairing is
|
||
/// mandatory (rule 3b).
|
||
///
|
||
/// A new host is never auto-connected without a stored pin or an explicit trust decision.
|
||
fn initiate_connect(app: Rc<App>, req: ConnectRequest) {
|
||
if app.busy.get() {
|
||
return;
|
||
}
|
||
let known = KnownHosts::load();
|
||
match &req.fp_hex {
|
||
Some(fp_hex) => {
|
||
if known.find_by_fp(fp_hex).is_some() {
|
||
// Rule 1: pinned fingerprint matches — silent connect.
|
||
start_session(app, req.clone(), crate::trust::parse_hex32(fp_hex));
|
||
} else if known.find_by_addr(&req.addr, req.port).is_some() {
|
||
// Rule 2: we trust a host at this address but the fingerprint changed —
|
||
// the impostor signal. Re-pair via the PIN ceremony (no TOFU shortcut).
|
||
app.toast("Host fingerprint changed — re-pair with a PIN to continue");
|
||
pin_dialog(app, req);
|
||
} else if req.pair_optional {
|
||
// Rule 3a: the host opted into reduced-security TOFU; offer it alongside PIN.
|
||
tofu_dialog(app, req);
|
||
} else {
|
||
// Rule 3b: pair=required or unknown policy — PIN pairing is mandatory.
|
||
pin_dialog(app, req);
|
||
}
|
||
}
|
||
None => {
|
||
// Manual entry (no advertised fingerprint). A known address connects silently
|
||
// on its stored pin (rule 1); an unknown one must pair — never silent TOFU.
|
||
match known
|
||
.find_by_addr(&req.addr, req.port)
|
||
.and_then(|k| crate::trust::parse_hex32(&k.fp_hex))
|
||
{
|
||
Some(pin) => start_session(app, req, Some(pin)),
|
||
None => pin_dialog(app, req), // rule 3b
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// First contact with a discovered host: show the advertised fingerprint and let the user
|
||
/// trust it (TOFU), run the PIN ceremony instead, or walk away.
|
||
fn tofu_dialog(app: Rc<App>, req: ConnectRequest) {
|
||
let fp = req.fp_hex.clone().unwrap_or_default();
|
||
let dialog = adw::AlertDialog::new(
|
||
Some("New Host"),
|
||
Some(&format!(
|
||
"{} at {}:{}\n\nCertificate fingerprint:\n{}\n\nPairing with a PIN verifies it; \
|
||
trusting accepts it as-is.",
|
||
req.name, req.addr, req.port, fp
|
||
)),
|
||
);
|
||
dialog.add_responses(&[
|
||
("cancel", "Cancel"),
|
||
("pair", "Pair with PIN…"),
|
||
("trust", "Trust & Connect"),
|
||
]);
|
||
dialog.set_response_appearance("trust", adw::ResponseAppearance::Suggested);
|
||
dialog.set_default_response(Some("trust"));
|
||
dialog.set_close_response("cancel");
|
||
let parent = app.window.clone();
|
||
dialog.connect_response(None, move |_, response| match response {
|
||
"trust" => {
|
||
let mut known = KnownHosts::load();
|
||
known.upsert(KnownHost {
|
||
name: req.name.clone(),
|
||
addr: req.addr.clone(),
|
||
port: req.port,
|
||
fp_hex: fp.clone(),
|
||
paired: false,
|
||
});
|
||
let _ = known.save();
|
||
start_session(app.clone(), req.clone(), crate::trust::parse_hex32(&fp));
|
||
}
|
||
"pair" => pin_dialog(app.clone(), req.clone()),
|
||
_ => {}
|
||
});
|
||
dialog.present(Some(&parent));
|
||
}
|
||
|
||
/// The SPAKE2 ceremony: 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.
|
||
fn pin_dialog(app: Rc<App>, req: ConnectRequest) {
|
||
let entry = gtk::Entry::builder()
|
||
.input_purpose(gtk::InputPurpose::Digits)
|
||
.placeholder_text("4-digit PIN shown by the host")
|
||
.activates_default(true)
|
||
.build();
|
||
let dialog = adw::AlertDialog::new(
|
||
Some("Pair with PIN"),
|
||
Some(&format!(
|
||
"Arm pairing on {} (console or web UI), then enter the PIN it displays.",
|
||
req.name
|
||
)),
|
||
);
|
||
dialog.set_extra_child(Some(&entry));
|
||
dialog.add_responses(&[("cancel", "Cancel"), ("pair", "Pair")]);
|
||
dialog.set_response_appearance("pair", adw::ResponseAppearance::Suggested);
|
||
dialog.set_default_response(Some("pair"));
|
||
dialog.set_close_response("cancel");
|
||
let parent = app.window.clone();
|
||
dialog.connect_response(Some("pair"), move |_, _| {
|
||
let pin = entry.text().to_string();
|
||
let app = app.clone();
|
||
let req = req.clone();
|
||
let identity = app.identity.clone();
|
||
let (tx, rx) = async_channel::bounded::<Result<[u8; 32], String>>(1);
|
||
let (host, port, name) = (req.addr.clone(), req.port, glib::host_name().to_string());
|
||
std::thread::spawn(move || {
|
||
let result = NativeClient::pair(
|
||
&host,
|
||
port,
|
||
(&identity.0, &identity.1),
|
||
pin.trim(),
|
||
&name,
|
||
std::time::Duration::from_secs(90),
|
||
)
|
||
.map_err(|e| format!("Pairing failed: {e:?} (wrong PIN, or pairing not armed?)"));
|
||
let _ = tx.send_blocking(result);
|
||
});
|
||
glib::spawn_future_local(async move {
|
||
match rx.recv().await {
|
||
Ok(Ok(fp)) => {
|
||
let fp_hex = crate::trust::hex(&fp);
|
||
let mut known = KnownHosts::load();
|
||
known.upsert(KnownHost {
|
||
name: req.name.clone(),
|
||
addr: req.addr.clone(),
|
||
port: req.port,
|
||
fp_hex,
|
||
paired: true,
|
||
});
|
||
let _ = known.save();
|
||
app.toast("Paired — connecting…");
|
||
start_session(app.clone(), req, Some(fp));
|
||
}
|
||
Ok(Err(msg)) => app.toast(&msg),
|
||
Err(_) => {}
|
||
}
|
||
});
|
||
});
|
||
dialog.present(Some(&parent));
|
||
}
|
||
|
||
/// Measure the path to a host over the real data plane (Swift's "Test Network Speed…"):
|
||
/// connect, have the host burst probe filler for 2 s up to its 3 Gbps ceiling, report
|
||
/// goodput · loss · a recommended bitrate (≈70 % of measured), and apply it in one tap.
|
||
fn speed_test(app: Rc<App>, req: ConnectRequest) {
|
||
if app.busy.replace(true) {
|
||
return;
|
||
}
|
||
let pin = req.fp_hex.as_deref().and_then(crate::trust::parse_hex32);
|
||
let status = gtk::Label::new(Some("Connecting…"));
|
||
let dialog = adw::AlertDialog::new(Some("Network Speed Test"), Some(&req.name));
|
||
dialog.set_extra_child(Some(&status));
|
||
dialog.add_responses(&[("close", "Close"), ("apply", "Apply")]);
|
||
dialog.set_response_enabled("apply", false);
|
||
dialog.set_close_response("close");
|
||
dialog.present(Some(&app.window));
|
||
|
||
let (tx, rx) =
|
||
async_channel::bounded::<Result<punktfunk_core::client::ProbeOutcome, String>>(1);
|
||
let identity = app.identity.clone();
|
||
let (host, port) = (req.addr.clone(), req.port);
|
||
std::thread::spawn(move || {
|
||
let result = (|| {
|
||
let c = NativeClient::connect(
|
||
&host,
|
||
port,
|
||
punktfunk_core::config::Mode {
|
||
width: 1280,
|
||
height: 720,
|
||
refresh_hz: 60,
|
||
},
|
||
CompositorPref::Auto,
|
||
GamepadPref::Auto,
|
||
0,
|
||
None, // launch: speed-test probe connect, no game
|
||
pin,
|
||
Some(identity),
|
||
std::time::Duration::from_secs(15),
|
||
)
|
||
.map_err(|e| format!("connect: {e:?}"))?;
|
||
c.request_probe(3_000_000, 2_000)
|
||
.map_err(|e| format!("probe: {e:?}"))?;
|
||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
|
||
loop {
|
||
std::thread::sleep(std::time::Duration::from_millis(250));
|
||
let r = c.probe_result();
|
||
if r.done {
|
||
// Let the last UDP shards land before tearing down.
|
||
std::thread::sleep(std::time::Duration::from_millis(400));
|
||
return Ok(c.probe_result());
|
||
}
|
||
if std::time::Instant::now() > deadline {
|
||
return Err("probe timed out".to_string());
|
||
}
|
||
}
|
||
})();
|
||
let _ = tx.send_blocking(result);
|
||
});
|
||
|
||
glib::spawn_future_local(async move {
|
||
let outcome = rx.recv().await;
|
||
app.busy.set(false);
|
||
match outcome {
|
||
Ok(Ok(r)) => {
|
||
let mbps = f64::from(r.throughput_kbps) / 1000.0;
|
||
let recommended_kbps = r.throughput_kbps / 10 * 7;
|
||
status.set_text(&format!(
|
||
"{mbps:.0} Mbit/s measured · {:.1} % loss\nRecommended bitrate: {:.0} Mbit/s",
|
||
r.loss_pct,
|
||
f64::from(recommended_kbps) / 1000.0,
|
||
));
|
||
dialog.set_response_enabled("apply", true);
|
||
dialog.set_response_appearance("apply", adw::ResponseAppearance::Suggested);
|
||
let settings = app.settings.clone();
|
||
let toasts = app.toasts.clone();
|
||
dialog.connect_response(Some("apply"), move |_, _| {
|
||
let mut s = settings.borrow_mut();
|
||
s.bitrate_kbps = recommended_kbps;
|
||
s.save();
|
||
toasts.add_toast(adw::Toast::new(&format!(
|
||
"Bitrate set to {:.0} Mbit/s",
|
||
f64::from(recommended_kbps) / 1000.0
|
||
)));
|
||
});
|
||
}
|
||
Ok(Err(msg)) => status.set_text(&msg),
|
||
Err(_) => {}
|
||
}
|
||
});
|
||
}
|
||
|
||
/// The mode to request: explicit settings, with `0` fields resolved to the native
|
||
/// size/refresh of the monitor the window currently occupies (mirrors the Swift client's
|
||
/// native-display default).
|
||
fn resolve_mode(app: &App) -> punktfunk_core::config::Mode {
|
||
let s = app.settings.borrow();
|
||
let mut mode = punktfunk_core::config::Mode {
|
||
width: s.width,
|
||
height: s.height,
|
||
refresh_hz: s.refresh_hz,
|
||
};
|
||
if mode.width == 0 || mode.refresh_hz == 0 {
|
||
let monitor = app
|
||
.window
|
||
.surface()
|
||
.zip(gdk::Display::default())
|
||
.and_then(|(surf, d)| d.monitor_at_surface(&surf));
|
||
if let Some(m) = monitor {
|
||
let geo = m.geometry();
|
||
let scale = m.scale_factor().max(1);
|
||
if mode.width == 0 {
|
||
mode.width = (geo.width() * scale) as u32;
|
||
mode.height = (geo.height() * scale) as u32;
|
||
}
|
||
if mode.refresh_hz == 0 {
|
||
mode.refresh_hz = ((m.refresh_rate() + 500) / 1000).max(30) as u32;
|
||
}
|
||
}
|
||
}
|
||
// No monitor info (early call, odd compositor) — a sane floor.
|
||
if mode.width == 0 {
|
||
(mode.width, mode.height) = (1920, 1080);
|
||
}
|
||
if mode.refresh_hz == 0 {
|
||
mode.refresh_hz = 60;
|
||
}
|
||
mode
|
||
}
|
||
|
||
fn start_session(app: Rc<App>, req: ConnectRequest, pin: Option<[u8; 32]>) {
|
||
if app.busy.replace(true) {
|
||
return;
|
||
}
|
||
let mode = resolve_mode(&app);
|
||
let s = app.settings.borrow();
|
||
let params = SessionParams {
|
||
host: req.addr.clone(),
|
||
port: req.port,
|
||
mode,
|
||
compositor: CompositorPref::from_name(&s.compositor).unwrap_or(CompositorPref::Auto),
|
||
// "Automatic" matches the physical pad (Swift parity); an explicit choice wins.
|
||
gamepad: match GamepadPref::from_name(&s.gamepad) {
|
||
Some(GamepadPref::Auto) | None => app.gamepad.auto_pref(),
|
||
Some(explicit) => explicit,
|
||
},
|
||
bitrate_kbps: s.bitrate_kbps,
|
||
mic_enabled: s.mic_enabled,
|
||
pin,
|
||
identity: app.identity.clone(),
|
||
};
|
||
let inhibit = s.inhibit_shortcuts;
|
||
drop(s);
|
||
let tofu = pin.is_none();
|
||
|
||
let mut handle = crate::session::start(params);
|
||
let frames = std::mem::replace(&mut handle.frames, async_channel::bounded(1).1);
|
||
glib::spawn_future_local(async move {
|
||
let mut frames = Some(frames);
|
||
let mut page: Option<crate::ui_stream::StreamPage> = None;
|
||
while let Ok(event) = handle.events.recv().await {
|
||
match event {
|
||
SessionEvent::Connected {
|
||
connector,
|
||
mode,
|
||
fingerprint,
|
||
} => {
|
||
// A TOFU connect just observed the real fingerprint — pin it from now on.
|
||
if tofu {
|
||
let fp_hex = crate::trust::hex(&fingerprint);
|
||
let mut known = KnownHosts::load();
|
||
known.upsert(KnownHost {
|
||
name: req.name.clone(),
|
||
addr: req.addr.clone(),
|
||
port: req.port,
|
||
fp_hex: fp_hex.clone(),
|
||
paired: false,
|
||
});
|
||
let _ = known.save();
|
||
app.toast(&format!(
|
||
"Trusted on first use — fingerprint {}…",
|
||
&fp_hex[..16]
|
||
));
|
||
}
|
||
tracing::debug!(?mode, "connected — pushing stream page");
|
||
let title = format!(
|
||
"{} · {}×{}@{}",
|
||
req.name, mode.width, mode.height, mode.refresh_hz
|
||
);
|
||
app.gamepad.attach(connector.clone());
|
||
let p = crate::ui_stream::new(
|
||
&app.window,
|
||
connector,
|
||
frames.take().expect("Connected delivered once"),
|
||
handle.stop.clone(),
|
||
inhibit,
|
||
&title,
|
||
);
|
||
app.nav.push(&p.page);
|
||
page = Some(p);
|
||
}
|
||
SessionEvent::Stats(s) => {
|
||
if let Some(p) = &page {
|
||
p.update_stats(s);
|
||
}
|
||
}
|
||
SessionEvent::Failed {
|
||
msg,
|
||
trust_rejected,
|
||
} => {
|
||
tracing::warn!(%msg, trust_rejected, "connect failed");
|
||
app.busy.set(false);
|
||
// A pinned connect rejected on trust grounds means the host's cert no
|
||
// longer matches the stored pin (rotated cert or impostor) — route to
|
||
// the PIN ceremony to re-establish trust rather than dead-ending.
|
||
if trust_rejected && !tofu {
|
||
app.toast("Host fingerprint changed — re-pair with a PIN to continue");
|
||
pin_dialog(app.clone(), req.clone());
|
||
} else {
|
||
app.toast(&msg);
|
||
}
|
||
break;
|
||
}
|
||
SessionEvent::Ended(err) => {
|
||
app.gamepad.detach();
|
||
app.nav.pop_to_tag("hosts");
|
||
if let Some(e) = err {
|
||
app.toast(&e);
|
||
}
|
||
app.busy.set(false);
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
});
|
||
}
|