use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
use nostr_sdk::prelude::*;
use serde::{Deserialize, Serialize};
use crate::crypto;
use crate::stored_event::event_kind;
const WALLPAPER_DTAG_VALUE: &str = "vector-wallpaper";
const MAX_WALLPAPER_DOWNLOAD_BYTES: u64 = 10 * 1024 * 1024;
pub const MAX_WALLPAPER_BYTES: usize = 5 * 1024 * 1024;
fn wallpapers_dir() -> Result<PathBuf, String> {
let npub = crate::db::get_current_account()?;
let dir = crate::db::account_dir(&npub)?.join("wallpapers");
std::fs::create_dir_all(&dir)
.map_err(|e| format!("Failed to create wallpapers dir: {}", e))?;
Ok(dir)
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WallpaperPreview {
pub path: String,
pub was_animated: bool,
pub recommended_dim: u8,
}
pub fn prepare_wallpaper_preview(
chat_npub: &str,
file_path: &str,
) -> Result<WallpaperPreview, String> {
let src = Path::new(file_path);
let bytes = std::fs::read(src)
.map_err(|e| format!("Failed to read image: {}", e))?;
if bytes.len() > MAX_WALLPAPER_BYTES {
return Err(format!(
"Image is too large ({} MB). Wallpapers max out at {} MB.",
bytes.len() / (1024 * 1024),
MAX_WALLPAPER_BYTES / (1024 * 1024),
));
}
let mime = crypto::mime_from_magic_bytes(&bytes).to_string();
if !mime.starts_with("image/") {
return Err("Wallpapers must be image files.".to_string());
}
let (final_bytes, final_extension, was_animated) =
extract_first_frame_if_animated(&bytes, &mime)?;
let recommended_dim = estimate_brightness_for_white_text(&final_bytes).unwrap_or(50);
clean_chat_files(chat_npub, FileKind::Preview, None)?;
let preview = wallpapers_dir()?.join(format!("{}.preview.{}", chat_npub, final_extension));
let tmp = preview.with_file_name(format!("{}.preview.{}.tmp", chat_npub, final_extension));
std::fs::write(&tmp, &final_bytes)
.map_err(|e| format!("Failed to stage preview file: {}", e))?;
std::fs::rename(&tmp, &preview)
.map_err(|e| format!("Failed to commit preview file: {}", e))?;
Ok(WallpaperPreview {
path: preview.to_string_lossy().to_string(),
was_animated,
recommended_dim,
})
}
fn estimate_brightness_for_white_text(bytes: &[u8]) -> Option<u8> {
use ::image::{GenericImageView, ImageReader};
use std::io::Cursor;
let mut reader = ImageReader::new(Cursor::new(bytes))
.with_guessed_format()
.ok()?;
reader.limits(crate::crypto::bounded_image_limits());
let img = reader.decode().ok()?;
let thumb = img.thumbnail(64, 64);
let rgb = thumb.to_rgb8();
let (w, h) = thumb.dimensions();
if w == 0 || h == 0 {
return None;
}
let mut sum: u64 = 0;
let mut count: u64 = 0;
for pixel in rgb.pixels() {
let r = pixel[0] as f32;
let g = pixel[1] as f32;
let b = pixel[2] as f32;
let y = 0.2126 * r + 0.7152 * g + 0.0722 * b;
sum += y as u64;
count += 1;
}
if count == 0 {
return None;
}
let avg = (sum / count) as f32; let brightness = (95.0 - (avg / 255.0) * 70.0) / 2.0;
Some(brightness.clamp(10.0, 50.0) as u8)
}
pub fn cancel_wallpaper_preview(chat_npub: &str) -> Result<(), String> {
clean_chat_files(chat_npub, FileKind::Preview, None)
}
fn extract_first_frame_if_animated(
src: &[u8],
mime: &str,
) -> Result<(Vec<u8>, String, bool), String> {
use ::image::{ImageFormat, ImageReader};
use std::io::Cursor;
let is_gif = mime == "image/gif";
let is_webp = mime == "image/webp";
if !is_gif && !is_webp {
let ext = crypto::extension_from_mime(mime);
return Ok((src.to_vec(), ext, false));
}
let mut reader = ImageReader::with_format(
Cursor::new(src),
if is_gif { ImageFormat::Gif } else { ImageFormat::WebP },
);
reader.limits(crate::crypto::bounded_image_limits());
let img = reader
.decode()
.map_err(|e| format!("Failed to decode image: {}", e))?;
let mut out: Vec<u8> = Vec::new();
img.write_to(&mut Cursor::new(&mut out), ImageFormat::Png)
.map_err(|e| format!("Failed to re-encode wallpaper: {}", e))?;
Ok((out, "png".to_string(), true))
}
#[derive(Copy, Clone)]
enum FileKind {
Preview,
Active,
}
fn clean_chat_files(
chat_npub: &str,
kind: FileKind,
extension_to_skip: Option<&str>,
) -> Result<(), String> {
let dir = wallpapers_dir()?;
let entries = match std::fs::read_dir(&dir) {
Ok(e) => e,
Err(_) => return Ok(()),
};
let preview_prefix = format!("{}.preview.", chat_npub);
let active_prefix = format!("{}.", chat_npub);
for entry in entries.flatten() {
let path = entry.path();
let name = match path.file_name().and_then(|n| n.to_str()) {
Some(n) => n.to_string(),
None => continue,
};
let matches = match kind {
FileKind::Preview => name.starts_with(&preview_prefix),
FileKind::Active => name.starts_with(&active_prefix) && !name.starts_with(&preview_prefix),
};
if !matches {
continue;
}
if let Some(ext) = extension_to_skip {
let suffix = format!(".{}", ext);
if name.ends_with(&suffix) {
continue;
}
}
let _ = std::fs::remove_file(&path);
}
Ok(())
}
pub async fn publish_wallpaper(chat_npub: &str, blur: u8, dim: u8) -> Result<(), String> {
let session = crate::state::SessionGuard::capture();
let blur = blur.min(30);
let dim = dim.min(100);
let dir = wallpapers_dir()?;
let prefix = format!("{}.preview.", chat_npub);
let mut preview_path: Option<PathBuf> = None;
for entry in std::fs::read_dir(&dir)
.map_err(|e| format!("Wallpapers dir: {}", e))?
.flatten()
{
let p = entry.path();
let n = p
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("")
.to_string();
if n.starts_with(&prefix) {
preview_path = Some(p);
break;
}
}
let preview = preview_path
.ok_or_else(|| "No wallpaper preview to publish. Pick an image first.".to_string())?;
let bytes = std::fs::read(&preview)
.map_err(|e| format!("Failed to read preview file: {}", e))?;
let extension = preview
.extension()
.and_then(|e| e.to_str())
.unwrap_or("png")
.to_string();
let mime = crypto::mime_from_extension(&extension).to_string();
let plaintext_hash = crypto::sha256_hex(&bytes);
let params = crypto::generate_encryption_params();
let encrypted = crypto::encrypt_data(&bytes, ¶ms)?;
let client = crate::state::nostr_client().ok_or("Not logged in")?;
let signer = client
.signer()
.await
.map_err(|e| format!("Signer: {}", e))?;
let my_pk = crate::state::my_public_key().ok_or("Public key not set")?;
let recipient_pk = PublicKey::from_bech32(chat_npub)
.map_err(|e| format!("Invalid chat npub: {}", e))?;
let servers = crate::state::get_blossom_servers();
let chat_npub_for_progress = chat_npub.to_string();
let progress_cb: crate::blossom::ProgressCallback = Arc::new(move |percentage, bytes| {
crate::traits::emit_event(
"wallpaper_upload_progress",
&serde_json::json!({
"chat_id": chat_npub_for_progress,
"progress": percentage.unwrap_or(0),
"bytes": bytes.unwrap_or(0),
}),
);
Ok(())
});
let upload_url = crate::blossom::upload_blob_with_progress_and_failover(
signer.clone(),
servers,
Arc::new(encrypted.clone()),
Some(&mime),
true,
progress_cb,
None, None, None, )
.await
.map_err(|e| format!("Wallpaper upload failed: {}", e))?;
let created_at = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs();
let rumor = EventBuilder::new(Kind::Custom(event_kind::APPLICATION_SPECIFIC), "")
.tag(Tag::identifier(WALLPAPER_DTAG_VALUE))
.tag(Tag::public_key(recipient_pk))
.tag(Tag::custom(
TagKind::Custom("url".into()),
vec![upload_url.clone()],
))
.tag(Tag::custom(
TagKind::Custom("decryption-key".into()),
vec![params.key.clone()],
))
.tag(Tag::custom(
TagKind::Custom("decryption-nonce".into()),
vec![params.nonce.clone()],
))
.tag(Tag::custom(
TagKind::Custom("x".into()),
vec![plaintext_hash.clone()],
))
.tag(Tag::custom(
TagKind::Custom("m".into()),
vec![mime.clone()],
))
.tag(Tag::custom(
TagKind::Custom("size".into()),
vec![encrypted.len().to_string()],
))
.tag(Tag::custom(
TagKind::Custom("blur".into()),
vec![blur.to_string()],
))
.tag(Tag::custom(
TagKind::Custom("dim".into()),
vec![dim.to_string()],
))
.custom_created_at(Timestamp::from(created_at))
.build(my_pk);
let pending_id = format!("pending-wallpaper-{}", created_at);
let send_config = crate::sending::SendConfig {
max_send_attempts: 3,
retry_delay: std::time::Duration::from_secs(2),
self_send: true,
..Default::default()
};
let send_callback: Arc<dyn crate::sending::SendCallback> =
Arc::new(crate::sending::NoOpSendCallback);
if let Err(e) = crate::sending::send_rumor_dm(
chat_npub, &pending_id, rumor.clone(), &send_config, send_callback,
).await {
log_warn!("[Wallpaper] send_rumor_dm to {} failed: {}", chat_npub, e);
return Err(format!(
"Couldn't send the wallpaper. Check that the relays you and your contact share are reachable, then try again. ({})",
e
));
}
log_info!("[Wallpaper] rumor delivered to {}", chat_npub);
if !session.is_valid() {
return Ok(());
}
let active = wallpapers_dir()?.join(format!("{}.{}", chat_npub, extension));
clean_chat_files(chat_npub, FileKind::Active, None)?;
std::fs::rename(&preview, &active)
.map_err(|e| format!("Failed to promote preview: {}", e))?;
let active_str = active.to_string_lossy().to_string();
let me_npub = my_pk.to_bech32().unwrap_or_default();
let (slim, prev_url, prev_uploader) = {
let mut state = crate::state::STATE.lock().await;
let prev = state.get_chat(chat_npub).map(|c| {
(c.wallpaper_url.clone(), c.wallpaper_uploader.clone())
});
if let Some(chat) = state.get_chat_mut(chat_npub) {
chat.wallpaper_path = active_str.clone();
chat.wallpaper_ts = created_at;
chat.wallpaper_blur = blur;
chat.wallpaper_dim = dim;
chat.wallpaper_url = upload_url.clone();
chat.wallpaper_uploader = me_npub.clone();
}
let slim = state
.get_chat(chat_npub)
.map(|c| crate::db::chats::SlimChatDB::from_chat(c, &state.interner));
let (pu, puploader) = prev.unwrap_or_default();
(slim, pu, puploader)
};
if let Some(slim) = slim {
if let Err(e) = crate::db::chats::save_slim_chat(&slim) {
log_warn!("[Wallpaper] save_slim_chat failed for {}: {}", chat_npub, e);
}
}
if !prev_url.is_empty() && prev_uploader == me_npub {
let signer_clone = signer.clone();
let prev_url_clone = prev_url.clone();
tokio::spawn(async move {
if let Err(e) =
crate::blossom::delete_blob_by_url(signer_clone, &prev_url_clone).await
{
log_warn!(
"[Wallpaper] DELETE prev blob {} failed: {}",
prev_url_clone, e
);
}
});
}
let event_id = rumor.id.ok_or("Rumor missing id")?.to_hex();
let inserted = match crate::db::events::save_system_event_by_id(
&event_id,
chat_npub,
crate::stored_event::SystemEventType::WallpaperChanged,
&me_npub,
Some("You"),
)
.await
{
Ok(b) => b,
Err(e) => {
log_warn!("[Wallpaper] save_system_event_by_id failed for {}: {}", event_id, e);
false
}
};
if inserted {
crate::traits::emit_event("system_event", &serde_json::json!({
"conversation_id": chat_npub,
"event_id": event_id,
"event_type": crate::stored_event::SystemEventType::WallpaperChanged.as_u8(),
"member_pubkey": me_npub,
"member_name": "You",
}));
} else {
log_warn!("[Wallpaper] system event {} was not inserted (already exists or save failed)", event_id);
}
crate::traits::emit_event(
"wallpaper_updated",
&serde_json::json!({
"chat_id": chat_npub,
"path": active_str,
"ts": created_at,
"blur": blur,
"dim": dim,
"by_npub": me_npub,
"event_id": event_id,
}),
);
Ok(())
}
#[allow(clippy::too_many_arguments)]
pub async fn apply_received_wallpaper(
chat_npub: &str,
sender_npub: &str,
created_at: u64,
url: &str,
decryption_key: &str,
decryption_nonce: &str,
plaintext_hash: Option<&str>,
mime: Option<&str>,
blur: Option<u8>,
dim: Option<u8>,
rumor_event_id: &str,
) -> Result<(), String> {
let session = crate::state::SessionGuard::capture();
let blur = blur.unwrap_or(0).min(30);
let dim = dim.unwrap_or(50).min(100);
{
let state = crate::state::STATE.lock().await;
if let Some(chat) = state.get_chat(chat_npub) {
if chat.wallpaper_ts >= created_at {
return Ok(());
}
}
}
if url.is_empty() {
clean_chat_files(chat_npub, FileKind::Active, None)?;
let (slim, prev_url, prev_uploader) = {
let mut state = crate::state::STATE.lock().await;
let prev = state.get_chat(chat_npub).map(|c| {
(c.wallpaper_url.clone(), c.wallpaper_uploader.clone())
});
if let Some(chat) = state.get_chat_mut(chat_npub) {
chat.wallpaper_path = String::new();
chat.wallpaper_url = String::new();
chat.wallpaper_uploader = String::new();
chat.wallpaper_ts = created_at;
chat.wallpaper_blur = blur;
chat.wallpaper_dim = dim;
}
let slim = state
.get_chat(chat_npub)
.map(|c| crate::db::chats::SlimChatDB::from_chat(c, &state.interner));
let (pu, puploader) = prev.unwrap_or_default();
(slim, pu, puploader)
};
if let Some(slim) = slim {
let _ = crate::db::chats::save_slim_chat(&slim);
}
delete_prior_blob_if_ours(&prev_url, &prev_uploader).await;
emit_wallpaper_removed(chat_npub, sender_npub, created_at, rumor_event_id).await;
return Ok(());
}
let mime_str = mime.unwrap_or("image/png").to_string();
let extension = crypto::extension_from_mime(&mime_str);
crate::net::validate_url_not_private(url)?;
let http = crate::net::build_http_client(Duration::from_secs(30))?;
let resp = http
.get(url)
.send()
.await
.map_err(|e| format!("Wallpaper download failed: {}", e))?;
if !resp.status().is_success() {
return Err(format!("Wallpaper HTTP {}", resp.status()));
}
if let Some(len) = resp.content_length() {
if len > MAX_WALLPAPER_DOWNLOAD_BYTES {
return Err("Wallpaper too large".to_string());
}
}
let mut resp = resp;
let mut bytes: Vec<u8> = Vec::new();
while let Some(chunk) = resp
.chunk()
.await
.map_err(|e| format!("Read body: {}", e))?
{
bytes.extend_from_slice(&chunk);
if bytes.len() as u64 > MAX_WALLPAPER_DOWNLOAD_BYTES {
return Err("Wallpaper too large".to_string());
}
}
let plaintext = crypto::decrypt_data(&bytes, decryption_key, decryption_nonce)?;
if let Some(want_hash) = plaintext_hash {
let got_hash = crypto::sha256_hex(&plaintext);
if !got_hash.eq_ignore_ascii_case(want_hash) {
return Err("Wallpaper integrity check failed".to_string());
}
}
if !session.is_valid() {
return Ok(());
}
let active = wallpapers_dir()?.join(format!("{}.{}", chat_npub, extension));
clean_chat_files(chat_npub, FileKind::Active, None)?;
let tmp = active.with_file_name(format!("{}.{}.tmp", chat_npub, extension));
std::fs::write(&tmp, &plaintext).map_err(|e| format!("Write wallpaper: {}", e))?;
std::fs::rename(&tmp, &active).map_err(|e| format!("Commit wallpaper: {}", e))?;
let active_str = active.to_string_lossy().to_string();
let (slim, prev_url, prev_uploader) = {
let mut state = crate::state::STATE.lock().await;
let prev = state.get_chat(chat_npub).map(|c| {
(c.wallpaper_url.clone(), c.wallpaper_uploader.clone())
});
if let Some(chat) = state.get_chat_mut(chat_npub) {
chat.wallpaper_path = active_str.clone();
chat.wallpaper_ts = created_at;
chat.wallpaper_blur = blur;
chat.wallpaper_dim = dim;
chat.wallpaper_url = url.to_string();
chat.wallpaper_uploader = sender_npub.to_string();
}
let slim = state
.get_chat(chat_npub)
.map(|c| crate::db::chats::SlimChatDB::from_chat(c, &state.interner));
let (pu, puploader) = prev.unwrap_or_default();
(slim, pu, puploader)
};
if let Some(slim) = slim {
let _ = crate::db::chats::save_slim_chat(&slim);
}
if !prev_url.is_empty() {
let me_npub = crate::state::my_public_key()
.and_then(|pk| pk.to_bech32().ok())
.unwrap_or_default();
if !me_npub.is_empty() && prev_uploader == me_npub {
if let Some(client) = crate::state::nostr_client() {
if let Ok(signer) = client.signer().await {
let prev_url_clone = prev_url.clone();
tokio::spawn(async move {
if let Err(e) =
crate::blossom::delete_blob_by_url(signer, &prev_url_clone).await
{
log_warn!(
"[Wallpaper] DELETE prev blob {} failed: {}",
prev_url_clone, e
);
}
});
}
}
}
}
let sender_display = {
let state = crate::state::STATE.lock().await;
state
.get_profile(sender_npub)
.and_then(|p| {
if !p.nickname.is_empty() {
Some(p.nickname.to_string())
} else if !p.name.is_empty() {
Some(p.name.to_string())
} else {
None
}
})
.unwrap_or_else(|| sender_npub.to_string())
};
let inserted = crate::db::events::save_system_event_by_id(
rumor_event_id,
chat_npub,
crate::stored_event::SystemEventType::WallpaperChanged,
sender_npub,
Some(&sender_display),
)
.await
.unwrap_or(false);
if inserted {
crate::traits::emit_event("system_event", &serde_json::json!({
"conversation_id": chat_npub,
"event_id": rumor_event_id,
"event_type": crate::stored_event::SystemEventType::WallpaperChanged.as_u8(),
"member_pubkey": sender_npub,
"member_name": sender_display,
}));
}
crate::traits::emit_event(
"wallpaper_updated",
&serde_json::json!({
"chat_id": chat_npub,
"path": active_str,
"ts": created_at,
"blur": blur,
"dim": dim,
"by_npub": sender_npub,
"event_id": rumor_event_id,
}),
);
Ok(())
}
async fn delete_prior_blob_if_ours(prev_url: &str, prev_uploader: &str) {
if prev_url.is_empty() {
return;
}
let me_npub = crate::state::my_public_key()
.and_then(|pk| pk.to_bech32().ok())
.unwrap_or_default();
if me_npub.is_empty() || prev_uploader != me_npub {
return;
}
if let Some(client) = crate::state::nostr_client() {
if let Ok(signer) = client.signer().await {
let prev_url = prev_url.to_string();
tokio::spawn(async move {
if let Err(e) = crate::blossom::delete_blob_by_url(signer, &prev_url).await {
log_warn!("[Wallpaper] DELETE prev blob {} failed: {}", prev_url, e);
}
});
}
}
}
async fn emit_wallpaper_removed(
chat_npub: &str,
by_npub: &str,
created_at: u64,
event_id: &str,
) {
let me_npub = crate::state::my_public_key()
.and_then(|pk| pk.to_bech32().ok())
.unwrap_or_default();
let display = if by_npub == me_npub {
"You".to_string()
} else {
let state = crate::state::STATE.lock().await;
state
.get_profile(by_npub)
.and_then(|p| {
if !p.nickname.is_empty() {
Some(p.nickname.to_string())
} else if !p.name.is_empty() {
Some(p.name.to_string())
} else {
None
}
})
.unwrap_or_else(|| by_npub.to_string())
};
let inserted = crate::db::events::save_system_event_by_id(
event_id,
chat_npub,
crate::stored_event::SystemEventType::WallpaperRemoved,
by_npub,
Some(&display),
)
.await
.unwrap_or(false);
if inserted {
crate::traits::emit_event("system_event", &serde_json::json!({
"conversation_id": chat_npub,
"event_id": event_id,
"event_type": crate::stored_event::SystemEventType::WallpaperRemoved.as_u8(),
"member_pubkey": by_npub,
"member_name": display,
}));
}
crate::traits::emit_event(
"wallpaper_updated",
&serde_json::json!({
"chat_id": chat_npub,
"path": "",
"ts": created_at,
"blur": 0,
"dim": 50,
"by_npub": by_npub,
"event_id": event_id,
}),
);
}
pub async fn remove_wallpaper(chat_npub: &str) -> Result<(), String> {
let session = crate::state::SessionGuard::capture();
let my_pk = crate::state::my_public_key().ok_or("Public key not set")?;
let recipient_pk = PublicKey::from_bech32(chat_npub)
.map_err(|e| format!("Invalid chat npub: {}", e))?;
let created_at = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs();
let rumor = EventBuilder::new(Kind::Custom(event_kind::APPLICATION_SPECIFIC), "")
.tag(Tag::identifier(WALLPAPER_DTAG_VALUE))
.tag(Tag::public_key(recipient_pk))
.custom_created_at(Timestamp::from(created_at))
.build(my_pk);
let pending_id = format!("pending-wallpaper-rm-{}", created_at);
let send_config = crate::sending::SendConfig {
max_send_attempts: 3,
retry_delay: std::time::Duration::from_secs(2),
self_send: true,
..Default::default()
};
let send_callback: Arc<dyn crate::sending::SendCallback> =
Arc::new(crate::sending::NoOpSendCallback);
if let Err(e) = crate::sending::send_rumor_dm(
chat_npub, &pending_id, rumor.clone(), &send_config, send_callback,
).await {
log_warn!("[Wallpaper] removal send to {} failed: {}", chat_npub, e);
return Err(format!(
"Couldn't remove the wallpaper. Check that the relays you and your contact share are reachable, then try again. ({})",
e
));
}
if !session.is_valid() {
return Ok(());
}
clean_chat_files(chat_npub, FileKind::Active, None)?;
let me_npub = my_pk.to_bech32().unwrap_or_default();
let (slim, prev_url, prev_uploader) = {
let mut state = crate::state::STATE.lock().await;
let prev = state.get_chat(chat_npub).map(|c| {
(c.wallpaper_url.clone(), c.wallpaper_uploader.clone())
});
if let Some(chat) = state.get_chat_mut(chat_npub) {
chat.wallpaper_path = String::new();
chat.wallpaper_url = String::new();
chat.wallpaper_uploader = String::new();
chat.wallpaper_ts = created_at;
}
let slim = state
.get_chat(chat_npub)
.map(|c| crate::db::chats::SlimChatDB::from_chat(c, &state.interner));
let (pu, puploader) = prev.unwrap_or_default();
(slim, pu, puploader)
};
if let Some(slim) = slim {
if let Err(e) = crate::db::chats::save_slim_chat(&slim) {
log_warn!("[Wallpaper] save_slim_chat (removal) failed for {}: {}", chat_npub, e);
}
}
delete_prior_blob_if_ours(&prev_url, &prev_uploader).await;
let event_id = rumor.id.ok_or("Rumor missing id")?.to_hex();
emit_wallpaper_removed(chat_npub, &me_npub, created_at, &event_id).await;
Ok(())
}