From 95f74a37d9373c525e6b846510b23be8772b9843 Mon Sep 17 00:00:00 2001 From: Ondrej Sedlak Date: Fri, 13 Nov 2026 13:55:13 +0100 Subject: [PATCH 1/5] feat: add upload speed test and startup health check to desktop app - new network_status module runs a startup health check that uploads a probe payload to /api/desktop/health-check and flags failures in the UI - periodic upload speed test (skipped while a recording is active) drives a bottom-left indicator and caps studio recording quality by bandwidth - new authenticated /api/desktop/health-check endpoint in the web app --- apps/desktop-gpui/src/auth.rs | 36 +++ apps/desktop-gpui/src/main.rs | 3 + apps/desktop-gpui/src/main_window.rs | 29 ++ apps/desktop-gpui/src/network_status.rs | 262 ++++++++++++++++++ apps/desktop-gpui/src/recording.rs | 5 +- .../api/desktop/[...route]/health-check.ts | 16 ++ apps/web/app/api/desktop/[...route]/route.ts | 2 + 7 files changed, 352 insertions(+), 1 deletion(-) create mode 100644 apps/desktop-gpui/src/network_status.rs create mode 100644 apps/web/app/api/desktop/[...route]/health-check.ts diff --git a/apps/desktop-gpui/src/auth.rs b/apps/desktop-gpui/src/auth.rs index cfc44f06190..f5c4daf07e5 100644 --- a/apps/desktop-gpui/src/auth.rs +++ b/apps/desktop-gpui/src/auth.rs @@ -433,6 +433,42 @@ pub async fn authed_request_with( Ok(response) } +pub async fn authed_raw_request( + method: reqwest::Method, + path: &str, + content_type: &str, + body: Vec, +) -> Result { + let client = shared_api_client()?; + let (server, token) = if let Ok(context) = UPLOAD_REQUEST.try_with(Clone::clone) { + let token = context.credentials()?; + (context.server_url, token) + } else { + let auth = store::auth_snapshot(); + ( + normalize_server_url(&auth.server_url), + auth.token.ok_or(AuthApiError::InvalidAuthentication)?, + ) + }; + let url = format!("{server}{path}"); + let request = client + .request(method, url) + .header("Content-Type", content_type) + .body(body) + .bearer_auth(token); + let response = request.send().await.map_err(|error| { + if error.is_timeout() { + AuthApiError::Timeout + } else { + AuthApiError::Other(error.to_string()) + } + })?; + if response.status() == reqwest::StatusCode::UNAUTHORIZED { + return Err(AuthApiError::InvalidAuthentication); + } + Ok(response) +} + pub fn process_auth_data(params: AuthParams) -> Result<(), String> { let (secret, user_id) = match params { AuthParams::ApiKey { api_key, user_id } => (json!({ "api_key": api_key }), user_id), diff --git a/apps/desktop-gpui/src/main.rs b/apps/desktop-gpui/src/main.rs index acdd53da302..153d2781a05 100644 --- a/apps/desktop-gpui/src/main.rs +++ b/apps/desktop-gpui/src/main.rs @@ -85,6 +85,7 @@ mod power; mod presets; mod style_presets; mod project_save; +mod network_status; mod recording; mod screenshot; mod screenshot_annotations; @@ -405,6 +406,7 @@ fn main() { platform::follow_system_reduce_motion(cx); let session = RecordingSession::init(cx); + crate::network_status::NetworkStatus::init(cx); crate::sign_in::SignIn::init(cx); crate::feeds::Feeds::init(cx); crate::media_access::MediaAccess::init(cx); @@ -512,6 +514,7 @@ fn main() { app_windows::schedule_launch_render_warmup(cx); upload::queue::init(cx); upload::instant_cleanup::init(cx); + network_status::spawn_monitor(cx); // The app menu (and with it ⌘W/⌘M/⌘Q) and the status-bar item. Both // reach into the window registry, so they come after it -- and the menu diff --git a/apps/desktop-gpui/src/main_window.rs b/apps/desktop-gpui/src/main_window.rs index 928b64406e1..44c0ac2828d 100644 --- a/apps/desktop-gpui/src/main_window.rs +++ b/apps/desktop-gpui/src/main_window.rs @@ -22,6 +22,7 @@ use crate::{ feeds::{self, Feeds}, library::{self, RecordingItem, ScreenshotItem}, media_access::MediaAccess, + network_status::{HealthStatus, NetworkStatus}, permissions::OSPermission, recording, session::{Phase, RecordingSession}, @@ -1110,6 +1111,8 @@ impl MainWindow { // Track the app-scoped feeds: the camera bubble's close button // deselects the camera there, and this window's selection has to // follow. Repaints are gated to what is actually visible. + cx.observe(&NetworkStatus::global(cx), |_, _, cx| cx.notify()) + .detach(); let feeds = Feeds::global(cx); let mic_level = feeds.read(cx).mic_level().clone(); let microphone_level = cx.new(|cx| { @@ -3085,6 +3088,7 @@ impl Render for MainWindow { .when_some(self.idle_error(cx), |this, error| { this.child(self.render_idle_error(error, cx)) }) + .child(self.render_network_status(cx)) .on_key_down(cx.listener(|this, event: &gpui::KeyDownEvent, _, cx| { if event.keystroke.key == "escape" && this.dismiss_main(cx) { cx.stop_propagation(); @@ -3970,6 +3974,31 @@ impl MainWindow { }); } + fn render_network_status(&self, cx: &mut Context) -> impl IntoElement { + let theme = self.theme; + let status = NetworkStatus::global(cx).read(cx); + let failed = status.health == HealthStatus::Failed; + let label = if failed { + "Upload check failed - contact support".to_string() + } else { + match status.upload_mbps { + Some(mbps) => format!("{mbps:.1} Mbps up"), + None => "Checking upload...".to_string(), + } + }; + div() + .absolute() + .bottom(px(8.)) + .left(px(8.)) + .px_2() + .py_0p5() + .rounded_md() + .text_xs() + .bg(if failed { theme.red_9 } else { theme.gray_3 }) + .text_color(if failed { theme.red_2 } else { theme.text_2 }) + .child(label) + } + fn render_idle_error(&self, error: String, cx: &mut Context) -> impl IntoElement { let theme = self.theme; let copied = self.copied_error.as_deref() == Some(error.as_str()); diff --git a/apps/desktop-gpui/src/network_status.rs b/apps/desktop-gpui/src/network_status.rs new file mode 100644 index 00000000000..86390687dda --- /dev/null +++ b/apps/desktop-gpui/src/network_status.rs @@ -0,0 +1,262 @@ +//! Upload speed test and startup health check. +//! +//! On startup the app uploads a small probe payload to the web app's +//! `/api/desktop/health-check` endpoint; a failure means uploading is broken +//! and the main window shows a warning. While idle (never during an active +//! recording) a periodic speed test measures the current upload bandwidth and +//! caps the studio recording quality to what the connection can sustain. + +use std::sync::{OnceLock, RwLock}; +use std::time::{Duration, Instant}; + +use cap_recording::StudioQuality; +use gpui::{App, AppContext as _, AsyncApp, Entity, Global}; + +use crate::auth::{self, AuthApiError}; +use crate::session::{Phase, RecordingSession}; + +pub const HEALTH_CHECK_PATH: &str = "/api/desktop/health-check"; + +const HEALTH_CHECK_TIMEOUT: Duration = Duration::from_secs(10); +const SPEED_TEST_INTERVAL: Duration = Duration::from_secs(60); +const SPEED_TEST_TIMEOUT: Duration = Duration::from_secs(20); +const SPEED_TEST_PAYLOAD_BYTES: usize = 2 * 1024 * 1024; + +const ULTRA_MIN_MBPS: f64 = 25.0; +const BALANCED_MIN_MBPS: f64 = 8.0; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum HealthStatus { + Pending, + Ok, + Failed, +} + +#[derive(Debug, Clone, Copy, Default)] +pub struct NetworkStatus { + pub health: HealthStatus, + pub upload_mbps: Option, + pub last_speed_test: Option, +} + +impl Default for HealthStatus { + fn default() -> Self { + Self::Pending + } +} + +struct NetworkStatusGlobal(Entity); + +impl Global for NetworkStatusGlobal {} + +static SNAPSHOT: OnceLock> = OnceLock::new(); + +fn snapshot() -> &'static RwLock { + SNAPSHOT.get_or_init(|| RwLock::new(NetworkStatus::default())) +} + +/// Latest measured upload speed, readable from non-gpui contexts (e.g. the +/// recording start path). +pub fn current_upload_mbps() -> Option { + snapshot().read().ok()?.upload_mbps +} + +fn publish(status: NetworkStatus) { + if let Ok(mut guard) = snapshot().write() { + *guard = status; + } +} + +impl NetworkStatus { + pub fn init(cx: &mut App) -> Entity { + let initial = snapshot().read().map(|s| *s).unwrap_or_default(); + let entity = cx.new(|_| initial); + cx.set_global(NetworkStatusGlobal(entity.clone())); + entity + } + + pub fn global(cx: &App) -> Entity { + cx.global::().0.clone() + } + + fn snapshot_of(&self) -> NetworkStatus { + NetworkStatus { + health: self.health, + upload_mbps: self.upload_mbps, + last_speed_test: self.last_speed_test, + } + } +} + +fn quality_tier(quality: StudioQuality) -> u8 { + match quality { + StudioQuality::Compatibility => 0, + StudioQuality::Balanced => 1, + StudioQuality::Ultra => 2, + } +} + +/// The quality tier the measured upload speed can sustain. +pub fn quality_for_upload_speed(upload_mbps: f64) -> StudioQuality { + if upload_mbps >= ULTRA_MIN_MBPS { + StudioQuality::Ultra + } else if upload_mbps >= BALANCED_MIN_MBPS { + StudioQuality::Balanced + } else { + StudioQuality::Compatibility + } +} + +/// The configured quality capped by what the current upload speed supports. +/// With no measurement yet, the configured quality stands. +pub fn effective_studio_quality( + configured: StudioQuality, + upload_mbps: Option, +) -> StudioQuality { + match upload_mbps { + Some(mbps) => { + let cap = quality_for_upload_speed(mbps); + if quality_tier(cap) < quality_tier(configured) { + cap + } else { + configured + } + } + None => configured, + } +} + +async fn post_probe(payload_bytes: usize, timeout: Duration) -> Result { + let mut payload = vec![0u8; payload_bytes]; + for (index, byte) in payload.iter_mut().enumerate() { + *byte = (index % 251) as u8; + } + let start = Instant::now(); + let response = tokio::time::timeout( + timeout, + auth::authed_raw_request( + reqwest::Method::POST, + HEALTH_CHECK_PATH, + "application/octet-stream", + payload, + ), + ) + .await + .map_err(|_| AuthApiError::Timeout)??; + if !response.status().is_success() { + return Err(AuthApiError::Status( + response.status(), + "health check endpoint returned an error".into(), + )); + } + Ok(start.elapsed()) +} + +async fn run_health_check(status: &Entity, cx: &mut AsyncApp) { + let outcome = match post_probe(64 * 1024, HEALTH_CHECK_TIMEOUT).await { + Ok(_) => HealthStatus::Ok, + Err(AuthApiError::InvalidAuthentication) => return, + Err(error) => { + tracing::warn!(error = %error, "upload health check failed"); + HealthStatus::Failed + } + }; + let _ = cx.update(|cx| { + status.update(cx, |status, cx| { + status.health = outcome; + publish(status.snapshot_of()); + cx.notify(); + }); + }); +} + +async fn measure_upload_speed(status: &Entity, cx: &mut AsyncApp) { + let outcome = match post_probe(SPEED_TEST_PAYLOAD_BYTES, SPEED_TEST_TIMEOUT).await { + Ok(elapsed) => { + Some(SPEED_TEST_PAYLOAD_BYTES as f64 * 8.0 / elapsed.as_secs_f64() / 1_000_000.0) + } + Err(AuthApiError::InvalidAuthentication) => return, + Err(error) => { + tracing::warn!(error = %error, "upload speed test failed"); + None + } + }; + let _ = cx.update(|cx| { + status.update(cx, |status, cx| { + status.upload_mbps = outcome; + status.last_speed_test = Some(Instant::now()); + publish(status.snapshot_of()); + cx.notify(); + }); + }); +} + +fn recording_active(cx: &mut AsyncApp) -> bool { + cx.update(|cx| RecordingSession::global(cx).read(cx).phase != Phase::Idle) + .unwrap_or(false) +} + +/// Runs the startup health check once, then measures upload speed on an +/// interval. Speed tests are skipped while a recording is active so the +/// probe never competes with a real upload. +pub fn spawn_monitor(cx: &mut App) { + let status = NetworkStatus::global(cx); + cx.spawn(async move |cx| { + run_health_check(&status, cx).await; + loop { + cx.background_executor().timer(SPEED_TEST_INTERVAL).await; + if recording_active(cx) { + continue; + } + measure_upload_speed(&status, cx).await; + } + }) + .detach(); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn speed_thresholds_map_to_quality_tiers() { + assert_eq!(quality_for_upload_speed(100.0), StudioQuality::Ultra); + assert_eq!(quality_for_upload_speed(25.0), StudioQuality::Ultra); + assert_eq!(quality_for_upload_speed(8.0), StudioQuality::Balanced); + assert_eq!(quality_for_upload_speed(10.0), StudioQuality::Balanced); + assert_eq!(quality_for_upload_speed(7.9), StudioQuality::Compatibility); + assert_eq!(quality_for_upload_speed(0.5), StudioQuality::Compatibility); + } + + #[test] + fn effective_quality_is_capped_by_upload_speed() { + assert_eq!( + effective_studio_quality(StudioQuality::Ultra, Some(50.0)), + StudioQuality::Ultra + ); + assert_eq!( + effective_studio_quality(StudioQuality::Ultra, Some(10.0)), + StudioQuality::Balanced + ); + assert_eq!( + effective_studio_quality(StudioQuality::Balanced, Some(1.0)), + StudioQuality::Compatibility + ); + assert_eq!( + effective_studio_quality(StudioQuality::Compatibility, Some(100.0)), + StudioQuality::Compatibility + ); + } + + #[test] + fn effective_quality_keeps_configured_without_measurement() { + assert_eq!( + effective_studio_quality(StudioQuality::Ultra, None), + StudioQuality::Ultra + ); + assert_eq!( + effective_studio_quality(StudioQuality::Compatibility, None), + StudioQuality::Compatibility + ); + } +} diff --git a/apps/desktop-gpui/src/recording.rs b/apps/desktop-gpui/src/recording.rs index 38165838b54..3284122b634 100644 --- a/apps/desktop-gpui/src/recording.rs +++ b/apps/desktop-gpui/src/recording.rs @@ -2541,7 +2541,10 @@ async fn start_attempt_with_upload( capture_keyboard_events: settings.capture_keyboard_events, crash_recovery_recording: settings.crash_recovery_recording, max_fps: settings.max_fps, - studio_recording_quality: settings.studio_recording_quality.into(), + studio_recording_quality: crate::network_status::effective_studio_quality( + settings.studio_recording_quality.into(), + crate::network_status::current_upload_mbps(), + ), out_of_process_muxer: settings.out_of_process_muxer, instant_mode_max_resolution: cap_recording::DEFAULT_INSTANT_MODE_MAX_RESOLUTION, }; diff --git a/apps/web/app/api/desktop/[...route]/health-check.ts b/apps/web/app/api/desktop/[...route]/health-check.ts new file mode 100644 index 00000000000..ca6ee5464e9 --- /dev/null +++ b/apps/web/app/api/desktop/[...route]/health-check.ts @@ -0,0 +1,16 @@ +import { Hono } from "hono"; +import { withAuth } from "../../utils"; + +export const app = new Hono(); + +const MAX_PROBE_BYTES = 8 * 1024 * 1024; + +app.get("/", withAuth, (c) => c.json({ ok: true })); + +app.post("/", withAuth, async (c) => { + const body = await c.req.arrayBuffer(); + if (body.byteLength > MAX_PROBE_BYTES) { + return c.json({ error: true, code: "payload_too_large" }, 413); + } + return c.json({ ok: true, received: body.byteLength }); +}); diff --git a/apps/web/app/api/desktop/[...route]/route.ts b/apps/web/app/api/desktop/[...route]/route.ts index 36857200042..249c8a60988 100644 --- a/apps/web/app/api/desktop/[...route]/route.ts +++ b/apps/web/app/api/desktop/[...route]/route.ts @@ -3,6 +3,7 @@ import { handle } from "hono/vercel"; import { corsMiddleware } from "../../utils"; +import * as healthCheck from "./health-check"; import * as root from "./root"; import * as s3Config from "./s3Config"; import * as session from "./session"; @@ -12,6 +13,7 @@ import * as video from "./video"; const app = new Hono() .basePath("/api/desktop") .use(corsMiddleware) + .route("/health-check", healthCheck.app) .route("/s3/config", s3Config.app) .route("/session", session.app) .route("/storage", storage.app) From 471cc938bde4e79a9ce3086347172b855bffc7db Mon Sep 17 00:00:00 2001 From: Ondrej Sedlak Date: Fri, 13 Nov 2026 14:10:24 +0100 Subject: [PATCH 2/5] fix: spawn probe on tokio runtime, fix cx update unwrap and check body limit --- apps/desktop-gpui/src/network_status.rs | 39 ++++++++++++------- .../api/desktop/[...route]/health-check.ts | 4 ++ 2 files changed, 30 insertions(+), 13 deletions(-) diff --git a/apps/desktop-gpui/src/network_status.rs b/apps/desktop-gpui/src/network_status.rs index 86390687dda..0b7ecb060f6 100644 --- a/apps/desktop-gpui/src/network_status.rs +++ b/apps/desktop-gpui/src/network_status.rs @@ -153,15 +153,20 @@ async fn post_probe(payload_bytes: usize, timeout: Duration) -> Result, cx: &mut AsyncApp) { - let outcome = match post_probe(64 * 1024, HEALTH_CHECK_TIMEOUT).await { - Ok(_) => HealthStatus::Ok, - Err(AuthApiError::InvalidAuthentication) => return, - Err(error) => { + let probe_task = gpui_tokio::Tokio::spawn(cx, post_probe(64 * 1024, HEALTH_CHECK_TIMEOUT)); + let outcome = match probe_task.await { + Ok(Ok(_)) => HealthStatus::Ok, + Ok(Err(AuthApiError::InvalidAuthentication)) => return, + Ok(Err(error)) => { tracing::warn!(error = %error, "upload health check failed"); HealthStatus::Failed } + Err(error) => { + tracing::warn!(error = %error, "upload health check task failed"); + HealthStatus::Failed + } }; - let _ = cx.update(|cx| { + cx.update(|cx| { status.update(cx, |status, cx| { status.health = outcome; publish(status.snapshot_of()); @@ -171,19 +176,28 @@ async fn run_health_check(status: &Entity, cx: &mut AsyncApp) { } async fn measure_upload_speed(status: &Entity, cx: &mut AsyncApp) { - let outcome = match post_probe(SPEED_TEST_PAYLOAD_BYTES, SPEED_TEST_TIMEOUT).await { - Ok(elapsed) => { - Some(SPEED_TEST_PAYLOAD_BYTES as f64 * 8.0 / elapsed.as_secs_f64() / 1_000_000.0) + let probe_task = gpui_tokio::Tokio::spawn(cx, post_probe(SPEED_TEST_PAYLOAD_BYTES, SPEED_TEST_TIMEOUT)); + let (outcome, recovered) = match probe_task.await { + Ok(Ok(elapsed)) => { + let mbps = SPEED_TEST_PAYLOAD_BYTES as f64 * 8.0 / elapsed.as_secs_f64() / 1_000_000.0; + (Some(mbps), true) } - Err(AuthApiError::InvalidAuthentication) => return, - Err(error) => { + Ok(Err(AuthApiError::InvalidAuthentication)) => return, + Ok(Err(error)) => { tracing::warn!(error = %error, "upload speed test failed"); - None + (None, false) + } + Err(error) => { + tracing::warn!(error = %error, "upload speed test task failed"); + (None, false) } }; - let _ = cx.update(|cx| { + cx.update(|cx| { status.update(cx, |status, cx| { status.upload_mbps = outcome; + if recovered { + status.health = HealthStatus::Ok; + } status.last_speed_test = Some(Instant::now()); publish(status.snapshot_of()); cx.notify(); @@ -193,7 +207,6 @@ async fn measure_upload_speed(status: &Entity, cx: &mut AsyncApp) fn recording_active(cx: &mut AsyncApp) -> bool { cx.update(|cx| RecordingSession::global(cx).read(cx).phase != Phase::Idle) - .unwrap_or(false) } /// Runs the startup health check once, then measures upload speed on an diff --git a/apps/web/app/api/desktop/[...route]/health-check.ts b/apps/web/app/api/desktop/[...route]/health-check.ts index ca6ee5464e9..45584c0b99d 100644 --- a/apps/web/app/api/desktop/[...route]/health-check.ts +++ b/apps/web/app/api/desktop/[...route]/health-check.ts @@ -8,6 +8,10 @@ const MAX_PROBE_BYTES = 8 * 1024 * 1024; app.get("/", withAuth, (c) => c.json({ ok: true })); app.post("/", withAuth, async (c) => { + const contentLength = c.req.header("content-length"); + if (contentLength && parseInt(contentLength, 10) > MAX_PROBE_BYTES) { + return c.json({ error: true, code: "payload_too_large" }, 413); + } const body = await c.req.arrayBuffer(); if (body.byteLength > MAX_PROBE_BYTES) { return c.json({ error: true, code: "payload_too_large" }, 413); From 990981124127469f0d17cf386c873bbce5137e91 Mon Sep 17 00:00:00 2001 From: Ondrej Sedlak Date: Fri, 13 Nov 2026 15:46:36 +0100 Subject: [PATCH 3/5] fix: apply desktop headers, stream body limit, cap on timeout and localize --- apps/desktop-gpui/src/auth.rs | 3 +-- apps/desktop-gpui/src/main_window.rs | 4 ++-- apps/desktop-gpui/src/network_status.rs | 11 +++++------ .../app/api/desktop/[...route]/health-check.ts | 18 ++++++++++++++---- 4 files changed, 22 insertions(+), 14 deletions(-) diff --git a/apps/desktop-gpui/src/auth.rs b/apps/desktop-gpui/src/auth.rs index f5c4daf07e5..12fb2826c06 100644 --- a/apps/desktop-gpui/src/auth.rs +++ b/apps/desktop-gpui/src/auth.rs @@ -451,8 +451,7 @@ pub async fn authed_raw_request( ) }; let url = format!("{server}{path}"); - let request = client - .request(method, url) + let request = apply_desktop_headers(client.request(method, url)) .header("Content-Type", content_type) .body(body) .bearer_auth(token); diff --git a/apps/desktop-gpui/src/main_window.rs b/apps/desktop-gpui/src/main_window.rs index 44c0ac2828d..95140791494 100644 --- a/apps/desktop-gpui/src/main_window.rs +++ b/apps/desktop-gpui/src/main_window.rs @@ -3979,11 +3979,11 @@ impl MainWindow { let status = NetworkStatus::global(cx).read(cx); let failed = status.health == HealthStatus::Failed; let label = if failed { - "Upload check failed - contact support".to_string() + crate::i18n::messages::localize("Upload check failed - contact support") } else { match status.upload_mbps { Some(mbps) => format!("{mbps:.1} Mbps up"), - None => "Checking upload...".to_string(), + None => crate::i18n::messages::localize("Checking upload..."), } }; div() diff --git a/apps/desktop-gpui/src/network_status.rs b/apps/desktop-gpui/src/network_status.rs index 0b7ecb060f6..96d5992a94f 100644 --- a/apps/desktop-gpui/src/network_status.rs +++ b/apps/desktop-gpui/src/network_status.rs @@ -20,7 +20,7 @@ pub const HEALTH_CHECK_PATH: &str = "/api/desktop/health-check"; const HEALTH_CHECK_TIMEOUT: Duration = Duration::from_secs(10); const SPEED_TEST_INTERVAL: Duration = Duration::from_secs(60); const SPEED_TEST_TIMEOUT: Duration = Duration::from_secs(20); -const SPEED_TEST_PAYLOAD_BYTES: usize = 2 * 1024 * 1024; +const SPEED_TEST_PAYLOAD_BYTES: usize = 1024 * 1024; const ULTRA_MIN_MBPS: f64 = 25.0; const BALANCED_MIN_MBPS: f64 = 8.0; @@ -55,8 +55,6 @@ fn snapshot() -> &'static RwLock { SNAPSHOT.get_or_init(|| RwLock::new(NetworkStatus::default())) } -/// Latest measured upload speed, readable from non-gpui contexts (e.g. the -/// recording start path). pub fn current_upload_mbps() -> Option { snapshot().read().ok()?.upload_mbps } @@ -96,7 +94,6 @@ fn quality_tier(quality: StudioQuality) -> u8 { } } -/// The quality tier the measured upload speed can sustain. pub fn quality_for_upload_speed(upload_mbps: f64) -> StudioQuality { if upload_mbps >= ULTRA_MIN_MBPS { StudioQuality::Ultra @@ -107,8 +104,6 @@ pub fn quality_for_upload_speed(upload_mbps: f64) -> StudioQuality { } } -/// The configured quality capped by what the current upload speed supports. -/// With no measurement yet, the configured quality stands. pub fn effective_studio_quality( configured: StudioQuality, upload_mbps: Option, @@ -182,6 +177,10 @@ async fn measure_upload_speed(status: &Entity, cx: &mut AsyncApp) let mbps = SPEED_TEST_PAYLOAD_BYTES as f64 * 8.0 / elapsed.as_secs_f64() / 1_000_000.0; (Some(mbps), true) } + Ok(Err(AuthApiError::Timeout)) => { + tracing::warn!("upload speed test timed out; capping quality to minimum tier"); + (Some(0.0), true) + } Ok(Err(AuthApiError::InvalidAuthentication)) => return, Ok(Err(error)) => { tracing::warn!(error = %error, "upload speed test failed"); diff --git a/apps/web/app/api/desktop/[...route]/health-check.ts b/apps/web/app/api/desktop/[...route]/health-check.ts index 45584c0b99d..c0eb7068aa9 100644 --- a/apps/web/app/api/desktop/[...route]/health-check.ts +++ b/apps/web/app/api/desktop/[...route]/health-check.ts @@ -12,9 +12,19 @@ app.post("/", withAuth, async (c) => { if (contentLength && parseInt(contentLength, 10) > MAX_PROBE_BYTES) { return c.json({ error: true, code: "payload_too_large" }, 413); } - const body = await c.req.arrayBuffer(); - if (body.byteLength > MAX_PROBE_BYTES) { - return c.json({ error: true, code: "payload_too_large" }, 413); + if (!c.req.raw.body) { + return c.json({ ok: true, received: 0 }); + } + const reader = c.req.raw.body.getReader(); + let bytesReceived = 0; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + bytesReceived += value.byteLength; + if (bytesReceived > MAX_PROBE_BYTES) { + await reader.cancel(); + return c.json({ error: true, code: "payload_too_large" }, 413); + } } - return c.json({ ok: true, received: body.byteLength }); + return c.json({ ok: true, received: bytesReceived }); }); From 012bc5453cb9d5cf15057db9d81ed209ad51f626 Mon Sep 17 00:00:00 2001 From: Ondrej Sedlak Date: Fri, 13 Nov 2026 16:03:04 +0100 Subject: [PATCH 4/5] fix: add network translation keys, correct timeout recovery flag and clean comments --- apps/desktop-gpui/locales/de.toml | 6 ++++++ apps/desktop-gpui/locales/en.toml | 6 ++++++ apps/desktop-gpui/locales/es.toml | 6 ++++++ apps/desktop-gpui/locales/fr.toml | 6 ++++++ apps/desktop-gpui/locales/hi.toml | 6 ++++++ apps/desktop-gpui/locales/ja.toml | 6 ++++++ apps/desktop-gpui/locales/zh-CN.toml | 6 ++++++ apps/desktop-gpui/src/main_window.rs | 6 +++--- apps/desktop-gpui/src/network_status.rs | 12 +----------- 9 files changed, 46 insertions(+), 14 deletions(-) diff --git a/apps/desktop-gpui/locales/de.toml b/apps/desktop-gpui/locales/de.toml index c96d36f22a2..a6fe2ec113d 100644 --- a/apps/desktop-gpui/locales/de.toml +++ b/apps/desktop-gpui/locales/de.toml @@ -971,6 +971,12 @@ description = "Den Link zum Teilen an Slack, Discord oder Ihren eigenen Webhook name = "Slack beim Teilen benachrichtigen" [main] + +[main.network] +upload_check_failed = "Upload-Prüfung fehlgeschlagen - Support kontaktieren" +checking_upload = "Upload wird geprüft..." +upload_speed = "{speed} Mbps Upload" + delete_recording_confirm = "Möchten Sie diese Aufnahme wirklich löschen?" delete_screenshot_confirm = "Möchten Sie dieses Bildschirmfoto wirklich löschen?" diff --git a/apps/desktop-gpui/locales/en.toml b/apps/desktop-gpui/locales/en.toml index 29f268aa5c6..956034a7188 100644 --- a/apps/desktop-gpui/locales/en.toml +++ b/apps/desktop-gpui/locales/en.toml @@ -973,6 +973,12 @@ description = "Send the share link to Slack, Discord, or your own webhook." name = "Tell Slack when you share something" [main] + +[main.network] +upload_check_failed = "Upload check failed - contact support" +checking_upload = "Checking upload..." +upload_speed = "{speed} Mbps up" + delete_recording_confirm = "Are you sure you want to delete this recording?" delete_screenshot_confirm = "Are you sure you want to delete this screenshot?" diff --git a/apps/desktop-gpui/locales/es.toml b/apps/desktop-gpui/locales/es.toml index 27fbf306d17..b1d3ca548d8 100644 --- a/apps/desktop-gpui/locales/es.toml +++ b/apps/desktop-gpui/locales/es.toml @@ -971,6 +971,12 @@ description = "Envía el enlace para compartir a Slack, Discord o tu propio webh name = "Avisar a Slack cuando compartas algo" [main] + +[main.network] +upload_check_failed = "Error en la comprobación de subida - contacte con soporte" +checking_upload = "Comprobando subida..." +upload_speed = "{speed} Mbps de subida" + delete_recording_confirm = "¿Seguro que quieres eliminar esta grabación?" delete_screenshot_confirm = "¿Seguro que quieres eliminar esta captura de pantalla?" diff --git a/apps/desktop-gpui/locales/fr.toml b/apps/desktop-gpui/locales/fr.toml index 87b46c59460..1910771e38f 100644 --- a/apps/desktop-gpui/locales/fr.toml +++ b/apps/desktop-gpui/locales/fr.toml @@ -971,6 +971,12 @@ description = "Envoyez le lien de partage à Slack, Discord ou votre propre webh name = "Prévenir Slack quand vous partagez" [main] + +[main.network] +upload_check_failed = "Échec du test de téléversement - contactez le support" +checking_upload = "Vérification du téléversement..." +upload_speed = "{speed} Mbps en téléversement" + delete_recording_confirm = "Voulez-vous vraiment supprimer cet enregistrement ?" delete_screenshot_confirm = "Voulez-vous vraiment supprimer cette capture d'écran ?" diff --git a/apps/desktop-gpui/locales/hi.toml b/apps/desktop-gpui/locales/hi.toml index 319ed6675bb..a022feecf49 100644 --- a/apps/desktop-gpui/locales/hi.toml +++ b/apps/desktop-gpui/locales/hi.toml @@ -971,6 +971,12 @@ description = "शेयर लिंक Slack, Discord या अपने व name = "कुछ शेयर करने पर Slack को बताएँ" [main] + +[main.network] +upload_check_failed = "अपलोड जांच विफल - सहायता से संपर्क करें" +checking_upload = "अपलोड जांचा जा रहा है..." +upload_speed = "{speed} Mbps अपलोड" + delete_recording_confirm = "क्या आप वाकई यह रिकॉर्डिंग हटाना चाहते हैं?" delete_screenshot_confirm = "क्या आप वाकई यह स्क्रीनशॉट हटाना चाहते हैं?" diff --git a/apps/desktop-gpui/locales/ja.toml b/apps/desktop-gpui/locales/ja.toml index d007debfd05..72857ed43ab 100644 --- a/apps/desktop-gpui/locales/ja.toml +++ b/apps/desktop-gpui/locales/ja.toml @@ -971,6 +971,12 @@ description = "共有リンクをSlack、Discord、または独自のWebhookに name = "共有したらSlackに知らせる" [main] + +[main.network] +upload_check_failed = "アップロードチェックに失敗しました - サポートにお問い合わせください" +checking_upload = "アップロードを確認中..." +upload_speed = "{speed} Mbps アップロード" + delete_recording_confirm = "この録画を削除してもよろしいですか?" delete_screenshot_confirm = "このスクリーンショットを削除してもよろしいですか?" diff --git a/apps/desktop-gpui/locales/zh-CN.toml b/apps/desktop-gpui/locales/zh-CN.toml index 922d4ee4a5d..d35d23baa6e 100644 --- a/apps/desktop-gpui/locales/zh-CN.toml +++ b/apps/desktop-gpui/locales/zh-CN.toml @@ -971,6 +971,12 @@ description = "将分享链接发送到 Slack、Discord 或你自己的 Webhook name = "分享内容时通知 Slack" [main] + +[main.network] +upload_check_failed = "上传检查失败 - 请联系支持" +checking_upload = "正在检查上传..." +upload_speed = "{speed} Mbps 上传" + delete_recording_confirm = "确定要删除这段录制吗?" delete_screenshot_confirm = "确定要删除这张截图吗?" diff --git a/apps/desktop-gpui/src/main_window.rs b/apps/desktop-gpui/src/main_window.rs index 95140791494..0a3ad97badc 100644 --- a/apps/desktop-gpui/src/main_window.rs +++ b/apps/desktop-gpui/src/main_window.rs @@ -3979,11 +3979,11 @@ impl MainWindow { let status = NetworkStatus::global(cx).read(cx); let failed = status.health == HealthStatus::Failed; let label = if failed { - crate::i18n::messages::localize("Upload check failed - contact support") + tr::main::network::upload_check_failed().to_string() } else { match status.upload_mbps { - Some(mbps) => format!("{mbps:.1} Mbps up"), - None => crate::i18n::messages::localize("Checking upload..."), + Some(mbps) => tr::main::network::upload_speed(&format!("{mbps:.1}")).to_string(), + None => tr::main::network::checking_upload().to_string(), } }; div() diff --git a/apps/desktop-gpui/src/network_status.rs b/apps/desktop-gpui/src/network_status.rs index 96d5992a94f..83e329efd17 100644 --- a/apps/desktop-gpui/src/network_status.rs +++ b/apps/desktop-gpui/src/network_status.rs @@ -1,10 +1,3 @@ -//! Upload speed test and startup health check. -//! -//! On startup the app uploads a small probe payload to the web app's -//! `/api/desktop/health-check` endpoint; a failure means uploading is broken -//! and the main window shows a warning. While idle (never during an active -//! recording) a periodic speed test measures the current upload bandwidth and -//! caps the studio recording quality to what the connection can sustain. use std::sync::{OnceLock, RwLock}; use std::time::{Duration, Instant}; @@ -179,7 +172,7 @@ async fn measure_upload_speed(status: &Entity, cx: &mut AsyncApp) } Ok(Err(AuthApiError::Timeout)) => { tracing::warn!("upload speed test timed out; capping quality to minimum tier"); - (Some(0.0), true) + (Some(0.0), false) } Ok(Err(AuthApiError::InvalidAuthentication)) => return, Ok(Err(error)) => { @@ -208,9 +201,6 @@ fn recording_active(cx: &mut AsyncApp) -> bool { cx.update(|cx| RecordingSession::global(cx).read(cx).phase != Phase::Idle) } -/// Runs the startup health check once, then measures upload speed on an -/// interval. Speed tests are skipped while a recording is active so the -/// probe never competes with a real upload. pub fn spawn_monitor(cx: &mut App) { let status = NetworkStatus::global(cx); cx.spawn(async move |cx| { From e0e0e25a0b1f0a3ca628198b7cbc7a1b0f3d5479 Mon Sep 17 00:00:00 2001 From: Ondrej Sedlak Date: Fri, 13 Nov 2026 16:10:23 +0100 Subject: [PATCH 5/5] fix: position network table after main delete messages in all catalogs --- apps/desktop-gpui/locales/de.toml | 9 +++++---- apps/desktop-gpui/locales/en.toml | 9 +++++---- apps/desktop-gpui/locales/es.toml | 9 +++++---- apps/desktop-gpui/locales/fr.toml | 9 +++++---- apps/desktop-gpui/locales/hi.toml | 9 +++++---- apps/desktop-gpui/locales/ja.toml | 9 +++++---- apps/desktop-gpui/locales/zh-CN.toml | 9 +++++---- 7 files changed, 35 insertions(+), 28 deletions(-) diff --git a/apps/desktop-gpui/locales/de.toml b/apps/desktop-gpui/locales/de.toml index a6fe2ec113d..0442aed1b1b 100644 --- a/apps/desktop-gpui/locales/de.toml +++ b/apps/desktop-gpui/locales/de.toml @@ -972,14 +972,15 @@ name = "Slack beim Teilen benachrichtigen" [main] + +delete_recording_confirm = "Möchten Sie diese Aufnahme wirklich löschen?" +delete_screenshot_confirm = "Möchten Sie dieses Bildschirmfoto wirklich löschen?" + [main.network] upload_check_failed = "Upload-Prüfung fehlgeschlagen - Support kontaktieren" checking_upload = "Upload wird geprüft..." upload_speed = "{speed} Mbps Upload" -delete_recording_confirm = "Möchten Sie diese Aufnahme wirklich löschen?" -delete_screenshot_confirm = "Möchten Sie dieses Bildschirmfoto wirklich löschen?" - [main.clean_capture] title = "Halten Sie Ihre Aufnahme sauber" @@ -3289,4 +3290,4 @@ no_resumable_state = "Für diese Aufnahme gibt es keinen fortsetzbaren Upload" verification_failed = "Die Prüfung der Aufnahme ist fehlgeschlagen: {status}" [message.cli] -binary_missing = "Mitgelieferte CLI unter {path} nicht gefunden" \ No newline at end of file +binary_missing = "Mitgelieferte CLI unter {path} nicht gefunden" diff --git a/apps/desktop-gpui/locales/en.toml b/apps/desktop-gpui/locales/en.toml index 956034a7188..bc1b5057ca7 100644 --- a/apps/desktop-gpui/locales/en.toml +++ b/apps/desktop-gpui/locales/en.toml @@ -974,14 +974,15 @@ name = "Tell Slack when you share something" [main] + +delete_recording_confirm = "Are you sure you want to delete this recording?" +delete_screenshot_confirm = "Are you sure you want to delete this screenshot?" + [main.network] upload_check_failed = "Upload check failed - contact support" checking_upload = "Checking upload..." upload_speed = "{speed} Mbps up" -delete_recording_confirm = "Are you sure you want to delete this recording?" -delete_screenshot_confirm = "Are you sure you want to delete this screenshot?" - [main.clean_capture] title = "Keep your capture clean" @@ -3291,4 +3292,4 @@ no_resumable_state = "This recording has no resumable upload state" verification_failed = { text = "Recording verification failed: {status}", context = "{status} is a technical status, usually in English." } [message.cli] -binary_missing = { text = "Bundled CLI binary not found at {path}", context = "{path} is a file path." } \ No newline at end of file +binary_missing = { text = "Bundled CLI binary not found at {path}", context = "{path} is a file path." } diff --git a/apps/desktop-gpui/locales/es.toml b/apps/desktop-gpui/locales/es.toml index b1d3ca548d8..edaeea35d59 100644 --- a/apps/desktop-gpui/locales/es.toml +++ b/apps/desktop-gpui/locales/es.toml @@ -972,14 +972,15 @@ name = "Avisar a Slack cuando compartas algo" [main] + +delete_recording_confirm = "¿Seguro que quieres eliminar esta grabación?" +delete_screenshot_confirm = "¿Seguro que quieres eliminar esta captura de pantalla?" + [main.network] upload_check_failed = "Error en la comprobación de subida - contacte con soporte" checking_upload = "Comprobando subida..." upload_speed = "{speed} Mbps de subida" -delete_recording_confirm = "¿Seguro que quieres eliminar esta grabación?" -delete_screenshot_confirm = "¿Seguro que quieres eliminar esta captura de pantalla?" - [main.clean_capture] title = "Mantén limpia tu captura" @@ -3289,4 +3290,4 @@ no_resumable_state = "Esta grabación no tiene una subida que se pueda reanudar" verification_failed = "La verificación de la grabación ha fallado: {status}" [message.cli] -binary_missing = "No se encontró la CLI incluida en {path}" \ No newline at end of file +binary_missing = "No se encontró la CLI incluida en {path}" diff --git a/apps/desktop-gpui/locales/fr.toml b/apps/desktop-gpui/locales/fr.toml index 1910771e38f..1b6bbc135eb 100644 --- a/apps/desktop-gpui/locales/fr.toml +++ b/apps/desktop-gpui/locales/fr.toml @@ -972,14 +972,15 @@ name = "Prévenir Slack quand vous partagez" [main] + +delete_recording_confirm = "Voulez-vous vraiment supprimer cet enregistrement ?" +delete_screenshot_confirm = "Voulez-vous vraiment supprimer cette capture d'écran ?" + [main.network] upload_check_failed = "Échec du test de téléversement - contactez le support" checking_upload = "Vérification du téléversement..." upload_speed = "{speed} Mbps en téléversement" -delete_recording_confirm = "Voulez-vous vraiment supprimer cet enregistrement ?" -delete_screenshot_confirm = "Voulez-vous vraiment supprimer cette capture d'écran ?" - [main.clean_capture] title = "Gardez votre capture propre" @@ -3289,4 +3290,4 @@ no_resumable_state = "Cet enregistrement n'a pas d'envoi pouvant reprendre" verification_failed = "La vérification de l'enregistrement a échoué : {status}" [message.cli] -binary_missing = "CLI intégrée introuvable dans {path}" \ No newline at end of file +binary_missing = "CLI intégrée introuvable dans {path}" diff --git a/apps/desktop-gpui/locales/hi.toml b/apps/desktop-gpui/locales/hi.toml index a022feecf49..a298d5f6d6b 100644 --- a/apps/desktop-gpui/locales/hi.toml +++ b/apps/desktop-gpui/locales/hi.toml @@ -972,14 +972,15 @@ name = "कुछ शेयर करने पर Slack को बताएँ" [main] + +delete_recording_confirm = "क्या आप वाकई यह रिकॉर्डिंग हटाना चाहते हैं?" +delete_screenshot_confirm = "क्या आप वाकई यह स्क्रीनशॉट हटाना चाहते हैं?" + [main.network] upload_check_failed = "अपलोड जांच विफल - सहायता से संपर्क करें" checking_upload = "अपलोड जांचा जा रहा है..." upload_speed = "{speed} Mbps अपलोड" -delete_recording_confirm = "क्या आप वाकई यह रिकॉर्डिंग हटाना चाहते हैं?" -delete_screenshot_confirm = "क्या आप वाकई यह स्क्रीनशॉट हटाना चाहते हैं?" - [main.clean_capture] title = "अपना कैप्चर साफ़ रखें" @@ -3289,4 +3290,4 @@ no_resumable_state = "इस रिकॉर्डिंग का कोई ऐ verification_failed = "रिकॉर्डिंग की जाँच विफल हुई: {status}" [message.cli] -binary_missing = "{path} पर बंडल की गई CLI नहीं मिली" \ No newline at end of file +binary_missing = "{path} पर बंडल की गई CLI नहीं मिली" diff --git a/apps/desktop-gpui/locales/ja.toml b/apps/desktop-gpui/locales/ja.toml index 72857ed43ab..9b716ac3a14 100644 --- a/apps/desktop-gpui/locales/ja.toml +++ b/apps/desktop-gpui/locales/ja.toml @@ -972,14 +972,15 @@ name = "共有したらSlackに知らせる" [main] + +delete_recording_confirm = "この録画を削除してもよろしいですか?" +delete_screenshot_confirm = "このスクリーンショットを削除してもよろしいですか?" + [main.network] upload_check_failed = "アップロードチェックに失敗しました - サポートにお問い合わせください" checking_upload = "アップロードを確認中..." upload_speed = "{speed} Mbps アップロード" -delete_recording_confirm = "この録画を削除してもよろしいですか?" -delete_screenshot_confirm = "このスクリーンショットを削除してもよろしいですか?" - [main.clean_capture] title = "余計なものを映さずに録画" @@ -3289,4 +3290,4 @@ no_resumable_state = "この録画には再開できるアップロード状態 verification_failed = "録画の確認に失敗しました:{status}" [message.cli] -binary_missing = "{path}に同梱のCLIが見つかりません" \ No newline at end of file +binary_missing = "{path}に同梱のCLIが見つかりません" diff --git a/apps/desktop-gpui/locales/zh-CN.toml b/apps/desktop-gpui/locales/zh-CN.toml index d35d23baa6e..7d5b5e8457a 100644 --- a/apps/desktop-gpui/locales/zh-CN.toml +++ b/apps/desktop-gpui/locales/zh-CN.toml @@ -972,14 +972,15 @@ name = "分享内容时通知 Slack" [main] + +delete_recording_confirm = "确定要删除这段录制吗?" +delete_screenshot_confirm = "确定要删除这张截图吗?" + [main.network] upload_check_failed = "上传检查失败 - 请联系支持" checking_upload = "正在检查上传..." upload_speed = "{speed} Mbps 上传" -delete_recording_confirm = "确定要删除这段录制吗?" -delete_screenshot_confirm = "确定要删除这张截图吗?" - [main.clean_capture] title = "保持画面整洁" @@ -3289,4 +3290,4 @@ no_resumable_state = "此录制没有可继续的上传状态" verification_failed = "录制验证失败:{status}" [message.cli] -binary_missing = "未在 {path} 找到内置的 CLI 程序" \ No newline at end of file +binary_missing = "未在 {path} 找到内置的 CLI 程序"