feat(avatars): custom image upload — W4 Phase 3

Completes W4: users can upload a custom avatar image.

- `Avatar::Custom(String)` carries a base64 PNG. `process_upload` decodes an
  arbitrary png/jpeg, downscales so the longest side is 128px (aspect kept),
  re-encodes PNG, base64s, and rejects anything over a hard cap.
- Settings "Avatar" gains an "Upload image…" button (native picker via rfd's
  xdg-portal backend, off-thread through Task::perform) and shows the current
  custom avatar as a selected tile.
- Untrusted peer avatars are validated at gossip ingest (`sanitize_incoming`):
  a custom image must be within the byte cap and decode as a PNG within bounds
  (image-crate decode limits guard against decompression bombs) or it's
  downgraded to a monogram.
- Raised the gossip max message size to 64 KB so a capped custom avatar fits
  inline on the presence plane (all peers already need a matching build).
- Deps: image (png/jpeg only), rfd (xdg-portal, no GTK); only `rfd`+`pollster`
  are actually new in the lockfile (rest were already transitive). cargo audit
  clean (0 vulns; the 2 unmaintained warnings are pre-existing S7).
- `Controller::send` now returns bool (was a Result carrying the now-larger
  CoreCommand by value, which tripped result_large_err).
- +5 avatar unit tests (upload resize/round-trip, reject non-image, ingest
  accept/reject). 227 lib tests green, clippy clean.

Manual check: Settings → Avatar → Upload; confirm the picker opens and the
image shows for you and (after redeploy) for a peer.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-14 17:00:18 -04:00
co-authored by Claude Opus 4.8
parent 3007002b1b
commit f029a30ea7
6 changed files with 264 additions and 18 deletions
Generated
+32
View File
@@ -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"
+4
View File
@@ -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"
+68 -8
View File
@@ -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<Vec<u8>>),
/// 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<AppMessage> {
// 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<Element<'_, AppMessage>> =
vec![avatar_choice(crate::avatar::Avatar::Monogram, "Monogram".to_string())];
let mut avatar_tiles: Vec<Element<'_, AppMessage>> = 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<Element<'_, AppMessage>> = Vec::new();
let mut tiles_iter = avatar_tiles.into_iter();
loop {
let chunk: Vec<Element<'_, AppMessage>> = 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,10 +2923,12 @@ 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)),
)
let png_bytes: Option<bytes::Bytes> = 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(),
+138 -4
View File
@@ -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<Vec<u8>> {
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<Avatar, String> {
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<u8> {
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
+13 -3
View File
@@ -42,8 +42,12 @@ impl CoreController {
Self { cmd_tx }
}
pub fn send(&self, cmd: CoreCommand) -> Result<(), mpsc::error::TrySendError<CoreCommand>> {
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);
+6
View File
@@ -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);