Skip to content
Merged
Show file tree
Hide file tree
Changes from 49 commits
Commits
Show all changes
71 commits
Select commit Hold shift + click to select a range
25a5e50
feat(web-domain): add Storage schemas and upload target models
richiemcilroy May 7, 2026
57b5395
feat(web-domain): attach storage integration metadata to Video
richiemcilroy May 7, 2026
b0b2594
feat(database): add storage integrations and object metadata tables
richiemcilroy May 7, 2026
b1ab930
feat(web-backend): add Storage Effect service for S3 and Google Drive
richiemcilroy May 7, 2026
a35d7ad
chore(web-backend): export storage integration helpers from package
richiemcilroy May 7, 2026
0def2ca
feat(web-backend): map storage integration ids in VideosRepo
richiemcilroy May 7, 2026
d5e694b
feat(web-backend): route video storage through unified Storage service
richiemcilroy May 7, 2026
a53eead
refactor(web-backend): surface StorageError in Videos RPC mapping
richiemcilroy May 7, 2026
41da9a9
fix(loom): default storage integration on imported videos
richiemcilroy May 7, 2026
fb119f5
feat(api-contract): add desktop storage integration RPC shapes
richiemcilroy May 7, 2026
ba5eec6
feat(web): add database video decoder for storage-aware domain
richiemcilroy May 7, 2026
f304b24
feat(web): add Google Drive storage quota cache helpers
richiemcilroy May 7, 2026
6863e85
feat(web): add upload-target client for Drive resumable uploads
richiemcilroy May 7, 2026
021fdc1
feat(web): register Storage layer in ManagedRuntime dependencies
richiemcilroy May 7, 2026
b7f75d1
feat(web api): add authenticated Google Drive object streaming route
richiemcilroy May 7, 2026
966bb17
feat(web api): add desktop Google Drive storage integration routes
richiemcilroy May 7, 2026
276b432
feat(web api): wire desktop uploads to Storage and quota invalidation
richiemcilroy May 7, 2026
65fac13
feat(web api): route multipart uploads through Storage access layer
richiemcilroy May 7, 2026
9097eb3
feat(web api): align playlist thumbnail and webhook paths with Storage
richiemcilroy May 7, 2026
e6a5c3f
feat(actions): resolve video operations against Storage backends
richiemcilroy May 7, 2026
af94f6c
feat(workflows): propagate storage integration across video jobs
richiemcilroy May 7, 2026
2259a56
chore(web): regenerate workflow automation manifest snapshot
richiemcilroy May 7, 2026
9983a6b
feat(web): teach web recorder uploads about Drive targets
richiemcilroy May 7, 2026
4fff0d1
feat(import): unify file imports with Storage upload helpers
richiemcilroy May 7, 2026
fda9fb5
feat(web): hydrate Google Drive quota cache in dashboard providers
richiemcilroy May 7, 2026
070aa22
feat(web): adjust share and embed pages for storage metadata
richiemcilroy May 7, 2026
03c1849
chore(web homepage): remove legacy Features section component
richiemcilroy May 7, 2026
53f6228
feat(web homepage): add bento panel illustrations
richiemcilroy May 7, 2026
3c7b2b0
feat(web homepage): ship Bento marketing layout and refreshed copy
richiemcilroy May 7, 2026
f5017b2
fix(media-server): probe Drive-hosted FFmpeg inputs reliably
richiemcilroy May 7, 2026
56ea266
feat(desktop): honor server upload targets for Drive recordings
richiemcilroy May 7, 2026
7c2c3a3
feat(desktop): add Google Drive storage settings flows
richiemcilroy May 7, 2026
f437de3
test(web): align coverage with Drive storage uploads
richiemcilroy May 7, 2026
a571e8c
feat(database): add Google Drive token fields and storage FKs
richiemcilroy May 7, 2026
fec866e
feat(web-backend): persist Google Drive access token in StorageRepo
richiemcilroy May 7, 2026
81597da
fix(web): escape HTML in Google Drive OAuth callback pages
richiemcilroy May 7, 2026
036fe0e
feat(web-backend): add Google Drive token refresh lease and request c…
richiemcilroy May 7, 2026
a57b9e5
feat(web): clear Google Drive token cache on connect and disconnect
richiemcilroy May 7, 2026
bcfd28e
feat(web-backend): wire GoogleDriveTokenStore into Drive storage access
richiemcilroy May 7, 2026
a23d814
feat(desktop): parse provider from multipart initiate API response
richiemcilroy May 7, 2026
f63032f
feat(desktop): detect Drive multipart uploads via provider flag
richiemcilroy May 7, 2026
7c7a852
chore(web): reformat workflow v1 manifest
richiemcilroy May 7, 2026
e24de0d
fix(web): escape HTML in Google Drive OAuth callback pages
richiemcilroy May 7, 2026
0ccddbe
feat(web): clear Google Drive token cache on connect and disconnect
richiemcilroy May 7, 2026
5b5552c
feat(desktop): parse provider from multipart initiate API response
richiemcilroy May 7, 2026
3883b29
feat(desktop): detect Drive multipart uploads via provider flag
richiemcilroy May 7, 2026
c4975da
chore(web): reformat workflow v1 manifest
richiemcilroy May 7, 2026
65b3d87
Merge branch 'google-integration' of https://github.com/CapSoftware/C…
richiemcilroy May 7, 2026
eae3b1a
Refactor storage/decoding and handle Drive not connected
richiemcilroy May 7, 2026
e6b2e51
feat(database): add googleDriveStorageQuotaCache column
richiemcilroy May 7, 2026
4532342
refactor(storage): drop quota cache field from Drive config type
richiemcilroy May 7, 2026
46abf6b
refactor(storage): expose getS3WritableAccessForUser helper
richiemcilroy May 7, 2026
9cb4c0e
feat(web): detect desktop feature flags from request headers
richiemcilroy May 7, 2026
dafb054
feat(desktop): advertise googleDriveUpload in API request headers
richiemcilroy May 7, 2026
c522e40
feat(web): gate uploads on Drive support vs S3 writable access
richiemcilroy May 7, 2026
71ca82e
refactor(web): persist Drive quota cache in integration column
richiemcilroy May 7, 2026
8fdecfc
fix(web): clear Drive quota cache on OAuth disconnect flows
richiemcilroy May 7, 2026
00efb5f
chore(web): refresh workflow manifest step ordering
richiemcilroy May 7, 2026
5414acc
feat(web-backend): add Drive lookup by cap object key
richiemcilroy May 7, 2026
8aec215
feat(web-backend): recover stale Drive IDs on storage reads
richiemcilroy May 7, 2026
438d6d3
fix(api): distinguish 404 vs 502 in storage object proxy
richiemcilroy May 7, 2026
ff3dc16
feat(api): expose upload targets in batch signed URLs
richiemcilroy May 7, 2026
8c6c99e
fix(web-recorder): set byte total when finalizing Drive streams
richiemcilroy May 7, 2026
c9529f9
test(web): cover Drive chunked finalize Content-Range
richiemcilroy May 7, 2026
9b39856
fix(share): defer segment playback with shared helper
richiemcilroy May 7, 2026
1eeaf13
chore(web): refresh workflow bundle manifest
richiemcilroy May 7, 2026
4c4a030
fix(web-backend): return null on invalid storage object token JSON
richiemcilroy May 7, 2026
af9ad47
fix(web-backend): complete Google Drive multipart with explicit objec…
richiemcilroy May 7, 2026
d8bb36a
fix(web): treat Google Drive final chunk 308 as incomplete upload
richiemcilroy May 7, 2026
bc21c71
fix(web): reject upload URL reuse when video owner differs
richiemcilroy May 7, 2026
a139aea
fix(web): map storage proxy errors and handle failed effects
richiemcilroy May 7, 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
18 changes: 9 additions & 9 deletions apps/desktop/src-tauri/src/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,17 +9,18 @@ use tracing::{instrument, trace};

