Skip to content

feat(desktop): implement upload speed test and health check for instant mode - #2433

Open
gilnett wants to merge 6 commits into
CapSoftware:mainfrom
gilnett:fix/issue-73-speed-test
Open

gilnett wants to merge 6 commits into
CapSoftware:mainfrom
gilnett:fix/issue-73-speed-test

Conversation

@gilnett

@gilnett gilnett commented Oct 8, 2026 •

Copy link
Copy Markdown

Closes #73
/claim #73

Summary

Implements an upload speed test and health check for Cap desktop in instant mode:

  • Adds a network health indicator in the desktop window displaying connection status and upload speed.
  • Adjusts recommended recording resolution based on measured upload bandwidth.
  • Automatically aborts active speed tests and blocks new ones while recording is in progress.
  • Adds startup connectivity probe against /api/desktop/health.
  • Adds backend health and speed test endpoints (/api/desktop/health and /api/desktop/health/speed-test) that stream and count bytes in memory without writing files to storage.

RetriggerConfidence Score: 5/5

The PR appears safe to merge; no blocking findings remain.

Summary

Adds a desktop network indicator, upload-speed recommendations, and health endpoints.

  • Uses native HTTP requests and pauses speed tests during recording.
  • Stops scheduled uploads after monitoring is cleaned up.
  • Repairs the delayed-request test and adds cleanup coverage.

Reviews (5) · Last reviewed commit: "fix(desktop): add stopped guard to healt..." · Reviewed by Greptile

