Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
d9bd50e
Defer desktop media uploads until send
klopez4212 Aug 3, 2026
2aae111
Keep upload progress clear of latest control
klopez4212 Aug 3, 2026
60022c5
Show desktop upload feedback immediately
klopez4212 Aug 3, 2026
96fa445
Polish desktop upload progress colors
klopez4212 Aug 3, 2026
f435c57
Show desktop media upload phases
klopez4212 Aug 3, 2026
891fc43
Shorten desktop upload phase labels
klopez4212 Aug 3, 2026
5697f36
Space desktop upload progress label
klopez4212 Aug 3, 2026
d47e8d6
Show spinner before desktop media transfer
klopez4212 Aug 3, 2026
4c579d4
Address deferred upload review feedback
klopez4212 Aug 3, 2026
b2099d8
Merge remote-tracking branch 'origin/main' into kennylopez-desktop-ba…
klopez4212 Aug 3, 2026
6f1b3e6
Prevent cancelling deferred publish
klopez4212 Aug 3, 2026
f23e813
Address deferred upload follow-up
klopez4212 Aug 3, 2026
3de2e6c
Merge remote-tracking branch 'origin/main' into kennylopez-desktop-ba…
klopez4212 Aug 3, 2026
1dddbec
Harden deferred upload lifecycle
klopez4212 Aug 3, 2026
3f9fd46
Preserve deferred upload drafts on cancellation
klopez4212 Aug 3, 2026
b63f42b
Merge remote-tracking branch 'origin/main' into kennylopez-desktop-ba…
klopez4212 Aug 3, 2026
5dbe717
Fix deferred composer locking
klopez4212 Aug 3, 2026
d2145a6
Cancel queued HEIC transcoding
klopez4212 Aug 3, 2026
28c0cf2
Merge remote-tracking branch 'origin/main' into kennylopez-desktop-ba…
klopez4212 Aug 3, 2026
8a652f9
Recover canceled background uploads
klopez4212 Aug 3, 2026
7143d97
Clean up canceled transcodes
klopez4212 Aug 3, 2026
0d667a5
Preserve deferred send recovery context
klopez4212 Aug 3, 2026
f227cbb
Merge remote-tracking branch 'origin/main' into kennylopez-desktop-ba…
klopez4212 Aug 3, 2026
279294e
Recover failed composer mention state
klopez4212 Aug 3, 2026
23e875a
Merge origin/main into deferred desktop uploads
wesbillman Aug 4, 2026
ea1ccc2
fix(desktop): retain deferred draft attachments
wesbillman Aug 4, 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
148 changes: 66 additions & 82 deletions desktop/src-tauri/src/commands/media.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,17 @@ use nostr::{EventBuilder, JsonUtil, Keys, Kind, Tag, Timestamp};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use tauri::State;
use tokio_util::sync::CancellationToken;

use crate::app_state::AppState;
use crate::relay::{
classify_request_error, parse_json_response, relay_api_base_url_with_override,
relay_error_message,
};
use crate::relay::{parse_json_response, relay_api_base_url_with_override, relay_error_message};

use super::media_transcode::{
has_heic_extension, is_heic_file, is_video_file, transcode_and_extract_poster,
transcode_heic_path_to_jpeg_bytes,
transcode_and_extract_poster_with_cancellation, transcode_heic_path_to_jpeg_bytes,
transcode_heic_path_to_jpeg_bytes_with_cancellation,
};
use super::media_upload_progress::{emit_media_upload_phase, send_upload_attempt, UploadAttempt};

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BlobDescriptor {
Expand Down Expand Up @@ -410,51 +410,6 @@ fn should_retry_legacy_upload(status: reqwest::StatusCode) -> bool {
)
}