use crate::web_api::{AuthedApiError, ManagerExt};

#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MultipartUploadInitiateResponse {
pub upload_id: String,
pub provider: Option<String>,
}

#[instrument(skip(app))]
pub async fn upload_multipart_initiate(
app: &AppHandle,
video_id: &str,
) -> Result<String, AuthedApiError> {
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Response {
upload_id: String,
}

) -> Result<MultipartUploadInitiateResponse, AuthedApiError> {
let resp = app
.authed_api_request("/api/upload/multipart/initiate", |c, url| {
c.post(url)
Expand All @@ -41,10 +42,9 @@ pub async fn upload_multipart_initiate(
return Err(format!("api/upload_multipart_initiate/{status}: {error_body}").into());
}

resp.json::<Response>()
resp.json::<MultipartUploadInitiateResponse>()
.await
.map_err(|err| format!("api/upload_multipart_initiate/response: {err}").into())
.map(|data| data.upload_id)
}

#[instrument(skip(app, upload_id))]
Expand Down
192 changes: 170 additions & 22 deletions apps/desktop/src-tauri/src/upload.rs
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,51 @@ const NETWORK_RECOVERY_TIMEOUT: Duration = Duration::from_secs(5 * 60);
const CONNECTIVITY_PROBE_INITIAL_DELAY: Duration = Duration::from_secs(2);
const CONNECTIVITY_PROBE_MAX_DELAY: Duration = Duration::from_secs(30);

fn is_google_drive_resumable_url(url: &str) -> bool {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

contains here can mis-detect non-Google hosts that happen to include the substring in the URL. Parsing + checking host/path keeps this strict and avoids false positives.

Suggested change
fn is_google_drive_resumable_url(url: &str) -> bool {
fn is_google_drive_resumable_url(url: &str) -> bool {
let Ok(url) = reqwest::Url::parse(url) else {
return false;
};
url.host_str().is_some_and(|host| host.ends_with("googleapis.com"))
&& url.path().starts_with("/upload/drive/")
}

let Ok(url) = reqwest::Url::parse(url) else {
return false;
};
url.host_str().is_some_and(|host| {
(host == "googleapis.com" || host.ends_with(".googleapis.com"))
&& url.path().starts_with("/upload/drive/")
})
}

fn is_google_drive_upload(provider: Option<&str>, upload_id: &str) -> bool {
provider == Some("googleDrive") || is_google_drive_resumable_url(upload_id)
}

fn with_drive_content_range(
request: reqwest::RequestBuilder,
url: &str,
offset: u64,
size: u64,
total_size: u64,
) -> reqwest::RequestBuilder {
if !is_google_drive_resumable_url(url) || size == 0 {
return request;
}

let end = offset.saturating_add(size).saturating_sub(1);
request.header(
"Content-Range",
format!("bytes {offset}-{end}/{total_size}"),
)
}

fn is_upload_response_accepted(
url: &str,
status: StatusCode,
offset: u64,
size: u64,
total_size: u64,
) -> bool {
status.is_success()
|| (is_google_drive_resumable_url(url)
&& status == StatusCode::PERMANENT_REDIRECT
&& offset.saturating_add(size) < total_size)
}

#[instrument(skip(app, channel, file_path, screenshot_path))]
pub async fn upload_video(
app: &AppHandle,
Expand All @@ -72,7 +117,9 @@ pub async fn upload_video(
info!("Uploading video {video_id}...");

let start = Instant::now();
let upload_id = api::upload_multipart_initiate(app, &video_id).await?;
let upload = api::upload_multipart_initiate(app, &video_id).await?;
let is_drive_upload = is_google_drive_upload(upload.provider.as_deref(), &upload.upload_id);
let upload_id = upload.upload_id;

let video_fut = async {
let failed_chunks: Arc<Mutex<Vec<FailedChunkInfo>>> = Arc::new(Mutex::new(Vec::new()));
Expand All @@ -84,6 +131,7 @@ pub async fn upload_video(
app.clone(),
video_id.clone(),
upload_id.clone(),
is_drive_upload,
from_pending_file_to_chunks(file_path.clone(), None),
failed_chunks.clone(),
),
Expand Down Expand Up @@ -480,7 +528,9 @@ impl InstantMultipartUpload {
.map_err(|e| error!("Failed to save recording meta: {e}"))
.ok();

let upload_id = api::upload_multipart_initiate(&app, &video_id).await?;
let upload = api::upload_multipart_initiate(&app, &video_id).await?;
let is_drive_upload = is_google_drive_upload(upload.provider.as_deref(), &upload.upload_id);
let upload_id = upload.upload_id;

let failed_chunks: Arc<Mutex<Vec<FailedChunkInfo>>> = Arc::new(Mutex::new(Vec::new()));

Expand All @@ -491,6 +541,7 @@ impl InstantMultipartUpload {
app.clone(),
video_id.clone(),
upload_id.clone(),
is_drive_upload,
from_pending_file_to_chunks(file_path.clone(), realtime_video_done),
failed_chunks.clone(),
),
Expand Down Expand Up @@ -656,6 +707,12 @@ struct SegmentUploadManifest {
is_complete: bool,
}

impl SegmentUploadManifest {
fn has_video_content(&self) -> bool {
self.video_init_uploaded && !self.video_segments.is_empty()
}
}

struct PresignedUrlCache {
urls: tokio::sync::Mutex<HashMap<String, String>>,
}
Expand Down Expand Up @@ -1394,6 +1451,23 @@ impl SegmentUploader {
.lock()
.unwrap_or_else(|e| e.into_inner())
.to_complete_manifest();
if !final_manifest.has_video_content() {
let error = format!("Segment upload completed without video segments for {video_id}");
error!(video_id, "Segment upload completed without video segments");

if let Ok(mut meta) = RecordingMeta::load_for_project(&recording_dir) {
meta.upload = Some(UploadMeta::Failed {
error: error.clone(),
});
if let Err(err) = meta.save_for_project() {
warn!("Failed to save failed segment upload metadata: {err}");
}
}

emit_upload_complete(&app, &video_id);

return Err(error.into());
}
Self::upload_manifest(&app, &video_id, &final_manifest).await?;

{
Expand Down Expand Up @@ -1633,6 +1707,7 @@ fn multipart_uploader(
app: AppHandle,
video_id: String,
upload_id: String,
is_drive_upload: bool,
stream: impl Stream<Item = io::Result<Chunk>> + Send + 'static,
failed_chunks: Arc<Mutex<Vec<FailedChunkInfo>>>,
) -> impl Stream<Item = Result<UploadedPart, AuthedApiError>> + 'static {
Expand All @@ -1644,6 +1719,11 @@ fn multipart_uploader(

stream::once(async move {
let use_md5_hashes = app.is_server_url_custom().await;
let max_concurrent_uploads = if is_drive_upload {
1
} else {
MAX_CONCURRENT_UPLOADS
};
let first_chunk_presigned_url = Arc::new(Mutex::new(None::<(String, Instant)>));

stream::unfold(
Expand Down Expand Up @@ -1747,11 +1827,18 @@ fn multipart_uploader(
})?
.clone();

let mut req = client
let req = client
.put(&presigned_url)
.header("Content-Length", size)
.timeout(Duration::from_secs(5 * 60))
.body(chunk);
let mut req = with_drive_content_range(
req,
&presigned_url,
offset,
size as u64,
total_size,
);

if let Some(md5_sum) = &md5_sum {
req = req.header("Content-MD5", md5_sum);
Expand Down Expand Up @@ -1784,11 +1871,18 @@ fn multipart_uploader(
md5_sum.as_deref(),
)
.await?;
let mut retry_req = client
let retry_req = client
.put(&retry_url)
.header("Content-Length", size)
.timeout(Duration::from_secs(5 * 60))
.body(chunk_for_retry);
let mut retry_req = with_drive_content_range(
retry_req,
&retry_url,
offset,
size as u64,
total_size,
);
if let Some(md5_sum) = &md5_sum {
retry_req =
retry_req.header("Content-MD5", md5_sum);
Expand Down Expand Up @@ -1821,7 +1915,13 @@ fn multipart_uploader(
.and_then(|etag| etag.to_str().ok())
.map(|v| v.trim_matches('"').to_string());

match !resp.status().is_success() {
match !is_upload_response_accepted(
&presigned_url,
resp.status(),
offset,
size as u64,
total_size,
) {
true => Err(format!(
"uploader/part/{part_number}/error: {}",
resp.text().await.unwrap_or_default()
Expand All @@ -1831,12 +1931,21 @@ fn multipart_uploader(

trace!("Completed upload of part {part_number}");

Ok::<_, AuthedApiError>(UploadedPart {
etag: etag.ok_or_else(|| {
format!(
"uploader/part/{part_number}/error: ETag header not found"
let etag = match etag {
Some(etag) => etag,
None if is_google_drive_resumable_url(&presigned_url) => {
format!("drive-{part_number}")
}
None => {
return Err(format!(
"uploader/part/{part_number}/missing_etag"
)
})?,
.into());
}
};

Ok::<_, AuthedApiError>(UploadedPart {
etag,
part_number,
size,
total_size,
Expand Down Expand Up @@ -1874,7 +1983,7 @@ fn multipart_uploader(
}
},
)
.buffered(MAX_CONCURRENT_UPLOADS)
.buffered(max_concurrent_uploads)
.filter_map(|item| async { item })
.boxed()
})
Expand Down Expand Up @@ -1948,11 +2057,18 @@ async fn retry_failed_chunks(
.map_err(|err| format!("retry/part/{}/client: {err:?}", failed.part_number))?
.clone();

let mut req = client
let req = client
.put(&presigned_url)
.header("Content-Length", size)
.timeout(Duration::from_secs(5 * 60))
.body(chunk);
let mut req = with_drive_content_range(
req,
&presigned_url,
failed.offset,
size as u64,
failed.total_size,
);

if let Some(md5_sum) = &md5_sum {
req = req.header("Content-MD5", md5_sum);
Expand Down Expand Up @@ -1990,11 +2106,18 @@ async fn retry_failed_chunks(
md5_sum.as_deref(),
)
.await?;
let mut retry_req = client
let retry_req = client
.put(&retry_url)
.header("Content-Length", size)
.timeout(Duration::from_secs(5 * 60))
.body(chunk_for_retry);
let mut retry_req = with_drive_content_range(
retry_req,
&retry_url,
failed.offset,
size as u64,
failed.total_size,
);
if let Some(md5_sum) = &md5_sum {
retry_req = retry_req.header("Content-MD5", md5_sum);
}
Expand Down Expand Up @@ -2025,7 +2148,13 @@ async fn retry_failed_chunks(
.and_then(|etag| etag.to_str().ok())
.map(|v| v.trim_matches('"').to_string());

if !resp.status().is_success() {
if !is_upload_response_accepted(
&presigned_url,
resp.status(),
failed.offset,
size as u64,
failed.total_size,
) {
return Err(format!(
"retry/part/{}/error: {}",
failed.part_number,
Expand All @@ -2039,13 +2168,18 @@ async fn retry_failed_chunks(
"Successfully retried chunk upload"
);

let etag = match etag {
Some(etag) => etag,
None if is_google_drive_resumable_url(&presigned_url) => {
format!("drive-{}", failed.part_number)
}
None => {
return Err(format!("retry/part/{}/missing_etag", failed.part_number).into());
}
};

retry_parts.push(UploadedPart {
etag: etag.ok_or_else(|| {
format!(
"retry/part/{}/error: ETag header not found",
failed.part_number
)
})?,
etag,
part_number: failed.part_number,
size,
total_size: failed.total_size,
Expand Down Expand Up @@ -2112,13 +2246,15 @@ pub async fn singlepart_uploader(
) -> Result<(), AuthedApiError> {
let presigned_url = api::upload_signed(&app, request).await?;

let resp = app
let request = app
.state::<RetryableHttpClient>()
.as_ref()
.map_err(|err| format!("singlepart_uploader/client: {err:?}"))?
.put(&presigned_url)
.header("Content-Length", total_size)
.body(reqwest::Body::wrap_stream(stream))
.body(reqwest::Body::wrap_stream(stream));

let resp = with_drive_content_range(request, &presigned_url, 0, total_size, total_size)
.send()
.await
.map_err(|err| format!("singlepart_uploader/error: {err:?}"))?;
Expand Down Expand Up @@ -2759,6 +2895,18 @@ mod tests {
let complete = state.to_complete_manifest();
assert!(complete.is_complete);
assert_eq!(complete.video_segments.len(), 2);
assert!(complete.has_video_content());
}

#[tokio::test]
async fn upload_state_without_video_segments_has_no_video_content() {
let mut state = SegmentUploadState::new();
state.video_init_uploaded = true;
state.audio_init_uploaded = true;

let complete = state.to_complete_manifest();
assert!(complete.is_complete);
assert!(!complete.has_video_content());
}

#[tokio::test]
Expand Down
Loading
Loading