From b9070693edf184388b667d0e977361ddcea7e070 Mon Sep 17 00:00:00 2001 From: zhom <2717306+zhom@users.noreply.github.com> Date: Sat, 25 Jul 2026 22:59:13 +0400 Subject: [PATCH] refactor: cleanup --- AGENTS.md | 2 + e2e/coverage-map.mjs | 3 - e2e/tests/integrations.test.mjs | 344 +++++++++++----- e2e/tests/ui.test.mjs | 56 +++ src-tauri/src/api_server.rs | 113 ++++- src-tauri/src/automation_rate_limiter.rs | 137 +++++++ src-tauri/src/cloud_auth.rs | 173 +------- src-tauri/src/lib.rs | 77 ++-- src-tauri/src/mcp_server.rs | 161 ++++++-- src/app/page.tsx | 51 ++- src/components/consistency-warning-dialog.tsx | 8 - src/components/icons/zen-browser.tsx | 25 -- src/components/integrations-dialog.tsx | 11 +- src/components/location-proxy-dialog.tsx | 358 ---------------- src/components/onboarding-card.tsx | 48 ++- src/components/onboarding-provider.tsx | 3 +- src/components/permission-dialog.tsx | 22 +- src/components/settings-dialog.tsx | 34 +- src/components/thank-you-dialog.tsx | 21 +- src/components/ui/card.tsx | 90 ---- src/components/ui/chart.tsx | 386 ------------------ src/components/ui/combobox.tsx | 115 ------ src/components/ui/dialog.tsx | 2 +- src/components/welcome-dialog.tsx | 258 ++++++++---- src/components/window-drag-area.tsx | 16 +- src/hooks/use-browser-support.ts | 54 --- src/hooks/use-extension-events.ts | 76 ---- src/hooks/use-permissions.ts | 62 +-- src/i18n/index.ts | 2 +- src/i18n/locales/en.json | 52 +-- src/i18n/locales/es.json | 52 +-- src/i18n/locales/fr.json | 52 +-- src/i18n/locales/ja.json | 52 +-- src/i18n/locales/ko.json | 52 +-- src/i18n/locales/pt.json | 52 +-- src/i18n/locales/ru.json | 52 +-- src/i18n/locales/tr.json | 52 +-- src/i18n/locales/vi.json | 52 +-- src/i18n/locales/zh.json | 52 +-- src/lib/backend-errors.ts | 31 ++ src/lib/browser-utils.ts | 13 +- src/lib/error-utils.ts | 31 -- src/lib/logger.ts | 27 +- src/lib/name-utils.ts | 9 - src/lib/onboarding-signal.ts | 5 + src/lib/platform.ts | 25 ++ src/lib/shortcuts.ts | 15 +- src/lib/toast-utils.ts | 24 -- src/lib/utils.ts | 4 - 49 files changed, 1204 insertions(+), 2208 deletions(-) create mode 100644 src-tauri/src/automation_rate_limiter.rs delete mode 100644 src/components/icons/zen-browser.tsx delete mode 100644 src/components/location-proxy-dialog.tsx delete mode 100644 src/components/ui/card.tsx delete mode 100644 src/components/ui/chart.tsx delete mode 100644 src/components/ui/combobox.tsx delete mode 100644 src/hooks/use-browser-support.ts delete mode 100644 src/hooks/use-extension-events.ts delete mode 100644 src/lib/error-utils.ts delete mode 100644 src/lib/name-utils.ts create mode 100644 src/lib/platform.ts diff --git a/AGENTS.md b/AGENTS.md index b838b3f..e391a5e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -31,6 +31,7 @@ donutbrowser/ │ │ ├── proxy_storage.rs # Proxy config persistence (JSON files) │ │ ├── api_server.rs # REST API (utoipa + axum) │ │ ├── mcp_server.rs # MCP protocol server +│ │ ├── automation_rate_limiter.rs # Shared REST/MCP automation quota │ │ ├── sync/ # Cloud sync (engine, encryption, manifest, scheduler) │ │ ├── vpn/ # WireGuard tunnels │ │ ├── wayfern_manager.rs # Wayfern (Chromium) browser management @@ -160,6 +161,7 @@ Handlers route manager errors through `manager_error_response`, which maps messa - `404` — entity not found (`… not found` / `*_NOT_FOUND`). - `400` — validation, duplicates, empty names, invalid/unsupported/unavailable input. - `409` — conflicts: browser version already being downloaded, profile locked by another team member (run), browser running during cookie import. +- `429` — authenticated automation request quota exceeded (`Retry-After` header included). - `500` — internal failures (IO, network, poisoned locks). Error bodies are plain-text diagnostics; some are the JSON `{"code": ...}` strings shared with the Tauri commands (e.g. `NAME_CANNOT_BE_EMPTY`, `GROUP_ALREADY_EXISTS`). The translated-error rule above applies to Tauri commands, not to REST bodies. diff --git a/e2e/coverage-map.mjs b/e2e/coverage-map.mjs index c9f9e1e..74cb779 100644 --- a/e2e/coverage-map.mjs +++ b/e2e/coverage-map.mjs @@ -241,9 +241,6 @@ export const commandCoverage = { "cloud_auth::cloud_logout", "cloud_auth::cloud_get_proxy_usage", "cloud_auth::cloud_get_countries", - "cloud_auth::cloud_get_regions", - "cloud_auth::cloud_get_cities", - "cloud_auth::cloud_get_isps", "cloud_auth::create_cloud_location_proxy", "cloud_auth::cloud_get_wayfern_token", "cloud_auth::cloud_refresh_wayfern_token", diff --git a/e2e/tests/integrations.test.mjs b/e2e/tests/integrations.test.mjs index ee9c1dc..8df5842 100644 --- a/e2e/tests/integrations.test.mjs +++ b/e2e/tests/integrations.test.mjs @@ -52,6 +52,11 @@ async function invokeContract(app, command, args = {}) { } } +async function assertCommandErrorCode(app, command, code, args = {}) { + const error = await app.invokeError(command, args); + assert.match(error, new RegExp(`"code":"${code}"`)); +} + test("authenticated REST API serves its complete OpenAPI contract and CRUD lifecycle", async () => { await withApp("integrations-rest", async (app) => { await seedTerms(app); @@ -189,7 +194,17 @@ test("authenticated REST API serves its complete OpenAPI contract and CRUD lifec test("MCP Streamable HTTP initialization, auth, discovery, calls, and isolated agent install", async () => { await withApp("integrations-mcp", async (app) => { await seedTerms(app); + await assertCommandErrorCode( + app, + "stop_mcp_server", + "MCP_SERVER_NOT_RUNNING", + ); const port = await app.invoke("start_mcp_server"); + await assertCommandErrorCode( + app, + "start_mcp_server", + "MCP_SERVER_ALREADY_RUNNING", + ); assert.equal(await app.invoke("get_mcp_server_status"), true); const config = await app.invoke("get_mcp_config"); assert.equal(config.port, port); @@ -263,6 +278,9 @@ test("MCP Streamable HTTP initialization, auth, discovery, calls, and isolated a const agents = await app.invoke("list_mcp_agents"); assert.ok(agents.some((agent) => agent.id === "cursor")); + await assertCommandErrorCode(app, "add_mcp_to_agent", "MCP_AGENT_UNKNOWN", { + agentId: "missing-e2e-agent", + }); await app.invoke("add_mcp_to_agent", { agentId: "cursor" }); assert.equal( (await app.invoke("list_mcp_agents")).find( @@ -291,113 +309,225 @@ test("MCP Streamable HTTP initialization, auth, discovery, calls, and isolated a }); }); -test("offline cloud, update, team-lock, trial, and synchronizer contracts are deterministic", async () => { - await withApp("integrations-contracts", async (app) => { - assert.equal(await app.invoke("cloud_get_user"), null); - assert.equal(await app.invoke("cloud_get_proxy_usage"), null); - assert.ok(await app.invoke("cloud_get_wayfern_token")); - assert.deepEqual(await app.invoke("get_team_locks"), []); - assert.equal( - await app.invoke("get_team_lock_status", { - profileId: "00000000-0000-0000-0000-000000000000", - }), - null, - ); - assert.deepEqual(await app.invoke("get_sync_sessions"), []); - const startResult = await invokeContract(app, "start_sync_session", { - leaderProfileId: "00000000-0000-0000-0000-000000000001", - followerProfileIds: ["00000000-0000-0000-0000-000000000002"], - }); - assert.equal(startResult.ok, false); - const stopError = await app.invokeError("stop_sync_session", { - sessionId: "missing", - }); - assert.match(stopError, /not found|session/i); - const removeError = await app.invokeError("remove_sync_follower", { - sessionId: "missing", - followerProfileId: "missing", - }); - assert.match(removeError, /not found|session/i); - - assert.equal(await app.invoke("check_for_app_updates"), null); - assert.equal(await app.invoke("check_for_app_updates_manual"), null); - assert.ok( - await invokeContract(app, "cloud_exchange_device_code", { - code: "DONUT-E2E-INVALID-CODE", - }), - ); - assert.ok(await invokeContract(app, "cloud_refresh_profile")); - assert.ok(await invokeContract(app, "cloud_get_countries")); - assert.ok( - await invokeContract(app, "cloud_get_regions", { - country: "ZZ", - }), - ); - assert.ok( - await invokeContract(app, "cloud_get_cities", { - country: "ZZ", - region: null, - }), - ); - assert.ok( - await invokeContract(app, "cloud_get_isps", { - country: "ZZ", - region: null, - city: null, - }), - ); - assert.ok( - await invokeContract(app, "create_cloud_location_proxy", { - name: "E2E unavailable cloud proxy", - country: "ZZ", - region: null, - city: null, - isp: null, - }), - ); - assert.ok(await invokeContract(app, "cloud_refresh_wayfern_token")); - - assert.ok(await invokeContract(app, "trigger_manual_version_update")); - assert.ok(await invokeContract(app, "clear_all_version_cache_and_refetch")); - assert.ok(await invokeContract(app, "check_for_browser_updates")); - await app.invoke("dismiss_update_notification", { - notificationId: "missing-e2e-notification", - }); - assert.deepEqual( - await app.invoke("complete_browser_update_with_auto_update", { - browser: "wayfern", - newVersion: "150.0.7871.100", - }), - [], - ); - const prepareError = await app.invokeError( - "download_and_prepare_app_update", - { - updateInfo: { - current_version: "0.0.0", - new_version: "0.0.1-e2e", - release_notes: "E2E invalid update contract", - download_url: `${process.env.DONUT_E2E_FIXTURE_URL}/invalid-update.zip`, - is_nightly: false, - published_at: "2026-01-01T00:00:00Z", - manual_update_required: false, - release_page_url: null, - repo_update: false, - checksums_url: null, - asset_digest: null, +test("REST and MCP share the browser automation rate limit", async () => { + await withApp( + "integrations-rate-limit", + async (app) => { + await seedTerms(app); + const settings = await app.invoke("get_app_settings"); + const saved = await app.invoke("save_app_settings", { + settings: { + ...settings, + api_enabled: true, + api_port: 0, + api_token: null, + onboarding_completed: true, }, - }, - ); - assert.match(prepareError, /checksum|verif|Failed to download/i); - const versionStatus = await app.invoke("get_version_update_status"); - assert.ok(versionStatus && typeof versionStatus === "object"); - assert.equal(typeof (await app.invoke("is_default_browser")), "boolean"); + }); - const trial = await app.invoke("get_commercial_trial_status"); - assert.ok(trial && typeof trial === "object"); - await app.invoke("acknowledge_trial_expiration"); - assert.equal(await app.invoke("has_acknowledged_trial_expiration"), true); - await app.invoke("cloud_logout"); - assert.equal(await app.invoke("cloud_get_user"), null); - }); + const apiPort = await app.invoke("start_api_server", { port: 0 }); + const mcpPort = await app.invoke("start_mcp_server"); + const mcpConfig = await app.invoke("get_mcp_config"); + const apiBase = `http://127.0.0.1:${apiPort}`; + const mcpUrl = `http://127.0.0.1:${mcpPort}/mcp/${mcpConfig.token}`; + + const initialized = await jsonRequest(mcpUrl, { + method: "POST", + body: { + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: { + protocolVersion: "2025-11-25", + capabilities: {}, + clientInfo: { name: "donut-e2e-rate-limit", version: "1" }, + }, + }, + }); + assert.equal(initialized.response.status, 200); + const mcpHeaders = { + "mcp-session-id": initialized.response.headers.get("mcp-session-id"), + }; + + const missingProfileId = "00000000-0000-0000-0000-000000000000"; + const first = await jsonRequest( + `${apiBase}/v1/profiles/${missingProfileId}/run`, + { + method: "POST", + token: saved.api_token, + body: {}, + }, + ); + assert.equal(first.response.status, 404); + + const second = await jsonRequest(mcpUrl, { + method: "POST", + headers: mcpHeaders, + body: { + jsonrpc: "2.0", + id: 2, + method: "tools/call", + params: { + name: "run_profile", + arguments: { profile_id: missingProfileId }, + }, + }, + }); + assert.equal(second.response.status, 200); + assert.equal(second.value.error.code, -32000); + + const restLimited = await jsonRequest( + `${apiBase}/v1/profiles/${missingProfileId}/run`, + { + method: "POST", + token: saved.api_token, + body: {}, + }, + ); + assert.equal(restLimited.response.status, 429); + assert.ok(Number(restLimited.response.headers.get("retry-after")) > 0); + + const mcpLimited = await jsonRequest(mcpUrl, { + method: "POST", + headers: mcpHeaders, + body: { + jsonrpc: "2.0", + id: 3, + method: "tools/call", + params: { + name: "run_profile", + arguments: { profile_id: missingProfileId }, + }, + }, + }); + assert.equal(mcpLimited.response.status, 429); + assert.ok(Number(mcpLimited.response.headers.get("retry-after")) > 0); + + const freeCall = await jsonRequest(mcpUrl, { + method: "POST", + headers: mcpHeaders, + body: { + jsonrpc: "2.0", + id: 4, + method: "tools/call", + params: { name: "list_profiles", arguments: {} }, + }, + }); + assert.equal(freeCall.response.status, 200); + assert.equal(freeCall.value.error, undefined); + + await app.invoke("stop_mcp_server"); + await app.invoke("stop_api_server"); + }, + { + extraEnv: { + DONUT_E2E_REQUESTS_PER_HOUR: "2", + WAYFERN_TEST_TOKEN: "donut-e2e-rate-limit", + }, + }, + ); +}); + +test("offline cloud, update, team-lock, trial, and synchronizer contracts are deterministic", async () => { + await withApp( + "integrations-contracts", + async (app) => { + await assertCommandErrorCode( + app, + "start_mcp_server", + "WAYFERN_TERMS_REQUIRED", + ); + assert.equal(await app.invoke("cloud_get_user"), null); + assert.equal(await app.invoke("cloud_get_proxy_usage"), null); + assert.ok(await app.invoke("cloud_get_wayfern_token")); + assert.deepEqual(await app.invoke("get_team_locks"), []); + assert.equal( + await app.invoke("get_team_lock_status", { + profileId: "00000000-0000-0000-0000-000000000000", + }), + null, + ); + assert.deepEqual(await app.invoke("get_sync_sessions"), []); + const startResult = await invokeContract(app, "start_sync_session", { + leaderProfileId: "00000000-0000-0000-0000-000000000001", + followerProfileIds: ["00000000-0000-0000-0000-000000000002"], + }); + assert.equal(startResult.ok, false); + const stopError = await app.invokeError("stop_sync_session", { + sessionId: "missing", + }); + assert.match(stopError, /not found|session/i); + const removeError = await app.invokeError("remove_sync_follower", { + sessionId: "missing", + followerProfileId: "missing", + }); + assert.match(removeError, /not found|session/i); + + assert.equal(await app.invoke("check_for_app_updates"), null); + assert.equal(await app.invoke("check_for_app_updates_manual"), null); + assert.ok( + await invokeContract(app, "cloud_exchange_device_code", { + code: "DONUT-E2E-INVALID-CODE", + }), + ); + assert.ok(await invokeContract(app, "cloud_refresh_profile")); + assert.ok(await invokeContract(app, "cloud_get_countries")); + assert.ok( + await invokeContract(app, "create_cloud_location_proxy", { + name: "E2E unavailable cloud proxy", + country: "ZZ", + region: null, + city: null, + isp: null, + }), + ); + assert.ok(await invokeContract(app, "cloud_refresh_wayfern_token")); + + assert.ok(await invokeContract(app, "trigger_manual_version_update")); + assert.ok( + await invokeContract(app, "clear_all_version_cache_and_refetch"), + ); + assert.ok(await invokeContract(app, "check_for_browser_updates")); + await app.invoke("dismiss_update_notification", { + notificationId: "missing-e2e-notification", + }); + assert.deepEqual( + await app.invoke("complete_browser_update_with_auto_update", { + browser: "wayfern", + newVersion: "150.0.7871.100", + }), + [], + ); + const prepareError = await app.invokeError( + "download_and_prepare_app_update", + { + updateInfo: { + current_version: "0.0.0", + new_version: "0.0.1-e2e", + release_notes: "E2E invalid update contract", + download_url: `${process.env.DONUT_E2E_FIXTURE_URL}/invalid-update.zip`, + is_nightly: false, + published_at: "2026-01-01T00:00:00Z", + manual_update_required: false, + release_page_url: null, + repo_update: false, + checksums_url: null, + asset_digest: null, + }, + }, + ); + assert.match(prepareError, /checksum|verif|Failed to download/i); + const versionStatus = await app.invoke("get_version_update_status"); + assert.ok(versionStatus && typeof versionStatus === "object"); + assert.equal(typeof (await app.invoke("is_default_browser")), "boolean"); + + const trial = await app.invoke("get_commercial_trial_status"); + assert.ok(trial && typeof trial === "object"); + await app.invoke("acknowledge_trial_expiration"); + assert.equal(await app.invoke("has_acknowledged_trial_expiration"), true); + await app.invoke("cloud_logout"); + assert.equal(await app.invoke("cloud_get_user"), null); + }, + { wayfernTermsAccepted: false }, + ); }); diff --git a/e2e/tests/ui.test.mjs b/e2e/tests/ui.test.mjs index 3eb7b4c..e4b6069 100644 --- a/e2e/tests/ui.test.mjs +++ b/e2e/tests/ui.test.mjs @@ -335,6 +335,62 @@ test("settings tabs, command palette filtering, and responsive layout survive re }); }); +test("first-run onboarding stays recoverable, responsive, and platform-aware", async () => { + await withApp( + "ui-onboarding", + async (app) => { + await app.waitForText("Welcome to Donut Browser"); + assert.equal(await app.invoke("get_onboarding_completed"), false); + + await app.session.command("POST", "/window/rect", { + width: 640, + height: 400, + }); + const layout = await app.execute(` + const dialog = document.querySelector("[role='dialog']"); + const progress = dialog?.querySelector("[role='progressbar']"); + if (!dialog || !progress) return null; + const rect = dialog.getBoundingClientRect(); + return { + left: rect.left, + top: rect.top, + right: rect.right, + bottom: rect.bottom, + viewportWidth: innerWidth, + viewportHeight: innerHeight, + progressNow: progress.getAttribute("aria-valuenow"), + }; + `); + assert.ok(layout); + assert.ok(layout.left >= 0 && layout.right <= layout.viewportWidth); + assert.ok(layout.top >= 0 && layout.bottom <= layout.viewportHeight); + assert.equal(layout.progressNow, "1"); + + await app.clickText("Next", { roles: ["button"] }); + await app.waitForText("Licensing"); + await app.clickText("I understand", { roles: ["button"] }); + + await app.waitFor( + async () => + /Allow microphone & camera|Setting things up|Setup failed/.test( + await app.bodyText(), + ), + { description: "platform-appropriate onboarding step" }, + ); + const body = await app.bodyText(); + if (process.platform !== "darwin") { + assert.doesNotMatch(body, /Allow microphone & camera/); + assert.match(body, /Setting things up|Setup failed/); + } + + // Setup and the optional product tour are still unfinished, so a crash + // or restart must be able to resume onboarding. + assert.equal(await app.invoke("get_onboarding_completed"), false); + }, + { onboardingCompleted: false }, + ); +}); + test("predefined theme remains rendered across navigation and restart", async () => { await withApp("ui-theme-predefined", async (app) => { await app.clickSelector('[aria-label="Settings"]'); diff --git a/src-tauri/src/api_server.rs b/src-tauri/src/api_server.rs index 99e743f..1789dfc 100644 --- a/src-tauri/src/api_server.rs +++ b/src-tauri/src/api_server.rs @@ -6,9 +6,9 @@ use crate::proxy_manager::PROXY_MANAGER; use crate::tag_manager::TAG_MANAGER; use axum::{ extract::{Path, Query, State}, - http::{HeaderMap, StatusCode}, + http::{header, HeaderMap, Method, StatusCode}, middleware::{self, Next}, - response::{Json, Response}, + response::{IntoResponse, Json, Response}, routing::get, Router, }; @@ -494,14 +494,16 @@ impl ApiServer { ); listener } - Err(e) => return Err(format!("Failed to bind to any port: {e}")), + Err(e) => { + return Err(crate::backend_error_with_detail("API_PORT_UNAVAILABLE", e)); + } } } }; let actual_port = listener .local_addr() - .map_err(|e| format!("Failed to get local address: {e}"))? + .map_err(|e| crate::backend_error_with_detail("INTERNAL_ERROR", e))? .port(); // Create router with OpenAPI documentation @@ -538,8 +540,7 @@ impl ApiServer { let api = ApiDoc::openapi(); let v1_routes = v1_routes - // Inert chokepoint (innermost → runs after auth) for the future per-hour - // automation request limit. See rate_limit_middleware. + // Innermost so only authenticated automation requests consume quota. .layer(middleware::from_fn(rate_limit_middleware)) .layer(middleware::from_fn_with_state( state.clone(), @@ -692,18 +693,47 @@ async fn request_logging_middleware(request: axum::extract::Request, next: Next) response } -/// Chokepoint for the future per-hour automation request limit. The limit -/// (`requests_per_hour`, default 100) is already plumbed through entitlements; -/// this middleware is intentionally inert today — it resolves the limit but -/// never blocks. To enforce, count authenticated requests per rolling hour and -/// return `StatusCode::TOO_MANY_REQUESTS` once the limit (when > 0) is exceeded. -async fn rate_limit_middleware( - request: axum::extract::Request, - next: Next, -) -> Result { - let _requests_per_hour = crate::cloud_auth::CLOUD_AUTH.requests_per_hour().await; - // TODO(rate-limit): enforce `_requests_per_hour` for automation routes. - Ok(next.run(request).await) +fn is_automation_request(method: &Method, path: &str) -> bool { + if method != Method::POST { + return false; + } + + if matches!(path, "/v1/profiles/batch/run" | "/v1/profiles/batch/stop") { + return true; + } + + let Some(profile_action) = path.strip_prefix("/v1/profiles/") else { + return false; + }; + let mut segments = profile_action.split('/'); + matches!( + (segments.next(), segments.next(), segments.next()), + (Some(_), Some("run" | "open-url" | "kill"), None) + ) +} + +async fn rate_limit_middleware(request: axum::extract::Request, next: Next) -> Response { + if !is_automation_request(request.method(), request.uri().path()) { + return next.run(request).await; + } + + match crate::automation_rate_limiter::check_automation_rate_limit().await { + crate::automation_rate_limiter::RateLimitOutcome::Limited { retry_after_secs } => { + log::warn!( + "[api] Rejected {}: automation rate limit exceeded; retry in {}s", + request.uri().path(), + retry_after_secs + ); + ( + StatusCode::TOO_MANY_REQUESTS, + [(header::RETRY_AFTER, retry_after_secs.to_string())], + "automation request rate limit exceeded", + ) + .into_response() + } + crate::automation_rate_limiter::RateLimitOutcome::Unlimited + | crate::automation_rate_limiter::RateLimitOutcome::Allowed { .. } => next.run(request).await, + } } // Global API server instance @@ -2036,6 +2066,7 @@ async fn delete_extension_group_api( (status = 402, description = "Active paid plan with browser automation required"), (status = 404, description = "Profile not found"), (status = 409, description = "Profile is locked by another team member"), + (status = 429, description = "Automation request rate limit exceeded"), (status = 500, description = "Internal server error") ), security( @@ -2124,6 +2155,7 @@ async fn run_profile( (status = 401, description = "Unauthorized"), (status = 402, description = "Active paid plan with browser automation required"), (status = 404, description = "Profile not found"), + (status = 429, description = "Automation request rate limit exceeded"), (status = 500, description = "Internal server error") ), security( @@ -2165,6 +2197,7 @@ async fn open_url_in_profile( (status = 401, description = "Unauthorized"), (status = 402, description = "Active paid plan required"), (status = 404, description = "Profile not found"), + (status = 429, description = "Automation request rate limit exceeded"), (status = 500, description = "Internal server error") ), security( @@ -2217,6 +2250,7 @@ async fn kill_profile( (status = 200, description = "Batch launch completed; inspect per-profile results", body = BatchRunResponse), (status = 401, description = "Unauthorized"), (status = 402, description = "Active paid plan with browser automation required"), + (status = 429, description = "Automation request rate limit exceeded"), (status = 500, description = "Internal server error") ), security( @@ -2312,6 +2346,7 @@ async fn batch_run_profiles( (status = 200, description = "Batch stop completed; inspect per-profile results", body = BatchStopResponse), (status = 401, description = "Unauthorized"), (status = 402, description = "Active paid plan with browser automation required"), + (status = 429, description = "Automation request rate limit exceeded"), (status = 500, description = "Internal server error") ), security( @@ -2672,6 +2707,35 @@ mod tests { assert!(!is_valid("")); } + #[test] + fn rate_limit_only_classifies_browser_automation_routes() { + for path in [ + "/v1/profiles/profile-id/run", + "/v1/profiles/profile-id/open-url", + "/v1/profiles/profile-id/kill", + "/v1/profiles/batch/run", + "/v1/profiles/batch/stop", + ] { + assert!( + is_automation_request(&Method::POST, path), + "automation route was not limited: {path}" + ); + } + + for (method, path) in [ + (Method::GET, "/v1/profiles/profile-id/run"), + (Method::POST, "/v1/profiles"), + (Method::POST, "/v1/profiles/import"), + (Method::GET, "/v1/profiles"), + (Method::GET, "/openapi.json"), + ] { + assert!( + !is_automation_request(&method, path), + "free or non-mutating route was limited: {method} {path}" + ); + } + } + fn schema_required(spec: &serde_json::Value, schema: &str) -> Vec { spec["components"]["schemas"][schema]["required"] .as_array() @@ -2764,5 +2828,18 @@ mod tests { !paths.keys().any(|p| p.contains("wayfern-token")), "wayfern-token endpoints were removed and must stay out of the spec" ); + + for path in [ + "/v1/profiles/{id}/run", + "/v1/profiles/{id}/open-url", + "/v1/profiles/{id}/kill", + "/v1/profiles/batch/run", + "/v1/profiles/batch/stop", + ] { + assert!( + paths[path]["post"]["responses"].get("429").is_some(), + "automation route is missing its 429 response: {path}" + ); + } } } diff --git a/src-tauri/src/automation_rate_limiter.rs b/src-tauri/src/automation_rate_limiter.rs new file mode 100644 index 0000000..766a346 --- /dev/null +++ b/src-tauri/src/automation_rate_limiter.rs @@ -0,0 +1,137 @@ +use std::collections::{HashMap, VecDeque}; +use std::sync::{LazyLock, Mutex}; +use std::time::{Duration, Instant}; + +use crate::cloud_auth::CLOUD_AUTH; + +const RATE_LIMIT_WINDOW: Duration = Duration::from_secs(60 * 60); + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RateLimitOutcome { + Unlimited, + Allowed { remaining: u64 }, + Limited { retry_after_secs: u64 }, +} + +#[derive(Default)] +struct AutomationRateLimiter { + requests: HashMap>, +} + +impl AutomationRateLimiter { + fn check_at(&mut self, identity: &str, requests_per_hour: u64, now: Instant) -> RateLimitOutcome { + if requests_per_hour == 0 { + return RateLimitOutcome::Unlimited; + } + + self.requests.retain(|_, requests| { + while requests + .front() + .is_some_and(|started| now.duration_since(*started) >= RATE_LIMIT_WINDOW) + { + requests.pop_front(); + } + !requests.is_empty() + }); + + let requests = self.requests.entry(identity.to_string()).or_default(); + if requests.len() as u64 >= requests_per_hour { + let retry_after_secs = requests + .front() + .map(|started| { + let remaining = RATE_LIMIT_WINDOW.saturating_sub(now.duration_since(*started)); + remaining + .as_secs() + .saturating_add(u64::from(remaining.subsec_nanos() > 0)) + .max(1) + }) + .unwrap_or(1); + return RateLimitOutcome::Limited { retry_after_secs }; + } + + requests.push_back(now); + RateLimitOutcome::Allowed { + remaining: requests_per_hour.saturating_sub(requests.len() as u64), + } + } +} + +static AUTOMATION_RATE_LIMITER: LazyLock> = + LazyLock::new(|| Mutex::new(AutomationRateLimiter::default())); + +pub async fn check_automation_rate_limit() -> RateLimitOutcome { + let Some((identity, requests_per_hour)) = CLOUD_AUTH.automation_rate_limit().await else { + return RateLimitOutcome::Unlimited; + }; + + AUTOMATION_RATE_LIMITER + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .check_at(&identity, requests_per_hour, Instant::now()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rolling_window_limits_per_identity_and_recovers() { + let mut limiter = AutomationRateLimiter::default(); + let now = Instant::now(); + + assert_eq!( + limiter.check_at("user-a", 2, now), + RateLimitOutcome::Allowed { remaining: 1 } + ); + assert_eq!( + limiter.check_at("user-a", 2, now + Duration::from_secs(1)), + RateLimitOutcome::Allowed { remaining: 0 } + ); + assert_eq!( + limiter.check_at("user-a", 2, now + Duration::from_secs(2)), + RateLimitOutcome::Limited { + retry_after_secs: 3598 + } + ); + + assert_eq!( + limiter.check_at("user-b", 2, now + Duration::from_secs(2)), + RateLimitOutcome::Allowed { remaining: 1 } + ); + assert_eq!( + limiter.check_at("user-a", 2, now + RATE_LIMIT_WINDOW), + RateLimitOutcome::Allowed { remaining: 0 } + ); + assert_eq!( + limiter.check_at( + "user-a", + 2, + now + RATE_LIMIT_WINDOW + Duration::from_secs(1) + ), + RateLimitOutcome::Allowed { remaining: 0 } + ); + assert_eq!( + limiter.check_at( + "user-a", + 2, + now + RATE_LIMIT_WINDOW * 2 + Duration::from_secs(1) + ), + RateLimitOutcome::Allowed { remaining: 1 } + ); + } + + #[test] + fn zero_limit_is_unlimited_and_does_not_consume_capacity() { + let mut limiter = AutomationRateLimiter::default(); + let now = Instant::now(); + + assert_eq!( + limiter.check_at("user-a", 0, now), + RateLimitOutcome::Unlimited + ); + assert_eq!( + limiter.check_at("user-a", 1, now), + RateLimitOutcome::Allowed { remaining: 0 } + ); + } +} diff --git a/src-tauri/src/cloud_auth.rs b/src-tauri/src/cloud_auth.rs index af28734..ed23f44 100644 --- a/src-tauri/src/cloud_auth.rs +++ b/src-tauri/src/cloud_auth.rs @@ -23,8 +23,7 @@ pub const CLOUD_API_URL: &str = "https://api.donutbrowser.com"; pub const CLOUD_SYNC_URL: &str = "https://sync.donutbrowser.com"; /// Default per-hour cap on local automation API / MCP requests. Mirrors the -/// backend's DEFAULT_REQUESTS_PER_HOUR. Not enforced yet — see the inert -/// rate-limit chokepoints in api_server / mcp_server. +/// backend's DEFAULT_REQUESTS_PER_HOUR. const DEFAULT_REQUESTS_PER_HOUR: i64 = 100; /// Capability + limit set the account is entitled to, derived from its plan. @@ -828,14 +827,24 @@ impl CloudAuthManager { } } - /// Per-hour cap on automation requests (0 when automation is unavailable). - /// Carried for the future local rate limiter; read by the inert chokepoints. - pub async fn requests_per_hour(&self) -> i64 { - self - .entitlements() - .await - .map(|e| e.requests_per_hour) - .unwrap_or(0) + /// Identity and positive per-hour cap for the shared REST/MCP automation + /// limiter. No active automation entitlement means no limiter entry; the + /// capability gates still reject paid operations independently. + pub async fn automation_rate_limit(&self) -> Option<(String, u64)> { + #[cfg(feature = "e2e")] + if crate::e2e_automation_enabled() { + if let Ok(limit) = std::env::var("DONUT_E2E_REQUESTS_PER_HOUR") { + if let Ok(limit) = limit.parse::() { + if limit > 0 { + return Some(("e2e-automation".to_string(), limit)); + } + } + } + } + + let state = self.get_user().await?; + let limit = state.user.entitlements().requests_per_hour; + (limit > 0).then_some((state.user.id, limit as u64)) } pub async fn is_fingerprint_os_allowed(&self, fingerprint_os: Option<&str>) -> bool { @@ -1040,126 +1049,6 @@ impl CloudAuthManager { .await } - /// Fetch region list for a country from the cloud backend - pub async fn fetch_regions(&self, country: &str) -> Result, String> { - let country = country.to_string(); - self - .api_call_with_retry(move |access_token| { - let url = format!( - "{CLOUD_API_URL}/api/proxy/locations/regions?country={}", - country - ); - let client = reqwest::Client::new(); - async move { - let response = client - .get(&url) - .header("Authorization", format!("Bearer {access_token}")) - .send() - .await - .map_err(|e| format!("Failed to fetch regions: {e}"))?; - - if !response.status().is_success() { - let status = response.status(); - let body = response.text().await.unwrap_or_default(); - return Err(format!("Regions fetch failed ({status}): {body}")); - } - - response - .json::>() - .await - .map_err(|e| format!("Failed to parse regions: {e}")) - } - }) - .await - } - - /// Fetch city list for a country, optionally filtered by region - pub async fn fetch_cities( - &self, - country: &str, - region: Option<&str>, - ) -> Result, String> { - let country = country.to_string(); - let region = region.map(|s| s.to_string()); - self - .api_call_with_retry(move |access_token| { - let mut url = format!( - "{CLOUD_API_URL}/api/proxy/locations/cities?country={}", - country - ); - if let Some(ref r) = region { - url.push_str(&format!("®ion={}", r)); - } - let client = reqwest::Client::new(); - async move { - let response = client - .get(&url) - .header("Authorization", format!("Bearer {access_token}")) - .send() - .await - .map_err(|e| format!("Failed to fetch cities: {e}"))?; - - if !response.status().is_success() { - let status = response.status(); - let body = response.text().await.unwrap_or_default(); - return Err(format!("Cities fetch failed ({status}): {body}")); - } - - response - .json::>() - .await - .map_err(|e| format!("Failed to parse cities: {e}")) - } - }) - .await - } - - /// Fetch ISP list for a country, optionally filtered by region and city - pub async fn fetch_isps( - &self, - country: &str, - region: Option<&str>, - city: Option<&str>, - ) -> Result, String> { - let country = country.to_string(); - let region = region.map(|s| s.to_string()); - let city = city.map(|s| s.to_string()); - self - .api_call_with_retry(move |access_token| { - let mut url = format!( - "{CLOUD_API_URL}/api/proxy/locations/isps?country={}", - country - ); - if let Some(ref r) = region { - url.push_str(&format!("®ion={}", r)); - } - if let Some(ref c) = city { - url.push_str(&format!("&city={}", c)); - } - let client = reqwest::Client::new(); - async move { - let response = client - .get(&url) - .header("Authorization", format!("Bearer {access_token}")) - .send() - .await - .map_err(|e| format!("Failed to fetch ISPs: {e}"))?; - - if !response.status().is_success() { - let status = response.status(); - let body = response.text().await.unwrap_or_default(); - return Err(format!("ISPs fetch failed ({status}): {body}")); - } - - response - .json::>() - .await - .map_err(|e| format!("Failed to parse ISPs: {e}")) - } - }) - .await - } - /// Request a wayfern token from the cloud API. Only succeeds for paid users. pub async fn request_wayfern_token(&self) -> Result<(), String> { if !self.has_active_paid_subscription().await { @@ -1459,30 +1348,6 @@ pub async fn cloud_get_countries() -> Result, String> { CLOUD_AUTH.fetch_countries().await } -#[tauri::command] -pub async fn cloud_get_regions(country: String) -> Result, String> { - CLOUD_AUTH.fetch_regions(&country).await -} - -#[tauri::command] -pub async fn cloud_get_cities( - country: String, - region: Option, -) -> Result, String> { - CLOUD_AUTH.fetch_cities(&country, region.as_deref()).await -} - -#[tauri::command] -pub async fn cloud_get_isps( - country: String, - region: Option, - city: Option, -) -> Result, String> { - CLOUD_AUTH - .fetch_isps(&country, region.as_deref(), city.as_deref()) - .await -} - #[tauri::command] pub async fn create_cloud_location_proxy( name: String, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 059e6a3..948b949 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -15,6 +15,14 @@ static PENDING_URLS: Mutex> = Mutex::new(Vec::new()); // to the confirmation dialog. static QUIT_CONFIRMED: AtomicBool = AtomicBool::new(false); +pub(crate) fn backend_error(code: &str) -> String { + serde_json::json!({ "code": code }).to_string() +} + +pub(crate) fn backend_error_with_detail(code: &str, detail: impl std::fmt::Display) -> String { + serde_json::json!({ "code": code, "params": { "detail": detail.to_string() } }).to_string() +} + fn e2e_automation_enabled() -> bool { #[cfg(feature = "e2e")] { @@ -39,6 +47,7 @@ mod api_server; mod app_auto_updater; pub mod app_dirs; mod auto_updater; +mod automation_rate_limiter; mod browser; mod browser_runner; mod browser_version_manager; @@ -271,15 +280,14 @@ async fn handle_url_open(app: tauri::AppHandle, url: String) -> Result<(), Strin Ok(()) } -/// Prefix a command error with context, but pass structured `{"code": ...}` -/// backend errors through untouched — the frontend can only translate a code -/// when the JSON is the entire message (see src/lib/backend-errors.ts). +/// Preserve structured backend errors and wrap lower-level diagnostics in the +/// generic structured error shape expected by the frontend. pub(crate) fn wrap_backend_error(e: impl std::fmt::Display, context: &str) -> String { let msg = e.to_string(); if msg.starts_with('{') { msg } else { - format!("{context}: {msg}") + backend_error_with_detail("INTERNAL_ERROR", format!("{context}: {msg}")) } } @@ -531,14 +539,14 @@ async fn get_mcp_config(app_handle: tauri::AppHandle) -> Result Result { let mcp_server = mcp_server::McpServer::instance(); - let port = mcp_server.get_port().ok_or("MCP server is not running")?; + let port = mcp_server + .get_port() + .ok_or_else(|| backend_error("MCP_SERVER_NOT_RUNNING"))?; let settings_manager = settings_manager::SettingsManager::instance(); let token = settings_manager .get_mcp_token(app_handle) .await - .map_err(|e| format!("Failed to get MCP token: {e}"))? - .ok_or("MCP token not found")?; + .map_err(|e| backend_error_with_detail("INTERNAL_ERROR", e))? + .ok_or_else(|| backend_error("MCP_CONFIGURATION_UNAVAILABLE"))?; Ok(format!("http://127.0.0.1:{port}/mcp/{token}")) } @@ -761,24 +771,28 @@ async fn list_mcp_agents() -> Result, String #[tauri::command] async fn add_mcp_to_agent(app_handle: tauri::AppHandle, agent_id: String) -> Result<(), String> { if !mcp_integrations::agent_exists(&agent_id) { - return Err(format!("Unknown agent: {agent_id}")); + return Err(backend_error("MCP_AGENT_UNKNOWN")); } - if agent_id == "claude-desktop" { - return add_mcp_to_claude_desktop_internal(&app_handle).await; - } - let url = current_mcp_url(&app_handle).await?; - mcp_integrations::install_generic(&agent_id, &url) + let result = if agent_id == "claude-desktop" { + add_mcp_to_claude_desktop_internal(&app_handle).await + } else { + let url = current_mcp_url(&app_handle).await?; + mcp_integrations::install_generic(&agent_id, &url) + }; + result.map_err(|e| backend_error_with_detail("MCP_AGENT_INSTALL_FAILED", e)) } #[tauri::command] async fn remove_mcp_from_agent(agent_id: String) -> Result<(), String> { if !mcp_integrations::agent_exists(&agent_id) { - return Err(format!("Unknown agent: {agent_id}")); + return Err(backend_error("MCP_AGENT_UNKNOWN")); } - if agent_id == "claude-desktop" { - return remove_mcp_from_claude_desktop_internal(); - } - mcp_integrations::uninstall_generic(&agent_id) + let result = if agent_id == "claude-desktop" { + remove_mcp_from_claude_desktop_internal() + } else { + mcp_integrations::uninstall_generic(&agent_id) + }; + result.map_err(|e| backend_error_with_detail("MCP_AGENT_REMOVE_FAILED", e)) } #[tauri::command] @@ -2432,9 +2446,6 @@ pub fn run_with_builder( cloud_auth::cloud_logout, cloud_auth::cloud_get_proxy_usage, cloud_auth::cloud_get_countries, - cloud_auth::cloud_get_regions, - cloud_auth::cloud_get_cities, - cloud_auth::cloud_get_isps, cloud_auth::create_cloud_location_proxy, cloud_auth::restart_sync_service, cloud_auth::cloud_get_wayfern_token, @@ -2481,6 +2492,24 @@ pub fn run_with_builder( mod tests { use std::fs; + #[test] + fn backend_error_helpers_preserve_codes_and_structure_diagnostics() { + let coded = super::backend_error("PROFILE_NOT_FOUND"); + assert_eq!( + serde_json::from_str::(&coded).unwrap()["code"], + "PROFILE_NOT_FOUND" + ); + assert_eq!(super::wrap_backend_error(&coded, "ignored"), coded); + + let wrapped = super::wrap_backend_error("disk unavailable", "Failed to save"); + let parsed = serde_json::from_str::(&wrapped).unwrap(); + assert_eq!(parsed["code"], "INTERNAL_ERROR"); + assert_eq!( + parsed["params"]["detail"], + "Failed to save: disk unavailable" + ); + } + #[test] fn test_no_unused_tauri_commands() { check_unused_commands(false); // Run in strict mode for CI diff --git a/src-tauri/src/mcp_server.rs b/src-tauri/src/mcp_server.rs index 7dcaa1f..0d98f8f 100644 --- a/src-tauri/src/mcp_server.rs +++ b/src-tauri/src/mcp_server.rs @@ -184,19 +184,17 @@ impl McpServer { pub async fn start(&self, app_handle: AppHandle) -> Result { if !WayfernTermsManager::instance().is_terms_accepted() { - return Err( - "Wayfern Terms and Conditions must be accepted before starting MCP server".to_string(), - ); + return Err(crate::backend_error("WAYFERN_TERMS_REQUIRED")); } if self.is_running() { - return Err("MCP server is already running".to_string()); + return Err(crate::backend_error("MCP_SERVER_ALREADY_RUNNING")); } let settings_manager = SettingsManager::instance(); let settings = settings_manager .load_settings() - .map_err(|e| format!("Failed to load settings: {e}"))?; + .map_err(|e| crate::backend_error_with_detail("INTERNAL_ERROR", e))?; // Get or generate token let existing_token = settings_manager @@ -211,12 +209,16 @@ impl McpServer { settings_manager .generate_mcp_token(&app_handle) .await - .map_err(|e| format!("Failed to generate MCP token: {e}"))? + .map_err(|e| crate::backend_error_with_detail("INTERNAL_ERROR", e))? }; // Determine port (use saved port, or try default, or random) let preferred_port = settings.mcp_port.unwrap_or(DEFAULT_MCP_PORT); - let actual_port = self.bind_to_available_port(preferred_port).await?; + let listener = self.bind_to_available_port(preferred_port).await?; + let actual_port = listener + .local_addr() + .map_err(|e| crate::backend_error_with_detail("INTERNAL_ERROR", e))? + .port(); // Save port if it changed if settings.mcp_port != Some(actual_port) { @@ -224,7 +226,7 @@ impl McpServer { new_settings.mcp_port = Some(actual_port); settings_manager .save_settings(&new_settings) - .map_err(|e| format!("Failed to save settings: {e}"))?; + .map_err(|e| crate::backend_error_with_detail("INTERNAL_ERROR", e))?; } // Store state @@ -244,31 +246,31 @@ impl McpServer { server: McpServer::instance(), token, }; - tokio::spawn(Self::run_http_server(actual_port, http_state, shutdown_rx)); + tokio::spawn(Self::run_http_server(listener, http_state, shutdown_rx)); log::info!("[mcp] Server started on port {}", actual_port); Ok(actual_port) } - async fn bind_to_available_port(&self, preferred: u16) -> Result { + async fn bind_to_available_port(&self, preferred: u16) -> Result { let addr = SocketAddr::from(([127, 0, 0, 1], preferred)); - if TcpListener::bind(addr).await.is_ok() { - return Ok(preferred); + if let Ok(listener) = TcpListener::bind(addr).await { + return Ok(listener); } for _ in 0..10 { let port = 51000 + (rand::random::() % 1000); let addr = SocketAddr::from(([127, 0, 0, 1], port)); - if TcpListener::bind(addr).await.is_ok() { - return Ok(port); + if let Ok(listener) = TcpListener::bind(addr).await { + return Ok(listener); } } - Err("Could not find available port for MCP server".to_string()) + Err(crate::backend_error("MCP_PORT_UNAVAILABLE")) } async fn run_http_server( - port: u16, + listener: TcpListener, state: McpHttpState, shutdown_rx: tokio::sync::oneshot::Receiver<()>, ) { @@ -286,28 +288,17 @@ impl McpServer { .delete(Self::handle_mcp_delete), ) .route("/health", get(Self::handle_health)) - // Inert chokepoint (innermost → runs after auth) for the future per-hour - // automation request limit. See rate_limit_middleware. - .layer(middleware::from_fn(Self::rate_limit_middleware)) .layer(middleware::from_fn_with_state( state.clone(), Self::auth_middleware, )) .with_state(state); - let addr = SocketAddr::from(([127, 0, 0, 1], port)); - - let server = async { - match TcpListener::bind(addr).await { - Ok(listener) => { - log::info!("[mcp] Server listening on http://127.0.0.1:{}/mcp", port); - if let Err(e) = axum::serve(listener, app).await { - log::error!("[mcp] Server error: {}", e); - } - } - Err(e) => { - log::error!("[mcp] Failed to bind on port {}: {}", port, e); - } + let port = listener.local_addr().map(|addr| addr.port()).unwrap_or(0); + let server = async move { + log::info!("[mcp] Server listening on http://127.0.0.1:{}/mcp", port); + if let Err(e) = axum::serve(listener, app).await { + log::error!("[mcp] Server error: {}", e); } }; @@ -319,17 +310,6 @@ impl McpServer { } } - /// Chokepoint for the future per-hour automation request limit, mirroring the - /// REST API's. The limit (`requests_per_hour`, default 100) is plumbed through - /// entitlements; this is intentionally inert today — it resolves the limit but - /// never blocks. To enforce, count authenticated tool calls per rolling hour - /// and return StatusCode::TOO_MANY_REQUESTS once the limit (when > 0) is hit. - async fn rate_limit_middleware(req: Request, next: Next) -> Result { - let _requests_per_hour = CLOUD_AUTH.requests_per_hour().await; - // TODO(rate-limit): enforce `_requests_per_hour` for MCP tool calls. - Ok(next.run(req).await) - } - async fn auth_middleware( State(state): State, req: Request, @@ -476,14 +456,65 @@ impl McpServer { } } + if Self::is_automation_tool_call(&request) { + if let crate::automation_rate_limiter::RateLimitOutcome::Limited { retry_after_secs } = + crate::automation_rate_limiter::check_automation_rate_limit().await + { + log::warn!( + "[mcp] Rejected tools/call: automation rate limit exceeded; retry in {}s", + retry_after_secs + ); + return ( + StatusCode::TOO_MANY_REQUESTS, + [(header::RETRY_AFTER, retry_after_secs.to_string())], + "automation request rate limit exceeded", + ) + .into_response(); + } + } + let response = state.server.handle_request(request).await; Json(response).into_response() } } + fn is_automation_tool_call(request: &McpRequest) -> bool { + if request.method != "tools/call" { + return false; + } + + let Some(tool_name) = request + .params + .as_ref() + .and_then(|params| params.get("name")) + .and_then(|name| name.as_str()) + else { + return false; + }; + + matches!( + tool_name, + "run_profile" + | "kill_profile" + | "batch_run_profiles" + | "batch_stop_profiles" + | "start_sync_session" + | "navigate" + | "screenshot" + | "evaluate_javascript" + | "click_element" + | "type_text" + | "get_page_content" + | "get_page_info" + | "get_interactive_elements" + | "click_by_index" + | "type_by_index" + ) + } + pub async fn stop(&self) -> Result<(), String> { if !self.is_running() { - return Err("MCP server is not running".to_string()); + return Err(crate::backend_error("MCP_SERVER_NOT_RUNNING")); } let mut inner = self.inner.lock().await; @@ -5528,4 +5559,46 @@ mod tests { let server = McpServer::new(); assert!(!server.is_running()); } + + #[test] + fn rate_limit_only_classifies_browser_automation_tools() { + let request = |method: &str, name: Option<&str>| McpRequest { + jsonrpc: "2.0".to_string(), + id: Some(serde_json::json!(1)), + method: method.to_string(), + params: name.map(|name| serde_json::json!({ "name": name, "arguments": {} })), + }; + + for name in [ + "run_profile", + "kill_profile", + "batch_run_profiles", + "batch_stop_profiles", + "start_sync_session", + "navigate", + "screenshot", + "evaluate_javascript", + "click_element", + "type_text", + "get_page_content", + "get_page_info", + "get_interactive_elements", + "click_by_index", + "type_by_index", + ] { + assert!( + McpServer::is_automation_tool_call(&request("tools/call", Some(name))), + "automation tool was not limited: {name}" + ); + } + + assert!(!McpServer::is_automation_tool_call(&request( + "tools/call", + Some("list_profiles") + ))); + assert!(!McpServer::is_automation_tool_call(&request( + "tools/list", + None + ))); + } } diff --git a/src/app/page.tsx b/src/app/page.tsx index cce5b7c..daf7637 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -69,6 +69,7 @@ import { translateBackendError } from "@/lib/backend-errors"; import { getEntitlements } from "@/lib/entitlements"; import { MOTION_EASE_OUT } from "@/lib/motion"; import { + ONBOARDING_TOUR_CLOSED_EVENT, ONBOARDING_TOUR_FINISHED_EVENT, setOnboardingActive, } from "@/lib/onboarding-signal"; @@ -113,31 +114,46 @@ export default function Home() { const onboardingHandledRef = useRef(false); const [welcomeOpen, setWelcomeOpen] = useState(false); const [thankYouOpen, setThankYouOpen] = useState(false); - // null = onboarding decision pending; false = not a first-run onboarding (run - // the normal permission checks); true = first-run onboarding, so the welcome - // flow drives permissions and the standalone permission dialog is suppressed. + // null = onboarding decision pending; false = not a first-run session (run + // normal permission checks); true = first-run session, so "Not now" really + // defers the standalone permission dialog until a later launch. const [firstRunOnboarding, setFirstRunOnboarding] = useState( null, ); + const persistOnboardingComplete = useCallback(() => { + void invoke("complete_onboarding").catch((err: unknown) => { + console.error("Failed to persist onboarding completion:", err); + }); + }, []); + // Welcome flow finished. Existing-profile users are done after the welcome + // commercial-use steps; users with no profile yet continue into the in-app // product tour that walks them through creating their first profile. const handleWelcomeComplete = useCallback(() => { setWelcomeOpen(false); - setFirstRunOnboarding(false); if (profiles.length === 0) { startOnborda(ONBOARDING_TOUR); + } else { + persistOnboardingComplete(); } - }, [startOnborda, profiles.length]); + }, [persistOnboardingComplete, profiles.length, startOnborda]); - // The product tour finished (user clicked "Finish", not "Skip") → celebrate. + // Finishing or explicitly skipping the product tour completes the one-shot + // onboarding. Only reaching the end triggers the celebration. useEffect(() => { - const handler = () => setThankYouOpen(true); - window.addEventListener(ONBOARDING_TOUR_FINISHED_EVENT, handler); - return () => - window.removeEventListener(ONBOARDING_TOUR_FINISHED_EVENT, handler); - }, []); + const handleClosed = () => persistOnboardingComplete(); + const handleFinished = () => setThankYouOpen(true); + window.addEventListener(ONBOARDING_TOUR_CLOSED_EVENT, handleClosed); + window.addEventListener(ONBOARDING_TOUR_FINISHED_EVENT, handleFinished); + return () => { + window.removeEventListener(ONBOARDING_TOUR_CLOSED_EVENT, handleClosed); + window.removeEventListener( + ONBOARDING_TOUR_FINISHED_EVENT, + handleFinished, + ); + }; + }, [persistOnboardingComplete]); // Suppress the global browser-download toasts while onboarding (welcome or // tour) is active — the welcome dialog shows setup progress itself. @@ -162,8 +178,9 @@ export default function Home() { return () => window.removeEventListener("scroll", pin, true); }, [isOnbordaVisible]); - // On the very first launch, always show the welcome + commercial-use steps - // (one-shot: the backend flag is set immediately so it can't trigger again). + // On the very first launch, always show the welcome + commercial-use steps. + // The completion flag is only persisted after the user finishes or skips the + // full flow, so an interrupted setup can recover on the next launch. // The welcome dialog itself decides whether to continue into the browser // download + profile-creation flow — only when the user has no profile yet. useEffect(() => { @@ -176,7 +193,6 @@ export default function Home() { setFirstRunOnboarding(false); return; } - await invoke("complete_onboarding"); setFirstRunOnboarding(true); setWelcomeOpen(true); } catch (err) { @@ -191,8 +207,11 @@ export default function Home() { useEffect(() => { if (isOnbordaVisible && currentStep === 0 && profiles.length > 0) { // Small delay so the new profile row (and its DNS dropdown target) has - // mounted before Onborda re-points at it. - setCurrentStep(1, 300); + // mounted before Onborda re-points at it. Owning the timeout here lets + // cleanup cancel it if the tour closes, instead of reopening a dismissed + // tour when Onborda's delayed setter eventually fires. + const timeout = window.setTimeout(() => setCurrentStep(1), 300); + return () => window.clearTimeout(timeout); } }, [isOnbordaVisible, currentStep, profiles.length, setCurrentStep]); diff --git a/src/components/consistency-warning-dialog.tsx b/src/components/consistency-warning-dialog.tsx index 64b16e9..7e200c5 100644 --- a/src/components/consistency-warning-dialog.tsx +++ b/src/components/consistency-warning-dialog.tsx @@ -29,14 +29,6 @@ export interface ConsistencyResult { const GLOBAL_DISABLE_KEY = "consistency-warn-disabled"; const perProfileKey = (id: string) => `consistency-warn-skip-${id}`; -export function isConsistencyWarningEnabled(): boolean { - try { - return localStorage.getItem(GLOBAL_DISABLE_KEY) !== "1"; - } catch { - return true; - } -} - export function isConsistencyWarningSuppressed(profileId: string): boolean { try { return ( diff --git a/src/components/icons/zen-browser.tsx b/src/components/icons/zen-browser.tsx deleted file mode 100644 index 1531d83..0000000 --- a/src/components/icons/zen-browser.tsx +++ /dev/null @@ -1,25 +0,0 @@ -export const ZenBrowser = (props: React.SVGProps) => ( - - Zen Browser - - - - -); diff --git a/src/components/integrations-dialog.tsx b/src/components/integrations-dialog.tsx index 69fc658..b17a900 100644 --- a/src/components/integrations-dialog.tsx +++ b/src/components/integrations-dialog.tsx @@ -213,8 +213,7 @@ export function IntegrationsDialog({ } catch (e) { console.error("Failed to toggle API:", e); showErrorToast(t("integrations.apiToggleFailed"), { - description: - e instanceof Error ? e.message : t("integrations.apiUnknownError"), + description: translateBackendError(t, e), }); } finally { setIsApiStarting(false); @@ -245,8 +244,7 @@ export function IntegrationsDialog({ } catch (e) { console.error("Failed to toggle MCP server:", e); showErrorToast(t("integrations.mcpToggleFailed"), { - description: - e instanceof Error ? e.message : t("integrations.apiUnknownError"), + description: translateBackendError(t, e), }); } finally { setIsMcpStarting(false); @@ -452,10 +450,7 @@ export function IntegrationsDialog({ showErrorToast( t("integrations.apiStartFailed"), { - description: - e instanceof Error - ? e.message - : t("integrations.apiUnknownError"), + description: translateBackendError(t, e), }, ); } finally { diff --git a/src/components/location-proxy-dialog.tsx b/src/components/location-proxy-dialog.tsx deleted file mode 100644 index c67840d..0000000 --- a/src/components/location-proxy-dialog.tsx +++ /dev/null @@ -1,358 +0,0 @@ -"use client"; - -import { invoke } from "@tauri-apps/api/core"; -import { emit } from "@tauri-apps/api/event"; -import { Loader2 } from "lucide-react"; -import { useCallback, useEffect, useState } from "react"; -import { useTranslation } from "react-i18next"; -import { toast } from "sonner"; -import { Button } from "@/components/ui/button"; -import { Combobox } from "@/components/ui/combobox"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import type { LocationItem } from "@/types"; -import { RippleButton } from "./ui/ripple"; - -interface LocationProxyDialogProps { - isOpen: boolean; - onClose: () => void; -} - -function LoadingSpinner() { - return ; -} - -export function LocationProxyDialog({ - isOpen, - onClose, -}: LocationProxyDialogProps) { - const { t } = useTranslation(); - const [countries, setCountries] = useState([]); - const [regions, setRegions] = useState([]); - const [cities, setCities] = useState([]); - const [isps, setIsps] = useState([]); - - const [selectedCountry, setSelectedCountry] = useState(""); - const [selectedRegion, setSelectedRegion] = useState(""); - const [selectedCity, setSelectedCity] = useState(""); - const [selectedIsp, setSelectedIsp] = useState(""); - const [proxyName, setProxyName] = useState(""); - - const [isLoadingCountries, setIsLoadingCountries] = useState(false); - const [isLoadingRegions, setIsLoadingRegions] = useState(false); - const [isLoadingCities, setIsLoadingCities] = useState(false); - const [isLoadingIsps, setIsLoadingIsps] = useState(false); - const [isCreating, setIsCreating] = useState(false); - - const handleClose = useCallback(() => { - setSelectedCountry(""); - setSelectedRegion(""); - setSelectedCity(""); - setSelectedIsp(""); - setProxyName(""); - setRegions([]); - setCities([]); - setIsps([]); - onClose(); - }, [onClose]); - - // Fetch countries on mount - useEffect(() => { - if (!isOpen) return; - setIsLoadingCountries(true); - void invoke("cloud_get_countries") - .then((data) => { - setCountries(data); - }) - .catch((err) => { - console.error("Failed to fetch countries:", err); - toast.error(t("locationProxy.loadFailed")); - }) - .finally(() => { - setIsLoadingCountries(false); - }); - }, [isOpen, t]); - - // Fetch regions when country changes - useEffect(() => { - if (!selectedCountry) { - setRegions([]); - return; - } - setIsLoadingRegions(true); - setSelectedRegion(""); - setSelectedCity(""); - setSelectedIsp(""); - setCities([]); - setIsps([]); - void invoke("cloud_get_regions", { - country: selectedCountry, - }) - .then((data) => { - setRegions(data); - }) - .catch((err) => { - console.error("Failed to fetch regions:", err); - }) - .finally(() => { - setIsLoadingRegions(false); - }); - }, [selectedCountry]); - - // Fetch cities when country or region changes (cities can be loaded without region) - useEffect(() => { - if (!selectedCountry) { - setCities([]); - return; - } - setIsLoadingCities(true); - setSelectedCity(""); - const args: { country: string; region?: string } = { - country: selectedCountry, - }; - if (selectedRegion) { - args.region = selectedRegion; - } - void invoke("cloud_get_cities", args) - .then((data) => { - setCities(data); - }) - .catch((err) => { - console.error("Failed to fetch cities:", err); - }) - .finally(() => { - setIsLoadingCities(false); - }); - }, [selectedCountry, selectedRegion]); - - // Fetch ISPs when country/region/city changes - useEffect(() => { - if (!selectedCountry) { - setIsps([]); - return; - } - setIsLoadingIsps(true); - setSelectedIsp(""); - const args: { country: string; region?: string; city?: string } = { - country: selectedCountry, - }; - if (selectedRegion) args.region = selectedRegion; - if (selectedCity) args.city = selectedCity; - void invoke("cloud_get_isps", args) - .then((data) => { - setIsps(data); - }) - .catch((err) => { - console.error("Failed to fetch ISPs:", err); - }) - .finally(() => { - setIsLoadingIsps(false); - }); - }, [selectedCountry, selectedRegion, selectedCity]); - - // Auto-generate name from selections - useEffect(() => { - const parts: string[] = []; - const countryItem = countries.find((c) => c.code === selectedCountry); - if (countryItem) parts.push(countryItem.name); - const regionItem = regions.find((s) => s.code === selectedRegion); - if (regionItem) parts.push(regionItem.name); - const cityItem = cities.find((c) => c.code === selectedCity); - if (cityItem) parts.push(cityItem.name); - const ispItem = isps.find((i) => i.code === selectedIsp); - if (ispItem) parts.push(ispItem.name); - if (parts.length > 0) { - setProxyName(parts.join(" - ")); - } - }, [ - selectedCountry, - selectedRegion, - selectedCity, - selectedIsp, - countries, - regions, - cities, - isps, - ]); - - const handleCreate = useCallback(async () => { - if (!selectedCountry || !proxyName.trim()) return; - setIsCreating(true); - try { - await invoke("create_cloud_location_proxy", { - name: proxyName.trim(), - country: selectedCountry, - region: selectedRegion || null, - city: selectedCity || null, - isp: selectedIsp || null, - }); - toast.success(t("locationProxy.createSuccess")); - await emit("stored-proxies-changed"); - handleClose(); - } catch (error) { - console.error("Failed to create location proxy:", error); - toast.error( - typeof error === "string" ? error : t("locationProxy.createFailed"), - ); - } finally { - setIsCreating(false); - } - }, [ - selectedCountry, - selectedRegion, - selectedCity, - selectedIsp, - proxyName, - handleClose, - t, - ]); - - const countryOptions = countries.map((c) => ({ - value: c.code, - label: c.name, - })); - const regionOptions = regions.map((s) => ({ value: s.code, label: s.name })); - const cityOptions = cities.map((c) => ({ value: c.code, label: c.name })); - const ispOptions = isps.map((i) => ({ value: i.code, label: i.name })); - - return ( - - - - {t("locationProxy.titleCreate")} - - {t("locationProxy.descriptionCreate")} - - - -
- {/* Country - always visible */} -
- - -
- - {/* Region - always visible, disabled until country is selected */} -
- - -
- - {/* City - always visible, disabled until country is selected */} -
- - -
- - {/* ISP - always visible, disabled until country is selected */} -
- - -
- - {/* Name */} -
- - { - setProxyName(e.target.value); - }} - placeholder={t("locationProxy.namePlaceholder")} - /> -
-
- - - - - {isCreating - ? t("locationProxy.creatingButton") - : t("locationProxy.createButton")} - - -
-
- ); -} diff --git a/src/components/onboarding-card.tsx b/src/components/onboarding-card.tsx index c5dffdd..1031c94 100644 --- a/src/components/onboarding-card.tsx +++ b/src/components/onboarding-card.tsx @@ -1,14 +1,17 @@ "use client"; +import { motion, useReducedMotion } from "motion/react"; import type { CardComponentProps } from "onborda"; import { useOnborda } from "onborda"; import { useTranslation } from "react-i18next"; import { Button } from "@/components/ui/button"; -import { ONBOARDING_TOUR_FINISHED_EVENT } from "@/lib/onboarding-signal"; +import { + ONBOARDING_TOUR_CLOSED_EVENT, + ONBOARDING_TOUR_FINISHED_EVENT, +} from "@/lib/onboarding-signal"; -// Custom Onborda card, themed with the app's CSS variables. Finishing the last -// step emits ONBOARDING_TOUR_FINISHED_EVENT so the page can show the celebratory -// thank-you dialog (skipping early does not emit it). +// Custom Onborda card, themed with the app's CSS variables. Closing always +// persists completion; finishing the last step also asks the page to celebrate. export function OnboardingCard({ step, currentStep, @@ -19,6 +22,7 @@ export function OnboardingCard({ }: CardComponentProps) { const { t } = useTranslation(); const { closeOnborda } = useOnborda(); + const reduceMotion = useReducedMotion(); const isFirst = currentStep === 0; const isLast = currentStep === totalSteps - 1; @@ -26,31 +30,44 @@ export function OnboardingCard({ // button), not by a "Next" button — advancing manually would jump to a step // whose target doesn't exist yet and block the button. So hide "Next" here. const requiresAction = step.selector === '[data-onborda="create-profile"]'; + const closeTour = () => { + closeOnborda(); + window.dispatchEvent(new Event(ONBOARDING_TOUR_CLOSED_EVENT)); + }; return ( -
+
-

