Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
cdc92e5
feat(agents): redesign shareable agent cards
cynfria Aug 13, 2026
a649a41
feat(agents): align imported card previews
cynfria Aug 13, 2026
9ec1884
fix(agents): harden card export lifecycle
cynfria Aug 13, 2026
dc4fb83
fix(agents): refine share card branding
cynfria Aug 13, 2026
e05cb21
fix(agents): polish import dialog actions
cynfria Aug 13, 2026
53a9ca7
fix(agents): harden card reveal layout
cynfria Aug 14, 2026
9b305be
chore(agents): remove obsolete card numbering
cynfria Aug 14, 2026
e0a06ae
chore(design-system): refresh component manifest
cynfria Aug 14, 2026
a7c7db5
feat(avatars): expose validated cached animation bytes
cynfria Aug 14, 2026
aebbf42
fix(agents): harden share card avatar fallbacks
cynfria Aug 14, 2026
0799e76
fix(agents): bound share card font loading
cynfria Aug 14, 2026
b85e85d
fix(agents): keep card dialogs viewport safe
cynfria Aug 14, 2026
4adbc5b
fix(agents): localize share card traits
cynfria Aug 14, 2026
c2894f1
fix(agents): unify card geometry and Unicode layout
cynfria Aug 14, 2026
5b057e7
fix(agents): tolerate minimal translation test doubles
cynfria Aug 14, 2026
bfcfafe
fix(agents): preserve card refraction within dialog bounds
cynfria Aug 14, 2026
a035608
fix(agents): restore visible card refraction halo
cynfria Aug 14, 2026
87ee207
fix(agents): fade refraction before stage boundary
cynfria Aug 14, 2026
eaeb626
revert(agents): restore original card refraction
cynfria Aug 14, 2026
707ee63
fix(agents): keep refraction geometry stable after reveal
cynfria Aug 14, 2026
27c1858
revert(agents): restore import card refraction overflow
cynfria Aug 14, 2026
06fe4b1
revert(agents): restore share card refraction overflow
cynfria Aug 14, 2026
cfd2c40
fix(agents): bound share card placeholder sizing
cynfria Aug 15, 2026
501bea8
fix(agents): contain extreme imported card ratios
cynfria Aug 15, 2026
6ef6fbf
fix(agents): classify Spanish card instructions
cynfria Aug 15, 2026
4fd3d84
fix(agents): prevent share refraction scroll containment
cynfria Aug 15, 2026
8a3bb99
fix(agents): recognize preloaded share card avatars
cynfria Aug 15, 2026
be4f23a
fix(agents): bound optional animation embedding
cynfria Aug 15, 2026
d1cd86c
fix(agents): unify localized trait layout
cynfria Aug 15, 2026
9eefaec
fix(agents): bound cached animation embedding
cynfria Aug 15, 2026
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
86 changes: 86 additions & 0 deletions src-tauri/src/commands/avatars.rs
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,15 @@ pub struct CachedAvatar {
pub asset: CachedAvatarAsset,
}

#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CachedAvatarAnimation {
pub bytes: Vec<u8>,
pub mime_type: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub alpha_mode: Option<String>,
}

#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
struct AvatarCacheWarmedPayload {
Expand Down Expand Up @@ -364,6 +373,46 @@ pub async fn get_avatar_library_snapshot(
})
}

#[tauri::command]
pub async fn read_cached_avatar_animation(
app: AppHandle,
avatar_ref: String,
) -> Result<Option<CachedAvatarAnimation>, String> {
let cached = get_cached_avatar_for_ref(app, avatar_ref).await?;
let Some(cached) = cached else {
return Ok(None);
};
read_cached_avatar_animation_asset(cached.asset)
}

fn read_cached_avatar_animation_asset(
asset: CachedAvatarAsset,
) -> Result<Option<CachedAvatarAnimation>, String> {
if !matches!(asset.mime_type.as_str(), "video/webm" | "video/mp4") {
return Ok(None);
}
let metadata = fs::metadata(&asset.path)
.map_err(|error| format!("Failed to inspect cached avatar animation: {error}"))?;
if metadata.len() == 0 || metadata.len() > MAX_IMPORTED_AVATAR_BYTES as u64 {
return Ok(None);
}
let bytes = fs::read(&asset.path)
.map_err(|error| format!("Failed to read cached avatar animation: {error}"))?;
// Recheck the actual payload after reading; metadata is only an early exit
// and the cache file could be replaced between the stat and read calls.
if bytes.is_empty() || bytes.len() > MAX_IMPORTED_AVATAR_BYTES {
return Ok(None);
}
if validate_imported_avatar_signature(&bytes, &asset.mime_type).is_err() {
return Ok(None);
}
Ok(Some(CachedAvatarAnimation {
bytes,
mime_type: asset.mime_type,
alpha_mode: asset.alpha_mode,
}))
}

