Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
46 commits
Select commit Hold shift + click to select a range
102f44d
upload progress API endpoints
oscartbeaumont Aug 18, 2025
4d999bd
avoid race conditions
oscartbeaumont Aug 18, 2025
0aa42a6
wip
oscartbeaumont Aug 18, 2025
b33c9e8
wip
oscartbeaumont Aug 18, 2025
a389e29
Merge branch 'main' into upload-progress
oscartbeaumont Aug 26, 2025
715b62e
wip
oscartbeaumont Aug 26, 2025
d9165ee
wip
oscartbeaumont Aug 26, 2025
1481f3c
cleanup
oscartbeaumont Aug 28, 2025
4c5fee2
Merge branch 'main' into upload-progress
oscartbeaumont Sep 3, 2025
1caecd5
Merge branch 'main' into upload-progress
oscartbeaumont Sep 8, 2025
c5f6e0b
Merge branch 'main' into upload-progress
oscartbeaumont Sep 8, 2025
f400cd4
Merge branch 'main' into upload-progress
oscartbeaumont Sep 10, 2025
5bd0d3c
progress circle in video while uploading placeholder
ameer2468 Sep 10, 2025
9442f3f
Merge branch 'main' into upload-progress
oscartbeaumont Sep 11, 2025
5306081
Merge branch 'main' into upload-progress-ameer
oscartbeaumont Sep 11, 2025
7a2b0a9
Merge branch 'upload-progress-ameer' into upload-progress
oscartbeaumont Sep 11, 2025
a36dd5b
we love effect
oscartbeaumont Sep 11, 2025
11a10b3
a bunch of reliability improvements
oscartbeaumont Sep 11, 2025
86ccf79
wip upload tracking code for web
oscartbeaumont Sep 11, 2025
324c76c
error ui
ameer2468 Sep 11, 2025
019f09c
moving upload progress to channel so it's scoped
oscartbeaumont Sep 14, 2025
06d4b16
bad upload circle
oscartbeaumont Sep 14, 2025
ceb10c0
cleanup
oscartbeaumont Sep 14, 2025
4c61317
cleanup
oscartbeaumont Sep 14, 2025
427e245
make upload progress code less cringe
oscartbeaumont Sep 14, 2025
c9b2ef5
format
oscartbeaumont Sep 14, 2025
e2d44ca
improvements
oscartbeaumont Sep 14, 2025
fcdb529
expo backoff
oscartbeaumont Sep 14, 2025
8b2c1a9
progress circle
ameer2468 Sep 14, 2025
5e184a0
disable progress requests if not pending
oscartbeaumont Sep 14, 2025
1570c7d
fix zindex
oscartbeaumont Sep 14, 2025
b3e8f2b
background
oscartbeaumont Sep 14, 2025
b75bbab
Merge branch 'upload-progress-ameer' into upload-progress
oscartbeaumont Sep 14, 2025
d6c4572
Clippyx
oscartbeaumont Sep 14, 2025
8e0acd9
cleanup AI slop
oscartbeaumont Sep 14, 2025
8fc4343
nit
oscartbeaumont Sep 14, 2025
8498a54
nits
oscartbeaumont Sep 14, 2025
3d6385c
fix
oscartbeaumont Sep 14, 2025
c91ce33
nit
oscartbeaumont Sep 14, 2025
02f9501
format
oscartbeaumont Sep 14, 2025
0a0de6d
fix progress sizing
ameer2468 Sep 14, 2025
a3050b3
Merge branch 'main' into upload-progress
oscartbeaumont Sep 19, 2025
c210134
a bunch of fixes
oscartbeaumont Sep 19, 2025
2451afc
fix upload progress + boolean DB types done properly
oscartbeaumont Sep 19, 2025
a4a9185
fix boolean logic
oscartbeaumont Sep 19, 2025
e470b08
fix `hasPassword` boolean logic
oscartbeaumont Sep 19, 2025
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
1,390 changes: 753 additions & 637 deletions Cargo.lock

Large diffs are not rendered by default.

24 changes: 12 additions & 12 deletions apps/desktop/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ use std::{
str::FromStr,
sync::Arc,
};
use tauri::{AppHandle, Manager, State, Window, WindowEvent};
use tauri::{AppHandle, Manager, State, Window, WindowEvent, ipc::Channel};
use tauri_plugin_deep_link::DeepLinkExt;
use tauri_plugin_dialog::DialogExt;
use tauri_plugin_global_shortcut::GlobalShortcutExt;
Expand Down Expand Up @@ -1034,7 +1034,7 @@ async fn list_audio_devices() -> Result<Vec<String>, ()> {
Ok(MicrophoneFeed::list().keys().cloned().collect())
}

