Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8982df364e |
+45
-197
@@ -30,55 +30,6 @@ pub enum Screen {
|
|||||||
Settings,
|
Settings,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
||||||
pub enum SettingsCategory {
|
|
||||||
Audio,
|
|
||||||
Recording,
|
|
||||||
Profile,
|
|
||||||
Appearance,
|
|
||||||
Network,
|
|
||||||
Notifications,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl SettingsCategory {
|
|
||||||
const ALL: [SettingsCategory; 6] = [
|
|
||||||
SettingsCategory::Audio,
|
|
||||||
SettingsCategory::Recording,
|
|
||||||
SettingsCategory::Profile,
|
|
||||||
SettingsCategory::Appearance,
|
|
||||||
SettingsCategory::Network,
|
|
||||||
SettingsCategory::Notifications,
|
|
||||||
];
|
|
||||||
|
|
||||||
fn label(self) -> &'static str {
|
|
||||||
match self {
|
|
||||||
SettingsCategory::Audio => "Audio",
|
|
||||||
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::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())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// One rendered room-chat line. `mine` distinguishes our own (locally echoed)
|
/// One rendered room-chat line. `mine` distinguishes our own (locally echoed)
|
||||||
/// messages from peers' for colouring.
|
/// messages from peers' for colouring.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
@@ -210,7 +161,6 @@ pub enum AppMessage {
|
|||||||
EventOccurred(Event),
|
EventOccurred(Event),
|
||||||
NavigateToSettings,
|
NavigateToSettings,
|
||||||
NavigateBack,
|
NavigateBack,
|
||||||
SelectSettingsCategory(SettingsCategory),
|
|
||||||
ToggleNotifications(bool),
|
ToggleNotifications(bool),
|
||||||
ToggleEchoCancellation(bool),
|
ToggleEchoCancellation(bool),
|
||||||
CustomSoundPathChanged(Sound, String),
|
CustomSoundPathChanged(Sound, String),
|
||||||
@@ -326,7 +276,6 @@ pub struct AppState {
|
|||||||
ever_connected: HashSet<EndpointId>,
|
ever_connected: HashSet<EndpointId>,
|
||||||
controller: Arc<CoreController>,
|
controller: Arc<CoreController>,
|
||||||
current_screen: Screen,
|
current_screen: Screen,
|
||||||
settings_category: SettingsCategory,
|
|
||||||
/// Whether we're currently sharing our own screen (confirmed by the core).
|
/// Whether we're currently sharing our own screen (confirmed by the core).
|
||||||
self_sharing: bool,
|
self_sharing: bool,
|
||||||
/// Whether the `pixelpass` binary is available, gating the Share controls.
|
/// Whether the `pixelpass` binary is available, gating the Share controls.
|
||||||
@@ -452,7 +401,6 @@ impl Default for AppState {
|
|||||||
ever_connected: HashSet::new(),
|
ever_connected: HashSet::new(),
|
||||||
controller,
|
controller,
|
||||||
current_screen: Screen::Home,
|
current_screen: Screen::Home,
|
||||||
settings_category: SettingsCategory::Audio,
|
|
||||||
self_sharing: false,
|
self_sharing: false,
|
||||||
pixelpass_available,
|
pixelpass_available,
|
||||||
self_node_id: None,
|
self_node_id: None,
|
||||||
@@ -987,9 +935,6 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
|
|||||||
crate::recents::remove_recent(&mut state.config.recents, &ticket);
|
crate::recents::remove_recent(&mut state.config.recents, &ticket);
|
||||||
state.config.save();
|
state.config.save();
|
||||||
}
|
}
|
||||||
AppMessage::SelectSettingsCategory(category) => {
|
|
||||||
state.settings_category = category;
|
|
||||||
}
|
|
||||||
AppMessage::ToggleNotifications(enabled) => {
|
AppMessage::ToggleNotifications(enabled) => {
|
||||||
state.config.notifications_enabled = enabled;
|
state.config.notifications_enabled = enabled;
|
||||||
state.config.save();
|
state.config.save();
|
||||||
@@ -2173,8 +2118,9 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
|
|||||||
|
|
||||||
// Presence + Friends moved to the home screen (see `friends_panel`).
|
// Presence + Friends moved to the home screen (see `friends_panel`).
|
||||||
|
|
||||||
let settings_body: Element<'_, AppMessage> = match state.settings_category {
|
let settings_content = scrollable(
|
||||||
SettingsCategory::Audio => column![
|
column![
|
||||||
|
// --- Audio Devices ---
|
||||||
section_header("Audio Devices"),
|
section_header("Audio Devices"),
|
||||||
row![
|
row![
|
||||||
column![
|
column![
|
||||||
@@ -2203,6 +2149,8 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
|
|||||||
].spacing(8).width(iced::Length::Fill),
|
].spacing(8).width(iced::Length::Fill),
|
||||||
].spacing(20).align_y(iced::alignment::Vertical::Top).width(iced::Length::Fill),
|
].spacing(20).align_y(iced::alignment::Vertical::Top).width(iced::Length::Fill),
|
||||||
vertical_space(section_gap),
|
vertical_space(section_gap),
|
||||||
|
|
||||||
|
// --- Microphone ---
|
||||||
section_header("Microphone"),
|
section_header("Microphone"),
|
||||||
column![
|
column![
|
||||||
mic_meter,
|
mic_meter,
|
||||||
@@ -2213,11 +2161,9 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
|
|||||||
.on_toggle(AppMessage::ToggleEchoCancellation),
|
.on_toggle(AppMessage::ToggleEchoCancellation),
|
||||||
text("Cancels speaker echo + suppresses noise (PipeWire). Takes effect on your next room join.").size(11).color(color_subtext),
|
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),
|
].spacing(8).width(iced::Length::Fill),
|
||||||
]
|
vertical_space(section_gap),
|
||||||
.spacing(10)
|
|
||||||
.width(iced::Length::Fill)
|
// --- Recording ---
|
||||||
.into(),
|
|
||||||
SettingsCategory::Recording => column![
|
|
||||||
section_header("Recording"),
|
section_header("Recording"),
|
||||||
column![
|
column![
|
||||||
mode_radio(RecordingMode::Mixed, "Mixed (single file)"),
|
mode_radio(RecordingMode::Mixed, "Mixed (single file)"),
|
||||||
@@ -2226,38 +2172,9 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
|
|||||||
vertical_space(2.0),
|
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),
|
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(8).width(iced::Length::Fill),
|
||||||
]
|
|
||||||
.spacing(10)
|
|
||||||
.width(iced::Length::Fill)
|
|
||||||
.into(),
|
|
||||||
SettingsCategory::Profile => column![
|
|
||||||
section_header("Avatar"),
|
|
||||||
avatar_section,
|
|
||||||
vertical_space(section_gap),
|
vertical_space(section_gap),
|
||||||
section_header("Identity"),
|
|
||||||
identity_section,
|
// --- Network & Privacy ---
|
||||||
]
|
|
||||||
.spacing(10)
|
|
||||||
.width(iced::Length::Fill)
|
|
||||||
.into(),
|
|
||||||
SettingsCategory::Appearance => column![
|
|
||||||
section_header("Room Layout"),
|
|
||||||
column![
|
|
||||||
row![
|
|
||||||
layout_choice(RoomLayout::ThreeColumn, "3-Column"),
|
|
||||||
layout_choice(RoomLayout::BottomDock, "Bottom Dock"),
|
|
||||||
layout_choice(RoomLayout::Drawer, "Drawer"),
|
|
||||||
].spacing(16),
|
|
||||||
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),
|
|
||||||
section_header("Theme"),
|
|
||||||
theme_section,
|
|
||||||
]
|
|
||||||
.spacing(10)
|
|
||||||
.width(iced::Length::Fill)
|
|
||||||
.into(),
|
|
||||||
SettingsCategory::Network => column![
|
|
||||||
section_header("Network & Privacy"),
|
section_header("Network & Privacy"),
|
||||||
column![
|
column![
|
||||||
pick_list(
|
pick_list(
|
||||||
@@ -2268,11 +2185,38 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
|
|||||||
text(network_mode_hint(state.config.network_mode)).size(11).color(color_subtext),
|
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),
|
text("Takes effect on your next room join.").size(11).color(color_subtext),
|
||||||
].spacing(4).width(iced::Length::Fill),
|
].spacing(4).width(iced::Length::Fill),
|
||||||
]
|
vertical_space(section_gap),
|
||||||
.spacing(10)
|
|
||||||
.width(iced::Length::Fill)
|
// --- Room Layout ---
|
||||||
.into(),
|
section_header("Room Layout"),
|
||||||
SettingsCategory::Notifications => column![
|
column![
|
||||||
|
row![
|
||||||
|
layout_choice(RoomLayout::ThreeColumn, "3-Column"),
|
||||||
|
layout_choice(RoomLayout::BottomDock, "Bottom Dock"),
|
||||||
|
layout_choice(RoomLayout::Drawer, "Drawer"),
|
||||||
|
].spacing(16),
|
||||||
|
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 ---
|
||||||
section_header("Notifications & Sounds"),
|
section_header("Notifications & Sounds"),
|
||||||
column![
|
column![
|
||||||
checkbox(state.config.notifications_enabled)
|
checkbox(state.config.notifications_enabled)
|
||||||
@@ -2300,92 +2244,9 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
|
|||||||
]
|
]
|
||||||
.spacing(10)
|
.spacing(10)
|
||||||
.width(iced::Length::Fill)
|
.width(iced::Length::Fill)
|
||||||
.into(),
|
)
|
||||||
};
|
.width(iced::Length::Fill)
|
||||||
|
.height(iced::Length::Fill);
|
||||||
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
|
// Sticky header bar: stays fixed above the scrollable content so the Back
|
||||||
// button is always reachable. The "Settings" title is centered by flanking
|
// button is always reachable. The "Settings" title is centered by flanking
|
||||||
@@ -4227,19 +4088,6 @@ mod tests {
|
|||||||
assert_eq!(format_duration(3661), "1:01:01");
|
assert_eq!(format_duration(3661), "1:01:01");
|
||||||
assert_eq!(format_duration(3725), "1:02:05");
|
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", "Recording", "Profile", "Appearance", "Network", "Notifications"]
|
|
||||||
);
|
|
||||||
assert_eq!(SettingsCategory::Audio.hint(), "Devices, mic gate, echo");
|
|
||||||
assert_eq!(SettingsCategory::Profile.hint(), "Avatar and identity");
|
|
||||||
}
|
|
||||||
|
|
||||||
use super::{clamp_chat_height, clamp_participants_width, CHAT_MIN_H, PARTICIPANTS_MIN_W};
|
use super::{clamp_chat_height, clamp_participants_width, CHAT_MIN_H, PARTICIPANTS_MIN_W};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
+23
-6
@@ -79,6 +79,8 @@ enum GossipReject {
|
|||||||
BadSignature,
|
BadSignature,
|
||||||
/// Timestamp outside the freshness window — stale (replay) or implausibly future.
|
/// Timestamp outside the freshness window — stale (replay) or implausibly future.
|
||||||
OutOfWindow,
|
OutOfWindow,
|
||||||
|
/// A signed Announce advertised an address for a different node id.
|
||||||
|
AnnounceAddressMismatch,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Authenticate a received payload against the room topic and local clock. The
|
/// Authenticate a received payload against the room topic and local clock. The
|
||||||
@@ -99,6 +101,10 @@ fn verify_gossip(
|
|||||||
if now_ms.abs_diff(payload.ts) > window_ms {
|
if now_ms.abs_diff(payload.ts) > window_ms {
|
||||||
return Err(GossipReject::OutOfWindow);
|
return Err(GossipReject::OutOfWindow);
|
||||||
}
|
}
|
||||||
|
if let GossipMessage::Announce(state) = &payload.msg
|
||||||
|
&& state.addr.id != payload.author {
|
||||||
|
return Err(GossipReject::AnnounceAddressMismatch);
|
||||||
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -490,10 +496,8 @@ mod tests {
|
|||||||
use crate::network::PeerState;
|
use crate::network::PeerState;
|
||||||
use iroh::SecretKey;
|
use iroh::SecretKey;
|
||||||
|
|
||||||
fn sample_peer_state() -> PeerState {
|
fn sample_peer_state_for(id: EndpointId) -> PeerState {
|
||||||
let secret = SecretKey::generate();
|
let addr = iroh::EndpointAddr::from(id);
|
||||||
let public = secret.public();
|
|
||||||
let addr = iroh::EndpointAddr::from(public);
|
|
||||||
PeerState {
|
PeerState {
|
||||||
name: "TestPeerGossip".to_string(),
|
name: "TestPeerGossip".to_string(),
|
||||||
is_muted: true,
|
is_muted: true,
|
||||||
@@ -563,7 +567,7 @@ mod tests {
|
|||||||
fn test_gossip_payload_announce_round_trip() {
|
fn test_gossip_payload_announce_round_trip() {
|
||||||
let secret = SecretKey::generate();
|
let secret = SecretKey::generate();
|
||||||
let topic = [9u8; 32];
|
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 payload = sign_gossip(&secret, &topic, 1000, GossipMessage::Announce(peer_state.clone()));
|
||||||
|
|
||||||
let serialized = serde_json::to_string(&payload).unwrap();
|
let serialized = serde_json::to_string(&payload).unwrap();
|
||||||
@@ -732,5 +736,18 @@ mod tests {
|
|||||||
// Within the window (clock skew tolerance) → accepted.
|
// Within the window (clock skew tolerance) → accepted.
|
||||||
assert!(verify_gossip(&p, &topic, 1_000_000 + GOSSIP_FRESHNESS_MS - 1, GOSSIP_FRESHNESS_MS).is_ok());
|
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)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user