From 137618356c2f9bee75d165f50f3e73927cd7030c Mon Sep 17 00:00:00 2001 From: duongynhi000005-oss Date: Mon, 2 Nov 2026 00:30:42 +0700 Subject: [PATCH] feat(desktop): add upload speed health check and adaptive instant mode resolution cap (Fixes #73) --- apps/desktop-gpui/src/auth.rs | 1 + apps/desktop-gpui/src/main.rs | 2 + apps/desktop-gpui/src/network_health.rs | 302 +++++++++++++++++++++++ apps/desktop-gpui/src/recording.rs | 2 +- apps/desktop-gpui/src/settings_window.rs | 39 +++ packages/web-backend/src/Http/Live.ts | 17 +- packages/web-domain/src/Http/Api.ts | 41 ++- 7 files changed, 400 insertions(+), 4 deletions(-) create mode 100644 apps/desktop-gpui/src/network_health.rs diff --git a/apps/desktop-gpui/src/auth.rs b/apps/desktop-gpui/src/auth.rs index 635dd4635a9..6993dacc3f6 100644 --- a/apps/desktop-gpui/src/auth.rs +++ b/apps/desktop-gpui/src/auth.rs @@ -446,6 +446,7 @@ pub fn process_auth_data(params: AuthParams) -> Result<(), String> { }))) { return Err("Failed to persist auth session".into()); } + crate::network_health::on_auth_changed(); Ok(()) } diff --git a/apps/desktop-gpui/src/main.rs b/apps/desktop-gpui/src/main.rs index e2edff04f36..9d042e27cc5 100644 --- a/apps/desktop-gpui/src/main.rs +++ b/apps/desktop-gpui/src/main.rs @@ -70,6 +70,7 @@ mod permissions_ui; mod picker_benchmark; #[cfg(debug_assertions)] mod picker_ui_benchmark; +mod network_health; mod platform; mod presets; mod project_save; @@ -505,6 +506,7 @@ fn main() { app_windows::schedule_launch_render_warmup(cx); upload::queue::init(cx); upload::instant_cleanup::init(cx); + network_health::init(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/network_health.rs b/apps/desktop-gpui/src/network_health.rs new file mode 100644 index 00000000000..cf8d762ebb2 --- /dev/null +++ b/apps/desktop-gpui/src/network_health.rs @@ -0,0 +1,302 @@ +//! Background upload-speed health check for Instant Mode resolution capping. + +use std::{ + sync::{Mutex, OnceLock}, + time::{Duration, Instant}, +}; + +use cap_recording::FREE_INSTANT_MODE_MAX_RESOLUTION; +use gpui::App; +use serde_json::json; + +pub(crate) const HD_INSTANT_MODE_MAX_RESOLUTION: u32 = 1080; +pub(crate) const QHD_INSTANT_MODE_MAX_RESOLUTION: u32 = 1440; +pub(crate) const UHD_INSTANT_MODE_MAX_RESOLUTION: u32 = 2160; + +const UPLOAD_HEALTH_PATH: &str = "/api/network/upload-health"; +const PROBE_PAYLOAD_BYTES: usize = 512 * 1024; +const PROBE_RETRY_COOLDOWN: Duration = Duration::from_secs(30); + +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub(crate) struct UploadHealthSnapshot { + pub(crate) testing: bool, + pub(crate) measured_mbps: Option, + pub(crate) recommended_max_resolution: Option, + pub(crate) last_attempt_at: Option, +} + +static STATE: OnceLock> = OnceLock::new(); + +fn state_lock() -> &'static Mutex { + STATE.get_or_init(|| Mutex::new(UploadHealthSnapshot::default())) +} + +pub(crate) fn snapshot() -> UploadHealthSnapshot { + *state_lock().lock().unwrap_or_else(|e| e.into_inner()) +} + +pub(crate) fn is_testing() -> bool { + snapshot().testing +} + +pub(crate) fn recommended_max_resolution_for_mbps(mbps: f64) -> u32 { + if !mbps.is_finite() || mbps <= 0.0 { + return UHD_INSTANT_MODE_MAX_RESOLUTION; + } + if mbps < 5.0 { + FREE_INSTANT_MODE_MAX_RESOLUTION + } else if mbps < 10.0 { + HD_INSTANT_MODE_MAX_RESOLUTION + } else if mbps < 25.0 { + QHD_INSTANT_MODE_MAX_RESOLUTION + } else { + UHD_INSTANT_MODE_MAX_RESOLUTION + } +} + +pub(crate) fn resolution_tier_label(resolution: u32) -> &'static str { + match resolution { + FREE_INSTANT_MODE_MAX_RESOLUTION => "720p", + HD_INSTANT_MODE_MAX_RESOLUTION => "1080p", + QHD_INSTANT_MODE_MAX_RESOLUTION => "1440p", + UHD_INSTANT_MODE_MAX_RESOLUTION => "4K", + _ => "720p", + } +} + +pub(crate) fn clamp_instant_resolution_with_probe( + desired: u32, + recommended_max: Option, +) -> u32 { + match recommended_max { + Some(cap) => desired.clamp( + FREE_INSTANT_MODE_MAX_RESOLUTION, + cap.max(FREE_INSTANT_MODE_MAX_RESOLUTION), + ), + None => desired, + } +} + +pub(crate) fn clamp_instant_resolution(desired: u32) -> u32 { + ensure_probe_if_unmeasured(); + clamp_instant_resolution_with_probe(desired, snapshot().recommended_max_resolution) +} + +pub(crate) fn active_cap_for(desired: u32) -> Option<(f64, u32)> { + ensure_probe_if_unmeasured(); + let snap = snapshot(); + let mbps = snap.measured_mbps?; + let cap = snap.recommended_max_resolution?; + let clamped = clamp_instant_resolution_with_probe(desired, Some(cap)); + (clamped < desired).then_some((mbps, clamped)) +} + +pub(crate) fn init(cx: &mut App) { + spawn_probe(cx); +} + +pub(crate) fn retest(cx: &mut App) { + spawn_probe(cx); +} + +pub(crate) fn on_auth_changed() { + { + let mut guard = state_lock().lock().unwrap_or_else(|e| e.into_inner()); + guard.measured_mbps = None; + guard.recommended_max_resolution = None; + guard.last_attempt_at = None; + } + spawn_detached_probe(); +} + +fn ensure_probe_if_unmeasured() { + if !crate::store::auth_snapshot().signed_in() { + return; + } + let should_spawn = { + let guard = state_lock().lock().unwrap_or_else(|e| e.into_inner()); + !guard.testing + && guard.measured_mbps.is_none() + && guard + .last_attempt_at + .is_none_or(|t| t.elapsed() >= PROBE_RETRY_COOLDOWN) + }; + if should_spawn { + spawn_detached_probe(); + } +} + +fn try_begin_probe() -> bool { + let mut guard = state_lock().lock().unwrap_or_else(|e| e.into_inner()); + if guard.testing { + return false; + } + guard.testing = true; + guard.last_attempt_at = Some(Instant::now()); + true +} + +fn finish_probe(result: Option) { + let mut guard = state_lock().lock().unwrap_or_else(|e| e.into_inner()); + guard.testing = false; + guard.measured_mbps = result; + guard.recommended_max_resolution = result.map(recommended_max_resolution_for_mbps); +} + +fn spawn_detached_probe() { + if !try_begin_probe() { + return; + } + let _ = std::thread::Builder::new() + .name("cap-upload-health-probe".into()) + .spawn(|| { + let result = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .ok() + .and_then(|rt| rt.block_on(run_upload_probe())); + finish_probe(result); + }); +} + +fn spawn_probe(cx: &mut App) { + if !try_begin_probe() { + return; + } + crate::app_windows::refresh_settings(cx); + + cx.spawn(async move |cx| { + let result = cx.background_executor().spawn(run_upload_probe()).await; + finish_probe(result); + let _ = cx.update(crate::app_windows::refresh_settings); + }) + .detach(); +} + +async fn run_upload_probe() -> Option { + if !crate::store::auth_snapshot().signed_in() { + return None; + } + let client = crate::auth::api_client().ok()?; + + // Warm up DNS, TCP/TLS handshake, and auth verification on the pooled + // connection before timing the throughput transfer. + let warmup = crate::auth::authed_request_with( + &client, + reqwest::Method::POST, + UPLOAD_HEALTH_PATH, + Some(json!({ "payload": "0" })), + ) + .await + .ok()?; + if !warmup.status().is_success() { + return None; + } + let _ = warmup.bytes().await; + + let payload = "0".repeat(PROBE_PAYLOAD_BYTES); + let body = json!({ "payload": payload }); + let start = Instant::now(); + let response = crate::auth::authed_request_with( + &client, + reqwest::Method::POST, + UPLOAD_HEALTH_PATH, + Some(body), + ) + .await + .ok()?; + + if !response.status().is_success() { + return None; + } + let _ = response.bytes().await.ok()?; + + let elapsed = start.elapsed().as_secs_f64(); + if elapsed <= 0.0 { + return None; + } + + let bits = (PROBE_PAYLOAD_BYTES as f64) * 8.0; + let mbps = bits / (elapsed * 1_000_000.0); + tracing::debug!( + mbps, + recommended_max_resolution = recommended_max_resolution_for_mbps(mbps), + "Upload speed health check completed" + ); + Some(mbps) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn maps_upload_speed_to_expected_resolution_caps() { + assert_eq!( + recommended_max_resolution_for_mbps(0.0), + UHD_INSTANT_MODE_MAX_RESOLUTION + ); + assert_eq!( + recommended_max_resolution_for_mbps(f64::NAN), + UHD_INSTANT_MODE_MAX_RESOLUTION + ); + assert_eq!( + recommended_max_resolution_for_mbps(4.99), + FREE_INSTANT_MODE_MAX_RESOLUTION + ); + assert_eq!( + recommended_max_resolution_for_mbps(5.0), + HD_INSTANT_MODE_MAX_RESOLUTION + ); + assert_eq!( + recommended_max_resolution_for_mbps(9.99), + HD_INSTANT_MODE_MAX_RESOLUTION + ); + assert_eq!( + recommended_max_resolution_for_mbps(10.0), + QHD_INSTANT_MODE_MAX_RESOLUTION + ); + assert_eq!( + recommended_max_resolution_for_mbps(24.99), + QHD_INSTANT_MODE_MAX_RESOLUTION + ); + assert_eq!( + recommended_max_resolution_for_mbps(25.0), + UHD_INSTANT_MODE_MAX_RESOLUTION + ); + } + + #[test] + fn clamps_desired_resolution_only_when_probe_recommends_lower_tier() { + assert_eq!( + clamp_instant_resolution_with_probe( + UHD_INSTANT_MODE_MAX_RESOLUTION, + Some(HD_INSTANT_MODE_MAX_RESOLUTION) + ), + HD_INSTANT_MODE_MAX_RESOLUTION + ); + assert_eq!( + clamp_instant_resolution_with_probe( + HD_INSTANT_MODE_MAX_RESOLUTION, + Some(QHD_INSTANT_MODE_MAX_RESOLUTION) + ), + HD_INSTANT_MODE_MAX_RESOLUTION + ); + assert_eq!( + clamp_instant_resolution_with_probe(UHD_INSTANT_MODE_MAX_RESOLUTION, None), + UHD_INSTANT_MODE_MAX_RESOLUTION + ); + } + + #[test] + fn failed_probe_clears_previous_cap() { + finish_probe(Some(6.0)); + assert_eq!( + snapshot().recommended_max_resolution, + Some(HD_INSTANT_MODE_MAX_RESOLUTION) + ); + finish_probe(None); + assert_eq!(snapshot().measured_mbps, None); + assert_eq!(snapshot().recommended_max_resolution, None); + } +} diff --git a/apps/desktop-gpui/src/recording.rs b/apps/desktop-gpui/src/recording.rs index c9658a5c016..0927b3e4d84 100644 --- a/apps/desktop-gpui/src/recording.rs +++ b/apps/desktop-gpui/src/recording.rs @@ -2475,7 +2475,7 @@ async fn start_attempt_with_upload( // time (`recording.rs:1634-1639`): free stays at the free cap even if a // stale store value says otherwise. let instant_max_resolution = if crate::store::auth_snapshot().is_upgraded() { - settings.instant_mode_max_resolution + crate::network_health::clamp_instant_resolution(settings.instant_mode_max_resolution) } else { cap_recording::FREE_INSTANT_MODE_MAX_RESOLUTION }; diff --git a/apps/desktop-gpui/src/settings_window.rs b/apps/desktop-gpui/src/settings_window.rs index 19b3740a460..054ff0da2ea 100644 --- a/apps/desktop-gpui/src/settings_window.rs +++ b/apps/desktop-gpui/src/settings_window.rs @@ -3492,6 +3492,45 @@ impl SettingsWindow { ); let mut rows = vec![resolution.into_any_element()]; + if self.has_cap_pro + && let Some((mbps, capped_res)) = crate::network_health::active_cap_for(effective) + { + let testing = crate::network_health::is_testing(); + let capped_label = crate::network_health::resolution_tier_label(capped_res); + let notice = format!( + "Capped at {capped_label} based on your upload speed ({mbps:.1} Mbps)." + ); + let retest_button = self + .button( + "instant-upload-health-retest", + (ui::ButtonVariant::Gray, Some("icons/rotate-ccw.svg")), + if testing { "Testing..." } else { "Re-test" }, + testing, + cx, + |_, _, cx| crate::network_health::retest(cx), + ) + .into_any_element(); + rows.push( + div() + .id("settings-instant-upload-health-notice") + .flex() + .flex_row() + .items_center() + .justify_between() + .gap(px(12.)) + .p(px(12.)) + .child( + div() + .flex_1() + .min_w_0() + .text_size(px(12.)) + .text_color(Hsla::from(theme.amber_11)) + .child(notice), + ) + .child(retest_button) + .into_any_element(), + ); + } if let Some(InstantQualityNotice::SaveFailed) = self.instant_quality_notice { rows.push( div() diff --git a/packages/web-backend/src/Http/Live.ts b/packages/web-backend/src/Http/Live.ts index 490739f51f1..de5f96aa1fe 100644 --- a/packages/web-backend/src/Http/Live.ts +++ b/packages/web-backend/src/Http/Live.ts @@ -1,9 +1,22 @@ import { Http } from "@cap/web-domain"; import { HttpApiBuilder } from "@effect/platform"; -import { Layer } from "effect"; +import { Effect, Layer } from "effect"; import { ExtensionHttpLive } from "../Extension/Http.ts"; +export const NetworkHttpLive = HttpApiBuilder.group( + Http.ApiContract, + "network", + (handlers) => + Effect.succeed( + handlers.handle("uploadHealth", ({ payload }) => + Effect.succeed({ + receivedBytes: payload.payload.length, + }), + ), + ), +); + export const HttpLive = HttpApiBuilder.api(Http.ApiContract).pipe( - Layer.provide(ExtensionHttpLive), + Layer.provide(Layer.mergeAll(ExtensionHttpLive, NetworkHttpLive)), ); diff --git a/packages/web-domain/src/Http/Api.ts b/packages/web-domain/src/Http/Api.ts index e827993b8cf..b0dfc62609f 100644 --- a/packages/web-domain/src/Http/Api.ts +++ b/packages/web-domain/src/Http/Api.ts @@ -1,8 +1,47 @@ -import { HttpApi, OpenApi } from "@effect/platform"; +import { + HttpApi, + HttpApiEndpoint, + HttpApiError, + HttpApiGroup, + OpenApi, +} from "@effect/platform"; +import { Schema } from "effect"; +import { HttpAuthMiddleware } from "../Authentication.ts"; import { EXTENSION_HTTP_PREFIX, ExtensionHttpApi } from "../Extension.ts"; +import { InternalServerError } from "./Errors.ts"; + +export const NETWORK_HTTP_PREFIX = "/network"; +export const MAX_UPLOAD_HEALTH_PROBE_BYTES = 1024 * 1024; + +export const UploadHealthProbeInput = Schema.Struct({ + payload: Schema.String.pipe( + Schema.maxLength(MAX_UPLOAD_HEALTH_PROBE_BYTES), + ), +}); + +export const UploadHealthProbeSuccess = Schema.Struct({ + receivedBytes: Schema.Int, +}); + +export class NetworkHttpApi extends HttpApiGroup.make("network") + .add( + HttpApiEndpoint.post("uploadHealth", "/upload-health") + .middleware(HttpAuthMiddleware) + .setPayload(UploadHealthProbeInput) + .addSuccess(UploadHealthProbeSuccess) + .addError(HttpApiError.BadRequest) + .addError(InternalServerError), + ) + .annotateContext( + OpenApi.annotations({ + title: "Network Health", + description: "Endpoints used to measure upload throughput.", + }), + ) {} export class ApiContract extends HttpApi.make("cap-web-api") .add(ExtensionHttpApi.prefix(EXTENSION_HTTP_PREFIX)) + .add(NetworkHttpApi.prefix(NETWORK_HTTP_PREFIX)) .annotateContext( OpenApi.annotations({ title: "Cap HTTP API",