From a99c789716072e7b1251e5c598d8c73d062480ec Mon Sep 17 00:00:00 2001 From: Mollusk Date: Mon, 15 Jun 2026 03:57:51 -0400 Subject: [PATCH] feat(identity): regenerate control + degraded-identity warning (W7 P1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Complete P1: surface the identity in Settings and let the user manage it. - New UiEvent::IdentityStatus { node_id, persisted, error }, sent at startup and after a regenerate, so the app always knows its own node id and whether the key is persisted. - CoreCommand::RegenerateIdentity: mints + persists a fresh key (identity:: regenerate), swaps the core's live key for the next join (same 'applies on next join' semantics as SetNetworkMode), and replies with a fresh status. - Settings 'Identity' section: shows your permanent ID, a left-aligned Regenerate button behind a confirm modal (destructive — discards the old id, warns friends will stop recognising you), and a standing red warning banner when the key isn't persisted (disk/permission failure -> ephemeral fallback), explaining the id won't survive the next launch. 238 lib tests green, clippy clean, release builds. Screenshot-verified: the Identity section, the confirm modal, and the degraded warning (chmod 000 the key file -> 'Permission denied (os error 13)' banner; Regenerate clears it). Co-Authored-By: Claude Opus 4.8 --- src/app/mod.rs | 196 ++++++++++++++++++++++++++++++++++++++++++- src/core/messages.rs | 11 +++ src/core/mod.rs | 46 ++++++++-- 3 files changed, 245 insertions(+), 8 deletions(-) diff --git a/src/app/mod.rs b/src/app/mod.rs index 1370de2..0a6fd47 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -143,6 +143,11 @@ pub enum AppMessage { CustomSoundPathChanged(Sound, String), /// Toggle the per-sound enable flag for a single chime (W6). ToggleSoundEnabled(Sound, bool), + /// Open / cancel the "Regenerate identity?" confirm modal (W7). + OpenRegenerateIdentityConfirm, + CloseRegenerateIdentityConfirm, + /// Confirmed: mint a fresh persistent identity, discarding the old one. + ConfirmRegenerateIdentity, ToggleMicTest(bool), /// Start/stop recording the call; the core confirms via Recording{Started,Stopped}. ToggleRecording, @@ -249,6 +254,19 @@ pub struct AppState { self_sharing: bool, /// Whether the `pixelpass` binary is available, gating the Share controls. pixelpass_available: bool, + /// Our persistent node id (W7), known from startup regardless of room state + /// (distinct from `self_id`, which is room-scoped). `None` until the core + /// reports it via `IdentityStatus`. + self_node_id: Option, + /// Whether our identity is persisted to disk. `false` = degraded ephemeral + /// fallback (the key file couldn't be read/written) → the UI shows a warning, + /// because the id won't survive the next launch and friends will stop + /// recognising us. Defaults `true` (optimistic until told otherwise). + identity_persisted: bool, + /// The reason the identity isn't persisted, for the warning explainer. + identity_error: Option, + /// Whether the "Regenerate identity?" confirm modal is open. + regenerate_identity_confirm_open: bool, } impl AppState { @@ -342,6 +360,10 @@ impl Default for AppState { current_screen: Screen::Home, self_sharing: false, pixelpass_available, + self_node_id: None, + identity_persisted: true, + identity_error: None, + regenerate_identity_confirm_open: false, } } } @@ -631,6 +653,11 @@ fn update(state: &mut AppState, message: AppMessage) -> Task { state.self_sharing = false; state.status_message = "Screen share stopped".to_string(); } + UiEvent::IdentityStatus { node_id, persisted, error } => { + state.self_node_id = Some(node_id); + state.identity_persisted = persisted; + state.identity_error = error; + } UiEvent::Error(err) => { state.status_message = format!("Error: {}", err); } @@ -734,6 +761,18 @@ fn update(state: &mut AppState, message: AppMessage) -> Task { state.config.save(); notify::set_sound_enabled(sound, enabled); } + AppMessage::OpenRegenerateIdentityConfirm => { + state.regenerate_identity_confirm_open = true; + } + AppMessage::CloseRegenerateIdentityConfirm => { + state.regenerate_identity_confirm_open = false; + } + AppMessage::ConfirmRegenerateIdentity => { + state.regenerate_identity_confirm_open = false; + // The core mints + persists the new key and replies with a fresh + // IdentityStatus (which updates self_node_id / persisted here). + let _ = state.controller.send(CoreCommand::RegenerateIdentity); + } AppMessage::ToggleRecording => { // Optimistic intent; the core flips `recording` for real via the // Recording{Started,Stopped} events (so a failed start won't lie). @@ -1381,6 +1420,61 @@ fn view(state: &AppState) -> Element<'_, AppMessage> { .into() }; + // --- Identity (W7) --- + // Your persistent node id + a Regenerate control. When the key isn't + // persisted (disk/permission failure → ephemeral fallback) we show a + // standing red warning, because the id won't survive the next launch and + // friends will stop recognising you. + let id_display = state + .self_node_id + .as_deref() + .map(|id| format!("{}…", short_id(id))) + .unwrap_or_else(|| "(starting…)".to_string()); + let identity_warning: Element = if state.identity_persisted { + column![].into() + } else { + let reason = state + .identity_error + .as_deref() + .unwrap_or("the key file could not be read or written"); + container( + column![ + text("⚠ Identity not saved") + .size(13) + .color(color_red), + text(format!( + "Your identity couldn't be saved to disk ({reason}). It won't \ + survive the next launch, so your friends will stop recognising \ + you. Check free space and permissions on ~/.config/peerspeak/." + )) + .size(12) + .color(color_subtext), + ] + .spacing(4), + ) + .padding(10) + .width(iced::Length::Fill) + .style(move |_t: &Theme| container::Style { + background: Some(Background::Color(Color { a: 0.12, ..color_red })), + border: Border { color: color_red, width: 1.0, radius: 8.0.into() }, + ..Default::default() + }) + .into() + }; + let identity_section = column![ + text("Your permanent ID — friends recognise you by this. It stays the \ + same across launches; regenerate only to start fresh as a new \ + identity (friends who saved the old one will no longer reach you).") + .size(12) + .color(color_subtext), + text(format!("ID: {id_display}")).size(13).color(color_text), + button(text("Regenerate identity").size(13)) + .on_press(AppMessage::OpenRegenerateIdentityConfirm) + .style(b_style(color_surface, color_maroon, color_text, 6.0)) + .padding(8), + identity_warning, + ].spacing(8).width(iced::Length::Fill); + let settings_content = scrollable( column![ // --- Audio Devices --- @@ -1472,6 +1566,11 @@ fn view(state: &AppState) -> Element<'_, AppMessage> { avatar_section, vertical_space(section_gap), + // --- Identity --- + section_header("Identity"), + identity_section, + vertical_space(section_gap), + // --- Notifications & Sounds --- section_header("Notifications & Sounds"), column![ @@ -1550,13 +1649,14 @@ fn view(state: &AppState) -> Element<'_, AppMessage> { .width(iced::Length::Fill) .height(iced::Length::Fill); - return container(settings_box) + let settings_screen = container(settings_box) .width(iced::Length::Fill) .height(iced::Length::Fill) .padding(24) .center_x(iced::Length::Fill) - .style(c_style(color_crust, Color::TRANSPARENT, 0.0)) - .into(); + .style(c_style(color_crust, Color::TRANSPARENT, 0.0)); + + return with_regenerate_confirm(settings_screen.into(), state); } if state.current_screen == Screen::Home { @@ -2703,6 +2803,96 @@ fn with_pixelpass_help<'a>( .into() } +/// Overlay the "Regenerate identity?" confirm dialog when open (W7). A +/// destructive action — minting a new id discards the old one — so it's gated +/// behind an explicit confirm with a clear warning. +fn with_regenerate_confirm<'a>( + base: Element<'a, AppMessage>, + state: &'a AppState, +) -> Element<'a, AppMessage> { + if !state.regenerate_identity_confirm_open { + return base; + } + let pal = state.config.theme.palette(); + let crust = pal.crust; + let mantle = pal.mantle; + let surface = pal.surface; + let text_c = pal.text; + let subtext = pal.subtext; + let maroon = pal.maroon; + + let backdrop = mouse_area( + container(horizontal_space()) + .width(iced::Length::Fill) + .height(iced::Length::Fill) + .style(move |_t: &Theme| container::Style { + background: Some(Background::Color(Color { a: 0.55, ..crust })), + ..Default::default() + }), + ) + .on_press(AppMessage::CloseRegenerateIdentityConfirm); + + let dialog = container( + column![ + text("Regenerate identity?").size(16).color(maroon), + text( + "This mints a brand-new identity and permanently discards your \ + current one. Friends who saved your old ID will no longer \ + recognise or reach you until you reconnect and they re-add you. \ + This can't be undone." + ) + .size(13) + .color(text_c), + text("Takes effect on your next room join.").size(12).color(subtext), + row![ + horizontal_space(), + button(text("Cancel").size(13)) + .on_press(AppMessage::CloseRegenerateIdentityConfirm) + .style(move |_t: &Theme, status: button::Status| button::Style { + background: Some(Background::Color(match status { + button::Status::Hovered => surface, + _ => mantle, + })), + text_color: text_c, + border: Border { color: surface, width: 1.0, radius: 6.0.into() }, + ..Default::default() + }) + .padding(8), + button(text("Regenerate").size(13)) + .on_press(AppMessage::ConfirmRegenerateIdentity) + .style(move |_t: &Theme, status: button::Status| button::Style { + background: Some(Background::Color(match status { + button::Status::Hovered => Color { a: 0.85, ..maroon }, + _ => maroon, + })), + text_color: text_c, + border: Border { color: Color::TRANSPARENT, width: 0.0, radius: 6.0.into() }, + ..Default::default() + }) + .padding(8), + ].spacing(10), + ] + .spacing(14), + ) + .style(move |_t: &Theme| container::Style { + text_color: Some(text_c), + background: Some(Background::Color(mantle)), + border: Border { color: surface, width: 1.0, radius: 12.0.into() }, + ..Default::default() + }) + .padding(20) + .width(iced::Length::Fixed(440.0)); + + stack![ + base, + backdrop, + container(dialog) + .center_x(iced::Length::Fill) + .center_y(iced::Length::Fill), + ] + .into() +} + /// A small two-pane glyph for the square layout-picker button in the top bar. struct LayoutIcon { fg: Color, diff --git a/src/core/messages.rs b/src/core/messages.rs index f93556f..b012412 100644 --- a/src/core/messages.rs +++ b/src/core/messages.rs @@ -48,6 +48,10 @@ pub enum CoreCommand { /// Watch a peer's screen share: spawn a pixelpass viewer for `ticket` and /// open it in a local player. ViewShare(String), + /// Mint a fresh persistent identity (W7), discarding the old one. Takes effect + /// on the next room join (the endpoint is rebuilt then). The core replies with + /// an updated [`UiEvent::IdentityStatus`]. + RegenerateIdentity, } #[derive(Debug, Clone)] @@ -78,5 +82,12 @@ pub enum UiEvent { ScreenShareStarted, /// Our own screen share stopped (or failed to start). ScreenShareStopped, + /// Our node identity (W7): the current node id string, and whether it is + /// PERSISTED to disk. Sent once at startup and again after a regenerate. + /// `persisted = false` means the key file couldn't be read/written and we're + /// running on an ephemeral fallback — a degraded state the UI must surface, + /// since the id (and thus friend recognition) won't survive the next launch. + /// `error` carries the reason when degraded, for the UI explainer. + IdentityStatus { node_id: String, persisted: bool, error: Option }, Error(String), } diff --git a/src/core/mod.rs b/src/core/mod.rs index 0e7a314..d0ca938 100644 --- a/src/core/mod.rs +++ b/src/core/mod.rs @@ -421,16 +421,26 @@ async fn run_core_loop( // Persistent identity (W7 P1): load a stable key so our node id survives // launches — the foundation for the friends-first contacts model. Fall back // to an ephemeral key only if the key file can't be read/created (e.g. no - // writable config dir), so a bad disk never blocks getting on a call. - let secret_key = match crate::identity::load_or_create() { - Ok(key) => key, + // writable config dir), so a bad disk never blocks getting on a call. The + // `persisted` flag + error reason are surfaced to the UI (degraded state). + let (mut secret_key, mut identity_error) = match crate::identity::load_or_create() { + Ok(key) => (key, None), Err(e) => { + let reason = format!("{e:#}"); crate::log_msg(&format!( - "identity: falling back to an ephemeral key (persistent load failed: {e:#})" + "identity: falling back to an ephemeral key (persistent load failed: {reason})" )); - iroh::SecretKey::generate() + (iroh::SecretKey::generate(), Some(reason)) } }; + // Tell the UI our node id + whether it's persisted. Re-sent after a regenerate. + let _ = ui_tx + .send(UiEvent::IdentityStatus { + node_id: secret_key.public().to_string(), + persisted: identity_error.is_none(), + error: identity_error.clone(), + }) + .await; // Peers seen in the current/most-recent room, retained ACROSS leave so a // rejoin can bootstrap to them. This is the fix for A8: the room creator's own // ticket lists only themselves as host, so on rejoin these retained peers are @@ -1186,6 +1196,32 @@ async fn run_core_loop( network_mode = mode; } + CoreCommand::RegenerateIdentity => { + // Mint + persist a fresh identity, discarding the old one. Takes + // effect on the NEXT join (the endpoint is rebuilt with this key + // then) — consistent with SetNetworkMode's "applies on next join." + match crate::identity::regenerate() { + Ok(key) => { + secret_key = key; + identity_error = None; + crate::log_msg("identity: regenerated to a fresh persistent id"); + } + Err(e) => { + // Couldn't write the new key — keep the current one in + // memory but report the disk problem as a degraded state. + identity_error = Some(format!("{e:#}")); + crate::log_msg(&format!("identity: regenerate failed: {e:#}")); + } + } + let _ = ui_tx + .send(UiEvent::IdentityStatus { + node_id: secret_key.public().to_string(), + persisted: identity_error.is_none(), + error: identity_error.clone(), + }) + .await; + } + CoreCommand::SetRecordingMode(mode) => { recording_mode = mode; }