feat(identity): regenerate control + degraded-identity warning (W7 P1)

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 <noreply@anthropic.com>
This commit is contained in:
2026-06-15 04:05:55 -04:00
co-authored by Claude Opus 4.8
parent 4ca497360b
commit a99c789716
3 changed files with 245 additions and 8 deletions
+193 -3
View File
@@ -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<String>,
/// 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<String>,
/// 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<AppMessage> {
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<AppMessage> {
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<AppMessage> = 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,
+11
View File
@@ -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<String> },
Error(String),
}
+41 -5
View File
@@ -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;
}