Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 80 additions & 1 deletion apps/desktop-gpui/src/power.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Duration> {
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::{
Expand Down
22 changes: 12 additions & 10 deletions apps/desktop-gpui/src/recording.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
152 changes: 152 additions & 0 deletions apps/web/app/api/upload/health-check/route.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof Layer.mergeAll>[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();
});
});
Loading