Comment on lines +160 to +167
const response = await fetch(targetUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: testPayload,
signal: controller.signal,
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Desktop checks cannot connect

Both checks use browser fetch, but the packaged desktop app's connect-src excludes the default server, https://cap.so. The requests are blocked, so users see “Upload Error” and never get a speed result even when recording uploads work. Use native fetch from @tauri-apps/plugin-http, as web-api.ts does. This also avoids the development CORS mismatch for http://localhost:3002.

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/desktop/src/utils/network-health.ts
Line: 160-167

Comment:
**Desktop checks cannot connect**

Both checks use browser `fetch`, but the packaged desktop app's `connect-src` excludes the default server, `https://cap.so`. The requests are blocked, so users see “Upload Error” and never get a speed result even when recording uploads work. Use native `fetch` from `@tauri-apps/plugin-http`, as `web-api.ts` does. This also avoids the development CORS mismatch for `http://localhost:3002`.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Comment on lines +264 to +268
try {
const { generalSettingsStore } = await import("~/store");
await generalSettingsStore.set({
instantModeMaxResolution: recommendedResolution,
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Saved quality gets replaced

Every successful test replaces the user's saved instantModeMaxResolution, including while Studio mode is selected. A Pro user who chooses 4K loses that choice at the next test, at most 45 seconds later. Keep the recommendation separate from the saved preference, and apply it only through an explicit automatic-quality setting.

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/desktop/src/utils/network-health.ts
Line: 264-268

Comment:
**Saved quality gets replaced**

Every successful test replaces the user's saved `instantModeMaxResolution`, including while Studio mode is selected. A Pro user who chooses 4K loses that choice at the next test, at most 45 seconds later. Keep the recommendation separate from the saved preference, and apply it only through an explicit automatic-quality setting.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Comment on lines +231 to +241
const startTime = performance.now();
const response = await fetch(targetUrl, {
method: "POST",
headers: {
"Content-Type": "application/octet-stream",
},
body: buffer,
signal: controller.signal,
});

const durationMs = performance.now() - startTime;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Delay lowers recording quality

Timing one 256 KiB request measures connection setup, server delay, and the return trip as well as upload time. A 100 Mbps upload with 200 ms of other delay reads roughly 9.5 Mbps and gets the medium setting instead of high. Because this result changes recording quality, use a warmed-up, longer upload sample or repeated samples that reduce the effect of fixed delays.

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/desktop/src/utils/network-health.ts
Line: 231-241

Comment:
**Delay lowers recording quality**

Timing one 256 KiB request measures connection setup, server delay, and the return trip as well as upload time. A 100 Mbps upload with 200 ms of other delay reads roughly 9.5 Mbps and gets the medium setting instead of high. Because this result changes recording quality, use a warmed-up, longer upload sample or repeated samples that reduce the effect of fixed delays.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Comment on lines +85 to +89
let isRecordingActive = false;
let activeSpeedTestAbort: AbortController | null = null;

export function setRecordingState(active: boolean) {
isRecordingActive = active;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Buttons miss recording changes

isRecordingActive is a plain variable, so disabled={isRecording()} does not update when recording starts or stops. The main indicator reads no signal in that expression, so changes to other parts of the indicator do not repair it either. Store recording state in a Solid signal so both buttons track the recording state.

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/desktop/src/utils/network-health.ts
Line: 85-89

Comment:
**Buttons miss recording changes**

`isRecordingActive` is a plain variable, so `disabled={isRecording()}` does not update when recording starts or stops. The main indicator reads no signal in that expression, so changes to other parts of the indicator do not repair it either. Store recording state in a Solid signal so both buttons track the recording state.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Comment on lines +40 to +46
const qualityLabel = () => {
const tier = speedTest().qualityTier;
const res = speedTest().recommendedResolution;
if (!tier) return "Standard (1080p)";
if (tier === "high") return `Full (${res}p)`;
if (tier === "medium") return `Standard (${res}p)`;
return `Adapted (${res}p)`;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Quality labels overstate output

recommendedResolution is a width limit, not a video height. Appending p therefore shows “1920p” for a 1080p limit and “2160p” for a limit of roughly 1215 pixels high. Use the same width-to-label mapping as the quality settings, and choose presets consistent with those labels.

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/desktop/src/components/NetworkHealthIndicator.tsx
Line: 40-46

Comment:
**Quality labels overstate output**

`recommendedResolution` is a width limit, not a video height. Appending `p` therefore shows “1920p” for a 1080p limit and “2160p” for a limit of roughly 1215 pixels high. Use the same width-to-label mapping as the quality settings, and choose presets consistent with those labels.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Comment on lines +34 to +37
const speedLabel = () => {
const mbps = speedTest().speedMbps;
if (mbps === null) return "Checking...";
return `${mbps} Mbps`;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Failed tests look unfinished

When the first speed test fails but the health check succeeds, speedMbps remains null and speedLabel() keeps returning “Checking...”. The tooltip still says “Operational”, and no view displays speedTest.error. Render the error state separately from a running test, including when an older speed result exists.

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/desktop/src/components/NetworkHealthIndicator.tsx
Line: 34-37

Comment:
**Failed tests look unfinished**

When the first speed test fails but the health check succeeds, `speedMbps` remains null and `speedLabel()` keeps returning “Checking...”. The tooltip still says “Operational”, and no view displays `speedTest.error`. Render the error state separately from a running test, including when an older speed result exists.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Comment on lines +20 to +26
const json = await c.req.json().catch(() => ({}));
const payload = typeof json.payload === "string" ? json.payload : "";
bytesReceived = Buffer.byteLength(payload, "utf8");
} else {
const bodyBuffer = await c.req
.arrayBuffer()
.catch(() => new ArrayBuffer(0));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Failed reads count as success

The POST handlers turn body-read failures into empty bodies and still return success. Invalid JSON therefore returns healthy: true, and a failed speed-test body read returns HTTP 200 with zero bytes. Let those failures reach the error response, and have the client verify bytesReceived before accepting the speed result.

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/web/app/api/desktop/[...route]/health.ts
Line: 20-26

Comment:
**Failed reads count as success**

The POST handlers turn body-read failures into empty bodies and still return success. Invalid JSON therefore returns `healthy: true`, and a failed speed-test body read returns HTTP 200 with zero bytes. Let those failures reach the error response, and have the client verify `bytesReceived` before accepting the speed result.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Comment on lines +4 to +6
export const app = new Hono().use(withOptionalAuth);

app.get("/", (c) => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 New API skips required pattern

The new health API uses Hono handlers. The repository guide requires new Next.js APIs under apps/web/app/api/* to use the @effect/platform HttpApi builder rather than ad-hoc handlers. Implement these endpoints with that pattern and its shared handler wiring. This repository requirement must be satisfied before merging.

Context Used: CLAUDE.md (source)

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/web/app/api/desktop/[...route]/health.ts
Line: 4-6

Comment:
**New API skips required pattern**

The new health API uses `Hono` handlers. The repository guide requires new Next.js APIs under `apps/web/app/api/*` to use the `@effect/platform` `HttpApi` builder rather than ad-hoc handlers. Implement these endpoints with that pattern and its shared handler wiring. This repository requirement must be satisfied before merging.

**Context Used:** CLAUDE.md ([source](https://github.com/capsoftware/cap/blob/main/CLAUDE.md))

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

@@ -0,0 +1,133 @@
import { cx } from "cva";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Filename breaks repository convention

The new file NetworkHealthIndicator.tsx uses a PascalCase filename. The repository guide requires kebab-case filenames while keeping component names PascalCase. Rename the file to network-health-indicator.tsx and update its import. This repository requirement must be satisfied before merging.

Context Used: AGENTS.md (source)

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/desktop/src/components/NetworkHealthIndicator.tsx
Line: 1

Comment:
**Filename breaks repository convention**

The new file `NetworkHealthIndicator.tsx` uses a PascalCase filename. The repository guide requires kebab-case filenames while keeping component names PascalCase. Rename the file to `network-health-indicator.tsx` and update its import. This repository requirement must be satisfied before merging.

**Context Used:** AGENTS.md ([source](https://github.com/capsoftware/cap/blob/main/AGENTS.md))

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

signalFactory = solid.createSignal;
}
} catch {
// Fallback to standalone reactive signal

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Comment promises nonexistent updates

The new “Fallback to standalone reactive signal” comment merely narrates the catch branch, which the repository's comments policy forbids. It is also inaccurate: createFallbackSignal stores a plain variable and cannot notify Solid when it changes. Remove the comment rather than describing the fallback as reactive. This repository requirement must be satisfied before merging.

Suggested change
// Fallback to standalone reactive signal

Context Used: AGENTS.md (source)

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/desktop/src/utils/network-health.ts
Line: 58

Comment:
**Comment promises nonexistent updates**

The new “Fallback to standalone reactive signal” comment merely narrates the catch branch, which the repository's comments policy forbids. It is also inaccurate: `createFallbackSignal` stores a plain variable and cannot notify Solid when it changes. Remove the comment rather than describing the fallback as reactive. This repository requirement must be satisfied before merging.

```suggestion

```

**Context Used:** AGENTS.md ([source](https://github.com/capsoftware/cap/blob/main/AGENTS.md))

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

@superagent-security superagent-security Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Superagent found 1 security concern(s).

});

const SpeedTestPayload = Schema.Struct({
payload: Schema.String.pipe(Schema.minLength(1)),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: The public speed-test endpoint accepts unbounded request bodies without rate limiting.

Public POST accepts an unbounded string and scans it for byte length, with no route auth or rate limit.

Enforce a strict body-size limit and per-IP rate limit; require auth if anonymous speed tests are unnecessary.

AI prompt
Check if this security scanner issue is valid. If so, understand the root cause and fix it. If appropriate, update or add tests. Keep the change focused and preserve intended behavior.

<file name="apps/web/app/api/desktop/health/route.ts">
<violation number="1" location="apps/web/app/api/desktop/health/route.ts:17">
<priority>P2</priority>
<title>The public speed-test endpoint accepts unbounded request bodies without rate limiting.</title>
<evidence>The new POST `/api/desktop/health` endpoint accepts `payload` as `Schema.String.pipe(Schema.minLength(1))` (line 17), with no maximum length or byte limit. Its handler then computes `Buffer.byteLength(payload.payload, "utf8")` (line 53), so request parsing and processing scale with attacker-supplied body size. The route is exported directly through `apiToHandler` (lines 64–67) and does not attach `HttpAuthMiddleware` or a rate limiter. `apiToHandler` provides the auth middleware implementation but does not globally require it (`apps/web/lib/server.ts:99-117`); protected endpoint examples explicitly opt in with `.middleware(HttpAuthMiddleware)` (`apps/web/app/api/recording/prepare/route.ts:21-41`). Thus an unauthenticated remote caller can repeatedly submit large POST bodies and consume request-processing memory and CPU. The base checkout's catch-all API route delegates only to existing `HttpLive` routes (`repository-base/apps/web/app/api/[[...route]]/route.ts:1-14`); a search of the base checkout found no equivalent desktop health/speed-test endpoint. The new route therefore adds this public, attacker-controlled parsing and byte-counting path.</evidence>
<recommendation>Set and enforce a strict maximum payload byte length (and reject oversized requests before fully parsing/retaining them), and apply an explicit per-IP rate limit to this unauthenticated endpoint. If speed testing does not need anonymous access, require the appropriate authentication middleware.</recommendation>
</violation>
</file>

@gilnett

gilnett commented Oct 8, 2026

Copy link
Copy Markdown
Author

@greptileai please re-review the latest head.
Updated the endpoint to use Effect HttpApi with rate-limiting, switched desktop calls to Tauri's native http plugin, fixed the Solid reactivity, and renamed to kebab-case. Tests are passing.

@gilnett

gilnett commented Oct 8, 2026

Copy link
Copy Markdown
Author

@greptileai please re-review. I applied both remaining changes by warming up the connection and using repeated samples with latency subtraction to avoid fixed delays skewing the speed test, and updating network health so successful tests properly clear any previous offline error status.

Comment on lines +346 to +349
if (networkHealth().status !== "healthy") {
void runUploadHealthCheck();
}
void runSpeedTest();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Late checks undo recovery

The periodic retry starts runUploadHealthCheck() and runSpeedTest() together. If the GET stalls while the POST samples succeed, its later timeout replaces the recovered networkHealth with "unhealthy". Users then see “Upload Error” despite the successful upload.

Run these checks in order, or prevent an older failed check from replacing a newer success.

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/desktop/src/utils/network-health.ts
Line: 346-349

Comment:
**Late checks undo recovery**

The periodic retry starts `runUploadHealthCheck()` and `runSpeedTest()` together. If the GET stalls while the POST samples succeed, its later timeout replaces the recovered `networkHealth` with `"unhealthy"`. Users then see “Upload Error” despite the successful upload.

Run these checks in order, or prevent an older failed check from replacing a newer success.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

expect(speedTest().status).toBe("completed");
expect(speedTest().error).toBeNull();

// Verify network health was restored and error cleared

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Comments repeat the test

The new comments narrate the setup and assertions. The repository guide requires comments to explain non-obvious context, not repeat what the code does. Remove these comments before merging to satisfy that requirement. The same pattern appears at lines 122 and 158, and elsewhere in this test.

Context Used: AGENTS.md (source)

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/desktop/src/utils/network-health.test.ts
Line: 154

Comment:
**Comments repeat the test**

The new comments narrate the setup and assertions. The repository guide requires comments to explain non-obvious context, not repeat what the code does. Remove these comments before merging to satisfy that requirement. The same pattern appears at lines 122 and 158, and elsewhere in this test.

**Context Used:** AGENTS.md ([source](https://github.com/capsoftware/cap/blob/main/AGENTS.md))

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

@gilnett

gilnett commented Oct 8, 2026

Copy link
Copy Markdown
Author

@greptileai please re-review. I resolved both items by sequencing the health check and speed test with timestamp guards to prevent late responses from overwriting a recovered status, and cleaned up the test comments to follow the repo guidelines.

await runSpeedTest();
expect(networkHealth().status).toBe("healthy");

delayedReject?.(new Error("Late network timeout"));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 New test breaks typechecking

delayedReject?.(...) fails strict TypeScript checking. delayedReject starts as null, and assigning it inside the Promise callback does not change its type at this call site. TypeScript treats the callable value as never. The desktop's tsconfig.json includes this test, so it breaks typechecking. Capture the reject function with a definite-assignment declaration instead.

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/desktop/src/utils/network-health.test.ts
Line: 198

Comment:
**New test breaks typechecking**

`delayedReject?.(...)` fails strict TypeScript checking. `delayedReject` starts as `null`, and assigning it inside the Promise callback does not change its type at this call site. TypeScript treats the callable value as `never`. The desktop's `tsconfig.json` includes this test, so it breaks typechecking. Capture the reject function with a definite-assignment declaration instead.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Comment on lines +167 to +170
globalThis.fetch = vi.fn().mockReturnValue(delayedPromise);
const pendingCheck = runUploadHealthCheck();

globalThis.fetch = vi

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Test misses the delayed request

The test replaces globalThis.fetch before the pending health check uses it. runUploadHealthCheck() first awaits getServerUrl(), then reads the fetch function. It therefore uses the replacement mock, not delayedPromise. Rejecting that promise does not produce the late health-check failure this test should cover, leaving the race untested. Wait until the GET has entered the delayed mock before replacing it, and assert that the check stays pending until rejection.

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/desktop/src/utils/network-health.test.ts
Line: 167-170

Comment:
**Test misses the delayed request**

The test replaces `globalThis.fetch` before the pending health check uses it. `runUploadHealthCheck()` first awaits `getServerUrl()`, then reads the fetch function. It therefore uses the replacement mock, not `delayedPromise`. Rejecting that promise does not produce the late health-check failure this test should cover, leaving the race untested. Wait until the GET has entered the delayed mock before replacing it, and assert that the check stays pending until rejection.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Comment on lines +346 to +348
await runUploadHealthCheck();
if (!isRecordingSignal()) {
await runSpeedTest();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Uploads start after cleanup

If the page leaves while runUploadHealthCheck() is pending, cleanup clears the interval and aborts any current speed test. Once the health check finishes, this callback starts a new speed test anyway. Both startup and an already-running interval callback can upload roughly 1 MiB after monitoring has stopped. Add a stopped flag and check it after the await, before calling runSpeedTest().

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/desktop/src/utils/network-health.ts
Line: 346-348

Comment:
**Uploads start after cleanup**

If the page leaves while `runUploadHealthCheck()` is pending, cleanup clears the interval and aborts any current speed test. Once the health check finishes, this callback starts a new speed test anyway. Both startup and an already-running interval callback can upload roughly 1 MiB after monitoring has stopped. Add a stopped flag and check it after the await, before calling `runSpeedTest()`.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

@gilnett

gilnett commented Oct 8, 2026

Copy link
Copy Markdown
Author

@greptileai please re-review. I resolved all three issues

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Implement speed test and health check for Cap desktop app

1 participant