diff --git a/Cargo.lock b/Cargo.lock index 7594e42..9657b52 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4602,11 +4602,13 @@ dependencies = [ "bytes", "dirs", "iced", + "image", "iroh", "iroh-gossip", "opus", "pipewire", "rand 0.10.1", + "rfd", "ringbuf", "serde", "serde_json", @@ -4773,6 +4775,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "pollster" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3" + [[package]] name = "polyval" version = "0.6.2" @@ -5275,6 +5283,30 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e061d1b48cb8d38042de4ae0a7a6401009d6143dc80d2e2d6f31f0bdd6470c7" +[[package]] +name = "rfd" +version = "0.17.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20dafead71c16a34e1ff357ddefc8afc11e7d51d6d2b9fbd07eaa48e3e540220" +dependencies = [ + "block2 0.6.2", + "dispatch2", + "js-sys", + "libc", + "log", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "percent-encoding", + "pollster", + "raw-window-handle", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows-sys 0.61.2", +] + [[package]] name = "rgb" version = "0.8.53" diff --git a/Cargo.toml b/Cargo.toml index 46ba8c6..1967184 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,6 +26,10 @@ base64 = "0.22.1" bytes = "1.11.1" dirs = "6.0.0" iced = { version = "0.14.0", features = ["canvas", "image"] } +# W4 custom avatars: decode/resize an arbitrary user image (png/jpeg only to keep +# the codec surface small) and a native file picker (xdg-portal backend, no GTK). +image = { version = "0.25", default-features = false, features = ["png", "jpeg"] } +rfd = { version = "0.17", default-features = false, features = ["xdg-portal"] } iroh = "1.0.0-rc.0" iroh-gossip = "0.99.0" opus = "0.3.1" diff --git a/src/app/mod.rs b/src/app/mod.rs index c154f29..4fdd3cf 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -165,6 +165,11 @@ pub enum AppMessage { SelectTheme(AppTheme), /// Choose our avatar (monogram or a preset); applied live + persisted (W4). SelectAvatar(crate::avatar::Avatar), + /// Open the native file picker to choose a custom avatar image (W4 Phase 3). + PickAvatarFile, + /// Result of the avatar file picker: the chosen file's raw bytes, or `None` + /// if the user cancelled. + AvatarFilePicked(Option>), /// Toggle the Chat drawer open/closed (drawer layout). ToggleDrawerChat, /// Start/stop sharing our own screen (spawns/kills a pixelpass host). @@ -790,6 +795,39 @@ fn update(state: &mut AppState, message: AppMessage) -> Task { // Re-announce to the room if we're in a call (no-op otherwise). let _ = state.controller.send(CoreCommand::SetAvatar(avatar)); } + AppMessage::PickAvatarFile => { + // Open the native picker off the UI thread; the result comes back as + // AvatarFilePicked. Filter to the formats we can actually decode. + return Task::perform( + async { + let handle = rfd::AsyncFileDialog::new() + .add_filter("Images", &["png", "jpg", "jpeg"]) + .set_title("Choose an avatar image") + .pick_file() + .await; + match handle { + Some(h) => Some(h.read().await), + None => None, + } + }, + AppMessage::AvatarFilePicked, + ); + } + AppMessage::AvatarFilePicked(picked) => { + if let Some(bytes) = picked { + match crate::avatar::process_upload(&bytes) { + Ok(avatar) => { + state.config.avatar = avatar.clone(); + state.config.save(); + state.status_message = "Avatar updated.".to_string(); + let _ = state.controller.send(CoreCommand::SetAvatar(avatar)); + } + Err(e) => { + state.status_message = e; + } + } + } + } AppMessage::ToggleDrawerChat => { state.drawer_chat_open = !state.drawer_chat_open; } @@ -1254,17 +1292,37 @@ fn view(state: &AppState) -> Element<'_, AppMessage> { .align_x(iced::alignment::Horizontal::Center) .into() }; - let mut avatar_tiles: Vec> = - vec![avatar_choice(crate::avatar::Avatar::Monogram, "Monogram".to_string())]; + let mut avatar_tiles: Vec> = Vec::new(); + // Show the current custom avatar (if any) as the first, selected tile. + if matches!(state.config.avatar, crate::avatar::Avatar::Custom(_)) { + avatar_tiles.push(avatar_choice(state.config.avatar.clone(), "Custom".to_string())); + } + avatar_tiles.push(avatar_choice(crate::avatar::Avatar::Monogram, "Monogram".to_string())); for i in 0..crate::avatar::PRESET_COUNT { avatar_tiles.push(avatar_choice( crate::avatar::Avatar::Preset(i), format!("Preset {}", i + 1), )); } + // Wrap into rows of four (no flex-wrap in iced 0.14) so the tiles don't + // run off a narrow Settings panel. + let mut tile_rows: Vec> = Vec::new(); + let mut tiles_iter = avatar_tiles.into_iter(); + loop { + let chunk: Vec> = tiles_iter.by_ref().take(4).collect(); + if chunk.is_empty() { + break; + } + tile_rows.push(iced::widget::Row::with_children(chunk).spacing(12).into()); + } + let upload_btn = button(text("Upload image…").size(13)) + .on_press(AppMessage::PickAvatarFile) + .style(b_style(color_surface, color_blue, color_text, 6.0)) + .padding(8); let avatar_section = column![ - iced::widget::Row::with_children(avatar_tiles).spacing(12), - text("Shown next to your name in the room and chat. Applies live.") + iced::widget::Column::with_children(tile_rows).spacing(12), + upload_btn, + text("Shown next to your name in the room and chat. Custom images are PNG/JPEG, auto-resized. Applies live.") .size(11) .color(color_subtext), ] @@ -2865,13 +2923,15 @@ fn avatar_view<'a>( key: &str, size: f32, ) -> Element<'a, AppMessage> { - match avatar.preset_png() { - Some(png) => iced::widget::image( - iced::widget::image::Handle::from_bytes(bytes::Bytes::from_static(png)), - ) - .width(iced::Length::Fixed(size)) - .height(iced::Length::Fixed(size)) - .into(), + let png_bytes: Option = avatar + .preset_png() + .map(bytes::Bytes::from_static) + .or_else(|| avatar.custom_png().map(bytes::Bytes::from)); + match png_bytes { + Some(b) => iced::widget::image(iced::widget::image::Handle::from_bytes(b)) + .width(iced::Length::Fixed(size)) + .height(iced::Length::Fixed(size)) + .into(), None => avatar_badge(name, key, size), } } diff --git a/src/avatar.rs b/src/avatar.rs index ff6602e..fc85f1f 100644 --- a/src/avatar.rs +++ b/src/avatar.rs @@ -23,10 +23,23 @@ pub const PRESET_PNGS: [&[u8]; PRESET_COUNT as usize] = [ include_bytes!("../assets/avatars/preset6.png"), ]; +/// Longest side a custom avatar is downscaled to on ingest (both our own upload +/// and the limit incoming peer images are validated against). Small on purpose: +/// avatars render tiny, and it keeps the transported bytes well within the gossip +/// frame budget. +pub const CUSTOM_MAX_PX: u32 = 128; + +/// Hard cap on the base64 length of a custom avatar. Bounds the presence payload +/// (which rides a 64 KB gossip frame) and the memory a malicious peer can make us +/// hold. A 128px PNG is comfortably under this; anything larger is rejected. +pub const CUSTOM_MAX_B64: usize = 48 * 1024; + /// A participant's chosen avatar. Rides the gossip presence plane (`PeerState`) /// and is persisted in `AppConfig`. `Monogram` is the default fallback (rendered /// from name + a colour key, no image). `Preset(i)` selects a bundled preset by -/// zero-based index. (Custom uploaded images are W4 Phase 3 — a future variant.) +/// zero-based index. `Custom` carries a base64-encoded, hard-capped PNG (W4 +/// Phase 3): produced by [`process_upload`] for our own uploads, and validated by +/// [`sanitize_incoming`] for untrusted peer ones. #[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)] pub enum Avatar { /// Initials-on-colour fallback. @@ -34,17 +47,91 @@ pub enum Avatar { Monogram, /// A bundled preset by zero-based index; out-of-range falls back to monogram. Preset(u8), + /// A custom image as a base64-encoded PNG (capped, see [`CUSTOM_MAX_B64`]). + Custom(String), } impl Avatar { - /// The embedded PNG bytes for this avatar, or `None` when it's a monogram - /// (or an out-of-range preset index, which renders as a monogram). + /// The embedded PNG bytes for this avatar, or `None` when it isn't a preset + /// (or the preset index is out of range, which renders as a monogram). pub fn preset_png(&self) -> Option<&'static [u8]> { match self { Avatar::Preset(i) => PRESET_PNGS.get(*i as usize).copied(), - Avatar::Monogram => None, + _ => None, } } + + /// The decoded PNG bytes for a custom avatar, or `None` for monogram/preset + /// (or if the stored base64 is malformed). Used to build an image handle. + pub fn custom_png(&self) -> Option> { + use base64::Engine; + match self { + Avatar::Custom(b64) => base64::engine::general_purpose::STANDARD + .decode(b64.as_bytes()) + .ok(), + _ => None, + } + } + + /// Validate an avatar that arrived from an untrusted peer (over presence), + /// returning a safe value. Monogram/Preset pass through (an out-of-range + /// preset just renders as a monogram). A `Custom` is accepted only if its + /// base64 is within [`CUSTOM_MAX_B64`], decodes, and the PNG decodes within + /// [`CUSTOM_MAX_PX`] bounds (guards against decompression bombs / junk); + /// otherwise it's downgraded to a monogram. Re-encode-free: we keep the + /// original (already-validated) base64 to avoid re-deriving it every receipt. + pub fn sanitize_incoming(self) -> Avatar { + match self { + Avatar::Custom(ref b64) => { + if b64.len() <= CUSTOM_MAX_B64 && custom_b64_is_valid_png(b64) { + self + } else { + Avatar::Monogram + } + } + other => other, + } + } +} + +/// Decode a base64 PNG and confirm it's a real PNG within [`CUSTOM_MAX_PX`] +/// bounds, using the `image` crate's decode limits so a malicious image can't +/// allocate unbounded memory. Returns false on any error. +fn custom_b64_is_valid_png(b64: &str) -> bool { + use base64::Engine; + let Ok(bytes) = base64::engine::general_purpose::STANDARD.decode(b64.as_bytes()) else { + return false; + }; + let mut limits = image::Limits::default(); + // Cap dimensions a bit above our resize target to allow for slight slack. + limits.max_image_width = Some(CUSTOM_MAX_PX * 2); + limits.max_image_height = Some(CUSTOM_MAX_PX * 2); + let mut reader = image::ImageReader::new(std::io::Cursor::new(bytes)); + reader.set_format(image::ImageFormat::Png); + reader.limits(limits); + match reader.decode() { + Ok(img) => img.width() <= CUSTOM_MAX_PX * 2 && img.height() <= CUSTOM_MAX_PX * 2, + Err(_) => false, + } +} + +/// Process a user-picked image file (arbitrary png/jpeg bytes) into a capped +/// custom [`Avatar`]: decode, downscale so the longest side is [`CUSTOM_MAX_PX`] +/// (aspect preserved), re-encode as PNG, base64. Errors (undecodable, or the +/// result somehow exceeds the cap) are returned as a message for the UI. +pub fn process_upload(raw: &[u8]) -> Result { + use base64::Engine; + let img = image::load_from_memory(raw).map_err(|e| format!("Couldn't read image: {e}"))?; + let small = img.thumbnail(CUSTOM_MAX_PX, CUSTOM_MAX_PX); + let mut png = std::io::Cursor::new(Vec::new()); + small + .write_to(&mut png, image::ImageFormat::Png) + .map_err(|e| format!("Couldn't encode image: {e}"))?; + let b64 = base64::engine::general_purpose::STANDARD.encode(png.into_inner()); + if b64.len() > CUSTOM_MAX_B64 { + return Err("Image is too large even after resizing.".to_string()); + } + Ok(Avatar::Custom(b64)) } /// Curated palette the monogram background is chosen from. Picked to be distinct @@ -159,6 +246,53 @@ mod tests { } } + /// A valid PNG of the given size, as raw bytes (test helper). + fn make_png(w: u32, h: u32) -> Vec { + let img = image::DynamicImage::new_rgba8(w, h); + let mut buf = std::io::Cursor::new(Vec::new()); + img.write_to(&mut buf, image::ImageFormat::Png).unwrap(); + buf.into_inner() + } + + #[test] + fn process_upload_resizes_and_round_trips() { + // A 400x200 image downscales so the longest side is CUSTOM_MAX_PX. + let raw = make_png(400, 200); + let avatar = process_upload(&raw).expect("should process"); + assert!(matches!(avatar, Avatar::Custom(_))); + // The stored base64 decodes back to a PNG within bounds. + let png = avatar.custom_png().expect("custom png decodes"); + let decoded = image::load_from_memory(&png).unwrap(); + assert_eq!(decoded.width().max(decoded.height()), CUSTOM_MAX_PX); + assert!(decoded.width() <= CUSTOM_MAX_PX && decoded.height() <= CUSTOM_MAX_PX); + } + + #[test] + fn process_upload_rejects_non_image() { + assert!(process_upload(b"definitely not an image").is_err()); + } + + #[test] + fn sanitize_incoming_passes_monogram_and_presets() { + assert_eq!(Avatar::Monogram.sanitize_incoming(), Avatar::Monogram); + assert_eq!(Avatar::Preset(2).sanitize_incoming(), Avatar::Preset(2)); + } + + #[test] + fn sanitize_incoming_accepts_valid_custom() { + let avatar = process_upload(&make_png(64, 64)).unwrap(); + assert_eq!(avatar.clone().sanitize_incoming(), avatar); + } + + #[test] + fn sanitize_incoming_rejects_junk_and_oversize() { + // Not valid base64 / not a PNG → downgraded to monogram. + assert_eq!(Avatar::Custom("not base64!!!".into()).sanitize_incoming(), Avatar::Monogram); + // Over the byte cap → downgraded without even decoding. + let huge = Avatar::Custom("A".repeat(CUSTOM_MAX_B64 + 1)); + assert_eq!(huge.sanitize_incoming(), Avatar::Monogram); + } + #[test] fn dark_text_chosen_on_light_backgrounds() { assert!(use_dark_text_on((0xFF, 0xFF, 0xFF))); // white bg → dark text diff --git a/src/core/mod.rs b/src/core/mod.rs index 130b6b8..057740d 100644 --- a/src/core/mod.rs +++ b/src/core/mod.rs @@ -42,8 +42,12 @@ impl CoreController { Self { cmd_tx } } - pub fn send(&self, cmd: CoreCommand) -> Result<(), mpsc::error::TrySendError> { - self.cmd_tx.try_send(cmd) + /// Queue a command for the core loop, best-effort. Returns `true` if it was + /// accepted, `false` if the channel is full or closed. (We return a plain + /// bool rather than the channel's `Result` so the bulky `CoreCommand` isn't + /// carried back by value in every caller's error type.) + pub fn send(&self, cmd: CoreCommand) -> bool { + self.cmd_tx.try_send(cmd).is_ok() } } @@ -541,7 +545,13 @@ async fn run_core_loop( }; // Initialize Gossip and Transport - let gossip = Gossip::builder().spawn(endpoint.clone()); + // Raise the gossip frame budget above the 4 KB default so a + // hard-capped custom avatar (W4) can ride presence inline. All + // peers must use the same value (already a breaking build req from + // the avatar field). 64 KB leaves generous headroom over our cap. + let gossip = Gossip::builder() + .max_message_size(65536) + .spawn(endpoint.clone()); let (transport, audio_proto) = IrohTransport::new(endpoint.clone()); let transport = Arc::new(transport); diff --git a/src/network/gossip.rs b/src/network/gossip.rs index 2cad05c..aabaa7c 100644 --- a/src/network/gossip.rs +++ b/src/network/gossip.rs @@ -293,6 +293,12 @@ impl RoomState for IrohGossipState { // spoofable): sanitize at ingest so every // consumer gets a safe value (security S4). state.name = crate::sanitize::sanitize_name(&state.name); + // A peer's avatar is equally untrusted: a + // custom image is validated (size + safe + // decode within bounds) or downgraded to a + // monogram, so a malformed/oversized/bomb + // image can't crash or exhaust us (W4). + state.avatar = state.avatar.sanitize_incoming(); let (is_new, state_changed) = { let mut peer_map = peers.lock().unwrap(); let is_new = !peer_map.contains_key(&payload.author);