diff --git a/apps/desktop-gpui/src/power.rs b/apps/desktop-gpui/src/power.rs index b23d9c3b472..6edc7fdc030 100644 --- a/apps/desktop-gpui/src/power.rs +++ b/apps/desktop-gpui/src/power.rs @@ -3,21 +3,100 @@ //! (lid closed, Sleep chosen, battery empty) the recording is paused first, so //! capture never dies inside sleep, and it waits paused for the user on wake. +use std::time::{Duration, Instant}; + +use cap_recording::upload_probe::{ + CALIBRATION_PROBE_BYTES, DEFAULT_PROBE_PAYLOAD_BYTES, SpeedProbeSample, build_probe_payload, + record_startup_health, should_run_speed_probe, +}; use gpui::App; -use crate::session::Phase; +use crate::session::{Phase, RecordingSession}; + +struct ProbeEndpoint<'a> { + client: &'a reqwest::Client, + url: &'a str, + token: &'a str, +} pub(crate) fn holds_off_idle_sleep(phase: Phase) -> bool { phase != Phase::Idle } pub fn install(cx: &mut App) { + spawn_startup_upload_probe(cx); #[cfg(target_os = "macos")] macos::install(cx); #[cfg(not(target_os = "macos"))] let _ = cx; } +fn spawn_startup_upload_probe(cx: &mut App) { + let phase = RecordingSession::global(cx).read(cx).phase; + let recording_active = holds_off_idle_sleep(phase); + if !should_run_speed_probe(recording_active) { + return; + } + gpui_tokio::Tokio::spawn(cx, run_startup_upload_probe()).detach(); +} + +async fn check_storage_ready(endpoint: &ProbeEndpoint<'_>) -> bool { + let req = crate::auth::apply_desktop_headers(endpoint.client.get(endpoint.url)) + .bearer_auth(endpoint.token); + req.send() + .await + .map(|resp| resp.status().is_success()) + .unwrap_or(false) +} + +async fn measure_probe_post(endpoint: &ProbeEndpoint<'_>, payload_bytes: usize) -> Option { + let payload = build_probe_payload(payload_bytes); + let started = Instant::now(); + let req = crate::auth::apply_desktop_headers(endpoint.client.post(endpoint.url)) + .bearer_auth(endpoint.token) + .header("Content-Type", "application/octet-stream") + .body(payload); + let resp = req.send().await.ok()?; + resp.status().is_success().then(|| started.elapsed()) +} + +async fn run_startup_upload_probe() { + let auth = crate::store::auth_snapshot(); + let Some(token) = auth.token.filter(|token| !token.is_empty()) else { + return; + }; + let Ok(client) = crate::auth::api_client() else { + return; + }; + let server = crate::auth::server_url(); + let health_url = format!("{server}/api/upload/health-check"); + let endpoint = ProbeEndpoint { + client: &client, + url: &health_url, + token: &token, + }; + + if !check_storage_ready(&endpoint).await { + record_startup_health(false, None); + return; + } + let Some(baseline_overhead) = measure_probe_post(&endpoint, CALIBRATION_PROBE_BYTES).await else { + record_startup_health(false, None); + return; + }; + let sample = measure_probe_post(&endpoint, DEFAULT_PROBE_PAYLOAD_BYTES) + .await + .map(|upload_elapsed| { + SpeedProbeSample::from_differential( + DEFAULT_PROBE_PAYLOAD_BYTES, + CALIBRATION_PROBE_BYTES, + upload_elapsed, + baseline_overhead, + ) + }); + record_startup_health(true, sample); +} + #[cfg(target_os = "macos")] mod macos { use std::{ diff --git a/apps/desktop-gpui/src/recording.rs b/apps/desktop-gpui/src/recording.rs index 1b7e1cabd45..2023fa651b4 100644 --- a/apps/desktop-gpui/src/recording.rs +++ b/apps/desktop-gpui/src/recording.rs @@ -2518,23 +2518,25 @@ async fn start_attempt_with_upload( // the user's persisted settings, applied through the same shared // `RecordingDefaults` seam so both apps build recordings identically. let settings = crate::store::GeneralSettings::load(); - let defaults = cap_recording::RecordingDefaults { - custom_cursor_capture: settings.custom_cursor_capture, - 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(), - out_of_process_muxer: settings.out_of_process_muxer, - instant_mode_max_resolution: cap_recording::DEFAULT_INSTANT_MODE_MAX_RESOLUTION, - }; // The instant output cap is plan-gated at start time, not at settings // 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() { + let base_instant_max_resolution = if crate::store::auth_snapshot().is_upgraded() { settings.instant_mode_max_resolution } else { cap_recording::FREE_INSTANT_MODE_MAX_RESOLUTION }; + let defaults = cap_recording::RecordingDefaults { + custom_cursor_capture: settings.custom_cursor_capture, + 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(), + out_of_process_muxer: settings.out_of_process_muxer, + instant_mode_max_resolution: base_instant_max_resolution, + } + .apply_detected_upload_speed(); + let instant_max_resolution = defaults.instant_mode_max_resolution; #[cfg(target_os = "macos")] let excluded_windows = { diff --git a/apps/web/app/api/upload/health-check/route.test.ts b/apps/web/app/api/upload/health-check/route.test.ts new file mode 100644 index 00000000000..b86a90e2ee0 --- /dev/null +++ b/apps/web/app/api/upload/health-check/route.test.ts @@ -0,0 +1,152 @@ +import { Storage } from "@cap/web-backend"; +import { HttpAuthMiddleware, Organisation, User } from "@cap/web-domain"; +import { Effect, Layer, Option } from "effect"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + rateLimited: false, + storageHealthy: true, + deleteHealthy: true, + putObject: vi.fn(), + deleteObject: vi.fn(), +})); + +vi.mock("server-only", () => ({})); +vi.mock("@/lib/rate-limit", () => ({ + isRateLimited: vi.fn(async () => mocks.rateLimited), +})); +vi.mock("@/lib/server", async () => { + const { HttpApiBuilder, HttpServer } = await import("@effect/platform"); + const authLayer = Layer.succeed( + HttpAuthMiddleware, + Effect.succeed({ + id: User.UserId.make("user_test"), + email: "user@cap.so", + activeOrganizationId: Organisation.OrganisationId.make("org_test"), + iconUrlOrKey: Option.none(), + }), + ); + const storageLayer = Layer.succeed(Storage, { + getWritableAccessForUser: () => + mocks.storageHealthy + ? Effect.succeed({ + access: { + putObject: (...args: unknown[]) => { + mocks.putObject(...args); + return Effect.void; + }, + deleteObject: (...args: unknown[]) => { + mocks.deleteObject(...args); + return mocks.deleteHealthy + ? Effect.void + : Effect.fail(new Error("Delete failed")); + }, + }, + bucketId: Option.none(), + storageIntegrationId: Option.none(), + }) + : Effect.fail(new Error("Storage offline")), + } as never); + return { + apiToHandler: (api: Parameters[0]) => { + const { handler } = HttpApiBuilder.toWebHandler( + Layer.mergeAll( + Layer.provide(api as never, Layer.mergeAll(authLayer, storageLayer)), + HttpServer.layerContext, + ) as never, + ); + return (request: Request) => handler(request); + }, + }; +}); + +const { GET, POST } = await import("@/app/api/upload/health-check/route"); + +describe("Cap desktop upload health-check API route", () => { + beforeEach(() => { + mocks.rateLimited = false; + mocks.storageHealthy = true; + mocks.deleteHealthy = true; + mocks.putObject.mockClear(); + mocks.deleteObject.mockClear(); + }); + + it("verifies POST storage write/delete and rejects invalid or cross-site probes", async () => { + const validPayload = new Uint8Array(1024).fill(42); + const okRes = await POST( + new Request("http://localhost/api/upload/health-check", { + method: "POST", + body: validPayload, + }), + ); + expect(okRes.status).toBe(200); + const okJson = await okRes.json(); + expect(okJson.ok).toBe(true); + expect(okJson.bytesReceived).toBe(1024); + expect(mocks.putObject).toHaveBeenCalledTimes(1); + expect(mocks.deleteObject).toHaveBeenCalledTimes(1); + + const crossSiteRes = await POST( + new Request("http://localhost/api/upload/health-check", { + method: "POST", + headers: { "sec-fetch-site": "cross-site" }, + body: validPayload, + }), + ); + expect(crossSiteRes.status).toBe(403); + + const smallRes = await POST( + new Request("http://localhost/api/upload/health-check", { + method: "POST", + body: new Uint8Array(4), + }), + ); + expect(smallRes.status).toBe(400); + + const oversizedRes = await POST( + new Request("http://localhost/api/upload/health-check", { + method: "POST", + headers: { "content-length": "1048576" }, + body: new Uint8Array(32), + }), + ); + expect(oversizedRes.status).toBe(413); + + mocks.deleteHealthy = false; + const deleteFailedRes = await POST( + new Request("http://localhost/api/upload/health-check", { + method: "POST", + body: validPayload, + }), + ); + expect(deleteFailedRes.status).toBe(503); + }); + + it("keeps GET strictly read-only without bucket mutations", async () => { + const getRes = await GET( + new Request("http://localhost/api/upload/health-check", { + method: "GET", + }), + ); + expect(getRes.status).toBe(200); + expect(mocks.putObject).not.toHaveBeenCalled(); + expect(mocks.deleteObject).not.toHaveBeenCalled(); + + const crossSiteGetRes = await GET( + new Request("http://localhost/api/upload/health-check", { + method: "GET", + headers: { "sec-fetch-site": "cross-site" }, + }), + ); + expect(crossSiteGetRes.status).toBe(403); + + mocks.rateLimited = true; + const limitedGetRes = await GET( + new Request("http://localhost/api/upload/health-check", { + method: "GET", + }), + ); + expect(limitedGetRes.status).toBe(429); + expect(mocks.putObject).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/app/api/upload/health-check/route.ts b/apps/web/app/api/upload/health-check/route.ts new file mode 100644 index 00000000000..1f925c56fa3 --- /dev/null +++ b/apps/web/app/api/upload/health-check/route.ts @@ -0,0 +1,267 @@ +import { Storage } from "@cap/web-backend"; +import { CurrentUser, HttpAuthMiddleware } from "@cap/web-domain"; +import { + HttpApi, + HttpApiBuilder, + HttpApiEndpoint, + HttpApiGroup, + HttpServerRequest, + HttpServerResponse, +} from "@effect/platform"; +import { Effect, Layer, Option } from "effect"; +import { isRateLimited } from "@/lib/rate-limit"; +import { apiToHandler } from "@/lib/server"; + +export interface UploadHealthCheckResponse { + ok: boolean; + status: "healthy" | "degraded"; + bytesReceived: number; + serverTimestampMs: number; + message: string; +} + +const MIN_PROBE_BYTES = 16; +const MAX_PROBE_BYTES = 262_144; +const HEALTH_CHECK_RULE_ID = "rl_upload_health_check"; + +class Api extends HttpApi.make("UploadHealthCheckApi").add( + HttpApiGroup.make("root") + .add( + HttpApiEndpoint.post("probeUploadHealth")`/api/upload/health-check`.middleware( + HttpAuthMiddleware, + ), + ) + .add( + HttpApiEndpoint.get("getUploadHealth")`/api/upload/health-check`.middleware( + HttpAuthMiddleware, + ), + ), +) {} + +const jsonResponse = ( + payload: Omit, + status: number, +) => + HttpServerResponse.text( + JSON.stringify({ ...payload, serverTimestampMs: Date.now() }), + { + status, + contentType: "application/json; charset=utf-8", + headers: { "Cache-Control": "private, no-store" }, + }, + ); + +const isCrossSiteRequest = (request: HttpServerRequest.HttpServerRequest): boolean => { + const headers = request.headers as Record; + const fetchSite = (headers["sec-fetch-site"] ?? "").toLowerCase(); + if (fetchSite === "cross-site") { + return true; + } + const origin = headers.origin; + const host = headers.host; + if (!origin || !host) { + return false; + } + try { + return new URL(origin).host !== host; + } catch { + return true; + } +}; + +const verifyUserStorageWrite = ( + userId: CurrentUser["Type"]["id"], + organizationId: CurrentUser["Type"]["activeOrganizationId"], + probeSlice: Uint8Array, +) => + Effect.gen(function* () { + const writable = yield* Storage.getWritableAccessForUser( + userId, + organizationId, + ); + const probeKey = `${userId}/.health-check/probe.bin`; + yield* writable.access.putObject(probeKey, probeSlice, { + contentType: "application/octet-stream", + contentLength: probeSlice.byteLength, + }); + yield* writable.access.deleteObject(probeKey); + }); + +const verifyUserStorageReadyReadOnly = ( + userId: CurrentUser["Type"]["id"], + organizationId: CurrentUser["Type"]["activeOrganizationId"], +) => + Effect.gen(function* () { + yield* Storage.getWritableAccessForUser(userId, organizationId); + }); + +const checkProbeRateLimit = ( + userId: CurrentUser["Type"]["id"], + request: HttpServerRequest.HttpServerRequest, +) => + Effect.tryPromise(() => + isRateLimited(HEALTH_CHECK_RULE_ID, { + key: userId, + headers: new Headers(request.headers as Record), + }), + ).pipe(Effect.orElseSucceed(() => false)); + +const rateLimitedResponse = () => + jsonResponse( + { + ok: false, + status: "degraded", + bytesReceived: 0, + message: "Rate limit exceeded for upload health probe.", + }, + 429, + ); + +const crossSiteForbiddenResponse = () => + jsonResponse( + { + ok: false, + status: "degraded", + bytesReceived: 0, + message: "Cross-site upload health probe requests are not permitted.", + }, + 403, + ); + +const readBoundedProbeBody = (request: HttpServerRequest.HttpServerRequest) => + Effect.gen(function* () { + const declared = Number(request.headers["content-length"] ?? "0"); + if (Number.isFinite(declared) && declared > MAX_PROBE_BYTES) { + return null; + } + return yield* request.arrayBuffer.pipe( + HttpServerRequest.withMaxBodySize(Option.some(MAX_PROBE_BYTES)), + Effect.map((buf) => new Uint8Array(buf)), + Effect.catchAll(() => Effect.succeed(null)), + ); + }); + +const handleProbePost = () => + Effect.gen(function* () { + const user = yield* CurrentUser; + const request = yield* HttpServerRequest.HttpServerRequest; + if (isCrossSiteRequest(request)) { + return crossSiteForbiddenResponse(); + } + if (yield* checkProbeRateLimit(user.id, request)) { + return rateLimitedResponse(); + } + + const bodyBuffer = yield* readBoundedProbeBody(request); + if (bodyBuffer === null) { + return jsonResponse( + { + ok: false, + status: "degraded", + bytesReceived: MAX_PROBE_BYTES, + message: "Upload probe payload exceeds maximum allowed size.", + }, + 413, + ); + } + + if (bodyBuffer.byteLength < MIN_PROBE_BYTES) { + return jsonResponse( + { + ok: false, + status: "degraded", + bytesReceived: bodyBuffer.byteLength, + message: "Upload probe payload is too small, please contact support.", + }, + 400, + ); + } + + const storageOk = yield* verifyUserStorageWrite( + user.id, + user.activeOrganizationId, + bodyBuffer.subarray(0, MIN_PROBE_BYTES), + ).pipe( + Effect.as(true), + Effect.catchAll(() => Effect.succeed(false)), + ); + if (!storageOk) { + return jsonResponse( + { + ok: false, + status: "degraded", + bytesReceived: bodyBuffer.byteLength, + message: + "Upload storage destination is unavailable, please contact support.", + }, + 503, + ); + } + + return jsonResponse( + { + ok: true, + status: "healthy", + bytesReceived: bodyBuffer.byteLength, + message: "Upload health check succeeded.", + }, + 200, + ); + }); + +const handleHealthGet = () => + Effect.gen(function* () { + const user = yield* CurrentUser; + const request = yield* HttpServerRequest.HttpServerRequest; + if (isCrossSiteRequest(request)) { + return crossSiteForbiddenResponse(); + } + if (yield* checkProbeRateLimit(user.id, request)) { + return rateLimitedResponse(); + } + + const storageReady = yield* verifyUserStorageReadyReadOnly( + user.id, + user.activeOrganizationId, + ).pipe( + Effect.as(true), + Effect.catchAll(() => Effect.succeed(false)), + ); + if (!storageReady) { + return jsonResponse( + { + ok: false, + status: "degraded", + bytesReceived: 0, + message: + "Upload storage destination is unavailable, please contact support.", + }, + 503, + ); + } + + return jsonResponse( + { + ok: true, + status: "healthy", + bytesReceived: 0, + message: "Upload health-check endpoint ready.", + }, + 200, + ); + }); + +const ApiLive = HttpApiBuilder.api(Api).pipe( + Layer.provide( + HttpApiBuilder.group(Api, "root", (handlers) => + handlers + .handle("probeUploadHealth", handleProbePost) + .handle("getUploadHealth", handleHealthGet), + ), + ), +); + +const handler = apiToHandler(ApiLive); + +export const POST = handler; +export const GET = handler; diff --git a/crates/recording/src/defaults.rs b/crates/recording/src/defaults.rs index 9d225666b90..0a9a7e59d88 100644 --- a/crates/recording/src/defaults.rs +++ b/crates/recording/src/defaults.rs @@ -57,13 +57,48 @@ impl RecordingDefaults { camera_active: bool, override_fps: Option, ) -> studio_recording::ActorBuilder { + let effective = self.apply_detected_upload_speed(); builder - .with_custom_cursor(self.custom_cursor_capture) - .with_keyboard_capture(self.capture_keyboard_events) - .with_fragmented(self.crash_recovery_recording) - .with_out_of_process_muxer(self.out_of_process_muxer) - .with_max_fps(self.studio_max_fps(camera_active, override_fps)) - .with_quality(self.studio_recording_quality) + .with_custom_cursor(effective.custom_cursor_capture) + .with_keyboard_capture(effective.capture_keyboard_events) + .with_fragmented(effective.crash_recovery_recording) + .with_out_of_process_muxer(effective.out_of_process_muxer) + .with_max_fps(effective.studio_max_fps(camera_active, override_fps)) + .with_quality(effective.studio_recording_quality) + } + + pub fn with_upload_speed_mbps(mut self, mbps: f64) -> Self { + let tier = crate::upload_probe::AdaptiveUploadTier::from_mbps(mbps); + self.instant_mode_max_resolution = self + .instant_mode_max_resolution + .min(tier.max_instant_resolution()); + if matches!( + tier, + crate::upload_probe::AdaptiveUploadTier::Low + | crate::upload_probe::AdaptiveUploadTier::Offline + ) { + self.studio_recording_quality = StudioQuality::Compatibility; + } + self + } + + pub fn effective_instant_mode_max_resolution(self) -> u32 { + self.apply_detected_upload_speed() + .instant_mode_max_resolution + } + + pub fn recommended_studio_quality(self) -> StudioQuality { + match crate::upload_probe::latest_upload_speed_mbps() { + Some(mbps) => crate::upload_probe::AdaptiveUploadTier::from_mbps(mbps).studio_quality(), + None => self.studio_recording_quality, + } + } + + pub fn apply_detected_upload_speed(self) -> Self { + match crate::upload_probe::latest_upload_speed_mbps() { + Some(mbps) => self.with_upload_speed_mbps(mbps), + None => self, + } } } diff --git a/crates/recording/src/lib.rs b/crates/recording/src/lib.rs index 341ed4c47f1..e6882e138cb 100644 --- a/crates/recording/src/lib.rs +++ b/crates/recording/src/lib.rs @@ -20,6 +20,7 @@ pub mod studio_recording; pub mod sync_calibration; pub mod track_heal; pub mod upload_preparation; +pub mod upload_probe; pub mod upload_resume; pub mod upload_verification; diff --git a/crates/recording/src/upload_probe.rs b/crates/recording/src/upload_probe.rs new file mode 100644 index 00000000000..3d9e8b19c55 --- /dev/null +++ b/crates/recording/src/upload_probe.rs @@ -0,0 +1,235 @@ +use std::{ + sync::OnceLock, + time::Duration, +}; + +use parking_lot::Mutex; +use serde::{Deserialize, Serialize}; + +use crate::{ + StudioQuality, + defaults::{FREE_INSTANT_MODE_MAX_RESOLUTION, PRO_INSTANT_MODE_MAX_RESOLUTION}, +}; + +pub const LOW_BANDWIDTH_MAX_RESOLUTION: u32 = 854; +pub const HIGH_SPEED_THRESHOLD_MBPS: f64 = 15.0; +pub const STANDARD_SPEED_THRESHOLD_MBPS: f64 = 5.0; +pub const CALIBRATION_PROBE_BYTES: usize = 16; +pub const DEFAULT_PROBE_PAYLOAD_BYTES: usize = 256 * 1024; + +static STARTUP_HEALTH: OnceLock>> = OnceLock::new(); + +fn startup_health_slot() -> &'static Mutex> { + STARTUP_HEALTH.get_or_init(|| Mutex::new(None)) +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum AdaptiveUploadTier { + High, + Standard, + Low, + Offline, +} + +impl AdaptiveUploadTier { + pub fn from_mbps(mbps: f64) -> Self { + if !mbps.is_finite() || mbps <= 0.0 { + return Self::Offline; + } + if mbps >= HIGH_SPEED_THRESHOLD_MBPS { + return Self::High; + } + if mbps >= STANDARD_SPEED_THRESHOLD_MBPS { + return Self::Standard; + } + Self::Low + } + + pub fn max_instant_resolution(self) -> u32 { + match self { + Self::High => PRO_INSTANT_MODE_MAX_RESOLUTION, + Self::Standard => FREE_INSTANT_MODE_MAX_RESOLUTION, + Self::Low | Self::Offline => LOW_BANDWIDTH_MAX_RESOLUTION, + } + } + + pub fn studio_quality(self) -> StudioQuality { + match self { + Self::High => StudioQuality::Ultra, + Self::Standard => StudioQuality::Balanced, + Self::Low | Self::Offline => StudioQuality::Compatibility, + } + } + + pub fn indicator_label(self, mbps: f64) -> String { + match self { + Self::High => format!("{mbps:.1} Mbps • HD Ready"), + Self::Standard => format!("{mbps:.1} Mbps • 720p Adaptive"), + Self::Low => format!("{mbps:.1} Mbps • Low Bandwidth (480p)"), + Self::Offline => "Upload check failed, contact support".to_string(), + } + } +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SpeedProbeSample { + pub bytes_transferred: usize, + pub elapsed: Duration, +} + +impl SpeedProbeSample { + pub fn from_differential( + payload_bytes: usize, + calibration_bytes: usize, + upload_elapsed: Duration, + baseline_overhead: Duration, + ) -> Self { + let bytes_transferred = payload_bytes.saturating_sub(calibration_bytes).max(1); + let elapsed = upload_elapsed + .saturating_sub(baseline_overhead) + .max(Duration::from_millis(1)); + Self { + bytes_transferred, + elapsed, + } + } + + pub fn throughput_mbps(&self) -> f64 { + let secs = self.elapsed.as_secs_f64(); + if secs <= 0.0 || self.bytes_transferred == 0 { + return 0.0; + } + let bits = (self.bytes_transferred as f64) * 8.0; + bits / (secs * 1_000_000.0) + } +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct StartupHealthCheckStatus { + pub upload_ok: bool, + pub speed_mbps: f64, + pub tier: AdaptiveUploadTier, + pub indicator_message: String, + pub contact_support_required: bool, +} + +pub fn evaluate_startup_health( + http_ok: bool, + sample: Option, +) -> StartupHealthCheckStatus { + let Some(sample) = sample.filter(|_| http_ok) else { + return StartupHealthCheckStatus { + upload_ok: false, + speed_mbps: 0.0, + tier: AdaptiveUploadTier::Offline, + indicator_message: + "Upload health check failed, please contact support".to_string(), + contact_support_required: true, + }; + }; + + let mbps = sample.throughput_mbps(); + let tier = AdaptiveUploadTier::from_mbps(mbps); + let upload_ok = tier != AdaptiveUploadTier::Offline; + + StartupHealthCheckStatus { + upload_ok, + speed_mbps: mbps, + tier, + indicator_message: tier.indicator_label(mbps), + contact_support_required: !upload_ok, + } +} + +pub fn record_startup_health( + http_ok: bool, + sample: Option, +) -> StartupHealthCheckStatus { + let status = evaluate_startup_health(http_ok, sample); + *startup_health_slot().lock() = Some(status.clone()); + status +} + +pub fn latest_startup_health() -> Option { + startup_health_slot().lock().clone() +} + +pub fn latest_upload_speed_mbps() -> Option { + latest_startup_health() + .filter(|status| status.upload_ok && status.speed_mbps > 0.0) + .map(|status| status.speed_mbps) +} + +pub fn should_run_speed_probe(is_recording_active: bool) -> bool { + !is_recording_active +} + +pub fn build_probe_payload(size_bytes: usize) -> Vec { + let target_len = if size_bytes == 0 { + DEFAULT_PROBE_PAYLOAD_BYTES + } else { + size_bytes + }; + (0..target_len).map(|idx| (idx % 251) as u8).collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn classifies_upload_tiers_and_resolutions() { + assert_eq!(AdaptiveUploadTier::from_mbps(25.0), AdaptiveUploadTier::High); + assert_eq!( + AdaptiveUploadTier::High.max_instant_resolution(), + PRO_INSTANT_MODE_MAX_RESOLUTION + ); + assert_eq!( + AdaptiveUploadTier::from_mbps(8.5), + AdaptiveUploadTier::Standard + ); + assert_eq!( + AdaptiveUploadTier::Standard.max_instant_resolution(), + FREE_INSTANT_MODE_MAX_RESOLUTION + ); + assert_eq!(AdaptiveUploadTier::from_mbps(2.1), AdaptiveUploadTier::Low); + assert_eq!( + AdaptiveUploadTier::Low.max_instant_resolution(), + LOW_BANDWIDTH_MAX_RESOLUTION + ); + assert_eq!( + AdaptiveUploadTier::from_mbps(0.0), + AdaptiveUploadTier::Offline + ); + } + + #[test] + fn blocks_probe_during_active_recording_and_evaluates_health() { + assert!(should_run_speed_probe(false)); + assert!(!should_run_speed_probe(true)); + + let sample = SpeedProbeSample { + bytes_transferred: 1_000_000, + elapsed: Duration::from_millis(500), + }; + let healthy = record_startup_health(true, Some(sample)); + assert!(healthy.upload_ok); + assert!(!healthy.contact_support_required); + assert_eq!(healthy.tier, AdaptiveUploadTier::High); + assert_eq!(latest_upload_speed_mbps(), Some(16.0)); + + let unmeasured = record_startup_health(true, None); + assert!(!unmeasured.upload_ok); + assert!(unmeasured.contact_support_required); + assert_eq!(unmeasured.tier, AdaptiveUploadTier::Offline); + assert_eq!(latest_upload_speed_mbps(), None); + + let failed = record_startup_health(false, None); + assert!(!failed.upload_ok); + assert!(failed.contact_support_required); + assert_eq!(latest_upload_speed_mbps(), None); + } +}