async fn send_upload_attempt(
state: &AppState,
url: String,
auth_header: &str,
mime: &str,
sha256: &str,
body: bytes::Bytes,
progress: Option<&(tauri::AppHandle, String)>,
) -> Result<reqwest::Response, String> {
let req = state
.http_client
.put(url)
.header("Authorization", auth_header)
.header("Content-Type", mime)
.header("X-SHA-256", sha256);

let response = if let Some((app, progress_id)) = progress {
use tauri::Emitter;
let app = app.clone();
let progress_id = progress_id.clone();
let total = body.len() as u64;
let chunk_size = 64 * 1024;
let chunk_count = body.len().div_ceil(chunk_size);
let mut sent: u64 = 0;
let stream = futures_util::stream::iter((0..chunk_count).map(move |i| {
let start = i * chunk_size;
let end = usize::min(start + chunk_size, body.len());
let chunk = body.slice(start..end);
sent += chunk.len() as u64;
let _ = app.emit(
"media-upload-progress",
serde_json::json!({ "id": progress_id, "sent": sent, "total": total }),
);
Ok::<bytes::Bytes, std::io::Error>(chunk)
}));
req.header(reqwest::header::CONTENT_LENGTH, total)
.body(reqwest::Body::wrap_stream(stream))
.send()
.await
} else {
req.body(body).send().await
};
response.map_err(|error| classify_request_error(&error))
}

pub(crate) async fn upload_image_bytes(
body: Vec<u8>,
state: &AppState,
Expand All @@ -464,14 +419,15 @@ pub(crate) async fn upload_image_bytes(
return Err("profile avatar must be an image".to_string());
}
let body = sanitize_image_for_upload(body, &mime)?;
do_upload(body, &mime, state, None).await
do_upload(body, &mime, state, None, None).await
}

