Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 40 additions & 1 deletion desktop/src-tauri/src/managed_agents/agent_snapshot.rs
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,13 @@ pub const PNG_CHUNK_KEYWORD: &str = "buzz_agent_snapshot";
/// this are stored as a URL reference instead.
const MAX_AVATAR_INLINE_BYTES: usize = 2 * 1024 * 1024; // 2 MB

/// Maximum edge (px) for the PNG image body. The body is only a card
/// thumbnail — the manifest keeps the full-resolution source reference — so a
/// large avatar is downscaled here to keep the encoded snapshot well under
/// `MAX_SNAPSHOT_PNG_BYTES`. Mirrors the frontend SVG rasterizer's 512×512 cap
/// in `snapshotAvatarPng.ts`.
const MAX_PNG_BODY_EDGE: u32 = 512;

/// Format discriminator — used for sniffing and validation.
pub const FORMAT_DISCRIMINATOR: &str = "buzz-agent-snapshot";

Expand Down Expand Up @@ -328,7 +335,7 @@ pub(crate) fn encode_chunk_payload_png(
// there is no avatar or it cannot be decoded.
let png_bytes = match avatar_bytes.filter(|bytes| !bytes.is_empty()) {
Some(bytes) => {
let encoded_avatar = if bytes.starts_with(b"\x89PNG") {
let encoded_avatar = if bytes.starts_with(b"\x89PNG") && png_within_body_cap(bytes) {
inject_text_chunk(bytes, PNG_CHUNK_KEYWORD, &chunk_text).or_else(|_| {
transcode_avatar_to_png_with_text(bytes, PNG_CHUNK_KEYWORD, &chunk_text)
})
Expand Down Expand Up @@ -449,20 +456,52 @@ pub(crate) fn make_png_with_text(keyword: &str, text: &str) -> Result<Vec<u8>, S
}

/// Transcode a decodable avatar to PNG and add the snapshot manifest chunk.
///
/// The decoded image is downscaled so its longest edge is at most
/// `MAX_PNG_BODY_EDGE` before PNG re-encoding. The body is only a card
/// thumbnail — this keeps a large source avatar (e.g. a 4K webp) from
/// producing a PNG that blows `MAX_SNAPSHOT_PNG_BYTES`.
fn transcode_avatar_to_png_with_text(
avatar_bytes: &[u8],
keyword: &str,
text: &str,
) -> Result<Vec<u8>, String> {
let image = image::load_from_memory(avatar_bytes)
.map_err(|e| format!("Failed to decode avatar image: {e}"))?;
let image = downscale_to_body_cap(image);
let mut png_bytes = Vec::new();
image
.write_to(&mut Cursor::new(&mut png_bytes), image::ImageFormat::Png)
.map_err(|e| format!("Failed to encode avatar as PNG: {e}"))?;
inject_text_chunk(&png_bytes, keyword, text)
}

/// Downscale so the longest edge is at most `MAX_PNG_BODY_EDGE`, preserving
/// aspect ratio. Images already within the cap are returned untouched.
fn downscale_to_body_cap(image: image::DynamicImage) -> image::DynamicImage {
if image.width() <= MAX_PNG_BODY_EDGE && image.height() <= MAX_PNG_BODY_EDGE {
return image;
}
image.resize(
MAX_PNG_BODY_EDGE,
MAX_PNG_BODY_EDGE,
image::imageops::FilterType::Lanczos3,
)
}

/// Whether an already-PNG avatar is within the body dimension cap and can be
/// carried as-is (via a cheap tEXt-chunk injection) instead of being decoded
/// and downscaled. Undecodable headers fall through to the transcode path.
fn png_within_body_cap(png_bytes: &[u8]) -> bool {
Decoder::new(Cursor::new(png_bytes))
.read_info()
.map(|reader| {
let info = reader.info();
info.width <= MAX_PNG_BODY_EDGE && info.height <= MAX_PNG_BODY_EDGE
})
.unwrap_or(false)
}

/// Inject a tEXt chunk into an existing PNG by re-encoding it.
///
/// Re-decodes the image data via the `png` crate and writes a fresh PNG with
Expand Down
55 changes: 54 additions & 1 deletion desktop/src-tauri/src/managed_agents/agent_snapshot_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -234,7 +234,60 @@ fn png_snapshot_transcodes_jpeg_avatar_into_image_body() {
assert_eq!((reader.info().width, reader.info().height), (3, 2));
}

// ── PNG memory parity ─────────────────────────────────────────────────────
#[test]
fn png_snapshot_downscales_oversize_avatar_under_cap() {
// A large avatar (mirrors Gurney's 2764×4096 image that encoded to ~26 MB)
// must be downscaled for the PNG body so the snapshot stays under the
// 10 MiB cap — while the manifest keeps the untouched source reference.
// An already-PNG oversize avatar exercises the `png_within_body_cap` guard
// that routes it through the downscaling transcode path.
let avatar = image::DynamicImage::ImageRgb8(image::RgbImage::from_fn(2764, 4096, |x, y| {
image::Rgb([(x % 256) as u8, (y % 256) as u8, ((x + y) % 256) as u8])
}));
let mut source_bytes = Vec::new();
avatar
.write_to(&mut Cursor::new(&mut source_bytes), image::ImageFormat::Png)
.unwrap();

let snapshot = build_snapshot(
&minimal_record(),
MemoryLevel::None,
vec![],
Some(&source_bytes),
);
let png_bytes = encode_snapshot_png(&snapshot, Some(&source_bytes)).unwrap();

assert!(
png_bytes.len()
<= super::MAX_PNG_BODY_EDGE as usize * super::MAX_PNG_BODY_EDGE as usize * 4,
"downscaled snapshot ({} bytes) must be far under the 10 MiB cap",
png_bytes.len()
);

let reader = Decoder::new(Cursor::new(png_bytes)).read_info().unwrap();
let (width, height) = (reader.info().width, reader.info().height);
assert!(
width <= 512 && height <= 512,
"body dimensions {width}×{height} must fit the 512px cap"
);
// Aspect ratio preserved: the longest edge (height) is clamped to the cap.
assert_eq!(height, 512, "longest edge should hit the 512px cap");

// The manifest keeps the untouched full-resolution source reference — only
// the PNG body is downscaled. The oversize source bytes exceed the inline
// cap, so the manifest falls back to the record's `avatar_url`.
let manifest =
decode_snapshot_png(&encode_snapshot_png(&snapshot, Some(&source_bytes)).unwrap()).unwrap();
assert_eq!(
manifest.profile.avatar_url.as_deref(),
Some("https://example.com/avatar.png"),
"manifest must preserve the untouched source avatar reference"
);
assert!(
manifest.profile.avatar_data_url.is_none(),
"oversize source bytes must not be inlined into the manifest"
);
}

#[test]
fn png_round_trip_with_core_memory() {
Expand Down
5 changes: 4 additions & 1 deletion desktop/src/features/agents/ui/AgentCardViewerDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,10 @@ function AgentCardViewerContent({
toast.success(`Sent ${agentName}'s card.`);
closeCardViewer();
} else if (sent === false) {
toast.error("Couldn’t send the card. Try again.");
toast.error(
sendController.getCurrentError() ??
"Couldn’t send the card. Try again.",
);
}
}

Expand Down
5 changes: 4 additions & 1 deletion desktop/src/features/agents/ui/PersonaShareDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -391,7 +391,10 @@ export function SnapshotShareDialog({
toast.success(`Sent a copy of ${displayName}`);
onOpenChange(false);
} else if (sent === false) {
toast.error(`Couldn’t send ${itemLabel}. Try again.`);
toast.error(
snapshotSendController.getCurrentError() ??
`Couldn’t send ${itemLabel}. Try again.`,
);
}
}

Expand Down
24 changes: 20 additions & 4 deletions desktop/src/features/agents/ui/useSnapshotSendController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -350,6 +350,12 @@ export type UseSnapshotSendControllerResult = {
/** Relay moderation identity to exclude from the people picker. */
relaySelfPubkey: string | null;
state: SnapshotSendState;
/**
* Read the latest error synchronously — right after `beginSend` resolves the
* render-captured `state.error` is stale until the next commit, so callers
* that toast on failure must read through here.
*/
getCurrentError: () => string | null;
/**
* Execute destination creation plus prepare → encode → upload → send behind
* one concurrency guard. A second call while the first is in flight returns
Expand Down Expand Up @@ -390,6 +396,15 @@ export function useSnapshotSendController(
error: null,
});

// Mirror `state` into a ref so callers can read the latest error
// synchronously right after `beginSend` resolves — the render-captured
// `state` in their closure is stale until the next render commits.
const stateRef = React.useRef(state);
const commitState = React.useCallback((next: SnapshotSendState) => {
stateRef.current = next;
setState(next);
}, []);

// Single-concurrency guard covering the full encode → upload → send action.
// Stored in a ref so it survives re-renders without triggering effects.
const guardRef = React.useRef(createSendGuard());
Expand Down Expand Up @@ -417,7 +432,7 @@ export function useSnapshotSendController(
checkEligibilityFn: () => checkSendEligibility(queryClient, channelId),
uploadFn: (bytes, filename) => uploadMediaBytes(bytes, filename),
sendFn: (args) => sendMutation.mutateAsync(args),
setStateFn: setState,
setStateFn: commitState,
buildMessageFn: (descriptor) => {
const message = buildOutgoingMessage("", [descriptor]);
return attachmentLabel?.trim()
Expand All @@ -430,15 +445,15 @@ export function useSnapshotSendController(
: message;
},
}),
setState,
commitState,
);
}

const reset = React.useCallback(() => {
if (!guardRef.current.inFlight) {
setState({ phase: "idle", error: null });
commitState({ phase: "idle", error: null });
}
}, []);
}, [commitState]);

return {
isDmSafetyReady:
Expand All @@ -447,6 +462,7 @@ export function useSnapshotSendController(
relaySelfQuery.status === "success"),
relaySelfPubkey: relaySelfQuery.data ?? null,
state,
getCurrentError: () => stateRef.current.error,
beginSend,
reset,
};
Expand Down
10 changes: 8 additions & 2 deletions desktop/tests/e2e/agents.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2287,7 +2287,9 @@ test("people sharing blocks a timeout before encoding or upload", async ({
});

await page.getByTestId("persona-share-send").click();
await expect(page.getByText("Couldn’t send agent. Try again.")).toBeVisible();
await expect(
page.getByText("You are currently timed out and cannot send messages."),
).toBeVisible();

const commands = await readAgentShareCommands(page);
expect(
Expand Down Expand Up @@ -2332,7 +2334,11 @@ test("people sharing rechecks destination eligibility after encoding", async ({
return testWindow.__BUZZ_E2E_INVALIDATE_CHANNELS__?.();
});

await expect(page.getByText("Couldn’t send agent. Try again.")).toBeVisible({
await expect(
page.getByText(
"The selected destination is no longer available. Please pick another.",
),
).toBeVisible({
timeout: 5_000,
});
const commands = await readAgentShareCommands(page);
Expand Down