#[tauri::command]
pub async fn get_cached_avatar_for_ref(
app: AppHandle,
Expand Down Expand Up @@ -2493,6 +2542,43 @@ mod tests {
format!("data:video/webm;base64,{}", BASE64.encode(bytes))
}

#[test]
fn cached_animation_reader_enforces_type_and_payload_bounds() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("avatar.webm");
fs::write(&path, WEBM_SIGNATURE).unwrap();
let asset = |mime_type: &str| CachedAvatarAsset {
id: "gloopy-1".to_string(),
path: path.to_string_lossy().into_owned(),
mime_type: mime_type.to_string(),
alpha_mode: Some("stacked".to_string()),
poster_path: None,
};

let animation = read_cached_avatar_animation_asset(asset("video/webm"))
.unwrap()
.unwrap();
assert_eq!(animation.bytes, WEBM_SIGNATURE);
assert_eq!(animation.mime_type, "video/webm");
assert_eq!(animation.alpha_mode.as_deref(), Some("stacked"));

assert!(read_cached_avatar_animation_asset(asset("image/png"))
.unwrap()
.is_none());
fs::write(&path, b"not webm").unwrap();
assert!(read_cached_avatar_animation_asset(asset("video/webm"))
.unwrap()
.is_none());
fs::write(&path, []).unwrap();
assert!(read_cached_avatar_animation_asset(asset("video/webm"))
.unwrap()
.is_none());
fs::write(&path, vec![0; MAX_IMPORTED_AVATAR_BYTES + 1]).unwrap();
assert!(read_cached_avatar_animation_asset(asset("video/webm"))
.unwrap()
.is_none());
}

#[test]
fn imported_avatar_limit_accounts_for_base64_padding_exactly() {
for size in [MAX_IMPORTED_AVATAR_BYTES - 1, MAX_IMPORTED_AVATAR_BYTES] {
Expand Down
1 change: 1 addition & 0 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -433,6 +433,7 @@ pub fn run() {
commands::avatars::refresh_avatar_cache,
commands::avatars::get_cached_avatar_for_ref,
commands::avatars::get_cached_avatars_for_refs,
commands::avatars::read_cached_avatar_animation,
commands::avatars::import_user_avatar_data_url,
commands::avatars::delete_user_avatar,
commands::cache::clear_local_media_caches,
Expand Down
10 changes: 10 additions & 0 deletions src/features/agents/agent-snapshot/pngCodec.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
decodeAgentImage,
decodeAvatarAnimation,
encodeAgentImage,
getPngDimensions,
} from "./pngCodec";
import {
AgentSnapshotError,
Expand Down Expand Up @@ -112,6 +113,15 @@ function expectCode(
}

describe("buzz-agent-snapshot PNG codec", () => {
it("returns dimensions from a fully validated PNG", () => {
expect(getPngDimensions(pngWithDimensions(321, 654))).toEqual({
width: 321,
height: 654,
});
expect(() => getPngDimensions(Uint8Array.from([1, 2, 3]))).toThrow(
"Invalid PNG signature",
);
});
it("round trips animated avatar bytes outside snapshot JSON", () => {
const animation = {
bytes: Uint8Array.from([26, 69, 223, 163]),
Expand Down
15 changes: 15 additions & 0 deletions src/features/agents/agent-snapshot/pngCodec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,21 @@ function crc32(bytes: Uint8Array): number {
return (crc ^ 0xffffffff) >>> 0;
}

export function getPngDimensions(bytes: Uint8Array): {
width: number;
height: number;
} {
const chunks = parseChunks(bytes);
const ihdr = chunks[0];
if (!ihdr || ihdr.type !== "IHDR") {
throw new AgentSnapshotError("PNG must begin with IHDR", "invalid-png");
}
return {
width: readU32(ihdr.data, 0),
height: readU32(ihdr.data, 4),
};
}

function parseChunks(bytes: Uint8Array): Chunk[] {
if (bytes.length > MAX_SNAPSHOT_PNG_BYTES) {
throw new AgentSnapshotError(
Expand Down
3 changes: 3 additions & 0 deletions src/features/agents/assets/share-card/berd-card-logo.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion src/features/agents/ui/AgentImageImportDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -237,7 +237,7 @@ export function AgentImageImportDialog({
<DialogContent
size="lg"
surface="solid"
className="bg-card"
className="bg-card [&_[data-slot=dialog-close]]:z-20"
aria-describedby={undefined}
>
<DialogHeader
Expand Down
Loading