From eb8e7b2e62e1b235de30fe8f419043ac0d934a3d Mon Sep 17 00:00:00 2001 From: Abraham Date: Sat, 14 Mar 2026 09:13:57 -0700 Subject: [PATCH] feat: add speed test and health check system for desktop app Implements upload speed testing with automatic quality adjustment and a startup health check that verifies server reachability, auth, and upload functionality. Addresses issue #73. Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/desktop/src-tauri/src/lib.rs | 9 + apps/desktop/src-tauri/src/recording.rs | 33 +- apps/desktop/src-tauri/src/speed_test.rs | 357 ++++++++++++++++++ apps/desktop/src/components/NetworkStatus.tsx | 193 ++++++++++ .../routes/(window-chrome)/new-main/index.tsx | 2 + .../api/desktop/[...route]/health-check.ts | 39 ++ apps/web/app/api/desktop/[...route]/route.ts | 2 + 7 files changed, 629 insertions(+), 6 deletions(-) create mode 100644 apps/desktop/src-tauri/src/speed_test.rs create mode 100644 apps/desktop/src/components/NetworkStatus.tsx create mode 100644 apps/web/app/api/desktop/[...route]/health-check.ts diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 303f7425aa..1ccf9f2c19 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -27,6 +27,7 @@ mod presets; mod recording; mod recording_settings; mod recovery; +mod speed_test; mod screenshot_editor; mod target_select_overlay; mod thumbnails; @@ -3141,6 +3142,9 @@ pub async fn run(recording_logging_handle: LoggingHandle, logs_dir: PathBuf) { recovery::find_incomplete_recordings, recovery::recover_recording, recovery::discard_incomplete_recording, + speed_test::run_speed_test, + speed_test::run_health_check, + speed_test::get_network_status, ]) .events(tauri_specta::collect_events![ RecordingOptionsChanged, @@ -3167,6 +3171,8 @@ pub async fn run(recording_logging_handle: LoggingHandle, logs_dir: PathBuf) { import::VideoImportProgress, SetCaptureAreaPending, DevicesUpdated, + speed_test::SpeedTestUpdate, + speed_test::HealthCheckUpdate, ]) .error_handling(tauri_specta::ErrorHandlingMode::Throw) .typ::() @@ -3310,6 +3316,7 @@ pub async fn run(recording_logging_handle: LoggingHandle, logs_dir: PathBuf) { app.manage(panel_manager::PanelManager::new()); app.manage(http_client::HttpClient::default()); app.manage(http_client::RetryableHttpClient::default()); + app.manage(Arc::new(RwLock::new(speed_test::NetworkState::default()))); app.manage(PendingScreenshots::default()); app.manage(FinalizingRecordings::default()); @@ -3456,6 +3463,8 @@ pub async fn run(recording_logging_handle: LoggingHandle, logs_dir: PathBuf) { audio_meter::spawn_event_emitter(app.clone(), mic_samples_rx); + speed_test::spawn_startup_health_check(app.clone()); + if let Err(err) = tray::create_tray(&app) { error!("Failed to create tray: {err}"); } diff --git a/apps/desktop/src-tauri/src/recording.rs b/apps/desktop/src-tauri/src/recording.rs index d65bce27fc..5e8acc9cdc 100644 --- a/apps/desktop/src-tauri/src/recording.rs +++ b/apps/desktop/src-tauri/src/recording.rs @@ -923,17 +923,34 @@ pub async fn start_recording( return Err(anyhow!("Video upload info not found")); }; + let settings_resolution = general_settings + .as_ref() + .map(|settings| settings.instant_mode_max_resolution) + .unwrap_or(1920); + + let speed_test_resolution = { + let network_state = app_handle + .state::>>(); + let state = network_state.read().await; + match &state.speed_test_status { + crate::speed_test::SpeedTestStatus::Completed(result) => { + Some(result.recommended_quality.max_resolution()) + } + _ => None, + } + }; + + let max_resolution = match speed_test_resolution { + Some(speed_res) => settings_resolution.min(speed_res), + None => settings_resolution, + }; + let mut builder = instant_recording::Actor::builder( recording_dir.clone(), inputs.capture_target.clone(), ) .with_system_audio(inputs.capture_system_audio) - .with_max_output_size( - general_settings - .as_ref() - .map(|settings| settings.instant_mode_max_resolution) - .unwrap_or(1920), - ); + .with_max_output_size(max_resolution); #[cfg(target_os = "macos")] { @@ -1044,6 +1061,8 @@ pub async fn start_recording( let _ = RecordingEvent::Started.emit(&app); let _ = RecordingStarted.emit(&app); + crate::speed_test::set_recording_active(&app, true).await; + spawn_actor({ let app = app.clone(); let state_mtx = Arc::clone(&state_mtx); @@ -1235,6 +1254,8 @@ fn mic_actor_not_running(err: &anyhow::Error) -> bool { #[specta::specta] #[instrument(skip(app, state))] pub async fn stop_recording(app: AppHandle, state: MutableState<'_, App>) -> Result<(), String> { + crate::speed_test::set_recording_active(&app, false).await; + let mut state = state.write().await; let Some(current_recording) = state.clear_current_recording() else { return Err("Recording not in progress".to_string())?; diff --git a/apps/desktop/src-tauri/src/speed_test.rs b/apps/desktop/src-tauri/src/speed_test.rs new file mode 100644 index 0000000000..75da4af663 --- /dev/null +++ b/apps/desktop/src-tauri/src/speed_test.rs @@ -0,0 +1,357 @@ +use crate::web_api::ManagerExt; +use serde::{Deserialize, Serialize}; +use specta::Type; +use std::sync::Arc; +use std::time::Duration; +use tauri::{AppHandle, Manager}; +use tauri_specta::Event; +use tokio::sync::RwLock; +use tokio::time::Instant; +use tracing::{info, warn}; + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, Type, PartialEq)] +#[serde(rename_all = "camelCase")] +pub enum UploadQualityPreset { + Full, + High, + Medium, + Low, +} + +impl UploadQualityPreset { + pub fn max_resolution(&self) -> u32 { + match self { + Self::Full => 3840, + Self::High => 1920, + Self::Medium => 1280, + Self::Low => 854, + } + } + + pub fn from_speed_mbps(speed: f64) -> Self { + if speed >= 20.0 { + Self::Full + } else if speed >= 10.0 { + Self::High + } else if speed >= 5.0 { + Self::Medium + } else { + Self::Low + } + } + + pub fn label(&self) -> &'static str { + match self { + Self::Full => "Full (4K)", + Self::High => "High (1080p)", + Self::Medium => "Medium (720p)", + Self::Low => "Low (480p)", + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct SpeedTestResult { + pub upload_speed_mbps: f64, + pub recommended_quality: UploadQualityPreset, + pub timestamp_ms: u64, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct HealthCheckResult { + pub server_reachable: bool, + pub auth_valid: bool, + pub upload_functional: bool, + pub message: String, + pub timestamp_ms: u64, +} + +#[derive(Clone, Serialize, Type, tauri_specta::Event)] +#[serde(rename_all = "camelCase")] +pub struct SpeedTestUpdate { + pub status: SpeedTestStatus, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub enum SpeedTestStatus { + Idle, + Running, + Completed(SpeedTestResult), + Failed(String), +} + +#[derive(Clone, Serialize, Type, tauri_specta::Event)] +#[serde(rename_all = "camelCase")] +pub struct HealthCheckUpdate { + pub result: HealthCheckResult, +} + +pub struct NetworkState { + pub speed_test_status: SpeedTestStatus, + pub health_check_result: Option, + pub is_recording: bool, +} + +impl Default for NetworkState { + fn default() -> Self { + Self { + speed_test_status: SpeedTestStatus::Idle, + health_check_result: None, + is_recording: false, + } + } +} + +fn now_millis() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis() as u64) + .unwrap_or(0) +} + +const SPEED_TEST_PAYLOAD_SIZE: usize = 1024 * 1024; + +async fn measure_upload_speed(app: &AppHandle) -> Result { + let payload = vec![0u8; SPEED_TEST_PAYLOAD_SIZE]; + + let start = Instant::now(); + + let response = app + .api_request("/api/desktop/health-check", |c, url| { + c.post(url) + .header("Content-Type", "application/octet-stream") + .header("X-Speed-Test", "true") + .body(payload) + .timeout(Duration::from_secs(30)) + }) + .await + .map_err(|e| format!("Speed test request failed: {e}"))?; + + let elapsed = start.elapsed(); + + if !response.status().is_success() { + let status = response.status().as_u16(); + return Err(format!("Speed test endpoint returned {status}")); + } + + let bytes_sent = SPEED_TEST_PAYLOAD_SIZE as f64; + let seconds = elapsed.as_secs_f64(); + let bits_per_second = (bytes_sent * 8.0) / seconds; + let mbps = bits_per_second / 1_000_000.0; + + Ok(mbps) +} + +async fn check_server_health(app: &AppHandle) -> HealthCheckResult { + let server_reachable = match app + .api_request("/api/desktop/health-check", |c, url| { + c.get(url).timeout(Duration::from_secs(10)) + }) + .await + { + Ok(resp) => resp.status().is_success(), + Err(_) => false, + }; + + if !server_reachable { + return HealthCheckResult { + server_reachable: false, + auth_valid: false, + upload_functional: false, + message: "Cannot reach Cap server. Check your internet connection.".to_string(), + timestamp_ms: now_millis(), + }; + } + + let auth_valid = match app + .authed_api_request("/api/desktop/health-check", |c, url| { + c.get(url) + .header("X-Auth-Check", "true") + .timeout(Duration::from_secs(10)) + }) + .await + { + Ok(resp) => resp.status().is_success(), + Err(_) => false, + }; + + let upload_functional = if auth_valid { + let test_payload = vec![0u8; 1024]; + match app + .authed_api_request("/api/desktop/health-check", |c, url| { + c.post(url) + .header("Content-Type", "application/octet-stream") + .header("X-Upload-Test", "true") + .body(test_payload) + .timeout(Duration::from_secs(15)) + }) + .await + { + Ok(resp) => resp.status().is_success(), + Err(_) => false, + } + } else { + false + }; + + let message = if !auth_valid { + "Sign in to enable cloud uploads.".to_string() + } else if !upload_functional { + "Upload test failed. Please contact support.".to_string() + } else { + "All systems operational.".to_string() + }; + + HealthCheckResult { + server_reachable, + auth_valid, + upload_functional, + message, + timestamp_ms: now_millis(), + } +} + +#[tauri::command] +#[specta::specta] +pub async fn run_speed_test(app: AppHandle) -> Result { + let network_state = app.state::>>(); + + { + let state = network_state.read().await; + if state.is_recording { + return Err("Cannot run speed test during an active recording".to_string()); + } + if matches!(state.speed_test_status, SpeedTestStatus::Running) { + return Err("Speed test is already running".to_string()); + } + } + + { + let mut state = network_state.write().await; + state.speed_test_status = SpeedTestStatus::Running; + } + SpeedTestUpdate { + status: SpeedTestStatus::Running, + } + .emit(&app) + .ok(); + + match measure_upload_speed(&app).await { + Ok(speed_mbps) => { + let result = SpeedTestResult { + upload_speed_mbps: (speed_mbps * 100.0).round() / 100.0, + recommended_quality: UploadQualityPreset::from_speed_mbps(speed_mbps), + timestamp_ms: now_millis(), + }; + + info!( + speed_mbps = result.upload_speed_mbps, + quality = ?result.recommended_quality, + "Speed test completed" + ); + + { + let mut state = network_state.write().await; + state.speed_test_status = SpeedTestStatus::Completed(result.clone()); + } + SpeedTestUpdate { + status: SpeedTestStatus::Completed(result.clone()), + } + .emit(&app) + .ok(); + + Ok(result) + } + Err(err) => { + warn!(error = %err, "Speed test failed"); + + { + let mut state = network_state.write().await; + state.speed_test_status = SpeedTestStatus::Failed(err.clone()); + } + SpeedTestUpdate { + status: SpeedTestStatus::Failed(err.clone()), + } + .emit(&app) + .ok(); + + Err(err) + } + } +} + +#[tauri::command] +#[specta::specta] +pub async fn run_health_check(app: AppHandle) -> Result { + let result = check_server_health(&app).await; + + info!( + server_reachable = result.server_reachable, + auth_valid = result.auth_valid, + upload_functional = result.upload_functional, + message = %result.message, + "Health check completed" + ); + + let network_state = app.state::>>(); + { + let mut state = network_state.write().await; + state.health_check_result = Some(result.clone()); + } + + HealthCheckUpdate { + result: result.clone(), + } + .emit(&app) + .ok(); + + Ok(result) +} + +#[tauri::command] +#[specta::specta] +pub async fn get_network_status( + app: AppHandle, +) -> Result<(SpeedTestStatus, Option), String> { + let network_state = app.state::>>(); + let state = network_state.read().await; + Ok(( + state.speed_test_status.clone(), + state.health_check_result.clone(), + )) +} + +pub async fn set_recording_active(app: &AppHandle, active: bool) { + let network_state = app.state::>>(); + let mut state = network_state.write().await; + state.is_recording = active; +} + +pub fn spawn_startup_health_check(app: AppHandle) { + tokio::spawn(async move { + tokio::time::sleep(Duration::from_secs(3)).await; + + let result = check_server_health(&app).await; + info!( + server_reachable = result.server_reachable, + auth_valid = result.auth_valid, + upload_functional = result.upload_functional, + "Startup health check completed" + ); + + let network_state = app.state::>>(); + { + let mut state = network_state.write().await; + state.health_check_result = Some(result.clone()); + } + + HealthCheckUpdate { + result: result.clone(), + } + .emit(&app) + .ok(); + }); +} diff --git a/apps/desktop/src/components/NetworkStatus.tsx b/apps/desktop/src/components/NetworkStatus.tsx new file mode 100644 index 0000000000..772c092575 --- /dev/null +++ b/apps/desktop/src/components/NetworkStatus.tsx @@ -0,0 +1,193 @@ +import { createMutation } from "@tanstack/solid-query"; +import { cx } from "cva"; +import { + createEffect, + createSignal, + onCleanup, + onMount, + Show, +} from "solid-js"; +import Tooltip from "~/components/Tooltip"; +import { + commands, + events, + type HealthCheckResult, + type SpeedTestStatus, +} from "~/utils/tauri"; +import IconLucideActivity from "~icons/lucide/activity"; +import IconLucideGauge from "~icons/lucide/gauge"; +import IconLucideCheck from "~icons/lucide/check"; +import IconLucideAlertTriangle from "~icons/lucide/alert-triangle"; +import IconLucideLoader from "~icons/lucide/loader-2"; + +export default function NetworkStatus() { + const [speedStatus, setSpeedStatus] = createSignal("idle"); + const [healthResult, setHealthResult] = + createSignal(null); + + onMount(async () => { + try { + const [status, health] = await commands.getNetworkStatus(); + setSpeedStatus(status); + setHealthResult(health ?? null); + } catch (e) { + console.error("Failed to get network status:", e); + } + }); + + onMount(async () => { + const unlistenSpeed = await events.speedTestUpdate.listen((event) => { + setSpeedStatus(event.payload.status); + }); + + const unlistenHealth = await events.healthCheckUpdate.listen((event) => { + setHealthResult(event.payload.result); + }); + + onCleanup(() => { + unlistenSpeed(); + unlistenHealth(); + }); + }); + + const speedTest = createMutation(() => ({ + mutationKey: ["speed-test"], + mutationFn: async () => { + return await commands.runSpeedTest(); + }, + })); + + const healthCheck = createMutation(() => ({ + mutationKey: ["health-check"], + mutationFn: async () => { + return await commands.runHealthCheck(); + }, + })); + + const speedLabel = () => { + const status = speedStatus(); + if (status === "idle") return "Speed: --"; + if (status === "running") return "Testing..."; + if (typeof status === "object" && "completed" in status) { + return `${status.completed.uploadSpeedMbps} Mbps`; + } + if (typeof status === "object" && "failed" in status) { + return "Speed: Error"; + } + return "Speed: --"; + }; + + const speedQuality = () => { + const status = speedStatus(); + if (typeof status === "object" && "completed" in status) { + return status.completed.recommendedQuality; + } + return null; + }; + + const qualityColor = () => { + const q = speedQuality(); + if (!q) return "text-gray-10"; + if (q === "full" || q === "high") return "text-green-10"; + if (q === "medium") return "text-yellow-10"; + return "text-red-10"; + }; + + const healthOk = () => { + const result = healthResult(); + if (!result) return null; + return result.serverReachable && result.authValid && result.uploadFunctional; + }; + + const healthColor = () => { + const ok = healthOk(); + if (ok === null) return "text-gray-10"; + if (ok) return "text-green-10"; + return "text-red-10"; + }; + + const healthMessage = () => { + const result = healthResult(); + if (!result) return "Health check not run yet"; + return result.message; + }; + + const isSpeedRunning = () => { + const status = speedStatus(); + return status === "running"; + }; + + return ( +
+ + + + +
+ + + + +
+ ); +} 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 bfd7e51126..4b5986c614 100644 --- a/apps/desktop/src/routes/(window-chrome)/new-main/index.tsx +++ b/apps/desktop/src/routes/(window-chrome)/new-main/index.tsx @@ -32,6 +32,7 @@ import { import { createStore, produce, reconcile } from "solid-js/store"; import { Transition } from "solid-transition-group"; import Mode from "~/components/Mode"; +import NetworkStatus from "~/components/NetworkStatus"; import { RecoveryToast } from "~/components/RecoveryToast"; import Tooltip from "~/components/Tooltip"; import { Input } from "~/routes/editor/ui"; @@ -1983,6 +1984,7 @@ function Page() {
+ ); } 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 0000000000..acfa97ad87 --- /dev/null +++ b/apps/web/app/api/desktop/[...route]/health-check.ts @@ -0,0 +1,39 @@ +import { Hono } from "hono"; +import { withOptionalAuth } from "../../utils"; + +export const app = new Hono(); + +app.get("/", withOptionalAuth, async (c) => { + const isAuthCheck = c.req.header("X-Auth-Check") === "true"; + const user = c.get("user"); + + if (isAuthCheck && !user) { + return c.json({ ok: false, message: "Not authenticated" }, 401); + } + + return c.json({ + ok: true, + timestamp: Date.now(), + authenticated: !!user, + }); +}); + +app.post("/", withOptionalAuth, async (c) => { + const isSpeedTest = c.req.header("X-Speed-Test") === "true"; + const isUploadTest = c.req.header("X-Upload-Test") === "true"; + + if (isSpeedTest || isUploadTest) { + try { + const body = await c.req.arrayBuffer(); + return c.json({ + ok: true, + bytesReceived: body.byteLength, + timestamp: Date.now(), + }); + } catch { + return c.json({ ok: false, message: "Failed to process upload" }, 500); + } + } + + return c.json({ ok: 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 d1a156d4d7..0995fba985 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"; @@ -11,6 +12,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("/video", video.app)