{step.title}

+

+ {step.title} +

{currentStep + 1}/{totalSteps}
-
+
{step.content}
-
+
{isLast ? ( ) : ( @@ -61,7 +78,6 @@ export function OnboardingCard({
{arrow} -
+ ); } diff --git a/src/components/onboarding-provider.tsx b/src/components/onboarding-provider.tsx index 31033bb..fc47463 100644 --- a/src/components/onboarding-provider.tsx +++ b/src/components/onboarding-provider.tsx @@ -57,7 +57,8 @@ export function OnboardingProvider({ cardComponent={OnboardingCard} interact shadowRgb="0,0,0" - shadowOpacity="0.6" + shadowOpacity="0.55" + cardTransition={{ type: "spring", bounce: 0, duration: 0.35 }} > {children} diff --git a/src/components/permission-dialog.tsx b/src/components/permission-dialog.tsx index 0625202..0e2c1de 100644 --- a/src/components/permission-dialog.tsx +++ b/src/components/permission-dialog.tsx @@ -40,24 +40,20 @@ export function PermissionDialog({ const { t } = useTranslation(); const [isRequesting, setIsRequesting] = useState(false); const [isWaitingForGrant, setIsWaitingForGrant] = useState(false); - const [isMacOS, setIsMacOS] = useState(false); const { requestPermission, isMicrophoneAccessGranted, isCameraAccessGranted, - } = usePermissions(); + isInitialized, + requiresSystemPermissions, + } = usePermissions(isOpen); - // Check if we're on macOS and close dialog if not + // This gate only exists for macOS TCC permissions. useEffect(() => { - const userAgent = navigator.userAgent; - const isMac = userAgent.includes("Mac"); - setIsMacOS(isMac); - - // If not macOS, close the dialog as permissions aren't needed - if (!isMac) { + if (isOpen && isInitialized && !requiresSystemPermissions) { onClose(); } - }, [onClose]); + }, [isInitialized, isOpen, onClose, requiresSystemPermissions]); // Get current permission status const isCurrentPermissionGranted = @@ -158,7 +154,9 @@ export function PermissionDialog({ const handleRequestPermission = async () => { setIsRequesting(true); try { - await requestPermission(permissionType); + const granted = await requestPermission(permissionType); + if (granted) return; + // The macOS permission poll runs every 5 s, so the new state can take // a moment to surface. Keep the grant button in its busy state for // that window so the user has clear feedback, and notify them if the @@ -187,7 +185,7 @@ export function PermissionDialog({ }; // Don't render if not macOS - if (!isMacOS) { + if (!requiresSystemPermissions) { return null; } diff --git a/src/components/settings-dialog.tsx b/src/components/settings-dialog.tsx index a37ae3b..054dc18 100644 --- a/src/components/settings-dialog.tsx +++ b/src/components/settings-dialog.tsx @@ -137,9 +137,7 @@ export function SettingsDialog({ const [isLoadingPermissions, setIsLoadingPermissions] = useState(false); const [requestingPermission, setRequestingPermission] = useState(null); - const [isMacOS, setIsMacOS] = useState(false); const [dnsBlocklistDialogOpen, setDnsBlocklistDialogOpen] = useState(false); - const [isLinux, setIsLinux] = useState(false); const [hasE2ePassword, setHasE2ePassword] = useState(false); const [e2ePassword, setE2ePassword] = useState(""); const [e2ePasswordConfirm, setE2ePasswordConfirm] = useState(""); @@ -168,7 +166,10 @@ export function SettingsDialog({ requestPermission, isMicrophoneAccessGranted, isCameraAccessGranted, - } = usePermissions(); + currentOS, + } = usePermissions(isOpen); + const isMacOS = currentOS === "macos"; + const isLinux = currentOS === "linux"; const { trialStatus } = useCommercialTrial(); const { user: cloudUser } = useCloudAuth(); // Encryption is available to everyone except team members who aren't owners @@ -630,14 +631,7 @@ export function SettingsDialog({ console.error(err); }); - // Check if we're on macOS - const userAgent = navigator.userAgent; - const isMac = userAgent.includes("Mac"); - setIsMacOS(isMac); - const isLin = !userAgent.includes("Mac") && !userAgent.includes("Win"); - setIsLinux(isLin); - - if (isMac) { + if (isMacOS) { loadPermissions(); } @@ -653,7 +647,13 @@ export function SettingsDialog({ clearInterval(intervalId); }; } - }, [isOpen, loadPermissions, checkDefaultBrowserStatus, loadSettings]); + }, [ + isOpen, + isMacOS, + loadPermissions, + checkDefaultBrowserStatus, + loadSettings, + ]); // Initialize language selection when dialog opens or language loads useEffect(() => { @@ -1023,7 +1023,7 @@ export function SettingsDialog({ }); }} > - Grant + {t("common.buttons.grant")} )}
@@ -1032,10 +1032,8 @@ export function SettingsDialog({
)} -

- These permissions allow browsers launched from Donut Browser - to access system resources. Each website will still ask for - your permission individually. +

+ {t("settings.permissions.description")}

)} @@ -1045,7 +1043,7 @@ export function SettingsDialog({ -

+

{t("settings.integrations.description")}

void; }) { const { t } = useTranslation(); + const reduceMotion = useReducedMotion(); useEffect(() => { - if (!isOpen) return; + if (!isOpen || reduceMotion) return; const fire = (options: confetti.Options) => { void confetti({ origin: { y: 0.7 }, ...options }); }; @@ -39,7 +40,7 @@ export function ThankYouDialog({ clearTimeout(t1); clearTimeout(t2); }; - }, [isOpen]); + }, [isOpen, reduceMotion]); return ( - +
@@ -66,8 +70,11 @@ export function ThankYouDialog({ {t("onboarding.thankYou.body")} diff --git a/src/components/ui/card.tsx b/src/components/ui/card.tsx deleted file mode 100644 index 11284ff..0000000 --- a/src/components/ui/card.tsx +++ /dev/null @@ -1,90 +0,0 @@ -import { cn } from "@/lib/utils"; - -function Card({ className, ...props }: React.ComponentProps<"div">) { - return ( -
- ); -} - -function CardHeader({ className, ...props }: React.ComponentProps<"div">) { - return ( -
- ); -} - -function CardTitle({ className, ...props }: React.ComponentProps<"div">) { - return ( -
- ); -} - -function CardDescription({ className, ...props }: React.ComponentProps<"div">) { - return ( -
- ); -} - -function CardAction({ className, ...props }: React.ComponentProps<"div">) { - return ( -
- ); -} - -function CardContent({ className, ...props }: React.ComponentProps<"div">) { - return ( -
- ); -} - -function CardFooter({ className, ...props }: React.ComponentProps<"div">) { - return ( -
- ); -} - -export { - Card, - CardAction, - CardContent, - CardDescription, - CardFooter, - CardHeader, - CardTitle, -}; diff --git a/src/components/ui/chart.tsx b/src/components/ui/chart.tsx deleted file mode 100644 index 85e27c2..0000000 --- a/src/components/ui/chart.tsx +++ /dev/null @@ -1,386 +0,0 @@ -"use client"; - -import * as React from "react"; -import * as RechartsPrimitive from "recharts"; -import type { - Props as DefaultLegendContentProps, - LegendPayload, -} from "recharts/types/component/DefaultLegendContent"; -import type { - NameType, - ValueType, -} from "recharts/types/component/DefaultTooltipContent"; -import type { TooltipContentProps } from "recharts/types/component/Tooltip"; - -import { cn } from "@/lib/utils"; - -// Format: { THEME_NAME: CSS_SELECTOR } -const THEMES = { light: "", dark: ".dark" } as const; - -export type ChartConfig = { - [k in string]: { - label?: React.ReactNode; - icon?: React.ComponentType; - } & ( - | { color?: string; theme?: never } - | { color?: never; theme: Record } - ); -}; - -type ChartContextProps = { - config: ChartConfig; -}; - -const ChartContext = React.createContext(null); - -function useChart() { - const context = React.useContext(ChartContext); - - if (!context) { - throw new Error("useChart must be used within a "); - } - - return context; -} - -const ChartContainer = React.forwardRef< - HTMLDivElement, - React.ComponentProps<"div"> & { - config: ChartConfig; - children: React.ComponentProps< - typeof RechartsPrimitive.ResponsiveContainer - >["children"]; - } ->(({ id, className, children, config, ...props }, ref) => { - const uniqueId = React.useId(); - const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`; - - return ( - -
- - - {children} - -
-
- ); -}); -ChartContainer.displayName = "Chart"; - -const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => { - const colorConfig = Object.entries(config).filter( - ([, config]) => config.theme || config.color, - ); - - if (!colorConfig.length) { - return null; - } - - return ( -