async fn do_upload(
body: Vec<u8>,
mime: &str,
state: &AppState,
progress: Option<(tauri::AppHandle, String)>,
cancellation: Option<&CancellationToken>,
) -> Result<BlobDescriptor, String> {
let sha256 = hex::encode(Sha256::digest(&body));

Expand All @@ -494,25 +450,34 @@ async fn do_upload(
URL_SAFE_NO_PAD.encode(auth_event.as_json().as_bytes())
);
let body = bytes::Bytes::from(body);
if let Some((app, progress_id)) = progress.as_ref() {
emit_media_upload_phase(app, Some(progress_id.as_str()), "uploading");
}
let mut resp = send_upload_attempt(
state,
format!("{base_url}/upload"),
&auth_header,
mime,
&sha256,
body.clone(),
progress.as_ref(),
UploadAttempt {
url: format!("{base_url}/upload"),
auth_header: &auth_header,
mime,
sha256: &sha256,
body: body.clone(),
progress: progress.as_ref(),
cancellation,
},
)
.await?;
if should_retry_legacy_upload(resp.status()) {
resp = send_upload_attempt(
state,
format!("{base_url}/media/upload"),
&auth_header,
mime,
&sha256,
body,
progress.as_ref(),
UploadAttempt {
url: format!("{base_url}/media/upload"),
auth_header: &auth_header,
mime,
sha256: &sha256,
body,
progress: progress.as_ref(),
cancellation,
},
)
.await?;
}
Expand Down Expand Up @@ -559,7 +524,7 @@ pub async fn upload_media(

let mime = detect_and_validate_mime(&body)?;
let body = sanitize_image_for_upload(body, &mime)?;
do_upload(body, &mime, &state, None).await
do_upload(body, &mime, &state, None, None).await
}

/// Read a picked path through the TOCTOU-safe pipeline (fd pin → sniff →
Expand All @@ -573,6 +538,7 @@ async fn process_picked_path(
path: std::path::PathBuf,
state: &AppState,
images_only: bool,
progress: Option<(tauri::AppHandle, String)>,
) -> Result<BlobDescriptor, String> {
// Pin the inode by opening the fd BEFORE spawn_blocking. This prevents a
// local attacker from swapping the file between dialog return and read.
Expand Down Expand Up @@ -639,10 +605,9 @@ async fn process_picked_path(

// Upload video first, then poster (best-effort). If poster upload fails,
// the video descriptor is returned without an image field.
let mut descriptor = do_upload(body, &mime, state, None).await?;

let mut descriptor = do_upload(body, &mime, state, progress, None).await?;
if let Some(poster) = poster_bytes {
match do_upload(poster, "image/jpeg", state, None).await {
match do_upload(poster, "image/jpeg", state, None, None).await {
Ok(poster_desc) => descriptor.image = Some(poster_desc.url),
Err(e) => eprintln!("buzz-desktop: poster upload failed (non-fatal): {e}"),
}
Expand Down Expand Up @@ -675,6 +640,7 @@ async fn process_picked_path(
#[tauri::command]
pub async fn pick_and_upload_media(
app: tauri::AppHandle,
progress_id: Option<String>,
state: State<'_, AppState>,
) -> Result<Vec<BlobDescriptor>, String> {
use tauri_plugin_dialog::DialogExt;
Expand All @@ -694,7 +660,8 @@ pub async fn pick_and_upload_media(
let mut descriptors = Vec::with_capacity(file_paths.len());
for file_path in file_paths {
let path = file_path.as_path().ok_or("invalid path")?.to_path_buf();
let descriptor = process_picked_path(path, &state, false).await?;
let progress = progress_id.clone().map(|id| (app.clone(), id));
let descriptor = process_picked_path(path, &state, false, progress).await?;
descriptors.push(descriptor);
}

Expand Down Expand Up @@ -735,56 +702,69 @@ pub async fn pick_and_upload_image(
};

let path = file_path.as_path().ok_or("invalid path")?.to_path_buf();
let descriptor = process_picked_path(path, &state, true).await?;
let descriptor = process_picked_path(path, &state, true, None).await?;
Ok(Some(descriptor))
}

/// Upload raw bytes directly (for paste and drag-drop).
///
/// The renderer already has the bytes in memory from the clipboard/drag event.
/// If the bytes are a video, they're written to a temp file, transcoded via
/// ffmpeg, and the transcoded output is uploaded instead.
#[tauri::command]
pub async fn upload_media_bytes(
pub(super) async fn upload_media_bytes_inner(
data: Vec<u8>,
filename: Option<String>,
progress_id: Option<String>,
app: tauri::AppHandle,
state: State<'_, AppState>,
cancellation: Option<&CancellationToken>,
) -> Result<BlobDescriptor, String> {
if data.is_empty() {
return Err("empty upload".to_string());
}

if cancellation.is_some_and(CancellationToken::is_cancelled) {
return Err("upload cancelled".to_string());
}

emit_media_upload_phase(&app, progress_id.as_deref(), "preparing");

let heic_by_extension = filename
.as_deref()
.is_some_and(|name| has_heic_extension(std::path::Path::new(name)));

let (body, poster_bytes) = if is_video_file(&data) {
emit_media_upload_phase(&app, progress_id.as_deref(), "processing-video");
// Video: write to temp → transcode + extract poster → read results.
// All blocking I/O runs off the async runtime via spawn_blocking.
let cancellation = cancellation.cloned();
tokio::task::spawn_blocking(move || -> Result<(Vec<u8>, Option<Vec<u8>>), String> {
Comment thread
klopez4212 marked this conversation as resolved.
let tmp_input =
std::env::temp_dir().join(format!("buzz-drop-{}", uuid::Uuid::new_v4()));
// Cleanup guard: remove temp file on ALL exit paths (including write failure).
let result = (|| {
std::fs::write(&tmp_input, &data)
.map_err(|e| format!("failed to write temp file: {e}"))?;
transcode_and_extract_poster(&tmp_input)
transcode_and_extract_poster_with_cancellation(&tmp_input, cancellation.as_ref())
})();
let _ = std::fs::remove_file(&tmp_input);
result
})
.await
.map_err(|e| format!("transcode task failed: {e}"))??
} else if is_heic_file(&data) {
} else if is_heic_file(&data) || heic_by_extension {
emit_media_upload_phase(&app, progress_id.as_deref(), "converting-image");
// HEIC/HEIF still pasted/dropped: no filename here, so detection is
// magic-bytes only. ffmpeg needs a path, so write to temp, transcode
// to JPEG, and clean up. (Mirrors mobile's pre-upload transcode.)
let cancellation = cancellation.cloned();
tokio::task::spawn_blocking(move || -> Result<(Vec<u8>, Option<Vec<u8>>), String> {
let tmp_input =
std::env::temp_dir().join(format!("buzz-drop-{}", uuid::Uuid::new_v4()));
// Cleanup guard: remove temp file on ALL exit paths (including write failure).
let result = (|| {
std::fs::write(&tmp_input, &data)
.map_err(|e| format!("failed to write temp file: {e}"))?;
transcode_heic_path_to_jpeg_bytes(&tmp_input).map(|jpeg| (jpeg, None))
transcode_heic_path_to_jpeg_bytes_with_cancellation(
&tmp_input,
cancellation.as_ref(),
)
.map(|jpeg| (jpeg, None))
})();
let _ = std::fs::remove_file(&tmp_input);
result
Expand All @@ -799,11 +779,15 @@ pub async fn upload_media_bytes(
let body = sanitize_image_for_upload(body, &mime)?;

// Upload video first, then poster (best-effort).
let progress = progress_id.map(|id| (app, id));
let mut descriptor = do_upload(body, &mime, &state, progress).await?;
let progress = progress_id.as_ref().map(|id| (app.clone(), id.clone()));
if cancellation.is_some_and(CancellationToken::is_cancelled) {
return Err("upload cancelled".to_string());
}
let mut descriptor = do_upload(body, &mime, &state, progress, cancellation).await?;

emit_media_upload_phase(&app, progress_id.as_deref(), "finishing");
if let Some(poster) = poster_bytes {
match do_upload(poster, "image/jpeg", &state, None).await {
match do_upload(poster, "image/jpeg", &state, None, cancellation).await {
Ok(poster_desc) => descriptor.image = Some(poster_desc.url),
Err(e) => eprintln!("buzz-desktop: poster upload failed (non-fatal): {e}"),
}
Expand Down
96 changes: 96 additions & 0 deletions desktop/src-tauri/src/commands/media_raw.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
use tauri::{
ipc::{InvokeBody, Request},
State,
};

use crate::app_state::AppState;

use super::{
media::{upload_media_bytes_inner, BlobDescriptor},
media_upload_progress::{
begin_media_upload, cancel_media_upload as cancel_registered_media_upload,
finish_media_upload,
},
};

/// Upload raw bytes directly (for paste and drag-drop).
///
/// The renderer already has the bytes in memory from the clipboard/drag event.
/// If the bytes are a video, they're written to a temp file, transcoded via
/// ffmpeg, and the transcoded output is uploaded instead.
#[tauri::command]
pub async fn upload_media_bytes(
data: Vec<u8>,
filename: Option<String>,
progress_id: Option<String>,
app: tauri::AppHandle,
state: State<'_, AppState>,
) -> Result<BlobDescriptor, String> {
upload_media_bytes_inner(data, filename, progress_id, app, state, None).await
}

fn decode_raw_upload_header(value: &str) -> Result<String, String> {
let bytes = URL_SAFE_NO_PAD
.decode(value)
.map_err(|error| format!("invalid raw upload header: {error}"))?;
String::from_utf8(bytes).map_err(|error| format!("invalid raw upload header text: {error}"))
}

fn optional_raw_upload_header(request: &Request<'_>, name: &str) -> Result<Option<String>, String> {
request
.headers()
.get(name)
.map(|value| {
value
.to_str()
.map_err(|error| format!("invalid {name} header: {error}"))
.and_then(decode_raw_upload_header)
})
.transpose()
}

/// Cancel the native upload associated with a background progress ID.
#[tauri::command]
pub fn cancel_media_upload(progress_id: String) {
Comment thread
klopez4212 marked this conversation as resolved.
cancel_registered_media_upload(&progress_id);
}

/// Upload raw IPC bytes without expanding a large browser File into JSON.
#[tauri::command]
pub async fn upload_media_bytes_raw(
request: Request<'_>,
app: tauri::AppHandle,
state: State<'_, AppState>,
) -> Result<BlobDescriptor, String> {
let data = match request.body() {
InvokeBody::Raw(data) => data.clone(),
InvokeBody::Json(_) => return Err("raw upload requires a byte body".to_string()),
};
let filename = optional_raw_upload_header(&request, "x-buzz-filename")?;
let progress_id = optional_raw_upload_header(&request, "x-buzz-progress-id")?;

let cancellation = begin_media_upload(progress_id.as_deref());
let result = upload_media_bytes_inner(
data,
filename,
progress_id.clone(),
app,
state,
cancellation.as_ref(),
)
.await;
finish_media_upload(progress_id.as_deref());
result
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn test_decode_raw_upload_header_preserves_unicode() {
let encoded = URL_SAFE_NO_PAD.encode("clip 🎬.mp4");
assert_eq!(decode_raw_upload_header(&encoded).unwrap(), "clip 🎬.mp4");
}
}
Loading
Loading