Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
319d0c5e29 | ||
|
|
d56c2c90b2 | ||
|
|
5086e86bd2 | ||
|
|
54780fa73b | ||
|
|
b1aa751a84 | ||
|
|
9efab491c7 | ||
|
|
f3f399a748 | ||
|
|
1afdccbefe | ||
|
|
7724da73b8 | ||
|
|
92c9d585b8 | ||
|
|
8982df364e |
+372
-147
@@ -32,6 +32,78 @@ pub enum Screen {
|
||||
Settings,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SettingsCategory {
|
||||
Audio,
|
||||
Hotkeys,
|
||||
Recording,
|
||||
Profile,
|
||||
Appearance,
|
||||
Network,
|
||||
Notifications,
|
||||
}
|
||||
|
||||
impl SettingsCategory {
|
||||
const ALL: [SettingsCategory; 7] = [
|
||||
SettingsCategory::Audio,
|
||||
SettingsCategory::Hotkeys,
|
||||
SettingsCategory::Recording,
|
||||
SettingsCategory::Profile,
|
||||
SettingsCategory::Appearance,
|
||||
SettingsCategory::Network,
|
||||
SettingsCategory::Notifications,
|
||||
];
|
||||
|
||||
fn label(self) -> &'static str {
|
||||
match self {
|
||||
SettingsCategory::Audio => "Audio",
|
||||
SettingsCategory::Hotkeys => "Hotkeys",
|
||||
SettingsCategory::Recording => "Recording",
|
||||
SettingsCategory::Profile => "Profile",
|
||||
SettingsCategory::Appearance => "Appearance",
|
||||
SettingsCategory::Network => "Network",
|
||||
SettingsCategory::Notifications => "Notifications",
|
||||
}
|
||||
}
|
||||
|
||||
fn hint(self) -> &'static str {
|
||||
match self {
|
||||
SettingsCategory::Audio => "Devices, mic gate, echo",
|
||||
SettingsCategory::Hotkeys => "Focused keyboard shortcuts",
|
||||
SettingsCategory::Recording => "Mixed and stem capture",
|
||||
SettingsCategory::Profile => "Avatar and identity",
|
||||
SettingsCategory::Appearance => "Layout and theme",
|
||||
SettingsCategory::Network => "Relay and privacy mode",
|
||||
SettingsCategory::Notifications => "Chimes and sounds",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for SettingsCategory {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(self.label())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum HomeLayoutMode {
|
||||
FocusedEmpty,
|
||||
ThreeColumn,
|
||||
Stacked,
|
||||
}
|
||||
|
||||
fn home_layout_mode(width: f32, has_recents: bool, has_friends: bool) -> HomeLayoutMode {
|
||||
if width < 900.0 {
|
||||
HomeLayoutMode::Stacked
|
||||
} else if !has_recents && !has_friends {
|
||||
HomeLayoutMode::FocusedEmpty
|
||||
} else if width >= 1280.0 {
|
||||
HomeLayoutMode::ThreeColumn
|
||||
} else {
|
||||
HomeLayoutMode::Stacked
|
||||
}
|
||||
}
|
||||
|
||||
/// One rendered room-chat line. `mine` distinguishes our own (locally echoed)
|
||||
/// messages from peers' for colouring.
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -175,6 +247,7 @@ pub enum AppMessage {
|
||||
EventOccurred(Event),
|
||||
NavigateToSettings,
|
||||
NavigateBack,
|
||||
SelectSettingsCategory(SettingsCategory),
|
||||
ToggleNotifications(bool),
|
||||
ToggleEchoCancellation(bool),
|
||||
CustomSoundPathChanged(Sound, String),
|
||||
@@ -298,6 +371,7 @@ pub struct AppState {
|
||||
ever_connected: HashSet<EndpointId>,
|
||||
controller: Arc<CoreController>,
|
||||
current_screen: Screen,
|
||||
settings_category: SettingsCategory,
|
||||
/// Whether we're currently sharing our own screen (confirmed by the core).
|
||||
self_sharing: bool,
|
||||
/// Whether the `pixelpass` binary is available, gating the Share controls.
|
||||
@@ -435,6 +509,7 @@ impl Default for AppState {
|
||||
ever_connected: HashSet::new(),
|
||||
controller,
|
||||
current_screen: Screen::Home,
|
||||
settings_category: SettingsCategory::Audio,
|
||||
self_sharing: false,
|
||||
pixelpass_available,
|
||||
self_node_id: None,
|
||||
@@ -880,13 +955,15 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
|
||||
state.friend_presence.insert(id, presence);
|
||||
}
|
||||
UiEvent::PresenceModeReverted { mode } => {
|
||||
// The Discoverable time-box elapsed; core dropped us back to
|
||||
// `mode` (Normal) and stopped publishing. Mirror + persist so the
|
||||
// presence picker reflects it, and tell the user why it changed.
|
||||
// Core corrected the committed presence mode. Mirror + persist so
|
||||
// the picker reflects the discovery state the endpoint actually has.
|
||||
state.config.presence_mode = mode;
|
||||
state.config.save();
|
||||
state.status_message =
|
||||
"Discoverable timed out — back to Normal".to_string();
|
||||
state.status_message = if mode == PresenceMode::Normal {
|
||||
"Discoverable timed out — back to Normal".to_string()
|
||||
} else {
|
||||
format!("Presence mode stayed {mode}")
|
||||
};
|
||||
}
|
||||
UiEvent::ShutdownComplete => {
|
||||
if state.closing {
|
||||
@@ -1097,6 +1174,9 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
|
||||
crate::recents::remove_recent(&mut state.config.recents, &ticket);
|
||||
state.config.save();
|
||||
}
|
||||
AppMessage::SelectSettingsCategory(category) => {
|
||||
state.settings_category = category;
|
||||
}
|
||||
AppMessage::ToggleNotifications(enabled) => {
|
||||
state.config.notifications_enabled = enabled;
|
||||
state.config.save();
|
||||
@@ -1544,7 +1624,7 @@ fn connect_card(state: &AppState) -> Element<'_, AppMessage> {
|
||||
selection: color_blue,
|
||||
};
|
||||
|
||||
let logo = text("PEERSPEAK").size(36).color(color_blue);
|
||||
let logo = text("PEERSPEAK").size(38).color(color_blue);
|
||||
let subtitle = text("NAT-traversing full-mesh voice chat").size(16).color(color_subtext);
|
||||
|
||||
let nickname_input = column![
|
||||
@@ -1608,8 +1688,8 @@ fn connect_card(state: &AppState) -> Element<'_, AppMessage> {
|
||||
.align_x(iced::alignment::Horizontal::Center),
|
||||
)
|
||||
.style(c_style(color_mantle, color_surface, 12.0))
|
||||
.padding(30)
|
||||
.width(380)
|
||||
.padding(32)
|
||||
.width(420)
|
||||
.into()
|
||||
}
|
||||
|
||||
@@ -1653,50 +1733,51 @@ fn recents_card(state: &AppState) -> Element<'_, AppMessage> {
|
||||
}
|
||||
};
|
||||
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0);
|
||||
let mut rows = column![].spacing(6).width(iced::Length::Fill);
|
||||
if state.config.recents.is_empty() {
|
||||
rows = rows.push(
|
||||
text("No recent rooms yet — they'll appear here after you join one.")
|
||||
.size(12)
|
||||
.color(color_subtext),
|
||||
);
|
||||
}
|
||||
for r in &state.config.recents {
|
||||
let label = {
|
||||
let n = crate::sanitize::sanitize_name(&r.name);
|
||||
if n.is_empty() { "Untitled room".to_string() } else { n }
|
||||
};
|
||||
let when = crate::recents::relative_time(now, r.joined_at);
|
||||
let entry = button(
|
||||
row![
|
||||
text(label).size(14).color(color_text),
|
||||
horizontal_space(),
|
||||
text(when).size(11).color(color_subtext),
|
||||
]
|
||||
.align_y(iced::alignment::Vertical::Center),
|
||||
)
|
||||
.on_press(AppMessage::JoinRecent(r.ticket.clone()))
|
||||
.style(b_style(color_crust, color_surface, color_text, 6.0))
|
||||
.padding(8)
|
||||
.width(iced::Length::Fill);
|
||||
rows = rows.push(
|
||||
row![
|
||||
entry,
|
||||
button(text("✕").size(12))
|
||||
.on_press(AppMessage::RemoveRecent(r.ticket.clone()))
|
||||
.style(b_style(color_surface, color_maroon, color_text, 6.0))
|
||||
.padding(8),
|
||||
]
|
||||
.spacing(6)
|
||||
.align_y(iced::alignment::Vertical::Center),
|
||||
);
|
||||
}
|
||||
let empty = state.config.recents.is_empty();
|
||||
let content: Element<'_, AppMessage> = if empty {
|
||||
column![
|
||||
text("RECENT ROOMS").size(14).color(color_subtext),
|
||||
text("No recent rooms yet.").size(12).color(color_subtext),
|
||||
]
|
||||
.spacing(4)
|
||||
.into()
|
||||
} else {
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0);
|
||||
let mut rows = column![].spacing(6).width(iced::Length::Fill);
|
||||
for r in &state.config.recents {
|
||||
let label = {
|
||||
let n = crate::sanitize::sanitize_name(&r.name);
|
||||
if n.is_empty() { "Untitled room".to_string() } else { n }
|
||||
};
|
||||
let when = crate::recents::relative_time(now, r.joined_at);
|
||||
let entry = button(
|
||||
row![
|
||||
text(label).size(14).color(color_text),
|
||||
horizontal_space(),
|
||||
text(when).size(11).color(color_subtext),
|
||||
]
|
||||
.align_y(iced::alignment::Vertical::Center),
|
||||
)
|
||||
.on_press(AppMessage::JoinRecent(r.ticket.clone()))
|
||||
.style(b_style(color_crust, color_surface, color_text, 6.0))
|
||||
.padding(8)
|
||||
.width(iced::Length::Fill);
|
||||
rows = rows.push(
|
||||
row![
|
||||
entry,
|
||||
button(text("✕").size(12))
|
||||
.on_press(AppMessage::RemoveRecent(r.ticket.clone()))
|
||||
.style(b_style(color_surface, color_maroon, color_text, 6.0))
|
||||
.padding(8),
|
||||
]
|
||||
.spacing(6)
|
||||
.align_y(iced::alignment::Vertical::Center),
|
||||
);
|
||||
}
|
||||
|
||||
container(
|
||||
column![
|
||||
text("RECENT ROOMS").size(18).color(color_text),
|
||||
text("Rooms you've been in — click to hop back. Best-effort: only works while someone's still there.")
|
||||
@@ -1705,11 +1786,14 @@ fn recents_card(state: &AppState) -> Element<'_, AppMessage> {
|
||||
vertical_space(10.0),
|
||||
rows,
|
||||
]
|
||||
.spacing(6),
|
||||
)
|
||||
.style(c_style(color_mantle, color_surface, 12.0))
|
||||
.padding(24)
|
||||
.width(380)
|
||||
.spacing(6)
|
||||
.into()
|
||||
};
|
||||
|
||||
container(content)
|
||||
.style(c_style(if empty { color_crust } else { color_mantle }, color_surface, 8.0))
|
||||
.padding(if empty { 16 } else { 24 })
|
||||
.width(if empty { 340 } else { 380 })
|
||||
.into()
|
||||
}
|
||||
|
||||
@@ -1725,6 +1809,7 @@ fn friends_panel(state: &AppState) -> Element<'_, AppMessage> {
|
||||
let color_red = pal.red;
|
||||
let color_maroon = pal.maroon;
|
||||
let color_green = pal.green;
|
||||
let has_friends = !state.friends.list().is_empty();
|
||||
|
||||
let c_style = move |bg: Color, b_color: Color, radius: f32| {
|
||||
move |_theme: &Theme| container::Style {
|
||||
@@ -1763,9 +1848,9 @@ fn friends_panel(state: &AppState) -> Element<'_, AppMessage> {
|
||||
|
||||
// The live friends list: status dot, inline rename, short id, remove.
|
||||
let mut friend_rows = column![].spacing(6).width(iced::Length::Fill);
|
||||
if state.friends.list().is_empty() {
|
||||
if !has_friends {
|
||||
friend_rows = friend_rows.push(
|
||||
text("No friends yet — add one by their node ID below.")
|
||||
text("No friends yet.")
|
||||
.size(12)
|
||||
.color(color_subtext),
|
||||
);
|
||||
@@ -1868,28 +1953,34 @@ fn friends_panel(state: &AppState) -> Element<'_, AppMessage> {
|
||||
]
|
||||
.spacing(4)
|
||||
.width(iced::Length::Fill);
|
||||
let intro: Element<'_, AppMessage> = if has_friends {
|
||||
text("Who's online — click Join to hop into a friend's room.")
|
||||
.size(11)
|
||||
.color(color_subtext)
|
||||
.into()
|
||||
} else {
|
||||
column![].into()
|
||||
};
|
||||
|
||||
container(
|
||||
column![
|
||||
text("FRIENDS").size(18).color(color_text),
|
||||
text("Who's online — click Join to hop into a friend's room.")
|
||||
.size(11)
|
||||
.color(color_subtext),
|
||||
vertical_space(10.0),
|
||||
text("FRIENDS").size(if has_friends { 18 } else { 14 }).color(color_text),
|
||||
intro,
|
||||
vertical_space(if has_friends { 10.0 } else { 4.0 }),
|
||||
readonly_warning,
|
||||
friend_rows,
|
||||
vertical_space(12.0),
|
||||
vertical_space(if has_friends { 12.0 } else { 8.0 }),
|
||||
text("Add a friend").size(13).color(color_subtext),
|
||||
add_form,
|
||||
vertical_space(14.0),
|
||||
vertical_space(if has_friends { 14.0 } else { 10.0 }),
|
||||
text("Your presence").size(13).color(color_subtext),
|
||||
presence_picker,
|
||||
]
|
||||
.spacing(6),
|
||||
)
|
||||
.style(c_style(color_mantle, color_surface, 12.0))
|
||||
.padding(24)
|
||||
.width(460)
|
||||
.padding(if has_friends { 24 } else { 18 })
|
||||
.width(if has_friends { 460 } else { 360 })
|
||||
.into()
|
||||
}
|
||||
|
||||
@@ -1959,19 +2050,23 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
|
||||
}
|
||||
};
|
||||
|
||||
let top_bar = row![
|
||||
horizontal_space(),
|
||||
tooltip(
|
||||
button(icon(IconKind::Info, 18.0, color_text))
|
||||
.on_press(AppMessage::OpenHotkeyInfo)
|
||||
.style(b_style(color_surface, color_blue, color_text, 6.0))
|
||||
.padding(8),
|
||||
container(text("Hotkeys").size(11).color(color_text))
|
||||
.padding(8)
|
||||
.style(c_style(color_crust, color_surface, 6.0)),
|
||||
iced::widget::tooltip::Position::Bottom,
|
||||
)
|
||||
.gap(8),
|
||||
// The Hotkeys info button is always available (hotkeys are app-wide). The
|
||||
// room-layout button is hidden on the Home screen, leaving only it + Settings.
|
||||
let info_button = tooltip(
|
||||
button(icon(IconKind::Info, 18.0, color_text))
|
||||
.on_press(AppMessage::OpenHotkeyInfo)
|
||||
.style(b_style(color_surface, color_blue, color_text, 6.0))
|
||||
.padding(8),
|
||||
container(text("Hotkeys").size(11).color(color_text))
|
||||
.padding(8)
|
||||
.style(c_style(color_crust, color_surface, 6.0)),
|
||||
iced::widget::tooltip::Position::Bottom,
|
||||
)
|
||||
.gap(8);
|
||||
|
||||
let layout_button: Element<'_, AppMessage> = if state.current_screen == Screen::Home {
|
||||
iced::widget::Space::new().width(0.0).height(0.0).into()
|
||||
} else {
|
||||
tooltip(
|
||||
button(
|
||||
Canvas::new(LayoutIcon { fg: color_text })
|
||||
@@ -1986,7 +2081,14 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
|
||||
.style(c_style(color_crust, color_surface, 6.0)),
|
||||
iced::widget::tooltip::Position::Bottom,
|
||||
)
|
||||
.gap(8),
|
||||
.gap(8)
|
||||
.into()
|
||||
};
|
||||
|
||||
let top_bar = row![
|
||||
horizontal_space(),
|
||||
info_button,
|
||||
layout_button,
|
||||
button(
|
||||
row![
|
||||
icon(IconKind::Settings, 15.0, color_text),
|
||||
@@ -2404,9 +2506,8 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
|
||||
|
||||
// Presence + Friends moved to the home screen (see `friends_panel`).
|
||||
|
||||
let settings_content = scrollable(
|
||||
column![
|
||||
// --- Audio Devices ---
|
||||
let settings_body: Element<'_, AppMessage> = match state.settings_category {
|
||||
SettingsCategory::Audio => column![
|
||||
section_header("Audio Devices"),
|
||||
row![
|
||||
column![
|
||||
@@ -2435,8 +2536,6 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
|
||||
].spacing(8).width(iced::Length::Fill),
|
||||
].spacing(20).align_y(iced::alignment::Vertical::Top).width(iced::Length::Fill),
|
||||
vertical_space(section_gap),
|
||||
|
||||
// --- Microphone ---
|
||||
section_header("Microphone"),
|
||||
column![
|
||||
mic_meter,
|
||||
@@ -2447,14 +2546,18 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
|
||||
.on_toggle(AppMessage::ToggleEchoCancellation),
|
||||
text("Cancels speaker echo + suppresses noise (PipeWire). Takes effect on your next room join.").size(11).color(color_subtext),
|
||||
].spacing(8).width(iced::Length::Fill),
|
||||
vertical_space(section_gap),
|
||||
|
||||
// --- Hotkeys ---
|
||||
]
|
||||
.spacing(10)
|
||||
.width(iced::Length::Fill)
|
||||
.into(),
|
||||
SettingsCategory::Hotkeys => column![
|
||||
section_header("Hotkeys"),
|
||||
hotkey_section,
|
||||
vertical_space(section_gap),
|
||||
|
||||
// --- Recording ---
|
||||
]
|
||||
.spacing(10)
|
||||
.width(iced::Length::Fill)
|
||||
.into(),
|
||||
SettingsCategory::Recording => column![
|
||||
section_header("Recording"),
|
||||
column![
|
||||
mode_radio(RecordingMode::Mixed, "Mixed (single file)"),
|
||||
@@ -2463,22 +2566,21 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
|
||||
vertical_space(2.0),
|
||||
text("Hover an option for what it does. Saved to ~/peerspeak-recordings/ — Multitrack/Both as a timestamped folder of tracks, Mixed as a single file. Applies to your next recording.").size(11).color(color_subtext),
|
||||
].spacing(8).width(iced::Length::Fill),
|
||||
]
|
||||
.spacing(10)
|
||||
.width(iced::Length::Fill)
|
||||
.into(),
|
||||
SettingsCategory::Profile => column![
|
||||
section_header("Avatar"),
|
||||
avatar_section,
|
||||
vertical_space(section_gap),
|
||||
|
||||
// --- Network & Privacy ---
|
||||
section_header("Network & Privacy"),
|
||||
column![
|
||||
pick_list(
|
||||
&NetworkMode::ALL[..],
|
||||
Some(state.config.network_mode),
|
||||
AppMessage::NetworkModeSelected,
|
||||
).width(iced::Length::Fill),
|
||||
text(network_mode_hint(state.config.network_mode)).size(11).color(color_subtext),
|
||||
text("Takes effect on your next room join.").size(11).color(color_subtext),
|
||||
].spacing(4).width(iced::Length::Fill),
|
||||
vertical_space(section_gap),
|
||||
|
||||
// --- Room Layout ---
|
||||
section_header("Identity"),
|
||||
identity_section,
|
||||
]
|
||||
.spacing(10)
|
||||
.width(iced::Length::Fill)
|
||||
.into(),
|
||||
SettingsCategory::Appearance => column![
|
||||
section_header("Room Layout"),
|
||||
column![
|
||||
row![
|
||||
@@ -2489,25 +2591,28 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
|
||||
text("How the in-call room is arranged. Applies live.").size(11).color(color_subtext),
|
||||
].spacing(8).width(iced::Length::Fill),
|
||||
vertical_space(section_gap),
|
||||
|
||||
// --- Theme ---
|
||||
section_header("Theme"),
|
||||
theme_section,
|
||||
vertical_space(section_gap),
|
||||
|
||||
// --- Avatar ---
|
||||
section_header("Avatar"),
|
||||
avatar_section,
|
||||
vertical_space(section_gap),
|
||||
|
||||
// --- Identity ---
|
||||
section_header("Identity"),
|
||||
identity_section,
|
||||
vertical_space(section_gap),
|
||||
|
||||
// (Presence + Friends now live on the home screen.)
|
||||
|
||||
// --- Notifications & Sounds ---
|
||||
]
|
||||
.spacing(10)
|
||||
.width(iced::Length::Fill)
|
||||
.into(),
|
||||
SettingsCategory::Network => column![
|
||||
section_header("Network & Privacy"),
|
||||
column![
|
||||
pick_list(
|
||||
&NetworkMode::ALL[..],
|
||||
Some(state.config.network_mode),
|
||||
AppMessage::NetworkModeSelected,
|
||||
).width(iced::Length::Fill),
|
||||
text(network_mode_hint(state.config.network_mode)).size(11).color(color_subtext),
|
||||
text("Takes effect on your next room join.").size(11).color(color_subtext),
|
||||
].spacing(4).width(iced::Length::Fill),
|
||||
]
|
||||
.spacing(10)
|
||||
.width(iced::Length::Fill)
|
||||
.into(),
|
||||
SettingsCategory::Notifications => column![
|
||||
section_header("Notifications & Sounds"),
|
||||
column![
|
||||
checkbox(state.config.notifications_enabled)
|
||||
@@ -2535,9 +2640,92 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
|
||||
]
|
||||
.spacing(10)
|
||||
.width(iced::Length::Fill)
|
||||
)
|
||||
.width(iced::Length::Fill)
|
||||
.height(iced::Length::Fill);
|
||||
.into(),
|
||||
};
|
||||
|
||||
let category_button = |category: SettingsCategory| -> Element<'_, AppMessage> {
|
||||
let selected = state.settings_category == category;
|
||||
let label_color = if selected { color_blue } else { color_text };
|
||||
let border_color = if selected { color_blue } else { Color::TRANSPARENT };
|
||||
let bg = if selected { color_surface } else { Color::TRANSPARENT };
|
||||
button(
|
||||
container(
|
||||
column![
|
||||
text(category.label()).size(14).color(label_color),
|
||||
text(category.hint()).size(11).color(color_subtext),
|
||||
]
|
||||
.spacing(2)
|
||||
.width(iced::Length::Fill),
|
||||
)
|
||||
.width(iced::Length::Fill),
|
||||
)
|
||||
.on_press(AppMessage::SelectSettingsCategory(category))
|
||||
.style(move |_theme: &Theme, status: button::Status| {
|
||||
let active_bg = match status {
|
||||
button::Status::Hovered if selected => color_surface,
|
||||
button::Status::Hovered => color_crust,
|
||||
_ => bg,
|
||||
};
|
||||
button::Style {
|
||||
background: Some(Background::Color(active_bg)),
|
||||
text_color: label_color,
|
||||
border: Border {
|
||||
color: border_color,
|
||||
width: if selected { 1.0 } else { 0.0 },
|
||||
radius: 8.0.into(),
|
||||
},
|
||||
..Default::default()
|
||||
}
|
||||
})
|
||||
.padding(10)
|
||||
.width(iced::Length::Fill)
|
||||
.into()
|
||||
};
|
||||
|
||||
let mut settings_nav = column![
|
||||
text("SETTINGS").size(11).color(color_subtext),
|
||||
]
|
||||
.spacing(8)
|
||||
.width(iced::Length::Fill);
|
||||
for category in SettingsCategory::ALL {
|
||||
settings_nav = settings_nav.push(category_button(category));
|
||||
}
|
||||
let settings_nav = container(settings_nav)
|
||||
.padding(12)
|
||||
.width(iced::Length::Fixed(220.0))
|
||||
.height(iced::Length::Fill)
|
||||
.style(c_style(color_crust, color_surface, 8.0));
|
||||
|
||||
let settings_content: Element<'_, AppMessage> = if state.window_size.width < 820.0 {
|
||||
scrollable(
|
||||
column![
|
||||
text("Category").size(12).color(color_subtext),
|
||||
pick_list(
|
||||
&SettingsCategory::ALL[..],
|
||||
Some(state.settings_category),
|
||||
AppMessage::SelectSettingsCategory,
|
||||
).width(iced::Length::Fill),
|
||||
vertical_space(10.0),
|
||||
settings_body,
|
||||
]
|
||||
.spacing(8)
|
||||
.width(iced::Length::Fill),
|
||||
)
|
||||
.width(iced::Length::Fill)
|
||||
.height(iced::Length::Fill)
|
||||
.into()
|
||||
} else {
|
||||
row![
|
||||
settings_nav,
|
||||
scrollable(settings_body)
|
||||
.width(iced::Length::Fill)
|
||||
.height(iced::Length::Fill),
|
||||
]
|
||||
.spacing(16)
|
||||
.width(iced::Length::Fill)
|
||||
.height(iced::Length::Fill)
|
||||
.into()
|
||||
};
|
||||
|
||||
// Sticky header bar: stays fixed above the scrollable content so the Back
|
||||
// button is always reachable. The "Settings" title is centered by flanking
|
||||
@@ -2597,29 +2785,44 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
|
||||
|
||||
if state.current_screen == Screen::Home {
|
||||
// --- HOME SCREEN ---
|
||||
// Two cards: Connect (left) + the live Friends list (right). They sit
|
||||
// side-by-side when the window is wide enough, and stack vertically on a
|
||||
// narrow window so the Friends card never gets crushed — below ~860px the
|
||||
// fixed-width Connect card would otherwise squeeze it until its node-ID
|
||||
// field and remove button clip away. `responsive` measures the available
|
||||
// width each layout pass and picks the orientation accordingly.
|
||||
// Three cards: Recents | Connect | Friends, side-by-side when there's room.
|
||||
// Three 380–460px cards need ~1280px to fit in a row, so below that the
|
||||
// `responsive` measure stacks them in a column (Connect first — the primary
|
||||
// action) rather than letting the row clip. Recents always shows (empty-
|
||||
// state hint when no history) for parity with the Friends card.
|
||||
// Keep Create/Join dominant on a fresh install. Once Recents or Friends
|
||||
// has real content, the wider three-card layout returns.
|
||||
let has_recents = !state.config.recents.is_empty();
|
||||
let has_friends = !state.friends.list().is_empty();
|
||||
let body = responsive(move |size| {
|
||||
let cards: Element<AppMessage> = if size.width < 1280.0 {
|
||||
column![connect_card(state), recents_card(state), friends_panel(state)]
|
||||
.spacing(20)
|
||||
.align_x(iced::alignment::Horizontal::Center)
|
||||
.into()
|
||||
} else {
|
||||
row![recents_card(state), connect_card(state), friends_panel(state)]
|
||||
let cards: Element<AppMessage> =
|
||||
match home_layout_mode(size.width, has_recents, has_friends) {
|
||||
HomeLayoutMode::FocusedEmpty => row![
|
||||
connect_card(state),
|
||||
column![friends_panel(state), recents_card(state)]
|
||||
.spacing(16)
|
||||
.width(iced::Length::Fixed(360.0)),
|
||||
]
|
||||
.spacing(22)
|
||||
.align_y(iced::alignment::Vertical::Top)
|
||||
.into(),
|
||||
HomeLayoutMode::ThreeColumn => row![
|
||||
recents_card(state),
|
||||
connect_card(state),
|
||||
friends_panel(state),
|
||||
]
|
||||
.spacing(20)
|
||||
.align_y(iced::alignment::Vertical::Top)
|
||||
.into()
|
||||
};
|
||||
.into(),
|
||||
HomeLayoutMode::Stacked => {
|
||||
let mut stack = column![connect_card(state)]
|
||||
.spacing(20)
|
||||
.align_x(iced::alignment::Horizontal::Center);
|
||||
if has_recents {
|
||||
stack = stack.push(recents_card(state));
|
||||
}
|
||||
stack = stack.push(friends_panel(state));
|
||||
if !has_recents {
|
||||
stack = stack.push(recents_card(state));
|
||||
}
|
||||
stack.into()
|
||||
}
|
||||
};
|
||||
scrollable(container(cards).center_x(iced::Length::Fill))
|
||||
.width(iced::Length::Fill)
|
||||
.into()
|
||||
@@ -4517,6 +4720,28 @@ mod tests {
|
||||
assert_eq!(format_duration(3661), "1:01:01");
|
||||
assert_eq!(format_duration(3725), "1:02:05");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_categories_are_stable_and_grouped_for_navigation() {
|
||||
use super::SettingsCategory;
|
||||
let labels: Vec<_> = SettingsCategory::ALL.iter().map(|c| c.label()).collect();
|
||||
assert_eq!(
|
||||
labels,
|
||||
vec!["Audio", "Hotkeys", "Recording", "Profile", "Appearance", "Network", "Notifications"]
|
||||
);
|
||||
assert_eq!(SettingsCategory::Audio.hint(), "Devices, mic gate, echo");
|
||||
assert_eq!(SettingsCategory::Profile.hint(), "Avatar and identity");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn home_layout_prioritizes_connect_on_empty_home() {
|
||||
use super::{home_layout_mode, HomeLayoutMode};
|
||||
assert_eq!(home_layout_mode(1280.0, false, false), HomeLayoutMode::FocusedEmpty);
|
||||
assert_eq!(home_layout_mode(760.0, false, false), HomeLayoutMode::Stacked);
|
||||
assert_eq!(home_layout_mode(1280.0, true, false), HomeLayoutMode::ThreeColumn);
|
||||
assert_eq!(home_layout_mode(1100.0, true, true), HomeLayoutMode::Stacked);
|
||||
}
|
||||
|
||||
use super::{clamp_chat_height, clamp_participants_width, CHAT_MIN_H, PARTICIPANTS_MIN_W};
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -124,11 +124,10 @@ pub enum UiEvent {
|
||||
/// joinable gathering (with a one-click ticket). Emitted by the outbound ping
|
||||
/// scheduler; absence of a recent event = treat as offline.
|
||||
FriendPresence { id: EndpointId, presence: FriendPresence },
|
||||
/// The Discoverable time-box elapsed (W7 P6): the core auto-reverted our presence
|
||||
/// posture to the carried `mode` (always `Normal`) and stopped publishing. The
|
||||
/// GUI must mirror + persist this so its presence picker stops showing
|
||||
/// Discoverable. Distinct from a user-driven change so the GUI knows to update
|
||||
/// without having issued the command itself.
|
||||
/// Core corrected the committed presence posture. Usually the Discoverable
|
||||
/// time-box elapsed and the core auto-reverted to `Normal`; on discovery apply
|
||||
/// failure, this carries the previous truthful mode. The GUI must mirror +
|
||||
/// persist this so its presence picker matches the endpoint's discovery state.
|
||||
PresenceModeReverted { mode: PresenceMode },
|
||||
/// Core finished orderly app shutdown and the GUI can exit.
|
||||
ShutdownComplete,
|
||||
|
||||
+197
-45
@@ -13,6 +13,7 @@ use crate::network::{
|
||||
use crate::core::messages::{CoreCommand, UiEvent};
|
||||
|
||||
use crate::config::{NetworkMode, RecordingMode};
|
||||
use crate::presence::PresenceMode;
|
||||
use crate::audio::multitrack::MultitrackRecorder;
|
||||
use iroh::{Endpoint, EndpointAddr, EndpointId, RelayMode, SecretKey, endpoint::presets, protocol::Router};
|
||||
use iroh_gossip::net::Gossip;
|
||||
@@ -64,6 +65,32 @@ impl CoreController {
|
||||
/// clears from the room promptly.
|
||||
const RECONNECT_GRACE: Duration = Duration::from_secs(45);
|
||||
|
||||
/// Opus frames sent by our encoder are one 20 ms mono frame, normally far below
|
||||
/// this. 4000 bytes still leaves room for large valid Opus packets (well above a
|
||||
/// 48 kHz / 60 ms frame) while bounding malicious datagram copy/decode churn.
|
||||
const MAX_OPUS_PAYLOAD: usize = 4000;
|
||||
|
||||
/// If the Discoverable time-box tries to revert but discovery service reconfiguration
|
||||
/// fails, retry soon while keeping the UI in the still-possible publishing state.
|
||||
const DISCOVERY_REVERT_RETRY: Duration = Duration::from_secs(60);
|
||||
|
||||
fn audio_datagram_len_ok(len: usize) -> bool {
|
||||
(4..=4 + MAX_OPUS_PAYLOAD).contains(&len)
|
||||
}
|
||||
|
||||
fn arm_discovery_retry(
|
||||
discovery_deadline: &mut Option<tokio::time::Instant>,
|
||||
now: tokio::time::Instant,
|
||||
) {
|
||||
let retry_deadline = now + DISCOVERY_REVERT_RETRY;
|
||||
if discovery_deadline
|
||||
.map(|current| current > retry_deadline)
|
||||
.unwrap_or(true)
|
||||
{
|
||||
*discovery_deadline = Some(retry_deadline);
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-peer reconnect grace timers (see [`RECONNECT_GRACE`]). Shared between the
|
||||
/// room-event task (which arms one on a transient drop and cancels it on a
|
||||
/// gossip rejoin) and the conn-event task (which cancels it when the audio link
|
||||
@@ -457,12 +484,11 @@ impl NetStack {
|
||||
/// `DnsAddressLookup`, mirroring the `N0` preset) is added when `plan.resolver`; the
|
||||
/// n0 DNS *publisher* (`PkarrPublisher`) when `plan.publisher`.
|
||||
///
|
||||
/// Idempotent and reversible: it clears the whole service set and reinstalls exactly
|
||||
/// what the plan wants, so flipping `publisher` off simply drops the publisher (its
|
||||
/// republish task ends when the last clone is dropped, and the already-published
|
||||
/// record TTL-expires within ~30s) without an endpoint rebuild and without disturbing
|
||||
/// resolution. The brief clear→re-add window is a few synchronous calls; presence
|
||||
/// toggles are rare, so a concurrent dial racing it is not a practical concern.
|
||||
/// Idempotent and reversible: it builds the replacement services first, then clears
|
||||
/// the service set and reinstalls exactly what the plan wants. Flipping `publisher`
|
||||
/// off drops the publisher (its republish task ends when the last clone is dropped,
|
||||
/// and the already-published record TTL-expires within ~30s) without an endpoint
|
||||
/// rebuild and without disturbing resolution.
|
||||
fn apply_discovery(
|
||||
endpoint: &Endpoint,
|
||||
memory_lookup: &iroh::address_lookup::memory::MemoryLookup,
|
||||
@@ -473,16 +499,34 @@ fn apply_discovery(
|
||||
pkarr::{PkarrPublisher, PkarrResolver},
|
||||
};
|
||||
let services = endpoint.address_lookup()?;
|
||||
let pkarr_resolver = if plan.resolver {
|
||||
Some(PkarrResolver::n0_dns().into_address_lookup(endpoint)?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let dns_resolver = if plan.resolver {
|
||||
Some(DnsAddressLookup::n0_dns().into_address_lookup(endpoint)?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let publisher = if plan.publisher {
|
||||
Some(PkarrPublisher::n0_dns().into_address_lookup(endpoint)?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
services.clear();
|
||||
// Always keep the local, server-free lookup (this is what ticket/gossip dialing
|
||||
// depends on — it must survive every posture, including DirectOnly).
|
||||
services.add(memory_lookup.clone());
|
||||
if plan.resolver {
|
||||
services.add(PkarrResolver::n0_dns().into_address_lookup(endpoint)?);
|
||||
services.add(DnsAddressLookup::n0_dns().into_address_lookup(endpoint)?);
|
||||
if let Some(pkarr_resolver) = pkarr_resolver {
|
||||
services.add(pkarr_resolver);
|
||||
}
|
||||
if plan.publisher {
|
||||
services.add(PkarrPublisher::n0_dns().into_address_lookup(endpoint)?);
|
||||
if let Some(dns_resolver) = dns_resolver {
|
||||
services.add(dns_resolver);
|
||||
}
|
||||
if let Some(publisher) = publisher {
|
||||
services.add(publisher);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -634,7 +678,7 @@ async fn probe_friends_once(
|
||||
let ep = endpoint.clone();
|
||||
set.spawn(async move {
|
||||
match crate::presence_net::probe(&ep, addr).await {
|
||||
Ok(reply) => crate::presence::interpret_pong(&reply).map(|p| (id, p)),
|
||||
Ok((from, reply)) => crate::presence::interpret_pong(&reply, from).map(|p| (id, p)),
|
||||
Err(_) => None,
|
||||
}
|
||||
});
|
||||
@@ -842,22 +886,64 @@ async fn run_core_loop(
|
||||
// W7 P6 time-box: Discoverable auto-reverts to Normal after DISCOVERY_TIMEBOX
|
||||
// so a publish beacon never stands indefinitely. The branch is disabled
|
||||
// (`if` guard) unless a deadline is armed; `unwrap_or_else` is unreachable
|
||||
// belt-and-braces. On fire: stop publishing, drop to Normal, tell the GUI.
|
||||
// belt-and-braces. On fire: stop publishing first, then commit Normal only
|
||||
// if the endpoint's discovery services accepted the non-publishing plan.
|
||||
_ = tokio::time::sleep_until(
|
||||
discovery_deadline.unwrap_or_else(tokio::time::Instant::now),
|
||||
), if discovery_deadline.is_some() => {
|
||||
discovery_deadline = None;
|
||||
*presence_mode.lock().unwrap() = crate::presence::PresenceMode::Normal;
|
||||
let plan = crate::discovery::lookup_plan(network_mode, false);
|
||||
if let Err(e) = apply_discovery(&net.endpoint, &net.memory_lookup, plan) {
|
||||
crate::log_msg(&format!("discovery: time-box revert failed: {e:#}"));
|
||||
let previous_mode = *presence_mode.lock().unwrap();
|
||||
if previous_mode != PresenceMode::Discoverable {
|
||||
discovery_deadline = None;
|
||||
continue;
|
||||
}
|
||||
|
||||
let requested_mode = PresenceMode::Normal;
|
||||
let now = tokio::time::Instant::now();
|
||||
let plan = crate::discovery::lookup_plan(
|
||||
network_mode,
|
||||
requested_mode.publishes_to_discovery(),
|
||||
);
|
||||
let apply_result = apply_discovery(&net.endpoint, &net.memory_lookup, plan);
|
||||
let (committed_mode, transition_error) =
|
||||
crate::discovery::resolve_presence_transition(
|
||||
previous_mode,
|
||||
requested_mode,
|
||||
apply_result.is_ok(),
|
||||
);
|
||||
*presence_mode.lock().unwrap() = committed_mode;
|
||||
discovery_deadline = if committed_mode == PresenceMode::Discoverable {
|
||||
Some(now + DISCOVERY_REVERT_RETRY)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
match apply_result {
|
||||
Ok(()) => {
|
||||
crate::log_msg(
|
||||
"discovery: Discoverable time-box elapsed → reverting to Normal",
|
||||
);
|
||||
let _ = ui_tx
|
||||
.send(UiEvent::PresenceModeReverted {
|
||||
mode: PresenceMode::Normal,
|
||||
})
|
||||
.await;
|
||||
}
|
||||
Err(e) => {
|
||||
crate::log_msg(&format!("discovery: time-box revert failed: {e:#}"));
|
||||
if committed_mode != requested_mode {
|
||||
let _ = ui_tx
|
||||
.send(UiEvent::PresenceModeReverted {
|
||||
mode: committed_mode,
|
||||
})
|
||||
.await;
|
||||
}
|
||||
if let Some(message) = transition_error {
|
||||
let _ = ui_tx
|
||||
.send(UiEvent::Error(format!("{message} ({e:#})")))
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
crate::log_msg("discovery: Discoverable time-box elapsed → reverting to Normal");
|
||||
let _ = ui_tx
|
||||
.send(UiEvent::PresenceModeReverted {
|
||||
mode: crate::presence::PresenceMode::Normal,
|
||||
})
|
||||
.await;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
@@ -923,7 +1009,12 @@ async fn run_core_loop(
|
||||
let ticket_str = if ticket.trim().is_empty() || ticket == "create" {
|
||||
let topic_id: [u8; 32] = rand::random();
|
||||
let host_addr = endpoint.addr();
|
||||
crate::log_msg(&format!("Creating room. host_addr={:?}, topic_id={:?}", host_addr, topic_id));
|
||||
crate::log_msg(&format!(
|
||||
"Creating room. host_id={}, host_addrs={}, topic={}",
|
||||
crate::short_id(&host_addr.id.to_string()),
|
||||
host_addr.addrs.len(),
|
||||
crate::short_bytes_hex(&topic_id)
|
||||
));
|
||||
// The creator's chosen cosmetic label rides in the ticket so
|
||||
// every joiner inherits it; sanitize it before it leaves here.
|
||||
let label = crate::sanitize::sanitize_name(&room_name);
|
||||
@@ -931,7 +1022,10 @@ async fn run_core_loop(
|
||||
ticket.to_string()
|
||||
} else {
|
||||
let ticket_str = ticket.trim().to_string();
|
||||
crate::log_msg(&format!("Joining room with existing ticket={}", ticket_str));
|
||||
crate::log_msg(&format!(
|
||||
"Joining room with existing ticket={}",
|
||||
crate::redact_for_log(&ticket_str)
|
||||
));
|
||||
ticket_str
|
||||
};
|
||||
|
||||
@@ -970,7 +1064,17 @@ async fn run_core_loop(
|
||||
.map(|peers| peers.values().cloned().collect())
|
||||
.unwrap_or_default();
|
||||
|
||||
crate::log_msg(&format!("Attempting room_state.join with self_state={:?}, extra_bootstrap={:?}", self_state, extra_bootstrap.iter().map(|a| a.id).collect::<Vec<_>>()));
|
||||
let extra_bootstrap_ids = extra_bootstrap
|
||||
.iter()
|
||||
.map(|a| crate::short_id(&a.id.to_string()))
|
||||
.collect::<Vec<_>>();
|
||||
crate::log_msg(&format!(
|
||||
"Attempting room_state.join self_id={}, self_name={:?}, sharing={}, extra_bootstrap={:?}",
|
||||
crate::short_id(&self_state.addr.id.to_string()),
|
||||
self_state.name,
|
||||
self_state.sharing.is_some(),
|
||||
extra_bootstrap_ids
|
||||
));
|
||||
if let Err(e) = room_state.join(&ticket_str, self_state.clone(), extra_bootstrap).await {
|
||||
crate::log_msg(&format!("Error room_state.join failed: {:?}", e));
|
||||
let _ = ui_tx.send(UiEvent::Error(format!("Failed to join room: {}", e))).await;
|
||||
@@ -1135,8 +1239,9 @@ async fn run_core_loop(
|
||||
};
|
||||
|
||||
while let Some((from_peer, bytes)) = datagram_rx.recv().await {
|
||||
if bytes.len() < 4 {
|
||||
continue; // malformed: missing sequence header
|
||||
if !audio_datagram_len_ok(bytes.len()) {
|
||||
// Malformed (< sequence header) or oversized Opus payload.
|
||||
continue;
|
||||
}
|
||||
let seq = u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]);
|
||||
let payload = bytes[4..].to_vec();
|
||||
@@ -1770,22 +1875,60 @@ async fn run_core_loop(
|
||||
}
|
||||
|
||||
CoreCommand::SetPresenceMode(mode) => {
|
||||
*presence_mode.lock().unwrap() = mode;
|
||||
// W7 P6: re-apply n0 DNS discovery for the new posture (publish on iff
|
||||
// Discoverable). Runtime — no endpoint rebuild; clears + reinstalls the
|
||||
// address-lookup services. The resolver stays on regardless so we can
|
||||
// still look up moved friends.
|
||||
let plan = crate::discovery::lookup_plan(network_mode, mode.publishes_to_discovery());
|
||||
if let Err(e) = apply_discovery(&net.endpoint, &net.memory_lookup, plan) {
|
||||
crate::log_msg(&format!("discovery: apply failed: {e:#}"));
|
||||
let previous_mode = *presence_mode.lock().unwrap();
|
||||
let now = tokio::time::Instant::now();
|
||||
|
||||
if previous_mode == mode {
|
||||
// Same-mode requests are no-ops for discovery wiring, but keep the
|
||||
// existing UX: re-selecting Discoverable restarts the clock.
|
||||
discovery_deadline = if mode == PresenceMode::Discoverable {
|
||||
Some(now + crate::discovery::DISCOVERY_TIMEBOX)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
continue;
|
||||
}
|
||||
// Arm (Discoverable) or cancel (any other posture) the auto-revert
|
||||
// time-box. Re-selecting Discoverable restarts the clock.
|
||||
discovery_deadline = if mode == crate::presence::PresenceMode::Discoverable {
|
||||
Some(tokio::time::Instant::now() + crate::discovery::DISCOVERY_TIMEBOX)
|
||||
|
||||
// W7 P6/S11: re-apply n0 DNS discovery for the requested posture
|
||||
// first, then commit the presence mode only if the endpoint accepted
|
||||
// that discovery plan. This keeps the UI truthful when dropping the
|
||||
// publisher fails.
|
||||
let plan =
|
||||
crate::discovery::lookup_plan(network_mode, mode.publishes_to_discovery());
|
||||
let apply_result = apply_discovery(&net.endpoint, &net.memory_lookup, plan);
|
||||
let (committed_mode, transition_error) =
|
||||
crate::discovery::resolve_presence_transition(
|
||||
previous_mode,
|
||||
mode,
|
||||
apply_result.is_ok(),
|
||||
);
|
||||
*presence_mode.lock().unwrap() = committed_mode;
|
||||
|
||||
if committed_mode == PresenceMode::Discoverable {
|
||||
if apply_result.is_ok() && mode == PresenceMode::Discoverable {
|
||||
discovery_deadline = Some(now + crate::discovery::DISCOVERY_TIMEBOX);
|
||||
} else {
|
||||
arm_discovery_retry(&mut discovery_deadline, now);
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
discovery_deadline = None;
|
||||
}
|
||||
|
||||
if let Err(e) = apply_result {
|
||||
crate::log_msg(&format!("discovery: apply failed: {e:#}"));
|
||||
if committed_mode != mode {
|
||||
let _ = ui_tx
|
||||
.send(UiEvent::PresenceModeReverted {
|
||||
mode: committed_mode,
|
||||
})
|
||||
.await;
|
||||
}
|
||||
if let Some(message) = transition_error {
|
||||
let _ = ui_tx
|
||||
.send(UiEvent::Error(format!("{message} ({e:#})")))
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
CoreCommand::SetRecordingMode(mode) => {
|
||||
@@ -1987,8 +2130,8 @@ async fn run_core_loop(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
apply_volume, frame_level, mix_frames, mix_stereo_frames, stereo_to_mono, MicLevelMeter,
|
||||
MIC_LEVEL_REPORT_SAMPLES,
|
||||
apply_volume, audio_datagram_len_ok, frame_level, mix_frames, mix_stereo_frames,
|
||||
stereo_to_mono, MicLevelMeter, MAX_OPUS_PAYLOAD, MIC_LEVEL_REPORT_SAMPLES,
|
||||
};
|
||||
|
||||
/// A frame of constant amplitude with the given sample count.
|
||||
@@ -2005,6 +2148,15 @@ mod tests {
|
||||
assert!(m.push(&frame(1000, MIC_LEVEL_REPORT_SAMPLES)).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audio_datagram_length_gate_preserves_header_and_caps_payload() {
|
||||
assert!(!audio_datagram_len_ok(0));
|
||||
assert!(!audio_datagram_len_ok(3));
|
||||
assert!(audio_datagram_len_ok(4));
|
||||
assert!(audio_datagram_len_ok(4 + MAX_OPUS_PAYLOAD));
|
||||
assert!(!audio_datagram_len_ok(5 + MAX_OPUS_PAYLOAD));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mic_meter_holds_the_peak_across_the_window() {
|
||||
let mut m = MicLevelMeter::new();
|
||||
|
||||
+96
-10
@@ -8,14 +8,19 @@
|
||||
//! The model (from `docs/contacts-plan.md` P6, decided 2026-06-16):
|
||||
//! - **Resolving is always allowed on relay-capable modes** — a stationary friend
|
||||
//! (typically in `Normal`) must be able to look up a friend who moved networks. A
|
||||
//! resolve is a DNS query to n0 that publishes nothing; it only fires when a saved
|
||||
//! address is stale and the dial falls through to discovery.
|
||||
//! resolve is a DNS query to n0 that publishes nothing, but still exposes query
|
||||
//! timing/source metadata to n0; it only fires when a saved address is stale and
|
||||
//! the dial falls through to discovery.
|
||||
//! - **Publishing is gated on `Discoverable`** and asymmetric: only the mover
|
||||
//! publishes their address to n0 DNS; everyone else just looks it up.
|
||||
//! - **Stopping publishing removes the local publisher service**; iroh does not
|
||||
//! expose an explicit unpublish call here, so already-published pkarr records can
|
||||
//! linger until their default ~30s TTL expires.
|
||||
//! - **`DirectOnly` is the explicit no-server posture** — neither resolve nor publish
|
||||
//! ever touches n0 there, regardless of the Discoverable toggle.
|
||||
|
||||
use crate::config::NetworkMode;
|
||||
use crate::presence::PresenceMode;
|
||||
use std::time::Duration;
|
||||
|
||||
/// How long `Discoverable` stays on before auto-reverting to `Normal`. Discovery is
|
||||
@@ -46,12 +51,43 @@ pub fn lookup_plan(network_mode: NetworkMode, want_publish: bool) -> LookupPlan
|
||||
match network_mode {
|
||||
// The explicit serverless posture: no n0 contact at all, even to resolve.
|
||||
// A Discoverable toggle here is intentionally inert.
|
||||
NetworkMode::DirectOnly => LookupPlan { resolver: false, publisher: false },
|
||||
NetworkMode::DirectOnly => LookupPlan {
|
||||
resolver: false,
|
||||
publisher: false,
|
||||
},
|
||||
// Relay-capable: always resolve (so a stationary friend can find a mover);
|
||||
// publish only when the user opted into Discoverable.
|
||||
NetworkMode::RelayNoDiscovery | NetworkMode::N0Full => {
|
||||
LookupPlan { resolver: true, publisher: want_publish }
|
||||
}
|
||||
NetworkMode::RelayNoDiscovery | NetworkMode::N0Full => LookupPlan {
|
||||
resolver: true,
|
||||
publisher: want_publish,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Decide which presence mode may be committed after attempting to apply discovery
|
||||
/// services for `requested`.
|
||||
///
|
||||
/// On failure, keep the previous mode: it is the only locally truthful state because
|
||||
/// the endpoint's discovery services may still reflect the old posture. Same-mode
|
||||
/// requests are no-ops from a presence-truth perspective and do not surface an error.
|
||||
pub fn resolve_presence_transition(
|
||||
previous: PresenceMode,
|
||||
requested: PresenceMode,
|
||||
apply_ok: bool,
|
||||
) -> (PresenceMode, Option<String>) {
|
||||
if previous == requested {
|
||||
return (previous, None);
|
||||
}
|
||||
|
||||
if apply_ok {
|
||||
(requested, None)
|
||||
} else {
|
||||
(
|
||||
previous,
|
||||
Some(format!(
|
||||
"Couldn't update discovery mode; keeping {previous}."
|
||||
)),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,12 +100,18 @@ mod tests {
|
||||
for mode in [NetworkMode::RelayNoDiscovery, NetworkMode::N0Full] {
|
||||
assert_eq!(
|
||||
lookup_plan(mode, false),
|
||||
LookupPlan { resolver: true, publisher: false },
|
||||
LookupPlan {
|
||||
resolver: true,
|
||||
publisher: false
|
||||
},
|
||||
"{mode:?}: resolve always on, no publish when not Discoverable"
|
||||
);
|
||||
assert_eq!(
|
||||
lookup_plan(mode, true),
|
||||
LookupPlan { resolver: true, publisher: true },
|
||||
LookupPlan {
|
||||
resolver: true,
|
||||
publisher: true
|
||||
},
|
||||
"{mode:?}: Discoverable adds publish on top of resolve"
|
||||
);
|
||||
}
|
||||
@@ -79,12 +121,18 @@ mod tests {
|
||||
fn direct_only_never_touches_n0_even_when_discoverable() {
|
||||
assert_eq!(
|
||||
lookup_plan(NetworkMode::DirectOnly, false),
|
||||
LookupPlan { resolver: false, publisher: false }
|
||||
LookupPlan {
|
||||
resolver: false,
|
||||
publisher: false
|
||||
}
|
||||
);
|
||||
// The serverless posture overrides the Discoverable request entirely.
|
||||
assert_eq!(
|
||||
lookup_plan(NetworkMode::DirectOnly, true),
|
||||
LookupPlan { resolver: false, publisher: false }
|
||||
LookupPlan {
|
||||
resolver: false,
|
||||
publisher: false
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -92,4 +140,42 @@ mod tests {
|
||||
fn timebox_is_thirty_minutes() {
|
||||
assert_eq!(DISCOVERY_TIMEBOX, Duration::from_secs(1800));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn presence_transition_commits_requested_mode_after_successful_apply() {
|
||||
assert_eq!(
|
||||
resolve_presence_transition(PresenceMode::Normal, PresenceMode::Discoverable, true),
|
||||
(PresenceMode::Discoverable, None)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn presence_transition_keeps_previous_mode_when_apply_fails() {
|
||||
let (mode, err) =
|
||||
resolve_presence_transition(PresenceMode::Normal, PresenceMode::Discoverable, false);
|
||||
|
||||
assert_eq!(mode, PresenceMode::Normal);
|
||||
assert!(err.unwrap().contains("keeping Normal"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn presence_transition_keeps_discoverable_when_off_transition_fails() {
|
||||
let (mode, err) =
|
||||
resolve_presence_transition(PresenceMode::Discoverable, PresenceMode::Normal, false);
|
||||
|
||||
assert_eq!(mode, PresenceMode::Discoverable);
|
||||
assert!(err.unwrap().contains("keeping Discoverable"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn presence_transition_same_mode_is_noop_without_error() {
|
||||
assert_eq!(
|
||||
resolve_presence_transition(
|
||||
PresenceMode::Discoverable,
|
||||
PresenceMode::Discoverable,
|
||||
false
|
||||
),
|
||||
(PresenceMode::Discoverable, None)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+118
-6
@@ -18,9 +18,13 @@ pub mod recents;
|
||||
pub mod discovery;
|
||||
pub mod hotkeys;
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::fs::File;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::OnceLock;
|
||||
|
||||
const LOG_MAX_BYTES: u64 = 5 * 1024 * 1024;
|
||||
const LOG_MODE: u32 = 0o600;
|
||||
|
||||
/// Resolves the log file path once: `$XDG_STATE_HOME/peerspeak/peerspeak.log`
|
||||
/// (via `dirs::state_dir`), falling back to the system temp dir. Computed lazily
|
||||
/// so we never hardcode a per-user path.
|
||||
@@ -43,6 +47,65 @@ pub fn log_file_path() -> PathBuf {
|
||||
log_path().clone()
|
||||
}
|
||||
|
||||
/// Short, human-matchable id prefix for diagnostics. Never use this where the
|
||||
/// full value is needed for protocol behavior.
|
||||
pub fn short_id(id: &str) -> String {
|
||||
id.chars().take(8).collect()
|
||||
}
|
||||
|
||||
/// Redact a capability-bearing value for logs while keeping a tiny prefix for
|
||||
/// support correlation. Tickets and endpoint addresses are bearer capabilities:
|
||||
/// logging the full string is equivalent to leaking the room/share.
|
||||
pub fn redact_for_log(value: &str) -> String {
|
||||
let value = value.trim();
|
||||
if value.is_empty() {
|
||||
"<redacted:empty>".to_string()
|
||||
} else {
|
||||
format!("<redacted:{}...>", short_id(value))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn short_bytes_hex(bytes: &[u8]) -> String {
|
||||
bytes.iter()
|
||||
.take(6)
|
||||
.map(|b| format!("{b:02x}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join("")
|
||||
}
|
||||
|
||||
fn rotated_log_path(path: &Path) -> PathBuf {
|
||||
let file_name = path.file_name().and_then(|n| n.to_str()).unwrap_or("peerspeak.log");
|
||||
path.with_file_name(format!("{file_name}.1"))
|
||||
}
|
||||
|
||||
fn prepare_log_file(path: &Path) -> std::io::Result<File> {
|
||||
prepare_log_file_with_limit(path, LOG_MAX_BYTES)
|
||||
}
|
||||
|
||||
fn prepare_log_file_with_limit(path: &Path, max_bytes: u64) -> std::io::Result<File> {
|
||||
use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
|
||||
|
||||
if let Some(parent) = path.parent() {
|
||||
let _ = std::fs::create_dir_all(parent);
|
||||
}
|
||||
|
||||
if std::fs::metadata(path).is_ok_and(|m| m.len() > max_bytes) {
|
||||
let rotated = rotated_log_path(path);
|
||||
let _ = std::fs::remove_file(&rotated);
|
||||
if std::fs::rename(path, &rotated).is_err() {
|
||||
let _ = std::fs::OpenOptions::new().write(true).truncate(true).open(path);
|
||||
}
|
||||
}
|
||||
|
||||
let file = std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.mode(LOG_MODE)
|
||||
.open(path)?;
|
||||
let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(LOG_MODE));
|
||||
Ok(file)
|
||||
}
|
||||
|
||||
pub fn log_msg(msg: &str) {
|
||||
// Format the whole line into one buffer first, then emit it with a single
|
||||
// `write_all`. The file is opened with `O_APPEND`, so a lone `write()` is
|
||||
@@ -52,12 +115,61 @@ pub fn log_msg(msg: &str) {
|
||||
Ok(time) => format!("[{}.{:03}] {}\n", time.as_secs(), time.subsec_millis(), msg),
|
||||
Err(_) => format!("{}\n", msg),
|
||||
};
|
||||
if let Ok(mut file) = std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(log_path())
|
||||
{
|
||||
if let Ok(mut file) = prepare_log_file(log_path()) {
|
||||
use std::io::Write;
|
||||
let _ = file.write_all(line.as_bytes());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::io::Write;
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
fn temp_log_dir() -> PathBuf {
|
||||
let stamp = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos();
|
||||
std::env::temp_dir().join(format!("peerspeak-log-test-{}-{stamp}", std::process::id()))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redaction_keeps_only_a_short_prefix() {
|
||||
let secret = "abcdefghijklmnopqrstuvwxyz";
|
||||
let redacted = redact_for_log(secret);
|
||||
assert!(redacted.contains("abcdefgh"));
|
||||
assert!(!redacted.contains("ijklmnopqrstuvwxyz"));
|
||||
assert_eq!(redact_for_log(" "), "<redacted:empty>");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn log_file_is_created_private() {
|
||||
let dir = temp_log_dir();
|
||||
let path = dir.join("peerspeak.log");
|
||||
let _file = prepare_log_file(&path).unwrap();
|
||||
|
||||
let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
|
||||
assert_eq!(mode, LOG_MODE);
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oversized_log_is_rotated_on_open() {
|
||||
let dir = temp_log_dir();
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("peerspeak.log");
|
||||
{
|
||||
let mut file = std::fs::File::create(&path).unwrap();
|
||||
file.write_all(b"oversized").unwrap();
|
||||
}
|
||||
|
||||
let _file = prepare_log_file_with_limit(&path, 4).unwrap();
|
||||
let rotated = rotated_log_path(&path);
|
||||
|
||||
assert_eq!(std::fs::read_to_string(rotated).unwrap(), "oversized");
|
||||
assert_eq!(std::fs::metadata(&path).unwrap().len(), 0);
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
}
|
||||
|
||||
+168
-13
@@ -43,7 +43,7 @@ impl std::fmt::Debug for GossipPayload {
|
||||
f.debug_struct("GossipPayload")
|
||||
.field("author", &self.author)
|
||||
.field("ts", &self.ts)
|
||||
.field("msg", &self.msg)
|
||||
.field("msg_kind", &gossip_message_kind(&self.msg))
|
||||
.finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
@@ -79,6 +79,58 @@ enum GossipReject {
|
||||
BadSignature,
|
||||
/// Timestamp outside the freshness window — stale (replay) or implausibly future.
|
||||
OutOfWindow,
|
||||
/// A signed Announce advertised an address for a different node id.
|
||||
AnnounceAddressMismatch,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
enum StateMutationKind {
|
||||
Announce,
|
||||
Leave,
|
||||
}
|
||||
|
||||
fn gossip_message_kind(msg: &GossipMessage) -> &'static str {
|
||||
match msg {
|
||||
GossipMessage::Announce(_) => "Announce",
|
||||
GossipMessage::Leave => "Leave",
|
||||
GossipMessage::Chat { .. } => "Chat",
|
||||
}
|
||||
}
|
||||
|
||||
fn state_mutation_kind(msg: &GossipMessage) -> Option<StateMutationKind> {
|
||||
match msg {
|
||||
GossipMessage::Announce(_) => Some(StateMutationKind::Announce),
|
||||
GossipMessage::Leave => Some(StateMutationKind::Leave),
|
||||
GossipMessage::Chat { .. } => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn admit_state_mutation(
|
||||
seen: &mut HashMap<(EndpointId, StateMutationKind), u64>,
|
||||
author: EndpointId,
|
||||
msg: &GossipMessage,
|
||||
ts: u64,
|
||||
) -> bool {
|
||||
let Some(kind) = state_mutation_kind(msg) else {
|
||||
return true;
|
||||
};
|
||||
let key = (author, kind);
|
||||
if seen.get(&key).is_some_and(|last_ts| ts <= *last_ts) {
|
||||
return false;
|
||||
}
|
||||
seen.insert(key, ts);
|
||||
true
|
||||
}
|
||||
|
||||
fn peer_state_for_log(state: &PeerState) -> String {
|
||||
format!(
|
||||
"name={:?}, muted={}, addr_id={}, addrs={}, sharing={}",
|
||||
state.name,
|
||||
state.is_muted,
|
||||
crate::short_id(&state.addr.id.to_string()),
|
||||
state.addr.addrs.len(),
|
||||
state.sharing.is_some()
|
||||
)
|
||||
}
|
||||
|
||||
/// Authenticate a received payload against the room topic and local clock. The
|
||||
@@ -99,6 +151,10 @@ fn verify_gossip(
|
||||
if now_ms.abs_diff(payload.ts) > window_ms {
|
||||
return Err(GossipReject::OutOfWindow);
|
||||
}
|
||||
if let GossipMessage::Announce(state) = &payload.msg
|
||||
&& state.addr.id != payload.author {
|
||||
return Err(GossipReject::AnnounceAddressMismatch);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -185,11 +241,21 @@ impl RoomState for IrohGossipState {
|
||||
self_state: PeerState,
|
||||
extra_bootstrap: Vec<EndpointAddr>,
|
||||
) -> Result<(), NetError> {
|
||||
crate::log_msg(&format!("RoomState::join: self_id={:?}, self_name={:?}, ticket={}", self_state.addr.id, self_state.name, ticket_str));
|
||||
crate::log_msg(&format!(
|
||||
"RoomState::join: self_id={}, self_name={:?}, ticket={}",
|
||||
crate::short_id(&self_state.addr.id.to_string()),
|
||||
self_state.name,
|
||||
crate::redact_for_log(ticket_str)
|
||||
));
|
||||
let ticket = ticket_str.parse::<PeerSpeakTicket>()?;
|
||||
let topic_id = TopicId::from_bytes(ticket.topic_id);
|
||||
|
||||
crate::log_msg(&format!("Parsed ticket. host_id={:?}, host_addrs={:?}, topic={:?}", ticket.host_addr.id, ticket.host_addr.addrs, topic_id));
|
||||
crate::log_msg(&format!(
|
||||
"Parsed ticket. host_id={}, host_addrs={}, topic={}",
|
||||
crate::short_id(&ticket.host_addr.id.to_string()),
|
||||
ticket.host_addr.addrs.len(),
|
||||
crate::short_bytes_hex(&ticket.topic_id)
|
||||
));
|
||||
|
||||
// Stop any currently running topic
|
||||
let _ = self.leave().await;
|
||||
@@ -236,6 +302,7 @@ impl RoomState for IrohGossipState {
|
||||
|
||||
let handle = tokio::spawn(async move {
|
||||
crate::log_msg(&format!("Spawned gossip topic loop for self_id={:?}", self_id));
|
||||
let mut state_mutations_seen = HashMap::new();
|
||||
|
||||
// Broadcast initial state
|
||||
let initial_payload = {
|
||||
@@ -285,7 +352,26 @@ impl RoomState for IrohGossipState {
|
||||
continue;
|
||||
}
|
||||
|
||||
crate::log_msg(&format!("Gossip Event::Received from author={:?}, payload={:?}", payload.author, payload.msg));
|
||||
if !admit_state_mutation(
|
||||
&mut state_mutations_seen,
|
||||
payload.author,
|
||||
&payload.msg,
|
||||
payload.ts,
|
||||
) {
|
||||
crate::log_msg(&format!(
|
||||
"Gossip dropped replayed state mutation author={}, kind={}, ts={}",
|
||||
crate::short_id(&payload.author.to_string()),
|
||||
gossip_message_kind(&payload.msg),
|
||||
payload.ts
|
||||
));
|
||||
continue;
|
||||
}
|
||||
|
||||
crate::log_msg(&format!(
|
||||
"Gossip Event::Received author={}, kind={}",
|
||||
crate::short_id(&payload.author.to_string()),
|
||||
gossip_message_kind(&payload.msg)
|
||||
));
|
||||
|
||||
match payload.msg {
|
||||
GossipMessage::Announce(mut state) => {
|
||||
@@ -299,6 +385,10 @@ impl RoomState for IrohGossipState {
|
||||
// monogram, so a malformed/oversized/bomb
|
||||
// image can't crash or exhaust us (W4).
|
||||
state.avatar = state.avatar.sanitize_incoming();
|
||||
// Screen-share tickets are capabilities and
|
||||
// peer-supplied: cap/validate once at ingest
|
||||
// so invalid offers never render a Watch button.
|
||||
state.sharing = state.sharing.and_then(crate::screenshare::sanitize_ticket);
|
||||
let (is_new, state_changed) = {
|
||||
let mut peer_map = peers.lock().unwrap();
|
||||
let is_new = !peer_map.contains_key(&payload.author);
|
||||
@@ -310,11 +400,19 @@ impl RoomState for IrohGossipState {
|
||||
};
|
||||
|
||||
if is_new {
|
||||
crate::log_msg(&format!("Gossip new peer joined: {:?}, state: {:?}", payload.author, state));
|
||||
crate::log_msg(&format!(
|
||||
"Gossip new peer joined: {}, state: {}",
|
||||
crate::short_id(&payload.author.to_string()),
|
||||
peer_state_for_log(&state)
|
||||
));
|
||||
address_lookup.add_endpoint_info(state.addr.clone());
|
||||
let _ = event_tx.send(RoomEvent::PeerJoined(payload.author, state)).await;
|
||||
} else if state_changed {
|
||||
crate::log_msg(&format!("Gossip peer state updated: {:?}, state: {:?}", payload.author, state));
|
||||
crate::log_msg(&format!(
|
||||
"Gossip peer state updated: {}, state: {}",
|
||||
crate::short_id(&payload.author.to_string()),
|
||||
peer_state_for_log(&state)
|
||||
));
|
||||
let _ = event_tx.send(RoomEvent::PeerUpdated(payload.author, state)).await;
|
||||
}
|
||||
}
|
||||
@@ -390,7 +488,10 @@ impl RoomState for IrohGossipState {
|
||||
}
|
||||
|
||||
async fn update_self_state(&self, self_state: PeerState) -> Result<(), NetError> {
|
||||
crate::log_msg(&format!("RoomState::update_self_state: state={:?}", self_state));
|
||||
crate::log_msg(&format!(
|
||||
"RoomState::update_self_state: state: {}",
|
||||
peer_state_for_log(&self_state)
|
||||
));
|
||||
*self.self_state.lock().unwrap() = Some(self_state.clone());
|
||||
|
||||
let sender_opt = self.active_sender.lock().unwrap().clone();
|
||||
@@ -489,11 +590,10 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::network::PeerState;
|
||||
use iroh::SecretKey;
|
||||
use std::collections::HashMap;
|
||||
|
||||
fn sample_peer_state() -> PeerState {
|
||||
let secret = SecretKey::generate();
|
||||
let public = secret.public();
|
||||
let addr = iroh::EndpointAddr::from(public);
|
||||
fn sample_peer_state_for(id: EndpointId) -> PeerState {
|
||||
let addr = iroh::EndpointAddr::from(id);
|
||||
PeerState {
|
||||
name: "TestPeerGossip".to_string(),
|
||||
is_muted: true,
|
||||
@@ -563,7 +663,7 @@ mod tests {
|
||||
fn test_gossip_payload_announce_round_trip() {
|
||||
let secret = SecretKey::generate();
|
||||
let topic = [9u8; 32];
|
||||
let peer_state = sample_peer_state();
|
||||
let peer_state = sample_peer_state_for(secret.public());
|
||||
let payload = sign_gossip(&secret, &topic, 1000, GossipMessage::Announce(peer_state.clone()));
|
||||
|
||||
let serialized = serde_json::to_string(&payload).unwrap();
|
||||
@@ -732,5 +832,60 @@ mod tests {
|
||||
// Within the window (clock skew tolerance) → accepted.
|
||||
assert!(verify_gossip(&p, &topic, 1_000_000 + GOSSIP_FRESHNESS_MS - 1, GOSSIP_FRESHNESS_MS).is_ok());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_rejects_announce_with_address_for_another_identity() {
|
||||
let signer = SecretKey::generate();
|
||||
let advertised = SecretKey::generate();
|
||||
let topic = [6u8; 32];
|
||||
let state = sample_peer_state_for(advertised.public());
|
||||
let p = sign_gossip(&signer, &topic, 5_000, GossipMessage::Announce(state));
|
||||
|
||||
assert_eq!(
|
||||
verify_gossip(&p, &topic, 5_000, GOSSIP_FRESHNESS_MS),
|
||||
Err(GossipReject::AnnounceAddressMismatch)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_mutation_replay_gate_drops_replayed_leave_and_announce() {
|
||||
let author = fresh_id();
|
||||
let mut seen = HashMap::new();
|
||||
|
||||
assert!(admit_state_mutation(&mut seen, author, &GossipMessage::Leave, 10));
|
||||
assert!(!admit_state_mutation(&mut seen, author, &GossipMessage::Leave, 10));
|
||||
assert!(!admit_state_mutation(&mut seen, author, &GossipMessage::Leave, 9));
|
||||
assert!(admit_state_mutation(&mut seen, author, &GossipMessage::Leave, 11));
|
||||
|
||||
let announce = GossipMessage::Announce(sample_peer_state_for(author));
|
||||
assert!(admit_state_mutation(&mut seen, author, &announce, 10));
|
||||
assert!(!admit_state_mutation(&mut seen, author, &announce, 10));
|
||||
assert!(!admit_state_mutation(&mut seen, author, &announce, 9));
|
||||
assert!(admit_state_mutation(&mut seen, author, &announce, 12));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_mutation_replay_gate_leaves_chat_ordering_untouched() {
|
||||
let author = fresh_id();
|
||||
let mut seen = HashMap::new();
|
||||
let later_chat = GossipMessage::Chat { name: "A".into(), text: "later".into(), ts: 200 };
|
||||
let earlier_chat = GossipMessage::Chat { name: "A".into(), text: "earlier".into(), ts: 100 };
|
||||
|
||||
assert!(admit_state_mutation(&mut seen, author, &later_chat, 200));
|
||||
assert!(admit_state_mutation(&mut seen, author, &earlier_chat, 100));
|
||||
assert!(admit_state_mutation(&mut seen, author, &later_chat, 200));
|
||||
assert!(seen.is_empty(), "chat must not populate the state-mutation replay map");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_mutation_replay_gate_is_per_author_and_kind() {
|
||||
let author = fresh_id();
|
||||
let other = fresh_id();
|
||||
let mut seen = HashMap::new();
|
||||
let announce = GossipMessage::Announce(sample_peer_state_for(author));
|
||||
|
||||
assert!(admit_state_mutation(&mut seen, author, &GossipMessage::Leave, 5));
|
||||
assert!(admit_state_mutation(&mut seen, author, &announce, 5));
|
||||
assert!(admit_state_mutation(&mut seen, other, &GossipMessage::Leave, 5));
|
||||
}
|
||||
}
|
||||
|
||||
+40
-21
@@ -110,26 +110,32 @@ pub enum FriendPresence {
|
||||
InRoom { name: String, ticket: String },
|
||||
}
|
||||
|
||||
/// Interpret a peer's reply defensively. Only a `Pong` is a reply (a `Ping` is
|
||||
/// not, so it yields `None`). When the peer reports a room, we **sanitize the
|
||||
/// peer-supplied name** and **only surface it as joinable if the ticket actually
|
||||
/// parses** as a [`crate::network::PeerSpeakTicket`] — a garbage or hostile
|
||||
/// ticket downgrades the friend to plain `Online` rather than offering a dead /
|
||||
/// dangerous Join button. (We still never auto-join; the user clicks.)
|
||||
pub fn interpret_pong(msg: &ControlMsg) -> Option<FriendPresence> {
|
||||
/// Interpret a peer's reply defensively. `from` must be the connection's
|
||||
/// authenticated remote id, not any value carried in the payload. Only a `Pong`
|
||||
/// is a reply (a `Ping` is not, so it yields `None`). When the peer reports a
|
||||
/// room, we **sanitize the peer-supplied name** and **only surface it as joinable
|
||||
/// if the ticket actually parses** as a [`crate::network::PeerSpeakTicket`] and
|
||||
/// points back at the replying friend. A garbage/redirect ticket downgrades the
|
||||
/// friend to plain `Online` rather than offering a dead or attacker-controlled
|
||||
/// Join button. (We still never auto-join; the user clicks.)
|
||||
pub fn interpret_pong(msg: &ControlMsg, from: EndpointId) -> Option<FriendPresence> {
|
||||
match msg {
|
||||
ControlMsg::Ping => None,
|
||||
ControlMsg::Pong { room: None } => Some(FriendPresence::Online),
|
||||
ControlMsg::Pong { room: Some(r) } => {
|
||||
if r.ticket.parse::<crate::network::PeerSpeakTicket>().is_ok() {
|
||||
Some(FriendPresence::InRoom {
|
||||
name: crate::sanitize::sanitize_name(&r.name),
|
||||
ticket: r.ticket.clone(),
|
||||
})
|
||||
} else {
|
||||
let Ok(ticket) = r.ticket.parse::<crate::network::PeerSpeakTicket>() else {
|
||||
// Online, but the advertised room is unusable — don't offer Join.
|
||||
Some(FriendPresence::Online)
|
||||
return Some(FriendPresence::Online);
|
||||
};
|
||||
if ticket.host_addr.id != from {
|
||||
// Online, but the advertised room redirects away from the friend
|
||||
// who authenticated this Pong — don't offer a phishing Join.
|
||||
return Some(FriendPresence::Online);
|
||||
}
|
||||
Some(FriendPresence::InRoom {
|
||||
name: crate::sanitize::sanitize_name(&r.name),
|
||||
ticket: r.ticket.clone(),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -206,21 +212,22 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn interpret_ping_is_not_a_reply() {
|
||||
assert_eq!(interpret_pong(&ControlMsg::Ping), None);
|
||||
assert_eq!(interpret_pong(&ControlMsg::Ping, id()), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interpret_pong_online_and_inroom() {
|
||||
let friend = id();
|
||||
// No room -> Online.
|
||||
assert_eq!(
|
||||
interpret_pong(&ControlMsg::Pong { room: None }),
|
||||
interpret_pong(&ControlMsg::Pong { room: None }, friend),
|
||||
Some(FriendPresence::Online)
|
||||
);
|
||||
// Valid ticket -> InRoom with a sanitized name.
|
||||
let t = valid_ticket(id());
|
||||
let t = valid_ticket(friend);
|
||||
let got = interpret_pong(&ControlMsg::Pong {
|
||||
room: Some(RoomPresence { name: "HangOut".into(), ticket: t.clone() }),
|
||||
});
|
||||
}, friend);
|
||||
assert_eq!(got, Some(FriendPresence::InRoom { name: "HangOut".into(), ticket: t }));
|
||||
}
|
||||
|
||||
@@ -230,17 +237,29 @@ mod tests {
|
||||
// Online — no dead/hostile Join button is surfaced.
|
||||
let got = interpret_pong(&ControlMsg::Pong {
|
||||
room: Some(RoomPresence { name: "Trap".into(), ticket: "not-a-ticket".into() }),
|
||||
});
|
||||
}, id());
|
||||
assert_eq!(got, Some(FriendPresence::Online));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interpret_pong_rejects_ticket_for_a_different_host() {
|
||||
let friend = id();
|
||||
let attacker = id();
|
||||
let t = valid_ticket(attacker);
|
||||
let got = interpret_pong(&ControlMsg::Pong {
|
||||
room: Some(RoomPresence { name: "Redirect".into(), ticket: t }),
|
||||
}, friend);
|
||||
assert_eq!(got, Some(FriendPresence::Online));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interpret_pong_sanitizes_a_hostile_room_name() {
|
||||
// Control/bidi characters in a peer-supplied name are stripped.
|
||||
let t = valid_ticket(id());
|
||||
let friend = id();
|
||||
let t = valid_ticket(friend);
|
||||
let got = interpret_pong(&ControlMsg::Pong {
|
||||
room: Some(RoomPresence { name: "Hang\u{202e}Out\u{0007}".into(), ticket: t.clone() }),
|
||||
});
|
||||
}, friend);
|
||||
match got {
|
||||
Some(FriendPresence::InRoom { name, .. }) => {
|
||||
assert!(!name.contains('\u{202e}'), "bidi override must be stripped");
|
||||
|
||||
+17
-15
@@ -49,16 +49,17 @@ fn decode(bytes: &[u8]) -> Result<ControlMsg> {
|
||||
serde_json::from_slice(bytes).context("failed to decode control message")
|
||||
}
|
||||
|
||||
/// Probe `peer` for presence: send a `Ping`, return their `Pong`. An error means
|
||||
/// no usable reply (offline / unreachable / refused / malformed) — the caller
|
||||
/// treats that as "appears offline". `peer` is usually a bare [`EndpointId`]
|
||||
/// (friends store the stable id); a full [`EndpointAddr`] is also accepted (and
|
||||
/// used by hermetic tests).
|
||||
pub async fn probe(endpoint: &Endpoint, peer: impl Into<EndpointAddr>) -> Result<ControlMsg> {
|
||||
/// Probe `peer` for presence: send a `Ping`, return their authenticated id and
|
||||
/// `Pong`. An error means no usable reply (offline / unreachable / refused /
|
||||
/// malformed) — the caller treats that as "appears offline". `peer` is usually a
|
||||
/// bare [`EndpointId`] (friends store the stable id); a full [`EndpointAddr`] is
|
||||
/// also accepted (and used by hermetic tests).
|
||||
pub async fn probe(endpoint: &Endpoint, peer: impl Into<EndpointAddr>) -> Result<(EndpointId, ControlMsg)> {
|
||||
let conn = tokio::time::timeout(IO_TIMEOUT, endpoint.connect(peer, FRIENDS_ALPN))
|
||||
.await
|
||||
.context("timed out connecting to peer")?
|
||||
.context("failed to connect to peer")?;
|
||||
let from = conn.remote_id();
|
||||
|
||||
let io = async {
|
||||
let (mut send, mut recv) = conn.open_bi().await.context("failed to open control stream")?;
|
||||
@@ -77,7 +78,7 @@ pub async fn probe(endpoint: &Endpoint, peer: impl Into<EndpointAddr>) -> Result
|
||||
.await
|
||||
.context("timed out awaiting pong")?;
|
||||
conn.close(VarInt::from_u32(0), b"done");
|
||||
result
|
||||
result.map(|msg| (from, msg))
|
||||
}
|
||||
|
||||
/// A reply policy: given the *authenticated* remote id, decide whether and how to
|
||||
@@ -110,6 +111,10 @@ async fn handle(incoming: Incoming, handler: Handler) -> Result<()> {
|
||||
async fn exchange(conn: &iroh::endpoint::Connection, handler: &Handler) -> Result<()> {
|
||||
// The authenticated remote id — NOT anything the peer puts in the payload.
|
||||
let from = conn.remote_id();
|
||||
let Some(reply) = handler(from) else {
|
||||
conn.close(VarInt::from_u32(0), b"not authorized");
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let io = async {
|
||||
let (mut send, mut recv) = conn.accept_bi().await.context("failed to accept stream")?;
|
||||
@@ -118,13 +123,9 @@ async fn exchange(conn: &iroh::endpoint::Connection, handler: &Handler) -> Resul
|
||||
ControlMsg::Ping => {}
|
||||
other => bail!("expected a ping, got {other:?}"),
|
||||
}
|
||||
// Ask the policy what to send. None -> answer nothing (stranger / invisible):
|
||||
// finish the stream with no bytes so the prober sees an empty (unusable) reply.
|
||||
if let Some(reply) = handler(from) {
|
||||
send.write_all(&encode(&reply)?)
|
||||
.await
|
||||
.context("failed to write pong")?;
|
||||
}
|
||||
send.write_all(&encode(&reply)?)
|
||||
.await
|
||||
.context("failed to write pong")?;
|
||||
send.finish().context("failed to finish reply stream")?;
|
||||
Ok::<_, anyhow::Error>(())
|
||||
};
|
||||
@@ -220,10 +221,11 @@ mod tests {
|
||||
let serve_task = tokio::spawn(async move { serve(server_ep, handler).await });
|
||||
|
||||
// The allowed prober gets a Pong with the room.
|
||||
let pong = tokio::time::timeout(Duration::from_secs(15), probe(&prober, server_addr.clone()))
|
||||
let (from, pong) = tokio::time::timeout(Duration::from_secs(15), probe(&prober, server_addr.clone()))
|
||||
.await
|
||||
.expect("probe timed out")
|
||||
.expect("probe failed");
|
||||
assert_eq!(from, server_addr.id);
|
||||
match pong {
|
||||
ControlMsg::Pong { room: Some(r) } => assert_eq!(r.name, "HangOut"),
|
||||
other => panic!("expected Pong with a room, got {other:?}"),
|
||||
|
||||
+56
-1
@@ -25,6 +25,10 @@ use tokio::process::{Child, Command};
|
||||
/// points elsewhere.
|
||||
const PIXELPASS_BIN: &str = "pixelpass";
|
||||
|
||||
/// Pixelpass endpoint tickets are normally ~140 chars. Leave headroom for format
|
||||
/// growth, but reject unbounded gossip payloads before the UI offers "Watch".
|
||||
const MAX_TICKET_LEN: usize = 512;
|
||||
|
||||
/// How long to wait for the host to emit its ticket / the viewer to connect
|
||||
/// before giving up and killing the child. Startup is normally sub-second; this
|
||||
/// is only a safety net so a hung pixelpass can't wedge the caller forever.
|
||||
@@ -108,6 +112,19 @@ pub fn viewer_args(ticket: &str) -> Vec<String> {
|
||||
]
|
||||
}
|
||||
|
||||
/// Sanitize a peer-advertised pixelpass ticket at the gossip boundary. Peerspeak
|
||||
/// intentionally does not depend on pixelpass/iroh-tickets, so this validates the
|
||||
/// stable CLI ticket envelope we consume: bounded ASCII endpoint tickets beginning
|
||||
/// with `endpoint`. Invalid input becomes `None`, which removes the Watch button.
|
||||
pub fn sanitize_ticket(ticket: String) -> Option<String> {
|
||||
let ticket = ticket.trim();
|
||||
let valid_len = !ticket.is_empty() && ticket.len() <= MAX_TICKET_LEN;
|
||||
let valid_shape = ticket.starts_with("endpoint")
|
||||
&& ticket.len() > "endpoint".len()
|
||||
&& ticket.bytes().all(|b| b.is_ascii_alphanumeric());
|
||||
(valid_len && valid_shape).then(|| ticket.to_string())
|
||||
}
|
||||
|
||||
/// Resolve the pixelpass binary: an explicit config override (used only if it
|
||||
/// points at an existing file), otherwise the first `pixelpass` found on
|
||||
/// `$PATH`. `None` means it isn't installed — a normal, handled state. An
|
||||
@@ -267,12 +284,29 @@ where
|
||||
tokio::spawn(async move {
|
||||
while let Ok(Some(line)) = lines.next_line().await {
|
||||
if let Some(ev) = parse_pixelpass_event(&line) {
|
||||
crate::log_msg(&format!("pixelpass {role}: {ev:?}"));
|
||||
crate::log_msg(&format!("pixelpass {role}: {}", event_for_log(&ev)));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn event_for_log(ev: &PixelpassEvent) -> String {
|
||||
match ev {
|
||||
PixelpassEvent::Ticket(ticket) => format!("ticket {}", crate::redact_for_log(ticket)),
|
||||
PixelpassEvent::Connected(_) => "connected".to_string(),
|
||||
PixelpassEvent::ViewerJoined { active, max } => {
|
||||
format!("viewer_joined active={active} max={max}")
|
||||
}
|
||||
PixelpassEvent::ViewerLeft { active, max } => {
|
||||
format!("viewer_left active={active} max={max}")
|
||||
}
|
||||
PixelpassEvent::Refused(reason) => format!("viewer_refused reason={reason:?}"),
|
||||
PixelpassEvent::CaptureStarted => "capture_started".to_string(),
|
||||
PixelpassEvent::CaptureStopped => "capture_stopped".to_string(),
|
||||
PixelpassEvent::Other => "other".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Open the viewer stream URL in a media player. Mirrors pixelpass's own
|
||||
/// low-latency mpv invocation; falls back to vlc. The player is reaped in a
|
||||
/// background task so it doesn't linger as a zombie when its window closes.
|
||||
@@ -340,6 +374,27 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_ticket_accepts_pixelpass_endpoint_ticket_shape() {
|
||||
let ticket = "endpointaabwxjexzensznfvuudiapn5tyzws3angd2merarm";
|
||||
assert_eq!(sanitize_ticket(format!(" {ticket}\n")), Some(ticket.to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_ticket_rejects_oversized_or_garbage_ticket() {
|
||||
assert_eq!(sanitize_ticket("not-a-ticket".into()), None);
|
||||
assert_eq!(sanitize_ticket(format!("endpoint{}", "a".repeat(MAX_TICKET_LEN))), None);
|
||||
assert_eq!(sanitize_ticket("endpointabc-def".into()), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn event_log_redacts_ticket_values() {
|
||||
let ticket = "endpointaabwxjexzensznfvuudiapn5tyzws3angd2merarm".to_string();
|
||||
let log = event_for_log(&PixelpassEvent::Ticket(ticket.clone()));
|
||||
assert!(log.contains("endpoint"));
|
||||
assert!(!log.contains(&ticket["endpoint".len() + 8..]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_ticket() {
|
||||
assert_eq!(
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
# Codex task report - 2026-06-16
|
||||
|
||||
## W2 - Per-peer EQ
|
||||
|
||||
- Added `src/audio/eq.rs`: a 3-band listener-side RBJ biquad EQ (low shelf, mid peaking, high shelf) with per-peer state and flat bypass.
|
||||
- Added local config persistence in `AppConfig.peer_eq`, keyed by peer node id string.
|
||||
- Added local `CoreCommand::SetPeerEq` and mixer-side per-peer `Eq` state. EQ is applied after local volume and before pan/mix; raw multitrack stems remain pre-volume/pre-EQ.
|
||||
- Added participant-card controls for Low/Mid/High gain sliders (-12 dB to +12 dB). Changes apply live and persist on slider release.
|
||||
- Tests added for flat identity, low/high boost energy, coefficient finiteness, clamping, and hot-signal processing.
|
||||
|
||||
Unverified: subjective voice quality and zipper/noise behavior on real devices.
|
||||
|
||||
## W1 - Per-listener pan / stereo playback
|
||||
|
||||
- Added `src/audio/pan.rs`: constant-power `pan_gains()` with tests, plus playback gains that preserve the legacy default dual-mono center.
|
||||
- Converted playback mix to interleaved stereo in `src/core/mod.rs`.
|
||||
- Switched PipeWire playback output to 2-channel S16LE and adjusted ring target/capacity/stride accounting in `src/audio/pipewire_impl.rs`.
|
||||
- Kept capture, Opus encode/decode, jitter buffers, and network audio mono.
|
||||
- Limiter now receives the interleaved stereo bus; shared limiter gain ducks both channels consistently.
|
||||
- Mixed WAV and multitrack convenience mix fold the listener stereo mix back to mono before writing. Per-peer stems remain raw mono.
|
||||
- Updated `audio_probe` to send dual-mono stereo frames.
|
||||
- Added tests for exact center dual-mono behavior, hard-left pan contribution, and stereo fold-down.
|
||||
|
||||
Decision for senior sanity-check: pure pan law is constant-power, but playback scales it by sqrt(2) so pan=0 is exactly the old mono signal in both ears. This satisfies the "default behavior unchanged" guardrail at the cost of louder hard-panned extremes, which the existing limiter catches.
|
||||
|
||||
Unverified: real PipeWire stereo playback, underrun behavior on actual hardware, and recorded WAV listening checks.
|
||||
|
||||
## W5 - Focused hotkeys + info popup
|
||||
|
||||
- Added `src/hotkeys.rs`: serializable `KeyBinding`, `HotkeyAction`, `HotkeyMap`, parse/format/lookup, tier checks, and duplicate conflict detection.
|
||||
- Added `AppConfig.hotkeys` with defaults: F9 mute, F10 deafen, F2 Settings, Space push-to-talk, Leave unset.
|
||||
- Replaced the hard-coded PTT key capture with config-backed binding capture.
|
||||
- Added Settings hotkey editor with Set/Clear per action and live conflict warnings.
|
||||
- Added top-right hotkey info popup that lists every action and current binding, showing `unset` for unbound actions.
|
||||
- Routed focused iced key events through the map. App-wide actions can fire from any screen while focused; room-only actions require an active call. PTT press/release still uses `SetPttActive`.
|
||||
- Tests added for unset formatting, duplicate detection, room-tier lookup, defaults, and character parse/format.
|
||||
|
||||
Unverified: manual keyboard interaction in the GUI. No OS-global hooks were added.
|
||||
|
||||
## W3 - PipeWire pro-routing plan (not implemented)
|
||||
|
||||
I stopped at design for W3. The current backend already supports simple target-node routing through PipeWire stream property `node.target`, but true "pro routing" (explicit ports / manual graph links / no-autoconnect patching) would require backend changes that are not safely verifiable offline.
|
||||
|
||||
Proposed future scope:
|
||||
|
||||
- Expose two advanced route targets: capture source node and playback sink node, with optional future per-port routing.
|
||||
- Enumerate available nodes with the existing `pw-cli list-objects Node` parser. For port-level routing, add a separate parser for `pw-cli list-objects Port` collecting `object.id`, `node.id`, `port.name`, direction, and channel position.
|
||||
- For node-level routing, continue using PipeWire stream property `node.target` on stream creation. This is the low-risk path and matches current backend behavior.
|
||||
- For explicit port routing, do not use `AUTOCONNECT`; instead capture the created PeerSpeak stream node/port ids from the PipeWire registry, then link with PipeWire-native APIs or `pw-link <source-port-id> <sink-port-id>`. Degrade by falling back to `node.target` autoconnect if any selected node/port is missing.
|
||||
- Offline tests should cover pure routing-plan decisions: selected node exists/missing, selected port exists/missing, capture/playback direction mismatch, and fallback choice. Real-device tests still need a PipeWire graph.
|
||||
|
||||
Reason for not implementing: the current `run_playback` / `run_capture` code does not retain stream node or port ids, and changing `AUTOCONNECT` behavior plus adding manual `pw-link` calls could destabilize the working audio path. That matches the assignment's "bail if risky" instruction.
|
||||
|
||||
## Backlog A21/A22 - correctness fixes
|
||||
|
||||
- Fixed A21 in `src/core/jitter.rs`: implausibly large sequence discontinuities now reset the per-peer jitter stream instead of being treated as ordinary late packets or packet loss.
|
||||
- The reset threshold is `500` frames, about 10 seconds at 20 ms/frame. That covers both same-identity sender restart back to sequence 0 and a faulty/malicious jump far ahead that would otherwise force a long PLC run.
|
||||
- Added jitter regression tests for both far-behind restart and far-ahead jump cases.
|
||||
- Fixed A22 in `src/audio/recorder.rs`: `WavWriter` now tracks data bytes as `u64`, checks additions before writing, and rejects data that cannot fit both the RIFF size field and the `data` chunk size field.
|
||||
- Added a WAV overflow regression test that exercises the limit without creating a huge file.
|
||||
|
||||
Unverified: the same-identity peer restart has not been exercised in a live 2-machine call; the WAV fix is counter/size-field tested, not a real >12h recording.
|
||||
|
||||
## Backlog A14 - orderly window-close shutdown
|
||||
|
||||
- Added `CoreCommand::Shutdown` and `UiEvent::ShutdownComplete`.
|
||||
- Window close now saves config, marks the GUI as closing, asynchronously queues `Shutdown`, and exits only after the core acknowledges completion or after a 5-second fallback timeout.
|
||||
- Core shutdown finalizes active mixed/multitrack recordings before session teardown, stops the standalone mic monitor, runs `ActiveSession::shutdown()` for active calls, clears room presence/routing, closes the persistent network stack, sends `ShutdownComplete`, and ends the core loop.
|
||||
- The shutdown command is queued with an awaited `mpsc::Sender::send` task instead of the best-effort `try_send`, so a full command queue does not immediately drop the close command.
|
||||
|
||||
Unverified: actual GUI window-close behavior during a live call/recording still needs a manual run; tests/builds only prove the path compiles and existing unit coverage still passes.
|
||||
|
||||
## Verification
|
||||
|
||||
- `cargo check` passed.
|
||||
- `cargo test --lib` passed: 288 passed, 0 failed, 2 ignored.
|
||||
- `cargo clippy --all-targets` passed.
|
||||
- `cargo build --release` passed.
|
||||
- Formatted the touched Rust files with `rustfmt --edition 2024`; I did not run repo-wide `cargo fmt` to avoid unrelated formatting churn.
|
||||
|
||||
No new dependencies were added. Runtime/manual/field verification is still pending for audio-device and 2-machine behavior.
|
||||
Reference in New Issue
Block a user