#[derive(Serialize, Type, tauri_specta::Event, Debug, Clone)]
#[derive(Serialize, Type, Debug, Clone)]
pub struct UploadProgress {
progress: f64,
}
Expand All @@ -1053,6 +1053,7 @@ async fn upload_exported_video(
app: AppHandle,
path: PathBuf,
mode: UploadMode,
channel: Channel<UploadProgress>,
) -> Result<UploadResult, String> {
let Ok(Some(auth)) = AuthStore::get(&app) else {
AuthStore::set(&app, None).map_err(|e| e.to_string())?;
Expand All @@ -1074,7 +1075,7 @@ async fn upload_exported_video(
return Ok(UploadResult::UpgradeRequired);
}

UploadProgress { progress: 0.0 }.emit(&app).ok();
channel.send(UploadProgress { progress: 0.0 }).ok();

let s3_config = async {
let video_id = match mode {
Expand Down Expand Up @@ -1113,11 +1114,12 @@ async fn upload_exported_video(
Some(s3_config),
Some(meta.project_path.join("screenshots/display.jpg")),
Some(metadata),
Some(channel.clone()),
)
.await
{
Ok(uploaded_video) => {
UploadProgress { progress: 1.0 }.emit(&app).ok();
channel.send(UploadProgress { progress: 1.0 }).ok();

meta.sharing = Some(SharingMeta {
link: uploaded_video.link.clone(),
Expand Down Expand Up @@ -1938,7 +1940,6 @@ pub async fn run(recording_logging_handle: LoggingHandle) {
NewNotification,
AuthenticationInvalid,
audio_meter::AudioInputLevelChange,
UploadProgress,
captions::DownloadProgress,
recording::RecordingEvent,
RecordingDeleted,
Expand Down Expand Up @@ -2310,13 +2311,12 @@ pub async fn run(recording_logging_handle: LoggingHandle) {
}
}

if *focused {
if let Ok(window_id) = window_id
&& window_id.activates_dock()
{
app.set_activation_policy(tauri::ActivationPolicy::Regular)
.ok();
}
if *focused
&& let Ok(window_id) = window_id
&& window_id.activates_dock()
{
app.set_activation_policy(tauri::ActivationPolicy::Regular)
.ok();
}
}
WindowEvent::DragDrop(tauri::DragDropEvent::Drop { paths, .. }) => {
Expand Down
5 changes: 3 additions & 2 deletions apps/desktop/src-tauri/src/recording.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ use std::{path::PathBuf, str::FromStr, sync::Arc, time::Duration};
use tauri::{AppHandle, Manager};
use tauri_plugin_dialog::{DialogExt, MessageDialogBuilder};
use tauri_specta::Event;
use tracing::{error, info};
use tracing::{debug, error, info};

use crate::{
App, CurrentRecordingChanged, MutableState, NewStudioRecordingAdded, RecordingState,
Expand Down Expand Up @@ -355,7 +355,7 @@ pub async fn start_recording(
)
});

println!("spawning actor");
debug!("spawning start_recording actor");

// done in spawn to catch panics just in case
let spawn_actor_res = async {
Expand Down Expand Up @@ -812,6 +812,7 @@ async fn handle_recording_finish(
Some(video_upload_info.config.clone()),
Some(display_screenshot.clone()),
meta,
None,
)
.await
{
Expand Down
166 changes: 145 additions & 21 deletions apps/desktop/src-tauri/src/upload.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,22 +11,29 @@ use image::codecs::jpeg::JpegEncoder;
use reqwest::StatusCode;
use reqwest::header::CONTENT_LENGTH;
use serde::{Deserialize, Serialize};
use serde_json::json;
use specta::Type;
use std::path::PathBuf;
use std::time::Duration;
use tauri::AppHandle;
use std::{
path::PathBuf,
time::{Duration, Instant},
};
use tauri::{AppHandle, ipc::Channel};
use tauri_plugin_clipboard_manager::ClipboardExt;
use tauri_specta::Event;
use tokio::io::{AsyncReadExt, AsyncSeekExt};
use tokio::task;
use tokio::task::{self, JoinHandle};
use tokio::time::sleep;
use tracing::{error, info, warn};
use tracing::{debug, error, info, trace, warn};

#[derive(Deserialize, Serialize, Clone, Type, Debug)]
pub struct S3UploadMeta {
id: String,
}

#[derive(Deserialize, Clone, Debug)]
pub struct CreateErrorResponse {
error: String,
}

// fn deserialize_empty_object_as_string<'de, D>(deserializer: D) -> Result<String, D::Error>
// where
// D: Deserializer<'de>,
Expand Down Expand Up @@ -105,13 +112,107 @@ pub struct UploadedImage {
// pub config: S3UploadMeta,
// }

pub struct UploadProgressUpdater {
video_state: Option<VideoProgressState>,
app: AppHandle,
video_id: String,
}

struct VideoProgressState {
uploaded: u64,
total: u64,
pending_task: Option<JoinHandle<()>>,
last_update_time: Instant,
}

impl UploadProgressUpdater {
pub fn new(app: AppHandle, video_id: String) -> Self {
Self {
video_state: None,
app,
video_id,
}
}

pub fn update(&mut self, uploaded: u64, total: u64) {
let should_send_immediately = {
let state = self.video_state.get_or_insert_with(|| VideoProgressState {
uploaded,
total,
pending_task: None,
last_update_time: Instant::now(),
});

// Cancel any pending task
if let Some(handle) = state.pending_task.take() {
handle.abort();
}

state.uploaded = uploaded;
state.total = total;
state.last_update_time = Instant::now();

// Send immediately if upload is complete
uploaded >= total
};

let app = self.app.clone();
if should_send_immediately {
tokio::spawn({
let video_id = self.video_id.clone();
async move {
Self::send_api_update(&app, video_id, uploaded, total).await;
}
});

// Clear state since upload is complete
self.video_state = None;
} else {
// Schedule delayed update
let handle = {
let video_id = self.video_id.clone();
tokio::spawn(async move {
tokio::time::sleep(Duration::from_secs(2)).await;
Self::send_api_update(&app, video_id, uploaded, total).await;
})
};

if let Some(state) = &mut self.video_state {
state.pending_task = Some(handle);
}
}
}

async fn send_api_update(app: &AppHandle, video_id: String, uploaded: u64, total: u64) {
let response = app
.authed_api_request("/api/desktop/video/progress", |client, url| {
client.post(url).json(&json!({
"videoId": video_id,
"uploaded": uploaded,
"total": total,
"updatedAt": chrono::Utc::now().to_rfc3339()
}))
})
.await;

match response {
Ok(resp) if resp.status().is_success() => {
trace!("Progress update sent successfully");
}
Ok(resp) => error!("Failed to send progress update: {}", resp.status()),
Err(err) => error!("Failed to send progress update: {err}"),
}
}
}

pub async fn upload_video(
app: &AppHandle,
video_id: String,
file_path: PathBuf,
existing_config: Option<S3UploadMeta>,
screenshot_path: Option<PathBuf>,
meta: Option<S3VideoMeta>,
channel: Option<Channel<UploadProgress>>,
) -> Result<UploadedVideo, String> {
println!("Uploading video {video_id}...");

Expand Down Expand Up @@ -145,20 +246,24 @@ pub async fn upload_video(

let reader_stream = tokio_util::io::ReaderStream::new(file);

let mut bytes_uploaded = 0;
let progress_stream = reader_stream.inspect({
let app = app.clone();
move |chunk| {
if bytes_uploaded > 0 {
let _ = UploadProgress {
progress: bytes_uploaded as f64 / total_size as f64,
}
.emit(&app);
}
let mut bytes_uploaded = 0u64;
let mut progress = UploadProgressUpdater::new(app.clone(), video_id);

if let Ok(chunk) = chunk {
bytes_uploaded += chunk.len();
let progress_stream = reader_stream.inspect(move |chunk| {
if let Ok(chunk) = chunk {
bytes_uploaded += chunk.len() as u64;
}

if bytes_uploaded > 0 {
if let Some(channel) = &channel {
channel
.send(UploadProgress {
progress: bytes_uploaded as f64 / total_size as f64,
})
.ok();
}

progress.update(bytes_uploaded, total_size);
}
});

Expand Down Expand Up @@ -316,6 +421,21 @@ pub async fn create_or_get_video(
return Err("Failed to authenticate request; please log in again".into());
}

if response.status() != StatusCode::OK {
if let Ok(error) = response.json::<CreateErrorResponse>().await {
if error.error == "upgrade_required" {
return Err(
"You must upgrade to Cap Pro to upload recordings over 5 minutes in length"
.into(),
);
}

return Err(format!("server error: {}", error.error));
}

return Err("Unknown error uploading video".into());
}

let response_text = response
.text()
.await
Expand Down Expand Up @@ -544,13 +664,12 @@ impl InstantMultipartUpload {
let mut uploaded_parts = Vec::new();
let mut part_number = 1;
let mut last_uploaded_position: u64 = 0;

println!("Starting multipart upload for {video_id}...");
let mut progress = UploadProgressUpdater::new(app.clone(), pre_created_video.id.clone());

// --------------------------------------------
// initiate the multipart upload
// --------------------------------------------
println!("Initiating multipart upload for {video_id}...");
debug!("Initiating multipart upload for {video_id}...");
let initiate_response = match app
.authed_api_request("/api/upload/multipart/initiate", |c, url| {
c.post(url)
Expand Down Expand Up @@ -654,6 +773,7 @@ impl InstantMultipartUpload {
&mut part_number,
&mut last_uploaded_position,
new_data_size.min(CHUNK_SIZE),
&mut progress,
)
.await
{
Expand All @@ -680,6 +800,7 @@ impl InstantMultipartUpload {
&mut 1,
&mut 0,
uploaded_parts[0].size as u64,
&mut progress,
)
.await
.map_err(|err| format!("Failed to re-upload first chunk: {err}"))?;
Expand Down Expand Up @@ -726,6 +847,7 @@ impl InstantMultipartUpload {
part_number: &mut i32,
last_uploaded_position: &mut u64,
chunk_size: u64,
progress: &mut UploadProgressUpdater,
) -> Result<UploadedPart, String> {
let file_size = match tokio::fs::metadata(file_path).await {
Ok(metadata) => metadata.len(),
Expand Down Expand Up @@ -838,6 +960,8 @@ impl InstantMultipartUpload {
}
};

progress.update(expected_pos, file_size);

if !presign_response.status().is_success() {
let status = presign_response.status();
let error_body = presign_response
Expand Down
Loading
Loading