diff --git a/apps/desktop-gpui/locales/de.toml b/apps/desktop-gpui/locales/de.toml index c96d36f22a2..0442aed1b1b 100644 --- a/apps/desktop-gpui/locales/de.toml +++ b/apps/desktop-gpui/locales/de.toml @@ -971,9 +971,16 @@ description = "Den Link zum Teilen an Slack, Discord oder Ihren eigenen Webhook 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" + [main.clean_capture] title = "Halten Sie Ihre Aufnahme sauber" @@ -3283,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 29f268aa5c6..bc1b5057ca7 100644 --- a/apps/desktop-gpui/locales/en.toml +++ b/apps/desktop-gpui/locales/en.toml @@ -973,9 +973,16 @@ description = "Send the share link to Slack, Discord, or your own webhook." 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" + [main.clean_capture] title = "Keep your capture clean" @@ -3285,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 27fbf306d17..edaeea35d59 100644 --- a/apps/desktop-gpui/locales/es.toml +++ b/apps/desktop-gpui/locales/es.toml @@ -971,9 +971,16 @@ description = "Envía el enlace para compartir a Slack, Discord o tu propio webh 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" + [main.clean_capture] title = "Mantén limpia tu captura" @@ -3283,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 87b46c59460..1b6bbc135eb 100644 --- a/apps/desktop-gpui/locales/fr.toml +++ b/apps/desktop-gpui/locales/fr.toml @@ -971,9 +971,16 @@ description = "Envoyez le lien de partage à Slack, Discord ou votre propre webh 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" + [main.clean_capture] title = "Gardez votre capture propre" @@ -3283,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 319ed6675bb..a298d5f6d6b 100644 --- a/apps/desktop-gpui/locales/hi.toml +++ b/apps/desktop-gpui/locales/hi.toml @@ -971,9 +971,16 @@ description = "शेयर लिंक Slack, Discord या अपने व name = "कुछ शेयर करने पर Slack को बताएँ" [main] + + delete_recording_confirm = "क्या आप वाकई यह रिकॉर्डिंग हटाना चाहते हैं?" delete_screenshot_confirm = "क्या आप वाकई यह स्क्रीनशॉट हटाना चाहते हैं?" +[main.network] +upload_check_failed = "अपलोड जांच विफल - सहायता से संपर्क करें" +checking_upload = "अपलोड जांचा जा रहा है..." +upload_speed = "{speed} Mbps अपलोड" + [main.clean_capture] title = "अपना कैप्चर साफ़ रखें" @@ -3283,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 d007debfd05..9b716ac3a14 100644 --- a/apps/desktop-gpui/locales/ja.toml +++ b/apps/desktop-gpui/locales/ja.toml @@ -971,9 +971,16 @@ description = "共有リンクをSlack、Discord、または独自のWebhookに name = "共有したらSlackに知らせる" [main] + + delete_recording_confirm = "この録画を削除してもよろしいですか?" delete_screenshot_confirm = "このスクリーンショットを削除してもよろしいですか?" +[main.network] +upload_check_failed = "アップロードチェックに失敗しました - サポートにお問い合わせください" +checking_upload = "アップロードを確認中..." +upload_speed = "{speed} Mbps アップロード" + [main.clean_capture] title = "余計なものを映さずに録画" @@ -3283,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 922d4ee4a5d..7d5b5e8457a 100644 --- a/apps/desktop-gpui/locales/zh-CN.toml +++ b/apps/desktop-gpui/locales/zh-CN.toml @@ -971,9 +971,16 @@ description = "将分享链接发送到 Slack、Discord 或你自己的 Webhook name = "分享内容时通知 Slack" [main] + + delete_recording_confirm = "确定要删除这段录制吗?" delete_screenshot_confirm = "确定要删除这张截图吗?" +[main.network] +upload_check_failed = "上传检查失败 - 请联系支持" +checking_upload = "正在检查上传..." +upload_speed = "{speed} Mbps 上传" + [main.clean_capture] title = "保持画面整洁" @@ -3283,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/src/auth.rs b/apps/desktop-gpui/src/auth.rs index cfc44f06190..12fb2826c06 100644 --- a/apps/desktop-gpui/src/auth.rs +++ b/apps/desktop-gpui/src/auth.rs @@ -433,6 +433,41 @@ 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 = apply_desktop_headers(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..0a3ad97badc 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 { + tr::main::network::upload_check_failed().to_string() + } else { + match status.upload_mbps { + Some(mbps) => tr::main::network::upload_speed(&format!("{mbps:.1}")).to_string(), + None => tr::main::network::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..83e329efd17 --- /dev/null +++ b/apps/desktop-gpui/src/network_status.rs @@ -0,0 +1,264 @@ + +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 = 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())) +} + +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, + } +} + +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 + } +} + +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 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 + } + }; + 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 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) + } + Ok(Err(AuthApiError::Timeout)) => { + tracing::warn!("upload speed test timed out; capping quality to minimum tier"); + (Some(0.0), false) + } + Ok(Err(AuthApiError::InvalidAuthentication)) => return, + Ok(Err(error)) => { + tracing::warn!(error = %error, "upload speed test failed"); + (None, false) + } + Err(error) => { + tracing::warn!(error = %error, "upload speed test task failed"); + (None, false) + } + }; + 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(); + }); + }); +} + +fn recording_active(cx: &mut AsyncApp) -> bool { + cx.update(|cx| RecordingSession::global(cx).read(cx).phase != Phase::Idle) +} + +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..c0eb7068aa9 --- /dev/null +++ b/apps/web/app/api/desktop/[...route]/health-check.ts @@ -0,0 +1,30 @@ +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 contentLength = c.req.header("content-length"); + if (contentLength && parseInt(contentLength, 10) > 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: bytesReceived }); +}); 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)