diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 2221c67362b..a9c15153e89 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -61,6 +61,7 @@ mod tray; mod update_project_names; mod updates; mod upload; +pub mod upload_health; pub mod web_api; mod window_exclusion; mod window_position_persistence; @@ -6959,6 +6960,8 @@ fn specta_builder() -> tauri_specta::Builder { updates::updates_download_and_install, restart_app, updates::updates_channel_changed, + upload_health::get_upload_health_status, + upload_health::refresh_upload_health_status, ]) .events(tauri_specta::collect_events![ linux_instant_camera::CameraPresentationRequested, @@ -6995,6 +6998,7 @@ fn specta_builder() -> tauri_specta::Builder { updates::UpdateDownloadProgress, updates::UpdateReady, diagnostics::DiagnosticProgress, + upload_health::UploadHealthUpdated, ]) .error_handling(tauri_specta::ErrorHandlingMode::Throw) .typ::() @@ -7283,6 +7287,8 @@ pub async fn run(recording_logging_handle: LoggingHandle, logs_dir: Option, + pub recommended_max_resolution: u32, + pub quality_label: String, + pub message: String, + pub support_url: String, + pub checked_at_unix_ms: Option, +} + +impl Default for UploadHealthStatus { + fn default() -> Self { + Self { + state: UploadHealthState::Checking, + upload_mbps: None, + recommended_max_resolution: RESOLUTION_HIGH_1080P, + quality_label: "1080p".to_string(), + message: "Checking upload connection and speed...".to_string(), + support_url: DEFAULT_SUPPORT_URL.to_string(), + checked_at_unix_ms: None, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, Type, Event)] +#[serde(rename_all = "camelCase")] +pub struct UploadHealthUpdated { + pub status: UploadHealthStatus, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct UploadHealthApiResponse { + ok: bool, + received_bytes: Option, + storage_ready: Option, + error: Option, +} + +#[derive(Default)] +pub struct UploadHealthManager { + status: RwLock, + in_flight: Mutex>, +} + +pub fn recommended_resolution_for_mbps(upload_mbps: f64) -> (u32, &'static str, UploadHealthState) { + if !upload_mbps.is_finite() || upload_mbps <= 0.0 { + return ( + RESOLUTION_LOW_540P, + "540p", + UploadHealthState::Degraded, + ); + } + if upload_mbps >= 8.0 { + ( + RESOLUTION_HIGH_1080P, + "1080p", + UploadHealthState::Healthy, + ) + } else if upload_mbps >= 3.5 { + ( + RESOLUTION_MEDIUM_720P, + "720p", + UploadHealthState::Healthy, + ) + } else { + ( + RESOLUTION_LOW_540P, + "540p", + UploadHealthState::Degraded, + ) + } +} + +pub fn compute_debiased_upload_mbps( + payload_bytes: usize, + received_bytes: Option, + post_elapsed: Duration, + rtt_estimate: Option, +) -> Option { + if payload_bytes == 0 { + return None; + } + if let Some(received) = received_bytes + && received != payload_bytes + { + return None; + } + + let effective_duration = match rtt_estimate { + Some(rtt) if post_elapsed > rtt => { + (post_elapsed - rtt).max(MIN_EFFECTIVE_UPLOAD_WINDOW) + } + _ => post_elapsed.max(MIN_EFFECTIVE_UPLOAD_WINDOW), + }; + + let seconds = effective_duration.as_secs_f64(); + if seconds <= 0.0 { + return None; + } + + let bits = (payload_bytes as f64) * 8.0; + let mbps = (bits / 1_000_000.0) / seconds; + Some((mbps * 100.0).round() / 100.0) +} + +pub fn cap_resolution_for_upload_speed( + configured_max_resolution: u32, + status: &UploadHealthStatus, +) -> u32 { + if status.upload_mbps.is_some() { + configured_max_resolution.min(status.recommended_max_resolution) + } else { + configured_max_resolution + } +} + +fn now_unix_ms() -> f64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_millis() as f64) + .unwrap_or(0.0) +} + +async fn is_recording_pending_or_active(app: &AppHandle) -> bool { + let Some(app_state) = app.try_state::>() else { + return false; + }; + let state = app_state.read().await; + !matches!(state.recording_state, RecordingState::None) +} + +pub async fn cancel_in_flight_probe(app: &AppHandle) { + if let Some(manager) = app.try_state::>() { + let mut guard = manager.in_flight.lock().await; + if let Some(token) = guard.take() { + debug!("Cancelling in-flight upload health probe because recording is starting"); + token.cancel(); + } + } +} + +pub async fn resolve_instant_max_resolution( + app: &AppHandle, + configured_max_resolution: u32, +) -> u32 { + cancel_in_flight_probe(app).await; + let Some(manager) = app.try_state::>() else { + return configured_max_resolution; + }; + let status = manager.status.read().await; + cap_resolution_for_upload_speed(configured_max_resolution, &status) +} + +async fn run_probe_once(app: &AppHandle, cancel_token: &CancellationToken) -> UploadHealthStatus { + let checked_at = Some(now_unix_ms()); + + let auth = match AuthStore::get(app) { + Ok(Some(auth)) => auth, + Ok(None) => { + return UploadHealthStatus { + state: UploadHealthState::Unauthenticated, + upload_mbps: None, + recommended_max_resolution: RESOLUTION_HIGH_1080P, + quality_label: "1080p".to_string(), + message: "Sign in to test cloud upload speed and health.".to_string(), + support_url: DEFAULT_SUPPORT_URL.to_string(), + checked_at_unix_ms: checked_at, + }; + } + Err(err) => { + warn!(%err, "Failed to read auth store for upload health check"); + return UploadHealthStatus { + state: UploadHealthState::Unavailable, + upload_mbps: None, + recommended_max_resolution: RESOLUTION_LOW_540P, + quality_label: "540p".to_string(), + message: "Upload check failed. Please contact support.".to_string(), + support_url: DEFAULT_SUPPORT_URL.to_string(), + checked_at_unix_ms: checked_at, + }; + } + }; + + let url = app.make_app_url(UPLOAD_HEALTH_PATH).await; + let bearer_token = match &auth.secret { + AuthSecret::ApiKey { api_key } => api_key.clone(), + AuthSecret::Session { token, .. } => token.clone(), + }; + + let client = app.state::(); + + // Step 1: Fast RTT baseline probe (bounded to 2 seconds) so connection/RTT latency + // does not artificially depress the measured upload speed. + let rtt_estimate = { + let head_started = Instant::now(); + let head_req = client + .head(&url) + .header("Authorization", format!("Bearer {bearer_token}")) + .timeout(RTT_PROBE_TIMEOUT) + .send(); + + tokio::select! { + _ = cancel_token.cancelled() => return cached_or_default(app).await, + res = head_req => res.ok().map(|_| head_started.elapsed()), + } + }; + + if cancel_token.is_cancelled() || is_recording_pending_or_active(app).await { + return cached_or_default(app).await; + } + + // Step 2: Upload 256 KiB test probe to /api/desktop/upload-health + let payload = vec![0x5Au8; PROBE_PAYLOAD_BYTES]; + let post_started = Instant::now(); + let post_req = client + .post(&url) + .header("Authorization", format!("Bearer {bearer_token}")) + .header("Content-Type", "application/octet-stream") + .timeout(UPLOAD_PROBE_TIMEOUT) + .body(payload) + .send(); + + let response = tokio::select! { + _ = cancel_token.cancelled() => return cached_or_default(app).await, + res = post_req => match res { + Ok(resp) => resp, + Err(err) => { + warn!(error = %err.without_url(), "Upload health check request failed"); + return UploadHealthStatus { + state: UploadHealthState::Unavailable, + upload_mbps: None, + recommended_max_resolution: RESOLUTION_LOW_540P, + quality_label: "540p".to_string(), + message: "Unable to reach Cap upload service. Please check your connection or contact support.".to_string(), + support_url: DEFAULT_SUPPORT_URL.to_string(), + checked_at_unix_ms: checked_at, + }; + } + } + }; + + let post_elapsed = post_started.elapsed(); + + if !response.status().is_success() { + let status_code = response.status().as_u16(); + warn!(status_code, "Upload health check returned non-success status"); + return UploadHealthStatus { + state: UploadHealthState::Unavailable, + upload_mbps: None, + recommended_max_resolution: RESOLUTION_LOW_540P, + quality_label: "540p".to_string(), + message: format!( + "Upload health check failed (HTTP {status_code}). Please contact support." + ), + support_url: DEFAULT_SUPPORT_URL.to_string(), + checked_at_unix_ms: checked_at, + }; + } + + let parsed: UploadHealthApiResponse = match response.json().await { + Ok(body) => body, + Err(err) => { + warn!(%err, "Failed to parse upload health check response"); + return UploadHealthStatus { + state: UploadHealthState::Unavailable, + upload_mbps: None, + recommended_max_resolution: RESOLUTION_LOW_540P, + quality_label: "540p".to_string(), + message: "Unexpected response from upload check. Please contact support." + .to_string(), + support_url: DEFAULT_SUPPORT_URL.to_string(), + checked_at_unix_ms: checked_at, + }; + } + }; + + if !parsed.ok || parsed.storage_ready == Some(false) { + return UploadHealthStatus { + state: UploadHealthState::Unavailable, + upload_mbps: None, + recommended_max_resolution: RESOLUTION_LOW_540P, + quality_label: "540p".to_string(), + message: parsed.error.unwrap_or_else(|| { + "Recording upload destination is currently unavailable. Please contact support." + .to_string() + }), + support_url: DEFAULT_SUPPORT_URL.to_string(), + checked_at_unix_ms: checked_at, + }; + } + + let Some(upload_mbps) = compute_debiased_upload_mbps( + PROBE_PAYLOAD_BYTES, + parsed.received_bytes, + post_elapsed, + rtt_estimate, + ) else { + return UploadHealthStatus { + state: UploadHealthState::Unavailable, + upload_mbps: None, + recommended_max_resolution: RESOLUTION_LOW_540P, + quality_label: "540p".to_string(), + message: "Upload test payload was truncated. Please check your network or contact support.".to_string(), + support_url: DEFAULT_SUPPORT_URL.to_string(), + checked_at_unix_ms: checked_at, + }; + }; + + let (recommended_max_resolution, quality_label, state) = + recommended_resolution_for_mbps(upload_mbps); + + let message = match state { + UploadHealthState::Healthy => format!( + "Upload ready ({upload_mbps:.1} Mbps) · Recording at {quality_label}" + ), + UploadHealthState::Degraded => format!( + "Slow upload ({upload_mbps:.1} Mbps) · Recording scaled to {quality_label} for reliability" + ), + _ => "Upload status updated".to_string(), + }; + + info!( + upload_mbps, + recommended_max_resolution, + quality_label, + "Completed desktop upload speed & health check" + ); + + UploadHealthStatus { + state, + upload_mbps: Some(upload_mbps), + recommended_max_resolution, + quality_label: quality_label.to_string(), + message, + support_url: DEFAULT_SUPPORT_URL.to_string(), + checked_at_unix_ms: checked_at, + } +} + +async fn cached_or_default(app: &AppHandle) -> UploadHealthStatus { + if let Some(manager) = app.try_state::>() { + manager.status.read().await.clone() + } else { + UploadHealthStatus::default() + } +} + +pub async fn perform_upload_health_check(app: &AppHandle) -> UploadHealthStatus { + // Do not perform speed/health checks once a recording is pending or active. + if is_recording_pending_or_active(app).await { + debug!("Skipping upload speed/health check because a recording is active or pending"); + return cached_or_default(app).await; + } + + let Some(manager) = app.try_state::>() else { + return UploadHealthStatus::default(); + }; + + let token = { + let mut in_flight = manager.in_flight.lock().await; + if in_flight.is_some() { + debug!("Upload health check already in flight; returning cached status"); + return manager.status.read().await.clone(); + } + let token = CancellationToken::new(); + *in_flight = Some(token.clone()); + token + }; + + let new_status = run_probe_once(app, &token).await; + + { + let mut in_flight = manager.in_flight.lock().await; + *in_flight = None; + } + + // If recording started while the probe was running, discard the probe update. + if token.is_cancelled() || is_recording_pending_or_active(app).await { + return manager.status.read().await.clone(); + } + + { + let mut status_lock = manager.status.write().await; + *status_lock = new_status.clone(); + } + + let _ = UploadHealthUpdated { + status: new_status.clone(), + } + .emit(app); + + new_status +} + +pub fn spawn_startup_check(app: AppHandle) { + tokio::spawn(async move { + tokio::time::sleep(Duration::from_secs(2)).await; + let _ = perform_upload_health_check(&app).await; + }); +} + +#[tauri::command] +#[specta::specta] +pub async fn get_upload_health_status(app: AppHandle) -> Result { + Ok(cached_or_default(&app).await) +} + +#[tauri::command] +#[specta::specta] +pub async fn refresh_upload_health_status(app: AppHandle) -> Result { + Ok(perform_upload_health_check(&app).await) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn maps_upload_speeds_to_expected_resolution_tiers() { + let (res_high, label_high, state_high) = recommended_resolution_for_mbps(15.0); + assert_eq!(res_high, RESOLUTION_HIGH_1080P); + assert_eq!(label_high, "1080p"); + assert_eq!(state_high, UploadHealthState::Healthy); + + let (res_med, label_med, state_med) = recommended_resolution_for_mbps(5.2); + assert_eq!(res_med, RESOLUTION_MEDIUM_720P); + assert_eq!(label_med, "720p"); + assert_eq!(state_med, UploadHealthState::Healthy); + + let (res_low, label_low, state_low) = recommended_resolution_for_mbps(1.8); + assert_eq!(res_low, RESOLUTION_LOW_540P); + assert_eq!(label_low, "540p"); + assert_eq!(state_low, UploadHealthState::Degraded); + } + + #[test] + fn debiases_rtt_and_validates_received_bytes() { + // 256 KiB = 262,144 bytes = 2.097152 Mbits. + // If total POST took 700ms and RTT was 500ms, actual upload window is 200ms -> ~10.49 Mbps. + let mbps = compute_debiased_upload_mbps( + PROBE_PAYLOAD_BYTES, + Some(PROBE_PAYLOAD_BYTES), + Duration::from_millis(700), + Some(Duration::from_millis(500)), + ) + .expect("should compute mbps"); + assert!(mbps > 10.0 && mbps < 11.0, "expected ~10.49 Mbps, got {mbps}"); + + // Truncated upload must be rejected + assert!( + compute_debiased_upload_mbps( + PROBE_PAYLOAD_BYTES, + Some(PROBE_PAYLOAD_BYTES - 10), + Duration::from_millis(200), + None, + ) + .is_none() + ); + } + + #[test] + fn caps_configured_resolution_without_exceeding_user_ceiling() { + let slow_status = UploadHealthStatus { + state: UploadHealthState::Degraded, + upload_mbps: Some(2.0), + recommended_max_resolution: RESOLUTION_LOW_540P, + quality_label: "540p".to_string(), + message: String::new(), + support_url: DEFAULT_SUPPORT_URL.to_string(), + checked_at_unix_ms: None, + }; + assert_eq!( + cap_resolution_for_upload_speed(RESOLUTION_HIGH_1080P, &slow_status), + RESOLUTION_LOW_540P + ); + + let fast_status = UploadHealthStatus { + state: UploadHealthState::Healthy, + upload_mbps: Some(25.0), + recommended_max_resolution: RESOLUTION_HIGH_1080P, + quality_label: "1080p".to_string(), + message: String::new(), + support_url: DEFAULT_SUPPORT_URL.to_string(), + checked_at_unix_ms: None, + }; + assert_eq!( + cap_resolution_for_upload_speed(RESOLUTION_MEDIUM_720P, &fast_status), + RESOLUTION_MEDIUM_720P + ); + } +} diff --git a/apps/desktop/src/components/upload-health-indicator.test.ts b/apps/desktop/src/components/upload-health-indicator.test.ts new file mode 100644 index 00000000000..267b3c566c5 --- /dev/null +++ b/apps/desktop/src/components/upload-health-indicator.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vitest"; +import { + describeUploadHealthBadge, + formatUploadMbps, +} from "./upload-health-indicator"; + +describe("upload-health-indicator helpers", () => { + it("formats zero and positive upload speeds without treating 0 as falsy", () => { + expect(formatUploadMbps(0)).toBe("0.0 Mbps"); + expect(formatUploadMbps(12.34)).toBe("12.3 Mbps"); + + const zeroSpeedBadge = describeUploadHealthBadge({ + state: "degraded", + uploadMbps: 0, + recommendedMaxResolution: 960, + qualityLabel: "540p", + message: "Slow upload", + supportUrl: "https://cap.so/support", + checkedAtUnixMs: 1700000000000, + }); + expect(zeroSpeedBadge.label).toBe("0.0 Mbps · 540p"); + expect(zeroSpeedBadge.tone).toBe("degraded"); + }); + + it("describes healthy and unavailable states accurately", () => { + const healthyBadge = describeUploadHealthBadge({ + state: "healthy", + uploadMbps: 18.6, + recommendedMaxResolution: 1920, + qualityLabel: "1080p", + message: "Upload ready", + supportUrl: "https://cap.so/support", + checkedAtUnixMs: 1700000000000, + }); + expect(healthyBadge.label).toBe("18.6 Mbps · 1080p"); + expect(healthyBadge.tone).toBe("healthy"); + + const unavailableBadge = describeUploadHealthBadge({ + state: "unavailable", + uploadMbps: null, + recommendedMaxResolution: 960, + qualityLabel: "540p", + message: "Upload check failed", + supportUrl: "https://cap.so/support", + checkedAtUnixMs: 1700000000000, + }); + expect(unavailableBadge.label).toBe("Upload issue · Contact support"); + expect(unavailableBadge.tone).toBe("error"); + }); +}); diff --git a/apps/desktop/src/components/upload-health-indicator.tsx b/apps/desktop/src/components/upload-health-indicator.tsx new file mode 100644 index 00000000000..59b0e406e99 --- /dev/null +++ b/apps/desktop/src/components/upload-health-indicator.tsx @@ -0,0 +1,168 @@ +import { createSignal, onCleanup, onMount, Show } from "solid-js"; +import Tooltip from "~/components/Tooltip"; +import { + commands, + events, + type UploadHealthState, + type UploadHealthStatus, +} from "~/utils/tauri"; + +const REFRESH_INTERVAL_MS = 5 * 60 * 1_000; + +export function formatUploadMbps(mbps: number): string { + if (!Number.isFinite(mbps) || mbps < 0) { + return "0.0 Mbps"; + } + return `${mbps.toFixed(1)} Mbps`; +} + +export function describeUploadHealthBadge(status: UploadHealthStatus): { + label: string; + tone: "healthy" | "degraded" | "error" | "neutral"; +} { + const speed = + status.uploadMbps != null ? formatUploadMbps(status.uploadMbps) : null; + + switch (status.state) { + case "healthy": + return { + label: speed + ? `${speed} · ${status.qualityLabel}` + : `Upload ready · ${status.qualityLabel}`, + tone: "healthy", + }; + case "degraded": + return { + label: speed + ? `${speed} · ${status.qualityLabel}` + : `Slow upload · ${status.qualityLabel}`, + tone: "degraded", + }; + case "unavailable": + return { + label: "Upload issue · Contact support", + tone: "error", + }; + case "unauthenticated": + return { + label: "Sign in for instant upload check", + tone: "neutral", + }; + case "checking": + default: + return { + label: "Checking upload speed...", + tone: "neutral", + }; + } +} + +export function UploadHealthIndicator(props: { isRecording: () => boolean }) { + const [status, setStatus] = createSignal(null); + const [isRefreshing, setIsRefreshing] = createSignal(false); + + const loadUploadHealth = async (refresh: boolean) => { + const shouldRefresh = refresh && !props.isRecording(); + setIsRefreshing(shouldRefresh); + try { + const nextStatus = shouldRefresh + ? await commands.refreshUploadHealthStatus() + : await commands.getUploadHealthStatus(); + setStatus(nextStatus); + } catch (error) { + console.error("Failed to load upload health:", error); + } finally { + setIsRefreshing(false); + } + }; + + onMount(() => { + void loadUploadHealth(false); + + const intervalId = window.setInterval(() => { + if (!props.isRecording()) { + void loadUploadHealth(true); + } + }, REFRESH_INTERVAL_MS); + + let recordingStoppedTimeout: number | undefined; + + const unlistenUpdatedPromise = events.uploadHealthUpdated.listen( + (event) => { + setStatus(event.payload.status); + }, + ); + + const unlistenStoppedPromise = events.recordingStopped.listen(() => { + if (recordingStoppedTimeout !== undefined) { + window.clearTimeout(recordingStoppedTimeout); + } + recordingStoppedTimeout = window.setTimeout(() => { + void loadUploadHealth(true); + }, 1_000); + }); + + onCleanup(() => { + window.clearInterval(intervalId); + if (recordingStoppedTimeout !== undefined) { + window.clearTimeout(recordingStoppedTimeout); + } + void unlistenUpdatedPromise.then((unlisten) => unlisten()); + void unlistenStoppedPromise.then((unlisten) => unlisten()); + }); + }); + + const currentBadge = () => { + const s = status(); + if (!s) return null; + return { status: s, ...describeUploadHealthBadge(s) }; + }; + + const dotColorClass = (state: UploadHealthState) => { + switch (state) { + case "healthy": + return "bg-green-9"; + case "degraded": + return "bg-amber-9"; + case "unavailable": + return "bg-red-9"; + default: + return "bg-gray-8"; + } + }; + + return ( + + {(badge) => ( +
+ {badge().status.message}}> + + + + + +
+ )} +
+ ); +} diff --git a/apps/desktop/src/routes/(window-chrome)/new-main/index.tsx b/apps/desktop/src/routes/(window-chrome)/new-main/index.tsx index b0981c60573..ead4ac5a5fd 100644 --- a/apps/desktop/src/routes/(window-chrome)/new-main/index.tsx +++ b/apps/desktop/src/routes/(window-chrome)/new-main/index.tsx @@ -38,6 +38,7 @@ import toast from "solid-toast"; import { Transition } from "solid-transition-group"; import Mode from "~/components/Mode"; import { RecoveryToast } from "~/components/RecoveryToast"; +import { UploadHealthIndicator } from "~/components/upload-health-indicator"; import Tooltip from "~/components/Tooltip"; import { Input } from "~/routes/editor/ui"; import { @@ -3432,6 +3433,7 @@ function Page() { + ); diff --git a/apps/desktop/src/utils/tauri.ts b/apps/desktop/src/utils/tauri.ts index 65e5b101a40..a4b904cc061 100644 --- a/apps/desktop/src/utils/tauri.ts +++ b/apps/desktop/src/utils/tauri.ts @@ -587,6 +587,12 @@ async restartApp() : Promise { }, async updatesChannelChanged() : Promise { return await TAURI_INVOKE("updates_channel_changed"); +}, +async getUploadHealthStatus() : Promise { + return await TAURI_INVOKE("get_upload_health_status"); +}, +async refreshUploadHealthStatus() : Promise { + return await TAURI_INVOKE("refresh_upload_health_status"); } } @@ -626,6 +632,7 @@ setCaptureAreaPending: SetCaptureAreaPending, targetUnderCursor: TargetUnderCursor, updateDownloadProgress: UpdateDownloadProgress, updateReady: UpdateReady, +uploadHealthUpdated: UploadHealthUpdated, uploadProgressEvent: UploadProgressEvent, videoImportProgress: VideoImportProgress }>({ @@ -661,6 +668,7 @@ setCaptureAreaPending: "set-capture-area-pending", targetUnderCursor: "target-under-cursor", updateDownloadProgress: "update-download-progress", updateReady: "update-ready", +uploadHealthUpdated: "upload-health-updated", uploadProgressEvent: "upload-progress-event", videoImportProgress: "video-import-progress" }) @@ -1377,6 +1385,9 @@ export type VideoMeta = { path: string; fps?: number; start_time?: number | null export type VideoRecordingMetadata = { duration: number; size: number } export type VideoUploadInfo = { id: string; link: string; config: S3UploadMeta } export type VoiceIsolation = "light" | "balanced" | "strong" +export type UploadHealthState = "checking" | "healthy" | "degraded" | "unavailable" | "unauthenticated" +export type UploadHealthStatus = { state: UploadHealthState; uploadMbps: number | null; recommendedMaxResolution: number; qualityLabel: string; message: string; supportUrl: string; checkedAtUnixMs: number | null } +export type UploadHealthUpdated = { status: UploadHealthStatus } export type WindowExclusion = { bundleIdentifier?: string | null; ownerName?: string | null; windowTitle?: string | null } export type WindowId = string export type WindowPosition = { x: number; y: number; displayId?: DisplayId | null } diff --git a/apps/web/app/api/desktop/[...route]/root.ts b/apps/web/app/api/desktop/[...route]/root.ts index 8eb06bd3d85..611e6d96eb6 100644 --- a/apps/web/app/api/desktop/[...route]/root.ts +++ b/apps/web/app/api/desktop/[...route]/root.ts @@ -661,3 +661,83 @@ app.post( return c.json({ error: true }, { status: 400 }); }, ); + +const MAX_UPLOAD_HEALTH_PROBE_BYTES = 1024 * 1024; + +async function readUploadHealthProbeBytes( + stream: ReadableStream | null, + maxBytes: number, +): Promise<{ receivedBytes: number; exceeded: boolean }> { + if (!stream) { + return { receivedBytes: 0, exceeded: false }; + } + + const reader = stream.getReader(); + let receivedBytes = 0; + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + receivedBytes += value?.byteLength ?? 0; + if (receivedBytes > maxBytes) { + await reader.cancel().catch(() => undefined); + return { receivedBytes, exceeded: true }; + } + } + return { receivedBytes, exceeded: false }; + } finally { + reader.releaseLock(); + } +} + +app.on(["GET", "HEAD"], "/upload-health", withAuth, async (c) => { + return c.json({ + ok: true, + storageReady: true, + timestamp: Date.now(), + }); +}); + +app.post("/upload-health", withAuth, async (c) => { + // Best-effort fast-path check when Content-Length is voluntarily advertised; + // readUploadHealthProbeBytes enforces the strict stream limit below. + const rawContentLength = c.req.header("content-length"); + if (rawContentLength !== undefined) { + const contentLength = Number(rawContentLength); + if ( + Number.isFinite(contentLength) && + contentLength > MAX_UPLOAD_HEALTH_PROBE_BYTES + ) { + return c.json( + { + ok: false, + error: "Upload probe payload exceeds maximum allowed size", + }, + { status: 413 }, + ); + } + } + + const { receivedBytes, exceeded } = await readUploadHealthProbeBytes( + c.req.raw.body, + MAX_UPLOAD_HEALTH_PROBE_BYTES, + ); + + if (exceeded) { + return c.json( + { + ok: false, + error: "Upload probe payload exceeds maximum allowed size", + }, + { status: 413 }, + ); + } + + return c.json({ + ok: true, + receivedBytes, + storageReady: true, + timestamp: Date.now(), + }); +}); diff --git a/apps/web/app/api/desktop/[...route]/route.ts b/apps/web/app/api/desktop/[...route]/route.ts index 36857200042..c2a7831e845 100644 --- a/apps/web/app/api/desktop/[...route]/route.ts +++ b/apps/web/app/api/desktop/[...route]/route.ts @@ -23,3 +23,4 @@ export const POST = handle(app); export const PATCH = handle(app); export const OPTIONS = handle(app); export const DELETE = handle(app); +export const HEAD = handle(app);