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:
+193
-3
@@ -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,
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user